高速扩张的隐忧
2016 年,特朗普还没当上美国总统,“删除 Uber”运动还未爆发,Travis Kalanick 还是 Uber CEO。那时,Uber 还处在国际化扩展的高速增长期,业务蒸蒸日上,公众情绪非常积极。
但是,高速扩张不可能一直风平浪静,Uber App 开始出现一些问题。那个时候,Uber 工程团队的规模几乎每年都在翻倍增长。当一家公司以如此快的速度增长,最终要面临的是令人难以置信的技术性爆炸问题。
再加上团队提倡的“让开发者放手去干”的理念,我们的应用架构变得既复杂又脆弱。Uber 当时非常注重客户端逻辑,所以应用程序会出现很多问题。我们一直在做热修复,不断发布版本,设计的扩展性也变得很差。
噩梦开始:重写应用程序
因为这些问题的出现,公司各个层面开始出现一种运动,主要的想法是“从头开始重写应用程序”。人们普遍认为,我们的架构正在拖累我们,只有重新开始才会让我们走得更快。因此,Uber 成立了一个团队,为新 App 构建全新的移动架构。这个团队的目标是构建一个能够“在未来 5 年内支撑 Uber 移动开发”的架构。
我们要同时支持两个平台,产品和设计也重新来过。在 iOS 平台方面,这次重写为采用 Swift(当时 Swift 的版本是 2.x)带来了机会。Uber 之前也尝试过 Swift,但早期使用过它的人都知道,它存在的问题比较多,所以在重写之前就被禁止了。
不过,架构团队的总体感觉是,当时 Swift 的大多数问题都集中在与 Objective-C 的互操作性上,所以如果我们开发的是一个纯 Swift 应用,就能规避这些问题。
架构团队希望在 Android 和 iOS 这两个平台上使用相同的架构模式。Android 团队都是 RxJava 的忠实粉丝,而 Swift 也有一个支持函数式编程的 RxSwift 库。于是,这个由设计、产品和架构组成的核心团队在一个房间里工作了几个月,使用新的函数式和反应式模式、新的编程语言开发新的应用程序,一切都进行得很顺利。
这个架构高度依赖了 Swift 的高级语言特性。新的 UI 设计为 Uber 不断增长的产品提供支持,函数式编程非常强大(虽然学习曲线有一定的坡度),新的架构以我们的新实时流网络协议为基础。
几个月后,通过一系列演示,这种势头逐渐形成。这个项目看起来很成功。他们在很短的时间内与少数工程师一起创造了令人惊叹的体验,核心产品的大部分功能都已经完成。
于是,在全公司范围内的推广开始了。各个团队开始将更多的功能引入到新 App 中。最初,新应用带来的兴奋感激发了他们的积极性和生产力。新架构的主要特点是功能隔离,可以让团队快速开发新功能。
问题不断:开发速度变慢、App 启动时间变长......
但是,使用 Swift 的工程师数量一旦超过 10 个,开发速度就会慢下来。当时,Swift 编译器仍然比 Objective-C 慢得多,因此构建时间大大增加,甚至几乎无法进行调试。
有一个 Uber 工程师在 Xcode 中输入了一行代码,等了 45 秒之后,字母才慢慢地、一个接一个地出现在编辑器中。
随后,我们又遇到动态链接器问题。那个时候,我们只能动态地链接 Swift 库,而链接器的执行时间是多项式时间,苹果建议单个二进制文件的最大链接库数量是 6,而我们有 92 个,而且还在不断增加。因此,在点击应用图标后,需要 8 秒到 12 秒才开始调用主函数。新 App 的启动速度比老款还要慢。
紧接着的是 App 的文件大小问题。
当这些问题开始出现时,我们已经走过了可以回头的临界点。
此时,整个公司都将精力倾注在新 App 上。数千人参与其中,花费数百万美元(我不能告诉你确切的数字,但肯定比你想象的多),管理层已经完全相信一切尽早掌握之中。
搞定各种难题
我私下里和主管提过“我们必须停下来”的话题。但他告诉我说,如果这个计划失败,他就要卷铺盖走人。他的老板,老板的老板,一直到副总裁,都要走人。没有回头路了!
所以我们撸起袖子,让最优秀的人负责处理每一个棘手的问题(动态链接、二进制文件大小)。
我们很快发现,将所有代码放到主文件中就可以解决 App 启动时的链接问题。但我们都知道,Swift 的命名空间与框架是混合在一起的,如果要这么做,就需要修改大量的代码,包括检查命名空间。
这时,聪明的 Richard Howell 发现,在读取 Xcode 的构建输出时,可以在构建完成后用自定义脚本将所有中间目标文件重新链接到主文件。由于 Swift 在编译时将对象命名空间转换为符号名称,这意味着他可以安全地保留命名空间。于是我们可以静态链接库,并将之前的时间从 10 秒减少到 0。
下一个是 App 大小问题。当时,我们计划将新 App 包含在旧 App 包中,并一步一步“安全”地发布出去。为节省空间,我们做的第一件事就是移除旧 App。我们将这种策略称为“Yolo”,由当时的 CEO 做的决定。
我们还用类替换了 Swift 的结构体。由于对象扁平化以及复制和自动初始化需要额外的机器代码,值类型通常需要大量的开销,所以替换掉结构体为我们节省了一些空间。
但随着 App 的不断发展,很快就达到了二进制文件(iOS 8 和更早的版本)的下载限制(100MB),这意味着有大量用户无法注册。
此时距离公开发布日期只有几周时间。我们得到一家公司的帮助,但他们不能解决我们的问题。我们唯一能做的就是为 Objective-C 重新生成所有的模型代码(占总代码总量的 25%)或放弃支持 iOS 8。iOS 9 引入了新架构,可以把大小降到原来的一半。因为留给我们的时间只有一周了,所以我们决定放弃支持 iOS 8。
我们的普遍想法是,iOS 9 版本的二进制文件大小减小了一半,所以我们仍然拥有足够的空间,可以在重写完成后,在未来的某个时间解决问题。不幸的是,我们完全想错了。
在 App 发布后,我们举办了一个盛大的派对。新 App 受到了媒体的好评,它快速、时髦、设计新颖。
一群人得到了升职。我们都松了一口气。在连续奋战了 90 个礼拜之后,我们消停了几个星期。
更糟糕的事情发生了
随后,公众的情绪开始发生转变。新 App 的设计核心是让用户先进入到目的地,这样他们就可以预先知道打车价格。如果不手动选择位置,就会以最后接收到的 GPS 位置为准。但这个非常不准确(尤其是在高楼林立的城市),司机可能会走错街区。这是一种很糟糕的用户体验。
为了改进位置获取功能,我们修改了位置权限,在后台收集位置信息,这样就可以把司机派到用户当前的位置。但人们被这个做法惊到了。我的一些 Twitter 旧同事建议我离开这家会追踪用户位置的“坏”公司。受到“惊吓”的人们关闭了手机的位置权限,但新 App 并没有相应的解决办法。
我们赶紧想办法讨论对策。我们想过关闭后台位置收集,但这样会破坏用户体验。
在特朗普入主白宫后(这是在新 App 发布三个月后),这个问题引发了连锁反应,导致“删除 Uber”运动的爆发。
在这段时间里,Swift 代码量一直在快速增长。问题的持续存在和缓慢的开发环境在 Uber 的 iOS 工程师中形成了两个敌对派别,我称它们为“Swift 狂热派”和“Objective-C 顽固派”。外部的压力和内部的派系斗争让气氛变得高度紧张。Swift 狂热派否认 Swift 所造成的问题。这些坏脾气的人抱怨着一切,却不怎么提供解决方案。
正是在这个时候,我们遇到了 App 大小的问题。我做好随时待命的准备,而发布团队在提交 App 时遇到了麻烦。事实证明,我们针对动态链接问题提出的解决方案创建的主文件对于某些平台来说太大了。在临时解决了这个问题后,我们做了一些调查,发现编译的代码大小以每周 1.3 MB 的速度增长。如果我们不采取行动,在 3 周内就会达到手机下载的上限。
但因为内部斗争太过激烈,我们被“无视”了。一位技术负责人写了两页的材料,试图证明手机下载限制并不是个问题。
我们的一名数据科学家设计了一个测试,人为地将架构的一部分推到限制阈值,并观察对业务指标的影响。在接下来的一个星期,我们把之前的部分下架,再把另一个部分推到限制阈值。
结果是灾难性的,这种做法对业务的负面影响比 Swift 重写的成本要大几个数量级。事实证明,很多人在第一次下载 Uber App 时就使用了手机网络。
我们组建了另一支突击团队。我们开始反编译目标文件,并逐行检查,看看为什么 Swift 代码生成的文件体积会这么大。我们删除了一些没有被使用的特性,并把 watchOS 应用重新改回了 Objective-C。
我们几乎达到了极限,精疲力尽,但每个人都努力打起精神。这是真正优秀的工程师开始散发光芒的时刻。阿姆斯特丹的一名开发人员想到了重新优化编译器 pass。关于编译器的 pass,我需要解释一下。
现代编译器会对代码进行大量的 pass,例如 pass 内联函数,或者用值来替换常量表达式。根据执行顺序的不同,可能会得到更小体积的机器码。
如果内联函数碰到一个常量,编译器就会知道,并进行替换。于是,如果先进行内联,
就会变成常量 7,这样生成的机器码就更少。
如果内联是后进行的,就无法推断函数体,会生成更多的机器码。当然,这完全取决于你所写的代码是什么样的,因此很难对 pass 的顺序进行通用的优化。
阿姆斯特丹的这位工程师在构建过程中使用退火算法来重新排序编译器优化,最小化生成的机器码。这减少了 11MB 的机器码,为我们提供了足够的空间继续开发功能。
但这却吓坏了 Swift 编译器工程师,他们担心未经测试的编译器优化命令会导致未经测试的 bug(即使每个 pass 都被认为是安全的,但很难推断出可能出现的组合)。不过,我们并没有遇到什么大问题。
我们也尝试了一些其他的解决方案,并按照开发周数来测算它们给我们带来的好处。但我们发现,真正的问题是增长曲线,它总是让我们的努力“功亏一篑”。
最终,我们让苹果将手机下载限制提高到 150MB,他们还添加了一些编译器选项(-Osize),帮我们进行文件大小优化。Swift 团队也承认,Swift 编译器不可能像 Objective-C 编译器那样将文件编译到很小。
但到了 2020 年,他们将 Swift 编译生成的机器码大小降至 Objective-C 的 1.5 倍,并将下载限制提升至 200MB 的可选上限。这足够让我们再撑好几年了。
如果不是因为苹果提高了上限,我们将被迫重新回到 Objective-C。最终,我们也解决了其他问题。聪明的 Alan Zeino 和他的团队让 Uber 的 BUCK 构建系统支持 Swift,极大地加快了构建速度。
一路下来,我们的很多同事都感到精疲力竭。Uber 花了一大笔钱,也吸取了惨痛的教训,但直到今天,大多数人仍然坚持认为重写是值得的。新加入的工程师喜欢新架构的一致性,但他们并不知道我们为了实现这一目标经历了怎样的痛苦。
社区也从我们的经历中受益。Ellie 做了一个很棒的演示,并通过巡回演讲来分享我们的经验。我用我的经验去教其他团队如何做出更好的决策。
写在最后
我认为,计算机科学当中的一切东西都存在一种权衡,不存在所谓的通用的高级语言。无论你做什么,都要明白你为什么要这么做,不要让它演变成各派固执己见的政治斗争。
设立好故障点。如果你意识到自己犯了一个错误,你要弄清楚如何做出权衡,并给自己一条出路。你陷在错误决策中的时间越长,成本就越高。不要做一个对解决问题没有贡献的坏脾气的人,不要做一个给别人制造更大问题的狂热者。与我共事过的那些优秀的工程师们都很善于避免落入这两个陷阱。
原文链接:
评论 2 条评论