こんにちは、あらさんです。iOSDC2023で発表されたBitkeyのスポンサーセッション「真似できる!実践的なプロジェクト構成 ~bitkey流簡単レシピ~」は見ていただけたでしょうか?
この記事ではこちらのセッションで話した内容を実践するための具体的な方法についてまとめています。ぜひお楽しみください!
レシピについて
レシピは実際にプロジェクトを手元で動かして学ぶことは良い方法であると信じています。そのため、まずはサンプルコードを用意しました。こちらの内容とセッションでのトピックをベースに解説を進めていきます。
iOSDC2023 Bitkeyのスポンサーセッションを動画でご覧になられていない方は是非そちらもどうぞ!セッションではこのプロジェクト構成を実際に開発・運用までしてみてどんな気持ちで開発ができたのか、という部分に焦点を当てています。
またこのサンプルコードではセッションの内容に加えてThe Composable Architectureを利用しています。
サンプルコードざっくり説明
プロジェクトはBitkey Project Config.xcodeproj
をベースに作られています。InternalディレクトリにはSwift Package中心としたコードが入っています。プロジェクト全体を開くときには.xcodeproj
を開いてください。
Internalには5つのモジュールと2つのテストが入っています。それぞれのモジュールは小さなコードで構成されています。依存はすべてPackage.swiftに記載されています。
- App本体:
Bitkey Project Config/ContentView.swift
- Appを構成するまとめ役:
Internal/Sources/AppFeature
- Viewを構成するコード:
Internal/Sources/ViewA
- 宣言部分:
Internal/Sources/Calculator
- 実装部分:
Internal/Sources/CalculatorLive
- ビルドが重いコード:
Internal/Sources/HeavyBuild
- ロジックの単体テスト:
Internal/Tests/HeavyBuildTest
- Viewの単体テスト:
Internal/Tests/ViewATest
プロジェクトとしては最小構成ですが必要な要素はある程度揃っています。CI/CDについては以前書いた以下の記事をご覧ください。
プロジェクト構成のキモ
サンプルコードでは特にビルド対象の依存について最終的なアプリを構築する際にコンパイルが重たいコードをビルドする形を取っています。
ここではCalculatorというモジュールが別のモジュールとしてLive実装を持つ形を取っています。これをPackage.swiftから見ていきます。
.target(
name: T.Calculator.name,
dependencies: [.di]
),
.target(
name: T.Calculator.nameLive,
dependencies: [.di, T.HeavyBuild.target]
),
.target(name: T.HeavyBuild.name),
このシンプルなターゲットの定義は宣言と処理の分離を意図しています。それぞれの依存はDependencyの解決のためのライブラリであるpointfreeco/swift-dependenciesを持っています。この他にHeavyBuildというコンパイルに時間が掛かるモジュールを実務を想定して作成しています。
今回用意しているHeavyBuildは単純にSwiftの型推論に対する計算量が増えることを利用したものとなっています。処理として意味はありません、ここでは誤ってLive実装がビルドされた場合に分かりやすいものとなるように作っています。
public let heavyCompile1: (Array<Int>) -> Array<Int> = { inputs in
return inputs
.map { String($0) }
.compactMap { Int($0) }
.map { String($0) }
.compactMap { Int($0) }
}
注目するコード
一番おもしろいコードはモジュール間の依存解決をしている部分です。まず、CalculatorのClient.swiftを覗いてみましょう。
以下のコードは宣言と疑似実装を定義しています。やりたいことは本来できることをダミー実装とあわせて他のモジュールが利用できるようにすることです。DependenciesライブラリではXcode Previewsを使ってコードを扱う場合には暗黙的にpreviewValueを利用します。今回のコードでは計算機と言いつつ定数を返す実装にしています。
import Dependencies
public struct Calculator {
public init(sum: @escaping @Sendable (Array<Int>) -> Int) {
self.sum = sum
}
public var sum: @Sendable (Array<Int>) -> Int
}
extension Calculator: TestDependencyKey {
public static var previewValue: Calculator {
.init { inputs in
return 42
}
}
public static var testValue: Calculator {
.init(sum: unimplemented("\(Self.self)\(#function)"))
}
}
extension DependencyValues {
public var calculator: Calculator {
get { self[Calculator.self] }
set { self[Calculator.self] = newValue }
}
}
この実装に対する依存のみでAppFeatureまで組み立ててみましょう。Package.swiftではCalculatorLiveに対する依存を親のViewやAppFeatureでは持っていません。これはXcodeの設定側で依存を持つようにしています。この挙動を観察するためサンプルコードではLinkさせたTarget(Bitkey Project Config)とLinkさせないTarget(No Link Target)を用意しています。
.library(
name: T.AppFeature.name,
targets: [T.AppFeature.name]
),
...
.target(
name: T.AppFeature.name,
dependencies: [.tca, .di, T.ViewA.target]
),
.target(name: T.ViewA.name, dependencies: [.tca, .di, T.Calculator.target]),
.target(name: T.Calculator.name, dependencies: [.di]),
.target(name: T.Calculator.nameLive, dependencies: [.di, T.HeavyBuild.target]),
.target(name: T.HeavyBuild.name),
Dependenciesの色々話
TaskLocalをベースに作られたDependenciesは利用するアーキテクチャによりThe Composable Architecture(TCA)を利用する場合のDependenciesとViewModelベースのDependenciesで利用方法が変わります。
ViewModelベースの実装であれば新しいModelを生成するとDependencyが引き継がれません。そのため明示的な依存の伝搬をする必要があります。TCAの場合は最終的に1つのRootReducerを作成するためDependencyはReducer間で同じcontextを共有します。
class Current: ObservableObject {
@Dependency(\.context) var context
var next: Next? = nil
func setNext() {
next = withDependencies(from: self) {
Next()
}
}
}
DependencyValuesに色々な依存を作成していくとそれらのモジュールを互いに使ったコードが欲しくなってくる場面が出てきます。この場合にどのようにDependencyを組み上げるべきでしょうか。考え方としてLiveに依存していなければ互いの宣言に依存していても致命的な問題になることは少ないかもしれません。
しかしこの場合は1つの責務を担わせるはずだったロジックのまとまりが崩壊してしまうため、上に更にまとめ上げを行う層を作ったほうが良いでしょう。The Composable Architectureの実装を眺めてみるとReducerはアプリとしてそれらの機能群を組み合わせるために使われています。またReducerはSwiftUI, UIKitの実装パラダイムが異なるフレームワークに対して同様のコードが記述できることを示しています。ここから考えるとThe Composable ArchitectureはViewを組み上げるためのフレームワーク以上にアプリが提供するはずの処理方法を提供するものだと考えたほうが自然です。ViewとInteractorの一部を担うフレームワークとして扱うと筋が良さそうに感じます。
Develop, Productionなどの環境依存コード管理
Swift Packagesを中心の取り組みをしたときにDevelopやProductionのみで実行したい依存コードがあります。例えばWebAPIの接続先はURIとして異なる場合があります。その場合にはそれぞれの依存を注入するモジュールを分割した上で、本体アプリ側のLinkするターゲットを指定するだけでOKです。
まとめ
楽しく快適に開発するための考察をここまで書いてきました。Bitkeyではより快適な開発者体験を実現するための取り組みを進めています。Bitkeyについてもっと知りたい、話してみたいと思われた方は是非iOSDC2023のブースであらさん(arasan01)とお話しましょう!