はじめに
こんにちは!システム開発部のYです。
ところで皆さんは、iOSプロジェクトにDIは導入していますか?OSSや、そもそもOSSを利用しない独自のDIコンテナを選択するケースもあると思います。iOS開発において、DIといえばこれ!というような方法やライブラリは現状ないですよね。。そこで今回複数の外部ライブラリを比較してみました。
そもそもDIとは?
DIとはDependency Injectionの略で、日本語で言う「依存性の注入」です。
DIとは、プログラミングにおけるデザインパターン(設計思想)の一種で、オブジェクトを成立させるために必要となるコードを実行時に注入(Inject)してゆくという概念のことである。
IT用語辞典バイナリ
外部から依存オブジェクトを注入することで、クラスの依存関係を分離します。そうすることで、コードの保守性、テスト容易性、柔軟性を向上といった様々なメリットがあります。
DIライブラリ比較
※ 2023/08/01 現在
今回調べたのは以下の4つになります。
名前 | URL | スター | 最終更新 |
Swinject | Swinject | 5.9k | 2022, 12, 3 |
needle | needle (Uber社が開発) | 1.6k | 2023, 4, 29 |
swift-dependencies | swift-dependencies (TCAから切り出されたライブラリ) | 998 | 2023, 7, 31 |
Factory | Factory | 1.1k | 2023, 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で上書きする際に注意点があるそうです。参考サイトの以下リンクです。
Factory ← 注入するオブジェクトを拡張コンテナに定義するだけで、DIすることが出来るので簡単です。ファイルも非常に軽量。コンパイルの安全性があります。調べた感じ今の所試した日本の記事はない?
導入する際はプロジェクトのニーズに合った最適なライブラリを選ぶことが重要だと思います。この記事がその手助けになれたら幸いです。
参考リンク
Swinject
- https://github.com/Swinject/Swinject
- https://dev.classmethod.jp/articles/swinject-dependency-injection/
- https://qiita.com/TokyoYoshida/items/049ed55edf5e624ce0c3
Needle
- https://github.com/uber/needle
- https://note.com/reality_eng/n/n124fd7da93c3
- https://lab.mo-t.com/blog/needle-in-ribs-architecture
- https://tech.medpeer.co.jp/entry/2022/04/18/150000
- https://qiita.com/ikezzi/items/728ef40568e69f0f802d
swift-dependencies
- https://github.com/pointfreeco/swift-dependencies
- https://pointfreeco.github.io/swift-dependencies/main/documentation/dependencies
- https://zenn.dev/mayaa/articles/409c07b5d9e0cb
- https://tech.uzabase.com/entry/2022/12/27/114409
- https://zenn.dev/kalupas226/articles/25ec066246473e
Factory
- https://github.com/hmlongco/Factory
- https://hmlongco.github.io/Factory/documentation/factory
- https://dokit.tistory.com/54#header-1