Manuelle Abhängigkeitsinjektion

Die empfohlene App-Architektur von Android empfiehlt, den Code in Klassen zu unterteilen, um von der Aufgabentrennung zu profitieren. Bei diesem Prinzip hat jede Klasse der Hierarchie eine einzelne definierte Aufgabe. Dies führt zu mehr kleineren Klassen, die miteinander verbunden werden müssen, um die Abhängigkeiten voneinander zu erfüllen.

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

Die Abhängigkeiten zwischen Klassen können als Graph dargestellt werden, in dem jede Klasse mit den Klassen verbunden ist, von denen sie abhängt. Die Darstellung aller Klassen und ihrer Abhängigkeiten bildet die Anwendungsgrafik. In Abbildung 1 sehen Sie eine Abstraktion des Anwendungsgraphen. Wenn Klasse A (ViewModel) von Klasse B (Repository) abhängt, gibt es eine Linie, die von A nach B zeigt und diese Abhängigkeit darstellt.

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

Grundlagen der manuellen Abhängigkeitsinjektion

In diesem Abschnitt erfahren Sie, wie Sie die manuelle Abhängigkeitsinjektion in einem echten Android-App-Szenario anwenden. Es wird ein iterativer Ansatz erläutert, wie Sie mit der Dependency Injection in Ihrer App beginnen können. Der Ansatz wird verbessert, bis er einem Punkt entspricht, der dem ähnelt, was Dagger automatisch für Sie generieren würde. Weitere Informationen zu Dagger finden Sie unter Grundlagen von Dagger.

Ein Navigationsfluss ist eine Gruppe von Bildschirmen in Ihrer App, die einer Funktion entsprechen. Anmeldung, Registrierung und Bezahlung sind Beispiele für Aufrufabfolgen.

Bei einem Anmeldevorgang für eine typische Android-App hängt LoginActivity von LoginViewModel ab, das wiederum von UserRepository abhängt. Dann hängt UserRepository von einem UserLocalDataSource und einem UserRemoteDataSource ab, die wiederum von einem Retrofit-Dienst abhängen.

LoginActivity ist der Einstiegspunkt in den Anmeldevorgang und der Nutzer interagiert mit der Aktivität. Daher muss LoginActivity LoginViewModel mit allen zugehörigen Abhängigkeiten erstellen.

Die Klassen Repository und DataSource des Ablaufs sehen so aus:

Kotlin

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

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

Java

class UserLocalDataSource {
    public UserLocalDataSource() { }
    ...
}

class UserRemoteDataSource {

    private final Retrofit retrofit;

    public UserRemoteDataSource(Retrofit retrofit) {
        this.retrofit = retrofit;
    }

    ...
}

class UserRepository {

    private final UserLocalDataSource userLocalDataSource;
    private final UserRemoteDataSource userRemoteDataSource;

    public UserRepository(UserLocalDataSource userLocalDataSource, UserRemoteDataSource userRemoteDataSource) {
        this.userLocalDataSource = userLocalDataSource;
        this.userRemoteDataSource = userRemoteDataSource;
    }

    ...
}

LoginActivity sieht so aus:

Kotlin

class LoginActivity: Activity() {

    private lateinit var loginViewModel: LoginViewModel

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

        // In order to satisfy the dependencies of LoginViewModel, you have to also
        // satisfy the dependencies of all of its dependencies recursively.
        // First, create retrofit which is the dependency of UserRemoteDataSource
        val retrofit = Retrofit.Builder()
            .baseUrl("https://example.com")
            .build()
            .create(LoginService::class.java)

        // Then, satisfy the dependencies of UserRepository
        val remoteDataSource = UserRemoteDataSource(retrofit)
        val localDataSource = UserLocalDataSource()

        // Now you can create an instance of UserRepository that LoginViewModel needs
        val userRepository = UserRepository(localDataSource, remoteDataSource)

        // Lastly, create an instance of LoginViewModel with userRepository
        loginViewModel = LoginViewModel(userRepository)
    }
}

Java

public class MainActivity extends Activity {

    private LoginViewModel loginViewModel;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        // In order to satisfy the dependencies of LoginViewModel, you have to also
        // satisfy the dependencies of all of its dependencies recursively.
        // First, create retrofit which is the dependency of UserRemoteDataSource
        Retrofit retrofit = new Retrofit.Builder()
                .baseUrl("https://example.com")
                .build()
                .create(LoginService.class);

        // Then, satisfy the dependencies of UserRepository
        UserRemoteDataSource remoteDataSource = new UserRemoteDataSource(retrofit);
        UserLocalDataSource localDataSource = new UserLocalDataSource();

        // Now you can create an instance of UserRepository that LoginViewModel needs
        UserRepository userRepository = new UserRepository(localDataSource, remoteDataSource);

        // Lastly, create an instance of LoginViewModel with userRepository
        loginViewModel = new LoginViewModel(userRepository);
    }
}

Dieser Ansatz birgt jedoch einige Probleme:

  1. Es gibt viel Boilerplate-Code. Wenn Sie in einem anderen Teil des Codes eine weitere Instanz von LoginViewModel erstellen möchten, würde es zu Codeduplizierungen kommen.

  2. Abhängigkeiten müssen in der richtigen Reihenfolge deklariert werden. Sie müssen UserRepository vor LoginViewModel instanziieren, um sie zu erstellen.

  3. Es ist schwierig, Objekte wiederzuverwenden. Wenn Sie UserRepository für mehrere Funktionen wiederverwenden möchten, müssen Sie das Singleton-Muster verwenden. Das Singleton-Muster erschwert das Testen, da alle Tests dieselbe Singleton-Instanz verwenden.

Abhängigkeiten mit einem Container verwalten

Um das Problem mit der Wiederverwendung von Objekten zu lösen, 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. Da im Beispiel nur eine Instanz von UserRepository benötigt wird, können Sie die Abhängigkeiten privat machen und sie bei Bedarf später öffentlich machen:

Kotlin

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

    // Since you want to expose userRepository out of the container, you need to satisfy
    // its dependencies as you did before
    private val retrofit = Retrofit.Builder()
                            .baseUrl("https://example.com")
                            .build()
                            .create(LoginService::class.java)

    private val remoteDataSource = UserRemoteDataSource(retrofit)
    private val localDataSource = UserLocalDataSource()

    // userRepository is not private; it'll be exposed
    val userRepository = UserRepository(localDataSource, remoteDataSource)
}

Java

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

    // Since you want to expose userRepository out of the container, you need to satisfy
    // its dependencies as you did before
    private Retrofit retrofit = new Retrofit.Builder()
            .baseUrl("https://example.com")
            .build()
            .create(LoginService.class);

    private UserRemoteDataSource remoteDataSource = new UserRemoteDataSource(retrofit);
    private UserLocalDataSource localDataSource = new UserLocalDataSource();

    // userRepository is not private; it'll be exposed
    public UserRepository userRepository = new UserRepository(localDataSource, remoteDataSource);
}

Da diese Abhängigkeiten in der gesamten Anwendung verwendet werden, müssen sie an einer gemeinsamen Stelle platziert werden, die von allen Aktivitäten verwendet werden kann: der Klasse Application. Erstellen Sie eine benutzerdefinierte Application-Klasse, die eine AppContainer-Instanz enthält.

Kotlin

// 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()
}

Java

// Custom Application class that needs to be specified
// in the AndroidManifest.xml file
public class MyApplication extends Application {

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

Jetzt können Sie die Instanz der AppContainer aus der Anwendung abrufen und die freigegebene UserRepository-Instanz abrufen:

Kotlin

class LoginActivity: Activity() {

    private lateinit var loginViewModel: LoginViewModel

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

        // Gets userRepository from the instance of AppContainer in Application
        val appContainer = (application as MyApplication).appContainer
        loginViewModel = LoginViewModel(appContainer.userRepository)
    }
}

Java

public class MainActivity extends Activity {

    private LoginViewModel loginViewModel;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        // Gets userRepository from the instance of AppContainer in Application
        AppContainer appContainer = ((MyApplication) getApplication()).appContainer;
        loginViewModel = new LoginViewModel(appContainer.userRepository);
    }
}

So haben Sie keine Singleton-UserRepository. Stattdessen gibt es eine AppContainer, die für alle Aktivitäten freigegeben ist, Objekte aus dem Diagramm enthält und 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, an einem zentralen Ort Instanzen von LoginViewModel zu erstellen. Sie können das Erstellen von LoginViewModel in den Container verschieben und neuen Objekten dieses Typs eine Fabrik zur Verfügung stellen. Der Code für eine LoginViewModelFactory sieht so aus:

Kotlin

// 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 {
    override fun create(): LoginViewModel {
        return LoginViewModel(userRepository)
    }
}

Java

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

// 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 implements Factory {

    private final UserRepository userRepository;

    public LoginViewModelFactory(UserRepository userRepository) {
        this.userRepository = userRepository;
    }

    @Override
    public LoginViewModel create() {
        return new LoginViewModel(userRepository);
    }
}

Sie können die LoginViewModelFactory in die AppContainer einfügen und die LoginActivity dazu veranlassen, sie zu verwenden:

Kotlin

// AppContainer can now provide instances of LoginViewModel with LoginViewModelFactory
class AppContainer {
    ...
    val userRepository = UserRepository(localDataSource, remoteDataSource)

    val loginViewModelFactory = LoginViewModelFactory(userRepository)
}

class LoginActivity: Activity() {

    private lateinit var loginViewModel: LoginViewModel

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

        // Gets LoginViewModelFactory from the application instance of AppContainer
        // to create a new LoginViewModel instance
        val appContainer = (application as MyApplication).appContainer
        loginViewModel = appContainer.loginViewModelFactory.create()
    }
}

Java

// AppContainer can now provide instances of LoginViewModel with LoginViewModelFactory
public class AppContainer {
    ...

    public UserRepository userRepository = new UserRepository(localDataSource, remoteDataSource);

    public LoginViewModelFactory loginViewModelFactory = new LoginViewModelFactory(userRepository);
}

public class MainActivity extends Activity {

    private LoginViewModel loginViewModel;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        // Gets LoginViewModelFactory from the application instance of AppContainer
        // to create a new LoginViewModel instance
        AppContainer appContainer = ((MyApplication) getApplication()).appContainer;
        loginViewModel = appContainer.loginViewModelFactory.create();
    }
}

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

  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 Fabriken 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 Workflows haben, möchten Sie möglicherweise, dass Objekte nur im Rahmen dieses Workflows vorhanden sind. Wenn Sie beispielsweise LoginUserData erstellen (das aus dem Nutzernamen und Passwort bestehen kann, die nur beim Anmeldevorgang verwendet werden), sollten Sie keine Daten aus einem alten Anmeldevorgang eines anderen Nutzers beibehalten. Sie benötigen eine neue Instanz für jeden neuen Ablauf. Dazu können Sie FlowContainer-Objekte innerhalb der AppContainer erstellen, wie im nächsten Codebeispiel gezeigt.

  2. Auch die Optimierung der Anwendungsgrafik und der Ablaufcontainer kann schwierig sein. Je nach Ablauf müssen Sie nicht mehr benötigte Instanzen löschen.

Angenommen, Sie haben einen Anmeldevorgang, der aus einer Aktivität (LoginActivity) und mehreren Fragmenten (LoginUsernameFragment und LoginPasswordFragment) besteht. Mit diesen Ansichten soll Folgendes erreicht werden:

  1. Rufen Sie dieselbe LoginUserData-Instanz auf, die freigegeben werden soll, bis der Anmeldevorgang abgeschlossen ist.

  2. Erstellen Sie eine neue Instanz von LoginUserData, wenn der Ablauf neu gestartet wird.

Das ist mit einem Container für den Anmeldevorgang möglich. Dieser Container muss beim Start des Anmeldevorgangs erstellt und am Ende des Vorgangs aus dem Arbeitsspeicher entfernt werden.

Fügen wir dem Beispielcode ein LoginContainer hinzu. Sie möchten mehrere Instanzen von LoginContainer in der App erstellen können. Anstatt sie also als Singleton zu erstellen, sollten Sie sie als Klasse mit den Abhängigkeiten definieren, die der Anmeldevorgang von AppContainer benötigt.

Kotlin

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
}

Java

// Container with Login-specific dependencies
class LoginContainer {

    private final UserRepository userRepository;

    public LoginContainer(UserRepository userRepository) {
        this.userRepository = userRepository;
        loginViewModelFactory = new LoginViewModelFactory(userRepository);
    }

    public LoginUserData loginData = new LoginUserData();

    public LoginViewModelFactory loginViewModelFactory;
}

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

    // LoginContainer will be null when the user is NOT in the login flow
    public LoginContainer loginContainer;
}

Sobald Sie einen für einen Ablauf spezifischen Container haben, müssen Sie entscheiden, wann Sie die Containerinstanz erstellen und löschen möchten. Da Ihr Anmeldevorgang in einer Aktivität (LoginActivity) enthalten ist, wird der Lebenszyklus dieses Containers von der Aktivität verwaltet. LoginActivity kann die Instanz in onCreate() erstellen und in onDestroy() löschen.

Kotlin

class LoginActivity: Activity() {

    private lateinit var loginViewModel: LoginViewModel
    private lateinit var loginData: LoginUserData
    private lateinit var appContainer: AppContainer


    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        appContainer = (application as MyApplication).appContainer

        // Login flow has started. Populate loginContainer in AppContainer
        appContainer.loginContainer = LoginContainer(appContainer.userRepository)

        loginViewModel = appContainer.loginContainer.loginViewModelFactory.create()
        loginData = appContainer.loginContainer.loginData
    }

    override fun onDestroy() {
        // Login flow is finishing
        // Removing the instance of loginContainer in the AppContainer
        appContainer.loginContainer = null
        super.onDestroy()
    }
}

Java

public class LoginActivity extends Activity {

    private LoginViewModel loginViewModel;
    private LoginData loginData;
    private AppContainer appContainer;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        appContainer = ((MyApplication) getApplication()).appContainer;

        // Login flow has started. Populate loginContainer in AppContainer
        appContainer.loginContainer = new LoginContainer(appContainer.userRepository);

        loginViewModel = appContainer.loginContainer.loginViewModelFactory.create();
        loginData = appContainer.loginContainer.loginData;
    }

    @Override
    protected void onDestroy() {
        // Login flow is finishing
        // Removing the instance of loginContainer in the AppContainer
        appContainer.loginContainer = null;

        super.onDestroy();
    }
}

Wie LoginActivity können auch Anmeldefragmente von AppContainer aus auf LoginContainer zugreifen und die freigegebene LoginUserData-Instanz verwenden.

Da es sich in diesem Fall um die Logik des Ansichtslebenszyklus handelt, ist die Verwendung der Lebenszyklusbeobachtung sinnvoll.

Fazit

Die Abhängigkeitsinjektion ist eine gute Methode, um skalierbare und testbare Android-Apps zu erstellen. Verwenden Sie Container, um Instanzen von Klassen in verschiedenen Teilen Ihrer App freizugeben und an einem zentralen Ort Instanzen von Klassen mithilfe von Fabriken 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. Außerdem müssen Sie den Umfang und den Lebenszyklus der Container selbst verwalten und nicht mehr benötigte Container optimieren und verwerfen, um Speicherplatz freizugeben. Andernfalls kann es zu subtilen Fehlern und Speicherlecks in Ihrer App kommen.

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