夜雨聆风学习资料网

ARTICLE · 1055707

保姆级学习开发安卓手机软件实战(三)--ViewModel + Flow

保姆级学习开发安卓手机软件实战(三)--ViewModel + Flow
正式开始前,想和大家说,微信最近9月的活动,文章最底下,有一个小红花的图标,点击助力一下,不需要花费任何费用,一个助力,腾讯公益慈善基金会将向该文章支持的公益项目捐赠 0.1元公益金。

上节我们有简单介绍,这节就具体来实现,如何把"数据从哪里来"和"UI 怎么显示"分离,VM(ViewModel) 在后台跑着,Flow 自动推送新数据给 UI。
一、添加引用
(之前我们或多或少的用上过,但是我发现没有扩展去讲讲为什么要添加这两个步骤,所以这里展开讲讲)
我们用c#来打比方,C#(比如 WPF / WinForms /.NET):在项目里「添加引用 / NuGet 包」,告诉编译器:我要用到这个外部类库,去加载对应的 dll。
那安卓就是libs.versions.toml + Gradle dependencies:就是 Android Gradle 的依赖管理,等同于 C# NuGet 引用,用来引入谷歌官方的 androidx 外部库(jar/aar 包,类比 C# 的 dll)

C# 你添加 NuGet 引用,一步直接安装。 

而 Gradle 现在的版本目录 (version catalog) 分成两步:

  1. ✅ toml(libs.versions.toml):只是登记清单

登记好库的名字、坐标、版本,只做注册,并不会真正引入到项目。 相当于:把一堆 dll 信息写进通讯录,还没引用

  1. ✅ build.gradle.kts 里写 implementation(libs.xxx)才是真正添加引用

告诉 Gradle:这个模块需要这个库,去 Maven 下载 aar,编译打包时加入,才等同 C# 真正 Add Reference

👉 一句话:libs.versions.toml = 维护一份统一的依赖清单(通讯录)implementation 才是真正 “启用这个引用”。

额外:为什么 Android 要单独搞这个 toml?
所有模块共用同一份 toml 清单,所有库版本统一。 就像解决方案里多个 C# 项目,统一锁定 NuGet 版本,避免不同项目引用不同版本 dll 导致冲突。
那现在我们就开始为本节导入引用吧。

修改 libs.versions.toml

androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycleRuntimeKtx" }androidx-lifecycle-runtime-compose    = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose",    version.ref = "lifecycleRuntimeKtx" }
逐字段讲:
androidx-lifecycle-viewmodel-compose(小名):你在 build.gradle.kts 里就要用 libs.androidx.lifecycle.viewmodel.compose 引用它
group = "androidx.lifecycle"(组织):Maven 仓库里的"包名前缀",相当于"哪家公司的"
name = "lifecycle-viewmodel-compose"(工件名):这个组织下的具体哪个库
version.ref = "lifecycleRuntimeKtx"(版本号引用):去 [versions] 段找叫 lifecycleRuntimeKtx 的那个版本号,即 "2.6.1"
为什么不直接写 "2.6.1": 因为 lifecycle 这套库有 5、6 个(runtime、viewmodel、runtime-compose、viewmodel-compose...),版本必须全部一致,不然运行时会崩。用 version.ref 升级时只改 [versions] 段一处。

修改app/build.gradle.kts

implementation(libs.androidx.lifecycle.viewmodel.compose)implementation(libs.androidx.lifecycle.runtime.compose)

implementation(...) ← Gradle 语法,意思是"我代码里要用这个库,请帮我下载"

添加之后,记得sync now:

等下载完毕,上面图中报的红色错误就会消失。

二、新建类文件,把UI(DeviceListView)要显示的数据打包成一个对象

这里我们新增一个类文件,专门给 DeviceListView.kt 这个页面用:

  • 一个页面一套 ViewModel,是 Compose 推荐的单页面单 ViewModel规范
  • 页面打开 → 创建 ViewModel;页面退出 → ViewModel 销毁
  • 别的页面不要直接拿这个 ViewModel 实例,避免状态混乱

类比 C#:相当于页面专属的后台服务类

怎么建:右键  screen 文件夹 → New → Kotlin Class →选 File → 命名 DeviceListViewModel

        2 个新 import(都是 ViewModel 在 Compose 里用的工具。):

import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel          

创建数据类:

data class DeviceListUiState(    val devices: List<String> = emptyList(),// 先用 String 占位,后面会换成 Device 模型    val isLoading: Boolean = false,    val errorMessage: String? = null)

后续 Composable 里只要 val state by ... 一个变量,就能拿到设备列表、加载状态、错误信息等。

类的继承:

classDeviceListViewModel : ViewModel(){}

在上面的类中添加双层状态:

private val _uiState = MutableStateFlow(DeviceListUiState())     // 内部可写val uiState: StateFlow<DeviceListUiState> = _uiState.asStateFlow() // 外部只读

StateFlow是一种"流",会主动推送新值给所有订阅者(类似微信公众号:作者发文章,所有订阅的人手机都弹出通知)

MutableStateFlow vs StateFlow:前者能改,后者只能读。_uiState 是可变版本,外面包装一层.asStateFlow() 变只读(用 asStateFlow() 锁住,外面改不了)。

继续在上面的类中添加初始化模块:

init {  loadMockDevices()}

init {} 是 Kotlin 的"构造时执行" —— 这个 VM 一被创建,就自动调用 loadMockDevices()。

实现效果:UI 一打开就要看到数据,所以 VM 创建时立刻去拿,不用等 UI 触发按钮。

loadMockDevices()数据方法:

private funloadMockDevices() {    _uiState.value = _uiState.value.copy(        devices = listOf("设备-01""设备-02""设备-03")    )}

这里的功能是模拟加载3个设备,后续会实际获取。

总的代码如下:

package com.example.jstz1.ui.screenimport androidx.lifecycle.ViewModelimport kotlinx.coroutines.flow.MutableStateFlowimport kotlinx.coroutines.flow.StateFlowimport kotlinx.coroutines.flow.asStateFlow/** * 设备列表页面的 UI 状态(一个数据类) * * 现在只装"设备列表 + 加载态 + 错误信息"三件套 * 后面会扩展: *   - 多设备(含 IP / 端口 / 在线状态) *   - 选中设备 ID *   - 搜索关键字 */data class DeviceListUiState(    val devices: List<String> = emptyList(),   // 先用 String 占位,后面会换成 Device 模型    val isLoading: Boolean = false,    val errorMessage: String? = null)/** * DeviceListViewModel —— 设备列表页面的"数据大脑" * * 为什么用 ViewModel(不是写在 Composable 里的 remember): * 1. 横竖屏切换时 Activity 重建,但 VM 不死 → 列表不丢 * 2. 未来从 Repository / Netty 拉数据时,UI 层不用改 * 3. 多页面共享数据时,只注入同一个 VM 即可 */class DeviceListViewModel : ViewModel() {    /** 内部可变状态(用 MutableStateFlow,可以发"新值"出去) */    private val _uiState = MutableStateFlow(DeviceListUiState())    /** 外部只读状态(用 asStateFlow() 锁住,外面改不了) */    val uiState: StateFlow<DeviceListUiState> = _uiState.asStateFlow()    init {        // VM 一创建就加载 mock 数据        // 未来这里改成:viewModelScope.launch { repository.fetchDevices() }        loadMockDevices()    }    /**     * 模拟加载 3 个设备     * 后面会被 Repository / Netty 替代,本方法删除     */    private funloadMockDevices() {        _uiState.value = _uiState.value.copy(            devices = listOf("设备-01""设备-02""设备-03")        )    }}

三、修改UI文件,绑定VM

DeviceListScreen.kt首先也是新增2 个 import:

import androidx.lifecycle.compose.collectAsStateWithLifecycle  import androidx.lifecycle.viewmodel.compose.viewModel          

    函数签名加 vm 参数:

vmDeviceListViewModel = viewModel()   // 默认从 Activity 拿 VM

这个逻辑是找当前 Activity 的 VM 存储,找叫 DeviceListViewModel::class 的 VM:有了就复用,没有就 new 一个新的。旋转屏幕时 Activity 重建 → VM 存储还在 → 找到旧的 → 复用(所以数据不丢)

= viewModel() 是默认值,意味着你调用 DeviceListScreen() 不传 vm 参数时,它自动从 Activity 拿。

把 Flow 转成 State

val state by vm.uiState.collectAsStateWithLifecycle()
逐词拆:
  1. vm.uiState:VM 暴露的 StateFlow(前面讲的双层包装的"只读流")
  2. .collectAsStateWithLifecycle():订阅这个流,每当流发新值,就触发 Composable 重组(重新执行一遍函数体,刷新界面)
  3. by ... .collectAsStateWithLifecycle():Kotlin 的属性委托,让 state 这个变量自动跟着流的值变
  4. val state by:原本写法是 val state = flow.collect...() 返回 State<T>,要用 state.value 才能取真实值;用 by 后,state 直接就是 T(这里是 DeviceListUiState),少打 .value
  5. collectAsStateWithLifecycle 跟 collectAsState 的区别:
    collectAsState:app 不在前台也继续订阅(费电)
    collectAsStateWithLifecycle:app 退到后台 →暂停订阅;回到前台 → 恢复订阅(省电,推荐)
改动显示内容
if (state.devices.isEmpty()) {    Text("还没有设备")else {    state.devices.forEach { name -> Text("● $name") }}

  Preview 函数手动 new VM

DeviceListScreen(vm = DeviceListViewModel())
这里主要是避免报错,因为我们设置了必须输入vm。
整个代码:
package com.example.jstz1.ui.screenimport androidx.compose.foundation.layout.*import androidx.compose.material3.Buttonimport androidx.compose.material3.MaterialThemeimport androidx.compose.material3.Surfaceimport androidx.compose.material3.Textimport androidx.compose.runtime.Composableimport androidx.compose.runtime.getValueimport androidx.compose.ui.Alignmentimport androidx.compose.ui.Modifierimport androidx.compose.ui.tooling.preview.Previewimport androidx.compose.ui.unit.dpimport androidx.lifecycle.compose.collectAsStateWithLifecycleimport androidx.lifecycle.viewmodel.compose.viewModel/** * 设备列表页面 —— 已接入 ViewModel * * 数据流: *   DeviceListViewModel.uiState (StateFlow) *      → collectAsStateWithLifecycle()  [生命周期感知] *      → Composable 自动重组 */@ComposablefunDeviceListScreen(    modifier: Modifier = Modifier,    vm: DeviceListViewModel = viewModel()   // 默认从 Activity 拿 VM,无需手动传) {    // 把 Flow 转成 State;生命周期暂停时不收集,省电    val state by vm.uiState.collectAsStateWithLifecycle()    Surface(        modifier = modifier.fillMaxSize(),        color = MaterialTheme.colorScheme.background    ) {        Column(            modifier = Modifier                .fillMaxSize()                .padding(24.dp),            horizontalAlignment = Alignment.CenterHorizontally,            verticalArrangement = Arrangement.Top        ) {            Text(                text = "设备列表",                style = MaterialTheme.typography.headlineMedium,                color = MaterialTheme.colorScheme.onBackground            )            Spacer(modifier = Modifier.height(16.dp))            if (state.devices.isEmpty()) {                Text(                    text = "还没有设备",                    style = MaterialTheme.typography.bodyMedium,                    color = MaterialTheme.colorScheme.onSurfaceVariant                )            } else {                // 显示设备列表(先用 Text,后面会改成 Card)                state.devices.forEach { name ->                    Text(                        text = "● $name",                        style = MaterialTheme.typography.bodyLarge,                        color = MaterialTheme.colorScheme.onBackground,                        modifier = Modifier.padding(vertical = 4.dp)                    )                }            }            Spacer(modifier = Modifier.height(24.dp))            Button(onClick = { /* TODO: 跳转添加设备页面 */ }) {                Text("+ 添加设备")            }        }    }}/** * AS 编辑器右上角的预览窗口用的, * 不会出现在 app 里,不影响运行 * * 注意:Preview 没有 Android 的 ViewModel 存储, * 所以手动 new 一个 VM 喂进去,避免 viewModel() 报错 */@Preview(showBackground = true, widthDp = 1280, heightDp = 800)@ComposablefunDeviceListScreenPreview() {    MaterialTheme {        DeviceListScreen(vm = DeviceListViewModel())    }}
那整个流程就是如下:
我们运行下:
    那下节,我们就创建实际的设备类,去看看实际设备界面效果。(因为通信协议没出,我还不确定设备类需要哪些信息,只能暂缓啦,但是这两天肯定会大致确定的~~~)

相关学习资料