Cached at:
06/28/26, 06:01 PM
# Lazy Loading Dynamic Libraries and the Plugin-Architecture on iOS
Source: [https://medium.com/@cjckytxz/lazy-loading-dynamic-libraries-and-building-plugin-architectures-on-ios-challenge-accepted-a554fccdb84c](https://medium.com/@cjckytxz/lazy-loading-dynamic-libraries-and-building-plugin-architectures-on-ios-challenge-accepted-a554fccdb84c)
[](https://medium.com/@cjckytxz?source=post_page---byline--a554fccdb84c---------------------------------------)
Press enter or click to view image in full size
A few weeks ago I found myself merrily galivanting with my husband on a whirlwind vacation through Japan\. Of course, I’m want to fully unplug, so on occasion while there’s downtime, I find myself giving social media a good scroll and a nerdy post caught my eye \(truly deep and nerdy things do\)\. The developer bravely took a dive into the performance tradeoffs of dynamic libraries vs static libraries\. Naturally as a former desktop developer who spent a year researching Xcode’s build system, I got sucked into the discussion and was eventually challenged to prove out that lazy linking dylibs is still possible on iOS\. Challenge accepted\!
To recap: back in the early aughts, a concept was introduced within the linux community\. Dynamic Libraries\. Picture yourself in the thick of it\. Binaries were getting big\. Latency was much higher than it is now\. What if you, on a humble dev team, wanted to update some module in your source code, but didn’t want to push the entirety of a new build\. Tools like rsync were new unto the world using exchanges of hashes quickly transmitted over highly latent networks between remote end systems to determine file changes\. Or perhaps your concern was that launch times were ballooning in your growing app\. Again think of it from the standpoint of a build engineer\. Maybe you’re publishing Adobe Photoshop or Microsoft Word\. Your users are experiencing slow launch times\. And your users may not even be touching above 5% of your actual source code during the entire app lifecycle, much less to complete a successful launch\.
Do you really need a single massive binary just to launch your app? Do you really want to bog down your CDNs redundantly pushing modules that haven’t changed on latent connections when the minor update being pushed only affects a tiny fraction of the total binary? A natural conclusion one might reach as an enterprising build engineer is that it might be advantageous, rather than compiling a single gargantuine binary that’s linked at compile time, to build a tree of binaries that are linked at or after launch time\. And thus the dynamic library was born\. While static libraries are highly advantageous in lots of situations, dynamic libraries pair well with Plugin Architecture and allow developers to choose when to load the appropriate modules of code into and out of memory\.
## Picture this: a modern Plugin Architecture
You’re building the Facebook app\. At launch you know your users will need a few things\. You’ll want the UI components system for sure because duh there will be a UI\. You’ll want the base models and API layer of your app because it’s cloud based\. Your users will probably see videos and photos shortly after launch so you’ll want your core media decoder library to decode images and video\. But then what? Do you need the camera and vfx module for those cool AR selfies? No\. What about the calendar and/or events module? No\. How about the marketplace module? In fact, what if your user hasn’t authenticated yet, and you’d like to shorten launch times even further by loading the security, UI, and API modules only?
This is where dynamic libraries and the plugin architecture shine\. For most developers working on most apps, it’s probably best to stick with statically compiling your dependencies into a single binary\. But in huge apps where users need a single digit percentage of the source code at launch, and perhaps even for the entire app lifecycle, dynamic libraries make a lot of sense\. The app can determine conditionally when it’s appropriate to pull a module into memory\.
## But how?
We’re going to build a sample POC app that loads a dynamic library\. To do this we’re going to do 4 things:
1. Create a sample framework, an interface framework, and a sample app, and load the frameworks into our sample app target’s build graph\.
2. Configure our build architecture by ensuring that the sample framework isn’t linked at compile/link time\.
3. Add the interface framework to the sample framework\.
4. Load the sample framework at runtime on command and verify that it is not loaded at launch\.
## Step 1: Create the app and dynamic libraries
In Xcode, start by creating a sample SwiftUI iOS app project called`MyHappyApp`\. You’ll will also want to create two framework projects called`MyHappyFramework`and`MyHappyInterfaceFramework`\. Notice I didn’t create a workspace as we’re not using shared code between the modules outside of explicitly linked frameworks\. Open the app project, go to the project settings and the MyHappyApp build target\.
Press enter or click to view image in full size
Under the general tab, scroll down and look out for a section called`Frameworks, Libraries, and Embedded Content`\. This is a general section we should all be familiar with by now designating entities that we intend to embed into our app bundle and what eventually becomes our IPA file\. You probably noticed that I imported quite a few dependencies\. I’ll get to that in a moment\. Press the plus button\. And at the bottom of the screen, click the drop down titled`Add Other\.\.\.`and select`Add Files\.\.\.`\. Navigate to the root of your MyHappyFramework project and add it\. You may have to clean your build folder \(command\+shift\+K\) and click the plus again and select the MyHappyFramework\.framework from the top of the menu\. Go to your build phases, select the`Embed Frameworks`phase, then change the destination to`Shared Frameworks`\. Clean and do this again for your MyHappyInterface\.framework, but do not adjust the build phases\. This brings both binaries into your embedded content but embeds them in different build phases \(output destinations\)\.
Be sure to leave`Embed & Sign`turned on for both binaries\. Before I move on, a bit of a walk through memory lane\. Experienced developers probably remember when one had to start by importing all framework references in this section\. For instance, you couldn’t just write`import CoreImage`in a header file somewhere\. The compiler wouldn’t know what you were talking about\. This was and is still the go to starting place for defining what is actually getting embedded and bundled into your app bundle\. I’ll explain how that changed next\.
## Step 2: Configure the build architecture
By default, when we declare new embedded content, if it’s not system content such as a dynamic system library \(e\.x\. SwiftUI, UIKit, AVFoundation etc\. etc\.\) Xcode will do a few things to your build architecture automatically to make your life easier\. First it’ll add the library to your`@rpath`\. if it’s not already discoverable using the inherited and declared search paths\. Second, it’ll add it to your`Link Binaries with Libraries`build phase\. Third, it’ll add it to your`Embed Frameworks`build phase\.
Let’s talk about`@rpath`for a second\. When your app becomes a bundle with an executable inside it, the executable will have a header\. The header will have certain commands at different offsets telling the OS to do certain things, such as loading and linking binares at different search paths\. You can inspect this behavior using`otool`\. Feel free to build and run\. Then go to derived data, find your app in the build products\. Point otool at it using`otool \-L \[path\_to\_your\_app\_bundles\_executable\]`\. This will print any dylibs being designated to load at launchtime\. Those are dylibs that are present in your binary’s header\. As you can see, in debug mode this loads a system dylib and an app specific debug dylib that allows lldb to interface directly with your app and it’s symbols\. Very important stuff\. You can also point otool at any of your frameworks and inspect their declared dylibs being loaded at launch time\. But let’s use otool to print all of the load commands for everything\. Run`otool \-l \[path\_to\_your\_app\_bundles\_executable\]`\. This will be a long list, but it has a specific anatomy to it\. There’s the command number \(this is a sequence after all\)\. Then there’s the`cmd`aka the command to the OS\. Pay close attention to LC\_PATH commands\. They declare a directory to look for entities to find dylibs and perform fixups\. You should see a command that looks like this:
```
Load command 17 cmd LC_RPATH cmdsize 40 path @executable_path/Frameworks (offset 12)
```
Guess what this is doing\. It’s telling`dyld`\(iOS’s dynamic library loader\) to look at the child directory`Frameworks`the and begin searching for any binaries therein\. Dyld does a lot work when it finds a library\. But I’ll talk about that later\.
In your app target’s build settings, let’s configure the build architecture\. In the top\-right filter field, type`rpath`\. Delete everything that isn’t`@executable\_path/Frameworks`or`inherited`from the`Runpath Search Paths`field\. Then go to the build phases tab, go to the Link Binary With Libraries phase, and remove the MyHappyFramework\.framework from the link phase, but leave the interface framework\.
And just for good measure, let’s***guarantee***that Swift and clang doesn’t link the sample framework\. Go back to the build settings, type`framework`in the filter field, scroll down to the`Swift Compiler — General`and set`Link Frameworks and Libraries Automatically`to`No`and`Skip Automatically Linking All Frameworks`to`Yes`\. Remember when I said experienced devs remember having to add frameworks to the embedded section of their target and that that changed? This is why\. Swift became “safer” at a certain point\. It searches your code for import declarations or even symbol references\. And if these flags are set to automatically link, and there’s an import or even a leaked symbol in your code, the Swift compiler***forces the linker to automatically link the framework\.***If lazy linking isn’t flagged as true, then the dylib will be linked at launch time\. By turning this off, we’re forcing ourselves to explicitly declare every single dependency we wish to be linked and loaded\.
As a final step before we move on, go back to the Frameworks, Libraries, and Embedded Content section under the general tab\. Remember all of those explictly declared frameworks in the screenshot above? Clean and compile and watch the build system complain about their absence\. Add all of them from the list above\. Then clean and compile again\. You may have to clean before adding each new dependency\. If there are no errors, everything has been added correctly\.
Fun fact, I sometimes fantasize about sadistically turning off the automatic swift linking build flags in a low key PR and watching the pandemonium among Jr devs the world over as they discover they can no longer just import anything by typing import everywhere\.
## Step 3: Add the interface framework to bridge your dynamic library and your app’s source code
While I could cop out here and simply throw lazy linking flags on under`Other Linker Flags`, there’s some peril there\. Remember, in Swift, if I even touch an entity that contains a symbolic reference to our dylib, that’s it, the dylib will get loaded instantly\. If it’s a big dylib and the reference is in the UI, that’s a UI hitch we just inadvertently created\. But more importantly, we’re trying to build a plugin architecture\! After all, we want explicit control over when the dylib is loaded, don’t we? Let’s close this project and open our sample framework MyHappyFramework\. Go to the Frameworks, Libraries, and Embedded Content section under the general tab\. Press the plus button to add a framework\. Then nav to`Add Other\.\.\.`and`Add Files\.\.\.`\. Add the MyHappyInterface project directory\. Then add the framework to the target\.
You might be thinking… hey wait, didn't we just add this to our main app target? You are correct\. However, when we build, the build system will detect that the dependency has been added twice\. Plenty of dependencies depend on common apis after all, right? For instance, two frameworks may depend on AFNetworking to opperate\. Do we want to compile them into the app twice if that’s not needed? Hell no\! But this optimization allows us to pull a trickety trick\. We can craft something of an interface that’s shared between the app and the all of the dynamic libraries using a shared dynamic library\. That allows the app to load nothing but a lightweight interface framework at launch\-time, while leaving other beefy frameworks out of memory until they’re needed\! And the cool thing about this is it allows us to inject mocks and stubs between modules very easily\.
## Step 4: Flesh out the runtime logic
Our build architecture is set\. Now let’s flesh out our runtime logic for lazy loading our dylib\. First, let’s build an interface we’ll use in both the app and the sample framework\. Open the MyHappyInterfaceFramework project\. Go to the main swift file and drop these two nuggets there\.
```
import Foundation@objcpublic protocol MyHappyInterfaceExporter { func export() -> Any}public protocol MyHappyInterface { func sayMyName() -> String}
```
Second, let’s go to the MyHappyFramework sample project, open the main Swift file, let’s import our interface\. And let’s build an exporter\.
```
import Foundationimport MyHappyInterfaceFramework@_cdecl("exportMyHappyInterface")public func exportMyHappyInterface() -> MyHappyInterfaceExporter { InterfaceExporter() }open class InterfaceExporter: MyHappyInterfaceExporter { open func export() -> Any { SayMyName.shared }}open class SayMyName: MyHappyInterface { public static let shared = SayMyName() open func sayMyName() -> String { "Heisenberg" }}
```
What did we do here? We used a c\-declaration flag to create a swift function that can be looked up dynamically using a non\-mangled unique name\. Since this is a c symbol, we’re using a single objective\-c protocol and a single compliant class that does only one thing: exports a singleton to the caller\. Then we created our compliant singleton and conformed it to the interface protocol in the shared interface framework\. When this library get’s loaded and linked, it’ll be using a shared protocol and therefore shared symbols between modules, to link everything together\.
Third, let’s open our MyHappyApp project create a general purpose framework loader in a file called LibLoader\.swift\.
```
import Foundationfunc loadFramework(at path: String) throws -> UnsafeMutableRawPointer? { guard let handle = dlopen(path, RTLD_NOW) else { if let error = dlerror() { throw NSError(domain: "dlopen", code: 1, userInfo: [NSLocalizedDescriptionKey: String(cString: error)]) } throw NSError(domain: "dlopen", code: 2, userInfo: [NSLocalizedDescriptionKey: "Unknown error"]) } return handle}
```
This takes a file path, and calls a function called`dlopen`within the Darwin Kernel\. dlopen kickstarts Dyld\. Dyld is iOS’s secure API that loads binaries into memory, hashes them and checks against our app bundle’s code signatures, then randomizes and maps address pointers between the incoming api to prevent certain injection attacks\. If the header in that binary has references to other binaries, it repeates the process recursively until everything is loaded\. So by calling dlopen with a path to a dylib, we’re lazily kickstarting a process that normally happens at launch time\. The unsafe pointer that returns from dlopen is the literal address to the offset of our sample framework in memory\!
Fourth, let’s build a loader for our specific interface to manage it as a resource\. Let’s create a file called`MyHappyInterfaceLoader\.swift`and put the following there:
```
import Foundationimport MyHappyInterfaceFrameworkinternal class MyHappyInterfaceLoader { private static var handle: UnsafeMutableRawPointer = { let rootPath = Bundle.main.sharedFrameworksPath! let frameworkPath = rootPath + "/MyHappyFramework.framework/MyHappyFramework" let handle = try! loadFramework(at: frameworkPath)! return handle }() static var shared: MyHappyInterface = { typealias ExportFunc = @convention(c) () -> MyHappyInterfaceExporter if let symbol = dlsym(handle, "exportMyHappyInterface") { let function = unsafeBitCast(symbol, to: ExportFunc.self) let interface = function().export() as! MyHappyInterface return interface } else { fatalError("Failed to load critical export symbol") } }()}
```
This file also contains a reference to the interface framework that is shared between the sample framework and the app\. In the lazy`handle`property, we get our shared frameworks filepath from the main bundle\. We append the path to our sample framework\. Then we load the handle for the sample framework\. In the lazy`shared`property, we use the c\-declaration we emitted in the sample to call the export function\. The function is called once, and emits the objc conforming protocol of a class that only exports our swift protocol conforming class\. We call`export`on the objc exporter class\. It returns our singleton as an`Any`which we typecast as an entity conforming to`MyHappyInterface`\. We set that as a singleton in our app binary as well\.
Finally, we need a way to test the lazy loading functionality\. We want to structure our code so that we can confirm that indeed, the framework is not loaded by the launch time rpath command, but instead programatically within our app when we tell it to\. How do we do dis? Let’s KISS\. I propose we take the boilerplate SwiftUI content view, and create a button that kickstarts loading, and upon loading, calls a function within the shared framework\. Go to the`ContentView`and modify it like so:
```
import SwiftUIimport MyHappyInterfaceFrameworkstruct ContentView: View { @State private var name: String? var body: some View { VStack(spacing: 20) { Button("Say my name...") { Task { name = MyHappyInterfaceLoader .shared .sayMyName() } } .font(.headline) .padding() if let name { Text(name) .font(.title2) .transition(.opacity) } } .animation(.easeInOut, value: name) .padding() }}#Preview { ContentView()}
```
Do you see what happened here? We have a button that kickstarts a task on the global queue\. The framework is loaded when we reference`shared`because shared calls`handle`, and the singleton from the sample framework is emitted wrapped in our interface protocol\. We imported that protocol up top so we can call functions on the dylib singleton\.
## Let’s test
Clean your build and run the app on an iPhone Simulator\. After the app launches, let’s use the sim CLI to find our sim, get our process id on the sim, then list the open frameworks linked to our process\.
Get the open sims\.
```
xcrun simctl list | grep Booted
```
Get your sim’s active processes\. Find your app on the list and get the PID next to it’s name\.
```
xcrun simctl spawn [your sim uuid here] launchctl list
```
Get your app’s open dylibs\.
```
lsof -p [your app's pid here]
```
Look very carefully at this list\. If we’ve done everything correctly, we’ll see our`MyHappyInterfaceFramework`, but we wont see the`MyHappyFramework`\.
And now the test\.**Press the button**\. You should see the Heisenberg string appear in the UI\. Go back to your terminal and run`lsof`on your app’s PID again\. In the delta between the two lists, you should see a newly added`MyHappyFramework`\. Success\!\!\! We lazily loaded a dylib post launch time on command within our app\!
## Some Tidying
At this point some tidying might be in order\. For instance, one might want to mangle one’s c symbol declaration strings for security purposes\. One might also want to reverse the flags preventing implicit linking within the app\. But then again, maybe not? As long as you make sure`MyHappyFramework`never appears as an import anywhere, you’ll be fine\. But some of us might want explicit warnings to stop us from importing new dependencies without thinking\. That’s a “feature” in Swift I never personally wanted\.
If you do go back to automatic imports, then you can probably discard the`libswift\.\.\.`and SwiftUI declarations in`Frameworks, Libraries, and Embedded Content`\.
## Conclusion and thoughts on Dynamic Libraries
Dynamic libraries arose from a place of real need\. And in large apps, they do still have relevance today\. Major apps like Facebook \(see Buck\), Instagram, Spotify, and Snapchat all use dynamic libraries to accelerate launch times\. Dynamic libraries are used to modularize features UX flows, core functionalities, or just to outright speed up launch times\. But what attracted me to this topic and admittedly made me want to write about this was an assertion that Apple disallows lazy loading dylibs\. What changed between macOS where dlopen was introduced and iOS?
When Apple unveiled the iOS developer SDK to the world\. They famously introduced a condition that they only further hardened enforcement of over successive releases\. While you can load your dylibs lazily,***you cannot load them from a remote source\.***All binaries must be present within your app bundle for the reviewers to review and must be code signed by***you***the developer\. Aka they want to be able to review your entire app, and they want to be able to know without a shred of doubt via your asymmetrical code signature that***you***were the one who pushed it\.
As I sat on my happy train in Tokyo Japan, I guess what inspired me to write this was that it seems over the years, because of Xcode and Cocoapods default settings which load all dylibs at launch time, because the Swift compiler suddenly thrust us all in a world where you can just start referencing symbols and the linker figure out everything else, these standard build system defaults may have inadvertently trained a generation of young iOS developers to believe that there was no better way\. That dylibs are just the slower version of static libs\. This has caused many of us to stop thinking about better ways to serve our users\. Dynamic libraries come at a cost\. The additional round trips to the filesystem, the additional hashing, security checking, and memory address randomization adds time\. So all apps that load dynamic non\-system libraries should probably either make those libraries static or figure out how to lazy load them\. But let’s just be sure we know what we’re talking about here\.
From the horse’s mouth:
> 2\.5\.2 Apps should be self\-contained in their bundles, and may not read or write data outside the designated container area, nor may they download, install, or execute code which introduces or changes features or functionality of the app, including other apps\.
And that’s it\! The review guidlines are abundantly clear\. You may not download a dylib and execute it\. The app you present at review must localize all of it’s code within the four corners of the app bundle, and must be the same app with the same functionality for the reviewers as that which executes on users’ devices\. This definitely eliminates one of the major use\-cases for which dynamic libraries were invented: updating modules within your source code over highly latent networks\. But the review guideline that comes the closest to touching on this topic, while disallowing loading remote binaries, greenlights loading binaries bundled with your app\!
So when would one want to make use of dynamic libraries? Listen, most developers don’t need to use dynamic libraries outside of the common system libraries like SwiftUI\. The key question is: is the first and second sigma cohort of your users going to only need a small percentage of your app’s source code on launch or perhaps even for the entire app lifecycle? If so, that’s a good flag that it’s time to speed up your app launches by moving most of your libraries into dynamic libraries\. But if your app isn’t above tens of megabytes in size, odds are all of this is overkill\.
Kon’nichiwa\.