Unifying Tuist Projects

Ronan O Ciosoig
6 min readSep 2, 2021

Bringing multiple projects together under one root

In the previous 4 articles I described the steps to break apart a single project into micro-features using tuist as the tool to generate the Xcode project for each, and the merged app. However, in trying to keep it simple, a certain amount of duplication was introduced as well as perhaps some complexity in the structure. In this article I am going show how to manipulate the project manifest to bring it all back into a single project, and how one could tackle building the many targets recommended by the tuist documentation for each micro-feature.

If you’re unfamiliar with tuist have a look here. And if you’re a little more curious there are a number of links to articles and videos here.

Finding You Way Around

The Pokedex example app (GitHub repo) is a trivial 4-scene iOS app that uses the Pokedex API to load Pokemon characters into a cache and display some of their properties. I split the code into a structure like this:

Pokedex project structure

Under the project root there are 6 tuist projects. For each of the UI modules, they have a <Name> and <Name>UI folders under Targets. The <Name> in this case is the example app. Haneke is an image library written in Objective C with a Swift wrapper, and copied over from a CocoaPod into a tuist project. The Network module follows the same pattern as the UI modules with an example app with the framework having a Kit suffix. The only real exception is PokedexCommon which doesn’t have an example app, and doesn’t need one either. This module contains the common code required to run and validate the other modules. Does the naming make sense? I honestly think so, and I will make it more understandable.

Overall, one of the (many) drawbacks of this approach is that it contains considerable duplication of the tuist project code and associated files and folders. It also means when switching between modules you have to change paths to launch them — trivial but still a point. So how about making this work nicer together?

Deleted

The first step is to move the Project.swift and Tuist folder from the Pokedex folder to the project root, then for each of the other projects delete those same files. Next I added a “Features” folder, and placed all the projects in it. This now looks like:

Next I run tuist edit to modify the Project and Project+Template files. After a bit of hacking away at the paths for all the frameworks, cycling through tuist lint project, and tuist generate, and editing again, I get to this commit where the project is working again, but there are a few small steps I’d to go through to continue the process of cleanup. Each of the (Home, Catch, Backpack) features require an example app but this no longer works. To fix it I add a new .app target to the makeFrameworkTargets function like so (note example app folder is renamed Example to be consistent):

let exampleAppTarget  = Target(name: "\(localFramework.name)ExampleApp",
platform: platform,
product: .app,
bundleId: "\(reverseOrganizationName).\(localFramework.name)ExampleApp",
infoPlist: .default,
sources: ["Features/\(localFramework.path)/Targets/Example/Sources/**"],
resources: ["Features/\(localFramework.path)/Targets/Example/Resources/**/*",
"Features/\(localFramework.path)/Targets/Example/Sources/**/*.storyboard"],
dependencies: exampleAppDependancies)

This presents a few new issues. The PokedexCommon sources need to be placed into a folder under Features, and needs to have its own example app, and the example app targets need to have their own dependancies and resources defined separate from those for the frameworks. I then rename LocalFramework to Module, and define the structure like this:

public struct Module {
let name: String
let path: String
let frameworkDependancies: [TargetDependency]
let exampleDependencies: [TargetDependency]
let frameworkResources: [String]
let exampleResources: [String]

public init(name: String,
path: String,
frameworkDependancies: [TargetDependency],
exampleDependencies: [TargetDependency],
frameworkResources: [String],
exampleResources: [String]) {
self.name = name
self.path = path
self.frameworkDependancies = frameworkDependancies
self.exampleDependencies = exampleDependencies
self.frameworkResources = frameworkResources
self.exampleResources = exampleResources
}
}

On running the example apps though I notice another problem — it doesn’t run full screen. Bah! The problem is that the LaunchScreen isn’t being loaded by Xcode. This can be fixed by replacing .default InfoPlist with a function like this:

public static func makeAppInfoPlist() -> InfoPlist {
let infoPlist: [String: InfoPlist.Value] = [
"UIMainStoryboardFile": "",
"UILaunchStoryboardName": "LaunchScreen"
]
return InfoPlist.extendingDefault(with: infoPlist)
}

The folder structure can be simplified as well for cosmetic reasons: Targets is not necessary. Example apps don’t need testing folders etc. I rename PokedexCommon to Common, but it would be nice to remove the unnecessary example app in Common module.

In the Module structure I add a new enum and property like so:

public enum uFeatureTarget {
case framework
case unitTests
case exampleApp
}
struct Module {
let targets: Set<uFeatureTarget>
...
public init(name: String, ...
...
targets: Set<uFeatureTarget> = Set([.framework, .unitTests, .exampleApp])) {
...
}

Then in the makeFrameworkTargets by checking the module.targets it can optionally add the targets required.

As a little aside, in the tuist documentation about micro-feature architecture it recommends having all of these targets:

It is trivial to add more options to that enumeration for testing and interface.

The final structure looks like this:

Now it is clear where the example code is, which target is where, etc. Not everything really is a feature, but I am going to leave it like that for now.

In the current version to tuist (1.48.1) when the focus command is run, if a target is not found then it prints a list of valid targets like this:

Available targets are BackpackUI, BackpackUIExampleApp, BackpackUITests, CatchUI, CatchUIExampleApp, CatchUITests, Common, CommonTests, Haneke, HanekeExampleApp, HanekeTests, HomeUI, HomeUIExampleApp, HomeUITests, NetworkKit, NetworkKitExampleApp, NetworkKitTests, Pokedex, PokedexTests, PokedexUITests

This is a nice addition and gives a clear summary of how the project and its modules are structured. The other more visual approach is to use tuist graph command.

The (simplified) project manifest looks like this:

let project = Project.app(name: “Pokedex”,
platform: .iOS,
packages: [
.package(url: "path/to/Moya.git", .exact("14.0.0")),
.package(url: "path/to/Result.git", from: "5.0.0"),
.package(url: "path/to/JGProgressHUD", .upToNextMajor(from: "2.0.0"))
],
targetDependancies: [
.package(product: “JGProgressHUD”)],
moduleTargets: [
Module(name: "Haneke", ...),
Module(name: "HomeUI", ...),
Module(name: "BackpackUI", ...),
Module(name: "CatchUI", ...),
Module(name: "Common",
Module(name: "NetworkKit",
])

Recap

I started with a repo containing 6 tuist projects and refactored them into one, added example app targets and removed some of the unnecessary folder hierarchy to make it cleaner and more understandable. In the next article I will take a deeper look into creating a template for the module structure defined here, so that new modules are trivial to add.

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