Compose 架构大升级,终于支持列表项独立 ViewModel 了!

0 阅读5分钟

androidx.lifecycle 2.11.0-beta01 的发布。不只是普通 bugfix,还调整了 rememberViewModelStoreOwner 和 rememberViewModelStoreProvider

它解决的是一个老问题:列表项、Pager 页面、局部复杂组件,能不能拥有自己的 ViewModel 生命周期。

Image

以前 ViewModel 主要跟着屏幕走

在 Compose 里直接写:

@Composable
fun FeedScreen(
    viewModel: FeedViewModel = viewModel()
) {
    // ...
}

这个 ViewModel 默认会绑定到最近的 ViewModelStoreOwner

通常是 Navigation 的一个 destination,也可能是宿主 Activity

这很适合“一个页面一个 ViewModel”。问题是,很多 UI 不再只是一个页面。

比如一个信息流列表,每个帖子都有展开状态、点赞中的 loading、评论草稿、图片加载控制、局部上报逻辑。

这些状态全塞进 FeedViewModel,最后会变成一个巨大的 Map<PostId, RowState>

data class FeedUiState(
    val posts: List<Post>,
    val expandedPostIds: Set<String>,
    val pendingLikeIds: Set<String>,
    val commentDrafts: Map<String, String>,
    val imageRetryCount: Map<String, Int>,
)

这不是不能写,但复杂度会集中到父 ViewModel。

父层需要理解每个 item 的内部状态,还要处理 item 移除、插入、重排后的清理问题。

Image

key 只能解决实例,不解决清理

很多人会想到 viewModel(key = post.id)

@Composable
fun PostRow(
    post: Post,
    viewModel: PostViewModel = viewModel(key = post.id)
) {
    // ...
}

这段代码能让不同 post.id 拿到不同 ViewModel 实例,但它们仍然属于同一个父 ViewModelStoreOwner

如果父 owner 是当前页面,这些 item ViewModel 会跟着页面一起活着。

列表滚出屏幕、数据被分页替换、某条帖子从列表移除,都不一定会触发对应 ViewModel 的清理。

key 解决的是“同一个 owner 下如何区分实例”,不是“这个实例该跟哪块 UI 一起销毁”。

这也是以前 Compose 做 per-item ViewModel 最别扭的地方:可以造实例,但作用域不自然。

现在可以创建局部 owner

Lifecycle 2.11 新增的能力,是在 Compose 层级里创建 ViewModelStoreOwner

最小用法是:

@Composable
fun ProfileCard() {
    val owner = rememberViewModelStoreOwner()

    CompositionLocalProvider(LocalViewModelStoreOwner provides owner) {
        val viewModel: ProfileCardViewModel = viewModel()
        ProfileCardContent(viewModel)
    }
}

这个 owner 绑定到当前 composable 调用点。

当这块 UI 永久离开 composition,它关联的 ViewModelStore 会被清理,里面的 ViewModel 会走 onCleared()

它和普通 remember 的区别在于:ViewModel 仍然能跨配置变更存活。

所以它不是把 ViewModel 降级成普通 Compose state,而是让 ViewModel 有了更细的 UI 作用域。

LazyColumn 的provider 

列表项更常见的写法,是先在列表外面创建一个 provider,再用 item 的稳定 key 创建 owner。

@Composable
fun FeedScreen(posts: List<Post>) {
    val storeProvider = rememberViewModelStoreProvider()

    LazyColumn {
        items(
            items = posts,
            key = { post -> post.id }
        ) { post ->
            val owner = rememberViewModelStoreOwner(
                provider = storeProvider,
                key = post.id
            )

            CompositionLocalProvider(LocalViewModelStoreOwner provides owner) {
                val viewModel: PostItemViewModel = viewModel()
                PostRow(post = post, viewModel = viewModel)
            }
        }
    }
}

这里有两个关键点。

rememberViewModelStoreProvider() 要在列表外面创建。它负责管理多个子 store。

key 要用业务稳定 ID,不要用 index。列表插入一条新数据后,index 会整体偏移,ViewModel 状态会串到别的 item 上。

Image

Pager 页面

HorizontalPager 是这个 API 的典型场景。

每个页面可能都有独立请求、滚动位置、临时输入、播放状态。页面切来切去时,状态要保留;页面被真正移除时,状态要释放。

@Composable
fun ProfilePager(pages: List<ProfilePage>) {
    val provider = rememberViewModelStoreProvider()
    val pagerState = rememberPagerState(pageCount = { pages.size })

    HorizontalPager(state = pagerState) { page ->
        val pageData = pages[page]
        val owner = rememberViewModelStoreOwner(
            provider = provider,
            key = pageData.id
        )

        CompositionLocalProvider(LocalViewModelStoreOwner provides owner) {
            val viewModel: ProfilePageViewModel = viewModel()
            ProfilePageContent(pageData, viewModel)
        }
    }
}

相比在父 ViewModel 里维护 Map<PageId, PageState>,这种写法更接近 UI 的真实结构。

页面有页面自己的 owner,页面里的 ViewModel 也只服务这一个页面。

需要注意的是,Pager 的预加载页面也会进入 composition。

如果 ViewModel 初始化就发网络请求,要确认这是不是你想要的行为。否则应该把重活放到明确的事件里,或者让数据层做去重。

Hilt 和 SavedStateHandle 

官方文档里提到,子 owner 会继承父作用域里的默认 ViewModelProvider.FactoryCreationExtrasSavedStateRegistryOwner 等信息。

这意味着常见的 Hilt ViewModel、SavedStateHandle、自定义 factory,不需要为每个 item 手写一套工厂。

如果项目里用 Hilt,业务代码仍然保持这个形状:

@HiltViewModel
class PostItemViewModel @Inject constructor(
    private val repository: FeedRepository,
    savedStateHandle: SavedStateHandle,
) : ViewModel() {
    // item 局部状态和业务逻辑
}

Composable 里根据项目依赖选择 viewModel() 或 hiltViewModel()

更重要的是,不要把 repository、use case 这种长生命周期对象放到 item ViewModel 里重复创建。

ViewModel 可以 per-item,数据层不应该 per-item 复制一份。

Image

不要给所有 item 都上 ViewModel

这个 API 容易被误用。

不是每个列表项都应该有 ViewModel。

如果 item 只是展示标题、头像、价格、开关状态,直接把状态从父层传进去就够了。

@Composable
fun SimplePostRow(
    post: Post,
    liked: Boolean,
    onLikeClick: () -> Unit,
) {
    // 纯展示 + 事件上抛
}

更适合 per-item ViewModel 的场景通常有几个特征:

  • • item 内部有独立异步任务

  • • item 状态复杂,父层维护会变成大量 Map

  • • item 会动态加入和移除,清理逻辑很容易漏

  • • Pager 页面、局部编辑器、可折叠复杂卡片需要跨配置变更保留状态

如果只是 expandedchecked 这类轻状态,rememberSaveable(key = post.id) 往往更直接。

var expanded by rememberSaveable(post.id) {
    mutableStateOf(false)
}

ViewModel 是生命周期工具,不是所有状态的默认容器。

最后

rememberViewModelStoreOwner 让 Compose 的 ViewModel 作用域终于不再只能绑定到屏幕。

对复杂列表和 Pager 来说,它补上了一个长期缺口:局部 UI 可以有自己的 ViewModel 生命周期,同时仍然保留配置变更能力。

但它不改变基本原则:简单状态留在 Compose,屏幕状态留在 screen ViewModel,只有真正复杂、独立、需要清理的局部 UI,才需要拆成 component-level ViewModel。

#Android #JetpackCompose #ViewModel #AndroidX