Shipping Binary Frameworks With Swift 5.0

How to get a binary framework shipped with Swift 5.0

Reddit
LinkedIn

"Module Stability"

Swift 5 introduces "ABI Stability", which means the calling conventions between two binaries is now guaranteed to be consistent. Previously, if two binaries were compiled with two different Swift versions, it was not guaranteed that one could call into the other and get the expected result.

The next step is "Module Stability", which introduces the ability for the compiler to know what methods are available between two Swift versions. Currently there is an opaque binary "swiftmodule" that the compiler can read to determine available methods, but it's not consistently formatted from version to version. Module stability is basically just adding a Swift equivalent of a header in C. Where in C/C++/ObjC, public methods are exposed via a handwritten header, Swift module stability will come from a pretty-straightforward generated equivalent.

The format should be pretty familiar if you've looked at the generated interface of a Swift file, with the exception being the inclusion of the bodies of @inlinable functions. As you can see there isn't too much to Module Stability. It's just standardizing on a forward/backward compatible way to generate and consume a framework's interface:

Swift Code

public class ExampleStruct {
    public let name: String

    public init(_ name: String) {
        self.name = name
    }

    @inlinable
    public func sayHello() {
        print("Hello \(name)")
    }
}

Xcode Generated Interface

public class ExampleStruct {

    public let name: String

    public init(_ name: String)

    @inlinable public func sayHello()
}

Generated Module Interface

public class ExampleStruct {
  final public let name: Swift.String
  public init(_ name: Swift.String)
  @inlinable public func sayHello() {
        print("Hello \(name)")
    }
}

(Generated using -emit-parseable-module-interface-path $(BUILT_PRODUCTS_DIR)/$(SWIFT_MODULE_NAME).swiftinterface in Swift 5.1)

Bridge to the Future

A Detour Through The Past

While full module stability is still coming in the future (Presumably with Swift 5.1 shipping circa Sept 2019), binary compatibility still exists. The lack of a common interface is all that is missing. This is where Objective-C bridging, a feature of Swift since its initial release, can come in handy.

What the @objc flag on a method in Swift does is create a "bridge", where an Objective-C Selector is generated that handles marshalling inputs into Swift objects, calling the underlying Swift function, then converting the result into Objective-C values to return.

In general, the @objc flag has to no cost to Swift callers; the Swift code is able to look at the Framework's swiftmodule to find the underlying call and call it directly. As previously mentioned, though, the swiftmodule's contents isn't stable between versions, so the compiler will generally refuse to read the output of older/newer toolchains, leading to an error like:

Module compiled with Swift 5.0 cannot be imported by the Swift 5.0.1

One solution to this issue is to wrap all public functionality in an Objective-C layer. The idea is to produce an internal module where all of the Swift code lives, and then wrap its interface in an Objective-C library that allows access.

"That seems like a lot of work"

I mean maybe not a lot of work, but hand-generating an Objective-C wrapper for an existing interface is the sort of thing that's somewhat time consuming but also extremely error prone. If I had a nickel for every time this sort of rote, copy/paste work has gone subtly wrong, I could probably buy a coffee.

One solution is to write a code generator that would take the Swift interface and generate this wrapper layer, but that seems like work as well. How are we even calling these @objc methods in the first place? Doesn't Swift generate its own wrapper already, and can we just use that? With some work, yes!

Finding a Solution

Let's try to build a framework using one toolchain, and then get it to work in an app using an older Swift compiler. In this case I'll build the framework in a Swift 5.1 toolchain I built, and then try to use it in an app using the stock 5.0.1 toolchain in Xcode 10.2.1.

The framework is called SwiftFramework and it defines this class:

@objc
public class ExampleClass: NSObject{

    @objc
    public func printSomething() {
        print("Hello world")
    }

}

Our goal is to consume this framework in a new app and use the framework to print "Hello world".

Starting by including the framework and importing it in our AppDelegate:

import UIKit
import SwiftFramework // 🛑 Module compiled with Swift 5.1 cannot be imported by the Swift 5.0.1 compiler

@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {

    var window: UIWindow?

    func application(_ application: UIApplication,
                     didFinishLaunchingWithOptions
        launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {

        return true
    }

}

As expected, the compiler doesn't want to import the framework at all, so we'll have to find a workaround...

Cutting Down The Framework

The first step to getting our framework working is getting the compiler to stop thinking the Swift code is there. This is fairly easy: image2

Just delete the .swiftmodule from your framework's Modules folder, and now the framework imports just fine:

import UIKit
import SwiftFramework

@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {

    var window: UIWindow?

    func application(_ application: UIApplication,
                     didFinishLaunchingWithOptions
        launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {

        return true
    }

}

But what if we try to use something from the framework?

@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {

    var window: UIWindow?

    func application(_ application: UIApplication,
                     didFinishLaunchingWithOptions
        launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {

        ExampleClass.printSomething() // 🛑 'ExampleClass' is unavailable: cannot find Swift declaration for this class

        return true
    }

}

Ugh.

Somehow the compiler knows that ExampleClass is a thing, but it still seems to know it should be treating ExampleClass as a Swift class. How does it know that, though?

Snitches Get Stitches

So where is Swift even getting the idea that ExampleClass was defined at all? The answer is in SwiftFramework-Swift.h, the header that Swift generates to provide an Objective-C interface.

Our class is defined in the generated header like so:

SWIFT_CLASS("_TtC14SwiftFramework12ExampleClass")
@interface ExampleClass : NSObject
- (void)printSomething;
- (nonnull instancetype)init OBJC_DESIGNATED_INITIALIZER;
@end

Deleting the SWIFT_CLASS macro appears like a good place to start, so let's try that?

❗ Undefined symbol: _OBJC_CLASS_$_ExampleClass

Oh well...let's find out what SWIFT_CLASS actually does...

Partially expanded __attribute__((objc_runtime_name("_TtC14SwiftFramework12ExampleClass"))) __attribute__((objc_subclassing_restricted)) SWIFT_CLASS_EXTRA

The first two things seem relatively innocuous:

__attribute__((objc_runtime_name("_TtC14SwiftFramework12ExampleClass")))

  • This attribute there to avoid the compiler error we hit earlier. It's telling the Objective-C runtime that ExampleClass is actually referring to the Swift class called _TtC14SwiftFramework12ExampleClass.

__attribute__((objc_subclassing_restricted))

  • This one is self explanatory and just keeps you from subclassing, which isn't supported on a Swift NSObject from Objective-C.

So let's try to just add those two attributes, without SWIFT_CLASS_EXTRA:

__attribute__((objc_runtime_name("_TtC14SwiftFramework12ExampleClass"))) __attribute__((objc_subclassing_restricted))
@interface ExampleClass : NSObject
- (void)printSomething;
- (nonnull instancetype)init OBJC_DESIGNATED_INITIALIZER;
@end

Success!

image1

So what's this SWIFT_CLASS_EXTRA thing? If you scan the generated file, it's not defined anywhere. The closest is just a check to make sure it's defined:

#if !defined(SWIFT_CLASS_EXTRA)
  #define SWIFT_CLASS_EXTRA
#endif

It's clearly passed in somewhere in the build process, but what does it add that breaks our build?

Hacky solution… get the compiler to tell us by using the contents of the variable as a deprecation message:

#define STR(x) #x
#define TO_STR(x) STR(x)

__deprecated_msg(TO_STR(SWIFT_CLASS_EXTRA))
__attribute__((objc_runtime_name("_TtC14SwiftFramework12ExampleClass"))) __attribute__((objc_subclassing_restricted))
@interface ExampleClass : NSObject
- (void)printSomething;
- (nonnull instancetype)init OBJC_DESIGNATED_INITIALIZER;
@end

Which yields: 'ExampleClass' is deprecated: __attribute__((__annotate__("__swift native")))

__attribute__((__annotate__("__swift native"))) certainly sounds suspicious 🤔. In addition to SWIFT_CLASS_EXTRA, the macros SWIFT_ENUM_EXTRA and SWIFT_PROTOCOL_EXTRA are also present and add the same attribute. It seems like we found the snitch in our generated header, so now what?

#undef SWIFT_CLASS_EXTRA
#undef SWIFT_PROTOCOL_EXTRA
#undef SWIFT_ENUM_EXTRA

Just nuke them!

Add that to the top of the generated header and voilà!

Putting It Together (TLDR)

The result of this can be summed up in a simple build step to run a postprocessing script:

FRAMEWORK="/$CONFIGURATION_BUILD_DIR/$EXECUTABLE_FOLDER_PATH"
rm -rf "$FRAMEWORK/Modules/$EXECUTABLE_NAME.swiftmodule"
SWIFT_HEADER="$FRAMEWORK/Headers/$EXECUTABLE_NAME-Swift.h"
echo "#undef SWIFT_CLASS_EXTRA\n#undef SWIFT_PROTOCOL_EXTRA\n#undef SWIFT_ENUM_EXTRA\n$(cat $SWIFT_HEADER)" > "$SWIFT_HEADER"

Just add this as a Run Script build step at the end of your build phases list, and the framework will come out ready to go.

User Notes

  • The user of your framework, regardless will need to be using at least Xcode 10.2, with either Swift 4.2 or 5.0+ (4.2 and 5.0 use the same compiler)

  • For Objective-C Only Users: If a consumer of your binary framework uses no Swift, they must set EMBEDDED_CONTENT_CONTAINS_SWIFT to YES as a User Defined Setting. Apple OS versions prior to Swift 5's release (iOS/tvOS <12.2, WatchOS <5.2, MacOS <10.14.4) do not include the Swift standard library and will cause the user's app to crash at run time. There is no build-time evidence of this issue.