Tryswift ryan nystrom cover

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

在 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

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

Receive news and updates from Realm straight to your inbox

胡乱堆砌 (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 构建自己的应用!

About the content

This talk was delivered live in September 2016 at try! Swift NYC. The video was recorded, produced, and transcribed by Realm, and is published here with the permission of the conference organizers.

Ryan Nystrom

Ryan is a lead iOS engineer at Instagram working on app infrastructure in New York City. He is an avid open source advocate and contributor at Facebook on projects like AsyncDisplayKit. Ryan is also an author and presenter with RayWenderlich.com, publishing work on the Apple Watch, 3D Touch, and Reactive Cocoa.

4 design patterns for a RESTless mobile integration »

close