- iOS/Androidアプリにおける状態管理の複雑さにリポジトリパターンを拡張して立ち向かう(1/3 考え方編)
- iOS/Androidアプリにおける状態管理の複雑さにリポジトリパターンを拡張して立ち向かう(2/3 実装方針編) ← いまここ
- iOS/Androidアプリにおける状態管理の複雑さにリポジトリパターンを拡張して立ち向かう(3/3 ライブラリ使い方編)
前置き
前回「iOS/Androidアプリにおける状態管理の複雑さにリポジトリパターンを拡張して立ち向かう(1/3 考え方編)」という記事を書きました。
今回はその考え方をどうやって実際のコードに落とし込んでいくのかを整理しながら紹介するとともに、実際にSwift/Kotlin/Dartそれぞれ向けにリファレンスライブラリを作成したので合わせて紹介します。
記事内のコードはKotlinとSwiftの2つで記載します。
作成したリファレンスライブラリの紹介
前回・今回の記事の思想を元に実装したライブラリをKotlin版、Swift版、Dart版にわけて作成しました。
いずれもApache License 2.0
にて提供しています。
上記ライブラリ群の提供する機能やインターフェースはほとんど同一です。
このことからも記事でお話する内容は特定の言語やフレームワークに依存するものではないことがわかるかと思います。
リファレンスとはいえ、Kotlin版については私が担当する数十万人が利用するAndroidアプリで実際に運用しているライブラリでもあります。
最初に申し上げておくと弊社プロダクトに必要な要件向けにライブラリまで落とし込んでみた一例であり、あらゆるシーンに対応するものではありません。
しかしながら、API通信とアプリ内キャッシングを行う一般的なアプリのユースケースでは十分だと考えています。
細かい思想や実装方針はいいからライブラリの使い方と使い勝手が知りたいんだと言う方は3/3 ライブラリ使い方編記事を御覧ください。
複雑さの軽減に必要な要素
前回の記事にてデータの取得先の抽象化や通知の仕組みが状態管理の複雑さの軽減につながるという話をしました。
ここで整理した内容を再度書き出してみます。
- データの状態を表現できる構造が存在すること
- データに変更があった場合は教えてくれる仕組みを用意する(Observerパターンの概念)
- 使う側からキャッシュかAPIかなどどこから取ってきているかを意識させず整合性の取れた値を返す(Repositoryパターンの概念)
- 出来る限り早く値を返却する
- 使う側でデータを変数などで保持せず、取得先を常に1箇所に絞る(Single Source of Truthの概念)
最終的なインターフェース
データ取得のための最終的な出力としては以下のような継続的に変更を受け取れる形がよさそうだという話も前回しました。
今回はこの形を吐き出すコードを実装を目指します。
interface MyRepository {
fun followUser(): Flow<LoadingState<User>>
}
protocol MyRepository {
func followUser() -> AnyPublisher<LoadingState<User>, Never>
}
※今回はLoadingState自体がErrorの情報を持ちうるため、AnyPublisherの第2ジェネリクスはNeverを指定しています。
1. データの「状態」を扱う
まずは整理した要素の「1. データの状態を表現できる構造が存在すること」をコードで表現してみます。
これは前回の記事に記載した通りですが、データの有無とは別にデータの状態を取り扱えるようにしておいたほうが良いです。
具体的には「取得中状態(Loading)・完了状態(Completed)・エラー状態(Error)」の3つあればモバイルアプリの要件としては十分だと思います。
以下のようにデータの状態とデータの実体を組み合わせたLoadingState<T>
というデータの箱を考えてみます。
sealed interface LoadingState<out T> {
data class Loading<out T>(val content: T?) : LoadingState<T>
data class Completed<out T>(val content: T) : LoadingState<T>
data class Error<out T>(val exception: Exception) : LoadingState<T>
}
enum LoadingState<T> {
case loading(content: T?)
case completed(content: T)
case error(error: Error)
}
作成したライブラリの該当コードを次のとおりです。(Kotlin版 / Swift版)
2. Observerパターンの構築
次に「2. データに変更があった場合は教えてくれる仕組みを用意する」について実装を考えます。
データを保持して変化したときに通知できる仕組み、いわゆるObserverパターンを構築します。
KotlinではCoroutinesに含まれるStateFlowを、SwiftではCombineに含まれるCurrentValueSubjectを用いることでこの仕組みは簡単に実現できます。
RxJava,RxSwiftではBehaviorSubjectと呼ばれているものがこれに相当します。
val observableData = MutableStateFlow<String>("initial_data")
observableData.collect { data ->
println("Data is changed: $data")
}
observableData.emit("new_data")
let observableData = CurrentValueSubject<String, Never>("initial_data")
observableData.sink { data in
print("Data is changed: \(data)")
}
observableData.send("new_data")
色々必要なコードは省いていますが、Swift・Kotlinともにこのようなコードで通知を記述するとデータを購読したタイミングで"initial_data"
が通知され、emit
,send
した時点で"new_data"
を購読者に伝えることが出来ます。
通知の仕組みとデータの保存処理の分離
データを通知する仕組み自体は比較的簡単ですが、今回実装したライブラリのコードではデータの実体をこのような仕組みでは保持していません。
これはデータの実体の保持の仕組みをStateFlow
やCurrentValueSubject
の振る舞いにロックインさせてしまうことを避けるためです。
StateFlow
やCurrentValueSubject
に直接データをもたせるのは直感的ですが、これではアプリのキル時にデータが揮発してしまいます。
ではこの通知の仕組みを維持したままデータを永続化したい場合はどうすればよいでしょう?
すでにAndroidのSharedPreferences
やiOSのCoreData
を使っていた場合は?これから使いたい場合は?
それぞれのアプリの性質や歴史によってデータをどういった形式でどこに保存するかは様々です。
ゆえに今回は汎用的な用途を前提どこに保存するかは問わずに、データの変更通知の仕組みと分けて考えてみます。
データの実体と状態を別に管理する
そこで着目するのが先の項目で話題に上げた「データの状態」です。
データの状態は一時的なものでアプリキル後まで永続化する必要は基本的にはありません。いかなる場合に揮発しても構わないはずです。
なのでこの要素を監視対象としてライブラリ内に組み込みStateFlow
やCurrentValueSubject
にセットします。
データの実体はどこに保存してもよく、状態の変化の通知に合わせてついでに取ってきて一緒に返すくらいの考え方です。
これを満たすために、まず以下のように状態だけを取り扱う箱(DataState
)を用意します。
エラー状態におけるのエラーの内容については基本的には揮発しても良いことが大半なので状態の一部として取り扱ってしまってよいでしょう。
sealed interface DataState {
class Fixed : DataState // 停止状態
class Loading : DataState // 取得中状態
class Error(val exception: Exception) : DataState // エラー状態
}
public enum DataState {
case fixed // 停止状態
case loading // 取得中状態
case error(rawError: Error) // エラー状態
}
これを内包するStateFlow
やCurrentValueSubject
をシングルトンクラスで維持しつつ、通知される状態の流れをmap
関数を使って変化させることで状態の変化に応じてデータを渡すことが出来るようになります。
作成したライブラリの該当コードを次のとおりです。(Kotlin版 / Swift版)
また、以下のコードはDataState
を通知のトリガーとして機能させつつキャッシュデータの実体と統合して前述したState<T>
として返却する一例です。(※解説用に実際のコードより簡略化しています)
val dataStateObserver = MutableStateFlow<DataState>(DataState.Fixed()) // 状態を監視できるObserver
fun observeData(): Flow<LoadingState<RawData>> {
return dataStateObserver
.map { dataState ->
val data = loadCacheData() // 状態が変化した際に対象のデータの実体をキャッシュから取得する
convertToState(data, dataState) // 最終的な出力形式である`State<T>`の形式に加工する
}
}
let dataStateObserver = CurrentValueSubject<DataState, Never>(.fixed()) // 状態を監視できるObserver
func observeData() -> AnyPublisher<LoadingState<RawData>, Never> {
dataStateObserver
.map { dataState in
let data = loadCacheData() // 状態が変化した際に対象のデータの実体をキャッシュから取得する
return convertToState(data, dataState) // 最終的な出力形式である`State<T>`の形式に加工する
}
.eraseToAnyPublisher()
}
このようなアプローチを取ることで、データの保存先や保存形式に左右されずにデータ通知の仕組みを構築することが可能です。
作成したライブラリの該当コードを次のとおりです。(Kotlin版 / Swift版)
この手法を取る上で一つ注意すべきなのは、状態が変化したときしか通知されないためデータの実体を直接書き換えてしまうと通知されません。
データの更新を行う際は実体と状態を必ずセットで更新して上げる必要があります。
これらは手動で行おうとすると大変ですが、この2つの処理をセットにして共通化した仕組みを用意して必ずそこから更新処理を行うように徹底することでこの問題に対処することは可能です。作成したライブラリでもそのような仕組みを用意しています。
3&4, データ取得先の抽象化
次に考えてみるのは「3. 使う側からキャッシュかAPIかなどどこから取ってきているかを意識させず整合性の取れた値を返す」と「4. 出来る限り早く値を返却する」についてです。
これについては実際にコードに落とし込むにあたりどういった処理が必要なのか簡単にフローチャートを書いてみます。
上記の手順を踏んでデータを返すことで、ローカルキャッシュとリモートからのデータの取得を抽象化したデータを取り出すことが可能です。
さらにデータを要求者に返す仕組みを前述したObserverパターンと組み合わせることで、要求時のデータのみならず将来的な更新や状態の変化まで監視することが出来ます。
これを限界まで簡略化した上で愚直にコードで表現してみるとこんな感じかと思います。
データの入出力や状態の入出力については外部へ切り出して抽象化処理の共通部分のみ表現しています。
fun process() {
val currentState = loadState() // データの状態を取り出す
if (currentState is DataState.Loading) return // 状態がLoadingなら何もしない
val cacheData = loadDataFromCache() // キャッシュデータの実体を取り出す
if (!needRefresh(cacheData)) return // キャッシュが有効なら何もしない
saveState(DataState.Loading()) // データの状態をLoadingに変える (データが通知される)
val response = fetchDataFromOrigin() // リモートから最新データを取得する
if (response.isSuccess) {
// データの取得に成功
saveDataToCache(response.data) // 取得したデータをキャッシュへ保存する
saveState(DataState.Fixed()) //データの状態をFixedに変える (データが通知される)
} else {
// データの取得に失敗
saveState(DataState.Error()) //データの状態をErrorに変える (データが通知される)
}
}
func process() {
let currentState = loadState() // データの状態を取り出す
if case .loading = currentState { return } // 状態がLoadingなら何もしない
let cacheData = loadDataFromCache() // キャッシュデータの実体を取り出す
if !needRefresh(cacheData) { return } // キャッシュが有効なら何もしない
saveState(.loading) // データの状態をLoadingに変える (データが通知される)
let response = fetchDataFromOrigin() // リモートから最新データを取得する
if response.isSuccess {
// データの取得に成功
saveDataToCache(response.data) // 取得したデータをキャッシュへ保存する
saveState(.fixed) //データの状態をFixedに変える (データが通知される)
} else {
// データの取得に失敗
saveState(.error) //データの状態をErrorに変える (データが通知される)
}
}
作成したライブラリの該当コードを次のとおりです。(Kotlin版 / Swift版)
実際には非同期処理やパラメータによる制御処理を多数入れているので上記で示したコードとはかなり異なります。
5. Single Source of Truthを守る
最後に「5. 使う側でデータを変数などで保持せず、取得先を常に1箇所に絞る」について考えてみます。
とはいえ、これに関してはライブラリ等でどうにかすることは出来ないので利用者に頑張ってルールを守ってもらうしかありません。
今回紹介している仕組みは前回の記事からお話している通り、擬似的なSingle Source of Truthを実現していますが
あくまでこの仕組みを通した場合に限定されるので、仕組みを介さずにデータを取得・更新した場合やこの仕組みの外側でデータを保持してしまった場合はこの前提は崩れてしまいます。
この仕組みを採用する以上は、「4. 出来る限り早く値を返却する」に基づくため、この仕組みの外側でデータをメンバー変数などで保持する必要はなく、常にこの仕組みを経由して取得・更新を行うことが十分可能な作りになっているはずです。
共通化出来ない部分を切り分ける
ここまでデータの保存先や保存形式に影響を受けない部分の共通化処理を考えてきました。
ここからは前回の記事でもお話しましたが、共通化出来ない部分についてもう一度書き出してみます。
- データの状態を保持する機構
- キャッシュからの取得処理
- キャッシュへの保存処理
- API等のプライマリデータからの取得処理
- キャッシュが有効か否かの判断処理(時間、個数、etc..)
これらに関してはデータによって処理が変わる部分なので、それぞれの実装時に処理を記述を変えられるように枠組みだけを提供してみます。
具体的には以下のようなインターフェースの提供を検討します。(※解説用に実際のコードより簡略化しています)
interface StoreFlowableFactory<DATA> {
fun loadDataFromCache(): DATA? // ローカルキャッシュからの取得処理
fun saveDataToCache(newData: DATA?) // ローカルキャッシュへの保存処理
suspend fun fetchDataFromOrigin(): DATA // リモートデータからの取得処理
fun needRefresh(cachedData: DATA): Boolean // ローカルキャッシュが有効かの判定処理
}
protocol StoreFlowableFactory {
associatedtype DATA
func loadDataFromCache() -> DATA? // ローカルキャッシュからの取得処理
func saveDataToCache(newData: DATA?) // ローカルキャッシュへの保存処理
func fetchDataFromOrigin() -> AnyPublisher<DATA, Error> // リモートデータからの取得処理
func needRefresh(cachedData: DATA) -> Bool // ローカルキャッシュが有効かの判定処理
}
上記のインターフェースに準じた実装をデータごとに用意してあげることで保存先や保存形式を実装した部分に任せつつ、その他の抽象化のための共通処理をまとめることが出来ます。
作成したライブラリの該当コードを次のとおりです。(Kotlin版 / Swift版)
また、これに加えて前述したデータの状態のみを取り扱う箱DataState
を通知の仕組みに乗せたシングルトンクラスも必要になります。
こちらについても共通化して隠蔽してしまうと柔軟性が損なわれるので今回のライブラリでは切り出していますが場合によっては共通化してしまっても良いかもしれません。
abstract class FlowableDataStateManager {
val dataState = MutableStateFlow<DataState>(DataState.Fixed())
}
open class FlowableDataStateManager {
let dataState = CurrentValueSubject<DataState, Never>(.fixed())
}
これらを継承して作成したクラスをシングルトンで保持する部分は隠蔽せず、実装側に任せます。
作成したライブラリの該当コードを次のとおりです。(Kotlin版 / Swift版)
複雑さを軽減する5つの要素をまとめる
ここまでで、以下の5つの要素を実装に落とし込むための実装のパーツを紹介してきました。
今回はデータ通知の仕組みにKotlin Coroutines FlowやCombine Frameworkを使って表現しましたが、RxJavaやRxSwift、Stream APIやReactiveSwiftなど他の近しい技術を使っても実現できるはずです。
また、データの取得先を抽象化する処理についても解説したフローチャートのような分岐処理を記述することはさほど難しくはないと思います。
これらのパーツを組み合わせつつ、パラメータによる細かい調整を可能にしつつ共通化出来る部分を整えたのが今回紹介したStoreFlowable
となります。
ライブラリ内では様々なパターンを考慮して抽象化している部分が多々ありますが、特定のプロダクトに特化すればもっと簡易な仕組みを自作するだけでも十分機能すると思います。
次回記事に続く
今回の記事では考え方をコードに表現するにあたっての実装のキモとなる部分を作成したライブラリを元に紹介しました。
次回はライブラリの具体的な使い方について解説してみます。
次回記事: iOS/Androidアプリにおける状態管理の複雑さにリポジトリパターンを拡張して立ち向かう(3/3 ライブラリ使い方編)