跨平台框架开发者视角上看GapBuffer

583 阅读13分钟

GapBuffer这个数据结构可以被运用在很多场景中,今天我们以一个跨平台框架开发者的视角来看看,这个东西究竟有什么魔力~

在跨平台框架或者结构良好的UI框架来说,很多情况下我们并不能很好的直接操控UI的控件树,通常情况下我们只能操作其内部的一些数据结构来驱动UI的显示。这一点体现在了很多方面,比如Flutter的三棵树、Compose的SlotTable、RN的Node树等等,因为我们通常需要一些特定的描述去驱动UI的生成。在这个过程当中,如果描述设计的不好,很容易造成控件的频繁创建与刷新,导致性能下降

因此今天我们来聊一下Compose中用到的一个数据结构,GapBuffer,我们来看它具体怎么做以及解决了什么问题。

从跨平台框架开发者视角上看GapBuffer

在UI框架的设计中,我们通常并不直接操作UI视图本身,而是采取一些中间结构的方式来描述UI,比如我们可以通过json数据来描述一颗UI树

下面我们以UI框架的开发者的视角下展开整个话题,比如这里我们定义一个View类型,其中有两个属性,viewId代表着其唯一的表述,subView代表着它是否有子控件等等,有了这个数据结构,我们就可以把UI本身进行一步抽象,通过这个数据结构我们就可以描述一个复杂的UI结构,至于其实现,可以是Andorid中的View/ViewGroup,iOS中的UIView,鸿蒙中的Component等等

data class View(val viewId: String,val subView:Array<View>){
     ... 
}
{
    "viewid" : "container" ,
    "subView" : [
        {
            "viewid" : "View1" ,
            "subView" : [
                {
                    "viewid" : "View1-1" ,
                    "subView" : [

                    ]
                },
                {
                    "viewid" : "View1-2" ,
                    "subView" : [

                    ]
                }
            ]
        },
        {
            "viewid" : "View2" ,
            "subView" : [
                ...
            ]
        },
        {
            "viewid" : "View3" ,
            "subView" : [
                ...
            ]
        },
        {
            "viewid" : "View4" ,
            "subView" : [
              ...
            ]
        }
    ]
}

上述的json数据,我们其实就可以转化为真实描述UI信息的控件树,比如下图:

image.png

在这里我们拿json数据给举例子,想表达一个思想就是,作为UI框架的开发者,我们必须抽象出中间层才能更加方便后续的扩展

然而,在App运行期间,我们避免不了要对一些UI进行删减或者增加,比如产品经理要求我们在特定的情况下需要把View2进行隐藏,这个时候我们需要实现的步骤就是移除View2

image.png

但是怎么移除呢?我们不能直接操纵UI的本身,因此我们就只能够重新生成一份json数据,把View2相关的数据结构删除

image.png

有了新的数据后,我们就可以根据新的数据去重新生成UI树

然而,事情却并没有我们想象中点简单,如果我们每次都需要重新生成UI树,那么必定会存在大量的UI树创建时间浪费,比如View1本身是没有任何修改的,但是因为我们改动了中间描述文件(这里我们拿json举例),因此我们就只能重新去生成整棵树。

于是聪明的程序猿就想到了一个策略,我们为啥每次都要生成控件呀,我们在其中比对找出不同的地方删除或者增加不就可以了,于是,一个名为diff策略的词汇就应运而生了。然而,实现diff策略却并没有想象中的那么简单,我们需要根据某些策略来决定控件是否重新生成还是复用,这其实是一件脑壳疼的事情。于是乎,在跨平台方案中,各种各样的diff策略就提出来了,这些策略都跟框架内部的实现机制有关。

Compose同样也遇到了这个问题,而它的“diff策略”中关键的一环,就是被称为SlotTable的系统,这里我们并不探究SlotTable的实现,我们来看一下它的内核“GapBuffer”

我们再回到上面的场景,这次我们简单一点,我们假设UI页面容器中,刚好有四个View,因为都是同级别的关系,我们其实就可以把这四个View理解为数组中的四个元素即可(我们上述的subview其实也是一个Array)

image.png

我们可以把UI控件树的修改行为分为两大类,一类是新增,另一类是删减

在UI的世界中,我们通常只会修改其中一个或者少数几个的控件,比如我们新增一个节点View5,因为View5在中间创建,因此会导致之后的View3与View4也发生重建

image.png

这其实是一件让人很苦恼的事情,明明我没想改View3跟View4,就因为插入了一个新节点就导致了后续节点的重新创建,如果View3跟View4还有子节点的话,那么还会导致其子节点也得重建,对于UI系统来说,这其实会造成一定的性能损耗。于是乎,我们有没有办法能让View3跟View4不收到影响呢?聪明的Compose程序员就想到了GapBuffer这种数据结构,如果没了解过GapBuffer也没关系,咱们继续看就行

回到刚刚的场景,我们如果想到插入一个View5,这个时候我们先不着急插入,我们可以先在整个容器中的尾部再分配两个元素吧,这里我们就把它称为Gap,Gap中并不包含真实的UI元素,它只是一个占位符,这里我们假设就创建两个吧,此时呢我们会经历以下几步

image.png

step1:我们把创建好的Gap 们移动到View5将要插入的位置,如图Step1所示

step2:我们把View5放到第一个空闲的Gap,也就是图中第三个位置,因为Gap只是一个占位的东西嘛,所以我们可以随便填充需要的数据进去

这个时候我们惊讶的发现,通过这种方式,我们插入一个新的结点时,View3跟View4还是保持着原本的数据,这也就意味着其并不需要重建,不错不错!

那么我们想要删除一个元素怎么办,比如我想把View1删掉,在不引入任何算法时,View1删掉时也就导致了View2、View3、View4重新创建

image.png

这样可不行呀~我就删除一个元素而已!于是乎,我们还是按照老样子,我们能够用Gap去解决呢?同样的,我们还是老样子在整个容器Array背后再创建两个Gap,此时我们按照以下的步骤进行

image.png

step1:因为View1即将被删除,因此我们把Gap们都移动到View1的位置,此时就如上图所示,左边的Gap坐标跟View1是同一个位置

step2:此时,我们直接把View1 (吧唧一下)干掉,让Gap代替原本View1的位置,同时呢我们把View1之后的元素都在Gap们背后续上

此时我们也惊讶的发现,View2,View3,View4也不用再重新创建了,挺好挺好。

后续我们再遇到节点的新增或者删除,我们也只需要再次移动Gap,重复上面两个方法步骤即可,当然如果Gap已经用完了,我们也可以重新在尾部生成Gap,因为这样生成的效率更快,毕竟数组尾部插入复杂度是o(1)

实战GapBuffer 数据类

了解了GapBuffer的核心原理后,我们来到实战环节,因为View不太直接的展示结果,因此我们这里简化一下把View当成一个Char就好了,方便我们看最终的结果展示。当然GapBuffer在文本编辑当中其实用的很多,这里我们正好可以体验一下处理文本的魅力哈哈哈哈,后续大家有需要对接到自己项目即可

声明数据类

首先呢,我们创建一个数据类,因为子元素都被放在一个数组里面,因此我们可以用一个数组来承接整个Char的存储,当然我们也可以一开始把整个空数据就填充为Gap,这里我们使用gapStart索引来标识Gap的起点,我们前面也说过,Gap其实是一个占位符,我们就拿null当作Gap的体现即可

class GapBuffer() {
    // 一开始没有任何gap填充// 
    
    // 使用Array来存储元素,初始化为包含默认值null的数组,大小为0
    private var buffer: Array<Char?>

    // 间隙的起始索引
    private var gapStart: Int = 0

    // 当前gap 为0 ,初始化时不创建任何gap
    private var gapSize = 8

    // 间隙的结束索引
    private var gapEnd: Int = 0


    init {
        buffer = Array<Char?>(this.gapSize) {
null
        }
gapStart = 0
        gapEnd = this.gapSize
    }

移动Gap

无论是新增还是删除元素,其中都会涉及到Gap的移动,相信大家可以从上文中了解到,对于Gap们的移动其实可以这样理解,我们把容器数据buffer分成了两部分,一部分是存储着实际数据的子数组,一部分是为null的代表着Gap的子数组

moveGap 方法中需要接受一个参数index,代表着我们想把gap移动到哪个位置,当然这里也分为两种情况,分别需要处理一下gap如何移动,当然我们得注意一下不要产生数组的越界,这里我们需要计算好gap的偏移

fun moveGap(index: Int) {
    if (index < this.gapStart) {
        // 计算gap偏移 
        val delta = this.gapStart - index
        for (i in delta - 1 downTo 0) {
            this.buffer[this.gapEnd - delta + i] = this.buffer[index + i]
        }
        this.gapStart -= delta
        this.gapEnd -= delta
    } else if (index > this.gapStart) {
        // 计算gap偏移 
        val delta = index - this.gapStart
        for (i in 0 until delta) {
            this.buffer[this.gapStart + i] = this.buffer[this.gapEnd + i]
        }
        this.gapStart += delta
        this.gapEnd += delta
    }
}

插入数据

插入数据对应着上面的讲述,分为两步,首先我们需要把Gap移动到相应的位置,然后再进行数据的插入,在这个过程中我们很有可能会遇到Gap已经用完的情况,因此如果遇到了Gap不足我们就需要对数组进行扩容,否则我们正常移动Gap们后在第一个可用的Gap插入数据即可


fun insert(index: Int, value: Char) {
    if (index < 0) {
        throw RuntimeException("insert index must be >= 0")
    }
    if (index > length()) {
        throw RuntimeException("insert index must be <= length")
    }

    // 需要扩容,Gap用完了
    if (this.gapStart == this.gapEnd) {
        val expendSize = this.buffer.size + this.gapSize
        val newBuffer: Array<Char?> = arrayOfNulls(expendSize)

        System.arraycopy(this.buffer, 0, newBuffer, 0, index)

        System.arraycopy(this.buffer, index, newBuffer, index + this.gapSize , this.buffer.size - index)
        this.buffer = newBuffer
        this.gapStart = index
        this.gapEnd = index + this.gapSize
    }else{
        moveGap(index)
    }
    // step2插入数据
    this.buffer[this.gapStart++] = value
}

删除数据

删除数据也比较类似,为了更加直观,我们把删除方法进一步简化(删除index前一个数据,因为直接删除数据会引入两次数组移动,而删除前一个数据只需要移动gap数组即可),方便大家理解,我们建立一个deleteBefore,删除index前面的数据,这样更加方便我们移动Gap数据,删除数据就更加简单了,我们只需要把Gap移动到要删除的数据即可,是不是很简单


fun deleteBefore(index: Int, length: Int) {
    if (index == 0 || index > length()) {
        return
    }
    moveGap(index)
    this.gapStart -= length
    if (this.gapStart < 0) {
        this.gapStart = 0
    }
}

获取数据

当然啦,从GapBuffer中获取数据的时候,也要记得剔除无用的Gap,因为Gap并不代表真实的数据。同时Gap有一个特性,它是连续的,因此我们获取index的时候,如果在gapStart与gapEnd之间那么我们还得加上一个偏移量才能得到index对应的真实数据

fun length(): Int {
    return this.buffer.count() - (this.gapEnd - this.gapStart)
}

// 从gapbuffer中获取数据
fun get(index: Int): Char? {
    var calIndex = index
    if (calIndex >= length()) {
        return null
    }
    if (calIndex >= this.gapStart) {
        calIndex += this.gapEnd - this.gapStart
    }
    return this.buffer[calIndex]
}

GapBuffer 应用

通过学习,我们了解到了一个简单的GapBuffer实现,在Compose中同样也实现了一套自己的GapBuffer ,当然,我们或许还忽略的另一个问题,回到我们上文中说到,在跨平台框架或者UI框架中会运用到diff算法等,但是很明显只有GapBuffer是不够的,因为GapBuffer的增加还是删除其实是需要外部告知的。这个怎么理解呢?

比如我们有两个UI树,origin代表当前的UI树,target代表着我们想要变化后的UI树

image.png

我们通过GapBuffer从origin中变化为target时,其实是可以有很多中间过程的,比如我们可以一直插入数据,即插入优先的方式,当插入完毕后删除无用节点,从origin变化为target。也可以采取删除优先的方式,遇到不一样的结点时就删除然后再创建,最终也能完成target树的构建,但是这样的效率其实就无法保证了,即GapBuffer并不能完成“diff”这个能力,但是GapBuffer能实现最终UI树的同步,这点需要大家注意。

因此在Compose中,是在编译时根据控件的特性进行UI树的驱动重建,我们说到驱动GapBuffer改变可以是新增或者删除。Compose当中提供了startXXXGroup方法,用于特定的场景下进行更加高效的修改

在if 语句场景中,Compose会在编译时植入startReplaceGroup方法进行diff,如果不一致即(上次记录的可以跟本次的key不一样),就会通过recordDelete方法记录本次的group会被删除

  override fun startReplaceGroup(key: Int) {
        val pending = pending
        if (pending != null) {
            start(key, null, GroupKind.Group, null)
            return
        }
        validateNodeNotExpected()

        updateCompoundKeyWhenWeEnterGroup(key, rGroupIndex, null, null)

        rGroupIndex++

        val reader = reader
        if (inserting) {
            reader.beginEmpty()
            writer.startGroup(key, Composer.Empty)
            enterGroup(false, null)
            return
        }
        val slotKey = reader.groupKey
        if (slotKey == key && !reader.hasObjectKey) {
            reader.startGroup()
            enterGroup(false, null)
            return
        }

        if (!reader.isGroupEnd) {
            // Delete the group that was not expected
            val removeIndex = nodeIndex
            val startSlot = reader.currentGroup
            recordDelete()
            val nodesToRemove = reader.skipGroup()
            changeListWriter.removeNode(removeIndex, nodesToRemove)

            invalidations.removeRange(startSlot, reader.currentGroup)
        }

        // Insert the new group
        reader.beginEmpty()
        inserting = true
        providerCache = null
        ensureWriter()
        val writer = writer
        writer.beginInsert()
        val startIndex = writer.currentGroup
        writer.startGroup(key, Composer.Empty)
        insertAnchor = writer.anchor(startIndex)
        enterGroup(false, null)
    }

最终通过的是SlotWriter的removeCurrentGroup操纵GapBuffer进行删除操作。

internal fun SlotWriter.removeCurrentGroup(rememberManager: RememberManager) {
    // Notify the lifecycle manager of any observers leaving the slot table
    // The notification order should ensure that listeners are notified of leaving
    // in opposite order that they are notified of entering.

    // To ensure this order, we call `enters` as a pre-order traversal
    // of the group tree, and then call `leaves` in the inverse order.

    forAllData(currentGroup) { slotIndex, slot ->
        // even that in the documentation we claim ComposeNodeLifecycleCallback should be only
        // implemented on the nodes we do not really enforce it here as doing so will be expensive.
        if (slot is ComposeNodeLifecycleCallback) {
            val endRelativeOrder = slotsSize - slotIndex
            rememberManager.releasing(slot, endRelativeOrder, -1, -1)
        }
        if (slot is RememberObserverHolder) {
            val endRelativeSlotIndex = slotsSize - slotIndex
            withAfterAnchorInfo(slot.after) { priority, endRelativeAfter ->
                rememberManager.forgetting(slot, endRelativeSlotIndex, priority, endRelativeAfter)
            }
        }
        if (slot is RecomposeScopeImpl) {
            slot.release()
        }
    }

    removeGroup()
}

我们举个例子:假如有四个节点,如果第一个节点不一致的话,就执行删除操作,到了第二个节点大概率key是一致的(即改变其他UI的可能性较小),如果第二个节点因为第一个节点删除而改变的话,那么就重新走新增策略,即startGroup方法。

这里为什么是(删除后新增)操作而不是(新增后删除)这种策略呢,这是因为在这种场景下,Compose都认为UI是单向变动的,比如某个控件隐藏或者删除,因此保证其他控件尽可能不收到影响即可。

@Composable
fun Test(){
    ....
    if (show) {
        Text(
            text = "text"
        )
    } else {
        Row { }
}
    无论if语句如何修改,下面的Row理应保持一致
    Row {

    }
}

当然,还有其他的startXXXGroup 就不一一分析了,这里我们主要掌握如何使得GapBuffer如何更好的进行数据修改即可

总结

通过上述的学习,我们了解到GapBuffer的实现的本质以及介绍了GapBuffer的应用场景。最近开始负责了公司跨平台方案的研发,我后续也考虑将GapBuffer融入整个UI树的构建过程,其中的心得与实践如果有机会的话也会再给大家分享~