Ручное внедрение зависимостей

Рекомендуемая архитектура приложений Android поощряет разделение кода на классы, чтобы воспользоваться принципом разделения ответственности, при котором каждый класс в иерархии имеет единственную определенную обязанность. Это приводит к появлению большего количества меньших классов, которые необходимо связать для выполнения зависимостей друг друга.

Приложения для Android обычно состоят из множества классов, и некоторые из них зависят друг от друга.
Рисунок 1. Модель графа приложения для Android.

Зависимости между классами можно представить в виде графа, в котором каждый класс связан с классами, от которых он зависит. Представление всех ваших классов и их зависимостей образует граф приложения . На рисунке 1 вы можете увидеть абстракцию графа приложения. Когда класс A ( ViewModel ) зависит от класса B ( Repository ), существует линия, указывающая от A к B, представляющая эту зависимость.

Внедрение зависимостей помогает устанавливать эти связи и позволяет заменять реализации для тестирования. Например, при тестировании ViewModel , зависящей от репозитория, вы можете передавать различные реализации Repository с поддельными объектами или моками для проверки различных случаев.

Основы ручного внедрения зависимостей

В этом разделе рассматривается, как применять ручное внедрение зависимостей в реальном сценарии Android-приложения. Пошагово описывается, как начать использовать внедрение зависимостей в вашем приложении. Подход совершенствуется до тех пор, пока не достигнет уровня, очень похожего на тот, который Dagger автоматически генерирует для вас. Для получения дополнительной информации о Dagger, прочитайте раздел «Основы Dagger» .

Представьте себе поток как группу экранов в вашем приложении, соответствующих определенной функции. Вход в систему, регистрация и оформление заказа — все это примеры потоков.

При описании процесса авторизации для типичного Android-приложения, LoginActivity зависит от LoginViewModel , который, в свою очередь, зависит от UserRepository . UserRepository в свою очередь, зависит от UserLocalDataSource и UserRemoteDataSource , которые, в свою очередь, зависят от службы Retrofit .

LoginActivity является точкой входа в процесс авторизации, и пользователь взаимодействует с этой активностью. Таким образом, LoginActivity должна создать LoginViewModel со всеми его зависимостями.

Классы Repository и DataSource потока выглядят следующим образом:

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

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

В Compose ComponentActivity является точкой входа; связывание зависимостей происходит один раз в onCreate , а пользовательский интерфейс описывается компонентами, вызываемыми из 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)
    )
    // ...
}

У такого подхода есть недостатки:

  1. Зависимости необходимо объявлять в правильном порядке. Для создания объекта UserRepository необходимо создать его до LoginViewModel .
  2. Повторное использование объектов затруднительно. Если вы хотите использовать UserRepository в нескольких функциях, вам придётся следовать шаблону Singleton . Шаблон Singleton усложняет тестирование, поскольку все тесты используют один и тот же экземпляр Singleton.

Управление зависимостями с помощью контейнера

Для решения проблемы повторного использования объектов можно создать собственный класс -контейнер зависимостей , который будет использоваться для получения зависимостей. Все экземпляры, предоставляемые этим контейнером, могут быть публичными. В примере, поскольку вам нужен только экземпляр UserRepository , вы можете сделать его зависимости приватными с возможностью сделать их публичными в будущем, если потребуется их предоставление:

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

Поскольку эти зависимости используются во всем приложении, их необходимо разместить в общем месте, доступном для всех действий: в классе Application . Создайте пользовательский класс Application , содержащий экземпляр 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()
}

При использовании Compose тот же AppContainer по-прежнему создается в подклассе Application . Вы получаете к нему доступ либо в активности, до вызова setContent , либо изнутри компонуемого объекта, используя 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)
    )
    // ...
}

Мы рекомендуем передавать зависимости в качестве параметров, которые можно комбинировать, вместо того, чтобы обращаться к LocalContext из глубины дерева. Это позволяет тестировать комбинируемые объекты и явно указывать их входные данные. Разрешите контейнер, находясь в корневом каталоге screen, и передавайте необходимые параметры вниз.

Таким образом, у вас нет единственного экземпляра UserRepository . Вместо этого у вас есть AppContainer общий для всех активностей, который содержит объекты из графа и создает экземпляры этих объектов, которые могут использоваться другими классами.

Если LoginViewModel требуется в нескольких местах приложения, имеет смысл иметь централизованное место для создания экземпляров LoginViewModel . Вы можете перенести создание LoginViewModel в контейнер и предоставлять новые объекты этого типа с помощью фабрики. Код для LoginViewModelFactory выглядит следующим образом:

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

При использовании Compose обновление AppContainer по-прежнему предоставляет доступ к фабрике. Затем фабрика используется компонуемым объектом viewModel , так что область видимости ViewModel ограничивается ближайшим ViewModelStoreOwner (обычно это хост-активность или, в случае Navigation Compose, элемент навигации):

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

Этот подход лучше предыдущего, но всё же есть некоторые проблемы, которые следует учитывать:

  1. Вам придётся самостоятельно управлять AppContainer , создавая экземпляры для всех зависимостей вручную.

  2. В коде по-прежнему много шаблонного кода. Вам приходится создавать фабрики или параметры вручную в зависимости от того, хотите ли вы повторно использовать объект или нет.

Управление зависимостями в потоках приложений

AppContainer усложняется, когда вы хотите добавить в проект больше функциональности. По мере роста приложения и внедрения различных сценариев развития функций возникают ещё большие проблемы:

  1. При наличии нескольких потоков данных может потребоваться, чтобы объекты существовали только в рамках этого потока. Например, при создании LoginUserData (который может содержать имя пользователя и пароль, используемые только в процессе входа в систему) вам не нужно сохранять данные из старого процесса входа в систему от другого пользователя. Вам нужен новый экземпляр для каждого нового потока. Этого можно добиться, создавая объекты FlowContainer внутри AppContainer , как показано в следующем примере кода.

  2. Оптимизация графа приложения и контейнеров потоков также может быть сложной задачей. Необходимо помнить об удалении ненужных экземпляров в зависимости от используемого потока.

Давайте добавим LoginContainer в пример кода. Вам нужно иметь возможность создавать несколько экземпляров LoginContainer в приложении, поэтому вместо того, чтобы делать его синглтоном, сделайте его классом с зависимостями, необходимыми для процесса авторизации от 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
}

В Compose время жизни контейнера потока привязано к композиции, а не к хост- Activity . Вам не нужно изменять общий AppContainer.loginContainer , поскольку компонуемые объекты получают свои зависимости в качестве параметров или считывают их из поднятой ViewModel . У вас есть два варианта:

  1. Навигация. Создайте вложенный граф (предпочтительно для многоэкранных сценариев). Разместите все экраны в сценарии входа в систему под вложенным графом навигации и ограничьте область действия контейнера элементом NavBackStackEntry этого графа. Контейнер создается, когда пользователь входит в сценарий, и очищается, когда удаляется элемент из стека возврата, без необходимости ручных вызовов жизненного цикла. Для получения дополнительной информации см. раздел «Разработка графа навигации» .
  2. remember находится в корневом каталоге экрана (для одноэкранного потока или когда вы не используете Navigation Compose). Создайте контейнер внутри remember таким образом, чтобы он создавался один раз для каждой записи в композиции и удалялся сборщиком мусора, когда компонуемый объект покидает ее:
@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.
}

Заключение

Внедрение зависимостей — это хороший метод для создания масштабируемых и тестируемых Android-приложений. Используйте контейнеры для совместного использования экземпляров классов в разных частях приложения, а также в качестве централизованного места для создания экземпляров классов с помощью фабрик.

По мере роста вашего приложения вы начнете замечать, что пишете много шаблонного кода (например, фабрики), который может быть подвержен ошибкам. Вам также придется самостоятельно управлять областью действия и жизненным циклом контейнеров, оптимизируя и удаляя ненужные контейнеры, чтобы освободить память. Неправильный подход к этому может привести к скрытым ошибкам и утечкам памяти в вашем приложении.

В разделе, посвященном Dagger , вы узнаете, как с помощью Dagger автоматизировать этот процесс и сгенерировать тот же код, который в противном случае пришлось бы писать вручную.

Дополнительные ресурсы

Просмотры контента