@ActivityScope з допомогою Dagger 2

Привіт, Хабр! Хочу поділитися досвідом створення 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 варіанти вирішення даної задачі:

  1. Використовувати для передачі посилань на загальні об'єкти саморобні синглтоны, Application клас або статичні змінні. Даний підхід мені однозначно не подобається, тому що порушує принципи ООП і SOLID, робить код заплутаним, важко читати і непідтримуваних.

  2. Самостійно передавати об'єкти з Активності потрібні класи допомогою сеттерів або конструкторів. Мінус цього підходу — витрати на написання рутинного коду, коли замість цього можна було б зосередитися на написанні нових фіч.

  3. Використовувати 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! :)
Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.