第八篇:ViewModel + Compose——生产级状态管理实践

8.1 ViewModel 在 Compose 中的角色

在 Android 传统开发中,ViewModel 的作用是持久的屏幕状态管理 ,它在配置变更(如屏幕旋转)后存活。在 Compose 中,这个角色没有变化------ViewModel 依然是业务逻辑与 UI 展示之间的桥梁。

8.2 viewModel() 函数

kotlin 复制代码
import androidx.lifecycle.viewmodel.compose.viewModel

@Composable
fun ProductScreen(
    categoryId: String,
    viewModel: ProductViewModel = viewModel()  // 自动获取或创建
) {
    // ...
}

viewModel() 函数是一个 Composable 函数,它会:

  1. 获取当前 LocalViewModelStoreOwner(通常是 Activity/Fragment)
  2. 从中获取或创建 ViewModel 实例
  3. 在配置变更后保持实例
  4. 在 ViewModelStoreOwner 销毁后自动清理

带参数工厂

kotlin 复制代码
// 如果 ViewModel 需要构造参数
class ProductViewModel(
    private val productId: String,
    private val repository: ProductRepository
) : ViewModel() {
    // ...
}

// 创建 Factory
class ProductViewModelFactory(
    private val productId: String,
    private val repository: ProductRepository = ProductRepository()
) : ViewModelProvider.Factory {
    override fun <T : ViewModel> create(modelClass: Class<T>): T {
        return ProductViewModel(productId, repository) as T
    }
}

// 使用
@Composable
fun ProductDetailScreen(productId: String) {
    val factory = remember(productId) {
        ProductViewModelFactory(productId)
    }
    val viewModel: ProductViewModel = viewModel(factory = factory)
    // ...
}

或者使用 SavedStateHandle(推荐的方式,无需 Factory):

kotlin 复制代码
class ProductViewModel(
    savedStateHandle: SavedStateHandle,
    private val repository: ProductRepository = ProductRepository()
) : ViewModel() {
    private val productId: String = savedStateHandle["productId"]!!
    
    // SavedStateHandle 中的值会自动在配置变更和进程终止后恢复
    var searchQuery by savedStateHandle.getStateFlow("searchQuery", "")
        private set
}

8.3 UiState 设计模式

密封类模式

kotlin 复制代码
sealed interface ProductUiState {
    data object Loading : ProductUiState
    data class Success(
        val product: Product,
        val isFavorite: Boolean = false
    ) : ProductUiState
    data class Error(val message: String) : ProductUiState
}

class ProductViewModel : ViewModel() {
    private val _uiState = MutableStateFlow<ProductUiState>(ProductUiState.Loading)
    val uiState: StateFlow<ProductUiState> = _uiState.asStateFlow()
    
    fun loadProduct(id: String) {
        viewModelScope.launch {
            _uiState.value = ProductUiState.Loading
            try {
                val product = repository.getProduct(id)
                _uiState.value = ProductUiState.Success(product)
            } catch (e: Exception) {
                _uiState.value = ProductUiState.Error(e.message ?: "Unknown error")
            }
        }
    }
}

// UI 使用
@Composable
fun ProductScreen(viewModel: ProductViewModel = viewModel()) {
    val uiState by viewModel.uiState.collectAsStateWithLifecycle()
    
    when (val state = uiState) {
        is ProductUiState.Loading -> LoadingIndicator()
        is ProductUiState.Success -> ProductDetail(state.product, state.isFavorite)
        is ProductUiState.Error -> ErrorScreen(state.message)
    }
}

单一数据类模式

kotlin 复制代码
data class ProductUiState(
    val product: Product? = null,
    val isLoading: Boolean = false,
    val error: String? = null,
    val isFavorite: Boolean = false,
    val quantity: Int = 1
)

class ProductViewModel : ViewModel() {
    private val _uiState = MutableStateFlow(ProductUiState())
    val uiState: StateFlow<ProductUiState> = _uiState.asStateFlow()
    
    fun setQuantity(qty: Int) {
        _uiState.update { it.copy(quantity = qty.coerceIn(1, 99)) }
    }
}

如何选择:

模式 适合场景
sealed interface 互斥的 UI 状态(Loading / Success / Error)
单一 data class 可组合的 UI 属性(多个独立字段)
混合 sealed interface 包裹 data class(常用推荐)

推荐的混合模式

kotlin 复制代码
// 屏幕级 UiState
sealed interface ScreenState<out T> {
    data object Loading : ScreenState<Nothing>
    data class Success<T>(val data: T) : ScreenState<T>
    data class Error(val message: String) : ScreenState<Nothing>
}

// 页面具体状态
data class HomeUiData(
    val banners: List<Banner> = emptyList(),
    val products: List<Product> = emptyList(),
    val searchHistory: List<String> = emptyList()
)

// ViewModel
class HomeViewModel : ViewModel() {
    private val _uiState = MutableStateFlow<ScreenState<HomeUiData>>(
        ScreenState.Loading
    )
    val uiState: StateFlow<ScreenState<HomeUiData>> = _uiState.asStateFlow()
}

// Composable
@Composable
fun HomeScreen(viewModel: HomeViewModel = viewModel()) {
    val uiState by viewModel.uiState.collectAsStateWithLifecycle()
    
    when (val state = uiState) {
        is ScreenState.Loading -> LoadingIndicator()
        is ScreenState.Success -> HomeContent(state.data)
        is ScreenState.Error -> ErrorView(state.message)
    }
}

8.4 事件处理(一次性事件)

Snackbar、导航、Toast 等一次性事件不需要状态来驱动 UI 展示:

kotlin 复制代码
// ViewModel
class OrderViewModel : ViewModel() {
    private val _events = Channel<OrderEvent>(Channel.BUFFERED)
    val events = _events.receiveAsFlow()
    
    fun placeOrder() {
        viewModelScope.launch {
            try {
                repository.placeOrder(cart)
                _events.send(OrderEvent.OrderPlaced("Order #12345 confirmed"))
            } catch (e: Exception) {
                _events.send(OrderEvent.Error(e.message ?: "Order failed"))
            }
        }
    }
}

sealed interface OrderEvent {
    data class OrderPlaced(val message: String) : OrderEvent
    data class Error(val message: String) : OrderEvent
}

// Composable
@Composable
fun OrderScreen(viewModel: OrderViewModel = viewModel()) {
    val snackbarHostState = remember { SnackbarHostState() }
    
    // 收集一次性事件
    LaunchedEffect(Unit) {
        viewModel.events.collect { event ->
            when (event) {
                is OrderEvent.OrderPlaced -> {
                    snackbarHostState.showSnackbar(event.message)
                }
                is OrderEvent.Error -> {
                    snackbarHostState.showSnackbar(event.message)
                }
            }
        }
    }
    
    // ...
}

Channel vs SharedFlow :一次性事件用 Channel(不保留历史),UI 状态用 StateFlow(保留最新值)。使用 Channel 可以防止屏幕旋转后重新播放旧事件。

8.5 实战:完整的列表搜索+收藏

kotlin 复制代码
// ViewModel
class ProductListViewModel(
    private val repository: ProductRepository = ProductRepository()
) : ViewModel() {

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

    private val _events = Channel<ProductListEvent>(Channel.BUFFERED)
    val events = _events.receiveAsFlow()

    init {
        loadProducts()
    }

    fun onSearchQueryChanged(query: String) {
        _uiState.update { it.copy(searchQuery = query) }
    }

    fun onSearch() {
        loadProducts()
    }

    fun onToggleFavorite(productId: String) {
        _uiState.update { state ->
            state.copy(
                products = state.products.map { product ->
                    if (product.id == productId) {
                        product.copy(isFavorite = !product.isFavorite)
                    } else product
                }
            )
        }
        viewModelScope.launch {
            repository.toggleFavorite(productId)
            _events.send(ProductListEvent.FavoriteToggled)
        }
    }

    fun onRefresh() {
        loadProducts()
    }

    private fun loadProducts() {
        viewModelScope.launch {
            _uiState.update { it.copy(isLoading = true, error = null) }
            try {
                val query = _uiState.value.searchQuery
                val products = repository.searchProducts(query)
                _uiState.update { it.copy(products = products, isLoading = false) }
            } catch (e: Exception) {
                _uiState.update { it.copy(error = e.message, isLoading = false) }
            }
        }
    }
}

data class ProductListUiState(
    val products: List<Product> = emptyList(),
    val searchQuery: String = "",
    val isLoading: Boolean = false,
    val error: String? = null
)

sealed interface ProductListEvent {
    data object FavoriteToggled : ProductListEvent
}

// Composable
@Composable
fun ProductListScreen(
    viewModel: ProductListViewModel = viewModel(),
    onProductClick: (String) -> Unit
) {
    val uiState by viewModel.uiState.collectAsStateWithLifecycle()
    val snackbarHostState = remember { SnackbarHostState() }

    LaunchedEffect(Unit) {
        viewModel.events.collect { event ->
            when (event) {
                is ProductListEvent.FavoriteToggled -> {
                    snackbarHostState.showSnackbar("收藏状态已更新")
                }
            }
        }
    }

    Scaffold(
        snackbarHost = { SnackbarHost(snackbarHostState) },
        topBar = {
            SearchTopBar(
                query = uiState.searchQuery,
                onQueryChange = viewModel::onSearchQueryChanged,
                onSearch = viewModel::onSearch
            )
        }
    ) { padding ->
        Box(modifier = Modifier.padding(padding)) {
            when {
                uiState.isLoading && uiState.products.isEmpty() -> LoadingIndicator()
                uiState.error != null && uiState.products.isEmpty() -> 
                    ErrorView(uiState.error!!, onRetry = viewModel::onRefresh)
                else -> ProductList(
                    products = uiState.products,
                    onProductClick = onProductClick,
                    onToggleFavorite = viewModel::onToggleFavorite,
                    onRefresh = viewModel::onRefresh,
                    isRefreshing = uiState.isLoading
                )
            }
        }
    }
}

8.6 ViewModel 在 Compose 中的陷阱

不要在 Composable 中创建 ViewModel

kotlin 复制代码
// ❌ 错误:每次重组都可能创建新的 ViewModel
@Composable
fun BadScreen() {
    val viewModel = remember { ProductViewModel() }
    // ...
}

// ✅ 正确:使用 viewModel() 函数,它由 ViewModelStoreOwner 管理
@Composable
fun GoodScreen() {
    val viewModel: ProductViewModel = viewModel()
    // ...
}

不要在 ViewModel 中持有 Composable 引用

kotlin 复制代码
// ❌ 错误:ViewModel 不能持有 Activity/Context/Composable 引用
class BadViewModel : ViewModel() {
    var someState by mutableStateOf(...)  // 不推荐在 VM 中使用
}

// ✅ 正确
class GoodViewModel : ViewModel() {
    private val _state = MutableStateFlow(...)
    val state: StateFlow<...> = _state.asStateFlow()
}

不要忘记清理资源

kotlin 复制代码
class MyViewModel : ViewModel() {
    init {
        viewModelScope.launch {
            // viewModelScope 的生命周期跟随 ViewModel
            // 不需要手动取消协程
        }
    }
}

8.7 本章小结

内容 要点
viewModel() Compose 中获取 ViewModel 的标准函数
UiState 设计 sealed interface 或 data class,保持不可变
一次性事件 用 Channel,不用 StateFlow
混合模式 ScreenState<T> + UiData 联合使用
配置变更 ViewModel 天然支持,搭配 rememberSaveable
生命周期 collectAsStateWithLifecycle 防止后台更新

下一篇:Compose 副作用详解------生命周期管理的正确姿势。

相关推荐
千里马学框架4 天前
一起学 Android 14:ShellTransition 屏幕旋转过程深度剖析
android·智能手机·性能优化·framework·性能·屏幕旋转·rotation
美狐美颜SDK开放平台4 天前
开发直播APP时如何接入视频美颜SDK?开发流程与注意事项
android·人工智能·计算机视觉·音视频·直播美颜sdk
AFinalStone4 天前
Android7 SystemUI源码解析(七)Keyguard锁屏模块深度解析
android·systemui
致远ccc4 天前
Google Play 上架前如何测试 App?多国家 Android 环境测试
android·app测试·googleplay·多国家应用测试
ttyyttemo4 天前
Kotlin 协程中的 Job 结构化并发与取消
android
sun0077004 天前
tbox 4g/5g切换,导致wan ip 改变,导致车机旧网络不可用。需要重启车机才行
android
其实防守也摸鱼4 天前
内网穿透与反向代理:原理、工具与实战指南
android·大数据·运维·安全·网络安全·自动化·渗透
AFinalStone4 天前
Android7 SystemUI 源码解析(四)NavigationBar 导航栏与 SystemBars
android·systemui
JMchen4 天前
属性动画原理与高级动画实现
android·kotlin·canvas
AFinalStone4 天前
Android7 SystemUI 源码解析(二)启动流程深度解析
android·systemui