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

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

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

以前 ViewModel 主要跟着屏幕走

在 Compose 里直接写:

bash 复制代码
@Composable
fun FeedScreen(
    viewModel: FeedViewModel = viewModel()
) {
    // ...
}

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

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

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

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

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

bash 复制代码
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 移除、插入、重排后的清理问题。

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

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

bash 复制代码
@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。

最小用法是:

bash 复制代码
@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。

bash 复制代码
@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 上。

Pager 页面

HorizontalPager 是这个 API 的典型场景。

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

bash 复制代码
@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.Factory、CreationExtras、SavedStateRegistryOwner 等信息。

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

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

bash 复制代码
@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 复制一份。

不要给所有 item 都上 ViewModel

这个 API 容易被误用。

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

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

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

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

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

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

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

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

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

bash 复制代码
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

相关推荐
andyweike23 分钟前
Android文件系统
android
studyForMokey1 小时前
【Android 面试】Paging3
android·面试·职场和发展
企业数字化笔记1 小时前
租户停用后数据和任务怎么处理?冻结、恢复与保留策略的实现
android·java·spring boot
小程故事多_8011 小时前
从 Tokenizer 到 Next Token,穿透数据流,完整理解 Transformer 底层逻辑
android·深度学习·transformer
三少爷的鞋14 小时前
Android 协程实践:为什么不应将 `supervisorScope` 作为常规方案
android
承渊政道15 小时前
【从零开始大模型开发与微调:基于PyTorch与ChatGLM】(从ChatGLM本地化到LoRA、PEFT的高级微调)
android·人工智能·pytorch·chatglm·本地化部署·大模型开发与微调
2601_9628857217 小时前
如何用 Python 判断一只股票“追涨”还是“抄底”更有效?
android·开发语言·python
素师良码17 小时前
第10篇:屏ESD Check内核逻辑驱动代码完整拆解
android
高晶18 小时前
Android 短信收发模块从零到实战:权限配置、界面设计、发送接收与兼容性适配全攻略
android·程序员
随遇丿而安18 小时前
第18周:ViewModel 基础功能 + 架构优化
android