Every fresh-faced junior knows the cardinal software sin:
However, some of the biggest iOS apps on the App Store commit the deadliest form of this sin: needless replication of entire modules.
Here's a typical example: the MyHyundai app, which allows drivers to easily access their vehicle's service history and receive roadside assistance.
Look at the large red blocks in our size analysis - these show duplication of the assets directory, which is copied into the app bundle three times.
This isn't just because the good people at Hyundai love .car
files. It's because iOS extensions such as widgets (MyHyundaiWidget
) and share extensions (MyHyundaiSharePoi
) are sandboxed separately from the app itself.
Therefore, unless you're very mindful of your architecture, it's easy to make the same mistake we see in MyHyundai: statically linking a shared UI library with each of your targets.
Static libraries, despite ostensibly being shared code, are packaged separately in the compiled binary of each target (here, that's 1 app plus 2 extensions), which can lead to unnecessary duplication.
The textbook solution is straightforward: for modules shared between targets, link them as dynamic frameworks instead of static libraries.
Instead of embedding a copy of the module into each target, frameworks live independently inside the Frameworks/
folder of the .app
bundle, and dyld links them to your app (or extension) at launch.
If you're unfamiliar with static libraries, dynamic frameworks, or dyld, get a primer on some theory with our article: Static vs Dynamic Frameworks on iOS - a discussion with ChatGPT.
In practice - particularly when your app is rolling a modern multi-module architecture with Swift Package Manager - it's not obvious how to link your modules dynamically.
Let's change that.
We'll work through a simple open-source tutorial project, EmergeMotors. We'll start in the slightly problematic Before/
folder and pair-program together; improving the architecture until it matches After/
. We'll analyse the app size impact of our changes as we go.
Enter EmergeMotors
Inspired by MyHyundai, EmergeMotors is the hot new app for... looking at photographs of cars. It's complete with a share extension and a widget extension which both, naturally, also display cars.
Like many modern apps, EmergeMotors has a dedicated UI library, EmergeUI
, which contains common components and assets. This is imported into all 3 targets: the app, the share extension, and the widget extension.
By sheer coincidence, EmergeMotors presents with the same architectural problem as MyHyundai: a tripled-up UI bundle in the binary.
As well as assets, the EmergeUI
view code and Lottie sub-dependency are also bundled individually with each binary.
As mentioned above, the textbook solution to this copy-pasted conundrum is to convert the statically-linked EmergeUI
library into a dynamic framework.
Making a Dynamic Framework with SwiftPM
By default, Xcode chooses whether to link a Swift package statically or dynamically. In practice, it always bundles your packages as static libraries.
You can tell Xcode to link your Swift Package dynamically by specifying the library type of your package as .dynamic
:
// 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")]
)
]
)
Hey presto! The library is now dynamic!
You can check this has worked by looking at your main project in Xcode.
With a static library, there is no option associated with your module under "Embed" in Frameworks, Libraries, and Embedded Content. Once you set the library type as dynamic, a dropdown menu appears, where you can specify how to embed the framework (if this still doesn't show up, force a refresh via File, Packages, Reset Package Caches).
Make sure that your main app target has the framework set to Embed & Sign, which ensures the framework is copied into the app bundle and code-signed with your profile & certificate.
Your extension targets should use the option Do Not Embed to avoid making additional copies in the app bundle.
Umbrella Frameworks
Your Swift Package is now a dynamic framework.
As well as wrapping the code defined in the package, sub-dependencies (including third-party libraries) are now a part of the dynamically-linked framework, even if the sub-dependency is static.
With this technique, you can even wrap many libraries in an umbrella framework and expose a unified public interface to consumers as if they're importing a single module.
Apple uses umbrella frameworks all the time (import Foundation
, import UIKit
, import AVKit
...), but it's generally recommended to avoid this heavy-handed approach unless you know what you're doing.
Early Results
Now that we have our dynamic framework defined in Package.swift
, and told Xcode how to link it for each target (in Frameworks, Libraries, and Embedded content), we can archive EmergeMotors and see how it looks.
Hmm... It looks like we still have a long way to go.
While our shared EmergeUI
library code and third-party Lottie dependency are both happily packaged as a framework, the heaviest component - the EmergeUI.bundle
- is still bundled into each target.
Inspecting our xcarchive
file directly, we can look inside the .app
bundle (right click + Show Package Contents) and check out EmergeUI.bundle
itself.
Both the asset catalog and the Lottie JSON are packaged into a bundle and statically linked to each target. For an asset-heavy module, this negates most of the benefits of using a framework.
Now, if your shared module is mostly code - for instance, a wrapper for third-party dependencies, internal SDKs, or an umbrella for a few sub-modules - then job well done. The default SwiftPM approach to creating dynamic frameworks works fantastically.
Unfortunately, if your shared code has lots of assets, we've bumped up against a serious limitation of Swift Package Manager.
Deduplicating Assets
It's possible to fix this problem. It's even possible to do so using SwiftPM. However, it requires desecrating your beautifully crafted package architecture.
If you're a SwiftUI veteran, you'll be used to dipping your toes into UIKit to access more complex functionality. The technique I'm about to show you is essentially the same thing, but for architecture geeks.
Assets
modules for each target to minimize duplication.There are 4 steps to this secret asset normalization technique:
- Create a new Xcode Framework and move the shared assets over.
- Create a new Swift package with a binary target.
- Build the framework for each architecture and wrap the build outputs in an
xcframework
, referenced by the above binary target. - Import the new package into your existing dynamic library.
Creating a Framework
OGs will be pretty familiar with this approach. I created a new Xcode project called EmergeAssets
and moved over my asset catalog & JSON resources (don't forget to check the target membership!).
For good measure, I created this vital helper function.
// EmergeAssets/EmergeAssets/BundleGetter.swift
public final class BundleGetter {
public static func get() -> Bundle {
Bundle(for: BundleGetter.self)
}
}
This allows us to reference the assets inside the EmergeAssets
bundle from other modules:
// EmergeUI/Sources/EmergeUI/Car/Car.swift
import EmergeAssets
public struct Car {
// ...
public var image: Image {
Image("(id)", bundle: EmergeAssets.BundleGetter.get())
}
}
Importing a binary target
Next, I created a new Swift Package, which I imaginatively coined EmergeAssetsSPM
.
As a wrapper package, its structure is very simple:
// EmergeAssetsSPM/Package.swift
let package = Package(
name: "EmergeAssetsSPM",
products: [
.library(
name: "EmergeAssetsSPM",
targets: ["EmergeAssetsSPM"]),
],
targets: [
.binaryTarget(
name: "EmergeAssetsSPM",
path: "EmergeAssets.xcframework"
)
]
)
This binaryTarget
is the key.
Binary targets are pre-compiled, ensuring that your assets bundle is already neatly packaged inside the framework. This means the compiler won't build it, and won't re-bundle it into each of your targets.
Initially, we have no files in the EmergeAssetsSPM
package, other than Package.swift
and this mysterious shell script: generate_xcframework.sh
.
Building our XCFramework
We can use the xcodebuild
command line tools to create a binary framework.
I wrote a shell script that builds the local EmergeAssets
Framework and packages up the architecture variants I want (iOS + Simulator) into an xcframework
, which can be imported as the binary target for 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
To use this yourself, you need to take care to include SDKs for all your target platforms - make sure, if you support them, you include macosx
, appletvos
, watchos
, and their corresponding simulators.
While experimenting with this, debug builds worked fine even when I'd only built the release configurations, but your mileage may vary.
Importing our Assets Framework
Finally, our EmergeUI
module can import our SwiftPM-wrapped framework as a regular local package dependency.
// 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"]),
]
)
The Results
With this rather substantial architectural segue out of the way, our project builds. All 3 of our targets (app, share extension, and widget extension) work as expected.
Upon archiving and analysis, we see a thing of beauty.
The assets catalog (and Lottie JSON) live a happy, singular life, wrapped in EmergeAssets.framework
. The EmergeUI
framework is linked separately, and the two extension plugins are barely visible - they are pretty small when they're not copying all our assets!
Install size has dropped drastically from 32.3MB to a breezy 13.7MB.
Launch Speed
I would be remiss to evangelise dynamic frameworks without explaining their downside: they can negatively impact app launch time.
In the pre-main phase of app launch, dyld links the necessary frameworks to the target, ensuring all executable code and assets are accessible.
I ran a quick Performance Analysis between builds to assess whether there was any impact, generating some nifty flame graphs in the process.
The <early startup>
phase is where dyld is linking the dynamic frameworks at launch. As well as linking our own EmergeUI
framework, dyld also links SwiftUI, Foundation, and Swift itself!
Below is the app launch profile for our original app from Before/
.
And here is the profile for our more storage-efficient app from After/
.
In this instance, no statistically significant change was found, meaning the additional dynamic linking had a negligible impact on launch time. However, I strongly suggest you profile your own apps to ensure you are mindful about the trade-off you're making.
Conclusion
Apple doesn't like to make things easy for us, do they?
They created a wonderful first-party package ecosystem in Swift Package Manager, but didn't put much work into explaining how to make the most of it.
It's easy enough to package a dynamic framework, however you need to jump through many undocumented hoops to properly deduplicate assets and make your app lightweight.
But when you do get it working, you can achieve awesome results like shedding 58% from your app binary size. Take the time to work through the sample project, understand these clandestine techniques, and apply similar improvements to your own apps!
This was an Emerge Tools guest post from Jacob Bartlett. If you want more of his content, you can subscribe to Jacob's Tech Tavern to receive in-depth articles about iOS, Swift, tech, and indie projects every 2 weeks; or follow him on Twitter.