green abstract decorative image with blue lines

How to Distribute a Binary iOS SDK with Third-Party Dependencies

The adjoe WAVE SDK allows app developers to integrate video, playable, and banner ad formats into their apps to monetize their reach easily and effectively. We distribute this SDK as a binary framework. It prevents our customers from making errors when building our SDK from the source code. And there are obviously some security reasons for keeping our code closed.

Our SDK has other third-party dependencies. We will refer to them as transitive dependencies in this article, because this is what they are from the app developer’s perspective. To provide the best experience for SDK users during integration, we need to answer a few questions:

  • Which dependency manager should we use for developing and distributing our SDK?
  • How do we plan to deliver our transitive dependencies to the app developers: source code, precompiled libraries, or another option?
  • How can we guarantee binary compatibility between the app and our SDK? In other words, how can we avoid Swift compiler conflicts?

In this article, I’ll answer these questions. As an example of a third-party dependency, we will use the grpc-swift library. We widely use it for client–server communication. grpc-swift dependencies have some significant restrictions, which makes this topic even more interesting.

Choosing Binary Formats

Back in the day, the main approach for distributing iOS binaries was using a fat framework, also known as a universal framework. This format combines binaries for multiple architectures with a single file. Developers used to build frameworks for multiple architectures and then used the lipo tool to put them together.

The main downsides of a fat library are:

  • The users of the framework need to strip unused architectures from the fat binaries. For instance, when building an app for an iOS device, the iPhone simulator binary part needs to be excluded from the framework. Otherwise, it unnecessarily occupies additional disk space.
  • A fat library doesn’t support multiple binaries with the same architecture. Since Apple Silicon was announced, we now have one more additional configuration for building binaries: arm64 for iPhone simulator. This is the same architecture as iPhone devices have, but the binary for the simulator should be built separately (since it’s built against different iOS SDKs).

In 2019 Apple announced XCFramework. It’s a distributable binary package created by Xcode that contains variants of a framework or library so that it can be used on multiple platforms (iOS, macOS, tvOS, and watchOS), including Simulator builds. It supports multiple binaries of the same architecture and doesn’t require stripping.

The best choice for our team is to use XCFramework as a format for distributing our SDK, since it’s essentially the only officially supported binary format.

grpc-swift through CocoaPods

There are two main reasons why we need to distribute our SDK through CocoaPods:

  • We need to support distribution through CocoaPods for many game developers who want to integrate our SDK. It’s the default dependency manager for Unity projects.
  • adjoe’s WAVE SDK depends on different ad networks, like Mintegral and Meta, and these frameworks are also exclusively distributed through CocoaPods.

We also can use CocoaPods to set up our development configuration, which means we’re going to have an Xcode project with an SDK target and a Podfile that describes all dependencies for our SDK target.

We can add grpc-swift as a dependency for our SDK by adding a pod 'grpc-swift’ line to the Podfile. Then we run pod install and build our XCFramework.

First, we need to build .framework for iPhone devices.

xcodebuild archive \
 -quiet \
 -project ${PROJECT} \
 -scheme ${SCHEME} \
 -sdk iphoneos \
 -archivePath "${ARCHIVES_PATH}/ios_devices.xcarchive" \
 SKIP_INSTALL=NO \
BUILD_LIBRARIES_FOR_DISTRIBUTION=YES

Then, if we want customers to be able to run apps on a simulator, we need to build our framework for iphonesimulator devices. This step will create .framework that supports both x86_64 (Intel processors) and an arm64 (Apple Silicon) simulator.

 xcodebuild archive \
 -quiet \
 -project ${PROJECT} \
 -scheme ${SCHEME} \
 -sdk iphonesimulator \
 -archivePath "${ARCHIVES_PATH}/ios_simulators.xcarchive" \
 SKIP_INSTALL=NO \
BUILD_LIBRARIES_FOR_DISTRIBUTION=YES

The final step is to generate .xcframework by combining framework files from the previous steps:

xcodebuild -create-xcframework \
   -framework ${ARCHIVES_PATH}/ios_devices.xcarchive/Products/Library/Frameworks/${XCFRAMEWORK_NAME}.framework \
   -framework ${ARCHIVES_PATH}/ios_simulators.xcarchive/Products/Library/Frameworks/${XCFRAMEWORK_NAME}.framework \
   -output ${XCFRAMEWORK_PATH}

When we try to run these scripts, we face the first issue. The output says the following:

Compiler error when trying to build grpc-swift with BUILD_LIBRARIES_FOR_DISTRIBUTION flag enabled

Let’s figure out what happened.

We passed BUILD_LIBRARIES_FOR_DISTRIBUTION=YES while archiving the frameworks. This option is required for making XCFramework. With this option, “xcode archive” generates a .swiftinterfece file, which is the compiler’s representation of a framework’s public interfaces. The swiftinterface file enables two features.

Module Stability and Library Evolution

The first is Module Stability, which allows you to use different Swift compiles for the libraries and the app. The other is Library Evolution that allows you to change the library API without recompiling the app. Both options are enabled by having a .swiftinterface file. And the file itself is generated only when we provide the BUILD_LIBRARIES_FOR_DISTRIBUTION=YES flag. You can read more here.

We don’t really use Library Evolution, since we never expect any app developer to update our library without recompiling their application. But Module Stability is a very important feature that we want to support. This is because we have many different SDK users, and we don’t want to force them to use the same compiler version as we do.

The reason for the issue above is that grpc-swift doesn’t support the Module Stability feature. Actually, the error itself is from one of the grpc-swift dependencies – SwiftNIO.

The workaround we can use is to build all grpc-swift dependencies with BUILD_LIBRARIES_FOR_DISTRIBUTION=FALSE and to keep it as YES only for our SDK. We need to do the following:

  1. Remove BUILD_LIBRARY_FOR_DISTRIBUTION=YES line from “xcode archive” scripts
  2. Set BUILD_LIBRARY_FOR_DISTRIBUTION to “Yes” in the Build Settings section
  3. Override BUILD_LIBRARY_FOR_DISTRIBUTION for swift-grpc targets in Podfile through the post-install hook. We need this because otherwise BUILD_LIBRARY_FOR_DISTRIBUTION will be inherited from the SDK target and will be YES for grpc-swift dependencies. Add the script below to your Podfile.
post_install do |installer|
 installer.pods_project.targets.each do |target|
   target.build_configurations.each do |config|     
     config.build_settings['BUILD_LIBRARY_FOR_DISTRIBUTION'] = 'NO'
   end
 end
end

After that, you regenerate the project and run xcodebuild scripts again.

Then it builds, and you have your XCFramework, which simultaneously supports iOS devices and iOS simulators for both Intel and Apple Silicon processors:

Internal file structure of xcframework

Now, we should add grpc-swift as a dependency to the podspec file, upload our XCFramework to the public server, and publish the podspec file to CocoaPods repo. The podspec file will look like this:

Pod::Spec.new do |spec|
 spec.name         = "AdjoeWaveSDK"
 spec.version      = "0.1.1"
 spec.summary      = "Adjoe Wave SDK"
 spec.description  = "Core functionality of Adjoe Wave SDK"
 spec.license      = { :type => 'Commercial', :text => 'https://adjoe.io/privacy/' }
 spec.homepage     = "https://adjoe.io"
 spec.source       = { :http => "path-to-sdk-on-server/AdjoeWaveSDK.zip", :type => 'zip' } 
 spec.author       = { "AdjoeWave" => "contact@adjoe.io" } 
 spec.platform     = :ios, "11.0"
 spec.swift_version = '5.0'


 spec.ios.preserve_paths = 'AdjoeWaveSDK.xcframework'
 spec.ios.vendored_frameworks = 'AdjoeWaveSDK.xcframework'


 spec.dependency 'gRPC-Swift'
end

Now the customer’s developer only needs to add our SDK to his Podfile: pod ‘AdjoeWave’.

With this approach, there’s one huge potential issue. Our SDK compiled and linked against grpc-swift libraries, but it didn’t embed them. On the client’s project side, these grpc-swift libraries will be downloaded as source files. Then they’ll be built by the client’s Swift compiler version, which can differ from the one we used to build our SDK. This can potentially make grpc-swift libraries incompatible with the our SDK, since grpc-swift doesn’t support the Module Stability feature.

The compiler is aware of this, and it gives you a warning during the build process of our SDK: “module ‘SwiftProtobuf’ was not compiled with Library Evolution support; using it means binary compatibility for ‘AdjoeWaveSDK’ can’t be guaranteed.”

Let’s see how we can benefit from using Swift Package Manager instead of CocoaPods.

grpc-swift through Swift Package Manager

Now, we are going to switch our development configuration from CocoaPods to Swift Package Manager.

The main advantages of having Swift Package Manager for SDK development are:

  • Native support in Xcode. You don’t need to install any additional Ruby libraries like with CocoaPods. It just works out of the box.
  • Many libraries are dropping support for CocoaPods. grpc-swift is one of these libraries. While it is still possible to use grpc-swift through CocoaPods, you cannot get the latest version of the library. That’s because it’s only available from Swift Package Manager.

 We don’t need Podfile anymore, and we’ll add grpc-swift dependency right from the Xcode:

Adding grpc-swift dependency to the SDK target

Now we can build the XCFramework. The same as for the CocoaPods configuration, we need to remove BUILD_LIBRARIES_FOR_DISTRIBUTION=YES from Xcode achieve scripts and add it to our SDK target.

We don’t need spec.dependency 'grpc-swift' in our distributable podspec file. Because now grpc-swift is linked statically (it’s the default linkage type for SPM packages) to our SDK and there is no need to provide it as a transitive dependency anymore. This also means that all the implementation details are hidden inside our framework’s binary.

If we try to test our binary with some app, we’ll receive an error message:

.swiftinterface file exposed internal grpc-swift dependencies

The reason for this issue is fascinating. When we link static libraries (grpc-swift) to a dynamic library (our SDK), those libraries’ symbols are exported by the dynamic library by default. This makes grpc-swift part of our SDK’s public interface, which is not allowed – as we found out earlier. There’s an open bug in the Swift repository that addresses this issue. Hopefully, it’ll be fixed some day.

The workaround here is to hide grpc-swift libraries from the public interface. For this, we need to mark all grpc-swift-related imports in our source code with @_implementationOnly, as shown below.

@_implementationOnly import SwiftProtobuf
@_implementationOnly import NIO
@_implementationOnly import NIOHPACK
@_implementationOnly import Logging
@_implementationOnly import GRPC

We need to do this not only for our own code but also for the code we generated through protoc tool. To automate with step, we can add a script that adds @_implementationOnly to every line that imports the grpc-swift library:

sed -i '' 's/import SwiftProtobuf/@_implementationOnly import SwiftProtobuf/g' generated_protos/**/*.swift
sed -i '' 's/import GRPC/@_implementationOnly import GRPC/g' generated_protos/**/*.swift
sed -i '' 's/import NIO/@_implementationOnly import NIO/g' generated_protos/**/*.swift

There’s an open merge request that proposes adding a specific option to protoc that does the same job as script above.

Now, after all these steps, our SDK works fine.

Here, we can see another huge benefit of using Swift Package Manager. Since it statically links all the dependencies (in our case grpc-swift) to the target (our SDK), dependencies are embedded in the target. Our SDK binary is standalone now, and all implementation details are hidden from the user. The app developer won’t have any Xcode or Swift compiler conflicts.

But there’s also one important caveat to pay attention to: If a customer’s application contains a grpc-swift library, it may cause issues with duplicated grpc-swift symbols in runtime. This happens because @_implementationOnly only hides things from the public interface but does not affect symbol visibility. There’s an open issue in the swift-package-manager repository for this.

What’s the Best Solution?

Let’s give some answers to the questions we asked ourselves in the beginning:

  • We are free to use different dependency managers for developing and distribution environments. As we have business requirements to support CocoaPods, we definitely need to support it for distribution. For our development environment, we choose Swift Package Manager because it’s an industry standard these days, and more libraries will be switching from CocoaPods to Swift Package manager. As we’ve just seen, grpc-swift has already done this.
  • Distributing transitive dependencies as a source code is a working approach, but it may cause binary incompatibility, as we’ve seen above – see “grpc-swift through CocoaPods.” Having transitive dependencies as precompiled binaries would be a perfect solution, but it’s not possible if the dependency doesn’t support the Module Stability feature. The trade-off solution is not to expose transitive dependencies to the app developer at all by using static linkage and @_implementationOnly trick. But in some scenarios, it may lead to duplicate-symbols warnings. The choice here really depends on your requirements.
  • To guarantee binary compatibility between the app and SDK, we may use the power of XCFramework and the Module Stability feature. If it’s not possible – as in the case of grpc-swift – we may simply embed transitive dependencies in our SDK by using static linkage.

If you’re interested in more details regarding grpc-swift and Module Stability, you can check out this thread and read a comment from one of the Swift-Protobuf developers. He explains how adding Library Evolution support for grpc-swift significantly impacts performance.

We’re programmed to succeed

See vacancies