Modular SwiftUI architecture

When we started introducing SwiftUI in Kiwi.com app, we already had to maintain 100+ UIKit screens. As we tried to convert them into SwiftUI in-place, without any other architectural change, the Xcode would just crash when attempting to work with SwiftUI previews.

To provide developers with stable and fast SwiftUI previews and remove the need to rebuild and run the whole app after each change in code, we needed to introduce an architecture that would force views to be lightweight and independent of non-UI code.

View original image

Feature package modularization

First, we structured our app around isolated feature SPM packages, on top of shared ones. It was a bit tricky in the first versions of SwiftUI and SPM, but after those technologies stabilized, it turned out to be a great timesaver.

The primary benefits of splitting features into packages for us were:

  1. The ability to open each feature package on its own (instead of the whole project or workflow).
    This is faster not only technically (by Xcode only managing a single package, although it may add a few GBs of derived data), but it also makes it easier for a developer to concentrate on the feature as the relevant search, navigation, tests, and previews in Xcode are easier to find, without distractions of unrelated app code.
  2. When a feature is removed from the app, almost all it takes is to drop the feature package folder, as this architecture forces developers to write code in a relevant package in the first place, making it less likely to require hunting for the codeto spread through the whole codebase.
  3. Faster and stable SwiftUI previews, without the need to build code that belongs to unrelated features.

Package target modularization

Second, to improve on the last point even more, due to the number of screens and features we had, we have further split each feature package into the following targets:

  1. Foo target that only contains protocols, reusable constants, and simple logic-free types (such as structs or enums that only carry information across ui-domain boundary) and other light-weight code. This target can be freely imported by other features as it is dirt-cheap to build.
  2. FooMocks target that contains mocks of types defined in the above target (or types of dependent sub-feature targets) for both unit tests and SwiftUI preview purposes of this and any dependent feature.
  3. FooViews target that contains screen and component definitions, UI resources (images, localization), and other UI-only, mostly internal, types. It can optionally contain internal ViewModels that extract some non-trivial logic out of views for testability purposes.
    This target may only depend on shared UI code or other non-implementation targets of other (sub)features. This way, the build hierarchy never touches code that is expensive to build.
  4. FooImplementation is the target that contains the most costly implementation of the feature, a code that would take the majority of the whole app build time. This target is thus only allowed to be linked from the main app target, in most cases to be used purely for dependency injection.
  5. FooTests target that contains unit tests for this feature or its interaction with its sub-features.

View original image

Exported imports

To simplify the imports of these granular targets at a call site, they are reexporting their dependencies. The call site then only needs to import the single relevant target:

// FooMocks/Exported.swift
@_exported import Foo

// FooViews/Exported.swift
@_exported import Foo

// FooImplementation/Exported.swift
@_exported import FooViews

// Call site ----------------------

// BarViews/Screen.swift
import FooViews      // Along with reexported Foo
import SharedViews
...

// App.swift
import FooImplementation // Along with reexported Foo and FooViews
import BarImplementation // Along with reexported Bar and BarViews
...

Using this approach, our build cycle was significantly reduced. A developer can interact with the feature using SwiftUI preview almost immediately, rather than spending minutes waiting for the typical whole-app build and simulator run, plus the manual navigation into the relevant feature.

Package target definition example

The following example shows the SPM definition of a feature ForceUpdate that encapsulates all code related to that feature (protocols, data, business logic, UI, tests):

let package = Package(
   name: "ForceUpdate",
   defaultLocalization: "en",
   platforms: [.iOS(.v16)],
   products: [
       .library(name: "ForceUpdate", targets: ["ForceUpdate"]),
       .library(name: "ForceUpdateImplementation", targets: ["ForceUpdateImplementation"]),
       .library(name: "ForceUpdateMocks", targets: ["ForceUpdateMocks"]),
       .library(name: "ForceUpdateViews", targets: ["ForceUpdateViews"]),
   ],
   dependencies: [
       .package(path: "../../../Network/Networking"),
       // ...
       .package(path: "../../../UI/SharedUI"),
   ],
   targets: [
       .target(
           name: "ForceUpdate",
           dependencies: [
               "Shared",
           ]
       ),
       .target(
           name: "ForceUpdateImplementation",
           dependencies: [
               "Dates",
               "ForceUpdate",
               "ForceUpdateViews",
               "Networking",
               "Shared",
               "SharedUI",
           ]
       ),
       .target(
           name: "ForceUpdateMocks",
           dependencies: [
               "ForceUpdate",
           ]
       ),
       .target(
           name: "ForceUpdateViews",
           dependencies: [
               "ForceUpdate",
               "ForceUpdateMocks",
               "Logging",
               "SharedUI",
           ]
       ),
       .testTarget(
           name: "ForceUpdateTests",
           dependencies: [
               "ForceUpdateImplementation",
               .product(name: "NetworkingMocks", package: "Networking"),
           ]
       ),
)

In our case, the incremental build times after introducing any UI change are less than a second for the feature package versus around 5 seconds for the whole app. There does not seem to be a huge difference one time, but when iterating on a feature, these build times quickly add up.
As a result of the above architecture, developers can concentrate on the feature in isolation, with a much faster preview feedback loop and fewer chances of introducing code conflicts with the rest of the app.