iOS上的动态库懒加载与插件架构
摘要
深入探讨iOS上动态库懒加载与插件架构实现,分析静态链接与动态链接之间的性能权衡。
<p><a href="https://lobste.rs/s/g8diwe/lazy_loading_dynamic_libraries_plugin">评论</a></p>
查看缓存全文
缓存时间: 2026/06/28 18:01
# iOS 上的动态库懒加载与插件架构
来源:https://medium.com/@cjckytxz/lazy-loading-dynamic-libraries-and-building-plugin-architectures-on-ios-challenge-accepted-a554fccdb84c
Scott Yelvington (https://medium.com/@cjckytxz?source=post_page---byline--a554fccdb84c---------------------------------------)
按回车或点击查看完整尺寸图片
几周前,我和丈夫在日本进行了一场旋风般的度假之旅,玩得很开心。当然,我想彻底放松,所以在空闲时偶尔会刷刷社交媒体,一条硬核的技术帖吸引了我的注意(没错,我就喜欢这种深奥又硬核的内容)。那位开发者勇敢地深入探讨了动态库与静态库在性能上的权衡。作为一位曾在 Xcode 构建系统上研究了一年的前桌面端开发者,我自然而然地被卷入了讨论,最后甚至被挑战去证明在 iOS 上仍然可以实现动态库的懒加载。接受挑战!
简单回顾一下:早在 2000 年代初期,Linux 社区引入了动态库的概念。想象一下当时的场景:二进制文件变得越来越大,延迟也比现在高得多。如果你在一个小团队里,想更新源码中的某个模块,却不想推送整个新构建包,怎么办?像 rsync 这样的工具在当时还是新事物,它通过在高延迟网络上快速交换哈希值来确定远程端系统的文件变化。或者,也许你担心的是应用启动时间随着应用增长而膨胀。再从构建工程师的角度想想:假设你在发布 Adobe Photoshop 或 Microsoft Word,用户遇到了启动缓慢的问题,而且用户在整个应用生命周期中可能只接触到实际源码的 5% 甚至更少,更别说完成一次成功的启动了。
你真的需要一个巨大的单一二进制文件来启动应用吗?你真的想在延迟较高的连接上,因为推送的微小更新只影响总二进制文件的一小部分,而冗余地将未变化的模块反复推送到 CDN 吗?作为一个有进取心的构建工程师,你可能会自然地得出结论:与其在编译时链接一个庞大的单一二进制文件,不如构建一个在启动时或之后链接的二进制文件树。于是,动态库诞生了。虽然静态库在很多情况下很有优势,但动态库与插件架构配合得很好,允许开发者选择何时将适当的代码模块加载或卸载出内存。
## 想象这样一个现代插件架构
你在构建 Facebook 应用。在启动时,你知道用户需要一些基本功能:肯定需要 UI 组件系统,因为显然要有界面;需要基础模型和 API 层,因为应用是云端的;用户可能在启动后不久就会看到视频和照片,所以需要核心媒体解码库来解码图像和视频。但然后呢?你需要摄像头和特效模块来实现那些酷炫的 AR 自拍吗?不需要。日历和/或事件模块呢?不需要。市场模块呢?实际上,如果你的用户还没有认证,你是否想通过只加载安全、UI 和 API 模块来进一步缩短启动时间?
这就是动态库和插件架构大放异彩的地方。对于大多数应用的大多数开发者来说,最好还是坚持将依赖静态编译成一个单一二进制文件。但在那些用户只需要源码个位数百分比就能启动、甚至在整个应用生命周期中都只需少量代码的大型应用中,动态库就非常有意义了。应用可以根据条件决定何时将某个模块加载到内存中。
## 但具体怎么做?
我们将构建一个示例 POC 应用,它加载一个动态库。为此,我们需要做以下 4 件事:
1. 创建一个示例框架、一个接口框架和一个示例应用,并将这些框架加载到示例应用目标的构建图中。
2. 配置我们的构建架构,确保示例框架在编译/链接时不进行链接。
3. 将接口框架添加到示例框架中。
4. 在运行时按需加载示例框架,并验证它在启动时并未加载。
## 步骤 1:创建应用和动态库
在 Xcode 中,首先创建一个名为 `MyHappyApp` 的 SwiftUI iOS 应用项目。你还需要创建两个框架项目,分别命名为 `MyHappyFramework` 和 `MyHappyInterfaceFramework`。注意,我没有创建工作区,因为除了显式链接的框架外,模块之间不共享代码。打开应用项目,进入项目设置和 MyHappyApp 构建目标。
按回车或点击查看完整尺寸图片
在 General 标签页下,向下滚动,找到名为 `Frameworks, Libraries, and Embedded Content` 的区域。这是一个我们都很熟悉的常规区域,用于指定我们要嵌入到应用包中最终成为 IPA 文件的实体。你可能注意到我导入了不少依赖。稍后我会解释。点击加号按钮,然后在屏幕底部点击名为 `Add Other...` 的下拉菜单,选择 `Add Files...`。导航到 MyHappyFramework 项目的根目录并添加它。你可能需要清理构建文件夹 (command+shift+K),然后再次点击加号,从菜单顶部选择 MyHappyFramework.framework。转到你的构建阶段,选择 `Embed Frameworks` 阶段,然后将目标位置改为 `Shared Frameworks`。清理并重复此操作以添加 MyHappyInterface.framework,但不要调整构建阶段。这会将两个二进制文件都添加到你的嵌入内容中,但将它们嵌入在不同的构建阶段(输出目标)中。
务必为这两个二进制文件保持启用 `Embed & Sign`。在继续之前,稍微回顾一下历史。有经验的开发者可能记得,以前必须在这个部分导入所有框架引用。例如,你不能仅仅在某个头文件中写 `import CoreImage`,编译器不知道你在说什么。这过去是、现在仍然是定义实际嵌入并打包到应用包中的内容的起点。接下来我将解释这发生了什么变化。
## 步骤 2:配置构建架构
默认情况下,当我们声明新的嵌入内容时,如果它不是系统内容(例如动态系统库,如 SwiftUI、UIKit、AVFoundation 等),Xcode 会自动对你的构建架构进行一些调整,以简化你的工作。首先,它会将该库添加到你的 `@rpath` 中(如果尚未通过继承或声明的搜索路径发现)。其次,它会将其添加到你的 `Link Binaries with Libraries` 构建阶段。第三,它会将其添加到你的 `Embed Frameworks` 构建阶段。
我们先来谈谈 `@rpath`。当你的应用成为一个包含可执行文件的包时,该可执行文件会有一个头部。头部中有不同偏移量的命令,告诉操作系统执行某些操作,例如在不同搜索路径下加载和链接二进制文件。你可以使用 `otool` 来检查这种行为。随意构建并运行。然后转到 Derived Data,在构建产品中找到你的应用。使用 `otool -L [你的应用包的可执行文件路径]` 来指向它。这会打印出任何指定在启动时加载的动态库(dylib)。这些是出现在二进制文件头部中的 dylib。如你所见,在调试模式下,它会加载一个系统 dylib 和一个特定于应用的调试 dylib,允许 lldb 直接与你的应用及其符号交互。非常重要。你也可以将 otool 指向你的任何一个框架,检查它们在启动时被声明加载的 dylib。但让我们使用 otool 打印所有内容的加载命令。运行 `otool -l [你的应用包的可执行文件路径]`。这将是一个很长的列表,但它有特定的结构。首先是命令编号(毕竟这是一个序列)。然后是 `cmd`,即给操作系统的命令。特别关注 LC_RPATH 命令。它们声明了一个目录,用于查找实体以找到 dylib 并执行修复。你应该会看到一个类似这样的命令:
```
Load command 17
cmd LC_RPATH
cmdsize 40
path @executable_path/Frameworks (offset 12)
```
猜猜这是做什么的?它告诉 `dyld`(iOS 的动态库加载器)去查找子目录 `Frameworks`,并开始搜索其中的任何二进制文件。Dyld 在找到库时会做很多工作。但稍后我会再谈这个。
在你的应用目标的构建设置中,我们来配置构建架构。在右上角的过滤字段中,输入 `rpath`。从 `Runpath Search Paths` 字段中删除所有不是 `@executable_path/Frameworks` 或 `inherited` 的内容。然后转到 Build Phases 标签页,进入 Link Binary With Libraries 阶段,从链接阶段中移除 MyHappyFramework.framework,但保留接口框架。
为了万无一失,让我们***确保*** Swift 和 clang 不会链接示例框架。回到构建设置,在过滤字段中输入 `framework`,向下滚动到 `Swift Compiler — General`,将 `Link Frameworks and Libraries Automatically` 设置为 `No`,将 `Skip Automatically Linking All Frameworks` 设置为 `Yes`。还记得我之前提到有经验的开发者记得必须将框架添加到目标嵌入部分,并且这后来发生了变化吗?原因就在于此。Swift 在某个时候变得更加“安全”。它会搜索你的代码以查找 import 声明甚至符号引用。如果这些标志设置为自动链接,并且你的代码中有 import 或泄漏的符号,Swift 编译器***会强制链接器自动链接该框架。***如果没有将懒链接标志标记为 true,那么该 dylib 将在启动时被链接。通过关闭这个选项,我们强制自己明确声明每个我们希望被链接和加载的依赖。
在继续之前,最后一步:回到 General 标签页下的 Frameworks, Libraries, and Embedded Content 部分。还记得上面截图中所有显式声明的框架吗?清理并编译,观察构建系统抱怨它们缺失。添加上面列表中的所有框架。然后再次清理并编译。你可能需要在添加每个新依赖之前进行清理。如果没有错误,说明一切已正确添加。
有趣的事实:我有时会幻想在一个低调的 PR 中恶意关闭 Swift 自动链接构建标志,然后观察世界各地的初级开发者发现他们不能再随意写 import 来导入任何东西时的混乱场面。
## 步骤 3:添加接口框架来桥接动态库和应用的源码
虽然我可以偷懒,直接在 `Other Linker Flags` 下添加懒加载标志,但这样做有些风险。记住,在 Swift 中,如果我接触到任何包含对我们 dylib 的符号引用的实体,那么 dylib 就会立即被加载。如果它是一个庞大的 dylib,并且引用在 UI 中,那就会造成我们无意中引入的 UI 卡顿。但更重要的是,我们正在尝试构建一个插件架构!毕竟,我们想要明确控制 dylib 何时被加载,不是吗?让我们关闭这个项目,打开我们的示例框架 MyHappyFramework。转到 General 标签页下的 Frameworks, Libraries, and Embedded Content 部分。点击加号按钮添加一个框架。然后导航到 `Add Other...` 和 `Add Files...`。添加 MyHappyInterface 项目目录。然后将该框架添加到目标。
你可能会想……等等,我们不是刚刚把它添加到主应用目标了吗?你说得对。然而,当我们构建时,构建系统会检测到该依赖被添加了两次。毕竟,很多依赖都依赖于公共 API,对吧?例如,两个框架可能依赖于 AFNetworking 来运行。如果不需要,我们难道要把它们编译两次吗?当然不!但这一优化允许我们玩一个巧妙的小把戏。我们可以使用一个共享的动态库,在应用和所有动态库之间创建一个共享的接口。这样,应用在启动时只加载一个轻量级的接口框架,而将其他重量级框架留在内存之外,直到需要时才加载!这样做的好处是,它允许我们在模块之间轻松注入模拟(mock)和桩(stub)。
## 步骤 4:充实运行时逻辑
我们的构建架构已经搭建好了。现在让我们充实用于懒加载 dylib 的运行时逻辑。首先,我们来构建一个将在应用和示例框架中都使用的接口。打开 MyHappyInterfaceFramework 项目。转到主 Swift 文件,将这两个代码片段放进去:
```swift
import Foundation
@objc
public protocol MyHappyInterfaceExporter {
func export() -> Any
}
public protocol MyHappyInterface {
func sayMyName() -> String
}
```
其次,让我们转到 MyHappyFramework 示例项目,打开主 Swift 文件,导入我们的接口。然后构建一个导出器。
```swift
import Foundation
import MyHappyInterfaceFramework
@_cdecl("exportMyHappyInterface")
public func exportMyHappyInterface() -> MyHappyInterfaceExporter {
InterfaceExporter()
}
open class InterfaceExporter: MyHappyInterfaceExporter {
open func export() -> Any {
SayMyName.shared
}
}
open class SayMyName: MyHappyInterface {
public static let shared = SayMyName()
open func sayMyName() -> String {
"Heisenberg"
}
}
```
我们做了什么?我们使用 c 声明标志创建了一个 Swift 函数,可以通过一个非重整的唯一名称动态查找它。由于这是一个 C 符号,我们使用了一个单一的 Objective-C 协议和一个只做一件事的合规类:将单例导出给调用者。然后我们创建了我们的合规单例,并使其符合共享接口框架中的接口协议。当这个库被加载和链接时,它将使用一个共享协议,因此模块之间共享符号,将所有内容链接在一起。
第三,让我们打开 MyHappyApp 项目,在一个名为 LibLoader.swift 的文件中创建一个通用的框架加载器。
```swift
import Foundation
func loadFramework(at path: String) throws -> UnsafeMutableRawPointer? {
guard let handle = dlopen(path, RTLD_NOW) else {
if let error = dlerror() {
throw NSError(domain: "dlopen", code: 1, userInfo: [NSLocalizedDescriptionKey: String(cString: error)])
}
throw NSError(domain: "dlopen", code: 2, userInfo: [NSLocalizedDescriptionKey: "Unknown error"])
}
return handle
}
```
这接收一个文件路径,然后调用 Darwin 内核中的一个名为 `dlopen` 的函数。dlopen 会启动 Dyld。Dyld 是 iOS 的安全 API,它将二进制文件加载到内存中,对其进行哈希并与应用包的代码签名进行比对,然后随机化并映射传入 API 之间的地址指针,以防止某些注入攻击。如果该二进制文件头部中有对其他二进制文件的引用,它会递归重复这个过程,直到所有内容加载完毕。因此,通过调用带有 dylib 路径的 dlopen,我们懒洋洋地启动了一个通常在启动时发生的过程。从 dlopen 返回的不安全指针就是示例框架在内存中偏移量的实际地址!
第四,让我们为特定的接口构建一个加载器来管理它作为资源。让我们创建一个名为 `MyHappyInterfaceLoader.swift` 的文件。
相似文章
Bug Archeology:借助LLM解开一个十年的Swift/C++谜题
一位开发者讲述了如何利用LLM解决一个Swift/C++跨平台音乐应用中存在十年的Bug,展示了AI如何协助调试复杂问题。
膨胀
一位开发者反思了应用中混乱的核心数据结构,承认技术债的存在,但表示除非它阻碍AI代码生成,否则不会优先修复。
探索MTE领域:iOS内存保护深度解析
深入探讨iOS内存标签扩展(MTE)在内存保护方面的实现与安全影响。
在dsymutil中采用并行DWARF链接器
苹果的dsymutil工具用于将DWARF调试信息链接到自包含的捆绑包中,现在正在采用并行DWARF链接器来解决类型去重中的单线程瓶颈,尽管由于输出并非二进制完全相同而在验证方面面临挑战。
学习软件架构
一位软件工程师分享了学习软件架构的见解,强调组织架构与激励机制优先于代码本身,并结合 rust-analyzer 与科学计算代码的实例进行了说明。