How the Zalando iOS App Abandoned CocoaPods and Reduced Build Time
Read the takeaways from our adoption of manual dependency management.
Dependency management doesn’t have to be complicated. The current dependency managers for iOS are CocoaPods, which is the de facto standard tool, Carthage, and Swift Package Manager. Despite the range of automated solutions, we found at Zalando that manual management brings about better performance and doesn’t require much maintenance in a big, modular project. In this article, I will describe my experience transitioning Zalando’s iOS app from CocoaPods to manual dependency management, which in the end resulted in a 40% improvement in build time and startup time.
The initial problem
Although an app such as ours may seem simple and straightforward in appearance, the project overall is quite complex under the hood. Our main project for building the app is split into several subprojects. Each subproject can import another. On top of that, each subproject can have third party dependencies.
An illustration of our model of subprojects and the dependencies between them can be seen in the following diagram:
Our model of a project structure: The main app imports many frameworks (represented by blue arrows), some of which import other frameworks, and most frameworks depend on external third party code, provided by CocoaPods. Due to link issues, we have to link all pods twice; once for a framework target and once in the main app target (represented by red dashed arrows).
Our project’s usual clean build time is 5 minutes. Since our subprojects are interdependent on one another, changes in one subproject can trigger an avalanche of rebuilding for other subprojects, along with all third party code in the workspace.
We use third party libraries inside our main project and within subprojects. Our subprojects can import one another, resulting in chains of up to six nested imports. In other words, there were frameworks, importing other frameworks, which import even more frameworks. And all of them also use third party dependencies.
For the Zalando iOS app, we used CocoaPods for dependency management. While using it over the past three years, we have been faced with its advantages and disadvantages.
CocoaPods does a great job automating updates to third party code and integrating them into your main project. You don’t have to wire up everything manually. When any of your dependencies needs to be updated, it’s just a matter of running a ‘pod update’ command.
While being very convenient to use with a monolithic project, we found it hard configuring Podfile dependencies for a modular project structure. Our Podfile was big (230+ lines of code!) and had some tricky configuration, both for target dependencies and post-install actions. We regularly had issues with build settings that changed due to automatic pod integration. An additional hassle was updating and installing CocoaPods itself.
CocoaPods is a wonderful tool. It allows you to automatically integrate and update dependencies, whilst supporting recursive dependencies. However, the software comes with a maintenance cost when your project becomes bigger and begins to use more dependencies. If your project is a mix of Swift and Objective-C, you only have the one choice of using dependencies as dynamic libraries, even though dependencies are written in Objective-C. Additionally, since CocoaPods integrates source code of dependencies into your project, it will be recompiled every time and affect build time.
When it comes to a mixed project, like ours, having 60% of Swift and rest Objective-C code, CocoaPods forces all dependencies to be dynamic frameworks, even though more than a half of them are written in Objective-C. With more than 50 dynamic frameworks, our app’s startup time became very slow due to slow dynamic library loading during app startup.
Another issue was our slow build time. The root cause came via including a pods’ source code into the workspace, which often forces recompilation of the same files that very rarely change. Again, with a number of dependencies we use, it becomes a problem over time.
So, what are the other alternatives for a project structure like ours and third party dependency management?
Alternative solutions
Besides CocoaPods, there are three alternatives when it comes to dependency management: Carthage, Swift Package Manager, and the manual approach.
When looking at Carthage, it is close to what we want: it compiles frameworks and includes them in your project. You can even include pre-compiled Objective-C libraries. The downside is that not all CocoaPods libraries are also available in Carthage.
Of course, we could use CocoaPods for Objective-C and Carthage for Swift dependencies. In fact, we did have a setup like this going for our project. But it made dependency management complicated. Instead of working with one tool, developers needed to handle two different tools for the one project. If there were conflicts between the two tools, then they needed to be resolved, which costs time and resources.
The next tool we assessed was Swift Package Manager. While it also compiles code into executable modules, and developers don’t need to rebuild the dependency over and over again, it is a Swift-only service, thus doesn’t suit our needs.
This leaves us with the manual approach. As it turns out, we needed just a few simple scripts that support the most common tasks for dependency management. Namely, downloading source code, compiling it into a static library or dynamic framework and integrating those products into our project. The rest of the work is done via Xcode build configuration files.
Our idea for a solution
After researching the available alternatives, I decided to try out the manual approach. Before performing the task, I wanted to test its feasibility. For this, I created a small prototype which replicated the complicated setup we had. You can take a look at the example project here.
The prototype consists of the main project and three subprojects. The main project builds an app and imports two subprojects. One of them is a static library and the other is a dynamic framework importing another dynamic framework.
After playing with the build settings for some time, I learned how to configure dependencies and wrote down the necessary Xcode configuration files. I also adapted a script from CocoaPods that embeds third party frameworks into the final app bundle. Great! Now I had an idea that might just work.
How to manually integrate dependencies
First off, you’ll need to place all your frameworks and libraries into the respective directories of your project. You don’t have to import them into the Xcode project itself. All we need to do is specify the path to those directories in the build settings.
All that is needed is to create a simple directory structure, build configuration file, and a “copy-frameworks” shell script to integrate the compiled dependencies into the Xcode project.
Once this is completed, you place all of your frameworks into the Frameworks folder and create a configuration file with the following setting:
ThirdPartyConfig.xcconfig:
FRAMEWORKS_SEARCH_PATH = “${SRCROOT}/Frameworks”
SRCROOT is a Xcode-provided build variable that contains a path to your .xcodeproj file.
Next, you’ll need to place all of your libraries into another directory, perhaps called “Libraries”, and specify the path to it in your build settings
ThirdPartyConfig.xcconfig:
…
LIBRARY_SEARCH_PATH = “{SRCROOT}/Libraries”
To be able to import a library into your code, you also need to copy the library’s header files into a folder, say Libraries/include/, and give it a path to include a folder to Xcode:
ThirdPartyConfig.xcconfig:
…
HEADER_SEARCH_PATH = “{SRCROOT}/Libraries/include”
If your library comes with resources, it’s convenient to package them into a resource bundle and include it into the “Copy Bundle Resources” build phase. You can place all such resources into a Libraries/Resources/ directory and import them into your Xcode main project from there.
From here you’ll need to specify which libraries your app is using in linker flags. This is needed in order for your app to link to compiled frameworks and third party libraries. To do so, add the setting `-framework “FrameworkName”` for each framework to OTHER_LDFLAGS; for each library add `-lLibraryName` (note that typically a library’s name is `libABC.a` and you’ll need to specify, for example, `-lABC`, which is without the `lib` prefix and extension).
ThirdPartyConfig.xcconfig:
…
OTHER_LDFLAGS = -framework “” -l …
Lastly, you’ll need to add a Run Script phase in which you copy all of your third party frameworks into the app bundle. This is required because your app needs dynamic libraries to run, which will be searched during runtime. On the one hand, Xcode doesn’t automatically copy frameworks that are specified inside the OTHER_LDFLAGS build setting, so you’ll have to complete this step yourself. On the other hand, the team from CocoaPods and Carthage have already solved this problem, so we can use their script to copy all the frameworks we have inside the main app. You can find a link to the script here.
The final configuration file will look like this:
FRAMEWORKS_SEARCH_PATH = “${SRCROOT}/Frameworks”
LIBRARY_SEARCH_PATH = “{SRCROOT}/Libraries”
HEADER_SEARCH_PATH = “{SRCROOT}/Libraries/include”
OTHER_LDFLAGS = -framework “Framework1” -lLibrary1
After creating my prototype, it became clear that the manual approach was a feasible choice – our team decided to scale it up into our real world project.
Implementation
The real work here will require more effort than just building a prototype. To approach the task of replacing CocoaPods with hand-built libraries and frameworks, I needed a plan. After analyzing the project and its dependencies, I set about creating diagrams to better illustrate our implementation plan.
The first diagram consisted of subprojects and their interdependencies. Next, I counted incoming and outgoing dependencies for each subproject we had. All of this helped me to think about how to better merge smaller subprojects with bigger ones and reduce the scope of configuration work. The latter part of this sequence will be achieved during the integration of third party dependencies.
Instead of having 20 subprojects, my aim was to have 10. In practice, I was able to reduce this down to only 8 relatively big subprojects. This benefited me later during third party dependency configuration.
I then listed all of our third party dependencies from Podfile and Podfile.lock, which allowed me to capture dependency versions. All in all, we had 61 items on the list.
This analysis really helped. Having a plan or a checklist is a good idea, because it covers your bases, ensures all steps are covered, and also gives you an overview of potential problems, for example, coping with the risk of shipping an unstable solution by adding the test and verification steps.
The plan
Below are the steps we defined for the transformation of our project:
Preparation
- Merge small subprojects with bigger ones or back to the main project
- Remove unnecessary dependencies
- De-integrate CocoaPods
Integrate third party dependencies
- Manually compile and integrate all dependencies
- Configure subprojects and the main app
Verify that the app still works
- App must run on simulator and device (and not crash!)
- Tests must succeed for phone and tablet targets
- App archiving must work
Initially, I thought it would take 2.5 months to complete this plan. In practice, it took 1.5 weeks, each step taking 2 to 3 days.
Planning is an important task in almost any problem solving activity. It allows us to spot potential problems early, allocate resources more efficiently, and eventually save time during implementation.
Preparation
After some careful planning we got started. I de-integrated CocoaPods, merged some subprojects together, downloaded and compiled dependencies, then integrated them. All in all, it took me 2 working days to accomplish.
I have started with preparation step, as it was the easiest to perform and didn’t require any significant code changes or testing. I merged smaller subprojects, adjusted import statements referencing those small projects in the code, and verified that the app was still working. I then de-integrated CocoaPods using the `deintegrate` plugin:
$> for x in */*.xcodeproj; do cd “$%.x”; pod deintegrate $x; done
The command above will go into every directory containing the xcodeproj file and run `pod deintegrate` on each project.
Integration of third party code
The next step that took approximately two days, which was the manual integration of third party dependencies. I had started doing everything manually and later automated some tasks using shell scripts.
What I learned from this experience is that many third party library authors do not provide ready-to-use Xcode projects, which I had to manually create to compile sources in a static library or (for Swift sources) a dynamic framework.
Another interesting aspect was that although some frameworks are provided in a ready-to-use form, they are actually just static libraries wrapped within a framework bundle – I went about converting such frameworks into static libraries. For example, the Google Analytics framework depends on other libraries from Google, all of which are distributed as static frameworks packaged in a framework bundle. I had to move the binary out of the frameworks and rename them to be static libraries.
My usual flow of converting a pod into manually wired library went like this: First, I looked up the pod name and concrete version in the Podfile and Podfile.lock files and searched for it on CocoaPods.org. With these steps, I had access to the pod’s repository.
The integration of third party libraries or frameworks has several steps. I began by downloading the source code, then optionally, creating an Xcode project with a library or framework target. I then compiled the product for simulator and device. Finally, I copied the produced framework or library to it’s respective location in the root of the project.
Once done, I cloned the repository and checked out the needed tag or branch. If a project came with a Xcode project file, I would build a static library or dynamic framework for release configurations – both for simulator and device architecture (choose Simulator, hit Cmd + Shift + I, then choose “Generic iOS Device”, hit Cmd+Shift+I).
Next up I compiled products inside the Build/Products/Release-iphonesimulator/ and Build/Products/Release-iphoneos/ directories. I then copied the libraries into main project’s Libraries/\({PLATFORM\_NAME} folder and did the same with the frameworks into the Frameworks/\) folder. As I mentioned above, I also had to copy all header files into included directories for the libraries.
I want to take the opportunity here to make a small side note about Clang modules and how you can make any static library into such a module.
Clang modules
In the code, I prefer to use Clang modules. For example, instead of #import I use @import Framework; which makes use of a precompiled module and results in better build time for Objective-C. Using modules is the only way for a static library to be accessible via Swift code.
Most of the time when compiling third party Objective-C libraries, I also had to add a special “module.modulemap” file which would describe the library as a module to the Clang compiler. I would then put this file into the library’s include directory. The usual modulemap file has the following content:
module FrameworkName {
umbrella header “FrameworkName.h”
export *
}
The code above makes sure that all of the classes imported from the umbrella header are accessible in the defined module.
Now, let’s go back to our transition into manual dependency management.
Saving third party code in the main repository
One problem I faced during third party code integration was how to store the source code of a dependency in a project. Initially, I started to work with git submodules which would store references to remote repositories in the main repository.
Working with submodules quickly became an issue. The drawback I faced was that all of the changes to third party source code I was making in order to integrate it into the main project (such as creating an Xcode project and sometimes adjusting source code to use quoted imports instead of angled) would be lost if not committed back to the remote repository.
I didn’t want this to happen because the changes were project-specific. One option would be to fork the remote repository and use it as another remote, then pull upstream changes once there are updates in the original repository. This option seemed like too much work.
In the end, I simply went with cloning the repository, checking out the revision I needed, and deleting the .git directory. By doing this, I had the source code and could commit the changes in the main repository I was using.
Xcode build settings from configuration files
After I had downloaded, compiled, and moved all of the libraries and frameworks to a central “ThirdParty” directory, I wrote build configuration files for each subproject as well as the main app.
Each configuration file would have a similar code:
FRAMEWORK_SEARCH_PATH = …
HEADER_SEARCH_PATH = …
LIBRARY_SEARCH_PATH = …
OTHER_LDFLAGS =
When I wanted to include framework X.framework, I would add ‘-framework “X”’ to OTHER_LDFLAGS, and when I wanted to include library libY.a, I would add “-lY” to OTHER_LDFLAGS. If a library or a framework required other system frameworks, I would also add it here.
In order for the build configuration file to be effective, it needs to be added to targets from the “Info” tab in a project.
In order to apply the build configuration file, you need to add it to your project first. Select your project in the project navigator, then select the Project’s Info page. Under the “Configurations” menu, select the build configuration file name for each configuration of your target.
For the main app target and for all of the test targets I also added a Run Script build phase that was copying all of the third party frameworks into a product bundle. Frameworks are dynamically linked in runtime and not part of the app or test bundle, making this step a must. They need to be explicitly copied into the final product. I’ve adapted the CocoaPods copy frameworks script to fit this need, see here.
After all of that and several cycles of fix-and-build, I could successfully compile the app. One of the errors I faced during the integration was that some libraries were compiled with an incompatible iOS platform version, and some were compiled with different versions of Swift, requiring me to compile those libraries again. Thankfully, the linker tells you whether there is an error or warning.
Verifying that the app works
After getting the app compiling and running, I wanted to make sure it was still working as before, that the tests were running successfully, and that app could be archived for distribution. This was a crucial step: If I ship my changes to teammates as is, with the possibility of errors, we would lose unnecessary time fixing the problem. Test before you declare your work to be done.
After moving dependencies to a manual integration model, I found that some of the test bundles were crashing. After some debug and analysis it was clear that the test bundles couldn’t access the dynamic libraries linked to the app. To fix this, I had to add a Run Script build phase to copy a frameworks script to every test bundle, which worked for this project. I also did some quick bug-bashing to make sure the app worked fine and ran an archiving script on top. You can find an example project illustrating the manual approach to dependency management on GitHub here.
Results
After compiling all of the dependencies, I was pleasantly surprised that this work had good side effects. I first experienced a clean build time that decreased by two minutes, dropping from five minutes to three minutes. This was made possible by Xcode since it didn’t need to recompile all of the dependency’s source code.
Another improvement was in the startup time of the app, which decreased from five seconds to three seconds. This came about due to many third party dependencies being converted to static libraries, and a number of dynamically linked frameworks then being dramatically decreased, meaning our app didn’t need to load those frameworks during startup. This work was interesting to do and I was glad that it led to improvements both for developers (build time) and our users (startup time).
Summary
To sum up, here are the takeaways from our adoption of manual dependency management
- Having a complex project structure with CocoaPods leads to maintenance problems and increased build times
- Using CocoaPods in a mixed Swift and Objective-C project leads to using third party dependencies as dynamic frameworks, despite many dependencies being Objective-C
- Most of the dependencies were rarely updated
- Currently, neither Carthage nor Swift Package Manager support Objective-C packages, meaning we had to switch to manual dependency management
- The manual approach consists of proper build settings for the compiler and linker to find required libraries and frameworks, and a shell script to copy third party frameworks into the final app bundle
- Transitioning from CocoaPods to manual package management is a complicated task and requires proper planning
- Saving the source code of your dependencies in the main repository is a viable alternative to submodules
- Switching from CocoaPods to manual package management improved build time and startup time
This was our experience at Zalando when we transformed our project from CocoaPods-managed dependencies to manual dependency management. If you have any questions or would like some help with your own project, reach out via GitHub and I’d be happy to lend a hand.
We're hiring! Do you like working in an ever evolving organization such as Zalando? Consider joining our teams as a Mobile Engineer!