使用FlatBuffers来提升Facebook客户端在Android上的性能

原文链接:https://code.facebook.com/posts/872547912839369/improving-facebook-s-performance-on-android-with-flatbuffers/
作者:George Xie

在Facebook上,你可以通过查看家人、好友的状态、照片了解他们的近况。你之所以能够看到他们的信息,是因为我们在后端存储了所有的社交关系(关系树),让你能够连接到他们。但是在手机上,我们不可能下载整个关系树,通常我们通过下载一个节点和它的部分连接信息来存放在本地。

可能你难以明白“节点”的概念,那我们来举个例子:

  1. John创建了一个帖子,写了一些话并附了张图;
  2. John的朋友Mary很喜欢这篇文章并点了个赞;
  3. Mary的朋友Mark通过Mary的赞看到了这篇帖子,他加了个评论;
  4. Mark的朋友Steve通过Mark看到了这篇帖子,他很喜欢里面的图片,于是给图片加了个评论。

这个例子可以表示为下图的节点关系:

fb1

在Android客户端上,我们看这个帖子的时候,会将这个包括作者、赞的人、评论的人、附件信息的关系图下载到手机上(参考上图右侧数据结构)。

那么问题来了,我们要用什么手段来承载和储存这个关系图呢?将他们存在不同的数据库表中是不现实的,因为我们有各种不同的取数据需求,数据库会让我们的取数据方法变得非常局限,从而加大很多代码量。所以我们决定直接将树形结构作为JSON储存到本地,那么这就需要我们解析JSON结构并反序列化成Java对象,这些都需要时间。我们之前使用了Jackson来解析JSON,但是碰到了这些问题:

  • 解析速度慢 20KB的JSON数据(Facebook通常的返回数据量)需要解析35ms,它已经超过了UI刷新速率16.6ms。那么我们就无法在滑动时从本地缓存加载一篇帖子并且不掉帧。
  • 解析器初始化 JSON解析器在它开始工作前需要进行初始化,在Jackson上体现为100~200ms,这会拖慢App的启动速度。
  • 频繁触发GC 在解析JSON时会生成非常多的临时数据。在我们的试验中20KB的JSON解析会生成约100KB的临时数据,这无疑会让Java的GC更加频繁。

我们希望找到一个更佳的存储方案来优化我们的Android应用。

FlatBuffers

在寻找替代物的过程中,我们发现了FlatBuffers,这是一个Google的开源项目。它是Protocol Buffer的一种进化方案与实现,具有不需要反序列化就能够获取到任意子元素、保持元数据的优点。

想象我们有一个代表”人“的类Person,它应该有几个属性:名字,社交状态,妻子,朋友列表。妻子和朋友同样也是Person,用代码来表示就是:

1
2
3
4
5
6
class Person {
String name;
int friendshipStatus;
Person spouse;
List<Person>friends;
}

假设我们有一个John,他有一个妻子Mary,那在FlatBuffers里面将会像下图一样存储:

fbs2

一头雾水吧,我们来解释一下:

  • 每个对象都有三个部分:元数据(偏移表), Pivot, 数据。 其中Pivot的位置代表着元数据的位数;
  • 每个属性都对应元数据里面一位,它记录着对应数据相对于Pivot的偏移字节数。在这个例子中,元数据第一位是1,代表着”name”变量对于元数据的偏移是1,即”JOHN”(注意,它的格式遵循C的规则,若是数字、字符则直接是该位,若是字符串、数组则以NULL位结尾);
  • Pivot就是一个对象的代表,你会注意到对于Mary的偏移(12)直接指到了Mary的Pivot上;
  • 你可以通过在元数据中指定0来说明这个属性不存在(在这个例子中,很遗憾JOHN的friends为null)。

下面这段代码展示了我们如何找到John的妻子的名字:

1
2
3
4
5
6
7
8
9
10
// FlatBuffer里面最开始就是根节点
int johnPosition = FlatBufferHelper.getRootObjectPosition(flatBuffer);
int maryPosition = FlatBufferHelper.getChildObjectPosition(
flatBuffer,
johnPosition, // John节点的位置
2 /* Wife是Person的第2个元素 */);
String maryName = FlatBufferHelper.getString(
flatBuffer,
maryPosition, // Mary节点的位置
0 /* Name是Person的第0个元素 */);

你会注意到我们获取John妻子时没有任何额外的对象创建,我们可以直接将FlatBuffer数据存到文件、取到内存中,需要的话我们甚至可以只读取部分需要的内容来减缓内存压力。不需要任何的序列化、反序列化的工作, 它会极大地减少性能开销。

FlatBuffers对象的修改

在使用过程中,我们经常需要去修改数据内容。由于FlatBuffers设计的初衷是只读的,所以没有一个直接的方式去修改。我们想到了一个折中的方案,使用FlatBuffer去记录修改!

我们希望在做微小修改(如变更好友状态、点赞)时不重新下载整个数据来达到更新本地的数据的效果。由于FlatBuffer里面数据的位置是唯一的(任何一个元数据位都只代表唯一的一个元素),我们提出了一个方案,用一个可视化的例子来说明:

  • John的社交状态的元数据在整个FlatBuffer里面是第2位, 现在John成为了我们朋友(数值从2变为1),那我们只需要记录FlatBuffer里面第2位的数据变成了1;
  • Mary的名字的元数据在整个FlatBuffer里面是第13位,她将名字改为了”Marilyn”,那我们只需要记录FlatBuffer里面第13位的数据变成了”Marilyn”。

最后我们将所有对于最初的修改整合在了一个FlatBuffer里面(Mutation Buffer),它记录了两部分: 1. 修改属性的元数据在整个FlatBuffer里面的位置, 2. 对应属性修改后的数据。

fbs3

我们在取数据的地方最一层封装,获取数据时检查元数据在FlatBuffer里面的index是否在Mutation Buffer中,若检测到Mutation Buffer中含有该属性,则使用Mutation Buffer里的,否则使用元数据。

Flat Models

FlatBuffers不仅可以用在文件IO优化,而且也可以用在“网络-内存”操作中。因为解析服务器返回的JSON至UI对应变化也是有类似的情景。这促使我们提出一个更加简洁的“Flat Models”架构来适应它。

在往常的情况下,JSON作为本地存储,所以应该加一个内存缓存来应对解析压力。同时要添加应用逻辑、网络逻辑在UI层和存储层中,于是这个架构看起来像下图一样:

fbs4

虽然这个三层的架构已经在iOS和桌面应用中应用得非常广泛,但是它在Android的应用上还有一些问题:

  • 应用内存缓存意味着要在内存中使用更多的空间。在市场上很多Android设备对于单个应用的内存限制还在48MB(甚至更少)。当你内存达到限制但是还想申请空间时,就会触发GC,这意味着App性能会受到很大影响;
  • 逻辑层需要结合内存缓存、UI逻辑和文件IO处理,但是通常情况下UI操作和文件IO操作在不同线程中执行。线程的同步在大型应用中是一个令人头痛的问题;
  • UI层会接受很多数据来源:缓存、网络返回、应用逻辑的处理。这可能会导致UI过度绘制。

在我们提出的Flat Model下,UI和存储层可以轻易的整合在一起,像下图一样:

fbs5

  • UI层建立在存储层之上,二者通过ContentProvider+Cursor进行交互;
  • 逻辑层、网络层在存储层之下,它们都在后台线程进行操作,所有的数据首先在存储层进行反映,并通过ContentProvider自有的Observer机制通知UI变化;
  • 在这个架构中UI层和逻辑层被隔离开,我们可以简化代码逻辑使它们只关心自己部分的逻辑。UI层只需要反映存储层的数据变化,逻辑层、网络层只需要负责将数据更新至存储层。UI层和逻辑层运行在不同线程中,他们也不需要直接交互。

结论

FlatBuffer是一种无需序列化/反序列化的数据格式。为了适配它,我们推动了一个额外的架构(Flat Models)应用在我们的App之上。我们对FlatBuffer的可变性的改动(Mutation Buffer)使得其更具有应用价值,包括追踪服务端数据改动、简化数据模型、接口一致性。

在过去的半年中,我们将大部分Facebook的Android应用逻辑使用FlatBuffer去处理,带来的性能提升如下:

  • 平均一篇帖子的加载时间由35ms减少到了4ms
  • 临时内存分配减少了75%
  • 冷启动时间减少10-15%
  • 占用存储空间减少15%

人们在使用Facebook的时候能够更快浏览到朋友的帖子、亲人的照片,这一切都归功于FlatBuffer,谢谢你!