Joys of accessing a bundle of a static framework in Xcode
As maintainers of Tuist, we get to work with so many interesting projects. Today was one of those days – this time I got to deep dive into the world of static framework symbols, resource bundle accessors, linking, and more.
The following exploration was sparked by this issue – GoogleMaps integration fails when using the Tuist XcodeProj-based integration.
Understanding the problem #
Whenever I hit an issue in Tuist, creating the smallest reproducible example helps a ton. You can find the reproducer here. What the graph in this scenario boils down to is:
App -> DynamicFramework -> StaticFrameworkWithResources
As you may know, static frameworks can't host resources. What both SPM and Tuist do instead is to generate a new .bundle
target that the .staticFramework
will depend on. The new graph looks like this:
App -> DynamicFramework -> StaticFramework -> StaticFramework_StaticFramework (bundle)
Now let's try to run the app ... and what we end up with is:
StaticFramework/TuistBundle+StaticFramework.swift:37: Fatal error: unable to find bundle named StaticFramework_StaticFramework
That's unexpected! What are we doing wrong here? Let's dig into the TuistBundle+StaticFramework.swift
accessor – for the record, SPM generates a very similar file at build time (whereas Tuist generates one during tuist generate
):
private class BundleFinder {}
extension Foundation.Bundle {
/// Since StaticFramework is a static framework, the bundle containing the resources is copied into the final product.
static let module: Bundle = {
let bundleName = "StaticFramework_StaticFramework"
var candidates = [
Bundle.main.resourceURL,
Bundle(for: BundleFinder.self).resourceURL,
Bundle.main.bundleURL,
]
...
for candidate in candidates {
let bundlePath = candidate?.appendingPathComponent(bundleName + ".bundle")
if let bundle = bundlePath.flatMap(Bundle.init(url:)) {
return bundle
}
}
fatalError("unable to find bundle named StaticFramework_StaticFramework")
}()
}
The crash that we got corresponds to the fatalError
at the bottom. So, how does the accessor bundle work and why does none of the candidates actually contain the bundle? The important bit here is the BundleFinder
class at the top. This class serves as a way to locate the bundle based on where the static framework symbols are copied to.
In Tuist, the StaticFramework_StaticFramework
bundle is copied to the first downstream target that can host resources. In our example, it's the DynamicFramework
. In fact, if we take a look at the Copy Bundle Resources
build phase of the DynamicFramework
, we can find the StaticFramework_StaticFramework.bundle
reference.
To double check, we can even go deeper and find the location of the bundle in the derived data. Based on the build phase, we'd expect StaticFramework_StaticFramework.bundle
to be in path-to-derived-data/Build/Products/Debug-iphonesimulator/App.app/Frameworks/DynamicFramework.framework/StaticFramework_StaticFramework.bundle
. And sure enough, there's the bundle! So what happens here?
Well, as mentioned above the BundleFinder
finds the bundle based on where the static framework symbols are copied to. We can use a nifty command called nm and search for BundleFinder
in the DynamicFramework.framework/DynamicFramework
executable:
nm path-to-derived-data/Build/Products/Debug-iphonesimulator/App.app/Frameworks/DynamicFramework.framework/DynamicFramework | grep BundleFinder
But this command doesn't find the BundleFinder
symbol in the DynamicFramework
. Where is it then? The only other executable in our example is App
. Let's search for BundleFinder
there instead:
nm path-to-derived-data/Build/Products/Debug-iphonesimulator/App.app/App | grep BundleFinder
This will indeed return some references:
000000010000582c t _$s15StaticFramework12BundleFinder33_E84FB199CA5BF790D4BABEA53F2A398ALLCADycfC
000000010000afac s _$s15StaticFramework12BundleFinder33_E84FB199CA5BF790D4BABEA53F2A398ALLCADycfCTq
0000000100005864 t _$s15StaticFramework12BundleFinder33_E84FB199CA5BF790D4BABEA53F2A398ALLCADycfc
000000010000b250 s _$s15StaticFramework12BundleFinder33_E84FB199CA5BF790D4BABEA53F2A398ALLCMF
000000010000af6c s _$s15StaticFramework12BundleFinder33_E84FB199CA5BF790D4BABEA53F2A398ALLCMXX
0000000100006ce4 t _$s15StaticFramework12BundleFinder33_E84FB199CA5BF790D4BABEA53F2A398ALLCMa
0000000100011228 d _$s15StaticFramework12BundleFinder33_E84FB199CA5BF790D4BABEA53F2A398ALLCMf
0000000100011200 d _$s15StaticFramework12BundleFinder33_E84FB199CA5BF790D4BABEA53F2A398ALLCMm
000000010000af78 s _$s15StaticFramework12BundleFinder33_E84FB199CA5BF790D4BABEA53F2A398ALLCMn
0000000100011240 d _$s15StaticFramework12BundleFinder33_E84FB199CA5BF790D4BABEA53F2A398ALLCN
00000001000057f0 t _$s15StaticFramework12BundleFinder33_E84FB199CA5BF790D4BABEA53F2A398ALLCfD
00000001000057cc t _$s15StaticFramework12BundleFinder33_E84FB199CA5BF790D4BABEA53F2A398ALLCfd
0000000100010b28 s __DATA__TtC15StaticFrameworkP33_E84FB199CA5BF790D4BABEA53F2A398A12BundleFinder
0000000100010ae0 s __METACLASS_DATA__TtC15StaticFrameworkP33_E84FB199CA5BF790D4BABEA53F2A398A12BundleFinder
000000010000b10c s _symbolic _____ 15StaticFramework12BundleFinder33_E84FB199CA5BF790D4BABEA53F2A398ALLC
Why is this happening? Let's take a look at the DynamicFramework
and App
source files.
The only file of the DynamicFramework
has the following content:
// DynamicFramework.swift
import Foundation
import StaticFramework
public enum DynamicFramework {
public static func printFromDynamicFramework() {
StaticFramework.printFromStaticFramework()
print("print from DynamicFramework")
}
}
And App
's CustomView.swift
looks like this:
// AppDelegate.swift
import Foundation
import UIKit
public final class CustomView: UIView {
private let label = UILabel()
override public init(frame: CGRect) {
super.init(frame: frame)
label.font = UIFont(font: StaticFrameworkFontFamily.Poppins.regular, size: 20.0) // -> This is where we end up accessing the `BundleFinder` class!
// ...
}
}
As you can see, only App
ends up using the BundleFinder
. And the Swift compiler is smart enough here to know where BundleFinder
is accessed and it only copies those symbols to where they are needed. This is actually useful, because otherwise the DynamicFramework.framework
binary would get unnecessarily bloated with symbols that the framework doesn't need.
Finding a solution #
Ok, now we have a pretty good understanding of what's happening! But how do we fix the problem then? There are three solutions that I explored from here.
Force accessing the BundleFinder
from the DynamicFramework
#
One, albeit hacky, way is to add the following line in DynamicFramework.swift
:
_ = StaticFrameworkFontFamily.Poppins.regular
We're technically using the BundleFinder
here and so the bundle accessor will now successfully find the StaticFramework_StaticFramework.bundle
in the DynamicFramework.framework
. Works, but yeah, hacky.
Set GENERATE_MASTER_OBJECT_FILE
to YES
#
What the heck is GENERATE_MASTER_OBJECT_FILE
? Let's look at the documentation for this setting:
Activating this setting will cause the object files built by a target to be prelinked using
ld -r
into a single object file, and that object file will then be linked into the final product. This is useful to force the linker to resolve symbols and link the object files into a single module before building a static library. (...)
In other words, we can override the default behavior of including static framework's symbols only where they are needed and instead copy all the symbols into the first executable. In our case this would be the DynamicFramework
. And indeed, it works!
If you are a Tuist user and run into this scenario, we recommend that you change this setting. Could we set this by default? Yes, but we could also unnecessarily bloat the final binary by including symbols that are not used.
SPM actually does set this setting by default, but at Tuist, we try to value explicitness over convenience.
Change the StaticFramework_StaticFramework.bundle
copy location #
Alternatively, we can update the Tuist generation logic to copy the StaticFramework_StaticFramework.bundle
directly to the App
instead of DynamicFramework
. Won't this mean that if DynamicFramework
suddenly includes the BundleFinder
symbols instead of App
that we won't be able to find the bundle again? Yes! If we depended solely on BundleFinder
. But let's take a look at the resource bundle accessor candidates again:
var candidates = [
Bundle.main.resourceURL,
Bundle(for: BundleFinder.self).resourceURL,
Bundle.main.bundleURL,
]
While the second candidate would lead to a miss, the resource bundle accessor also looks into the Bundle.main
– and that will be the App
.
What did we do in the end? #
In the end, we decided to go with the last solution – but only for external (SPM) resources. While I would personally lean to setting the GENERATE_MASTER_OBJECT_FILE
, this turned out not to be a viable solution for some SPM dependencies such as GoogleMaps
that actually depend on the implicit SPM behavior and they expect the bundle to be always in the main bundle.
For non-SPM resources, Tuist users can set the GENERATE_MASTER_OBJECT_FILE
themselves if needed, although setting it by default when we detect it might be necessary is something we might consider in the future.
If you are interested in the final solution, here's the PR: https://github.com/tuist/tuist/pull/6565
Tschüss and see you at the next deep-dive 😛