Shipping Binary Frameworks With Swift 5.0
How to get a binary framework shipped with Swift 5.0
"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:
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!
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
toYES
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.