Apps schnell und effizient programmieren - wer will das nicht? Doch ob das gelingt, entscheidet sich meist schon bei der Wahl des richtigen Software Design Patterns. Für Standardanwendungen, bei denen alle Daten innerhalb einer app-internen Datenbank gespeichert sind und nur der Portrait Modus verfügbar ist, kann Model-View-Controller (kurz: MVC) eine ganz gute Option sein. Doch sobald z.B. Netzwerkaufrufe nötig werden oder der Nutzer zwischen Landscape und Portrait-Modus hin- und herwechseln kann, wird es in der Regel knifflig. Durch trial and error haben wir herausgefunden, dass Model-View-ViewModel (kurz: MVVM) als Software Design Pattern für unsere Zwecke die besten Resultate erzielt. Es ist sicher nicht die einzige Lösung, aber in der App-Entwicklung funktioniert sie hervorragend. Was ist also MVVM und wie unterscheidet es sich von MVC?
Zunächst mal ein kurzer Blick auf MVC. Der Name verrät uns schon einiges:
- Das Model enthält die Daten, die angezeigt werden sollen. Dieser kann Logik enthalten, umfasst aber mindestens alle benötigten Entitäten. In Android ist das Model oft ein Plain Old Java Object (POJO) bzw. eine Datenklasse in Kotlin.
- Die View enthält keine Logik. Sie wird vom Controller benutzt, um die Daten aus dem Model anzuzeigen. In Android wird die View in der Regel durch eine .xml Layout Datei repräsentiert.
- Der Controller wählt die View aus. Er kann Logik enthalten und steuert die Interaktion mit dem Nutzer. In Android übernimmt diese Aufgabe die Activity oder das Fragment.
Im Vergleich nun ein erster Überblick über MVVM:
- Das Model repräsentiert wie bei MVC das Datenmodell.
- Die View enthält auch hier keine Logik, genau wie in MVC.
- Das ViewModel macht den entscheidenden Unterschied: Es enthält die Präsentationslogik und dient als Schnittstelle zwischen dem Model und der View. Das Wichtigste ist aber, dass es keine Informationen über die konkrete Implementierung enthält.
Funktionsweise von MVVM
Aber die einzelnen Bestandteile zu kennen, verrät noch nicht viel. Wie also funktioniert MVVM genau? Der Hauptvorteil ist: Die View kommuniziert bidirektional. Entsprechend reagiert die View auf Änderungen an Variablen, die aus dem ViewModel kommen, während das ViewModel im Gegenzug auch auf Änderungen aus der View reagiert. Ein Beispiel dafür sind Eingabefelder, die entweder durch das ViewModel, etwa mit Daten, die aus dem Internet geladen wurden, oder vom Benutzer geändert werden können.
Was bedeutet das nun für Android? Das klassische MVC-Modell mit Activities und Fragments als Controller hat das Problem, dass diese unter anderem beim Rotieren des Bildschirms neu erzeugt werden. Damit gehen alle im Controller gespeicherten Daten, die nicht manuell einzeln persistiert werden, verloren. Wenn eine Netzwerkanfrage läuft während Fragment oder Activity neu erzeugt werden, läuft deren Ergebnis ins Leere. Ein ViewModel ist dagegen entkoppelt vom Lebenszyklus von Activites und Fragments. Wenn diese neu erzeugt werden, verbindet sich die gleiche ViewModel-Instanz mit der neuen Activity beziehungsweise dem neuen Fragment, wobei alle Daten und Callbacks erhalten bleiben. Das Framework, das sich um die Verbindung von ViewModel und Activity/Fragment kümmert, wurde während der Google I/O Anfang des Jahres präsentiert.
Ein weiterer Vorteil für die App-Entwicklung ist die klare Trennung von Verarbeitungs- und Präsentationslogik. Diese Trennung verbessert die Testability für das ViewModel deutlich, denn im ViewModel sollten keinerlei Referenzen aus der View oder dem Android Framework zu finden sein. Somit kann ein ViewModel mit Unit-Tests lokal auf dem Rechner getestet werden, ohne dass das Android-Framework benötigt wird.
Interaktionen mit dem Nutzer können dank Data Binding, dem Vermittler zwischen ViewModel und View, nach wie vor leicht von der View auf das ViewModel übertragen werden. Und bei Bedarf kann die Logik auf mehrere ViewModels aufgeteilt werden, um den Code übersichtlich zu halten.
Von der Theorie in die Praxis
Mit einem Beispiel aus der realen Welt wird das Konzept noch klarer. Bei Jamit Labs haben wir eine Projektstruktur ausgearbeitet, die das MVVM-Pattern verwendet und laufend weiter verbessert wird. Das Beispielprojekt gibt es auch auf GitHub. Aber jetzt zum Code:
Zuerst werfen wir einen Blick auf das BaseFragment, von dem alle anderen Fragmente erben. In der Regel verwenden wir nur eine einzelne Activity, in der dann pro Ansicht die Fragmente ausgetauscht werden.
Im BaseFragment wird zunächst ein ViewModel über die Lifecycle-Owner-Erweiterung "getViewModelByClass" geladen. Entweder wird hierbei ein neues Objekt instantiiert, oder ein bereits existierendes zurückgegeben, z.B. nach einer Rotation des Bildschirms. Beim Erstellen der View wird dann über die Databinding Utility das Layout erzeugt und an das ViewModel gebunden.
01 abstract class BaseFragment<Binding : ViewDataBinding, VM : BaseViewModel> : Fragment() {
02 abstract val layoutId: Int
03 abstract val viewModelClass: KClass<VM>
04 open val variableId: Int = BR.viewModel
05
06 override fun onCreate(savedInstanceState: Bundle?) {
07 super.onCreate(savedInstanceState)
08 viewModel = getViewModelByClass(viewModelClass)
09 onViewModelInitialised()
10 }
11
12 override fun onCreateView(inflater: LayoutInflater,
13 container: ViewGroup?, savedInstanceState: Bundle?):
14 View? {
15 binding = DataBindingUtil.inflate(inflater, layoutId,
16 container, false)
17 binding.setVariable(variableId, viewModel)
18
19 return binding.root
20 }
21 }
Wie weiter oben bereits erwähnt, darf ein ViewModel keine Referenzen auf das Android-Framework enthalten. Trotzdem ist für manche Aktionen - wie die Navigation zwischen Fragments oder das Anzeigen einer Snackbar - Zugriff auf das Framework erforderlich. Dieses Problem lösen wir über Events, die folgendermaßen aussehen:
01 sealed class CommonAction
02
03 data class ShowSnackbar(
04 val message: String,
05 @ColorRes val colorRes: Int = R.color.colorNegative,
06 val length: Int = Snackbar.LENGTH_LONG
07 ) : CommonAction()
08
09 data class NavigateTo(
10 @IdRes val navigationTargetId: Int,
11 val clearBackStack: Boolean = false,
12 val args: Bundle? = null,
13 val navOptions: NavOptions? = null,
14 val extras: Navigator.Extras? = null
15 ) : CommonAction()
Innerhalb des BaseViewModels, von dem alle ViewModels erben, gibt es ein LiveData-Feld events. In dieses werden aus dem ViewModel alle Ereignisse gesendet, die im Fragment ausgewertet werden sollen.
01 val events = MutableLiveData<Event<CommonAction>>()
Anschließend wird im Fragment ein Observer auf die Ereignisse im ViewModel gesetzt. Dieser informiert das Fragment über alle neuen Events, die aus dem ViewModel gesendet werden. Die observe-Methode stellt außerdem sicher, dass es keine Leaks gibt, wenn das Fragment neu erzeugt wird.
// BaseFragment
01 override fun onActivityCreated(savedInstanceState: Bundle?) {
02 super.onActivityCreated(savedInstanceState)
03
04 viewModel.events.observe(this, Observer { event ->
05 event.getContentIfNotHandled()?.let { content ->
06 when(content) {
07 is ShowSnackbar -> showSnackbar(content)
08 is NavigateTo -> navigateTo(content)
09 }
10 }
11 })
12 }
Die Liste der Events kann bei Bedarf beliebig erweitert werden.
Nachdem wir die Basisklassen angeschaut haben, schauen wir uns noch ein konkretes Fragment an – und damit sind wir quasi schon fertig:
01 class MessageFragment : BaseFragment<FragmentMessageBinding,
02 MessageViewModel>() {
03
04 override val layoutId = R.layout.fragment_message
05 override val viewModelClass = MessageViewModel::class
06
07 override fun onStart() {
08 super.onStart()
09 viewModel.loadItems()
10 }
11
12 }
Das Erstellen eines Fragments ist so ziemlich simpel geworden.
Wie wir weiter oben gesehen haben, liegt die Logik im ViewModel statt im Fragment. Dazu gehören unter Anderem:
- Erstellen von Item-Listen für den Adapter
- Anlegen und Zuordnen von Bindings
- Anwenden von (Klick-)Listenern
- Tätigen von Anrufen
- etc.
Fazit
Bei MVVM wird die meiste Zeit am ViewModel entwickelt, da hier die Haupt-Logik der App liegt. In Kombination mit Data Binding und reaktiver Programmierung löst MVVM dadurch viele der alltäglich auftretenden Schwierigkeiten beim Programmieren von Apps. Alles, was es jetzt noch braucht, ist den Mut, Neues auszuprobieren und mit der Technologie zu experimentieren.