こんにちは!今週のAKARI Tech Blogは、DX Solution事業部の田川が担当します!
燈ではWebアプリケーションだけでなく、モバイルアプリケーションの開発も行っています。
その中でiOSアプリ開発においてはTCA(The Composable Architecture)というアーキテクチャを利用しています。さらに、ローカルDBとしては比較的導入が容易で高速なRealmを利用することも少なくありません。
本記事では、TCAとRealmを利用したモダンなiOSアプリ開発について一例を交えながらご紹介できればと思います。
TL;DR
こちらは、今回紹介するTCAにRealmを導入する際の概略図です。
TCAの一般的な利用に加え、RealmをActorで定義してDependencyValuesを使って依存関係を注入しています。
TCAの導入コストは高く、特に途中からの移行に時間を要すなどの課題に直面しながらも、状態管理の一元化・テストの容易化が可能になるなどのメリットを享受できました。
この記事で学べること
TCAとRealmの基本概念と特徴
両技術を組み合わせるメリットとアーキテクチャ設計
実践的な実装パターンとコード例
TCAとRealmとは?
TCA(The Composable Architecture)とは
TCAは、Point-Free が開発したSwift用の状態管理フレームワークです。単一方向データフローによる予測可能な状態管理を実現しています。
TCAは、以下のようなアーキテクチャの特徴を持ちます:
- State(状態)、Action(操作)、Reducer(状態の変化の定義)を明確に分離し、アプリの挙動を予測しやすく保つ
- 環境依存の処理(依存関係)を注入可能にし、テスト容易性を高める
- 名前の通り、Composable(合成可能)な構造により、小さな機能を再利用可能な部品として構築・結合可能
- Combine や Swift Concurrency(Task / async / await)と連携でき、非同期処理も整然と管理可能
そのため、アプリが中〜大規模に成長しても破綻しにくい構造を保つことができます。
燈でも大規模アプリ開発を見据え、拡張性・保守性・テスト容易性を重視してTCAを導入しました。
↓Reducer, State, Actionの例
import ComposableArchitecture @Reducer public struct AppReducer: Reducer { @ObservableState public struct AppState: Equatable { var counter: Int = 0 public init() {} } public enum AppAction: BindableAction { case increment case decrement } public init() {} public var body: some ReducerOf<Self> { BindingReducer() Reduce { state, action in switch action { case .increment: state.counter += 1 return .none case .decrement: state.counter -= 1 return .none } } } }
Realmとは
Realmは、MongoDBが提供するモバイル用のNoSQLなローカルデータベースです。
Realmはスレッドをまたいで アクセスするとクラッシュや予期しない動作になります。 そのため、Actorとして定義することで同時アクセスを順序付けして直列化できるため、Realm の制約を意識せず使えるようになります。
↓RealmをActorの中で定義した例
import RealmSwift public final class Project: Object { @Persisted(primaryKey: true) public var _id: ObjectId @Persisted var name: String = "" @Persisted var createdAt: Date = Date() } public actor RealmActor { var realm: Realm! public init() async throws { realm = try await Realm(actor: self) } private var allProjects: [Project] { realm.objects(Project.self).map { $0 } } public func createProject(name: String) async throws { try await realm.asyncWrite { realm.create(Project.self, value: [ "_id": ObjectId.generate(), "name": name, "createdAt": Data() ]) } } }
TCAにRealmを組み込む
では、実際にRealmActorをTCAに組み込んでみます。
1. Stateに変数を定義
先ほど定義したStateとActionにProjectに関する操作を組み込みたいと思います。
Stateの中に持たせるにはEquatableでなければなりません。 まずは、RealmのProjectオブジェクトと変換可能なProjectStructを、Equatableで定義してみましょう。
public struct ProjectStruct: Equatable { public var _id: String public var name: String? public var createdAt: Data public var realmObjectId: ObjectId? { try? ObjectId(string: _id) } } public actor RealmActor { ... public var projectStructs: [ProjectStruct] { realm.objects(Project.self).map { return ProjectStruct( _id: $0._id.stringValue, name: $0.name, createdAt: $0.createdAt ) } } }
では、Stateにprojectsを定義します。
@Reducer public struct AppReducer: Reducer { @ObservableState public struct AppState: Equatable { ... var projects = [ProjectStruct]() public init() {} } ... }
2. ClientをReducerにDependency注入
次に、Actionにprojectsをfetchする処理を書いてみましょう。と言いたいところですが、ProjectStructを取り扱うClientを定義します。
Dependencyを使うことで依存関係を外部から注入することができ、疎結合で保守性の高いコードになります。
import Dependencies import RealmActorFeature // RealmActorを定義したモジュール struct ProjectsClient { var projectStructs: () async throws -> [ProjectStruct] } extension ProjectsClient: DependencyKey { static let liveValue = Self( projectStructs: { let realmActor = try await RealmActor() return realmActor.projectStructs } ) } extension DependencyValues { var projectsClient: ProjectsClient { get { self[ProjectsClient.self] } set { self[ProjectsClient.self] = newValue } } }
次に、projectsClientを@DependencyでStoreに注入します。
@Reducer public struct AppReducer: Reducer { ... public enum AppAction: BindableAction { ... } @Dependency(\.projectsClient) var projectsClient public init() {} public var body: some ReducerOf<Self> { ... } }
3. Actionの定義
@Reducer public struct AppReducer: Reducer { ... public enum AppAction: BindableAction { ... case fetchProjects case projectsLoaded(Result<[ProjectStruct], Error>) } ... public var body: some ReducerOf<Self> { BindingReducer() Reduce { state, action in switch action { ... case .fetchProjects: return .run { send in do { let projects = try await projectsClient.projectStructs() await send(.projectsLoaded(.success(projects))) } catch { await send(.projectsLoaded(.failure(error))) } } case let .projectsLoaded(result): switch result { case let .success(projects): state.projectStructs = projects case let .failure(error): print("プロジェクトのロードに失敗しました: \(error)") state.projectStructs = [] } return .none } } } }
これでTCAにRealmを組み込むことができました!
開発アプリ例
今回紹介したコードを応用して、手書きノートアプリを作成してみました。
直面した課題
TCAは非常に有用なアーキテクチャである一方で、導入には苦労しました。
苦労した点
学習コストの高さ
今まで単純なMVVMでの開発を行なっていたため、新たにTCAを導入する際には概念の理解が難しかったです。
他のアーキテクチャからの移行の難しさ
MVVMで開発されたアプリを途中でTCAに切り替えることを行なったため、必要以上の工数がかかりデバッグにも苦しみました。
アップデート頻度の高さ
TCAは頻繁にアップデートが繰り返されています。そのため、数ヶ月後にはメソッドの使い方が変わっていることなどもあります。
よくわからないエラーが出る
TCAでは本質的でないエラーが出ることが多く、検索しても中々正解に辿り着くことができないことも少なくありません。
これらの課題はありながらも、TCAの導入とともに燈内部でテンプレートを拡充し、実装スピードが格段に上がりました!
まとめ
今回は、TCA、Realm、Swift Actorを組み合わせたモダンなiOSアプリケーションアーキテクチャについて解説してみました。
TCAは使いこなせられれば非常に有用なアーキテクチャです。 Realmなどのローカルデータベースを組み合わせることで幅広いアプリ開発が可能になります。
これからも新しい技術に貪欲に挑戦していきたいと思います!
We're hiring
燈では、新しい技術に興味のあるモバイルエンジニアを募集しています!
少しでも興味を持っていただけたらカジュアル面談でお話しさせていただけると嬉しいです! akariinc.co.jp