Jetpack Compose 入门系列(七):ViewModel 与界面状态管理

Jetpack Compose 入门系列(七):ViewModel 与界面状态管理

学完上篇你已经会用 Navigation 3 在多个页面之间跳转、传参和管理返回栈了。但页面一复杂,只靠 remember 把状态写在 Composable 里就不够了,本篇解决一个问题:如何用 ViewModel 管理界面状态,让页面更稳定、更好维护


一、为什么需要 ViewModel

前面几篇里,我们已经用过 rememberrememberSaveable 管理状态,比如输入框内容、按钮点击次数、Tab 选中项。

这种写法在简单页面里没问题:

kotlin 复制代码
@Composable
fun CounterScreen() {
    var count by rememberSaveable { mutableIntStateOf(0) }

    Button(
        onClick = {
            count++
        }
    ) {
        Text(text = "点击次数:$count")
    }
}

对于这种"只属于当前 UI 的小状态",remember 就够了------计数器用 rememberSaveable 只是顺手让它在旋转屏幕后不归零,不代表所有小状态都要用它。

但真实页面通常没这么简单。比如一个课程列表页,可能有:

  • 加载中
  • 加载成功
  • 加载失败
  • 空列表
  • 搜索关键词
  • 刷新按钮
  • 点击重试

如果这些都写在 Composable 里,页面很快就会变成这样:

kotlin 复制代码
@Composable
fun CourseListScreen() {
    // 这些是业务状态,正常应该交给 ViewModel,这里硬塞进 Composable 只是为了演示"反面示例"
    var isLoading by remember { mutableStateOf(false) }
    var errorMessage by remember { mutableStateOf<String?>(null) }
    var courses by remember { mutableStateOf(emptyList<String>()) }
    var keyword by remember { mutableStateOf("") }

    // 下面继续写 UI、请求、错误处理、刷新逻辑......
}

这里不只是状态多的问题:isLoadingerrorMessagecourses 都是业务状态,正常应该交给 ViewModel 管------ViewModel。把它们塞进 Composable 里,加载逻辑和 UI 搅在一起,才是真正的问题。

这就开始不舒服了:Composable 既负责画界面,又负责业务状态,还要处理加载逻辑。时间一长,这个函数就会胖成一坨。

在 XML 时代,我们通常会用 Activity/Fragment + ViewModel

XML 时代 Compose 时代
XML 负责布局 Composable 负责界面
Activity/Fragment 观察状态 Composable 收集状态
ViewModel 保存页面数据 ViewModel 保存页面状态
LiveData 更新 UI StateFlow 更新 UI

Compose 并没有取消 ViewModel,反而更适合和 ViewModel 搭配。

flowchart LR A[用户操作] --> B[Composable 调用事件] B --> C[ViewModel 修改 UI State] C --> D[StateFlow 发出新状态] D --> E[Composable 重组显示新界面]

用一句话概括:Composable 负责展示,ViewModel 负责状态和逻辑


二、添加 ViewModel 和 Lifecycle 依赖

这篇会用到三个重点:

依赖 作用
lifecycle-viewmodel-compose 在 Composable 中获取 ViewModel
lifecycle-runtime-compose 提供 collectAsStateWithLifecycle()
kotlinx-coroutines-android 在 ViewModel 中使用协程

2.1 libs.versions.toml

toml 复制代码
[versions]
agp = "9.2.1"
kotlin = "2.2.10"
composeBom = "2026.02.01"
activity = "1.8.0"
lifecycle = "2.11.0"
coroutines = "1.11.0"

[libraries]
androidx-compose-bom = { module = "androidx.compose:compose-bom", version.ref = "composeBom" }
androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activity" }
androidx-compose-material3 = { module = "androidx.compose.material3:material3" }
androidx-compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" }

androidx-lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycle" }
androidx-lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "lifecycle" }

kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutines" }

[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }

2.2 app/build.gradle.kts

kotlin 复制代码
plugins {
    alias(libs.plugins.android.application)
    alias(libs.plugins.kotlin.android)
    alias(libs.plugins.compose.compiler)
}

android {
    namespace = "com.example.viewmodeldemo"
    compileSdk = 36

    defaultConfig {
        applicationId = "com.example.viewmodeldemo"
        minSdk = 24
        targetSdk = 36
        versionCode = 1
        versionName = "1.0"
    }

    buildFeatures {
        compose = true
    }
}

dependencies {
    implementation(platform(libs.androidx.compose.bom))

    implementation(libs.androidx.activity.compose)
    implementation(libs.androidx.compose.material3)
    implementation(libs.androidx.compose.ui.tooling.preview)

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

    implementation(libs.kotlinx.coroutines.android)
}

这段配置里发生了什么:

  1. lifecycle-viewmodel-compose 让你可以在 Composable 中使用 viewModel()
  2. lifecycle-runtime-compose 提供 collectAsStateWithLifecycle()
  3. kotlinx-coroutines-android 让 ViewModel 能通过 viewModelScope 启动协程。

关键点:在 Compose 里收集 StateFlow,推荐使用 collectAsStateWithLifecycle(),而不是直接用 collectAsState()。它会根据页面生命周期自动开始和停止收集,少踩很多坑。


三、UI State:把页面状态收成一个对象

很多刚开始写 ViewModel 的同学,会这样定义状态:

kotlin 复制代码
class CourseViewModel : ViewModel() {
    var isLoading by mutableStateOf(false)
    var errorMessage by mutableStateOf<String?>(null)
    var courses by mutableStateOf(emptyList<String>())
}

能用,但页面状态一多,很容易散。

更推荐把一个页面的状态收成一个 UiState

kotlin 复制代码
data class CourseListUiState(
    val keyword: String = "",
    val contentState: CourseListContentState = CourseListContentState.Loading
)

sealed interface CourseListContentState {
    data object Loading : CourseListContentState

    data class Error(
        val message: String
    ) : CourseListContentState

    data object Empty : CourseListContentState

    data class Success(
        val courses: List<Course>
    ) : CourseListContentState
}

再定义页面数据:

kotlin 复制代码
data class Course(
    val id: Int,
    val title: String,
    val description: String
)

这段代码里发生了什么:

  1. CourseListUiState 表示课程列表页当前所有 UI 状态。
  2. keyword 表示搜索框输入内容,它是页面里的普通状态。
  3. contentState 表示内容区域当前处于哪种状态。
  4. LoadingErrorEmptySuccess 是互斥状态,同一时间只能出现一种。
  5. Success 里携带课程列表,Error 里携带错误信息。

为什么要这样写?因为页面里有两类状态:

状态类型 示例 推荐写法
可以和其他状态并存的普通状态 搜索关键词、Tab 选中项、开关状态 放在 data class UiState
互斥的内容状态 加载中、加载失败、空列表、有数据 sealed interface 表达

如果只用 isLoadingerrorMessagecourses 这几个字段,也能实现页面,但它允许一些不合理状态,比如"正在加载"同时又"加载失败"。用密封类把内容状态收起来,可以让状态更明确。

页面状态集中以后,Composable 的参数会更清晰:

kotlin 复制代码
@Composable
private fun CourseListContent(
    uiState: CourseListUiState,
    onKeywordChange: (String) -> Unit,
    onRetryClick: () -> Unit
) {
    // 根据 uiState 画界面
}

关键点:一个页面最好对应一个 UiState。普通状态放在 data class 里,互斥状态可以用密封类表达,别让加载中、错误、数据、输入框状态散落在各处。


四、ViewModel:用 StateFlow 暴露状态

接下来写 ViewModel。

完整代码如下:

kotlin 复制代码
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch

class CourseListViewModel : ViewModel() {

    private val _uiState = MutableStateFlow(CourseListUiState())
    val uiState: StateFlow<CourseListUiState> = _uiState.asStateFlow()

    init {
        loadCourses()
    }

    fun onKeywordChange(keyword: String) {
        _uiState.update {
            it.copy(keyword = keyword)
        }
    }

    fun retry() {
        loadCourses()
    }

    private fun loadCourses() {
        viewModelScope.launch {
            _uiState.update {
                it.copy(
                    contentState = CourseListContentState.Loading
                )
            }

            delay(1000)

            _uiState.update {
                it.copy(
                    contentState = if (sampleCourses.isEmpty()) {
                        CourseListContentState.Empty
                    } else {
                        CourseListContentState.Success(sampleCourses)
                    }
                )
            }
        }
    }
}

逐步拆解:

  1. _uiState 是 ViewModel 内部可修改的状态。
  2. uiState 是暴露给 UI 层的只读状态。
  3. asStateFlow()MutableStateFlow 以只读的 StateFlow 类型暴露,让外部在编译期就无法调用 value = 修改,从而划清"谁能改"的边界。
  4. init 中进入页面后自动加载课程。
  5. onKeywordChange() 响应搜索框输入。
  6. retry() 响应重试按钮。
  7. viewModelScope.launch 用来启动和 ViewModel 生命周期绑定的协程。
  8. _uiState.update { it.copy(...) } 基于旧状态生成新状态。
  9. 加载中、加载失败、空列表、有数据这些互斥状态通过 CourseListContentState 表达。

为什么要分 _uiStateuiState

属性 谁能改 给谁用
_uiState ViewModel 内部 ViewModel 自己
uiState 外部只能读 Composable 页面

这就像你开店:仓库你自己能改,顾客只能看货架,不能直接冲进仓库改库存。多花一行字的事,能省一堆 bug。

ViewModel 对外暴露只读 StateFlow,内部保留 MutableStateFlow。这是状态管理里非常重要的边界。


五、Composable:用 collectAsStateWithLifecycle 收集状态

ViewModel 有了,页面怎么拿状态?

在 Compose 中可以这样写:

kotlin 复制代码
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel

@Composable
fun CourseListRoute(
    viewModel: CourseListViewModel = viewModel()
) {
    val uiState by viewModel.uiState.collectAsStateWithLifecycle()

    CourseListScreen(
        uiState = uiState,
        onKeywordChange = viewModel::onKeywordChange,
        onRetryClick = viewModel::retry
    )
}

这段代码里发生了什么:

  1. viewModel() 获取当前页面对应的 ViewModel。
  2. collectAsStateWithLifecycle()StateFlow 转成 Compose 能观察的 State
  3. val uiState by ... 让我们可以直接使用 uiState
  4. 状态变化后,Compose 会自动重组。
  5. 页面事件通过 viewModel::onKeywordChangeviewModel::retry 传给 ViewModel。

这里我特意把函数命名为 CourseListRoute,而不是直接叫 CourseListScreen

通常可以这样拆:

层级 职责
CourseListRoute 拿 ViewModel、收集状态、连接事件
CourseListScreen 纯 UI,根据参数显示界面
CourseListViewModel 管理状态和业务逻辑

结构图如下:

flowchart TB A[CourseListRoute] -->|获取| B[CourseListViewModel] B -->|StateFlow| A A -->|uiState + events| C[CourseListScreen] C -->|用户输入/点击| A A -->|调用方法| B

关键点:Route 负责连接 ViewModel 和 UI,Screen 尽量保持"傻瓜式"------给什么状态就显示什么,点了什么就抛事件。


六、根据 UI State 显示不同界面

现在我们写纯 UI 层。

一个列表页一般有四种状态:

状态 显示内容
加载中 Loading 文案
加载失败 错误信息 + 重试按钮
空列表 空状态文案
有数据 列表内容

代码如下:

kotlin 复制代码
@Composable
private fun CourseListScreen(
    uiState: CourseListUiState,
    onKeywordChange: (String) -> Unit,
    onRetryClick: () -> Unit
) {
    Scaffold { innerPadding ->
        Column(
            modifier = Modifier
                .fillMaxSize()
                .padding(innerPadding)
                .padding(24.dp),
            verticalArrangement = Arrangement.spacedBy(16.dp)
        ) {
            Text(
                text = "课程列表",
                style = MaterialTheme.typography.headlineMedium
            )

            OutlinedTextField(
                value = uiState.keyword,
                onValueChange = onKeywordChange,
                modifier = Modifier.fillMaxWidth(),
                label = {
                    Text(text = "搜索课程")
                }
            )

            when (val contentState = uiState.contentState) {
                CourseListContentState.Loading -> {
                    Text(text = "正在加载课程......")
                }

                is CourseListContentState.Error -> {
                    ErrorContent(
                        message = contentState.message,
                        onRetryClick = onRetryClick
                    )
                }

                CourseListContentState.Empty -> {
                    Text(text = "暂无课程")
                }

                is CourseListContentState.Success -> {
                    val filteredCourses = contentState.courses.filter {
                        it.title.contains(uiState.keyword, ignoreCase = true)
                    }

                    if (filteredCourses.isEmpty()) {
                        Text(text = "没有找到相关课程")
                    } else {
                        filteredCourses.forEach { course ->
                            CourseItem(course = course)
                        }
                    }
                }
            }
        }
    }
}

错误区域和列表项拆成单独组件:

kotlin 复制代码
@Composable
private fun ErrorContent(
    message: String,
    onRetryClick: () -> Unit
) {
    Column(
        verticalArrangement = Arrangement.spacedBy(8.dp)
    ) {
        Text(text = message)

        Button(
            onClick = onRetryClick
        ) {
            Text(text = "重试")
        }
    }
}

@Composable
private fun CourseItem(
    course: Course
) {
    Card(
        modifier = Modifier.fillMaxWidth(),
        elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
    ) {
        Column(
            modifier = Modifier.padding(16.dp),
            verticalArrangement = Arrangement.spacedBy(8.dp)
        ) {
            Text(
                text = course.title,
                style = MaterialTheme.typography.titleMedium
            )

            Text(text = course.description)
        }
    }
}

这段 UI 代码的重点是:它不关心数据怎么来的。

它只关心:

  • uiState.contentState 当前是 LoadingErrorEmpty 还是 Success
  • 如果是 Success,取出里面的课程列表
  • uiState.keyword 当前是什么搜索词,并用它过滤课程列表
  • 用户输入时调用 onKeywordChange
  • 用户点重试时调用 onRetryClick

UI 层不要自己发请求、不要自己改业务状态。它只根据状态画界面,把用户操作告诉外层。


七、常见错误:不要让状态到处乱飞

7.1 不要在 Composable 里直接写加载逻辑

❌ 错误:

kotlin 复制代码
@Composable
fun CourseListScreen() {
    var courses by remember { mutableStateOf(emptyList<Course>()) }

    LaunchedEffect(Unit) {
        courses = loadCoursesFromNetwork()
    }

    // 显示课程列表
}

这段代码的问题是:页面既负责 UI,又负责加载数据。以后要加重试、错误、刷新、缓存,很快就会乱。

✅ 正确:

kotlin 复制代码
@Composable
fun CourseListRoute(
    viewModel: CourseListViewModel = viewModel()
) {
    val uiState by viewModel.uiState.collectAsStateWithLifecycle()

    CourseListScreen(
        uiState = uiState,
        onKeywordChange = viewModel::onKeywordChange,
        onRetryClick = viewModel::retry
    )
}

加载逻辑放 ViewModel,UI 只收状态。

LaunchedEffect 不是不能用,但不要把整页业务加载逻辑都塞进 Composable。

7.2 不要把 MutableStateFlow 暴露给 UI

❌ 错误:

kotlin 复制代码
class CourseListViewModel : ViewModel() {
    val uiState = MutableStateFlow(CourseListUiState())
}

这样 UI 层拿到后,也可以直接改:

kotlin 复制代码
viewModel.uiState.value = CourseListUiState()

边界就乱了。

✅ 正确:

kotlin 复制代码
class CourseListViewModel : ViewModel() {
    private val _uiState = MutableStateFlow(CourseListUiState())
    val uiState: StateFlow<CourseListUiState> = _uiState.asStateFlow()
}

ViewModel 内部能改,UI 外部只能读。

7.3 不要用 collectAsState 替代 collectAsStateWithLifecycle

❌ 错误:

kotlin 复制代码
val uiState by viewModel.uiState.collectAsState()

✅ 正确:

kotlin 复制代码
val uiState by viewModel.uiState.collectAsStateWithLifecycle()

collectAsStateWithLifecycle() 会结合生命周期收集 Flow,更适合 Android 页面。

在 Android Compose 项目里,只要是从 ViewModel 收集 StateFlow,优先使用 collectAsStateWithLifecycle()


八、综合实战:课程列表状态管理 Demo

下面把前面的内容串起来,写一个完整可运行的 Demo:

  • 进入页面自动加载课程
  • 显示加载中
  • 加载完成后展示课程列表
  • 支持搜索过滤
  • 支持模拟错误和重试
  • 支持模拟空列表,让 Empty 状态也能被触发
  • UI 只根据 UiStateContentState 显示内容
  • ViewModel 负责状态和逻辑

完整代码如下:

kotlin 复制代码
package com.example.viewmodeldemo

import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.ViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.compose.viewModel
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch

class MainActivity : ComponentActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            MaterialTheme {
                CourseListRoute()
            }
        }
    }
}

data class Course(
    val id: Int,
    val title: String,
    val description: String
)

data class CourseListUiState(
    val keyword: String = "",
    val contentState: CourseListContentState = CourseListContentState.Loading
)

sealed interface CourseListContentState {
    data object Loading : CourseListContentState

    data class Error(
        val message: String
    ) : CourseListContentState

    data object Empty : CourseListContentState

    data class Success(
        val courses: List<Course>
    ) : CourseListContentState
}

class CourseListViewModel : ViewModel() {

    private val _uiState = MutableStateFlow(CourseListUiState())
    val uiState: StateFlow<CourseListUiState> = _uiState.asStateFlow()

    private var shouldFailNextTime = false
    private var shouldEmptyNextTime = false

    // 保存上一次加载任务,新请求前取消旧的,避免快速连点时多个协程叠加
    private var loadJob: Job? = null

    init {
        loadCourses()
    }

    fun onKeywordChange(keyword: String) {
        _uiState.update {
            it.copy(keyword = keyword)
        }
    }

    fun retry() {
        loadCourses()
    }

    fun simulateError() {
        shouldFailNextTime = true
        loadCourses()
    }

    fun simulateEmpty() {
        shouldEmptyNextTime = true
        loadCourses()
    }

    private fun loadCourses() {
        loadJob?.cancel()
        loadJob = viewModelScope.launch {
            _uiState.update {
                it.copy(
                    contentState = CourseListContentState.Loading
                )
            }

            delay(1000)

            when {
                shouldFailNextTime -> {
                    shouldFailNextTime = false
                    _uiState.update {
                        it.copy(
                            contentState = CourseListContentState.Error(
                                message = "课程加载失败,请稍后重试"
                            )
                        )
                    }
                }

                shouldEmptyNextTime -> {
                    shouldEmptyNextTime = false
                    _uiState.update {
                        it.copy(
                            contentState = CourseListContentState.Empty
                        )
                    }
                }

                else -> {
                    _uiState.update {
                        it.copy(
                            contentState = if (sampleCourses.isEmpty()) {
                                CourseListContentState.Empty
                            } else {
                                CourseListContentState.Success(sampleCourses)
                            }
                        )
                    }
                }
            }
        }
    }
}

private val sampleCourses = listOf(
    Course(
        id = 1,
        title = "Compose 基础控件",
        description = "学习 Text、Button、TextField 等常用组件。"
    ),
    Course(
        id = 2,
        title = "Compose 布局与 State",
        description = "掌握 Column、Row、Box 和状态管理基础。"
    ),
    Course(
        id = 3,
        title = "Navigation 3 页面导航",
        description = "学习页面跳转、传参和返回栈管理。"
    ),
    Course(
        id = 4,
        title = "ViewModel 状态管理",
        description = "把页面状态从 Composable 中拆出来。"
    )
)

@Composable
private fun CourseListRoute(
    viewModel: CourseListViewModel = viewModel()
) {
    val uiState by viewModel.uiState.collectAsStateWithLifecycle()

    CourseListScreen(
        uiState = uiState,
        onKeywordChange = viewModel::onKeywordChange,
        onRetryClick = viewModel::retry,
        onSimulateErrorClick = viewModel::simulateError,
        onSimulateEmptyClick = viewModel::simulateEmpty
    )
}

@Composable
private fun CourseListScreen(
    uiState: CourseListUiState,
    onKeywordChange: (String) -> Unit,
    onRetryClick: () -> Unit,
    onSimulateErrorClick: () -> Unit,
    onSimulateEmptyClick: () -> Unit
) {
    Scaffold { innerPadding ->
        Column(
            modifier = Modifier
                .fillMaxSize()
                .padding(innerPadding)
                .padding(24.dp),
            verticalArrangement = Arrangement.spacedBy(16.dp)
        ) {
            Text(
                text = "课程列表",
                style = MaterialTheme.typography.headlineMedium
            )

            OutlinedTextField(
                value = uiState.keyword,
                onValueChange = onKeywordChange,
                modifier = Modifier.fillMaxWidth(),
                label = {
                    Text(text = "搜索课程")
                }
            )

            Row(
                horizontalArrangement = Arrangement.spacedBy(8.dp)
            ) {
                OutlinedButton(
                    onClick = onRetryClick
                ) {
                    Text(text = "刷新")
                }

                OutlinedButton(
                    onClick = onSimulateErrorClick
                ) {
                    Text(text = "模拟错误")
                }

                OutlinedButton(
                    onClick = onSimulateEmptyClick
                ) {
                    Text(text = "模拟空列表")
                }
            }

            CourseListStateContent(
                uiState = uiState,
                onRetryClick = onRetryClick
            )
        }
    }
}

@Composable
private fun CourseListStateContent(
    uiState: CourseListUiState,
    onRetryClick: () -> Unit
) {
    when (val contentState = uiState.contentState) {
        CourseListContentState.Loading -> {
            Text(text = "正在加载课程......")
        }

        is CourseListContentState.Error -> {
            ErrorContent(
                message = contentState.message,
                onRetryClick = onRetryClick
            )
        }

        CourseListContentState.Empty -> {
            Text(text = "暂无课程")
        }

        is CourseListContentState.Success -> {
            val filteredCourses = contentState.courses.filter {
                it.title.contains(uiState.keyword, ignoreCase = true)
            }

            if (filteredCourses.isEmpty()) {
                Text(text = "没有找到相关课程")
            } else {
                Column(
                    verticalArrangement = Arrangement.spacedBy(12.dp)
                ) {
                    filteredCourses.forEach { course ->
                        CourseItem(course = course)
                    }
                }
            }
        }
    }
}

@Composable
private fun ErrorContent(
    message: String,
    onRetryClick: () -> Unit
) {
    Column(
        verticalArrangement = Arrangement.spacedBy(8.dp)
    ) {
        Text(text = message)

        Button(
            onClick = onRetryClick
        ) {
            Text(text = "重试")
        }
    }
}

@Composable
private fun CourseItem(
    course: Course
) {
    Card(
        modifier = Modifier.fillMaxWidth(),
        elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
    ) {
        Column(
            modifier = Modifier.padding(16.dp),
            verticalArrangement = Arrangement.spacedBy(8.dp)
        ) {
            Text(
                text = course.title,
                style = MaterialTheme.typography.titleMedium
            )

            Text(text = course.description)
        }
    }
}

这份 Demo 的数据流如下:

flowchart LR A[用户输入搜索词] --> B[onKeywordChange] B --> C[ViewModel 更新 UiState] C --> D[StateFlow 发出新状态] D --> E[CourseListRoute 收集状态] E --> F[CourseListScreen 重组]

实战知识点对应表

实战中的效果 使用的 API 对应章节
页面状态集中管理 CourseListUiState
ViewModel 保存状态 CourseListViewModel
对外暴露只读状态 StateFlow + asStateFlow()
修改状态 _uiState.update { it.copy(...) }
页面收集状态 collectAsStateWithLifecycle()
Route 连接状态与 UI CourseListRoute
根据状态显示不同内容 when 判断 contentState
加载、错误、空状态、列表 CourseListContentState 六、八

这就是 Compose 里常见的一条线:用户操作 → ViewModel 改状态 → StateFlow 发出新状态 → Composable 重组。


九、ViewModel 使用速查表

你想做什么 推荐写法
定义页面状态 data class XxxUiState(...) + sealed interface XxxContentState
创建 ViewModel class XxxViewModel : ViewModel()
内部可变状态 private val _uiState = MutableStateFlow(...)
对外只读状态 val uiState = _uiState.asStateFlow()
修改状态 _uiState.update { it.copy(...) }
启动异步任务 viewModelScope.launch { ... }
Composable 获取 ViewModel viewModel: XxxViewModel = viewModel()
收集 StateFlow collectAsStateWithLifecycle()
UI 组件暴露事件 onClickonRetryClickonKeywordChange

十、总结

本篇你学到了 Compose 中 ViewModel 与界面状态管理的核心写法:

  1. 状态分层remember 适合组件内部小状态,ViewModel 适合页面级业务状态
  2. UI State :用 data class XxxUiState 描述页面普通状态,用密封类描述互斥的内容状态
  3. StateFlow :ViewModel 内部用 MutableStateFlow,对外暴露只读 StateFlow
  4. 状态更新 :用 update { it.copy(...) } 基于旧状态生成新状态
  5. 生命周期收集 :Composable 中使用 collectAsStateWithLifecycle() 收集状态
  6. Route 与 Screen 拆分:Route 负责连接 ViewModel,Screen 负责纯 UI 展示
  7. 状态驱动 UI :根据 LoadingErrorEmptySuccess 等状态显示不同界面

核心原则:Composable 不应该变成业务逻辑大杂烩,页面状态和业务操作交给 ViewModel,UI 只负责根据状态显示结果

下一篇我们将学习 Compose 中的副作用处理 ------如何正确使用 LaunchedEffectDisposableEffectrememberUpdatedState,处理一次性任务、生命周期回调和状态变化触发的逻辑。


如果你在学习过程中有任何疑问,欢迎在评论区留言,我会尽可能回复。

系列文章:

相关推荐
落魄Android在线炒饭1 小时前
Android Framework 开发技巧:android.jar 生成与系统快速编译验证
android
如此风景2 小时前
Kotlin Flow操作符学习
android·kotlin
plainGeekDev3 小时前
GreenDAO → Room
android·java·kotlin
weiggle3 小时前
第八篇:ViewModel + Compose——生产级状态管理实践
android
恋猫de小郭8 小时前
Amper 正式转正 Kotlin Toolchain ,Gradle 未来何去何从
android·前端·flutter
plainGeekDev9 小时前
ButterKnife → ViewBinding
android·java·kotlin
成都大菠萝1 天前
Android Car CarProperty 车辆信号链路
android
敲代码的鱼1 天前
PDF 预览与签名批注写回 支持安卓 iOS 鸿蒙 UTS插件
android·前端·ios
时光足迹1 天前
uni-app 视频通话实战:康复师与患者视频问诊的 6 个致命 Bug 与解决方案
android·ios·uni-app