Static vs dynamic frameworks
Like most people in the tech world, we've been playing around with ChatGPT and wanted to see how it would do with low-level technical details. We asked ChatGPT to write a blog on Static vs. Dynamic Frameworks in iOS — here's its response, along with what it got right and what it got wrong.
Prompt: I am a software engineer working on iOS apps. Can you write a blog post summarizing the differences between static and dynamic frameworks on iOS and how they affect app size and performance.
Not bad! It knows that static frameworks are linked during build time and dynamic frameworks are linked at runtime. There is one important distinction to make for dynamic frameworks.
First, let me clarify that an app bundle (eg the
.ipa file you download) is different from an app binary (a Mach-O executable within the app bundle). These are often used interchangeably which can be confusing.
Here, we're showing the app bundle and then highlighting the main app binary. In addition to the main executable binary, each unique framework and extension will have its own binary.
What ChatGPT says is partially true. Dynamic frameworks provided by the system – like the Swift runtime now that it has a stable ABI – can be updated independently from your app. However, user-provided dynamic frameworks have to be embedded within your app bundle and can't be updated in the same way.
Let's say you're using the Stripe SDK to process payments. ChatGPT's answer suggests that if the Stripe SDK is updated to include new functionality, your iOS app can leverage the newer version without any changes. In reality, that is not how it works. The code needs to be updated and the app rebuilt for the new version to be included in the app bundle.
We'll use version 2022.1201.1015 of the LinkedIn iOS app to illustrate how dynamic frameworks are included in the app bundle.
We can see all the dynamic frameworks grouped under "Frameworks", a few being:
otool, we can see what libraries the main app binary is using.
@rpath declaration means that the LinkedIn binary is linking to a dynamic framework based on configured runpath search settings, whereas the Swift libraries are linked with absolute system paths. The main app binary is using the dynamic frameworks included in the app bundle. Yes, dynamic frameworks are "loaded at runtime", but it's the version in the bundle that is loaded and can not be "updated or changed" without rebuilding the bundle.
Effect on app size
Again, what ChatGPT says is generally true for frameworks — if you were building an app for macOS, the response would be ok. But this isn't accurate for the iOS runtime environment.
Static frameworks and size
The answer ChatGPT provides here isn't completely wrong, but it misses some nuance of static frameworks.
One of the primary benefits of static frameworks is that the linker can often support dead stripping of executables. You can enable this feature in Xcode and allow the linker to analyze which symbols are used, resulting in potential size savings.
We built a sample Swift app to help demonstrate this. One build of the app uses static frameworks and another build uses the same frameworks linked dynamically. There was no other change besides switching frameworks from static to dynamic.
Comparing the two builds, we see that install size is 2.7 MB smaller in the build that uses the frameworks statically.
Dynamic frameworks and size
As we showed above, dynamic frameworks are embedded somewhere inside the final app bundle and will increase the overall app bundle size. ChatGPT saying dynamic frameworks "do not increase the size of the app package" is flat out wrong.
This does not mean that there aren't size advantages to using dynamic frameworks. Depending on your app configuration, multiple binaries within your app bundle can use the same dynamic framework. This is common when you want to share code between the main app binary and an app extension.
To demonstrate how dynamic frameworks can share code, we'll again use the LinkedIn iOS app and examine its plugins.
LinkedIn has five different plugins:
otool -L command to see which shared frameworks each plugin is using.
Similar to the main app binary, three of the app plugins are linking to the framework
VoyagerLibs and can access all of its resources.
Looking a little deeper we see a 7.4 MB
ArtDecoIconsResources.bundle in the plugin
NotificationServiceExtension_extension. This file also exists in
NotificationServiceExtension doesn't link to
VoyagerLibs. This is seemingly an oversight by LinkedIn, causing a substantial file to be duplicated.
Effect on performance
ChatGPT's answer gets a few things wrong here. First, the claim that static frameworks have slower launch times is incorrect (we'll come back to this point later).
ChatGPT also oversimplifies its explanation of dynamic frameworks on app launch. While it is technically possible to load a dylib later on using
dlopen(), we don't particularly recommend using this feature for iOS apps and it's not what developers consider when deciding between static vs. dynamic frameworks. In practice, iOS apps load all of their dynamic frameworks up front during app launch with the dynamic linker
dyld (you can our read deep dives into
dyld here and here).
Now, some considerations to be aware of for each approach.
Static frameworks and performance
Static frameworks will always load faster than dynamic frameworks, that is part of the trade-off you make when linking during build time — faster startup, slower build times. You can read this blog post for a detailed comparison on the effects of static vs. dynamic frameworks for performance.
A nice side-effect of using static frameworks is that larger binaries make order files more efficient. When the system goes to load symbols from your binary into memory during app launch, it does so by triggering page faults. If these symbols are not packed closely together, say they are spread loosely across one or many binaries, this will cause a lot of extra memory to be unnecessarily paged in.
To illustrate this, we'll look at the page faults in the Robinhood app (version 2022.48), which has an 86.5 MB main executable binary and uses 1559 pages during startup. Each blue grid represents a page that is used during startup.
Notice how the pages used on the left portion of the chart is scattered around the binary. This means that during app launch the entire file is going to be read from the file system, then into memory, and eventually to the CPU. This is slow because the binary is such a big file.
Order files are similar to defragmenting a hard drive on an old windows computer. It moves all the functions used during startup to be close together in the binary, so the whole file isn’t read in. Here's an example of page faults when using an optimized Order File.
Dynamic frameworks and performance
Apple has provided guidance over the years as to how many dynamic frameworks should be loaded during app startup. This continually changes as they make improvements to DYLD with each release of iOS, and it's important for you to do your own profiling. I highly recommend you check out Apple's latest WWDC2022 talk on the subject!
Above we mentioned that
dyld will load all dynamic libraries during app launch. This means that the more dynamic frameworks we add, the longer this process takes. Let's go back to our sample Swift app to demonstrate. Reminder — we have a build almost exclusively using static frameworks and a build using the same frameworks linked dynamically.
Before getting into comparing the app launch for both approaches, it's important to understand the role of
dyld in startup — what ChatGPT got wrong. Here is an app launch flamegraph for our sample app when it is using static frameworks. Before any app launch code executes,
dyld loads in all dynamic frameworks, which we characterize as
Now here's what happens to startup when we switch the app to use dynamic frameworks. Changing the frameworks to be dynamically link caused a 8.7% regression in app launch.
Yes, this is a very simple app and the actual change in startup is quite small. However, this change does represent that the more dynamically linked frameworks you have, the more work
dyld has to do.
In this case, dynamic frameworks went from 2.6 MB to 12.7 MB and still caused a sizeable regression. Dynamic framework app bloat is very common and many devs don't realize the real effects sloppy management of dynamic frameworks can have on app startup.
ChatGPT is already properly trained to know the answer is often “it depends”. 😂
Sadly, we see ChatGPT start to contradict itself regarding performance. Earlier it said that static frameworks "may have slower launch times" but now confidently says they "provide faster launch times".
Overall, it looks like ChatGPT understands there are differences but has trouble applying it to the context of iOS. To recap, our advice is to:
- Start with a static framework when possible
- Make sure Dead Code Stripping is enabled to ensure they have the smallest size footprint
- You can use Order Files to optimize your launch performance
- Explore dynamic frameworks if there is an opportunity to share code/resource duplication between binaries
- Carefully measure the effect this has on your app launch performance and app bundle size
- One pattern we see is having a single dynamic framework statically link all third-party dependencies. You can chat with us to learn more approaches!
- Be aware that with either approach, bloat has an effect on app launch
For fun I wanted to see what else ChatGPT is capable of. I've been exploring some Mach-O formats lately, so maybe it can help.
Prompt: Write me a Swift program that extracts the LOAD_COMMANDs from a Mach-O executable
Amazing! This almost compiles, I needed to convert
UnsafeMutableRawPointer.init(mutating: $0.baseAddress), but that was it. I also had to update it to use
mach_header_64(), but that was my fault for not asking.
Testing this out:
We can see it matches the output of
otool -l AmazingApp:
Pretty Neat. 😎