Логіка відображення стану View в Android: проектуємо і тестуємо
Підписуйтеся на Telegram-канал «DOU #tech», щоб не пропустити нові технічні статті
«Што такоє? Што, спокойно ходіть нє можетє?» Лесь Подерв’янський, «До$*я масла»
У цьому матеріалі зібрані мої думки щодо того, як краще облаштувати стани View в Android. Так, одна View має кілька станів. Так вже склалося — View одна, а відображається по-різному. Наприклад, ми отримуємо дані з мережі (хто цього не робив, нехай першим кине в мене дизлайк) і бачимо такий ланцюжок станів:
Стан | Стан UI |
тільки включились та нумо ініціалізувати все, що цвяхами не прибито | IniState Може щось напишемо, може покажемо літаючого ковбасного монстра (infinite ProgressBar ) |
припадаємо до джерела (DataSource) | відображається лоадер LoadDataState відображається помилка LoadErrorState |
відображення списку даних | відображається список ListShowState
відображення екрану порожнього списку |
Приклад логіки фрагменту з програми (тест слуху, робота зі списком пройдених тестів)
Для такого простого варіанту використання у нас вже цілих чотири стани та нульовий (init).
А зараз трохи страшилок правди життя.
Лишень уявіть, що сюди додається нова фіча. Наприклад: раптом знадобилося отримати список відсортованих/відфільтрованих результатів на тому ж самому екрані. Або, якщо ваш застосунок був у режимі без зареєстрованого користувача, то йому потрібно/не потрібно показувати маркетинговий банер, а тепер цей користувач здійснив вхід у застосунок, і вам потрібно показати йому іншу інформацію. А на додачу ще й необхідно отримати з локальної бази ще трохи даних, перевірити відповідність із даними зареєстрованого користувача, при цьому показувати стан перевірки. А як щодо завантаження з мережі ще однієї порції даних, за необхідності розбиваючи її на сторінки? З часом ці стани множаться відповідно до зростання та вдосконалення сценаріїв та вимог.
І до речі, якщо ви не пишете тести для перевірки правильності логіки відображення станів, то через збільшення кількості станів ймовірність помилки зростає, та й шукати такі помилки стає важче.
Тести також заощадять ваш час на ручне тестування кожного сценарію, при проведенні регресу, при додаванні нового стану. Особливо, коли перехід до конкретного стану прихований за декількома кроками.
Наскільки незручно працювати з непокритим тестами кодом можна подивитися, виконавши чекаут на коміт.
Перехід по станам там відбувається з імовірністю 50%. Спробуйте знайти помилку в коді, перевіряючи результат виконання — відчуйте себе в казино.
Відповідно потрібно ж якось все це узагальнити, застосувати патерн (а то і два). Та й взагалі — залишити все це неподобство без концепції буде неправильно.
Отже, вашій увазі пропонується: концепція побудови єдиного стану для View — рівня показу, яка об’єднує у собі дані та методи, необхідні для вибору способу візуалізації призначеного для користувача інтерфейса. Я продемонструю, як можна абстрагуватися від логіки стану відображення даних за допомогою інтерфейсів, а також протестувати її.
І відразу disclaimer: це не ідеальне рішення для кожної програми і кожного View. Свою думку з цього питання запрошую висловити в коментарях.
Поки ми не зайшли далеко, домовимось про умови, яким має відповідати рішення (сценарії):
- Об’єкт стану повинен мати однозначне зіставлення зі станом призначеного для користувача інтерфейсу.
- Ланцюжок викликів для створеного стану не повинен змінюватися з часом.
- Додавання нових станів не повинно впливати на існуючі стани.
- При додаванні нового об’єкта стану повинен бути передбачений захист від випадкового не зіставлення зі станом призначеного для користувача інтерфейсу.
- Логіка вибору стану має буть покрита тестами.
Цей концептуальний перформанс буде відбуватися з використанням Android Architecture Components — ViewModel і, відповідно, згідно паттерна MVVM.
Трохи теорії: Що таке MVVM паттерн?
MVVM — це архітектурний паттерн, розшифровується як Model-View-ViewModel. Народився від MVP, представлений в 2005.
Щоправда, якщо розташувати букви в порядку їхньої взаємодії, то вийде View-ViewModel-Model, тому що в реальності ViewModel знаходиться саме посередині, поєднуючи View та Model.
Для зацікавлених приведу історичну довідку про розвиток легалайзу появу три- і чотири-літерних «заклинань». А також дам опис їх відмінностей:
- MVC: описаний у 1979 році. Доступ до даних (Model) є і у View, і у контролерів. Тобто View може перебудовуватись самостійно лише на підставі зміни даних в Model або взаємодії користувача з View (натискання на екран), або сама змінити дані в Model і тільки повідомити контролеру про це. Controller може змінювати стан View на підставі своїх джерел даних (например, зовнішні запити, натискання на кнопку) і також може змінювати Model.
- MVP: описаний у 1990 році. Presenter є посередником між View та Model.View і Model змінюються даними через встановлене API. Відображення у View залежить тільки від даних, які встановив Presenter.
- MVVM: представлений у 2005році. ViewModel також є посередником між View та Model. Але ViewModel не може безпосередньо впливати на View, а лише є джерелом даних і має можливість через функції виклику передавати актуальні дані. Те, як відображати, визначається на рівні View.
Детальніше — в цій статті та в цій.
View — це абстракція для Activity, Fragment або будь-якою іншою навіть кастомною View (Android Custom View). View повинна максимально абстрагуватися від реалізації і даних, ми повинні уникати писати якусь логіку в неї. Також View не повинна знати нічого про інші частини програми. Вона повинна зберігати посилання на екземпляр ViewModel, і всі дані, які потрібні View, повинні приходити звідти.
ViewModel або Interactor. Відповідає за прийом дій користувача від View і віддачу оброблених і підготовлених даних для відображення у View.
Model — або в термінах clean — Use Case. Одна або кілька моделей отримують та/або обробляють специфічні операції з даними і логіку для інших фундаментальних подій системи та взаємодіють з ViewModel.
Підбиваючи підсумок
View відповідає за вигляд і відображення даних та взаємодію з користувачем.
ViewModel — за обробку взаємодії з користувачем, містять дані і логіку про те, коли ці дані повинні бути отримані і коли показані.
Model — містить логіку обробки специфічних операцій з даними і логіку для інших фундаментальних подій системи.
Трохи теорії: стан і ДКА (FSM)
Ми почули на початку про стани View. І чогось кудись переходить. Хм, все-таки, універ — це сила, а її мало не буває. Згадую, на парах я щось таке чув. Так це ж про кінцевий автомат або коротко — про КА (SM або StateMachine)!
Що таке кінцевий автомат? Все — від простих поведінкових шаблонів до розподілених систем — містить їх.
Давайте розберемо докладніше. У коді регулярно зустрічаються комутативні функції. Це така примітивна абстракція без власної пам’яті: на вхід аргумент, на виході якесь значення. Вихідне значення залежить тільки від вхідного. Приклад: switch або if-else.
Але доволі часто в реальному світі додаток може перебувати в одному з декількох станів, які змінюють один одного. І на однакові впливи реагувати по-різному. Тобто нам необхідно, щоб наступне значення функції залежало від попереднього. А іноді потрібно враховувати кілька попередніх.
Тут вже приходимо до якоїсь абстракції з власною пам’яттю. Це і називається автомат. Значення на виході автомата залежать від значення на вході і поточного стану автомата. Якщо у цього автомата кількість значень на виході кінечна — то це кінцевий автомат (SM). Ось і перша абревіатура з 2х літер. Але ДКА (FSM) чомусь трилітерні?
Найпростіший SM, в якому може бути один стан в поточний момент часу, має детермінованість. Детермінованість означає, що для всіх станів є максимум і мінімум одне правило для будь-якого можливого вхідного символу, тобто, наприклад, для «стану1» не може бути двох переходів з однією і тією ж вхідною послідовністю.
Для повноти опису згадаємо про існування недетермінованих кінцевих автоматів (НКА або ж NFA, Nondeterministic Finite Automaton). Висловлюючись простіше, в NFA насипано синтаксичного цукру у вигляді вільних переходів, недетермінованости і безлічі станів. NFA може бути представлений певною структурою з FSM. Прикладом є побудова з FSM еквівалентного NFA за алгоритмом Томпсона.
Переваги FSM:
- Дозволяє відокремити автомат і стан системи від коду, яким він керує. Або іншими словами — розділити реалізацію стану системи від реалізації керуючих функцій системи.
- Немає фатального недоліку.
- Можливість збереження стану.
- Прозорі і детерміновані дії в станах і переходах між ними, їх легко змінити і приблизно ясно де вони знаходяться.
Як будуємо FSM
Беремо велику складну хмару станів і як салямі розрізаємо на скибочки — на маленькі дискретні стани і розмічаємо граф зв’язків (переходів) між ними.
Для нашої задачі підходить FSM з жорстко заданими (в класі MainFragmentUiStatesModel) станами. Модифікуємий під час виконання програми автомат теж реалізований. Приклад знаходиться в тому ж репозиторії і, можливо, його буде розглянуто в спін-офі цієї статті.
Трохи теорії: Доповнюємо MVVM
Стани
Стани прописані в класі MainFragmentUiStatesModel. Він оголошений у вигляді sealed class. Так зроблено тому, що кожна з реалізацій sealed class сама по собі є повноцінним класом. Це означає, що кожен з них може мати свої власні набори властивостей незалежно один від одного. Цим синтаксичним цукром ми посипаємо умову 4 з наших сценаріїв: «При додаванні нового об’єкта стану повинен бути передбачений захист від випадкового не зіставлення зі станом призначеного для користувача інтерфейсу».
Граф переходів
Тримати в ViewModel не тільки стан, а й перелік станів і логіку переходу зі стану в стан якось не дуже зручно. Їх непогано б винести окремо, щоб уникнути дублювання і дотримати принцип єдиної відповідальності.
Для цього у View має бути якийсь-то контракт, згідно з яким View буде відображати дані з ViewModel. відповідно, ViewModel буде керувати відображенням даних просто змінюючи стан View. Отже, додаємо Contract до View.
Repository
В Android-співтоваристві поширене визначення Repository як об’єкта, який надає доступ до даних.
Ілюстрацію взято звідси
У View з’являється контракт, який відповідає за стани і логіку їх відображення. Методи для перемикання View в різні стани визначено в контракті і описані в View, яка реалізує MainFragmentViewStatesRenderContract. Самі стани знаходяться в MainFragmentUiStatesModel.
ViewModel — це абстрактне ім’я для класу, що містить дані і логіку їх підготовки до відображення; логіку, коли ці дані повинні бути отримані і як будуть показані. Також ViewModel зберігає поточний стан. У прикладі це mViewState зі значеннями типу класу MainFragmentUiStatesModel. Коли потрібно змінити стан View, ViewModel змінює mViewState. Потім View, яка отримує повідомлення про цю зміну стану, використовує контракт, щоб визначити, як саме змінюватись.
Також ViewModel зберігає посилання на одну або кілька DataModel. У нашому випадку це ExampleRepository. Все дані ViewModel отримує від них.
Завдяки цьому ViewModel не знає, наприклад, звідки отримує дані Repository — з бази даних або з сервера. Крім того, ViewModel не повинна знати про View.
А тепер усе разом
ViewModel генерує події. View, згідно контракту, відображає стани. Дані беруться з репозиторію (Repository).
MVVM, якщо реалізований правильно, є чудовим способом розбити код на менші частини (так, «салямі принцип» працює і тут) і зробити його більш «передбачуваним». Це допомагає нам слідувати принципам SOLID, тому наш код легше підтримувати.
У такій реалізації ми виконуємо умови з 1 по 3 наших сценаріїв:
- Об’єкт стану повинен мати однозначне зіставлення зі станом призначеного для користувача інтерфейсу.
- Ланцюжок викликів для створеного стану не повинна змінюватися з часом.
- Додавання нових станів не повинно впливати на існуючі стану.
Почнемо. Написання тестів
Shu Ha Ri, три бажання ... TDD також не минула ця доля — тут теж присутня магія трьох:
- Написання тесту, що дає збій, для невеликого фрагменту функціоналу.
- Реалізація функціоналу, яка призводить до успішного проходження тесту.
- Рефакторинг старого і нового коду для того, щоб підтримувати його в добре структурованому і читабельному стані.
TDD ще називають циклом «червоний, зелений, рефакторинг» ( «Red, Green, Refactoring»).
Підготуємось
Почнемо з написання тестів на контракт View.
Нагадую: клас, де знаходяться View стани, — це MainFragmentUiStatesModel
. Відповідно контракт MainFragmentViewStatesRenderContract
by default містить:
- метод render (viewState:
MainFragmentUiStatesModel
);
і по одному методу відображення до відповідного стану:
showIni ()
доIniState
showLoadCounterPercentData
доLoadCounterPercentDataState
- і т. ін.
Господині на замітку: в Android Studio є комбінація клавіш Ctrl + Shift + T (⇧⌘T) у якій ховається меню автогенерації тесту.
Використовуємо заклинання автогенерації тесту на MainFragment
і вибираємо для тесту всі методи з MainFragmentViewStatesRenderContract
.
Стан коду, після автогенерації тесту, див. комміт
Налаштування тестового класу
Вказуємо перед найменуванням класу, що будемо використовувати Mockito:
@RunWith(MockitoJUnitRunner::class)
Ми перевіряємо contract і нам потрібно створити його як змінну, що реалізує інтерфейс MainFragmentViewStatesRenderContract
. Оскільки Mockito не вміє перевіряти виклики методів через інтерфейс, нам доведеться реалізувати його в порожньому класі. Назвемо його MockForTestMainFragmentViewStatesRenderContract
, оголосивши як open class і подбаємо про те, щоб анотувати його за допомогою @Spy.
Червоний. Перший тест: функція testRenderInitState ()
Господині на замітку: Хорошим тоном вважається розбивати тести на три частини: Налаштування (Setup), Дія (Act) і Перевірка (Assert).
Налаштування (Setup):
Ця частина порожня, так як особливо нема чого налаштовувати.
Дія(Act):
Зазвичай це виклик функції, яку ми тестуємо. В цьому випадку ми перевіряємо, чи може функція render () відображати стан IniState.
Перевірка(Assert):
Перевіряємо, що правильна функція у contract викликається після виклику render (), а інші відповідно, не викликаються:
// Assert verify(contract).showIni() verify(contract, never()).showLoadCounterPercentData(any()) verify(contract, never()).showLoadError(any()) verify(contract, never()).showListEmpty() verify(contract, never()).showListShow(any())
Запускаємо тест клас на перевірку:
Результат виконання:
Чого і слід було очікувати. Стан коду на цей момент: див. комміт
Зелений
Давайте додамо в MainFragmentViewStatesRenderContract
мінімум коду, тільки щоб тест пройшов:
fun render(viewState:MainFragmentUiStatesModel){ showIni ()}
Досить одного рядка :)
Стан коду на цей момент: див. комміт.
Наступна ітерація
Червоний
Так як процес написання тестів буде однаковий для решти станів, щоб скоротити «простирадло», відразу пишемо тести на решту станів:
@Test fun testRenderLoadCounterPercentData() { // Act contract.render(MainFragmentUiStatesModel.LoadCounterPercentDataState(50)) // Assert verify(contract, never()).showIni() verify(contract).showLoadCounterPercentData(50) verify(contract, never()).showLoadError(any()) verify(contract, never()).showListEmpty() verify(contract, never()).showListShow(any ())} @Test fun testRenderLoadError() { // Act contract.render(MainFragmentUiStatesModel.LoadErrorState("Error")) // Assert verify(contract, never()).showIni() verify(contract, never()).showLoadCounterPercentData(any()) verify(contract).showLoadError("Error") verify(contract, never()).showListEmpty() verify(contract, never()).showListShow(any ())} @Test fun testRenderListEmpty() { // Act contract.render(MainFragmentUiStatesModel.ListEmptyState) // Assert verify(contract, never()).showIni() verify(contract, never()).showLoadCounterPercentData(any()) verify(contract, never()).showLoadError(any()) verify(contract).showListEmpty() verify(contract, never()).showListShow(any ())} @Test fun testRenderListShow() { // Act contract.render(MainFragmentUiStatesModel.ListShowState(ArrayList())) // Assert verify(contract, never()).showIni() verify(contract, never()).showLoadCounterPercentData(any()) verify(contract, never()).showLoadError(any()) verify(contract, never()).showListEmpty() verify(contract).showListShow(any ())}
свистимо — тарган не біжить
Результат виконання:
Стан коду на цей момент: див. комміт
Зелений
Додамо в MainFragmentViewStatesRenderContract трохи коду — аби лише тест пройшов:
fun render(viewState:MainFragmentUiStatesModel){ when (viewState){ is MainFragmentUiStatesModel.IniState -> { showIni ()} is MainFragmentUiStatesModel.LoadCounterPercentDataState -> { showLoadCounterPercentData(viewState. percent)} is MainFragmentUiStatesModel.LoadErrorState -> { showLoadError(viewState. errorCode)} is MainFragmentUiStatesModel.ListEmptyState -> { showListEmpty ()}}}
Я пропустив покриття ListShowState
стану, і компілятор мені нагадав про це:
Не можу не відзначити зручність синтаксичного цукру — MainFragmentUiStatesModel
оголошений як sealed class, і тепер компілятор контролює повноту покриття всіх станів методами перемикання View в директиві типу when (viewState). Хоча ми і передбачили відсутність можливості дублювання коду для цього вибору і покрили його тестами, але все одно це зручна та корисна властивість.
Додаю покриття стану ListShowState:
is MainFragmentUiStatesModel.ListShowState -> { showListShow(viewState. listItem)}
Запускаю тести:
Висновок
Тепер у нашому View немає коду, що відповідає за перемикання станів. Реалізація всіх методів відображення перемикання станів контролюється нашим контрактом. Код, що відповідає за перемикання, знаходиться там же. Його легко задіяти для підтримки подібних станів у інших View проекту.
Також ми виконуємо умову 5 з наших сценаріїв: «Логіка вибору стану має бути покрита тестами».
І ще один плюс використання TDD для написання коду — перегляд послідовності коммітів полегшує розуміння логіки побудови коду.
Код прикладів, розглянутих у статті знаходиться тут: Git репозиторій
Буду радий спілкуванню по темі (і не по темі теж :)) в коментарях.
Графоманські Творчі плани
У наступній частині статті ми візьмемось за реалізацію ViewModel і Repository. У ній ми будемо використовувати Flows, елементи Functional Programming (за посиланням, до речі, цікава стаття про питання функціонального програмування на Kotlin) з використанням бібліотеки від Arrow. ФП використаємо в гомеопатичних кількостях, а саме — тип Either для роботи з джерелами даних і обробки помилок в функціональному стилі. І, звичайно, підемо «дорогою з хмарками» з TDD.
Подивитись, так би мовити, на тізер, та оцінити, чи варто чекати наступну частину, (ну і як легко та приємно працювати з непокритим тестами кодом) можна, виконавши чекаут на ось цей комміт.
Написати автору, що «функціональщина» це не «дорога з хмарками», а Lucy In The Sky With Diamonds, завжди можна у коментарях.
30 коментарів
Додати коментар Підписатись на коментаріВідписатись від коментарів