【Kotlin】Fluxアーキテクチャで実装するAPI通信

## はじめに
これまでのAndroid開発ではMVVMによる実装経験のみでしたが、インターンでFluxというアーキテクチャに触れました。Fluxの単方向データフローによる状態管理に興味が湧き、インターン時の記憶とさまざまな記事を参考に、自分で実装してみました。

この記事はFluxの説明ではなく、FluxによるAPI通信の実装にフォーカスを当てたものになります。試行錯誤しながらの実装なので間違いを含んでいる可能性もありますし、Fluxによる導入実績を増やさないことには自身での評価もし難いです。

あくまで自身のアウトプットに留まります。

## サンプル
今回は、Fluxアーキテクチャの実装部分にフォーカスを当て、[OpenWeatherMapAPI](https://openweathermap.org/current)によって東京の現在の天気を表示するというシンプルなアプリを実装しました。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3889377/23399bea-d74b-26b0-f74a-aa61590256e0.gif" width="225">

全体のソースコードはこちら↓

https://github.com/tera330/FluxSample

## Fluxの概要
Meta（旧Facebook）が提唱したアーキテクチャです。ユーザーアクションによりViewから発火されたActionをDispatcherが受け取り、Storeに通知します。ViewはStoreを購読し、その状態に応じてレンダリングします。この単方向データフローによりアプリケーションの状態変化の挙動を把握しやすくなるという利点があります。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3889377/a32821f7-78af-ab96-12f7-8a1071065271.png)

https://facebookarchive.github.io/flux/docs/in-depth-overview

その他参考にした記事
https://qiita.com/knhr__/items/5fec7571dab80e2dcd92)

## 実装

![sample.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3889377/3c927ec8-a4f1-c245-825d-9b8c98c473ff.png)

全てのクラスを含んではいませんが、基本的に上記のクラス図の通りに実装していきます。
ここからのソースコードはFluxの構成要素や、その呼び出し部分のコードを記載し、その他具体的なAPI通信部分などの実装は省きたいと思います。

### Action/ActionCreator
```Action.kt
interface Action {
    val payload: Any?
}
```
dispatcherを介してStoreに伝達されるActionのインターフェースを定義します。Actionが持つデータ部分としてpayloadプロパティを持ちます。

```FetchWeatherActionCreator.kt
class FetchWeatherActionCreator
    @Inject
    constructor(
        private val dispatcher: Dispatcher,
        private val repository: FetchWeatherRepository,
    ) {
        suspend fun fetchWeatherByCityName(cityName: String) {
            dispatcher.dispatch(FetchWeatherAction.Loading)
            try {
                val response =
                    repository
                        .fetchWeatherByCityName(cityName)
                        ?.weather
                        ?.get(0)
                        ?.description
                val weather = response.toString()
                dispatcher.dispatch(FetchWeatherAction.Success(weather))
            } catch (e: Exception) {
                dispatcher.dispatch(FetchWeatherAction.Failure("Failed to fetch weather."))
            }
        }
    }

sealed class FetchWeatherAction : Action {
    data object Initial : FetchWeatherAction() {
        override val payload: Any? = null
    }

    data object Loading : FetchWeatherAction() {
        override val payload: Any? = null
    }

    data class Success(
        override val payload: String,
    ) : FetchWeatherAction()

    data class Failure(
        override val payload: String,
    ) : FetchWeatherAction()
}
```

FetchWeatherActionは４つの状態を持ちます。API通信前のInitial、通信開始からレスポンス待ちまでのLoading,レスポンス結果のSuccessとFailureです。今回ペイロードはUIとして表示するテキストを扱うため、SuccessとFailureをStringにしています。

FetchWeatherActionCreatorでは、後述するDispatcherのdispatchメソッドを使用してActionをStoreに伝達します。

### Dispatcher

```Dispatcher.kt
typealias DispatchToken = String

private const val _prefix = "ID_"

@Singleton
class Dispatcher
    @Inject
    constructor() {
        private val _callbacks = mutableMapOf<DispatchToken, (Action) -> Unit>()
        private var _lastID = 1

        // payloadがディスパッチされると登録されたコールバックが呼ばれる
        fun dispatch(payload: Action) {
            for (id in _callbacks.keys) {
                _callbacks[id]?.invoke(payload)
            }
        }

        // コールバックを登録する
        // コールバックを識別するためのユニークなDispatchTokenを返す
        fun register(callback: (@UnsafeVariance Action) -> Unit): DispatchToken {
            val id = _prefix + _lastID++
            _callbacks[id] = callback
            return id
        }

        fun unregister(id: DispatchToken) {
            require(_callbacks.containsKey(id))
            _callbacks.remove(id)
        }
    }
```
Dispatcherはcallbacksというマップを持ちます。registerメソッドでコールバックを登録し、dispatchメソッドで登録されたコールバックにペイロードをブロードキャストします。

### 抽象Store/実装Store

```Store.kt
abstract class Store(
    private val dispatcher: Dispatcher,
) : ViewModel() {
    private var _dispatchToken: DispatchToken

    init {
        _dispatchToken =
            dispatcher.register { payload ->
                onDispatch(payload)
            }
    }

    override fun onCleared() {
        dispatcher.unregister(_dispatchToken)
    }

    // コールバックをDispatcherに登録する
    protected abstract fun onDispatch(payload: Action)
}
```

抽象Storeのinitブロックでregisterメソッドを呼び出し、onDispatchメソッドをコールバックとしてDispatcherの_callbacksに登録します。このonDispatchメソッドは具象Storeでオーバーライドしなければなりません。

```WeatherStore.kt
class WeatherStore(
    dispatcher: Dispatcher,
) : Store(dispatcher) {
    private var _uiState: MutableState<WeatherUiState> = mutableStateOf(WeatherUiState.Initial)
    val uiState: State<WeatherUiState>
        get() = _uiState

    override fun onDispatch(payload: Action) {
        when (payload) {
            is FetchWeatherAction.Initial -> {
                _uiState.value = WeatherUiState.Initial
            }
            is FetchWeatherAction.Loading -> {
                _uiState.value = WeatherUiState.Loading
            }
            is FetchWeatherAction.Success -> {
                _uiState.value = WeatherUiState.Success(payload.payload)
            }
            is FetchWeatherAction.Failure -> {
                _uiState.value = WeatherUiState.Failure(payload.payload)
            }
        }
    }
}
```

具象StoreでonDispatchメソッドをオーバーライドすることによって、特定のアクションを登録し、アクションがディスパッチされた際に処理されるという流れになります。UIに表示するデータとしてUiStateを保持し、onDispatchのpayloadによって更新します。

### ViewModel

```MainViewModel.kt
class MainViewModel(
    dispatcher: Dispatcher,
) : ViewModel() {
    val weatherStore = WeatherStore(dispatcher)
}
```
StoreではUiStateを保有していますが、アクティビティ破棄時に破棄されてしまうので、これをViewModel内でインスタンス化します。

### MainActivity

```MainActivity.kt
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
    @Inject lateinit var dispatcher: Dispatcher
    @Inject lateinit var fetchWeatherRepository: FetchWeatherRepositoryImpl
    @Inject lateinit var weatherActionCreator: FetchWeatherActionCreator

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
        setContent {
            FluxSampleTheme {
                Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
                    FluxSampleApp(
                        modifier = Modifier.padding(innerPadding),
                        weatherActionCreator = weatherActionCreator,
                        dispatcher = dispatcher,
                    )
                }
            }
        }
    }
}

@Composable
fun FluxSampleApp(
    modifier: Modifier,
    weatherActionCreator: FetchWeatherActionCreator,
    dispatcher: Dispatcher,
    viewModel: MainViewModel =
        viewModel {
            MainViewModel(dispatcher)
        },
) {
    val scope = rememberCoroutineScope()
    val weatherStore = viewModel.weatherStore

    Column(
        modifier = modifier.fillMaxSize(),
        horizontalAlignment = Alignment.CenterHorizontally,
        verticalArrangement = Arrangement.Center,
    ) {
        Button(onClick = {
            scope.launch {
                // 地名を引数に天気を取得
                weatherActionCreator.fetchWeatherByCityName("Tokyo")
            }
        }) {
            Text(
                text = "天気を取得",
                fontSize = 30.sp,
            )
        }

        Spacer(modifier = Modifier.padding(50.dp))

        when (weatherStore.uiState.value) {
            is WeatherUiState.Initial -> {
                Text(
                    text = "まだ天気を取得していません",
                    fontSize = 25.sp,
                )
            }
            is WeatherUiState.Loading -> {
                IndeterminateCircularIndicator()
            }
            is WeatherUiState.Success -> {
                Text(
                    text = "東京の現在の天気：${(weatherStore.uiState.value as WeatherUiState.Success).weatherDescription}",
                    fontSize = 30.sp,
                )
            }
            is WeatherUiState.Failure -> {
                Text(
                    text = (weatherStore.uiState.value as WeatherUiState.Failure).errorMessage,
                    fontSize = 30.sp,
                )
            }
        }
    }
}
```

MainActivityでFluxの構成要素をインスタンス化します。今回はHiltによるDIを行っていますが、基本的にはDispatcherがStoreとActionCreatorの仲介者となるようにDIすれば良いと思います。Composableでは、ボタン押下時にweatherActionCreatorを介して天気取得APIを叩き、結果がweatherStoreで保持されるので、when式を使って状態ごとの画面を表示します。

## 感想
* データの流れが単方向なので状態変化の挙動を追いやすいです。ActionがコールバックとしてDispatcherに登録されているため、Action発行後のStoreの状態変化を予測しやすいというメリットを感じました。
* 各要素の責任が明確で、どこで何が起きているのかを整理しやすいです。これはインターンで感じたことですが、開発に途中から参加してもFluxの責任範囲が理解できれば構造もシンプルで馴染みやすいのではないかと思います。
* ActionやStoreなどFlux独自の構成要素が多いため、コード量も多くなります。シンプルで小規模なアプリケーションの場合、Fluxを採用することはオーバーヘッドとなるかもしれません。
* 個人的に感じるFluxの最大のメリットは、１つのアクションの発火で複数のStoreの状態を更新できるという点です。異なるStore間で共通のActionを受け取れば、Store毎にonDispatchメソッドの処理を分けてあげることで、それぞれのStateを更新することができます。そのため、異なる画面で同じデータ更新を行いたいとき（記事アプリの記事詳細画面のいいね数の更新と、マイページ画面の記事の合計いいね数の更新など）に、１つのユーザーアクションで複数の画面の状態を更新できます。これをMVVMでするとなるとViewModel間でデータをやり取りしたり、StateのプロパティにStateを持たせるということをしなければならない場合があります。
* 上記の注意点として、１つのアクションにより更新されたくない状態が更新されてしまうケースがあります。それはComposable AからComposable Aに画面遷移をする、つまり現在の画面からインスタンスの異なる同じ画面に循環するようなケースです。この場合、アクション発火により、共通のStoreを参照している全てのComposableで状態の更新が発生してしまいます。これを阻止するには、ディスパッチが起こる際に、StoreのonDispatchメソッドでScreenIdを判別させ、同じComposableでも画面のインスタンスによって状態の更新を制御しなければなりません。

今回はFluxアーキテクチャの学習として小さなアプリを作成しました。Flux全体の流れを理解することはできましたが、実際に開発への導入を考えると課題や不明点も残ると思います。特に、Fluxを採用するべきアプリの規模や用途について考える必要があります。Fluxは状態管理がしやすいという一方、アプリの規模によっては逆に煩雑化することもあるため、導入の際にはしっかりとした検討が必要だと思います。

## 参考にした記事
https://buildersbox.corp-sansan.com/entry/2019/12/13/110000

https://techblog.zozo.com/entry/android-flux

https://tech.mirrativ.stream/entry/2021/06/01/102044