查看原文
其他

用户数十亿的iOS超级应用,10年代码变化,你发现了吗?

51CTO技术栈 2023-03-28

作者 | Dustin Shahidehpour
策划 | 言征 

iOS版Facebook(FBiOS)可以说是Meta最古老的移动代码库了。自2012年该应用程序被重写以来,数千名工程师对其进行了研究,并将其交付给数十亿用户,它可以支持数百名工程师一次对其进行迭代。

FBiOS架构演变到今天,并不是有意为之的。它反映了10年以来的发展,这是由越来越多的工程师开发该App所需的技术决策、稳定性以及最重要的用户体验所推动的。
补充知识:

截止到2022年,该代码库已经走过了十周年,笔者将对这一演变背后的技术决策以及它们的历史背景进行一些说明。

经过多年的迭代,Facebook代码库与典型的iOS代码库不同:

(1)它包含了C++、Objective-C(++)和Swift。
(2)它有几十个动态加载的库(dylib),以及太多的类,无法一次将它们加载到Xcode中。
(3)苹果SDK的原始使用几乎为零——一切都被内部抽象所包装或替换。
(4)该应用程序大量使用代码生成,这是由我们的自定义构建系统 Buck 推动的。
(5)如果我们的构建系统没有大量缓存,工程师将不得不花一整天时间等待应用程序的构建。  

2014:建立我们自己的移动框架

2014年,对Facebook应用程序进行本地重写已经过去两年,这时,News Feed的代码库开始出现可靠性问题。当时,News Feed的数据模型得到了苹果管理数据模型的默认框架:核心数据的支持。核心数据中的对象是可变的,这并不适合新闻提要的多线程架构。更糟糕的是,News Feed利用了双向数据流,这源于它对Cocoa应用程序使用了苹果事实上的设计模式:模型视图控制器。

最终,这种设计加剧了不确定性代码的产生,这些代码很难调试或再现错误。很明显,这种架构是不可持续的,是时候重新思考了。

在考虑新的设计时,一位工程师研究了React,Facebook的(开源)UI框架,该框架在Javascript社区中非常流行。React的声明性设计抽象了导致Feed(在web上)出现问题的棘手命令式代码,并利用了单向数据流,这使得代码更易于推理。这些特征似乎很适合News Feed面临的问题:苹果的SDK中没有声明性UI。

Swift将在几个月内发布,SwiftUI(苹果的声明性UI框架)将在2019年之前发布。如果NewsFeed想要有一个声明性UI,那么团队必须构建一个新的UI框架。

最终,这就是他们所做的。

在花了几个月时间构建和迁移新闻提要以在新的声明性UI和新的数据模型上运行后,FBiOS的性能提高了50%。

几个月后,他们开源了基于React的移动UI框架ComponentKit。时至今日,ComponentKit仍然是在Facebook中构建本机UI的事实上的选择。它通过视图重用池、视图展平和背景布局计算为应用程序提供了无数性能改进。它也启发了其Android对手Litho和SwiftUI。

最终,选择用自定义infra替换UI和数据层是一种权衡。为了获得可以可靠维护的令人愉快的用户体验,新员工必须搁置他们对Apple API的行业知识,学习定制的内部基础设施。

这将不是FBiOS最后一次做出平衡最终用户体验与开发者体验和速度的决定。进入2015年,该应用的成功将引发我们所称的功能爆炸。这也带来了一系列独特的挑战。

2015:架构拐点

到2015年,Meta在其“移动第一”的口号上翻了一番,FBiOS代码库的每日贡献者数量急剧增加。随着越来越多的产品被集成到应用程序中,其发布时间开始缩短,人们开始注意到。到2015年底,启动性能非常缓慢(接近30秒!),以至于有可能被手机的操作系统杀死。

经过调查,很明显有许多因素导致启动性能下降。为了简洁起见,我们将只关注那些对应用程序架构有长期影响的方面:

(1)随着应用程序的规模随着每种产品的增长而增长,该应用程序的“前置”时间正在以无限的速度增长。

(2)该应用程序的“模块”系统为每个产品提供了对该应用程序所有资源的无管制访问。这导致了一个公共问题的悲剧,因为每个产品都利用它的“钩子”来启动,以执行计算上昂贵的操作,从而快速导航到该产品。

缓解和改善启动所需的更改将从根本上改变产品工程师为FBiOS编写代码的方式。

2016年:Dylibs和模块化

根据苹果关于改进发布时间的维基,在调用应用程序的“主”功能之前,必须执行许多操作。通常,一个应用程序的代码越多,所需时间就越长。

虽然“pre-main”在发布过程中仅贡献了30秒的一小部分时间,但这是一个特别令人担忧的问题,因为随着FBiOS不断积累新功能,它将继续以无限的速度增长。

为了帮助缓解应用程序发布时间的无限增长,我们的工程师开始将大量产品代码移入一个称为动态库(dylib)的延迟加载容器中。当代码移动到动态加载的库中时,不需要在应用程序的main()函数之前加载。

最初,FBiOS dylib结构如下:


创建了两个产品dylib(FBCamera和NotOnStartup),第三个dylib(FBShared)用于在不同的dylib和主应用程序的二进制文件之间共享代码。

dylib溶液效果很好。FBiOS能够抑制应用程序启动时间的无限增长。随着时间的推移,大多数代码都会以dylib结尾,这样启动时的性能就会保持快速,并且不会受到应用程序中添加或删除产品的持续波动的影响。

dylibs的加入引发了Meta产品工程师编写代码方式的思想转变。随着dylib的添加,像NSClassFromString()这样的运行时API冒着运行时失败的风险,因为所需的类存在于卸载的dylib中。由于FBiOS的许多核心抽象都是在遍历内存中的所有类的基础上构建的,因此FBiOS必须重新思考其核心系统的工作情况。

除了运行时失败之外,dylibs还引入了一类新的链接器错误。如果Facebook(启动集)中的代码引用了dylib中的代码,工程师将看到如下链接器错误:
Undefined symbols for architecture arm64:
"_OBJC_CLASS_$_SomeClass", referenced from:
objc-class-ref in libFBSomeLibrary-9032370.a(FBSomeFile.mm.o)
左右滑动查看完整代码

为了解决这个问题,工程师们需要用一个特殊的函数来包装他们的代码,如果需要的话,可以加载dylib,比如:
int main() {
DoSomething(context);
}

看起来像这样:
int main() {
FBCallFunctionInDylib(
NotOnStatupFramework,
DoSomething,
context
);
}
该解决方案有效,但有很多奇怪的地方:

(1)应用程序特定的dylib枚举被硬编码到各种调用站点中。Meta的所有应用程序都必须共享一个dylib枚举,读者有责任确定代码运行的应用程序是否使用了该dylib。

(2)如果使用了错误的dylib枚举,代码将失败,但仅在运行时失败。考虑到应用程序中大量的代码和功能,这个延迟的信号导致了开发过程中的许多挫折。

最重要的是,我们唯一能防止在启动过程中引入这些调用的系统是基于运行时的,在应用程序引入最后一分钟的回归时,许多发布都被延迟。

最终,dylib优化抑制了应用程序发布时间的无限增长,但这意味着应用程序架构的巨大转折点。FBiOS工程师将在接下来的几年里重新设计应用程序,以消除dylib带来的一些粗糙边缘,我们(最终)推出了一个比以往任何时候都更强大的应用程序架构。

2017:重新思考FBiOS架构


随着dylibs的引入,FBiOS的几个关键组件需要重新思考:

(1)“模块注册系统”不能再基于运行时。

(2)工程师们需要一种方法来了解启动期间的任何代码路径是否会触发dylib加载。

(3)为了解决这些问题,FBiOS转向Meta的开源构建系统Buck。
在Buck中,每个“目标”(app、dylib、library等)都用一些配置声明,如下所示:
apple_binary(
name = "Facebook",
...
deps = [
":NotOnStartup#shared",
":FBCamera#shared",
],
)

apple_library(
name = "NotOnStartup",
srcs = [
"SomeFile.mm",
],
labels = ["special_label"],
deps = [
":PokesModule",
...
],
)
每个“目标”都列出了构建它所需的所有信息(依赖项、编译器标志、源等),当调用“buck build”时,它会将所有这些信息构建成一个可以查询的图形。
$ buck query “deps(:Facebook)”
> :NotOnStartup
> :FBCamera

$ buck query “attrfilter(labels, special_label, deps(:Facebook))”
> :NotOnStartup
左右滑动查看完整代码

使用这个核心概念(以及一些特殊的酱汁),FBiOS开始生成一些buck查询,这些查询可以在构建过程中生成应用程序中的类和函数的整体视图。这些信息将成为该应用程序下一代架构的基石。

2018:生成代码的激增

既然FBiOS能够利用Buck查询依赖关系中的代码信息,那么它就可以创建一个“function/classes->dylibs”的映射,可以在运行中生成。
{
"functions": {
"DoSomething": Dylib.NotOnStartup,
...
},
"classes": {
"FBSomeClass": Dylib.SomeOtherOne
}
}
使用该映射作为输入,FBiOS使用它生成从调用站点抽象出dylib枚举的代码:
static std::unordered_map<const char *, Dylib> functionToDylib {{
{ "DoSomething", Dylib.NotOnStartup },
{ "FBSomeClass", Dylib.SomeOtherOne },
...
}};
左右滑动查看完整代码

使用代码生成之所以吸引人,有几个原因:

(1)因为代码是基于本地输入重新生成的,所以没有什么可签入的,也没有更多的合并冲突!考虑到FBiOS的工程规模每年都会翻倍,这是一个巨大的开发效率胜利。

(2)不再需要应用程序特定的dylib(因此可以重命名为“FBCallFunction”)。相反,调用将从构建期间为每个应用程序生成的静态映射中读取。

事实证明,将Buck查询与代码生成相结合是如此成功,以至于FBiOS将其作为新插件系统的基础,最终取代了基于运行时的应用程序模块系统。

左移信号

使用Buck支持的插件系统。FBiOS能够通过将infra迁移到基于插件的架构中,以构建时警告取代大多数运行时失败。

构建FBiOS时,Buck可以生成一个图表,显示应用程序中所有插件的位置,如下所示:


从这个角度来看,插件系统可以显示构建时间错误,以便工程师发出警告:

(1)“插件D、E可能会触发dylib加载。这是不允许的,因为这些插件的调用方位于应用程序的启动路径中。”

(2)“应用程序中没有用于呈现配置文件的插件……这意味着导航到该屏幕将无法工作。”

(3)“有两个插件用于呈现组(插件A、插件B)。其中一个应该删除。”

对于旧的应用程序模块系统,这些错误将是“懒惰”的运行时断言。现在,工程师们相信,当FBiOS成功构建时,它不会因为功能缺失、应用程序启动期间的dylib加载或模块运行时系统中的不变量而失败。

代码生成的代价

虽然将FBiOS迁移到插件系统提高了应用程序的可靠性,为工程师提供了更快的信号,并使应用程序可以与其他移动应用程序轻松共享代码,但这是有代价的:

(1)插件错误在Stack Overflow上很难找到答案,调试时会感到有些吃力。

(2)基于代码生成和Buck的插件系统与传统的iOS开发有着天壤之别。

(3)插件为代码库引入了一层中间层。大多数应用程序都会有一个包含所有功能的注册表文件,这些都是在FBiOS中生成的,很难找到。

毫无疑问,插件使FBiOS远离了惯用的iOS开发,但这种权衡似乎是值得的。我们的工程师可以更改Meta的许多应用程序中使用的代码,并确保如果插件系统运行良好,任何应用程序都不会因缺少很少测试的代码路径中的功能而崩溃。像News Feed和Groups这样的团队可以为插件构建一个扩展点,并确保产品团队可以在不触及核心代码的情况下集成到其表面。

2020:Swift与语言架构

应用程序规模问题导致的架构变化上,但苹果SDK的变化也迫使FBiOS重新考虑其一些架构决策。

2020年,FBiOS开始看到来自苹果的Swift专用API的数量增加,并且越来越多的人希望在代码库中使用更多的Swift。终于是时候接受这样一个事实了:Swift是FB应用程序中不可避免的租户。

历史上,FBiOS曾使用C++作为构建抽象的杠杆,因为C++的“零开销”原则,这节省了代码大小。但C++尚未与Swift互操作。对于大多数FBiOS API(如ComponentKit),必须创建某种垫片以在Swift中使用,从而导致代码膨胀。
下面是一个图表,概述了代码库中的问题:


考虑到这一点,我们开始形成一种关于何时何地使用各种代码的语言策略:


最终,FBiOS团队开始建议:面向产品的API/代码不应包含C++,这样我们就可以自由使用苹果公司的Swift和未来的Swift API。使用插件,FBiOS可以抽象出C++实现,这样它们仍然为应用提供动力,但对大多数工程师来说是隐藏的。

这种类型的工作流意味着FBiOS工程师构建抽象的方式发生了一些变化。自2014年以来,影响框架构建中的最大因素是对应用程序大小和表现力的贡献度(这就是为什么ComponentKit选择Objective-C++而不是Objective-C的原因)。

Swift的引入导致开发人员的效率降低,不急,未来还能看到更多。

2022年:旅程已完成1%

自2014年以来,FBiOS架构发生了很大变化:

(1)它引入了大量内部抽象,如ComponentKit和GraphQL。

(2)它使用dylibs将“pre-main”时间保持在最小,并有助于快速启动应用程序。

(3)它引入了一个插件系统(由Buck提供支持),这样就可以从工程师那里抽象出dylib,因此代码很容易在应用程序之间共享。

(4)它引入了关于何时何地使用各种语言的语言指南,并开始改变代码库以反映这些语言指南。

与此同时,苹果对其手机、操作系统和SDK进行了令人兴奋的改进:

(1) 他们的新手机速度很快。装载成本比以前小得多。

(2) dyld3和链修复等操作系统改进提供了软件,使代码加载更快。

(3) 他们引入了SwiftUI,这是一个用于UI的声明性API,它与ComponentKit共享了很多概念。

(4) 他们提供了改进的SDK,以及我们可以为其构建自定义框架的API(如iOS8中的可中断动画)。

随着Facebook、Messenger、Instagram和WhatsApp分享了更多的体验,FBiOS正在重新审视所有这些优化,以了解在哪些方面可以更接近平台正统。最终,我们发现,共享代码的最简单方法是使用应用程序免费提供的东西,或者构建一个几乎无依赖性且可以在所有应用程序之间集成的东西。

我们将于2032年在这里与您见面,回顾代码库的20周年纪念!


您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存