Warum Android MVVM eine gute Sache ist

Lesezeit: 13 Min, veröffentlicht am 14.08.2019
Warum Android MVVM eine gute Sache ist

Warum Android MVVM eine gute Sache ist

„Your App has stopped.“

App-Nutzer und -Entwickler hassen diesen Satz. Apps, die nicht mehr reagieren, sich inkonsistent verhalten oder ständig abstürzen, sind ein Ärgernis für alle Beteiligten und können bis zum Misserfolg des Softwareproduktes führen.

Die Ursache von Mängeln in der Softwarequalität ist häufig die unbedachte Strukturierung ihrer einzelnen Komponenten. Durch fehlende Trennung von Anwendungslogik und UI-Framework entstehen schwergewichtige Komponenten, deren Logik schlecht testbar, kaum zu verstehen und schwer modifizierbar ist. Durch enge Kopplung von Komponenten werden Abhängigkeiten geschaffen, die häufig dazu führen, dass die Modifizierung einer Komponente Auswirkungen auf andere Komponenten hat. Kleine Änderungen einer Komponente können so zu großen Änderungen an vielen Komponenten führen (aka „der Rattenschwanz“). Dabei ist jede Änderung mit dem Risiko verbunden, bestehende Funktionalität zu zerstören, ohne es zu merken. Im Zusammenspiel mit schlecht testbaren Komponenten begibt man sich in einen Teufelskreis. Die Auswirkungen beschreibt Robert C. Martin treffend im Buch “Clean Architecture”. Mit der Zeit werden bei Modifikationen immer größere Änderungen nötig, die durch fehlende Tests immer häufiger zu Problemen führen. Das Team wird, je weiter das Projekt fortgeschritten ist, stetig mehr Zeit für Änderungen und Fehlerbeseitigung benötigen. Der daraus resultierende Produktivitätsverlust hat häufig zur Folge, dass sich selbst die Umsetzung kleinster Features über Monate erstreckt und große Mengen von Ressourcen bindet. [1] Unter wirtschaftlichen Gesichtspunkten werden solche Projekte in der Praxis nicht selten eingestellt.

Um den Projektverlauf in eine erfolgsversprechendere Richtung zu steuern, muss sicher gestellt werden, dass ein Softwareprodukt über seinen Lebenszyklus hinweg mit konstantem Aufwand modifizierbar ist und Fehler, die durch Änderung bestehender Komponenten entstehen, frühstmöglich entdeckt werden. Letzteres kann vor allem durch automatisierte Modultests (engl. Unit-Test) erreicht werden. Die grundlegende Strukturierung der App und das Design ihrer Komponenten, hat maßgeblichen Einfluss auf die Modifizierbarkeit, Erweiterbarkeit und die Testbarkeit der verwendeten Komponenten. Architekturmuster wie Model-View-Presenter (MVP) oder Model-View-ViewModel (MVVM) kombinieren verschiedene Konzepte der Software-Entwicklung, wie lose Komponentenkopplung und klare Trennung von Zuständigkeiten und stellen Strukturierungsvorschläge für interaktive Anwendungen wie Android-Apps dar.

Mit der Google I/O 2017 hat Google erstmals selbst eine Empfehlung für ein Architekturmuster ausgesprochen: Das Model-View-ViewModel Pattern. Begleitet wurde die Empfehlung durch die “Android Architecture Components”. Eine Sammlung von Bibliotheken, die unter anderem die Implementierung des MVVM-Patterns auf der Android-Plattform erleichtern sollen.

Auch vor der Einführung der Architecture Components gab es rasant wachsende Apps mit stabiler technischer Basis. Also warum sollte man MVVM benutzen? Dieser Frage gehe ich in diesem Blog nach. Als Vergleich dient das häufig für Android-Apps genutzte MVP-Pattern.

Eine MVP-Implementierung

UI-Framework-Komponenten wie zum Beispiel Android Activities sind schwer automatisiert zu testen. Die Schnittstelle dieser Komponente ist auf die Interaktion mit dem Android OS ausgelegt und nicht auf die programmatische Verwendung durch automatisierte Modultests. Daher empfiehlt es sich, diese Komponenten nur mit den nötigsten Verantwortlichkeiten zu betrauen. Mit diesem Ziel wurde das MVP-Muster entwickelt. Martin Fowler unterscheidet in seinem Essay “Retirement note for Model View Presenter Pattern” [2] zwischen zwei Abwandlungen des MVP-Musters. In diesem Artikel wird beispielhaft, die oft in Android App verwendete “Passive-View” Abwandlung, vorgestellt.

Die Grundidee des MVP ist die Trennung von Präsentation und Domäne. Dazu findet eine Dreiteilung der Anwendungslogik statt: Die Model-Komponente kapselt Domänenlogik und Daten, die von der View-Komponente präsentiert werden. Der Presenter ist eine Art Vermittler, der Nutzeraktionen von der View entgegen nimmt und die entsprechende Funktionalität am Model aufruft. Der Informationsfluss ist in Abbildung 1 dargestellt. Nach Fowler sollte die View-Komponente einer Passive-View MVP-Implementierung nur die absolut nötige Funktionalität implementieren [3]. Die Präsentationslogik wird aus diesem Grund im Presenter implementiert.

MVP Diagram Abbildung 1: Informationsfluss im MVP-Muster

Doch wie lässt sich das MVP-Muster auf eine Android App übertragen? Innerhalb der Android-Community findet man eine Vielzahl unterschiedlicher Meinungen und Ansätze. Die wohl einfachste Trennung wäre, die Android Layouts (XML) als View zu betrachten. Einer Activity käme dann die Bedeutung eines Presenters gleich, der die View steuert und zwischen dem Model und der View vermittelt. Dies ermöglicht die Trennung von Präsentation und Domäne. Ein Nachteil dieser Architektur ist, dass die Steuerungslogik, die View und Model verbindet, weiterhin in einer schlecht testbaren Android-Komponente implementiert ist.

Um die Steuerungslogik aus den schlecht testbaren Android-Komponenten in besser testbare Kotlin-Klassen zu verlagern, empfiehlt es sich, Activities und Fragments als Teil der View zu sehen. Nach dem Passive-View-Ansatz sollte so viel Präsentationslogik wie möglich aus der View heraus in den Presenter hinein verlagert werden. Dieser kann als pure Kotlin-Klasse implementiert werden und sollte frei von Abhängigkeiten zum Android-Framework gehalten werden. Durch die bidirektionale Abhängigkeit zwischen View und Presenter werden beide Komponenten eng miteinander gekoppelt. Der Presenter kann dann nicht mehr ohne Weiteres durch Modultests getestet werden, da zu dessen Initialisierung eine Activity oder ein Fragment nötig wäre. Um den Presenter von Android-Komponenten zu entkoppeln, implementiert die View ein Interface, über das der Presenter mit ihr kommuniziert. Im Testkontext kann das Interface einfach gegen einen Stub ausgetauscht werden.

Listing 1 zeigt eine Presenter-Implementierung am Beispiel einer App, die den Body-Mass-Index berechnet. Der Presenter interagiert über das BmiView-Interface mit dem BmiFragment und ruft in Abhängigkeit zur Nutzeraktion Funktionalität des Models auf. Das Model ist in diesem Beispiel als “Usecase”-Klasse implementiert.

class BmiPresenter(private val view: BmiView) {

    private val bodyMassIndex = BodyMassIndexUsecase()

    fun dimensionChanged(
        bodyWeightGramm: String,
        bodyHeightCm: String
    ) = calculateBodyMassIndex(
        bodyWeightGramm.toInt(),
        bodyHeightCm.toInt()
    ).updateViewWith()

    private fun calculateBodyMassIndex(
        bodyWeightGramm: Int,
        bodyHeightCm: Int
    ) = bodyMassIndex
        .execute(
            bodyWeightGramm,
            bodyHeightCm
        )

    private fun Bmi.updateViewWith() {
        view.setBmi(bmi)
        view.setClassification(classification.name)
    }
}

Listing 1: Der BmiPresenter

Über das Interface BmiView kann der Presenter die View aktualisieren. Dazu werden die in Listing 2 abgebildeten Funktionen von der View implementiert.

interface BmiView {
    fun setBmi(bmi: String)
    fun setClassification(classification: String)
}

Listing 2: Das BmiView Interface

Die Entkopplung des Presenters von einer konkreten View-Implementierung hat mehrere Vorteile. Sie erhöht die Testbarkeit des Presenters, da BmiView einfach als Stub implementiert werden kann. Darüber hinaus vereinfacht sie die Implementierung verschiedener Sichten auf das Model.

class BmiFragment : Fragment(), BmiView {
    lateinit var presenter: BmiPresenter

    private val heightTextWatcher = object: TextWatcher {
        override fun afterTextChanged(newText: Editable?) {
            val weight = weightEditText.text.toString()
            val height = newText.toString()
            presenter.dimensionChanged(weight, height)
        }

       // More overrides
    }

    override fun setBmi(bmi: Int) {
        bmiTextView.text = bmi.toString()
    }

    override fun setClassification(classification: String) {
        bmiClassificationTextView.text = classification
    }

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        presenter = BmiPresenter(this)
        heightEditText.addTextChangedListener(heightTextWatcher)
   }
}

Listing 3: Das BmiFragment implementiert BmiView, um die Steuerung durch den Presenter zu ermöglichen.

BmiView kann von einer Activity oder einem Fragment implementiert werden. Diese Komponenten nutzen Daten und Funktionen, die der Presenter bereit stellt, um die View zu aktualisieren. Listing 3 zeigt wie die EditTextView zur Eingabe der Körpergröße an den Presenter gebunden wird. Ausgehend vom EditTextView werden Nutzeraktionen über den Textwatcher an den Presenter weitergegeben. Dieser interagiert mit dem Model und stößt dessen Neuberechnung nach Änderung der Körpergröße an. Der aktualisierte BMI wird dann vom Presenter zurück an das BmiFragment gegeben, welches die View aktualisiert.

Durch die Umsetzung des MVP-Musters kann erreicht werden, dass die Steuerung der View vom Presenter übernommen wird. Dadurch wird eine logische Trennung zwischen Anwendungslogik und Präsentationslogik möglich, was sich wiederum positiv auf die Modultestbarkeit auswirkt und Komponenten gegen Änderung anderer Komponenten absichert. Wie in Listing 1 zu sehen ist, sind die Informationen, wie die View mit welchen Daten bei welchem Ergebnis zu aktualisieren sind, im Presenter hinterlegt. Dennoch ist das BmiFragment verhältnismäßig groß. Verblieben ist „Verbindungscode“, der die Android-Views auf den Presenter abbildet. Dieser Code ist weiterhin schlecht testbar. Ein weiterer Nachteil ergibt sich aus der bidirektionalen Abhängigkeit zwischen Presenter und View. Führt der Presenter lang andauernde Hintergrundaufgaben aus, kann eine unachtsame Implementierung dazu führen, dass der Presenter eine nicht mehr aktive View aktualisieren möchte, was zu Programmabstürzen führen kann. Andersrum können langlebige Presenter-Implementierungen durch ihre Referenzen auf die View zu Memory Leaks führen. Um dies zu verhindern, müssen lange andauernde Aufgaben über die entsprechenden Lifecycle Callbacks der View abgebrochen werden. Presenter sollten nicht als Singletons implementiert werden, um zu verhindern, dass Referenzen auf Views leaken. Beides kann im Eifer des Gefechts schnell vergessen werden.

Zusammengefasst kann das MVP-Pattern helfen, eine saubere Trennung zwischen Präsentationslogik und Domänenlogik zu erreichen. Durch die Implementierung von Presenter-Klassen kann Steuerungslogik aus Android-Komponenten in besser testbare Kotlin-Klassen verschoben werden. Die Entkopplung der Darstellung von Präsentations- und Anwendungslogik vereinfacht die Implementierung von unterschiedlichen Sichten auf das Model. Den Vorteilen gegenüber steht ein erhöhter Programmierungsaufwand, zur Implementierung von Getter- und Setter-Methoden. Trotz des Passive-View Ansatzes verbleibt “Verbindungscode” in den schlecht testbaren Android-Komponenten. Darüber hinaus sind MVP-Implementierungen unter Android anfällig für die Verursachung von Memory-Leaks und für durch den Android-Lifecycle bedingten Laufzeitfehler.

Von MVP zu MVVM

Das MVVM-Muster wurde 2005 von Microsoft entwickelt [4]. Es kann als eine Abwandlung des MVP Patterns gesehen werden. Ähnlich zu MVP sieht auch das MVVM Pattern eine Dreiteilung der Anwendungslogik auf die Komponenten View, ViewModel und Model vor. Die Bedeutung des Models und der View unterscheiden sich nicht zur MVP-Implementierung. Der Presenter wurde durch das ViewModel ersetzt.

Ein wesentlicher Unterschied zwischen dem MVP- und MVVM-Muster ist die Verwendung von Data Binding. Unter Data Binding versteht man die Verknüpfung eines UI-Elements, beispielsweise eines Textfeldes mit einer Eigenschaft eines Datenobjektes. Durch die Verwendung des Beobachter-Musters kann so eine bidirektionale Synchronisierung zwischen Model und View geschaffen werden. Änderungen am Model werden automatisch an die View propagiert, Änderungen der View wiederum werden automatisch an das Model gespiegelt.

Ähnlich einem Presenter besteht die Aufgabe des ViewModels unter anderem in der Vermittlung zwischen Model und View. Während der Presenter die View direkt manipuliert, exportiert das ViewModel beobachtbare Eigenschaften und Aktionen, die mittels Data Binding direkt an UI-Elemente gebunden werden. Diese Eigenschaften und Aktionen beschreiben, welche Funktionalität von der View bereitgestellt werden muss, die View entscheidet, wie sie dargestellt werden. Nutzeraktionen verändern den Zustand der ViewModel-Eigenschaften. Das ViewModel ruft darauf hin die benötigte Funktionalität des Models auf und aktualisiert seine Eigenschaften mit dem Ergebnis. Durch Data Binding wird dann das assoziierte UI-Element automatisch aktualisiert.

Der Informationsfluss zwischen den MVVM-Komponenten ist in Abbildung 2 dargestellt. Nutzeraktionen werden von der View entgegen genommen. Über eine Implementierung des Beobachtermusters werden sie an das ViewModel propagiert, das dann entscheidet welche Model-Funktionalität aufzurufen ist. Sobald das Model die Verarbeitung des Events abgeschlossen hat, wird das ViewModel über das Ergebnis informiert. Das ViewModel ändert nun seine öffentlichen Eigenschaften. Diese Änderungen werden anschließend automatisch an die View emittiert, welche die aktualisierten Informationen darstellt.

MVVM Diagram Abbildung 2: Informationsfluss im MVP-Muster

Android MVVM mit den Android Architecture Components

Die Android Architecture Components sind eine Sammlung von Tools und Libraries, die entwickelt wurden, um die Erstellung von stabilen und testbaren Android-Apps zu vereinfachen. Teil der Architecture Components sind Libraries, die die Umsetzung des MVVM-Musters auf Android erleichtern können. Die Data Binding Library beispielsweise ermöglicht das Binden von Views an ViewModel Eigenschaften über XML. Dafür wird eine Ausdruckssprache im XML-Layout genutzt. So können Eigenschaften von Android Views, wie zum Beispiel android:text, an Eigenschaften von ViewModels gebunden werden. Listing 4 zeigt einen Ausschnitt des BmiFragment-Layout aus der BMI-App unter Verwendung der Data Binding-Library. Um Data Bindings nutzen zu können, muss die Wurzel des Layouts vom Typ layout sein. Innerhalb des layout-Elements werden die Objekte, deren Eigenschaften gebunden werden sollen, durch das data-Element definiert. Diese können fortan in den Bindungsausdrücken genutzt werden. Durch den Ausdruck @{bmiViewModel.bmi} wird beispielsweise das android:text-Attribut des TextViews an die Eigenschaft bmi des BmiViewModel gebunden. Bei jeder Änderung der Eigenschaft im ViewModel wird das android:text Attribut des assoziierten TextView automatisch aktualisiert.

<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
        xmlns:tools="http://schemas.android.com/tools">

    <data>
        <variable
                name="bmiViewModel"
                type=".BmiViewModel"/>
    </data>

    <LinearLayout
            android:orientation="vertical"
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            tools:context=".view.BmiFragment"
            android:padding="16dp">
        <!-- More layout here ... -->

        <TextView
                android:id="@+id/bmiTextView"
                android:layout_width="match_parent"
                android:layout_height="wrap_content"
                android:textAlignment="center"
                android:text=„@{bmiViewModel.bmi}"/>

        <!-- More layout here ... -->
    </LinearLayout>
</layout>

Listing 4: Auszug des BmiFragment-Layouts mit unidirektionalem Data Binding

Die Eigenschaft bmi des BmiViewModel, ist vom Typ LiveData<String>. LiveData ist ein Daten-Container aus der Sammlung der Architecture Components, der das Beobachter-Muster implementiert. View-Komponenten können sich bei LiveData-Objekten registrieren und werden dann über Wertänderungen informiert. Im Gegensatz zu anderen Event-basierten Libraries wie RxJava hat LiveData Kenntnis über den Lebenszyklus von Activities und Fragments. Ein LiveData-Objekt wird bei dessen Erzeugung an einen LifecycleOwner gebunden. Durch diese Bindung kann sich die View automatisch vom LiveData-Objekt deregistrieren, beispielsweise wenn die View vom Android-Betriebssystem zerstört wird. [5] Die Nutzung von LiveData ist daher weniger fehleranfällig, da die Deregistrierung der Beobachter entfällt.

TextViews stellen Informationen aus dem Model dar. EditTextViews hingegen nehmen zusätzlich Nutzeraktionen in Form von Texteingaben entgegen. Diese müssen an das ViewModel propagiert werden. Diese bidirektionale Synchronisierung wird durch die @=-Schreibweise (Listing 5) des Bindungsausdruckes erreicht. Die Verwendung von LiveData in Verbindung mit Zwei-Wege-Synchronisierung von Android Views und ViewModel Eigenschaften hat zur Folge, dass das ViewModel LiveData-Objekte beobachten muss. Da das ViewModel selbst kein LifecycleOwner ist und auch keine Informationen über die assoziierte View inne hat, kann keine automatische Deregistrierung der View erfolgen, sondern muss manuell durch den Entwickler durchgeführt werden.

<!-- More layout here ... -->
<EditText
        android:id="@+id/heightEditText"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:inputType="numberDecimal"
        android:hint="@string/default_height_hint"
        android:text="@={bmiViewModel.bodyHeightCm}"/>
<!-- More layout here ... -->

Listing 5: Zwei-Wege Data Binding eines EditText-Views

Die fehleranfällige Beobachtung von LiveData-Objekten im ViewModel kann vermieden werden, indem Views an reguläre Variablen des ViewModels gebunden werden. Die gebundene Variable muss dazu explizite Getter- und Setter-Methoden bereitstellen. Listing 6 zeigt wie die vom Nutzer eingegebene Körpergröße vom BmiViewModel verwaltet wird. Die Eigenschaft bodyHeightCm wird mit dem Defaultwert DEFAULT_HEIGHT_DISPLAYED initialisiert. Ändert sich der Wert des assoziierten EditTexts durch eine Eingabe des Nutzers, wird über die Setter-Methode der Eigenschaft die Funktion dimensionChanged() aufgerufen. Diese aktualisiert das Model und gibt das Ergebnis an die Methode updateState() weiter. Durch die Neuzuweisung der value-Eigenschaft der beiden LiveData-Objekte bmi und bmiClassification ändern sich die assoziierten TextViews automatisch durch das Data Binding.

class BmiViewModel : ViewModel() {
    private val bmiUsecase = BodyMassIndexUsecase()

    val bmi: MutableLiveData<String> by lazy { MutableLiveData<String>() }
    val bmiClassification: MutableLiveData<String> by lazy { MutableLiveData<String>() }

    var bodyHeightCm = DEFAULT_HEIGHT_DISPLAYED
        set(value) {
            field = value
            dimensionChanged()
        }
        
    /*
     * ...
     */
        
    private fun dimensionChanged() = updateStates(calculateBmi())

    private fun calculateBmi(): Bmi = bmiUsecase
        .execute(
            bodyWeightGramm.toInt(),
            bodyHeightCm.toInt()
        )

    private fun updateStates(modelResult: Bmi) {
        bmi.value = modelResult.bmi.toString()
        bmiClassification.value = modelResult.classification.name    }
}

Listing 6: BmiViewModel

Das Binden der Views an Daten aus dem ViewModel übernimmt die Data Binding Library. Die dazu benötigte Logik wird während der Kompilierung der App erzeugt. Zur Laufzeit werden diese Klassen genutzt, um das Data binding zu initialiseren. Der nötige Code ist in Listing 7 abgebildet.

class BmiFragment : Fragment() {
    // More code...
    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View? {
        viewModel = ViewModelProviders.of(this).get(BmiViewModel::class.java)
        val binding = FragmentBmiBinding.inflate(inflater, container, false)
        binding.lifecycleOwner = this
        binding.bmiViewModel = viewModel
        return binding.root
    }
    // More code...
}

Listing 7: Initialisieren des Data Bindings und Auflösen des ViewModels

Listing 7 zeigt, wie mit Hilfe der generierten FragmentBmiBinding-Klasse das Data Binding initialisiert wird. Die View-Komponente wird über das Binding-Objekt mit dem ViewModel verknüpft.

Aus Listing 7 geht ebenfalls hervor, dass das ViewModel über den ViewModelProvider aufgelöst wird. Die ViewModel-Implementierung der Architecture Components besitzt einen eigenen Lifecycle, der unabhängig vom Lifecycle der View-Komponente ist. Dies hat den Vorteil, das ViewModels über ConfigurationChanges hinweg am Leben bleiben. [6] Activities und Fragments werden beispielweise neu erzeugt, sobald das Gerät gedreht wird. Das ViewModel überlebt diese Konfigurationsänderung. Dies erspart Programmieraufwand und macht die Implementierung weniger anfällig für Fehler.

Warum also Android MVVM nutzen?

Das MVVM-Muster vereint Konzepte, die bei der Entwicklung von testbaren und erweiterbaren Android-Apps helfen können. MVVM erbt die Vorteile des MVP-Musters und kann einige Nachteile abschwächen. Im Vergleich zum MVP-Muster kann die Logik in schwer testbaren Activities und Fragments durch Data Binding weiter reduziert werden. Darüber hinaus kann durch den Einsatz des Beobachter-Entwurfsmusters die implizite Kopplung zwischen View und Presenter eliminiert werden. Dies verringert das Risiko Memory-Leaks zu erzeugen oder Programmabstürze durch Zugriff auf zerstörte Views zu verursachen. Komponenten wie LiveData oder das ViewModel aus den Android Architecture Components unterstützen den Entwickler bei der Vermeidung gängiger Fehler. Die Verwendung des ViewModels erleichtert den Umgang mit Konfigurationsänderungen.

Dennoch sind in der Praxis die projektspezifischen Anforderungen maßgeblich für die Wahl der Architektur. Für Apps mit simplen Benutzeroberflächen kann die Umsetzung von Android MVVM ein schlechtes Zeit-Nutzen-Verhältnis haben. Zudem sind Data Bindings schwer zu Debuggen. Android MVVM stellt trotzdem eine sinnvolle Basis an Konzepten und Werkzeugen zur Verfügung, die einen Leitfaden bei der Strukturierung von Android Apps darstellen und nach Bedarf in andere App-Architektur integriert werden können.

Quellen

[1] “Clean Architecture” - Robert C. Martin

[2] Retirement note for Model View Presenter Pattern

[3] Passive View

[4] Introduction to Model/View/ViewModel pattern for building WPF apps

[5] Architecture Components - LiveData

[6] Architecture Components

Tags

Verfasst von:

Foto von Ricardo

Ricardo

Ricardo ist Android Engineer bei cosee und hat bereits jahrelang Erfahrungen in Android Native und reaktiver Programmierung gesammelt.