AKARI Tech Blog

燈株式会社のエンジニア・開発メンバーによる技術ブログです

【TCA × Realm】モダンなiOS開発のグッドプラクティス

こんにちは!今週のAKARI Tech Blogは、DX Solution事業部の田川が担当します!

燈ではWebアプリケーションだけでなく、モバイルアプリケーションの開発も行っています。

その中でiOSアプリ開発においてはTCA(The Composable Architecture)というアーキテクチャを利用しています。さらに、ローカルDBとしては比較的導入が容易で高速なRealmを利用することも少なくありません。

本記事では、TCAとRealmを利用したモダンなiOSアプリ開発について一例を交えながらご紹介できればと思います。

TL;DR

TCAにRealmを導入する概略図

こちらは、今回紹介する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は非常に有用なアーキテクチャである一方で、導入には苦労しました。

苦労した点

  1. 学習コストの高さ

    今まで単純なMVVMでの開発を行なっていたため、新たにTCAを導入する際には概念の理解が難しかったです。

  2. 他のアーキテクチャからの移行の難しさ

    MVVMで開発されたアプリを途中でTCAに切り替えることを行なったため、必要以上の工数がかかりデバッグにも苦しみました。

  3. アップデート頻度の高さ

    TCAは頻繁にアップデートが繰り返されています。そのため、数ヶ月後にはメソッドの使い方が変わっていることなどもあります。

  4. よくわからないエラーが出る

    TCAでは本質的でないエラーが出ることが多く、検索しても中々正解に辿り着くことができないことも少なくありません。

これらの課題はありながらも、TCAの導入とともに燈内部でテンプレートを拡充し、実装スピードが格段に上がりました!

まとめ

今回は、TCA、Realm、Swift Actorを組み合わせたモダンなiOSアプリケーションアーキテクチャについて解説してみました。

TCAは使いこなせられれば非常に有用なアーキテクチャです。 Realmなどのローカルデータベースを組み合わせることで幅広いアプリ開発が可能になります。

これからも新しい技術に貪欲に挑戦していきたいと思います!

We're hiring

燈では、新しい技術に興味のあるモバイルエンジニアを募集しています!

少しでも興味を持っていただけたらカジュアル面談でお話しさせていただけると嬉しいです! akariinc.co.jp