📝

[SwiftUI/UIKit] iOSアプリの状態管理について思っていること

に公開

Claude 4 Opusにて

iOSアプリの状態管理:完璧を求めず、トレードオフを理解する

はじめに

iOSアプリ開発において、状態管理は避けて通れない重要なテーマです。アプリが複雑になるにつれ、データの一貫性を保ちながら、パフォーマンスも維持する必要があります。リアクティブなUIの実現も、現代のアプリ開発では必須要件となっています。

本記事では、私自身の経験を通じて見てきた状態管理の変遷と、それぞれのアプローチが持つトレードオフについて考察します。完璧な解決策は存在しないという前提に立ち、プロジェクトに応じた最適な選択をするための視点を提供したいと思います。

状態管理アーキテクチャの歴史的変遷

Fluxの衝撃とReduxの台頭

2014年頃、FacebookがFluxアーキテクチャを発表したとき、多くの開発者がその単純明快なコンセプトに魅了されました。単一方向のデータフローは、それまでの双方向バインディングによる複雑さから解放してくれる約束のように見えました。

// Reduxの基本的な考え方
const store = createStore(reducer);
store.dispatch({ type: 'INCREMENT' });
const state = store.getState();

Reduxは、このFluxの考えをさらに洗練させ、単一のStoreで全てのアプリケーション状態を管理するアプローチを提案しました。「Single Source of Truth」という理想は美しく、理論的には完璧に見えました。

しかし、実際のアプリケーション開発では問題が顕在化してきます:

  • 巨大な状態ツリーの管理が困難に
  • 小さな変更でも全体の再計算が発生
  • パフォーマンス最適化のためにreselectなどのSelectorライブラリが必須に
// reselectを使った最適化の例
const getUsers = state => state.users;
const getActiveUserId = state => state.activeUserId;

const getActiveUser = createSelector(
  [getUsers, getActiveUserId],
  (users, activeUserId) => users[activeUserId]
);

iOS界隈でのRxSwift + MVVM時代

一方、iOS開発の世界では、RxSwiftを使ったMVVMパターンが流行しました。これは、リアクティブプログラミングの力を借りて、UIとビジネスロジックを分離しようという試みでした。

// 典型的なRxSwift + MVVMの例
class UserViewModel {
    let userName = BehaviorSubject<String>(value: "")
    let isValid = BehaviorSubject<Bool>(value: false)
    let saveButtonEnabled = BehaviorSubject<Bool>(value: false)
    
    init() {
        Observable.combineLatest(userName, isValid)
            .map { name, valid in !name.isEmpty && valid }
            .bind(to: saveButtonEnabled)
            .disposed(by: disposeBag)
    }
}

しかし、このアプローチも規模が大きくなると問題が生じました:

  • BehaviorSubjectが大量に作られる
  • それらを繋ぐStreamの管理が複雑化
  • 依存関係の把握が困難に

細粒度状態管理への回帰:Recoilの登場

これらの問題を受けて、状態管理は再び「分割」の方向へ向かいます。Recoilは、小さな値を格納するAtomを大量に作り、それらの依存関係をDAG(有向非巡回グラフ)として自動管理するアプローチを提案しました。

// Recoilのアプローチ
const userNameAtom = atom({
  key: 'userName',
  default: '',
});

const userValidSelector = selector({
  key: 'userValid',
  get: ({get}) => {
    const name = get(userNameAtom);
    return name.length > 0;
  },
});

これは興味深い洞察を含んでいます。BehaviorSubjectとStreamが大量だったのは、依存関係を手動で管理する必要があったからです。Recoilは、この依存関係の管理を自動化することで、開発者の負担を軽減しました。

Immutabilityの理想と現実

巨大な構造体の更新コスト

Swiftで状態をImmutableに保とうとすると、必然的に巨大なstructを作ることになります:

struct AppState {
    var users: [User]
    var posts: [Post]
    var comments: [Comment]
    var settings: Settings
    // ... 数十のプロパティ
}

// 一つのプロパティを更新するだけでも全体のコピーが発生
var newState = appState
newState.users[0].name = "New Name"

Copy on Write(CoW)はこの問題を部分的に解決しますが、変更検知の問題は残ります。特定のプロパティが変わったことを効率的に検出するのは困難で、結局は値の比較に頼ることになります。

正規化のジレンマ

データの正規化は、重複を排除し更新を効率化する古典的な手法です:

// 正規化前
struct Post {
    let id: String
    let author: User
    let comments: [Comment]
}

// 正規化後
struct NormalizedState {
    var users: [String: User]
    var posts: [String: Post]
    var comments: [String: Comment]
}

struct Post {
    let id: String
    let authorId: String
    let commentIds: [String]
}

しかし、正規化されたデータをViewで使うためには、再度結合(denormalize)する必要があります:

// Selectorでの再結合
func getPostWithDetails(state: NormalizedState, postId: String) -> PostDetails? {
    guard let post = state.posts[postId],
          let author = state.users[post.authorId] else { return nil }
    
    let comments = post.commentIds.compactMap { state.comments[$0] }
    return PostDetails(post: post, author: author, comments: comments)
}

この再結合のロジックは、アプリケーション全体で必要になり、管理が煩雑になります。

代替アプローチの探求

CoreDataという選択肢

私は以前、CoreDataを状態管理に使えないか真剣に検討したことがあります。CoreDataは以下の特徴を持っています:

  • オブジェクトグラフの管理
  • 変更通知の仕組み
  • 効率的なクエリ

しかし、実装には踏み切りませんでした。理由は:

  • リレーショナルデータベースの構成であるところが問題
  • UIのステートとしては基本的にtreeであることが望ましい
  • 正規化する場合には有効だが、多くのケースではtree構造の方が適している

Meta Messengerの革新的アプローチ

MetaがMessengerアプリを作り直した際の記事は、非常に示唆に富んでいます。彼らは全てのデータをSQLiteで管理し、SQLiteのViewを使ってUIのためのデータをクエリするアプローチを採用しました。

-- SQLiteのViewを使った例(概念的)
CREATE VIEW conversation_list AS
SELECT 
    c.id,
    c.title,
    m.text as last_message,
    u.name as last_sender_name
FROM conversations c
LEFT JOIN messages m ON c.last_message_id = m.id
LEFT JOIN users u ON m.sender_id = u.id;

このアプローチの利点:

  • データベースレベルでの最適化
  • 複雑なクエリの効率的な実行
  • サーバーサイドとの強力な連携

実際にMessengerアプリのSQLiteを覗いてみたことがありますが、本当にこのような構造になっていて、その徹底ぶりに感銘を受けました。

SwiftのObservation Framework

新しいパラダイムの登場

iOS 17で導入されたObservation frameworkは、プロパティレベルでの変更追跡を可能にします:

@Observable
class UserModel {
    var name: String = ""
    var email: String = ""
    var isVerified: Bool = false
}

// SwiftUIでの使用
struct UserView: View {
    let user: UserModel
    
    var body: some View {
        // nameが変更されたときのみ再描画される
        Text(user.name)
    }
}

重要なのは、これはコンポーネントであり、全体のアーキテクチャを定義するものではないということです。このツールを使って、プロジェクトに適したアーキテクチャを設計する自由があります。

SwiftDataとの統合

SwiftDataのModelは、内部的にObservableを使用しています:

@Model
class User {
    var name: String
    var email: String
    
    init(name: String, email: String) {
        self.name = name
        self.email = email
    }
}

これは、NSManagedObjectのKVOに代わる、よりモダンな変更通知の仕組みです。

トレードオフを理解した選択

完璧な解決策は存在しない

ここまで見てきたように、各アプローチには必ずトレードオフが存在します:

アプローチ 利点 欠点
Redux風単一Store シンプルな概念、予測可能 パフォーマンス、スケーラビリティ
RxSwift + MVVM 柔軟性、リアクティブ 複雑性、学習コスト
細粒度State 効率的な更新 多数のオブジェクト管理
SQLiteベース 高性能、複雑なクエリ 実装の複雑さ

Struct vs Class の選択

ModelやDataをstructで作るかclassで作るかも、ケースバイケースです:

// Structの場合:Immutability、値セマンティクス
struct UserData {
    let id: String
    var name: String
}

// Classの場合:参照セマンティクス、Observableとの相性
@Observable
class UserModel {
    let id: String
    var name: String
}

全体としてのImmutabilityを捨てると、多くのことが楽になる場合があります。値は最終的にはプリミティブ型で表現されているわけですし、必要以上に複雑な抽象化は避けるべきです。

実践的なアドバイス

プロジェクトの特性を考慮する

  1. アプリの規模:小規模なアプリなら、シンプルな@Stateと@Bindingで十分かもしれません
  2. データの複雑さ:リレーションが多い場合は、正規化やSQLiteベースのアプローチを検討
  3. パフォーマンス要件:リアルタイム性が求められる場合は、細粒度の状態管理が有効

チームとの整合性

  • チームメンバーのスキルセット
  • 既存コードベースとの一貫性
  • 学習コストと開発速度のバランス

アーキテクチャパターンの本質

最後に、MVVMやVIPERなど、様々なアーキテクチャパターンがありますが、これらは結局MVCの責務分割を調整したものに過ぎません。デファクトスタンダードに見えるものを盲目的に採用する必要はありません。

重要なのは:

  • 何を得て、何を失っているかを理解すること
  • プロジェクトの要件に合った選択をすること
  • 必要に応じて柔軟に変更できる設計にすること

まとめ

状態管理に「銀の弾丸」は存在しません。各アプローチのトレードオフを理解し、プロジェクトの特性に応じて適切な選択をすることが重要です。時には「雰囲気的にデファクトスタンダード」に見えるものから離れる勇気も必要です。

技術選択で無駄に消耗することなく、本質的な価値創造に集中できるよう、pragmaticな判断を心がけましょう。完璧を求めず、十分に良い解決策を選ぶ。それが、持続可能な開発への道だと私は考えています。


っていうのを
https://github.com/VergeGroup/swift-state-graph
を作る過程で思った。というのが発端


生成元

  • 状態管理は重要
  • リアクティブさも大切
  • 自分視点での歴史の流れ
  • Fluxのコンセプトがくる
  • 一つのstoreを用いたReduxが流行る
    • 巨大なステート
    • 再計算を防ぐためのselecter (reselectなど)
    • 限界が来る
  • iOSはRxを使ったMVVMが流行る
    • BehaviorSubjectが大量
      • それを繋ぐstreamが大量
  • Storeを分割する考えに戻る
  • しかし、結局property単体レベルの細かい粒度でのトラッキングの必要性は残る
  • Recoilが来る
    • これはBehaviorSubjectのような小さな値を格納するstoreを細かく作るというもの
    • その細かく分割されたstoreをsubscribeすることで解像度の高いトラッキングができる
    • BehaviorSubjectとstreamが大量だったのは、依存を作り出し、元の値が変わることに応じて新しい値を作り出すため
    • ここがDAGのコンセプトにより自動的に依存が管理されることでStoreは大量だが、streamは管理しなくて済むようになった。
  • stateはsingle source of truthであることに価値があることは確かだが、そのsourceはtreeとしてimmutableである必要は必ずしもない。
  • immutable性を求めようとすると難しいことが増える
    • まずは巨大なstructを作ることになる。
    • そのstructのmodifyにかかるコストは大きさに依存する
    • それを解消するためにCopy on Writeが役立つ
    • しかし、特定のpropertyが変わったことを判定するにはstructの読み出しが必要
      • 結果としてはvalueを比較することになる。
      • modifyされたpropertyだけ知ることができればいいが、それは難しい
      • 比較の効率はvalueのtypeに依存する
    • 加えて、normalizationには課題がある
      Vuex ORM | Vuex ORM
      Redux Essentials, Part 6: Performance, Normalizing Data, and Reactive Logic | Redux
      • データをmapに落とし込みupdateに対する効率化
        • relationshipがある場合に特に効率化される
      • しかしそのnormalizeされたデータをviewのためにdenormalizeする時には別の仕組みとしてrelationshipを解決する必要がある。
        • relationshipはidとして保管されているため。
      • このためにさらにselectorを作る必要が出る。
        • それはライブラリによって簡略化はできるが、やることは同じ。
          • relationshipをreferenceで解決していれば話は変わってくるが
            • それはimmutable性を捨てていることになるはず
  • というところから、もし、アプリケーションがnormalizeを必要することがないケースならこのやり方でも良い。
    • normalizeが必要なケースは何か
      • 画面を跨いで共通のデータを必要とするケース。
        • 一覧と詳細など
          • お気に入りのステータス表示をどちらにも出している場合など
  • 全体としてのimmutableを捨てるといろんなことが楽になる
    • ちょっとこの言葉も不思議なんだけど、
      • そもそも値は最終的にはprimitive-typeで表現されているわけだし
    • そもそも昔に、CoreDataをstate-managementに使えないか?と考えたこともあった。
      • 実際問題これには踏み切ったことはなかったが、アイデアとしては強力だった
        • MetaがMessengerアプリを作り直したという記事では全てをSQLiteとして管理をし、SQLiteが持つViewを使ってUIのためにデータをqueryしているという話だった
          Project LightSpeed: Rewriting the Messenger codebase for a faster, smaller, and simpler messaging app
        • 実際このSQLiteを覗いてみたことがあったが、実際にそういう感じだった
        • この構成はかなり高度なものだが実現させていることは本当にすごい
        • サーバーサイドとの強力な連携もあってのことだろう。
    • 小さなstoreの変更を監視すれば良いだけになる
      • 比較して監視するのではなく、単にupdateされたかを見れば良い
      • 本当に変わっているかどうかを知りたければ比較すれば良いが
      • SwiftUIなどのパラダイムのUIフレームワークではそれはUIレイヤーがやるので神経質にならなくても良い。
      • まず、updateする前に異なる値であれば代入するなどの余地もあるが
      • 一方で大量の小さなstoreのallocationはトレードオフ
  • SwiftのObservation framework
    • これはコンポーネントであり、全体のアーキテクチャを定義するものではない。
    • classオブジェクトのプロパティの変化をトラッキングできるようにするものである。
    • この特性を用いてアプリケーションに合ったアーキテクチャをデザインすれば良い
    • これでViewModelを作りましょう。というルールはない
    • SwiftData.ModelはObservableを用いている。
      • NSManagedObjectのKVOの代わり
  • ModelやDataに相当するものをstructとclassどちらで作るのかはそれもケース次第
  • つまるところ、
    • stateをvalue-typeで一気に表現することは理論的には美しいが、現実問題プログラムが消費するリソースとの問題が出てくる。
    • 発展を考えると、そこを固定した変数にしていると限界が生じてしまう。
    • 全てにトレードオフがある。
    • 完璧なものはない。
    • 何を失っているかを理解して選択をすること。
    • 雰囲気的にデファクトスタンダードに見えるものを必ず使う必要もない。
    • それで無駄に消耗するケースもあり得る。
      • その逆をとって消耗するケースも然り。
  • 余談を言えば、MVVMとかいろんな呼び方をされるアーキテクチャがあるが、どれも結局はMVCの分解をちょっと調節もしくは細かめにしたに過ぎない

Discussion