Bessere Android-Apps mit MVVM mit sauberer Architektur

Veröffentlicht: 2022-03-11

Wenn Sie nicht die richtige Architektur für Ihr Android-Projekt auswählen, wird es Ihnen schwer fallen, es zu warten, wenn Ihre Codebasis wächst und Ihr Team wächst.

Dies ist nicht nur ein Android-MVVM-Tutorial. In diesem Artikel werden wir MVVM (Model-View-ViewModel oder manchmal stilisiert „das ViewModel-Muster“) mit Clean Architecture kombinieren. Wir werden sehen, wie diese Architektur verwendet werden kann, um entkoppelten, testbaren und wartbaren Code zu schreiben.

Warum MVVM mit sauberer Architektur?

MVVM trennt Ihre Ansicht (dh Activity und Fragment ) von Ihrer Geschäftslogik. MVVM reicht für kleine Projekte aus, aber wenn Ihre Codebasis riesig wird, fangen Ihre ViewModel an aufzublähen. Die Trennung von Verantwortlichkeiten wird schwierig.

MVVM mit Clean Architecture ist in solchen Fällen ziemlich gut. Es geht noch einen Schritt weiter, indem es die Verantwortlichkeiten Ihrer Codebasis trennt. Es abstrahiert klar die Logik der Aktionen, die in Ihrer App ausgeführt werden können.

Hinweis: Sie können Clean Architecture auch mit der Model-View-Presenter (MVP)-Architektur kombinieren. Da Android-Architekturkomponenten jedoch bereits eine integrierte ViewModel -Klasse bereitstellen, verwenden wir MVVM über MVP – kein MVVM-Framework erforderlich!

Vorteile der Verwendung einer sauberen Architektur

  • Ihr Code ist sogar noch einfacher testbar als mit reinem MVVM.
  • Ihr Code wird weiter entkoppelt (der größte Vorteil.)
  • Die Paketstruktur ist noch einfacher zu navigieren.
  • Das Projekt ist noch einfacher zu warten.
  • Ihr Team kann neue Funktionen noch schneller hinzufügen.

Nachteile sauberer Architektur

  • Es hat eine leicht steile Lernkurve. Es kann einige Zeit dauern, bis Sie verstehen, wie alle Schichten zusammenarbeiten, insbesondere wenn Sie von Mustern wie einfachem MVVM oder MVP kommen.
  • Es fügt viele zusätzliche Klassen hinzu und ist daher nicht ideal für Projekte mit geringer Komplexität.

Unser Datenfluss sieht folgendermaßen aus:

Der Datenfluss von MVVM mit Clean Architecture. Daten fließen von View zu ViewModel zur Domäne zum Datenrepository und dann zu einer Datenquelle (lokal oder remote).

Unsere Geschäftslogik ist vollständig von unserer Benutzeroberfläche entkoppelt. Es macht unseren Code sehr einfach zu warten und zu testen.

Das Beispiel, das wir sehen werden, ist ziemlich einfach. Es ermöglicht Benutzern, neue Beiträge zu erstellen und eine Liste der von ihnen erstellten Beiträge anzuzeigen. Der Einfachheit halber verwende ich in diesem Beispiel keine Bibliothek von Drittanbietern (wie Dagger, RxJava usw.).

Die Schichten von MVVM mit sauberer Architektur

Der Code ist in drei separate Schichten unterteilt:

  1. Präsentationsfolie
  2. Domänenschicht
  3. Datenschicht

Wir werden weiter unten auf jede Ebene näher eingehen. Im Moment sieht unsere resultierende Paketstruktur so aus:

MVVM mit Clean Architecture-Paketstruktur.

Selbst innerhalb der von uns verwendeten Android-App-Architektur gibt es viele Möglichkeiten, Ihre Datei-/Ordnerhierarchie zu strukturieren. Ich gruppiere Projektdateien gerne nach Features. Ich finde es übersichtlich und prägnant. Sie können frei wählen, welche Projektstruktur zu Ihnen passt.

Die Präsentationsschicht

Dazu gehören unsere Activity s, Fragment s und ViewModel s. Eine Activity sollte so dumm wie möglich sein. Setzen Sie niemals Ihre Geschäftslogik in Activity ein.

Eine Activity kommuniziert mit einem ViewModel und ein ViewModel kommuniziert mit der Domänenebene, um Aktionen auszuführen. Ein ViewModel nie direkt mit der Datenschicht.

Hier übergeben wir einen UseCaseHandler und zwei UseCase an unser ViewModel . Darauf werden wir gleich näher eingehen, aber in dieser Architektur ist ein UseCase eine Aktion, die definiert, wie ein ViewModel mit der Datenschicht interagiert.

So sieht unser Kotlin-Code aus:

 class PostListViewModel( val useCaseHandler: UseCaseHandler, val getPosts: GetPosts, val savePost: SavePost): ViewModel() { fun getAllPosts(userId: Int, callback: PostDataSource.LoadPostsCallback) { val requestValue = GetPosts.RequestValues(userId) useCaseHandler.execute(getPosts, requestValue, object : UseCase.UseCaseCallback<GetPosts.ResponseValue> { override fun onSuccess(response: GetPosts.ResponseValue) { callback.onPostsLoaded(response.posts) } override fun onError(t: Throwable) { callback.onError(t) } }) } fun savePost(post: Post, callback: PostDataSource.SaveTaskCallback) { val requestValues = SavePost.RequestValues(post) useCaseHandler.execute(savePost, requestValues, object : UseCase.UseCaseCallback<SavePost.ResponseValue> { override fun onSuccess(response: SavePost.ResponseValue) { callback.onSaveSuccess() } override fun onError(t: Throwable) { callback.onError(t) } }) } }

Die Domänenschicht

Die Domänenschicht enthält alle Anwendungsfälle Ihrer Anwendung. In diesem Beispiel haben wir UseCase , eine abstrakte Klasse. Alle unsere UseCase werden diese Klasse erweitern.

 abstract class UseCase<Q : UseCase.RequestValues, P : UseCase.ResponseValue> { var requestValues: Q? = null var useCaseCallback: UseCaseCallback<P>? = null internal fun run() { executeUseCase(requestValues) } protected abstract fun executeUseCase(requestValues: Q?) /** * Data passed to a request. */ interface RequestValues /** * Data received from a request. */ interface ResponseValue interface UseCaseCallback<R> { fun onSuccess(response: R) fun onError(t: Throwable) } }

Und UseCaseHandler behandelt die Ausführung eines UseCase . Wir sollten die Benutzeroberfläche niemals blockieren, wenn wir Daten aus der Datenbank oder unserem Remote-Server abrufen. Dies ist der Ort, an dem wir uns entscheiden, unseren UseCase in einem Hintergrundthread auszuführen und die Antwort im Hauptthread zu erhalten.

 class UseCaseHandler(private val mUseCaseScheduler: UseCaseScheduler) { fun <T : UseCase.RequestValues, R : UseCase.ResponseValue> execute( useCase: UseCase<T, R>, values: T, callback: UseCase.UseCaseCallback<R>) { useCase.requestValues = values useCase.useCaseCallback = UiCallbackWrapper(callback, this) mUseCaseScheduler.execute(Runnable { useCase.run() }) } private fun <V : UseCase.ResponseValue> notifyResponse(response: V, useCaseCallback: UseCase.UseCaseCallback<V>) { mUseCaseScheduler.notifyResponse(response, useCaseCallback) } private fun <V : UseCase.ResponseValue> notifyError( useCaseCallback: UseCase.UseCaseCallback<V>, t: Throwable) { mUseCaseScheduler.onError(useCaseCallback, t) } private class UiCallbackWrapper<V : UseCase.ResponseValue>( private val mCallback: UseCase.UseCaseCallback<V>, private val mUseCaseHandler: UseCaseHandler) : UseCase.UseCaseCallback<V> { override fun onSuccess(response: V) { mUseCaseHandler.notifyResponse(response, mCallback) } override fun onError(t: Throwable) { mUseCaseHandler.notifyError(mCallback, t) } } companion object { private var INSTANCE: UseCaseHandler? = null fun getInstance(): UseCaseHandler { if (INSTANCE == null) { INSTANCE = UseCaseHandler(UseCaseThreadPoolScheduler()) } return INSTANCE!! } } }

Wie der Name schon sagt, ist der GetPosts UseCase dafür verantwortlich, alle Posts eines Benutzers zu erhalten.

 class GetPosts(private val mDataSource: PostDataSource) : UseCase<GetPosts.RequestValues, GetPosts.ResponseValue>() { protected override fun executeUseCase(requestValues: GetPosts.RequestValues?) { mDataSource.getPosts(requestValues?.userId ?: -1, object : PostDataSource.LoadPostsCallback { override fun onPostsLoaded(posts: List<Post>) { val responseValue = ResponseValue(posts) useCaseCallback?.onSuccess(responseValue) } override fun onError(t: Throwable) { // Never use generic exceptions. Create proper exceptions. Since // our use case is different we will go with generic throwable useCaseCallback?.onError(Throwable("Data not found")) } }) } class RequestValues(val userId: Int) : UseCase.RequestValues class ResponseValue(val posts: List<Post>) : UseCase.ResponseValue }

Der Zweck der UseCase s besteht darin, ein Vermittler zwischen Ihren ViewModel s und Repository s zu sein.

Nehmen wir an, Sie entscheiden sich in Zukunft, eine Funktion zum Bearbeiten von Beiträgen hinzuzufügen. Alles, was Sie tun müssen, ist, einen neuen EditPost UseCase und der gesamte Code wird vollständig von anderen UseCase getrennt und entkoppelt. Wir haben es alle schon oft gesehen: Neue Funktionen werden eingeführt und beschädigen versehentlich etwas in bereits vorhandenem Code. Das Erstellen eines separaten UseCase hilft immens, dies zu vermeiden.

Natürlich können Sie diese Möglichkeit nicht zu 100 Prozent ausschließen, aber Sie können sie auf jeden Fall minimieren. Das unterscheidet Clean Architecture von anderen Mustern: Der Code ist so entkoppelt, dass Sie jede Ebene wie eine Black Box behandeln können.

Die Datenschicht

Diese enthält alle Repositorys, die die Domänenschicht verwenden kann. Diese Ebene macht eine Datenquellen-API für externe Klassen verfügbar:

 interface PostDataSource { interface LoadPostsCallback { fun onPostsLoaded(posts: List<Post>) fun onError(t: Throwable) } interface SaveTaskCallback { fun onSaveSuccess() fun onError(t: Throwable) } fun getPosts(userId: Int, callback: LoadPostsCallback) fun savePost(post: Post) }

PostDataRepository implementiert PostDataSource . Es entscheidet, ob wir Daten von einer lokalen Datenbank oder einem entfernten Server abrufen.

 class PostDataRepository private constructor( private val localDataSource: PostDataSource, private val remoteDataSource: PostDataSource): PostDataSource { companion object { private var INSTANCE: PostDataRepository? = null fun getInstance(localDataSource: PostDataSource, remoteDataSource: PostDataSource): PostDataRepository { if (INSTANCE == null) { INSTANCE = PostDataRepository(localDataSource, remoteDataSource) } return INSTANCE!! } } var isCacheDirty = false override fun getPosts(userId: Int, callback: PostDataSource.LoadPostsCallback) { if (isCacheDirty) { getPostsFromServer(userId, callback) } else { localDataSource.getPosts(userId, object : PostDataSource.LoadPostsCallback { override fun onPostsLoaded(posts: List<Post>) { refreshCache() callback.onPostsLoaded(posts) } override fun onError(t: Throwable) { getPostsFromServer(userId, callback) } }) } } override fun savePost(post: Post) { localDataSource.savePost(post) remoteDataSource.savePost(post) } private fun getPostsFromServer(userId: Int, callback: PostDataSource.LoadPostsCallback) { remoteDataSource.getPosts(userId, object : PostDataSource.LoadPostsCallback { override fun onPostsLoaded(posts: List<Post>) { refreshCache() refreshLocalDataSource(posts) callback.onPostsLoaded(posts) } override fun onError(t: Throwable) { callback.onError(t) } }) } private fun refreshLocalDataSource(posts: List<Post>) { posts.forEach { localDataSource.savePost(it) } } private fun refreshCache() { isCacheDirty = false } }

Der Code ist größtenteils selbsterklärend. Diese Klasse hat zwei Variablen, localDataSource und remoteDataSource . Ihr Typ ist PostDataSource , daher ist es uns egal, wie sie tatsächlich unter der Haube implementiert werden.

In meiner persönlichen Erfahrung hat sich diese Architektur als unschätzbar erwiesen. In einer meiner Apps habe ich mit Firebase am Backend begonnen, was großartig ist, um Ihre App schnell zu erstellen. Ich wusste, dass ich irgendwann auf meinen eigenen Server umsteigen musste.

Dabei musste ich lediglich die Implementierung in RemoteDataSource . Ich musste selbst nach einer so großen Veränderung keine andere Klasse berühren. Das ist der Vorteil von entkoppeltem Code. Das Ändern einer bestimmten Klasse sollte sich nicht auf andere Teile Ihres Codes auswirken.

Einige der zusätzlichen Klassen, die wir haben, sind:

 interface UseCaseScheduler { fun execute(runnable: Runnable) fun <V : UseCase.ResponseValue> notifyResponse(response: V, useCaseCallback: UseCase.UseCaseCallback<V>) fun <V : UseCase.ResponseValue> onError( useCaseCallback: UseCase.UseCaseCallback<V>, t: Throwable) } class UseCaseThreadPoolScheduler : UseCaseScheduler { val POOL_SIZE = 2 val MAX_POOL_SIZE = 4 val TIMEOUT = 30 private val mHandler = Handler() internal var mThreadPoolExecutor: ThreadPoolExecutor init { mThreadPoolExecutor = ThreadPoolExecutor(POOL_SIZE, MAX_POOL_SIZE, TIMEOUT.toLong(), TimeUnit.SECONDS, ArrayBlockingQueue(POOL_SIZE)) } override fun execute(runnable: Runnable) { mThreadPoolExecutor.execute(runnable) } override fun <V : UseCase.ResponseValue> notifyResponse(response: V, useCaseCallback: UseCase.UseCaseCallback<V>) { mHandler.post { useCaseCallback.onSuccess(response) } } override fun <V : UseCase.ResponseValue> onError( useCaseCallback: UseCase.UseCaseCallback<V>, t: Throwable) { mHandler.post { useCaseCallback.onError(t) } } }

UseCaseThreadPoolScheduler ist für die asynchrone Ausführung von Aufgaben mit ThreadPoolExecuter .

 class ViewModelFactory : ViewModelProvider.Factory { override fun <T : ViewModel> create(modelClass: Class<T>): T { if (modelClass == PostListViewModel::class.java) { return PostListViewModel( Injection.provideUseCaseHandler() , Injection.provideGetPosts(), Injection.provideSavePost()) as T } throw IllegalArgumentException("unknown model class $modelClass") } companion object { private var INSTANCE: ViewModelFactory? = null fun getInstance(): ViewModelFactory { if (INSTANCE == null) { INSTANCE = ViewModelFactory() } return INSTANCE!! } } }

Dies ist unsere ViewModelFactory . Sie müssen dies erstellen, um Argumente in Ihrem ViewModel -Konstruktor zu übergeben.

Abhängigkeitsspritze

Ich erkläre die Abhängigkeitsinjektion anhand eines Beispiels. Wenn Sie sich unsere Klasse PostDataRepository , hat sie zwei Abhängigkeiten, LocalDataSource und RemoteDataSource . Wir verwenden die Injection -Klasse, um diese Abhängigkeiten für die PostDataRepository -Klasse bereitzustellen.

Die Injektion von Abhängigkeit hat zwei Hauptvorteile. Einer davon ist, dass Sie die Instanziierung von Objekten von einem zentralen Ort aus steuern können, anstatt sie über die gesamte Codebasis zu verteilen. Ein weiterer Grund ist, dass uns dies beim Schreiben von Komponententests für PostDataRepository helfen wird, da wir jetzt nur verspottete Versionen von LocalDataSource und RemoteDataSource anstelle von tatsächlichen Werten an den PostDataRepository Konstruktor übergeben können.

 object Injection { fun providePostDataRepository(): PostDataRepository { return PostDataRepository.getInstance(provideLocalDataSource(), provideRemoteDataSource()) } fun provideViewModelFactory() = ViewModelFactory.getInstance() fun provideLocalDataSource(): PostDataSource = LocalDataSource.getInstance() fun provideRemoteDataSource(): PostDataSource = RemoteDataSource.getInstance() fun provideGetPosts() = GetPosts(providePostDataRepository()) fun provideSavePost() = SavePost(providePostDataRepository()) fun provideUseCaseHandler() = UseCaseHandler.getInstance() }

Hinweis: Ich bevorzuge die Verwendung von Dagger 2 für die Abhängigkeitsinjektion in komplexen Projekten. Aber mit seiner extrem steilen Lernkurve würde es den Rahmen dieses Artikels sprengen. Wenn Sie also daran interessiert sind, tiefer zu gehen, empfehle ich Hari Vignesh Jayapalans Einführung in Dagger 2.

MVVM mit sauberer Architektur: Eine solide Kombination

Unser Ziel bei diesem Projekt war es, MVVM mit sauberer Architektur zu verstehen, also haben wir ein paar Dinge übersprungen, die Sie versuchen können, es weiter zu verbessern:

  1. Verwenden Sie LiveData oder RxJava, um Rückrufe zu entfernen und es ein wenig übersichtlicher zu gestalten.
  2. Verwenden Sie Zustände, um Ihre Benutzeroberfläche darzustellen. (Sehen Sie sich dazu diesen erstaunlichen Vortrag von Jake Wharton an.)
  3. Verwenden Sie Dagger 2, um Abhängigkeiten einzufügen.

Dies ist eine der besten und skalierbarsten Architekturen für Android-Apps. Ich hoffe, Ihnen hat dieser Artikel gefallen, und ich freue mich darauf, zu hören, wie Sie diesen Ansatz in Ihren eigenen Apps verwendet haben!

Verwandte Themen: Xamarin Forms, MVVMCross und SkiaSharp: The Holy Trinity of Cross-Platform App Development