我们如何将 iOS 应用启动时间减少 60%
对消费者而言,应用程序的启动时间是一个重要的指标,因为这是消费者首次接触到应用程序,即便是最细微的改善,都会给消费者体验带来极大的好处。第一印象是影响消费者转化的主要因素,而启动时间通常反映了整个应用程序的质量。另外,还有一些公司发现,延迟的增加等同于销售量的下降。
在 DoorDash ,我们对应用程序的启动速度给予了很大的关注。我们努力优化消费者的体验,并持续改善。
本文将探讨三个独立的优化,将我们的 iOS 消费者应用程序启动时间减少 60%。我们使用专用的性能工具发现了这些机会,但 Xcode 工具或 DTrace 也可作为替代方案。
在 2022 年初,我们的应用程序启动优化之旅开始于使用 Emerge Tools 的 Performance Analysis 工具可视化顶级瓶颈,如图 1 所示。
图 1:堆栈跟踪显示了三种性能优化机会
这个性能工具有助于从鸟瞰和细节的角度来显示未优化的分支。其中一个最直接的亮点是我们在 Swift 协议一致性检查(检查一个类型是否符合协议)上花费的时间,但为什么呢?
架构原则,如单一责任原则、关注点分离等,是我们在 DoorDash 编写代码的关键。服务和依赖项通常按其类型进行注入和描述。问题是我们使用 String(describing:) 来标识服务,这带来了检查类型是否符合各种其他协议的运行时性能损失。图 2 中的堆栈跟踪直接取自我们的应用程序启动,以展示这一点。
图 2:String(describing:) API 幕后发生的堆栈跟踪
我们问自己的第一个问题是:“我们真的需要一个字符串来标识类型吗?”取消字符串要求,转而使用 ObjectIdentifier 来标识类型(仅仅是指向类型的指针),可以使应用程序启动速度提高 11% 。我们还将这种技术应用到其他领域,在这些领域中,指针代替原始字符串就足够了,从而产生了额外的 11% 的改进。
如果可以使用指向该类型的原始指针而不是使用 String (description:) ,我们建议进行相同的更改以节省延迟时间。
在 DoorDash 中,我们将用者操作、网络请求、数据变更和其他计算工作负载封装到(我们称之为)命令中。例如,当我们加载存储菜单时,我们将其作为请求提交给命令执行引擎。然后,引擎将把命令存储在处理数组中,并按顺序执行入站命令。以这种方式构建我们的操作是我们新体系结构的关键部分,在这里,我们有目的地隔离直接突变并观察预期操作的结果。
这种优化始于重新思考如何识别命令并生成它们的散列值。我们的处理数组和其他依赖项依赖于唯一的散列值来标识和分隔各个命令。从历史上看,我们通过使用 AnyHasable 避免了必须考虑散列的需要。然而,正如 SWIFT 标准中指出的那样,这样做是危险的,因为依赖 AnyHasable 给出的哈希值可能会在不同的版本之间发生变化。
我们本可以选择以几种方式来优化我们的散列策略,但是我们首先要重新考虑最初的限制和界限。最初,命令的哈希值是其关联成员的组合。这一决定是故意做出的,因为我们希望保持对命令的灵活而强大的抽象。但是在应用程序广泛采用新的架构之后,我们注意到设计选择为时过早,而且总体上没有被使用。通过改变这一要求来识别命令的类型,可以使应用程序启动速度提高 29%,命令执行速度提高 55%,命令注册速度提高 20%。
在 DoorDash,我们竭尽全力在任何可能的地方摆脱第三方依赖。不过,有时候消费者的体验可能会从第三方整合中获益匪浅。无论如何,我们对第三方依赖关系如何影响我们的服务和我们维护的质量进行了几次严格的审计。
最近的一次审计发现,某个第三方框架导致我们的 iOS 应用程序启动大约慢了 200 毫秒。仅这个框架就占了大约 40% 我们的应用程序启动时间,如图 3 所示。
让事情变得更棘手的是,这个框架是确保积极的消费者体验的关键部分。那么我们能做些什么呢?我们如何在客户体验的每一个方面与快速的应用程序发布时间之间取得平衡?
通常,一种好的方法是首先将任何计算开销较大的启动函数转移到启动过程的较后部分,然后从那里重新评估。在我们的例子中,我们只是在流程的后期调用或引用框架中的类,但框架仍然阻塞我们的启动时间;为什么?
当应用程序启动并加载到内存中时,动态链接器(dyld)负责让它准备好。Dyld 的步骤之一是扫描动态链接的框架并调用它可能具有的任何模块初始化函数。Dyld 通过查找标记为 0x9(S_MOD_INIT_FUNC_POINTERS) 的节类型来实现这一点,这些节类型通常位于“_DATA”段中。
找到之后,dyld 将一个 Boolean 变量设置为 true,并在随后的另一个阶段调用初始化器。
所讨论的第三方框架总共有九个模块初始化器,由于 dyld,所有这些初始化器都被授权在我们的应用程序运行 main() 之前运行。这九个初始化器归因于延迟我们应用程序启动的总成本。那么我们该如何修复它呢?
有几种方法可以解决延迟问题。一个流行的选项是使用 dlopen 并为尚未解析的函数编写包装器接口。然而,这种方法意味着失去编译器的安全性,因为编译器无法再保证编译时框架中存在某个函数。这个选项还有其他缺点,但编译安全对我们来说最重要。
我们还联系了第三方开发人员,要求他们将模块初始化器转换为一个简单的函数,我们可以在空闲时调用它。不幸的是,他们还没有回复我们。
相反,我们采用了一种与众所周知的方法略有不同的方法。这样做的目的是欺骗 DYLD,使其认为它正在查看常规部分,从而跳过调用模块初始化器。然后,在稍后的运行时,我们将使用 dladdr 获取框架的基地址,并在已知的静态偏移量处调用初始化器。我们将通过在编译时验证框架的散列、在运行时验证节以及检查节标志是否已经被替换来实施这种偏移。考虑到这些安全保障和总体计划,我们成功地推出了这个优化,并使应用程序的启动速度额外提高了 36%。
在任何优化过程中,精确地确定性能瓶颈和机遇往往是最困难的。大家都知道,一个常见的错误是测量 A,优化 B,得出 C。在这里,优秀的性能工具可以帮助你凸显瓶颈,并让它显现出来。作为 Xcode 的一部分,Xcode Instruments 提供了几个模板来帮助确定 macOS/iOS 应用程序中的各种潜在问题。但是为了增加粒度和易用性,EmergTools 提供了一个简化的应用程序性能视图和它们的性能工具。
菲利普·布斯克(Filip Busic),DoorDash 软件工程师,自 2020 年 3 月以来,他一直致力于 iOS 性能、降低应用程序二进制大小以及其他稳定性改进方面的工作。他也是开发人员社区的热心成员,喜欢在空闲时间建立家庭实验室。
原文链接:
https://doordash.engineering/2023/01/31/how-we-reduced-our-ios-app-launch-time-by-60/
声明:本文为 InfoQ 翻译,未经许可,禁止转载
没有 NGINX 和 OpenResty 的未来:Cloudflare 工程师正花费大量时间用 Rust 重构现有功能
开源意味着不问责,我们准备好应对比 Log4Shell 更大的安全危机了吗?|Log4j 一周年特别报道
阿里过去一年裁员达19000人;字节跳动布局中国版 ChatGPT;马斯克称下周将开源推特算法代码 | Q资讯
微信扫码关注该文公众号作者