Pull to refresh

Статья 3: Из чего готовят MVI

Level of difficultyMedium
Reading time12 min
Views207

Оригиналы этих постов можно почитать в тг канале НеКрутой Архитектор
Там набирается материал для будущих статей с сильным опережением

План:

⚓️ Парадигма Реактивное программирование (Reactive programming)

Когда вещи названы правильно, их суть становится куда понятнее

Например Presenter(Ведущий). Одно слово, а роль в системе уже ясна

Реактивное движение — мы что-то бросаем в одну сторону, а сами движемся в другую, по сути, отталкиваясь от брошенного
Отдача у ружья – тот же самый эффект

Реактивное программирование — парадигма программирования, ориентированная на потоки данных и распространение событий

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

Пример в коде
interface Loader {
    fun load(): Observable
}

fun main() {
    ...
    val observable = loader.load()
    observable.subscribe { text -> // Подписались
        println(text) // Реагируем
    }
}

Когда load передает строку, мы выводим ее
Пока все просто

Но почему именно Реактивное?
Почему не Программирование цепных реакций?

Цепная ядерная реакция — это последовательность реакций, где каждая вызвана частицей из предыдущей

Очень похоже, правда?

Нет.

В предыдущем примере мы кое-что упустили
В паттерне Наблюдатель, мы сначала получаем наблюдателя, подписываемся, и только потом вызываем load, который и начинает генерировать события
А в нашем примере мы сразу вызываем load и получаем объект для подписки в ответ

Чувствуешь разницу?

Давайте вспомним, как устроен Наблюдатель, и попробуем сделать так, чтобы load возвращал нам его

class Observable {
    var obs: (T) -> Unit = {}
    fun subscribe(observer: (T) -> Unit) { obs = observer }
    fun update(value: T) = obs(value)
}

class Loader {
    fun load(): Observable {
        val obs = Observable()
        obs.update("Загрузка")
        // работа
        obs.update("Готово")
        return obs
    }
}

fun main() {
    ...
    val observable = Loader().load() // load уже отработал!
    observable.subscribe { text -> // Подписались слишком поздно
      println(text)
    }
}

Такой код работает не корректно, потому, что load сначала выполнит работу,
и только потом вернет Observable
К моменту подписки мы уже все пропустим

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

Давай напишем свой, очень простой класс,
который будет решать именно эту задачу
Назовем его Flow

Этого должно быть достаточно для базового понимания
typealias Observer = (T) -> Unit // Просто переименование типа
typealias Work = (Observer) -> Unit

class Flow(val work: Work) {
    fun subscribe(observer: Observer) = work(observer)
}

class Loader {
    fun load(): Flow {
        return Flow(
            work = { observer ->
                observer("Загрузка")
                // работа
                observer("Готово")
            }
        )
    }
}

При вызове load, мы не запускаем саму загрузку, а лишь оборачиваем нашу работу в объект Flow

А выполняться эта работа начнет только после того, как мы вызовем subscribe на этом Flow (потоке данных/событий)

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

Существуют и горячие потоки – они больше похожи на радиостанцию: вещают постоянно
Подключился – получаешь данные с текущего момента

Так что же такое Реактивное программирование?
Вспомним оружие: чтобы произошел выстрел и отдача, оружие сначала нужно зарядить
Вот и мы сначала создаем и настраиваем все потоки, как будто прокладываем систему труб
И только когда мы вызываем subscribe, событие подписки уходит вперед по потоку, запуская работу и создавая реактивное движение событий/данных назад, к нашему подписчику

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

Во многих языках существуют библиотеки для реактивного программирования, например семейство Rx*
В Kotlin, например, стандартным решением является Flow

P.S.: У каждой библиотеки есть свои особенности и тонкости, которые можно изучить в интернете. Моей же целью было донести саму суть этого подхода

🌯 Как завернуть все в -шаурму- Intent

Дано:

Набор методов, где каждый метод отвечает за обработку одного события
Например: onTestChange(text: String), onSaveClick(), onCancelClick()

Задача:

Сделать один метод handle, который сможет обработать все события

Решение:

  • Создать общий пустой интерфейс, который сможет принимать метод handle
    Назовем его Intent

  • Для каждого события сделать своего наследника этого интерфейса:

    • class ChangeText(text: String) : Intent

    • object SaveText : Intent

    • object CancelTextChanges : Intent

  • Реализовать метод handle(intent: Intent), в котором перебрать всех наследников и выполнить соответствующие действия.

Ответ:

sealed interface Intent {
  class ChangeText(val text: String) : Intent
  object SaveText : Intent
  object CancelTextChanges : Intent
}

fun handle(intent: Intent) {
	when(intent) {
		is ChangeText -> changeText(intent.text)
		is SaveText -> saveText()
		is CancelTextChanges -> cancelTextChanges()
	}
}

private fun changeText(text: String) // меняем текст
private fun saveText() // сохраняем текст
private fun cancelTextChanges() // возвращаем исходный текст

Молодец, садись 5

Как тебе такое решение?
Мы добавили 12 новых строчек, и при этом все так же сохранили 3 старые функции
Пустая трата времени и сил
Не нравится, правда?

Но что мы приобрели? 🌯

Благодаря тому, что все события теперь наследуют один интерфейс,
мы можем одной строчкой залогировать сразу все события:
Logger.info("Получили событие $intent")

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

Дополнительный метод
fun handle(intent: Intent) {
	hapticFeedback(intent)
	when...
}

private fun hapticFeedback(intent: Intent) {
	when(intent) {
		is SaveText,
		is CancelTextChanges -> // делаем вибрацию
	}
}

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

Группировка для событий Button
sealed interface Intent {
	class ChangeText(val text: String) : Intent
	sealed interface Button : Intent {
		object SaveText : Button
		object CancelTextChanges : Button
	}
}

fun handle(intent: Intent) {
	when(intent) {
		is ChangeText -> changeText(intent.text)
		is Intent.Button -> handle(intent)
	}
}

private fun handle(intent: Intent.Button) {
	hapticFeedback(intent) // делаем вибрацию
	when(intent) {
		is SaveText -> saveText()
		is CancelTextChanges -> cancelTextChanges()
	}
}

В подходе отдельными методами, нам бы пришлось общую логику (логирование и вибрация) добавлять в каждый из методов по отдельности, а тут одна строчка и все готово!

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

Мы можем по разному дорабатывать этот подход, чтобы попробовать избавиться от появившихся проблем, но на данный момент мы на этом остановимся
Текущего примера должно быть достаточно, чтобы понять как выглядит заворачивание событий в Intent, и для понимания дальнейших принципов, которые вокруг этого строит MVI

🌽 Как собрать -урожай- состояние?

Для State я буду использовать псевдокод, чтобы сконцентрироваться только на сути

Какое твое состояние?

Сонливый или бодрый?
Сидишь или идешь?
Голодный или сытый?

Каждый объект в реальном мире имеет какое-то состояние
Состояние это данные о том, что происходит с объектом в данный момент времени

Пример:
True or False по правильному называется Boolean

State {
	isRun: True or False // бежит
	isSleep: True or False // спит
	isLaying: True or False // лежит
}

Состояние всегда согласовано

Что это значит?
Давай разберем на тебе:
Ты НЕ можешь одновременно бежать и лежать. НЕ согласовано
Ты можешь одновременно лежать и спать. Согласовано
Ты НЕ можешь одновременно бежать и спать. НЕ согласовано

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

Чтобы избежать несогласованных состояний, необходимо исключить их возможность, правильным проектированием состояния (State)

Шаг 1. Разделение

Если простыми словами,
заменим 1 состояние с 3 переменными, на 3 отдельных состояния

State {
	Run
	Sleep
	Laying
}

Отлично, мы выделили бег в отдельное состояние, но мы МОЖЕМ сразу лежать и спать
Эти два состояния нужно объединить. Но как?
Если состояния равнозначные, то их нужно сгруппировать в общее состояние
Если одно состояние не возможно без другого, то нужно поместить одно внутрь другого

Шаг 2. Объединение

Можно НЕ лежать и спать? НЕТ
Можно лежать и НЕ спать? ДА
Можно лежать и спать? ДА
Можно НЕ лежать и НЕ спать? Вопрос не нужен, потому что мы бежим

Если на все вопросы будет ответ ДА, то это равнозначные состояния и их нужно объединить в группу

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

State {
	Run
	Laying {
		isSleep: True or False
	}
}

state = State.Run
state = State.Laying(isSleep = true)
state = State.Laying(isSleep = false)

Шаг 3. Если осталась возможность не согласованных состояний, то повторить шаги 1 и 2

Такими не хитрыми преобразованиями, мы получили объект,
у которого не может быть не согласованного состояния

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

С ними тоже все просто
Это дополнительное состояние, которое может быть и которого может не быть
Конкретное значение и тип данных для проектирования не рассматривается

Добавим данные как отдельные состояния

State {
	Run
	Laying {
		isSleep: True or False
	}
	speed: Value or Empty
	sleepTime: Value or Empty
}

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

Время сна, как ни странно относится только ко сну
Причем его не может быть когда мы НЕ спим
Поэтому мы разделяем логическое состояние True or False,
на два отдельных состояния спим и не спим
И в состояние сна переносим данные о времени сна, по аналогии со скоростью

State {
	Run {
		speed: Value
	}
	Laying {
		Sleep {
			sleepTime: Value
		}
		NoSleep
	}
}

Правда ведь, ничего сложного?
В реальной работе, конечно такие состояния могут быть гораздо более объемными
Но и с ними можно легко справиться, просто это займет немного больше времени

🚜 Зачем -трактору- нужен редуктор?

Что же такое Редуктор?
Это механизм, который изменяет крутящий момент
В нашем же контексте, это сущность, которая умеет изменять состояние

🤷‍♂️ Почему вообще нам нужно уметь правильно менять состояние?

Например, мы бежим
У нас есть скорость и направление бега

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

  • поворот снижает скорость
    побочное изменение данных

Такие преобразования одного состояния в другое,
не затрагивают ничего кроме самого текущего состояния

И для их реализации лучше всего использовать чистые функции

💎 Чистая функция - это когда функция получает входные данные и на их основе отдает стабильный результат

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

Чистоты функции преобразования, можно добиться только если наше состояние будет неизменяемым (Immutable) 🔏

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

Если состояние изменится во время выполнения функции,
то результат может оказаться другим, а это уже не чистая функция

Такие функции очень надежные и хорошо тестируются

⚙️ Reducer (Редуктор) - чистая функция, которая на вход получает текущее состояние, а на выход отдает новое состояние

Но есть еще один момент, о котором нужно поговорить
Изменение состояния называется переходом➡️

Не всегда из одного состояния можно перейти в другое

Когда мы лежим и спим, мы не можем сразу побежать

Собака лунатик не считается

Поэтому в редукторе, перед тем как создать новое состояние, мы должны проверить,
а можем ли мы вообще это сделать?

Как поступить, в случае недоступного перехода, это уже решается по разному в каждом отдельном случае:

  • Можно проигнорировать и вернуть текущее состояние

  • Можно вернуть состояние ошибки, чтобы сообщить об этом

Если мы возьмем все возможные состояния и нарисуем их точками на бумаге, а после, нарисуем стрелки между теми состояниями, для которых доступен переход,
то мы получим граф, который называется Конечный автомат 🔫
Подробнее с этими терминами можно ознакомиться в интернете

Конечный автомат будет описывать все возможные состояния и все возможные переходы между ними

Каждый такой переход (стрелка) это отдельная чистая функция,
которая и называется ⚙️ Reducer редуктор или редьюсер, кто как хочет

Я специально не рассказываю, где и как расположить эти функции
Об этом мы поговорим потом🙂

Сейчас важно понять что такое State(Состояние) в целом,
и что получить новое состояние можно только через Reducer функции

Более подробно про проектирование состояний и работу с ними можно посмотреть в докладе от Михаила Левченко

🏪 Как открыть магазин с перехватчиками?

Представь

Ты хочешь купить компьютер (Intent), красивый такой, с подсветкой
Компьютер большой, поэтому тебе нужна доставка (Observer)
Ты приходишь в магазин (Store) и сообщаешь о своем намеренье купить компьютер
Продавец берет данные об остатках (current State) после предыдущего покупателя
Прикладывает к ним твой заказ (Intent) и отдает сборщику (Reducer)
Сборщик (Reducer) собирает нужный тебе компьютер (new State) и передает в доставку (Observer)

Давай покороче

Store берет текущий State и новый Intent,
пропускает через Reducer и получает новый State,
который передает в Observer

Сущность Store

  • отдает поток новых State

  • имеет способ получения Intent

  • использует Reducer для изменения State

  • хранит текущий State, чтобы было что изменять

🛩️ Причем тут перехватчики (Interceptor)?

Можно еще встретить название Middleware

Хозяин магазина, может захотеть вести аналитику или предоставлять скидку постоянным покупателям
Для этого, он не будет перестраивать весь процесс
Ему достаточно вклиниться в любое место нашей схемы, не меняя суть самой схемы

Например, продавец при получении заказа, сначала занесет его в CRM систему
Эта система проанализирует заказ и текущее состояние, запишет его в аналитику, и вернет продавцу в том же виде, что и получила
Или система может определить, что это постоянный покупатель, тогда создаст новый заказ со скидкой и уже его вернет продавцу

Такие перехватчики (Interceptor) можно выстраивать в цепочки и добавлять до или после вызова Reducer

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

Чувствую, ты уже устал от аналогий

Давай терминами из кода

🛩️ Interceptor - читая функция, для перехвата потока событий, которая обрабатывает событие и передает его дальше

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

Если завернем все действия пользователя в Intent,
то класс Store будет иметь всего один метод,
куда и нужно будет добавить Interceptor'ы

На этом этапе мы уже имеем целую схему из
Intent, State, Reducer, Store, Interceptor

🤵🏾‍♂️👨🏾‍🎨👨🏾‍💻👨🏾‍💼🕵🏾‍♂️👷🏼‍♀️

Вот только одно но - работать некому

👷🏼‍♀️ 5 менеджеров и 1 работник

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

Но если присмотреться, то Intent, Reducer, State и Store — это "менеджеры", которые принимают решения и управляют состоянием

Но кто выполняет настоящую работу?
Сетевые вызовы, операции с базой данных, чтение файлов

Для этого в MVI есть специальный «работник» — Actor

Что такое Actor?

Actor — это сущность, которая отвечает за всю асинхронную логику

  • Вход: Принимает Intent или State (зависит от реализации)

  • Выход: Возвращает целый поток новых Intent'ов

  • Особенность: Это НЕ чистая функция.
    Результат его работы может меняться (например, ответ сервера)

  • Требование: Должен уметь отменять свою работу.
    Если пользователь ушёл с экрана, нет смысла продолжать загрузку

Зачем возвращать целый поток, а не один Intent?

Чтобы сообщать о процессе:

  • Начали загрузку

  • Вот промежуточные данные

  • Данные успешно загружены

  • Произошла ошибка

Окей, с «работником» определились. Но куда его поставить в нашей MVI-цепочке?
Варианта всего два: ДО или ПОСЛЕ Reducer
Давай рассмотрим оба варианта

Вариант 1: Actor ДО Reducer'а

В этом случае Actor стоит на входе и работает как фильтр

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

  1. Actor получает все Intent'ы и текущий State

  2. Если Intent требует асинхронной работы — Actor её выполняет и порождает новые Intent'ы с результатом

  3. Если Intent'у работа не нужна — Actor просто пропускает его дальше в Reducer

✅ Плюсы:

  • Полная декомпозиция логики.
    Reducer отвечает только за синхронное изменение State
    Это максимальное разделение ответственностей:
    Actor — про «как сделать»,
    Reducer — про «что изменилось»

❌ Минусы:

  • Зависимость Actor'а от State.
    Actor'у нужен доступ к State, что бы определить необходимость выполнения работы и получить данные для нее

  • Actor знает про все Intent.
    Он может "проглотить" Intent, хотя Reducer будет его ожидать

Примеры библиотек: MVICore, MVIKotlin, Orbit

Вариант 2: Actor ПОСЛЕ Reducer'а

Здесь Actor сам решает, когда нужна асинхронная работа,
на основе нового State

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

  1. Reducer получает Intent и отдает State

  2. Actor получает этот State (и только его), выполняет работу и отправляет Intent с результатом обратно в начало цепочки

✅ Плюсы:

  • Полная декомпозиция логики.
    Reducer отвечает только за синхронное изменение State.
    Это максимальное разделение ответственностей:
    Reducer — про «что изменилось»
    Actor — про «что сделать»

  • State всегда верный.
    State полностью отражает текущую ситуацию
    и является единственным источником правды

  • Простой и независимый Actor.
    Ему нужен только State, на основе которого он решает что сделать

  • Более явный и предсказуемый поток.
    Цепочка всегда линейна:
    IntentReducerStateActorIntent.
    Легко отследить, что вызвало эффект

❌ Минусы:

  • Сложный State.
    По состоянию должно быть возможно определить, что нужно делать в данный момент

  • Проблема повторного запуска работы.
    Если запускать работу по флагу isLoading,
    то изменение другой части State, заново запустит работу, т.к.
    при повторном вызове Actor, isLoading все еще будет true
    Необходимо будет сделать решение, чтобы этого не происходило
    Есть различные способы решения, но это отдельная большая тема для обсуждения

Пример библиотеки: VisualFSM

Что выбрать?

⚖️ Подход 1 (ДО Reducer'а):

  • Плюс: Reducer остаётся чистым и простым

  • Минус: Actor становится сложным, «знает» слишком много

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

⚖️ Подход 2 (ПОСЛЕ Reducer'а):

  • Плюс: State единственный источник правды

  • Минус: State может оказаться сложным в проектировании

  • Вердикт: Более лаконичный и прямолинейный, на мой взгляд

Итог

Вот мы и разобрали все основные сущности MVI:
Intent, Reducer, State, Store, Interceptor и Actor
Каждая из них проста по отдельности

Но как собрать этот пазл воедино?
Об этом — в следующей статье! 🚀

Tags:
Hubs:
+1
Comments0

Articles