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信息的控件树,比如下图:
在这里我们拿json数据给举例子,想表达一个思想就是,作为UI框架的开发者,我们必须抽象出中间层才能更加方便后续的扩展
然而,在App运行期间,我们避免不了要对一些UI进行删减或者增加,比如产品经理要求我们在特定的情况下需要把View2进行隐藏,这个时候我们需要实现的步骤就是移除View2
但是怎么移除呢?我们不能直接操纵UI的本身,因此我们就只能够重新生成一份json数据,把View2相关的数据结构删除
有了新的数据后,我们就可以根据新的数据去重新生成UI树
然而,事情却并没有我们想象中点简单,如果我们每次都需要重新生成UI树,那么必定会存在大量的UI树创建时间浪费,比如View1本身是没有任何修改的,但是因为我们改动了中间描述文件(这里我们拿json举例),因此我们就只能重新去生成整棵树。
于是聪明的程序猿就想到了一个策略,我们为啥每次都要生成控件呀,我们在其中比对找出不同的地方删除或者增加不就可以了,于是,一个名为diff策略的词汇就应运而生了。然而,实现diff策略却并没有想象中的那么简单,我们需要根据某些策略来决定控件是否重新生成还是复用,这其实是一件脑壳疼的事情。于是乎,在跨平台方案中,各种各样的diff策略就提出来了,这些策略都跟框架内部的实现机制有关。
Compose同样也遇到了这个问题,而它的“diff策略”中关键的一环,就是被称为SlotTable的系统,这里我们并不探究SlotTable的实现,我们来看一下它的内核“GapBuffer”
我们再回到上面的场景,这次我们简单一点,我们假设UI页面容器中,刚好有四个View,因为都是同级别的关系,我们其实就可以把这四个View理解为数组中的四个元素即可(我们上述的subview其实也是一个Array)
我们可以把UI控件树的修改行为分为两大类,一类是新增,另一类是删减
在UI的世界中,我们通常只会修改其中一个或者少数几个的控件,比如我们新增一个节点View5,因为View5在中间创建,因此会导致之后的View3与View4也发生重建
这其实是一件让人很苦恼的事情,明明我没想改View3跟View4,就因为插入了一个新节点就导致了后续节点的重新创建,如果View3跟View4还有子节点的话,那么还会导致其子节点也得重建,对于UI系统来说,这其实会造成一定的性能损耗。于是乎,我们有没有办法能让View3跟View4不收到影响呢?聪明的Compose程序员就想到了GapBuffer这种数据结构,如果没了解过GapBuffer也没关系,咱们继续看就行
回到刚刚的场景,我们如果想到插入一个View5,这个时候我们先不着急插入,我们可以先在整个容器中的尾部再分配两个元素吧,这里我们就把它称为Gap,Gap中并不包含真实的UI元素,它只是一个占位符,这里我们假设就创建两个吧,此时呢我们会经历以下几步
step1:我们把创建好的Gap 们移动到View5将要插入的位置,如图Step1所示
step2:我们把View5放到第一个空闲的Gap,也就是图中第三个位置,因为Gap只是一个占位的东西嘛,所以我们可以随便填充需要的数据进去
这个时候我们惊讶的发现,通过这种方式,我们插入一个新的结点时,View3跟View4还是保持着原本的数据,这也就意味着其并不需要重建,不错不错!
那么我们想要删除一个元素怎么办,比如我想把View1删掉,在不引入任何算法时,View1删掉时也就导致了View2、View3、View4重新创建
这样可不行呀~我就删除一个元素而已!于是乎,我们还是按照老样子,我们能够用Gap去解决呢?同样的,我们还是老样子在整个容器Array背后再创建两个Gap,此时我们按照以下的步骤进行
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树
我们通过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树的构建过程,其中的心得与实践如果有机会的话也会再给大家分享~