大规模重构——重写 Instagram Feed 的经验之谈

3,281 阅读15分钟
原文链接: academy.realm.io

在 Instagram 团队重写他们全新的 iOS Feed 的过程中,他们积累了大量的经验,遇到的坑无疑已经超出了他们的预料,比如说集合视图、差异化 (Diffing) 以及冗长代码所带来的危险之处。在本次 try! Swift 讲演之中,Ryan Nystrom 向我们分享了如何才能进行一次成功的重构,并且向我们介绍了 Instagram 的一个很赞的开源组件:IGListKit。


概述 (0:00)

大家好,我的名字是 Ryan Nystrom,是 Instagram 纽约的一名工程师。我们在基础框架上面做了很多很酷的工作。在过去的一年里,我一直在努力重构 Instagram 的 Feed 模块。这个过程非常有趣,从中我们获取到了很多经验。每当我去参加会议的时候,我都非常喜欢去听其他行业的经验分享,因为我觉得其他公司的组织架构和运作形式是非常有趣的。

因此,我想和大家分享一下,我们是如何重构 Instagram Feed 这个模块的。

技术债务 (1:29)

我们为什么要重写 Feed 呢?答案很简单:因为技术债务 (Technical Debt)

Instagram 已经 6 岁半了,但是底层代码仍然一成不变。如果您去搜索 git 历史并进行归类的话,就会发现有很多 Instagram 的初始代码提交。此外还有很多手动内存管理时代的东西,代码杂乱无序,简直是一团糟。这使得我们要做一些新的事情就比较捉襟见肘。

最初是怎么实现的呢? (2:05)

最开始,我们使用了集合视图。当我们在查看 Instagram 上的一个 Post 的时候,我们会看到一大个 Section,我们可以将它们分解成多个小单元格。分解的结果如下:

nystrom feed item split

可以看到,顶部有一个补充视图 (supplementary view),中间是视图单元格,然后是包含操作项的单元格,最后就是底部的那些文本单元格了。这是完全由一个名为 “Feed Item” 的数据模型驱动的。

这样我们便使用 FeedItem 来决定有多少条评论、是否要显示图片、是否要播放视频、用户名是什么等各种问题。我们整个应用是完全基于这个 FeedItem 数据模型来构建的,而这个数据模型就需要包含有图像、视频、评论等各种信息。当有人过来说:「我们想向这个 Feed 中增加一个这玩意儿」,但是我们只能遗憾地告诉他们「不行,我们没法做,因为它不在 FeedItem 这个模型里面」。

nystrom new feed item

这是一个单元格,是一位设计师设计的,并且还有一名产品经理一直在跟进这个设计,但是它里面的数据实际上是一组用户,而不是评论,因此我们毫无办法。我觉得对我们自己的团队说「做不到」是很不好的一件事。

胡乱堆砌 (3:41)

Instagram 是 2010 年的时候发布的,那个时候里面的数据还只有图片。随着时间的推移,我们便想要增加诸如视频、用户以及其他类型的数据模型。我很确定那个时候我们只是想着「改一点点就好了」,而不是选择重构,我们选择了错误的做法……因此我们只是胡乱堆砌,有功能就往上加。

好吧,这就是我们的做法。我们没有创建一系列独立的小模型,而是创建了一个臃肿模型。解析这个模型变得越来越复杂,并且极大地拖慢了我们的速度。要记住这个单元格是映射为由 FeedItem 驱动的。如果您看一下 Instagram 当中的 Feed,实际上您看到不仅仅只是一个简单的 Post,这里面包含了大量的数据信息。

我们视图控制器的任务就是获取对应的数据模型,然后将其放到 Section 当中,然后配置这些单元格(视图控制器的数目有很多个)。对于集合视图而言还有一个单独的视图控制器,继承自执行网络任务的视图控制器,而这个网络任务视图控制器继承自执行通用 Feed 的视图控制器,此外对于应用的主界面来说,同样也还有一个视图控制器。这使得这类视图控制器有四层的纵深。添加新单元格变得无比困难。

您可能会想「代码复杂了一点,可能有些时候会减慢开发速度,那么这种做法糟糕吗?」

没错,非常糟糕!技术债务开始拖后腿了。因此我们决定要严肃对待这个问题,因此我们在三个月前上线了新版本的 Feed。

Feed 2.0 (6:35)

我们的主要目标之一是解决视图控制器层叠继承的问题,我们想让 Feed 更加轻巧、简单,并且还能够让开发人员使用不同的单元格和数据模型。我们希望能够摆脱 Feed Item 这个方法,这个数据类型是完全不可控的。

差异化 (7:10)

我们首先想到了差异化操作,这个概念是创建一个包含一系列模型的数组。当我们对这个数组进行删除、插入或者移动操作的时候,就发生了值的更新。在构建基础框架的时候,差异化操作是非常有用的,但是要用在集合视图当中就非常困难。

首先,我们必须要删除旧数组当中的内容,然后重新加载数据,才能将数组当中的内容放置到合适的位置,接着基于最后的索引来执行插入操作。要做好这个操作需要一点点数学知识。

差异化操作最原始实现的时间复杂度是 O(n²)。当操作过多的话,就会发现速度被大大减缓了。我看过的大多数实现方式都会前往后台队列当中,执行一些数学运算,然后再回到前台并继续操作,但是即便这样速度仍然不够快。因为他们是用低优先级队列来解决复杂问题,那么为什么不直接在主线程上执行呢?

我们去搜索了一下解决方案,然后发现一篇撰于 1978 年的论文,作者是 Paul Heckel。这篇文章使用一个名为最小公共子序列 (least common subsequence) 的东西,使得这个问题能够在线性时间内得以解决。

基于这篇论文,我们编写了一个算法,从而能够在线性时间内找到两个数据集之间所有需要删除的内容,然后重新加载,然后执行插入和移动。这使得我们可以在主队列当中执行这个操作,这样我们便可以在集合视图上执行所有的这些更新;这对我们而言提供了一个更为简单的模型。此外,我们还想出了集合视图才能更好地配合这个算法进行工作,这个过程中耗费的时间是大家无法想象的。

执行更新 (9:35)

让我们回到视图控制器来,这时候我们已经去除了很多的内容了,我们将这些内容改写为共享对象、使用系统库等等来规避掉了。这样就不必继承这么多视图控制器了。网络归网络,诸如分析之类的主 Feed 可以变为一个共享对象。但是我们仍然还是要对 Feed 进行一些处理。

这里用到了一个我们称之为「世界」的概念,视图控制器知晓项目数组的全部信息,知道这些项目该如何添加到 Section 当中,知道这些 Section 该如何配置,知道单元格该如何填充。它将处理用户交互、日志记录、显示事件等一系列操作。

项目控制器 (10:28)

在我们创建的新基础框架当中,我们决定将这些任务进行分解。我们创建了一个名为「项目控制器 (Item Controller)」的抽象概念。实际上它是专门实现 Section 的一个小型视图控制器。

在这里,我们决定项目的数量、对单元格进行配置、返回单元格尺寸,以及处理用户交互。但最为重要的是,这里存放了所有的业务逻辑。我们也没有用什么黑科技,它就是一个集合视图,但是通过这样的分解方式,使得我们可以向集合视图当中添加任意一种类型的对象

而我们所要做的就是创建一个新的项目控制器,它会自行处理所有的逻辑。

我们此前觉得这简直是异想天开,但是我们还是将其实现了。整个团队为之欢欣鼓舞,我们将这个架构变成了我们的基础框架。

我们能给大家回馈什么? (11:26)

当我们构建完框架之后,我们意识到我们已经解决了个大问题,因此我们扪心自问,我们能给社区回馈什么呢?我们想要大家摆脱这个问题的困扰。

IGListKit (12:48)

我们将开源一个全新的框架:IGListKit(发布时间待定),这个框架会帮助您实现我上面所述的那些内容。

我们所有的示例应用和文档是完全用 Swift 编写的,此外还使用了 Objective-C 可空性、完善的注释以及泛型。这是完全适配 Swift 的,C++ 被完全掩盖住了,您将不会看到一丁点 C++ 的内容。

IGItemController (13:34)

这个框架当中最为重要的一个类就是 IGItemController 了。这就是我在一开始所提到的「项目控制器」。这里面的代码不是很多。它默认只是处理���一个带有文本标签的单元格,仅此而已。

要让这个类派上用场的话,我们需要创建一个新的项目控制器,然后让其实现 IGListItemType 协议。

class LabelItemController: IGListItemController, IGListItemType {
    ...
}

在编译时,这个协议可以确保您实现了所有必需的方法,比如说返回项目的总数:

func numberOfItems() -> UInt {
    return 1
}

在 Instagram 的主 Feed 当中,我们存放了一个包含图片、评论和动作条的动态数组。我们这里将会返回这个数组的大小。同时也要注意到,我们还有一个上下文对象 (context object):

func sizeForItemAtIndex(index: Int) -> CGSize {
    return CGSize(width: collectionContext!.containerSize.width, height: 55)
}

这样可以将这个单元格设置为屏幕的宽度,或者设置为其父容器的宽度,并且设置其高度为 55 个点。接下来就是一个全新的概念了,这与传统的集合视图截然不同。我们需要实现这个 didUpdateToItem 方法:

var item: String?

func didUpdateToItem(item: AnyObject) {
    self.item = item as? String
}

在这里,基础框架将会向您的项目控制器传递所需的模型。通过使用映射,我们可以将所有的模型与项目控制器建立映射关系。在这种情况下,我们得到了一个项目之后,我们可以选择将其转换为字符串,然后存储到一个实例变量当中。接下来我们就可以读取这个实例变量,然后在对应索引的单元格项目当中,对这个单元格进行重用,然后设置标签上的文本,然后再将这个单元格返回,这样就和集合视图的数据源类似了。

我们已经移除了重用标识符的概念,并且我们还完全消除了注册单元格的需求和提供补充视图的需求。因此,这就是这个控制器所做的工作了。那么我们该如何去使用这个项目控制器呢?

IGListAdapter (15:42)

这里我们推出了 IGListAdapter 的概念:

//MARK: IgListAdapterDataSource

func itemsForListAdapter(listAdapter: IGListAdapter) -> [IGListDiffable] {
    return [
        "Foo",
        "Bar",
        "Baz"
    ]
}

func listAdapter(listAdapter: IGListAdapter, itemControllerForItem item: AnyObject) -> IGListItemController {
    return LabelItemController()
}

它将会把相应的数据、所有的项目控制器以及您的集合视图糅合在一起,让它们能够共同协作。为了要使用这个功能,我们需要连接数据源。

在第一个方法当中,我们仅仅只是返回了一个数组。现在它们当中的值全都是字符串。这里实际上可以是任何值。注意到返回类型实际上是 IGListDiffable。我们已经为这个协议提供了标准的实现,因此大家无需去关注太多内容。不过,您仍然可以重写并扩展这个协议,以便实现自己的处理操作,从而实现更灵活的差异化操作。然后还有另外一个方法,对于指定的项目,我们可以返回对应的项目控制器。这里我们只返回了相同的基础项目控制器,这里面只有一个标签。

假设当我们在等待一个网络请求返回数据的时候,我们想要添加一个指示器。这样我们可以创建一个令牌对象(这里只是一个名为 spinToken 的 NSObject 对象),我们可以将其放到数组的中间位置。由于这里是一个协议,因此我们可以放上我们所期望的任何类型的模型对象。接下来,当框架让我们提供项目控制器的时候,我们需要检查「这个项目是否是 spinToken?」如果是的话,我们就返回这个新的 SpinnerItemController。否则就保持原样:

func listAdapter(listAdapter: IGListAdapter, 
            itemControllerForItem item: AnyObject) -> IGListItemController {
    if item === spinToken {
        return SpinnerItemController()
    } else {
        return LabelItemController()
    }
}

这样便会在我们的单元格中间显示一个指示器:

nystrom spinner

这看起来可能不是很 exciting,但是我其实对我们搞的这个大新闻很是骄傲。试想假设我们提供了一个 UISearchBar。当用户输入了文本之后,我们就可以实时更新搜索结果了:

func searchBar(searchBar: UISearchBar, textDidChange searchText: String) {
    filterString = text
    adapter.performUpdatesAnimated(true, completion: nil)
}

因此在这个 searchBar 委托方法当中,我们向实例变量中存放了用户搜索的文本,然后调用 performUpdatesAnimated 方法。这将告知框架去获取新的项目,然后执行差异化操作,更新集合视图。

我们同样可以对数组进行匹配:

let words = ["Foo", "Bar", "Baz"]

func itemsForListAdapter(listAdapter: IGListAdapter) -> [IGListDiffable] {
    return words.filter { word in
        return word.containsString(filterString)
    }
}

我们对字符串数组进行匹配操作,然后将匹配的结果返回。这个方法是一个数据源方法,因为只有当您通知框架进行更新的时候这个方法才会被调用。它会自动执行插入、删除、更新等所有在集合视图上发生的操作。我并没有为这个集合视图撰写任何一条代码;我只是配置了下单元格、项目控制器,然后通知适配器 (adapter) 进行更新就行了。所有的动画和更新操作都在框架内部执行了。

为什么要使用 IGListKit 呢? (19:15)

假设我有一个很简单的应用,其中有一个很简单的表视图,我调用了它的 reloadData 方法。使用 IGListKit 有什么好处呢?其实,我建议当您遇到像 Feed 一样,视图当中有多种数据类型的时候使用 IGListKit 会更好。如果您的 Feed 非常复杂,并且也讨厌去处理那些恼人的整数枚举的话(我就是这样的人),那么 IGListKit 正是您的选择。

如果您希望有一个快速、不会发生崩溃、同时拥有更新动画的 Feed 的话,那么您也可以选择 IGListKit。这同样也会鼓励您撰写可重用的功能组件,将您的单元格和项目控制器从视图控制器当中分离。

我可以在某个地方编写一个项目控制器,然后在其他视图控制器当中使用,因为它们不需要去考虑其父容器的情况。同样我也很高兴,我不用再去调用那些烦人的 performBatchUpdates 或者 reloadData 方法了。

您可能会想「Instagram 编写了这个框架,那么我该不该去使用它呢?」在应用发布的 15 分钟后,我们在全球范围内处理了 3,900 万次差异化操作,而这些操作没有一例发生了崩溃,并且这些操作都是在主线程进行的,也没有卡顿的现象发生。

该在何处使用它呢? (21:13)

这整个项目源于我们期望重写我们的 Feed。现在我们的「Explore」页面、「Activity Feed」,甚至通信模块当中的那些复杂单元格和交互也使用了 IGListKit。我们一个月前发布的 Instagram Stories 这个产品,也使用了这个框架,并且是完完全全使用 IGListKit 构建的。我们当然非常赞同大家来使用这个框架。因为这正是我们未来应用也要使用的。

IGListKit 即将到来

我在这儿期望能和大家分享一些 Instagram 中工作的一些故事,我希望大家能从中学习到一些知识,并且将这些知识应用到您所在组织的应用当中。我真的很高兴看到大家使用 IGListKit 构建自己的应用!