【辨析】Compose 完全脱离 View 系统了吗?

9,531

本文已参与「掘力星计划」,赢取创作大礼包,挑战创作激励金。

前言

Compose正式发布1.0已经相当一段时间了,但相信很多同学对Compose还是有很多迷惑的地方
Compose跟原生的View到底是什么关系?是跟Flutter一样完全基于Skia引擎渲染,还是说还是View的那老一套?
相信很多同学都会有下面的疑问


下面我们就一起来看下下面这个问题

现象分析

我们先看这样一个简单布局

class TestActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        setContent {        	
        	ComposeBody()              
        }
    }
}

@Composable
fun ComposeBody() {
    Column {
        Text(text = "这是一行测试数据", color = Color.Black, style = MaterialTheme.typography.h6)
        Row() {
            Text(text = "测试数据1!", color = Color.Black, style = MaterialTheme.typography.h6)
            Text(text = "测试数据2!", color = Color.Black, style = MaterialTheme.typography.h6)
        }
    }
}

如上所示,就是一个简单的布局,包含Column,RowText
然后我们打开开发者选项中的显示布局边界,效果如下图所示:


我们可以看到Compose的组件显示了布局边界,我们知道,FlutterWebView H5内的组件都是不会显示布局边界的,难道Compose的布局渲染其实还是View的那一套?

我们下面再在onResume时尝试遍历一下View的层级,看一下Compose到底会不会转化成View

    override fun onResume() {
        super.onResume()
        window.decorView.postDelayed({
            (window.decorView as? ViewGroup)?.let { transverse(it, 1) }
        }, 2000)
    }

    private fun transverse(view: View, index: Int) {
        Log.e("debug", "第${index}层:" + view)
        if (view is ViewGroup) {
            view.children.forEach { transverse(it, index + 1) }
        }
    }

通过以上方式打印页面的层级,输出结果如下:

E/debug: 第1层:DecorView@c2f703f[RallyActivity]
E/debug: 第2层:android.widget.LinearLayout{4202d0c V.E...... ........ 0,0-1080,2340}
E/debug: 第3层:android.view.ViewStub{2b50655 G.E...... ......I. 0,0-0,0 #10201b1 android:id/action_mode_bar_stub}
E/debug: 第3层:android.widget.FrameLayout{9bfc86a V.E...... ........ 0,90-1080,2340 #1020002 android:id/content}
E/debug: 第4层:androidx.compose.ui.platform.ComposeView{1b4d15b V.E...... ........ 0,0-1080,2250}
E/debug: 第5层:androidx.compose.ui.platform.AndroidComposeView{a8ec543 VFED..... ........ 0,0-1080,2250}

如上所示,我们写的Column,Row,Text并没有出现在布局层级中,跟Compose相关的只有ComposeViewAndroidComposeView两个View
ComposeViewAndroidComposeView都是在setContent时添加进去的Compose的容器,我们后面再分析,这里先给出结论

Compose在渲染时并不会转化成View,而是只有一个入口View,即AndroidComposeView
我们声明的Compose布局在渲染时会转化成NodeTree,AndroidComposeView中会触发NodeTree的布局与绘制
总得来说,Compose会有一个View的入口,但它的布局与渲染还是在LayoutNode上完成的,基本脱离了View

总得来说,纯Compose页面的页面层级如下图所示:

原理分析

前置知识

我们知道,在View系统中会有一棵ViewTree,通过一个树的数据结构来描述整个UI界面
Compose中,我们写的代码在渲染时也会构建成一个NodeTree,每一个组件就是一个ComposeNode,作为NodeTree上的一个节点

ComposeNodeTree 管理涉及 ApplierCompositionComposeNode
Composition 作为起点,发起首次的 composition,通过 Compose 的执行,填充 Slot Table,并基于 Table 创建 NodeTree。渲染引擎基于 Compose Nodes 渲染 UI, 每当 recomposition 发生时,都会通过 ApplierNodeTree 进行更新。 因此

Compose 的执行过程就是创建 Node 并构建 NodeTree 的过程。


为了了解NodeTree的构建过程,我们来介绍下面几个概念

Applier:增删 NodeTree 的节点

简单来说,Applier的作用就是增删NodeTree的节点,每个NodeTree的运算都需要配套一个Applier
同时,Applier 会提供回调,基于回调我们可以对 NodeTree 进行自定义修改:

interface Applier<N> {

    val current: N // 当前处理的节点

    fun onBeginChanges() {}

    fun onEndChanges() {}

    fun down(node: N)

    fun up()

    fun insertTopDown(index: Int, instance: N) // 添加节点(自顶向下)

    fun insertBottomUp(index: Int, instance: N)// 添加节点(自底向上)

    fun remove(index: Int, count: Int) //删除节点
    
    fun move(from: Int, to: Int, count: Int) // 移动节点

    fun clear() 
}

如上所示,节点增删时会回调到Applier中,我们可以在回调的方法中自定义节点添加或删除时的逻辑,后面我们可以一起看下在Android平台Compose是怎样处理的

Composition: Compose执行的起点

CompositionCompose执行的起点,我们来看下如何创建一个Composition

val composition = Composition(
    applier = NodeApplier(node = Node()),
    parent = Recomposer(Dispatchers.Main)
)

composition.setContent {
    // Composable function calls
}

如上所示

  1. Composition中需要传入两个参数,ApplierRecomposer
  2. Applier上面已经介绍过了,Recomposer非常重要,他负责Compose的重组,当重组后,Recomposer 通过调用 Applier 完成 NodeTree 的变更
  3. Composition#setContent 为后续 Compose 的调用提供了容器

通过上面的介绍,我们了解了NodeTree构建的基本流程,下面我们一起来分析下setContent的源码

setContent过程分析

setContent入口

setContent的源码其实比较简单,我们一起来看下:

public fun ComponentActivity.setContent(
    parent: CompositionContext? = null,
    content: @Composable () -> Unit
) {
    //判断ComposeView是否存在,如果存在则不创建
    if (existingComposeView != null) with(existingComposeView) {
        setContent(content)
    } else ComposeView(this).apply {
        //将Compose content添加到ComposeView上
        setContent(content)
        // 将ComposeView添加到DecorView上
        setContentView(this, DefaultActivityContentLayoutParams)
    }
}

上面就是setContent的入口,主要作用就是创建了一个ComposeView并添加到DecorView

Composition的创建

下面我们来看下AndroidComposeViewComposition是怎样创建的
通过ComposeView#setContent->AbstractComposeView#createComposition->AbstractComposeView#ensureCompositionCreated->ViewGroup#setContent
最后会调用到doSetContent方法,这里就是Compose的入口:Composition创建的地方

private fun doSetContent(
    owner: AndroidComposeView, //AndroidComposeView是owner
    parent: CompositionContext,
    content: @Composable () -> Unit
): Composition {
    //..
    //创建Composition,并传入Applier与Recomposer
    val original = Composition(UiApplier(owner.root), parent)
    val wrapped = owner.view.getTag(R.id.wrapped_composition_tag)
        as? WrappedComposition
        ?: WrappedComposition(owner, original).also {
            owner.view.setTag(R.id.wrapped_composition_tag, it)
        }
    //将Compose内容添加到Composition中   
    wrapped.setContent(content)
    return wrapped
}

如上所示,主要就是创建一个Composition并传入UIApplierRecomposer,并将Compose content传入Composition

UiApplier的实现

上面已经创建了Composition并传入了UIApplier,后续添加了Node都会回调到UIApplier

internal class UiApplier(
    root: LayoutNode
) : AbstractApplier<LayoutNode>(root) {
    //...
    
    override fun insertBottomUp(index: Int, instance: LayoutNode) {
        current.insertAt(index, instance)
    }

    //...
}

如上所示,在插入节点时,会调用current.insertAt方法,那么这个current到底是什么呢?

private fun doSetContent(
    owner: AndroidComposeView, //AndroidComposeView是owner
): Composition {
    //UiApplier传入的参数即为AndroidComposeView.root
    val original = Composition(UiApplier(owner.root), parent)
}

abstract class AbstractApplier<T>(val root: T) : Applier<T> {
    private val stack = mutableListOf<T>()
    override var current: T = root
    }
}        

可以看出,UiApplier中传入的参数其实就是AndroidComposeViewroot,即current就是AndroidComposeViewroot

    # AndroidComposeView
    override val root = LayoutNode().also {
        it.measurePolicy = RootMeasurePolicy
        //...
    }

如上所示,root其实就是一个LayoutNode,通过上面我们知道,所有的节点都会通过Applier插入到root

布局与绘制入口

上面我们已经在AndroidComposeView中拿到NodeTree的根结点了,那Compose的布局与测量到底是怎么触发的呢?

    # AndroidComposeView
    override fun dispatchDraw(canvas: android.graphics.Canvas) {
    	//Compose测量与布局入口
        measureAndLayout()
        
        //Compose绘制入口
        canvasHolder.drawInto(canvas) { root.draw(this) }
        //...
    }

    override fun measureAndLayout() {
        val rootNodeResized = measureAndLayoutDelegate.measureAndLayout()
        measureAndLayoutDelegate.dispatchOnPositionedCallbacks()
    }

如上所示,AndroidComposeView会通过root,向下遍历它的子节点进行测量布局与绘制,这里就是LayoutNode绘制的入口

小结

  1. Compose在构建NodeTree的过程中主要通过Composition,Applier,Recomposer构建,Applier会将所有节点添加到AndroidComposeView中的root节点下
  2. setContent的过程中,会创建ComposeViewAndroidComposeView,其中AndroidComposeViewCompose的入口
  3. AndroidComposeViewdispatchDraw中会通过root向下遍历子节点进行测量布局与绘制,这里是LayoutNode绘制的入口
  4. Android平台上,Compose的布局与绘制已基本脱离View体系,但仍然依赖于Canvas

Compose与跨平台

上面说到,Compose的绘制仍然依赖于Canvas,但既然这样,Compose是怎么做到跨平台的呢?
这主要是通过良好的分层设计

Compose 在代码上自下而上依次分为6层:

其中compose.runtimecompose.compiler最为核心,它们是支撑声明式UI的基础。

而我们上面分析的AndroidComposeView这一部分,属于compose.ui部分,它主要负责Android设备相关的基础UI能力,例如 layoutmeasuredrawinginput
但这一部分是可以被替换的,compose.runtime 提供了 NodeTree 管理等基础能力,此部分与平台无关,在此基础上各平台只需实现UI的渲染就是一套完整的声明式UI框架

基于compose.runtime可以实现任意一套声明式UI框架,关于compose.runtime的详细介绍可参考fundroid大佬写的:Jetpack Compose Runtime : 声明式 UI 的基础

Button的特殊情况

上面我们介绍了在纯Compose项目下,AndroidComposeView不会有子View,而是遍历LayoutnNode来布局测量绘制
但如果我们在代码中加入一个Button,结果可能就不太一样了

@Composable
fun ComposeBody() {
    Column {
        Text(text = "这是一行测试数据", color = Color.Black, style = MaterialTheme.typography.h6)
        Row() {
            Text(text = "测试数据1!", color = Color.Black, style = MaterialTheme.typography.h6)
            Text(text = "测试数据2!", color = Color.Black, style = MaterialTheme.typography.h6)
        }

        Button(onClick = {}) {
            Text(text = "这是一个Button",color = Color.White)
        }
    }
}

然后我们再看看页面的层级结构

E/debug: 第1层:DecorView@182e858[RallyActivity]
E/debug: 第2层:android.widget.LinearLayout{397edb1 V.E...... ........ 0,0-1080,2340}
E/debug: 第3层:android.widget.FrameLayout{e2b0e17 V.E...... ........ 0,90-1080,2340 #1020002 android:id/content}
E/debug: 第4层:androidx.compose.ui.platform.ComposeView{36a3204 V.E...... ........ 0,0-1080,2250}
E/debug: 第5层:androidx.compose.ui.platform.AndroidComposeView{a8ec543 VFED..... ........ 0,0-1080,2250}
E/debug: 第6层:androidx.compose.material.ripple.RippleContainer{28cb3ed V.E...... ......I. 0,0-0,0}
E/debug: 第7层:androidx.compose.material.ripple.RippleHostView{b090222 V.ED..... ......I. 0,0-0,0}

可以看到,很明显,AndroidComposeView下多了两层子View,这是为什么呢?

我们一起来看下RippleHostView的注释

Empty View that hosts a RippleDrawable as its background. This is needed as RippleDrawables cannot currently be drawn directly to a android.graphics.RenderNode (b/184760109), so instead we rely on View's internal implementation to draw to the background android.graphics.RenderNode. A RippleContainer is used to manage and assign RippleHostViews when needed - see RippleContainer.getRippleHostView.

意思也很简单,Compose目前还不能直接绘制水波纹效果,因此需要将水波纹效果设置为View的背景,这里利用View做了一个中转
然后RippleHostViewRippleContainer自然会添加到AndroidComposeView中,如果我们在Compose中使用了AndroidView,效果也是一样的
但是这种情况并没有违背我们上面说的,纯Compose项目下,AndroidComposeView下没有子View,因为Button并不是纯Compose

总结

本文主要分析回答了Compose到底有没有完全脱离View系统这个问题,总结如下:

  1. Compose在渲染时并不会转化成View,而是只有一个入口View,即AndroidComposeView,纯Compose项目下,AndroidComposeView没有子View
  2. 我们声明的Compose布局在渲染时会转化成NodeTree,AndroidComposeView中会触发NodeTree的布局与绘制,AndroidComposeView#dispatchDraw是绘制的入口
  3. Android平台上,Compose的布局与绘制已基本脱离View体系,但仍然依赖于Canvas
  4. 由于良好的分层体系,Compose可通过 compose.runtimecompose.compiler实现跨平台
  5. 在使用Button时,AndroidComposeView会有两层子View,这是因为Button中使用了View来实现水波纹效果

参考资料

Jetpack Compose Runtime : 声明式 UI 的基础