Xcode: Monitoring your public API — Part 1
If you’ve ever worked on an SDK/framework/library you’ll hopefully have done your best to keep a stable public API for the developers who use it. They’ll be happier if they don’t have to make changes in their code when you ship a new release (unless they want to use something that wasn’t available before of course!).
Sometimes it’s difficult to make sure your public API doesn’t change unexpectedly. There are a number of ways it could happen, some are easier to spot than others, but that’s not the focus of this article so I won’t be going into depth on that.
So how can you keep an eye on your public API, in an automated way? Something that would really help would be a list of each public type, including its functions and properties etc. We could then put that under source control and any time the code is changed we can see how it’s affected the public API and hopefully spot any dangerous changes.
This article is focussed on Objective-C and Swift, so anyone working on macOS, iOS, tvOS and watchOS. There may well be similar solutions possible for other languages, I’ve certainly seen the same being done with Kotlin.
Objective-C
Yep, let’s start with good old ObjC (don’t worry, Swift is coming up next!). I’ve been working on a legacy, pure ObjC framework recently and was prompted to look into monitoring its public API after a breaking change unexpectedly crept into a release (oops!).
This particular breaking change was actually only a breaking change in the Swift interface of the framework. An oversight in the name of a new value in an enum meant that every single value in the enum had a new prefix added to it, ouch. So for this case I wanted to be able to see what the public API of the ObjC framework looked like in Swift, something that needs a lot of knowledge or guesswork to figure out normally.
It turns out that we can output the Swift interface of an ObjC framework pretty easily with a simple script:
echo -e “import <framework name>\n:type lookup <framework name>” | swift -F ./
We pipe a simple Swift script, which imports our framework and calls type lookup on it, into swift. The -F is telling swift where it can find our framework, in this example it’s the same folder that we’re running the command from.
Given a simple ObjC framework with one struct, OCStruct, and one class, OCClass:
Here’s the output:
Welcome to Apple Swift version 5.4 (swiftlang-1205.0.26.9 clang-1205.0.19.55).
Type :help for assistance.
@_inheritsConvenienceInitializers @objc class OCClass : ObjectiveC.NSObject {
@discardableResult @objc func getInt() -> Swift.Int
@discardableResult @objc func intToString(with integer: Swift.Int) -> Swift.String
@available(swift, obsoleted: 3, renamed: “intToString(with:)”) @objc func intToStringWith(_ integer: Swift.Int) -> Swift.String
@discardableResult @objc func tryIntToString(with integer: Swift.Int) -> Swift.String?
@available(swift, obsoleted: 3, renamed: “tryIntToString(with:)”) @objc func tryIntToStringWith(_ integer: Swift.Int) -> Swift.String?
@objc override init()
}
struct OCStruct {
var integer: Swift.Int
init() {return
}
init(integer: Swift.Int)
}
var objc_frameworkVersionNumber: Swift.Double
Awesome. It’s showing us what the public API looks like in Swift and we should be able to spot any changes happening to this output if we put it under source control.
NOTE: Running that command in Terminal works fine for me, but when I tried to put it into an Xcode build phase script of an iOS framework project, it no longer worked. I found that I needed to use bash instead of sh (for the -e on echo to work) and to specify the use of the macOS SDK, rather than the iOS SDK:
echo -e “import <framework name>\n:type lookup <framework name>” | xcrun -sdk macosx swift -F ./
You can see the working example in my example project on GitHub.
BIG SAD NOTE: As it turns out this only works for macOS-compatible ObjC, so if your framework is intended for something like iOS, importing UIKit and the like, then it’s not going to work, unless it actually builds for macOS.
Swift
If you’re writing a macOS framework you could use the same script that works for ObjC above (I didn’t manage to get it working for an iOS framework due to architecture mismatches). But it ends up including a lot of things that aren’t actually part of your framework’s API, so is incredibly noisy (over 2k lines of additional definitions for the example code below).
There is an alternative, to get a much cleaner API listing, by setting your framework target to Build Libraries for Distribution within its build settings. If you want module stability in your framework (and you should), you’ll already have this enabled.
When you’re building a Swift library for distribution additional files are generated in the framework (one for each architecture), with the .swiftinterface extension (you’ll find them within the framework in the Modules/<framework name>.swiftmodule folder). This file gives a breakdown of the public API for your framework.
Given a simple Swift framework with one struct, SStruct, and one class, SClass:
Here’s the output:
// swift-interface-format-version: 1.0
// swift-compiler-version: Apple Swift version 5.4 (swiftlang-1205.0.26.9 clang-1205.0.19.55)
// swift-module-flags: -target arm64-apple-ios14.5-simulator -enable-objc-interop -enable-library-evolution -swift-version 5 -enforce-exclusivity=checked -Onone -module-name swift_framework
import Swift
@_exported import swift_framework
public struct SStruct {
public var integer: Swift.Int
}
@_hasMissingDesignatedInitializers public class SClass {
public func getInt() -> Swift.Int
public func intToString(_ int: Swift.Int) -> Swift.String
public func tryIntToString(_ int: Swift.Int) -> Swift.String?
@objc deinit
}
Pretty good! Should do the trick!
Conclusion
Some good, simple solutions to be able to monitor your public API as it appears in the Swift language. To automate all this you can setup some build phases in your project so whenever you build your framework it spits out the public API to a file and if you put that file under source control you’ll be able to see what changes have occurred in the public API in pull requests or track down which commit caused a change.
Here’s an example of how a change to the signature of the getInt method in OCClass showed up in my git client:
The fact there’s a big red highlight showing something has been removed from the public API is pretty easy to spot here.
As I mentioned, I’ve put together an example project on GitHub, which demonstrates everything in this article, so feel free to check it out for a working example.
Keep an eye on how long these scripts take to run, you may well find that the more code you have in your frameworks the longer they take to execute and might noticeably slow down your build time. You may want to run them only in your CI pipelines (or manually if you don’t have CI!).
Hold on, what about the ObjC API of a framework? In this article I covered how to generate the public API in the Swift language (for both ObjC and Swift frameworks), if you want the same for how your framework’s public API would look in the ObjC language then head over to part 2!