聽說Compose與RecyclerView結合會有水土不服?

語言: CN / TW / HK

theme: juejin

背景&筆者碎碎談

最近Compose也慢慢火起來了,作為google力推的ui框架,我們也要用起來才能進步呀!在最新一期的評測中LazyRow等LazyXXX列表元件已經慢慢逼近RecyclerView的效能了!但是還是有很多同學顧慮呀!沒關係,我們就算用原有的view開發體系,也可以快速遷移到compose,這個利器就是ComposeView了,那麼我們在RecyclerView的基礎上,整合Compose用起來!這樣我們有RecyclerView的效能又有Compose的好處不是嘛!相信很多人都有跟我一樣的想法,但是這兩者結合起來可是有隱藏的效能開銷!(本次使用compose版本為1.1.1)

在原有view體系接入Compose

在純compose專案中,我們都會用setContent代替原有view體系的setContentView,比如 setContent { ComposeTestTheme { // A surface container using the 'background' color from the theme Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colors.background) { Greeting("Android") Hello() } } } 那麼setContent到底做了什麼事情呢?我們看下原始碼 ``` public fun ComponentActivity.setContent( parent: CompositionContext? = null, content: @Composable () -> Unit ) { val existingComposeView = window.decorView .findViewById(android.R.id.content) .getChildAt(0) as? ComposeView

if (existingComposeView != null) with(existingComposeView) {
    setParentCompositionContext(parent)
    setContent(content)
} else ComposeView(this).apply {
    // 第一步走到這裡
    // Set content and parent **before** setContentView
    // to have ComposeView create the composition on attach
    setParentCompositionContext(parent)
    setContent(content)
    // Set the view tree owners before setting the content view so that the inflation process
    // and attach listeners will see them already present
    setOwners()
    setContentView(this, DefaultActivityContentLayoutParams)
}

} 由於是第一次進入,那麼一定就走到了else分支,其實就是建立了一個ComposeView,放在了android.R.id.content裡面的第一個child中,這裡就可以看到,compose並不是完全脫了原有的view體系,而是採用了移花接木的方式,把compose體系遷移了過來!ComposeView就是我們能用Compose的前提啦!所以在原有的view體系中,我們也可以通過ComposeView去“嫁接”到view體系中,我們舉個例子 class CustomActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_custom) val recyclerView = this.findViewById(R.id.recyclerView) recyclerView.adapter = MyRecyclerViewAdapter() recyclerView.layoutManager = LinearLayoutManager(this) } } ```

```

class MyRecyclerViewAdapter:RecyclerView.Adapter() { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): MyComposeViewHolder { val view = ComposeView(parent.context) return MyComposeViewHolder(view) }

override fun onBindViewHolder(holder: MyComposeViewHolder, position: Int) {
      holder.composeView.setContent {
          Text(text = "test $position", modifier = Modifier.size(200.dp).padding(20.dp), textAlign = TextAlign.Center)
      }

}

override fun getItemCount(): Int {
    return 200
}

}

class MyComposeViewHolder(val composeView:ComposeView):RecyclerView.ViewHolder(composeView){

} ``` 這樣一來,我們的compose就被移到了RecyclerView中,當然,每一列其實就是一個文字。嗯!普普通通,好像也沒啥特別的對吧,假如這個時候你打開了profiler,當我們向下滑動的時候,會發現記憶體會慢慢的往上浮動

image.png 滑動嘛!有點記憶體很正常,畢竟誰不生成物件呢,但是這跟我們平常用RecyclerView的時候有點差異,因為RecyclerView滑動的漲幅可沒有這個大,那究竟是什麼原因導致的呢?

探究Compose

有過對Compose瞭解的同學可能會知道,Compose的介面構成會有一個重組的過程,當然!本文就不展開聊重組了,因為這類文章有挺多的(填個坑,如果有機會就填),我們聊點特別的,那麼什麼時候停止重組呢?或者說什麼時候這個Compose被dispose掉,即不再參與重組!

Dispose策略

其實我們的ComposeView,以1.1.1版本為例,其實建立的時候,也建立了取消重組策略,即 @Suppress("LeakingThis") private var disposeViewCompositionStrategy: (() -> Unit)? = ViewCompositionStrategy.DisposeOnDetachedFromWindow.installFor(this) 這個策略是什麼呢?我們點進去看原始碼 ``` object DisposeOnDetachedFromWindow : ViewCompositionStrategy { override fun installFor(view: AbstractComposeView): () -> Unit { val listener = object : View.OnAttachStateChangeListener { override fun onViewAttachedToWindow(v: View) {}

        override fun onViewDetachedFromWindow(v: View?) {
            view.disposeComposition()
        }
    }
    view.addOnAttachStateChangeListener(listener)
    return { view.removeOnAttachStateChangeListener(listener) }
}

} 看起來是不是很簡單呢,其實就加了一個監聽,在onViewDetachedFromWindow的時候呼叫的view.disposeComposition(),聲明當前的ComposeView不參與接下來的重組過程了,我們再繼續看 fun disposeComposition() { composition?.dispose() composition = null requestLayout() } 再看dispose方法 override fun dispose() { synchronized(lock) { if (!disposed) { disposed = true composable = {} val nonEmptySlotTable = slotTable.groupsSize > 0 if (nonEmptySlotTable || abandonSet.isNotEmpty()) { val manager = RememberEventDispatcher(abandonSet) if (nonEmptySlotTable) { slotTable.write { writer -> writer.removeCurrentGroup(manager) } applier.clear() manager.dispatchRememberObservers() } manager.dispatchAbandons() } composer.dispose() } } parent.unregisterComposition(this) } 那麼怎麼樣才算是不參與接下里的重組呢,其實就是這裡 slotTable.write { writer -> writer.removeCurrentGroup(manager) }

... composer.dispose() 而removeCurrentGroup其實就是把當前的group移除了 for (slot in groupSlots()) { when (slot) { .... is RecomposeScopeImpl -> { val composition = slot.composition if (composition != null) { composition.pendingInvalidScopes = true slot.composition = null } } } } ``` 這裡又多了一個概念,slottable,我們可以這麼理解,這裡面就是Compose的快照系統,其實就相當於對應著某個時刻view的狀態!之所以Compose是宣告式的,就是通過slottable裡的slot去判斷,如果最新的slot跟前一個slot不一致,就回調給監聽者,實現更新!這裡又是一個大話題了,我們點到為止

image.png

跟RecyclerView有衝突嗎

我們看到,預設的策略是當view被移出當前的window就不參與重組了,嗯!這個在99%的場景都是有效的策略,因為你都看不到了,還重組幹嘛對吧!但是這跟我們的RecyclerView有什麼衝突嗎?想想看!誒,RecyclerView最重要的是啥,Recycle呀,就是因為會重複利用holder,間接重複利用了view才顯得高效不是嘛!那麼問題就來了

image.png 如圖,我們item5其實完全可以利用item1進行顯示的對不對,差別就只是Text元件的文字不一致罷了,但是我們從上文的分析來看,這個ComposeView對應的composition被回收了,即不參與重組了,換句話來說,我們Adapter在onBindViewHolder的時候,豈不是用了一個沒有compositon的ComposeView(即不能參加重組的ComposeView)?這樣怎麼行呢?我們來猜一下,那麼這樣的話,RecyclerView豈不是都要生成新的ComposeView(即每次都呼叫onCreateViewHolder)才能保證正確?emmm,很有道理,但是卻不是的!如果我們把程式碼跑起來看的話,複用的時候依舊是會呼叫onBindViewHolder,這就是Compose的祕密了,那麼這個祕密在哪呢 ``` override fun onBindViewHolder(holder: MyComposeViewHolder, position: Int) { holder.composeView.setContent { Text(text = "test $position", modifier = Modifier.size(200.dp).padding(20.dp), textAlign = TextAlign.Center) }

} 其實就是在ComposeView的setContent方法中, fun setContent(content: @Composable () -> Unit) { shouldCreateCompositionOnAttachedToWindow = true this.content.value = content if (isAttachedToWindow) { createComposition() } } fun createComposition() { check(parentContext != null || isAttachedToWindow) { "createComposition requires either a parent reference or the View to be attached" + "to a window. Attach the View or call setParentCompositionReference." } ensureCompositionCreated() } 最終呼叫的是 private fun ensureCompositionCreated() { if (composition == null) { try { creatingComposition = true composition = setContent(resolveParentCompositionContext()) { Content() } } finally { creatingComposition = false } } } ``` 看到了嗎!如果composition為null,就會重新建立一個!這樣ComposeView就完全嫁接到RecyclerView中而不出現問題了!

其他Dispose策略

我們看到,雖然在ComposeView在RecyclerView中能正常執行,但是還存在缺陷對不對,因為每次複用都要重新建立一個composition物件是不是!歸根到底就是,我們預設的dispose策略不太適合這種擁有複用邏輯或者自己生命週期的元件使用,那麼有其他策略適合RecyclerView嗎?別急,其實是有的,比如DisposeOnViewTreeLifecycleDestroyed ``` object DisposeOnViewTreeLifecycleDestroyed : ViewCompositionStrategy { override fun installFor(view: AbstractComposeView): () -> Unit { if (view.isAttachedToWindow) { val lco = checkNotNull(ViewTreeLifecycleOwner.get(view)) { "View tree for $view has no ViewTreeLifecycleOwner" } return installForLifecycle(view, lco.lifecycle) } else { // We change this reference after we successfully attach var disposer: () -> Unit val listener = object : View.OnAttachStateChangeListener { override fun onViewAttachedToWindow(v: View?) { val lco = checkNotNull(ViewTreeLifecycleOwner.get(view)) { "View tree for $view has no ViewTreeLifecycleOwner" } disposer = installForLifecycle(view, lco.lifecycle)

                // Ensure this runs only once
                view.removeOnAttachStateChangeListener(this)
            }

            override fun onViewDetachedFromWindow(v: View?) {}
        }
        view.addOnAttachStateChangeListener(listener)
        disposer = { view.removeOnAttachStateChangeListener(listener) }
        return { disposer() }
    }
}

} 然後我們在ViewHolder的init方法中對composeview設定一下就可以了 class MyComposeViewHolder(val composeView:ComposeView):RecyclerView.ViewHolder(composeView){ init { composeView.setViewCompositionStrategy( ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed ) } } ```

為什麼DisposeOnViewTreeLifecycleDestroyed更加適合呢?我們可以看到在onViewAttachedToWindow中呼叫了 installForLifecycle(view, lco.lifecycle) 方法,然後就removeOnAttachStateChangeListener,保證了該ComposeView建立的時候只會被呼叫一次,那麼removeOnAttachStateChangeListener又做了什麼呢? val observer = LifecycleEventObserver { _, event -> if (event == Lifecycle.Event.ON_DESTROY) { view.disposeComposition() } } lifecycle.addObserver(observer) return { lifecycle.removeObserver(observer) } 可以看到,是在對應的生命週期事件為ON_DESTROY(Lifecycle.Event跟activity生命週期不是一一對應的,要注意)的時候,才呼叫view.disposeComposition(),本例子的lifecycleOwner就是CustomActivity啦,這樣就保證了只有當前被lifecycleOwner處於特定狀態的時候,才會銷燬,這樣是不是就提高了compose的效能了!

擴充套件

我們留意到了Compose其實存在這樣的小問題,那麼如果我們用了其他的元件類似RecyclerView這種的怎麼辦,又或者我們的開發沒有讀過這篇文章怎麼辦!(ps:看到這裡的同學還不點贊點贊),沒關係,官方也注意到了,並且在1.3.0-alpha02以上版本添加了更換了預設策略,我們來看一下 val Default: ViewCompositionStrategy get() = DisposeOnDetachedFromWindowOrReleasedFromPool ``` object DisposeOnDetachedFromWindowOrReleasedFromPool : ViewCompositionStrategy { override fun installFor(view: AbstractComposeView): () -> Unit { val listener = object : View.OnAttachStateChangeListener { override fun onViewAttachedToWindow(v: View) {}

        override fun onViewDetachedFromWindow(v: View) {
        // 注意這裡
            if (!view.isWithinPoolingContainer) {
                view.disposeComposition()
            }
        }
    }
    view.addOnAttachStateChangeListener(listener)

    val poolingContainerListener = PoolingContainerListener { view.disposeComposition() }
    view.addPoolingContainerListener(poolingContainerListener)

    return {
        view.removeOnAttachStateChangeListener(listener)
        view.removePoolingContainerListener(poolingContainerListener)
    }
}

} ``` DisposeOnDetachedFromWindow從變成了DisposeOnDetachedFromWindowOrReleasedFromPool,其實主要變化點就是一個view.isWithinPoolingContainer = false,才會進行dispose,isWithinPoolingContainer定義如下

image.png

也就是說,如果我們view的祖先存在isPoolingContainer = true的時候,就不會進行dispose啦!所以說,如果我們的自定義view是這種情況,就一定要把isPoolingContainer變成true才不會有隱藏的效能開銷噢!當然,RecyclerView也要同步到1.3.0-alpha02以上才會有這個屬性改寫!現在穩定版本還是會存在本文的隱藏效能開銷,請注意噢!不過相信看完這篇文章,效能優化啥的,不存在了對不對!

結語

Compose是個大話題,希望開發者都能夠用上並深入下去,因為宣告式ui會越來越流行,Compose相對於傳統view體系也有大幅度的效能提升與架構提升!最後記得點贊關注呀!往期也很精彩!

我正在參與掘金技術社群創作者簽約計劃招募活動,點選連結報名投稿