Manuelle Abhängigkeitsinjektion

Die empfohlene App-Architektur für Android sieht vor, dass Sie Ihren Code in Klassen aufteilen, um von der Trennung von Belangen zu profitieren. Bei diesem Prinzip hat jede Klasse der Hierarchie eine einzelne definierte Aufgabe. Das führt zu mehr, kleineren Klassen, die miteinander verbunden werden müssen, um die Abhängigkeiten zu erfüllen.

Android-Apps bestehen in der Regel aus vielen Klassen, von denen einige voneinander abhängig sind.
Abbildung 1. Ein Modell des Anwendungsdiagramms einer Android-App

Die Abhängigkeiten zwischen Klassen können als Diagramm dargestellt werden, in dem jede Klasse mit den Klassen verbunden ist, von denen sie abhängt. Die Darstellung aller Ihrer Klassen und ihrer Abhängigkeiten bildet den Anwendungsgraphen. Abbildung 1 zeigt eine Abstraktion des Anwendungsdiagramms. Wenn Klasse A (ViewModel) von Klasse B (Repository) abhängt, wird diese Abhängigkeit durch eine Linie dargestellt, die von A nach B zeigt.

Die Abhängigkeitsinjektion hilft dabei, diese Verbindungen herzustellen, und ermöglicht es Ihnen, Implementierungen für Tests auszutauschen. Wenn Sie beispielsweise eine ViewModel testen, die von einem Repository abhängt, können Sie verschiedene Implementierungen von Repository mit Fakes oder Mocks übergeben, um die verschiedenen Fälle zu testen.

Grundlagen der manuellen Abhängigkeitsinjektion

In diesem Abschnitt wird beschrieben, wie die manuelle Abhängigkeitsinjektion in einem realen Android-App-Szenario angewendet wird. Darin wird ein iterativer Ansatz beschrieben, wie Sie mit der Verwendung von Dependency Injection in Ihrer App beginnen können. Der Ansatz wird immer weiter verbessert, bis er einem Punkt entspricht, der dem sehr ähnlich ist, was Dagger automatisch für Sie generieren würde. Weitere Informationen zu Dagger finden Sie unter Dagger-Grundlagen.

Ein Ablauf ist eine Gruppe von Bildschirmen in Ihrer App, die einer Funktion entsprechen. Anmeldung, Registrierung und Kaufabwicklung sind Beispiele für Abläufe.

Bei der Beschreibung eines Anmeldevorgangs für eine typische Android-App hängt LoginActivity von LoginViewModel ab, was wiederum von UserRepository abhängt. UserRepository hängt dann von einem UserLocalDataSource und einem UserRemoteDataSource ab, die wiederum von einem Retrofit-Dienst abhängen.

LoginActivity ist der Einstiegspunkt für den Anmeldevorgang und der Nutzer interagiert mit der Aktivität. LoginActivity muss also die LoginViewModel mit allen ihren Abhängigkeiten erstellen.

Die Klassen Repository und DataSource des Ablaufs sehen so aus:

class UserRepository(
    private val localDataSource: UserLocalDataSource,
    private val remoteDataSource: UserRemoteDataSource
) { ... }

class UserLocalDataSource { ... }
class UserRemoteDataSource(
    private val loginService: LoginRetrofitService
) { ... }

In Compose ist ComponentActivity der Einstiegspunkt. Die Abhängigkeitsverkabelung erfolgt einmal in onCreate und die Benutzeroberfläche wird durch Composables beschrieben, die von setContent aufgerufen werden:

class ApiService {
    /* Your API implementation here */
}

class UserRepository(private val apiService: ApiService) {
    /* Your implementation here */
}

class LoginActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        // Satisfy the dependencies of LoginViewModel recursively,
        // then pass what the UI needs into setContent.
        val apiService = ApiService()
        val userRepository = UserRepository(apiService)

        setContent {
            LoginScreen(userRepository)
        }
    }
}

@Composable
fun LoginScreen(userRepository: UserRepository) {
    val viewModel: LoginViewModel = viewModel(
        factory = LoginViewModelFactory(userRepository)
    )
    // ...
}

Dieser Ansatz hat jedoch einige Nachteile:

  1. Abhängigkeiten müssen in der richtigen Reihenfolge deklariert werden. Sie müssen UserRepository instanziieren, bevor Sie LoginViewModel erstellen können.
  2. Es ist schwierig, Objekte wiederzuverwenden. Wenn Sie UserRepository für mehrere Funktionen wiederverwenden möchten, muss es dem Singleton-Muster entsprechen. Das Singleton-Muster erschwert das Testen, da alle Tests dieselbe Singleton-Instanz verwenden.

Abhängigkeiten mit einem Container verwalten

Um das Problem der Wiederverwendung von Objekten zu beheben, können Sie eine eigene Abhängigkeitscontainer-Klasse erstellen, mit der Sie Abhängigkeiten abrufen. Alle von diesem Container bereitgestellten Instanzen können öffentlich sein. Im Beispiel benötigen Sie nur eine Instanz von UserRepository. Daher können Sie die zugehörigen Abhängigkeiten mit der Option, sie in Zukunft öffentlich zu machen, wenn sie bereitgestellt werden müssen, als privat festlegen:

// Container of objects shared across the whole app
class AppContainer {

    // apiService and userRepository aren't private and will be exposed
    val apiService = ApiService()
    val userRepository = UserRepository(apiService)
}

Da diese Abhängigkeiten in der gesamten Anwendung verwendet werden, müssen sie an einem gemeinsamen Ort platziert werden, den alle Aktivitäten nutzen können: der Klasse Application. Erstellen Sie eine benutzerdefinierte Application-Klasse, die eine AppContainer-Instanz enthält.

// Custom Application class that needs to be specified
// in the AndroidManifest.xml file
class MyApplication : Application() {

    // Instance of AppContainer that will be used by all the Activities of the app
    val appContainer = AppContainer()
}

Mit Compose wird weiterhin dieselbe AppContainer in der Application-Unterklasse erstellt. Sie können entweder in der Aktivität darauf zugreifen, bevor Sie setContent aufrufen, oder in einem Composable mit LocalContext:

class LoginActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        val appContainer = (application as MyApplication).appContainer

        setContent {
            LoginScreen(appContainer.userRepository)
        }
    }
}

// Alternatively, read AppContainer from inside a composable:
@Composable
fun LoginScreen() {
    val context = LocalContext.current
    val appContainer = (context.applicationContext as MyApplication).appContainer
    val viewModel: LoginViewModel = viewModel(
        factory = LoginViewModelFactory(appContainer.userRepository)
    )
    // ...
}

Wir empfehlen, Abhängigkeiten als zusammensetzbare Parameter zu übergeben, anstatt tief im Baum auf LocalContext zuzugreifen. So bleiben Composables testbar und ihre Eingaben explizit. Lösen Sie den Container einmal am Bildschirm-Root auf und geben Sie die erforderlichen Informationen nach unten weiter.

So haben Sie kein Singleton UserRepository. Stattdessen haben Sie ein AppContainer, das für alle Aktivitäten freigegeben ist und Objekte aus dem Diagramm enthält. Außerdem werden Instanzen dieser Objekte erstellt, die von anderen Klassen verwendet werden können.

Wenn LoginViewModel an mehreren Stellen in der Anwendung benötigt wird, ist es sinnvoll, einen zentralen Ort zu haben, an dem Instanzen von LoginViewModel erstellt werden. Sie können die Erstellung von LoginViewModel in den Container verschieben und neue Objekte dieses Typs mit einer Factory bereitstellen. Der Code für ein LoginViewModelFactory sieht so aus:

// Definition of a Factory interface with a function to create objects of a type
interface Factory<T> {
    fun create(): T
}

// Factory for LoginViewModel.
// Since LoginViewModel depends on UserRepository, in order to create instances of
// LoginViewModel, you need an instance of UserRepository that you pass as a parameter.
class LoginViewModelFactory(private val userRepository: UserRepository) : Factory<LoginViewModel> {
    override fun create(): LoginViewModel {
        return LoginViewModel(userRepository)
    }
}

Bei Compose wird mit dem AppContainer-Update die Factory weiterhin verfügbar gemacht. Die Factory wird dann von der viewModel-Composable-Funktion verwendet, sodass der ViewModel-Bereich auf den nächsten ViewModelStoreOwner beschränkt ist (in der Regel die Hostaktivität oder, mit Navigation Compose, ein Navigationsziel):

// AppContainer exposing the factory (unchanged from the snippet above)
class AppContainer {
    // ...
    val userRepository = UserRepository(localDataSource, remoteDataSource)
    val loginViewModelFactory = LoginViewModelFactory(userRepository)
}

// Compose entry point + screen composable
class LoginActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        val appContainer = (application as MyApplication).appContainer
        setContent {
            LoginScreen(appContainer.loginViewModelFactory)
        }
    }
}

@Composable
fun LoginScreen(factory: LoginViewModelFactory) {
    val viewModel: LoginViewModel = viewModel(factory = factory)
    // ...
}

Dieser Ansatz ist besser als der vorherige, aber es gibt immer noch einige Herausforderungen:

  1. Sie müssen AppContainer selbst verwalten und Instanzen für alle Abhängigkeiten manuell erstellen.

  2. Es gibt immer noch viel Boilerplate-Code. Sie müssen Factories oder Parameter manuell erstellen, je nachdem, ob Sie ein Objekt wiederverwenden möchten oder nicht.

Abhängigkeiten in Anwendungsabläufen verwalten

AppContainer wird kompliziert, wenn Sie dem Projekt weitere Funktionen hinzufügen möchten. Wenn Ihre App größer wird und Sie verschiedene Funktionsabläufe einführen, treten noch mehr Probleme auf:

  1. Wenn Sie verschiedene Flows haben, möchten Sie möglicherweise, dass Objekte nur im Bereich dieses Flows vorhanden sind. Wenn Sie beispielsweise LoginUserData erstellen (die möglicherweise aus dem Nutzernamen und dem Passwort besteht, die nur im Anmeldevorgang verwendet werden), möchten Sie keine Daten aus einem alten Anmeldevorgang eines anderen Nutzers beibehalten. Sie möchten für jeden neuen Ablauf eine neue Instanz. Dazu erstellen Sie FlowContainer-Objekte innerhalb des AppContainer, wie im nächsten Codebeispiel gezeigt.

  2. Auch das Optimieren des Anwendungsdiagramms und der Flusscontainer kann schwierig sein. Je nach Ablauf müssen Sie daran denken, Instanzen zu löschen, die Sie nicht mehr benötigen.

Fügen wir dem Beispielcode ein LoginContainer hinzu. Sie möchten mehrere Instanzen von LoginContainer in der App erstellen können. Machen Sie es daher nicht zu einem Singleton, sondern zu einer Klasse mit den Abhängigkeiten, die der Anmeldevorgang von AppContainer benötigt.

class LoginContainer(val userRepository: UserRepository) {

    val loginData = LoginUserData()

    val loginViewModelFactory = LoginViewModelFactory(userRepository)
}

// AppContainer contains LoginContainer now
class AppContainer {
    ...
    val userRepository = UserRepository(localDataSource, remoteDataSource)

    // LoginContainer will be null when the user is NOT in the login flow
    var loginContainer: LoginContainer? = null
}

In Compose ist die Lebensdauer des Flow-Containers an die Komposition gebunden und nicht an den Host Activity. Sie müssen ein gemeinsam genutztes AppContainer.loginContainer nicht ändern, da Composables ihre Abhängigkeiten als Parameter erhalten oder aus einem gehosteten ViewModel lesen. Es stehen zwei Optionen zur Verfügung:

  1. Verschachtelter Navigationsgraph in Compose (bevorzugt für Multi-Screen-Abläufe) Platzieren Sie alle Bildschirme im Anmeldevorgang in einem verschachtelten Navigationsdiagramm und legen Sie den Bereich des Containers auf das NavBackStackEntry dieses Diagramms fest. Der Container wird erstellt, wenn der Nutzer den Ablauf aufruft, und gelöscht, wenn der Backstack-Eintrag entfernt wird. Manuelle Lifecycle-Aufrufe sind nicht erforderlich. Weitere Informationen finden Sie unter Navigationsdiagramm entwerfen.
  2. remember im Stammverzeichnis des Bildschirms (für einen Single-Screen-Ablauf oder wenn Sie Navigation Compose nicht verwenden). Erstellen Sie den Container in remember, damit er einmal pro Aufruf der Komposition erstellt und bereinigt wird, wenn die Composable verlassen wird:
@Composable
fun LoginFlow(appContainer: AppContainer) {
    val loginContainer = remember(appContainer) {
        LoginContainer(appContainer.userRepository)
    }
    val viewModel: LoginViewModel = viewModel(
        factory = loginContainer.loginViewModelFactory
    )
    // Render the login flow using loginContainer.loginData and viewModel.
}

Fazit

Die Abhängigkeitsinjektion ist eine gute Methode zum Erstellen skalierbarer und testbarer Android-Apps. Sie können Container verwenden, um Instanzen von Klassen in verschiedenen Teilen Ihrer App freizugeben, und als zentralen Ort, um Instanzen von Klassen mithilfe von Factories zu erstellen.

Wenn Ihre Anwendung größer wird, werden Sie feststellen, dass Sie viel Boilerplate-Code (z. B. Factories) schreiben, was fehleranfällig sein kann. Sie müssen auch den Umfang und den Lebenszyklus der Container selbst verwalten und Container, die nicht mehr benötigt werden, optimieren und verwerfen, um Speicherplatz freizugeben. Wenn Sie das falsch machen, kann das zu subtilen Fehlern und Speicherlecks in Ihrer App führen.

Im Dagger-Abschnitt erfahren Sie, wie Sie diesen Prozess mit Dagger automatisieren und denselben Code generieren können, den Sie sonst manuell geschrieben hätten.

Zusätzliche Ressourcen

Inhalte ansehen