原理分析,Jetpack Compose 完全脱离 View 系统了吗?

前言

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两个ViewComposeViewAndroidComposeView都是在setContent时添加进去的Compose的容器,我们后面再分析,这里先给出结论

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

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

原理分析

前置知识

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

ComposeNodeTree 管理涉及 ApplierCompositionComposeNodeComposition 作为起点,发起首次的 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执行的起点

Composition`是`Compose`执行的起点,我们来看下如何创建一个`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框架

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来实现水波纹效果

作者:程序员江同学
转载来源于:https://juejin.cn/post/7017811394036760612
如有侵权,请联系删除!

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,098评论 5 476
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,213评论 2 380
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 149,960评论 0 336
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,519评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,512评论 5 364
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,533评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,914评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,574评论 0 256
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,804评论 1 296
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,563评论 2 319
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,644评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,350评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,933评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,908评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,146评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,847评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,361评论 2 342

推荐阅读更多精彩内容