Upgrade to Pro — share decks privately, control downloads, hide ads and more …

_アニメーション抜き__MVIに基づくStateMachineアーキテクチャ_KMPとJet...

Marco Valentino
September 15, 2023

 _アニメーション抜き__MVIに基づくStateMachineアーキテクチャ_KMPとJetpack_ComposeとSwiftUIを組み合わせる.pdf

目次:
- KMMとJetpack ComposeとSwiftUIの紹介
- MVIアーキテクチャ
- なぜMVIを使用するのか
- MVIの実装方法
- MVIを使って画面を実装する
- MVIに基くStateMachine
- Jetpack Compose & SwiftUI の導入
- Jetpack Compose上の使い方
- SwiftUI上の使い方
- StateMachine DSL
- StateMachine DSL を使ってリファクタリングする
- 開発上のメリット・デメリット

Marco Valentino

September 15, 2023
Tweet

More Decks by Marco Valentino

Other Decks in Programming

Transcript

  1. ςΫϊϩδʔͷ঺հ 1 ▶︎ K o t l i n M

    u l t i p l a t f o r m の紹介 ▶︎ J e t p a c k C o m p o s e の紹介 ▶︎ S w i f t U I の紹介 MVI ΞʔΩςΫνϟ 2 ▶︎ M V I の紹介 ▶︎なぜ� M V I �を使用するのか ▶︎ M V I の実装方法 ( K M P ) ▶︎ M V I を使って画面を実装する ▶︎ M V I に基づく S t a t e M a c h i n e State Machine DSL 4 ▶︎ S t a t e M a c h i n e D S L �を使って リファクタリングする ▶︎開発上のメリット・デメリット Jetpack Compose & SwiftUIͷಋೖ 3 ▶︎ J e t p a c k C o m p o s e 上の使い方 ▶︎ S w i f t U I 上の使い方 4
  2. 6 ςΫϊϩδʔͷ঺հ ▶︎ コードを削減 ▶︎ 直感的 ▶︎ 開発を加速させる ▶︎ パワフル

    ▶︎ 高度なアニメーション制御 ▶︎ シンプルになったデータフロー ▶︎ A P I の拡 ▶︎ 新タイプのグラフとインタラクティブな機能 Jetpack Compose SwiftUI
  3. sealed interface Contract interface Intent : Contract interface Action :

    Contract { interface Event : Action } interface State : Contract # macaron-core/../contract/Contract.kt macaron-core/../contract/Contract.kt 11
  4. # macaron-core/../components/Store.kt interface Store<I : Intent, A : Action, S

    : State> { val state: StateFlow<S> val event: Flow<A?> fun dispatch(intent: I) fun process(event: A) fun dispose() fun collect( onState: (S) -> Unit, onEvent: (A?) -> Unit, ): Job } 12
  5. 13 macaron-core/../components/Processor.kt interface Processor<I : Intent, A : Action, S

    : State> { suspend fun process(intent: I, state: S): Flow<A> } macaron-core/../components/Reducer.kt interface Reducer<A : Action, S : State> { suspend fun reduce(action: A, state: S): S }
  6. 14 macaron-core/../components/DefaultStore.kt class DefaultStore<I : Intent, A : Action, S

    : State>( initialState: S, private val processor: Processor<I, A, S>, private val reducer: Reducer<A, S>, coroutineContext: CoroutineContext, ) : Store<I, A, S> { private val scope: CoroutineScope = CoroutineScope(coroutineContext + SupervisorJob()) private val intents: MutableSharedFlow<I> = MutableSharedFlow(Int.MAX_VALUE, Int.MAX_VALUE) private val _state: MutableStateFlow<S> = MutableStateFlow(initialState) private val _events: MutableStateFlow<List<Action.Event>> = MutableStateFlow(emptyList()) override val state: MutableStateFlow<S> = _state override val events: Flow<A?> = _events.map { it.firstOrNull() as A? } override val currentState: S get() = _state.value override fun dispatch(intent: I) { scope.launch { intents.emit(intent) } } override fun process(event: A) { scope.launch { _events.emit(_events.value.filterNot { it == event }) } } private suspend fun send(event: Action.Event) { _events.emit(_events.value + event) } override fun dispose() { scope.cancel() } ... }
  7. 15 macaron-core/../components/DefaultStore.kt override fun dispatch(intent:aI a ) a {a scope.launch

    { intents.emit(intent)a}a a }a private val intents = MutableSharedFlow<I>(...)
  8. # macaron-core/../components/DefaultStore.kt private val intents = MutableSharedFlow<I>(...) init0{0 scope.launch0{0 intents0

    0 .flatMapMerge0{0intent0->0 processor.process(intent,0currentState)0 }0 0 .map0{0action0->0 if0(action0is0Action.Event)0send(action)0 reducer.reduce(action,0currentState) }2 .collect0{0state0->0_state.value0=0state }3 }4 } } 15
  9. 18 Domain Contract Processor Reducer shared/../entities/Monster.kt data class Monster( val

    id: Int, val name: String, val imageUrl: String, ) shared/../data/repositories/MonsterRepository.kt interface MonsterRepository { suspend fun getMonster( offset: Int, limit: Int, ): List<Monster> }
  10. 19 shared/../feature/monsterList/MonsterListState.kt sealed0class0MonsterListState0:0State0{0 data0object0Initial0:0MonsterListState()0 data0object0Loading0:0MonsterListState()0 sealed class0Stable :0MonsterListState() {1 abstract0val0monsterList:0List<Monster>0

    data0class0List( override0val0monsterList:0List<Monster> )0:0Stable()0 data0class0PageLoading( override0val0monsterList:0List<Monster> )0:0Stable()0 data0class0PageError( override0val0monsterList:0List<Monster>,0 val error:0Throwable )0:0Stable() }0 data0class0Error(val0error:0Throwable)0:0MonsterListState()0 }1 Domain Contract Processor Reducer
  11. shared/../feature/monsterList/MonsterListIntent.kt # sealed class MonsterListIntent : Intent { data object

    OnInit : MonsterListIntent() data class ClickItem( val monster: Monster ) : MonsterListIntent() data object ClickErrorRetry : MonsterListIntent() data object OnScrollToBottom : MonsterListIntent() } Domain Contract Processor Reducer 20
  12. shared/../feature/monsterList/MonsterListAction.kt # sealed class MonsterListAction : Action { data object

    Loading : MonsterListAction() data class LoadSuccess( val monsterList: List<Monster> ) : MonsterListAction() data class LoadError( val error: Throwable ) : MonsterListAction() data class NavigateDetails( val monster: Monster ) : MonsterListAction(), Action.Event } Domain Contract Processor Reducer 21
  13. # shared/../feature/monsterList/MonsterProcessor.kt when (intent) { is MonsterListIntent.OnInit -> when (state)

    { is MonsterListState.Initial -> loadMonsterList(0, repository, ::emit) } is MonsterListIntent.ClickItem -> when (state) { is MonsterListState.Stable -> { emit(MonsterListAction.NavigateDetails(intent.monster)) } } is MonsterListIntent.ClickErrorRetry -> when (state) { is MonsterListState.Error -> loadMonsterList(0, repository, ::emit) is MonsterListState.Stable.PageError -> loadMonsterList(state.currentOffset, repository, ::emit ) } is MonsterListIntent.OnScrollToBottom -> when (state) { is MonsterListState.Stable.List -> loadMonsterList(state.currentOffset, repository, ::emit) } } Domain Contract Processor Reducer 22
  14. # shared/../feature/monsterList/MonsterProcessor.kt private suspend fun loadMonsterList(a offset: Int, repository: Repository,

    emit: suspenda(MonsterListAction a ) a -> Unit, a )a{a emit(MonsterListAction.Loading) runCatchinga{ repository.getMonster(offset = offset, limit = 20) }.onSuccessa{ monsterLista-> emit(MonsterListAction.LoadSuccess(monsterList)) }.onFailurea{ errora-> emit(MonsterListAction.LoadError(error)) } }a Domain Contract Processor Reducer 22
  15. # shared/../feature/monsterList/MonsterReducer.kt Domain Contract Processor Reducer when (action) { is

    MonsterListAction.Loading -> when (state) { is MonsterListState.Initial -> MonsterListState.Loading is MonsterListState.Stable.List -> MonsterListState.Stable.PageLoading(state.monsterList) } is MonsterListAction.LoadSuccess -> when (state) { is MonsterListState.Loading -> MonsterListState.Stable.List(action.monsterList) is MonsterListState.Stable.PageLoading -> MonsterListState.Stable.List(state.monsterList + action.monsterList) } is MonsterListAction.LoadError -> when (state) { is MonsterListState.Loading -> MonsterListState.Error(action.error) is MonsterListState.Stable.PageLoading -> MonsterListState.Stable.PageError(state.monsterList, action.error) } is MonsterListAction.NavigateDetails -> state } 23
  16. 26 when (intent) { isaMonsterListIntent.OnInita-> { emit(MonsterListAction.Loading) runCatchinga{ repository.getMonster(offset =

    0, limit = 20) }.onSuccessa{ monsterLista-> emit(MonsterListAction.LoadSuccess(monsterList)) } ... } ... } when (action) { is MonsterListAction.Loading -> when (state) { is MonsterListState.Initial -> MonsterListState.Loading ... } is MonsterListAction.LoadSuccess -> when (state) { is MonsterListState.Loading -> MonsterListState.Stable.List(action.monsterList) ... } ... } // Processor // Reducer
  17. State Machineͱ͸ʁ # ݻମ on: ༥ղ transition: ӷମ ӷମ on:

    ڽݻ transition: ݸମ on: ؾԽ transition: ؾମ ؾମ on: ڽॖ(ӷԽ) transition: ӷମ 29
  18. State Machineͱ͸ʁ # ݻମ on: ༥ղ transition: ӷମ on: ڽॖ

    transition: 🙅 ӷମ on: ڽݻ transition: ݸମ on: ؾԽ transition: ؾମ ؾମ on: ڽॖ(ӷԽ) transition: ӷମ 29
  19. # MVIΛجͮ͘StateMachine // Processor when (intent) { is MonsterListIntent ->

    when (state) { is MonsterListState -> { ... } } } // Processor when (state) { is MonsterListState -> when (intent) { is MonsterListIntent -> { ... } } } // Reducer when (action) { is MonsterAction -> when (state) { is MonsterListState -> { ... } } } // Reducer when (state) { is MonsterListState -> when (action) { is MonsterListAction -> { ... } } } 30
  20. 31 shared/../feature/monsterList/MonsterProcessor.kt // Processor when (intent) { is MonsterListIntent.OnInit ->

    when (state) { is MonsterListState.Initial -> loadMonsterList(0, repository, ::emit) } ... } // Reducer when (action) { is MonsterListAction.Loading -> when (state) { is MonsterListState.Initial -> MonsterListState.Loading ... } ... }
  21. # shared/../feature/monsterList/MonsterProcessor.kt // Processor when (state) { is MonsterListState.Initial ->

    when (intent) { is MonsterListIntent.OnInit -> loadMonsterList(0, repository, ::emit) } ... } // Reducer when (state) { is MonsterListState.Initial -> when (action) { is MonsterListAction.Loading -> MonsterListState.Loading ... } ... } 31
  22. 32 Initial intent: OnInit action: Loading reduce: State.Loading action: LoadSuccess|LoadError

    // Processor when (state) { is MonsterListState.Initial -> when (intent) { is MonsterListIntent.OnInit -> { emit(MonsterListAction.Loading) ... emit(MonsterListAction.LoadSuccess) // or emit(MonsterListAction.LoadError) } } ... } // Reducer when (state) { is MonsterListState.Initial -> when (action) { is MonsterListAction.Loading -> MonsterListState.Loading ... } ... }
  23. # Initial intent: OnInit action: Loading reduce: State.Loading action: LoadSuccess|LoadError

    Stable intent: ClickItem event: NavigateDetails Error intent: OnInit action: Loading reduce: State.Loading action: LoadSuccess | LoadError Initial intent: OnInit action: Loading reduce: State.Loading action: LoadSuccess | LoadError Loading receive: LoadSuccess reduce: State.Stable.List receive: LoadError reduce: State.Stable.Error Error intent: ClickErrorRetry action: Loading reduce: State.Stable.Loading action: LoadSuccess | LoadError Loading receive: LoadSuccess reduce: State.Stable.List receive: LoadError reduce: State.Stable.Error ݻମ on: ༥ղ transition: ӷମ ӷମ on: ڽݻ transition: ݸମ on: ؾԽ transition: ؾମ ؾମ on: ڽॖ(ӷԽ) transition: ӷମ 33
  24. # Initial intent: OnInit action: Loading reduce: State.Loading action: LoadSuccess|LoadError

    Stable intent: ClickItem event: NavigateDetails Error intent: OnInit action: Loading reduce: State.Loading action: LoadSuccess | LoadError Initial intent: OnInit action: Loading reduce: State.Loading action: LoadSuccess | LoadError Loading receive: LoadSuccess reduce: State.Stable.List receive: LoadError reduce: State.Stable.Error Error intent: ClickErrorRetry action: Loading reduce: State.Stable.Loading action: LoadSuccess | LoadError Loading receive: LoadSuccess reduce: State.Stable.List receive: LoadError reduce: State.Stable.Error 33
  25. Initial intent: OnInit action: Loading reduce: State.Loading action: LoadSuccess|LoadError intent:

    ClickErrorRetry action: 🙅 # Stable intent: ClickItem event: NavigateDetails Error intent: OnInit action: Loading reduce: State.Loading action: LoadSuccess | LoadError Initial intent: OnInit action: Loading reduce: State.Loading action: LoadSuccess | LoadError Loading receive: LoadSuccess reduce: State.Stable.List receive: LoadError reduce: State.Stable.Error Error intent: ClickErrorRetry action: Loading reduce: State.Stable.Loading action: LoadSuccess | LoadError Loading receive: LoadSuccess reduce: State.Stable.List receive: LoadError reduce: State.Stable.Error 33
  26. 34 MonsterListState Stable I.ClickMonsterEntry > E.NavigateDetails Initial I.OnInit > A.Loading,

    [A.LoadSuccess | A.LoadError] Loading Error I.ClickErrorRetry > A.Loading, [A.LoadSuccess | A.LoadError] Stable.Initial I.OnScrollToBottom > A.Loading, [A.LoadSuccess | A.LoadError] Stable.PageLoading Stable.PageError I.ClickErrorRetry > A.Loading, [A.LoadSuccess | A.LoadError] A.Loading A.LoadSuccess A.LoadError A.Loading A.Loading A.LoadSuccess A.LoadError A.Loading PlantUML Diagram
  27. 35 androidApp/../ui/screen/MonsterListScreen.kt @Composable fun MonsterListScreen( contract: Contract<...>, ) { LaunchedEffect(Unit)

    { contract.dispatch(MonsterListIntent.OnInit) } contract.handleEvents { action -> when (action) { is MonsterListAction.NavigateDetails -> Timber.d("Handle Navigation") else -> Unit } } MonsterListContent(contract.state, contract.dispatch) }
  28. 36 androidApp/../ui/screen/MonsterListScreen.kt state.render<MonsterListState.Loading>a{ ... } state.render<MonsterListState.Stable>a{ val lazyListState = rememberLazyListState().apply

    { onScrolledToBottom { dispatch(MonsterListIntent.OnScrollToBottom) } } LazyColumn(state = lazyListState, modifier = Modifier.fillMaxSize()) { items(items = monsterList) { monster -> MonsterListItem(name = monster.name, imageUrl = monster.imageUrl, onClick = { dispatch(MonsterListIntent.ClickItem(monster = monster)) }) } state.renderItems<MonsterListState.Stable.PageLoading>a{ item { PageLoadingIndicatorItem() } } state.renderItems<MonsterListState.Stable.PageError>a{ item { PageErrorItem(error = it.error, onClickRetry = { dispatch(MonsterListIntent.ClickErrorRetry) }) } } } } }
  29. 37 androidApp/../ui/screen/MonsterListScreen.kt @Composable fun MonsterListScreen( contract: Contract<...>, ) { LaunchedEffect(Unit)

    { contract.dispatch(MonsterListIntent.OnInit) } contract.handleEvents { action -> when (action) { is MonsterListAction.NavigateDetails -> Timber.d("Handle Navigation") else -> Unit } } MonsterListContent(contract.state, contract.dispatch) }
  30. 38 androidApp/../core/Contract.kt data class Contract<I : Intent, A : Action,

    S : State>( val state: S, val dispatch: (I) -> Unit = {}, val event: A? = null, val process: (A) -> Unit = {}, ) @Composable fun <I : Intent, A : Action, S : State> contract( store: Store<I, A, S>, ): Contract<I, A, S> { val state by store.state.collectAsState() val event by store.event.collectAsState(initial = null) return Contract(state, event, store::dispatch, store::process) }
  31. 39 iosApp/../Core/Contract.swift open class Contract<I: Intent, A: Action, S: shared.State>:

    ObservableObject { @Published public private(set) var state: S public let dispatch: (I) -> Void public var events: some Publisher<A, Never> { eventSubject } private let eventSubject = PassthroughSubject<A, Never>() private var cancellables: Set<AnyCancellable> = [] public init(store: Store) { self.state = store.currentState as! S self.dispatch = { store.dispatch(intent: $0) } AnyCancellable { store.dispose() }.store(in: &cancellables) store.collect { [weak self] newState in if let self, let newState = newState as? S { self.state = newState } } onEvent: { [eventSubject, store] event in if let action = event as? A { store.process(event: action) eventSubject.send(action) } } } }
  32. 40 iosApp/../UI/Screen/MonsterListScreen.swift struct MonsterListScreen: View { @ObservedObject var contract: Contract<MonsterListIntent,

    MonsterListAction, MonsterListState> var body: some View { ZStack { switch contract.state { case _ as MonsterListState.Loading: LoadingIndicator() case let state as MonsterListState.Stable: MonsterListView(state: state, dispatch: contract.dispatch) case let state as MonsterListState.Error: FullScreenErrorView( error: state.error, onClickRetry: { contract.dispatch(MonsterListIntent.ClickErrorRetry()) } ) default: EmptyView() } }.onAppear { contract.dispatch(.OnInit()) } } }
  33. 41 whenจ͕ଟ͍ɹ 1 ▶︎ s t a t e と

    i n t e n t と a c t i o n 増えていくと w h e n 文も二次関数的に増えていく 😱 ॲཧ͕ผΕ͍ͯΔ 2 ▶︎コードの流れ分かりにくい ▶︎バグ修正や追加開発難しくなる
  34. // Processor is MonsterListState.Initial -> when (intent) { is MonsterListIntent.OnInit

    -> { loadMonsterList(0, repository, ::emit) } } # state<MonsterListState.Initial> { process<MonsterListIntent.OnInit> { loadMonsterList(0, repository, ::emit) } reduce<MonsterListAction.Loading> { } }a // Reducer is MonsterListState.Initial -> when (action) { is MonsterListAction.Loading -> { MonsterListState.Loading } } MonsterListState Initial I.OnInit > A.Loading, [A.LoadSuccess | A.LoadError] Loading A.Loading 44
  35. # state<MonsterListState.Initial> { process<MonsterListIntent.OnInit> { loadMonsterList(0, repository, ::emit) } reduce<MonsterListAction.Loading>

    { MonsterListState.Loading } }a MonsterListState Initial I.OnInit > A.Loading, [A.LoadSuccess | A.LoadError] Loading A.Loading // Reducer is MonsterListState.Initial -> when (action) { is MonsterListAction.Loading -> { MonsterListState.Loading } } 44
  36. 45 class MonsterListStateMachine(private val repository: MonsterRepository) : StateMachine<MonsterListIntent, MonsterListAction, MonsterListState>(

    builder = { state<MonsterListState.Initial> { process<MonsterListIntent.OnInit> { loadMonsterList(0, repository, ::emit) } reduce<MonsterListAction.Loading> { MonsterListState.Loading } } state<MonsterListState.Loading> { reduce<MonsterListAction.LoadSuccess> { MonsterListState.Stable.List(action.monsterList) } reduce<MonsterListAction.LoadError> { MonsterListState.Error(action.error) } } state<MonsterListState.Stable> { process<MonsterListIntent.ClickItem> { emit(MonsterListAction.NavigateDetails(intent.monster)) } } state<MonsterListState.Stable.List> { process<MonsterListIntent.OnScrollToBottom> { loadMonsterList(state.currentOffset, repository, ::emit) } reduce<MonsterListAction.Loading> { MonsterListState.Stable.PageLoading(state.monsterList) } } state<MonsterListState.Stable.PageLoading> { reduce<MonsterListAction.LoadSuccess> { MonsterListState.Stable.List(state.monsterList + action.monsterList) } reduce<MonsterListAction.LoadError> { MonsterListState.Stable.PageError(state.monsterList, action.error) } } state<MonsterListState.Stable.PageError> { process<MonsterListIntent.ClickErrorRetry> { loadMonsterList(state.currentOffset, repository, ::emit) } reduce<MonsterListAction.Loading> { MonsterListState.Stable.PageLoading(state.monsterList) } } state<MonsterListState.Error> { process<MonsterListIntent.ClickErrorRetry> { loadMonsterList(0, repository, ::emit) } reduce<MonsterListAction.Loading> { MonsterListState.Loading } } } )
  37. 46 val stateMachine = MonsterListStateMachine(monsterRepository = ...) val processor =

    StateMachineProcessor(stateMachine) val reducer = StateMachineReducer(stateMachine) Reducer Processor Store
  38. 47 whenจ͕ଟ͍ɹ 1 ▶︎ w h e n 文一個も必要なくなった ▶︎書くコードもかなり減った

    ॲཧ͕ผΕ͍ͯΔ 2 ▶︎ p r o c e s s o r と r e d u c e r の処理は同じ ブロックに入っているので、コードの 流れわかりやすくなった
  39. UML͕࢓༷ॻʹͳΔ 1 ▶︎ U M L を見ながら S t a

    t e M a c h i n e も�� 実装しやすい ࣮૷͸ಉ࣌ฒߦͰ͖Δ 2 ▶︎ 最初に U M L と S t a t e と E n t i t y を用意 するだけで、アプリの画面も K M P 側の S t a t e M a c h i n e も同時に実装できる Kotlinͷ஌ࣝগͳͯ͘΋͍͍ 3 ▶︎ S t a t e M a c h i n e は D S L 化されている�� ので i O S エンジニアでも複雑すぎない 画面の S t a t e M a c h i n e を書けます খ͍͞࢓༷มߋʹ΋ ରԠ͠΍͍͢ 4 ▶︎ボタンなど追加する場合、 I n t e n t と A c t i o n を用意して p r o c e s s と r e d u c e を 書くだけで追加できます 51
  40. 52 ਂ͍Domain஌͕ࣝඞཁ 2 ▶︎ U M L を作る時に D o

    m a i n と U I の ロジックを落とし込むので D o m a i n 層や U I の動きの深い理解が必要 ྆OSͷ஌͕ࣝ๬·͍͠ 3 ▶︎ U M L を作成する時に両 O S の動きを 考えながら組む事が必要 ֶशίετ͕͋Δ 1 ▶︎ M V I や S t a t e M a c h i n e の考え方が 慣れていない方が多い େ͖͍࢓༷มߋʹऑ͍ 4 ▶︎仕様変更が大きければ、 S t a t e の� 構造を考え直すことがあります
  41. Macaronʹ͍ͭͯ 53 0.1.0ϦϦʔε 1 ▶︎ドキュメンテーションまだ用意できてない ▶︎ A P I はこれから変わる可能性ある

    Roadmap 2 ▶︎ ドキュメンテーションの作成 ▶︎ M i d d l e w a r e / P l u g i n の仕組みを考え直す ▶︎ サンプルコードやアプリを増やす ▶︎ U M L からのコード生成 ▶︎ T i m e T r a v e l デバッグ https://github.com/fika-tech/Macaron
  42. 54 Attributions: ▶︎ https://icons8.com/icons/collections/JLYaFSTqBIyi ▶︎ https://www.flaticon.com/free-icons/flow-chart ▶︎ https://www.flaticon.com/free-icon/uml_5687240 ▶︎ https://www.flaticon.com/free-icons/feet

    ▶︎ https://greenchess.net/ ▶︎ https://www.freepik.com/free-vector/scary-monster- set-colored-monsters-with-teeth-eyes-illustration- funny-monsters_13031939.htm ▶︎ https://www.freepik.com/free-vector/monsters-set- cartoon-cute-character-isolated-white- background_13031453.htm References: ▶︎ https://github.com/Tinder/StateMachine ▶︎ https://github.com/orbit-mvi/orbit-mvi ▶︎ https://github.com/arkivanov/MVIKotlin ▶︎ https://github.com/reduxjs/redux ▶︎ https://github.com/ReactorKit/ReactorKit ▶︎ https://github.com/kubode/reaktor Resources: ▶︎ https://developer.android.com/topic/architecture/ui-layer/state-production ▶︎ https://medium.com/swlh/mvi-architecture-with-android-fcde123e3c4a ▶︎ https://yuyakaido.hatenablog.com/entry/2017/12/12/235143 ▶︎ https://plantuml.com/ Links: ▶︎ https://github.com/fika-tech/Macaron ▶︎ https://github.com/fika-tech/DroigKaigi-Sample