A
第 49 章KOTLIN40 分钟

Compose + ViewModel + 互操作

Compose 与架构打通:viewModel() 获取 ViewModel、StateFlow collectAsStateWithLifecycle 与 LiveData observeAsState、向 ViewModel 传递事件、副作用 API(LaunchedEffect/DisposableEffect/rememberCoroutineScope)以及 AndroidView 与 ComposeView 双向互操作。

学习目标

  • 在 Compose 中使用 viewModel() 获取并复用 ViewModel
  • 掌握 StateFlow collectAsStateWithLifecycle 与 LiveData observeAsState 的接入方式
  • 理解副作用 API 的使用边界:LaunchedEffect / DisposableEffect / rememberCoroutineScope
  • 能够用 AndroidView 嵌入传统 View,用 ComposeView 将 Compose 嵌入 View 体系

学习目标

  • 在 Compose 中使用 viewModel() 获取并复用 ViewModel;
  • 掌握 StateFlow.collectAsStateWithLifecycle 与 LiveData.observeAsState 的接入方式;
  • 理解副作用 API 的使用边界:LaunchedEffect / DisposableEffect / rememberCoroutineScope;
  • 能够用 AndroidView 嵌入传统 View,用 ComposeView 将 Compose 嵌入 View 体系。

Compose + ViewModel + 互操作

上一章讨论了 Compose 内部状态。真实工程中,跨屏幕共享、跨进程恢复的状态应交给 ViewModel。本章把 ViewModel、响应式流、副作用与新旧视图互操作串起来。

依赖配置

dependencies {
    val composeBom = platform("androidx.compose:compose-bom:2024.09.03")
    implementation(composeBom)
    implementation("androidx.compose.material3:material3")
    // ViewModel for Compose
    implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.6")
    // collectAsStateWithLifecycle 所在
    implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.6")
    // LiveData 互操作(如已用 LiveData)
    implementation("androidx.compose.runtime:runtime-livedata")
    implementation("androidx.activity:activity-compose:1.9.2")
}

ViewModel in Compose:viewModel()

androidx.lifecycle:lifecycle-viewmodel-compose 提供了 viewModel() 可组合函数,它会从最近的 ViewModelStoreOwner(Activity / Fragment / Navigation 目标)拿到对应 ViewModel:

import androidx.lifecycle.viewmodel.compose.viewModel

class ProfileViewModel : ViewModel() {
    private val _name = MutableStateFlow("Tom")
    val name: StateFlow<String> = _name.asStateFlow()

    fun update(name: String) { _name.value = name }
}

@Composable
fun ProfileScreen(vm: ProfileViewModel = viewModel()) {
    val name by vm.name.collectAsStateWithLifecycle()
    Column {
        Text("姓名:$name")
        Button(onClick = { vm.update("Jerry") }) { Text("改名") }
    }
}

viewModel() 返回的实例与 ViewModelProvider(this).get(...) 等价,配置变更前后拿到的是 同一个实例。

带参数的 ViewModel:ViewModelProvider.Factory

如果 ViewModel 构造时需要参数(仓库、ID),需要提供 ViewModelProvider.Factory。Compose 提供了更简洁的 viewModel(factory = ...):

class DetailViewModel(private val itemId: String) : ViewModel() {
    private val _state = MutableStateFlow(DetailUiState())
    val state: StateFlow<DetailUiState> = _state.asStateFlow()

    init { load() }

    private fun load() {
        viewModelScope.launch {
            _state.value = _state.value.copy(loading = true)
            // ... 异步加载
        }
    }
}

class DetailViewModelFactory(private val itemId: String) : ViewModelProvider.Factory {
    @Suppress("UNCHECKED_CAST")
    override fun <T : ViewModel> create(modelClass: Class<T>): T {
        if (modelClass.isAssignableFrom(DetailViewModel::class.java)) {
            return DetailViewModel(itemId) as T
        }
        throw IllegalArgumentException("Unknown ViewModel class")
    }
}

@Composable
fun DetailScreen(itemId: String) {
    val vm: DetailViewModel = viewModel(factory = DetailViewModelFactory(itemId))
    val state by vm.state.collectAsStateWithLifecycle()
    // ...
}

Compose 1.6+ 推荐 Application 容器 + CreationExtras 的方式(viewModelFactory { initializer { ... } })替代手写 Factory,可避免反射:

import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModel

@Composable
fun DetailScreen(itemId: String) {
    val vm: DetailViewModel = viewModel {
        initializer { DetailViewModel(itemId) }
    }
}

StateFlow.collectAsStateWithLifecycle

collectAsStateWithLifecycle 是 Compose 接 StateFlow/LiveData 的 推荐 方式。它会在 Composable 进入 STOPPED 以下生命周期时 停止收集,回到前台自动恢复,避免后台浪费资源:

import androidx.lifecycle.compose.collectAsStateWithLifecycle

@Composable
fun CounterScreen(vm: CounterViewModel = viewModel()) {
    // Lifecycle 感知:UI 不可见时不收集
    val count by vm.count.collectAsStateWithLifecycle()
    Text("$count")
}

旧 API collectAsState() 不感知生命周期,只要 Composable 在组合中就一直收集。新项目一律用 collectAsStateWithLifecycle。

LiveData.observeAsState

对于历史代码里仍是 LiveData 的状态,用 runtime-livedata 工件提供的 observeAsState() 接入,无需迁移到 StateFlow:

import androidx.compose.runtime.livedata.observeAsState

class LegacyViewModel : ViewModel() {
    private val _user = MutableLiveData<User>()
    val user: LiveData<User> = _user

    fun load() { _user.value = User(1, "Tom") }
}

@Composable
fun LegacyScreen(vm: LegacyViewModel = viewModel()) {
    val user by vm.user.observeAsState()
    // 初次订阅前 user 为 null,注意处理
    Text(user?.name ?: "加载中")
}

observeAsState 内部已实现生命周期感知(LiveData 本身就有),不像 StateFlow 那样需要 WithLifecycle 变体。

向 ViewModel 传递事件:UDF

UI 把用户操作作为“事件”传给 ViewModel,状态反过来再流向 UI:

data class LoginUiState(
    val username: String = "",
    val password: String = "",
    val loading: Boolean = false,
    val error: String? = null
)

class LoginViewModel : ViewModel() {
    private val _state = MutableStateFlow(LoginUiState())
    val state: StateFlow<LoginUiState> = _state.asStateFlow()

    fun onUsernameChange(v: String) { _state.value = _state.value.copy(username = v) }
    fun onPasswordChange(v: String) { _state.value = _state.value.copy(password = v) }

    fun submit() {
        viewModelScope.launch {
            _state.value = _state.value.copy(loading = true, error = null)
            // ... 异步校验
            _state.value = _state.value.copy(loading = false)
        }
    }
}

@Composable
fun LoginScreen(vm: LoginViewModel = viewModel()) {
    val state by vm.state.collectAsStateWithLifecycle()
    Column {
        TextField(value = state.username, onValueChange = vm::onUsernameChange)
        TextField(value = state.password, onValueChange = vm::onPasswordChange)
        Button(onClick = vm::submit, enabled = !state.loading) {
            Text(if (state.loading) "登录中" else "登录")
        }
        state.error?.let { Text(it, color = Color.Red) }
    }
}

要点:UI 永远不直接修改 ViewModel 字段,只调用方法。

副作用 API

Compose 中的“副作用”指 不应在重组过程中执行的、对组合外产生影响 的操作,例如网络请求、订阅、动画、Toast。这些必须放到副作用 API 中。

LaunchedEffect:协程副作用

LaunchedEffect(key1) 在进入组合时启动一个协程,key 变化时取消旧的并重启新的:

@Composable
fun SnackDetailScreen(snackId: String) {
    LaunchedEffect(snackId) {
        // snackId 变化时重新加载;离开组合时自动取消
        val snack = repository.fetch(snackId)
        // 更新本地状态
    }
}

特点:

  • 离开组合时协程自动取消;
  • key 变化时取消旧协程并启动新的;
  • 不要在协程外修改 MutableState 持有的状态时跨线程——LaunchedEffect 默认在主调度器。

LaunchedEffect 不应只为了“启动一次”而省略 key——如果需要在特定条件下重启,写明 key。

DisposableEffect:需要清理的副作用

DisposableEffect 用于需要 onDispose 的场景,典型是注册监听、View 互操作:

@Composable
fun ConnectivityAware(network: ConnectivityManager) {
    val state = remember { mutableStateOf(false) }
    DisposableEffect(network) {
        val callback = object : NetworkCallback() {
            override fun onAvailable(network: Network) { state.value = true }
            override fun onLost(network: Network) { state.value = false }
        }
        network.registerDefaultNetworkCallback(callback)
        onDispose { network.unregisterNetworkCallback(callback) }
    }
    Text(if (state.value) "在线" else "离线")
}

rememberCoroutineScope

rememberCoroutineScope 返回一个绑定到 Dispatchers.Main、并在 Composable 离开组合时自动取消的 CoroutineScope,用于在事件回调里启动协程(例如按钮点击触发异步):

@Composable
fun SaveButton() {
    val scope = rememberCoroutineScope()
    var saving by remember { mutableStateOf(false) }
    Button(
        onClick = {
            saving = true
            scope.launch {
                repository.save()
                saving = false
            }
        },
        enabled = !saving
    ) { Text("保存") }
}

rememberCoroutineScope 与 LaunchedEffect 的差异:前者“启动时机由事件决定”,后者“启动时机由进入组合/key 变化决定”。

produceState:把异步流变成 State

produceState 把非 Compose 的异步源(如 callback、flow)转成 State,自带协程上下文与取消逻辑:

@Composable
fun loadImage(url: String): State<Bitmap?> = produceState(initialValue = null, url) {
    value = CoilLoader.load(url)  // 离开组合自动取消
}

@Composable
fun Avatar(url: String) {
    val bitmap by loadImage(url)
    if (bitmap != null) Image(bitmap.asImageBitmap(), null)
}

derivedStateOf 与 remember 的协作

回顾上一章,派生状态请配合 remember 使用,避免每次重组重新创建 derivedStateOf 实例。

AndroidView:在 Compose 中嵌入传统 View

并非所有组件都有 Compose 版本——地图、视频、广告、WebView 等仍需用 AndroidView 嵌入传统 View:

import androidx.compose.ui.viewinterop.AndroidView

@Composable
fun MapScreen() {
    AndroidView(
        factory = { ctx -> MapView(ctx).apply {
            onCreate(null)
            getMapAsync { /* 配置地图 */ }
        } },
        update = { map -> /* 每次 view 引用变化或重组时调用 */ },
        onRelease = { map -> map.onDestroy() }
    )
}
  • factory:首次创建 View 实例,仅在第一次组合时调用;
  • update:每次重组(或 View 实例变化)时调用,用于同步 Compose 状态到 View;
  • onRelease:View 离开组合时调用,用于释放资源。

同步 Compose 状态到 View

@Composable
fun WebViewScreen(url: String) {
    AndroidView(
        factory = { ctx -> WebView(ctx).apply {
            settings.javaScriptEnabled = true
        } },
        update = { web -> web.loadUrl(url) }   // url 变化时自动重新加载
    )
}

update 在每次 url 变化的重组时被调用,因此能“把 Compose 状态推到传统 View”。

ComposeView:把 Compose 嵌入 View 体系

反向场景:在传统 View/Fragment/Adapter 里嵌入一小段 Compose,用 ComposeView:

class LegacyActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(ComposeView(this).apply {
            setViewCompositionStrategy(
                ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed
            )
            setContent {
                MaterialTheme {
                    Text("我在传统布局里")
                }
            }
        })
    }
}

Fragment 中用 ComposeView 更常见,注意 viewLifecycleOwner:

class MyFragment : Fragment() {
    override fun onCreateView(
        inflater: LayoutInflater, container: ViewGroup?, s: Bundle?
    ): View = ComposeView(requireContext()).apply {
        setViewCompositionStrategy(
            ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed
        )
        setContent {
            MaterialTheme {
                Surface {
                    Text("Fragment 中的 Compose")
                }
            }
        }
    }
}

setViewCompositionStrategy 决定何时销毁 Compose:默认跟随 View 树的生命周期。务必显式设置以免泄漏。

AbstractComposeView:自定义 View 包装 Compose

如果要在传统 XML 里像普通 View 一样使用一段 Compose 内容,可以继承 AbstractComposeView:

class ComposeButtonView @JvmOverloads constructor(
    context: Context, attrs: AttributeSet? = null, defStyle: Int = 0
) : AbstractComposeView(context, attrs, defStyle) {

    var onClick: () -> Unit by mutableStateOf({})
    var text: String by mutableStateOf("按钮")

    @Composable
    override fun Content() {
        Button(onClick = onClick) { Text(text) }
    }
}

这样在 XML 中能直接 <com.example.ComposeButtonView android:layout_width="wrap_content" />,把 Compose 包装成传统 View。

实战:带网络的列表页

下面把 ViewModel + collectAsStateWithLifecycle + 副作用 + 状态提升整合成一个完整的列表页骨架:

class ListViewModel : ViewModel() {
    private val _state = MutableStateFlow(ListUiState())
    val state: StateFlow<ListUiState> = _state.asStateFlow()

    fun refresh() {
        viewModelScope.launch {
            _state.value = _state.value.copy(loading = true, error = null)
            try {
                val items = repo.fetch()
                _state.value = _state.value.copy(loading = false, items = items)
            } catch (e: Exception) {
                _state.value = _state.value.copy(loading = false, error = e.message)
            }
        }
    }
}

data class ListUiState(
    val loading: Boolean = false,
    val items: List<Item> = emptyList(),
    val error: String? = null
)

@Composable
fun ListScreen(vm: ListViewModel = viewModel()) {
    val state by vm.state.collectAsStateWithLifecycle()
    // 进入屏幕自动加载一次
    LaunchedEffect(Unit) { vm.refresh() }

    Box {
        if (state.loading) CircularProgressIndicator(Modifier.align(Center))
        LazyColumn {
            items(state.items) { item -> ItemRow(item) }
        }
        state.error?.let {
            Text(it, color = Color.Red, modifier = Modifier.align(BottomCenter))
        }
    }
}

常见坑与最佳实践

  1. 在 Composable 里 new ViewModel():每次重组都创建新实例。务必用 viewModel(),让它复用 Activity 范围内的实例。
  2. 用 collectAsState 而非 collectAsStateWithLifecycle:后台仍在收集,浪费电量和流量。新项目一律 WithLifecycle。
  3. LaunchedEffect 的 key 错位:传了 Unit 但又依赖外部参数,导致 key 变化时不重启。仔细想清楚 key 与副作用的依赖关系。
  4. 在副作用外启动协程:直接 GlobalScope 或在 remember 内创建 CoroutineScope,会泄漏。用 LaunchedEffect 或 rememberCoroutineScope。
  5. AndroidView 里在 factory 中读取状态:factory 仅执行一次,状态变化要在 update 中同步。
  6. ComposeView 没设 ViewCompositionStrategy:默认行为不一定符合预期,Fragment 里尤其要绑定 viewLifecycleOwner。
  7. ViewModel 暴露 MutableStateFlow:对外只暴露 StateFlow/SharedFlow,自己持有 Mutable*,避免 UI 反向修改。
  8. 在 LaunchedEffect 中长时间持有 Composable 引用:协程取消前若持有 View 引用会泄漏。副作用块里尽量只引用 ViewModel 或不可变状态。
  9. observeAsState 默认值缺失:UI 上对 null 没处理会崩溃或显示空。给 LiveData 初始值或 UI 兜底。
  10. 混合使用 LiveData/StateFlow 导致双数据源:迁移期可以并存,但同一状态只应有一个可信源,否则会出现状态不一致。

章节小结

本章把 Compose 与架构层打通:viewModel() 复用 ViewModel 实例;StateFlow.collectAsStateWithLifecycle 是生命周期感知的推荐接入方式,LiveData.observeAsState 用于历史代码迁移;UI 通过方法调用把事件上传 ViewModel;副作用 API(LaunchedEffect、DisposableEffect、rememberCoroutineScope、produceState)规范了“不该在重组里做的事”的写法;AndroidView 用于把传统 View 嵌入 Compose,ComposeView/AbstractComposeView 反向嵌入,是迁移期的关键工具。

下一章预告

下一章进入多页面导航:用 navigation-compose 搭建 NavHost 与 composable 目标,掌握 NavController、参数传递(必选/可选)、navigation {} 嵌套 NavGraph、底部导航栏 BottomBar 集成以及深度链接(deep link)的接入。