In this article, I will talk about the problems that I encountered when connecting heavy dependencies to an iOS project using the Swift Package Manager and how to solve them.
First, let’s define the concept of heavy addiction. By a heavy dependency, I mean a dependency in which there is a large amount of source code. The most common example of such a relationship is firebase. This is a set of services from Google that are used in the development of web and mobile applications. The most commonly used services are Firebase Crashlytics for crash collection and analysis, and Firebase Analytics for product analytics.
Firebase contains mostly Objective-C and C++ code. But there is also Swift and Objective-C++ code.
After analyzing the source code using the utility
clocyou can find that there are 4,803 files, 150,000 lines of Objective-C code and 93,000 lines of C++ code.
Another example of severe addiction is AWS Swift SDK This is a dependency for working with AWS services using Swift. Contains Swift code, about 2,700 files and 3 million lines of Swift code.
What problems arise when connecting heavy dependencies through SPM
If you connect heavy dependencies using the Swift Package Manager to an Xcode project, the following problems will arise:
1. The time of the cold build of the project will greatly increase
If you connect a heavy dependency in the standard way, through the Swift Package Manager, then the sources of the selected libraries are connected to the project. Because of this, the build time of the application will greatly increase, since it will be necessary to compile these files.
Example 1 – A very simple application
Let’s take for example the simplest application without heavy dependencies.
After calling Clean Build and Build With Timing Summary, we get a cold build time of 0.4 seconds.
Next, we’ll include a heavy dependency, such as the AWS SDK for iOS. Since the AWS SDK consists of many libraries, for example, I connected just one of them – AWS DynamoDB – NoSQL database.
After calling Clean Build and Build With Timing Summary, we get a cold build time of 19 seconds. This happened due to the fact that the source files of this library are compiled during the build process.
Example 2 – Medium sized multi-module application
Medium sized application with dozens of modules no heavy dependencies assembled in 50 seconds.
If you connect Firebase Crashlytics, Analytics and Messaging to this application, then it will take almost 10 seconds longer to build.
When a heavy dependency is connected via SPM, the application’s cold build time increases. This leads to the fact that on CI the application will take longer to build before passing the tests. Developers will wait longer for CI checks.
2. Indexing time will greatly increase
Due to the large number of source files in heavy dependencies written in different languages, the time to index the project in Xcode will greatly increase.
To understand which files Xcode indexes, you need to get and study the indexing logs. To do this, just launch Xcode as follows using Terminal:
SOURCEKIT_LOGGING=3 /Applications/Xcode.app/Contents/MacOS/Xcode &> ~/Desktop/indexing.log
Or call the command:
defaults write com.apple.dt.Xcode IDEIndexShowLog -bool YES
This command allows you to see the indexing logs directly in Xcode, in the Report Navigator panel.
Having studied the logs, you can see that Xcode indexes absolutely all the sources that are in the plug-in dependency. If it is divided into hundreds of separate libraries, and we connect only one to the project, then Xcode will still index the sources of all hundreds of libraries.
Example 1 – A very simple application
Without heavy dependencies, indexing the entire project takes 12 seconds. With the AWS SDK for iOS heavy dependency enabled, indexing the entire project takes 7 minutes 40 seconds.
Example 2 – Medium sized multi-module application
A medium-sized application with a few dozen modules without heavy dependencies is indexed in 2 minutes 15 seconds.
If you connect Firebase Crashlytics, Analytics and Messaging to the same application, then it will be indexed for 4 minutes 20 seconds.
Since heavy dependencies are included as sources, Xcode fully indexes them. If on CI this is not so important. Since indexing happens during the build process, this is a serious problem on developers’ computers. You can normally write code only after the end of indexing.
3. Bugs in Xcode when using SPM
There is a serious bug in Xcode if Swift packages are connected to the project. If you switch branches, which developers often do, it will start doing the Resolving Package Graph, indexing, and Preparing Editor Functionality. And this is despite the fact that the Package.resolved file has not changed. And now the most important thing: if heavy dependencies are connected to the project, then immediately after switching to another branch, Xcode starts loading all the processor cores and for several tens of seconds, or even minutes, it is impossible to do anything with the project. A lot of people complained about this problem on Twitter (once, two, three, four) and on the Apple forum (once, two).
We move from one branch to another, in which 2 new Swift files have been added, and 8 others have changed.
Let’s see what happens with Xcode:
Xcode does Resolving Package Graph first, then indexing, and finally Preparing Editor Functionality. At this time, first one processor core is loaded at 100%, and then all 8 on M1. And now about the reasons. In the DerivedData folder there is a SymbolCache folder in which the project.plist file is located. This file stores information about all the symbols in the project. This file for a simple iOS project with a heavy library connected weighs 141 MB. This is extremely high. When switching branches, Xcode, for some unknown reason, starts removing elements from this file, which takes a very long time. While deleting elements from this file, Xcode completely loads the main thread, which leads to its complete hang. The so-called Spinning Wheel appears. Below is a screenshot from Xcode Instruments → Time Profiler, where you can see all the problems that I mentioned in this paragraph.
But, the most annoying thing is that after that, Xcode loads all 8 cores on the M1 poppy, which causes not only Xcode to freeze, but the entire computer.
From the profiler it is difficult to understand what exactly Xcode does. But from the names you can understand that it continues to work with SymbolCache which is extremely heavy.
Xcode contains serious bugs that prevent you from using Xcode in the case of a project with heavy libraries included.
4. Display a list of dependencies
Heavy dependencies tend to be split across a large number of libraries. In addition, when trying to connect one library, such as Analytics from Firebase, 12 others are connected. Or, for example, if you connect AWS DynamoDB from AWS, then 6 different libraries are connected.
Here I see a small problem in that there are a lot of additional dependencies and they clog the list of dependencies displayed in Xcode.
Solving all problems
To solve all the problems that arise when connecting heavy dependencies through SPM, it is enough to simply refuse to include them in the form of source codes. They must be included as compiled static libraries. Then they will not need to be compiled or indexed. Both Firebase and AWS, and possibly other heavy dependencies for each release, add XCFramework files that are easy to include in the project. Even if the XCFramework file does not exist, you can build it yourself.
Example. XCFramework files for Firebase can be downloaded from GitHub on the releases page – https://github.com/firebase/firebase-ios-sdk/releases. They are in the Firebase.zip file.
To make it more convenient to connect and update XCFramework files, they can be wrapped in a Swift package. This is detailed in the documentation from Apple:
Distributing Binary Frameworks as Swift Packages
Next, I will show, using the example of Firebase Crashlytics and Analytics, how to create a Swift Package with a binary dependency.
We create a new local Swift package and connect it to the project.
We transfer the necessary XCFramework files to the root directory of this package. For example, if we want to connect Crashyltics and Analytics to the project, then we need to transfer XCFramework files from the FirebaseCrashlytics and FirebaseAnalytics directories.
In the Package.swift file under
targets remove all code and add links to XCFramework files using
.binaryTarget. Next, we specify these binary targets for the library that we will distribute. As a result, the Package.swift file will look like this:
// swift-tools-version: 5.6 import PackageDescription let package = Package( name: "FirebaseBinaries", platforms: [ .iOS(.v14) ], products: [ .library( name: "FirebaseBinaries", targets: [ "FirebaseAnalytics", "FirebaseCore", "FirebaseCoreDiagnostics", "FirebaseInstallations", "GoogleAppMeasurement", "GoogleDataTransport", "GoogleUtilities", "nanopb", "PromisesObjC", "FirebaseCrashlytics" ]) ], targets: [ .binaryTarget(name: "FirebaseAnalytics", path: "Frameworks/FirebaseAnalytics/FirebaseAnalytics.xcframework"), .binaryTarget(name: "FirebaseCore", path: "Frameworks/FirebaseAnalytics/FirebaseCore.xcframework"), .binaryTarget(name: "FirebaseCoreDiagnostics", path: "Frameworks/FirebaseAnalytics/FirebaseCoreDiagnostics.xcframework"), .binaryTarget(name: "FirebaseInstallations", path: "Frameworks/FirebaseAnalytics/FirebaseInstallations.xcframework"), .binaryTarget(name: "GoogleAppMeasurement", path: "Frameworks/FirebaseAnalytics/GoogleAppMeasurement.xcframework"), .binaryTarget(name: "GoogleDataTransport", path: "Frameworks/FirebaseAnalytics/GoogleDataTransport.xcframework"), .binaryTarget(name: "GoogleUtilities", path: "Frameworks/FirebaseAnalytics/GoogleUtilities.xcframework"), .binaryTarget(name: "nanopb", path: "Frameworks/FirebaseAnalytics/nanopb.xcframework"), .binaryTarget(name: "PromisesObjC", path: "Frameworks/FirebaseAnalytics/PromisesObjC.xcframework"), .binaryTarget(name: "FirebaseCrashlytics", path: "Frameworks/FirebaseCrashlytics/FirebaseCrashlytics.xcframework") ] )
Next, you need to connect the library to the main target
FirebaseBinaries. We select the target, and in the section Frameworks, Libraries and Embedded Content click on + and choose
In addition, since Firebase is partially written in C ++, you need to include the library
The Firebase.zip file in README.md also states that you need to add a link flag
All! Now you can use Crashlytics and Analytics in your project.
If you do not want to keep the Swift package with xcframework files next to the project, then you can create a separate repository, transfer this package there and connect it to the main project by specifying the repository address.
The build time of the simplest application, after connecting the heavy library, almost did not change. Was 0.4 sec became 1 sec.
The time for indexing has not changed in any way. binary dependency is not indexed.
Xcode no longer uses CPU when switching branches.
The number of dependencies has increased by only one – FirebaseBinaries.
Developer satisfaction has increased. No one else complains that Xcode slows down, it hurts to switch branches.
There is only one disadvantage of this approach – it will become more difficult to update dependencies. You need to download the zip file with the xcframework files and place them in the Swift Package. Fortunately, this can be automated.
If you want to connect to an iOS project, via the Swift Package Manager, a dependency that contains a huge amount of source code, include it in a compiled form, via an XCFramework file. Thanks to this, the build speed of your application will not change, as will the time for indexing the source code, and most importantly, you will avoid Xcode bugs when working with SPM.