Reducing iOS Build Times by using Interface Modules

Reducing iOS Build Times by using Interface Modules

Update (2022-08-31): A more detailed version of this article is available here. Alternatively, you can watch my SwiftHeroes talk about the same subject.

While dividing an app into several modules can improve the build time of an iOS app, the result heavily depends on what is being changed. If you're not careful with how your dependency graph is laid out, you can often have results that are worse than non-modularized apps. Let's take a look at a technique used at iFood to have big and consistent improvements to the build times of our app.

Context: The process to modularize an app

Before jumping into the actual techniques, let's add some context to this so we have a better idea of why they are necessary.

(For the purposes of this article, we ignore the concept of Xcode's incremental build feature as I personally never saw it make a considerable difference in build times compared to the improvements you get by properly modularizing the app. This is geared towards apps that are built on top of monorepo systems that cache builds between CI builds)

When tasked to develop a small project, a developer's first idea will usually be to create a single module that contains absolutely all the code and resources:

Big module

This is how beginners usually develop their first few apps, and how seasoned developers still develop apps that are sufficiently small for this approach to be useful.

This approach is the easiest one in terms of project maintainability, but the worse one in terms of build times. Because everything is packed together, changing anything results in everything being recompiled, even if they have nothing to do with what's changed. While this not much of a problem for small apps, big ones take massive performance hits with this approach, easily reaching build times of over twenty minutes.

Because of that, more seasoned developers will often opt for a modularized structure when developing apps. This time, instead of having a single monster modules, we divide our code and resources into several smaller ones, which are then linked together either manually inside Xcode, or through a dependency manager like CocoaPods / monorepo build system like Buck (what we personally use at iFood)

App with some modules

In this specific diagram, because the modules have no connection to each other, making changes to a feature will make so that other features do not get recompiled, which provides a massive boost to the app's build time. Hooray!

Unfortunately, this statement is only true to this specific diagram. As an app grows, it's more likely that you will end with something like this:

Bad dependency cycle

While the previous condition is still true if the changes are made to the modules in the lower end of this graph (AppFeature1, AppFeature2, AppFeature3), it is not true for the rest.

Take the HTTPClient module for example: Because everyone depends on it, making changes to it will make the entire app need to be recompiled, even if the changes have nothing to do with the modules themselves, like a simple code quality improvement!

Another common problem of this approach is how AppFeature1 is structured: It has a dependency on AppFeature4, which on its turn has a dependency on AppFeature5. An example of how this can happen is if these features represent UIViewControllers -- making so a module needs to import another one for a view to be able to be pushed. This linear dependency between these modules has three major problems:

- Because the dependency is linear, you are unable to compile them in parallel, which is a major performance problem.

- Changing AppFeature5 will recompile AppFeature4 and AppFeature1 even if the changes have nothing to do with them, just because they depend on it!

- Finally, the fact that they completely depend on each other is overkill. They are dependencies only for the purpose of being able to navigate between each other -- they don't need access to anything else of the respective modules. This makes the previous problem even more critical as these features will be recompiled for no reason almost 100% of the time!

The problems of this diagram are a good representation of how iFood looked like for a long time, and although the modularization itself improved build times in some cases, most of the cases still provided very bad build times.

In order to achieve the best possible build times, we need to make our dependency graph as horizontal as possible. If everything is independent, everything can be compiled in parallel.

Horizontal dependency

Unfortunately, this is impossible in our case. Because our modules need to navigate between each other's UIViewControllers, they need to be able to reference each other somehow. The same applies to components like HTTPClient -- as they need to somehow have access to it in order to make HTTP requests, they can never be truly independent. ...or can they?

The true answer is, well, no. However, through dependency injection, there's a technique we can apply to get pretty close to it.

"Interface" modules: Never depend on concrete modules

Let's focus on the problems caused by the previously mentioned linear dependency of some of the modules: These features depend on the modules of the features they navigate to, but they don't access anything of these modules besides the UIViewController that they are navigating to.

Problem

If this problem is caused because the features are depending on more than they need to, what if we divide the navigation aspect of a feature from its actual contents? Having this in mind, instead of a having a massive "feature" module that has everything, we can separate the relevant navigation content into its own "interface" module:

App with interfaces

"Interface" modules don't contain any concrete code or dependencies -- they just contain protocols that are used by the modules that depend on it to reference some piece of code that is defined in the real, concrete module.

For example, assuming that AppFeature1 wants to push a UIViewController that lives in AppFeature2, instead of having AppFeature1 depend on AppFeature2 and directly reference such UIViewController, we can have it depend on a hypothetical AppFeature2Interface module that has a protocol that serves no purpose but to expose the existence of that view to AppFeature1:

protocol Feature2ViewProtocol {}

AppFeature2 can then implement this protocol into the related, concrete UIViewController:

import AppFeature2Interface
class Feature2ViewController: UIViewController, Feature2ViewProtocol

With this setup, AppFeature1 can now reference AppFeature2's UIViewController without actually importing it:

import AppFeature2Interface
let viewTypeToBePushed = Feature2ViewProtocol.self

While AppFeature1 still imports the interface itself, its size is considerably smaller than the actual feature's module, while the final result (getting a view to be pushed) is the same. AppFeature1 now imports only what it really needs, making so it will only be recompiled by changes that actually affect it.

However, there's a small catch. You might notice that this example doesn't make sense: While AppFeature1 has access to the protocol that references AppFeature2's UIViewController, it can't actually push it. If we only have access to the bare protocol, we are unable to create an instance of the concrete class that only exists in AppFeature2.

This is because we're missing a key component in this structure: a dependency injector. In an app like this, because there's no way for modules to reference the concrete information from other modules, there needs to be a global system that is capable of returning the (now hidden) concrete information to modules when a certain protocol is given. At iFood, we solved this by creating a dependency injector called RouterService -- here, each feature's module's interface only exposes a series of Route structs, while each feature's concrete module connects these Routes to the related UIViewController.

With this information exposed, iFood's AppDelegate creates an instance of a RouterService which receives every feature's routes and related UIViewControllers. When a feature asks for a specific Route from another feature's interface to be executed, the RouterService automatically locates which UIViewController should be pushed. The process to create something like was detailed in my previous article, Using Type Erasure to Build a Dependency Injecting Routing Framework in Swift. Here's a simplified example of how the previous example looks like in our app:

// AppFeature2Interface, which depends on RouterServiceInterface
public struct Feature2Route: Route { ... }
// AppFeature2
import AppFeature2Interface

class Feature2ViewController: Feature { ... }

public class Feature2RouteHandler: RouteHandler { 
    var routes: [Route.Type] {
        return [Feature2Route.self]
    }

    func featureFor(route: Route) -> AnyFeature {
        return AnyFeature(Feature2ViewController.self)
    }
}
// iFood's AppDelegate
import AppFeature1
import AppFeature2

let routerService = RouterService()
routerService.register(Feature1RouteHandler())
routerService.register(Feature2RouteHandler())
routerService.start(fromRoute: Feature1Route.self)
// AppFeature1
import AppFeature2Interface

func goToFeature2() {
    routerService.navigate(toRoute: Feature2Route())
    // RouterService translates Feature2Route into the actual Feature2ViewController
    // and pushes the related UIViewController.
}

In terms of build time, because AppFeature1 doesn't depend on AppFeature2 anymore, changes to AppFeature2 will not recompile AppFeature1. If you had multiple modules depending on each other, an app that runs entirely on this structure will provide a massive boost in build performance!

As a bonus, this structure can be applied to everything that can be injected. Using the previous complete diagram as an example, we could also add an interface to the HTTPClient that contains only the protocol that defines how requests are made. This allows modules to only reference this protocol, while the dependency injector becomes responsible for injecting the actual concrete class into the modules that reference such protocol. In the end, we can end up with the following diagram:

Full app with interfaces

(Not shown: In our case, everyone depends on something called a RouterServiceInterface, with the main module depending on the concrete RouterService, which dynamically links the interfaces to their actual classes.)

Note how the graph is considerably more horizontal than its counterpart -- even though everyone depends on HTTPClientInterface, changes to the actual HTTPClient will have no repercussions on the other modules, making the app compile considerably faster. With an entire app running on this structure, you should only have bad compilation times if the interfaces themselves are changed -- something that should be a rare occurrence. In general, every module is going to be completely independent of each other, which can be especially useful and productive when developing inside the module's specific scheme in Xcode.