作者 | Tuomas Artman
翻译 | Vincent
本文出自Uber移动架构和框架组负责人托马斯·阿特曼于2016年在湾区Swift峰会上的演讲,分享了使用Swfit重写Uber的好与坏。以下为译文:
我是托马斯·阿特曼,目前是Uber移动架构和框架组负责人。Uber现在的用户量已经达到数百万,这么大的用户量,Uber是如何用框架实现的呢?
今天我想谈谈一百多名Uber工程师是如何使用Swift编程语言的,在上周三新发布的Rider App主应用程序全部都是用Swift语言重构的。接下来我的分享主要包括三个部分:选择Swift的原因、Uber新架构;重构经验。
这是整个移动团队四年前的样子(指向屏幕显示有三名工程师的照片),就是从那时开始,他们着手搭建了我们现在这套老应用的基础。老的应用程序已经稳定使用了四年,但由于移动开发团队的指数级的增长,这套架构的缺点也逐渐显示出来,基于这套老架构想做功能开发也变得越来越困难。由于跟不同团队之间共用了很多ViewController,所以每次也需要对其它的代码进行测试。老架构真正让我们感到崩溃的主要原因是它是由两位工程师写出来的,但是目前团队已经发展到了100多人。与此同时,那套产品本身的用户量也不大。我们已经在多个城市开始运行,产品滑块底部密集的问题也显示出来了,原因就是因为所有的团队都希望在他们所在的城市能够推出新的产品。我们也想对Rider App做一套全新的用户体验界面。基于上述的这些问题,其实归纳起来也就是目前那套应用的架构问题和用户体验界面的全新设计问题。未来不再是研究老架构然后去解决问题这种形式了,而是一切都从头开始。
2015年做了很多纠正错误工作,试图去完善老的结构,但对Uber的全新设计,将会从根本上解决问题,到时会处于一个更安全的阶段,从头去重新设计也是最理想的一种解决问题的方式。
基于这两个重构原因开始了新架构的研发。最基本的需求就是满足上述两个要求,保证四条核心流程的稳定,这基本上就意味着崩溃率处于最低级别。 如果您的应用程序没有崩溃,但用户仍然停留在某些屏幕上,显然这问题很重大,这会让用户觉得不可靠。
我们当然也希望新开发的架构能够支持Uber接下来数年的发展,就像当时设计这套老架构的时候是为了满足过去这四年发展的想法是一样的。
为了实现上述的两个目标,我们选择了Swift。当时我们认为Swift是更加安全的,至少在设想里是的,然而实际生活中并没有人去验证这一点。
我们认为编译器中的类型安全性会让问题更早的暴露出来,而不是等到产品上线以后再出现问题。
而我们知道,从现在开始的这四年,Swift将会进入到一段黄金发展期,它将会成为苹果公司未来唯一一门大力推广的语言。
时间线
从今年年初开始启动的,在二月份的时候,我们当时还希望我们所做的事情是正确的,因为有一些工程师在以前的公司就花费了大量的时间去做重构的事,但最终都以失败告终。为了保证重构能成功,挑选出了几位核心工程师,让他们花了5个月的时间去研究老的架构,在这5个月的时间内,我们就只干这一件事:架构,框架,完成一些基础的工作,最终搭建了一套很完美的基础框架,所有人都是以这套基础框架为原型进行开发。
6月,架构搭建好,开始让核心流团队开始使用。核心流打算采用一种新的uberX骑行或者是uberPOOL骑行,因此我们增加了20位工程师,花了两个月的时间去审查新的架构,确保我们提出来的东西与之前构建一款新产品的要求是吻合的。事实证明,与最开始的产品要求相比,的确遗漏了一些东西,比如在视图层,一旦工程师开始进行转换或者做一些复杂的视图操作,那么我们必须调整架构以满足他们的需求。但是过了两个月,我们取得了新的进展,我们不再需要对代码库进行大量迁移,并且把平台开放给了每一个人,如果他们需要的话,也可以移交他们的功能了。
新架构叫”Riblets”,它是由Router、Interaction、Builder、Presenter、View这几个核心组件构成的,这也是VIPER框架的一种思想。我们研究了VIPER、MVVM和MVC,最终提出的方案是在VIPER基础之上增加一些我们自己创新的元素在里面。最终目标就是将每个功能模块化,并且每一个模块可以独立的进行测试。Riblet框架里的每一个核心组件都有一个协议接口,所以开发者可以把每一个单元单独拿出来,对它进行充分的测试。Riblets框架里的每一个模块都会在树里面进行管理,因此没有状态机,取而代之的是一个状态树。状态树里面的每一个节点就是一个Riblet,新架构中的核心部分是基于业务逻辑的,而不是视图逻辑,并且所有的业务逻辑都是由本地决定的。
以这张树形图里的“注册”模块为例,并不知道它的父节点是谁,但是它所需要的都已经注入进来了,是它的父节点注入了它所需要依赖的东西,可能还会有一个监听器正在监听注册流,但是监听器是不知道注册模块位于树的哪里。所以说,这些模块是完全独立的,每一个单独的模块都会做本地决策。再比如,从“App”模块开始,它仅仅只负责一个业务模块:“目前系统是否有session令牌”,这就是它监听的唯一一件事,如果App模块发现在流里面没有session,它就会把路径指向到“Welcome”处;如果它发现了有session,那么它就会跳过“Welcome”模块,直接进入到“Bootstrap”模块。
之后,树形图里面右边的每一个组件都知道系统目前是处于“已登录”状态,它们都会有一个令牌,它们都可以从独立注入中取到session令牌,它们也没必要去关心用户是否已经退出了。如果在下面的某一个节点处突然进来了一个网络电话,并且最终导致了session无效,那么App组件就会监听到,它就会通过流被调用,然后知道系统目前是没有session的状态,紧接着App组件就会中断Bootstrap树,并且最终将流指向Welcome组件。
这就可以让不同团队之间只关注自己负责的那部分业务,而没有必要说每做一步都需要去跟其他团队进行沟通交流。每个团队都可以做出自己的本地决策,并且依赖关系始终得到满足。
多个文件里面的多行代码
开发过程中会产生很多代码,每一个模块之间我们都定义了协议。有些组件会关联一个Riblit,同时又关联5个不同的文件,因此在代码库里面会有五千多个文件,同时还有五十多万行代码。此外还有一些核心组件是用的Objective-C,这也是完全没问题的。
在学习Swift的过程中,我们也得到了一些经验。
好的一面
很显然Swift是一门更好的语言,也正因为这一点,我们才有了一个很好的开端,我们几乎用到了Swift提供的所有的功能。
1. 可靠性
Swift的可靠性是它带给我们的第一件惊喜,好像是在框架研发的四个月内,我突然发现在整个研发的过程中,我的集成开发工具还有我的应用都没出现崩溃现象,即使是在调试的模式下。我问了团队里面的其它成员,他们的回答都是没有出现崩溃。而在整个开发过程中,第一次出现崩溃是我们尝试着用了一台32位的机器,最终导致在解析JSON时出现了整数溢出。那是整个开发周期中出现的第一次崩溃现象。
Swift的可靠性让我们感到非常的振奋,最终的数据显示绝对无故障率是99.99%,这已经很接近100%了。一个应用的第一次运行就几乎是没有出现崩溃,这种情况我还从来没遇到过。
必须考虑的一件事是不能允许其他人无条件对新应用进行解压,正因这样,也就不会有99.99%的绝对无故障率了,所以我们放了一个linting在里面,从而确保没有人可以在任何条件下进行解压。
你必须考虑到所有的临界情况,就好比你写了很多if,但是没有对应的else,那应用程就有可能出现异常,因此在调试阶段必须使用声明,最终上线时需要去掉,这样应用程序就很少会出现崩溃。
糟糕的一面
现在我们需要说一些糟糕的事情了,但如果你能从失败中和逆境中得到成长,这也是非常有意义的。
1. 艰难的测试
首先,如何进行测试就是一件很困难的事。Swift是一门静态语言,因此就没有办法像在Objective-C开发中那样去依靠mock测试框架进行测试。由于都是基于协议的形式进行开发,并且协议还是以我们这边为主,因此我们必须找出针对这些协议的测试方案。举个例子,这个协议是用来为实现类创建的一个接口,这个接口允许你根据一个key进行数据保存,也允许你根据这个key进行检索数据,如果你有了交互器,想对一些业务逻辑进行测试,比如当它得到某些输入值的时候,是否能够将这些值保存到硬盘当中,那你就必须得有一个实现者,也必须得有一个模拟这种存储场景的页面,有了这些东西,你才能测试哪一个方法被调用了。我们开始手动创建这些模拟,开始编写代码,最终得到不可扩展这个结论。 我们不能为多个工程师都提供支持。
我们所做的就是生成了一个小脚本,这个小脚本就是负责把大脚本给转换成小脚本。虽然它自身也有一些问题,但最终我们都解决掉了,无论你在哪个环节想生成测试内容,只需引入script/generate-mocks。它将会通过你的源码,去协议里面查找带有@CreateMock的那些声明,希望Swift在某种程度上给我们提供属性,并为你创建mocks。所以当你通过代码库运行时,这份协议最终会变成一个StoringMock,它实现了存储。它所做的就是实现协议里面所有共有的方法。如果你想知道这份协议被调用了多少次,它还提供了计数功能。它将会为你实现所有的实际的方法,无论何时它都有可能返回一个默认的类型。例如在dataForKey中,你有一个可选的NSData,而mock只返回nil,因为这是完美的。它符合接口,如果要排序测试您的输入,您也可以随时调用dataForKeyHandlers,将其设置为关闭,并且可以在测试中测试您从测试中得到正确的输入。
同样的原理,storageDataForKey返回一个StorageResult,它是枚举类型的,默认情况下会返回枚举中的第一个成员。测试工作的问题就解决了,并且还可以生成所有的mock,我大概算了一下,生成的mock大概有100,000行,这100,000行完全是自动生成的,是不需要我们再去手工敲代码的。
2. 工具问题
另一件糟糕的事情就是开发工具的问题了。我们称之为“无限索引”。我不知道为什么会出现这种情况,也许你已经遇到过,就是索引器一直在进行索引。不知道为什么,它就是无法完成索引工作。与此同时它带来的负面影响就是CPU使用率高达328%,这样笔记本就会变热,在不插入电源的情况下,笔记本大概只可以使用一个半小时。这真是一件奇怪的事,由于代码每天都在增长,这个问题也变得越来越严重。之前我们并没有遇到过这些问题,但是我们一旦超过了了200000或者300000行代码,这个问题就将会变得更加的严重。
此外,IDE开始这样做:(屏幕显示Xcode的视频,慢慢键入的字符串)。 这不是我打字慢,而是我已经输入了整个字符串,但是IDE是用SourceKit对每一个关键笔划进行检查,它可不管我写的代码是不是正确的,而且此时你也根本没办法打字。
解决方案:
如果你碰到这个问题,不妨做这样(屏幕显示删除Xcode的视频)。 您可以换成其他应用,比如AppCode,团队里有些人就是使用的AppCode。 也有人是这么做的,先在AppCode中编写代码,然后复制粘贴到Xcode进行编译,这样也不会出现问题,真是太奇怪了。当然你也可以改善Nuclide,Nuclide是Facebook的IDE,目前还不支持Swift,但需要完善才能支持。
我们的解决方案是增加更多的框架。 将整套应用程序分解成多个框架,每个框架只包含很少的文件,他这样做带来的好处就是所有的一切都变得更快了。 因为根据我们解决的经验来看,如果框架里面的东西越多,工具出现问题的概率也就越大。
最开始的时候,定义了70还是80个框架,如果想定义更多也不是什么难事。 当然了,如果你只想编写代码,不需要进行编译,那么也可以关闭索引功能,也有一部分人是这么做的。
3. 二进制文件的大小
再来说说二进制文件的大小问题。 任何一款App应用,它的大小必须控制在100M以内,如果超出了,那么就必须通过WIFI进行下载,这样就会遇到一些问题。如果你的APP应用中存在结构体,应用就会变大,如果列表中存在结构体,它们会在堆栈中被创建出来,导致应用变大。最开始的时候,我们将模型都设置成了结构体,最终编译出的二进制文件好像是80M,这不是我们所希望的。
可选的功能也会增加文件的体积,表面上这些功能你可以选择性使用,但是其实你并不知道,编译器已经在后台默默地做了很多事,编译器必须去检查这部分代码,还得去解压等等,实际上编译出来了很多东西。
泛型特化是我们遇到的另一个问题。 只要你使用了泛型,如果你希望这些泛型变快,编译器将会对它们进行特化,最终编译后的二进制文件也会变大。
Swift运行时所依赖的那些库文件也会包含到应用程序中,我们对这些库文件进行了压缩,最终实际大小只有4.5MB。
解决方案:
你可以通过优化设置解决这个问题。打开O-whole-module-optimization优化等级,有时可能会将编译文件变小,有时也会导致变大,这就需要你知道哪里的编译比较消耗时间,因此我们也做了一个工具,它会将一个单独的符号映射到一个文件,最终结合这些文件,你就可以直观的看见应用程序的文件夹结构以及每一个Swift文件的大小。
4. 启动速度
启动速度是我们开发过程中遇到的另一件棘手事情。如果你看过了苹果全球开发者大会的演讲,那么你就会得出这样一个结论 - Swift可以实现更快的启动速度,现在却出现了完全相反的情况。通常情况下二进制文件中的动态库的数量将会直接影响在pre-main中启动时间,可pre-main和post-main就是由这两个决定的。Pre-main发生在主方法调用之前,如果动态库的数量太大,花费的时间也就会更多。
比如,在一台iPhone 6s手机上面,Swift运行时的库需要花费250毫秒才能完成他们的动作,这也就意味着在这250毫秒期间,你使用Swift也没办法返回,这是一种懒汉现象。
我们发现我们所遇到的工具问题是由于创建了更多的框架引起的,你框架里的东西越多,那么你的启动速度就越慢。
解决方案
可以将所有内容重新链接到二进制文件中,这就是我们采用的方案。 我们构建了这些框架,并做了后期处理,将所有的符号从这些框架中取出,将它们链接到静态二进制文件中,这就是我们解决启动速度慢的方案。
在企业证书方面你也有可能会遇到问题。如果你的设备具有企业证书,那么APP时可能需要花费十秒钟的时间去进行初始化加载,具体得依赖于证书数量。
你可以通过重链接降低时间,当然你也可以通过做其它的一些调整来增加post-main 时间。
目前我们正在尝试使用DTrace来探测启动序列中的访问符号。由于做了重链接,所以保证它们是按照正确顺序进行,这样就防止在一些老设备当中,不需要将加载大量的页面到内存中,但是启动过程中你可以按照需求将某些页面给读到内存中。
如果你参加了昨天或者一年前的Swift峰会的话,你就能感受到了,在演示的过程中我们遇到了一件真正的麻烦事,那就是编译速度非常的慢,我们的基础应用需要花费15到20分钟才能完成clean工作。
对于这件事情,我们都很担心,因此我们去咨询了团队中的每个人:“这个问题到底有多大”,当时我们是这么问的:”根据以往编程过程中遇到的问题,整体思考一下,在优步未来发展的过程中,哪一门语言你觉得会更适合于iOS的开发?”
这是根据根据结果做的统计图,几乎是一半一半:
结果显示即使Swift有出错率、无限索引、编译速度等各种问题,但是他们还是坚持会使用Swift,另一半人则选择换回Objective-C。
因此我们又增加了另外一个问题:
“如果实现了下面哪一件事或者哪两件事,那么你就会选择Swift,甚至也改变了你对Swift的认知”。
如果仅仅是由于编译速度的问题,实际上我们是可以解决的。
编译速度优化
弄清楚原因以后,我们做的第一件事就是切换回Swift。尽量不要在代码中使用类型判断,我们研究出了一个使用SourceKit开发的脚本,这个脚本可以在后期构建所有类型,只需更改代码,使其具有所有类型信息就可以了。
最后,我们开始组合文件,我们发现将我们所有的200个模型组合成一个文件以后,可以将编译时间从1分35秒减少到只有17秒。 所以我们觉得“如果继续将其它的一切都结合成一个文件,那速度岂不是可以更快,这真是太有趣了”。 这样做的原因是因为编译器会对每个独立的文件进行类型检查,所以如果您生成了Swift编译器的200个进程,则需要检查所有其他文件的200x,因此将所有内容组合成一个文件可以使其编译的更快。
全模块的优化正是我们想要做的。 它将所有的文件都编译成了一个文件。 全模块优化问题就是优化,所以它相当慢。 但是如果添加用户定义的自定义标志SWIFT_WHOLE_MODULE_OPTIMIZATION,将其设置为yes,并将优化级别设置为none,那么,它将完成全模块的优化,而不进行优化,它会超级快。
目前我们最大的框架是基于Core Flow做的,它有900个文件,以前需要四分钟才能编译完,现在只需要23秒就可以了。只需要花23秒的时间就能够将最大的库给编译完,所以即使再也不能进行增量编译了,我也觉得无所谓了。大多数其他目标的文件少得多,速度也会更快。
Uber正在为Facebook的“Buck”做出贡献,并加入了Swift的支持
如果整个模块的CPU使用率已经优化到30%以下,那么你就可以考虑做一些其他事情了。如果使用Objective-C语言进行开发,那我们就必须使用Buck。Buck提供了更好的依赖管理,可靠的增量编译以及远程编译缓存。它是由Facebook创建,如果在编译期间出现问题你可能就会关注它了。我们之前曾分别在objective-c和Android编译过,最终我们的清理编译速度提高了4倍,我们的增量编译快了20倍,因为它使用了远程编译缓存,所以如果你正在编译多个目标,而另一些人已经在其他机器上编译了该代码,那么它将在远程编译缓存中可用,并且只会使用该文件,它不会重新编译任何东西。 在Android上,它的速度更快,像6倍快的清理编译时间,而增量版本只是快速的。
这不是Swift,但我们正在努力,所以我们一直在为Facebook能够支持Swift而努力,我们现在开始尝试在生成Xcode项目文件的时候能够添加Swift支持,我认为现在这个目标已经几乎或者接近实现了,我们已经在内部开始这么做了,现在已经可以根据文件夹结构是用Buck来生成工程文件了。
接下来,我们正在为实现Buck编译添加Swift支持而努力,这么做的目的就是以后可以使用Buck编译我们的应用程序。 最终我们还想要去研究如何将已经添加到Buck的Swift支持整合到Xcode中,如果研究成功的话,那么当你打cmd + B,它不会使用Xcode编译,而是会使用Buck进行编译。
如果使用Buck的话,现在6分钟的编译时间,以后可能会减少到的2分钟甚至更短。 这将从本质上解决Swift编译时间的问题。 这一切都可以按照Buck repo进行,您最终会看到Swift支持也会加进来的。