How to manage the dependencies of binary frameworks on iOS

Nowadays, managing dependencies for iOS applications with Swift Package Manager, CocoaPods and Carthage is a well-documented topic. However, when it comes to the management of binary frameworks dependencies, there seems to be a lack of comprehensive resources.

One such case is the iOS Contentsquare SDK, distributed as binary framework to be integrated in the applications of our customers. When we decided to add SwiftProtobuf as a dependency of our SDK, we had to explore and compare multiple options before we finally identified the best solution, keeping in mind all the different package managers we had to support.

Along the way, we gained valuable insights and learned a few lessons we would like to share with you today!

Option 1: Copying all Swift files from SwiftProtobuf to our SDK

Often called “vendoring”, this is undoubtedly the safest solution but it has following drawbacks:

  • It requires tedious work to implement. We need to copy all the swift code from SwiftProtobuf to our SDK and then remove all the public/open access.
  • Version management is difficult. We need to check if a new version of SwiftProtobuf is released by ourselves from time to time.
  • Last but not least, there’s limited opportunity for learning from this approach.

Option 2: Adding the dependency with Swift Package Manager

Naively, we can add SwiftProtobuf with Swift Package Manager into our SDK: it works very well except if this SDK is included into an application along with another SwiftProtobuf. Then we’ll see the following warning log:

objc[39572]: Class _TtC13SwiftProtobuf17AnyMessageStorage is implemented in both xxx/Build/Products/Debug-iphonesimulator/Contentsquare.framework/Contentsquare (0x1050a1e28) and xxx framework. One of the two will be used. Which one is undefined.

We definitely want to avoid this for our customers so this option was excluded.

Option 3: Adding the dependency manually

Let’s create a sample Xcode project called Contentsquare using Framework as a template:

Project template

While adding dependencies to a Framework differs from adding them to an application, we can still use Carthage to build them manually. Here is how to add SwiftProtobuf:

  1. Create a Cartfile and add the following dependency:
Cartfile
github "apple/swift-protobuf" ~> 1.0
  1. Use the following command to build it.
Terminal window
$ carthage build --use-xcframeworks --configuration Release --platform iOS

Once we have the XCFramework, it’s the same process as adding dependencies to the application: we can drag and drop it to the “Frameworks and Libraries” section of our SDK target, except we need to select “Do Not Embed” for the Embed option, as nested bundles are not allowed by the App Store:

Distribution failed with errors

Since SwiftProtobuf is not embedded, we will have this error when we launch the application:

dyld[4516]: Library not loaded: @rpath/SwiftProtobuf.framework/SwiftProtobuf

We need to fix this error differently for each dependency manager.

Carthage

Carthage is the easiest, we just need to add SwiftProtobuf into the app’s Cartfile:

Cartfile
github "apple/swift-protobuf" ~> 1.0

Then you can add SwiftProtobuf to the “Frameworks and Libraries” section, and that’s it.

Frameworks and Libraries

CocoaPods

CocoaPods supports adding dependencies to pods themselves, we just need to add the following line in the Podspec file:

Podspec
spec.dependency 'SwiftProtobuf', '~> 1.0'

Now SwiftProtobuf is declared as a dependency of Contentsquare, when we add pod 'Contentsquare', '~> 1.0' to Podfile to our App project, SwiftProtobuf will be added automatically. This should work in most cases unless the configuration of the SwiftProtobuf project is modified.

Known Issues and Workarounds

Changing IPHONEOS_DEPLOYMENT_TARGET

Some of us may change the IPHONEOS_DEPLOYMENT_TARGET in the Podfile with following code:

Podfile
post_install do |pi|
pi.pods_project.targets.each do |t|
t.build_configurations.each do |bc|
bc.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '13.4'
end
end
end

The original IPHONEOS_DEPLOYMENT_TARGET of SwiftProtobuf is 9.0, changing this could cause a build error:

dyld[47592]: Symbol not found: __ZN5swift34swift50override_conformsToProtocolEPKNS_14TargetMetadataINS_9InProcessEEEPKNS_24TargetProtocolDescriptorIS1_EEPFPKNS_18TargetWitnessTableIS1_EES4_S8_E
Referenced from: ...

We need to revert this modification from SwiftProtobuf to fix this:

Podfile
post_install do |pi|
pi.pods_project.targets.each do |t|
# Should not update IPHONEOS_DEPLOYMENT_TARGET for protobuf
next if t.to_s == 'SwiftProtobuf'
t.build_configurations.each do |bc|
bc.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '13.4'
end
end
end
Forcing static linking

We can use use_frameworks! :linkage => :static to force static linking but when this option is enabled, we’ll have a runtime error:

dyld[43291]: Library not loaded: @rpath/SwiftProtobuf.framework/SwiftProtobuf

This is because our Contentsquare Framework expect a dynamic SwiftProtobuf Framework bundle at runtime. When linked statically, the dynamic bundle will not be generated. We need to configure SwiftProtobuf as a dynamic framework to fix this problem.

Podfile
use_frameworks! :linkage => :static
plugin 'cocoapods-user-defined-build-types'
enable_user_defined_build_types!
target "Xxxxx" do
pod 'Xxxx'
...
pod 'SwiftProtobuf', '~> 1.0', :build_type => :dynamic_framework
...

This is a temporary solution, we’ll provide a better one with Swift Package Manager.

Swift Package Manager

Swift Package Manager allows us to distribute binary frameworks. Here is an example using binaryTarget:

.binaryTarget(
name: "SomeRemoteBinaryPackage",
url: "https://url/to/some/remote/xcframework.zip",
checksum: "The checksum of the ZIP archive that contains the XCFramework."
),

But unlike target, the binaryTarget method doesn’t have a dependencies parameter.

While there is no official support for this, here is a workaround used by Firebase:

import PackageDescription
let package = Package(
name: "CS_iOS_SDK",
platforms: [.iOS(.v11)],
products: [
.library(
name: "Contentsquare",
targets: ["ContentsquareWrapper"]),
],
dependencies: [
.package(
name: "SwiftProtobuf",
url: "https://github.com/apple/swift-protobuf.git",
"1.15.0" ..< "2.0.0"),
],
targets: [
// binaryTarget doesn't support dependency, use a wrapper to fix this.
.target(
name: "ContentsquareWrapper",
dependencies: [
.target(name: "Contentsquare"),
"SwiftProtobuf"
],
path: "ContentsquareWrapper",
.binaryTarget(
name: "Contentsquare",
url: "https://xxx/Contentsquare.xcframework.zip",
checksum: "xxx"),
]
)

As Swift Package Manager provides no easy way to link dependencies dynamically, we will have the same issue as with CocoaPods static linking:

dyld[43291]: Library not loaded: @rpath/SwiftProtobuf.framework/SwiftProtobuf

To fix it, we can build our Contentsquare framework statically. This solution should also work for the previous CocoaPods problem.

Static framework

Conclusion

Once all the problems were solved, we found that Option 3 works very well in production for all package management systems.

For more details, you can refer to the public documentation of our iOS SDK!