Mavericks
一言で表すと
いい感じのAndroid MVIアーキテクチャフレームワーク
Mori Atsushi
TOP
MVIフレームワーク
Airbnbの何百もの画面で使用されている
登場人物
State
ViewModel
View
ViewModel and State factories
こんな感じでViewModelFactoryを定義する
MavericksViewModelはAACのViewModelを継承していない
MavericksViewModelFactoryはAACのViewModelFactoryを継承していない
code:kotlin
class MyViewModel(
initialState: MyState,
...
) : MavericksViewModel<MyState>(initialState) {
...
companion object : MavericksViewModelFactory<MyViewModel, MyState> {
override fun initialState(viewModelContext: ViewModelContext): MyState {
return MyState(...)
}
override fun create(viewModelContext: ViewModelContext, state: MyState): MyViewModel {
return MyViewModel(state, ...)
}
}
}
ViewModelContextはActivityViewModelContextかFragmentViewModelContext
code:kotlin
data class ActivityViewModelContext(
override val activity: ComponentActivity,
override val args: Any?,
override val owner: ViewModelStoreOwner = activity,
override val savedStateRegistry: SavedStateRegistry = activity.savedStateRegistry,
) : ViewModelContext()
code:kotlin
data class FragmentViewModelContext(
override val activity: ComponentActivity,
override val args: Any?,
/**
* The fragment owner of the ViewModel.
*/
val fragment: Fragment,
override val owner: ViewModelStoreOwner = fragment,
override val savedStateRegistry: SavedStateRegistry = fragment.savedStateRegistry
) : ViewModelContext()
MavericksViewModelWrapper というのがAAC ViewModelを保持している
chigichan24.icon Android の文脈に依存させたくないのかな?
code:kotlin
class MavericksViewModelWrapper<VM : MavericksViewModel<S>, S : MavericksState>(val viewModel: VM) : ViewModel() {
override fun onCleared() {
super.onCleared()
viewModel.onCleared()
}
}
現段階ではActivity、Fragment、NavGraphにしかbindできない
Jetpack Compose
code:kotlin
@Composable
fun MyComponent() {
val viewModel: CounterViewModel = mavericksViewModel()
// You can now call functions on your viewModel.
}
code:kotlin
@Composable
fun MyComponent() {
val viewModel: CounterViewModel = mavericksViewModel()
// All changes to your state
val state by viewModel.collectAsState()
// Just count.
val count1 = viewModel.collectAsState(CounterState::count)
// Equivalent to count but with more flexibility for nested props.
val count2 = viewModel.collectAsState { it.count }
Text("Count is ${state.count}")
Text("Count is $count1")
Text("Count is $count2")
}
2つ目の定義はこんな感じ
code:kotlin
fun <VM : MavericksViewModel<S>, S : MavericksState, A> VM.collectAsState(prop1: KProperty1<S, A>): State<A>
chigichan24.icon これどうなんだろ
ViewModelのスコープ
LocalLifecycleOwnerに最も近いスコープのビューモデルを取得または作成する
Mori Atsushi.icon ちょっと強引…w
code:kotlin
val viewModelStoreOwner = scope as? ViewModelStoreOwner
?: error("LifecycleOwner must be a ViewModelStoreOwner!")
val savedStateRegistryOwner = scope as? SavedStateRegistryOwner
?: error("LifecycleOwner must be a SavedStateRegistryOwner!")
chigichan24
Mavericks is used in hundreds of screens at Airbnb including 100% of new screens.
すごい
Mavericks is built on top of Android Jetpack and Kotlin Coroutines so it can be thought of as a complement rather than a departure from Google's standard set of libraries.
Googleの提供する基本的なアセットからはそこまで離れないよ。全く別の思想というより補完するようなもの
MavericksState
クラスが State を持つことを明示的にする
明示的にすると良いこと
Thread safe
色んな人が理解しやすい
イベントの順序に関係なく描画される
などなど
data class である
immutable only
すべてのパラメータにデフォルト値を強制(すぐに描画できるように)
MavericksViewModel
About
Exposing a stream of states for other classes to subscribe to (MavericksViewModel.stateFlow)
Jetpack ViewModels とほぼ同じだが、MavericksState クラスのgenericsが追加されている
MavericksState の更新
code:kotlin
setState { copy(yourProp = newValue) }
Handling async/db/network operations
非同期操作は簡単にしている
Async<T> か execute(...) でなんとかなるらしい
Subscribing to state changes
基本は init {} block でやる
code:kotlin
// Invoked every time state changes
onEach { state ->
}
// Invoked whenever propA changes only.
onEach(YourState::propA) { a ->
}
// Invoked whenever propA, propB, or propC changes only.
onEach(YourState::propA, YourState::propB, YourState::propC) { a, b, c ->
}
stateFlow
normal Kotlin Flow that emits the current state as well as any future updates
基本的にはただの Kotlin Flow
Accessing state once
一度だけ値を見に行く
withState { state -> ... } を使うと良い
同期的には呼ばれない。すべてバックグラウンドの queue を経由して (内部にいる) reducer が呼び出されている
MavericksView
飛ばす
Async/Network/Db Operations
ネットワークやDBリクエストなど、非同期に色々頑張るためのサポートの型として、Async<T> を用意している。
イメージとしては以下のような感じ。
code: kotlin
sealed class Async<out T>(private val value: T?) {
open operator fun invoke(): T? = value
object Uninitialized : Async<Nothing>(value = null)
data class Loading<out T>(private val value: T? = null) : Async<T>(value = value)
data class Success<out T>(private val value: T) : Async<T>(value = value) {
override operator fun invoke(): T = value
}
data class Fail<out T>(val error: Throwable, private val value: T? = null) : Async<T>(value = value)
}
なので当然 Success(5) とかで直接的に呼び出すこともできる。
実際に非同期にリクエストを飛ばす時の話
execute(...) を使う
こいつは、suspend () -> T とか Flow<T> とか Deferred<T> みたいな一般的なやつを返してくれる。
mvrx-rxjava2 だと Observable<T> とか Single<T> とか Completable<T> みたいなやつでも返してくれる
execute した直後は Loading を返し、成功したら Success 失敗したら Fail を返す
lifecycle 周りの話
Mavericksはいい感じにしてくれるので viewModel の onClearedが呼ばれた時に unsubscribing とかを勝手にやってくれる。
非同期の処理も setState みたいに、個別のイベントに対して reducer が呼び出される
コード例
code:kotlin
interface WeatherRepository {
fun fetchTemperature(): Flow<Int>
}
// Inside of a function in your ViewModel.
weatherRepository.fetchTemperature().execute { copy(currentTemperature = it) }
解説
currentTemperature は Async<Int> であり、初期状態は Uninitialized
execute が呼ばれると、currentTemperature は Loading になる
成功したら、currentTemerature は Success(temp) になり、失敗したら Fail(e) になる
もし API call 途中で view が殺されるなどしたら勝手にリクエストはキャンセルされる
Subscribing to Async properties
onEach みたいに onAsync でプロパティをサブスクライブできる
code:kotlin
data class MyState(val name: Async<String>) : MavericksState
...
onAsync(MyState::name) { name ->
// Called when name is Success and any time it changes.
}
// Or if you want to handle failures
onAsync(
MyState::name,
onFail = { e -> .... },
onSuccess = { name -> ... }
)
別 Dispatcher で見ることもできる
プロパティで渡すことができる
code:kotlin
suspend {
weatherApi.fetchTemperature()
}.execute(Dispatchers.IO)
Retaining data across reloads with retainValue
リロード時にデータを保持できる (retainValue)
Success が来るまで前のデータを保持する
code:kotlin
suspend {
weatherApi.fetchTemperature()
}.execute(retainValue = MyState::currentTemperature) { copy(currentTemperature = it) }
例えばもともと 5 というデータを保持していて、次に fetch した時に失敗したら 5 のまま表示
Go Takahana
persist-stateってところを読む!
Persistence(永続性)
Androidだと永続化を考慮しなければならないケースが2つある。
Configuration Changes
Back Stack
従来ならsavedInstanceStateを使うが、Mavericksでは全く同じインスタンスのViewModelが返されるので、savedInstanceStateを使わなくていい。
新しいプロセスになった時に、Mavericksは元のプロセスに存在していた全てのViewModelを再作成しようとする。
@PersistStateをプロパティにつけると、復元できる。
code:kotlin
@Target(AnnotationTarget.VALUE_PARAMETER) // コンストラクタか関数のパラメータにつけれる
@Retention(AnnotationRetention.RUNTIME)
annotation class PersistState
注意点
プロパティはParcelableかSeiralizableでなければならない
Asyncオブジェクトではだめ
code:kotlin
data class FormState(
@PersistState val firstName: String = "",
@PersistState val lastName: String = "",
@PersistState val homeTown: String = ""
) : MavericksState
ユースケース
ユーザのフォーム入力
Tipsも読もうかな
Use derived props when possible
できるだけderived props(派生プロパティ)を使おう
テストが簡単なので、規模が大きくなると便利。
Go.iconそうかも
Go.icon値のバリデーションとかのロジックを寄せれるから?
例)
code:kotlin
data class CounterState(
val count: Int = 0
) : MavericksState {
val isEven = count % 2 == 0 // 派生プロパティ
}
code:kotlin
data class SignUpState(
val firstName: String = "",
val lastName: String = "",
val email: String = ""
) : MavericksState {
val hasName = firstName.isNotBlank() && lastName.isNotBlank()
val hasValidEmail = EMAIL_PATTERN.matches(email)
val canSubmitForm = hasName && hasValidEmail
}
Working with immutable maps
Map
code:kotlin
setState { copy(yourMap = yourMap.copy(“a” to 1, “b” to 2)) }
or
setState { copy(yourMap = yourMap.delete(“a”, “b”)) }
code:kotlin
/**
* Returns a new immutable map with the provided keys set/updated.
*/
fun <K, V> Map<K, V>.copy(vararg pairs: Pair<K, V>) = HashMap<K, V>(size + pairs.size).apply {
putAll(this@copy)
pairs.forEach { put(it.first, it.second) }
}
/**
* Returns a new map with the provided keys removed.
*/
fun <K, V> Map<K, V>.delete(vararg keys: K): Map<K, V> {
// This should be slightly more efficient than Map.filterKeys because we start with a map of the
// correct size rather than a growing LinkedHashMap.
return HashMap<K, V>(size - keys.size).apply {
this@delete.entries.asSequence()
.filter { it.key !in keys }
.forEach { put(it.key, it.value) }
}
}
Drive animations from ViewModel.onEach
ViewModel.onEachを使って、アニメーションを動かしたり、特定の時間だけエラー表示ができたりする。
Go.icon
code:kotlin
viewModel.onAsync(YourState::yourProp, onFail = {
viewLifecycleOwner.lifecycleScope.whenStarted {
binding.error.isVisible = true
delay(4000)
binding.error.isVisible = false
}
})
whenStartedはJetpackライブラリのもの
code:kotlin
lifecycleScope.launch {
whenStarted {...
Go.iconえ、こんな書き方できたんだ...
気になるポイント
コメント