Від монстра до гнучкої системи. Наш досвід створення компонентної архітектури для Android
Мене звати Сергій Неруш, я займаюся розробкою для Android уже 8 років. Пʼять із них працюю в венчур-білдері SKELAR, у бізнесі Storyby. Ми шукаємо класних авторів і даємо їм можливість публікувати на платформі свої історії. А також конвертуємо найкращі історії у фільми.
На DOU Mobile Meetup я розповідав, як ми з командою боролися з фічами-монстрами та прийшли до компонентного підходу в розробці для Android. Отримав багато запитань після, тому вирішив поділитися в матеріалі, як завдяки компонентній архітектурі ми реалізували власну інфраструктуру для роботи з великими та складними фічами.
Після моїх розповідей про такий підхід, а також про його переваги та недоліки, на вас чекає посилання на цю реалізацію на GitHub.
Проблеми масштабованості фіч та як це впливає на розробників
В одному з наших застосунків є фіча Reader — головний екран, що дає можливість купувати та читати книжки. Історія створення цієї фічі — стандартна:
- Спочатку був MVP-продукт із простим інтерфейсом, логікою та можливістю швидко вносити зміни.
- Потім інтерфейс обростав деталями, а тримати логіку в голові було складніше й складніше.
За рік покращень та A/B-тестів навіть прості зміни стали складним завданням.
Фіча Reader: було
Фіча Reader: стало
Знайома ситуація, коли внесення навіть маленької зміни перетворюється на годинне занурення в код? Ось чому зазвичай так відбувається:
- Розмір робочого «контексту». Щоб внести зміни у функціонал, необхідно «завантажити його контекст у голову»: розібратися, що це за фіча, з чим вона взаємодіє, та які в неї сайд-ефекти. Відповідно, що більший контекст, то більше часу потрібно, щоб зрозуміти його та внести зміни.
- Стрес. Коли розробник бачить файл на 1000+ рядків логіки, це викликає страх, що він врахував не все, і виконані зміни призведуть до неочікуваної поведінки фічі для користувача. А це потенційно втрачені гроші та шкода для бізнесу.
- Тестування. Для мануальних тестувальників велика фіча — це дуже багато тест-кейсів. Так само з автоматизованим тестуванням: покрити величезну ViewModel тестами — не найприємніше завдання. Ба більше, навіть якщо були внесені невеличкі зміни, часто тестувальникам доводиться перевіряти всю фічу.
Тож ми розуміли, що з «монстром» треба боротись. Звичайно, ми вже винесли всю логіку в UseCases, а взаємодію з даними — в Repositories та Managers.
Наступним кроком був рефакторинг. Він допоміг розібратися, чим є ця фіча та нагадати собі її логіки. Але обсяг фічі збільшився, оскільки незрозумілі речі переписали доступніше та водночас обʼємніше.
Тоді ми й дійшли висновку, що стандартна MVVM (Model-View-ViewModel) архітектура на Android чудово підходить для простих екранів. Проте зі складними екранами зʼявляється більше проблем, тому почали шукати інші варіанти.
Як ми обрали компонентну архітектуру та чому вирішили писати її самостійно
Спочатку ми думали перейти на MVI (Model-View-Intent) або Redux. Вони дійсно мали крутий вигляд, але водночас складний. Також ми зрозуміли, що не маємо ані часу, ані ресурсу, щоб переписати на них найскладнішу фічу застосунку. Тим паче ми вже маємо такий досвід: це завдання зайняло місяць. Підозрюю, що в цьому кейсі могло б тривати всі два.
Загалом завдання було не змінити абсолютно все, а поліпшити. За ідеальним планом — спрощувати частинами. Так ми прийшли до компонентної архітектури.
Компонентна архітектура — та, в якій все будується з компонентів, які, відповідно, будуються з менших компонентів.
Якщо абстрагуватись, ми використовуємо її щодня: наприклад, пишемо методи. Декілька методів утворюють клас; декілька класів — фічу. А з фіч складається наш застосунок. Ми користуємось цим, бо така система — зручна. Чому ж тоді не будуємо з компонентів екрани?
Насправді все просто. Бо в нас немає out-of-the-box інструментів для цього. Офіційно Google надає нам лише ViewModel. А ми хотіли ділити екран на незалежні компоненти UI й логіки та працювати з ними ізольовано, не турбуючись, що відбувається в інших частинах фічі.
Ми розглядали декілька готових альтернатив: архітектуру Lyft, MVICore та інші. В кожній були як переваги, так і недоліки. Наприклад, висока складність, необхідність все переписати (навіть навігацію) або в результаті отримати такого ж самого «монолітного монстра», але написаного інакше.
Які вимоги були до «нової» архітектури:
- Наші застосунки побудовані на MVVM. Хотілося не замінити її, а розширити.
- Рішення мало б бути максимально простим.
- Компоненти повинні мати особистий Lifecycle (як ViewModel зі своїм viewModelScope).
- Тестування необхідно було спростити.
Основні елементи нашої компонентної інфраструктури
Інфраструктура для нового підходу — це всього 5 класів та 95 рядків. Далі розкажу, як вони працюють.
Composable-функція
UI-компонент — це composable-функція у Jetpack Compose, яка визначає свій UI та взаємодіє зі своїм Component. Вона:
- приймає на вхід компонент;
- підписується на його state;
- передає команди компоненту для виконання логіки та зміни стейту.
Це схоже на те, як ми взаємодіємо з ViewModel:
@Composable fun ExampleUiComponent(component: ExampleComponent) { val state by component.stateFlow.collectAsState() // Update UI by state ExampleComponentContent( state = state, onDoSomething = component::doSomething, ) // ... }
Інтерфейс Component
Основний інфраструктурний елемент — це інтерфейс Component, у якого є свій coroutineScope для реалізації власного життєвого циклу. Зокрема, маємо метод очищення, завдяки якому змушуємо компонент припинити всю роботу. Тут ми й будемо розміщувати всю логіку та взаємодію з репозиторіями, як це робить ViewModel.
Компонент інтерфейсу наслідується від ComponentStoreOwner, тобто може містити інші компоненти (як я казав, компоненти можуть будуватися з менших компонентів).
interface Component : ComponentStoreOwner { val coroutineScope: CoroutineScope fun clear() { coroutineScope.cancel() clearComponents() } }
Інтерфейс ComponentStoreOwner
ComponentStoreOwner — це простий інтерфейс, який каже, що в нас є store, куди ми складаємо всі компоненти, а також три методи:
- attach/detach-компоненту;
- очищення всіх компонентів.
interface ComponentStoreOwner { val componentStore: ComponentStore fun attachComponent(component: Component) { componentStore.put(component) } fun detachComponent(component: Component) { componentStore.remove(component) } fun clearComponents() { componentStore.clear() } }
Клас ComponentStore
Клас ComponentStore, на який посилається інтерфейс ComponentStoreOwner, — це обгортка над Map для зручного зберігання компонентів. Особливість цього класу в тому, що коли ми видаляємо компоненти, ми їх очищаємо. І таким чином зупиняємо всю роботу.
class ComponentStore { private val map = mutableMapOf<String, Component>() fun put(component: Component) { val oldComponent = map.put(component.key, component) oldComponent?.clear() } fun remove(component: Component) { map.remove(component.key)?.clear() } fun clear() { for (component in map.values) { component.clear() } map.clear() } private val Component.key: String get() = this::class.java.canonicalName ?: throw IllegalArgumentException("Local and anonymous classes can not be Component") }
Для зручності ми додали також два абстрактні класи:
- BaseComponent (наслідується від Component). В ньому ми прописуємо поведінку: де він буде зберігати свої компоненти, а також який у нього coroutineScope.
- StateComponent (наслідується від BaseComponent). Він оголошує стан компонента, який передається зокрема і в UI.
abstract class BaseComponent( override val componentStore: ComponentStore = ComponentStore(), override val coroutineScope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate), ) : Component abstract class StateComponent<S>(initialState: S) : BaseComponent() { private val _stateFlow = MutableStateFlow(initialState) val stateFlow: StateFlow<S> = _stateFlow.asStateFlow() protected fun updateState(reducer: S.() -> S) { _stateFlow.update(reducer) } }
Короткий гайд для тих, кому було ліньки читати все, що вище:
- В нас є будь-яка Composable-функція, яка приймає клас Component, підписується на його state та взаємодіє із Component.
- Вся логіка та взаємодія з репозиторіями прописується в його Component.
- Також у нас є інтерфейс ComponentStoreOwner. Той, хто його імплементує, отримує можливість мати (в собі) компоненти.
- Є проста обгортка для Map — ComponentStore.
- А також є допоміжні, базові класи, які можна переписати під себе, щоб вони краще відповідали вашим підходам та інфраструктурі.
Як навчити ViewModel працювати з компонентами
Майже в кожному
abstract class BaseViewModel( override val componentStore: ComponentStore = ComponentStore() ) : ViewModel(), ComponentStoreOwner { override fun onCleared() { clearComponents() super.onCleared() } }
Як це працює на практиці
У нашому застосунку є екран Paywall Screen — екран розблокування розділу. Ми продаємо книжки й користувач платить за кожен розділ окремо за допомогою абстрактних коїнів, які він купує за реальні гроші.
Глобально цей екран може бути у двох станах:
- Коїнів достатньо → можна розблокувати розділ;
- Коїнів недостатньо → треба поповнити рахунок.
Два стани екрана PayWall Screen
Поведінка цих станів дуже різна: в першому є логіки розблокування, авторозблокування та онбординг для нових користувачів. А другий стан зав’язаний на менеджері оплати, підписках тощо. Тому їх можна розділити на різні компоненти.
Інфраструктурно це має такий вигляд:
PaywallScreen має свій базовий UI, а також викликає одну з composable-функцій: StoreUiComponent або UnlockUiComponent (залежно від доступності розблокування). Відповідно, є два компоненти з логікою (StoreComponent та UnlockComponent). А також екран має власну PaywallViewModel. У цієї ViewModel є особистий state, яким вона обмінюється з екраном.
Розглянемо, як ізольовано виглядає один із компонентів — StoreComponent.
Спрощена логіка така:
- він може відображати якусь помилку;
- може бути в стані завантаження;
- може відображати стан пакетів коїнів.
@Immutable data class StoreState( val isError: Boolean = false, val isLoading: Boolean = false, val coinsPackages: List<CoinsPackage>? = null ) @Composable fun StoreUiComponent(component: StoreComponent) { val state: StoreState by component.stateFlow.collectAsState() StoreUiComponent(state = state, buyCoins = component::buyCoins) } //...
Composable-функція відмальовує UI та може викликати якісь методи свого компонента.
Клас StoreComponent — це простий клас, у конструктор якого ми передаємо всі залежності. За умови ініціалізації він починає завантажувати пакети з коїнами.
@Stable class StoreComponent( val bookId: BookId, val billingManager: BillingManager, val analyticsManager: AnalyticsManager ) : StateComponent<StoreState>(StoreState()), PaywallState.UnlockAvailabilityComponent { init { loadCoinsPackages() } fun buyCoins(coinsPackage: CoinsPackage) { ... } private fun loadCoinsPackages() { updateState { copy(isLoading = true) } coroutineScope.launch { billingManager.getCoinsPackages().fold( onSuccess = { updateState { copy(isLoading = false, coinsPackages = it) } }, onFailure = { updateState { copy(isLoading = false, isError = true) } } ) } } // ... }
Коли ми викликаємо Composable-функцію та передаємо їй Component, то отримуємо ізольований UI та функціонал, з якими можемо працювати як із маленькою фічею.
Далі розберемо, як сам екран взаємодіє з компонентами.
Почнемо зі state-екрана: в нього також є стани помилки, завантаження, і сюди ми зберігаємо прев’ю розділу. Оскільки ми хочемо, щоб відображався або один компонент, або інший, створюємо для них спільний інтерфейс — UnlockAvailabilityComponent. Обидва наші компоненти будуть імплементувати його.
Якби такої задачі не було, ми просто поклали б обидва компоненти поряд як змінні, не використовуючи проміжний інтерфейс.
@Immutable data class PaywallState( val isError: Boolean = false, val isLoading: Boolean = false, val chapterPreview: String? = null, val unlockAvailabilityComponent: UnlockAvailabilityComponent? = null, ) { interface UnlockAvailabilityComponent : Component }
Сам екран має досить звичний вигляд:
@Composable fun PaywallScreen() { val viewModel: PaywallViewModel = hiltViewModel() val state by viewModel.stateFlow.collectAsState() // Some screen staff ChapterPreview(state) when (val component = state.unlockAvailabilityComponent) { is UnlockComponent -> { UnlockUiComponent(component) } is StoreComponent -> { StoreUiComponent(component) } } } // ...
Отримуємо ViewModel, підписуємось на її state, відмальовуємо його UI. Коли нам потрібно відобразити той чи інший компонент, питаємо у state-екрану, який із них зараз маємо і викликаємо відповідну composable-функцію.
Що при цьому відбувається у ViewModel?
Під час ініціалізації ViewModel дивиться, чи доступне розблокування, і створює необхідний компонент. Після цього викликається attach і передає туди компонент. Це потрібно, щоб за умови знищення ViewModel з нею знищувався й компонент.
@HiltViewModel class PaywallViewModel(...) : StateViewModel<PaywallState>(PaywallState()) { init { val unlockAvailabilityComponent = if (isAvailableToUnlock()) { createUnlockComponent() } else { createStoreComponent() } attachComponent(unlockAvailabilityComponent) updateState { copy(unlockAvailabilityComponent = unlockAvailabilityComponent) } } private fun isAvailableToUnlock(): Boolean { ... } private fun createUnlockComponent(): UnlockComponent { ... } private fun createStoreComponent(): StoreComponent { ... } // ... }
6 важливих нюансів компонентного підходу
Dependency Injection
Ми завжди маємо простий варіант — мануальний. Ніщо не заважає створити компонент через конструктор.
@HiltViewModel class PaywallViewModel @Inject constructor( private val bookId: BookId, private val billingManager: BillingManager, private val analyticsManager: AnalyticsManager ) : StateViewModel<PaywallState>(PaywallState()) { private fun createStoreComponent(): StoreComponent { return StoreComponent( bookId = bookId, billingManager = billingManager, analyticsManager = analyticsManager ) } // ... }
Але якщо ви користуєтесь Dagger або Hilt, цей процес можна покращити. Навіщо? Компоненти, так само як і ViewModel, з часом розростаються. Крім того, в прикладі нижче ми передаємо у ViewModel менеджер оплати, але в ідеалі вона не повинна про нього знати, бо він потрібен лише нашому компоненту.
Тут на допомогу приходить AssistedFactory. Що нам необхідно зробити:
- Додати в StoreCompoment анотацію AssistedInject. А також анотацію Assited для поля, яке ми хочемо передавати вручну;
- Створити інтерфейс нашої фабрики. Створюємо єдиний метод create(), що приймає лише ті змінні, які хочемо передавати вручну (bookId), а не інжектити.
@AssistedFactory interface StoreComponentFactory { fun create(bookId: BookId): StoreComponent } @Stable class StoreComponent @AssistedInject constructor( @Assisted private val bookId: BookId, private val billingManager: BillingManager, private val analyticsManager: AnalyticsManager ) : StateComponent<StoreState>(StoreState()), PaywallState.UnlockAvailabilityComponent { // ... }
Після цього кодогенерація Hilt автоматично згенерує реалізацію фабрики, в яку сама буде інжектити всі залежності.
Відповідно у ViewModel все спростилось: залежності, які були потрібні лише компоненту, зникли. Натомість маємо лише StoreComponentFactory, а коли необхідно створити компонент, просто викликаємо create().
@HiltViewModel class PaywallViewModel @Inject constructor( private val storeComponentFactory: StoreComponentFactory, ) : StateViewModel<PaywallState>(PaywallState()) { private fun createStoreComponent(): StoreComponent { return storeComponentFactory.create(bookId) } // ... }
Взаємодія з компонентами
Розглянемо наступний приклад: користувач зробив успішну покупку й тепер на екрані треба замінити початковий компонент на компонент розблокування.
Для цього ми передаємо в конструктор лямбда-функцію onSuccessPurchase(). Відповідно, коли буде успішна покупка, вона викличеться.
@Stable class StoreComponent @AssistedInject constructor( @Assisted private val bookId: BookId, @Assisted private val onSuccessPurchase: () -> Unit, private val billingManager: BillingManager, private val analyticsManager: AnalyticsManager ) : StateComponent<StoreState>(StoreState()), PaywallState.UnlockAvailabilityComponent { // ... }
Що в цей момент відбувається у ViewModel: коли ми створюємо наш компонент, то кажемо, що за умови виклику onSuccessPurchase() ми викличемо метод changeToUnlockComponent().
У цьому методі:
- Ми беремо той компонент, який у нас вже був, та від’єднуємо його (detach), оскільки він нам більше не потрібен. Вся робота в ньому зупиняється.
- Після цього створюємо новий компонент — UnlockComponent. За потреби можемо викликати для нього якусь функцію. Тут це не потрібно, але для прикладу показав, що ViewModel може взаємодіяти з нашим компонентом, викликаючи його методи.
- Під’єднуємо (attach) новий компонент, щоб прив’язати його життєвий цикл до ViewModel, та передаємо його в state.
private fun createStoreComponent(): StoreComponent { return storeComponentFactory.create( bookId = bookId, onSuccessPurchase = ::changeToUnlockComponent ) } private fun changeToUnlockComponent() { state.unlockAvailabilityComponent?.let(::detachComponent) val unlockComponent = createUnlockComponent() unlockComponent.doSomethingImportant() attachComponent(unlockComponent) updateState { copy(unlockAvailabilityComponent = unlockComponent) } }
Тестування
Якщо ще раз глянути на компонент, зрозуміло, що він має вигляд як ViewModel.
@Stable class StoreComponent( private val bookId: BookId, private val billingManager: BillingManager, private val analyticsManager: AnalyticsManager ) : StateComponent<StoreState>(StoreState()), PaywallState.UnlockAvailabilityComponent { init { loadCoinsPackages() } fun buyCoins(coinsPackage: CoinsPackage) { ... } private fun loadCoinsPackages() { updateState { copy(isLoading = true) } coroutineScope.launch { billingManager.getCoinsPackages().fold( onSuccess = { updateState { copy(coinsPackages = it, isLoading = false) } }, onFailure = { updateState { copy(isError = true, isLoading = false) } } ) } } // ... }
Єдина відмінність: він наслідується від Component, а не ViewModel. Відповідно тестується він так само як ViewModel, але легше, бо його логіка ізольована, а не розташована у величезній ViewModel.
Використовувати компоненти треба НЕ завжди
Якщо ви маєте простий екран, то компонентна архітектура все тільки ускладнить.
Компоненти розширюють, а не замінюють
Могло здатися, що ми будуємо екран тільки з компонентів, але це не так. Наш екран як був, так і залишається з усіма логіками. Ми просто виділяємо частину його UI та логіки й виносимо їх у компонент.
Компоненти реюзабельні
Вже написаний компонент можна перевикористати в якомусь іншому місці застосунку. Достатньо тільки його підключити.
Недоліки компонентного підходу
Звісно, кожен підхід має як переваги, так і недоліки. Було б нечесно, якби я розповів лише про перше, тому ловіть перелік мінусів.
Потрібно памʼятати про використання attach/detach. Додаючи компоненти, треба їх під’єднати, а видаляючи, — від’єднати. Якщо цього не робити, їхній життєвий цикл не прив’яжеться до ViewModel (або до іншого компонента) і вони будуть жити вічно.
Preview всього екрана разом із компонентами. Якщо ви хочете отримати прев’ю одного компонента, проблеми немає. Ми працюємо з ним як із маленьким екраном. Та коли потрібно отримати прев’ю всього екрана з кількома компонентами, стає трохи незручно — необхідно створювати окремий інтерфейс. В моєму прикладі на GitHub я показав, як це робити.
Висновки
- Після того, як ми поділили екран на окремі компоненти, майже всі зроблені зміни ми вносили в окремі компоненти ізольовано. Тобто, щоб внести зміни, вже не було потреби «завантажувати в голову контекст» всього екрану. Достатньо змінити значно менший за обʼємом компонент.
- Рішення дуже просте і його можна написати за 15 хвилин.
- Підхід дуже добре масштабується. Якщо ваш компонент із часом перетворюється на ще одного «монстра», його можна знову поділити на менші компоненти.
Втім, моя мета — ознайомити з підходом, а не з інструментом. Ви можете реалізувати власну інфраструктуру інакше. А тих, кому хочеться ознайомитися з нашою реалізацією ближче, запрошую на GitHub. Там ви знайдете реалізацію інфраструктури та приклад використання.
4 коментарі
Додати коментар Підписатись на коментаріВідписатись від коментарів