别再只用 Loading、Success、Error 表示所有页面状态了

0 阅读21分钟

0.png

各位 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,还是继续显示原来的内容,只弹出一条错误提示?
  • 如果用户刚刚选择了一个筛选条件,进程重建之后,这个条件又该从哪里恢复?

从需求来讲,每个问题的答案都很明确。但是对于开发者来讲,如果继续使用上面那套方案,就会有点小问题了。

我们当然可以继续增加 RefreshingRefreshFailed 等状态,但页面稍微复杂一些,密封类很快就会膨胀。

如果换成一组布尔值呢?

也没有好到哪里去:isLoadingisRefreshinghasErrorhasContent 可以拼出很多业务上根本不可能出现的组合。

这时我们会发现一个问题,一开始可能就把页面状态理解错了。

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
}

结算页不能同时处于 LoadingReady,借助类型系统就可以直接阻止这种无效状态被创建出来。

在这个简化的结算示例中,LoadingReadyFailed 三个页面阶段互斥,因此采用密封层级很合适。

但 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()
}

totalPricecanCheckout 都可以根据 items 得到。如果这两个再分别保存数据,就必须处理三者之间的同步问题,此时,就有可能构造出“购物车为空,但仍然允许结算”这样的矛盾状态。

同样的原则也适用于 Composable。不过,能计算并不代表都要套一层 derivedStateOf

val showScrollToTop by remember {
    derivedStateOf {
        listState.firstVisibleItemIndex > 0
    }
}

列表滚动位置变化得很频繁,但 UI 只关心“是否已经离开第一项”。这种输入频繁变化、输出却很少变化的场景,很适合 derivedStateOf

如果只是把两个很少变化的字符串拼接起来,直接计算通常更简单。derivedStateOf 本身也有开销,不需要给每一个派生值都加上一层。

有一个判断标准,如果输入变化的次数多于输出变化的次数,那么就用 derivedStateOf

你想想上面的例子:列表滚动位置变化和字符串拼接,是不是这个道理?

状态的流动

1.png

状态模型确定之后,接下来要把完整的数据链连接起来:

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 开始,它才正式稳定,不再需要额外选择加入。还没有升级到对应版本的项目,继续使用 _uiStateasStateFlow() 即可。

当新值需要根据当前值计算时,使用 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 一路穿过 FeedScreenLazyColumn,最后传到每个 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()

“一次性事件”是不是状态

很多项目会通过 ChannelSharedFlow 向 UI 发送导航、Snackbar 或错误事件:

// 不推荐把它作为所有 UI 结果的默认架构
private val events = Channel<UiEvent>()

ChannelSharedFlow 本身没有问题,你可以选择任何一个,关键在于我们需要什么交付语义:事件是否允许丢失、是否允许覆盖、是否需要重放,又是否必须等 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
    }
}

rememberSaveableSavedStateHandle 最终都会使用 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 函数。