はじめに

こんにちは!システム開発部のYです。

ところで皆さんは、iOSプロジェクトにDIは導入していますか?OSSや、そもそもOSSを利用しない独自のDIコンテナを選択するケースもあると思います。iOS開発において、DIといえばこれ!というような方法やライブラリは現状ないですよね。。そこで今回複数の外部ライブラリを比較してみました。

そもそもDIとは?

DIとはDependency Injectionの略で、日本語で言う「依存性の注入」です。

DIとは、プログラミングにおけるデザインパターン(設計思想)の一種で、オブジェクトを成立させるために必要となるコードを実行時に注入(Inject)してゆくという概念のことである。

IT用語辞典バイナリ

外部から依存オブジェクトを注入することで、クラスの依存関係を分離します。そうすることで、コードの保守性、テスト容易性、柔軟性を向上といった様々なメリットがあります。

DIライブラリ比較

※ 2023/08/01 現在

今回調べたのは以下の4つになります。

名前URLスター最終更新
SwinjectSwinject5.9k2022, 12, 3
needleneedle (Uber社が開発)1.6k2023, 4, 29
swift-dependenciesswift-dependencies
(TCAから切り出されたライブラリ)
9982023, 7, 31
FactoryFactory1.1k2023, 7, 2

Swinject

https://github.com/Swinject/Swinject

調べた外部ライブラリの中で、圧倒的にスターの数が多いです!

公式サンプル

// Containerの設定
let container = Container()
container.register(Animal.self) { _ in Cat(name: "Mimi") }
container.register(Person.self) { r in
    PetOwner(pet: r.resolve(Animal.self)!)
}

// Containerからresolveを行い、オブジェクトを取り出す
let person = container.resolve(Person.self)!
person.play() // prints "I'm playing with Mimi."

コンテナを作成し、container.registerメソッドを使用してオブジェクトを登録。その後、使いたい場所でcontainer.resolveを使用してオブジェクトを取り出し使用する事ができます。

StoryboardDI

調査した限り、唯一StoryboardによるDIを提供しています。詳しくは以下を参照してください。

https://github.com/Swinject/SwinjectStoryboard

SwinjectStoryboardを使う場合(「メイン」ストーリーボードからの暗黙的なインスタンス化)

前提として、Main.storyboardにアプリ起動時の初期画面としてAnimalViewControllerを登録します。Swiftソースファイルの先頭にSwinjectStoryboard をインポートします。

import SwinjectStoryboard

extension SwinjectStoryboard {
    @objc class func setup() {
        defaultContainer.storyboardInitCompleted(AnimalViewController.self) { r, c in
            c.animal = r.resolve(Animal.self)
        }
        defaultContainer.register(Animal.self) { _ in Cat(name: "Mimi") }
    }
}

defaultContainerにAnimalViewControllerを登録するだけで、依存関係を解決してくれます。

SwinjectStoryboardを使う場合(AppDelegate での明示的なインスタンス化)

import UIKit
import Swinject
import SwinjectStoryboard

@main
class AppDelegate: UIResponder, UIApplicationDelegate {

    /// ウィンドウ
    var window: UIWindow?

    var container: Container = {
        let container = Container()
        container.storyboardInitCompleted(AnimalViewController.self) { r, c in
            c.animal = r.resolve(Animal.self)
        }
        container.register(Animal.self) { _ in Cat(name: "Mimi") }
        return container
    }()

    func application(
        _ application: UIApplication,
        didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey : Any]? = nil) -> Bool {

        let window = UIWindow(frame: UIScreen.mainScreen().bounds)
        window.makeKeyAndVisible()
        self.window = window

        let storyboard = SwinjectStoryboard.create(name: "Main", bundle: nil, container: container)
        window.rootViewController = storyboard.instantiateInitialViewController()

        return true
    }
}

※アプリのMain storyboard file base name項目を削除する必要があります。

SwinjectStoryboardなし

Storyboardを使わずに、インスタンス化する場合はAppDelegataで登録します。

class AppDelegate: UIResponder, UIApplicationDelegate {
    var window: UIWindow?
    let container: Container = {
        let container = Container()
        container.register(Animal.self) { _ in Cat(name: "Mimi") }
        container.register(Person.self) { r in
            PetOwner(pet: r.resolve(Animal.self)!)
        }
        container.register(PersonViewController.self) { r in
            let controller = PersonViewController()
            controller.person = r.resolve(Person.self)
            return controller
        }
        return container
    }()

    func application(
        _ application: UIApplication,
        didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey : Any]? = nil) -> Bool {

        // Instantiate a window.
        let window = UIWindow(frame: UIScreen.main.bounds)
        window.makeKeyAndVisible()
        self.window = window

        // Instantiate the root view controller with dependencies injected by the container.
        window.rootViewController = container.resolve(PersonViewController.self)

        return true
    }
}

Needle

https://github.com/uber/needle/tree/master
NeedleはUber社が開発したSwift製のDIフレームワークです。

階層DI

Needleでは、階層的なDIツリーを構築し、依存解決コードをコンパイル時に自動生成する仕組みとなっています。

/// This protocol encapsulates the dependencies acquired from ancestor scopes.
protocol MyDependency: Dependency {
    /// These are objects obtained from ancestor scopes, not newly introduced at this scope.
    var chocolate: Food { get }
    var milk: Food { get }
}

/// This class defines a new dependency scope that can acquire dependencies from ancestor scopes
/// via its dependency protocol, provide new objects on the DI graph by declaring properties,
/// and instantiate child scopes.
class MyComponent: Component<MyDependency> {

    /// A new object, hotChocolate, is added to the dependency graph. Child scope(s) can then
    /// acquire this via their dependency protocol(s).
    var hotChocolate: Drink {
        return HotChocolate(dependency.chocolate, dependency.milk)
    }

    /// A child scope is always instantiated by its parent(s) scope(s).
    var myChildComponent: MyChildComponent {
        return MyChildComponent(parent: self)
    }
}

NeedleにはComponentクラスとDependencyプロトコルがあります。Dependencyに準拠したプロトコルを作成し、そのプロトコルに準拠したComponentクラスを作成します。

コンパイル時の安全性

class RootComponent: BootstrapComponent {
    
//    DIしなければならない変数を、コメントアウト
//    var user: UserProtocol {
//        shared { User(name: "taro") }
//    }
    
    var loggedInBuilder: LoggedInBuilder {
        return LoggedInComponent(parent: self)
    }
}

class LoggedInComponent: Component<LoggedInDependency>, LoggedInBuilder {
    
    var loggedInViewController: UIViewController {
        let storyboard = UIStoryboard(name: "LoggedInViewController", bundle: nil)
        guard let LoggedInVC = storyboard.instantiateInitialViewController() as? LoggedInViewController else { return UIViewController() }
        LoggedInVC.user = dependency.user
        // 設定やら
        return LoggedInVC
    }
}

protocol LoggedInBuilder {
    
    var loggedInViewController: UIViewController { get }
}

protocol LoggedInDependency: Dependency {
    
    var user: UserProtocol  { get }
}

上記のCodeでは、RootComponentにuserが不足している状態です。この状態で、コンパイルを行うとエラーが起き具体的に示してくれます。

使用方法

Root > LoggedIn の2画面で、Root > LoggedInでNeedleの機能を利用してみます。

import NeedleFoundation
import UIKit

class LoggedInComponent: Component<LoggedInDependency>, LoggedInBuilder {
    
    var loggedInViewController: UIViewController {
        let storyboard = UIStoryboard(name: "LoggedInViewController", bundle: nil)
        guard let LoggedInVC = storyboard.instantiateInitialViewController() as? LoggedInViewController else { return UIViewController() }
        LoggedInVC.user = dependency.user
        // 設定やら
        return LoggedInVC
    }
}

protocol LoggedInBuilder {
    
    var loggedInViewController: UIViewController { get }
}

protocol LoggedInDependency: Dependency {
    
    var user: UserProtocol  { get }
}

まずコンポネートを作成する必要があります。親から受け取るプロパティ、そのコンポーネントを親とする子コンポーネントを宣言したりします。上記ではuserというプロパティを持っており、loggedInViewControllerという依存性を注入したVCプロパティを持っています。

class RootComponent: BootstrapComponent {
    
    var user: UserProtocol {
        shared { User(name: "taro") }
    }
    
    var rootViewController: UIViewController {
        let storyboard = UIStoryboard(name: "RootViewController", bundle: nil)
        guard let rootVC = storyboard.instantiateInitialViewController() as? RootViewController else { return UIViewController() }
        // 設定やら
        rootVC.loggedInBuilder = loggedInBuilder
        return rootVC
    }
    
    var loggedInBuilder: LoggedInBuilder {
        return LoggedInComponent(parent: self)
    }
}

上記のBootstrapComponentを宣言することで、RootComponentが最上位コンポーネントになります。これを利用して、App/SceneDelegateで設定をします。

@main
class AppDelegate: UIResponder, UIApplicationDelegate {

    private(set) var rootComponent: RootComponent!

    func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
        // Override point for customization after application launch.
        
        registerProviderFactories() // Needle.swift で定義されている
        rootComponent = RootComponent()
        return true
    }
}

class SceneDelegate: UIResponder, UIWindowSceneDelegate {

    var window: UIWindow?

    func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
        guard let scene = (scene as? UIWindowScene) else { return }
        
        let window = UIWindow(windowScene: scene)
        self.window = window
        window.makeKeyAndVisible()

        let rootComponent = (UIApplication.shared.delegate as! AppDelegate).rootComponent
        
        window.rootViewController = rootComponent?.rootViewController
        window.makeKeyAndVisible()
    }
}

Needleコードジェネレータを追加しビルドに成功すると、指定したディレクトリにNeedleGenerated.swiftというファイル名が生成されます。その中身を見ると、registerProviderFactoriesメソッドが記載されているので、これをアプリ起動時に呼び出す必要があります。

上記のコードだと、AppDelegateでregisterProviderFactoriesメソッドを呼び出し、その後にRootComponentをインスタンス化し保持しています。そしてSceneDelegateで保持しておいたrootComponentを取得、rootComponent?.rootViewControllerで反映しています。

import UIKit

class RootViewController: UIViewController {
    
    var loggedInBuilder: LoggedInBuilder!
    
    override func viewDidLoad() {
        super.viewDidLoad()
        // Do any additional setup after loading the view.
        
        print("view didload")
    }
    
    @IBAction func tappedLoginBtn(_ sender: UIButton) {
        self.present(loggedInBuilder.loggedInViewController, animated: true)
    }
}

上記がRootViewControllerです。loggedInBuilderは、すでに依存注入されています。self.present(loggedInBuilder.loggedInViewController, animated: true)loggedInViewControllerに遷移します。

import UIKit

class LoggedInViewController: UIViewController {
    
    var user: UserProtocol!
    
    @IBOutlet weak var userNameLabel: UILabel!
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        self.userNameLabel.text = user.name
    }
}

LoggedInViewControllerで、依存注入されたuserの名前を取得して表示しています。

swift-dependencies

https://github.com/pointfreeco/swift-dependencies

swift-dependenciesは swift-composable-architecture (TCA) から切り出されたライブラリです、TCAを利用していないアプリでも使う事ができます。またSwiftUIの組み合わせにとても便利です。

使い方

GitHubのリポジトリを取得して、表示するというアプリを考えてみます。

ContentViewには、レポジトリ一覧の名前を表示するrepo.nameとレポジトリ一覧を取得するボタン、エラーを表示するテキストを並べます。

import SwiftUI

struct ContentView: View {
    
    @StateObject private var viewModel: ContentViewModel = .init()
    
    var body: some View {
        VStack {
            
            if let repos = viewModel.repos {
                List(repos) { repo in
                    Text(repo.name)
                }
            }
            
            Button("get Repos") {
                Task {
                    await viewModel.fetch()
                }
            }
            if let errorStr = viewModel.errorString {
                Text(errorStr)
            }
        }
    }
}

ViewとAPI処理の繋ぎ込みをViewModelに定義します。

import Foundation
import Dependencies

@MainActor
class ContentViewModel: ObservableObject {
    
    @Published private(set) var repos: [Repo]?
    @Published private(set) var errorString: String?
    
    // TODO: 依存の取得
    private let repoApiClient = RepoAPIClient()

    func fetch() async {
        
        do {
            let repos = try await repoApiClient.getRepos()
        
            self.repos = repos
        
            self.errorString = nil
        } catch {
            
            self.errorString = "bad error"
            
        }
    }
}

以下が、APIの処理とProtocolを宣言しています。

import Foundation

protocol RepoAPIClientProtocol {
    
    func getRepos() async throws -> [Repo]
}

struct RepoAPIClient: RepoAPIClientProtocol {
        
    private let baseURL = "https://api.github.com/orgs/apple/repos"
    
    func getRepos() async throws -> [Repo] {
        
        let url = URL(string: self.baseURL)!
        
        var urlRequest = URLRequest(url: url)
        urlRequest.httpMethod = "GET"
        urlRequest.allHTTPHeaderFields = [
            "Accept": "application/vnd.github.v3+json"
        ]
        
        let (data, response) = try await URLSession.shared.data(for: urlRequest)
        
        guard let response = response as? HTTPURLResponse,
              response.statusCode == 200 else {
            throw URLError(.badServerResponse)
        }

        let jsonDecoder = JSONDecoder()
        jsonDecoder.keyDecodingStrategy = .convertFromSnakeCase
        return try jsonDecoder.decode([Repo].self, from: data)
    }  
}

RepoAPIClientを依存として、ContentViewModelに注入する事とします。

DependencyKeyの定義

// 1. DependencyKey の作成
private enum RepoRepositoryKey: DependencyKey {
    static let liveValue: any RepoAPIClientProtocol = RepoAPIClient()
}

keyは、DependencyKeyプロトコルに準拠する必要があります。準拠すると、liveValueのstaticプロパティの実装をする必要があります。上記の例だと、RepoAPIClientProtocolの実装であるRepoAPIClientインスタンスを代入しています。

Extensionにプロパティを追加

// 2. DependencyValues の拡張
extension DependencyValues {
    var repoApiclient: any RepoAPIClientProtocol {
        get { self[RepoRepositoryKey.self] }
        set { self[RepoRepositoryKey.self] = newValue }
    }
}

プロパティを公開するために拡張を作成します。keyを使用してRepoAPIClientの保存と取得をするためのコードを書きます。

@Dependencyで依存の取得

@MainActor
class ContentViewModel: ObservableObject {

    // 依存の取得
    @Dependency(\.repoApiclient) private var repoApiClient
}

先ほどDependencyValuesにExtensionで拡張したことにより、@Dependencyで参照することをが可能であり、登録したRepoAPIClientを取得してきます。

Mock

ContentViewModelのテストを書いてみます。API通信をしないで、設定したresultを返すRepoAPIClientモックを作成しました。

import Foundation

public struct RepoAPIClientMock: RepoAPIClientProtocol {

    private let result: Result<[Repo], Error>
    
    init(result: Result<[Repo], Error>) {
        self.result = result
    }

    func getRepos() async throws -> [Repo] {
        try result.get()
    }
}

上記のモックを、ContentViewModelに注入するためにはwithDependencies関数を使用します。

@MainActor
final class DISwiftDependenciesSwiftUITests: XCTestCase {
 
    func test_sucess_contentViewModel() async throws {
        
        let viewModel = withDependencies {
            $0.repoApiclient = RepoAPIClientMock(result: .success([.mock1, .mock2]))
            
        } operation: {
            ContentViewModel()
        }
        
        await viewModel.fetch()
        
        XCTAssertEqual(viewModel.repos, [.mock1, .mock2])
        XCTAssertEqual(viewModel.errorString, nil)
    }
}

withDependencies関数を使用すると、repoApiclientの内部を変更する事ができ上記だとRepoAPIClientMockに依存を差し替えています。

エラー時のテストも簡単に書けます。

    func test_failed_contentViewModel() async throws {
        
        let viewModel = withDependencies {
            $0.repoApiclient = RepoAPIClientMock(result: .failure(URLError(.badServerResponse)))
        } operation: {
            ContentViewModel()
        }
        
        await viewModel.fetch()

        XCTAssertEqual(viewModel.repos, nil)
        XCTAssertEqual(viewModel.errorString, "bad error")
        
    }

liveValue, testValue, previewValue

liveValue以外にも、テスト時、プレビュー時に利用されるtestValue, previewValueがあり定義することも出来ます。

testValueを実装すると、TestStoreによってDIされた場合にその実装がテストで使用されます。また、swift-dependenciesドキュメントでは、testValueには呼ばれるとエラーになるunimplementedを定義することが推奨されています。

struct AnalyticsClient {
  var track: (String, [String: String]) async throws -> Void
}

import Dependencies

extension AnalyticsClient: TestDependencyKey {
  static let testValue = Self(
    track: unimplemented("AnalyticsClient.track")
  )
}

previewValueはXcodeプレビューで使用されます。

extension APIClient: TestDependencyKey {
  static let previewValue = Self(
    fetchUsers: {
      [
        User(id: 1, name: "Blob"),
        User(id: 2, name: "Blob Jr."),
        User(id: 3, name: "Blob Sr."),
      ]
    },
    fetchUser: { id in
      User(id: id, name: "Blob, id: \(id)")
    }
  )
}
struct Feature_Previews: PreviewProvider {
  static var previews: some View {
    FeatureView(
      model: withDependencies {
        $0.apiClient.fetchUsers = .previewValue
      } operation: {
        FeatureModel()
      }
    )
  }

swift-dependencies/document 参照

Factory

https://github.com/hmlongco/Factory

比較的簡単に使用できます。

使い方

依存性の登録

import Factory

extension Container {

    /// 注入するオブジェクトを拡張コンテナに定義
    var myService: Factory<MyServiceType> { 
        Factory(self) { MyService() }
    }
}

注入するオブジェクトを拡張コンテナに定義するだけ、上記はMyServiceTypeに準拠するサービスを返すシンプルな依存性登録です。

オブジェクト取得

class ContentViewModel: ObservableObject {
    @Injected(\.myService) private var myService
    ...
}

Factoryの@Injectedプロパティラッパーを使用して、必要な依存関係を要求します。SwiftUIの@Environment似たように、プロパティラッパーには、希望するタイプのFactoryへのキーパスを指定し(上記だとmyService)、ContentViewModelが生成されると同時にそのタイプを解決します。

SwiftUIでなくても使用できます。

class ContentViewModel: ObservableObject {
    private let myService = Container.shared.myService()
    private let eventLogger = Container.shared.eventLogger()
    ...
}

シングルトンコンテナにアクセスして取得することも出来ます。

class ContentViewModel: ObservableObject {
    let service: MyServiceType
    init(container: Container) {
        service = container.service()
    }
}

ViewModelにコンテナのインスタンスを渡し、そのコンテナからサービスのインスタンを直接取得することも出来ます。

Mock

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        let _ = Container.myService.register { MockService2() }
        ContentView()
    }
}

プレビューが表示される際に、ContentViewから生成されるContentVIewModelの中で、@Injectedプロパティラッパーを使用して、myServiceに依存しています。上記の.registerを使用して依存しているmyServiceをMockService2()に置き換えることが出来ます。

Testing

Mockと同様に、オブジェクトを作成する前にMockに変更してテストしています。

final class FactoryCoreTests: XCTestCase {

    override func setUp() {
        super.setUp()
        Container.shared.reset()
    }
    
    func testLoaded() throws {
        Container.shared.accountProvider.register { MockProvider(accounts: .sampleAccounts) }
        let model = Container.shared.someViewModel()
        model.load()
        XCTAssertTrue(model.isLoaded)
    }

    func testEmpty() throws {
        Container.shared.accountProvider.register { MockProvider(accounts: []) }
        let model = Container.shared.someViewModel()
        model.load()
        XCTAssertTrue(model.isEmpty)
    }

    func testErrors() throws {
        Container.shared.accountProvider.register { MockProvider(error: .notFoundError) }
        let model = Container.shared.someViewModel()
        model.load()
        XCTAssertTrue(model.errorMessage = "Some Error")
    }  
}

Scope

extension Container {
    var networkService: Factory<NetworkProviding> { 
        self { NetworkProvider() }
            .singleton
    }
    var myService: Factory<MyServiceType> { 
        self { MyService() }
            .scope(.session)
    }
}

宣言したオブジェクトのライフサイクルを指定できます。上記だとnetworkServiceはシングルトンとなり、取得時全て同じオブジェクトのインスタンスが取得されます。

let a = container.myService()
let b = container.myService()

aとb同じインスタンスを参照します。

Sessionはある特定のタイミングで、オブジェクトをリセットします。

func logout() {
    Container.shared.manager.reset(scope: .session)
        ...
}

上記ではログアウトをすることによって、セッションスコープがリセットされます。

他にも様々なScopeがあり、有効期間を指定したりすることも出来ます。詳細は以下を参照してください。

https://hmlongco.github.io/Factory/documentation/factory/scopes/ 参照

終わりに

ぱっと見調べてみると、様々なDIライブラリがありどれもメリット/デメリットがありました。

Swinject ← 一番スターを獲得しており、唯一(?)StoryboardによるDIがある。IUOを多用する為、型安全ではない。SwiftUIが少し使いずらそう。更新は2022年に止まっている。(2023/08/01 現在)

Needle ← Uberが運用しているDI、同じUber社のフレームワークRibsと親和性は高そう。Needleコードジェネレータで、依存関係が解決できない場合ビルドに失敗するので、実装・修正のサイクルが速くなる。階層型なDIツリーを慣れていない人は学習コストが高めかなと感じた。

swift-dependencies ← TCAから切り出されたDI、TCAを利用していないアプリでも使用可能。依存の登録が簡単そして柔軟。testValueで予期しない実行をテスト時に検知できる。更新頻度がまめ。withDependenciesで上書きする際に注意点があるそうです。参考サイトの以下リンクです。

swift-dependencies注意点

Factory ← 注入するオブジェクトを拡張コンテナに定義するだけで、DIすることが出来るので簡単です。ファイルも非常に軽量。コンパイルの安全性があります。調べた感じ今の所試した日本の記事はない?

導入する際はプロジェクトのニーズに合った最適なライブラリを選ぶことが重要だと思います。この記事がその手助けになれたら幸いです。

参考リンク

Swinject

Needle

swift-dependencies

Factory



ギャップロを運営しているアップフロンティア株式会社では、一緒に働いてくれる仲間を随時、募集しています。 興味がある!一緒に働いてみたい!という方は下記よりご応募お待ちしております。
採用情報をみる