Як Anvil зменшує кількість boilerplate-коду Android-розробникам

💡 Усі статті, обговорення, новини про Mobile — в одному місці. Приєднуйтесь до Mobile спільноти!

Всім привіт! Мене звати Володимир, я Android Engineer в компанії Uptech. Уже декілька років я створюю Android-застосунки, що оптимізують бізнес-процеси та роблять життя людей трошки простішим.

У цій статті я хочу поділитись досвідом покращення Dependency Injection у проєкті.

Dependency Injection (DI) — невід’ємна частина сучасних проєктів. Цей патерн допомагає скорочувати шаблонне створення ручної ін’єкції залежностей у вашому проєкті. Коли я прийшов на проєкт, DI було реалізовано за допомогою бібліотеки Dagger 2. На момент створення цього застосунку найкраща бібліотека, яка підтримувала мультимодульність, була Dagger 2.

Тоді не існувало Hilt або ж інших зручних засобів для скорочування шаблонної ручної ін’єкції.

За деякий час роботи над проєктом наша команда вирішила знайти спрощену реалізацію DI, яка б могла добре працювати в мультимодульному середовищі й спрощувала б синтаксис зі створенням модулів. Отже потрібно було знайти рішення, яке б мало легкий перехід з Dagger 2 до іншої бібліотеки DI.

Нам дуже сподобався Anvil як доповнення до наявного Dagger 2. Він дозволяє зменшити кількість шаблонного коду й гарно працює в мультимодульному середовищі.

Тріада DI у світі Android-розробки: що і коли використовувати

Перш за все, DI — це паттерн розробки, що може бути реалізований багатьма способами, зокрема цими трьома бібліотеками, що зараз найпопулярніші для розробки Android-застосунків:

  • Koin;
  • Dagger 2;
  • Hilt.

Koin

Koin — це гарна бібліотека для невеликих рішень. Наприклад, тестування проєкту або pet-проєкту. Це ідеальний варіант, коли ти хочеш зосередитися на створенні проєкту, написанні нового функціоналу, але не хочеш витрачати багато часу на шаблонний код. З головних особливостей цієї реалізації бібліотеки — Runtime генерація залежностей. Це пришвидшує загальний час збірки проєкту, але нижче зазначено і недолік цього підходу.

Плюси:

● швидша збірка проєкту порівняно з Dagger 2 та Hilt (за рахунок Runtime генерації графу залежностей);

● легке налаштування для тестування.

Мінуси:

● Runtime генерація залежностей;

  • усі залежності, потрібні для певного екрану, будуть ініціалізуватись під час його використання, це може призвести до runtime-помилок. Тому якщо ми щось зіпсуємо у графі залежностей, проєкт однаково буде скомпільовано, але він може аварійно завершити роботу на початку або пізніше на певному екрані;
  • розробник мав би перевірити, чи залежності в цій конкретній частині програми працюють правильно і чи програма не аварійно завершує роботу. У цьому разі використання Dagger/Hilt набагато безпечніше.

Dagger 2

Щодо Dagger 2 — це рішення, що гарно підходить для мультимодульних проєктів, але має надто складний синтаксис для початківців у DI.

Плюси:

● Compile time генерація залежностей;

  • помилки можуть бути виявлені під час компіляції програми. Це означало б, що вам не потрібно тестувати застосунок, щоб виявити неправильно налаштований компонент. Після створення графа залежностей його можна перетворити на статичний код для створення і впровадження компонента.

● Гарно працює з мультимодульним проєктом;

  • Варто зазначити, що наша команда хотіла знайти найкращий варіант DI-бібліотеки, яка підтримує мультимодульність без складного налаштування залежностей.

Мінуси:

● Складніший синтаксис, аніж Koin та Hilt.

Hilt

Hilt створено на основі популярної бібліотеки DI Dagger, щоб отримати переваги від продуктивності під час виконання, масштабованості та підтримки Android Studio, яку надає Dagger. Він набагато простіший за синтаксисом, ніж Dagger 2, але дуже залежить від загальних Android-компонентів.

Плюси:

● Compile time генерація залежностей;

● легший синтаксис за Dagger 2.

Мінуси:

● дуже залежить від Android-компонентів (Fragments, Viewmodels);

  • варто зазначити, що деякі наші модулі будуть перевикористовуватись для більш загальних модулів застосунку для різних платформ.

Anvil

Anvil — це взагалі доповнення до Dagger 2, тож у нього також compile time генерація залежностей і він також гарно працює з мультимодульними проєктами. Але його головною особливістю є спрощений синтаксис і кодогенерація.

Плюси:

● Compile time генерація залежностей;

● спрощений синтаксис порівнянно з Dagger 2.

Мінуси:

● використовує неявні залежності;

  • Для Dagger 2 треба створити файли component та module, прописати там потрібні залежності й зробити зручне створення об’єкта, який потім ініціалізувати за допомогою Fragment / Activity. Це призведе до boilerplate-коду, проте в цьому випадку ми точно розуміємо, що було зроблено. Для Anvil більшість із цього буде згенеровано під капотом, що може призвести до незрозумілостей для інженерів, які мало працювали з Dagger 2.

Що таке Anvil і чим він крутіший за інші бібліотеки DI

Anvil — це доповнення до наявного Dagger 2, тож має всі особливості синтаксису останнього. У цьому розділі розповідаю про приклад використання Anvil у тестовому проєкті.

Коли ми будуємо застосунок, потрібно перш за все правильно структурувати логіку розробки на декілька кроків. Слід розуміти, що кожна нова фіча буде окремим модулем.

У Dagger 2 у кожному такому модулі потрібно створювати окремий @Scope і він буде генерувати та віддавати залежності тільки для тих класів, що мають зазначений @Scope. У нашому випадку ми використовували структуру, коли є головний AppComponent, який буде генеруватися на основі використаного AppScope, який знатиме про всі залежності компонентів та модулів.

Поглиблену інформацію щодо будування мультимодульних застосунків для Dagger 2 ви можете знайти нижче в розділі «Документація».

Головною особливістю Anvil у нашому проєкті є те, що ми маємо один великий AppComponent. Усі компоненти та модулі ми додаємо до AppScope.

Приклад створення AppScope, що буде використовуватися у всьому застосунку:

abstract class AppScope private constructor()

Швидкість у використанні одномодульного застосунку

Anvil має невелику перевагу у швидкості компілювання проєкту (одномодульних застосунків).

Таблиця з прикладом використання різних DI для одномодульного проєкту

Anvil

9c 543мс

9с 703мс

9с 807мс

9с 215мс

9c 600мс

Dagger 2

12с 109мс

10с 194мс

9с 758мс

10с 451мс

10c 500мс

Hilt

12с 227мс

11с 284мс

12с 465мс

13с 414мс

12с 300мс

Таблиця з середніми результатами

Anvil

Dagger 2

Hilt

9c 573мс

10с 602мс

12с 338мс

Слід зауважити, що така швидкість працюватиме лише для одномодульних проєктів. Anvil виконав збірку швидше на 10% у порівнянні з Dagger 2 і на 29% швидше за Hilt для однакового за розміром застосунку.

І невеликий опис проєкту: застосунок для показу погоди з двома екранами — одномодульний. Головна сторінка з пошуком країни та міста, і другий екран — детальна інформація погоди для обраного місця.

Таблиця з прикладом використання різних DI для мультимодульного проєкту

Anvil

30c 243мс

31с 503мс

31с 807мс

Dagger 2

31с 109мс

31с 564мс

30с 828мс

Hilt

32с 227мс

33с 284мс

33с 465мс

Таблиця з середніми результатами

Anvil

Dagger 2

Hilt

31c 184мс

31с 167мс

32м 958мс

Цей застосунок для показу погоди з трьома екранами — мультимодульний. Головна сторінка з пошуком країни та міста, і другий екран — детальна інформація погоди для обраного місця. Третій екран для налаштувань користувача.

Щодо мультимодульних проєктів, то швидкість там буде майже така сама або Anvil навіть трошки поступатиметься за швидкістю збірки проєкту. Потрібно памʼятати, що всі проєкти різні, і можуть бути випадки, коли кількість модулів, стрічок коду, певні залежності можуть змінювати загальну швидкість компілювання застосунку.

Можливості бібліотеки

Потрібно памʼятати, що Anvil є доповненням до наявної бібліотеки Dagger 2, проте Anvil має декілька дуже зручних анотацій, що спрощують повсякденну взаємодію з шаблонним кодом.

@ContributesTo використовується зазвичай для звʼязки інтерфейсів і модулів

Наприклад, працюючи з Dagger 2, ми маємо зробити інтерфейс, імплементацію і лише після цього зробити модуль, який усе це з’єднає:

interface Authenticator
class RealAuthenticator @Inject constructor(): Authenticator {}
@Module
class AuthenticatorModule(){
       @Provides
       fun provideAuthenticator(): Authenticator = RealAuthenticator()
}

А ось приклад того, як це може бути реалізовано за допомогою Anvil:

interface Authenticator
@ContributesTo(ApplicationScope::class)
class RealAuthenticator @Inject constructor(): Authenticator{}

Нам не потрібно взагалі створювати модуль. Усе це робиться за допомогою анотації @ContributesTo

Коли ви працюватимете з Anvil, вам не потрібно буде створювати модулі для зʼєднування інтерфейсів і імплементацій, модуль анотація + клас до неї потрібен лише для створення ручних ініціалізацій проєкта, наприклад Retrofit, OkHttp.

@Module
class BasicModule { 
      @Singleton 
      @Provides
      fun provideOkHttpClient(Logging: HttpLoggingInterceptor): OkHttpClient {
          return OkHttpClient.Builder()
              .addInterceptor (logging)
              .build()
      }

@MergeComponents використовується замість анотації @Component. Anvil генерує код на основі всіх необхідних модулів і компонентів, що належать одному @Scope.

Так само ми можемо зробити з анотацією @MergeSubcomponent, але вже для субкомпонентів. Насамперед це потрібно, коли ми маємо створити новий компонент і зʼєднати необхідні залежності з іншими наявними компонентами. І вищезазначена анотація це робить сама за розробника, тобто використовуючи один AppComponent, воно створить необхідний клас.

@MergeComponent

@Component 
interface ApiComponent
@Component 
@MergeComponent(ApiComponent::class) 
interface AppComponent

@MergeSubcomponent

@Component 
interface ApiComponent
@Component 
@MergeComponent(ApiComponent::class)
interface AppComponent

@ContributesBinding потрібен для того, щоб додавати залежності @Binds. Він зменшує кодогенерацію у випадках, коли у нас є проста залежність імплементації від інтерфейсу. Поглиблену інформацію ви можете знайти в розділі «Документація».

А загалом я створив невелику cheat-sheet, щоб піддивлятися перший час, що куди потрібно додавати:

І ще одна цікавинка. Anvil дозволяє підмінювати реалізації інтерфейсів на тестовi. Це надзвичайно корисно, коли нам потрібно покрити нашу логіку тестами.

@Module
@ContributesTo(
    scope = AppScope::class,
    replaces = [DevelopmentApplicationModule::class]
)
object DevelopmentApplicationTestModule {
  @Provides
  fun provideEndpointSelector(): EndpointSelector = TestingEndpointSelector
}

Посилання (приклади)

Також ниже за посиланнями ви можете подивитися декілька прикладів з Anvil

Документація

А також кілька гарних посилань, де можна подивитися документацію

Висновок

Варто памʼятати, що Anvil — це не срібна куля і не вирішує всі проблеми, з якими щодня стикаються Android-розробники, але він точно робить наше життя простішим.

Використовуючи Anvil, не потрібно створювати модулі для приєднання інтерфейсів та імплементацій. Він не вносить чогось кардинально нового, проте спрощує роботу з наявним функціоналом Dagger 2.

Anvil підійде для будь-якого проєкту, де розробники хочуть покращити і зменшити кількість boilerplate коду.

Для яких проєктів НЕ підійде:

  • він може були надлишковиме для проєктів, які мають дуже мало коду, краще в такому разі використовувати Koin або ж просту імплементацію Dagger 2;
  • для тих, де вже використовується Hilt як основна DI на проєкті.
👍ПодобаєтьсяСподобалось5
До обраногоВ обраному3
LinkedIn
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter

Не дивлячись на те що Anvil, і інші бібліотеки, є досить популярні, і пишуться авторитетними людьми в індустрії. Я все ще залишаються при свої думці, що все краще написати DI руками, так ще додать 100 строк коду до вашого модуля, за те код буде явний. Ви все будете наглядно бачити де і коли створюється той чи інший обєкт, до якого житєвого циклу від привязаний, і т.д.

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

DI руками і без код гену? Тоді який сенс з такого DI?:)

Сенс, DI в тому що було зручно працювати з обєктами, та контролювати їх lifecycler. А codegen , reflection чи руками, це лише інструменти для досягнення цілі

Користувався коїном в проді на кількох середнього розміру проектах.

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

В мене висновок наступний — допоки ви маєте один модуль і котлін-first апку, коїн на порядок краще.
А багатомодульність, в свою чергу, теж така штука, яка має сенс тільки для великих апок з командою в хоча б 8-10 інженерів, або специфічні кейси, коли з одної кодової бази можуть збиратись різні апки (сlient/driver для делівері, наприклад) і тре просто шарити спільні модулі. Фігачити мультимодульність, коли в тебе апка на 10-20 екранів і пару девів — це оверінжиніринг, бо не буде такого, що окремі люди працюють над різнимим модулями, всі робитимуть все.
Але якщо прям вже треба багатомодульність, то тіко даггер з його shenanigans.

Ну і незалежно від фреймворку, команда має мати явно проговорений протокол як що і куди прокидується, бо якщо хтось буде окшттп клієнт в актівіті інжектити, то ніяка магія від такої лапші не врятує.

Хмм, погоджуся щодо невеликої кількості екранів та мультимодульності

Просто я найчастіше працював на проектах, де досить багато екранів. Плюс проект на якому я працюю зараз напевно у загальному більше 100+ модулів та отже екранів

В койні лейзі ініціалізація. Тому впаде при заході на екран. Можна тести писати, щоб це покрити. Мультимодульність теж працює норм.

Імхо писати ці тести важче, ніж користуватися компайм-тайм перевіркою дагера

Підписатись на коментарі