Около года назад я заинтересовался новой технологией Kotlin Multiplatform. Она позволяет писать общий код и компилировать его под разные платформы, имея при этом доступ к их API. С тех пор я активно экспериментирую в этой области и продвигаю этот инструмент в нашей компании. Одним из результатов, например, является наша библиотека Reaktive — Reactive Extensions для Kotlin Multiplatform.

В приложениях Badoo и Bumble для разработки под Android мы используем архитектурный шаблон MVI (подробнее о нашей архитектуре читайте в статье Zsolt Kocsi: «Современная MVI-архитектура на базе Kotlin»). Работая над различными проектами, я стал большим поклонником этого подхода. Конечно, я не мог упустить возможность попробовать MVI и в Kotlin Multiplatform. Тем более случай был подходящий: нам нужно было написать примеры для библиотеки Reaktive. После этих моих экспериментов я был вдохновлён MVI ещё больше.

Я всегда обращаю внимание на то, как разработчики используют Kotlin Multiplatform и как они выстраивают архитектуру подобных проектов. По моим наблюдениям, среднестатистический разработчик Kotlin Multiplatform — это на самом деле Android-разработчик, который в своей работе использует шаблон MVVM просто потому, что так привык. Некоторые дополнительно применяют «чистую архитектуру». Однако, на мой взгляд, для Kotlin Multiplatform лучше всего подходит именно MVI, а «чистая архитектура» является ненужным усложнением.

Поэтому я решил написать эту серию из трёх статей на следующие темы:

  1. Краткое описание шаблона MVI, постановка задачи и создание общего модуля с использованием Kotlin Multiplatform.
  2. Интеграция общего модуля в iOS- и Android-приложения.
  3. Модульное и интеграционное тестирование.

Ниже — первая статья серии. Она будет интересна всем, кто уже использует или только планирует использовать Kotlin Multiplatform.

Сразу замечу, что целью этой статьи не является обучение навыкам работы с самим Kotlin Multiplatform. Если вы чувствуете, что в этой области у вас не достаточно знаний, рекомендую сначала ознакомиться с введением и документацией (особенно с разделами “Concurrency” и “Immutability”, чтобы понимать особенности модели памяти Kotlin/Native). Я не буду описывать в этой статье настройку проекта, модулей и прочих вещей, не относящихся к теме.

MVI


Для начала давайте вспомним, что такое MVI. Аббревиатура расшифровывается как Model-View-Intent. В системе есть всего два основных компонента:

  • модель (Model) — слой логики и данных (также модель хранит текущее состояние (state) системы);
  • представление (View) — UI-слой, отвечающий за отображение состояний (states) системы и выдачу намерений (intents).

Следующая диаграмма наверняка уже многим знакома:



Итак, мы видим те самые основные компоненты: модель и представление. Всё остальное — это данные, которые циркулируют между ними.

Нетрудно заметить, что данные перемещаются только в одном направлении. Состояния исходят из модели и попадают в представление для отображения, намерения исходят из представления и попадают в модель для обработки. Эта циркуляция называется однонаправленным потоком данных (Unidirectional Data Flow).

На практике модель часто представляется сущностью под названием Store (оно заимствовано из Redux). Однако это происходит далеко не всегда. Например, в нашей библиотеке MVICore модель имеет название Feature.

Стоит также отметить, что MVI очень тесно связан с реактивностью. Представление потоков данных и их преобразование, а также управление жизненными циклами подписок очень удобно осуществлять с использованием библиотек реактивного программирования. Сейчас доступно достаточно большое их количество, однако при написании общего кода в Kotlin Multiplatform мы можем использовать только мультиплатформенные библиотеки. Нам нужна абстракция для потоков данных, нужна возможность соединять и разъединять их входы и выходы, а также осуществлять преобразования. На данный момент мне известно две таких библиотеки:

  • наша библиотека Reaktive — реализация Reactive Extensions на Kotlin Multiplatform;
  • корутины и Flow — реализация холодных потоков (cold streams) при помощи Kotlin coroutines.

Постановка задачи


Цель этой статьи — показать, как использовать шаблон MVI в Kotlin Multiplatform и какие есть преимущества и недостатки у такого подхода. Поэтому я не стану привязываться к какой-либо конкретной реализации MVI. Однако я буду использовать Reaktive, поскольку потоки данных всё-таки нужны. При желании, поняв идею, Reaktive можно заменить на корутины и Flow. В целом я постараюсь сделать наш MVI как можно проще, без лишних усложнений.

Чтобы продемонстрировать MVI, я попробую реализовать максимально простой проект, удовлетворяющий следующим требованиям:

  • поддержка Android и iOS;
  • демонстрация асинхронной работы (ввод-вывод, обработка данных и т. д.);
  • как можно больше общего кода;
  • реализация UI нативными средствами каждой платформы;
  • отсутствие Rx на стороне платформ (чтобы не пришлось указывать зависимости на Rx как “api”).

В качестве примера я выбрал очень простое приложение: один экран с кнопкой, по нажатию на которую будет загружаться и отображаться список с произвольными изображениями котиков. Для загрузки изображений я буду использовать открытый API: https://thecatapi.com. Это позволит выполнить требование об асинхронной работе, так как придётся загружать списки из Сети и парсить JSON-файл.

Весь исходный код проекта вы можете найти на нашем GitHub.

Начало работы: абстракции для MVI


Сначала нам нужно ввести некоторые абстракции для нашего MVI. Нам понадобятся те самые базовые компоненты — модель и представление — и пара typealias-ов.

Typealiases


Для обработки намерений введём актор (Actor) — функцию, принимающую намерение и текущее состояние и возвращающую поток результатов (Effect):


Нам также понадобится редуктор (Reducer) — функция, которая принимает эффект и текущее состояние и возвращает новое состояние:


Store


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

Давайте введём соответствующий интерфейс:


Итак, наш Store обладает следующими свойствами:

  • имеет два generic-параметра: входной Intent и выходной State;
  • является потребителем намерений (Consumer<Intent>);
  • является потоком состояний (Observable<State>);
  • он разрушаемый (Disposable).

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



StoreHelper — это небольшой класс, который облегчит для нас процесс создания Store. Он обладает следующими свойствами:

  • имеет три generic-параметра: входные Intent и Effect и выходной State;
  • принимает через конструктор начальное состояние, актор и редуктор;
  • является потоком состояний;
  • разрушаемый (Disposable);
  • незамораживаемый (чтобы подписчики тоже не были заморожены);
  • реализует DisposableScope (интерфейс из Reaktive для управления подписками);
  • принимает и обрабатывает намерения и эффекты.

Посмотрите на диаграмму нашего Store. Актор и редуктор в нём являются деталями реализации:



Рассмотрим подробнее метод onIntent:

  • принимает намерения как аргумент;
  • вызывает актор и передаёт в него намерение и текущее состояние;
  • подписывается на поток эффектов, возвращённый актором;
  • направляет все эффекты в метод onEffect;
  • подписка на эффекты выполняется с использованием флага isThreadLocal (это позволяет избежать заморозки в Kotlin/Native).

Теперь рассмотрим подробнее метод onEffect:

  • принимает эффекты как аргумент;
  • вызывает редуктор и передаёт в него эффект и текущее состояние;
  • передаёт новое состояние в BehaviorSubject, что приводит к получению нового состояния всеми подписчиками.

View


Теперь займёмся представлением. Оно должно принимать модели для отображения и выдавать поток событий. Тоже сделаем отдельный интерфейс:



Представление обладает следующими свойствами:

  • имеет два generic-параметра: входной Model и выходной Event;
  • принимает модели для отображения с помощью метода render;
  • выдаёт поток событий с помощью свойства events.

Я добавил префикс Mvi в название MviView, чтобы избежать путаницы с Android View. Также я не стал расширять интерфейсы Consumer и Observable, а использовал просто свойство и метод. Это для того, чтобы можно было выставить интерфейс представления в платформу для реализации (Android или iOS) без экспортирования Rx как “api”-зависимости. Хитрость в том, что клиенты не будут напрямую взаимодействовать со свойством “events”, а будут реализовывать интерфейс MviView, расширяя абстрактный класс.

Сразу добавим этот абстрактный класс для представления:


Этот класс поможет нам с выдачей событий, а также избавит платформы от взаимодействия с Rx.

Вот диаграмма, на которой видно, как это будет работать:



Store выдаёт состояния, которые преобразовываются в модели и отображаются представлением. Последнее выдаёт события, которые преобразовываются в намерения и поступают в Store для обработки. Такой подход избавляет от связанности между Store и представлением. Но в простых случаях представление может работать с состояниями и намерениями напрямую.

Это всё, что нам понадобится для реализации MVI. Приступим к написанию общего кода.

Общий код


План


  1. Мы сделаем общий модуль, задачей которого будет загрузка и отображение списка изображений котиков.
  2. UI абстрагируем интерфейсом и будем передавать его реализацию снаружи.
  3. Скроем нашу реализацию за удобным фасадом.

KittenStore


Начнём с главного — создадим KittenStore, который будет загружать список изображений:



Мы расширили интерфейс Store, указав типы намерений и тип состояния. Обратите внимание: интерфейс объявлен как internal. Наш KittenStore — это детали реализации модуля. Намерение у нас только одно — Reload, оно вызывает загрузку списка изображений. А вот состояние стоит рассмотреть подробнее:

  • флаг isLoading показывает, происходит в данный момент загрузка или нет;
  • свойство data может принимать один из двух вариантов:
    • Images — список ссылок на изображения;
    • Error — означает, что произошла ошибка.

Теперь начнём реализацию. Мы будем это делать поэтапно. Для начала создадим пустой класс KittenStoreImpl, который будет реализовывать интерфейс KittenStore:



Мы также реализовали уже знакомый нам интерфейс DisposableScope. Это необходимо для удобного управления подписками.

Нам понадобится загружать список изображений из Сети и парсить JSON-файл. Объявим соответствующие зависимости:


Network будет загружать текст для JSON-файла из Сети, а Parser — парсить JSON-файл и возвращать список ссылок на изображения. В случае ошибки Maybe будут просто заканчиваться без результата. В рамках данной статьи тип ошибки нас не интересует.

Теперь объявим эффекты и редуктор:


Перед началом загрузки мы выдаём эффект LoadingStarted, что приводит к выставлению флага isLoading. После окончания загрузки мы выдаём либо LoadingFinished, либо LoadingFailed. В первом случае мы сбрасываем флаг isLoading и применяем список изображений, во втором — тоже сбрасываем флаг и применяем состояние ошибки. Обратите внимание на то, что эффекты — это приватный API нашего KittenStore.

Теперь реализуем саму загрузку:


Тут стоит обратить внимание на то, что мы передали Network и Parser в функцию reload, несмотря на то, что они нам и так доступны как свойства из конструктора. Это сделано для того, чтобы избежать ссылок на this и, как следствие, заморозки всего KittenStore.

Ну и наконец используем StoreHelper и закончим реализацию KittenStore:


Наш KittenStore готов! Переходим к представлению.

KittenView


Объявим следующий интерфейс:


Мы объявили модель представления с флагами загрузки и ошибки и списком ссылок на изображения. Событие у нас всего одно — RefreshTriggered. Оно выдаётся каждый раз, когда пользователь вызывает обновление. KittenView — это публичный API нашего модуля.

KittenDataSource


Задачей этого источника данных будет загрузка текста для JSON-файла из Сети. Как обычно, объявим интерфейс:


Реализации источника данных будут сделаны для каждой платформы отдельно. Поэтому мы можем объявить фабричный метод, используя expect/actual:


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

Интеграция


Заключительный этап — интеграция всех компонентов.

Реализация интерфейса Network:



Реализация интерфейса Parser:


Здесь мы использовали библиотеку kotlinx.serialization. Парсинг выполняется на computation-планировщике во избежание блокировки главного потока.

Преобразование состояния в модель представления:


Преобразование событий в намерения:


Подготовка фасада:


Знакомый многим Android-разработчикам жизненный цикл. Он отлично подходит и для iOS, и даже для JavaScript. Диаграмма перехода между состояниями жизненного цикла нашего фасада выглядит так:


Поясню вкратце, что здесь происходит:

  • первым делом вызывается метод onCreate, после него — onViewCreated и затем — onStart: это переводит фасад в рабочее состояние (started);
  • в какой-то момент после этого вызывается метод onStop: это переводит фасад в остановленное состояние (stopped);
  • в остановленном состоянии может быть вызван один из двух методов: onStart или onViewDestroyed, то есть либо фасад может быть снова запущен, либо его представление может быть уничтожено;
  • когда представление уничтожено, либо оно может быть создано снова (onViewCreated), либо весь фасад может быть разрушен (onDestroy).

Реализация фасада может выглядеть следующим образом:


Как это работает:

  • сначала мы создаём экземпляр KittenStore;
  • в методе onViewCreated мы запоминаем ссылку на KittenView;
  • в onStart подписываем KittenStore и KittenView друг на друга;
  • в onStop зеркально отписываем их друг от друга;
  • в onViewDestroyed очищаем ссылку на представление;
  • в onDestroy уничтожаем KittenStore.

Заключение


Это была первая статья из моей серии про MVI в Kotlin Multiplatform. В ней мы:

  • вспомнили, что такое MVI и как он работает;
  • сделали простейшую реализацию MVI на Kotlin Multiplatform с использованием библиотеки Reaktive;
  • создали общий модуль для загрузки списка изображений с применением MVI.

Отметим наиболее важные свойства нашего общего модуля:

  • нам удалось вынести в мультиплатформенный модуль весь код за исключением кода UI; вся логика, плюс связи и преобразования между логикой и UI — общие;
  • логика и UI совершенно не связаны между собой;
  • реализация UI очень простая: необходимо только отображать входящие модели представления и выдавать события;
  • интеграция модуля тоже простая: всё, что нужно, — это:
    • реализовать интерфейс (протокол) KittenView;
    • создать экземпляр KittenComponent;
    • вызывать его методы жизненного цикла в нужный момент;
  • такой подход позволяет избежать «протекания» Rx (или корутин) в платформы, а это значит, что нам не придётся управлять какими-либо подписками на уровне приложений;
  • все важные классы абстрагированы интерфейсами и тестируемы.

В следующей части я покажу на практике, как выглядит интеграция KittenComponent в iOS- и Android-приложения.

Подписывайтесь на меня в Twitter и оставайтесь на связи!