Facebook iOS版:探索移动应用10年演进之路
iOS 版 Facebook(FBiOS)是 Meta 公司所掌握的最古老的移动端代码库。自 2012 年应用重写 以来,这版应用经过数千名工程师的改动和调整、被交付给全球数十亿用户,并可支持数百名工程师同时对其进行迭代。
经过多年演进,FBiOS 的代码库已经跟典型的 iOS 代码库大有区别:
充斥着 C++、Objective-C(++)与 Swift 代码。
包含几十个 动态加载库(dylibs),以及很多无法一次性加载到 Xcode 中的类。
Apple SDK 的直接使用率几乎为零——所有内容都被打包或替换成了内部抽象的形式。
在 Fcaebook 的自定义构建系统 Buck 的推动下,这款应用大量使用代码生成技术。
如果构建系统不配备巨大的缓存,那么应用构建大概要花掉工程师们整整一天的时间。
千万不要误会,FBiOS 的整个架构设计过程从来就没有故意朝着这个混乱的方向前进。这款应用的代码库其实是过去 10 年间持续演进的体现,反映的是越来越多工程师参与到开发中来、对应用稳定性的强调以及改善用户体验等核心技术决策。
如今,为了庆祝这套代码库的 10 岁生日,让我们一同回顾整个演变历程背后的重要决定和历史背景。
在 Meta 对 Facebook 应用进行原生重写的两年之后,News Feed 代码库开始出现可靠性问题。当时,News Feed 的数据模型由苹果的默认数据模型管理框架 Core Data 负责支持。Core Data 中的对象是可变的,这并不适合 News Feed 的多线程架构。更糟糕的是,News Feed 还在使用双向数据流,这正源自苹果 Cocoa 应用中的默认设计模式 Model View Controller。
最终,这样的设计加剧了非确定性代码的产生,工程师们发现这些代码既难以调试、又很难进行 bug 重现。面对这样一种不可持续的架构,是时候做点什么了。
在考虑新设计时,一位工程师研究了 Facebook 的(开源)UI 框架 React。React 的声明式设计能够将 Feed 上棘手的命令式代码抽象出来,同时利用单向数据流降低代码的归因难度,因此在 JavaScript 社区中越来越流行。这些功能特性似乎很适合处理 News Feed 面对的挑战,只剩下一个问题……
Apple SDK 中不提供声明式 UI。
那时候距离 Swift 的发布 还有几个月,而 SwiftUI(苹果的声明式 UI 框架)更是要到 2019 年才亮相。如果 News Feed 想要获得声明式 UI,就必须得由开发团队自己动手打造。
认真权衡了一番后,开发团队决定自己动手。
经过几个月的构建和迁移之后,News Feed 终于运行在了新的声明式 UI 与数据模型上,这让 FBiOS 的性能提高了 50%。几个月后,他们将这套受 React 启发的移动 UI 框架推向开源,并正式命名为 ComponentKit。
时至今日,COmponentKit 仍然是 Facebook 中原生 UI 的最佳构建选项。它通过视图重用池、视图扁平化和背景布局计算等功能,为应用带来了可观的性能改进。它甚至启发了 SwiftUI 乃至 Android 阵营的对手 Litho。
总而言之,选择用自定义基础设施替换 UI 和数据层代表着一种权衡。为了实现可靠、可维护且令人满意的用户体验,开发团队决定放下自己在 Apple API 上积累的专业知识,从零开始探索内部基础设施。
FBiOS 在最终用户体验与开发者体验 / 速度间的取舍,到这里才刚刚拉开帷幕。时间来到 2015 年,该应用的成功迅速引起了功能爆炸,一系列独特挑战也接踵而来。
2015 年,Meta 开始全力强调其“移动优先”的口号,FBiOS 代码库的日均贡献量也开始急剧提升。随着越来越多产品被集成到该应用当中,程序的启动速度也越来越慢,甚至连普通用户都注意到了这一点。到 2015 年底,应用的启动时长已将近 30 秒,稍不留神就有可能被手机操作系统强制关闭。
经过调查,开发团队发现了一系列会导致启动性能下降的因素。为了简洁起见,这里我们只提对于应用架构存在长期影响的原因:
由于应用体量随着新的产品和功能的增加而膨胀,应用的“premain”时间正在无限制延长。
应用的“模块”系统允许各个产品不受约束地访问所有应用资源,于是引发了 公共资源争用 的闹剧。每个产品在启动时都会利用自己的“hook”执行高计算量操作,这就造成指向各产品的导航操作经常出现卡顿。而要想对优化和改进启动过程,相应的变更将从根本上改变产品工程师在 FBiOS 上的代码编写方式。
根据苹果关于改进启动时间的维基页面,在调用应用的“main”函数之前必须完成一系列操作。通常来说,应用本体的代码越多,启动所需的时间就越长。
虽然“pre-main”只占 30 秒启动时长当中的一小部分,但却特别值得关注。因为随着 FBiOS 新功能的不断引入,这部分负载会无休无止地膨胀、延长。
为了帮助缓解应用启动时间无限延长的问题,Facebook 工程师开始将大量产品代码转移到动态库(dylib)当中,也就是延迟加载容器。通过这种方式,这部分代码就不需要在应用的 main() 函数前进行预加载了。
最初,FBiOS dylib 的结构如下所示:
这里我们创建两个产品动态库(FBCamera 和 NotOnStartup),而第三个动态库(FBShared)是用于在各动态库和主应用的二进制文件间共享代码的。
动态库的效果很好,FBiOS 也终于遏制住了应用启动时间无限延长的问题。随着时光流逝,大部分代码最终都跑到了动态库里头,这样应用的启动性能就一直很快、不会受到应用中产品添加 / 删除的影响。
事实上,动态库的加入了彻底改变了 Meta 产品工程师编写代码的方式。在此之后,NSClassFromString() 这类运行时 API 所需要的类存在于未经加载的动态库内,所以可能引发运行时故障。另外,因为 FBiOS 中大量原有核心抽象均建立在遍历内存中所有类的基础之上,因此 FBiOS 被迫重新审视这套核心系统的有效性。
除了运行时故障之外,动态库还引发了一类新的链接器错误。如果 Facebook(启动集)中的代码引用了动态库中的代码,则链接器会向工程师弹出以下错误:
Undefined symbols for architecture arm64:
"_OBJC_CLASS_$_SomeClass", referenced from:
objc-class-ref in libFBSomeLibrary-9032370.a(FBSomeFile.mm.o)
为了解决这个问题,工程师需要用特殊的函数将代码打包起来,由该函数在必要时加载动态库:
于是在一夜之间,原本的:
int main() {
DoSomething(context);
}
就变成了:
int main() {
FBCallFunctionInDylib(
NotOnStatupFramework,
DoSomething,
context
);
}
这么改当然不是不行,但却带来了新的麻烦:
特定于应用的动态库枚举将被硬编码至各种调用站点上。Meta 的所有应用都必须共享同一个动态库枚举,读取方则须负责确定运行代码的应用是否使用了该动态库。
如果使用了错误的动态库枚举,代码将失败,但这种失败仅发生在运行时上。由于应用内的代码和功能已经极为庞大,这个故障信号延后问题给开发工作制造了不少麻烦。
最重要的是,我们在启动期间防止过量引入调用的唯一系统,是以运行时为基础来实现的。应用的很多版本都曾因临时加入回归而被迫推迟发布。
总之,动态库优化确实遏制了应用启动时间的无限延长,但也标志着应用架构设计的重大转折点。FBiOS 工程师将在接下来几年内重构这款应用,打磨那些因引入动态库而造成的粗糙“接缝”。最终,我们发布了有史以来最为健壮的应用架构。
随着动态库的引入,我们开始重新审视 FBiOS 中的几大关键组件:
“模块注册系统”不能再基于运行时。
工程师需要一种新方法,以了解启动期间是否有代码路径会触发动态库加载。
为了解决这些问题,FBiOS 求助于 Meta 的开源构建系统 Buck。
在 Buck 当中,每个“target”(即目标,包括 app、dylib、library 等)都需要通过配置来声明,例如:
apple_binary(
name = "Facebook",
...
deps = [
":NotOnStartup#shared",
":FBCamera#shared",
],
)
apple_library(
name = "NotOnStartup",
srcs = [
"SomeFile.mm",
],
labels = ["special_label"],
deps = [
":PokesModule",
...
],
)
每个“target”都列出了构建它所需要的各项信息(包括依赖项、编译器标志、源代码等)。在调用“buck build”时,它会将所有这些信息构建成一份可查询的图表。
$ buck query “deps(:Facebook)”
> :NotOnStartup
> :FBCamera
$ buck query “attrfilter(labels, special_label, deps(:Facebook))”
> :NotOnStartup
使用这个核心概念(再加上一些特殊的技术手段),FBiOS 中出现了可以在构建期间生成应用中类和函数整体视图的 buck 查询。这些信息,也成为下一代应用架构的设计基石。
现在,FBiOS 已经能利用 Buck 查询相关依赖项中的代码信息,并创建出能够动态生成“函数 / 类 ->动态库”的映射。
{
"functions": {
"DoSomething": Dylib.NotOnStartup,
...
},
"classes": {
"FBSomeClass": Dylib.SomeOtherOne
}
}
使用该映射作为输入,FBiOS 就能生成从 callsites 处抽象出动态库枚举的代码:
static std::unordered_map<const char *, Dylib> functionToDylib {{
{ "DoSomething", Dylib.NotOnStartup },
{ "FBSomeClass", Dylib.SomeOtherOne },
...
}};
这种代码生成方式的优点包括:
由于代码是根据本地输入重新生成的,所以不存在签入需求,也不存在大量合并冲突。考虑到 FBiOS 的工程体量每年都会翻一番,这将带来巨大的开发效率提升。
FBCallFunctionInDylib 不再需要特定于应用的动态库(所以不妨更名为「FBCallFunction」)。相反,该调用可以读取各应用在构建期间生成的静态映射。
事实证明,这种将 Buck 查询与代码生成相结合的办法非常成功,FBiOS 也因此将其作为新型插件系统的基石,最终取代了基于运行时的应用模块系统。
借助 Buck 驱动型插件系统,开发团队得以将部分基础设施转为基于插件的新架构,让 FBiOS 用构建时警告取代了大部分运行时故障。
在构建 FBiOS 时,Buck 可以生成图表以展示应用中所有插件的位置,如下所示:
从积极的方面来看,这套插件系统可以显示构建时错误以提醒工程人员:
“插件 D、E 可能触发动态库加载。不允许这项操作,因为这些插件的调用者位于应用的启动路径之内。”
“在应用中找不到用于呈现 Profile 的插件……因此无法导航至该屏幕。”
“同时存在两个用于 Groups 呈现的插件(插件 A,插件 B),应删除其中一个。”
事实证明,这种将 Buck 查询与代码生成相结合的办法非常成功,FBiOS 也因此将其作为新型插件系统的基石,最终取代了基于运行时的应用模块系统。
虽然 FBiOS 在转向插件系统之后大大提升了应用可靠性,也能为工程师快速提供提示信号,降低了不同移动应用之间的代码共享门槛,但这一切都是有代价的:
Stack Overflow 上查不到插件错误,而且可能在调试期间造成混淆。
基于代码生成和 Buck 的插件系统,与传统 iOS 开发思路相去甚远。
插件相当于给代码库引入了间接层。大部分应用都有一个包含所有功能的注册表文件,但此文件在 FBiOS 中由自动生成的,而且特别难以查找。
毫无疑问,插件的引入让 FBiOS 一步步远离了我们所熟悉的 iOS 开发,但这样的取舍似乎是值得的。我们的工程师可以调整 Meta 旗下各类应用中的代码,保证只要插件系统这边不出问题,那更新后的版本就基本能在代码路径中顺畅运行。像 News Feed 和 Groups 这样的团队还可以为插件构建扩展点,确保产品团队能在不触及核心代码的前提下将其集成到自有界面当中。
虽然本文的大部分内容都在讨论 Facebook 应用规模所引起的架构变化,但 Apple SDK 的演进也确实对 FBiOS 的某些架构决策造成了影响。
2020 年,FBiOS 发现苹果那边仅限 Swift 使用的 API 数量越来越多,人们也更希望能在代码库中多用 Swift。到这个时候,我们必须承认 Swift 一定会在 Facebook 应用中占据一席之地——无论我们愿不愿意。
从历史上看,FBiOS 曾使用 C++ 作为构建抽象的杠杆。由于 C++ 的零开销优势,它显著降低了代码体量。但 C++ 与 Swift 目前还无法实现互操作,所以大部分 FBiOS API(包括 ComponentKit)必须通过某种“垫片”才能在 Swift 中运行——这无疑会造成代码膨胀。
下图为 FBiOS 代码库中的语言架构问题:
考虑到这一点,我们开始认真考虑什么时候、在哪里该使用哪种编程语言:
最终,FBiOS 团队建议将 C++ 从面向产品的 API/ 代码中全面清理出去,以便大家可以自由使用 Swift 和苹果未来提供的 Swift API。通过插件,FBiOS 可以抽象出 C++ 实现,确保它们继续为应用提供支持;而对大多数工程师来说,又不必直接跟 C++ 打交道。
这样的工作流,标志着 FBiOS 工程师对于抽象构建的考量方式发生了转变。自 2014 年以来,框架构建中的最核心因素,就是对应用代码量和表现力的影响(正因为如此,ComponentKit 才选择了 Objective-C++,而非 Objective-C)。
Swift 的加入第一次下调了开发者效率的优先级,预计未来还会出现更多类似的情况。
自 2014 年以来,FBiOS 架构发生了重大变化:
引入了无数内部抽象,包括 ComponentKit 和 GraphQL。
使用动态库将“pre-main”时长保持在最低水平,并有助于加快应用启动速度。
引入了插件系统(由 Buck 提供支持),将动态库从工程师那边抽象出来,降低了代码在不同应用间的共享难度。
引入了什么时候在哪里该用什么语言的评判准则,并开始转变代码库以反映这些语言准则。
与此同时,苹果则在手机、操作系统和 SDK 层面做出了激动人心的改进:
新 iPhone 的性能越来越强,加载成本比以往低得多。
操作系统持续改进,dyld3 和 chain fixups 大大加快了软件代码的加载速度。
推出 SwiftUI,一种用于 UI 的声明式 API,其中很多概念与 ComponentKit 相通。
发布了经过改进的 SDK 和 API(例如 iOS8 中的可中断动画),可供开发者据此构建自定义框架。
随着 Facebook、Messenger、Instagram 和 WhatsApp 之间的可共享体验越来越多,FBiOS 也在重新审视之前做出的一系列优化,思考其中哪些可以跟苹果平台的原生功能合并起来。现在看来,共享代码的最简单方法,就是直接使用应用免费提供的功能、或者直接在构建过程中消除一切依赖项。能做到这一点,应用之间就能顺畅对接、毫无滞涩。
期待到 2032 年时,我们能在 FBiOS 的 20 岁生日时再相聚,看看那时会有哪些值得一聊的心得体会!
原文链接:
https://engineering.fb.com/2023/02/06/ios/facebook-ios-app-architecture/
声明:本文为 InfoQ 翻译,未经许可禁止转载。