从零到一写一个完整的 Compose 版本的天气

10,803 阅读15分钟

忍不了了

最近在手机上看天气的时候发现一堆广告,烦得要死,一个天气app,我想要的只是查看下天气,结果钢筋天气应用就给我来个开屏广告,好家伙,原来我这么喜欢广告吗???开屏广告就不说了,我忍了,因为很多应用都有开屏广告;但是进来天气页面尽然还是一堆广告,误触就会跳转别的应用,这也不说了,我也忍了;你一个天气应用有一堆资讯我还忍了!最让我忍不了的是竟然还有 VIP ???我TM看个天气还要开个VIP???实在是忍不了了,自己写一个吧,正好可以多练练Compose,而且自己写的天气绝对没有广告,也不会乱下载东西,也不会胡乱跳别的应用,比如某多多、某宝等等。。

说干就干,找了一圈感觉和风天气还挺好,之前也使用过他们的api,那就用和风天气吧。

废话不多说,先来看下项目的实现好的样式吧:

请添加图片描述在这里插入图片描述
请添加图片描述在这里插入图片描述
请添加图片描述请添加图片描述
请添加图片描述请添加图片描述

其实上面的图并没有完整展示出项目的全部,项目中还适配了横屏、多语言、深色模式、LCE展示等等,为了增加用户体验,项目中还使用了Room、Hilt、Lottie等等依赖库,大家项目中想要的都有。

配置相关账号

进入和风天气的官网:console.qweather.com/

没有账号的可以注册一个账号,然后进入控制台,选择应用管理谋模块:

image.png

然后点击创建应用:

image.png

这里选择免费开发版即可,咱们个人使用的话用不了多少的,之后输入天气数据应用的名称:

image.png

然后点击下一步,选择KEY的类型:

image.png

毫无疑问这里选择Android SDK,下一步来填写应用的包名等信息:

image.png

最后点击创建就创建成功了:

image.png

接下来咱们创建一个空的项目重新开始,这里需要注意,创建新项目的时候要选择Empty Compose Activity:

image.png

下面一步一步创建应用就行了。对了,还有一点需要注意:包名要和上面填写的一致。

创建好项目之后需要创建一个Application,来将上面配置的和风天气的相关KEY值给配置上:

/**
 * Application
 *
 * @author jiang zhu on 2021/10/28
 */
@HiltAndroidApp
class App : Application() {
​
    companion object {
        // 和风天气 Public Id
        private const val WEATHER_PUBLIC_ID = "申请的WEATHER_PUBLIC_ID"
        // 和风天气 Key
        private const val WEATHER_KEY = "申请的WEATHER_KEY"
    }
​
    override fun onCreate() {
        super.onCreate()
        // 初始化和风天气
        HeConfig.init(WEATHER_PUBLIC_ID, WEATHER_KEY)
        //切换至开发版服务
        HeConfig.switchToDevService()
    }
​
}

接下来添加下和风天气所需要的权限吧:

<!--和风天气所需权限-->
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.READ_PHONE_STATE" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />

到这里为止就将和风天气相关的东西配置好了,接下来就可以愉快地编码了!

搭建项目基础

LCE页面搭建

首先来说下什么是LCE,L就是Loading,表示页面的加载状态,C就是Content,表示页面的正常状态,E就是Error,表示页面的错误页面。

这里需要考虑一个问题,如何让页面展示LCE页面呢?在之前的Android View中咱们可以将这些状态写入BaseActivity 或者 BaseFragment 中,但现在是 Compose ,无法使用继承等特性来展示,这应该怎么搞呢?

咱们都知道,Compose 中的UI都是可组合项、可组合项通过 State 来驱动UI展示的,那么咱们就可以定义相关的 State,然后根据 State的值来判断该显示那个页面就好!说干就干,先来定义一个State吧:

sealed class PlayState<out R> {
    fun isLoading() = this is PlayLoading
    fun isSuccessful() = this is PlaySuccess
​
    override fun toString(): String {
        return when (this) {
            is PlaySuccess<*> -> "Success[data=$data]"
            is PlayError -> "Error[exception=${error}]"
            PlayLoading -> "Loading"
        }
    }
}
​
data class PlaySuccess<out T>(val data: T) : PlayState<T>()
data class PlayError(val error: Throwable) : PlayState<Nothing>()
object PlayLoading : PlayState<Nothing>()

首先创建了一个密封类 PlayState,并增加了泛型,然后定义了三个子类,分别表示成功、失败和加载中,

这里我只定义了这三种状态,你当然也可以定义多种状态,比如就可以新增一个无数据的状态:

data class PlayNoContent(val reason: String) : PlayState<String>()

这块比较灵活,大家可以自由定义。State 定义好之后就可以来编写 LCE 页面了:

/**
 * 通过State进行控制的Loading、Content、Error页面
 *
 * @param playState 数据State
 * @param onErrorClick 错误时的点击事件
 * @param content 数据加载成功时应显示的可组合项
 */
@Composable
fun <T> LcePage(
    playState: PlayState<T>,
    onErrorClick: () -> Unit,
    content: @Composable (T) -> Unit
) = when (playState) {
    PlayLoading -> {
        LoadingContent()
    }
    is PlayError -> {
        ErrorContent(onErrorClick = onErrorClick)
    }
    is PlaySuccess<T> -> {
        content(playState.data)
    }
}

可以看到,LcePage 中我们传入了定义好的 State,并传入了错误时的点击时间,还有加载成功时应显示的可组合项,这里需要注意,我们将成功的数据直接回传给了 content,这样的话使用的时候就不需要对数据进行强转了。

上面还提到了 LoadingContent 加载页面和 ErrorContent 错误页面,来看下吧:

@Composable
fun ErrorContent(
    modifier: Modifier = Modifier,
    onErrorClick: () -> Unit
) {
    val composition by rememberLottieComposition(
        LottieCompositionSpec.RawRes(R.raw.weather_error)
    )
​
    val progress by animateLottieCompositionAsState(
        composition = composition,
        iterations = LottieConstants.IterateForever
    )
    Column(
        modifier = modifier
            .fillMaxSize()
            .background(color = MaterialTheme.colors.onSecondary),
        verticalArrangement = Arrangement.Center,
        horizontalAlignment = Alignment.CenterHorizontally
    ) {
        LottieAnimation(
            composition = composition,
            modifier = Modifier.size(130.dp),
            progress = progress
        )
        Button(onClick = onErrorClick) {
            Text(text = stringResource(id = R.string.bad_network_view_tip))
        }
    }
}

可以看到,上面代码写的是错误页面,代码很简单,只放了一个 Lottie 动画和一个错误时的按钮(Conpose中使用Lottie我之前写过一篇文章,不懂的可以点击进行跳转)。

再来看下 LoadingContent 加载页面吧:

@Composable
fun LoadingContent(
    modifier: Modifier = Modifier
) {
    val composition by rememberLottieComposition(
        LottieCompositionSpec.RawRes(R.raw.weather_load)
    )
​
    val progress by animateLottieCompositionAsState(
        composition = composition,
        iterations = LottieConstants.IterateForever
    )
​
    Column(
        modifier = modifier
            .fillMaxSize()
            .background(color = MaterialTheme.colors.onSecondary),
        verticalArrangement = Arrangement.Center,
        horizontalAlignment = Alignment.CenterHorizontally,
    ) {
        LottieAnimation(
            composition = composition,
            modifier = Modifier.size(130.dp),
            progress = progress
        )
    }
​
}

代码很简单,和上面的错误页面很像,不同的就是错误页面有错误时重新加载的按钮。

Dialog 样式创建

下面来创建下 Dialog 的样式,不知道为啥,我一直觉得 Google 的对话框丑死了,但原生一直是那样,所以这里咱们不使用 AlertDialog 而使用 Dialog,这样就能自定义对话框的样式了。大家可能有的还没有使用过 Compose 中的对话框,先带大家看看 Dialog 的方法定义吧:

@Composable
fun Dialog(
    onDismissRequest: () -> Unit,
    properties: DialogProperties = DialogProperties(),
    content: @Composable () -> Unit
)

非常简单有没有!只有三个参数,第一个是关闭对话框的回调,第二个是对话框的一些配置,最后就是要显示的内容,咱们来着重看下对话框的配置,也就是第二个参数:

class DialogProperties constructor(
    val dismissOnBackPress: Boolean = true,
    val dismissOnClickOutside: Boolean = true,
    val securePolicy: SecureFlagPolicy = SecureFlagPolicy.Inherit,
    val usePlatformDefaultWidth: Boolean = true
)

可以看到 DialogProperties 的构造方法中有四个参数,咱们来逐一看下吧:

  • dismissOnBackPress:是否可以通过按下后退按钮来关闭对话框。 如果为 true,按下后退按钮将调用 onDismissRequest;
  • dismissOnClickOutside:是否可以通过在对话框边界外单击来关闭对话框。 如果为 true,单击对话框外将调用 onDismissRequest;
  • securePolicy:用于在对话框窗口上设置 WindowManager.LayoutParams.FLAG_SECURE 的策略;
  • usePlatformDefaultWidth:对话框内容的宽度是否应限制为平台默认值,小于屏幕宽度。

一般来说咱们在使用对话框的时候直接使用默认值就行,即无需配置 DialogProperties。接下来咱们来自己画下咱们的对话框吧:

@Composable
fun ShowDialog(
    alertDialog: MutableState<Boolean>,
    title: String,
    content: String,
    cancelString: String,
    confirmString: String,
    onConfirmListener: () -> Unit
) {
    if (!alertDialog.value) return
    val buttonHeight = 45.dp
    Dialog(onDismissRequest = {
        alertDialog.value = false
    }) {
        Card(shape = RoundedCornerShape(10.dp)) {
            Column(
                horizontalAlignment = Alignment.CenterHorizontally,
                modifier = Modifier.padding(top = 20.dp)
            ) {
                Text(text = title)
​
                Text(text = content)
              
                Divider()
              
                Row {
                    TextButton(
                        modifier = Modifier
                            .weight(1f)
                            .height(buttonHeight),
                        onClick = {
                            alertDialog.value = false
                        }
                    ) {
                        Text(text = cancelString)
                    }
                    Divider(
                        modifier = Modifier
                            .width(1.dp)
                            .height(buttonHeight)
                    )
                    TextButton(
                        modifier = Modifier
                            .weight(1f)
                            .height(buttonHeight),
                        onClick = {
                            alertDialog.value = false
                            onConfirmListener()
                        }
                    ) {
                        Text(text = confirmString,)
                    }
                }
            }
        }
    }
}

大家别慌,看着代码多,其实只有一行标题、一行内容还有两个按钮而已,在看咱们新定义的对话框的样式前先来看看 AlertDialog 的样式吧:

image.png

然后再来看看咱们新定义的:

image.png

是不是挺好看,当然,每个人的审美不同,一千个人心中有一千个哈姆雷特嘛!反正我自己觉得新定义的对话框好看,哈哈哈!

实现滑动删除

滑动删除使用的场景非常多,并且 Github 中的轮子一找一大把,根本不需要自己来实现,但 Compose 刚刚稳定,所以没有写好的轮子,那就只能自己来写一个了!

在文章开始的动图中就能看到实现好的样式,来看看是如何实现的吧!

首先来定义下滑动的方向吧:

enum class SwipeStyle {
    StartToEnd,  // 从开始往结束方向滑动
    EndToStart // 从结束往开始方向滑动
}

可以看到这里定义了两种模式,一种是从开始往结束方向滑动,也就是从左往右滑动,删除按钮在左边;另一种是从结束往开始方向滑动,也就是从右往左滑动,删除按钮在右边。

接下来又需要考虑一个问题了,怎么进行滑动呢?之前考虑过 draggable,这个是拖动的事件,但如果使用这个的话咱们还需要控制拖动的阈值,而且各种回弹效果也都需要自定义,太麻烦了,于是就想到了使用 swipeable,这个是滑动的事件,可以直接设置滑动的阈值,回弹效果系统已经做了处理,那就OK了,来看看实现代码吧:

/**
 * 左滑右滑对Item进行操作
 *
 * @param modifier 修饰符,不做描述
 * @param isShowChild 是否要显示操作子item
 * @param swipeStyle [SwipeStyle.EndToStart]:从结束往开始方向滑动 [SwipeStyle.StartToEnd]:从开始往结束方向滑动
 * @param childContent 子item
 * @param content item
 */
@ExperimentalMaterialApi
@Composable
fun SwipeDeleteLayout(
    modifier: Modifier = Modifier,
    swipeState: SwipeableState<Int>,
    isShowChild: Boolean = true,
    swipeStyle: SwipeStyle = SwipeStyle.EndToStart,
    childContent: @Composable () -> Unit,
    content: @Composable () -> Unit,
) {
    var deleteWidth by remember { mutableStateOf(1) }
    var contentHeight by remember { mutableStateOf(1) }
    Box(
        modifier.swipeable(
            state = swipeState,
            anchors = mapOf(deleteWidth.toFloat() to 1, 0.7f to 0),
            thresholds = { _, _ ->
                FractionalThreshold(0.7f)
            },
            reverseDirection = swipeStyle == SwipeStyle.EndToStart,
            orientation = Orientation.Horizontal
        )
    ) {
        Box(modifier = Modifier
            .onGloballyPositioned { deleteWidth = it.size.width }
            .height(with(LocalDensity.current) { contentHeight.toDp() })
            .align(getDeleteAlign(swipeStyle))
        ) { childContent() }
        Box(modifier = Modifier
            .fillMaxWidth()
            .onGloballyPositioned { contentHeight = it.size.height }
            .offset {
              IntOffset(
                    if (isShowChild) {
                        if (swipeStyle == SwipeStyle.EndToStart) {
                            -swipeState.offset.value.toInt()
                        } else swipeState.offset.value.toInt()
                    } else { 0 }, 0 )
            }
        ) { content() }
    }
}
​
/**
 * 获取删除的位置
 */
@Composable
private fun getDeleteAlign(swipeStyle: SwipeStyle) =
    if (swipeStyle == SwipeStyle.EndToStart) Alignment.CenterEnd else Alignment.CenterStart

来大概说下上面的代码吧,定义了一个 Box,其中包含了两个 Box,一个中方删除的可组合项,另一个放 Item 的可组合项,然后根据滑动来控制修改 Item 可组合项的位置,就实现了滑动删除的功能。

实现页面跳转动画

说起 Compose 中的页面跳转,大家想到的肯定是 Navigation,但 Navigation 中虽然预留了动画 API,但设置上并没有用,Google 又写了一个新的库来实现这个功能。

首先来添加下依赖吧:

implementation "com.google.accompanist:accompanist-navigation-animation:0.20.2"

项目其实一共就三个页面:天气页面、城市选择页面、城市控制页面,先来创建下这三个页面的 Route 吧:

object PlayDestinations {
    const val HOME_PAGE_ROUTE = "home_page_route"
    const val WEATHER_LIST_ROUTE = "weather_list_route"
    const val CITY_LIST_ROUTE = "city_list_route"
}

接下来咱们对应用程序中的导航操作进行建模:

class PlayActions(navController: NavHostController) {
​
    val toWeatherList: () -> Unit = {
        navController.navigate(PlayDestinations.WEATHER_LIST_ROUTE)
    }
​
    val toCityList: () -> Unit = {
        navController.navigate(PlayDestinations.CITY_LIST_ROUTE)
    }
​
    val upPress: () -> Unit = {
        navController.navigateUp()
    }
​
}

然后重点来了,为页面跳转动画进行配置:

composable(
    route = route,
    arguments = arguments,
    deepLinks = deepLinks,
    enterTransition = { _, _ ->
        expandVertically(animationSpec = tween(300))
    },
    exitTransition = { _, _ ->
        shrinkOut(animationSpec = tween(300))
    },
    content = content,
)

可以看到上面代码配置了进入和退出的转场动画,进入动画设置的是垂直展开,退出动画设置的是缩小动画,动画规格都设置的是 tween,动画直接都设置的是 300 毫秒。页面跳转的动画效果大家也可以从上面的展示图中看到。

实现主要功能

本来想的是将整个项目全部说一遍,但是后来想了想太啰嗦了,即使都写出来,也不会有多少人愿意看的,所以就想只写一些我认为较为重要的吧!如果大家对项目中别的地方或一些细节有疑问的话都可以在下面留言告诉我。

自定义空气质量显示图

先来看下实现好的样式吧:

image.png

上面这个自定义的空气质量显示图还可以吧,哈哈哈,模仿的苹果中的空气质量样式,先来看看这个自定义控件,上面的一些文字很简单,直接使用 Text 就行,稍微费劲点的是就是下面的那个条。

其实也不难,就是几个颜色的渐变,然后将当前的空气质量显示为一个小白点,根据数值显示到对应的地方即可。

首先咱们需要知道空气质量的数值是多少,我百度查了下,最高好像是 500 左右,而且和风天气官网中提供了空气质量的区间:

image.png

这就好说了,下面需要做的就是根据数值绘制一条线了:

drawLine(
    brush = Brush.linearGradient(
        0.0f to Color(red = 139, green = 195, blue = 74),
        0.1f to Color(red = 255, green = 239, blue = 59),
        0.2f to Color(red = 255, green = 152, blue = 0),
        0.3f to Color(red = 244, green = 67, blue = 54),
        0.4f to Color(red = 156, green = 39, blue = 176),
        1.0f to Color(red = 143, green = 0, blue = 0),
    ),
    start = Offset.Zero,
    end = Offset(size.width, 0f),
    strokeWidth = 20f,
    cap = StrokeCap.Round,
)

颜色大家可以自己进行控制,我只是大概找了一些颜色。线画好了再来画当前的小白点吧:

drawPoints(
    points = arrayListOf(
        Offset(size.width / 500 * aqiValue, 0f)
    ),
    pointMode = PointMode.Points,
    color = Color.White,
    strokeWidth = 20f,
    cap = StrokeCap.Round,
)

上面代码将线均分为 500 份,然后进行对应显示即可。

到这里下面的线就画好了,再把上面的几个文字加上就行:

@Composable
fun AirQuality(airNowBean: AirNowBean.NowBean?) {
    if (airNowBean == null) return
    Card(
        modifier = Modifier.fillMaxWidth().padding(horizontal = 10.dp),
        shape = RoundedCornerShape(10.dp)
    ) {
        Column(modifier = Modifier.fillMaxWidth().padding(10.dp)) {
            Text(text = stringResource(id = R.string.air_quality_title), fontSize = 14.sp)
            Text(text = "${airNowBean.aqi ?: "10"} - ${
                    airNowBean.category ?: stringResource(id = R.string.air_quality_level)
                }",
                modifier = Modifier.padding(top = 5.dp),
                fontSize = 24.sp,
                color = MaterialTheme.colors.primary
            )
            Text(text = "${
                    stringResource(id = R.string.air_quality_Current_aqi)
                }${airNowBean.aqi ?: "10"}${airNowBean.primary ?: ""}",
                modifier = Modifier.padding(top = 5.dp),
                fontSize = 14.sp
            )
            Spacer(modifier = Modifier.height(10.dp))
            AirQualityProgress((airNowBean.aqi ?: "10").toInt())
        }
    }
}

上面代码中最外层包了一个 Card 用以实现外面的卡片,然后里面用竖向的线型布局 Column 将几个 Text 和上面写好的空气质量图包裹起来,然后就实现了图中的样式。

进入页面弹出输入法

其实在之前 Android View 中并不是什么难事,网上也有很多相关的工具类,直接进行调用即可,或者通过修改 AndroidManifest 也可以实现,但是在 Compose 中和 Android View 中不太一样,下面就来看看在 Compose 中该如何实现进入页面就弹出输入法吧!

val requester = FocusRequester()
LaunchedEffect(Unit) {
    requester.requestFocus()
}
BasicTextField(
    value = value,
    onValueChange = { value = it },
    modifier = Modifier
        .fillMaxWidth()
        .focusRequester(requester)
)

代码很简单,首先构建一个 FocusRequester,这是 Compose 中的焦点请求类,然后调用 Modifier 的 扩展函数 focusRequester 将构建好的 FocusRequester 传进去,最后调用 requester.requestFocus() 即可获取到焦点。

可以看到上面代码中 requester.requestFocus() 使用 LaunchedEffect 进行了包裹,这是为了当可组合项执行的时候保证只获取一次焦点。

权限处理

这也是一个老生常谈的问题,大家都知道,Android 在 6.0 的时候引入了动态权限这个概念,至此之后这一块就变成了必须做的一个适配。

之前在 Android View 中申请权限的话只需要调用 requestPermissions 将需要申请的权限传入,然后重写 onRequestPermissionsResult 方法,在里面即可接收到权限是否获取成功的回调。但在 Compose 中推荐单个 Activity 的方案,这种获取权限的方式就不太合适了。

Google 同样为我们单独写了一个依赖库用以申请 Compose 中的权限,先来添加下依赖吧:

implementation "com.google.accompanist:accompanist-permissions:$0.20.2"

来看下使用方式吧:

@OptIn(ExperimentalPermissionsApi::class)
@Composable
fun FeatureThatRequiresLocationPermissions(weatherViewModel: WeatherViewModel) {
    val context = LocalContext.current
    val alertDialog = rememberSaveable { mutableStateOf(false) }
    // 权限状态
    val locationPermissionState = rememberPermissionState(
        Manifest.permission.ACCESS_FINE_LOCATION,
    )
    when {
        // 成功获取权限
        locationPermissionState.hasPermission -> {
            LaunchedEffect(Unit) {
                getLocation(context, weatherViewModel)
            }
        }
        // 当前没有权限,需要申请权限
        locationPermissionState.shouldShowRationale ||
                !locationPermissionState.permissionRequested -> {
            LaunchedEffect(Unit) {
                locationPermissionState.launchPermissionRequest()
            }
        }
        // 用户拒绝该权限,弹出对话框提醒用户跳转设置进行获取
        else -> {
            LaunchedEffect(Unit) {
                alertDialog.value = true
            }
            ShowDialog(
                alertDialog = alertDialog,
                title = stringResource(id = R.string.permission_title),
                content = stringResource(id = R.string.permission_content),
                cancelString = stringResource(id = R.string.permission_cancel),
                confirmString = stringResource(id = R.string.permission_sure)
            ) {
                startSettingAppPermission(context)
            }
        }
    }
}

可以看到,在 Compose 中可以通过 rememberPermissionState 来获取到 PermissionState ,通过这个 State 就可以判断当前权限的状态从而进行对应的操作。

这个库其实还进行了深一层的包装,来看下吧:

@ExperimentalPermissionsApi
@Composable
fun PermissionRequired(
    permissionState: PermissionState,
    permissionNotGrantedContent: @Composable (() -> Unit),
    permissionNotAvailableContent: @Composable (() -> Unit),
    content: @Composable (() -> Unit),
) {
    when {
        permissionState.hasPermission -> {
            content()
        }
        permissionState.shouldShowRationale || !permissionState.permissionRequested -> {
            permissionNotGrantedContent()
        }
        else -> {
            permissionNotAvailableContent()
        }
    }
}

上面的可组合项 PermissionRequired 也是权限依赖包中的,可以看到这块的判断和上面咱们使用的一样,但上面咱们在有权限的时候并不需要展示 UI,而是需要获取当前的定位,所以可组合项 PermissionRequired 在这里对我们来说并不适用。

MVVM 的使用

MVVM 在 Android View 中的使用已经非常广泛,Google 也一直在大力推广,Compose 使用 MVVM 进行开发是再好不过的选择了,这里我挑选了城市列表来进行演示吧,天气页面内容太多了,不太适合举例子。

在之前 ViewModel 的生命周期和 Compose 中的 Navigation 生命周期对应不起来,但现在可以利用 Hilt 将 ViewModel 的作用域限定为 Navigation,先来添加下依赖吧:

implementation 'androidx.hilt:hilt-navigation-compose:1.0.0-alpha03'

添加完依赖后再来看下使用方法吧:

composable(PlayDestinations.WEATHER_LIST_ROUTE) {
    val weatherListViewModel = hiltViewModel<WeatherListViewModel>()
    WeatherListPage(
        weatherListViewModel = weatherListViewModel,
        onBack = actions.upPress,
        toWeatherDetails = actions.upPress
    )
}

是不是很简单,下面来重点看下 ViewModel 的编写吧:

@HiltViewModel
class WeatherListViewModel @Inject constructor(
    application: Application,
    private val weatherListRepository: WeatherListRepository
) : AndroidViewModel(application) {
​
    private var language: Lang = getDefaultLocale(getApplication())
    private var nameToCityJob: Job? = null
​
    private val _locationBeanList =
        MutableLiveData<PlayState<List<GeoBean.LocationBean>>>(PlayLoading)
    val locationBeanList: LiveData<PlayState<List<GeoBean.LocationBean>>> = _locationBeanList
​
    private fun onLocationBeanListChanged(hourlyBean: PlayState<List<GeoBean.LocationBean>>) {
        if (hourlyBean == _locationBeanList.value) {
            XLog.d("onLocationBeanListChanged no change")
            return
        }
        _locationBeanList.postValue(hourlyBean)
    }
​
​
    /**
     * 根据城市名称进行模糊查询
     *
     * @param cityName 城市名称
     */
    fun getGeoCityLookup(cityName: String = "北京") {
        nameToCityJob.checkCoroutines()
        nameToCityJob = viewModelScope.launch {
            val cityLookup = weatherListRepository.getGeoCityLookup(cityName)
            onLocationBeanListChanged(cityLookup)
        }
    }
​
}

由于咱们使用了依赖注入,所以要在类上添加依赖注入的注解:@HiltViewModel,并通过注解:@Inject 来将 Repository 进行注入。下面就和在 Android View 中一样了,创建一个可变的 LiveData 和一个不可变的 LiveData,这里需要注意下,LiveData 中的值为上面咱们定义的 PlayState,也就可以使用上面定义的 LCE 页面了。

下面来看下 Repository 的代码吧:

@ViewModelScoped
class WeatherListRepository @Inject constructor(private val context: Application) {
​
    /**
     * 根据城市名称进行模糊查询
     *
     * @param cityName 城市名称
     */
    suspend fun getGeoCityLookup(cityName: String = "北京") =
        suspendCancellableCoroutine<PlayState<List<GeoBean.LocationBean>>> { continuation ->
            if (!NetCheckUtil.checkNet(context)) {
                continuation.resume(PlayError(IllegalStateException("无网络链接")))
                return@suspendCancellableCoroutine
            }
            QWeather.getGeoCityLookup(context, cityName, object : QWeather.OnResultGeoListener {
                override fun onError(e: Throwable) {
                    continuation.resume(PlaySuccess(listOf()))
                    XLog.e("getGeoCityLookup onError: ${e.message}")
                    showToast(context, R.string.add_location_warn2)
                }
​
                override fun onSuccess(geoBean: GeoBean?) {
                    if (geoBean == null) {
                        continuation.resume(PlayError(NullPointerException("返回值为空")))
                        XLog.d("getGeoCityLookup onError: 返回值为空")
                        return
                    }
                    val json = Gson().toJson(geoBean)
                    XLog.d("getGeoCityLookup onSuccess: $json")
                    // 先判断返回的status是否正确,当status正确时获取数据,若status不正确,可查看status对应的Code值找到原因
                    if (Code.OK === geoBean.code) {
                        continuation.resume(PlaySuccess(geoBean.locationBean))
                    } else {
                        //在此查看返回数据失败的原因
                        val code: Code = geoBean.code
                        XLog.w("getGeoCityLookup failed code: $code")
                        showToast(context = context, code.txt)
                    }
                }
            })
        }
}

这段代码很简单,首先判断网络状况,如果无网的话直接返回 PlayError,如果访问成功则返回 PlaySuccess 并将访问成功的数据带回。

接下来看下可组合项中如何使用 LCE 吧:

@Composable
fun WeatherListPage(
    weatherListViewModel: WeatherListViewModel,
    onBack: () -> Unit,
    onSearchCity: (String) -> Unit,
    onErrorClick: () -> Unit,
    toWeatherDetails: (CityInfo) -> Unit,
) {
    val context = LocalContext.current
    val locationBeanState by weatherListViewModel.locationBeanList.observeAsState(PlayLoading)
    LcePage(playState = locationBeanState, onErrorClick = onErrorClick) { locationBeanList ->
        Column(modifier = Modifier.fillMaxSize()) {
            SearchBar(onBack) { city ->
                if (city.isNotEmpty()) {
                    onSearchCity(city)
                } else {
                    // 搜索城市为空,提醒用户输入城市名称
                    showToast(context = context, R.string.city_list_search_hint)
                }
            }
            Spacer(Modifier.height(10.dp))
            val listState = rememberLazyListState()
            LazyColumn(
                modifier = Modifier.padding(horizontal = 15.dp),
                state = listState
            ) {
                items(locationBeanList) { locationBean ->
                    WeatherCityItem(locationBean, toWeatherDetails)
                }
            }
        }
    }
}

通过上面代码可以看到,通过 observeAsState 将 LiveData 转为 Compose 中可以进行观察的 State,然后将 State 作为参数传给上面写的 LcePage,如果发生错误的话直接显示错误页面,如果有数据的话则显示应该显示的列表。

到这里就是一整个页面的流程,由于篇幅原因并没有将完整代码贴上,其实这个页面还会展示热门的城市、添加城市到数据库等等,大家可以去 Github 下载代码进行查看。

草率的总结

不知不觉写了这么多字了,本来想着整个项目一点一点说一说的,但是知识点太碎了,大家可以购买我的新书《Jetpack Compose:Android全新UI编程》进行阅读,里面有完整的 Compose 框架。

京东购买地址

当当购买地址

呸呸呸,太不要脸了,又在推荐自己的新书。。。

说了这么多还没放 Github 地址呢:github.com/zhujiang521…

如果你在学习或者想要学习关于 Compose 的话,这个项目应该或多或少会对你有点帮助,如果对你有帮助的话,别忘记点个 Star,感激不尽。

先写到这里吧,再会!