|
| 1 | +--- |
| 2 | +layout: post |
| 3 | +title: "MVI" |
| 4 | +date: 2019-08-26 |
| 5 | +categories: ["Wzorce projektowe"] |
| 6 | +permalink: /blog/wzorce/:title/ |
| 7 | +image: patterns/mvi |
| 8 | +github: design-patterns/tree/master/mvi |
| 9 | +description: "Wzorce projektowe / architektoniczny" |
| 10 | +keywords: "mvi, model, view, intent, wzorzec, wzorce projektowe, wzorzec architektoniczny, design patterns, android, java, programowanie, programming" |
| 11 | +--- |
| 12 | + |
| 13 | +## Zastosowanie |
| 14 | +`MVI` (ang. `Model-View-Intent`) (wzorzec architektoniczny) usprawnia proces tworzenia i rozwijania aplikacji pisanych przy użyciu programowania reaktywnego poprzez wyróżnienie podziału odpowiedzialności pomiędzy trzy podmioty: widoku (`View`), modelu (`Model`) i intencji (`Intent`). Wzorzec ten jest w pewnym sensie pochodną `MVP` i `MVVM` dostosowaną do `programowania reaktywnego`. Eliminuje użycie metod zwrotnych (`callback`) oraz znacznie redukuje ilość metod wejście/wyjście (`input/output`). Jest także odpowiedzią na pojawiający się problem synchronizacji i rozbieżności przyjmowanych stanów przez warstwę widoku i logiki biznesowej wynikający z faktu iż stan jest sterowany przez warstwę `Presenter` lub `ViewModel`. W efekcie może prowadzić to do nieoczekiwanych zachowań i trudności w ich przewidzeniu oraz utrudnić debugowanie. `MVI` opiera się cyklicznym i jednokierunkowym przepływie oraz na istnieniu jednego niezmiennego (`immutable`) stanu wspólnego dla wszystkich warstw jako jedynego źródła prawdy. Rolę reprezentanta stanu przyjmuje model, który definiuje możliwe stany na podstawie wartości przechowywanych danych. `Model` nie jest zatem kontenerem danych i łącznikiem do logiki biznesowej lecz instancją stanu, który może zawierać pewne informacje jednoznacznie go definiujące. `Warstwa widoku` obserwuje akcje użytkownika i zdarzenia systemu w efekcie których nadaje intencję dla wywołanego zdarzenia, a także nasłuchuje i reaguje na zmianę stanu modelu. Widok nie musi zatem znać zarówno odbiorcy jak i nadawcy strumienia danych. `Intencja` przeważnie nie jest osobną warstwą lecz reprezentantem przyszłej akcji zmieniającej stan modelu. |
| 15 | + |
| 16 | +## Ograniczenia |
| 17 | +Wykorzystanie wzorca `MVI` jest propozycją na reaktywną architekture aplikacji co jednocześnie definuje jego charakterystykę oraz ogranicza jego zastosowanie. W przeciwieństwie do `MVP` i `MVVM` nie może być użyty w oderwaniu od programowania reaktywnego ponieważ stanowi jego implementacje dla architektury systemu. Pomimo iż programowanie reaktywne może być realizowane bez użycia dodatkowych zewnętrznych zależności przeważnie jednak jest implementowane z wykorzystaniem zewnętrznej biblioteki np. `RxJava` co dodatkowo wiąże `MVI` z zależnościami. Wymaga od programisty znajomości mechanizmów tworzenia aplikacji w sposób reaktywny. Ponadto nie eliminuje problemu zmiany konfiguracji czy wycieku pamięci. |
| 18 | + |
| 19 | +## Użycie |
| 20 | +Podobnie jak w przypadku innych wzorców architektonicznych zastosowanie `MVI` ma za zadanie wyeliminować problem `God Activity` oraz zwiększyć czytelność kodu i możliwości jego rozwoju. Może zostać zaimplementowany zarówno przy tworzeniu nowej aplikacji jak i już istniejącej poprzez migracje obecnej architektury. Sprawdzi się jednak przede wszystkim tam, gdzie aplikacja już jest realizowana w sposób reaktywny. Ponadto jest dobrym wyborem w przypadku skomplikowanych przepływów pracy, które cechują się dużą ilością metod wejściowych i wyjściowych w odpowiadającej implementacji `MVP` i `MVVM`. |
| 21 | + |
| 22 | +## Implementacja |
| 23 | +Realizacja wzorca `MVI` może przypominać tą znaną z `MVP`, różnica polega jednak na implementacji i interakcji komponentów. Na podstawie klasy `Model` będącej kontenerem danych opisujących stan, definiowane są finalne klasy stanów o wspólnym rodzicu `PartialState`. Warstwa widoku `ViewImpl` implementuje interfejs `View` dostarczając zachowania obsługi otrzymanych stanów w metodzie `render` oraz emisji intencji w odpowiedzi na akcje użytkownika. Klasa `Presenter` odpowiada za odbieranie i przetwarzanie intencji z widoku oraz delegowanie ich do logiki biznesowej. W odpowiedzi na otrzymany stan tworzy nowy niezmienny obiekt modelu w metodzie `reduce`, który trafia do widoku. |
| 24 | + |
| 25 | +{: .center-image } |
| 26 | + |
| 27 | +Poniższy listing przedstawia realizację wzorca `MVI` z pominięciem zależności `Androidowych` oraz zastosowaniem `RxJava` w celu realizacji programowania reaktywnego. |
| 28 | + |
| 29 | +{% highlight kotlin %} |
| 30 | +//define Model where fields values describe the states of the screen |
| 31 | +//e.g. screen can be in progress loading data, showing error or the final result |
| 32 | +data class Model( |
| 33 | + val progress : Boolean = false, |
| 34 | + val error : Boolean = false, |
| 35 | + val result : String = "" |
| 36 | +) |
| 37 | + |
| 38 | +//decompose Model's fields into parts which represent single State |
| 39 | +//Kotlin sealead class is great for this |
| 40 | +sealed class PartialState { |
| 41 | + |
| 42 | + object ProgressState : PartialState() |
| 43 | + object ErrorState : PartialState() |
| 44 | + class SuccessState(val result : String) : PartialState() |
| 45 | +} |
| 46 | + |
| 47 | +interface View { |
| 48 | + |
| 49 | + //the main responsibility is to react for received state in single method |
| 50 | + fun render(state : Model) |
| 51 | + |
| 52 | + //declare methods for emitting Intents like click on the button |
| 53 | + fun emitActionIntent() : Observable<Boolean> //observable of RxJava |
| 54 | +} |
| 55 | + |
| 56 | +//to simplify MVI idea the View is implemented by some class, in Android it should be e.g. Activity |
| 57 | +class ViewImpl : View { |
| 58 | + |
| 59 | + private val presenter = Presenter(Interactor()) |
| 60 | + |
| 61 | + public ViewImpl() { |
| 62 | + presenter.bindIntents(this) |
| 63 | + |
| 64 | + //init views |
| 65 | + |
| 66 | + emitActionIntent() //emit action on start |
| 67 | + } |
| 68 | + |
| 69 | + override fun emitActionIntent() : Observable<Boolean> { |
| 70 | + return Observable.just(true) |
| 71 | + } |
| 72 | + |
| 73 | + //render received model and react for changes i |
| 74 | + override fun render(state : Model) { |
| 75 | + with(state) { |
| 76 | + showProgress(progress) |
| 77 | + showError(error) |
| 78 | + showResult(result) |
| 79 | + } |
| 80 | + } |
| 81 | + |
| 82 | + private fun showProgress(enable : Boolean) { |
| 83 | + //show or hide progress bar |
| 84 | + } |
| 85 | + |
| 86 | + private fun showError(enable : Boolean) { |
| 87 | + //show or hide some error |
| 88 | + } |
| 89 | + |
| 90 | + private fun showResult(result : String) { |
| 91 | + //show some result |
| 92 | + } |
| 93 | +} |
| 94 | + |
| 95 | +class Presenter(private val interactor : Interactor) { |
| 96 | + |
| 97 | + fun bindIntents(view : View) { |
| 98 | + //bindIntents to observer Intents from View |
| 99 | + val actionObservable = view.emitActionIntent() |
| 100 | + .flatMap { interactor.fetchResult() } |
| 101 | + .startWith(PartialState.ProgressState) |
| 102 | + |
| 103 | + //subscribe to backend response |
| 104 | + actionObservable.subscribe { |
| 105 | + view.render(reduce(it)) |
| 106 | + } |
| 107 | + } |
| 108 | + |
| 109 | + //remember to proper manage lifecycle and memory leaks and unbind observers and destroy observable |
| 110 | + |
| 111 | + //map received partial State to Model |
| 112 | + private fun reduce(partialState : PartialState) : Model { |
| 113 | + return when(partialState) { |
| 114 | + PartialState.ProgressState -> Model(progress = true) |
| 115 | + PartialState.ErrorState -> Model(error = true) |
| 116 | + is PartialState.SuccessState -> Model(result = partialState.result) |
| 117 | + } |
| 118 | + } |
| 119 | +} |
| 120 | + |
| 121 | +//define some backend by Repository or Manager or Interactor class etc. |
| 122 | +class Interactor { |
| 123 | + |
| 124 | + fun fetchResult() : Observable<PartialState> { |
| 125 | + //try to download some data and return proper partial state based on result |
| 126 | + return Observable.just(PartialState.SuccessState("some result")) //mock |
| 127 | + } |
| 128 | +} |
| 129 | +{% endhighlight %} |
| 130 | + |
| 131 | +Implementacja `MVI` może zostać zrealizowana na różne sposoby. Częstą praktyką jest wykorzystanie elementów ze wzorca`MVP`, dzięki czemu podział warstw i przepływ pracy staje się przejrzysty. Wprowadzenie interfejsu `View` umożliwia zachowanie ogólności typów i pozwala na ponowną implementacje warstwy widoku. `Presenter` wyraźnie wskazuje miejsce odbierania i przetwarzania intencji oraz decyduje o wartości modelu, natomiast `Interactor` pełni rolę realizacji logiki biznesowej. Niezmiennym elementem wzorca `MVI` jest programowanie reaktywne, które może zostać zrealizowane natywnie lub poprzez zewnętrzne biblioteki. Przeważnie jednak w tym celu wykorzystywana jest `RxJava` wraz z `RxBinding`, które służy do emisji strumienia w odpowiedzi na zdarzenie interfejsu użytkownika. Wyzwanie stanowi także odpowiednie zarządzanie cyklem życia strumieni oraz zachowanie ostatniego znanego stanu co może zostać częściowo osiągnięte przy użyciu klasy `ViewModel`. Ponadto ważnym asptektem jest optymalna implementacja metody `reduce` uwzględniającej stan poprzedni oraz metody `render`, szczególnie w przypadku wielu możliwych stanów. |
| 132 | + |
| 133 | +## Przykład |
| 134 | +Aplikacja Cantor umożliwia śledzenie kursów walut względem waluty bazowej. Wyniki przedstawiane są w postaci listy nieskończonej, która pobiera dane dzień po dniu (wstecz) w odpowiedzi na przesunięcie listy elementów na dół. Aby zapewnić dobry odbiór aplikacji (User Experience) należy odpowiednio prezentować postęp ładowania danych oraz komunikaty błędu. W tym celu zastosowano wzorzec `MVI` wraz z realizacją programowania reaktywnego przez `RxJava` i `RxBinding`. |
| 135 | + |
| 136 | +{% highlight kotlin %} |
| 137 | +data class CantorState( |
| 138 | + val progress : Boolean = false, |
| 139 | + val error : Boolean = false, |
| 140 | + val progressList : Boolean = false, |
| 141 | + val errorList : Boolean = false, |
| 142 | + val items : List<ExchangeRate> = listOf() |
| 143 | +) |
| 144 | + |
| 145 | +sealed class PartialCantorState { |
| 146 | + |
| 147 | + object ProgressState : PartialCantorState() |
| 148 | + object ErrorState : PartialCantorState() |
| 149 | + object ProgressMoreState : PartialCantorState() |
| 150 | + object ErrorMoreState : PartialCantorState() |
| 151 | + class SuccessState(val result : List<ExchangeRate>) : PartialCantorState() |
| 152 | +} |
| 153 | + |
| 154 | +interface CantorView { |
| 155 | + |
| 156 | + fun render(state : CantorState) |
| 157 | + |
| 158 | + fun loadTodayIntent() : Observable<Boolean> |
| 159 | + fun loadMoreIntent() : Observable<Boolean> |
| 160 | +} |
| 161 | + |
| 162 | +class MainActivity : AppCompatActivity(), CantorView { |
| 163 | + |
| 164 | + private val presenter = CantorPresenter(CantorInteractor()) |
| 165 | + private val itemAdapter = ExchangeRatesAdapter() |
| 166 | + |
| 167 | + override fun onCreate(savedInstanceState: Bundle?) { |
| 168 | + super.onCreate(savedInstanceState) |
| 169 | + setContentView(R.layout.activity_main) |
| 170 | + |
| 171 | + recyclerView.apply { |
| 172 | + adapter = itemAdapter |
| 173 | + layoutManager = LinearLayoutManager(this@MainActivity) |
| 174 | + } |
| 175 | + |
| 176 | + presenter.bind(this) |
| 177 | + } |
| 178 | + |
| 179 | + //avoid it, this could makes memory leak, provide saving last state in presenter |
| 180 | + override fun onDestroy() { |
| 181 | + presenter.unbind() |
| 182 | + super.onDestroy() |
| 183 | + } |
| 184 | + |
| 185 | + override fun loadTodayIntent(): Observable<Boolean> { |
| 186 | + return Observable.just(true) |
| 187 | + } |
| 188 | + |
| 189 | + override fun loadMoreIntent(): Observable<Boolean> { |
| 190 | + //use RxBinding |
| 191 | + return recyclerView.scrollStateChanges() |
| 192 | + .filter { isBottomScroll() } |
| 193 | + .map { true } |
| 194 | + } |
| 195 | + |
| 196 | + override fun render(state : CantorState) { |
| 197 | + with(state) { |
| 198 | + showProgress(progress) |
| 199 | + showError(error) |
| 200 | + showProgressMore(progressList) |
| 201 | + showErrorMore(errorList) |
| 202 | + showResult(items) |
| 203 | + } |
| 204 | + } |
| 205 | + |
| 206 | + private fun showProgress(enable : Boolean) { |
| 207 | + //show or hide main progress bar |
| 208 | + } |
| 209 | + |
| 210 | + private fun showError(enable : Boolean) { |
| 211 | + //show or hide some main error container |
| 212 | + } |
| 213 | + |
| 214 | + private fun showProgressMore(enable : Boolean) { |
| 215 | + //show or hide progress bar on the bottom of the list |
| 216 | + } |
| 217 | + |
| 218 | + private fun showErrorMore(enable : Boolean) { |
| 219 | + //show or hide some error about load more |
| 220 | + } |
| 221 | + |
| 222 | + private fun showResult(newItems : List<ExchangeRate>) { |
| 223 | + itemAdapter.addItems(newItems) |
| 224 | + } |
| 225 | + |
| 226 | + private fun isBottomScroll() : Boolean { |
| 227 | + //detect somehow is the bottom of the list to load more items |
| 228 | + val linearLayoutManager = recyclerView.layoutManager as LinearLayoutManager |
| 229 | + return linearLayoutManager.findLastCompletelyVisibleItemPosition() == itemAdapter.itemCount - 1 |
| 230 | + } |
| 231 | +} |
| 232 | + |
| 233 | +class CantorPresenter(private val interactor : CantorInteractor) { |
| 234 | + |
| 235 | + //for proper init and unit stream |
| 236 | + private val compositeDisposable = CompositeDisposable() |
| 237 | + |
| 238 | + //downgrade date by one day for every load more in real app |
| 239 | + private var date = Date(System.currentTimeMillis()) |
| 240 | + |
| 241 | + fun bind(view : CantorView) { |
| 242 | + //create and set all intents from view |
| 243 | + val loadToday = view.loadTodayIntent() |
| 244 | + .flatMap { interactor.fetchDataToday() |
| 245 | + .startWith(PartialCantorState.ProgressState) |
| 246 | + .onErrorReturn { PartialCantorState.ErrorState } |
| 247 | + } |
| 248 | + |
| 249 | + val loadMore = view.loadMoreIntent() |
| 250 | + .flatMap { interactor.fetchDataDay(date) |
| 251 | + .startWith(PartialCantorState.ProgressMoreState) |
| 252 | + .onErrorReturn { PartialCantorState.ErrorMoreState } |
| 253 | + } |
| 254 | + |
| 255 | + //merge them for proper UI and states manage |
| 256 | + val allIntents = Observable.merge(listOf(loadToday, loadMore)) |
| 257 | + |
| 258 | + compositeDisposable.add( |
| 259 | + //use state reducer by scan operator to merge current and previous state |
| 260 | + allIntents.scan(CantorState(), this::reduce).subscribe { view.render(it) } |
| 261 | + ) |
| 262 | + } |
| 263 | + |
| 264 | + fun unbind() { |
| 265 | + compositeDisposable.clear() |
| 266 | + } |
| 267 | + |
| 268 | + private fun reduce(previous : CantorState, changes : PartialCantorState) : CantorState { |
| 269 | + //for some states there could be need previous state data model |
| 270 | + return when(changes) { |
| 271 | + PartialCantorState.ProgressState -> CantorState(progress = true) |
| 272 | + PartialCantorState.ErrorState -> CantorState(error = true) |
| 273 | + PartialCantorState.ProgressMoreState -> CantorState(progressList = true, items = previous.items) //save the data |
| 274 | + PartialCantorState.ErrorMoreState -> CantorState(errorList = true, items = previous.items) //save the data |
| 275 | + is PartialCantorState.SuccessState -> { |
| 276 | + //add data to previous model instead of init only by changes |
| 277 | + val data = mutableListOf<ExchangeRate>() |
| 278 | + data.addAll(previous.items) |
| 279 | + data.addAll(changes.result) |
| 280 | + CantorState(items = data) |
| 281 | + } |
| 282 | + } |
| 283 | + } |
| 284 | +} |
| 285 | + |
| 286 | +class CantorInteractor { |
| 287 | + |
| 288 | + //get data exchange rates from remote server, this is just mock implementation |
| 289 | + //return proper state depends on the fetch items |
| 290 | + |
| 291 | + fun fetchDataToday() : Observable<PartialCantorState> { |
| 292 | + val items = mutableListOf<ExchangeRate>() |
| 293 | + repeat(20) { |
| 294 | + Random.nextDouble(0.1, 100.0) |
| 295 | + items.add(ExchangeRate("Currency $it", random())) |
| 296 | + } |
| 297 | + return Observable.just(PartialCantorState.SuccessState(items)) |
| 298 | + } |
| 299 | + |
| 300 | + fun fetchDataDay(date : Date) : Observable<PartialCantorState> { |
| 301 | + val items = mutableListOf<ExchangeRate>() |
| 302 | + repeat(20) { |
| 303 | + items.add(ExchangeRate("Currency $it", random())) |
| 304 | + } |
| 305 | + return Observable.just(PartialCantorState.SuccessState(items)) |
| 306 | + } |
| 307 | + |
| 308 | + private fun random() : Double { |
| 309 | + return ((1..10000).random() / 100).toDouble() |
| 310 | + } |
| 311 | +} |
| 312 | +{% endhighlight %} |
| 313 | + |
| 314 | +## Biblioteki |
| 315 | +Biblioteka `Mosby` jest przykładem realizacji wzorca `MVI`, która znacznie usprawnia implementację oraz eliminuje problem zmiany konfiguracji i wycieku pamięci. Do realizacji programowania reaktywnego wykorzystuje `RxJava`. W przypadku własnej implementacji zalecanym jest również wykorzystanie biblioteki programowania reaktywnego `RxJava` wraz z `RxBinding`, która upraszczają zarządzanie strumieniami. |
0 commit comments