将 iOS 应用体积缩小一半的秘籍:妥善运用动态框架
每个开发新手,在编写软件前都听说过这样一条原则:“别自我重复”。但 App Store 上不少体量最大的 iOS 应用却仍在犯下同样的致命错误:不必要地照搬整个模块。
以现代汽车发布的 MyHundai 应用为例,这款软件可供车主轻松访问车辆的服务历史记录并申请道路救援。
看看我们分析后得出的大块红色片段——这些就是资产目录中重复的部分,而且在应用程序包中整整被照搬了三回。
这当然不纯是因为现代汽车的开发者特别喜欢.car 文件,而是 iOS 扩展中的部件(MyHyundaiWidget)和共享扩展(MyHundaiSharePoi)都以沙箱化的形式与应用本体各自保持独立。
所以除非大家小心谨慎地规划应用架构,否则就很容易犯下我们在 MyHyundai 软件中看到的错误:将共享 UI 库同各个目标静态链接。
静态库虽然表面上是在共享代码,但实际上却被单独打包在每个目标的编译二进制文件当中(在本示例中就是 1 个应用加 2 个扩展),而这很可能会导致不必要的重复。
教科书式的解决方案并不复杂:对于在各目标之间共享的模块,应该将其链接为动态框架,而非静态库。
不同于将模块副本嵌入到各个目标当中,动态框架会将各模块独立存放在.app 捆绑包的 Frameworks/ 文件夹内,再由 dyId 在启动时将其链接至您的应用(或者扩展)。
在实践当中,特别是当大家的应用软件用到 Swift Packge Manager 提供的现代多模块架构时,对模块的动态链接往往会被隐藏起来。
所以这里我们需要做一点调整。
本文将以简单的开源教程项目 EmergeMotors 为例,带大家从存在问题的 Before/ 文件夹入手,以结对编程的形式不断改进架构,直至与 After/ 完全吻合。期间我们会随时分析调整对于应用程序大小的影响。
EmergeMotors 其实是受到 MyHyundai 应用的启发,假设这是一款人气颇高的新应用,主要功能是……查看汽车照片。其中配有共享扩展和部件扩展,均可用于显示汽车图像。
与各类现代应用一样,EmergeMotors 拥有一个专用的 UI 库 EmergeUI,其中包含常用组件及资产。这一切都将被导入至全部三个目标当中:应用本体、共享扩展和部件扩展。
于是乎,EmergeMotors 自然也就与 MyHyundai 应用面临相同的架构问题:二进制文件中的 UI 包被照搬了三次。
除了资产之外,EmergeUI 视图代码和 Lottie 子依赖项也被单独与各二进制文件捆绑在了一起。
如前所述,解决这个问题的标准方案就是将静态链接的 EmergeUI 库转换为动态框架。
默认情况下,Xcode 允许开发者选择以静态还是动态方式链接 Swift 包。而在实际操作中,它总是会直接将包捆绑为静态库。
大家可以将包的库类型指定为.dynamic 来要求 Xcode 动态接入 Swift 包:
// EmergeUI/Package.swift
let package = Package(
name: "EmergeUI",
platforms: [.iOS(.v16)],
products: [
.library(
name: "EmergeUI",
type: .dynamic,
targets: ["EmergeUI"]),
],
dependencies: [
.package(url: "https://github.com/airbnb/lottie-ios.git", .upToNextMajor(from: "4.4.1")),
],
targets: [
.target(
name: "EmergeUI",
dependencies: [.product(name: "Lottie", package: "lottie-ios")]
)
]
)
好了,现在咱们的库“动”起来了!
大家可以查看 Xcode 中的主项目来检查是否设置成功。
对于静态库,框架、库和嵌入内容中的“Embed”下不会存在与模块相关联的选项。而将库类型设置为动态后,则会出现一个下拉菜单,我们可以在其中指定如何嵌入框架(如果仍无显示,请通过文件、包、重置包缓存的方式强制刷新)。
确保您的主应用目标将框架设置为“Embed & Sign”,这样即可确保框架被复制到应用程序包内并使用您的配置文件与证书对代码进行签名。
我们的扩展目标应使用“Do Not Embed”不嵌入选项,以避免在应用程序包中制作额外的副本。
现在,我们的 Swift 包已经成为动态框架。
除了包内定义的代码之外,各子依赖项(包括第三方库)现在也已成为动态链接框架的一部分,即使子依赖项本身仍为静态。
通过这种方式,我们甚至可以将多个库打包进同一伞形框架之内,并向用户开放统一的公共接口,就如同只导入单一模块一样。
苹果一直使用伞形框架(导入 Foundation、导入 UIKit、导入 AVKit……),但除非大家明确知道自己在做什么,否则常规方案一般不建议使用这种粗暴的方法。
现在我们已经在 Package.swift 中定义了动态框架,并告知 Xcode 如何将其链接至各个目标(包括框架、库和嵌入内容),现在我们可以保存 EmergeMotors 并尝试分析。
好吧,看来我们还有很长的路要走。
虽然我们共享的 EmergeUI 库代码和第三方 Lottie 依赖项都被顺利打包成了框架,但占比最大的组件 EmergeUI.bundle 仍然被捆绑到了各目标当中。
直接检查我们的 xcarchive 文件,我们即可查看.app 包内部(右键单击 + 显示包内容)并观察 EmergeUI.bundle 本体。
资产目录与 Lottie JSOn 被统一打包起来并静态链接至各个目标。对于资产密集型模块来说,这已经抵消了使用框架带来的大部分好处。
现在,如果大家的共享模块主要是代码——比如第三方依赖项的打包器、内部 SDK 或者某些子模块的伞形框架——那么优化工作已经完成了。用默认 SwiftPM 方法创建动态框架已经可以带来很好的效果。
但如果您的应用不幸跟我们的示例类似,即共享代码中包含大量资源,那么 Swift Package Manager 就会严重限制优化效果。
这个问题当然也可以解决,甚至仍旧可以通过 SwiftPM 来实现。但这样肯定会破坏我们精美的包架构。
如果各位已经是经验丰富的 SwiftUI 老手,而且习惯了用 UIKit 来访问更复杂的功能,那么接下来我要展示的方法在本质上是相同的,只是操作起来更加友好。
免责声明:整个设置过程确实有点烦人,而且每次更新共享资源时都会带来沉重的运行开销。所以在让架构复杂化之前,请确保各个目标是否确有必要共享资产。或者,大家也可以考虑为每个目标单独创建最小资产模块,以最大程度减少重复。
我的这门资产标准化秘方包含四个步骤:
创建一个新的 Xcode Framework 并将共享资源转移过去。
使用二进制目标创建一个新的 Swift 包。
为每个架构建立框架,并将 build 输出打包在 xcframework 当中,由上述二进制目标进行引用。
将新包导入至现有动态库中。
这里我创建了一个名叫 EmergeAssets 的新 Xcode 项目,并把资产目录和 JSON 资源全部转移过去(记得检查目标的成员身份!)。
为了便于量化,我还创建了下面这条重要的辅助函数。
// EmergeAssets/EmergeAssets/BundleGetter.swift
public final class BundleGetter {
public static func get() -> Bundle {
Bundle(for: BundleGetter.self)
}
}
这样我们就能从其他模块处引用 EmergeAssets 包内的资产:
// EmergeUI/Sources/EmergeUI/Car/Car.swift
import EmergeAssets
public struct Car {
// ...
public var image: Image {
Image("(id)", bundle: EmergeAssets.BundleGetter.get())
}
}
接下来,我创建了一个新的 Swift 包,然后毫不意外将其命名为 EmergeAssetsSPM。
作为一个打包器包,它的架构非常简单:
EmergeAssetsSPM/Package.swift
let package = Package(
name: "EmergeAssetsSPM",
products: [
.library(
name: "EmergeAssetsSPM",
targets: ["EmergeAssetsSPM"]),
],
targets: [
.binaryTarget(
name: "EmergeAssetsSPM",
path: "EmergeAssets.xcframework"
)
]
)
这里的 binaryTarget 正是关键。
二进制目标经过预编译,以确保我们的资产包已被整齐打包在框架之内。也就是说编译器不会对其进行构建,也不会将其重新捆绑至各个目标当中。
起初,除了 Package.swift 和这个神秘的 shell 脚本: generate_xcframework.sh,EmergeAssetsSPM 包中再无其他文件。
我们可以使用 xcodebuild 命令行工具来创建二进制框架。
我编写了一个 shell 脚本,用于构建本地 EmergeAssets 框架,并将我需要的架构变体(iOS+ 模拟器)打包进 xcframework 当中。该 xcframework 可以作为 EmergeAssetsSPM 的二进制目标进行导入。
// EmergeAssetsSPM/generate_xcframework.sh
# /bin/bash!
# Build framework for iOS
xcodebuild -project ../EmergeAssets/EmergeAssets.xcodeproj -scheme EmergeAssets -configuration Release -sdk iphoneos BUILD_LIBRARY_FOR_DISTRIBUTION=YES SKIP_INSTALL=NO
# Build framework for Simulator
xcodebuild -project ../EmergeAssets/EmergeAssets.xcodeproj -scheme EmergeAssets -configuration Release -sdk iphonesimulator BUILD_LIBRARY_FOR_DISTRIBUTION=YES SKIP_INSTALL=NO
# To find the Build Products directory, you can either:
# 1. Manually build the framework and look in Derived Data
# 2. run `xcodebuild -project EmergeAssets.xcodeproj -scheme EmergeAssets -showBuildSettings` and search for BUILT_PRODUCTS_DIR
PRODUCTS_DIR=~/Library/Developer/Xcode/DerivedData/EmergeAssets-fuszllvjudzokhdzeyiixzajigdl/Build/Products
# Delete the old framework if it exists
rm -r EmergeAssets.xcframework
# Generate xcframework from build products
xcodebuild -create-xcframework -framework $PRODUCTS_DIR/Release-iphoneos/EmergeAssets.framework -framework $PRODUCTS_DIR/Release-iphonesimulator/EmergeAssets.framework -output EmergeAssets.xcframework
要亲自尝试,大家需要注意包含适用于所有目标平台的 SDK——要正常支持,请确保包含 macosx、appletvos、watchos 以及相应的模拟器。
虽然我只构建了发布配置,但在试验过程中调试构建仍然顺利通过,大家的实操结果可能会有所不同。
最后,我们的 EmergeUI 模块可以导入 SwiftPM 打包的框架以作为常规本地包依赖项。
// EmergeUI/Package.swift
let package = Package(
name: "EmergeUI",
platforms: [.iOS(.v16)],
products: [
.library(
name: "EmergeUI",
type: .dynamic,
targets: ["EmergeUI"]),
],
dependencies: [
.package(url: "https://github.com/airbnb/lottie-ios.git", .upToNextMajor(from: "4.4.1")),
.package(path: "../EmergeAssetsSPM")
],
targets: [
.target(
name: "EmergeUI",
dependencies: ["EmergeAssetsSPM", .product(name: "Lottie", package: "lottie-ios")]
),
.testTarget(
name: "EmergeUITests",
dependencies: ["EmergeUI"]),
]
)
在解决这个重大架构难题之后,我们的项目终于构建完成了。我们的全部三个目标(应用程序、共享扩展与部件扩展)均能按预期正常工作。
经过 归档和分析,我们看到了以下结果——终于舒服了。
资产目录(及 Lottie JSON)在 EmergeAssets.framework 中彼此独立地和谐共存。EmergeUI 框架保持单独链接,两个扩展插件几乎微不可见——只要不照搬非必要资源,它们本可以如此小巧!
安装包大小也从 32.3 MB 急剧缩小至 13.7 MB。
我可不是要盲目宣传动态框架,它也有自己的缺点,而且最直接的影响就是大大拖慢应用程序的启动速度。
在应用程序启动的预主阶段,dyId 会将必要的框架链接至目标,确保所有可执行代码及资产均可访问。
我在各 builds 之间进行了快速性能分析,想要评估具体有何影响,最终得出了漂亮的焰形统计图。
这里的
以下就是 Before/ 中我们初始应用的启动性能统计。
优化之前,EmergeMotors 应用程序的启动性能统计。
以下是 After/ 瘦身优化之后的应用程序启动性能。
优化之后,EmergeMotors 应用程序的启动性能统计。
在本示例中,二者几乎没有统计学意义上的显著变化,意味着额外的动态链接对于启动时间的影响可以忽略不计。但我强烈建议大家分析自己的应用程序,在明确性能影响之后再做权衡。
苹果就是不愿意让我们简简单单、舒舒服服地搞开发。
他们在 Swift Package Manager 中提供了出色的第一方包生态系统,但却不愿认真解释要如何充分加以使用。
打包一个动态框架并不困难,但我们得经历很多莫名其妙的环节才能正确删除重复资产,并让应用程序保持“纤细苗条”。
但在一切尘埃落定之后,我们最终获得了令人惊叹的结果,比如应用程序的二进制文件大小缩减了 58%。欢迎大家亲自上手示例项目,体验这些秘密技术,并以类似的方式对自己的应用程序进行瘦身!
原文链接:
https://www.emergetools.com/blog/posts/make-your-ios-app-smaller-with-dynamic-frameworks
声明:本文为 InfoQ 翻译,未经许可禁止转载。
OpenAI 宣布:给开发者分钱!“飞书”裁员 20%,波及上千人;小扎亲自招人:无需面试即录用,年薪 1400 万 | Q资讯
微信扫码关注该文公众号作者