Eric Horacek

How we cut our iOS app’s launch time in half

Jun 22, 2017

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:

Launch time before and after

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.

Diagram of the how the frameworks are organized

How we merge many static frameworks into a single monolithic dynamic framework

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.

Tagged with: