Навіщо потрібен Dagger

Якщо ви хочете знизити залежність об'єктів один від одного і спростити написання тестів для вашого коду, то вам підійде патерн Dependency Injection . А Dagger - це бібліотека, яка допоможе в реалізації цього патерну. У цьому міні-курсі я опишу використання бібліотеки Dagger версії 2 (далі за текстом даггер).

Плюси даггера порівняно з іншими бібліотеками:

  • генерує код нескладний для розуміння і налагодження
  • перевіряє залежності на етапі компіляції
  • не створює проблем під час використання proguard

Щоб зрозуміти, навіщо нам може знадобитися Dependency Injection і даггер, давайте розглянемо невеликий абстрактний приклад, у якому змоделюємо ситуацію, коли створення одного об'єкта може спричинити створення ще кількох.

Нехай у нашому додатку є якась MainActivity і, відповідно до патерну MVP, для неї є презентер. Презентеру для роботи потрібні будуть якісь ItemController і DataController. Тобто нам треба буде створити два ці об'єкти перед тим, як створити презентер. Але для створення двох цих об'єктів нам, своєю чергою, потрібні об'єкти ApiService і SharedPreferences. А для створення ApiService потрібні RestAdapter, RestAdapter.Builder, OkHttpClient і Cache.

У звичайній реалізації це може виглядати так:

public class MainActivity extends Activity {
 
    MainActivityPresenter activityPresenter;
 
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
 
        File cacheDirectory = new File("some path");
        Cache cache = new HttpResponseCache(cacheDirectory, 50 * 1024 * 1024);
 
        OkHttpClient httpClient = new OkHttpClient();
        httpClient.setCache(cache);
 
        RestAdapter.Builder builder = new RestAdapter.Builder();
        builder.setClient(new OkClient(httpClient));
        RestAdapter restAdapter = builder.build();
        ApiService apiService = restAdapter.create(ApiService.class);
 
        ItemController itemController = new ItemController(apiService);
 
        SharedPreferences preference = getSharedPreferences("item_prefs", MODE_PRIVATE);
        DataController dataController = new DataController(preference);
 
        activityPresenter = new MainActivityPresenter(this, itemController, dataController);
    }
 
}

У MainActivity ми створюємо купу об'єктів, щоб за підсумком отримати один презентер. Нам у цьому прикладі не важливо, які саме об'єкти створюються. Головне - це скільки коду може знадобитися написати в MainActivity, щоб отримати результат.

Якщо ми застосуємо патерн Dependency Injection і використаємо дагер, то код в Activity матиме такий вигляд:

public class MainActivity extends Activity {
 
    MainActivityPresenter activityPresenter;
 
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
 
        activityPresenter = App.getComponent().getPresenter();
    }
}

Зрозуміло, код створення об'єктів нікуди не зник. Але він винесений з Activity в окремі класи, до яких дагер має доступ. У підсумку ми просто викликаємо метод getPresenter, щоб отримати об'єкт MainActivityPresenter. А дагер уже сам створить цей об'єкт і всю необхідну для нього ієрархію об'єктів.

Те ж саме ми могли б зробити і без дагера, простим перенесенням коду створення об'єктів у метод типу MainActivityPresenter.createInstance(). Але якщо у нас є інший presenter, якому частково потрібні ті самі об'єкти, в його методі createInstance нам доведеться дублювати код створення деяких об'єктів.

При використанні дагера, код створення необхідного нам об'єкта буде існувати тільки в одному місці і в одному екземплярі, і дагер використовує цей код скрізь, де буде потрібно створити об'єкт.

Теорія

Тепер давайте дивитися, як працює дагер зсередини.

Візьмемо все той же приклад з Activity і Presenter. Тобто коли Activity для своїх потреб створює об'єкт Presenter. Звичайна схема створення матиме такий вигляд:

Activity > Presenter

Тобто Activity створює Presenter самостійно

При використанні дагера схема виглядатиме так:

Activity -> Component -> Module -> Presenter

Activity звертається до компонента, компонент за допомогою модулів створює Presenter і повертає його в Activity.

Модулі та компоненти - це два ключові поняття дагера.

Модулі - це просто класи, куди ми поміщаємо код створення об'єктів. І зазвичай кожен модуль містить у собі об'єкти, близькі за змістом. Наприклад:

Модуль ItemModule міститиме код створення об'єктів, пов'язаних із користувачами, тобто що-небудь типу Item і ItemController. Модуль NetworkModule - об'єкти OkHttpClient і ApiService. Модуль StorageModule - об'єкти DataController і SharedPreferences

Компонент - це посередник між Activity і модулем. Коли Activity потрібен якийсь об'єкт, вона повідомляє про це компонент. Компонент знає, який модуль вміє створювати такий об'єкт, просить модуль створити об'єкт і передає його в Activity. При цьому компонент може використовувати інші модулі, щоб створити всю ієрархію об'єктів, необхідну для створення шуканого об'єкта.

Процес роботи дагера можна порівняти з обідом у McDonalds. Тобто за аналогією зі схемою дагера:

Activity -> Component -> Module -> Presenter

схема McDonalds виглядає так:

Клієнт -> Касир -> Виробнича лінія -> Замовлення (Бігмак/Картошка/Кола)

Розглянемо детальніше кроки цих схем:

McDonald'sДаггер
Клієнт вирішив, що його замовлення складатиметься з бігмака, картоплі та коли, і повідомляє про це касираActivity повідомляє компоненту, що йому знадобиться Presenter
Касир проходить по виробничій лінії та збирає замовлення: бере бігмак, наливає колу, насипає картоплюКомпонент використовує модулі, щоб створити всі необхідні об'єкти, які знадобляться для створення Presenter
Касир комплектує замовлення в пакет або на піднос і видає його клієнтуКомпонент зрештою отримує від модулів потрібний об'єкт Presenter і передає його Activity

Практика

Тепер на простому прикладі подивимося, як створювати модулі та компоненти, і як за їхньою допомогою Activity отримуватиме необхідні об'єкти.

Підключення дагера до проєкту Створіть новий проєкт. Щоб використовувати дагер, додайте в розділ dependencies файлу build.gradle вашого модуля:

compile 'com.google.dagger:dagger:2.7'
annotationProcessor 'com.google.dagger:dagger-compiler:2.7'

Як об'єкти, які ми будемо запитувати від дагера, використовуємо пару класів: DatabaseHelper і NetworkUtils.

public class DatabaseHelper {
   
}
```java
public class NetworkUtils {
 
}

Їхня реалізація нам зараз не важлива, залишаємо їх порожніми.

Припустимо, що ці об'єкти будуть потрібні нам у MainActivity.

public class MainActivity extends Activity {
 
    DatabaseHelper databaseHelper;
    NetworkUtils networkUtils;
 
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
    }
}

Щоб отримати їх за допомогою дагера, нам потрібно створити модулі та компонент.

Створюємо модулі, які вмітимуть надавати необхідні об'єкти. Саме в модулях ми і пишемо весь код зі створення об'єктів. Це звичайні класи, але з парою анотацій:

@Module
public class NetworkModule {
 
    @Provides
    NetworkUtils provideNetworkUtils() {
        return new NetworkUtils();
    }
 
}
@Module
public class StorageModule {
 
    @Provides
    DatabaseHelper provideDatabaseHelper() {
        return new DatabaseHelper();
    }
 
}

Анотацією @Module ми повідомляємо даггеру, що цей клас є модулем. А анотація @Provides вказує, що метод є постачальником об'єкта і компонент може використовувати його, щоб отримати об'єкт. Технічно можна було цілком обійтися й одним модулем. Але логічніше буде розділити об'єкти на модулі за їхнім змістом і сферою застосування. Модулі готові, тепер створюємо компонент. Для цього нам необхідно створити інтерфейс

@Component()
public interface AppComponent {
 
}

Цей інтерфейс описує порожній компонент, який поки що нічого не вмітиме. Під час компіляції проєкту, дагер знайде цей інтерфейс за анотацією @Component і згенерує клас DaggerAppComponent (ім'я класу = слово Dagger + ім'я інтерфейсу), який реалізує цей інтерфейс. Це і буде клас компонента.

Усе що від нас вимагається - наповнити інтерфейс методами. Цим ми дамо зрозуміти компоненту, які об'єкти він має вміти нам повертати. А під час збирання проєкту дагер уже сам їх реалізує в згенерованому класі компонента.

Компонент може повертати нам об'єкти двома способами. Перший - це звичайні get-методи . Тобто ми просто викликаємо метод, який поверне нам об'єкт. Другий спосіб цікавіший, це inject-методи. У цьому випадку ми передаємо компоненту екземпляр Activity, і компонент сам заповнює там усі необхідні поля, створюючи необхідні об'єкти.

Розглянемо обидва способи на прикладах.

Get методи

Доповнимо інтерфейс, щоб компонент навчився створювати для нас об'єкти.

@Component(modules = {StorageModule.class, NetworkModule.class})
public interface AppComponent {
    NetworkUtils getNetworkUtils();
    DatabaseHelper getDatabaseHelper();
}

Список modules - це модулі, в яких компонент зможе шукати код створення об'єктів.

Методи можуть бути з будь-яким ім'ям, головне - це їхні типи, що повертаються (NetworkUtils і DatabaseHelper). Вони дають зрозуміти компоненту, які саме об'єкти ми захочемо від нього отримати. Під час компіляції, дагер перевірить, у якому модулі який об'єкт можна дістати і нагенерує в реалізації двох цих методів відповідний код створення цих об'єктів. А в MainActivity ми просто викличемо ці методи компонента, щоб отримати готові об'єкти.

Залишилося десь описати створення екземпляра компонента. Використовуємо для цього Application клас. Не забудьте додати його в маніфест

Клас App, що наслідується від Application, є стандартною практикою в Android для створення власного класу застосунку. Цей клас використовується для виконання ініціалізації на глобальному рівні додатка перед запуском будь-якої активності, сервісу чи іншого компонента.

Що таке Application?

Application — це базовий клас Android, який відповідає за підтримку глобального стану додатка. Він створюється системою під час запуску додатка і залишається активним доти, доки додаток працює.

Основні властивості та можливості Application:

  • Глобальний контекст: Application забезпечує глобальний доступ до контексту додатка через метод getApplicationContext().
  • Ініціалізація залежностей: Ви можете використовувати цей клас для ініціалізації бібліотек, DI-фреймворків (наприклад, Dagger 2, Hilt), бази даних (Room, SQLite) або аналітики (Google Analytics).
  • Глобальні змінні: Ви можете зберігати дані, які мають бути доступними у всіх частинах додатка.
public class App extends Application {
    @Override
    public void onCreate() {
        super.onCreate();
        // Ініціалізація глобальних залежностей
        initializeDagger();
        initializeRoomDatabase();
        initializeAnalytics();
    }

    private void initializeDagger() {
        // Ініціалізація DI контейнера, наприклад Dagger
    }

    private void initializeRoomDatabase() {
        // Ініціалізація бази даних
    }

    private void initializeAnalytics() {
        // Налаштування аналітики
    }
}

Оголошення у AndroidManifest.xml: Щоб система знала, що ваш клас має використовуватись як Application, його потрібно оголосити в AndroidManifest.xml:

<application
    android:name=".App"
    android:icon="@mipmap/ic_launcher"
    android:label="@string/app_name">
    <!-- Інші атрибути та компоненти -->
</application>

Використання в коді: Ви можете отримати доступ до екземпляра класу App через глобальний контекст:

App app = (App) getApplicationContext();

Продовжуємо про Dagger 2

public class App extends Application {
 
    private static AppComponent component;
 
    @Override
    public void onCreate() {
        super.onCreate();
        component = DaggerAppComponent.create();
    }
 
    public static AppComponent getComponent() {
        return component;
    }
 
}

У методі onCreate створюємо компонент. На цьому місці ваше середовище розробки найімовірніше буде лаятися на клас DaggerAppComponent. Так відбувається, тому що класу DaggerAppComponent поки що не існує. Ми тільки описали інтерфейс компонента AppComponent, але нам треба скомпілювати проєкт, щоб даггер створив цей клас-компонент.

Скомпілюйте проєкт. В Android Studio це можна зробити через меню Build -> Make Project (CTRL+F9). Після того, як процес завершиться, клас DaggerAppComponent буде створено в надрах папки build\generated. Студія тепер знає цей клас і повинна пропонувати додати його в import, щоб у коді не було жодних помилок.

Тепер у MainActivity ми можемо використовувати цей компонент, щоб отримати готові об'єкти DatabaseHelper і NetworkUtils:

public class MainActivity extends Activity {
 
    DatabaseHelper databaseHelper;
    NetworkUtils networkUtils;
 
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
 
        databaseHelper = App.getComponent().getDatabaseHelper();
        networkUtils = App.getComponent().getNetworkUtils();
    }
}

Під час запуску програми об'єкти будуть створені дагером. Якщо у вас крашить з NPE, переконайтеся, що додали App клас у маніфест.

Inject-методи

У нас в MainActivity зараз лише два об'єкти, які ми отримуємо від компонента. Але якщо буде штук 20, то доведеться в інтерфейсі компонента описати 20 get-методів і в коді MainActivity написати 20 викликів цих методів. У дагера є більш зручне рішення для таких випадків. Ми можемо навчити компонент не просто повертати об'єкти, а самому наповнювати Activity необхідними об'єктами. Тобто ми даємо компоненту екземпляр MainActivity, а він дивиться, які об'єкти потрібні, створює їх і сам поміщає у відповідні поля.

Перепишемо інтерфейс компонента

@Component(modules = {StorageModule.class, NetworkModule.class})
public interface AppComponent {
    void injectsMainActivity(MainActivity mainActivity);
}

Замість пари get-методів ми описуємо один inject-метод. Ім'я може бути будь-яким, головне - це тип його єдиного параметра. Ми вказуємо тут MainActivity. Тим самим, ми говоримо компоненту, що коли ми будемо викликати цей метод і передавати туди екземпляр MainActivity, ми очікуємо, що компонент наповнить цей екземпляр необхідними об'єктами.

Під час компіляції проєкту, дагер побачить цей метод в інтерфейсі, перегляне клас MainActivity на наявність (позначених спеціальними анотаціями) полів і визначить, які об'єкти йому потрібно буде створювати. У підсумку, в класі компонента дагер реалізує метод injectsMainActivity так, щоб він отримував об'єкти зі своїх модулів і підставляв їх у відповідні змінні переданого йому екземпляра MainActivity.

Перепишемо MainActivity

public class MainActivity extends Activity {
 
    @Inject
    DatabaseHelper databaseHelper;
 
    @Inject
    NetworkUtils networkUtils;
 
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        App.getComponent().injectsMainActivity(this);
    }
     
}

Анотаціями @Inject ми позначаємо поля, які компонент має заповнити. Під час виклику методу injectsMainActivity компонент витягне з модулів об'єкти DatabaseHelper і NetworkUtils і помістить їх у поля MainActivity

Цей механізм можна подивитися в коді класу компонента, який був згенерований дагером. Метод injectsMainActivity:

public void injectsMainActivity(MainActivity mainActivity) {
    mainActivityMembersInjector.injectMembers(mainActivity);
}

Якщо піти далі і подивитися всередину mainActivityMembersInjector.injectMembers, побачимо таке:

@Override
public void injectMembers(MainActivity instance) {
    if (instance == null) {
        throw new NullPointerException("Cannot inject members into a null reference");
    }
    instance.databaseHelper = databaseHelperProvider.get();
    instance.networkUtils = networkUtilsProvider.get();
}

Тут просто перевірка на null і присвоєння об'єктів у поля MainActivity.

Зрозуміло, get-методи та inject-методи можуть бути використані разом в одному компоненті. Я описував їх окремо один від одного тільки для простоти розуміння.

Граф залежностей

Сукупність усіх об'єктів, які вміє створювати компонент, називається граф об'єктів компонента, або граф залежностей компонента. Тобто в прикладі вище цей граф складається всього з двох об'єктів: DatabaseHelper і NetworkUtils. Компонент знає як створити ці об'єкти і може їх надати.

У деяких випадках при створенні одного об'єкта, компоненту може знадобитися інший об'єкт. Ми говорили про це на самому початку цього уроку. Коли для створення презентера нам знадобилося створити ще з десяток об'єктів.

Розглянемо приклад модуля

@Module
public class NetworkModule {
 
    @Provides NetworkUtils provideNetworkUtils(HttpClient httpClient) {
        return new NetworkUtils(httpClient);
    }
 
    @Provides HttpClient provideHttpClient() {
        return new HttpClient();
    }
 
}

Коли ми від компонента попросимо об'єкт NetworkUtils, компонент прийде в цей модуль і викличе метод provideNetworkUtils. Але на вхід цьому методу потрібен об'єкт HttpClient. Компонент шукає, який з його модулів вміє створювати такий об'єкт і знаходить його в цьому ж модулі. Він викликає метод provideHttpClient, отримує об'єкт HttpClient і використовує його під час виклику provideNetworkUtils. Тобто якщо ваш об'єкт вимагає для створення інші об'єкти, то вам необхідно в модулях описати створення всіх цих об'єктів. У цьому разі компонент створить весь ланцюжок і отримає шуканий об'єкт.

Бувають випадки, коли не все можна так просто створити в модулях і потрібні якісь об'єкти ззовні дагера. Цей випадок я опишу в одному з наступних уроків.

Виявлення помилок

До плюсів дагера відносять те, що якщо у вас є якась помилка в побудові залежностей, то ви дізнаєтеся про це не в Runtime, а на етапі компіляції. Давайте перевіримо. Створимо ще один порожній клас Preferences.

public class Preferences {
     
}

І додамо в MainActivity змінну цього типу з анотацією Inject:

@Inject
Preferences preferences;

Тепер компонент під час інджекту має створити об'єкт Preferences, але ми не додали створення цього об'єкта в модулі. І компонент просто не знає звідки його взяти.

Намагаємося скомпілювати. І отримуємо помилку: Error:(24, 10) error: Preferences cannot be provided without an @Inject constructor or from an @Provides- or @Produces-annotated method.

Компілятор цілком закономірно скаржиться, що не знає, звідки компоненту взяти об'єкт Preferences.