Modular Architecture on iOS and how I decreased build time by 50%.
Recently, I was hired by Freelancer as an iOS engineer to work on their core iOS application. As a quite young, fresh, progressive, over-motivated (thanks to YouTube motivation videos) bloke who loves to code I suggested roughly 6 improvements that we should do to the project to improve it. One of the bigger ones was regarding the compile time of the app; which when I started took around 15 minutes after a clean. In this post I’m going to describe how I tackled improving the compile time and the process I went through in transforming the project from one depending exclusively on CocoaPods to a project split into separate framework components, what we gained from it and how the whole process went.
This article is partly about CocoaPods and Carthage. For those who do not know what they are, I will give a brief introduction here. Feel free to skip this section if you are already familiar with both.
Both CocoaPods and Carthage are third-party dependency tools. The main difference between them is the compilation and how libraries are attached to a project.
Pods in Cocoapods are libraries that are bound together with your application code and compiled alongside it. Every time you do a clean on a project, all the third party libraries must be compiled. Compiling libraries can take a significant amount of time if your project uses a lot of them.
Carthage does it differently: it fetches and builds libraries into frameworks. Built libraries must then be integrated manually. It also takes much longer to build libraries with Carthage as they are compiled for all the different processor architectures. However, they need not be rebuilt after a clean in a project; a library should only be built again when you want to update it to a new version. It should be noted that a library can be updated without touching all other libraries. Built libraries are linked to a project and attached to the app after it is compiled. To setup Carthage and link all libraries to the project might be quite time-consuming compared to CocoaPods setup.
Both tools have pros and cons. In general, CocoaPods libraries are easier to add, remove and maintain but they have to be compiled after clean again. Carthage is a bit more difficult to add, remove and maintain but it saves time as all the libraries are not compiled again after a project clean.
About the App
The project has been in development since 2012. I had to deal with a lot of legacy code, rewrite some Objective-C classes to Swift along the way and so on… But overall, the biggest problem were CocoaPods libraries. We had approximately 80 libraries bundled with the app which of course took a significant time to build. Unfortunately, getting rid of CocoaPods libraries was not as easy as replacing them with Carthage because of the dependencies that some of the libraries have, especially the in-house developed ones.
18 out of 80 CocoaPods libraries were developed in-house. For example, the Core library was dealing with API and persistence, the Components library had UI components and of course, the Feature1 library had a feature1 and so on. I was very unhappy with the CocoaPods dependency graph i.e., the Core library had Moya, RxSwift, RxMoya, SwiftyJSON and others as a dependency and the Core library was a dependency of the Feature1 library which had its own dependencies like RxCocoa, RxDataSources and so on… The image below describes simplified dependency graph of CocoaPods libraries we had.
Please imagine the concept from the image above on 18 in-house CocoaPods libraries and 62 third-parties… Well to only install CocoaPods libraries took some time. You could easily break the tree by adding some other dependency into an in-house podspec as each in-house library has it’s own podspec so it was quite hard to organize where the specific third-party library is coming from.
Migrating to Frameworks
Compiling third-party pods after clean took approximately 6 minutes which was just unacceptable. But I could not just easily replace e.g Alamofire with its framework version as Moya has Alamofire as a dependency so as RxCocoa has RxSwift and so forth. Therefore, if I was to replace Alamofire with its framework version, the Moya library would download the Alamofire anyway as it depends on it. I would end up with two Alamofire libraries linked to the project, one manually and the second one used with CocoaPods. This was just an example of the problem no need to mention that I was facing similar issues with the other 62 libraries.
I did not want to do it this way. We decided to get rid of in-house pods and create frameworks instead. We did not want to abandon CocoaPods as some of our CocoaPods libraries were not Carthage compatible and honestly it is much easier to test new libraries with CocoaPods than with Carthage. Our intent was to have both — include third-party libraries built by Carthage alongside with CocoaPods to our in-house frameworks and to the project.
Nice idea… and that is where the real work began. After some research of how to combine pods with frameworks, I created a proof of concept which worked but I was not very certain about it and did not know how it was going to work on a large scale project.
What is a framework anyway? We can look at a framework as some bundle that is standalone and can be linked to a project. The main difference between a library and a framework is in the Inversion Of Control (IoC). When you are using something from a library, you are in control of it. On the other hand when you are using something from a framework you are passing responsibility for it to the Framework. I’ll delve more into IoC in the paragraph below. Libraries, at least on iOS, cannot contain anything other than code. A framework can contain everything you can think of e.g storyboards, xibs, images and so on…
As mentioned above, the way framework code execution works is slightly different than in a classic project or a library. For instance, calling a function from the framework is done through a framework interface. Let’s say a class from a framework is instantiated in the project and then a specific method is called on it. When the call is being done you are passing the responsibility for it to the framework and the framework itself then makes sure that the specific action is executed and the results then passed back to the caller. This programming paradigm is known as Inversion Of Control. Thanks to the umbrella file you know exactly what you can call and instantiate from the framework.
A framework does not support any Bridging-Header file; instead there is an umbrella.h file. An umbrella file should contain all Objective-C imports as you would normally have in the bridging-Header file. The umbrella file is basically one big interface for the framework and it is usually named after the framework name e.g myframework.h. If you do not want to manually add all the Objective-C headers, you can just mark .h files as public. Xcode generates headers for public files when compiling. It does the same thing for Swift files as it puts the ClassName-Swift.h into the umbrella file. You can check the final umbrella file under the derived data folder. Obviously, classes and other structures must be marked as public to be visible outside of a framework. Not surprisingly, you want to expose only files that are called outside of the framework.
There are two types of frameworks, dynamic frameworks and static frameworks. The major difference between them is how the framework is attached to your project. Static frameworks are attached to the project directly and loaded into the memory address which obviously takes time during app startup and leaves a memory footprint. Every change inside of a static library requires an app to compile again as the framework is part of the app. On the other hand, dynamic frameworks are saved in the app frameworks directory and are loaded only when necessary but they are linked to the project during startup. An app crashes when the framework is not found with an error similar to “dyld: library not loaded” when running the app. More on this topic here.
Creating frameworks is pretty straightforward: under the ‘File’ menu, select “New Project”, and then select “Cocoa Touch Framework”.
You can copy any files such as assets, source code, xib files, or storyboards to the framework via drag and drop. One problem I came across while doing this was dealing with the Bridging-Header file: as mentioned previously, the Bridging-Header file is not supported in frameworks. You have to make sure that all the necessary Objective-C headers are imported into the umbrella file.
When I started migrating in-house pods to frameworks I wanted to build them first without Carthage third-party frameworks, meaning that all the third-party libraries were still linked as CocoaPods libraries. The idea was to have a single podfile that contains all of our CocoaPod dependencies. This podfile would be used by all of our frameworks as well as our main project.
I was able to achieve that with a little bit of Ruby and CocoaPods documentation. Each CocoaPod library used in a framework should also be included in the main app, as including one in a framework does not also copy it to the main app target; otherwise, you would end up with “dyld: library not loaded” runtime exceptions when starting the app. It took me nearly 2 weeks to get it to a point where I compiled the app with freshly migrated in-house frameworks linked with Pods.
Basically, I went through all the in-house pods one by one, migrating each of them to frameworks while resolving all of their dependencies, and tried to build them without deploying.
Another week past and I was fixing all the bugs related to the migration. I had tons of issues. One of the most typical was related to `dyld: Library not loaded`. Some issues were related to the access control, some were project related. I also had to deal with some linker errors which were pretty hard to fix.
One library in particular caused a number of problems — Google Maps. The Google Maps SDK library for iOS is made available as a static library, this is because Google continues to support iOS 7 while dynamic frameworks are only available from iOS 8 and above. It takes extra effort to get static libraries working correctly in this setup so I decided to dedicate a section in this article about how to deal with static libraries in particular.
Adding third-party frameworks built by Carthage
To link a third-party framework with an in-house framework, it is necessary to add the framework to “Linked Frameworks and Libraries” under the framework target’s General Settings. Afterwards, you want to tell the linker where to find this framework by specifying the framework path under “Build Settings”. This could be as simple as adding `$(inherited)` keyword, which is very handy if you want to have more then one target in the framework that is going to be using third-party dependencies.
You do not have to set up the Carthage copy phase script here (which copies the frameworks into an iOS application) because a framework cannot be deployed by itself. However, the Copy Phase script should be specified from the App target.
In my case, each in-house framework should have their own set of unit tests. These tests are deployed to a device, therefore, they must contain the copy phase script, otherwise the test target would crash with the previously- mentioned `Dyld: Library not loaded`.
Dealing with static libraries
In this app, we are using the Google Maps library to show users locations on a map. Using our approach so far, this library was added by CocoaPods both to the main app and to an in-house framework.
Due to it being a static library, this led to console warnings such as `Class ClassName is implemented in both …` being printed during startup. It turned out the whole library was loaded into memory space *twice*. This had the effect of increasing the app startup time as well as using some additional memory space.
As previously mentioned, since static libraries are attached directly to the project (unlike dynamic libraries which are linked during run time), it is necessary to make sure that the library is attached only from one location. The solution for us was to extract the GoogleMaps from CocoaPods and link GoogleMaps static frameworks directly to one of our in-house developed frameworks. The library is used only within the framework. However, the framework is linked to other places that are using this library.
Out of curiosity (and good engineering practice), I looked to measure the app’s *pre-main cold start* times. “Cold start” here means that the app was not yet installed on the device, and the app was installed immediately right after the device was restarted, so that the kernel cache is clean. “Pre-main”, as the word implies, simply means the time it took until iOS calls the `main()` function in the app. (You can enable this by setting the `DYLD_PRINT_STATISTICS` environment variable in the app arguments to `1`.)
I ran five tests before GoogleMaps was extracted into a separate framework and five tests after. On average, the pre-main took 7.0s with the library loaded twice and 4.2 seconds without on the iPhone 6 Plus and 11.5s vs 7.3s on iPhone 5. I chose an older iPhone as I was expecting the time difference to grow more significantly with the before versus after; results confirmed this to be the case. With that being said, completing the GoogleMaps extraction saved us around 40% of the total time the app spent in the pre-main load cycle.
Thanks to our migration to frameworks, we now have a modular system and we can look at the app from a different perspective. The image below describes the app after completing this process.
Also while implementing this architecture, we were able to remove more than half of all pre-existing CocoaPods dependencies and replace them with their Carthage framework versions.
After roughly 6 weeks, everything was set up and the project was compiling happily. Things that we have achieved on the project thanks to this architecture are really positive. I would like to highlight the pros and cons here.
- Decreased clean project compile time thanks to not compiling CocoaPods libraries repeatedly. When I started this project our compile time was between 12–15 minutes — now compile takes 6–7 minutes. There are still more than 30 CocoaPods libraries left to migrate, so we are aiming to get it even lower than that.
- Divided project into logical parts that are living their life nicely under the framework and are visible from the umbrella file.
- Developers can run and develop only specific parts of the app (specific framework) without compiling everything.
- More flexibility on the project. A framework can be quite easily taken out or linked back into the project.
- We can now develop more efficiently using XCPlaygrounds as a project cannot be imported to the XCPlaygrounds but a framework can. A perfect use case is for developing UIComponents. With Playgrounds there is almost an instant build of the view.
- Every framework contains its unit tests so it can be run in isolation from the rest of the app.
- Setting up and maintaining everything might be quite painful as there are so many things to do and maintain during the development.
- Merge conflicts of pbxproj are something that is very unpleasant. The solution for it is to use XcodeGen. More on XcodeGen here.
I would probably not use this architecture on a project of a small scale as the overall complexity grows a lot with this setup. I recommend everybody who is interested in it on a deeper level to watch this video.
Feel free to download and test an example of this architecture here.
I would like to give a special credit here to my team lead Aldrich Co, who helped me a lot with proof reading of this article.
Thank you for reading.