はじめに
皆さんはアプリ開発において、SSOTを意識した開発ができていますか?
「自分は間違いなくできてるよ」
そう感じる方へ、開発業務の中で以下のような経験をされたことはないでしょうか。
- ストレージ内のデータとメモリ上の変数を個別に定義してしまい、それらの整合性を保つためわざわざ手動同期している
- グローバルな共有データと画面固有のStateを二重に管理してしまい、実質的に同じデータが二箇所に存在している
- ファイルパスとそのファイルの内容を個別にStateで管理してしまい、パスの変更と内容の読み込みを連動させるための複雑なライフサイクル処理が発生している
実は、私がエンジニアとして経験した現場において、こういった問題について考慮されていた現場はたった1社のみと少なく、多くの現場ではこの課題を
- 運用(この画面から遷移する際に同期処理をかくetc...)
- エンジニアの注意(コード見れば流石にわかるよね?)
で解決しようとしていました。
しかし、そういった間違った実装が生まれやすい環境において、ヒューマンエラーが起こり、テストフェーズでのバグ報告や、リリース後の障害として報告されるのを幾度となく目にしてきました。
「それでは、実際にどのように解決するのがベストなのか?」
その一つの解として、Swiftで使用されるTCA(The Composable Architecture)の思想が非常に参考になります。
「TCAの開発元であるPoint-Freeは、これらの問題を明確にSSOTが遵守できていない設計上の不備と捉え、その危うさについて次のように指摘しています。」
"Manual synchronization is inherently buggy. It is easy to forget to update one branch of state when another changes, or forget to persist a change to an external system."
(日本語訳例)
「手動による同期は、本質的にバグを誘発するものです。ある箇所の状態が変わった際にもう一方の更新を忘れたり、あるいは外部システムへの保存を忘れたりといったミスが容易に起こり得ます。」
— Point-Free Episode #276: Shared State: The Problem
本記事では、開発においてSSOTを意識することを改めて見つめ直し、TCAが提唱するAPIがどのように『仕組み』で不整合を防いでいるのか、その優れたポイントを今一度考えてみたいと思います。
SSOTのアンチパターンと理想形
前述した問題について、アンチパターン、理想形を実際のソースコードで見ていきたいと思います。
【パターン1】内部データの依存関係:関連データの「二重持ち」
アンチパターン
filePath と fileContent をそれぞれ独立した変数として定義し、それらを「同期させるロジック」を Reducer の各所に散らばらせているケース。
「Aを変えたらBも変える」というルールを全エンジニアが覚えておくという前提の上に成り立つため、バグが気づかぬうちに混在してしまう。
@Reducer
struct EditorFeature {
@ObservableState
struct State: Equatable {
var filePath: URL?
var fileContent: String = "" // 本来はfilePathから導出されるべきデータ
}
enum Action {
case fileSelected(URL)
case textChanged(String)
}
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case let .fileSelected(url):
state.filePath = url
// ⚠️ ここで「古い内容を消す」のを忘れたり、読み込みに失敗した際の考慮が漏れる
return .run { send in /* ファイル読み込み処理... */ }
case let .textChanged(text):
state.fileContent = text // filePathとの整合性は保証されない
return .none
}
}
}
}
理想形
fileContent を @Shared(.fileStorage) として定義し、変数とファイルストレージを直接紐づけているケース。
状態の更新がそのままファイルへの書き込みとなるため、手動での読み書きロジックや、パスとコンテンツの不整合を防ぐための不要な状態管理を完全に排除できる。
@Reducer
struct EditorFeature {
@ObservableState
struct State: Equatable {
@Shared(.fileStorage(URL(fileURLWithPath: "/path/to/document.txt"))) var fileContent: String = ""
}
enum Action {
case textChanged(String)
}
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case let .textChanged(text):
state.fileContent = text // メモリの更新と同時にファイルシステムへ同期される
return .none
}
}
}
}
動的なパスへの対応について
上記のコードは固定パスの例ですが、実際のアプリではユーザーが動的にファイルを選択するケースも多いでしょう。その場合は、パスが確定した時点で @Shared を初期化し直すアプローチが有効です。
// ユーザーがファイルを選択した際に、動的にSharedを生成して渡す
case let .fileSelected(url):
state.fileContentRef = Shared(.fileStorage(url))
return .none
この場合、fileContentRef は @Shared<String>? 型として定義し、選択されたファイルパスを元に毎回新たな @Shared を生成することで、パスとコンテンツの結びつきを常に一致させることができます。
【パターン2】永続化の境界線:ストレージとメモリの「乖離」
アンチパターン
外部ストレージを「単なる保存先」と捉え、メモリ上の State との同期を「保存ボタン」や「画面遷移時」などの特定のタイミングに頼っているケース。
新たな画面遷移イベントが実装された際に、同期処理を記載し忘れると、必然的にバグが起こりうる。
@Reducer
struct SettingsFeature {
@ObservableState
struct State: Equatable {
var isNotificationEnabled: Bool = false // メモリ上の「真実」
}
enum Action {
case toggleTapped
case closeButtonTapped
}
@Dependency(\.settingsClient) var settingsClient
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case .toggleTapped:
state.isNotificationEnabled.toggle()
return .none // ⚠️ この時点ではストレージに保存されていない!
case .closeButtonTapped:
// ⚠️ 画面を閉じる時に一括保存しようとするが、
// アプリがクラッシュしたり強制終了されると変更が消える
return .run { [isEnabled = state.isNotificationEnabled] _ in
try await settingsClient.save(isEnabled)
}
}
}
}
}
理想形
ストレージのデータを @Shared(.appStorage) として定義し、変数の読み書きを自動的に User Defaults と同期させているケース。
常にストレージが真実の源泉(SSOT)となるように設計されているため、手動での保存処理や購読処理といったボイラープレートを完全に排除できる。
@Reducer
struct SettingsFeature {
@ObservableState
struct State: Equatable {
@Shared(.appStorage("isNotificationEnabled")) var isNotificationEnabled: Bool = false
}
enum Action {
case toggleTapped
}
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case .toggleTapped:
state.isNotificationEnabled.toggle() // 自動的に User Defaults に保存される
return .none
}
}
}
}
【パターン3】スコープの壁:フィーチャー間の「コピー」
アンチパターン
親フィーチャーから子へデータを渡す際、参照ではなく「値のコピー」を渡し、変更があったら「親に通知して親が他の子を更新する」という仲介ロジック(親Reducerが子フィーチャー間のデータ同期を担うロジック)を組んでいるケース。
画面が増えるたびに、親の Reducer に「Aが変わったらBとCも更新する」というボイラープレートが増え続ける。本来同義であるはずの変数がそれぞれ別々の状態を持っているため、画面ごとの違う値であるケースが頻繁に発生。
@Reducer
struct AppFeature {
@ObservableState
struct State {
var currentUser: User
var profile: ProfileFeature.State
var settings: SettingsFeature.State
}
enum Action {
case profile(ProfileFeature.Action)
case settings(SettingsFeature.Action)
}
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case .profile(.userUpdated(let newUser)):
state.currentUser = newUser
// ⚠️ 他の子(Settings)にも「手動で」反映させなければならない
state.settings.user = newUser
return .none
// ...
}
}
}
}
理想形
子フィーチャーにデータを渡す際、コピーではなく @Shared を用いて「同じデータの実体」への参照を共有しているケース。
ある画面で値が更新されれば自動で全体に反映されるため、親が仲介して各子フィーチャーを同期させる不要なボイラープレートを完全に排除できる。
@Reducer
struct AppFeature {
@ObservableState
struct State {
@Shared var currentUser: User
var profile: ProfileFeature.State
var settings: SettingsFeature.State
}
enum Action {
case profile(ProfileFeature.Action)
case settings(SettingsFeature.Action)
}
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case .profile(.userUpdated):
// 仲介ロジックは不要。
// @Shared によって自動的に他のフィーチャーへ変更が同期されるため、
// 手動で状態を同期させる必要はない。
return .none
// ...
}
}
}
}
まとめ
SSOTの本質は「どこが正しいデータを持つか、ひとつに決める」ことです。TCAのアーキテクチャはStateをひとつの木構造に集約するため、アプリ内のSSOTとしては優秀に機能します。しかし アプリの外側(ファイルシステム、データベース、他のプロセス)との境界では、SSOTをどちらに置くかを意識的に決めなければなりません。
不具合に出会ったとき、まず疑うべきはロジックの細部だけではありません。そのデータの真実がどこにあるか、複数のコピーを持っていないか、同期を人手に頼りすぎていないかを確認することが重要です。パターン1・2はその同期をReducerのロジックとして書く方法であり、@Shared はさらに一歩進んで、「SSOTがどこにあるか」をプロパティの型注釈として宣言する方法を提供します。
- 外部ストレージを真実の源泉とするなら、Stateはそのリフレクションに過ぎません
- StateとSSOTの同期が崩れる可能性のある全アクションを洗い出し、同期を対称的に設計します
-
@Sharedが使える場面では、SSOTの宣言をロジックから型へ格上げします
コードは複雑になるほど「どこが本物のデータか」が曖昧になります。SSOTの宣言はその曖昧さへの処方箋です。