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:
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)
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:
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.
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.
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:
"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:
(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.