Привет! Хочу поделиться опытом создания ActivityScope. Те примеры, которые я видел на просторах интернета, на мой взгляд, не достаточно полны, неактуальны, искусственны и не учитывают некоторых нюансов практической разработки.
Статья предполагает, что читатель уже знаком с Dagger 2 и понимает что такое компонент, модуль, инжектирование и граф объектов и как все это вместе работает. Здесь же мы, в первую очередь, сконцентрируемся на создании ActivityScope и на том, как его увязать с фрагментами.
Итак, поехали… Что же такое scope?
Скоуп — это механизм Dagger 2, позволяющий сохранять некоторое множество объектов, которое имеет свой жизненный цикл. Иными словами скоуп — это граф объектов имеющий свое время жизни, которое зависит от разработчика.
По умолчанию Dagger 2 «из коробки» предоставляет нам поддержку javax.inject.Singleton скоупа. Как правило, объекты в этом скоупе существуют ровно столько, сколько существует инстанс нашего приложения.
Кроме того, мы не ограничены в возможности создания своих дополнительных скоупов. Хорошим примером кастомного скоупа может послужить UserScope, объекты которого существуют до тех пор, пока пользователь авторизован в приложении. Как только сессия пользователя заканчивается, или пользователь явно выходит из приложения, граф объектов уничтожается и пересоздается при следующей авторизации. В таком скоупе удобно хранить объекты, связанные с конкретным пользователем и не имеющие смысла для других юзеров. Например, какой-нибудь AccountManager, позволяющий просматривать списки счетов конкретного пользователя.
На рисунке показан пример жизненного цикла Singleton и UserScope в приложении.
- При запуске создается Singleton скоуп, время жизни которого равняется времени жизни приложения. Иными словами, объекты принадлежащие Singleton скоупу будут существовать до тех пор, пока система не уничтожит и не выгрузит из памяти наше приложение.
- После запуска приложения, User1 авторизуется в приложении. В этот момент создается UserScope, содержащий объекты, имеющие смысл для данного пользователя.
- Через некоторое время пользователь решает «выйти» и разлогинивается из приложения.
- Теперь User2 авторизуется и этим инициирует создание объектов UserScope для второго пользователя.
- Когда сессия пользователя истекает, это приводит к уничтожению графа объектов.
- Пользователь User1 возвращается в приложение, авторизуется, тем самым создает граф объектов UserScope и отправляет приложение в бэкграунд.
- Спустя некоторое время система в ситуации нехватки ресурсов принимает решение об остановке и выгрузке из памяти нашего приложения. Это приводит к уничтожению как UserScope, так и SingletonScope.
Надеюсь, со скоупами немного разобрались.
Перейдем теперь к нашему примеру — ActivityScope. В реальных Android приложениях ActivityScope может оказаться крайне полезным. Еще бы! Достаточно представить себе какой-нибудь сложный экран, состоящий из кучи классов: пяток различных фрагментов, куча адаптеров, хелперов и презентеров. Было бы идеально в таком случае “шарить” между ними модель и/или классы бизнес логики, которые должны быть общими.
Есть 3 варианта решения данной задачи:
- Использовать для передачи ссылок на общие объекты самодельные синглтоны, Application класс или статические переменные. Данный подход мне однозначно не нравится, потому что нарушает принципы ООП и SOLID, делает код запутанным, трудночитаемым и неподдерживаемым.
- Самостоятельно передавать объекты из Активности в нужные классы посредством сеттеров или конструкторов. Минус данного подхода — затраты на написание рутинного кода, когда вместо этого можно было бы сфокусироваться на написании новых фич.
- Использовать Dagger 2 для инжектирования разделяемых объектов в необходимые места нашего приложения. В этом случае мы получаем все преимущества второго подхода, при этом не тратим время на написание шаблонного кода. По сути, перекладываем написание связующего кода на библиотеку.
Давайте посмотрим по шагам как с помощью Dagger 2 создать и использовать ActivityScope.
Итак, для создания кастомного скоупа необходимо:
- Объявить скоуп (создать аннотацию)
- Объявить хотя бы один компонент и соответствующий модуль для скоупа
- В нужный момент инстанцировать граф объектов и удалить его после использования
Интерфейс нашего демо-приложения будет состоять из двух экранов ActivityА и ActivityB и общего фрагмента, используемого обоими активностями SharedFragment.
В приложении будет 2 скоупа: Singleton и ActivityScope.
Условно все наши бины можно разделить на 3 группы:
- Синглтоны — SingletonBean
- Бины активити скоупа, которые нужные только внутри активити — BeanA и BeanB
- Бины активити скоупа, доступ к которым нужен как из самой активити, так и из других мест активити скоупа, например, фрагмента — SharedBean
Каждый бин при создании получает уникальный id. Это позволяет наглядно понять, работает ли скоуп как задумывалось, потому что каждый новый инстанс бина будет иметь id, отличный от предыдущего.
Таким образом, в приложении будет существовать 3 графа объектов (3 компонента)
- SingletonComponent — граф объектов, которые существуют, пока приложение запущено, и не убито системой
- ComponentActivityA — граф объектов, необходимых для работы ActivityA (в том числе ее фрагментов, адаптеров, презентеров и так далее) и существующих до тех пор, пока существует экземпляр ActivityA. При уничтожении и пересоздании активити, граф также будет уничтожен и создан заново вместе с новым экземпляром активити. Данный граф является супермножеством, включающим в себя все объекты из Singleton скоупа.
- ComponentActivityB — аналогичный граф, но для ActivityB
Перейдем к реализации. Для начала подключаем 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! :)
Автор: Тинькофф Банк