Entdecken Sie die Vorteile von Android Clean Architecture

Veröffentlicht: 2022-03-11

Was würden Sie bevorzugen: eine neue Funktion zu einer sehr gut funktionierenden App mit schrecklicher Architektur hinzufügen oder einen Fehler in der gut strukturierten, aber fehlerhaften Android-Anwendung beheben? Ich persönlich würde definitiv die zweite Option wählen. Das Hinzufügen einer neuen Funktion, selbst einer einfachen, kann in einer App sehr mühsam werden, wenn man alle Abhängigkeiten von allem in jeder Klasse berücksichtigt. Ich erinnere mich an eines meiner Android-Projekte, bei dem mich ein Projektmanager bat, eine kleine Funktion hinzuzufügen – so etwas wie das Herunterladen von Daten und die Anzeige auf einem neuen Bildschirm. Es war eine App, die von einem meiner Kollegen geschrieben wurde, der einen neuen Job gefunden hat. Das Feature sollte nicht länger als einen halben Arbeitstag dauern. Ich war sehr optimistisch…

Nachdem ich sieben Stunden lang untersucht hatte, wie die App funktioniert, welche Module es gibt und wie sie miteinander kommunizieren, habe ich einige Testimplementierungen der Funktion vorgenommen. Es war die Hölle. Eine kleine Änderung im Datenmodell erzwang eine große Änderung im Anmeldebildschirm. Das Hinzufügen einer Netzwerkanforderung erforderte Änderungen der Implementierung fast aller Bildschirme und der GodOnlyKnowsWhatThisClassDoes -Klasse. Farbänderungen der Schaltflächen verursachten ein seltsames Verhalten beim Speichern der Daten in der Datenbank oder einen totalen App-Absturz. Mitte des nächsten Tages sagte ich zu meinem Projektmanager: „Wir haben zwei Möglichkeiten, das Feature zu implementieren. Erstens kann ich drei weitere Tage damit verbringen und werde es schließlich auf eine sehr schmutzige Weise implementieren, und die Implementierungszeit jedes nächsten Features oder Bugfixes wird exponentiell wachsen. Oder ich kann die App umschreiben. Dafür brauche ich zwei oder drei Wochen, aber wir sparen Zeit für zukünftige App-Änderungen.“ Glücklicherweise stimmte er der zweiten Option zu. Wenn ich jemals Zweifel hatte, warum eine gute Softwarearchitektur in einer App (auch einer sehr kleinen) wichtig ist, hat diese App sie vollständig zerstreut. Aber welches Android-Architekturmuster sollten wir verwenden, um solche Probleme zu vermeiden?

In diesem Artikel möchte ich Ihnen ein sauberes Architekturbeispiel in einer Android-App zeigen. Die Hauptideen dieses Musters können jedoch an jede Plattform und Sprache angepasst werden. Gute Architektur sollte unabhängig von Details wie Plattform, Sprache, Datenbanksystem, Input oder Output sein.

Beispiel-App

Wir werden eine einfache Android-App erstellen, um unseren Standort mit den folgenden Funktionen zu registrieren:

  • Der Benutzer kann ein Konto mit einem Namen erstellen.
  • Der Benutzer kann den Kontonamen bearbeiten.
  • Der Benutzer kann das Konto löschen.
  • Der Benutzer kann das aktive Konto auswählen.
  • Der Benutzer kann den Standort speichern.
  • Der Benutzer kann die Standortliste für einen Benutzer sehen.
  • Der Benutzer kann eine Liste der Benutzer sehen.

Saubere Architektur

Die Schichten sind der Hauptkern einer sauberen Architektur. In unserer App verwenden wir drei Ebenen: Präsentation, Domäne und Modell. Jede Schicht sollte getrennt sein und sollte nichts über andere Schichten wissen müssen. Es sollte in seiner eigenen Welt existieren und höchstens eine kleine Schnittstelle zur Kommunikation teilen.

Layer-Zuständigkeiten:

  • Domain: Enthält die Geschäftsregeln unserer App. Es sollte Anwendungsfälle bereitstellen, die die Funktionen unserer App widerspiegeln.
  • Präsentation: Präsentiert dem Benutzer Daten und sammelt auch notwendige Daten wie den Benutzernamen. Dies ist eine Art Input/Output.
  • Modell: Liefert Daten für unsere App. Es ist dafür verantwortlich, Daten aus externen Quellen zu beziehen und sie in der Datenbank, dem Cloud-Server usw. zu speichern.

Welche Schichten sollten von den anderen wissen? Der einfachste Weg, die Antwort zu erhalten, besteht darin, über Veränderungen nachzudenken. Nehmen wir die Präsentationsebene – wir werden dem Benutzer etwas präsentieren. Wenn wir etwas an der Präsentation ändern, sollten wir dann auch eine Änderung in einer Modellebene vornehmen? Stellen Sie sich vor, wir haben einen „Benutzer“-Bildschirm mit dem Namen und dem letzten Standort des Benutzers. Wenn wir die letzten beiden Standorte des Benutzers statt nur einem präsentieren möchten, sollte unser Modell nicht betroffen sein. Wir haben also das erste Prinzip: Die Präsentationsschicht weiß nichts von der Modellschicht.

Und im Gegenteil – sollte die Modellebene über die Präsentationsebene Bescheid wissen? Nochmals – nein, denn wenn wir z. B. die Datenquelle von einer Datenbank zu einem Netzwerk ändern, sollte dies nichts in der Benutzeroberfläche ändern (wenn Sie daran gedacht haben, hier einen Loader hinzuzufügen – ja, aber wir können auch einen UI-Loader haben). bei Verwendung einer Datenbank). Die beiden Schichten sind also vollständig getrennt. Toll!

Was ist mit der Domänenschicht? Es ist das wichtigste, da es die gesamte Hauptgeschäftslogik enthält. Hier wollen wir unsere Daten verarbeiten, bevor wir sie an die Modellschicht übergeben oder dem Benutzer präsentieren. Sie sollte von jeder anderen Schicht unabhängig sein – sie weiß nichts über die Datenbank, das Netzwerk oder die Benutzeroberfläche. Da dies der Kern ist, werden andere Schichten nur mit dieser kommunizieren. Warum wollen wir das völlig unabhängig haben? Geschäftsregeln werden sich wahrscheinlich seltener ändern als die UI-Designs oder etwas in der Datenbank oder dem Netzwerkspeicher. Wir werden mit dieser Schicht über einige bereitgestellte Schnittstellen kommunizieren. Es verwendet kein konkretes Modell oder keine UI-Implementierung. Dies sind Details, und denken Sie daran – Details ändern sich. Eine gute Architektur ist nicht an Details gebunden.

Genug Theorie für jetzt. Fangen wir an zu programmieren! Dieser Artikel dreht sich um den Code, daher sollten Sie – zum besseren Verständnis – den Code von GitHub herunterladen und prüfen, was darin enthalten ist. Es werden drei Git-Tags erstellt – architecture_v1, architecture_v2 und architecture_v3, die den Teilen des Artikels entsprechen.

App-Technologie

In der App verwende ich Kotlin und Dagger 2 für die Abhängigkeitsinjektion. Weder Kotlin noch Dagger 2 sind hier notwendig, aber es macht die Dinge viel einfacher. Sie werden vielleicht überrascht sein, dass ich weder RxJava (noch RxKotlin) verwende, aber ich fand es hier nicht brauchbar, und ich verwende keine Bibliothek, nur weil sie oben ist und jemand sagt, dass sie ein Muss ist. Wie gesagt – Sprache und Bibliotheken sind Details, also können Sie verwenden, was Sie wollen. Einige Android-Komponententestbibliotheken werden ebenfalls verwendet: JUnit, Robolectric und Mockito.

Domain

Die wichtigste Ebene im Design unserer Android-Anwendungsarchitektur ist die Domänenebene. Fangen wir damit an. Hier befinden sich unsere Geschäftslogik und die Schnittstellen zur Kommunikation mit anderen Schichten. Kernstück sind die UseCase s, die widerspiegeln, was der Nutzer mit unserer App machen kann. Lassen Sie uns eine Abstraktion für sie vorbereiten:

 abstract class UseCase<out Type, in Params> { private var job: Deferred<OneOf<Failure, Type>>? = null abstract suspend fun run(params: Params): OneOf<Failure, Type> fun execute(params: Params, onResult: (OneOf<Failure, Type>) -> Unit) { job?.cancel() job = async(CommonPool) { run(params) } launch(UI) { val result = job!!.await() onResult(result) } } open fun cancel() { job?.cancel() } open class NoParams }

Ich habe mich entschieden, hier Kotlins Coroutinen zu verwenden. Jeder UseCase muss eine run-Methode implementieren, um die Daten bereitzustellen. Diese Methode wird in einem Hintergrundthread aufgerufen, und nachdem ein Ergebnis empfangen wurde, wird es an den UI-Thread übermittelt. Der zurückgegebene Typ ist OneOf<F, T> – wir können einen Fehler oder Erfolg mit Daten zurückgeben:

 sealed class OneOf<out E, out S> { data class Error<out E>(val error: E) : OneOf<E, Nothing>() data class Success<out S>(val data: S) : OneOf<Nothing, S>() val isSuccess get() = this is Success<S> val isError get() = this is Error<E> fun <E> error(error: E) = Error(error) fun <S> success(data: S) = Success(data) fun oneOf(onError: (E) -> Any, onSuccess: (S) -> Any): Any = when (this) { is Error -> onError(error) is Success -> onSuccess(data) } }

Die Domänenschicht benötigt ihre eigenen Entitäten, daher besteht der nächste Schritt darin, sie zu definieren. Wir haben vorerst zwei Entitäten: User und UserLocation :

 data class User(var id: Int? = null, val name: String, var isActive: Boolean = false) data class UserLocation(var id: Int? = null, val latitude: Double, val longitude: Double, val time: Long, val userId: Int)

Nachdem wir nun wissen, welche Daten zurückgegeben werden sollen, müssen wir die Schnittstellen unserer Datenanbieter deklarieren. Dies sind IUsersRepository und ILocationsRepository . Sie müssen in der Modellschicht implementiert werden:

 interface IUsersRepository { fun setActiveUser(userId: Int): OneOf<Failure, User> fun getActiveUser(): OneOf<Failure, User?> fun createUser(user: User): OneOf<Failure, User> fun removeUser(userId: Int): OneOf<Failure, User?> fun editUser(user: User): OneOf<Failure, User> fun users(): OneOf<Failure, List<User>> } interface ILocationsRepository { fun locations(userId: Int): OneOf<Failure, List<UserLocation>> fun addLocation(location: UserLocation): OneOf<Failure, UserLocation> }

Diese Reihe von Aktionen sollte ausreichen, um die erforderlichen Daten für die App bereitzustellen. Wir entscheiden zu diesem Zeitpunkt nicht, wie die Daten gespeichert werden – von diesem Detail wollen wir unabhängig sein. Im Moment weiß unsere Domänenebene nicht einmal, dass sie sich auf Android befindet. Wir werden versuchen, diesen Zustand beizubehalten (so ungefähr. Ich erkläre es später).

Der letzte (oder fast letzte) Schritt besteht darin, Implementierungen für unsere UseCase zu definieren, die von den Präsentationsdaten verwendet werden. Alle von ihnen sind sehr einfach (so wie unsere App und Daten einfach sind) – ihre Operationen beschränken sich darauf, eine geeignete Methode aus dem Repository aufzurufen, z.

 class GetLocations @Inject constructor(private val repository: ILocationsRepository) : UseCase<List<UserLocation>, UserIdParams>() { override suspend fun run(params: UserIdParams): OneOf<Failure, List<UserLocation>> = repository.locations(params.userId) }

Die Repository -Abstraktion macht unsere UseCases sehr einfach zu testen – wir müssen uns nicht um ein Netzwerk oder eine Datenbank kümmern. Es kann auf beliebige Weise verspottet werden, sodass unsere Komponententests tatsächliche Anwendungsfälle testen und keine anderen, nicht verwandten Klassen. Dadurch werden unsere Unit-Tests einfach und schnell:

 @RunWith(MockitoJUnitRunner::class) class GetLocationsTests { private lateinit var getLocations: GetLocations private val locations = listOf(UserLocation(1, 1.0, 1.0, 1L, 1)) @Mock private lateinit var locationsRepository: ILocationsRepository @Before fun setUp() { getLocations = GetLocations(locationsRepository) } @Test fun `should call getLocations locations`() { runBlocking { getLocations.run(UserIdParams(1)) } verify(locationsRepository, times(1)).locations(1) } @Test fun `should return locations obtained from locationsRepository`() { given { locationsRepository.locations(1) }.willReturn(OneOf.Success(locations)) val returnedLocations = runBlocking { getLocations.run(UserIdParams(1)) } returnedLocations shouldEqual OneOf.Success(locations) } }

Fürs Erste ist die Domänenschicht fertig.

Modell

Als Android-Entwickler werden Sie sich wahrscheinlich für Room entscheiden, die neue Android-Bibliothek zum Speichern von Daten. Aber stellen wir uns vor, der Projektmanager fragt, ob Sie die Entscheidung über die Datenbank verschieben können, weil das Management versucht, sich zwischen Room, Realm und einer neuen, superschnellen Speicherbibliothek zu entscheiden. Wir benötigen einige Daten, um mit der Benutzeroberfläche zu arbeiten, also behalten wir sie vorerst nur im Gedächtnis:

 class MemoryLocationsRepository @Inject constructor(): ILocationsRepository { private val locations = mutableListOf<UserLocation>() override fun locations(userId: Int): OneOf<Failure, List<UserLocation>> = OneOf.Success(locations.filter { it.userId == userId }) override fun addLocation(location: UserLocation): OneOf<Failure, UserLocation> { val addedLocation = location.copy(id = locations.size + 1) locations.add(addedLocation) return OneOf.Success(addedLocation) } }

Präsentation

Vor zwei Jahren habe ich einen Artikel über MVP als sehr gute App-Struktur für Android geschrieben. Als Google die großartigen Architekturkomponenten ankündigte, die die Entwicklung von Android-Anwendungen erheblich vereinfachten, wird MVP nicht mehr benötigt und kann durch MVVM ersetzt werden. Einige Ideen aus diesem Muster sind jedoch immer noch sehr nützlich – wie die über dumme Ansichten. Sie sollten sich nur um die Anzeige der Daten kümmern. Um dies zu erreichen, verwenden wir ViewModel und LiveData.

Das Design unserer App ist sehr einfach – eine Aktivität mit unterer Navigation, in der zwei Menüeinträge das locations oder das users anzeigen. In diesen Ansichten verwenden wir ViewModels, die wiederum UseCase aus der Domänenschicht verwenden, wodurch die Kommunikation übersichtlich und einfach bleibt. Hier ist zum Beispiel LocationsViewModel :

 class LocationsViewModel @Inject constructor(private val getLocations: GetLocations, private val saveLocation: SaveLocation) : BaseViewModel() { var locations = MutableLiveData<List<UserLocation>>() fun loadLocations(userId: Int) { getLocations.execute(UserIdParams(userId)) { it.oneOf(::handleError, ::handleLocationsChange) } } fun saveLocation(location: UserLocation, onSaved: (UserLocation) -> Unit) { saveLocation.execute(UserLocationParams(location)) { it.oneOf(::handleError) { location -> handleLocationSave(location, onSaved) } } } private fun handleLocationSave(location: UserLocation, onSaved: (UserLocation) -> Unit) { val currentLocations = locations.value?.toMutableList() ?: mutableListOf() currentLocations.add(location) this.locations.value = currentLocations onSaved(location) } private fun handleLocationsChange(locations: List<UserLocation>) { this.locations.value = locations } }

Eine kleine Erklärung für diejenigen, die mit ViewModels nicht vertraut sind – unsere Daten werden in der Locations-Variablen gespeichert. Wenn wir Daten aus dem Anwendungsfall getLocations erhalten, werden sie an den LiveData Wert übergeben. Diese Änderung wird die Beobachter benachrichtigen, damit sie reagieren und ihre Daten aktualisieren können. Wir fügen einen Beobachter für die Daten in einem Fragment hinzu:

 class LocationsFragment : BaseFragment() { ... private fun initLocationsViewModel() { locationsViewModel = ViewModelProviders.of(activity!!, viewModelFactory)[LocationsViewModel::class.java] locationsViewModel.locations.observe(this, Observer<List<UserLocation>> { showLocations(it ?: emptyList()) }) locationsViewModel.error.observe(this, Observer<Failure> { handleError(it) }) } private fun showLocations(locations: List<UserLocation>) { locationsAdapter.locations = locations } private fun handleError(error: Failure?) { toast(R.string.user_fetch_error).show() } }

Bei jeder Standortänderung übergeben wir die neuen Daten einfach an einen Adapter, der einer Recycler-Ansicht zugewiesen ist – und dorthin geht der normale Android-Flow zum Anzeigen von Daten in einer Recycler-Ansicht.

Da wir ViewModel in unseren Ansichten verwenden, ist ihr Verhalten auch einfach zu testen – wir können die ViewModels einfach simulieren und uns nicht um die Datenquelle, das Netzwerk oder andere Faktoren kümmern:

 @RunWith(RobolectricTestRunner::class) @Config(application = TestRegistryRobolectricApplication::class) class LocationsFragmentTests { private var usersViewModel = mock(UsersViewModel::class.java) private var locationsViewModel = mock(LocationsViewModel::class.java) lateinit var fragment: LocationsFragment @Before fun setUp() { UsersViewModelMock.intializeMock(usersViewModel) LocationsViewModelMock.intializeMock(locationsViewModel) fragment = LocationsFragment() fragment.viewModelFactory = ViewModelUtils.createFactoryForViewModels(usersViewModel, locationsViewModel) startFragment(fragment) } @Test fun `should getActiveUser on start`() { Mockito.verify(usersViewModel).getActiveUser() } @Test fun `should load locations from active user`() { usersViewModel.activeUserId.value = 1 Mockito.verify(locationsViewModel).loadLocations(1) } @Test fun `should display locations`() { val date = Date(1362919080000)//10-03-2013 13:38 locationsViewModel.locations.value = listOf(UserLocation(1, 1.0, 2.0, date.time, 1)) val recyclerView = fragment.find<RecyclerView>(R.id.locationsRecyclerView) recyclerView.measure(100, 100) recyclerView.layout(0,0, 100, 100) val adapter = recyclerView.adapter as LocationsListAdapter adapter.itemCount `should be` 1 val viewHolder = recyclerView.findViewHolderForAdapterPosition(0) as LocationsListAdapter.LocationViewHolder viewHolder.latitude.text `should equal` "Lat: 1.0" viewHolder.longitude.text `should equal` "Lng: 2.0" viewHolder.locationDate.text `should equal` "10-03-2013 13:38" } }

Möglicherweise stellen Sie fest, dass die Präsentationsebene auch in kleinere Ebenen mit klaren Grenzen unterteilt ist. Ansichten wie activities , fragments , ViewHolders usw. sind nur für die Anzeige von Daten verantwortlich. Sie kennen nur die ViewModel-Schicht – und verwenden nur diese, um Benutzer und Standorte abzurufen oder zu senden. Es ist ein ViewModel, das mit der Domäne kommuniziert. ViewModel-Implementierungen sind für die Ansicht dieselben wie die UseCases für die Domäne. Reine Architektur ist, um es mit anderen Worten zu formulieren, wie eine Zwiebel – sie hat Schichten, und Schichten können auch Schichten haben.

Abhängigkeitsspritze

Wir haben alle Klassen für unsere Architektur erstellt, aber es gibt noch etwas zu tun – wir brauchen etwas, das alles miteinander verbindet. Die Präsentations-, Domänen- und Modellschichten werden sauber gehalten, aber wir brauchen ein Modul, das das schmutzige ist und alles über alles weiß – durch dieses Wissen wird es in der Lage sein, unsere Schichten zu verbinden. Der beste Weg, dies zu erreichen, ist die Verwendung eines der gängigen Entwurfsmuster (eines der in SOLID definierten Clean-Code-Prinzipien) – Abhängigkeitsinjektion, die geeignete Objekte für uns erstellt und sie in gewünschte Abhängigkeiten einfügt. Ich habe hier Dagger 2 verwendet (in der Mitte des Projekts habe ich die Version auf 2.16 geändert, die weniger Boilerplate hat), aber Sie können jeden beliebigen Mechanismus verwenden. Kürzlich habe ich ein bisschen mit der Koin-Bibliothek gespielt, und ich denke, es ist auch einen Versuch wert. Ich wollte es hier verwenden, hatte aber beim Testen viele Probleme damit, die ViewModels zu verspotten. Ich hoffe, ich finde einen Weg, sie schnell zu lösen – wenn ja, kann ich Unterschiede für diese App bei der Verwendung von Koin and Dagger 2 präsentieren.

Sie können die App für diese Stufe auf GitHub mit dem Tag architecture_v1 überprüfen.

Änderungen

Wir haben unsere Layer fertiggestellt, die App getestet – alles funktioniert! Bis auf eine Sache – wir müssen immer noch wissen, welche Datenbank unser PM verwenden möchte. Angenommen, sie kommen zu Ihnen und sagen, dass das Management der Nutzung von Room zugestimmt hat, sie aber weiterhin die Möglichkeit haben möchten, die neueste, superschnelle Bibliothek in der Zukunft zu nutzen, also müssen Sie mögliche Änderungen im Hinterkopf behalten. Außerdem fragte einer der Beteiligten, ob die Daten in einer Cloud gespeichert werden könnten, und möchte die Kosten einer solchen Änderung wissen. Dies ist also der Zeitpunkt, um zu prüfen, ob unsere Architektur gut ist und ob wir das Datenspeichersystem ohne Änderungen in der Präsentation oder der Domänenschicht ändern können.

Änderung 1

Das erste, was Sie bei der Verwendung von Room tun müssen, ist das Definieren von Entitäten für eine Datenbank. Wir haben bereits einige: User und UserLocation . Alles, was wir tun müssen, ist, Anmerkungen wie @Entity und @PrimaryKey , und dann können wir es in unserer Modellebene mit einer Datenbank verwenden. Toll! Dies ist eine hervorragende Möglichkeit, alle Architekturregeln zu brechen, die wir beibehalten wollten. Tatsächlich kann die Domänenentität auf diese Weise nicht in eine Datenbankentität umgewandelt werden. Stellen Sie sich vor, wir wollen die Daten auch aus einem Netzwerk herunterladen. Wir könnten einige weitere Klassen verwenden, um die Netzwerkantworten zu verarbeiten – indem wir unsere einfachen Entitäten konvertieren, damit sie mit einer Datenbank und einem Netzwerk funktionieren. Das ist der kürzeste Weg zu einer zukünftigen Katastrophe (und schreien: „Wer zum Teufel hat diesen Code geschrieben?“). Wir benötigen separate Entitätsklassen für jeden Datenspeichertyp, den wir verwenden. Es kostet nicht viel, also definieren wir die Raumentitäten richtig:

 @Entity data class UserEntity( @PrimaryKey(autoGenerate = true) var id: Long?, @ColumnInfo(name = "name") var name: String, @ColumnInfo(name = "isActive") var isActive: Boolean = false ) @Entity(foreignKeys = [ ForeignKey(entity = UserEntity::class, parentColumns = [ "id" ], childColumns = [ "userId" ], onDelete = CASCADE) ]) data class UserLocationEntity( @PrimaryKey(autoGenerate = true) var id: Long?, @ColumnInfo(name = "latitude") var latitude: Double, @ColumnInfo(name = "longitude") var longitude: Double, @ColumnInfo(name = "time") var time: Long, @ColumnInfo(name = "userId") var userId: Long )

Wie Sie sehen können, sind sie fast identisch mit Domänenentitäten, daher ist die Versuchung groß, sie zusammenzuführen. Dies ist nur ein Zufall – bei komplizierteren Daten wird die Ähnlichkeit geringer sein.

Als nächstes müssen wir das UserDAO und das UserLocationsDAO , unsere AppDatabase und schließlich die Implementierungen für IUsersRepository und ILocationsRepository . Hier gibt es ein kleines Problem – ILocationsRepository sollte eine UserLocation , aber es erhält eine UserLocationEntity von der Datenbank. Dasselbe gilt für User Klassen. In umgekehrter Richtung übergeben wir die UserLocation , wenn die Datenbank UserLocationEntity benötigt. Um dies zu lösen, benötigen wir Mapper zwischen unserer Domäne und den Mappers . Ich habe eine meiner Lieblingsfunktionen von Kotlin verwendet – Erweiterungen. Ich habe eine Datei namens Mapper.kt erstellt und alle Methoden für die Zuordnung zwischen den Klassen dort abgelegt (natürlich befindet sie sich in der Modellebene – die Domäne benötigt sie nicht):

 fun User.toEntity() = UserEntity(id?.toLong(), name, isActive) fun UserEntity.toUser() = User(this.id?.toInt(), name, isActive) fun UserLocation.toEntity() = UserLocationEntity(id?.toLong(), latitude, longitude, time, userId.toLong()) fun UserLocationEntity.toUserLocation() = UserLocation(id?.toInt(), latitude, longitude, time, userId.toInt())

Die kleine Lüge, die ich zuvor erwähnt habe, handelt von Domain-Entitäten. Ich habe geschrieben, dass sie nichts über Android wissen, aber das ist nicht ganz richtig. Ich habe der User -Entität die Annotation @Parcelize hinzugefügt und dort Parcelable erweitert, sodass es möglich ist, die Entität an ein Fragment zu übergeben. Für kompliziertere Strukturen sollten wir die eigenen Datenklassen der Ansichtsschicht bereitstellen und Mapper wie zwischen Domänen- und Datenmodellen erstellen. Das Hinzufügen von Parcelable zur Domänenentität ist ein kleines Risiko, das ich einzugehen gewagt habe – das ist mir bewusst, und im Falle von Änderungen an der User werde ich separate Datenklassen für die Präsentation erstellen und Parcelable aus der Domänenebene entfernen.

Als letztes müssen Sie unser Abhängigkeitsinjektionsmodul ändern, um die neu erstellte Repository -Implementierung anstelle des vorherigen MemoryRepository . Nachdem wir die App erstellt und ausgeführt haben, können wir zur PM gehen, um die funktionierende App mit einer Room-Datenbank anzuzeigen. Wir können den PM auch darüber informieren, dass das Hinzufügen eines Netzwerks nicht zu viel Zeit in Anspruch nehmen wird und dass wir für jede Speicherbibliothek offen sind, die das Management wünscht. Sie können überprüfen, welche Dateien geändert wurden – nur die in der Modellebene. Unsere Architektur ist wirklich ordentlich! Jeder nächste Speichertyp kann auf die gleiche Weise erstellt werden, indem Sie einfach unsere Repositories erweitern und die richtigen Implementierungen bereitstellen. Natürlich könnte es sich herausstellen, dass wir mehrere Datenquellen benötigen, beispielsweise eine Datenbank und ein Netzwerk. Was dann? Nichts dagegen, wir müssten nur drei Repository-Implementierungen erstellen – eine für das Netzwerk, eine für die Datenbank und eine Hauptimplementierung, in der die richtige Datenquelle ausgewählt würde (z. B. wenn wir ein Netzwerk haben, laden Sie von der Netzwerk, und falls nicht, Laden aus einer Datenbank).

Sie können sich die App für diese Stufe auf GitHub mit dem Tag architecture_v2 ansehen.

Der Tag ist also fast zu Ende – Sie sitzen mit einer Tasse Kaffee vor Ihrem Computer, die App ist bereit, an Google Play gesendet zu werden, als plötzlich der Projektmanager zu Ihnen kommt und fragt: „Können Sie eine Funktion hinzufügen, die kann der aktuelle Standort des Benutzers vom GPS gespeichert werden?“

Änderung 2

Alles ändert sich… besonders die Software. Deshalb brauchen wir sauberen Code und saubere Architektur. Allerdings können selbst die saubersten Dinge schmutzig werden, wenn wir ohne nachzudenken programmieren. Der erste Gedanke bei der Implementierung des Abrufens eines Standorts vom GPS wäre, den gesamten ortsbezogenen Code in die Aktivität einzufügen, ihn in unserem SaveLocationDialogFragment und einen neuen UserLocation mit entsprechenden Daten zu erstellen. Dies könnte der schnellste Weg sein. Aber was ist, wenn unser verrückter PM zu uns kommt und uns bittet, den Standort von GPS auf einen anderen Anbieter zu übertragen (z. B. etwas wie Bluetooth oder ein Netzwerk)? Die Änderungen würden bald außer Kontrolle geraten. Wie können wir es sauber machen?

Der Benutzerstandort sind Daten. Und das Abrufen eines Standorts ist ein UseCase – daher denke ich, dass unsere Domänen- und Modellebenen hier ebenfalls einbezogen werden sollten. Daher müssen wir noch einen weiteren UseCase implementieren GetCurrentLocation . Wir brauchen auch etwas, das uns einen Standort liefert – eine ILocationProvider -Schnittstelle, um den UseCase unabhängig von Details wie dem GPS-Sensor zu machen:

 interface ILocationProvider { fun getLocation(): OneOf<Failure, SimpleLocation> fun cancel() } class GetCurrentLocation @Inject constructor(private val locationProvider: ILocationProvider) : UseCase<SimpleLocation, UseCase.NoParams>() { override suspend fun run(params: NoParams): OneOf<Failure, SimpleLocation> = locationProvider.getLocation() override fun cancel() { super.cancel() locationProvider.cancel() } }

Sie können sehen, dass wir hier eine zusätzliche Methode haben – stornieren. Dies liegt daran, dass wir eine Möglichkeit brauchen, GPS-Standortaktualisierungen abzubrechen. Unsere in der Modellschicht definierte Provider -Implementierung geht hier:

 class GPSLocationProvider constructor(var activity: Activity) : ILocationProvider { private var locationManager: LocationManager? = null private var locationListener: GPSLocationListener? = null override fun getLocation(): OneOf<Failure, SimpleLocation> = runBlocking { val grantedResult = getLocationPermissions() if (grantedResult.isError) { val error = (grantedResult as OneOf.Error<Failure>).error OneOf.Error(error) } else { getLocationFromGPS() } } private suspend fun getLocationPermissions(): OneOf<Failure, Boolean> = suspendCoroutine { Dexter.withActivity(activity) .withPermission(Manifest.permission.ACCESS_FINE_LOCATION) .withListener(PermissionsCallback(it)) .check() } private suspend fun getLocationFromGPS(): OneOf<Failure, SimpleLocation> = suspendCoroutine { locationListener?.unsubscribe() locationManager = activity.getSystemService(Context.LOCATION_SERVICE) as LocationManager locationManager?.let { manager -> locationListener = GPSLocationListener(manager, it) launch(UI) { manager.requestLocationUpdates(LocationManager.NETWORK_PROVIDER, 0L, 0.0f, locationListener) } } } override fun cancel() { locationListener?.unsubscribe() locationListener = null locationManager = null } }

Dieser Anbieter ist bereit, mit Kotlin-Coroutinen zu arbeiten. Wenn Sie sich erinnern, wird die run-Methode der UseCase s in einem Hintergrund-Thread aufgerufen – also müssen wir sicherstellen, dass unsere Threads richtig markiert sind. Wie Sie sehen, müssen wir hier eine Aktivität übergeben – es ist äußerst wichtig, Aktualisierungen abzubrechen und sich von den Listenern abzumelden, wenn wir sie nicht mehr benötigen, um Speicherlecks zu vermeiden. Da es den ILocationProvider implementiert, können wir es in Zukunft problemlos auf einen anderen Anbieter umstellen. Wir können auch die Handhabung des aktuellen Standorts (automatisch oder manuell) leicht testen, auch ohne das GPS in unserem Telefon zu aktivieren – alles, was wir tun müssen, ist, die Implementierung zu ersetzen, um einen zufällig konstruierten Standort zurückzugeben. Damit es funktioniert, müssen wir den neu erstellten UseCase zum LocationsViewModel hinzufügen. Das ViewModel wiederum muss eine neue Methode haben, getCurrentLocation , die den Anwendungsfall tatsächlich aufruft. Mit nur ein paar kleinen UI-Änderungen zum Aufrufen und Registrieren des GPSProviders in Dagger – und voila, unsere App ist fertig!

Zusammenfassung

Ich habe versucht, Ihnen zu zeigen, wie wir eine Android-App entwickeln können, die einfach zu warten, zu testen und zu ändern ist. Es sollte auch leicht verständlich sein – wenn jemand neu zu Ihrer Arbeit kommt, sollte er kein Problem damit haben, den Datenfluss oder die Struktur zu verstehen. Wenn sie sich bewusst sind, dass die Architektur sauber ist, können sie sicher sein, dass Änderungen an der Benutzeroberfläche nichts im Modell beeinflussen und das Hinzufügen einer neuen Funktion nicht länger dauert als vorhergesagt. Aber dies ist nicht das Ende der Reise. Selbst wenn wir eine gut strukturierte App haben, ist es sehr einfach, sie durch unordentliche Codeänderungen zu beschädigen, „nur für einen Moment, nur um zu arbeiten“. Denken Sie daran – es gibt keinen Code „nur für jetzt“. Jeder Code, der gegen unsere Regeln verstößt, kann in der Codebasis bestehen bleiben und eine Quelle für zukünftige, größere Brüche sein. Wenn Sie nur eine Woche später zu diesem Code kommen, sieht es so aus, als hätte jemand einige starke Abhängigkeiten in diesen Code implementiert, und um das Problem zu lösen, müssen Sie sich durch viele andere Teile der App wühlen. Eine gute Code-Architektur ist nicht nur zu Beginn des Projekts eine Herausforderung – sie ist eine Herausforderung für jeden Teil der Lebensdauer einer Android-App. Das Nachdenken und Überprüfen des Codes sollte jedes Mal berücksichtigt werden, wenn sich etwas ändert. Um sich daran zu erinnern, können Sie beispielsweise Ihr Android-Architekturdiagramm ausdrucken und aufhängen. Sie können die Unabhängigkeit der Ebenen auch ein wenig erzwingen, indem Sie sie in drei Gradle-Module aufteilen, wobei das Domänenmodul die anderen nicht kennt und die Präsentations- und Modellmodule sich nicht gegenseitig verwenden. Aber nicht einmal das kann das Bewusstsein ersetzen, dass sich das Durcheinander im App-Code an uns rächen wird, wenn wir es am wenigsten erwarten.