Enhancing XcodeGen for simpler maintenance of dependencies in modular iOS app

Rafał Kwiatkowski
Fandom Engineering
Published in
5 min readJun 5, 2020
Photo by Zan on Unsplash

Breaking the monolith

Developers that take the challenge of breaking the monolith in their iOS codebase and splitting it into separate modules face another problem: How to best manage multiple .xcodeproj files and potential conflict resolution on them? Fortunately, there’s a really convenient tool called XcodeGen that makes it quite easy — it generates the .xcodeproj files based on the YAML files stored in the codebase. Using YAML files greatly simplifies conflict resolution since you no longer need to version control .xcodeproj files. Instead, you can define your project with a much simpler syntax.

However, when you have a bunch of dynamic framework targets with dependencies between them and other third party dependencies, like Carthage-built libraries, managing them manually with YAML files might be a pain. It’s going to be even more difficult when each of those frameworks has its own unit or UI test target.

In this article, I will present a solution to simplify managing dependencies between app modules. This post assumes that you already know and use XcodeGen.

What’s the problem?

In our codebase we have a setup of one Xcode workspace with multiple projects per application. For the purpose of this blog post, let’s consider a sample application to have a better understanding of what we are talking about. This application consists of three modules:

  • API module — dynamic framework responsible for fetching application data. Let’s say it has a third party dependency, for example SwiftyJSON, used for parsing json
  • Feature module — dynamic framework containing part of application UI, responsible for displaying application data
  • Application target — responsible for application configuration and displaying view controllers provided by the Feature module.

Targets listed above depend on each other. For example, the Feature module depends on the API module and the Application target depends on the Feature module. In our setup, each of the targets has its own unit tests target too.

Let’s see how we define the API module’s dependencies:

targets: ApiModule:
dependencies:
- carthage: SwiftyJSON
embed: false

We define that it depends on SwiftyJSON Carthage library. We usually don’t want to embed third party dependencies in our dynamic frameworks, just link them. It’s because when multiple of them depend on the same library, it would be embedded multiple times in the application package — that’s why we set embed flag to false.

The situation is different with unit tests target. It needs to link and embed the dynamic framework, as well as all its dependencies. If we don’t embed them, we’ll get runtime errors like “Library not loaded: @rpath/SwiftyJSON.framework/SwiftyJSON”. Dependencies definition for API module tests target file would look as follows:

ApiModuleTests: 
dependencies:
- framework: ApiModule.framework
embed: true
implicit: true
- carthage: SwiftyJSON
embed: true

Feature module is simpler — it needs to link only API module to work:

FeatureModule: 
dependencies:
- framework: ApiModule.framework
embed: false
implicit: true

However, when we get to dependencies of the Feature unit tests target, it’s getting more interesting. It needs to link and embed the Feature target, all its dependencies, as well as dependencies of its dependencies and so on. Let’s see:

FeatureModuleTests: 
dependencies:
- framework: ApiModule.framework
embed: true
implicit: true
- framework: FeatureModule.framework
embed: true
implicit: true
- carthage: SwiftyJSON
embed: true

Now imagine that you have a new “Feature2” module, similarly to Feature module depending on the API module, and you need to add another third party dependency to the API module. You will need to link and embed the new third party dependency in both Feature and Feature2 unit tests targets.

The application target needs to link and embed all the modules, so that they are present in the resulting IPA package.

ModularApp: 
dependencies:
- framework: ApiModule.framework
embed: true
implicit: true
- framework: FeatureModule.framework
embed: true
implicit: true
- carthage: SwiftyJSON
embed: true

Although adding dependencies manually is not a big problem when you have only a few modules, it’s not scalable with more of them. Adding a dependency to a module at the end of the dependency chain results in the need of adding it in unit tests of all dynamic frameworks that depend on it, as well as in the application target.

What’s the solution?

In our codebase we needed a solution that lets us specify only the direct dependencies of the modules, so that all the indirect ones are resolved automatically. We also wanted to not need to manually specify whether given dependency should be embedded or not.

We simplified the format of the YAML files that XcodeGen uses. In the new format, we define only the type of the dependency. Let’s see how the definitions look now.

API module:

ApiModule: 
dependencies:
- carthage: SwiftyJSON

API module unit tests:

ApiModuleTests: 
dependencies:
- framework: ApiModule.framework

Feature module:

FeatureModule: 
dependencies:
- framework: ApiModule.framework

Feature module unit tests:

FeatureModuleTests: 
dependencies:
- framework: FeatureModule.framework

Application:

ModularApp: 
dependencies:
- framework: FeatureModule.framework
- framework: ApiModule.framework

Having this, we’ve created a Python script that converts the simplified format of the YAML files to the format that XcodeGen consumes. Its implementation can be found here: https://github.com/Wikia/ios-modular-example/blob/master/resolve-dependencies.py

What it does is:

  1. It reads the YAML definitions of all projects in the workspace
  2. For each target in the project, it iterates over all its dependencies and adds them with a proper embed flag, depending on the project target type — it sets embed as true for test and application target type, and false if it’s a dynamic framework.
  3. It adds all the indirect dependencies — for each dependency of the project targets, it recursively adds all its dependencies with a proper embed flag, depending on the project target type.

Dependency types that we allow are:

  • framework — a dynamically linked framework
  • staticFramework — a framework with Mach-O type set to “Static Library”. It always resolves as a framework with embed flag set to false, as it’s linked statically to the target. We add it as an application dependency to avoid duplicate symbol errors. This type was added because some third party dependencies that we use with the help from Carthage are linked statically, like Fabric or Firebase libraries
  • sdk — one of iOS system SDKs
  • target — another target in the same Xcode project. We usually use it to add a target application of unit/UI tests target

It’s worth mentioning that we recursively add only framework and carthage dependencies of our internal framework dependencies. We’ve decided not to do this for target type because we use it to link a target application of the tests, so we don’t really need to link the dependencies of it to the tests target.

Summary

Using this solution gave us some noticeable benefits. First of all, we no longer need to dive into linking logic of the modules — the script does it automatically for us, we just need to add direct dependencies of the modules. Secondly, we reduced the amount of manual intervention to a minimum when adding a dependency, as now you only need to add the dependencies to definitions of targets that really use them. Last but not least, as a side effect of the dependency resolution, the script produces a very handy dependency graph in the form of a json file that you may take advantage of for different purposes like dependency visualization.

You can find a sample repository with a project that was used as a background to this blog post here: https://github.com/Wikia/ios-modular-example

In my next blog post, I will show how we utilize the dependency graph produced by the dependency resolution script to decrease the time of Pull Request checks in our CI environment, so stay tuned!

Originally published at https://dev.fandom.com.

--

--