home

Dynamically Loading Frameworks to Support Multiple iOS Versions in the Same Bundle

If you’ve been an Apple platforms developer for a while now, there’s probably been a time when you’ve debated raising the minimum version requirement of your app so you have access to new APIs.

A few days ago I was in that situation. We’re lucky on Apple platforms that most users update to the latest OS releases very quickly—but I remembered that I promised users of my app Quanta that I would always keep the minimum requirement at iOS 15. 15 was released 4 years ago now though, and it’s time to move forward. While conditionally available APIs can work, they often clutter your codebase and can become difficult to manage as complexity grows. What should I do?

Well… dynamic frameworks was my answer, and it’s pretty simple to get working. I couldn’t find many people using them for this purpose which I found a bit odd, so that’s why I’m writing this. This dynamic loading approach allows you to seamlessly experiment with newer APIs without leaving behind users on older OS versions, and keeps your codebase clean and separated.

My goal was pretty simple: Allow users on iOS 15-16 to use the current version of my app while developing modern features for users on iOS 17-18.

Separating Out Crucial Shared Logic

You’ll probably have some shared extensions, utilities, assets and resources, API representations, etc that you want to be shared between the legacy and modern version of your app.

Create a new framework under your app target and name it something like “Shared”. Make sure Embed in Application is set to your app target.

Now move over any resources and all your shared logic into this framework. You’ll need to make sure everything you want exposed is public, which may take some time. Approach this with a decent level of patience—it’s gonna get a little tedious. You might choose to keep your xcassets inside your main application still so that you don’t have to specify the bundle when creating images.

Creating the Legacy and Modern Code Base

Now that you’ve split out your shared logic, you’ll want to create two more frameworks—or however many you want for the different versions you want to support. Make sure when creating these, that you set Embed in Application to None! If we don’t do this, Xcode will attempt to statically link the framework incompatible with your minimum deployment target and result in a crash.

Now:

Because we set Embed in Application to None, the frameworks won’t be included in your bundle whatsoever. Add them under your main target in Build Phases and then Target Dependencies and Embed Frameworks. Adding your frameworks under target dependencies will make sure they are built in the same build process as your main target, but won’t explicitly link them. This is what we want. Adding them under embed frameworks will copy the .framework file to your main bundle where we can load them dynamically at runtime. If you run into issues, ensure Link Binary With Libraries only has your Shared framework(s)!

In order for your legacy and modern framework to access your shared code, you’ll need to add your shared frameworks under General > Frameworks and Libraries. Make sure you set Do Not Embed however, since they are already embedded in the main app, and you don’t want to duplicate symbols and resources.

Loading the Correct Framework Depending on the User's Actual iOS Version at Runtime

This is where it gets fun! At this point your main app should literally just be the entry point for your application. In my case I’m using the SwiftUI app lifecycle since it’s the most simple for this example, but it really doesn’t make a difference.

We’re going to first need to make a main bootstrap class within each of your frameworks to dynamically load the correct one for the user's iOS version. The bootstrap class acts as the entry point within each dynamic framework, responsible for initializing your app-specific content or view.

Create a main.swift file within each framework and create a class that has a function that will return a root point of each one of your apps. Really, here you should be thinking about each one of these frameworks as a separate application. The main target is just going to choose which one to load at runtime.

We need to return a type-erased AnyView in the case that the root of your app is SwiftUI. Otherwise, it could be a UIViewController, or whatever you want your root to be.

import SwiftUI

@objc public class main: NSObject {
    @objc public static func loadMainContent() -> Any {
        return AnyView(RootView())
    }
}

private struct RootView: View {
    var body: some View {
        Text("This is the modern framework!")
    }
}

In the root of your application we can finally load the framework with the magic of dlopen and Objective-C runtime utilities!

Your frameworks may be in one of the following locations, seemingly depending on whether you’re running on device or in simulator. We try to load from the first and then the second if that fails.

Note how we change the framework name depending on your iOS version. After dlopen-ing the correct framework, we need to fetch the main content from the bootstrap we created. We use NSClassFromString to get the main class (make sure you prefix with the framework name here), and NSSelectorFromString to get the loadMainContent method. Now it’s as simple as calling it, taking the return value as AnyView and returning it.

import SwiftUI

func loadMainContent() -> AnyView {
    var frameworkName = "MultiVersionLegacy"
    
    if #available(iOS 17, *) {
        frameworkName = "MultiVersionModern"
    }
    
    // potential framework location 1
    var frameworkPath = Bundle.main.bundleURL
        .appendingPathComponent("\(frameworkName).framework")
        .appendingPathComponent(frameworkName)
        .path
    
    if dlopen(frameworkPath, RTLD_NOW) == nil {
        // potential framework location 2
        frameworkPath = URL(fileURLWithPath: Bundle.main.privateFrameworksPath!)
            .appendingPathComponent("\(frameworkName).framework")
            .appendingPathComponent(frameworkName)
            .path
        
        dlopen(frameworkPath, RTLD_NOW)
        
        if let error = dlerror() {
            print("dlopen error: \(String(cString: error))")
        }
    }
    
    let bootstrapClass = NSClassFromString("\(frameworkName).main") as! NSObject.Type
    let selector = NSSelectorFromString("loadMainContent")
    bootstrapClass.responds(to: selector)

    return bootstrapClass.perform(selector).takeUnretainedValue() as! AnyView
}

@main
struct MultiVersionApp: App {
    var body: some Scene {
        WindowGroup {
            loadMainContent()
        }
    }
}

Done! Now you’ll need to duplicate some code across your legacy and modern frameworks to get the app to the current state, but when you want to add new features—just use the modern framework with access to all the new APIs. Try to separate out as much critical logic as possible into your shared framework, or create more frameworks to separate out different parts of your app. Where you go from here is up to you! You could even use this to load a different version of your app depending on if the user is on iPad or iPhone. It’s probably not recommended, but technically you can load these frameworks based on any given property you can access at runtime.

Last Notes

There's a few small things to keep in mind when using this approach.

There's probably more to cover, but I'll leave that to you to figure out for now. Send me a message if you have any questions or feedback. @priva28_