Compose 架構大升級,終於支援列表項獨立 ViewModel 了!

androidx.lifecycle 2.11.0-beta01 已經發布。不只是一般的 bug 修正,還調整了 rememberViewModelStoreOwnerrememberViewModelStoreProvider

它要解決的是一個老問題:列表項、Pager 頁面、局部複雜元件,能不能擁有自己的 ViewModel 生命週期。

Image

以前 ViewModel 主要跟著畫面走

在 Compose 裡直接寫:

bash 体验AI代码助手 代码解读复制代码@Composable
fun FeedScreen(
    viewModel: FeedViewModel = viewModel()
) {
    // ...
}

這個 ViewModel 預設會綁定到最近的 ViewModelStoreOwner

通常是 Navigation 的某個 destination,也可能是宿主 Activity

這很適合「一個頁面一個 ViewModel」。問題是,很多 UI 已經不再只是單一頁面。

比如一個資訊流列表,每個貼文都有展開狀態、按讚中的 loading、留言草稿、圖片載入控制、局部回報邏輯。

這些狀態全部塞進 FeedViewModel,最後就會變成一個巨大的 Map<PostId, RowState>

bash 体验AI代码助手 代码解读复制代码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)

bash 体验AI代码助手 代码解读复制代码@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 体验AI代码助手 代码解读复制代码@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 体验AI代码助手 代码解读复制代码@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 的典型場景。

每個頁面可能都有獨立請求、捲動位置、暫時輸入、播放狀態。頁面切換時,狀態要保留;頁面被真正移除時,狀態要釋放。

bash 体验AI代码助手 代码解读复制代码@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,業務程式碼仍然維持這個樣子:

bash 体验AI代码助手 代码解读复制代码@HiltViewModel
class PostItemViewModel @Inject constructor(
    private val repository: FeedRepository,
    savedStateHandle: SavedStateHandle,
) : ViewModel() {
    // item 局部狀態和業務邏輯
}

Composable 裡根據專案依賴選擇 viewModel()hiltViewModel()

更重要的是,不要把 repository、use case 這種長生命週期物件放進 item ViewModel 裡重複建立。

ViewModel 可以是 per-item,但資料層不應該每個 item 都複製一份。

Image

不要讓所有 item 都上 ViewModel

這個 API 很容易被誤用。

不是每個列表項都應該有 ViewModel。

如果 item 只是顯示標題、頭像、價格、開關狀態,直接把狀態從父層傳進去就夠了。

bash 体验AI代码助手 代码解读复制代码@Composable
fun SimplePostRow(
    post: Post,
    liked: Boolean,
    onLikeClick: () -> Unit,
) {
    // 純展示 + 事件上拋
}

比較適合 per-item ViewModel 的場景通常有幾個特徵:

  • • item 內部有獨立的非同步任務
  • • item 狀態很複雜,父層維護會變成大量 Map
  • • item 會動態加入和移除,清理邏輯很容易漏掉
  • • Pager 頁面、局部編輯器、可摺疊複雜卡片需要跨設定變更保留狀態

如果只是 expandedchecked 這類輕量狀態,rememberSaveable(key = post.id) 往往更直接。

bash 体验AI代码助手 代码解读复制代码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


原文出處:https://juejin.cn/post/7643821395647627291


精選技術文章翻譯,幫助開發者持續吸收新知。

共有 0 則留言


精選技術文章翻譯,幫助開發者持續吸收新知。
🏆 本月排行榜
🥇
站長阿川
📝12   💬4   ❤️1
455
🥈
alicec
📝1   ❤️2
86
#4
我愛JS
💬1  
3
評分標準:發文×10 + 留言×3 + 獲讚×5 + 點讚×1 + 瀏覽數÷10
本數據每小時更新一次
📢 贊助商廣告 · 我要刊登