原文地址
人们通过FaceBook关注家人朋友的动态更新,浏览他们上传的照片。我们的后端存储了组成社交媒介的数据结构。在移动手机端,我们不能拉取整个数据结构,所以只拉取某个节点和其相关的联系构成的树状结构。
下图说明了一条带图朋友圈的工作原理。John发了一条朋友圈,之后他的朋友进行了点赞和评论,左边的图是社交图,用于后端的关系展示。当Android应用查询这条朋友圈时,就可以拉取到树状结构,包括作者信息,反馈和图片信息(右图展示)。
应用的关键在于展现和存储信息。使用SQLite数据库把全部数据持久化是不现实的,因为有很多方法从后端获取到某个节点的树状数据。其中一个可行的方法是使用Json来存储树状信息,但是UI展示数据时,需要额外的开销把Json解析成为Java对象,包括时间,我们在Android上使用Jackson Json解析器,有如下缺点:
- 解析速度:20KB的Json数据流(一个典型的FaceBook数据包)需要35ms来解析,超出了UI帧的刷新频率16.6ms。所以,即使是加载硬盘缓存数据,也无法做到顺滑如丝的滚动效果。
- 初始化解析:在解析开始前,Json解析器需要构造映射表,大概需要100~200ms,大幅降低了应用的启动速度。
- 垃圾回收:在Json解析过程中会产生很多小对象,调查中发现,解析20KB的数据流时大概分配100KB的临时内存,给垃圾回收器带来巨大的压力。
我们希望有一个更好的存储格式来提高Android应用的体验。
FlatBuffers
在可行的替代格式的探索中,我们碰到了 FlatBuffers,Google的一个开源项目。FlatBuffers是协议缓冲区的一个演化,它包括了元数据,可以在不反序列化整个对象的前提下,直接取到这个对象中的某个子数据(例如上述的树状结构)。
想象一个简单的类,有四个变量:姓名,关系,配偶,朋友列表。其中配偶和朋友也是Person
对象,所以构成了一个树状结构。下面简单地分析,如何使用FlatBuffers
来表示一个人,John,他的配偶,Mary。
class Person {
String name;
int friendshipStatus;
Person spouse;
Listfriends;
}
注意上述结构的几个方面:
- 每个对象的描述分为两部分,左中心点的元数据(虚函数表),右中心点的真正数据;
- 每个虚函数表中的格子代表一个变量,存储着真正数据的下标。例如,John的虚函数表的第一个格子有个1,指向在右中心点中,John名字的这个字节。
- 对于对象变量,虚函数表的偏移量会指向子对象的中心点。例如,John的虚函数表的第三个格子(12)指向Mary的中心点(4)。
- 在虚函数表中使用0表示没有其他数据可以展示了。
下面的代码片段展示了如何在这个数据结构中取得John的配偶信息:
// Root object position is normally stored at beginning of flatbuffer.
int johnPosition = FlatBufferHelper.getRootObjectPosition(flatBuffer);
int maryPosition = FlatBufferHelper.getChildObjectPosition(
flatBuffer,
johnPosition, // parent object position
2 /* field number for spouse field */);
String maryName = FlatBufferHelper.getString(
flatBuffer,
johnPosition, // parent object position
2 /* field number for name field */);
注意并没有涉及中间对象,节省了内存分配。更进一步,可以使用FlatBuffers
数据进行存储,直接映射到内存中。这意味着我们只需要加载我们需要的部分数据,大大减少了过度的数据加载。
更重要的是,在读取变量之前不需要反序列整个对象。这减少了UI层和数据层之间的交互时间,提高了性能。
FlatBuffers的动态更新
数据是随着时间改变的,所以要更新FlatBuffers
中存储的数据。因为FlatBuffers
是设计为不可变的,所以没有直接的方法实现动态更新。解决方案是使用原始FlatBuffers
数据进行比较。
FlatBuffer
中每一块数据拥有独一无二的绝对位置,为了不需要每次都下载整个数据块,希望实现动态更新——例如关系的变更,下面举例子说明如何进行比较的:
- John的好友状态在
FlatBuffer
中是虚函数表的第二个格子(6),为了改变好友状态,我们需要记录指向的数值现在是1(说明是朋友),而不是2(非朋友,但添加请求已发送)。
- Mary的名字("Mary")在虚函数表的第13格子,相似地,修改Mary的名字需要把第13个格子指向新的字符串。
当查询FlatBuffer
数据时,可以计算出数据的绝对位置,查看Mutation Buffer
看是否有动态更新,如果没有则返回Base Buffer
。
扁平化Models
FlatBuffer
可以作为应用的内存格式来处理数据存储和网络请求。它消除了从后端数据转化成前端UI展示的额外开销,也让我们转向于更为简洁的扁平化Models的架构,减少了UI层和数据层之间的复杂性。
使用Json作为数据格式时,为了用户体验,通常会在反序列化的时候做一些缓存,UI层和数据层之间添加了应用和网络的逻辑。
iOS和桌面应用采用这种三层架构很普遍,在Android上就有缺点了:
- 内存缓存意味着需要在内存中保存UI上的数据,很多Android设备都对应用有一个最大内存使用上限48MB或者更少。当开销增加时会触发垃圾回收机制,引起卡顿;
- 应用逻辑需要处理内存缓存,UI,存储,特别是UI和存储相关的使用多线程处理,将会是个巨大的难题;
- UI层的数据来源可能是缓存数据,网络数据,本地数据等等,当切换数据源时可能会引起UI层的过度绘制。
使用扁平化Models,UI层和数据层的交互就更为简单了,像下面图示:
- UI层位于最高一层,使用标准Android的
Cursor
,在大多数Android应用中是通过路径进行存储的,保证了及时响应。 - 应用和网络逻辑被数据层屏蔽了,在后台线程中运行逻辑处理并反映到数据层,使用标准的
Android content provider
通知,可以使UI层进行重绘。 - 更好地分离了UI层与应用逻辑层,UI层仅仅需要对数据层的变更做出反应,应用逻辑层只需要把数据写到数据层中。UI层与应用逻辑层各自在工作线程做处理,互不干扰。
结论
FlatBuffers
减少了UI层和数据层之间数据转换的花销,同样驱动了应用的架构升级。动态更新策略可以随时了解后端数据的更新,而本地状态统统存储在一个小小的数据结构中,大大减少了额外的花销。
我们用六个月时间完成了大部分Facebook的Android应用使用FlatBuffers
作为存储格式,发现:
- 加载本地缓存的时间从35ms下降到4ms;
- 临时内存分配减少大约75%;
- 冷启动时间减少10~15%
- 存储空间减少了15%
很兴奋使用一个新的数据结构,让用户在看到朋友的动态如此迅速,感谢FlatBuffers
!