Modular Apps with a Tuist — Part 1 — Migrating to tuist

Ronan O Ciosoig
9 min readApr 11, 2021

A step-by-step walk-through converting an existing simple Xcode iOS app into a modular architecture project based on Tuist.io tools

This article is the first of a 4-part series, the others are:

Part 2: Network Module

Part 3: Backpack UI

Part 4: Catch & Home

The accompanying GitHub repo is here

The A-Ha Moment

When I first read about tuist, I felt compelled to give it a try with a simple project I had developed previously to put the ideas presented to the test, run it through its paces and find out if there are any blocking issues that a development team would consider critical when adopting this tool.

I chose a simple project I did a couple of years ago called Pokedex (there are literally thousands of implementations of this) — a very basic 4-screen app as the test subject. It consists of a home screen with 2 buttons (Catch and Backpack), a catch screen, and a backpack screen, which are presented modally. Tapping on one of the characters in the backpack shows the character detail screen. Internally it is using Moya via CocoaPods to make network requests to the Pokemon API. Additionally it uses 2 other libraries defined in the Podfile that show a loading spinner UI and an image cache. There are a few token unit and UI tests. A detailed description of the project can be found in the README in the repo.

The Goal Posts

This is the first in a series of articles where I go in depth through every step of migrating this project into a modular structure using tuist to generate all the projects and frameworks. In the following paragraphs I go through the process of replacing CocoaPods dependencies to load using Swift Package manager, and converting another CocoaPods Pod into a tuist project and load it as a module. Targets for the automated tests are added, as well as 2 custom build schemes with launch arguments, keeping the original functionality of the project.

To accompany this article a Github repo was initialised with the working project, updated to iOS 14.4. tuist version 1.31 is used for the project. Every change described in the article has a corresponding commit.

I strongly recommend being broadly familiar with tuist documentation before continuing this article. Have a look at docs and this introduction by Sarunw.

The Break-Up

What could a modularised app look like? A first step might be thinking of a layered approach and separating out the networking code into a dedicated module encapsulating all the dependencies loaded via CocoaPods. Next, in the UI layer, the image loading and caching library used could be refactored into another module, encapsulating all the Objective-C code in a framework. Then the project structure has some core files that define how the coordinator pattern works and some common shared sources that need to be in a separate framework so as to prevent a cyclical dependency. And finally the UI could be split into “Home” and “Catch” and “Backpack” modules.

The resulting modular structure would look like this:

Noise Filtering

The core concept of tuist is that the Xcode project and workspace files are generated so they should not be committed to Git. The generate command also parses Info.plist files into code and places the output in a “Derived” folder. A new tuist project has a gitignore included in its template and since I am modifying an existing project which already has a gitignore file I add the following:

### Projects ###*.xcodeproj*.xcworkspace### Tuist derived files ###graph.dotDerived/

A New Tuist

The first step is to rename the project folder that contains the sources and then create a new empty folder of the same name.

Pokedex-master % mv Pokedex OldPokedexPokedex-master % mkdir PokedexPokedex-master % cd PokedexPokedex % tuist init --platform ios

The last command creates the tuist project with the following structure:

Screenshot of the files added by the init command

The init command generates 3 targets following this naming convention:

<Name> .app

<NameKit> .framework

<NameUI> .framework

Each <NameKit> framework needs to have an app that will load and verify it. The functionality of the module may be further separated into an additional framework for the UI part.

As previously mentioned the aim is to have all the sources in the app as a single target and the Xcode project file generated by Tuist. Since I only want to have a single application without other frameworks so I delete the PokedexKit and PokedexUI folders that were auto-generated inside the Targets folder. Then using “tuist edit” command, I removed the objects defined in the project “additionalTargets”. I then drag all the code from the OldPokedex folder into Sources, and drag Assets.xcassets and Base.lproj folders into the Resources folder.

Trying to add a file at path /Users/ronan.ociosoig/Downloads/Pokedex-master/Pokedex/Targets/Pokedex/Resources/LaunchScreen.storyboard to a build phase that hasn't been added to the project.

This is caused by a duplicate of the LaunchScreen.storyboard because there was one provided by the tuist command and the original one inside the Base.lproj folder. After deleting the generated one the ‘tuist generate’ command works, and the workspace can be opened.

The Dependency Clinic

Next I am going to tackle the dependencies loaded via CocoaPods. The Podfile only has 3 pods defined, but Moya loads several others including Alamofire, which in turn loads many more.

pod 'Moya'pod 'Haneke'pod 'JGProgressHUD'

Both Tuist and CocoaPods have dependency graphs and tuist cannot merge them. Thus it is best to avoid the latter, and replace them with either Swift packages or Carthage. As it happens, both Moya and JGProgressHUD are available as Swift packages. Haneke, an image loading library written in Objective-C and isn’t being maintained, is only available as a Pod. I will make this into a new framework instead.

I run “tuist edit” and select the Project+Templates.swift file. This is a helper source file and adds extensions to Project. To add packages I need to update the app function signature to:

public static func app(name: String,platform: Platform,packages: [Package],targetDependancies: [TargetDependency],additionalTargets: [String]) -> Project

Then add this:

var dependencies = additionalTargets.map { TargetDependency.target(name: $0) }dependencies.append(contentsOf: targetDependancies)var targets = makeAppTargets(name: name,platform: platform,dependencies: dependencies)targets += additionalTargets.flatMap({ makeFrameworkTargets(name: $0, platform: platform) })return Project(name: name,organizationName: organizationName,packages: packages,targets: targets)

And then in the Project.swift file the changes needed looks like following:

let project = Project.app(name: "Pokedex",platform: .iOS,packages: [.package(url: "https://github.com/JonasGessner/JGProgressHUD", .upToNextMajor(from: "2.0.0")),.package(url: "https://github.com/Moya/Moya.git", .exact("14.0.0")),.package(url: "https://github.com/antitypical/Result.git", from: "5.0.0")],targetDependancies: [.package(product: "Moya"), .package(product: "Result"), .package(product: "JGProgressHUD")],additionalTargets: [])

With those changes done, the project now loads all the Swift packages from Moya, JGProgressHUD and all the other packages that depend on Moya. It would be nice to eliminate those dependencies but that’s a topic for a different article. When done, I verify the changes work by generating the project again (repeat this cycle every time either Project.swift or template changes) and open the workspace.

Screenshot of Xcode project showing the Swift packages loaded as dependancies

Note that Moya 14.0.1 seems to introduce 7 other dependencies so that’s why the exact version is used. See the last few commits for the fix.

Cached And Loading

The final step to get this project compiling again is to fix the Haneke dependency.

I download the zip from the original repo here: https://github.com/Haneke/Haneke

Note that there is a Swift version of it, but alas it isn’t as good (missing some optimisations) and the Swift version doesn’t have the same API.

Next, I create a new tuist project for Haneke and copy over the sources downloaded from Github into the Targets/HanekeKit/Sources folder.

I update the template definition for the framework so that it includes the header files.

Since the library is called “Haneke” and not “HanekeKit”, it is simpler to just rename the Haneke folder to HanekeApp, the HanekeKit folder to Haneke, and then update the Project.swift file, and then do a “tuist focus” on HanekeApp, and edit the AppDelegate source. Here I change the name of the image to “Haneke”, and then extend the viewController to load a sample image using the extension to ImageView.

At this point the HanekeApp loads an image using the Haneke framework.

Screenshot of the Pokemon character loaded in the Haneke example app
The Pokemon on the Haneke sample app screen

I next have to integrate this framework into the Pokedex application.

Running “tuist edit” again in the Pokedex folder, there is an issue with loading the Haneke framework. In the app function the parameter “additionalTargets” is an array of strings. I change this to a struct with name and path properties. Then in the makeFramework target I add the relative header path.

At this point, the project compiles and runs, but it crashes at startup with this error: “Could not find a storyboard named ‘HomeViewController’ in bundle NSBundle”. The issue here is that storyboards and Xib files are not loading. The solution is to define the resources path globs so that they include all the storyboards and Xibs like so:

"Targets/\(name)/Sources/**/*.storyboard","Targets/\(name)/Sources/**/*.xib"

With this added to the project template, one final run through “tuist generate” and “tuist focus Pokedex” and the app runs correctly. Success.

Automated Tests.

I copy over all the sources and mocks over to the Pokedex/Targets/Pokedex/Tests folder, then edit the project template to include the resources in the test target. Once I open the project again and run the tests they all pass. Easy.

Getting the UI tests working is a little more work. I update the project template for the main app by adding a target for the UI tests. This is almost the same as the unit tests target, but changing the product to “.uiTests”. I create a UITests folder under the Pokedex/Targets/Pokedex/ hierarchy, and move the 2 UI test sources here, and run ‘tuist generate’ and then ‘tuist focus Pokedex’ to open the project again, and run them. Crash.

In the sources there were 2 issues causing the tests to crash: the JSON mock data file was not loading, and this was in the main app target, and then a mistake in the accessibility identifier where the button was not defined. With those 2 changes made, the UI test run perfectly.

Spring Cleaning

Now that the migration away from CocoaPods is completed the Podfile and the Pods folder can be deleted.

A little bit of Scheming

In the original project I had added 2 custom schemes each with a launch argument. One was “Pokedex Network Testing” which enabled the logging feature in Moya and gives a detailed trace for each call. The other is “Pokedex UI Testing”, which is simply passing the launch argument replicating the UI test.

To add additional schemes, again in the project templates, I add a function to generate the 2 schemes with the launch arguments, and pass the output into the project constructor. As a bonus I have enabled code coverage on the UI testing scheme.

makeSchemes(targetName: String) -> [Scheme]

The final tweak to the project was to disable the auto-generated schemes in the config (see in the Pokedex/Tuist folder).

Recap

Finally to recap what has been covered: I take a standard Xcode monolithic project that uses CocoaPods and convert it to use the tuist tool that generates the project and workspace. I replaced the CocoaPods dependencies with Swift Packages where they were available and converted an image loading and caching library in Objective-C into a tuist project and loaded it as a framework dependency. The application is still a monolith, but in the next article I go through splitting out the network code into a dedicated module.

The completed code for this article is here.

Please follow to know about the upcoming articles.

--

--

Ronan O Ciosoig

iOS software architect eDreams ODIGEO and electronic music fan