- iOS/Androidアプリにおける状態管理の複雑さにリポジトリパターンを拡張して立ち向かう(1/3 考え方編)
- iOS/Androidアプリにおける状態管理の複雑さにリポジトリパターンを拡張して立ち向かう(2/3 実装方針編)
- iOS/Androidアプリにおける状態管理の複雑さにリポジトリパターンを拡張して立ち向かう(3/3 ライブラリ使い方編) ← いまここ
前置き
前回、前々回とモバイルアプリにおける状態管理の一つの考え方と、コードに落とし込むための実装方針、そしてなぜこのライブラリを作成したのかについてお話してきました。
今回は純粋に作成したStoreFlowable
ライブラリでどういった機能を提供しているかと、その使い方について紹介したいと思います。
Dart版も同等の機能を提供していますが、まだドキュメントやサンプルコード、テストなどが準備できていないので本記事ではKotlin版とSwift版を元に紹介します。
サンプルコード
それぞれのリポジトリにはサンプルコードが含まれています。Kotlin版 / Swift版
下記の使い方と合わせて見ていただくと良いかと思います。
導入
Kotlin版はMavenCentralで配信しているので、Gradleの依存に以下を追加して下さい。
dependencies {
implementation("com.kazakago.storeflowable:storeflowable:x.x.x")
}
Swift版はSwift Package Managerでの導入が可能です。
dependencies: [
.package(url: "https://github.com/KazaKago/StoreFlowable.swift.git", from: "x.x.x"),
],
基本的な使い方
1. FlowableDataStateManager
を継承したシングルトンクラスを作成する
まず、FlowableDataStateManager<KEY>
継承クラスをシングルトンクラスとして作成して下さい。
このクラスが前回の記事でも解説している、状態を通知できる仕組みを内包しています。
object UserStateManager : FlowableDataStateManager<UserId>()
class UserStateManager: FlowableDataStateManager<UserId> {
static let shared = UserStateManager()
private override init() {}
}
2. StoreFlowableFactory<KEY, DATA>
を実装したクラスを作る
まず、リモートからデータを取得するApiクラスとローカルキャッシュへのデータ入出力Cacheクラスを用意してください。
ここでは便宜上UserApi
クラスとUserCache
クラスとします。
次にStoreFlowableFactory<KEY, DATA>
の実装クラスを作ります。
このクラスがデータごとに処理が変わる、共通化部分としてまとめることが出来ない部分を記述したクラスとなります。
以下に例を示します。
// StoreFlowableFactoryを実装したクラスを作成してください。
// 同一のデータが複数存在する場合はジェネリクスのKEYにその区別となる型を指定して下さい。データが一つの場合はUnitでOKです。
// DATAジェネリクスには扱うデータの型を指定して下さい。
class UserFlowableFactory(userId: UserId) : StoreFlowableFactory<UserId, UserData> {
private val userApi = UserApi()
private val userCache = UserCache()
// データが複数存在する場合はその区別となるデータを渡して下さい。
override val key: UserId = userId
// 作成したFlowableStateManagerのシングルトンインスタンスを指定して下さい。
override val flowableDataStateManager: FlowableDataStateManager<UserId> = UserStateManager
// ローカルキャッシュからの取得処理を実装して下さい
override suspend fun loadDataFromCache(): UserData? {
return userCache.load(key)
}
// ローカルキャッシュへの保存処理を実装して下さい
override suspend fun saveDataToCache(data: UserData?) {
userCache.save(key, data)
}
// リモートからの取得処理を実装して下さい
override suspend fun fetchDataFromOrigin(): UserData {
return userApi.fetch(key)
}
// キャッシュが有効かどうかを判断する処理を実装して下さい。キャッシュが期限切れする必要がなければ常にfalseを返してしまってもOKです。
override suspend fun needRefresh(cachedData: UserData): Boolean {
return cachedData.isExpired()
}
}
// StoreFlowableFactoryを実装したクラスを作成してください。
// 同一のデータが複数存在する場合はassosiatedTypeのKEYにその区別となる型を指定して下さい。データが一つの場合はUnitHashを指定して下さい。
// DATA assosiatedTypeには扱うデータの型を指定して下さい。
struct UserFlowableFactory : StoreFlowableFactory {
typealias KEY = UserId
typealias DATA = UserData
private let userApi = UserApi()
private let userCache = UserCache()
init(userId: UserId) {
key = userId
}
// データが複数存在する場合はその区別となるデータを渡して下さい。
let key: UserId
// 作成したFlowableStateManagerのシングルトンインスタンスを指定して下さい。
let flowableDataStateManager: FlowableDataStateManager<UserId> = UserStateManager.shared
// ローカルキャッシュからの取得処理を実装して下さい
func loadDataFromCache() -> AnyPublisher<UserData?, Never> {
userCache.load(userId: key)
}
// ローカルキャッシュへの保存処理を実装して下さい
func saveDataToCache(newData: UserData?) -> AnyPublisher<Void, Never> {
userCache.save(userId: key, data: newData)
}
// リモートからの取得処理を実装して下さい
func fetchDataFromOrigin() -> AnyPublisher<UserData, Error> {
userApi.fetch(userId: key)
}
// キャッシュが有効かどうかを判断する処理を実装して下さい。キャッシュが期限切れする必要がなければ常にfalseを返してしまってもOKです。
func needRefresh(cachedData: UserData) -> AnyPublisher<Bool, Never> {
cachedData.isExpired()
}
}
<KEY>
の利用するシーンとしては、例えばGET /users/{user_id}/repos
のようなREST APIがある場合などに、UserIdごとにキャッシュを保持しておきたいケースなどに使用して下さい。
このような場合分けが不要な場合は<KEY>
にKotlin版ではUnit
を指定して下さい。SwiftではUnitHash
というstructを作成してあるのでそちらを指定して下さい。
3. Repositoryクラスを作成する
ここまででStoreFlowable
を利用するための準備は整っているので、Repositoryパターンを体現したクラスを作成します。
2.で作成したクラスのインスタンスに生えているcreate()
メソッドからStoreFlowableクラスを作成できます。
このクラスが本ライブラリの本体となり、データの監視や入出力を司るメソッドが生えています。
class UserRepository {
fun followUserData(userId: UserId): Flow<LoadingState<UserData>> {
val userFlowable: StoreFlowable<UserId, UserData> = UserFlowableFactory(userId).create()
return userFlowable.publish()
}
suspend fun updateUserData(userData: UserData) {
val userFlowable: StoreFlowable<UserId, UserData> = UserFlowableFactory(userData.userId).create()
userFlowable.update(userData)
}
}
struct UserRepository {
func followUserData(userId: UserId) -> AnyPublisher<LoadingState<UserData>, Never> {
let userFlowable: AnyStoreFlowable<UserId, UserData> = UserFlowableFactory(userId: userId).create()
return userFlowable.publish()
}
func updateUserData(userData: UserData) -> AnyPublisher<Void, Never> {
let userFlowable: AnyStoreFlowable<UserId, UserData> = UserFlowableFactory(userId: userId).create()
return userFlowable.update(newData: userData)
}
}
データの入出力を行う際には必ずこのStoreFlowableを経由して行って下さい。
データの実体を直接書き換えてしまうと変更通知が行われません。
データの監視を行いたい場合はpublish()
を用いて下さい。
このメソッドを通すことで考え方編で当初目指していたRepositoryクラスのインターフェースを提供することが出来ます。
4. 作成したRepositoryクラスを利用する
作成したリポジトリクラスを利用するには利用側(ActivityやViewController、ViewModelなど)で監視するメソッドを実行します。
Kotlin版であればFlowによる通知なのでcollect {}
, Swift版であればCombineによる通知なので sink {}
を利用してデータ監視を開始できます。
また、返却されるLoadingStateクラスはdoActionメソッドで状態の分岐が可能です。
**データの状態(取得中・取得完了・取得エラー)**の分岐を表示上網羅させれば、データがどのような状態になっても概ねカバーできるはずです。
private fun subscribe(userId: UserId) = viewModelScope.launch {
userRepository.followUserData(userId).collect { loadingState ->
loadingState.doAction(
onLoading = { userData: UserData? ->
... // 取得中
},
onCompleted = { userData: UserData, _, _ ->
... // 取得完了
},
onError = { exception: Exception ->
... // 取得エラー
}
)
}
}
private func subscribe(userId: UserId) {
userRepository.followUserData(userId: userId)
.receive(on: DispatchQueue.main)
.sink { loadingState in
loadingState.doAction(
onLoading: { (content: UserData?) in
... // 取得中
},
onCompleted: { (content: UserData, _, _) in
... // 取得完了
},
onError: { (error: Error) in
... // 取得エラー
}
)
}
.store(in: &cancellableSet)
}
ここまでが基本的な使い方となります。
適切に使えば表示の不整合を解消しつつ、リモートとキャッシュの抽象化が実現できているはずです。
その他の機能
LoadingState<T>
が不要な一度きりのデータ取得を行いたい場合
ここまでデータを監視して変化を受け取れることを前提にお話してきましたが、常にデータ監視が適切とは限りません。
その瞬間のデータが一度だけ必要で継続的な監視を必要としない場合はgetData()
あるいはrequiredData()
メソッドを使って下さい。
interface StoreFlowable<KEY, DATA> {
suspend fun getData(from: GettingFrom = GettingFrom.Both): DATA?
suspend fun requireData(from: GettingFrom = GettingFrom.Both): DATA
}
public extension StoreFlowable {
func getData(from: GettingFrom = .both) -> AnyPublisher<DATA?, Never>
func requireData(from: GettingFrom = .both) -> AnyPublisher<DATA, Error>
}
requiredData()
は有効なキャッシュが存在せず、リモートからのデータ取得にも失敗した場合は例外を投げます。
getData()
では例外の代わりにnull, nilを返します。
引数のGettingFromはどこからデータを取得するかを指定します。
デフォルトは両方からよしなに取得する.Both
ですが、キャッシュからのみ取得する.Cache
と、リモートからのみ取得する.Origin
を指定することも出来ます。
enum class GettingFrom {
Both,
Origin,
Cache,
}
画面上でデータの取得から表示を行う場合は基本的にはpublish()
によるデータ監視の仕組みを利用して下さい。
requiredData()
, getData()
についてはデータ監視と相性の悪い場合のみ使用して下さい。
データを強制的に更新する
通常の使い方ではキャッシュが無効にならない限りはリモートから新しいデータは取得しませんが、要件によっては監視を開始するタイミング(画面を開いたときなど)でデータの更新を強制的に行いたい場面もあると思います。
その場合はpublish()
メソッドのforceRefresh引数にtrueを指定して下さい。
interface StoreFlowable<KEY, DATA> {
fun publish(forceRefresh: Boolean = false): Flow<LoadingState<<DATA>>
}
public extension StoreFlowable {
func publish(forceRefresh: Bool = false) -> AnyPublisher<LoadingState<DATA>, Never>
}
また、監視開始のタイミングではなく任意のタイミングでデータの更新をしたい場合はrefresh()
を用いることも可能です。
引っ張って更新の機能などを提供する場合に利用して下さい。
interface StoreFlowable<KEY, DATA> {
suspend fun refresh()
}
public extension StoreFlowable {
func refresh() -> AnyPublisher<Void, Never>
}
キャッシュデータが有効か検証する
現時点で保持するデータが有効か検証したい場合はvalidate()
が利用できます。
キャッシュが無効であればリモートからの再取得処理が実行されます。キャッシュが有効であれば何もしません。
interface StoreFlowable<KEY, DATA> {
suspend fun validate()
}
public protocol StoreFlowable {
func validate() -> AnyPublisher<Void, Never>
}
キャッシュデータを更新する
なんらかの処理の都合でキャッシュデータを更新する必要がある場合はupdate()
メソッドが使用できます。
null, nilを指定することでキャッシュデータの削除も可能です。
このメソッドからキャッシュを更新することで、データの変更通知が発火しすべてのデータ監視者にデータの変更が反映されます。
interface StoreFlowable<KEY, DATA> {
suspend fun update(newData: DATA?)
}
public protocol StoreFlowable {
func update(newData: DATA?) -> AnyPublisher<Void, Never>
}
LoadingState<T>
関連オペレーター
LoadingState<T>
に内包されるデータをストリーム内で触りたい場合に便利なオペレータをいくつか用意しています。
Flow<LoadingState<T>>
、AnyPublisher<LoadingState<T>, Never>
の変換
Flow<LoadingState<T>>
、AnyPublisher<LoadingState<T>, Never>
のストリーム内でデータを別のデータに置き換えたい場合にはmapContent()
という関数を利用できます。
val flowState: Flow<LoadingState<Int>> = ...
val flowMergedState: Flow<LoadingState<String>> = flowState1.mapContent { value ->
value.toString()
}
let statePublisher: AnyPublisher<LoadingState<Int>, Never> = ..
let mergedStatePublisher: AnyPublisher<LoadingState<String>, Never> = statePublisher1.mapContent { value in
String(value)
}
Flow<LoadingState<T>>
、AnyPublisher<LoadingState<T>, Never>
の統合
複数のFlow<LoadingState<T>>>
、AnyPublisher<LoadingState<Int>, Never>
を統合したい場合は、Kotlinの場合はcombineState()
、Swiftの場合はzipState()
が利用できます。
val flowState1: Flow<LoadingState<Int>> = ...
val flowState2: Flow<LoadingState<Int>> = ...
val flowMergedState: Flow<LoadingState<Int>> = flowState1.combineState(flowState2) { value1, value2 ->
value1 + value2
}
let statePublisher1: AnyPublisher<LoadingState<Int>, Never> = ..
let statePublisher2: AnyPublisher<LoadingState<Int>, Never> = ..
let mergedStatePublisher: AnyPublisher<LoadingState<Int>, Never> = statePublisher1.zipState(statePublisher2) { value1, value2 in
value1 + value2
}
片方のステータスがLoadingやErrorだった場合は全体がLoadingやErrorとして扱われるのでご注意下さい。
状態の優先度は Error > Loading > Fixed となります。
ページネーションサポート
APIから取得したリモートのデータとローカルキャッシュのデータをうまくやりくりしないといけない一般的なユースケースの一つとしてページネーションがあります。
下記のようにリストの最下部に達したときに追加をAPIから読み込んで繋ぎ合わせるような仕組みです。
これに関してもRepositoryよりも外側でキャッシュを意識せずに利用するための追加クラスを提供しています。
1. PaginationStoreFlowableFactory
を実装する
この機能を使うにはStoreFlowableFactory
の代わりにPaginationStoreFlowableFactory
を実装したクラスを作成して下さい。
基本的には同じですがsaveNextDataToCache()
とfetchNextDataFromOrigin()
を追加で実装する必要があるという部分が違いとしてあります。以下に実装例を示します。
class UserListFlowableFactory : PaginationStoreFlowableFactory<Unit, List<UserData>> {
private val userListApi = UserListApi()
private val userListCache = UserListCache()
override val key: Unit = Unit
override val flowableDataStateManager: FlowableDataStateManager<Unit> = UserListStateManager
override suspend fun loadDataFromCache(): List<UserData>? {
return userListCache.load()
}
override suspend fun saveDataToCache(newData: List<UserData>?) {
userListCache.save(newData)
}
// 追加読み込みを行ったデータをキャッシュデータとどう繋ぎ合わせるかを定義して下さい
override suspend fun saveNextDataToCache(cachedData: List<UserData>, newData: List<UserData>) {
userListCache.save(cachedData + newData)
}
// 戻り値のFetched.nextKeyには次の追加読み込みで使用するパラメータを指定してください
// これ以上追加のデータが存在しないことがわかっている場合はnullを代入します
override suspend fun fetchDataFromOrigin(): Fetched<List<UserData>> {
val fetchedData = userListApi.fetch(null)
return Fetched(data = fetchedData, nextKey = fetchedData.nextToken)
}
// 追加読み込みを行う際のリモートからのデータの取得処理を記載して下さい。
// 戻り値のFetched.nextKeyには次の追加読み込みで使用するパラメータを指定してください
// これ以上追加のデータが存在しないことがわかっている場合はnullを代入します
override suspend fun fetchNextDataFromOrigin(nextKey: String): Fetched<List<GithubOrg>> {
val fetchedData = userListApi.fetch(nextKey)
return Fetched(data = fetchedData, nextKey = fetchedData.nextToken)
}
override suspend fun needRefresh(cachedData: List<UserData>): Boolean {
return cachedData.last().isExpired()
}
}
struct UserListFlowableFactory : PaginationStoreFlowableFactory {
typealias KEY = UnitHash
typealias DATA = [UserData]
private let userListApi = UserListApi()
private let userListCache = UserListCache()
let key: UnitHash = UnitHash()
let flowableDataStateManager: FlowableDataStateManager<UnitHash> = UserListStateManager.shared
func loadDataFromCache() -> AnyPublisher<[UserData]?, Never> {
userListCache.load()
}
func saveDataToCache(newData: [UserData]?) -> AnyPublisher<Void, Never> {
userListCache.save(data: newData)
}
// 追加読み込みを行ったデータをキャッシュデータとどう繋ぎ合わせるかを定義して下さい
func saveNextDataToCache(cachedData: [UserData], newData: [UserData]) -> AnyPublisher<Void, Never> {
userListCache.save(data: cachedData + newData)
}
// 戻り値のFetched.nextKeyには次の追加読み込みで使用するパラメータを指定してください
// これ以上追加のデータが存在しないことがわかっている場合はnullを代入します
func fetchDataFromOrigin() -> AnyPublisher<Fetched<[UserData]>, Error> {
userListApi.fetch(page: 1).map { data in
Fetched(data: data, nextKey: data.nextToken)
}.eraseToAnyPublisher()
}
// 追加読み込みを行う際のリモートからのデータの取得処理を記載して下さい。
// 戻り値のFetched.nextKeyには次の追加読み込みで使用するパラメータを指定してください
// これ以上追加のデータが存在しないことがわかっている場合はnullを代入します
func fetchNextDataFromOrigin(nextKey: String) -> AnyPublisher<Fetched<[UserData]>, Error> {
let page = ((cachedData?.count ?? 0) / 10 + 1)
return userListApi.fetch(page: page).map { data in
Fetched(data: data, nextKey: data.nextToken)
}.eraseToAnyPublisher()
}
func needRefresh(cachedData: [UserData]) -> AnyPublisher<Bool, Never> {
cachedData.last.isExpired()
}
}
このFactoryクラスから、create()
で本体となるPaginationStoreFlowable
クラスを作成することができます。
2. requestNextData()
で追加読み込みを行う
PaginationStoreFlowable
も通常のStoreFlowable
クラスと使い方にほとんど変わりはありませんが、追加読み込みを行うためのメソッドが追加されています。
interface PaginationStoreFlowable<KEY, DATA> {
suspend fun requestNextData(continueWhenError: Boolean = true)
}
public extension PaginationStoreFlowable {
func requestNextData(continueWhenError: Bool = true) -> AnyPublisher<Void, Never>
}
このメソッドを呼ぶことで自動的にデータがつなぎ合わされた状態で通知されます。
すでに読込中の状態で連続でこのメソッドが呼ばれても、前回解説したとおり多重にAPIがリクエストされてしまうことはありません。
ゆえに画面側で読込中かどうかを判定して、メソッドを呼ぶかどうかを分岐させる処理は不要です。
3. 追加読み込み状態をハンドリングする
PaginationStoreFlowable
で追加読み込みが発生した場合もLoadingStateで状態をハンドリングして追加読込中表示や追加読み込みエラー表示などを表示させることができます。
LoadingState.doAction
の通信完了を示すonCompleted
内の2つ目の引数のAdditionalLoadingState
を使うことで追加読み込みの状態を知ることができます。以下に例を示します。
val userFlowable = UserFlowableFactory(userId).create()
userFlowable.publish(userId).collect {
it.doAction(
onLoading = { contents: List<UserData>? ->
// 全体(初回)のデータ読込中
},
onCompleted = { contents: List<UserData>, next: AdditionalLoadingState, _ ->
// 全体(初回)のデータ読込完了
next.doAction(
onFixed = { canRequestAdditionalData: Boolean ->
// 追加読み込みに関する処理なし
},
onLoading = {
// 追加読み込み中
},
onError = { exception: Exception ->
// 追加読み込み失敗
}
)
},
onError = { exception: Exception ->
// 全体(初回)のデータ読込失敗
}
)
}
let userFlowable = UserFlowableFactory(userId: userId).create()
userFlowable.publish()
.receive(on: DispatchQueue.main)
.sink { state in
state.doAction(
onLoading: { (content: UserData?) in
// 全体(初回)のデータ読込中
},
onCompleted: { (content: UserData, next: AdditionalLoadingState, _) in
// 全体(初回)のデータ読込完了
next.doAction(
onFixed: { (canRequestAdditionalData: Bool) in
// 追加読み込みに関する処理なし
},
onLoading: {
// 追加読み込み中
},
onError: { (error: Error) in
// 追加読み込み失敗
}
)
},
onError: { (error: Error) in
// 全体(初回)のデータ読込失敗
}
)
}
.store(in: &cancellableSet)
繋ぎ合わされたデータは毎回まとめて通知されるため、実際に画面上にリスト表示する際にはRecyclerView
やUITableView
などに対応する差分更新機能(DiffUtilやUITableViewDiffableDataSource)などを用いて描画更新してあげて下さい。
一連の記事のまとめ
ここまで様々な視点から長々と解説とライブラリの紹介をしてきました。
もちろん今回紹介したライブラリを使って頂いても構いませんが、最も大事なのは状態管理に対する考え方であり、アプリ内におけるデータの不整合が出づらく、ローカルキャッシュに振り回されない仕組みが用意できていることです。
それが達成されてさえいればどのような仕組みのでも構わないと思います。
データ取得処理のインターフェースをなるべく早く安定させて、変わらないようにしておくこともとても大事です。
その際に技術的詳細を隠蔽したり、取得先を抽象化することを意識してみてください。
一年後〜数年後に、きっと状態管理が少しだけ楽になっているはずです!