Ręczne wstrzykiwanie zależności

Zalecana architektura aplikacji na Androida zachęca do dzielenia kodu na klasy, aby korzystać z zasady rozdzielenia odpowiedzialności, zgodnie z którą każda klasa w hierarchii ma jedno określone zadanie. Prowadzi to do powstania większej liczby mniejszych klas, które muszą być połączone, aby spełniać swoje zależności.

Aplikacje na Androida zwykle składają się z wielu klas, a niektóre z nich są od siebie zależne.
Rysunek 1. Model grafu aplikacji na Androida

Zależności między klasami można przedstawić w postaci grafu, w którym każda klasa jest połączona z klasami, od których zależy. Reprezentacja wszystkich klas i ich zależności tworzy wykres aplikacji. Na rysunku 1 widać abstrakcyjny wykres aplikacji. Jeśli klasa A (ViewModel) zależy od klasy B (Repository), między nimi znajduje się linia wskazująca zależność.

Wstrzykiwanie zależności pomaga nawiązywać te połączenia i umożliwia zamianę implementacji na potrzeby testowania. Na przykład podczas testowania ViewModel, które zależy od repozytorium, możesz przekazywać różne implementacje Repository z użyciem atrap lub obiektów pozornych, aby przetestować różne przypadki.

Podstawy ręcznego wstrzykiwania zależności

Z tej sekcji dowiesz się, jak zastosować ręczne wstrzykiwanie zależności w rzeczywistej aplikacji na Androida. Opisuje iteracyjne podejście do rozpoczęcia korzystania z wstrzykiwania zależności w aplikacji. Podejście to jest ulepszane, aż osiągnie punkt bardzo podobny do tego, co Dagger wygenerowałby automatycznie. Więcej informacji o Daggerze znajdziesz w artykule Podstawy Daggera.

Ścieżka to grupa ekranów w aplikacji, które odpowiadają funkcji. Logowanie, rejestracja i płatność to przykłady ścieżek.

W przypadku typowej aplikacji na Androida proces logowania LoginActivity zależy od LoginViewModel, który z kolei zależy od UserRepository. Wtedy UserRepository zależy od UserLocalDataSource i UserRemoteDataSource, które z kolei zależą od usługi Retrofit.

LoginActivity to punkt wejścia do procesu logowania, a użytkownik wchodzi w interakcję z aktywnością. Dlatego LoginActivity musi utworzyć LoginViewModel ze wszystkimi zależnościami.

Klasy RepositoryDataSource przepływu wyglądają tak:

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

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

W Compose punktem wejścia jest ComponentActivity. Okablowanie zależności odbywa się raz w onCreate, a interfejs jest opisywany przez funkcje kompozycyjne wywoływane z setContent:

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

Z tym podejściem wiążą się pewne problemy:

  1. Zależności muszą być deklarowane w odpowiedniej kolejności. Aby utworzyć obiekt UserRepository, musisz go najpierw zainicjować LoginViewModel.
  2. Trudno jest ponownie użyć obiektów. Jeśli chcesz ponownie użyć UserRepositoryw wielu funkcjach, musisz zastosować wzorzec singleton. Wzorzec singleton utrudnia testowanie, ponieważ wszystkie testy korzystają z tej samej instancji singletona.

Zarządzanie zależnościami za pomocą kontenera

Aby rozwiązać problem z ponownym używaniem obiektów, możesz utworzyć własną klasę kontenera zależności, której będziesz używać do pobierania zależności. Wszystkie instancje udostępniane przez ten kontener mogą być publiczne. W tym przykładzie potrzebujesz tylko instancji UserRepository, więc możesz ustawić jej zależności jako prywatne, a w przyszłości, jeśli będzie to konieczne, możesz je udostępnić:

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

Ponieważ te zależności są używane w całej aplikacji, muszą znajdować się w miejscu wspólnym, z którego mogą korzystać wszystkie aktywności: w klasie Application. Utwórz klasę niestandardową Application, która zawiera instancję AppContainer.

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

W przypadku Compose ten sam element AppContainer jest nadal tworzony w podklasie Application. Możesz uzyskać do niego dostęp w aktywności przed wywołaniem funkcji setContent lub w funkcji kompozycyjnej za pomocą funkcji 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)
    )
    // ...
}

Zalecamy przekazywanie zależności jako parametrów funkcji kompozycyjnych zamiast sięgać do LocalContext z głębi drzewa. Dzięki temu funkcje kompozycyjne można testować, a ich dane wejściowe są jawne. Rozwiąż kontener raz na poziomie głównym ekranu i przekaż potrzebne elementy w dół.

W ten sposób nie masz pojedynczego elementu UserRepository. Zamiast tego masz AppContainerudostępniony we wszystkich działaniach, który zawiera obiekty z wykresuAppContainer i tworzy instancje tych obiektów, które mogą być używane przez inne klasy.

Jeśli LoginViewModel jest potrzebny w wielu miejscach w aplikacji, warto mieć centralne miejsce, w którym tworzysz instancje LoginViewModel. Możesz przenieść tworzenie LoginViewModel do kontenera i zapewnić nowe obiekty tego typu za pomocą fabryki. Kod dla LoginViewModelFactory wygląda tak:

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

W przypadku Compose aktualizacja AppContainer nadal udostępnia fabrykę. Fabryka jest następnie wykorzystywana przez funkcję kompozycyjną viewModel, dzięki czemu ViewModel jest ograniczony do najbliższego ViewModelStoreOwner (zwykle aktywności hosta lub, w przypadku Navigation Compose, wpisu nawigacyjnego):

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

To podejście jest lepsze od poprzedniego, ale nadal wiąże się z pewnymi wyzwaniami:

  1. Musisz samodzielnie zarządzać AppContainer, ręcznie tworząc instancje dla wszystkich zależności.

  2. Wciąż jest dużo powtarzalnego kodu. Fabryki lub parametry musisz tworzyć ręcznie w zależności od tego, czy chcesz ponownie użyć obiektu.

Zarządzanie zależnościami w przepływach aplikacji

AppContainer staje się skomplikowane, gdy chcesz dodać do projektu więcej funkcji. Gdy aplikacja się rozrasta i zaczynasz wprowadzać różne ścieżki funkcji, pojawia się jeszcze więcej problemów:

  1. Jeśli masz różne automatyzacje, możesz chcieć, aby obiekty istniały tylko w zakresie danej automatyzacji. Na przykład podczas tworzenia LoginUserData (który może składać się z nazwy użytkownika i hasła używanych tylko w procesie logowania) nie chcesz zachowywać danych ze starego procesu logowania innego użytkownika. Chcesz, aby każdy nowy przepływ miał nową instancję. Możesz to zrobić, tworząc obiekty FlowContainer w obiekcie AppContainer, jak pokazano w przykładzie kodu poniżej.

  2. Optymalizacja wykresu aplikacji i kontenerów przepływu może być trudna. W zależności od wybranego procesu musisz pamiętać o usuwaniu instancji, których nie potrzebujesz.

Dodajmy LoginContainer do przykładowego kodu. Chcesz mieć możliwość tworzenia w aplikacji wielu instancji LoginContainer, więc zamiast tworzyć z niej singleton, utwórz klasę z zależnościami, których proces logowania potrzebuje od AppContainer.

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
}

W Compose okres istnienia kontenera przepływu jest powiązany z kompozycją, a nie z Activity hosta. Nie musisz zmieniać stanu obiektu sharedAppContainer.loginContainer, ponieważ funkcje kompozycyjne otrzymują zależności jako parametry lub odczytują je z obiektu ViewModel przeniesionego wyżej. Dostępne są dwie opcje:

  1. Zagnieżdżony wykres Navigation Compose (zalecany w przypadku przepływów na wielu ekranach). Umieść wszystkie ekrany w procesie logowania w zagnieżdżonym wykresie nawigacji i ogranicz zakres kontenera do NavBackStackEntry tego wykresu. Kontener jest tworzony, gdy użytkownik rozpoczyna proces, a usuwany, gdy element ze stosu wstecznego zostaje usunięty. Nie wymaga to ręcznego wywoływania funkcji cyklu życia. Więcej informacji znajdziesz w artykule Projektowanie wykresu nawigacji.
  2. remember na poziomie głównym ekranu (w przypadku przepływu na jednym ekranie lub gdy nie używasz Navigation Compose). Skonstruuj kontener w remember, aby był tworzony raz na wejście do kompozycji i zbierany przez odśmiecanie, gdy funkcja kompozycyjna zostanie opuszczona:
@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.
}

Podsumowanie

Wstrzykiwanie zależności to dobra technika tworzenia skalowalnych i łatwych do testowania aplikacji na Androida. Używaj kontenerów do udostępniania instancji klas w różnych częściach aplikacji oraz jako centralnego miejsca do tworzenia instancji klas za pomocą fabryk.

Gdy aplikacja się rozrośnie, zaczniesz zauważać, że piszesz dużo powtarzalnego kodu (np. fabryk), co może prowadzić do błędów. Musisz też samodzielnie zarządzać zakresem i cyklem życia kontenerów, optymalizować i usuwać te, które nie są już potrzebne, aby zwolnić pamięć. Nieprawidłowe wykonanie tej czynności może prowadzić do subtelnych błędów i wycieków pamięci w aplikacji.

sekcji Dagger dowiesz się, jak używać Daggera do automatyzacji tego procesu i generowania kodu, który w inny sposób musiałbyś napisać ręcznie.

Dodatkowe materiały

Wyświetla treści