MVI

Wzorce architektoniczne

  |   15 min czytania

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.

MVI diagram

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.