Инъекция зависимостей — это шаблон программирования, который предписывает классам не конструировать экземпляры своих зависимостей, а предоставлять такие экземпляры. Этот паттерн позволяет разделить ответственность, повышает тестируемость, возможность повторного использования и простоту обслуживания. О преимуществах инъекции зависимостей и ее основных концепциях читайте в статье “Инъекция зависимостей в Android”.
Возможно, вы уже знакомы с Hilt, библиотекой для Android, основанной на Dagger, которая реализует решение для инъекции зависимостей в приложениях для Android. Подход Hilt включает в себя две важные особенности:
- Предоставление зависимостей: как объекты и их зависимости создаются и приобретаются классами, которым они нужны.
- Определение границ зависимостей: определение места хранения объектов и времени жизни, в течение которого эти объекты действительны.
Тем временем Compose быстро становится новым стандартом для создания пользовательского интерфейса в приложениях для Android, используя функциональное программирование для управления тем, что появляется на экране. В этой статье мы подробно рассмотрим, как Hilt обеспечивает и определяет зависимости в традиционном приложении для Android и как Compose меняет наш подход.
Предоставление зависимостей
Hilt использует аннотации для генерации исходного кода, который инстанцирует объекты и предоставляет их в качестве зависимостей для других объектов. Каждый тип в графе зависимостей должен быть известен, равно как и способ создания объектов этого типа, чтобы подтвердить, что каждая зависимость может быть выполнена, иначе генератор кода не сработает и приложение не будет собрано.
За эту работу приходится платить чуть большим временем сборки, но взамен вы получаете производительный код и безопасность типов. Сгенерированный код точно знает, какие объекты где нужны и как их правильно создать, поэтому вы не столкнетесь с ошибками во время выполнения, вызванными отсутствием зависимостей или получением зависимостей неправильного типа.
Предположим, вы используете инъекцию зависимостей для создания объектов, реализующих вашу бизнес-логику, и в какой-то момент вам нужно подключить их к пользовательскому интерфейсу. В приложении для Android мост к слою пользовательского интерфейса проходит через Activities и Fragments, но эти классы инстанцируются фреймворком Android и не могут быть созданы Hilt или любым другим DI-решением.
К счастью, Hilt может автоматически вводить члены в такие классы фреймворка, как Activity
и Fragment
, просто добавив несколько аннотаций. Например, этому CheckoutFragment
нужен PaymentApi
, чтобы пользователь мог оформить свой заказ, и вот как это будет выглядеть:
class PaymentApi @Inject constructor() { fun submitPayment(...) { /* other business logic */ } } @AndroidEntryPoint class CheckoutFragment : Fragment() { @Inject lateinit var paymentApi: PaymentApi // example dependency override fun onViewCreated(...) { // ... submitButton.setOnClickListener { paymentApi.submitPayment(...) } } }
Однако рекомендуемый подход — использовать ViewModel и внедрять туда зависимости через конструктор. Это поможет вам сгруппировать зависимости таким образом, чтобы они не зависели от UI-фреймворка, а также позволит сохранить эти объекты при повторном использовании Activity или Fragment. Давайте поместим этот PaymentApi
в ViewModel:
@AndroidEntryPoint class CheckoutFragment : Fragment() { private val viewModel: CheckoutViewModel by viewModels() override fun onViewCreated(...) { // ... submitButton.setOnClickListener { viewModel.submitPayment(...) } } } @HiltViewModel class CheckoutViewModel @Inject constructor( private val savedStateHandle: SavedStateHandle, private val paymentApi: PaymentApi, // example dependency ) : ViewModel() { fun submitPayment(...) { paymentApi.submitPayment(...) } }
Иногда инъекция во ViewModel невозможна. Если PaymentApi
должен быть построен на основе экземпляра Activity, то ViewModel, удерживающая эту зависимость, после изменения конфигурации передаст старую Activity. В таких случаях следует вернуться к инжектированию в класс фреймворка.
Границы зависимостей
Hilt позволяет объявлять границы (scope) зависимостей и контейнеры, в которых они хранятся; эти контейнеры называются Компонентами. Область и соответствующий ей Компонент определяют время жизни объекта.
Hilt поставляется с готовым набором границами и Компонентов для Android, которые соответствуют таким ключевым типам, как Activity
, Fragment
и ViewModel
, поскольку все они имеют четко определенное время жизни, и обычно требуется, чтобы зависимости сохранялись в соответствии с этим временем жизни.
Выбор правильных границ или области видимости для зависимости влияет на корректность и производительность. Если вам нужен объект на одном конкретном экране, но вы внедряете его в более широкое время жизни, например, во время жизни приложения, этот объект может жить и потреблять память, когда он не используется. Аналогично, выбор слишком маленькой области видимости для объекта, управляющего общими данными, может привести к проблемам, когда этот объект исчезнет слишком быстро.
Что изменилось в Compose
Ранее мы рассмотрели два способа, с помощью которых Hilt инжектирует зависимости: через конструктор (как у ViewModel) или через установку членов (как у Activity). Но функции Composable — это просто функции. Они не являются классами, не инстанцируются и не имеют членов, поэтому ни инъекция конструктора, ни инъекция членов невозможны. Все, к чему имеет доступ Composable-функция, это ее параметры и любые члены ее enclosing класса — если у нее есть этот класс.
Кроме того, время жизни Composable не так четко определено, как у Activity или Fragment. Он может часто входить и выходить из композиции, и он в ней может присутствовать в нескольких местах, возможно, на разных глубинах иерархии. Поэтому наличие зависимого компонента, связанного с Composable, не так просто, и Hilt не определяет область применения и не включает компонент для использования с Composable.
Рекомендации
Используйте ViewModel и Compose Navigation
Если ваши зависимости могут быть инжектированы во ViewModel, это по-прежнему рекомендуемый способ подключения ваших объектов к слою пользовательского интерфейса. ViewModel имеют четко определенное время жизни, не привязанное к конкретному Composable, и могут быть получены по требованию.
@HiltViewModel class CheckoutViewModel @Inject constructor( private val savedStateHandle: SavedStateHandle, private val paymentApi: PaymentApi // example dependency ) : ViewModel() { fun submitPayment(...) { paymentApi.submitPayment(...) } } @Composable fun CheckoutScreen( viewModel: CheckoutViewModel = hiltViewModel() ) { // ... Button( onClick = { viewModel.submitPayment(...) } ) { Text("Submit") } }
Функция hiltViewModel()
позаботится о том, чтобы найти ближайшего подходящего владельца, обычно Activity или Fragment, который может сохранить ViewModel. Однако все больше приложений, созданных в основном с помощью Compose, имеют только одну Activity и не имеют Фрагментов. Если единственная Activity является единственным доступным владельцем, то все ViewModel (и все их инжектированные объекты) будут храниться до тех пор, пока эта Activity жива, что, вероятно, слишком долго для ViewModel, предназначенных только для определенной части пользовательского интерфейса.
Чтобы облегчить эту проблему, используйте библиотеку Compose Navigation, где hiltViewModel()
будет автоматически использовать запись в стеке навигации в качестве владельца ViewModel. ViewModel будет сохраняться до тех пор, пока ее цель присутствует в стеке навигации, что лучше соответствует месту ее использования.
Используйте enclosing класс с инъекцией конструктора
Если у вас есть зависимости, которые нельзя инжектировать во ViewModel, но вы все равно хотите логически сгруппировать их рядом с контентом, который их использует, вы можете создать класс, содержащий функцию Composable, и инжектировать зависимости в него. Затем инжектируйте enclosing класс в Activity или Fragment, расположенный рядом с тем местом, где он нужен, и вызывайте Composable-функцию оттуда. Enclosing класс не должен содержать никакого состояния, кроме инжектированных зависимостей, и не должен использовать никаких аннотаций области видимости.
// This time the dependency needs an Activity, so Hilt // can't inject it into a ViewModel. class PaymentApi @Inject constructor( private val activity: Activity, ) { ... } // Encloses the composable which uses the PaymentApi. class CheckoutScreenFactory @Inject constructor( private val paymentApi: PaymentApi, // ... more dependencies ) { @Composable fun Content(...) { // paymentApi can be accessed here } } @AndroidEntryPoint class ShoppingActivity : ComponentActivity { @Inject lateinit var checkoutScreenFactory: CheckoutScreenFactory fun onCreate(savedInstanceState: Bundle) { super.onCreate(savedInstanceState) setContent { // somewhere down in the composition hierarchy... checkoutScreenFactory.Content(...) } } }
Это полезный паттерн, потому что вы можете построить CheckoutScreenFactory
с тестовым дублем PaymentApi
, чтобы протестировать Composable.
Избегайте хранения зависимостей в CompositionLocal
Может возникнуть соблазн хранить @Injected объекты внутри CompositionLocal
и позволить Composables приобретать эти объекты таким образом. На первый взгляд, это проще, чем передавать эти объекты в качестве параметров через серию Composable.
Но такой подход лишает нас некоторых гарантий, которые предоставляет Hilt, и вносит возможные ошибки в рантайм. Нужный объект может отсутствовать или может быть заменен другим объектом, любым другим Composable по пути без вашего ведома. Если вы не будете осторожны, вы можете предоставить объект, который является частью неправильной области видимости.
Используйте точки входа
Hilt предоставляет возможность получить объект из графа зависимостей с помощью @EntryPoint
. Это меньше похоже на «инжектирование» и больше на «запрос» объекта, но, в отличие от CompositionLocal
, использование точки входа гарантирует, что полученный объект имеет правильный тип и подходит для текущей области видимости. Например, этот код получит PaymentApi
внутри CheckoutScreen
Composable:
@EntryPoint @InstallIn(ActivityComponent::class) interface PaymentApiEntryPoint { fun paymentApi(): PaymentApi } @Composable fun CheckoutScreen() { val activity = LocalContext.current as Activity val paymentApi = remember { EntryPointAccessors.fromActivity( activity, PaymentApiEntryPoint::class.java ).paymentApi() } // ... }
Необходимый шаг — узнать, в каком зависимом компоненте находится нужный объект, и связать с ним точку входа с помощью @InstallIn
. Поскольку мы получаем PaymentApi
из ActivityComponent
, для запроса мы используем EntryPointAccessors.fromActivity()
.
Помните, что это важно для того, чтобы не обращаться к графу зависимостей каждый раз, когда CheckoutScreen
перекомпонуется. Помните, что paymentApi
будет забыт каждый раз, когда CheckoutScreen
покинет композицию, поэтому этот паттерн следует использовать только для высокоуровневых композиций, которые не исчезнут, пока пользователь не перейдет в другую часть приложения или не произойдет какое-либо другое значительное изменение пользовательского интерфейса.
Следует также отметить, что компонент зависимости, который мы используем, по-прежнему принадлежит Hilt и живет дольше, чем наш Composable. Этого должно быть достаточно для большинства случаев, но если вам нужен зависимый компонент, время жизни которого полностью совпадает с конкретным Composable, читайте дальше.
Использование кастомного Компонента зависимости
Время жизни зависимых компонентов в Hilt не может быть изменено, поэтому вместо этого мы можем создать новый компонент. Поскольку он не входит в число включенных, Hilt не будет знать, где его создавать и как долго хранить, поэтому эту часть мы также должны реализовать сами. Сначала давайте определим PaymentComponent
для хранения нашего PaymentApi
:
@DefineComponent(parent = ActivityComponent::class) interface PaymentComponent { @DefineComponent.Builder interface PaymentComponentBuilder { fun build(): PaymentComponent } } @EntryPoint @InstallIn(ActivityComponent::class) interface PaymentComponentBuilderEntryPoint { fun paymentComponentBuilder(): Provider<PaymentComponentBuilder> } @EntryPoint @InstallIn(PaymentComponent::class) interface PaymentApiEntryPoint { fun paymentApi(): PaymentApi }
Для создания кастомного Компонента необходимы две вещи: родительский Компонент, которым в нашем примере является ActivityComponent
, и интерфейс Builder для его создания. Мы добавляем новую точку входа под названием PaymentComponentBuilderEntryPoint
, которая даст нам доступ к конструктору для компонента, и изменяем PaymentApiEntryPoint
, устанавливая его в PaymentComponent
.
Возвращаясь к CheckoutScreen
Composable, мы можем получить PaymentComponentBuilder
и сбилдить его, а затем получить PaymentApi
из полученного PaymentComponent
. Как и раньше, используется remember
, чтобы не повторять это при каждой перекомпозиции.
@Composable fun CheckoutScreen() { val activity = LocalContext.current as Activity val paymentApi = remember { val paymentComponent = EntryPointAccessors.fromActivity( activity, PaymentComponentBuilderEntryPoint::class.java ).paymentComponentBuilder().get().build() EntryPoints.get( paymentComponent, PaymentApiEntryPoint::class.java ).paymentApi() } // ... }
Теперь CheckoutScreen контролирует время жизни PaymentComponent
. Это время жизни начинается, когда CheckoutScreen
впервые создается и компонент строится внутри блока remember
, и заканчивается, когда CheckoutScreen
покидает композицию и вычисление remember забывается.
Как уже говорилось в предыдущем разделе о точках входа, старайтесь ограничивать это высокоуровневыми компонентами, которые должны оставаться в композиции в течение длительных периодов времени.
Заключение
Compose совершает революцию в разработке пользовательских интерфейсов в Android, и вместе с ним меняются наши взгляды на внедрение зависимостей в приложениях для Android. Мы надеемся, что эти приемы и идеи помогут вам получить максимальную отдачу от Compose и Hilt в ваших приложениях.