How we cut our iOS app’s launch time in half (with this one cool trick)
Building with frameworks at Automatic has sped up our development workflow
and improved our code quality by enforcing strong boundaries between
our components. In total, our iOS app contains over 40 first and
third-party frameworks: one for consuming our HTTP APIs, another for
interacting with our hardware devices via CoreBluetooth, and so on.
Although this pattern has made development easier, it came with a cost: slow app launch times.
This slowdown happens because every dynamic framework adds overhead for dyld to do before an app’s
main()
function is called (known as “loading, rebasing, and binding”). In this WWDC 2016 talk,
Apple suggests replacing dynamic frameworks with static archives to
mitigate this. To take this approach, we rebuilt as many of our dynamic
frameworks as possible statically and then merged them into a single
monolithic dynamic framework named AutomaticCore.
We used
DYLD_PRINT_STATISTICS
to measure our app’s pre-main()
launch times with a cold dyld cache (after a reboot of iOS) before and
after we merged our frameworks. The difference was dramatic: our app’s launch time was cut in half:
Apple
wasn’t exaggerating when they said too many dynamic frameworks could
slow down your app’s launch time. However, merging our dynamic
frameworks wasn’t trivial: we had to rework our development workflow to
build our app this way from now on.
Here’s how
We use Carthage
for dependency management at Automatic. However, when we dug into what
it would take to rebuild our frameworks statically, we found that it was
not possible with the way that Carthage worked at the time. Thankfully,
Carthage is written in Swift, so it was easy to dive in and make the
necessary changes to support this.
It was not trivial to add support to Carthage for building static archives (
.a
files). However, with some minimal changes, we were able to update Carthage to support building static frameworks (.framework
files). Static frameworks are just like static archives, but packaged
differently. They have a similar structure to dynamic frameworks, but
with a static Mach-O file in place of a dynamic one.
Once
a new version of Carthage was released with our changes integrated,
building static frameworks was as simple as overriding some build
settings when building our dependencies (see below). This was great news
because we didn’t need to make changes to any of the third-party
Carthage-compatible projects we depend on to build them statically.
Let’s
quickly discuss what it takes for Xcode to produce a static framework
from a target that normally builds a dynamic framework. To build an
Objective-C dynamic framework target statically, you only need to
override one build setting:
MACH_O_TYPE = staticlib
. However, if you’re attempting to build a Swift dynamic framework target statically, things get a bit more complicated (Keith Smiley
of Lyft deserves kudos for this workaround). Since this Swift solution
works for both cases (Objective-C and Swift frameworks), it’s the path
we took. To briefly summarize the approach, we create a temporary xcconfig
file with the necessary build setting overrides, and then set that file as the XCODE_XCCONFIG_FILE
environment variable during our invocation of carthage build
.
This environment variable causes the build settings in this temporary
file to override the build settings defined by any project built with xcodebuild
(which Carthage uses under the hood). With this change, Carthage now produces static frameworks in Carthage/Build
directory instead of dynamic ones.
Next,
we needed to merge these static frameworks together into a single
monolithic dynamic framework. To do so, we first created a new dynamic
framework target in our application’s Xcode project. We named this
target
AutomaticCore.framework
,
since it would contain all of the “core” dependencies of our app and
its app extensions. We then linked each of our shared static frameworks
built by Carthage into this target. Linking a static framework into a
target is very similar to linking a dynamic one—just drag and drop the .framework
file into the “Link with Libraries” build phase. Next, we dynamically
linked the “AutomaticCore” framework into our application and its app
extensions, and finally embedded it into the Automatic.app package using
a “Copy Files” build phase with a “Frameworks” destination. To ensure
that all of the symbols from our static libraries were included in
AutomaticCore, we passed the -all_load
flag to the linker by adding it to the OTHER_LDFLAGS
build setting.
Since
we have app extensions that share many of the dependencies of our app
(e.g. a Today Widget), we needed to use a merged framework to prevent
duplication. If we were to statically link each of the shared frameworks
into both our app and our app extension separately, we would have
duplication between their executables, inflating our app bundle size.
However, for frameworks that our app depends on exclusively (e.g. our
framework for showing a support ticket composer interface), we found
that it was acceptable to statically link them directly into our primary
app, rather than
AutomaticCore.framework
.
We
needed to make some additional changes to get our frameworks containing
bundled resources working after we built them statically. When a
dynamic framework is linked and embedded within an application, its
resources are automatically made accessible to consumers. However,
unlike dynamic frameworks, static frameworks are not embedded—meaning we
needed to do some extra legwork to make their resources available. To
do so, we explicitly added any required resources from the root of the
static framework package to the target that it was linked into. This
change allowed our resources to be loaded successfully again. Depending
on the way that your frameworks reference resources, some code or bundle
changes may be required to get your resources loading successfully
again at runtime. For us, no additional changes were required.
Finally,
we found that we needed to add some additional linker flags to our
targets that link with static frameworks. Since some of our frameworks
use Objective-C extensions, we needed to pass the
-ObjC
flag via the OTHER_LDFLAGS
build setting to allow for Objective-C extensions in static frameworks
to be called without causing a runtime crash. Additionally, for
frameworks that depend on system libraries like libz
,
we had to link with them explicitly to fix missing symbols
errors—whereas previously this linkage was inferred. To do so, we
included flags like -lz
in the OTHER_LDFLAGS
as well.Conclusions
We
were delighted with the performance improvements we experienced after
merging our dynamic frameworks together. While we feared adopting this
pattern would force us to rearchitect our app or fork many third-party
dependencies, it surprisingly turned out to require only a small number
of changes. If you’re encountering similar pains with your app’s launch
times, we hope that our experiences will be helpful in guiding you to a
solution.
No comments: