Zastosowanie
MVI
(Model-View-Intent
) 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.
Ograniczenia
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.
Użycie
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
.
Implementacja
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.
Poniższy listing przedstawia realizację wzorca MVI
z pominięciem zależności Android
oraz zastosowaniem RxJava
w celu realizacji programowania reaktywnego.
//define Model where fields values describe the states of the screen
//e.g. screen can be in progress loading data, showing error or the final result
data class Model(
val progress : Boolean = false,
val error : Boolean = false,
val result : String = ""
)
//decompose Model's fields into parts which represent single State
//Kotlin sealead class is great for this
sealed class PartialState {
object ProgressState : PartialState()
object ErrorState : PartialState()
class SuccessState(val result : String) : PartialState()
}
interface View {
//the main responsibility is to react for received state in single method
fun render(state : Model)
//declare methods for emitting Intents like click on the button
fun emitActionIntent() : Observable<Boolean> //observable of RxJava
}
//to simplify MVI idea the View is implemented by some class, in Android it should be e.g. Activity
class ViewImpl : View {
private val presenter = Presenter(Interactor())
public ViewImpl() {
presenter.bindIntents(this)
//init views
emitActionIntent() //emit action on start
}
override fun emitActionIntent() : Observable<Boolean> {
return Observable.just(true)
}
//render received model and react for changes i
override fun render(state : Model) {
with(state) {
showProgress(progress)
showError(error)
showResult(result)
}
}
private fun showProgress(enable : Boolean) {
//show or hide progress bar
}
private fun showError(enable : Boolean) {
//show or hide some error
}
private fun showResult(result : String) {
//show some result
}
}
class Presenter(private val interactor : Interactor) {
fun bindIntents(view : View) {
//bindIntents to observer Intents from View
val actionObservable = view.emitActionIntent()
.flatMap { interactor.fetchResult() }
.startWith(PartialState.ProgressState)
//subscribe to backend response
actionObservable.subscribe {
view.render(reduce(it))
}
}
//remember to proper manage lifecycle and memory leaks and unbind observers and destroy observable
//map received partial State to Model
private fun reduce(partialState : PartialState) : Model {
return when(partialState) {
PartialState.ProgressState -> Model(progress = true)
PartialState.ErrorState -> Model(error = true)
is PartialState.SuccessState -> Model(result = partialState.result)
}
}
}
//define some backend by Repository or Manager or Interactor class etc.
class Interactor {
fun fetchResult() : Observable<PartialState> {
//try to download some data and return proper partial state based on result
return Observable.just(PartialState.SuccessState("some result")) //mock
}
}
Implementacja MVI
może zostać zrealizowana na różne sposoby. Częstą praktyką jest wykorzystanie elementów ze wzorcaMVP
, 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.
Przykład
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
.
data class CantorState(
val progress : Boolean = false,
val error : Boolean = false,
val progressList : Boolean = false,
val errorList : Boolean = false,
val items : List<ExchangeRate> = listOf()
)
sealed class PartialCantorState {
object ProgressState : PartialCantorState()
object ErrorState : PartialCantorState()
object ProgressMoreState : PartialCantorState()
object ErrorMoreState : PartialCantorState()
class SuccessState(val result : List<ExchangeRate>) : PartialCantorState()
}
interface CantorView {
fun render(state : CantorState)
fun loadTodayIntent() : Observable<Boolean>
fun loadMoreIntent() : Observable<Boolean>
}
class MainActivity : AppCompatActivity(), CantorView {
private val presenter = CantorPresenter(CantorInteractor())
private val itemAdapter = ExchangeRatesAdapter()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
recyclerView.apply {
adapter = itemAdapter
layoutManager = LinearLayoutManager(this@MainActivity)
}
presenter.bind(this)
}
//avoid it, this could makes memory leak, provide saving last state in presenter
override fun onDestroy() {
presenter.unbind()
super.onDestroy()
}
override fun loadTodayIntent(): Observable<Boolean> {
return Observable.just(true)
}
override fun loadMoreIntent(): Observable<Boolean> {
//use RxBinding
return recyclerView.scrollStateChanges()
.filter { isBottomScroll() }
.map { true }
}
override fun render(state : CantorState) {
with(state) {
showProgress(progress)
showError(error)
showProgressMore(progressList)
showErrorMore(errorList)
showResult(items)
}
}
private fun showProgress(enable : Boolean) {
//show or hide main progress bar
}
private fun showError(enable : Boolean) {
//show or hide some main error container
}
private fun showProgressMore(enable : Boolean) {
//show or hide progress bar on the bottom of the list
}
private fun showErrorMore(enable : Boolean) {
//show or hide some error about load more
}
private fun showResult(newItems : List<ExchangeRate>) {
itemAdapter.addItems(newItems)
}
private fun isBottomScroll() : Boolean {
//detect somehow is the bottom of the list to load more items
val linearLayoutManager = recyclerView.layoutManager as LinearLayoutManager
return linearLayoutManager.findLastCompletelyVisibleItemPosition() == itemAdapter.itemCount - 1
}
}
class CantorPresenter(private val interactor : CantorInteractor) {
//for proper init and unit stream
private val compositeDisposable = CompositeDisposable()
//downgrade date by one day for every load more in real app
private var date = Date(System.currentTimeMillis())
fun bind(view : CantorView) {
//create and set all intents from view
val loadToday = view.loadTodayIntent()
.flatMap { interactor.fetchDataToday()
.startWith(PartialCantorState.ProgressState)
.onErrorReturn { PartialCantorState.ErrorState }
}
val loadMore = view.loadMoreIntent()
.flatMap { interactor.fetchDataDay(date)
.startWith(PartialCantorState.ProgressMoreState)
.onErrorReturn { PartialCantorState.ErrorMoreState }
}
//merge them for proper UI and states manage
val allIntents = Observable.merge(listOf(loadToday, loadMore))
compositeDisposable.add(
//use state reducer by scan operator to merge current and previous state
allIntents.scan(CantorState(), this::reduce).subscribe { view.render(it) }
)
}
fun unbind() {
compositeDisposable.clear()
}
private fun reduce(previous : CantorState, changes : PartialCantorState) : CantorState {
//for some states there could be need previous state data model
return when(changes) {
PartialCantorState.ProgressState -> CantorState(progress = true)
PartialCantorState.ErrorState -> CantorState(error = true)
PartialCantorState.ProgressMoreState -> CantorState(progressList = true, items = previous.items) //save the data
PartialCantorState.ErrorMoreState -> CantorState(errorList = true, items = previous.items) //save the data
is PartialCantorState.SuccessState -> {
//add data to previous model instead of init only by changes
val data = mutableListOf<ExchangeRate>()
data.addAll(previous.items)
data.addAll(changes.result)
CantorState(items = data)
}
}
}
}
class CantorInteractor {
//get data exchange rates from remote server, this is just mock implementation
//return proper state depends on the fetch items
fun fetchDataToday() : Observable<PartialCantorState> {
val items = mutableListOf<ExchangeRate>()
repeat(20) {
Random.nextDouble(0.1, 100.0)
items.add(ExchangeRate("Currency $it", random()))
}
return Observable.just(PartialCantorState.SuccessState(items))
}
fun fetchDataDay(date : Date) : Observable<PartialCantorState> {
val items = mutableListOf<ExchangeRate>()
repeat(20) {
items.add(ExchangeRate("Currency $it", random()))
}
return Observable.just(PartialCantorState.SuccessState(items))
}
private fun random() : Double {
return ((1..10000).random() / 100).toDouble()
}
}
Biblioteki
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.