For a while now in every new feature I’m building into Secrets, I’m defaulting to using Swift.
One of the things Swift brings to the table is access control. Using the
internal, etc, modifiers is a great way to document your intent and vend a clean API.
But to be effective, these features need to be encapsulated into separate modules.
Maintainable software comes from writing small cohesive highly intra-connected components with few inter-connections between them. Using modules forces you to think about the responsibilities of each one, and makes it harder to break abstractions. They also improve reusability and testability.
I was convinced this was the way to go but…
How should I split my project to accommodate this? I’m sure there’s an easy solution.
— A naive Paulo
Well, I just spent two days1 on this 😳.
Now that Xcode has support for Swift Packages this was my first stop. But I didn’t stay long…
Swift Packages are great if your starting a new Swift-only project or building something for Linux as well. In my case, Secrets is +90% Objective-C, with first and third-party frameworks (using Carthage) and a bunch of .xcconfig files. Trying to write a
Package.swift that would accommodate all this was enough to put this idea aside.
Besides, there wasn’t any real advantages of using Swift Packages in my case. There’s nothing a Swift Package can do that I can’t using Xcode’s library and framework targets.
You can think of libraries (static or dynamic) as a collection of code objects (like books in a library). Unlike frameworks, libraries don’t have a built-in way to associate resources such as .strings, images, etc. But for most of these features this wouldn’t be a problem as they are usually code only.
The problem with these targets is that they don’t define a module by default. And while it’s easy to fix this for Swift simply by setting the
DEFINES_MODULE build setting to
YES, this alone isn’t enough to expose the module to Objective-C. To do that, you’d need to create a module map and correctly place the generated Objective-C compatibility header in a custom “Run Script” phase.
While not terribly difficult, having to do so for every new target seemed like too much. Specially considering that frameworks have all this sorted out.
Like libraries, frameworks can be dynamic or statically linked. By default Xcode creates dynamic frameworks but you can easily change that using the
MACH_O_TYPE build setting.
As of writing Secrets already includes 15 dynamic frameworks! And this is excluding Swift’s dynamic libraries. By using dynamic frameworks to encapsulate these features, this number would certainly increase drastically when it should be decreasing instead.
You can reduce your app’s launch time by limiting the number of frameworks you embed.
Although devices keep getting faster, I definitely didn’t feel like starting to split up my project knowing I could eventually have to deal with slow app launches.
Static frameworks solve both problems mentioned above. They automatically define a module usable from Swift and Objective-C and their code is included in your final binary at compile time. So there’s no need to do dynamic symbol resolution at startup for them. You don’t even need to embed these frameworks in you app bundle.
And if all your frameworks are static then you’re all set. But more often than not you’ll want to have one or more dynamic frameworks. Either because you need to bundle them with resources or you don’t want to duplicate your code in all the executables you’re shipping (with all the extensions an iOS app can bundle nowadays this can add up).
The problem with mixing dynamic and static frameworks is that if they depend on each other, you can easily run into to duplicate symbols issues2.
objc: Class _TtC6Static13StaticSymbolC is implemented in both /Redacted/Path/StaticFrameworks-dvmjkolrxuxdzkcjfczlotmecujq/Build/Products/Debug/DynamicB.framework/Versions/A/DynamicB (0x1003903b8) and /Redacted/Path/StaticFrameworks-dvmjkolrxuxdzkcjfczlotmecujq/Build/Products/Debug/DynamicA.framework/Versions/A/DynamicA (0x1003835b0). One of the two will be used. Which one is undefined.
Before starting to slice and dice my project up, I wanted to know if there was a way around this.
So I dug deeper in the rabbit hole and created a test project to come up with a solution. After reproducing the issue I tried a bunch of different tactics like messing with the link order and whatnot… but they were all a dead end.
Taking a step back, and looking a drawing I had made of the picture above, I figured I just needed to turn those solid arrows between the dynamic and static frameworks to be dashed. This way they would just reference the symbols but not include them, since they can be included when linking the final executable.
Or putting it in
ld terms, we need all symbols from the static frameworks to remain undefined on all other frameworks.
Easier said than done.
First of all, Xcode will write all your targets to your built products dir. And it will also include this directory on the framework search path. This means that when building the dynamic frameworks the linker will find the static frameworks that are already in the built products dir and include any referenced symbol automatically.
I didn’t find a way to tell Xcode not to include the built products dir on the framework search path but I did find a workaround: I changed the built products dir for the static frameworks target to be a sub-dir called “Static”.
CONFIGURATION_BUILD_DIR = "$SYMROOT/$CONFIGURATION/Static"
This prevents the linker from finding static frameworks when compiling another framework. But it also means it can’t find the modules these frameworks define…
To fix that, we need to copy both the
.swiftmodule, the module map and headers from the framework, neatly pack them, and place them somewhere Xcode can find. I decided to place these in a “Modules” directory. Adding a “Run Script” phase to our static framework targets can take care of that:
MODULES_DIR="$CONFIGURATION_BUILD_DIR/../Modules" OBJC_MODULE_DIR="$MODULES_DIR/$PRODUCT_MODULE_NAME" SWIFT_MODULE_DIR="$MODULES_DIR/$PRODUCT_MODULE_NAME.swiftmodule" [ -d "$OBJC_MODULE_DIR" ] && rm -rf "$OBJC_MODULE_DIR" [ -d "$SWIFT_MODULE_DIR" ] && rm -rf "$SWIFT_MODULE_DIR" mkdir -p "$MODULES_DIR" # copy swift module cp -R "$CONFIGURATION_BUILD_DIR/$MODULES_FOLDER_PATH/$PRODUCT_MODULE_NAME.swiftmodule" "$MODULES_DIR/" # copy/make objc module mkdir -p "$OBJC_MODULE_DIR" cp "$CONFIGURATION_BUILD_DIR/$MODULES_FOLDER_PATH/module.modulemap" "$OBJC_MODULE_DIR/" cp -R "$CONFIGURATION_BUILD_DIR/$PUBLIC_HEADERS_FOLDER_PATH" "$OBJC_MODULE_DIR/"
And let all the targets know where to find these modules:
HEADER_SEARCH_PATHS = $CONFIGURATION_BUILD_DIR/Modules # ObjC needs this SWIFT_INCLUDE_PATHS = $CONFIGURATION_BUILD_DIR/Modules # Swift needs this
Our built products dir now looks like this:
build/Debug ├── DynamicA.framework ├── DynamicB.framework ├── Modules │ ├── StaticModuleA # ObjC will use this │ │ ├── Headers │ │ │ └── StaticModuleA-Swift.h │ │ └── module.modulemap │ └── StaticModuleA.swiftmodule. # Swift will use this ├── Static │ └── StaticModuleA.framework ├── YourApp.app
You can now import these modules and have autocompletion work as expected. But compiling will still produce an error because the linker won’t be able to find the static frameworks. But we were already expecting that. We need to tell the linker not to treat undefined symbols as errors.
OTHER_LDFLAGS = -Xlinker -undefined -Xlinker dynamic_lookup
What the above setting does is tell the linker to treat any undefined symbols as dynamic.
Finally we just need to make sure the final executable target knows where to find the static frameworks so the linker can do its job and pull in any needed symbols.
FRAMEWORK_SEARCH_PATHS = $SYMROOT/$CONFIGURATION/Static
Done. The project now builds and runs with no issues or warnings related to duplicate symbols 🥳.
Did I complicate this too much? Let me know on Twitter!