Leonardo Maia Pugliese
Holy Swift

Holy Swift

Introduction to App Modularisation with Swift Package Manager: A tale to be told

Introduction to App Modularisation with Swift Package Manager: A tale to be told

Let's pack our stuff!

Leonardo Maia Pugliese's photo
Leonardo Maia Pugliese
·Dec 2, 2021·

11 min read

Subscribe to my newsletter and never miss my upcoming articles

Table of contents

  • The Painting
  • The Problem
  • Start Modularisation
  • Extracting Login View Controller
  • Extracting HomeViewController
  • Summary

Hallo allemaal, Leo hier.

Today we will do a little project with modularisation. We will use Swift Package Manager because is how natively we have access to modularisation and it's the way Apple is pointing. So nothing better than start to study this great technology.

The technique you will follow up in this post solve a very specific problem. Imagine that you have a team and as the team grows the swift files and merge problems tend to grow. Also the build time tend to grow also.

With a clever modularisation you can delay the grow of the build time because it will only compile code that are modified. If all of your swift files are in the same project, when you change one of them, you have to build them all. But if you have modules, you will only have to build the module/code that changed. And this is a big difference in medium to large projects.

Disclaimer: This article is heavily inspired in one of the best Brazilian's Swift content creator, Cicero Camargo. It content is in Portuguese but I can't stress enough how good they are.

The Painting

The painting is a 1634 masterpiece called Dance to the Music of Time from Nicolas Poussin. Nicolas Poussin (June 1594 – 19 November 1665) was the leading painter of the classical French Baroque style, although he spent most of his working life in Rome. Most of his works were on religious and mythological subjects painted for a small group of Italian and French collectors.

I chose this painting because the women are holding hands like our Swift modules will be.

The Problem

You want to start to break your app into modules with Swift Package Manager.

First of all we have to plan what we want to achieve with our module separation. Remember that modules can also be a hell to compile with the graph dependency is not well made.

To start imagine that you have a very simple application, just with two screens: the Login Screen and the Home Screen. And to navigate through them we use a coordinator. Very very simple stuff.

See how everything connects below:

Screenshot 2021-12-01 at 08.23.16.png

As you can see each viewController has one dependency that is the AbstractCoordinator. Everything working great but imagine that different teams will start to work in the app, one team taking care of Authentication process and other team taking care of Home of the app. They should be able to work without any interference of each other. How can they do that?

If we separate this into modules we can came up with three modules already: a Login Module, a Home Module and the Coordinator Module. We will use Apple way of naming calling them CompanyLoginKit, CompanyHomeKit and CompanyCoordinatorKit.

Screenshot 2021-12-01 at 08.35.05.png

Now that we know how everything should look like, let's implement it!

Implementation

Create a new project. Clean up the Main storyboard, we will use view code. Now copy/paste to the SceneDelegate file this code, it will not work yet but no worry, we will add the required files later:

import UIKit

class SceneDelegate: UIResponder, UIWindowSceneDelegate {

    var window: UIWindow?

    func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {

        guard let windowScene = (scene as? UIWindowScene) else { return }

        let window = UIWindow(windowScene: windowScene)
        let coordinator = Coordinator()
        window.rootViewController = coordinator.start()
        window.makeKeyAndVisible()
        self.window = window
    }
}

Create a new Swift file for LoginViewController, copy/paste the code below:

import UIKit

class LoginViewController: UIViewController {

    private let passwordTextField = UITextField()
    private let loginTextField = UITextField()
    private let loginButton = UIButton()
    private let coordinator: AbstractCoordinator

    init(coordinator: AbstractCoordinator) {
        self.coordinator = coordinator
        super.init(nibName: nil, bundle: nil)
    }

    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    override func viewDidLoad() {
        super.viewDidLoad()

        view.backgroundColor = .white
        title = "Login Screen"


        configureLoginTextField()
        configurePasswordTextField()
        configureLoginButton()
    }

    private func configureLoginTextField() {
        view.addSubview(loginTextField)
        loginTextField.translatesAutoresizingMaskIntoConstraints = false
        loginTextField.textContentType = .username
        loginTextField.layer.borderColor = UIColor.gray.cgColor
        loginTextField.layer.borderWidth = 1
        loginTextField.layer.cornerRadius = 10
        loginTextField.placeholder = "Login"
        loginTextField.layer.sublayerTransform = CATransform3DMakeTranslation(5, 0, 0)

        NSLayoutConstraint.activate([
            loginTextField.centerXAnchor.constraint(equalTo: view.centerXAnchor),
            loginTextField.centerYAnchor.constraint(equalTo: view.centerYAnchor, constant: -100),
            loginTextField.widthAnchor.constraint(equalToConstant: 200),
            loginTextField.heightAnchor.constraint(equalToConstant: 30),

        ])
    }

    private func configurePasswordTextField() {
        view.addSubview(passwordTextField)
        passwordTextField.translatesAutoresizingMaskIntoConstraints = false
        passwordTextField.layer.borderColor = UIColor.gray.cgColor
        passwordTextField.layer.borderWidth = 1
        passwordTextField.layer.cornerRadius = 10
        passwordTextField.placeholder = "Password"
        passwordTextField.textContentType = .password
        passwordTextField.layer.sublayerTransform = CATransform3DMakeTranslation(5, 0, 0)

        NSLayoutConstraint.activate([
            passwordTextField.widthAnchor.constraint(equalToConstant: 200),
            passwordTextField.heightAnchor.constraint(equalToConstant: 30),
            passwordTextField.topAnchor.constraint(equalTo: loginTextField.bottomAnchor, constant: 20),
            passwordTextField.centerXAnchor.constraint(equalTo: loginTextField.centerXAnchor)
        ])
    }

    private func configureLoginButton() {
        view.addSubview(loginButton)
        loginButton.translatesAutoresizingMaskIntoConstraints = false
        loginButton.setTitle("Login", for: .normal)
        loginButton.setTitleColor(.black, for: .normal)
        loginButton.configuration = UIButton.Configuration.filled()
        loginButton.addAction(UIAction(handler: { [weak self] action in
            self?.coordinator.goToHomeScreen()
        }), for: .touchUpInside)

        NSLayoutConstraint.activate([
            loginButton.centerXAnchor.constraint(equalTo: view.centerXAnchor),
            loginButton.topAnchor.constraint(equalTo: passwordTextField.bottomAnchor, constant: 20)
        ])
    }
}

Create the HomeViewController.swift file, and copy/paste this code to it:

import UIKit

class HomeViewController: UIViewController {

    private let coordinator: AbstractCoordinator

    init(coordinator: AbstractCoordinator) {
        self.coordinator = coordinator
        super.init(nibName: nil, bundle: nil)
    }

    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    override func viewDidLoad() {
        super.viewDidLoad()
        view.backgroundColor = .lightGray
        title = "Home View Controller"
    }
}

Create a CoordinatorProtocol.swift file, and copy/paste this code to it:

import Foundation
import UIKit

protocol AbstractCoordinator: AnyObject {    
    func goToHomeScreen()
    func start() -> UIViewController
}

And as the last file, create the Coordinator.swift file, and copy/paste the code below to it:

import Foundation
import UIKit

class Coordinator: AbstractCoordinator {

    var navigationController: UINavigationController?

    func goToHomeScreen() {
        navigationController?.pushViewController(HomeViewController(coordinator: self), animated: true)
    }

    func start() -> UIViewController {
        self.navigationController = UINavigationController(rootViewController: LoginViewController(coordinator: self))

        return navigationController!
    }
}

And we are done with file creation. You should have the below files in your project:

Screenshot 2021-12-01 at 08.45.17.png

You now should be able to build and run the project. Finally we are ready to go!

Start Modularisation

Every time we have to start to break something in modules, it's easier to begin with everything that has LESS dependencies. This seems obvious but it's not. Always try to plan ahead your modularisations to begin with modules that don't have any dependencies.

In our example, this is the CompanyCoordinatorKit. Let's create the first module, go to File -> New -> Package:

Screenshot 2021-11-30 at 08.09.12.png

Them pay attention on where you are going to create your root module:

Screenshot 2021-11-30 at 08.10.07.png

The First red square is the folder location, be sure to be inside your project folder, and the second red box it to ensure that module is added to your project structure.

Now we have to say to Xcode that the new module is part of our build, go to your Project -> General -> Frameworks, Libraries and Embedded Content:

Screenshot 2021-11-30 at 08.14.02.png

Now go inside your CompanyKit package, and let's edit the Package.swift file:

Screenshot 2021-12-01 at 08.54.52.png

The code is below:

// swift-tools-version:5.5
// The swift-tools-version declares the minimum version of Swift required to build this package.

import PackageDescription

let package = Package(
    name: "CompanyKit",
    platforms: [.iOS(.v15)],
    products: [
        .library(
            name: "CompanyKit",
            targets: ["CompanyCoordinatorKit"]),
    ],
    dependencies: [],
    targets: [
        .target(
            name: "CompanyCoordinatorKit",
            dependencies: []),
        .testTarget(
            name: "CompanyCoordinatorKitTests",
            dependencies: ["CompanyCoordinatorKit"]),
    ]
)

What's the meaning of those lines?

In the products key, we are saying that we have one library that contains one Target called CompanyCoordinatorKit. This is our first module.

Them in the targets key we are specifying how those targets relate with each other. So we have a Target named CompanyCoordinatorKit with no dependencies and it already have a test target called CompanyCoordinatorKitTests. This is one of the advantages of working with SPM, it demands a test target for every module. (This can be controversial, but I think it's a very good thing because stimulate testing practices).

Now rename the Sources -> CompanyKit to CompanyCoordinatorKit and the Tests -> CompanyKitTests to CompanyCoordinatorKitTests as the image show below:

Screenshot 2021-12-01 at 09.01.28.png

Now your module should build. ( command + b )

Now move the CoordinatorProtocol.swift file to inside CompanyCoordinatorKit folder group:

Screenshot 2021-12-01 at 09.03.12.png

And add public access modifier to it, because now he is in another module:

public protocol AbstractCoordinator: AnyObject {    
    func goToHomeScreen()
    func start() -> UIViewController
}

Build your module, but now your app doesn't build anymore... Let's solve that.

Now you have to add to LoginViewController, HomeViewController and Coordinator files the import of our new created module:

import CompanyCoordinatorKit

If you follow up everything correctly, you should be able to build and run your app now: (command + R)

Screenshot 2021-12-01 at 09.14.05.png

Extracting Login View Controller

Now the dependency graph should look like this:

Screenshot 2021-12-02 at 07.49.39.png

We now have to extract the other two view controllers to give freedom for each team (of our imaginary example) to work separately.

Let's start extracting the LoginViewController.

Go to CompanyKit -> Package and update the file like following code:

// swift-tools-version:5.5
// The swift-tools-version declares the minimum version of Swift required to build this package.

import PackageDescription

let package = Package(
    name: "CompanyKit",
    platforms: [.iOS(.v15)],
    products: [
        .library(
            name: "CompanyKit",
            targets: ["CompanyCoordinatorKit", "CompanyLoginKit"]),
    ],
    dependencies: [],
    targets: [
        .target(
            name: "CompanyCoordinatorKit",
            dependencies: []),
        .testTarget(
            name: "CompanyCoordinatorKitTests",
            dependencies: ["CompanyCoordinatorKit"]),

            .target(
                name: "CompanyLoginKit",
                dependencies: ["CompanyCoordinatorKit"]),
            .testTarget(
                name: "CompanyLoginKitTests",
                dependencies: ["CompanyLoginKit"]),
    ]
)

Look close for the changes. We added a new ".target" and a new ".testTarget" entries. Those are the definition of our new module CompanyLoginKit. This module will have only one file but you can have as many as you need. We also updated the ".library" key with the new created module.

It not builds yet because we didn't create the group for the new CompanyLoginKit.

Screenshot 2021-12-02 at 07.54.26.png

Let's create the desired folders:

Screenshot 2021-12-02 at 07.59.43.png

And move the LoginViewController to the CompanyLoginKit folder.

Now you have to create the folder under Tests called "CompanyLoginKitTests", in this test folder you should add a blank swift file. For example in this test I just create a new Swift file inside the folder and called it CompanyLoginKitTests.swift. Every module needs at least 1 file to build properly.

In the end you will have this structure:

Screenshot 2021-12-02 at 08.07.17.png

We have to update the access modifier of our LoginViewController, adding public to it:

import UIKit
import CompanyCoordinatorKit

public class LoginViewController: UIViewController { // New public access modifier here

    private let passwordTextField = UITextField()
    private let loginTextField = UITextField()
    private let loginButton = UIButton()
    private let coordinator: AbstractCoordinator

    public init(coordinator: AbstractCoordinator) { // New public access modifier here
        self.coordinator = coordinator
        super.init(nibName: nil, bundle: nil)
    }

    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    public override func viewDidLoad() { // New public access modifier here
        super.viewDidLoad()

        view.backgroundColor = .white
        title = "Login Screen"


        configureLoginTextField()
        configurePasswordTextField()
        configureLoginButton()
    }

    private func configureLoginTextField() {
        view.addSubview(loginTextField)
        loginTextField.translatesAutoresizingMaskIntoConstraints = false
        loginTextField.textContentType = .username
        loginTextField.layer.borderColor = UIColor.gray.cgColor
        loginTextField.layer.borderWidth = 1
        loginTextField.layer.cornerRadius = 10
        loginTextField.placeholder = "Login"
        loginTextField.layer.sublayerTransform = CATransform3DMakeTranslation(5, 0, 0)

        NSLayoutConstraint.activate([
            loginTextField.centerXAnchor.constraint(equalTo: view.centerXAnchor),
            loginTextField.centerYAnchor.constraint(equalTo: view.centerYAnchor, constant: -100),
            loginTextField.widthAnchor.constraint(equalToConstant: 200),
            loginTextField.heightAnchor.constraint(equalToConstant: 30),

        ])
    }

    private func configurePasswordTextField() {
        view.addSubview(passwordTextField)
        passwordTextField.translatesAutoresizingMaskIntoConstraints = false
        passwordTextField.layer.borderColor = UIColor.gray.cgColor
        passwordTextField.layer.borderWidth = 1
        passwordTextField.layer.cornerRadius = 10
        passwordTextField.placeholder = "Password"
        passwordTextField.textContentType = .password
        passwordTextField.layer.sublayerTransform = CATransform3DMakeTranslation(5, 0, 0)

        NSLayoutConstraint.activate([
            passwordTextField.widthAnchor.constraint(equalToConstant: 200),
            passwordTextField.heightAnchor.constraint(equalToConstant: 30),
            passwordTextField.topAnchor.constraint(equalTo: loginTextField.bottomAnchor, constant: 20),
            passwordTextField.centerXAnchor.constraint(equalTo: loginTextField.centerXAnchor)
        ])
    }

    private func configureLoginButton() {
        view.addSubview(loginButton)
        loginButton.translatesAutoresizingMaskIntoConstraints = false
        loginButton.setTitle("Login", for: .normal)
        loginButton.setTitleColor(.black, for: .normal)
        loginButton.configuration = UIButton.Configuration.filled()
        loginButton.addAction(UIAction(handler: { [weak self] action in
            self?.coordinator.goToHomeScreen()
        }), for: .touchUpInside)

        NSLayoutConstraint.activate([
            loginButton.centerXAnchor.constraint(equalTo: view.centerXAnchor),
            loginButton.topAnchor.constraint(equalTo: passwordTextField.bottomAnchor, constant: 20)
        ])
    }
}

And finally add import CompanyLoginKit at the top of the Coordinator.swift file.

We extracted another module!

Extracting HomeViewController

The graph dependency almost looks like we wanted. Check below:

Screenshot 2021-12-02 at 08.19.43.png

Now we have to do the same process to extract the HomeViewController.

Go to CompanyKit -> Package and update the file like following code to create the CompanyHomeKit module:

// swift-tools-version:5.5
// The swift-tools-version declares the minimum version of Swift required to build this package.

import PackageDescription

let package = Package(
    name: "CompanyKit",
    platforms: [.iOS(.v15)],
    products: [
        .library(
            name: "CompanyKit",
            targets: ["CompanyCoordinatorKit", "CompanyLoginKit","CompanyHomeKit"]),
    ],
    dependencies: [],
    targets: [
        .target(
            name: "CompanyCoordinatorKit",
            dependencies: []),
        .testTarget(
            name: "CompanyCoordinatorKitTests",
            dependencies: ["CompanyCoordinatorKit"]),

        .target(
            name: "CompanyLoginKit",
            dependencies: ["CompanyCoordinatorKit"]),
        .testTarget(
            name: "CompanyLoginKitTests",
            dependencies: ["CompanyLoginKit"]),

        .target(
            name: "CompanyHomeKit",
            dependencies: ["CompanyCoordinatorKit"]),
        .testTarget(
            name: "CompanyHomeKitTests",
            dependencies: ["CompanyHomeKit"]),
    ]
)

It not builds yet because we didn't create the group folders for the new CompanyHomeKit.

Screenshot 2021-12-02 at 08.23.09.png

Create the desired folders:

Screenshot 2021-12-02 at 08.24.07.png

And move the HomeViewController to the CompanyHomeKit folder.

Screenshot 2021-12-02 at 08.25.03.png

Now you have to create the folder under Tests called "CompanyHomeKitTests", in this test folder you should add a blank swift file. For example in this test I just create a new Swift file inside the folder and called it CompanyHomeKitTests.swift. * Remember! Every module needs at least 1 file to build properly.

This is the final structure of our example project:

Screenshot 2021-12-02 at 08.27.39.png

We have to update the access modifier of our HomeViewController, adding public to it:

import UIKit
import CompanyCoordinatorKit

public class HomeViewController: UIViewController { // New public access modifier here

    private let coordinator: AbstractCoordinator

    public init(coordinator: AbstractCoordinator) { // New public access modifier here
        self.coordinator = coordinator
        super.init(nibName: nil, bundle: nil)
    }

    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    public override func viewDidLoad() { // New public access modifier here
        super.viewDidLoad()
        view.backgroundColor = .lightGray
        title = "Home View Controller"
    }
}

And finally add import CompanyHomeKit at the top of the Coordinator.swift file.

And... That's it!

Now, the final graph dependency result is the same as the beginning of the post:

Screenshot 2021-12-01 at 08.35.05.png

Summary

To sum up, to add new modules you have to:

  1. Add in the ".library" key with the new created module name.
  2. Inside ".targets" key you have to add two new entries, the ".target" with the name and dependencies of the new module and ".testTarget" with the structure of the tests of that new module.
  3. Add new folder structure to your module matching with the names you put in your ".targets", and add at least one file to each new module folder.
  4. Transfer the desired files to the module and add access modifiers where apply.
  5. Import the new dependency to other files/modules in your project.

The full project you can checkout in my GitHub page.

That's all my people, I hope you liked as I enjoyed write this article. If you want to support this blog you can Buy Me a Coffee or just leave a comment saying hello. You can also sponsor posts and I'm open to writing freelancing! Just reach me in LinkedIn or Twitter for details.

Thanks for the reading and... That's all folks.

Credits: image

Did you find this article valuable?

Support Leonardo Maia Pugliese by becoming a sponsor. Any amount is appreciated!

Learn more about Hashnode Sponsors
 
Share this