Gute Apps durch klare Software Struktur - Android Clean Architecture in Kotlin

Lukasz Kalnik

Lukasz Kalnik – Developer

Der App-Markt zeichnet sich durch eine hohe Zahl an App-Dienstleistern aus – und geht einher mit einem entsprechend großen und unübersichtlichen Angebot. Sich in diesem Dschungel für einen Dienstleister zu entscheiden, kann dabei herausfordernd sein. Denn gerade in Bezug auf die Funktionalität der Apps kommen mehrere Anbieter in Frage. Doch neben der reinen Funktionalität sollte eine App zusätzlich gut wartbar, modifizierbar und flexibel erweiterbar sein – hier wird das Angebot bereits übersichtlicher.

Bei der Suche nach einem App-Dienstleister ist es daher ratsam, gezielt nach der Software-Architektur zu fragen. Denn ein klares Softwaredesign sorgt zum einen für eine bessere Code-Struktur, zum anderen für eine einfachere und kosteneffizientere Realisierung bei Änderungen in den unterschiedlichen Phasen eines App-Lebenszyklus. Das gelingt vor allem dann, wenn die unterschiedlichen Ebenen voneinander unabhängig sind bzw. ihre Kommunikation klaren Regeln folgt – dieser Ansatz ist als Clean Architecture bekannt.

Bei grandcentrix verwenden wir in unseren Android Apps Clean Architecture in Kotlin mit funktionalen Datentypen, um so klar die Zuständigkeiten der Architekturschichten zu trennen und potentielle, zukünftige Änderungen zu isolieren. Clean Architecture besteht dabei aus folgenden Schichten:

  • Datenschicht (z.B. Netzwerkkommunikation)
  • Domänenschicht (Geschäftslogik in der Form von Anwendungsfällen)
  • Präsentationsschicht (UI oder – für Bibliotheken – die API-Oberfläche)

Im Folgenden zeigen wir anhand konkreter Anwendungsbeispiele, wie mit Hilfe von Kotlin gute Android-App-Designs realisierbar sind und wie sich auf diese Weise schnell und effizient nachhaltige Apps entwickeln lassen.

Android-Bibliothek für den Zugriff auf ein IoT Gateway zum Kontrollieren von Leuchten

Nehmen wir als Beispiel eine Android-Bibliothek, die über REST API auf ein IoT Gateway zugreift, welches die Leuchten im Haus kontrolliert. Ein Beispielprojekt ist hier aufgeführt.

Abfragen einer Liste von Leuchten in einem bestimmten Raum

In der Domänenschicht brauchen wir einen Anwendungsfall (Use case), um die Liste von Leuchten in einem Raum mit bestimmter roomId abzufragen. Der Anwendungsfall soll mögliche HTTP-Fehler oder aber eine erfolgreiche Antwort des Gateways in einen Summentyp wie Either verpacken. Er soll auch den Gateway-Antwort-Datentyp Entity zu einem aussagekräftigeren Datenmodell List<Light> konvertieren.

Ziel ist es, diese Funktionalität über die Bibliothek-API (Präsentationsschicht) anderen Android Apps zur Verfügung stellen.

Funktionale Datentypen für Anwendungsfälle und ihre Abhängigkeiten

Der Einfachheit halber können wir typealiases erstellen, sowohl für den Anwendungsfall als auch für die Konverterfunktion, von welcher der Anwendungsfall abhängig ist.

GetLightsUseCase ist eine suspendierende Funktion (wir benutzen Kotlin Coroutines). Sie nimmt einen Parameter von dem Typ String (die roomId) und gibt einen Wert vom Typ Either zurück. Der zurückgegebene Wert beinhaltet entweder einen HTTP-Fehler als Throwable oder eine erfolgreiche Antwort als List<Light>.

EntityConverter ist eine generische Funktion, die weiß, wie die spezifischen Felder in einer Gateway-Antwort zu interpretieren sind. Sie konvertiert die erhaltene Entity zu erwartetem Datenmodell vom generischen Typ T. In unserem Fall der Abfrage der Liste von Leuchten werden wir den Parameter T zu einer List<Light> setzen.

typealias GetLightsUseCase = suspend (String) -> Either<Throwable, List<Light>>
typealias EntityConverter<T> = (Entity) -> T

 

Anwendungsfall-Factory-Funktion

Der GetLightsUseCase sollte unserer Bibliothek-API eine Liste von Leuchten basierend auf der roomId liefern. Er muss aber auch wissen, wie er auf die Gateway REST API zugreifen kann und welchen Konverter er benutzen soll.

Wir möchten die Bibliothek-API von den Abhängigkeiten der Anwendungsfälle aus der Domänenschicht isolieren. Wenn wir in Zukunft z. B. die Gateway-Kommunikation von HTTP auf Bluetooth umstellen würden, möchten wir in der Lage sein, die Gateway Api-Implementation einfach auszutauschen und die Bibliothek-API-Schicht dabei nicht anfassen zu müssen.

Ähnlich – sollte die Gateway API in Zukunft einen anderen Datentyp als Entity zurückgeben – sollte es ausreichen, nur den Konverter innerhalb des Anwendungsfalls auszutauschen.

Wir können die gewünschte Trennung der Architekturschichten mit Hilfe einer Anwendungsfall-Factory-Funktion erreichen. Sie wird einen GetLightUseCase für konkrete GatewayApi und EntityConverter<T> Instanzen erstellen und zurückgeben.

fun getLightsUseCaseFactory(
api: GatewayApi,
converter: EntityConverter<List<Light>>
): GetLightsUseCase = { roomId: String ->
api.getLights(roomId)
.toEither()
.map { converter(it) }
}

 

Unit-Testen des Anwendungsfalls

Bei grandcentrix verlassen wir uns auf automatische Unit Tests. Sie erlauben uns, konsistente Qualität und API-Zuverlässigkeit für uns und unsere Kunden zu gewährleisten, auch wenn die Implementationdetails sich ändern.

In dem Beispiel Unit Test mocken (ersetzen) wir die Abhängigkeiten des Anwendungsfalls (die Gateway API und den Konverter) mit MockK.

Wir simulieren das Verhalten von mocks mit der Syntax every/coEvery { ... } returns ... (coEvery ist hier die Version für Coroutines).

Dann injektieren wir gemockte Abhängigkeiten in unseren useCase, den wir testen wollen. Als Ergebnis können wir mit verify/coVerify verifizieren, welche Mocks mit welchen Argumenten aufgerufen wurden, und mit Hilfe von assertThat … Annahmen über die Ergebnisse machen.

class GetLightsUseCaseTest {
    val testEntity = TestClassesProvider.entity()

    val mockApi = mockk<GatewayApi>()

    val lights = listOf(Light(id = "1234"))
    val mockConverter = mockk<EntityConverter<List<Light>>> converter@{
        every { this@converter.invoke(testEntity) } returns lights
    }

    val useCase = getLightsUseCaseFactory(mockApi, mockConverter)

    @Test
    fun `calling use case with response success should return list of lights`() {
        val roomId = "room 1"
        coEvery { mockApi.getLights(roomId) } returns
        Response.success(testEntity)

    val result = runBlocking { useCase(roomId) }

    coVerifyAll { mockApi.getLights(roomId) }
    verifyAll { mockConverter(testEntity) }

    assertThat(result).isInstanceOf(Success::class.java)
        result as Success
        assertThat(result.success).isEqualTo(lights)
    }
}

 

Zusammenfassung

In Kotlin können wir Factory-Funktionen höherer Ordnung benutzen, um Anwendungsfall-Funktionen mit einigen eingebauten Parametern (wie das Gateway API und der Konverter) zu erstellen. Dieses Verfahren hilft uns, die Abhängigkeiten der Anwendungsfälle vor höheren Architekturschichten zu verstecken. Es erlaubt uns auch, die Anwendungsfall-Implementation in Zukunft zu ändern, ohne höhere Architekturschichten zu beeinträchtigen. Darüber hinaus ermöglicht es uns, Unit Tests durch Austauschen mit Mocks der Parameter der Anwendungsfall-Factory zu erstellen. Mehr Details über weitere Architekturschichten (Datenschicht und Bibliothek-API-Schicht) finden Sie in diesem Artikel.

Sie wollen mehr über unsere Leistungen im Bereich der App-Entwicklung erfahren? Informieren Sie sich hier über unsere App-Entwicklungs-Expertise von der Idee bis zur Distribution.