はじめに
ユーザが一度自身の情報をサーバに登録した後、変更したいことってよくありますよね。
例えば登録しているメールアドレスを変えたいとか、引っ越しをしたので住所を変えたいとか。
その際編集画面では、すでに登録済みの値が入力フォームに入っているのが普通です。
ですが、AndroidでStateFlowを使用した際に、サーバから取得した値とユーザが入力した値をひとつのStateとして管理するのにてこずったのでここに備忘録を残しておきます。
アーキテクチャ
Androidのアーキテクチャガイドに従い、以下のようなパッケージ構成を想定しています。
src/main/java/
┣ data
┃ ┣ datasource // 外部データソースと通信するクラス
┃ ┣ repository // ViewModelとdatasourceを結ぶインターフェース
┃ ┗ response // レスポンスのためのdata class
┗ ui
┣ state // 画面用の状態を持たせるためのdata class
┗ viewmodel // ViewModel
StateFlowの作成手順
以下の流れでStateFlowを作成していきます。
- Repository経由でサーバから登録済みの値を取得し、ViewModelにFlowを返す。
- ユーザが入力した値を保持するMutableStateFlowを作成する。
- 上記2つのFlowをガッチャンコさせる。
- UIからガッチャンコしたStateFlowを参照する。
- UIからMutableStateFlowを更新するロジックを追加する。
登録済みの値を取得する
最初に、データ取得のためのRepositoryを用意します。サーバからのデータ取得はIO処理を伴うため、suspend関数にしています。
interface UserInfoRepository {
suspend fun getUserInfo(): Flow<UserInfo>
}
DataSourceの実装については、今回は具体的な実装(HTTP通信やgRPC通信など)は省略し、直接ユーザ情報を返す形にしています。DataSourceの主要な役割は、指定された情報源からデータを取得してflowブロック内でemitすることです。
class UserInfoDataSource: UserInfoRepository {
override suspend fun getUserInfo(): Flow<UserInfo> = flow {
// 通常はサーバからのデータ取得処理がここに入る
emit(
//サーバから取得した情報を元にレスポンス型をemitする。
UserInfo(
name = "Jason Statham",
email = "jasonisawsome@gmail.com",
address = "Hoge state, U.S."
)
)
}
}
これでデータ取得の準備が整ったので、ViewModelからRepositoryを呼び出すようにします。具体的なデータ取得は、後述するUIのcollectAsStateWithLifecycleが呼び出されたタイミングで実行されます。
@HiltViewModel
class TwoFlowsViewModel @Inject constructor(
userInfoRepository: UserInfoRepository
): ViewModel() {
private lateinit var fetchedState: Flow<User>
init {
viewModelScope.launch {
fetchedState = userInfoRepository.getUserInfo()
}
}
}
今回はRepositoryの関数をsuspend関数で定義したので、initブロックの中でviewModelScopeをlaunchしてRepositoryのメソッドを呼び出しています。
Repositoryの関数を普通の関数にしてプロパティ宣言とともに代入をする案もありますが、RepositoryのgetUserInfo()メソッドはこの画面の初期化以外にも呼び出される可能性があるのでinitブロックから呼び出すようにしています。
interface UserInfoRepository {
//このメソッドを画面の初期化時にしか呼び出さないことがルール化されていればこれでも可。
fun getUserInfo(): Flow<UserInfo>
}
@HiltViewModel
class TwoFlowsViewModel @Inject constructor(
userInfoRepository: UserInfoRepository
): ViewModel() {
private val fetchedState: Flow<User> = userInfoRepository.getUserInfo()
}
ユーザ入力を保持するMutableStateFlowの作成
次にユーザが入力した値を反映させるためのMutableStateFlowを作成します。
@HiltViewModel
class TwoFlowsViewModel @Inject constructor(
userInfoRepository: UserInfoRepository
): ViewModel() {
//ユーザが入力した値を保持するMutableStateFlow
private val userInputState = MutableStateFlow(TwoFlowsUiState(name = "", email = ""))
//サーバから取得する用のFlow
private lateinit var fetchedState: Flow<User>
init {
viewModelScope.launch {
fetchedState = userInfoRepository.getUserInfo()
}
}
}
ガッチャンコ
これで2つのFlowが準備できたのでそれらをガッチャンコして、1つのStateFlowにします。
@HiltViewModel
class TwoFlowsViewModel @Inject constructor(
userInfoRepository: UserInfoRepository
): ViewModel() {
//ユーザが入力した値を保持するMutableStateFlow
private val userInputState = MutableStateFlow(TwoFlowsUiState(name = "", email = ""))
//サーバから取得する用のFlow
private lateinit var fetchedState: Flow<UserInfo>
//2つのFlowを合成したStateFlow
lateinit var uiState: StateFlow<TwoFlowsUiState>
//ユーザがテキストフィールドを更新したか判定するためのフラグ
private var isUserOverridden = false
init {
viewModelScope.launch {
fetchedState = userInfoRepository.getUserInfo()
//combine関数を使うことでFlowを合成することができる
uiState = combine(userInputState, fetchedState) { input, fetched ->
if(isUserOverridden) {
//ユーザが何かを入力した場合は、以降ユーザが入力した値を優先する
input
} else {
//inputフィールドに取得した情報を反映する
val converted = toUiStateFrom(fetched)
userInputState.value = converted
converted
}
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = TwoFlowsUiState(name = "", email = "")
)
}
}
//サーバレスポンスの型をUI表示用の型に変換する
private fun toUiStateFrom(fetched: UserInfo): TwoFlowsUiState {
return TwoFlowsUiState(
name = fetched.name,
email = fetched.email,
)
}
}
isUserOverriddenは後述するMutableStateFlowを更新する処理の中でtrueに更新されます。
UIからStateFlowを参照する
UIからはガッチャンコしたStateFlowを参照するようにします。
@Composable
fun TwoFlowsScreen(
viewModel: TwoFlowsViewModel
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
Column(
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
InputField(
label = "name",
fieldValue = uiState.name,
updateValue = { updatedValue -> viewModel.updateName(updatedValue) }
)
InputField(
label = "email",
fieldValue = uiState.email,
updateValue = { updatedValue -> viewModel.updateEmail(updatedValue) }
)
}
}
collectAsStateWithLifecycle()を使うことでComposable関数のライフサイクルに合わせてFlowを開始・停止させることができます。
つまりComposableが画面に表示されている間のみサーバとの通信処理が行われるので、リソースを無駄に消費してしまうことがありません。
(厳密にはstateInのstartedでWhileSubscribed(5_000)を指定しているので、キャンセルされるまでに5秒間の猶予があります。画面回転等によるComposableの退場にそなえるため猶予を与えています。)
MutableStateFlowを更新するロジックの追加
文字入力などのUIイベント契機でMutableStateFlowを更新するためのロジックを追加します。
//nameフィールドに入力された値をMutableStateFlowに反映する
fun updateName(newName: String) {
//以降はユーザの入力を優先させるためフラグをtrueにする
isUserOverridden = true
userInputState.value = userInputState.value.copy(name = newName)
}
//emailフィールドに入力された値をMutableStateFlowに反映する
fun updateEmail(newEmail: String) {
//以降はユーザの入力を優先させるためフラグをtrueにする
isUserOverridden = true
userInputState.value = userInputState.value.copy(email = newEmail)
}
さいごに
よくあるシチュエーションだと思うのですが、参考文献が全然見当たりませんでした。
他によい方法をご存知の方はコメント欄で教えていただけると助かります!
この記事で紹介したソースコードはこちらにまとめてあります。
https://github.com/Aniokrait/AndroidToyBox/tree/main/combineTwoFlows