各位 Compose 开发者,周五好。趁着这个愉快的周五,我们一起探讨一个问题:Compose 中的 UI State 到底应该怎么写?
我相信,大部分开发者在使用 Compose 时,很容易把页面状态写成下面这样:
sealed interface FeedUiState {
data object Loading : FeedUiState
data class Success(
val articles: List<ArticleUiModel>,
) : FeedUiState
data class Error(
val reason: FeedError,
) : FeedUiState
}
这套写法看起来非常清楚,页面无非就是加载中、加载成功和加载失败三种状态。
坦白讲,这个写法一点问题没有,很多官方的代码示例就是这么写的,因为很多页面的状态,就是互斥的。
但是!如果产品提出这样一个很普通的需求:下拉刷新。问题来了。
众所周知,下拉刷新是不需要全局显示 Loading 的,也就是说,数据还存在,只是有一个和初始 Loading 不一样的 Loading 状态。
如果按照之前的划分,我问你:
- 刷新过程中,页面到底算
Loading,还是Success? - 如果刷新失败,是把整个页面切换到
Error,还是继续显示原来的内容,只弹出一条错误提示? - 如果用户刚刚选择了一个筛选条件,进程重建之后,这个条件又该从哪里恢复?
从需求来讲,每个问题的答案都很明确。但是对于开发者来讲,如果继续使用上面那套方案,就会有点小问题了。
我们当然可以继续增加 Refreshing、RefreshFailed 等状态,但页面稍微复杂一些,密封类很快就会膨胀。
如果换成一组布尔值呢?
也没有好到哪里去:isLoading、isRefreshing、hasError 和 hasContent 可以拼出很多业务上根本不可能出现的组合。
这时我们会发现一个问题,一开始可能就把页面状态理解错了。
UI 状态
是 UI 在某一时刻完成渲染所需全部信息的不可变快照。
它不是一次请求的结果,也不一定只能从几个互斥状态中选择一个。页面上正在显示的内容、刷新进度、筛选条件和待展示的消息,都可能同时属于这张快照。
接下来,我们就从一个脑海中想象的 Feed 页面出发,看看状态应该放在哪里、如何建模,又该怎样流向 UI,最后再处理状态恢复、性能和测试。
恰好,脑海中的 Feed 页面,用的基本上就是上面的数据结构。
状态放在哪里
好好好,我知道这里要涉及StateFlow 或 MVI,但是在这之前,先要解决一个更基础的问题:状态到底应该由谁持有?
Compose 状态都放进 ViewModel?能用 remember 就不要提升?
我先提供一个更实用的判断方式:
把状态提升到所有读取和修改它的 Composable 的最低公共祖先,并尽量让它靠近真正使用它的地方。
如果这份状态需要业务逻辑或数据层参与,最低公共祖先也可以位于组合之外,此时再由 ViewModel 充当页面级状态持有者。
页面状态和 UI 元素状态不是一回事
页面 UI 状态通常来自业务逻辑,或者由 Repository 中的数据生成。例如:
- 信息流中展示的文章列表
- 刷新请求是否正在执行
- 当前使用的业务筛选条件
- 校验结果
- 用户当前是否可以继续下一步操作
这类状态会影响整个页面,也常常需要访问数据层。
在 Android 项目中,数据通常由 Repository 管理,交给 ViewModel 处理后,再暴露给 UI 层使用。
而 UI 元素状态控制的则是某个具体界面元素的表现。例如:
- 某张卡片是否展开
- 某个局部对话框是否可见
- 滚动位置
- 当前选中的标签页——前提是这个选择不具备业务含义
例如,卡片是否展开只影响这张卡片本身,就可以直接留在对应的 Composable 中:
@Composable
fun ExpandableArticleCard(
article: ArticleUiModel,
modifier: Modifier = Modifier,
) {
var expanded by rememberSaveable(article.id) {
mutableStateOf(false)
}
ArticleCard(
article = article,
expanded = expanded,
onExpandClick = { expanded = !expanded },
modifier = modifier,
)
}
这里没有必要专门创建一个 ViewModel。
如果以后有多个子组件都需要读取或修改 expanded,再把它提升到这些组件的最低公共祖先即可。如果局部 UI 逻辑继续变复杂,也可以提取成一个普通的状态持有类。
ViewModel 的使用边界现在清楚了:状态需要作用于整个页面、需要业务逻辑参与,或者需要访问数据层时,再把它提升到 ViewModel。
不要因为 ViewModel 能够跨越配置变更,就把所有局部开关都塞进去。
等下,我这里先说明一下:什么是业务逻辑,什么是纯粹的 UI 逻辑。
粗略来讲,产品经理描述的需求通常更接近业务逻辑,设计师描述的交互和视觉表现通常更接近 UI 逻辑。
不过,这只能作为一个帮助理解的说法,不能当成严格的判断标准。真正需要看的,是这份状态是否参与业务规则、数据处理,或者需要跨越某个局部 Composable 的生命周期。
页面状态建模
好,现在回到 Feed 页面,我们解决那个真正的问题。
页面既可能处于初次加载阶段,也可能已经有内容;已有内容时,又可能正在刷新。
刷新失败并不意味着旧内容必须消失,待展示的 Snackbar 也可以与文章列表同时存在。
因此,这里不适合用一个覆盖整页的 Loading | Success | Error。
先把页面描述成一张不可变快照
data class FeedUiState(
val articles: List<ArticleUiModel> = emptyList(),
val initialLoad: InitialLoad = InitialLoad.Loading,
val isRefreshing: Boolean = false,
val filter: FeedFilter = FeedFilter.All,
val pendingMessages: List<UiMessage> = emptyList(),
)
sealed interface InitialLoad {
data object Loading : InitialLoad
data object Ready : InitialLoad
data class Failed(val reason: FeedError) : InitialLoad
}
enum class FeedFilter {
All,
Following,
}
enum class FeedError {
Offline,
Unknown,
}
enum class UserMessage {
RefreshFailed,
ContentUpdateFailed,
}
data class UiMessage(
val id: Long,
val type: UserMessage,
)
InitialLoad 描述的是“第一次能否拿到页面内容”,isRefreshing 描述的是“已有页面是否正在更新”。
这两件事本来就不是同一个维度,因此没有必要强行塞进同一个互斥状态中。
这里的属性都通过 val 和只读类型对外暴露,UI 不能重新给它们赋值,也不能通过 List 接口直接修改集合。
不过,val 并不等于深层不可变:
val articles: List<ArticleUiModel>
它只能保证变量本身不能被重新赋值,并且调用方只能看到只读的 List 接口。如果底层仍然持有同一个 MutableList,其他代码依然可能修改集合内容。因此,生成新状态时,还要避免把之后会继续修改的可变集合直接交给 UI。
如果集合本身就是局部 UI 状态,并且确实需要逐项响应变化,可以使用 mutableStateListOf() 创建可观察的 SnapshotStateList。它和普通 MutableList 不是一回事:对 SnapshotStateList 的增删操作能够被 Compose 观察到。
下面这些内容也不应该暴露出去:
// 不推荐
val articles: MutableList<ArticleUiModel>
val selectedIds: MutableSet<String>
val uiState: MutableStateFlow<FeedUiState>
可变集合可以绕开状态更新,直接改变页面正在使用的数据。暴露 MutableStateFlow 也会带来同样的问题:调用方能够绕过真正的状态持有者,直接改写状态。
一旦项目里出现多个修改入口,“唯一可信的数据来源”也就只剩下一句口号了。
通俗地讲,如果状态里保存的是普通 MutableList,随后又直接在原集合上增删数据,Compose 通常无法把这种变化识别为一次新的状态更新。你在 ViewModel 中添加了数据,页面不一定会按预期刷新。
不可变性首先解决的是正确性问题。它确实也能帮助 Compose 判断输入有没有变化,但不能为了消除编译器报告中的提示,就随手加上 @Immutable。这个注解只是我们向编译器作出的稳定性承诺,不会自动把一个可变类型变成不可变类型。
数据类和密封层级不是二选一
如果页面的几种状态真的互斥,密封层级依然非常合适:
sealed interface CheckoutUiState {
data object Loading : CheckoutUiState
data class Ready(val order: OrderUiModel) : CheckoutUiState
data class Failed(val reason: CheckoutError) : CheckoutUiState
}
结算页不能同时处于 Loading 和 Ready,借助类型系统就可以直接阻止这种无效状态被创建出来。
在这个简化的结算示例中,Loading、Ready 和 Failed 三个页面阶段互斥,因此采用密封层级很合适。
但 Feed 页面却不同。它需要同时表达:
- 已有内容和刷新指示器
- 已有内容和非阻塞式错误
- 搜索结果、查询条件与筛选条件
- 表单内容与字段级校验信息
这类页面更适合使用数据类,再把真正互斥的小范围状态收拢成密封类型。前面的 InitialLoad 就是在做这件事。
所以,真正的判断标准不是“数据类更简单”或者“密封类更安全”,而是页面中的状态究竟互斥,还是需要同时存在。
能算出来的值,不要再保存一份
状态建模还有一个很常见的问题:把同一份事实保存好几次。
解决方案也很简单:
data class CartUiState(
val items: List<CartItemUiModel> = emptyList(),
) {
val totalPrice: Money
get() = items.fold(Money.Zero) { total, item ->
total + item.totalPrice
}
val canCheckout: Boolean
get() = items.isNotEmpty()
}
totalPrice 和 canCheckout 都可以根据 items 得到。如果这两个再分别保存数据,就必须处理三者之间的同步问题,此时,就有可能构造出“购物车为空,但仍然允许结算”这样的矛盾状态。
同样的原则也适用于 Composable。不过,能计算并不代表都要套一层 derivedStateOf:
val showScrollToTop by remember {
derivedStateOf {
listState.firstVisibleItemIndex > 0
}
}
列表滚动位置变化得很频繁,但 UI 只关心“是否已经离开第一项”。这种输入频繁变化、输出却很少变化的场景,很适合 derivedStateOf。
如果只是把两个很少变化的字符串拼接起来,直接计算通常更简单。derivedStateOf 本身也有开销,不需要给每一个派生值都加上一层。
有一个判断标准,如果输入变化的次数多于输出变化的次数,那么就用 derivedStateOf。
你想想上面的例子:列表滚动位置变化和字符串拼接,是不是这个道理?
状态的流动
状态模型确定之后,接下来要把完整的数据链连接起来:
Repository → ViewModel → StateFlow → Route → Screen
↑ ↓
业务处理 ← 用户操作
状态从上向下流动,用户操作从下向上传递。ViewModel 处理业务逻辑并生成新的状态,Composable 只根据状态渲染页面。
让 Repository、刷新状态和恢复状态汇合
下面继续补全 Feed 示例:
private const val FILTER_KEY = "feed_filter"
private sealed interface ArticleStreamState {
data object Loading : ArticleStreamState
data class Ready(val articles: List<Article>) : ArticleStreamState
data class Failed(val reason: FeedError) : ArticleStreamState
}
class FeedViewModel(
private val repository: FeedRepository,
private val savedStateHandle: SavedStateHandle,
) : ViewModel() {
private val isRefreshing = MutableStateFlow(false)
private val pendingMessages =
MutableStateFlow<List<UiMessage>>(emptyList())
private var nextMessageId = 0L
private var hasReceivedArticles = false
private var latestArticles: List<Article> = emptyList()
private val articleStreamState: Flow<ArticleStreamState> =
repository.observeArticles()
.map<List<Article>, ArticleStreamState> { articles ->
hasReceivedArticles = true
latestArticles = articles
ArticleStreamState.Ready(articles)
}
.onStart {
if (!hasReceivedArticles) {
emit(ArticleStreamState.Loading)
}
}
.catch { error ->
if (hasReceivedArticles) {
enqueueMessage(UserMessage.ContentUpdateFailed)
emit(ArticleStreamState.Ready(latestArticles))
} else {
emit(
ArticleStreamState.Failed(
reason = error.toFeedError(),
),
)
}
}
private val filter: StateFlow<FeedFilter> =
savedStateHandle.getStateFlow(
key = FILTER_KEY,
initialValue = FeedFilter.All,
)
val uiState: StateFlow<FeedUiState> = combine(
articleStreamState,
isRefreshing,
filter,
pendingMessages,
) { articleState, refreshing, selectedFilter, messages ->
when (articleState) {
ArticleStreamState.Loading -> FeedUiState(
initialLoad = InitialLoad.Loading,
isRefreshing = refreshing,
filter = selectedFilter,
pendingMessages = messages,
)
is ArticleStreamState.Ready -> FeedUiState(
articles = articleState.articles
.applyFilter(selectedFilter)
.map(Article::toUiModel),
initialLoad = InitialLoad.Ready,
isRefreshing = refreshing,
filter = selectedFilter,
pendingMessages = messages,
)
is ArticleStreamState.Failed -> FeedUiState(
initialLoad = InitialLoad.Failed(articleState.reason),
isRefreshing = refreshing,
filter = selectedFilter,
pendingMessages = messages,
)
}
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = FeedUiState(),
)
fun refresh() {
if (!isRefreshing.compareAndSet(
expect = false,
update = true,
)
) {
return
}
viewModelScope.launch {
try {
repository.refresh()
} catch (_: IOException) {
enqueueMessage(UserMessage.RefreshFailed)
} finally {
isRefreshing.value = false
}
}
}
fun onFilterChanged(filter: FeedFilter) {
savedStateHandle[FILTER_KEY] = filter
}
fun onMessageShown(id: Long) {
pendingMessages.update { messages ->
messages.filterNot { it.id == id }
}
}
private fun enqueueMessage(type: UserMessage) {
val message = UiMessage(
id = nextMessageId++,
type = type,
)
pendingMessages.update { messages ->
messages + message
}
}
}
private fun List<Article>.applyFilter(
filter: FeedFilter,
): List<Article> = when (filter) {
FeedFilter.All -> this
FeedFilter.Following -> {
filter { article ->
article.isFromFollowingAuthor
}
}
}
private fun Throwable.toFeedError(): FeedError =
if (this is IOException) {
FeedError.Offline
} else {
FeedError.Unknown
}
这一次,InitialLoad.Failed 不再只是定义出来却从未使用了。Repository 的数据流在发出第一份内容之前发生异常,会生成对应的阻塞式失败状态。
这里需要特别注意:catch 捕获的是整个上游 Flow 的异常,并不天然等同于“初次加载失败”。因此,代码先通过 hasReceivedArticles 判断是否已经拿到过内容。
如果内容还从未出现,页面会进入 InitialLoad.Failed。如果页面已经拿到过内容,catch 会重新发出最后一次成功的数据,并加入一条非阻塞消息。这样,后续的数据源异常就不会把已有内容清空。
还要注意,catch 发出兜底值之后,这一次上游收集也就结束了。如果这个数据源需要自动恢复,应该根据异常类型在 catch 之前使用 retryWhen,或者把重试和退避策略下沉到 Repository;不能误以为 catch 之后原来的 Flow 还会继续发出数据。
刷新走另一条路径。这里的 repository.refresh() 只把 IOException 视为可以向用户提示的网络类失败:失败后会向 pendingMessages 中加入一条消息,但不会把文章列表清空,也不会把整个页面切换为初次加载失败。其他未预期的异常不会被这段代码静默吞掉。
这里使用的是带唯一 ID 的消息列表,而不是单个可空字段。因为短时间内可能连续产生多条消息,如果交互设计要求每条消息都通知用户,列表能够保留每一条待处理消息。
如果业务允许后来的消息覆盖前一条,也可以简化成 UserMessage?。
StateFlow 应该活跃多久
SharingStarted.WhileSubscribed(5_000) 表示:只要还有订阅者,上游数据流就保持活跃;最后一个订阅者消失后,再等待 5 秒停止数据流动。
这个短暂的等待,可以避免配置变更等生命周期切换导致上游立即停止,随后又马上重启。5_000 不是所有项目都必须照抄的数字,具体时间仍然要看上游重启成本和页面的使用方式。
我习惯根据用户短时间内返回页面的概率和上游重启成本适当延长,但等待时间越长,上游资源也会保持越久。如果上游连接了网络、传感器或者开销较大的查询,这个时间就不能随便拉长。
如果希望状态在出现第一个订阅者之后一直保持活跃,即使页面暂时离开屏幕,也可以使用 SharingStarted.Lazily。例如,某个标签页暂时不可见,但用户很可能很快切回来,这种场景就可能适合采用该策略。不过,一旦首次订阅发生,它会持续到 viewModelScope 被取消,上游资源也会一直保持。
选择哪一种策略,取决于这份状态需要存活多久,而不是项目模板里默认写了什么。
如果 UI 状态不依赖 Repository 的数据流,也可以在 ViewModel 内部直接维护私有的 MutableStateFlow,再向外暴露只读版本:
private val _uiState = MutableStateFlow(EditProfileUiState())
val uiState: StateFlow<EditProfileUiState> = _uiState.asStateFlow()
fun onNameChanged(name: String) {
_uiState.update { current ->
current.copy(name = name)
}
}
如果项目已经升级到 Kotlin 2.4.0,也可以使用已经稳定的 Explicit backing fields,代码会更短一点:
val uiState: StateFlow<EditProfileUiState> field = MutableStateFlow(EditProfileUiState())
Kotlin 2.3.x 已经提供了这套语法,但当时仍是实验性特性;从 Kotlin 2.4.0 开始,它才正式稳定,不再需要额外选择加入。还没有升级到对应版本的项目,继续使用 _uiState 和 asStateFlow() 即可。
当新值需要根据当前值计算时,使用 update 可以原子地完成“读取—修改—写入”,避免并发更新被某份过期的状态副本覆盖。
Route 负责接线,Screen 只负责渲染
可复用的 Composable 没有必要知道 ViewModel 的存在。
把 ViewModel 直接传进 Screen 或列表项,会让 UI 和具体的状态持有者绑定在一起,预览、测试以及以后更换状态来源都会变得麻烦。
因此,可以先把 Feed 页面拆成 Route 和 Screen 两层:
@Composable
fun FeedRoute(
onArticleClick: (String) -> Unit,
viewModel: FeedViewModel = hiltViewModel(),
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
val snackbarHostState = remember { SnackbarHostState() }
FeedMessageEffect(
message = state.pendingMessages.firstOrNull(),
snackbarHostState = snackbarHostState,
onMessageShown = viewModel::onMessageShown,
)
Scaffold(
snackbarHost = {
SnackbarHost(hostState = snackbarHostState)
},
) { contentPadding ->
FeedScreen(
state = state,
onRefresh = viewModel::refresh,
onFilterChanged = viewModel::onFilterChanged,
onArticleClick = onArticleClick,
modifier = Modifier.padding(contentPadding),
)
}
}
Route 获取 ViewModel、收集状态,并负责协调 Snackbar 和导航。Screen 只接收数据与回调:
@Composable
fun FeedScreen(
state: FeedUiState,
onRefresh: () -> Unit,
onFilterChanged: (FeedFilter) -> Unit,
onArticleClick: (String) -> Unit,
modifier: Modifier = Modifier,
) {
FeedContent(
articles = state.articles,
initialLoad = state.initialLoad,
isRefreshing = state.isRefreshing,
selectedFilter = state.filter,
onRefresh = onRefresh,
onFilterChanged = onFilterChanged,
onArticleClick = onArticleClick,
modifier = modifier,
)
}
列表项同样只需要拿到它真正使用的数据和事件 Lambda,不需要让 ViewModel 一路穿过 FeedScreen、LazyColumn,最后传到每个 ArticleCard。
虽然 FeedRoute 也是个 Composable 函数,但它的主要职责是准备 FeedScreen 的数据。这一点避免不掉,再干净的代码也有脏的地方。此时,保证 FeedScreen(真正的 UI 渲染)的独立性和可移植性,是至关重要的。
收集状态
FeedRoute 使用了 collectAsStateWithLifecycle()。在 Android Compose 页面中,它通常比 collectAsState() 更合适,因为它会结合 Android 生命周期控制数据收集。UI 不再处于活跃状态时,收集过程也可以随之停止。
项目中需要添加下面这个依赖:
dependencies {
implementation(
"androidx.lifecycle:lifecycle-runtime-compose:2.11.0"
)
}
这里直接写出了文章发布时的稳定版本。如果项目使用 Version Catalog,也可以改成 implementation(libs.androidx.lifecycle.runtime.compose)。不要把 @latest 写进依赖坐标,它不是 Gradle 可以解析的版本号。
另外,从 Lifecycle 2.10.0 开始,minSdk 已经从 API 21 提升到了 API 23。如果项目仍然需要支持 API 21 或 22,就要选择与项目 minSdk 兼容的 Lifecycle 版本。Lifecycle 2.11.0 的 Compose 相关产物还要求 Compose UI 1.7.0 以上,并且由于 compileSdk 的变化,最低需要 AGP 9.2.0。
对于不依赖 Android 生命周期的平台无关 Compose 代码,则可以继续使用 collectAsState()。
“一次性事件”是不是状态
很多项目会通过 Channel 或 SharedFlow 向 UI 发送导航、Snackbar 或错误事件:
// 不推荐把它作为所有 UI 结果的默认架构
private val events = Channel<UiEvent>()
Channel 和 SharedFlow 本身没有问题,你可以选择任何一个,关键在于我们需要什么交付语义:事件是否允许丢失、是否允许覆盖、是否需要重放,又是否必须等 UI 处理完成。
当生产者是 ViewModel、消费者是 Compose UI 时,两边的生命周期并不一致。事件发出的一刻,UI 可能正好不存在。如果这个业务结果不能丢失,把它立即归约到 UI 状态中,通常更容易保证页面的一致性。
Feed 示例中的刷新失败就是这样处理的。UI 展示消息,展示完成后再通知 ViewModel 删除对应状态:
@Composable
private fun FeedMessageEffect(
message: UiMessage?,
snackbarHostState: SnackbarHostState,
onMessageShown: (Long) -> Unit,
) {
val text = when (message?.type) {
UserMessage.RefreshFailed -> {
stringResource(R.string.feed_refresh_failed)
}
UserMessage.ContentUpdateFailed -> {
stringResource(R.string.feed_content_update_failed)
}
null -> null
}
LaunchedEffect(message?.id) {
if (message != null && text != null) {
snackbarHostState.showSnackbar(text)
onMessageShown(message.id)
}
}
}
最终展示给用户的文本仍然由 UI 层映射为字符串资源,ViewModel 只保存与平台无关的消息类型。
如果导航由一次明确的 UI 点击直接触发,它仍然属于 UI 行为:
ArticleCard(
article = article,
onClick = {
onArticleClick(article.id)
},
)
如果导航必须等待业务校验,就让 ViewModel 处理校验并把结果写入状态,再由 UI 根据状态决定如何导航。ViewModel 负责业务判断,UI 负责执行具体的导航行为。
状态的恢复、优化和测试
页面已经能够稳定地展示和更新状态,接下来还有三个经常被混在一起的问题:进程重建后如何恢复、发生重组性能问题时如何判断,以及怎样验证这些状态规则真的成立。
只保存恢复页面所需的最小输入
remember 能让状态在重组期间继续存在,却无法跨越 Activity 重建。
对于支持保存的数据类型,rememberSaveable 可以让 UI 元素状态跨越 Activity 重建和系统触发的进程重建:
var selectedTab by rememberSaveable {
mutableIntStateOf(0)
}
如果某个小型状态已经因为业务逻辑提升到 ViewModel,可以使用 SavedStateHandle。Feed 示例中的筛选条件就是这样处理的:
private val filter: StateFlow<FeedFilter> =
savedStateHandle.getStateFlow(
key = FILTER_KEY,
initialValue = FeedFilter.All,
)
fun onFilterChanged(filter: FeedFilter) {
savedStateHandle[FILTER_KEY] = filter
}
同样的方式也可以用来保存搜索条件:
class SearchViewModel(
private val savedStateHandle: SavedStateHandle,
) : ViewModel() {
val query: StateFlow<String> =
savedStateHandle.getStateFlow("query", "关注 RockByte 公众号")
fun onQueryChanged(value: String) {
savedStateHandle["query"] = value
}
}
rememberSaveable 和 SavedStateHandle 最终都会使用 Bundle。因此,不要拿它们保存大型对象图、很长的列表或整份页面状态。
真正需要保存的,通常是查询条件、ID、Key 和选中项索引这类最小输入。进程重建之后,再根据这些输入从数据层重新生成页面状态。
状态恢复本身也是页面行为的一部分。对于重要状态,可以使用 StateRestorationTester 验证它能否在重建之后正确恢复。
把稳定性当成一个需要测量的问题
先保证状态归属正确、状态模型清晰,并且数据确实不可变。至于稳定性优化,要等实际测量之后再做。
从 Kotlin 2.0.20 开始,Strong Skipping 已经默认启用。新项目遇到重组性能问题时,不需要先寻找“怎样开启强跳过模式”,而是先确认问题是否真的与稳定性有关。
这篇文章,可以帮助你更好地理解 Strong Skipping 与性能问题。
如果经过分析,确认集合的稳定性确实带来了性能问题,可以考虑:
- 使用真正不可变的数据模型和集合
- 在代码库能够遵守稳定性契约的前提下配置稳定性规则
- 使用一个稳定的抽象包装集合
Compose 编译器能够识别 Kotlinx 不可变集合,但该库目前在官方文档中仍然标记为 Alpha。是否使用它,要根据项目情况明确选择,不需要把它变成所有 UI 状态的默认要求。
@Stable 和 @Immutable 也不是装饰性注解。如果标记错误,数据已经变化时,Compose 仍可能跳过本应发生的重组。能通过真实的不可变设计解决,就不要先用注解覆盖编译器的判断。
也没有必要追求让每一个 Composable 都能够跳过。没有测量依据的稳定性改造,很容易把简单代码变成难以维护的代码。
随着 Strong Skipping 默认启用,Compose 编译器已经能够处理很多常见场景。因此,稳定性通常不该成为排查性能问题的第一站:先用工具确认瓶颈,再决定是否需要调整状态模型、集合类型或稳定性声明。
分开测试状态生成与 UI 渲染
状态边界清楚之后,测试也会自然分成两部分。
先测试 ViewModel 是否生成了正确的状态:
@Test
fun refreshFailure_keepsContentAndQueuesMessage() = runTest {
repository.articles.value = listOf(article)
repository.refreshResult =
Result.failure(IOException())
viewModel.uiState
.filter {
it.articles.isNotEmpty() &&
it.pendingMessages.isNotEmpty()
}
.test {
viewModel.refresh()
val state = awaitItem()
assertEquals(
listOf(articleUiModel),
state.articles,
)
assertEquals(
UserMessage.RefreshFailed,
state.pendingMessages.first().type,
)
}
}
这个测试关注的是页面规则:刷新失败后,缓存内容仍然存在,并且产生了一条待展示消息。至于内部到底使用了几个 MutableStateFlow,并不重要。
然后,传入一份明确的状态,测试无状态 Screen 的渲染结果:
@Test
fun existingContentRemainsVisibleWhileRefreshing() {
composeRule.setContent {
FeedScreen(
state = FeedUiState(
articles = listOf(articleUiModel),
initialLoad = InitialLoad.Ready,
isRefreshing = true,
),
onRefresh = {},
onFilterChanged = {},
onArticleClick = {},
)
}
composeRule
.onNodeWithText(articleUiModel.title)
.assertIsDisplayed()
composeRule
.onNodeWithTag("refreshIndicator")
.assertIsDisplayed()
}
真正值得验证的是用户能够感知到的页面行为:
- 刷新期间,缓存内容仍然可见
- 第一次加载失败时,页面显示阻塞式错误
- 重试成功后,阻塞式错误被清除
- 消息展示完成后,对应消息从状态中删除
- 可恢复的筛选条件能够跨越重建继续存在
- 业务上不可能出现的互斥状态无法被构造出来
结语
做好 Compose 状态管理,并不意味着一定要选择最复杂的 MVI 框架,也不意味着每个状态都要专门创建一个事件密封类。
作为开发者真正需要做的,是先弄清楚页面在某一时刻究竟需要展示哪些信息,再把这些信息组织成一份明确的状态。状态尽量靠近使用者;需要业务逻辑或数据层参与时,再提升到 ViewModel。状态持有者对外暴露不可变快照,UI 结合生命周期收集状态,并把用户操作明确地传回去。
当刷新、失败、消息、筛选和恢复都能落到同一套状态模型中时,页面也就不再是一堆互相牵制的布尔值和事件。它最终会重新变成 Compose 最擅长的形式:一个由状态决定结果的 UI 函数。