Compose 系列【4】Applier

153 阅读10分钟

Applier

Applier 是一个树的构建器的接口定义。

指针

其内部属性 current,作为当前操作的 node,在调用 up 和 down 操作时,该属性的值会发生变化。

interface Applier<N> {
    // 当前操作应用的节点。在调用 [down] 和 [up] 时,该属性的值预期会发生变化。
    val current: N
  
  	// ...
}

生命周期

为了方便监控 Applier 的更改时机,定义了更改开始和结束的生命周期方法:

    // 当 [Composer] 即将开始使用这个 applier 应用更改时调用。
    // 当更改完成时,将调用 [onEndChanges]。
    fun onBeginChanges() {}

    // 当 [Composer] 完成使用这个 applier 应用更改时调用。
    // 每次调用 [onEndChanges] 之前,都会先调用 [onBeginChanges]。
    fun onEndChanges() {}

遍历

包括两个操作,自上而下遍历树的 down 操作和自下而上遍历树的 up 操作

    // 表示 applier 正在向下遍历树。当调用此方法时,
    // [node] 预期是 [current] 的子节点,并且在此操作后,
    // [node] 预期将成为新的 [current]。
    fun down(node: N)

    // 表示 applier 正在向上遍历树。在此操作完成后,
    // [current] 应该返回操作开始时 [current] 节点的 "父节点"。
    fun up()

插入

通常情况下,往一个树上插入新的节点,有两种查找插入位置的方式,即自上而下(Top-Down)和自下而上(Bottom-Up)。假设插入一个新的节点需要通知到和新节点相关的节点,那么就会产生不同的性能上的差异。

自上而下(Top-Down)插入

过程:从根节点开始,逐层向下插入节点。

步骤

  1. 首先把新节点插入到父节点中。
  2. 然后再依次插入新节点的子节点。

举例

假设你有一个树结构 R -> B -> (A, C)

  • 第一步,把 B 插入到 R。

  • 第二步,把 A 插入到 B。

  • 第三步,把 C 插入到 B。

  • 最终结构:

        R
        |
        B
       / \
      A   C
    

自下而上(Bottom-Up)插入

过程:先插入子节点,然后再插入父节点。

步骤

  1. 先把子节点插入到它的父节点中。
  2. 然后把它的父节点插入到祖父节点中。

举例

同样的树结构 R -> B -> (A, C):

  • 第一步,把 A 和 C 插入到 B。

  • 第二步,把 B(已经有了 A 和 C)插入到 R。

  • 最终结构:

        R
        |
        B
       / \
      A   C
    

性能和通知的差异

当你向树中插入节点时,可能会有通知机制,这些通知会告诉父节点和子节点有新节点加入。不同插入方式对通知数量的影响如下:

自上而下的通知(Top-Down)

  • 每一步都会触发通知
    1. 插入 B 到 R:R 被通知。
    2. 插入 A 到 B:B 和 R 都被通知。
    3. 插入 C 到 B:B 和 R 都被通知。
  • 总通知次数:随着节点数量增加,通知次数会迅速增长。例如,插入 3 个节点总共会通知 5 次。

自下而上的通知(Bottom-Up)

  • 一次插入只触发一次通知
    1. 插入 A 到 B:B 被通知。
    2. 插入 C 到 B:B 被通知。
    3. 插入 B(包含 A 和 C)到 R:R 被通知。
  • 总通知次数:通知次数与插入的节点数量成线性关系。例如,插入 3 个节点总共会通知 3 次。

选择哪种方式

  • 性能考虑
    • 自上而下(Top-Down):在每次插入时,从根节点到当前节点的所有父节点都会收到通知。如果你的树结构需要频繁地通知父节点,这种方式的通知次数会随着树的深度和节点数量迅速增加,可能导致性能问题。
    • 自下而上(Bottom-Up):在插入时,先构建完整的子树,然后插入到父节点。这种方式下,每个节点的插入只触发一次通知,通知数量相对较少,因此在需要大量通知的情况下性能更高效。
  • 实现的复杂性
    • 自上而下(Top-Down):的插入实现起来可能更直观,因为你按照从上到下的顺序构建树,非常符合人们对树结构的直观理解。适合树的层次结构较浅、通知需求简单的场景。
    • 自下而上(Bottom-Up):需要先构建子节点并将它们插入到父节点,然后再将整个子树插入到更高层次的父节点中。这种方法可能需要额外的步骤来管理子节点的构建和插入。适合树的层次结构较深、通知需求复杂的场景,因为这种方法能减少插入过程中触发的通知次数,从而提高性能。
    fun insertTopDown(index: Int, instance: N)

    fun insertBottomUp(index: Int, instance: N)

移除

		fun remove(index: Int, count: Int)

表示应该删除 [current] 从 [index] 到 [index] + [count] 的子节点。

移动

    fun move(from: Int, to: Int, count: Int)

表示应该将 [current] 的 [count] 个子节点从索引 [from] 移动到索引 [to]。

索引 [to] 是相对于变更前的位置的。例如,要将位置 1 的元素移动到位置 2 之后,[from] 应该是 1,而 [to] 应该是 3

如果元素是 A B C D E,调用 move(1, 3, 1),将导致元素重新排序为 A C B D E。

这里有点难以理解,假设以数组的思路去思考移动过程:

// 1. 先把 B 置空
0 1 2 3 4
A _ C D E 
// 2. 把 B 添加 index = 3 的位置
0 1 2 3 4 5
A _ C B D E 
// 3. 移除空槽整体向前移动
0 1 2 3 4
A C B D E

[to] 这里是移动后的索引位置,原索引位置的元素的索引会变为 [to] + 1,[from] 原来的槽会后续删除,从而导致移动索引 3,但实际上从结果上看索引是 2 的效果。

但实际的实现并不是这样的,下面的 AbstractApplier 中会解释该问题。

清理数据

    fun clear()

移动到根节点并移除根节点下的所有节点,准备好当前的 [Applier] 和其根节点以便将来作为新组合的目标使用。

完整代码

@JvmDefaultWithCompatibility
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()
}

AbstractApplier

AbstractApplier 是 Applier 的基础实现。

AbstractApplier 内部创建了一个 MutableList(本质是一个 ArrayList)作为栈。

参数中的 root 保存到 current,current 作为指针指向当前待处理到节点。

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

遍历的实现

  	// 将 current 添加到栈中,然后 current 指向新节点 
    override fun down(node: T) {
        stack.add(current)
        current = node
    }

  	// 确保栈内非空,移除最后一个元素(栈顶),保存为 current
    override fun up() {
        check(stack.isNotEmpty()) { "empty stack" }
        current = stack.removeAt(stack.size - 1)
    }

自上而下:将 current (也就是 root)入栈,然后 current 更新为新节点(逐渐从上向下添加元素)。

自下而上:检查栈非空,移除最后一个元素,并将其保存到 current(逐渐从下向上移除元素)。

插入的实现

AbstractApplier 没有实现默认的插入逻辑,需要它的子类去实现。

移除元素的实现

   	// 从栈中移除指定位置的数据,或一串数据
    protected fun MutableList<T>.remove(index: Int, count: Int) {
        if (count == 1) {
            removeAt(index)
        } else {
            subList(index, index + count).clear()
        }
    }

根据要移除的数量分为一个和多个的情况,本质上是对 MutableList 的指定索引或子列表的移除操作。

移动的实现

  	// **计算目标位置**:
    protected fun MutableList<T>.move(from: Int, to: Int, count: Int) {
        val dest = if (from > to) to else to - count
        if (count == 1) {
            if (from == to + 1 || from == to - 1) {
                // Adjacent elements, perform swap to avoid backing array manipulations.
                val fromEl = get(from)
                val toEl = set(to, fromEl)
                set(from, toEl)
            } else {
                val fromEl = removeAt(from)
                add(dest, fromEl)
            }
        } else {
            val subView = subList(from, from + count)
            val subCopy = subView.toMutableList()
            subView.clear()
            addAll(dest, subCopy)
        }
    }

首先取出 dest,移动距离,如果 from 大于 to,也就是需要向原位置之前移动取 to;否则取 to - count。

从 dest 的取值可以看出,基于原位置无论是向前还是向后移动,都按照从前向后的顺序计算距离。

然后是根据移动的数量区分单个元素的情况和多个元素的情况:

  • 单个元素:

    • from 如果和 to 一前一后挨着,直接交换位置即可。

    • 否则,先移除 from 位置的元素,拿前面的例子说明,以 move(1, 3, 1)为例:

      • 首先,可以计算出 可以计算出 dest = 2;

      • 然后,移除元素:

        0 1 2 3 4
        A B C D E
        
        0 1 2 3
        A C D E
        
      • 然后添加元素 add(dest, fromEl),这里插入位置是 dest,dest = 2,所以移动后到位置 :

        0 1 2 3 4
        A B C D E
        
        0 1 2 3 4
        A C B D E
        

      为什么插入位置上 dest 而不是 to 呢?add(dest, fromEl) 使用 dest 而不是 to 是因为 dest 计算的是目标插入位置在删除元素后的正确索引位置。

      • from > to :目标位置在当前元素之前。插入位置就是 to,即 dest = to。
      • from <= to :目标位置在当前元素之后。插入位置需要减去要移动的元素数量,来调整因为元素被删除导致的索引变化,dest = to - count
  • 多个元素:

    • 多个元素取子列表;

    • 然后 copy 一份转换为 MutableList;

    • 从原 MutableList 中删除子列表位置的数据;

    • 最后整体添加到 MutableList 到 dest 位置后面。

    • 举例说明, 以 move(1, 3, 2)为例:

      0 1 2 3 4
      A B C D E
      
      // 取子列表
      0 1
      B C
      
      // 从原列表中删除原子列表数据
      0 1 2
      A D E
      
      // 添加到新位置 dest = 2
      0 1 2 3 4 
      A D B C E
      

清理数据的实现

  	// 清空栈,重置当前节点为 root,调用实现的清理逻辑
    final override fun clear() {
        stack.clear()
        current = root
        onClear()
    }

    protected abstract fun onClear()

完整代码

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

    override fun down(node: T) {
        stack.add(current)
        current = node
    }

    override fun up() {
        check(stack.isNotEmpty()) { "empty stack" }
        current = stack.removeAt(stack.size - 1)
    }

    final override fun clear() {
        stack.clear()
        current = root
        onClear()
    }

    protected abstract fun onClear()

    protected fun MutableList<T>.remove(index: Int, count: Int) {
        if (count == 1) {
            removeAt(index)
        } else {
            subList(index, index + count).clear()
        }
    }
		
    protected fun MutableList<T>.move(from: Int, to: Int, count: Int) {
        val dest = if (from > to) to else to - count
        if (count == 1) {
            if (from == to + 1 || from == to - 1) {
                // Adjacent elements, perform swap to avoid backing array manipulations.
                val fromEl = get(from)
                val toEl = set(to, fromEl)
                set(from, toEl)
            } else {
                val fromEl = removeAt(from)
                add(dest, fromEl)
            }
        } else {
            val subView = subList(from, from + count)
            val subCopy = subView.toMutableList()
            subView.clear()
            addAll(dest, subCopy)
        }
    }
}

UiApplier

UiApplier 是 Jetpack Compose 中用于应用 UI 树操作的组件。它的作用是将界面上的更改应用到实际的 UI 元素树中。

internal class UiApplier(
    root: LayoutNode
) : AbstractApplier<LayoutNode>(root) {

    override fun insertTopDown(index: Int, instance: LayoutNode) {
        // Ignored. Insert is performed in [insertBottomUp] to build the tree bottom-up to avoid
        // duplicate notification when the child nodes enter the tree.
    }

    override fun insertBottomUp(index: Int, instance: LayoutNode) {
        current.insertAt(index, instance)
    }

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

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

    override fun onClear() {
        root.removeAll()
    }

    override fun onEndChanges() {
        super.onEndChanges()
        root.owner?.onEndApplyChanges()
    }
}

UiApplier 实现 AbstractApplier,通过其实现,可以看出 UiApplier 没有采用自上而下的插入方式,而是采用自下而上的方式,其主要目的是为了避免新的子节点进入树时的重复通知。

current 的类型是 LayoutNode, insertBottomUp 本质逻辑在 LayoutNode 的 insertAt 方法中,其他的包括 remove、move 等操作都是直接通过 LayoutNode 的调用来完成。

最后是在树操作完成后有一个通知,onEndApplyChanges。

总结

Applier 是对树操作的抽象定义,包括了遍历、插入、移动、删除等操作的定义,其中遍历和插入分为自上而下和自下而上两种形式。

AbstractApplier 是 Applier 基础实现,主要是提供了默认的自上而下、自下而上的遍历和 move 操作的实现。

UiApplier 则是 Compose 对 UI 树操作的实现,明确了采取自下而上的插入逻辑,并在变更完成后提供通知,核心的逻辑在 LayoutNode 中。

下一篇继续分析 LayoutNode。