@ActivityScope с помощью Dagger 2

в 7:26, , рубрики: android development, dagger 2, dependency injection, scopes, Блог компании Тинькофф Банк, Разработка под android

Привет! Хочу поделиться опытом создания ActivityScope. Те примеры, которые я видел на просторах интернета, на мой взгляд, не достаточно полны, неактуальны, искусственны и не учитывают некоторых нюансов практической разработки.

Статья предполагает, что читатель уже знаком с Dagger 2 и понимает что такое компонент, модуль, инжектирование и граф объектов и как все это вместе работает. Здесь же мы, в первую очередь, сконцентрируемся на создании ActivityScope и на том, как его увязать с фрагментами.

Итак, поехали… Что же такое scope?

@ActivityScope с помощью Dagger 2 - 1

Скоуп — это механизм Dagger 2, позволяющий сохранять некоторое множество объектов, которое имеет свой жизненный цикл. Иными словами скоуп — это граф объектов имеющий свое время жизни, которое зависит от разработчика.

По умолчанию Dagger 2 «из коробки» предоставляет нам поддержку javax.inject.Singleton скоупа. Как правило, объекты в этом скоупе существуют ровно столько, сколько существует инстанс нашего приложения.

Кроме того, мы не ограничены в возможности создания своих дополнительных скоупов. Хорошим примером кастомного скоупа может послужить UserScope, объекты которого существуют до тех пор, пока пользователь авторизован в приложении. Как только сессия пользователя заканчивается, или пользователь явно выходит из приложения, граф объектов уничтожается и пересоздается при следующей авторизации. В таком скоупе удобно хранить объекты, связанные с конкретным пользователем и не имеющие смысла для других юзеров. Например, какой-нибудь AccountManager, позволяющий просматривать списки счетов конкретного пользователя.

@ActivityScope с помощью Dagger 2 - 2

На рисунке показан пример жизненного цикла Singleton и UserScope в приложении.

  • При запуске создается Singleton скоуп, время жизни которого равняется времени жизни приложения. Иными словами, объекты принадлежащие Singleton скоупу будут существовать до тех пор, пока система не уничтожит и не выгрузит из памяти наше приложение.
  • После запуска приложения, User1 авторизуется в приложении. В этот момент создается UserScope, содержащий объекты, имеющие смысл для данного пользователя.
  • Через некоторое время пользователь решает «выйти» и разлогинивается из приложения.
  • Теперь User2 авторизуется и этим инициирует создание объектов UserScope для второго пользователя.
  • Когда сессия пользователя истекает, это приводит к уничтожению графа объектов.
  • Пользователь User1 возвращается в приложение, авторизуется, тем самым создает граф объектов UserScope и отправляет приложение в бэкграунд.
  • Спустя некоторое время система в ситуации нехватки ресурсов принимает решение об остановке и выгрузке из памяти нашего приложения. Это приводит к уничтожению как UserScope, так и SingletonScope.

Надеюсь, со скоупами немного разобрались.

Перейдем теперь к нашему примеру — ActivityScope. В реальных Android приложениях ActivityScope может оказаться крайне полезным. Еще бы! Достаточно представить себе какой-нибудь сложный экран, состоящий из кучи классов: пяток различных фрагментов, куча адаптеров, хелперов и презентеров. Было бы идеально в таком случае “шарить” между ними модель и/или классы бизнес логики, которые должны быть общими.

@ActivityScope с помощью Dagger 2 - 3 Есть 3 варианта решения данной задачи:

  1. Использовать для передачи ссылок на общие объекты самодельные синглтоны, Application класс или статические переменные. Данный подход мне однозначно не нравится, потому что нарушает принципы ООП и SOLID, делает код запутанным, трудночитаемым и неподдерживаемым.
  2. Самостоятельно передавать объекты из Активности в нужные классы посредством сеттеров или конструкторов. Минус данного подхода — затраты на написание рутинного кода, когда вместо этого можно было бы сфокусироваться на написании новых фич.
  3. Использовать Dagger 2 для инжектирования разделяемых объектов в необходимые места нашего приложения. В этом случае мы получаем все преимущества второго подхода, при этом не тратим время на написание шаблонного кода. По сути, перекладываем написание связующего кода на библиотеку.

Давайте посмотрим по шагам как с помощью Dagger 2 создать и использовать ActivityScope.

Итак, для создания кастомного скоупа необходимо:

  • Объявить скоуп (создать аннотацию)
  • Объявить хотя бы один компонент и соответствующий модуль для скоупа
  • В нужный момент инстанцировать граф объектов и удалить его после использования

Интерфейс нашего демо-приложения будет состоять из двух экранов ActivityА и ActivityB и общего фрагмента, используемого обоими активностями SharedFragment.

@ActivityScope с помощью Dagger 2 - 4 @ActivityScope с помощью Dagger 2 - 5

В приложении будет 2 скоупа: Singleton и ActivityScope.

Условно все наши бины можно разделить на 3 группы:

  • Синглтоны — SingletonBean
  • Бины активити скоупа, которые нужные только внутри активити — BeanA и BeanB
  • Бины активити скоупа, доступ к которым нужен как из самой активити, так и из других мест активити скоупа, например, фрагмента — SharedBean

Каждый бин при создании получает уникальный id. Это позволяет наглядно понять, работает ли скоуп как задумывалось, потому что каждый новый инстанс бина будет иметь id, отличный от предыдущего.

@ActivityScope с помощью Dagger 2 - 6

Таким образом, в приложении будет существовать 3 графа объектов (3 компонента)

  • SingletonComponent — граф объектов, которые существуют, пока приложение запущено, и не убито системой
  • ComponentActivityA — граф объектов, необходимых для работы ActivityA (в том числе ее фрагментов, адаптеров, презентеров и так далее) и существующих до тех пор, пока существует экземпляр ActivityA. При уничтожении и пересоздании активити, граф также будет уничтожен и создан заново вместе с новым экземпляром активити. Данный граф является супермножеством, включающим в себя все объекты из Singleton скоупа.
  • ComponentActivityB — аналогичный граф, но для ActivityB

@ActivityScope с помощью Dagger 2 - 7

Перейдем к реализации. Для начала подключаем Dagger 2 к нашему проекту. Для этого подключим android-apt плагин в корневом build.gradle

buildscript {
   //...
   dependencies {
      //...
       classpath 'com.neenbedankt.gradle.plugins:android-apt:1.8'
   }
}

и сам Dagger 2 в app/build.gradle

dependencies {
   compile 'com.google.dagger:dagger:2.7'
   apt 'com.google.dagger:dagger-compiler:2.7'
}

Далее объявляем модуль, который будет провайдить синглтоны

@Module
public class SingletonModule {


   @Singleton
   @Provides
   SingletonBean provideSingletonBean() {
       return new SingletonBean();
   }
}

и компонент синглтон:

@Singleton
@Component(modules = SingletonModule.class)
public interface SingletonComponent {
}

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

public final class Injector {

   private static final Injector INSTANCE = new Injector();


   private SingletonComponent singletonComponent;


   private Injector() {
       singletonComponent = DaggerSingletonComponent.builder()
               .singletonModule(new SingletonModule())
               .build();
   }


   public static SingletonComponent getSingletonComponent() {
       return INSTANCE.singletonComponent;
   }
}

Объявляем ActivityScope. Для того, чтобы объявить свой скоуп, необходимо создать аннотацию с именем скоупа и пометить ее аннотацией javax.inject.Scope.

@Scope
public @interface ActivityScope {
}

Группируем бины в модули: разделяемый и для активностей

@Module
public class ModuleA {


   @ActivityScope
   @Provides
   BeanA provideBeanA() {
       return new BeanA();
   }
}


@Module
public class ModuleB {


   @ActivityScope
   @Provides
   BeanB provideBeanB() {
       return new BeanB();
   }
}


@Module
public class SharedModule {


   @ActivityScope
   @Provides
   SharedBean provideSharedBean() {
       return new SharedBean();
   }
}

Объявляем соответствующие компоненты активностей. Для того чтобы реализовать компонент, который будет включать в себя объекты другого компонента, есть 2 способа: subcomponents и component dependencies. В первом случае дочерние компоненты имеют доступ ко всем объектам родительского компонента автоматически. Во втором — в родительском компоненте необходимо явно указать список объектов, которые мы хотим экспортировать в дочерние. В рамках одного приложения, на мой взгляд, удобнее использовать первый вариант.

@ActivityScope
@Subcomponent(modules = {ModuleA.class, SharedModule.class})
public interface ComponentActivityA {

   void inject(ActivityA activity);

   void inject(SharedFragment fragment);
}

@ActivityScope
@Subcomponent(modules = {ModuleB.class, SharedModule.class})
public interface ComponentActivityB {

   void inject(ActivityB activity);

   void inject(SharedFragment fragment);
}

В созданных сабкомпонентах объявляем точки инжекции. В нашем примере таких точек две: Activity и SharedFragment. Они будут иметь общие разделяемые бины SharedBean.

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

@Singleton
@Component(modules = SingletonModule.class)
public interface SingletonComponent {

   ComponentActivityA newComponent(ModuleA a, SharedModule shared);

   ComponentActivityB newComponent(ModuleB b, SharedModule shared);
}

Вот и всё. Вся инфраструктура готова, осталось инстанцировать объявленные компоненты и заинжектить зависимости. Начнем с фрагмента.

Фрагмент используется сразу внутри двух различных активностей, поэтому он не должен знать конкретных деталей об активности, внутри которой находится. Однако, нам необходим доступ к компоненту активити, чтобы через него получить доступ к графу объектов нашего скоупа. Чтобы решить эту «проблему», используем паттерн Inversion of Control, создав промежуточный интерфейс InjectorProvider, через который и будет строится взаимодействие с активностями.

public class SharedFragment extends Fragment {

   @Inject
   SharedBean shared;
   @Inject
   SingletonBean singleton;

   //…

   @Override
   public void onAttach(Context context) {
       super.onAttach(context);
       if (context instanceof InjectorProvider) {
           ((InjectorProvider) context).inject(this);
       } else {
           throw new IllegalStateException("You should provide InjectorProvider");
       }
   }

   public interface InjectorProvider {
       void inject(SharedFragment fragment);
   }
}

Осталось инстанцировать компоненты уровня ActivityScope внутри каждой из активностей и проинжектить активность и содержащийся внутри неё фрагмент

public class ActivityA extends AppCompatActivity implements SharedFragment.InjectorProvider {


   @Inject
   SharedBean shared;
   @Inject
   BeanA a;
   @Inject
   SingletonBean singleton;


   ComponentActivityA component =
           Injector.getSingletonComponent()
                   .newComponent(new ModuleA(), new SharedModule());


  //...


   @Override
   public void inject(SharedFragment fragment) {
       component.inject(this);
       component.inject(fragment);
   }
}

Озвучу еще раз основные моменты:

  • Мы создали 2 различных скоупа: Singleton и ActivityScope
  • ActivityScope реализуется через Subcomponent, а не component dependencies, чтобы не нужно было явно экспотировать все бины из Singleton скоупа
  • Активити хранит ссылку на граф объектов соответствующего ей ActivityScop-а и выполняет инжектирование себя и всех классов, которые хотят в себя инжектировать бины из ActivityScope, например, SharedFragment
  • С уничтожением активити уничтожается и граф объектов для данной активности
  • Граф Singleton объектов существует до тех пор, пока существует инстанс приложения

На первый взгляд может показаться, что для реализации такой простой задачи необходимо написать достаточно много связующего кода. В демо-приложении количество классов, выполняющих «работу» (бинов, фрагментов и активностей), примерно сопоставимо с количеством «связующих» классов даггера. Однако:

  • В реальном проекте количество «рабочих» классов будет значительно больше.
  • Связующий код достаточно написать однажды, а потом просто добавлять нужные компоненты и модули.
  • Использование DI сильно облегчает тестирование. У вас появляются дополнительные возможности по инжектированию моков и стабов вместо реальных бинов во время тестирования
  • Код бизнес-логики становится более изолированным и лаконичным за счет переноса связующего и инстанциирующего кода в классы даггера. При этом в самих классах бизнес-логики остается только бизнес-логика и ничего лишнего. Такие классы опять же легче писать, поддерживать и покрывать юнит-тестами

» Демо-проект доступен на гитхабе

Всем Dagger и happy coding! :)

Автор: Тинькофф Банк

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js