EXPEDIA GROUP TECHNOLOGY — SOFTWARE

The Effects of Linking Dependencies on App Performance and Development

Can statically linked iOS Apps be both faster and smaller?

Corbin Montague
Expedia Group Technology

--

Person looking at white board with drawings of flow charts and app screens on it.
Photo by Christina Morillo from Pexels

Four years ago we only had around five engineers working on the iOS Vrbo™ (part of Expedia Group™) app. We now have well over 30 active contributors maintaining a much larger codebase. As you can imagine, major changes were necessary from both an organizational and architectural standpoint to allow us to scale. The key architectural change that enabled this scalability was the decision to modularize the codebase and establish patterns to support this new structure.

For those unfamiliar with the concept, modularization involves decomposing a software system into many discrete modules that can operate independently. It’s a software design technique perfect for teams looking to scale. This new structure has worked well for us so far, giving us faster builds when working within each module and making it easier to share code between teams.

However, like most solutions, there are trade-offs. Supporting this architecture requires us to build and link many dynamic frameworks and maintain additional code to stitch the modules together and allow each to function independently. Launch times, app size, and build times have increased as a result of this approach alongside new features that continue to be added. In an effort to improve these metrics and reduce developer friction, we began to spike this problem from different angles, starting here with dependency management.

The Goal

Look at the effects of linking dependencies statically vs dynamically across key app performance and development metrics. Our hypothesis was:

If most dependencies are linked statically we will produce a larger app with quicker launch times. If most dependencies are linked dynamically we will produce a smaller app with slower launch times.

We were unsure how build times would be affected.

The Results

I spiked several approaches in branches off of a repo similar to this one which acts as a template workspace that closely mirrors the structure of the actual iOS Vrbo mono-repo: a modularized, multi-brand app. Unfortunately, I can’t include the actual source code that my spike was based off of, but I’ll do my best to explain how each branch was setup below. At a high level, there is a single xcworkspace containing multiple projects:

  • App — A project containing two targets producing an executable, Lightside and Darkside (yes, I’m a Star Wars nerd), and one for UI tests. Both of the targets producing an executable depend on the targets listed below (Module1, Module2, and Core) along with three external dependencies: Alamofire, ApolloGraphQL™️, and Facebook.
  • Module1 — A project containing a single target: Module1. This contains code for defining a subset of the UX within the app (a single view controller and image asset in the case of this spike project). This target depends on Core and the three external dependencies listed above.
  • Module2 — A project containing a single target: Module2. Similar to Module1, this target contains code for defining a subset of the UX within the app and depends on Core and the three external dependencies listed above.
  • Core — A project containing a single target: Core. This contains low-level code that is shared by all targets listed above. It has no dependencies.
A table highlighting the results of this spike.
Spike Results

How The Data Was Gathered

First, let me clarify a few things around how I gathered this data so you can better form your own opinion:

  • All time values listed above are averages over five runs. I know this is a small sample size, but given how time consuming it was to build out all these approaches and gather the metrics, I had to draw the line somewhere.
  • App launch times were gathered using this UI test.
  • For clean builds, I used Xcode’s Product → Clean Build Folder action before building.
  • For rebuilds (aka incremental builds), I made a slight modification to Module1’s view controller before building. The same modification was made before every rebuild.
  • The SpikeMixedLinking branch is setup to build internal dependencies (Module1, Module2, and Core) as frameworks with a Dynamic Library Mach-O Type. These dependencies are linked dynamically into targets that depend on them, and embedded into targets producing an executable (Lightside/Darkside). This is a common dynamic linking setup. Here’s the complicated part: The external dependencies (Alamofire, Apollo, and Facebook) are pulled in as Swift packages producing static libraries. They are statically linked into an umbrella framework called CoreDependencies which is defined as a target within the Core project. CoreDependencies is dynamically linked into all the internal targets that depend on those external dependencies and embedded into targets producing an executable. This is a necessary workaround to avoid duplicate symbol errors, which is what we would see at compile time if we tried to statically link these external dependencies into all the targets that depend on them. This approach most closely mirrors how the actual iOS Vrbo app is setup.
  • The SpikeStaticLinkingWithFrameworks branch is setup to build internal dependencies as frameworks with a Static Library Mach-O Type. These dependencies are statically linked into targets that depend on them, but not embedded into any target since linking them statically means they will already be copied into the app’s executable. Since static libraries can’t easily share resources, those are contained within a separate resource bundle that is copied into the Lightside/Darkside targets. The external dependencies are pulled in as Swift packages producing static libraries and linked into every target that depends on them.
  • The SpikeStaticLinkingWithLibraries branch is setup exactly the same as the SpikeStaticLinkingWithFrameworks branch, but internal dependencies produce static libraries directly instead of frameworks containing static libraries.
  • The SpikeStaticLinkingWithSwiftPackages branch is setup to build internal dependencies as local Swift packages producing static libraries. Those dependencies are statically linked into targets that depend on them. External dependencies are pulled in as remote Swift packages producing static libraries and linked into targets that depend on them.
  • The SpikeDynamicLinkingWithXCFrameworks branch is setup to build internal dependencies as frameworks with a Dynamic Library Mach-O Type. These dependencies are dynamically linked into targets that depend on them and embedded into targets producing an executable. External dependencies are managed and built by Carthage as XCFrameworks and dynamically linked into targets that depend on them, while also being embedded into targets producing an executable. The exception to this is Facebook which doesn’t support that setup well, so it’s still pulled in as a Swift package using the umbrella framework approach mentioned above.

What Do These Results Mean?

App Size

Right off the bat we have a stunner. Statically linking all dependencies (internal and external) using Swift packages produces the smallest possible app size while linking them dynamically using XCFrameworks produces the largest. This directly contradicts our hypothesis! If we look at the size of the executable we find the opposite is true, but this makes sense given the binaries of our dependencies are copied directly into the executable when linked statically. So dynamically linking our dependencies did give us a smaller app executable, but not a smaller app (which is what we really care about). Why might this be the case? Well, only the static linker can strip dead code, which will reduce app size. So while the static linker is allowing us to embed a stripped down version of the Module1, Module2, Core, Alamofire, Apollo, and Facebook binaries into the app’s executable, the dynamic linker has to include the entire binary for each of those dependencies! It’s a feature many developers, myself included, often overlook when considering the trade-offs between static and dynamic linking, but if code stripping is enabled, statically linking your dependencies can actually produce a smaller app. Apple has some documentation around dead code stripping here (it’s old, but still accurate AFAIK).

🥇 Winner — SpikeStaticLinkingWithSwiftPackages

App Launch Time

Things continue to stay interesting when we look at app launch times across simulators and physical devices. We expect to see longer launch times when linking dependencies dynamically and quicker launch times when linking statically. This hypothesis holds true on physical devices, however not on simulators. Just a guess here, but maybe Apple isn’t randomizing the memory map on simulators since there isn’t a reason to worry about security vulnerabilities. Or perhaps the dynamic linker just runs faster on x86 architecture (I don’t have an Apple Silicon Mac yet)? I’m not entirely sure why this is the case, but we only really care about app launch times on a physical device anyway, so the winner is clear.

🥇 Winner — SpikeStaticLinkingWithSwiftPackages

Build Times

This metric was our big unknown. SpikeDynamicLinkingWithXCFrameworks is the clear winner for clean builds and also performs well when rebuilding on simulators. However, SpikeStaticLinkingWithSwiftPackages manages to post the best rebuild times across both simulators and physical devices. To understand how we feel about this trade-off, we have to consider how developers typically behave. When engineers are developing they usually spend most of their time building against simulators, often only building against physical devices near the end of their work to verify changes look good there as well. This isn’t always the case, but speaking with many colleagues, this notion generally holds true. This means build times for physical devices shouldn’t matter to us as much as simulator build times. Clean build times are also not as important. Once you’ve compiled everything once, most development will be spent building in situations where only a subset of the code needs to be recompiled. This means out of all four columns in the table dedicated to build times, Re-build Time (simulator) is the one we care about most, and we have a tie for that metric.

🥇 Winner — SpikeDynamicLinkingWithXCFrameworks

🥈 SpikeStaticLinkingWithSwiftPackages is a close second

Build Dependencies

You may be wondering why so many approaches are highlighted green for the Build Dependencies column. It became clear to me that performance around resolving Swift packages can vary wildly. All of the approaches highlighted green pull in the same three external dependencies as Swift packages and should therefore take the same amount of time to resolve those dependencies. The actual comparison here is between SPM and Carthage. It took 960s on average to build just two of the three external dependencies using Carthage while it only took between 122s to 184s to build them all as Swift packages. That’s a big win for SPM. I know a lot of the iOS community still uses CocoaPods, but Vrbo has moved away from that dependency manager so I didn’t include it in this spike. Our goal is to eventually migrate everything to SPM as dependencies support it, but we are taking our time with that process.

🥇 Winner — Swift Package Manager

Conclusion

Reviewing the data, statically linking all dependencies using Swift packages is the most performant approach across the metrics measured in this spike. While that approach did introduce longer clean build times, it beat out its counterparts across all other metrics. That’s quite the statement, and aligns with what Apple has been pushing recently: Swift packages producing static libraries. Keep in mind, this spike was solely focused on the metrics covered above and there are many other factors to consider when determining how to distribute and consume code. For instance, supporting a fully statically linked dependency graph can be difficult, especially when some of your app’s dependencies live in their own repo and share dependencies with your app or other targets in your xcworkspace (an approach not explored in this spike). Or perhaps you don’t want to vend your source code to consumers and would rather distribute your library as a binary framework instead. Factors such as these can limit how you manage and link dependencies regardless of the performance impact. At the very least, these results make me feel more confident in our approach at Vrbo to continue migrating to SPM, while also highlighting the benefits of embracing static over dynamic linking.

--

--