iTranslated by AI

The content below is an AI-generated translation. This is an experimental feature, and may contain errors. View original article
💉

Summary & Supplement: DI Strategy for Effortless Xcode Previews

に公開

Introduction

This article is a summary and supplement to the session "DI Strategy for Using Xcode Preview with Ease" at iOSDC Japan 2023.
https://fortee.jp/iosdc-japan-2023/proposal/2bfb2b2e-5e5e-44a7-87c9-e0dc66819a1e

I have also published the slides, so please check them out as well.

I've written a summary for those who didn't see the presentation, but since the content is the same as the talk and quite long, those who saw the presentation can skip the summary and go straight to the Supplement.

Summary of the Presentation

DI for Xcode Preview

When using Xcode Preview, Dependency Injection (DI) is an unavoidable theme.
As an example, let's consider an ItemListView that performs API communication to fetch a list of items.

struct ItemListView: View {
    @StateObject var viewModel = ItemListViewModel()
    
    var body: some View {
        // ...
	.task {
	    await viewModel.fetchItems()
	}
    }
}

struct ItemListView_Previews: PreviewProvider {
    static var previews: some View {
        ItemListView()
    }
}

class ItemListViewModel: ObservableObject {
    @Published private(set) var items: [Item] = []
    @Published private(set) var error: (any Error)? = nil
    
    func fetchItems() async {
	do {
            items = try await APIClient.shared.getItems()
	} catch {
	    self.error = error
	}
    }
}

There is a problem with this code.
It uses a singleton to make the API call.

func fetchItems() async {
    do {
        items = try await APIClient.shared.getItems()
    } catch {
        self.error = error
    }
}

This causes various issues.

  • An API call is made every time you check the UI in Xcode Preview.
  • The results of the Xcode Preview depend on external factors such as the server implementation or the Mac's network environment.

The second issue is particularly fatal.
If it depends on external factors, you may need to change server settings or the Mac's communication environment just to display the target UI you want to check in Xcode Preview.

This is where DI is needed.
While there are several third-party libraries that support DI, here we will consider the following two methods that can be implemented using standard frameworks:

  • Initializer Injection (Constructor Injection)
  • Environment / EnvironmentObject

Initializer Injection (Constructor Injection)

The simplest way to perform DI is through Initializer Injection.

protocol APIClientProtocol {
    func getItems() async throws -> [Item]
}

struct ItemListView: View {
    @StateObject var viewModel: ItemListViewModel
    
    var body: some View {
        // ...
	.task {
	    await viewModel.fetchItems()
	}
    }
}

struct ItemListView_Previews: PreviewProvider {
    static var previews: some View {
        ItemListView()
    }
}

class ItemListViewModel: ObservableObject {
    @Published private(set) var items: [Item] = []
    @Published private(set) var error: (any Error)? = nil
    
    private let apiClient: any APIClientProtocol
    
    init(apiClient: some APIClientProtocol) {
        self.apiClient = apiClient
    }
    
    func fetchItems() async {
	do {
            items = try await APIClient.shared.getItems()
	} catch {
	    self.error = error
	}
    }
}

There are three main changes:

  • Define a protocol for APIClient.
  • Inject APIClientProtocol into the init of ItemListViewModel.
  • Inject ItemListViewModel into the init of ItemListView.

Initializer Injection is the safest DI method because it can guarantee that dependencies are injected at compile time, despite its simplicity.

Challenges of Initializer Injection

While Initializer Injection is a safe method, it also has its challenges.
The main issue is that it requires a "dependency bucket brigade" (passing dependencies down through multiple layers).

For example, let's say you transition from the ItemListView introduced earlier to an ItemDetailView.
And suppose this ItemDetailView requires a Database.
Just like before, we use Initializer Injection for this Database.

struct ItemDetailView: View {
    init(item: Item, databsse: any Database) { 
        // ...
    }
}

By the way, since this ItemDetailView transitions from ItemListView, you need to write something like this somewhere inside ItemListView:

ItemDetailView(item: item, database: database)

The item is the result of an API call, so it's information already inside ItemListView. However, ItemListView doesn't know about the database.
Therefore, you need to inject the database into ItemListView as well.

struct ItemListView: View {
    @StateObject var viewModel: ItemListViewModel
    let databse: any Database
    
    init(viewModel: ItemListViewModel, databse: some Darabase) {
        // ....
    }
}

The calling side would look like this:

ItemListView(
    viewModel: ItemListViewModel(apiClient: apiClient),
    database: database
)

ItemListView only performs API communication and doesn't inherently need the database, but it ends up having to receive it just for the sake of the transition destination.
If more screens are added as transition destinations in the future, it might need to receive not only the database but also things like userDefaults, keychain, fileManager, and so on.

ItemListView(
    viewModel: ItemListViewModel(apiClient: apiClient),
    database: database,
    userDefaults: userDefaults,
    keychain: keychain,
    fileManager: fileManager
)

Even though ItemListView only needs the apiClient to pass to the ItemListViewModel, it ends up having to pass many dependencies for the transition destinations.

This also has downsides when using Xcode Preview.
To check the operation, you only need the apiClient, but you are forced to prepare mocks for everything else as well, making it difficult to perform quick checks.

Environment / EnvironmentObject

In SwiftUI, we can avoid the bucket brigade by using the property wrappers @Environment / @EnvironmentObject.

In the entry point view of the app, we pass the dependencies into the environment.
(The method for customizing the environment is omitted here.)

RootView()
    .environment(\.apiClient, APIClient())
    .environment(\.database, Database())

The calling side can retrieve the necessary dependencies via property wrappers.

struct ItemListView: View {
    @Environemnt(\.apiClient) var apiClient
    
    init() { // No need to receive apiClient in init
        // ...
    }
}

Since it is no longer necessary to pass dependencies through the init, this method seems like it would eliminate the need for the bucket brigade.

Challenges of Environment / EnvironmentObject

While Environment solves the bucket brigade problem, this method has other challenges:

  • Forgetting to register a dependency does not result in a compile error.
  • You cannot access Environment values within init.

For example, when previewing ItemListView using Environment, you need to write it like this:

ItemListView()
    .environemnt(\.apiClient, MockAPIClient())

However, if the call to .environemnt(\.apiClient, MockAPIClient()) is missing, it will not result in a compile error.
You must keep track of which dependencies are used inside each View and pass the appropriate dependencies into the Environment every time.
Furthermore, when the dependencies required to use a View increase, you need to register additional Environment values, but the compiler will not point this out. You have to read the code and fix it yourself, which raises the hurdle for trying out Previews.

Also, the inability to access these values in init is subtly inconvenient.
As shown in the following code, there are many use cases where you want to pass an APIClient or Database to an ObservableObject like a ViewModel.

struct ItemListView: View {
    @StateObject var viewModel: ItemListViewModel
    @Envirtonment(\.apiClient) var apiClient: 
    
    init() {
        let viewModel = ItemListViewModel(apiClient: apiClient)
	self._viewState = StateObject(wrappedValue: viewModel)
    }
}

However, the code above will trigger a runtime warning from Xcode and will not function correctly.
(This is my own assumption, but it is likely because at the time of init, it is not yet determined which view hierarchy the View will belong to, so the Environment, which requires the view hierarchy, cannot be used.)

For these reasons, Environment does not seem to be the solution either.

Proposed Method

Idea

When you want to try out Xcode Preview easily, the developer's concern is only the view being previewed.
In other words, the transition destination screens are outside the scope of interest during the preview.
We adopt different DI methods depending on whether the view is the target of interest for the preview or not:

  • Target of interest for preview -> Initializer Injection
  • Outside the scope of interest for preview -> Environment

Implementation

First, define a struct that consolidates all the app's dependencies into one.

struct AppDependency {
    let apiClient: any APIClient
    let database: any Database
}

And then, pass this through the environment from the app's entry point.

let dependency = AppDependency(
    apiClient: APIClient(),
    databse: Databse()
)

RootView()
    .environment(\\.dependency, dependency)

The trick is to consolidate the dependencies into a single struct rather than passing each one individually as an environment value.
By doing this, whenever a dependency is added, you add a property to AppDependency.
Since this will cause a compile error wherever it is used, there is no worry about forgetting to pass the dependency.

Next, prepare a View that retrieves this AppDependency from the environment.
It's a bit long, but what it's doing is very simple:

  • If dependency is registered in the environment, pass it to the closure received in the init.
  • If it's not registered, display a message in white text on a red background indicating that dependency is not set.
struct DependencyProvider<ChildView: View>: View {
    @Environment(\\.dependency) private var dependency: AppDependency?
    private let childView: (AppDependency) -> ChildView

    init(childView: @escaping (AppDependency) -> ChildView) {
        self.content = childView
    }

    var body: some View {
        if let dependency {
            childView(dependency)
        } else {
#if DEBUG
            Text("AppDependency is not set.")
                .foregroundColor(.white)
                .font(.system(.title3, weight: .black))
                .frame(maxWidth: .infinity, maxHeight: .infinity)
                .background(Color.red)
                .ignoresSafeArea()
#else
            EmptyView()
#endif
        }
    }
}

Usage

Despite such a simple implementation, it makes retrieving dependencies very easy.
I'll explain how to use it using the ItemListView introduced earlier as an example.

First, implement ItemListView using Initializer Injection.
In this init, declare only the dependencies used within the ItemListView screen.

struct ItemListView: View {
    init(viewModel: ItemListViewModel) {
        // ...
    }
}

Since you only need to pass the dependencies required for that screen in the init, it's easy to use Xcode Preview.

struct ItemListView_Previews: PreviewProvider {
    static var previews: some View {
        let viewModel = ItemListViewModel(apiClient: MockAPIClient())
        ItemListView(viewModel: viewModel)
    }
}

There's no need to pass unnecessary dependencies; you just inject the necessary and sufficient ones as directed by the compiler.

Then, to transition from ItemListView to ItemDetailView, you can resolve the dependencies just by inserting the DependencyProvider created earlier.

struct ItemListView: View {
    var body: some View {
        // ...
	.navigationDesitination(Item.self) { item in
	    DependencyProvider { dependency in
	        ItemDetailtView(
		    item: item, 
		    database: item.databsase
		)
	    }
	}
    }
}

The key point is that the type of dependency passed to the argument of the trailing closure of DependencyProvider is AppDependency, not AppDependency?.
The calling side does not need to worry about whether AppDependency is being DI-ed via Environment.

At this point, the behavior of the Xcode Preview for ItemListView looks like this:

In the ItemListView, which was the target of interest, you can display the list of items using mocked values.
On the other hand, when a cell is tapped, it transitions to a red screen instead of ItemDetailView.
This is because AppDependency is not registered in the environment in the Xcode Preview.
However, since ItemDetailView is not the target of interest for the Xcode Preview of ItemListView, this behavior is not an issue for development at all.
If you want to check the behavior of ItemDetailView, you just need to prepare an Xcode Preview for ItemDetailView in the same way as ItemListView.

What's Great About DependencyProvider

1. Dependencies can be retrieved from any View

You can retrieve dependencies from anywhere in a SwiftUI View. Moreover, since you can obtain them as non-optional values, it's nice that the calling side doesn't have to consider the case where the dependency is missing.

2. Each View can declare only the necessary dependencies in init

There is no need to declare extra dependencies for transition destination screens, and the compiler only needs to know the necessary and sufficient dependencies to display that View. An error will occur at compile time if too many or too few dependencies are passed.

3. Easy to notice if Environment settings are forgotten

Since AppDependency only needs to be injected once at the app's RootView, you only need to set it once, and after that, you just need to pass dependencies according to the compiler's instructions. Also, if you forget to set it, various parts of the app will turn bright red, making it easy to notice.

4. The DI method is contained within the View layer

Since this is a DI method using the Environment of the SwiftUI View, you can maintain simple and safe Initializer Injection in the presentation and business logic layers. Therefore, it's also a plus that you don't need to depend on DependencyProvider when performing unit tests.

Supplement to the Presentation

From here on, I'll cover content that I couldn't include in the presentation due to time constraints.

Implementation in UIKit

In UIKit, you can use the Coordinator pattern or a Router in VIPER. However, while it's not as painful as in SwiftUI, each Coordinator or Router still needs to pass AppDependency through a bucket brigade.

With iOS 17, there was a powerful update to UITraitCollection. It allows achieving the same thing as SwiftUI's Environment using UIKit's UITraitCollection. I couldn't introduce the detailed implementation during the presentation, so I'll introduce it here.

First, add a custom key to UITraitCollection.

enum DependencyKey: EnvironmentKey, UITraitBridgedEnvironmentKey {
    static let defaultValue: Dependency? = nil

    static func read(from traitCollection: UITraitCollection) -> Dependency? {
        traitCollection.dependency
    }

    static func write(to mutableTraits: inout UIMutableTraits, value: Dependency?) {
        mutableTraits.dependency = value
    }
}

extension EnvironmentValues {
    public var dependency: Dependency? {
        get { self[DependencyKey.self] }
        set { self[DependencyKey.self] = newValue }
    }
}

private struct DependencyTrait: UITraitDefinition {
    static let defaultValue: Dependency? = nil
}

extension UITraitCollection {
    public var dependency: Dependency? { self[DependencyTrait.self] }
}

extension UIMutableTraits {
    public var dependency: Dependency? {
        get { self[DependencyTrait.self] }
        set { self[DependencyTrait.self] = newValue }
    }
}

The key point is conforming to UITraitBridgedEnvironmentKey on the first line. (Everything else is mostly getter/setter implementation.) By conforming to this, values can be exchanged between SwiftUI's Environment and UIKit's UITraitCollection.

Next, implement UIDependencyProvider for UIKit like this:

public enum UIDependencyProvider {
    public static func makeViewController(for view: UIView, factory: (AppDependency) -> some UIViewController) -> UIViewController {
        if let dependency = view.traitCollection.dependency {
            factory(dependency)
        } else {
            UIHostingController(rootView: Text("Dependency is not set.")
                .foregroundColor(.white)
                .font(.system(.title3, weight: .black))
                .frame(maxWidth: .infinity, maxHeight: .infinity)
                .background(Color.red)
                .ignoresSafeArea()
            )
        }
    }
}

Usage would look something like this:

class FooViewController: UIViewController {
    func buttonDidTap() {
        let nextViewController = UIDependencyProvider.makeViewController(for: view) { dependency in 
	    BarViewController(database: dependency.database)
	}
	self.present(nextViewController, animated: true)
    }
}

Performance Issues

Actually, in the WWDC video introducing the method using UITraitCollection, it is mentioned that "traits should be value types, ideally Int, Bool, Double, or an enum with an Int raw value."
It seems that value comparisons are performed frequently as part of the differential update system.
It is also mentioned that "if you use custom data, conform to Equatable and make the performance of equality checks as fast as possible."

While AppDependency itself is a struct, it will contain many classes internally.
Therefore, AppDependency violates the aforementioned advice.
A possible workaround is to use some kind of identifier internally and use it for the Equatable implementation.

struct AppDependency: Equatable { 
    private let id = UUID()
    let database: any Database
    // ....
    
    static func == (lhs: Self, rhs: Self) -> Bool {
        lhs.id == rhs.id
    }
}

The code above generates a UUID internally and performs equality checks using only that value.
Also, all internal dependencies of AppDependency are defined with let instead of var.
By doing this, whenever a dependency of AppDependency is updated, the developer is forced to call the AppDependency init, creating a mechanism where the UUID is also updated when dependencies change.

While performance can be improved this way, it still doesn't achieve the best practices suggested by Apple (using only raw Int or Bool).
It may be necessary to monitor the actual performance of the app and tune it according to the situation.

Conclusion

I have introduced a DI method to lower the hurdle for using Xcode Preview as much as possible.
I believe this method balances safety and ease of use as much as possible, as it allows the compiler to know the necessary and sufficient dependencies when performing Xcode Previews.

However, this method has only been used in my personal development so far, and I think there are still unknown challenges.
If you encounter any issues while using it, I would be happy to hear from you.

I hope this article helps with your development.

Discussion