查看原文
其他

使用 Jetpack Compose Runtime 打造声明式UI

fundroid AndroidPub 2022-05-15


Jetpack Compose 不只是一个 UI 框架,更是一个通用的 NodeTree 管理引擎。本文介绍 compose.runtime 如何通过 NodeTreecompose.ui 提供支持。

大家知道 Jetpack Compose 不仅限于在 Android 中使用 ,Compose For Desktop、 Compose For Web 等项目也已相继发布,未来也许还会出现 Compose For iOS 。Compose 能够在不同平台上实现相似的声明式UI开发体验,这得益于其分层的设计。

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

ModulesDescription
compose.compiler基于 Kotlin compiler plugin 对 @Composable 进行编译期代码生成和优化
compose.runtime提供 NodeTree管理、State管理等,声明式UI的基础运行时
compose.uiAndroid设备相关的基础UI能力,例如 layout、measure、drawing、input 等
compose.foundation通用的UI组件,包括 Column、Row 等容器、以及各种 Shape 等
compose.animation负责动画的实现、提升用户体验
compose.material提供符合 Material Design 标准的UI组件

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

Jake Wharton 在他的博客提到 :

What this means is that Compose is, at its core, a general-purpose tool for managing a tree of nodes of any type. Well a “tree of nodes” describes just about anything, and as a result Compose can target just about anything.
- https://jakewharton.com/a-jetpack-compose-by-any-other-name/

compose.runtime 提供了 NodeTree 管理等基础能力,此部分与平台无关,在此基础上各平台只需实现UI的渲染就是一套完整的声明式UI框架。而 compose.compiler 通过编译期的优化,帮助开发者书写更简单的代码调用 runtime 的能力。

从 Composable 到 NodeTree

“Compose 也好,React、Flutter 也好,其代码本质上都是对一颗树形结构的描述。”

所谓“数据启动UI",就是当state变化时,重建这颗树型结构并基于这棵NodeTree刷新UI。当然,处于性能考虑,当 NodeTree 需要重建时,各框架会使用 VirtualDom 、GapBuffer(或称SlotTable) 等不同技术对其进行“差量”更新,避免“全量”重建。compose.runtime 的重要工作之一就是负责 NodeTree 的创建与更新。

如上,React 基于 VDOM "差量" 更新右侧的DOM树。

Compose 中的 NodeTree

对于 OOP 语言,我们通常使用如下方式描述一颗树:

fun TodoApp(items: List<TodoItem>): Node {
  return Stack(Orientation.Vertical).apply {
    for (item in items) {
      children.add(Stack(Orientation.Horizontal).apply {
        children.add(Text(if (item.completed) "x" else " "))
        children.add(Text(item.title))
      })
    }
  }
}

TodoApp 返回 Node 对象,可以被父 Node 继续 add,循环往复构成一棵完整的树。

但是 OOP 的写法模板代码多,不够简洁,且缺乏安全性。返回值 Node 成为句柄被随意引用甚至修改,这破坏了声明式UI中 “不可变性” 的原则,如果 UI 可以随意修改,diff 算法的准确性将无法保证。

因此,为了保证 UI 的不可变性,我们设法抹去返回值 Node:

fun Composer.TodoApp(items: List<TodoItem>) {
  Stack(Orientation.Vertical) {
    for (item in items) {
      Stack(Orientation.Horizontal) {
        Text(if (item.completed) "x" else " ")
        Text(item.title)
      }
    }
  }
}

fun Composer.Stack(orientation:Int, content: Composer.() -> Unit) {
    emit(StackNode(orientation)) {
        content()
    }
}

fun Composer.Text() {
    ...
}

通过 Composer 提供的上下文, 将创建的 Node emit 到树上的合适位置。

interface Composer {
  // add node as a child to the current Node, execute
  // `content` with `node` as the current Node
  fun emit(node: Node, content: () -> Unit = {})
}

Composer.Stack() 作为一个无返回值的函数,使得 NodeTree 的构建从 OOP 方式变为了 FP(函数式编程) 方式。

Compose Compiler 的加持

compose.compiler 的意义是让 FP 的写法进一步简单,添加一个 @Composable 注解, TodoApp 不必定义成 Composer  的扩展函数, 但是在编译期会修改 TodoApp 的签名,添加 Composer 参数。

@Composable
fun TodoApp {
  Stack {
    for (item in items) {
      Stack(Orientation.Horizontal){
        Text(if (item.completed) "x" else " ")
        Text(item.title))
      })
    }
  }
}

在 Compiler 的加持下,我们可以使用 @Composable 高效地写代码。抛开语言上的差异不讲,Compose 比 Flutter 写起来要舒服得多。但无论写法上有多少差别,其归根结底还是会转换为对 NodeTree 的操作

NodeTree操作:Applier、ComposeNode、Composition

Compose 的 NodeTree 管理涉及 ApplierCompositionCompose Nodes 的工作:

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

“Composable 的执行过程就是创建 Node 并构建 NodeTree 的过程。”

Applier:变更 NodeTree 的节点

前文提到,出于性能考虑,NodeTree 会使用 “差量” 方式自我更新,而这正是基于 Applier 实现的。Applier 使用 Visitor 模式遍历树上的 Node ,每种 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() 
}

insertTopDowninsertBottomUp 都用来添加节点,针对不同的树形结构选择不同的添加顺序有助于提高性能。参考: insertTopDown

insertTopDown(自顶向下)insertBottomUp(自底向上)

我们可以实现自定义的 NodeApplier, 如下:

class Node {
  val children = mutableListOf<Node>()
}

class NodeApplier(node: Node) : AbstractApplier<Node>(node) {
  override fun onClear() {}
  override fun insertBottomUp(index: Int, instance: Node) {}

  override fun insertTopDown(index: Int, instance: Node) {
    current.children.add(index, instance) // `current` is set to the `Node` that we want to modify.
  }

  override fun move(from: Int, to: Int, count: Int) {
    current.children.move(from, to, count)
  }

  override fun remove(index: Int, count: Int) {
    current.children.remove(index, count)
  }
}

Applier 需要在 composition/recomposition 过程中被调用。composition 是通过 Composition 中对 Root Composable 的调用发起的,进而调用全部 Composalbe 最终形成NodeTree。

Composition:Composalbe 执行的起点

fun Composition(applier: Applier<*>, parent: CompositionContext) 创建 Composition对象,参数传入 ApplierRecomposer

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

composition.setContent {
    // Composable function calls
}

Recomposer 非常重要,他负责 Compose 的 recomposiiton 。当 NodeTree 首次创建之后,与 state 建立关联,监听 state 的变化发生重组。这个关联的建立是通过 Recomposer 的 “快照系统” 完成的。重组后,Recomposer 通过调用 Applier 完成 NodeTree 的变更。

关于 “快照系统” 以及 Recomposer 原理请参考:https://compose.net.cn/principle/snapshot/https://compose.net.cn/principle/recompose_working_principle/

Composition#setContent 为后续 Compodable 的调用提供了容器:


interface Composition {

    val hasInvalidations: Boolean

    val isDisposed: Boolean

    fun dispose()

    fun setContent(content: @Composable () -> Unit)
}

ComposeNode:创建 UiNode 并进行更新

理论上每个 Composable 的执行都对应一个 Node 的创建, 但是由于 NodeTree 无需全量重建,所以也不是每次都需要创建新 Node。大多的 Composalbe 都会调用 ComposeNode() 接受一个 factory,仅在必要的时候创建 Node。

Layout 的实现为例,

@Composable inline fun Layout(
    content: @Composable () -> Unit,
    modifier: Modifier = Modifier,
    measurePolicy: MeasurePolicy
)
 {
    val density = LocalDensity.current
    val layoutDirection = LocalLayoutDirection.current
    ComposeNode<ComposeUiNode, Applier<Any>>(
        factory = ComposeUiNode.Constructor,
        update = {
            set(measurePolicy, ComposeUiNode.SetMeasurePolicy)
            set(density, ComposeUiNode.SetDensity)
            set(layoutDirection, ComposeUiNode.SetLayoutDirection)
        },
        skippableUpdate = materializerOf(modifier),
        content = content
    )
}
  • factory:创建 Node 的工厂
  • update:接受 receiver 为 Updater<T>的 lambda,用来更新当前 Node 的属性
  • content:调用子 Composable

ComposeNode() 的实现非常简单:

inline fun <T, reified E : Applier<*>> ComposeNode(
    noinline factory: () -> T,
    update: @DisallowComposableCalls Updater<T>.() -> Unit,
    noinline skippableUpdate: @Composable SkippableUpdater<T>.() -> Unit,
    content: @Composable () -> Unit
)
 {
    if (currentComposer.applier !is E) invalidApplier()
    currentComposer.startNode()
    if (currentComposer.inserting) {
        currentComposer.createNode(factory)
    } else {
        currentComposer.useNode()
    }
    Updater<T>(currentComposer).update()
    SkippableUpdater<T>(currentComposer).skippableUpdate()
    currentComposer.startReplaceableGroup(0x7ab4aae9)//在编译期决定真正的GroupId
    content()
    currentComposer.endReplaceableGroup()
    currentComposer.endNode()
}

在 composition 过程中,通过 Composer上下文,更新 SlotTable, content()递归创建子 Node

SlotTable 在更新过程中,通过 diff 决定是否需要对 Node 进行 add/update/remove 等操作。此处的 startNodeuseNodeendNode 等就是对 SlotTable 的遍历过程。

有关 SlotTable(GapBuffer) 的介绍,请参考:https://compose.net.cn/principle/gap_buffer/

SlotTable 的 diff 结果通过 Applier 的回调处理 NodeTree 结构的变化;通过调用 Updater<T>.update() 来处理 Node 属性的变化

Jake wharton 的实验项目 Mosica

基于 compose.runtime 可以实现任意一套声明式UI框架。J神有一个实验性的项目 Mosica,就很好地展示了这一点 :https://github.com/JakeWharton/mosaic

fun main() = runMosaic {
 var count by mutableStateOf(0)

 setContent {
            Text("The count is: $count")
 }

 for (i in 1..20) {
            delay(250)
            count = i
 }
}

上面是 Mosica 中的一个 Counter 的例子。

Mosica Composition

runMosaic() 创建 Composition、Recomposer 和 Applier

fun runMosaic(body: suspend MosaicScope.() -> Unit) = runBlocking {
 //...
 val job = Job(coroutineContext[Job])
 val composeContext = coroutineContext + clock + job

 val rootNode = BoxNode() //根节点Node
 val recomposer = Recomposer(composeContext) //Recomposer
 val composition = Composition(MosaicNodeApplier(rootNode), recomposer) //Composition
        
          
 coroutineScope {
  val scope = object : MosaicScope, CoroutineScope by this {
   override fun setContent(content: @Composable () -> Unit) {
    composition.setContent(content)//调用@Composable
    hasFrameWaiters = true
   }
  }

  //...
  val snapshotObserverHandle = Snapshot.registerGlobalWriteObserver(observer)
  try {
   scope.body()//CoroutineScope中执行setContent{}
  } finally {
   snapshotObserverHandle.dispose()
  }
 }

        
}

而后,在 Composition 的 setContent{} 中,调用 @Composable。

Mosaic Node

看一下 Mosaic 中的 @Composalbe 和其对应的 Node

@Composable
private fun Box(flexDirection: YogaFlexDirection, children: @Composable () -> Unit) {
 ComposeNode<BoxNode, MosaicNodeApplier>(
  factory = ::BoxNode,
  update = {
   set(flexDirection) {
    yoga.flexDirection = flexDirection
   }
  },
  content = children,
 )
}
@Composable
fun Text(
 value: String,
 color: Color? = null,
 background: Color? = null,
 style: TextStyle? = null,
)
 {
 ComposeNode<TextNode, MosaicNodeApplier>(::TextNode) {
  set(value) {
   this.value = value
  }
  set(color) {
   this.foreground = color
  }
  set(background) {
   this.background = background
  }
  set(style) {
   this.style = style
  }
 }
}

ComposeNode 通过泛型关联对应的 Node 和 Applier 类型

Box 和 Text 内部都使用 ComposeNode() 创建对应的 Node 对象。其中 Box 是容器类的 Composalbe,在 conent 中进一步创建子 Node。Box 和 Text 在Updater<T>.update()中更新 Node 属性 。

看一下 BoxNode:

internal class BoxNode : MosaicNode() {
 val children = mutableListOf<MosaicNode>()

 override fun renderTo(canvas: TextCanvas) {
  for (child in children) {
   val childYoga = child.yoga
   val left = childYoga.layoutX.toInt()
   val top = childYoga.layoutY.toInt()
   val right = left + childYoga.layoutWidth.toInt() - 1
   val bottom = top + childYoga.layoutHeight.toInt() - 1
   child.renderTo(canvas[top..bottom, left..right])
  }
 }

 override fun toString() = children.joinToString(prefix = "Box(", postfix = ")")
}

internal sealed class MosaicNode {
 val yoga: YogaNode = YogaNodeFactory.create()

 abstract fun renderTo(canvas: TextCanvas)

 fun render(): String {
  val canvas = with(yoga) {
   calculateLayout(UNDEFINED, UNDEFINED)
   TextSurface(layoutWidth.toInt(), layoutHeight.toInt())
  }
  renderTo(canvas)
  return canvas.toString()
 }
}

BoxNode 继承自 MosaicNode, MosaicNode 在 render() 中,通过 yoga 实现UI的绘制。通过调用 renderTo() 在 Canvas中 递归绘制子 Node,类似 AndroidView 的绘制逻辑。

理论上需要在首次 composition 或者 recomposition 时,调用 Node 的 render() 进行 NodeTree 的绘制, 为简单起见,Mosica 只是使用了定时轮询的方式调用 render()

 launch(context = composeContext) {
  while (true) {
   if (hasFrameWaiters) {
    hasFrameWaiters = false
    output.display(rootNode.render())
   }
   delay(50)
  }
 }
        
        
        //counter的state变化后,重新setContent,hasFrameWaiters更新后,重新render
        coroutineScope {
  val scope = object : MosaicScope, CoroutineScope by this {
   override fun setContent(content: @Composable () -> Unit) {
    composition.setContent(content)
    hasFrameWaiters = true
   }
  }
         }

MosaicNodeApplier

最后看一下 MosaicNodeApplier:

internal class MosaicNodeApplier(root: BoxNode) : AbstractApplier<MosaicNode>(root) {
 override fun insertTopDown(index: Int, instance: MosaicNode) {
  // Ignored, we insert bottom-up.
 }

 override fun insertBottomUp(index: Int, instance: MosaicNode) {
  val boxNode = current as BoxNode
  boxNode.children.add(index, instance)
  boxNode.yoga.addChildAt(instance.yoga, index)
 }

 override fun remove(index: Int, count: Int) {
  val boxNode = current as BoxNode
  boxNode.children.remove(index, count)
  repeat(count) {
   boxNode.yoga.removeChildAt(index)
  }
 }

 override fun move(from: Int, to: Int, count: Int) {
  val boxNode = current as BoxNode
  boxNode.children.move(from, to, count)

  val yoga = boxNode.yoga
  val newIndex = if (to > from) to - count else to
  if (count == 1) {
   val node = yoga.removeChildAt(from)
   yoga.addChildAt(node, newIndex)
  } else {
   val nodes = Array(count) {
    yoga.removeChildAt(from)
   }
   nodes.forEachIndexed { offset, node ->
    yoga.addChildAt(node, newIndex + offset)
   }
  }
 }

 override fun onClear() {
  val boxNode = root as BoxNode
  // Remove in reverse to avoid internal list copies.
  for (i in boxNode.yoga.childCount - 1 downTo 0) {
   boxNode.yoga.removeChildAt(i)
  }
 }
}

MosaicNodeApplier 实现了对 Node 的 add/move/remove, 最终都反映到了对 YogaNode 的操作上,通过 YogaNode 刷新 UI

基于 AndroidView 的声明式UI

参考 Moscia 的示范,我们可以使用 compose.runtime 打造一个基于 Android 原生 View 的声明式 UI 框架。

LinearLayout & TextView Node

@Composable
fun TextView(
    text: String,
    onClick: () -> Unit = {}
)
 {
    val context = localContext.current
    ComposeNode<TextView, ViewApplier>(
        factory = {
            TextView(context)
        },
        update = {
            set(text) {
                this.text = text
            }
            set(onClick) {
                setOnClickListener { onClick() }
            }
        },
    )
}

@Composable
fun LinearLayout(children: @Composable () -> Unit) {
    val context = localContext.current
    ComposeNode<LinearLayout, ViewApplier>(
        factory = {
            LinearLayout(context).apply {
                orientation = LinearLayout.VERTICAL
                layoutParams = ViewGroup.LayoutParams(
                    ViewGroup.LayoutParams.MATCH_PARENT,
                    ViewGroup.LayoutParams.MATCH_PARENT,
                )
            }
        },
        update = {},
        content = children,
    )
}

ViewApplier

ViewApplier 中只实现 add

class ViewApplier(val view: FrameLayout) : AbstractApplier<View>(view) {
    override fun onClear() {
        (view as? ViewGroup)?.removeAllViews()
    }

    override fun insertBottomUp(index: Int, instance: View) {
        (current as? ViewGroup)?.addView(instance, index)
    }

    override fun insertTopDown(index: Int, instance: View) {
    }

    override fun move(from: Int, to: Int, count: Int) {
        // NOT Supported
        TODO()
    }

    override fun remove(index: Int, count: Int) {
        (view as? ViewGroup)?.removeViews(index, count)
    }
}

创建 Composition

创建 Root Composable:AndroidViewApp

@Composable
private fun AndroidViewApp() {
    var count by remember { mutableStateOf(1) }
    LinearLayout {
        TextView(
            text = "This is the Android TextView!!",
        )
        repeat(count) {
            TextView(
                text = "Android View!!TextView:$it $count",
                onClick = {
                    count++
                }
            )
        }
    }
}

最后在 content 调用  AndroidViewApp

fun runApp(context: Context): FrameLayout {
    val composer = Recomposer(Dispatchers.Main)

    GlobalSnapshotManager.ensureStarted()
    val mainScope = MainScope()
    mainScope.launch(start = CoroutineStart.UNDISPATCHED) {
        withContext(coroutineContext + DefaultMonotonicFrameClock) {
            composer.runRecomposeAndApplyChanges()
        }
    }
    mainScope.launch {
        composer.state.collect {
            println("composer:$it")
        }
    }

    val rootDocument = FrameLayout(context)
    Composition(ViewApplier(rootDocument), composer).apply {
        setContent {
            CompositionLocalProvider(localContext provides context) {
                AndroidViewApp()
            }
        }
    }
    return rootDocument
}

效果展示:

总结

  • 当 State 变化时触发 recomposition,Composable 重新执行
  • Composable 在执行中,通过 SlotTable 的 diff,找出待变更的 Node
  • 通过 Applier 更新 TreeNode,并在 UI 层渲染这棵树。
  • 基于 compose.runtime ,我们可以实现自己的声明式UI

~FIN~

目前有一个正在进行的 Jetpack Compose 中文手册 项目,其中整理和收录了许多有关 Compose 的原创和翻译文章,旨在帮助大家更好地学习和上手 Compose 框架,欢迎有志之士的你一同参与建设。  
项目地址:https://github.com/compose-museum/compose-library

推荐阅读


盘点一下 Fragment 间的几种通信方式


还在用 ContentProvider 初始化?App Startup 了解一下


面试官:你了解 LiveData 的 postValue 吗?


Kotlin 1.5 新特性:密封接口有啥用?




加我好友拉你进技术交流群,每天干货聊不停~


↓关注公众号↓↓添加微信交流↓



您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存