Русский
Русский
English
Статистика
Реклама

Mvi

Архитектурный шаблон MVI в Kotlin Multiplatform. Часть 3 тестирование

27.08.2020 16:05:58 | Автор: admin


Эта статья является заключительной в серии о применении архитектурного шаблона MVI в Kotlin Multiplatform. В предыдущих двух частях (часть 1 и часть 2) мы вспомнили, что такое MVI, создали общий модуль Kittens для загрузки изображений котиков и интегрировали его в iOS- и Android-приложения.

В этой части мы покроем модуль Kittens модульными и интеграционными тестами. Мы узнаем о текущих ограничениях тестирования в Kotlin Multiplatform, разберёмся, как их преодолеть и даже заставить работать в наших интересах.

Обновлённый пример проекта доступен на нашем GitHub.

Пролог


Нет никаких сомнений в том, что тестирование важный этап в разработке программного обеспечения. Конечно, оно замедляет процесс, но при этом:

  • позволяет проверить крайние случаи, которые сложно поймать вручную;
  • уменьшает шанс регресса при добавлении новых функций, исправлении ошибок и рефакторинге;
  • заставляет декомпозировать и структурировать код.

Последний пункт на первый взгляд может показаться недостатком, потому как отнимает время. Однако это делает код более читаемым и приносит пользу в долгосрочной перспективе.

Indeed, the ratio of time spent reading versus writing is well over 10 to 1. We are constantly reading old code as part of the effort to write new code. ...[Therefore,] making it easy to read makes it easier to write. Robert C. Martin, Clean Code: A Handbook of Agile Software Craftsmanship

Kotlin Multiplatform расширяет возможности тестирования. Эта технология добавляет одну важную особенность: каждый тест автоматически выполняется на всех поддерживаемых платформах. Если поддерживаются, например, только Android и iOS, то количество тестов можно умножить на два. И если в какой-то момент добавляется поддержка ещё одной платформы, то она автоматически становится покрытой тестами.

Тестирование на всех поддерживаемых платформах важно, потому что могут быть различия в поведении кода. Например, у Kotlin/Native особенная модель памяти, Kotlin/JS тоже иногда даёт неожиданные результаты.

Прежде чем идти дальше, стоит упомянуть о некоторых ограничениях тестирования в Kotlin Multiplatform. Самое большое из них это отсутствие какой-либо библиотеки моков для Kotlin/Native и Kotlin/JS. Это может показаться большим недостатком, но я лично считаю это преимуществом. Мне довольно трудно давалось тестирование в Kotlin Multiplatform: приходилось создавать интерфейсы для каждой зависимости и писать их тестовые реализации (fakes). На это уходило много времени, но в какой-то момент я понял, что трата времени на абстракции это инвестиция, которая приводит к более чистому коду.

Я также заметил, что последующие модификации такого кода требуют меньше времени. Почему так? Потому что взаимодействие класса с его зависимостями не прибито гвоздями (моками). В большинстве случаев достаточно просто обновить их тестовые реализации. Нет необходимости углубляться в каждый тестовый метод, чтобы обновить моки. В результате я перестал использовать библиотеки моков даже в стандартной Android-разработке. Я рекомендую прочитать следующую статью: "Mocking is not practical Use fakes" (автор Pravin Sonawane).

План


Давайте вспомним, что у нас есть в модуле Kittens и что нам стоит протестировать.

  • KittenStore основной компонент модуля. Его реализация KittenStoreImpl содержит бОльшую часть бизнес-логики. Это первое, что мы собираемся протестировать.
  • KittenComponent фасад модуля и точка интеграции всех внутренних компонентов. Мы покроем этот компонент интеграционными тестами.
  • KittenView публичный интерфейс, представляющий UI, зависимость KittenComponent.
  • KittenDataSource внутренний интерфейс для доступа к Сети, который имеет платформенно-зависимые реализации для iOS и Android.

Для лучшего понимания структуры модуля приведу его UML-диаграмму:



План следующий:

  • Тестирование KittenStore
    • Создание тестовой реализации KittenStore.Parser
    • Создание тестовой реализации KittenStore.Network
    • Написание модульных тестов для KittenStoreImpl

  • Тестирование KittenComponent
    • Создание тестовой реализации KittenDataSource
    • Создание тестовой реализации KittenView
    • Написание интеграционных тестов для KittenComponent

  • Запуск тестов
  • Выводы


Модульное тестирование KittenStore


Интерфейс KittenStore имеет свой класс реализации KittenStoreImpl. Именно его мы и собираемся тестировать. Он имеет две зависимости (внутренние интерфейсы), определённые прямо в самом классе. Начнём с написания тестовых реализаций для них.

Тестовая реализация KittenStore.Parser


Этот компонент отвечает за сетевые запросы. Вот как выглядит его интерфейс:


TestKittenStoreNetwork имеет хранилище строк (как и настоящий сервер) и может их генерировать. По каждому запросу текущий список строк кодируется в одну строку. Если свойство images равно нулю, то Maybe просто завершится, что должно рассматриваться как ошибка.

Мы также использовали TestScheduler. У этого планировщика есть одна важная функция: он замораживает все поступающие задачи. Таким образом, оператор observeOn, используемый вместе с TestScheduler, будет замораживать нисходящий поток, а также все данные, проходящие через него, прямо как в реальной жизни. Но в то же время многопоточность не будет задействована, что упрощает тестирование и делает его надёжнее.

Кроме того, TestScheduler имеет специальный режим ручной обработки, который позволит нам моделировать сетевые задержки.

Тестовая реализация KittenStore.Parser


Этот компонент отвечает за разбор ответов от сервера. Вот его интерфейс:


Как и в случае с Network, используется TestScheduler для замораживания подписчиков и проверки их совместимости с моделью памяти Kotlin/Native. Ошибки обработки ответов моделируются, если входная строка пуста.

Модульные тесты для KittenStoreImpl


Теперь у нас есть тестовые реализации всех зависимостей. Пришло время модульных тестов. Все модульные тесты можно найти в репозитории, здесь я только приведу инициализацию и несколько самих тестов.

Первый шаг создать экземпляры наших тестовых реализаций:


Теперь можно провести несколько тестов. KittenStoreImpl должен загружать изображения сразу после создания. Это означает, что должен быть выполнен сетевой запрос, его ответ должен быть обработан, а состояние должно быть обновлено с новым результатом.


Этапы теста:

  • сгенерировать исходные изображения;
  • создать экземпляр KittenStoreImpl;
  • сгенерировать новые изображения;
  • отправить Intent.Reload;
  • убедиться, что состояние содержит новые изображения.

И наконец давайте проверим следующий сценарий: когда в состоянии установлен флаг isLoading во время загрузки изображений.


Есть две зависимости: KittenDataSource и KittenView. Нам понадобятся тестовые реализации для них, прежде чем мы сможем начать тестирование.

Для полноты картины на этой диаграмме показан поток данных внутри модуля:



Тестовая реализация KittenDataSource


Этот компонент отвечает за сетевые запросы. У него есть отдельные реализации для каждой платформы, и нам нужна ещё одна реализация для тестов. Вот как выглядит интерфейс KittenDataSource:


Как и раньше, мы генерируем разные списки строк, которые кодируются в массив JSON при каждом запросе. Если изображения не сгенерированы или аргументы запроса неверные, Maybe просто завершится без ответа.

Для формирования JSON-массива используется библиотека kotlinx.serialization. Кстати, тестируемый KittenStoreParser использует её же для декодирования.

Тестовая реализация KittenView


Это последний компонент, для которого нам нужна тестовая реализация, прежде чем мы сможем начать тестирование. Вот его интерфейс:


Нам просто нужно запоминать последнюю принятую модель это позволит проверить правильность отображаемой модели. Мы также можем отправлять события от имени KittenView с помощью метода dispatch(Event), который объявлен в наследуемом классе AbstractMviView.

Интеграционные тесты для KittenComponent


Полный набор тестов можно найти в репозитории, здесь я приведу лишь несколько самых интересных.

Как и раньше, давайте начнём с создания экземпляров зависимостей и инициализации:


Теперь мы можем написать несколько тестов. Давайте сначала проверим главный сценарий, чтобы убедиться, что при запуске изображения загружаются и отображаются:


Этапы:

  • сгенерировать исходные ссылки на изображения;
  • создать и запустить KittenComponent;
  • сгенерировать новые ссылки;
  • отправить Event.RefreshTriggered от имени KittenView;
  • убедиться, что новые ссылки достигли TestKittenView.


Запуск тестов


Чтобы запустить все тесты, нам нужно выполнить следующую Gradle-задачу:

./gradlew :shared:kittens:build

Это скомпилирует модуль и запустит все тесты на всех поддерживаемых платформах: Android и iosx64.

А вот JaCoCo-отчёт о покрытии:



Заключение


В этой статье мы покрыли модуль Kittens модульными и интеграционными тестами. Предложенный дизайн модуля позволил охватить следующие части:

  • KittenStoreImpl содержит бОльшую часть бизнес-логики;
  • KittenStoreNetwork отвечает за сетевые запросы высокого уровня;
  • KittenStoreParser отвечает за разбор сетевых ответов;
  • все преобразования и связи.

Последний пункт очень важен. Охватить егои возможно благодаря особенности MVI. Единственная ответственность представления отображать данные и отправлять события. Все подписки, преобразования и связи выполняются внутри модуля. Таким образом, мы можем покрыть общими тестами всё, кроме непосредственно отображения.

Такие тесты имеют следующие преимущества:

  • не используют платформенные API;
  • выполняются очень быстро;
  • надёжные (не мигают);
  • выполняются на всех поддерживаемых платформах.

Мы также смогли проверить код на совместимость со сложной моделью памяти Kotlin/Native. Это тоже очень важно из-за отсутствия безопасности во время сборки: код просто падает во время выполнения с исключениями, которые трудно отлаживать.

Надеюсь, это поможет вам в ваших проектах. Спасибо, что читали мои статьи! И не забудьте подписаться на меня в Twitter.



Бонусное упражнение


Если вы хотите поработать с тестовыми реализациями или поиграть с MVI, предлагаю выполнить несколько практических заданий.

Рефакторинг KittenDataSource


В модуле существуют две реализации интерфейса KittenDataSource: одна для Android и одна для iOS. Я уже упоминал, что они отвечают за доступ к сети. Но на самом деле у них есть ещё одна функция: они генерируют URL-адрес для запроса на основе входных аргументов limit и page. В то же время у нас есть класс KittenStoreNetwork, который ничего не делает, кроме делегирования вызова в KittenDataSource.

Задание: переместить логику генерирования URL-запроса из KittenDataSourceImpl (на Android и iOS) в KittenStoreNetwork. Вам нужно изменить интерфейс KittenDataSource следующим образом:



Как только вы это сделаете, вам нужно будет обновить тесты. Единственный класс, к которому вам потребуется прикоснуться, это TestKittenDataSource.

Добавление постраничной загрузки


TheCatAPI поддерживает разбивку на страницы, поэтому мы можем добавить эту функцию для лучшего взаимодействия с пользователем. Вы можете начать с добавления нового события Event.EndReached для KittenView, после чего код перестанет компилироваться. Затем вам нужно будет добавить соответствующий Intent.LoadMore, преобразовать новый Event в Intent и обработать последний в KittenStoreImpl. Вам также потребуется изменить интерфейс KittenStoreImpl.Network следующим образом:



Наконец, вам потребуется обновить некоторые тестовые реализации, исправить один или два существующих теста, а затем написать несколько новых, чтобы покрыть разбивку на страницы.

Подробнее..

Как мы выбрали архитектуру слоя представления на новом проекте и не прогадали

23.12.2020 08:22:02 | Автор: admin

Про проект

Всем привет! Меня зовут Даниил Климчук. Год назад я пришел в vivid.money третьим Android-разработчиком. Несмотря на это, в проекте практически не было кода, а первые фичи только начинали разрабатываться. Нам нужно было запустить новое банковское приложение в европе, где придется конкурировать с такими компаниями, как Revolut. Уже тогда было понятно, что команда очень быстро значительно вырастет. Конечно, стоило сразу задуматься о том, как будет развиваться архитектура проекта. Через год, когда проект запустится, на это не останется времени, а оправданий вносить значительные изменения просто не будет. Одним из ключевых решений на начальном этапе стал выбор архитектуры слоя представления. В этой статье я поделюсь тем, как мы его принимали.

Про выбор

Возможные подходы для нас явно разделились на две группы: проверенные временем и надежные MVP, MVVM и MVC, а также новые архитектуры, использующие Unidirectional Data Flow: Redux, MVI, Elm (aka MVU) и т.д.. Не хотелось сравнивать каждые в отдельности, а для упрощения определиться в какую сторону смотреть в первую очередь. Поэтому быстро набросали список требований.

Хотелось чтобы:

  • Код был поддерживаемым
    Лучше помнить про то, что с прошествием времени код все еще нужно будет понимать и менять.

  • Новые люди могли быстро влиться.

В данном случае время - деньги, а еще комфортность работы. После прихода в компанию нового всегда хватает и вникать в детали архитектуры может не хватить сил и времени.

  • Уменьшить boilerplate
    Печатать одно и то же на каждом экране утомительно и в добавок может привести к ошибкам.

  • Был единый подход к архитектуре
    В среднем разные части приложения лучше писать в похожем стиле, чтобы разработчики могли переключаться между задачами и тратили меньше времени на понимание как именно работает экран.

  • Было проще покрыть тестами
    Мы сразу думали о том, что будем покрывать всю логику unit-тестами и хотелось по возможности облегчить себе работу.

Исходя из этого принять решение сразу было достаточно сложно. Пришлось обратиться к проверенному методу и расписать плюсы и минусы обоих подходов:

За старое доброе

  • Нет boilerplate
    Достаточно реализации базовых классов MVP, после этого на каждый экран нужно создавать только Presenter/ViewModel/Controller. В отличие от UDF архитектур, для которых даже каждое событие требует своего класса.

  • Это всем известные архитектуры
    Пожалуй, чтобы найти андроид разработчика, который не работал с MVP нужно еще постараться. А нам важен порог вхождения, ведь необходимо будет собрать большую команду.

  • Проще code review
    При изменении экрана меняется только Presenter и View. В UDF архитектурах логика из Presenter разбивается на несколько классов, каждый из которых приходится просматривать в отдельности.

  • Нет проблемы SingleLiveEvent
    Проблема описана в issue для android architecture components. В MVP в принципе отсутствует, а в MVVM с LiveData можно использовать собственно сам класс SingleLiveEvent. Для UDF архитектур нет устоявшегося подхода с решением этой проблемы, для нее придется придумывать что-то свое.

  • Простота в понимании

Если рассматривать саму архитектуру, то MVP и MVVM определяют только наличие двух классов View и Presenter (или соответственно ViewModel). В UDF архитектурах структура более сложная и у их составляющих более узкая зона ответственности.

За новое хайповое

  • Собственно сам UDF
    В таких архитектурах есть только один фиксированный путь, по которому данные передаются в приложении. В отличие например MVP, где в Presenter со временем может накапливаться огромное количество спагетти-кода, который со временем становится сложно понимать.

  • Single immutable state
    Состояние экрана выделяет в отдельный класс, который называется State. Если нет такого явного ограничения, состояние может описываться множеством флагов иногда частично находиться где-то во View или дублируется в нескольких местах. Такой подход позволяет иметь single source of truth о текущем состоянии экрана. Важным достоинством этого подхода является возможность в каждый момент времени обратиться с State и понять, например, идет ли сейчас загрузка данных.

  • Обработка смены конфигурации и восстановления процесса
    Намного проще, поскольку есть single state. Достаточно просто отрисовать его заново на экране, чтобы полностью восстановить предыдущее состояние. При обработке смерти процесса есть необходимость сохранить только единственный класс. Справедливости ради, например, использование LiveDatа позволит обработать смену конфигурации. Однако это дополнительная зависимость, которую придется тянуть в проект. Также, стандартный механизм обработки смерти процесса для ViewModel на основе SavedStateHandle намного сложнее в реализации и усложняет логику во ViewModel.

  • Separation of Concerns
    Логика слоя представления разделена на несколько классов, каждый из которых выполняет свою функцию. В отличие, например, от MVP в котором все логика находится в Presenter. Получается, что он отвечает за обработку изменения состояния, загрузку данных, изменение модели итд. Явного разделения на зоны ответственности нет и часто она вся находится в одном классе.

  • Thread safety
    Не нужно думать о потокобезопасности, вся синхронизация происходит на уровне реализации архитектуры. Из-за разделения ответственности и неизменяемого состояния различные части кода не должны обращаться к одним и тем же изменяемым данным. Например в MVP в рамках Presenter намного проще выстрелить себе в ногу, случайно поменяв какой-то флаг в состоянии не с главного потока.

  • Проблема bloated presenter
    Со временем Presenter или ViewModel может вырасти до нескольких тысяч строк кода. В этот момент придется думать о том как разделять логику, что может вполне вылиться в решение, менее гибкое, чем изначально заложенная UDF архитектура.

  • Горизонтальное масштабирование

    В некоторых UDF архитектурах есть возможность составлять экран из нескольких частей вместо одного большого Presenter. Например в MviCore есть разделение на Feature, а в ELM - компоненты. Каждая из них написана в одном стиле и вместе они составляют логику экрана. Вдобавок эти части можно переиспользовать, в отличие MVP и MVVM, где придется придумывать свое нестандартное решения этой проблемы.

  • Простота тестирования
    Логика разделена на компоненты, которые можно тестировать в отдельности. А Reducer или его аналог вообще является чистой функцией и к тому же не содержит работы с многопоточностью.

  • Заставляет планировать логику экрана заранее
    Перед написанием экрана изначально приходится подумать о том, каким может быть состояние экрана, какие события будут происходить и как все это связать вместе. В MVP и остальных архитектурах нет четкого подхода к написанию экрана, поэтому планирование остается на совести разработчика.

  • Возможность реализовать Time Travel Debug
    Позволяет записывать последовательность состояний экрана, и потом их воспроизводить. Что позволяет разработчику воспроизвести последовательность действий, приводящих к ошибке.

  • Jetpack Compose
    UDF архитектуры лучше подходят для работы с Jetpack Compose, для которого недавно уже вышла alpha версия. UDF архитектуры имеют единый метод для отрисовки состояния, которое сразу можно преобразовать в иерархию View.

  • Хайп
    Больше шансов, что разработчиков заинтересует вакансия с современной архитектурой, которая даст возможность развиваться или попробовать что-то новое.

Как принимали решение

У UDF архитектур очень много преимуществ, которыми не хотелось жертвовать в угоду простоте. Помимо хайпа и интересных фичей подкупала возможность борьбы с запутанной логикой в Presenter. Не хотелось через несколько лет возвращаться к той же проблеме или оказаться с кучей неподдерживаемого кода. В итоге решили остановиться на UDF.

MVI vs ELM

Многие реализации UDF архитектур сильно похожи, поэтому выделили основное различие: в MVI логика экрана разделена между Reducer и Intent, а в ELM полностью находится в Update.

Например, при нажатии на кнопку загрузки, в MVI Intent знает про то, что нужно получить данные, а reducer отвечает за то, чтобы показать состояние загрузки. В Elm за все это отвечает один класс Update, и только само получение данных происходит в рамках Side Effect.

Почему выбрали ELM

Решили руководствоваться уже существующими недостатками UDF архитектур, в которых были различия. Победил однозначно Elm:

  • Покрытие тестами
    Elm позволяет покрыть тестами всю логику экрана, написав тесты всего на один класс. При этом этот класс не содержит асинхронного кода и писать тесты значительно легче. Более сложные сценарии будут покрываться ui тестами, а работа по написанию unit тестов значительно сократится.

  • Понимание новыми членами команды
    Человеку, который только что пришел работать Elm проще объяснить: "вот здесь логика, а вот здесь асинхронные операции". В отличии от MVI, в котором приходится представлять как все работает в целом.

  • Code review
    Update из Elm можно рассматривать отдельно, поскольку в нем содержится вся логика. При code review кода, написанного на mvi, приходится больше переключаться между Intent и Reducer, потому что логика разделена между ними.

На текущий момент уже есть несколько open-source реализаций Elm архитектуры, например Teapot, Puerh и Elmo, однако мы решили сделать свою.

Как решить проблемы UDF

Остались нерешенными еще два пункта, по ним пришлось искать решения.

Схематично нашу реализацию с итоговым неймингом можно представить вот так:

Boilerplate

Головной болью таких подходов является создание большого числа классов на этапе создания экрана. Например, в нашей реализации это Actor, Reducer, State, Event, Effect, Command и StoreFactory. Простой экран с одним запросом превращается в долгое печатание давно заученного наизусть кода. Для решения этой проблемы был реализован плагин для Android Studio. Весь повторяющийся код можно сгенерировать и добавить новый экран становится не сложнее чем в привычном MVP.

SingleLiveEvent

Мы поддержали решение этой проблемы на уровней нашей реализации Elm. Мы выделили отдельную сущность для сообщений, которые должны быть переданы во View только один раз. Для них выделили отдельную очередь сообщений, на изменения в которой и подписывается View. На схеме эта сущность обозначена как Effect.

Восстановление состояния

Эту проблему можно разделить на две части: восстановление состояния при смене конфигурации и при восстановлении процесса. Для решения первой проблемы хватает хранения Elm компонента внутри Dagger Scope. Новый инстанс фрагмента подключится к компоненту и при инициализации получит последнее состояние. Чуть более сложной получилась обработка смерти процесса. По скольку есть выделенное в отдельный класс состояние, достаточно сохранить его в onSaveInstanceState.

А что дальше

Мы старались подойти к принятию решения о выборе архитектуры с должным вниманием, поскольку полагали что это окажет значительное влияние на развитие проекта в дальнейшем. По прошествии года можно сказать, что наше решение выдержало расширение команды до 12 человек, однако потребовало общих усилий в своем развитии. Помимо изначальной реализации самой архитектуры также пришлось править в ней баги, писать гайды для новичков, вырабатывать общий подход к написанию тестов и многое другое. В итоге мы получили решение, которое упрощает нашим разработчикам написание кода вместо того чтобы усложнять им жизнь. А более подробно о нашей реализации мы расскажем в следующей части.

Подробнее..

Пишем под android с Elmslie

20.04.2021 08:05:05 | Автор: admin

Вступление

Это третья часть серии статей об архитектуре android приложения vivid.money. В ней мы расскажем об Elmslie - библиотеке для написания кода под android с использованияем ELM архитектуры. Мы назвали ее в честь Джорджа Эльмсли, шотландского архитектора. С сегодняшнего дня она доступна в open source. Это реализация TEA/ELM архитектуры на kotlin поддержкой android. В первой статье мы рассказали о том почему выбрали ELM. Перед прочтением этой статьи лучше ознакомиться как минимум со второй частью, в которой мы более подробно рассказывали том собственно такое ELM.

Оглавление

Что будем писать

В предыдущей статье мы рассказывали о том что такое ELM архитектура. В ней приводился пример, его и будем реализовывать. В нем происходит загрузка числового значения и есть возможность его обновлять. Исходный код для этого примера доступен на GitHub.

Модель

Написание экрана проще начинать с проектирования моделей. Для каждого экрана нужны State, Effect, Command и Event. Рассмотрим каждый из них по очереди:

State

State описывает полное состояние экрана. В каждый момент времени по нему можно полностью восстановить то что сейчас показывается пользователю. Правильнее делать исключения, если это усложняет логику. Не нужно сохранять, показывается ли сейчас диалог на экране или что сейчас находится в каждом EditText.

На нашем экране будет отображаться либо числовое значение, либо состояние загрузки. Это можно задать двумя полями в классе: val isLoading: Boolean и val value: Int?. Для удобства изменения, State лучше реализовывать как data class. В итоге получается так:

data class State(  val isLoading: Boolean = false,  val value: Int? = null)

Effect

Каждый Effect описывает side-effect в работе экрана. То есть это события, связанные с UI, происходящие ровно один раз, причем только когда экран виден пользователю. Например, это могут быть навигация, показ диалога или отображение ошибки.

В нашем примере единственной командой UI будет показ Snackbar при ошибке загрузки value. Для этого заведем Effect ShowError. Для удобства Effect можно создавать как sealed class, чтобы не забыть обработать новые добавленные эффекты:

sealed class Effect {  object ShowError : Effect()}

Command

Каждая Command обозначает одну асинхронную операцию. Подробнее о том как они обрабатываются расскажем чуть позже. В результате выполнения команды получаются события, которые в свою очередь повлияют на состояние экрана.

У нас будет одна операция - загрузить данные. Эту Command назовем LoadValue. Команды так же удобнее задавать как sealed class:

sealed class Command {  object LoadValue : Command()} 

Event

Все события, которые влияют на состояние и действия на экране: Ui: ЖЦ экрана, взаимодействие с пользователем, все что приходит из View слоя Internal: Результаты операций с бизнес логикой

Теперь перейдем к событиям. В нашем проекте мы разделяем события на две категории:

  • Event.UI: все события, которые происходят во View слое

  • Event.Internal: результаты выполнения команд в Actor.

В этом примере будет два UI события: Init - открытие экрана и ReloadClick - нажатие на кнопку обновления значение. Internal события тоже два: ValueLoadingSuccess - успешный результат Command LoadValue и ValueLoadingError, которое будет отправляться при ошибке загрузки значения.

Если использовать разделение на UI и Internal, то Event удобнее задавать как иерархию sealed class:

sealed class Event {  sealed class Ui : Event() {    object Init : Ui()    object ReloadClick : Ui()  }     sealed class Internal : Event() {    data class ValueLoadingSuccess(val value: Int) : Internal()    object ValueLoadingError : Internal()  }}

Реализуем Store

Закончив с моделями, перейдем собственно к написанию кода. Сам Store реализовывать не нужно, он предоставляется библиотекой классом ElmStore.

Repository

Для нашего примера напишем симуляцию работы с моделью, которая будет возвращать либо случайный Int, либо ошибку:

object ValueRepository {private val random = Random()fun getValue() = Single.timer(2, TimeUnit.SECONDS)    .map { random.nextInt() }    .doOnSuccess { if (it % 3 == 0) error("Simulate unexpected error") }}

Actor

Actor - место, в котором выполняются долгосрочные операции на экране - запросы в сеть, подписка на обновление данных, итд. В большинстве случаев, как и в нашем примере просто маппит результаты запросов в Event.

Для его создания нужно реализовать интерфейс Actor, который предоставляется библиотекой. Actor получает на вход Command, а результатом его работы должен быть Observable<Event>, с событиями, которые сразу будут отправлены в Reducer. Для удобства в библиотеке есть функции mapEvents, mapSuccessEvent, mapErrorEvent и ignoreEvents, которые позволяют преобразовать данные в Event.

В нашем случае Actor будет выполнять только одну команду. При выполнении команды загрузки мы будем обращаться к репозиторию. В случае получения успешного значения будет оправляться событие ValueLoaded, а при ошибке ErrorLoadingValue. B итоге получается такая реализация:

class Actor : Actor<Command, Event> {override fun execute(command: Command): Observable&lt;Event&gt; = when (command) {    is Command.LoadNewValue -&gt; ValueRepository.getValue()        .mapEvents(Internal::ValueLoaded, Internal.ErrorLoadingValue)}}

Reducer

В этом классе содержится вся логика работы экрана и только она. Это позволяет в одном месте увидеть все что происходит на экране, не отвлекаясь на детали реализации. Так же для него удобно писать тесты, поскольку он не содержит многопоточного кода и представляет из себя чистую функцию. В нем на основании предыдущего состояния экрана и нового события рассчитывается новое состояние экрана, команды и эффекты.

В этом классе нужно реализовать функцию reduce для обработки событий. Помимо вашей логики в Reducer можно использовать 3 функции:

  • state - позволяет изменить состояние экрана

  • effects - отправляет эффект во View

  • commands - запускает команду в Actor

class Reducer : DslReducer<Event, State, Effect, Command>() {override fun Result.reducer(event: Event) = when (event) {    is Internal.ValueLoaded -&gt; {        state { copy(isLoading = false, value = event.value) }    }    is Internal.ErrorLoadingValue -&gt; {        state { copy(isLoading = false) }        effects { +Effect.ShowError }    }    is Ui.Init -&gt; {        state { copy(isLoading = true) }        commands { +Command.LoadNewValue }    }    is Ui.ClickReload -&gt; {        state { copy(isLoading = true, value = null) }        commands { +Command.LoadNewValue }    }}}

Собираем Store

После того как написаны все компоненты нужно создать сам Store:

fun storeFactory() = ElmStore(    initialState = State(),    reducer = MyReducer(),    actor = MyActor()).start()

Экран

Для написания android приложений в elmslie есть отдельный модуль elmslie-android, в котором предоставляются классы ElmFragment и ElmAсtivity. Они упрощают использование библиотеки и имеют схожий вид. В них нужно реализовать несколько методов:

  • val initEvent: Event - событие инициализации экрана

  • fun createStore(): Store - создает Store

  • fun render(state: State) - отрисовывает State на экране

  • fun handleEffect(effect: Effect) - обрабатывает side Effect

В нашем примере получается такая реализация:

class MainActivity : ElmActivity<Event, Effect, State>() {override val initEvent: Event = Event.Ui.Initoverride fun onCreate(savedInstanceState: Bundle?) {    super.onCreate(savedInstanceState)    setContentView(R.layout.activity_main)    findViewById&lt;Button&gt;(R.id.reload).setOnClickListener {        store.accept(Event.Ui.ClickReload)     }}override fun createStore() = storeFactory()override fun render(state: State) {    findViewById&lt;TextView&gt;(R.id.currentValue).text = when {        state.isLoading -&gt; &quot;Loading...&quot;        state.value == null -&gt; &quot;Value = Unknown&quot;        else -&gt; &quot;Value = ${state.value}&quot;    }}override fun handleEffect(effect: Effect) = when (effect) {    Effect.ShowError -&gt; Snackbar        .make(findViewById(R.id.content), &quot;Error!&quot;, Snackbar.LENGTH_SHORT)        .show()}}

Заключение

В нашей библиотеке мы постарались реализовать максимально простой подход к ELM архитектуре, который был бы максимально удобен в использовании. По ощущениям нашим разработчиков, впервые сталкивающихся с ELM, порог входа в библиотеку невысокий. Также мы постарались сильно облегчить написание кода с помощью кодогенерации. Саму библиотеку мы без проблем используем уже около года в продакшене и полностью ей довольны. Будем рады фидбеку и надеемся, что она пригодится и вам.

Подробнее..

MVI и SwiftUI одно состояние

26.07.2020 20:06:58 | Автор: admin


Представим, нам нужно внести небольшую правку в работу экрана. Экран меняется каждую секунду, поскольку в нем одновременно происходит множество процессов. Как правило, чтобы урегулировать все состояния экрана, необходимо обратиться к переменным, каждая из которых живет своей жизнью. Держать их в голове либо очень трудно, либо вовсе невозможно. Чтобы найти источник проблемы, придется разобраться в переменных и состояниях экрана, да еще проследить, чтобы наше исправление не поломало что-то в другом месте. Допустим, мы потратили уйму времени и все-таки внесли нужную правку. Можно ли было решить эту задачу проще и быстрее? Давайте разбираться.


MVI


Первым этот паттерн описал JavaScript разработчик Андрэ Штальц. С общими принципами можно ознакомиться по ссылке



Intent: ждет событий от пользователя и обрабатывает их
Model: ждет обработанные события для изменения состояния
View: ждет изменений состояния и показывает их
Custom element: подраздел View, который сам по себе является UI элементом. Может быть реализован как MVI или как веб-компонент. Необязательно использовать во View.

На лицо реактивный подход. Каждый модуль (function) ожидает какое-либо событие, а после его получения и обработки передает это событие в следующий модуль. Получается однонаправленный поток. Единое состояние View находится в Model, и таким образом решается проблема множества трудноотслеживаемых состояний.

Как это можно применить в мобильном приложении?

Мартин Фаулер и Райс Дейвид в книге Шаблоны корпоративных приложений писали, что паттерны это шаблоны решения проблем, и вместо того, чтобы копировать один в один, лучше адаптировать их под текущие реалии. У мобильного приложения есть свои ограничения и особенности, которые надо учитывать. View получает событие от пользователя, а дальше его можно проксировать в Intent. Схема немного видоизменяется, но принцип работы паттерна остается прежним.



Реализация


Прежде чем приступить к реализации, нам понадобится расширение для View, которое упростит написание кода и сделает его более читабельным.

extension View {    func toAnyView() -> AnyView {        AnyView(self)    }}


View


View принимает событие от пользователя, передает их в Intent и ждет изменения состояния от Model

import SwiftUIstruct RootView: View {    // 1    @ObservedObject private var intent: RootIntent    var body: some View {        ZStack {          // 4            imageView()            errorView()            loadView()        }        // 3        .onAppear(perform: intent.onAppear)    }    // 2    static func build() -> some View {        let intent = RootIntent()        let view = RootView(intent: intent)        return view    }    private func imageView() -> some View {        Group { () -> AnyView  in // 5            if let image = intent.model.image {                return Image(uiImage: image)                    .resizable()                    .toAnyView()            } else {                return Color.gray.toAnyView()            }        }        .cornerRadius(6)        .shadow(radius: 2)        .frame(width: 100, height: 100)    }    private func loadView() -> some View {   // 5        guard intent.model.isLoading else {            return EmptyView().toAnyView()        }        return ZStack {            Color.white            Text("Loading")        }.toAnyView()    }    private func errorView() -> some View {   // 5        guard intent.model.error != nil else {            return EmptyView().toAnyView()        }        return ZStack {            Color.white            Text("Fail")        }.toAnyView()    }}

  1. Все события, которые получает View, передаются в Intent. Intent держит ссылку на актуальное состояние View у себя, так как именно он меняет состояния. Обертка @ObservedObject нужна для того, чтобы передавать во View все изменения, происходящие в Model (подробнее чуть ниже)
  2. Упрощает создание View, таким образом проще принимать данные от другого экрана (пример RootView.build() или HomeView.build(articul: 42))
  3. Передает событие цикла жизни View в Intent
  4. Функции, которые создают Custom elements
  5. Пользователь может видеть разные состояния экрана, все зависит от того, какие сейчас данные в Model. Если булевое значение атрибута intent.model.isLoading true, пользователь видит загрузку, если false, то видит загруженный контент или ошибку. В зависимости от состояния пользователь будет видеть разные Custom elements.


Model


Model держит у себя актуальное состояние экрана

 import SwiftUI// 1protocol RootModeling {    var image: UIImage? { get }    var isLoading: Bool { get }    var error: Error? { get }}class RootModel: ObservableObject, RootModeling {    // 2    @Published private(set) var image: UIImage?    @Published private(set) var isLoading: Bool = true    @Published private(set) var error: Error?} 

  1. Протокол нужен для того, чтобы показывать View только то, что необходимо для отображения UI
  2. @Published нужен для реактивной передачи данных во View


Intent


Inent ждет событий от View для дальнейших действий. Работает с бизнес логикой и базами данных, делает запросы на сервер и т.д.

import SwiftUIimport Combineclass RootIntent: ObservableObject {    // 1    let model: RootModeling    // 2    private var rootModel: RootModel! { model as? RootModel }    // 3    private var cancellable: Set<AnyCancellable> = []    init() {        self.model = RootModel()  // 3        let modelCancellable = rootModel.objectWillChange.sink { self.objectWillChange.send() }        cancellable.insert(modelCancellable)    }}// MARK: - APIextension RootIntent {    // 4    func onAppear() {  rootModel.isLoading = true  rootModel.error = nil        let url: URL! = URL(string: "https://upload.wikimedia.org/wikipedia/commons/f/f4/Honeycrisp.jpg")        let task = URLSession.shared.dataTask(with: url) { [weak self] (data, _, error) in            guard let data = data, let image = UIImage(data: data) else {                DispatchQueue.main.async {       // 5                    self?.rootModel.error = error ?? NSError()                    self?.rootModel.isLoading = false                }                return            }            DispatchQueue.main.async {   // 5                self?.model.image = image                self?.model.isLoading = false            }        }        task.resume()    }} 


  1. Intent содержит в себе ссылку на Model, и когда это необходимо, меняет данные у Model. RootModelIng это протокол, который показывает атрибуты Model и не дает их менять
  2. Для того, чтобы изменить атрибуты в Intent, мы преобразуем RootModelProperties в RootModel
  3. Intent постоянно ждет изменения атрибутов у Model и передает их View. AnyCancellable позволяет не держать в памяти ссылку на ожидание изменений от Model. Таким нехитрым способом View получает самое актуальное состояние
  4. Эта функция получает событие от пользователя и скачивает картинку
  5. Так мы меняем состояние экрана


У этого подхода (менять состояния по очереди) есть недостаток: если атрибутов у Model много, то при смене атрибутов можно что-то забыть поменять.

Одно из возможных решений
protocol RootModeling {    var image: UIImage? { get }    var isLoading: Bool { get }    var error: Error? { get }}class RootModel: ObservableObject, RootModeling {    enum StateType {        case loading, show(image: UIImage), failLoad(error: Error)    }    @Published private(set) var image: UIImage?    @Published private(set) var isLoading: Bool = true    @Published private(set) var error: Error?    func update(state: StateType) {        switch state {        case .loading:            isLoading = true            error = nil            image = nil        case .show(let image):            self.image = image            isLoading = false        case .failLoad(let error):            self.error = error            isLoading = false        }    }}// MARK: - APIextension RootIntent {    func onAppear() {   rootModel?.update(state: .loading)... 


Верю, что это не единственное решение и можно решить проблему другими способами.

Есть еще один недостаток класс Intent может сильно вырасти при большом количестве бизнес логики. Это проблема решается разбиением бизнес логики на сервисы.

А что с навигацией? MVI+R


Если удается все делать во View, то проблем, скорее всего, не будет. Но если логика усложняется, возникает ряд трудностей. Как оказалось, сделать Router с передачей данных на следующий экран и возвратом данных обратно во View, который вызвал этот экран, не так-то просто. Передачу данных можно сделать через @EnvironmentObject, но тогда доступ к этим данным будут у всех View ниже иерархии, что нехорошо. От этой идеи отказываемся. Так как состояния экрана меняются через Model, обращение к Router делаем через эту сущность.

protocol RootModeling {    var image: UIImage? { get }    var isLoading: Bool { get }    var error: Error? { get }    // 1    var routerSubject: PassthroughSubject<RootRouter.ScreenType, Never> { get }}class RootModel: ObservableObject, RootModeling {    // 1    let routerSubject = PassthroughSubject<RootRouter.ScreenType, Never>() 

  1. Точка входа. Через этот атрибут будем обращаться к Router


Чтобы не засорять основной View, все, что касается переходов на другие экраны, выносим отдельным View

 struct RootView: View {    @ObservedObject private var intent: RootIntent    var body: some View {        ZStack {            imageView()   // 2                .onTapGesture(perform: intent.onTapImage)            errorView()            loadView()        }  // 1        .overlay(RootRouter(screen: intent.model.routerSubject))        .onAppear(perform: intent.onAppear)    }} 

  1. Отдельный View, в котором находится вся логика и Custom elements, относящиеся к навигации
  2. Передает событие цикла жизни View в Intent


Intent собирает все необходимые данные для перехода

// MARK: - APIextension RootIntent {    func onTapImage() {        guard let image = rootModel?.image else {      // 1            rootModel?.routerSubject.send(.alert(title: "Error", message: "Failed to open the screen"))            return        }        // 2        model.routerSubject.send(.descriptionImage(image: image))    }} 

  1. Если по каким-либо причинам картинки нет, тогда передает все необходимые данные в Model для показа ошибки
  2. Передает необходимые данные в Model для открытия экрана с подробным описанием картинки


import SwiftUIimport Combinestruct RootRouter: View {    // 1    enum ScreenType {        case alert(title: String, message: String)        case descriptionImage(image: UIImage)    }    // 2    let screen: PassthroughSubject<ScreenType, Never>    // 3    @State private var screenType: ScreenType? = nil    // 4    @State private var isFullImageVisible = false    @State private var isAlertVisible = false    var body: some View {  Group {            alertView()            descriptionImageView()        }  // 2        .onReceive(screen, perform: { type in            self.screenType = type            switch type {            case .alert:                self.isAlertVisible = true            case .descriptionImage:                self.isFullImageVisible = true            }        }).overlay(screens())    }    private func alertView() -> some View {  // 3        guard let type = screenType, case .alert(let title, let message) = type else {            return EmptyView().toAnyView()        }          // 4        return Spacer().alert(isPresented: $isAlertVisible, content: {            Alert(title: Text(title), message: Text(message))        }).toAnyView()    }    private func descriptionImageView() -> some View {  // 3        guard let type = screenType, case .descriptionImage(let image) = type else {            return EmptyView().toAnyView()        }        // 4        return Spacer().sheet(isPresented: $isFullImageVisible, onDismiss: {            self.screenType = nil        }, content: {            DescriptionImageView.build(image: image)        }).toAnyView()    }}

  1. Enum с необходимыми данными для экранов
  2. Через этот атрибут будут передаваться события. По событиям мы будем понимать, какой экран надо показывать
  3. Это атрибут нужен для хранения данных для открытия экрана
  4. Меняем с false на true и нужный экран открывается


Заключение


SwiftUI так же, как и MVI, построен на реактивности, поэтому они хорошо подходят друг другу. Есть сложности с навигацией и большим Intent при сложной логике, но все решаемо. MVI позволяет реализовывать сложные экраны и с минимальными усилиями, очень динамично менять состояние экрана. Эта реализация, конечно, не единственно верная, всегда существуют альтернативы. Однако паттерн прекрасно ложится на новый подход к UI от Apple. Один класс для всех состояний экрана значительно упрощает работу с экраном.
Код из статьи можно посмотреть в GitHub
Подробнее..

Работа с толстофичами как разобрать слона на части и собрать обратно

28.12.2020 10:22:36 | Автор: admin

Как часто в разработке функциональности мобильного приложения вы сталкиваетесь со слонами? Большими и неповоротливыми, но настолько умными и полезными для пользователей, что игнорировать их просто невозможно. Хотя бы из-за их размера.


Казалось бы, еще месяц-другой назад это был такой простой в обращении питомец, но теперь приходится быть особенно осторожным, чтобы не превратить кодовую базу в груду раздавленных осколков. В этой статье я предлагаю вам отправиться в продуктово-техническое сафари. Мы возьмем одного такого слона из соискательского приложения hh и попробуем обучить его балетной гибкости. Шаг за шагом, фрагмент за фрагментом.



Цель статьи: поделиться нашим текущим опытом на тернистом пути к безболезненному росту и переиспользованию фич приложения, а так же рассмотреть и опробовать на реальном кейсе общий принцип к проектированию архитектуры, описанный в докладе The immense benefits of not thinking in screens.


Под словом фича будем понимать неделимую единицу функциональности приложения.
Делить функциональность на фичи мы хотели бы так, чтобы сохранить общий язык с бизнесом и конечными пользователями. Так мы сможем развивать техническую сторону приложения, исходя из логики развития продукта.


В hh мы крепко подсели на MVICore для управления состоянием наших фич. По именованию сущностей в этой статье может показаться, что статья имеет смысл только в контексте MVI, но на самом деле мы не будем затрагивать внутренние особенности этого паттерна. Вместо этого мы воспользуемся общим принципом построения архитектуры на основе black box компонентов.


Black box компоненты


Перед тем, как перейти к практическому кейсу, разберем, что такое black box компоненты. Основы этой концепции отлично описаны в статьях от коллег из Badoo (особенно в этой).


В общем виде black box это система, внутреннее устройство которой неизвестно за ее пределами. Все, что мы знаем о black box, это то, что с ним можно взаимодействовать через некоторый известный Input и наблюдать его реакции через Output.


Рассмотрим более частный вариант black box, который мы будем называть Feature, и при помощи которого будем моделировать функциональность приложения.


Схема Feature


Wish это внешние команды для фичи. Можно думать о них как о просьбах сделать фичу то, для чего она предназначена. Wish может вызвать у фичи ответную реакцию двух типов:


  • Изменить свое текущее состояние (State)
  • Отправить одно или несколько событий (News)

Приведем пример: представьте фичу форма заявки от пользователя в виде black box. Форма может содержать правила валидации, автозамены, предзаполнения etc., но всё это является внутренним устройством фичи, которое не имеет значения в контексте интеграции с другими компонентами приложениями. Для пользователей формы Wish-ами станут события ввода данных, State текущее содержимое полей формы, а News ошибки заполнения формы, которые, например, будут нарисованы на UI одноразовыми сообщениями в Snackbar.


Схема разницы между State и News


В терминах реактивных фреймворков (RxJava, Reaktive) Feature реализует интерфейсы Consumer<Wish>, ObservableSource<State> и ObservableSource<News>.


class Feature : Consumer<Wish>, ObservableSource<State> {    override fun accept(wish: Wish) { ... }    override fun subscribe(observer: Observer<State>) { ... }    val news = ObservableSource<News> { ... }}

Мы можем подписывать Consumer<B> на ObservableSource<A>, если нам известно преобразование между типами (A) -> B. Это позволяет нам научить наши фичи общаться друг с другом. Так мы получаем инструмент для создания систем реактивных компонентов, который попробуем применить для описания фич приложения и их взаимодействия друг с другом.


Толстофича и ее проблемы


Перейдем к конкретному примеру. В приложении hh есть экран со списками откликов на вакансии (negotiations). На этом экране пользователь должен видеть различные списки сделанных им откликов, разбитых по статусам. Каждый список поддерживает пагинацию, обновление по свайпу, показ дополнительных сообщений и многое другое.



Скриншоты специально подобраны так, чтобы затронуть как можно больше функциональных особенностей этого экрана.


Давайте попробуем подумать об этом экране, как об изолированном black box компоненте.


Чтобы однозначно описать фичу в виде black box, достаточно описать 3 типа Wish, State и News. Если попробовать описать типы "в лоб" и проектировать экран при помощи подхода "фича == экран" (как мы и сделали изначально), то получится нечто подобное:


Black box экрана откликов
State экрана

Wish экрана

News экрана

Вот несколько наблюдений, которые можно сделать по этим сниппетам:


  • Фича имеет довольно много "внешних ручек" (Wish). При обработке такого количества вариантов Wish в when-выражении мы регулярно сталкиваемся с ворнингами статического анализатора о том, что метод слишком сложный. У фичи слишком сложный внешний интерфейс.
  • Как на уровне состояния фичи, так и на уровне ее Wish и News можно выделить отдельные ответственности экрана. И есть предположение, что разные Wish независимо друг от друга влияют на разные части состояния. Явный намек на то, что плоская иерархия Wish и полей в State может быть как-то сгруппирована.
  • Из-за настолько объёмного контракта фичи практически невозможно отделить специфичные функциональные особенности экрана откликов от более общих, которые мы бы хотели использовать на других экранах. Например, логику показа баннера с рекомендацией ("Откликайтесь чаще!") можно добавить и в другие списки внутри приложения, но она уже привязана к логике экрана откликов. Низкая переиспользуемость.
  • Если приоткрыть ящик Пандоры дверцу black box-а и посмотреть на детали внутренней реализации фичи, то мы найдём там ещё одну sealed-иерархию из множества data-классов, предназначенных для изменения состояния фичи (Effect-ы в терминах MVICore), и набор сущностей, специфичных для библиотеки MVICore: Actor, Reducer, Bootstraper, PostProcessor, реализация которых может насчитывать несколько сотен строк. Я не буду вдаваться в подробности о том, что именно происходит внутри этих сущностей, просто отмечу сложность и масштабы содержимого black box, который мы получили с таким подходом. Сложная реализация.

Кроме того:


  • В контексте интеграции фичи с остальным приложением неважно, насколько сложно фича устроена внутри, если рассматривать её как "чёрный ящик", потому что есть чёткий контракт входов и выходов. С другой стороны, если вся логика работы экрана сосредоточена внутри одного black box, то при добавлении новой функциональности придётся заново изучать все детали его реализации. Только так мы сможем гарантировать, что новый код будет правильно дружить с написанным ранее. Сложная поддержка.
  • Ещё отмечу, что хоть зачастую мы и можем поделить экран на набор обособленных кусочков функциональности, не всегда получается сделать их полностью независимыми друг от друга. Например, на приведенном выше экране откликов есть панель с табами-статусами. У каждого таба есть счетчик непрочитанных откликов, который периодически обновляется. По бизнес-правилам экрана если счётчик при обновлении получит новое значение, необходимо выполнить обновление соответствующего списка откликов. При использовании подхода "фича = экран" такие бизнес-правила (которые описывают связи между разными кусочками функциональности) будут растворяться во внутренней реализации фичи, и порой тяжело узнать об их существовании, не говоря о том, чтобы чётко сформулировать. Неочевидно как одна функциональность может затрагивать другую.

Декомпозиция фичи списка откликов


Но что будет, если мы попробуем разбить один большой "чёрный ящик" на несколько маленьких, разделяя их по функциональности?


Давайте пройдемся по всем функциональным особенностям экрана откликов, которые мы можем увидеть на скриншотах сверху вниз. Сначала попробуем выделить те функциональные черты, которые присущи экрану на уровне самого верхнего UI-контейнера, а затем будем спускаться вниз по иерархии визуальных компонентов. Несмотря на то, что мы сейчас пытаемся декомпозировать в первую очередь бизнес-логику экрана, а не его UI, бизнес-правила зачастую проецируются на конкретные визуальные компоненты. Далее по пунктам рассмотрим какую функциональность мы выделили в отдельные фичи.


Авторизация



Экран списка откликов (и некоторые другие экраны приложения) доступны как авторизованным, так и неавторизованным пользователям. Если пользователь не авторизован, логика экрана предельно простая: мы показываем заглушку, которая предлагает пройти авторизацию.


Мы не хотим, чтобы остальные фичи экрана знали о том, в авторизованной они зоне, или нет. Мы хотим вообще избавить их от необходимости подписываться на состояние авторизации. Более того, схожая логика по контролю авторизации присутствует и на других экранах. Если мы сможем выделить переиспользуемый юнит для этой логики и в контексте других фич не зависеть от него напрямую, то почему бы этого не сделать?


Получился black box авторизации с простейшим контрактом:


AuthFeature

У AuthFeature нет ни одного Wish, так как эта фича самостоятельно подписывается на источник информации о смене активного пользователя. Если нам потребуется знать больше информации о текущем пользователе, помимо индикатора авторизации, мы расширим состояние и внутреннюю логику именно этой фичи.


Статусы откликов



Статусы откликов визуально представлены как панель с табами. Помимо отображения счётчиков непросмотренных изменений, каждый таб является фильтром по статусу откликов. Мы смоделировали эту фичу следующим образом:


StatusFeature

Фича описывает выбранный пользователем статус (NegotiationStatusPage) и значения счётчиков для каждого статуса. При изменении значений счётчиков или выборе нового статуса мы должны сообщать об этом наружу, чтобы узнать о необходимости обновления соответствующего списка откликов. Мы описали это через News, так как снаружи нас интересует единоразовая реакция на событие перехода между конкретными состояниями.


Рекомендация Откликайтесь чаще



Баннер "Откликайтесь чаще" по бизнесовым правилам должен отображаться только если среди статусов откликов выбран фильтр "Все". При этом, если пользователь скроет баннер нажатием на крестик, данное сообщение больше не должно всплывать.


RecommendationFeature

Wish CheckVisibility и ClearVisibility нужны для проверки состояния и сброса флага о показе баннера при смене состояния авторизации.


Помните я говорил, что не хотелось бы, чтобы другие фичи экрана что-то знали про авторизацию? На самом деле мы можем избавиться от этих двух Wish, если не создавать экземпляр фичи до логина пользователя и уничтожать её сразу после логаута. Но к этой теме мы вернёмся позже, когда закончим декомпозицию фич и перейдем к их связыванию, заодно обсудим, причём тут декларативный UI.


Список откликов с пагинацией



То, ради чего и создавался экран отображение списка откликов. Это типичный списочный компонент с пагинацией, завязанной на скролл. Пагинация не самая тривиальная фича, поэтому реализовывать её на каждом конкретном экране с нуля не очень хочется, учитывая количество возможных краевых случаев. Поэтому мы решили выделить её в отдельный black box, параметризованный типом элементов списка и функцией для подгрузки очередной страницы:


PaginationFeature

Статистика работодателя



Каждый элемент списка откликов содержит индикатор индекса вежливости работодателя (отображает процент просмотренных работодателем откликов). По клику на этот индикатор нужно развернуть более подробное описание статистики.


EmployerStatsFeature

Действия с откликами



Соискатель может удалить свой отклик, и опционально, он может сделать это с отказом. Для изоляции логики действий с откликами мы решили завести отдельную фичу, которая через News может сообщить о результатах удаления. В случае успеха мы обновим список с этим откликом, в случае ошибки покажем Snackbar.


NegotiationActionsFeature



Некоторые фичи (например, статистика работодателя EmployerStatsFeature), получились очень простыми и могут показаться излишними, ведь они управляют всего лишь UI-логикой отображения элементов списка. Однако, сформулировать чёткие критерии и границы, где логика перестаёт быть UI-only и превращается в чистую бизнес-логику, довольно сложно. На выходе мы хотели получить набор однородных компонентов, которые будут отделены друг от друга не по границам искусственно введённых слоёв, а по границам той функциональности, за которую они отвечают.


Промежуточный итог декомпозиции


Итак, мы получили семейство "чёрных ящиков" (фич) экрана, которые пока что никак не связаны друг с другом. Прежде чем начать связывать их в коде, давайте построим диаграмму связей между фичами, которые диктуются нам бизнес-требованиями:



Черные прямоугольники это фичи экрана. Стрелками отмечено влияние на другие фичи. Красным описано, что произошло, а зеленым как отразится на другой фиче. Обратите внимание, что диаграмма отражает естественную сложность нашего экрана и не содержит искусственных терминов, навязанных кодом.


Я уже упоминал выше, что связи между событиями авторизации и реакциями других фич в стиле "Сбрасываем состояние" выглядят не очень красиво, и их можно избежать, если создавать фичи только в авторизованной зоне, гарантированно уничтожая их при логауте. Чтобы лучше раскрыть эту тему, следует взглянуть еще разок на экран откликов и оценить его уже с точки зрения иерархии фрагментов.



Корень иерархии фрагмент NegotiationsContainerFragment, который нужен для показа содержимого вкладки нижней навигации, а заодно в нем мы можем показать bottom sheet-диалог для действий с откликами. В контейнер кладётся фрагмент NegotiationPagerFragment, отображающий состояние экрана списка откликов в авторизованной и неавторизованной зоне, а заодно содержащий ViewPager для списков откликов. Списки откликов, разбитые по статусам, находятся в отдельных StatusPageFragment.


За каждым фрагментом мы закрепляем автоматически создаваемый DI-скоуп, в котором поддерживаем экземпляры наших фич. В идеале нужно было вынести экран-заглушку (который показывается в случае отсутствия авторизации) в отдельный фрагмент и не прикреплять фрагмент для авторизованной зоны (NegotiationsPagerFragment) до тех пор, пока фича авторизации не сообщит нам, что пользователь авторизовался. Тогда мы бы получили автоматическое пересоздание всех нужных фич при логине/логауте пользователя.


Однако в реалиях работы с фрагментами синхронизация состояния FragmentManager с состоянием бизнес-логики приложения не самая приятная задача и потенциальный источник багов, связанных с неконсистентностью отображения экрана. В рамках рефакторинга логики экрана меньше всего хотелось трогать уже существующий код отображения состояний на View. Поэтому пока что мы решили оставить уже имеющуюся иерархию фрагментов, что заставило нас добавить специальные Wish для сброса состояния фич из авторизованной зоны.


Фреймворки для декларативного UI должны решить проблему гибкого управления жизненным циклом экземпляров фич, так как изменив флажок в состоянии фичи, мы сможем автоматически перестроить иерархию UI-компонентов, а значит и создать/уничтожить привязанные к ним фичи.


Как связать фичи в коде



Наша цель прозрачно описать граф связей между фичами, чтобы он соответствовал построенной диаграмме бизнес-связей.


Каждая фича реализует интерфейсы Consumer<Wish>, ObservableSource<State>, ObservableSource<News>. И по сути, связывание фич это реактивные подписки с маппингом State/News одних фич в Wish для других фич.


Важное наблюдение. Black box очень общая концепция, которой можно описывать не только stateful бизнес-логику фич. Следовательно, и связывание можно делать не только между фичами, а между произвольными ObservableSource<A> и Consumer<B>. Например, UI тоже можно рассматривать как black box с контрактами Consumer<UiState> и ObservableSource<UiEvent>, а любые внешние события, которые происходят за рамками данного фрагмента, как ObservableSource<ExternalEvent>.


Для создания таких подписок мы использовали Binder из MVICore, но концептуально подход применим и для других фреймворков: можно использовать корутины, подписывать Rx-потоки напрямую или использовать обычные коллбэки. Главная идея описать связи между фичами в явном виде под нужды конкретного контекста использования, вытащив эти связи из внутренней реализации фич наружу.


По рекомендации от Badoo связывание фич удобно вынести в отдельный класс Bindings. Мы решили разделить связи между фичами экрана по структурным компонентам (в нашем случае это обычные фрагменты).


В DI-скоупе самого верхнего уровня (который привязан к жизненному циклу NegotiationsPagerFragment) создается экземпляр фичи авторизации (AuthFeature), действий с откликами (NegotiationActionsFeature) и фича статусов откликов (StatusFeature). Также на этом уровне мы слушаем внешние события, которые могут произойти на других экранах приложения и повлиять на состояния этих фич.


NegotiationsPagerBindings

Binder автоматически выполнит отписку связей между фичами, когда переданный ему жизненный цикл (объект Lifecycle) перейдет в уничтоженное состояние. Отмечу, что здесь мы используем два lifecycle. featureLifecycle соответствует жизненному циклу DI-скоупа, связанного с фрагментом, а viewLifecycle соответствует жизненному циклу View этого же фрагмента.


Трансформации между типами событий описываются несложными чистыми функциями, вот пример:


authToStatus, actionToStatus

Трансформации событий между фичами могут описывать бизнес-правила их взаимодействия. При необходимости эти правила можно легко протестировать.


На уровень ниже находится StatusPageFragment, в котором создается экземпляр фичи пагинации (PaginationFeature) и показа баннера (RecommendationFeature). При этом для каждой страницы ViewPager создаются свои экземпляры этих фич.


NegotiationsStatusPageBindings

Примеры трансформаций для этого уровня:


statusToNegotiationsList, externalEventsToNegotiationsList

Еще раз отмечу, что наши фичи не имеют ни одной прямой зависимости друг от друга и связываются только на уровне реактивных потоков извне.


Преимущества подхода



"Look around we live in a perfect world where everything fits together and no one gets hurt."
Homer Jay Simpson


  • Легко писать и тестировать небольшие изолированные фичи, в которых не смешаны разные ответственности. Тесты на фичи похожи на декларативную спецификацию бизнес-требований.
  • Возможность переиспользования фич на разных экранах в считанные минуты за счет отсутствия прямых зависимостей между ними. Достаточно создать экземпляр уже готовой фичи и подключить его входы и выходы к контексту структурного компонента, в котором она должна использоваться.
  • Связи между фичами перестают быть неявными, сосредотачиваются в одном месте, а само по себе связывание становится отдельной ответственностью, которую можно контролировать.

Пример теста на фичу для списков с пагинацией

Проблемы подхода


Binding hell: связи между фичами могут выходить из-под контроля


Один из вариантов решения проблемы делать иерархию структурных компонентов менее плоской. Под структурными компонентами здесь мы понимаем сущности типа Fragments, RIBs, Controller из Conductor и т.п., в контексте которых мы связываем фичи.


Можно ввести иерархию потоков данных между структурными компонентами и стараться упрощать их внутреннее устройство. Это не избавляет нас от большого количества связей, но помогает в них ориентироваться. Подробнее про эту идею можно почитать в одном из туториалов по RIBs.



Нарушение целостности системы


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


Приведу пример. Допустим, у нас есть фича, которая управляет состоянием вакансии, и отдельная фича для управления процессом отклика на вакансию (отклик это самостоятельная процедура, включающая несколько этапов). Один из инвариантов, который мы хотим поддержать между этими фичами, заключается в том, что если процесс отклика перешёл в состояние "отклик сделан", то вакансия должна иметь состояние "текущий пользователь откликался". Фиче вакансии необходимо знание о наличии отклика, чтобы открывать пользователю дополнительные действия.



Таким образом, разделив состояние связанных друг с другом фич на отдельные сущности, мы создали возможность нарушения инварианта в нашей реактивной системе, пусть и на очень малый отрезок времени. Когда процесс отклика перейдет в конечное состояние, фича вакансии может еще не успеть среагировать на этот факт в силу чисто технических причин (вроде затрат времени на переключение потоков). И не исключено, что Consumer состояния вакансии успеет сделать что-то невалидное, предполагая, что отклика на вакансию всё ещё нет.


На практике это свойство систем с распределенным состоянием не всегда является критичным. Например, если "очень малый отрезок времени" несогласованности данных не ломает дальнейшую бизнес логику. Или если он влияет только на UI, но происходит в пределах отрисовки одного кадра и не вызывает резких мерцаний. Тем не менее, всегда стоит иметь это в виду и ознакомиться с опытом коллег из других областей разработки. Один из способов частично избежать проблем с несогласованностью данных при их расщеплении проектировать распределенные состояния так, как если бы мы проектировали реляционную базу данных, стараясь избегать дублирования и противоречивости в модели данных.


Однако если два состояния должны поддерживать между собой большое количество инвариантов, это признак того, что будет лучше контролировать их в рамках одной фичи. В таких случаях могут помочь подходы к композиции фич в рамках одного состояния.


Заключение и альтернативные способы композиции фич


В этой статье я рассказал про наш боевой тест-драйв подхода к изоляции фич в реактивные black box компоненты. Неизбежным последствием стало расщепление состояния между компонентами и их связывание с использованием реактивного фреймворка. Основное преимущество, которое мы получили облегчили переиспользование и тестирование фич. Также мы получили возможность масштабировать сложные фичи, конструируя их из простых компонентов. В нашем случае мы больше всего были рады тому, что теперь у нас есть универсальный и хорошо протестированный компонент для работы со списочной пагинацией, для подключения которого не нужно затрагивать реализацию других компонентов экрана. Будем продолжать экспериментировать с этим подходом и для других фич приложения.


Как я отметил в предыдущем разделе, фичи можно композировать в рамках одного состояния. Например, при использовании подходов с централизованным управлением состоянием (в духе TEA или Redux) композиция логики может быть реализована на уровне чистых функций. Главный плюс подхода к композиции на уровне единого хранилища состояния сохранение возможности более консистентного контроля состояния и отсутствие сильной зависимости от реактивных фреймворков. Но для подробного описания композиции с единым состоянием, кажется, потребуется отдельная большая статья.


Дабы избежать холиваров, сразу отмечу, что целью статьи является не сравнение различных подходов в стиле лучше/хуже, а лишь практическая демонстрация особенностей подхода к декомпозиции составных фич на реактивную систему black box компонентов. Выбор подходящего инструмента под нужды конкретно вашего проекта is your own. Однако нам всегда интересно поделиться с вами нашим опытом.


Отдельное спасибо за помощь при подготовке текста статьи: Ztrel, alaershov, Xanderblinov

Подробнее..

Из песочницы Так для чего же нам все таки нужен MVI в мобильной разработке

05.09.2020 22:11:28 | Автор: admin
Много уже сказано про MVI, о том как его правильно прожарить и настроить. Однако не так много времени уделяется тому, насколько этот метод упрощает жизнь в определенных ситуациях, в сравнении с остальными подходами.

Цель этой статьи


Я не буду углубляться в то как технически реализуется MVI (способов больше одного и у каждого есть свои плюсы и минусы). Моя главная цель в короткой статье заинтересовать тебя изучать эту тему в дальнейшем и возможно побудить внедрить данный паттерн на своих боевых проектах или хотя бы проверить на домашних заготовках.

С какой проблемой можно столкнуться


Мой дорогой друг, давай представим такую ситуацию, у нас имеется интерфейс вью, с которым
предстоит работать:

interface ComplexView {    fun showLoading()      fun hideLoading()      fun showBanner()       fun hideBanner()       fun dataLoaded(names: List<String>)       fun showTakeCreditDialog()   fun hideTakeCreditDialog()}

На первый взгляд кажется, что ничего сложного. Ты просто выделяешь для работы с этой вьюшкой отдельную сущность, называешь ее презентером (вуаля вот и MVP готов), а это большие проблемы небольшие сложности и сейчас я постараюсь объяснить почему.

А вот и сам презентер:

interface Presenter {     fun onLoadData(dataKey: String)       fun onLoadCredit()}

Все просто, вьюшка дергает методы презентера, когда надо загрузить данные, презентер в свою очередь имеет право дергать вьюшку, для того, чтобы отобразить загруженную информацию, а также отобразить прогресс. Но тут и появляется проблема сложность это абсолютное отсутствие контроля консистентности твоего UI мой товарищ.

К примеру мы хотим вывести диалог предлагающий кредит выгодное предложение пользователю и делаем этот вызов из презентера, имея на руках ссылку на интерфейс вью:

view.hideTakeCreditDialog()


Но при этом тебе не следует забывать, что при отображении диалога, нужно скрывать лоадинг и не показывать его пока у тебя имеется диалог на экране. Плюс к этому есть метод, показывающий баннер, который мы не должны вызывать пока у нас отображается диалог (либо закрывать диалог и уже после этого отображать баннер, все зависит от требований). Картина у тебя складывается такая.

Тебе ни в коем случае нельзя вызывать:

view.showBanner()


view.showLoading()


Пока идет показ диалога. Иначе будут плакать котики тестировщики и пользователи, от боли в глазах от одного взгляда на баннер поверх важного диалога c кредитом выгодным предложением.

А сейчас давай еще подумаем с тобой и предположим, что все таки захотелось показать баннер (такое уж требование от бизнеса). О чем же надо помнить?
Дело в том, что при вызове сего метода:

view.showBanner()


Обязательно надо вызывать:

view.hideLoading()


view.hideTakeCreditDialog()


Опять же для того, чтобы у нас на экране ничего не скакало поверх остальных юайных элементов, пресловутая консистентность.

Вот и всплывает вопрос, кто ударит тебя по рукам, если сделаешь что-то не так? Ответ прост НИКТО. В такой реализации у тебя нет абсолютно никакого контроля.

Возможно в будущем понадобится добавить еще какую-либо функциональность во вью, которая также будет связано с тем, что уже есть. Какие минусы мы из этого получаем?

  1. Лапша из зависимостей состояний юайных элементов
  2. Логика переходов из одного состояния отображения в другое будет размазана по
    презентеру
  3. Довольно тяжело добавлять новое состояние экрана, так как велик риск того, что
    забудешь что-то скрыть перед тем как отобразить новый баннер или диалог

И это мы с тобой разбирали кейс когда во вью всего 7 методов. И даже тут получилось встрять на проблемы.

А ведь бывают вот такие вью:

interface ChatView : IView<ChatPresenter> {    fun setMessage(message: String)        fun showFullScreenProgressBar()        fun updateExistingMessage(model: ChatMessageModel)        fun hideFullScreenProgressBar()        fun addNewMessage(localMessage: ChatMessageModel)        fun showErrorFromLoading(message: String)           fun moveChatToStart()        fun containsMessage(message: ChatMessageModel): Boolean        fun getChatMessagesSize(): Int    fun getLastMessage(): ChatMessageModel?        fun updateMessageStatus(messageId: String, status: ChatMessageStatus)        fun setAutoLoading(autoLoadingEnabled: Boolean)    fun initImageInChat(needImageInChat: Boolean)        fun enableNavigationButton()       fun hideKeyboard()       fun scrollToFirstMessage()        fun setTitle(@StringRes titleRes: Int)        fun setVisibleSendingError(isVisible: Boolean)        fun removeMessage(localId: String)        fun setBottomPadding(hasPadding: Boolean)        fun initMessagesList(pageSize: Int)        fun showToast(@StringRes textRes: Int)       fun openMessageDialog(message: String)       fun showSuccessRating()        fun setRatingAvailability(isEnabled: Boolean)        fun showSuccessRatingWithResult(ratingValue: String)}

Добавлять сюда что-либо новое или править старое будет довольно тяжело, придется посмотреть, что и как связано между собой, а потом начать плакать работать и молиться, чтобы тестировщик ничего не пропустил. И в момент твоего отчаяния появляется он.

MVI



image

Вся суть


Суть в том, что мы имеем сущность, которая называется стейт. На основе этого стейта вьюшка будет рендерить свое отображение. Не буду углубляться, так моя задача пробудить в тебе интерес, так что сразу перейду к примерам. А в конце статьи будет список очень полезных источников, если тебя все же появится интерес.

Давай вспомним наше положение в начале статьи, у нас есть вью, на которой мы показываем диалоги, баннеры и волшебство. Опишем ка мы с тобой стейт вьюшки

data class UIState(       val loading: Boolean = false,       val names: List<String>? = null,           val isBannerShowing: Boolean = false,           val isCreditDialogShowing: Boolean = false)

Установим правило, мы с тобой можем менять вью только с помощью этого стейта, будет такой интерфейс:

interface ComplexView {    fun renderState(state: UIState)}

А сейчас установим еще одно правило. Мы можем обращаться к владельцу стейта (в нашем случае это будет презентер) только через одну точку входа. Путем отправления ему событий. Хорошая идея назавать эти события экшенами.

sealed class UIAction {           class LoadNamesAction(dataKey: String) : UIAction()           object LoadBannerAction : UIAction()           object LoadCreditDialogInfo : UIAction()}

Только не кидай в меня помидоры за силд классы, они упрощают жизнь в текущей ситуации, избавляя от дополнительных кастов при обрабокте экшенов в презентере, пример будет ниже. Интерфейс презентера будет выглядеть так:

interface Presenter {    fun processAction(action: UIAction)}

А теперь давай подумаем как связать все это дело:

fun processAction(action: UiAction): UIState {    return when (action) {            is UiAction.LoadNamesAction -> state.copy(                loading = true,                 isBannerShowing = false,                         isCreditDialogShowing = false    )           is UiAction.LoadBannerAction -> state.copy(                            loading = false,                           isBannerShowing = true,                            isCreditDialogShowing = false    )            is UiAction.LoadCreditDialogInfo -> state.copy(                    loading = false,                            isBannerShowing = false,                            isCreditDialogShowing = true    )      }}

Если ты обратил внимание, то перетекание из одного состояния отображения в другое сейчас происходит в одном месте и уже проще сложить в голове картину как все работает.

Это не стало супер просто, однако твоя жизнь должна стать легче. Плюс в моем примере этого не видно, но мы можем решать как запроцесить наш новый стейт на основе предыдущего стейта (для реализации такого также есть несколько придумок). Я уж не говорю о безумной возможности пере использования, которой добились ребята из badoo, одним из их помощников в достижении этой цели был MVI.

Однако не стоит рано радоваться, у всего в этом мире есть как плюсы так и минусы, а вот и они


  1. Нас ломает об ногу обычный показ toast
  2. При обновлении одного флажка весь стейт будет скопирован заново и отправлен во
    вью, то есть произойдет ненужная перерисовка, если ничего с этим не сделать

Предположим, что мы хотим вывести обычный андроидный toast, по текущей логике мы заведем в нашем стейте флаг для вывода нашего тостика.

data class UIState(       val showToast: Boolean = false,)

Первое


Берем и меняем стейт в презентере, ставим showToast = true и самое простое, что может произойти это поворот экрана. Все уничтожается взрывы и разрушения активити пересоздается, но так как ты крутой разработчик твой стейт все это дело переживает. А в стейте у нас волшебство флаг, который говорит отобразить toast. Результат toast показывается дважды. Для решения данной проблемы есть несколько способов и все выглядят как костыли. Опять же об этом будет написано в источниках, приложенных к этой статье.

Ну, а второе


Это уже проблема ненужных отрисовок во вью, которые будут происходить каждый раз даже когда в стейте меняется всего одно из полей. И эта проблема решается несколькими иногда не самыми красивыми способами (порой тупой проверкой перед тем как сетать во вью новое значение, на то, что оно отличается от предыдущего). Но с выходом compose в stable версию эта проблема будет решена, вот тогда мой друг заживем с тобой в преображенном и счастливом мире!

Время для плюсов:


  1. Одна точка входа во вью
  2. Мы всегда под рукой имеем текущее состояние экрана
  3. Еще на стадии реализации приходится продумывать как один стейт будет перетекать
    в другой и какая между ними связь
  4. Unidirectional Data Flow

Любите андроид и никогда не теряйте свою мотивацию!

Список моих вдохновителей



Подробнее..

Категории

Последние комментарии

  • Имя: Макс
    24.08.2022 | 11:28
    Я разраб в IT компании, работаю на арбитражную команду. Мы работаем с приламы и сайтами, при работе замечаются постоянные баны и лаги. Пацаны посоветовали сервис по анализу исходного кода,https://app Подробнее..
  • Имя: 9055410337
    20.08.2022 | 17:41
    поможем пишите в телеграм Подробнее..
  • Имя: sabbat
    17.08.2022 | 20:42
    Охренеть.. это просто шикарная статья, феноменально круто. Большое спасибо за разбор! Надеюсь как-нибудь с тобой связаться для обсуждений чего-либо) Подробнее..
  • Имя: Мария
    09.08.2022 | 14:44
    Добрый день. Если обладаете такой информацией, то подскажите, пожалуйста, где можно найти много-много материала по Yggdrasil и его уязвимостях для написания диплома? Благодарю. Подробнее..
© 2006-2024, personeltest.ru