MEL — пошаговое руководство
MEL — это способ описать экран как маленькую машину состояний: что экран показывает, какие события на нём происходят и как одно превращается в другое. Здесь — всё по шагам, простыми словами, с разбором каждой детали и связи между ними.
Идея за минуту
Любой экран в MEL крутит один и тот же цикл из четырёх шагов:
- Вью показывает текущее состояние и шлёт наружу намерения («нажали кнопку»).
- Машина принимает намерение и решает, что делать — это называется reduce.
- Результат решения — переход: новое состояние плюс, при необходимости, побочные дела.
- Новое состояние возвращается во вью — цикл замкнулся.
Однонаправленный цикл: данные идут только по кругу, в одну сторону.
Вью никогда не меняет данные напрямую. Оно только отправляет намерение и рисует состояние. Всё остальное — работа машины. Поэтому экран легко понять, проверить и протестировать.
Дальше мы соберём такой экран с нуля: сначала словарь, потом по одному кирпичику — переходы, стек, команды, подписки, UI и тесты. Сквозной пример — экран документа, который грузится, обновляется и подписывается.
Словарь экрана: State, Intent, Effect
Прежде чем что-то делать, экран описывают тремя «словарями». Это просто интерфейсы-маркеры — вы перечисляете в них варианты для своего экрана.
| Маркер | Что это | Простыми словами |
|---|---|---|
MelState | состояние | Что экран показывает сейчас. Например: «грузим», «показываем документ», «ошибка». |
MelIntent | намерение | Что произошло. Клик пользователя, ответ сервера, тик таймера — всё это входящие события. |
MelEffect | эффект | Разовое побочное действие. Показать снек, перейти на другой экран, закрыться. То, что не является состоянием. |
Состояния и намерения удобно собирать в sealed interface — тогда компилятор
видит все варианты, а намерения можно сгруппировать по состояниям, к которым они относятся.
// Что экран может показывать.
sealed interface DocState : MelState {
data object Loading : DocState // шиммер на старте
data class Content(val doc: Doc, val refreshing: Boolean = false) : DocState
data class Confirm(val doc: Doc, val signing: Boolean = false) : DocState // оверлей
data class Error(val message: String) : DocState
}
// Что на экране может произойти. Группируем по состоянию, которому событие принадлежит
// (на эту группировку опирается страж полноты графа, шаг 14).
sealed interface DocIntent : MelIntent {
sealed interface LoadingIntent : DocIntent {
data class Loaded(val doc: Doc) : LoadingIntent // сервер ответил
data class LoadFailed(val message: String) : LoadingIntent
}
sealed interface ContentIntent : DocIntent {
data object Refresh : ContentIntent // клик «обновить»
data class Refreshed(val doc: Doc) : ContentIntent
data class RefreshFailed(val message: String) : ContentIntent
data object SignClick : ContentIntent
}
sealed interface ConfirmIntent : DocIntent {
data object ConfirmSign : ConfirmIntent
data class Signed(val doc: Doc) : ConfirmIntent
data class SignFailed(val message: String) : ConfirmIntent
data object CancelSign : ConfirmIntent
}
sealed interface ErrorIntent : DocIntent {
data object Retry : ErrorIntent
}
}
// Разовые действия наружу.
sealed interface DocEffect : MelEffect {
data class ShowMessage(val text: String) : DocEffect
data object Close : DocEffect, MelCloseEffect // закрыть экран (см. шаг 11)
}
Заметьте: «грузим» и «обновляем» — это поля состояния (Loading,
refreshing = true), а не эффекты вроде «показать спиннер». Всё, что описывает
как сейчас выглядит экран, — это факт состояния. Эффект — это только то,
что происходит один раз и улетает (показали снек, ушли с экрана).
Есть и четвёртый маркер — MelFacts. Им помечают модели данных
(как Doc в примере), чтобы было видно: это «факты», а не часть протокола экрана.
Связь
Эти три словаря — это контракт экрана. Машина и вью будут говорить только на нём.
Вся остальная архитектура — про то, как из Intent получить новый State
и какие Effect при этом выпустить.
Клетка: один шаг машины
Машина — это таблица правил. Каждое правило отвечает на вопрос:
«если экран в состоянии X и пришло намерение Y — что делать?». Такое правило называется
клеткой (cell) и пишется через on<State, Intent>:
on<Loading, Loaded> { state, intent ->
next(Content(intent.doc))
}
Читается как фраза: «находясь в Loading, получив Loaded — перейти в
Content с этим документом». Лямбда получает типизированные state и
intent (ровно тех типов, что в угловых скобках), а возвращает переход —
что описано в шаге 3.
Одна клетка = одно правило «состояние + событие → переход».
Как машина выбирает клетку
Когда приходит намерение, машина ищет клетку, чья пара (тип состояния, тип намерения)
подходит к текущему состоянию и пришедшему намерению. Если подходящих несколько —
выбирается самая специфичная: подтип бьёт надтип, сначала сравнивается тип намерения,
потом (при равных или несравнимых намерениях) тип состояния, а если и это не решило —
побеждает объявленная первой. Хендлер эффекта, вью и окно дебаунса выбираются похоже, но
каждый своей реализацией и по одному типу: подтип бьёт надтип, из несравнимых — объявленный
первым. Анимации transitions (шаг 10) специфичность не учитывают: срабатывает первое
подходящее правило. Если клетки нет вовсе —
намерение просто игнорируется (тихо логируется как «drop»; исключение — «назад», шаг 12). Это
нормально: значит, событие неактуально в этом состоянии.
Варианты записи клеток
| Форма | Когда |
|---|---|
on<S, I> { s, i -> … } | Обычная клетка: конкретное состояние + конкретное намерение. |
on<S, Группа> { s, i -> … } | Клетка на sealed-группу намерений (или на надтип состояния). Клетка на конкретный лист специфичнее и бьёт её. |
onAny<I> { s, i -> … } | Намерение I обрабатывается в любом состоянии (например, глобальное «закрыть»). Это fallback: конкретная on<S, I> для своего состояния её бьёт. I — любой MelIntent, не только намерение экрана (так ловят MelBack, шаг 12). |
if (state is …) и приведений типов
Внутри клетки state и intent уже нужного типа — проверки и касты
делать не надо. Это и есть «диспетчеризация по типам»: разбор вариантов вынесен в саму
структуру таблицы клеток.
Переход: что возвращает клетка
Клетка всегда возвращает Transition — описание того, что должно произойти. Это просто данные (никаких немедленных действий), которые складываются из четырёх частей:
Transition = операция над стеком + эффекты + (опц.) команда + (опц.) отмена команд.
Операции над стеком
Главная часть — что станет с состоянием. Внутри клетки доступны функции-строители:
| Функция | Что делает |
|---|---|
next(to) | Заменить текущее состояние на новое (тот же «экран», новые данные). |
stay() | Состояние не меняется. Удобно, когда нужно только выпустить эффект или запустить команду. |
push(to) | Положить новое состояние поверх текущего (открыть оверлей/подэкран). См. шаг 4. |
pop() | Снять верхнее состояние и вернуться к тому, что было под ним. На последнем кадре стек не трогается — экран закрывается (MelExhausted, шаг 11). |
popTo<Target>() | Снимать состояния, пока на вершине не окажется состояние типа Target. Если Target в стеке нет — нарушение, переход игнорируется целиком. Форма popTo<T>() доступна в теле машины/секции; в вынесенной функции — popTo(Target::class.java). |
Эффекты и команды — теми же вызовами
Любой строитель принимает эффекты позиционно и команду именованным аргументом. Поэтому «перейти и заодно показать снек» — это одна строка:
// перейти в Error и показать сообщение
next(Error(intent.message), DocEffect.ShowMessage(intent.message))
// остаться на месте, только показать снек
stay(DocEffect.ShowMessage("Готово"))
// перейти и запустить асинхронную работу (про command — шаг 5)
next(state.copy(refreshing = true), command = loadCommand())
Куда что уходит после возврата перехода:
Клетка ничего не делает «прямо сейчас»: она не ходит в сеть, не показывает диалоги, не трогает экран. Она только описывает переход и возвращает его как данные. Поэтому её тривиально протестировать — дал вход, проверил выход (шаг 14).
Стек состояний
Состояние экрана — это не одно значение, а стек. Обычно в нём один кадр (основной
экран). Но когда нужно показать что-то поверх — диалог, пуллер, шторку — вы делаете
push, и новый кадр ложится сверху. pop снимает его обратно.
После push(Confirm(doc)): Content остаётся живым под оверлеем,
Confirm — на вершине. pop() вернёт нас к Content.
Зачем держать нижний кадр живым
Когда вы делаете push, нижнее состояние не уничтожается — оно просто
перестаёт быть вершиной. Его данные сохранены, а если у него есть фоновые подписки (шаг 6) —
они продолжают работать. Поэтому, закрыв оверлей через pop, вы возвращаетесь
к актуальному экрану, а не к замороженному снимку.
// открыть подтверждение поверх документа
on<Content, SignClick> { state, _ -> push(Confirm(state.doc)) }
// закрыть подтверждение — вернуться к документу
on<Confirm, CancelSign> { _, _ -> pop() }
Как стек рисуется
Вью различает два вида кадров (об этом — шаг 10):
- view — обычный кадр. На экране виден только самый верхний из таких (база).
- overlay — кадр-наложение. Рисуется поверх базы (с анимацией въезда/ухода). Над одной базой может лежать несколько оверлеев.
Когда приходит намерение, машина проверяет кадры стека сверху вниз и отдаёт его первой
подходящей клетке. Это значит: пока сверху стоит Confirm, его намерения
обрабатывает он, а фоновое событие для Content (например, обновление с сервера)
всё равно долетит до Content под оверлеем. Так оверлей и фон не мешают друг другу.
Фоновый кадр может менять только себя: его клетка возвращает next или
stay. push/pop/popTo из фоновой клетки —
нарушение, такой переход игнорируется целиком.
Команды: асинхронная работа
Клетки синхронны и чисты — они не умеют «сходить в сеть». Для этого есть команды.
Команда — это кусок suspend-работы, который машина запустит за вас, а результат
вернёт обратно в виде намерения. Так цикл остаётся замкнутым: всё, что приходит в машину, —
это намерения, неважно, от клика они или от сети.
Команда уходит работать в фон, а возвращается обычным намерением — и снова через reduce.
Как объявить команду
Внутри клетки доступна функция command(...). Вы задаёте, что делать в успехе
(блок возвращает намерение) и что делать в ошибке (onError превращает исключение
в намерение). MEL сам ловит исключения — падать в фоне нечему.
on<Content, Refresh> { state, _ ->
next(
state.copy(refreshing = true), // факт «идёт обновление»
command = command(
key = CommandKey("load"), // см. ниже про ключи
onError = { RefreshFailed(it.message ?: "Ошибка") },
) {
Refreshed(repo.get(docId)) // suspend-вызов; результат → Intent
},
)
}
Ключ команды: замена, отмена, защита от дублей
CommandKey — это имя «слота». В один слот помещается одна живая команда:
- Перезапуск. Запустили команду с тем же ключом — предыдущая с этим ключом автоматически отменяется. Не будет двух параллельных загрузок.
- Явная отмена. В любом переходе можно передать
cancel = setOf(CommandKey("load"))— остановить команду, ничего не запуская. - Без ключа — команда живёт сама по себе, её нельзя отменить и она ни с чем не конкурирует.
- Ключи общие на всю машину. Слоты не делятся по секциям (шаг 8): одноимённый ключ в
теле машины, в
start()и в любой секции — один и тот же слот. Запуск в одном месте отменит команду из другого, аcancelостанавливает команду с этим ключом, откуда бы она ни была запущена.
Смена состояния команду не отменяет: её результат придёт намерением и попадёт в клетку актуального состояния (или будет отброшен, если клетки нет). Остановить команду можно только по ключу.
Команды с прогрессом
Если работа должна слать несколько промежуточных намерений (проценты загрузки, стадии),
используйте commandWithProgress — её блок получает функцию emit:
command = commandWithProgress(onError = { LoadFailed("…") }) { emit ->
repo.download(docId).collect { p -> emit(Progress(p)) } // много раз
Loaded(repo.get(docId)) // и финальное
}
Команду запускает переход (действие пользователя или предыдущий шаг). А вот «идёт
загрузка» — это факт состояния (refreshing = true), который вы выставляете
тем же next. Когда команда вернёт Refreshed, следующая клетка снимет
флаг (refreshing = false). Спиннер гаснет «по построению» — отдельного эффекта
«спрятать спиннер» не существует.
Подписки: внешние потоки
Команда — это «сделать раз и вернуть результат». А что, если данные приходят сами и
непрерывно — пуши, сокет, поток из БД? Для этого есть подписки. Подписка превращает
внешний Flow в поток намерений и сама включается/выключается в зависимости от того,
в каком состоянии находится экран.
subscribe(
SubscriptionKey("docUpdates"),
active<Content>(), // живёт, пока в стеке есть Content…
active<Confirm>(), // …или Confirm
onError = { RefreshFailed(it.message ?: "…") },
) {
docUpdates.flow(docId).map(::Refreshed) // Flow<Doc> → Flow<Intent>
}
Скоуп: где подписка живёт
Аргументы active<…>() задают скоуп — список состояний, при которых
подписка должна работать. MEL сам следит за стеком: как только в стеке появляется подходящее
состояние — подписка стартует; как только все подходящие исчезли — она останавливается.
Нужен хотя бы один active<…>(): подписка с пустым скоупом не запустится никогда,
и граф с ней не соберётся.
Пока в стеке есть Content или Confirm, поток жив и кормит машину намерениями.
Можно сузить скоуп предикатом: active<Content> { it.refreshing.not() } —
подписка активна только когда документ не в процессе обновления.
Надёжность
- Если у подписки задан
onError— упавший поток превращается в намерение (вы решаете, что показать). На этом подписка заканчивается: снова она запустится, только когда скоуп опустеет и наполнится заново. - Если
onErrorнет — MEL сам переподпишется с нарастающей паузой (1с → 2с → … → 30с). Поток восстановится, когда источник оживёт. В строгом режиме (шаг 13) такое падение роняет приложение. - detekt-правило
MelSubscribeOnError(mel-detekt) предупреждает оsubscribeбезonError. Оно молчит, когда ошибка невозможна или уже обработана (.catchв цепочке,flowOf/emptyFlow), для шин, живущих всё время работы приложения (.asFlow(), методыlisten*/observe*), и для горячих источников (stateIn,MutableStateFlowи т.п.) — их ловит отдельное правилоMelHotFlowSubscriptionSource.
Команда запускается переходом, отрабатывает и заканчивается (один-два результата). Подписка привязана к состоянию и живёт, пока экран в нужном состоянии, выдавая поток событий. Команда — «сделай и доложи», подписка — «следи и сообщай».
Машина целиком
Теперь соберём кирпичики. Экран описывается классом-наследником MelMachine<S, I, E>
с тремя частями:
| Часть | Что задаёт |
|---|---|
start() | Рождение экрана — стартовое состояние и (опц.) первая команда. |
graph | Таблица клеток и подписок — всё поведение экрана. |
config | Опциональные настройки: дебаунс и перехватчики (шаг 13). |
Зависимости (репозитории, аргумент-id, источники потоков) машина получает через конструктор — обычное constructor injection.
class DocMachine(
private val docId: String, // аргумент рождения
private val repo: DocRepository, // зависимости — в конструктор
private val docUpdates: DocUpdates,
) : MelMachine<DocState, DocIntent, DocEffect>() {
// 1. Рождение: показываем Loading и сразу запускаем загрузку.
override fun start() = startNext(Loading, command = loadCommand())
// 2. Всё поведение — в графе.
override val graph = graph<DocState, DocIntent, DocEffect> {
// загрузка
on<Loading, Loaded> { _, i -> next(Content(i.doc)) }
on<Loading, LoadFailed> { _, i -> next(Error(i.message)) }
on<Error, Retry> { _, _ -> next(Loading, command = loadCommand()) }
// документ
on<Content, Refresh> { s, _ ->
next(s.copy(refreshing = true), command = command(
key = CommandKey("load"),
onError = { RefreshFailed(it.message ?: "Ошибка") },
) { Refreshed(repo.get(docId)) })
}
on<Content, Refreshed> { s, i -> next(s.copy(doc = i.doc, refreshing = false)) }
on<Content, RefreshFailed> { s, i -> next(s.copy(refreshing = false), DocEffect.ShowMessage(i.message)) }
on<Content, SignClick> { s, _ -> push(Confirm(s.doc)) }
// подтверждение (оверлей)
on<Confirm, ConfirmSign> { s, _ ->
next(s.copy(signing = true), command = command(
key = CommandKey("sign"),
onError = { SignFailed(it.message ?: "…") },
) { Signed(repo.sign(s.doc)) })
}
on<Confirm, SignFailed> { s, i -> next(s.copy(signing = false), DocEffect.ShowMessage(i.message)) }
on<Confirm, CancelSign> { _, _ -> pop() }
on<Confirm, Signed> { _, _ -> pop(DocEffect.ShowMessage("Подписано")) }
// живые обновления с сервера — пока в стеке Content или Confirm;
// под оверлеем Refreshed долетает до Content (шаг 4)
subscribe(
SubscriptionKey("docUpdates"), active<Content>(), active<Confirm>(),
onError = { RefreshFailed(it.message ?: "…") },
) {
docUpdates.flow(docId).map(::Refreshed)
}
}
private fun loadCommand() =
command(onError = { LoadFailed(it.message ?: "Ошибка") }) {
Loaded(repo.get(docId))
}
}
start() — это, по сути, нулевой переход: «появись в таком-то состоянии и,
если надо, запусти такую-то команду». startNext(Loading, command = …) делает ровно
это. Дальше всё крутит цикл из клеток.
Машина не хранит состояние сама — состоянием владеет рантайм (шаг 9). Машина — это только описание: правила, старт, настройки. Поэтому одну и ту же машину легко проверять и переиспользовать.
Состояние не обязано быть sealed-иерархией: экрану-форме хватает одного data class,
поля которого клетки меняют через next(s.copy(…)) (пример — в шаге 12). Тело клетки
можно вынести в функцию машины или секции: on<Content, SignClick> { s, _ -> openConfirm(s) }.
Экстрактор графа (плагин, mel-graph) понимает оба случая:
одиночное состояние считает целью всех next/push машины, ветвление
if/when в цели next даёт по ребру на ветку, а хвостовой вызов функции
машины/секции разворачивает в её переходы.
Секции: переиспользуемые куски графа
Когда граф разрастается или часть поведения повторяется на разных экранах, её выносят в
секцию — самостоятельную зону графа со своими клетками, подписками и зависимостями.
Секция подключается в граф одной строкой include(...).
// Зона «подписание»: своя ответственность, своя зависимость.
class SignSection(
private val repo: DocRepository,
) : Section<DocState, DocIntent, DocEffect>() {
override fun GraphBuilder<DocState, DocIntent, DocEffect>.register() {
on<Confirm, ConfirmSign> { s, _ ->
next(s.copy(signing = true), command = command(
key = CommandKey("sign"),
onError = { SignFailed(it.message ?: "…") },
) { Signed(repo.sign(s.doc)) }) // своя зависимость секции
}
on<Confirm, CancelSign> { _, _ -> pop() }
on<Confirm, Signed> { _, _ -> pop() }
}
}
// В графе машины:
override val graph = graph<DocState, DocIntent, DocEffect> {
on<Loading, Loaded> { _, i -> next(Content(i.doc)) }
// …
include(SignSection(repo)) // подключили всю зону подписания
}
- Секция — такой же «строитель графа», как и тело машины: те же
on,subscribe, те жеnext/push/pop. - Зависимости секции — её собственные (через конструктор).
- Если две клетки случайно столкнутся на одной паре (состояние, намерение) — граф не соберётся и подскажет, где дубль. То же с дублями ключей подписок: они общие на весь граф.
- Ключи команд тоже общие на всю машину:
CommandKey("load")в двух секциях — один слот, и запуск в одной отменит команду другой (шаг 5). - Клетки и подписки секции помечаются коротким именем её класса — оно видно в сообщении о
дубле и в дампе графа (
melGraphDump, шаг 14). На выбор клетки эта пометка не влияет.
Маленький экран целиком умещается в теле машины. Секции — инструмент для больших экранов и для повторяющихся зон, которые хочется подключать в несколько машин.
Рантайм и модель
Машина — это описание. Чтобы оно «ожило» и закрутило цикл, нужен рантайм.
Рантайм — это MelViewModel (наследник Android ViewModel): он держит стек,
гоняет клетки, исполняет команды и подписки. UI же работает не с конкретным рантаймом,
а с интерфейсом MelModel.
MelModel — что видит вью
| Член | Что это |
|---|---|
state: StateFlow<S> | Текущая вершина стека — то, что рисуем. |
stack: StateFlow<List<S>> | Весь стек (нужен для оверлеев). |
effects: Flow<E> | Поток эффектов — их разбирает эффект-слой (шаг 11). У канала один потребитель — MelScreen; вручную его не собирают, иначе эффекты разделятся между коллекторами и пропадут. |
send(intent: I) | Отправить намерение в машину. |
pop() | Запросить «назад» (шаг 12). |
handlesBack: StateFlow<Boolean> | Владеет ли машина кнопкой «назад» сейчас (шаг 12). |
Как создать модель
Чаще всего — фабрикой melViewModel { … } прямо в Compose. Она создаёт и кеширует
рантайм по правилам ViewModel (переживает поворот экрана):
val model = melViewModel {
DocMachine(docId = "doc-42", repo, docUpdates)
}
Лямбда вызывается один раз при рождении модели. Ключ по умолчанию выводится из типов
S/I/E, поэтому разные экраны под одним владельцем
(одним ViewModelStoreOwner) не «слипаются». Явный key нужен, когда под одним
владельцем живут два экземпляра одного экрана.
Новый key → новая модель → новый вызов start() → экран рождается
заново. Так, например, открытие того же экрана с другим документом
(melViewModel(key = docId) { … }) начинает всё с чистого листа.
Экран: MelScreen
MelScreen — это корневой Composable, который связывает модель с UI. Внутри него
вы декларативно описываете три вещи: что рисовать, как исполнять эффекты и
(опционально) как анимировать переходы.
Обязателен только content. Остальное — по необходимости.
MelScreen(
model = model,
router = router,
context = { MelSnackContext(it.router, snackbar) }, // инструменты эффектов — шаг 11
) {
// 1. ЧТО РИСОВАТЬ: по одному вью на каждый тип состояния.
content {
view<Loading> { DocShimmer() }
view<Content> { state -> DocContent(state, model::send) }
overlay<Confirm> { state -> DocConfirm(state, model::send) } // поверх Content
view<Error> { state -> DocError(state, model::send) }
}
// 2. КАК ИСПОЛНЯТЬ ЭФФЕКТЫ: эффект → побочное действие.
effects {
on<DocEffect.ShowMessage> { e, ctx -> ctx.showSnack(e.text) }
// DocEffect.Close разбирает дефолтный хендлер — строчку писать не нужно
}
// 3. АНИМАЦИИ (опционально).
transitions {
between<Loading, Content>(melFadeThrough)
}
}
content — вью как функция состояния
Каждый view<S> / overlay<S> — это чистый Composable,
принимающий состояние и рисующий его. Внутри он только читает поля состояния и зовёт
model::send на кликах. Никаких if (state is …) — нужный вью выбирает
MelScreen по типу вершины. Такой вью тривиально показать в @Preview —
ему не нужна ни модель, ни DI, достаточно собрать состояние руками.
@Composable
private fun DocContent(state: Content, send: (DocIntent) -> Unit) {
Column {
DocCard(state.doc, loading = state.refreshing)
Button(onClick = { send(SignClick) }) { Text("Подписать") }
OutlinedButton(onClick = { send(Refresh) }) { Text("Обновить") }
}
}
view vs overlay
view<S>— обычный экран. Виден верхний из таких, смена класса состояния между ними кроссфейдится (или по правилу изtransitions);nextв состояние того же класса перерисовывает вью без анимации.overlay<S>— наложение поверх базы: выезжает снизу с проявлением и уходит вниз с затуханием. Эта анимация встроена и не настраивается. Соответствуетpush/popв стеке.rememberSaveableвнутри вью привязан к ключу записи — её позиции в стеке и классу состояния: он переживаетpushповерх иnextв состояние того же класса; когда такой ключ пропадает из стека (pop, смена класса на этой позиции), сохранённое стирается.
transitions — точечные анимации
По умолчанию смена базового вью — кроссфейд. Хотите другое между конкретными состояниями —
опишите between<A, B>(transform). Готовые трансформы: melFadeThrough,
melSlideUp. Если подходят несколько правил between (например, одно на
sealed-группу), действует объявленное первым. Блок управляет только сменой базовых вью — на
оверлеи он не влияет.
Эффект-слой и роутер
Эффекты — это данные, которые вернула клетка. Кто-то должен их исполнить: показать снек,
перейти на другой экран, закрыться. Это делает блок effects { }, а инструменты для
исполнения он получает из эффект-контекста.
ctx.showSnack(…)
Эффект-слой — единственное место, где экран «делает что-то снаружи».
Роутер — навигация наружу
Базовый контекст MelEffectContext несёт ровно один инструмент — router
с методом close(). Передайте свою реализацию в MelScreen(router = …):
через неё экран закрывается, а вы решаете, что значит «закрыть» (снять с навстека, вернуть
результат и т.п.). Если роутер не передан, запрос закрытия — нарушение (лог или падение в строгом
режиме), и экран не закроется.
Отправки намерений в контексте нет: эффект-слой исполняет действия, а не кормит машину. Если
результат внешнего действия должен вернуться в машину (выбранный файл, ответ системного экрана),
его отправляет обычный код экрана через model.send(…) — так, например, лаунчер выбора
файла в боевом пуллере создания (feature/creation) отдаёт результат намерением.
Хендлеры — suspend-функции, каждый исполняется в своей корутине, пока экран не
ниже STARTED: долгий хендлер (диалог ждёт ответа) не задерживает остальные эффекты.
Эффекты, рождённые в фоне, буферизуются и доставляются, когда экран вернётся.
Готовые эффекты закрытия
Закрытие обрабатывается само, без строчки в effects { }: у ядра два
дефолтных хендлера, и оба вызывают router.close():
- хендлер маркера
MelCloseEffect— для ваших эффектов, помеченных им (какDocEffect.Close); - хендлер
MelExhausted— служебного сигнала ядра «стек исчерпан» (popна последнем кадре или «назад» без клетки на корне, шаг 12). Это неMelCloseEffect, а отдельный эффект ядра (MelCommonEffect): он идёт своим внутренним каналом мимо типизированногоmodel.effectsи вeffects { }не регистрируется.
Ваши хендлеры стоят в очереди раньше дефолтных: on<DocEffect.Close> или хендлер
на всю группу on<DocEffect> заберёт закрывающий эффект себе, и роутер сам уже не
вызовется.
Свои инструменты — через расширение контекста
Базовый контекст знает только про роутер — это сделано нарочно, чтобы ядро
не тащило в себя Material и прочее. Нужен снек, диалоги, пейджер? Расширьте контекст. Готовое
расширение для снеков — MelSnackContext из модуля :core:mel-compose-material
(артефакт ru.sberbank_sbbol.mel:compose-material):
val snackbar = remember { SnackbarHostState() }
MelScreen(
model = model,
router = router,
context = { MelSnackContext(it.router, snackbar) }, // расширили контекст
) {
content { /* … */ }
effects {
on<DocEffect.ShowMessage> { e, ctx -> ctx.showSnack(e.text) } // ctx — теперь MelSnackContext
}
}
Свой контекст — подкласс, который передаёт роутер базовому конструктору. Лямбда
context получает базовый контекст (it) и берёт роутер из него; вызывается она
один раз, повторно — только при смене router. Изменяемого состояния в
контексте не держат — это набор инструментов, факты живут в состоянии.
class DocContext(router: MelRouter, val pdf: PdfViewer) : MelEffectContext(router)
// MelScreen(…, context = { DocContext(it.router, pdfViewer) }) { … }
Инструменты (роутер, снек) получает только блок effects. Вью их не видит —
и не должно: вью это чистая функция состояния (state in, intent out). Любое «действие наружу»
проходит ровно через одно место — эффект-слой.
Кнопка «назад»
Системная кнопка «назад» в MEL — это просто намерение MelBack (его и шлёт
model.pop(), мимо дебаунса). MelScreen перехватывает её по простому правилу:
- Стек глубже одного кадра (открыт оверлей/подэкран): перехват включён всегда.
- На корневом кадре (стек из одного состояния): перехват включён, только если машина
«владеет» back — в графе есть клетка на
MelBackдля текущей вершины. Иначе «назад» отдаётся системе (экран закрывается обычным образом).
Перехваченный MelBack маршрутизируется как любое намерение. Нашлась клетка —
решает она (например, показать «выйти без сохранения?»). Не нашлась — срабатывает дефолт
ядра: при стеке глубже одного кадра ядро снимает верхний кадр, а на корне шлёт MelExhausted —
экран закрывается через роутер (шаг 11). Системный «назад» на корень без клетки не попадает
(перехват там выключен), так что обычно второй случай — прямой вызов model.pop(), например со
стрелки в тулбаре. Дефолтное снятие кадра — не переход клетки: перехватчики (шаг 13) его не видят.
Чтобы машина «владела» back, добавьте клетку. MelBack — служебное
намерение ядра, а не намерение экрана, поэтому клетка пишется через onAny
(on<Content, MelBack> не скомпилируется: второй тип-параметр on ограничен
намерениями экрана I, а onAny принимает любой MelIntent).
onAny отдаёт состояние общим типом S, поэтому решение принимают по фактам
состояния — как в боевом экране реквизитов (feature/payorder/…/ui/mel/selfrequisites),
где форма — одно состояние, а диалог выхода — флаг в нём:
// машина-форма с одним состоянием: MelMachine<FormState, FormIntent, FormEffect>
onAny<MelBack> { s, _ ->
when {
s.isExitDialogShown -> next(s.copy(isExitDialogShown = false)) // закрыть диалог
s.hasChanges() -> next(s.copy(isExitDialogShown = true)) // «выйти без сохранения?»
else -> stay(FormEffect.Close) // Close : MelCloseEffect
}
}
// мастер, где «назад» на любом шаге закрывает экран целиком, а не снимает оверлей
onAny<MelBack> { _, _ -> stay(WizardEffect.Close) }
onAny<MelBack> подходит к любой вершине, включая оверлей. Дефолт ядра
до дела не дойдёт, и неявного снятия оверлея не будет: там, где «назад» должен закрыть слой,
клетка сама возвращает pop(). Закрыть экран клетка может эффектом-MelCloseEffect
или pop() на корне (он пошлёт MelExhausted).
Флаг model.handlesBack отражает, есть ли у машины клетка на MelBack для
текущей вершины; он пересчитывается при каждой смене вершины, и на нём построен перехват на корне.
С onAny<MelBack> он всегда true. Вам обычно не нужно читать его вручную,
но он есть.
Глубокий стек → «назад» закрывает верхний слой. Корень → back ваш, только если вы написали
про него клетку; иначе он системный. Клетка на MelBack всегда главнее дефолта ядра.
Конфиг: дебаунс, перехватчики, логи
Дебаунс — гасим частые тапы
Если намерение прилетает слишком часто (двойной клик «обновить», ввод в поиск), его можно дебаунсить — обрабатывать только после паузы. Указывается по типу намерения:
override val config = config {
debounce<Refresh>(500.milliseconds) // схлопываем серию «обновить»
}
- Из серии в цикл попадает последнее намерение — после паузы длиной в окно.
- Окно можно объявить на sealed-группу; если у листа есть своё окно, действует оно. Окно группы — один слот на всю группу: из серии разных её листьев дойдёт только последний.
- Тип-параметр
debounceкомпилятор не ограничивает (debounce<T : Any>): окно с типом, которому не соответствует ни одно намерение экрана (скажем,String), соберётся, но не сработает никогда. Повторное объявление того же типа молча заменяет окно. - Дебаунсится только то, что отправлено через
model.send()(из вью и кода экрана). Результаты команд и подписок и «назад» (model.pop()) идут в цикл без задержки.
Перехватчики — наблюдение за машиной
Перехватчик (MelInterceptor) видит каждый переход (onTransition) и каждое
«потерянное» намерение (onDropped) — удобно для логов, аналитики, отладки. Он только
наблюдает: изменить переход не может. Готовый — MelDebugInterceptor, печатает
переходы в лог:
override val config = config {
interceptor(MelDebugInterceptor("Doc"))
}
// в логе: [Doc] Content + Refresh -> next(Content) command=CommandKey(id=load)
Логирование
Внутренние сообщения MEL идут через MelLog. По умолчанию это
android.util.Log с тегом MEL, но можно подменить делегата — свою
реализацию MelLogger (например, мост на логгер проекта):
MelLog.delegate = MyAppLogger // object MyAppLogger : MelLogger { … }
Строгий режим в тестах
Флаг MelRuntime.failFast = true превращает «нарушения» (эффект без хендлера,
состояние без вью, стек без базового кадра, не объявленный content { }, исключение в
клетке, popTo к состоянию, которого нет в стеке, push/pop из
фонового кадра, закрытие без роутера, падение подписки без onError) из логов в исключения. Включайте его в тестах — чтобы ошибки контракта
падали громко, а не тонули в логе.
Тестирование
Поскольку клетка — чистая функция, тест клетки — это «дал вход, проверил выход». Модуль
:core:mel-test даёт две удобные проверки.
Transition.assert — проверка одной клетки
Прогоните клетку через machine.graph.reduce(state, intent) — тот же поиск клетки,
что в рантайме — и проверьте переход по фактам (прямой assertEquals не годится —
внутри команды лежит лямбда):
machine.graph.reduce(Content(doc), Refresh)!!.assert {
to<Content> { it.refreshing } // перешли в Content с флагом
command("load") // запустилась команда «load»
noEffects() // эффектов не было
}
// рождение — строка той же таблицы
machine.start().toTransition().assert { to<Loading>(); command() }
reduce возвращает null, если клетки для пары нет, — так проверяют, что
намерение в чужом состоянии не обрабатывается. Доступные проверки: to<T>{},
pushed<T>{}, popped(), poppedTo<T>(),
stayed(), command(key) / noCommand(),
effect<E>{} / noEffects(), cancels(...). Само тело
команды (suspend-работу) тестируют отдельно как обычный suspend-код.
melGraphAssert — страж полноты графа
Один тест на машину, который проверяет, что в графе нет дыр: не забыта ли клетка для нового намерения, нет ли «мёртвых» намерений без клеток, нет ли состояний без поведения. «Забытая клетка» ищется по группировке намерений (шаг 1): если состояние обработало хоть один лист группы, остальные листья этой группы в нём тоже должны быть обработаны. Сознательные пропуски помечаются с причиной — чтобы они были видны в ревью.
@Test fun graph() = melGraphAssert(DocMachine("doc", fakeRepo, fakeUpdates)) // у DocMachine дыр нет
// Сознательные пропуски объявляются в блоке, каждый — с причиной:
melGraphAssert(machine) {
terminal<S>() // состояние без клеток (тупик-заглушка)
allowUnhandled<S, I>("причина") // намерение (лист или группа) не обрабатывается в состоянии
allowDead<I>("причина") // намерение без клеток во всём графе
}
При провале тест перечисляет все дыры сразу и печатает дамп графа; тот же дамп доступен
отдельно — melGraphDump(graph).
Логика экрана проверяется без Compose, без корутин фреймворка и без моков рантайма —
только машина как данные. Быстро и надёжно. UI же отдельно показывается в @Preview,
ведь вью — чистые функции состояния.
Полная картина: части и связи
Соберём всё вместе. Слева — что вы пишете, по центру — рантайм, который это исполняет, справа — внешний мир.
Слева — UI (вход content и выход effects), по центру — рантайм с правилами, справа — асинхронщина, её источники и внешний мир. Стрелки внутрь рантайма — всегда намерения; стрелки наружу — состояние и эффекты.
Кто за что отвечает
| Часть | Роль | Связи |
|---|---|---|
| State / Intent / Effect | Словарь экрана. | На нём говорят все остальные. |
Клетка on<S,I> | Правило «состояние + событие → переход». | Чистая функция: вход — state+intent, выход — Transition. |
| Transition | Описание шага: стек + эффекты + команда. | Это данные; исполняет их рантайм. |
| Машина / секции | Вся таблица правил + старт + конфиг. | Зависимости — через конструктор. |
| Команды | Разовая async-работа. | Запускаются переходом, возвращают Intent. |
| Подписки | Непрерывные внешние потоки. | Живут по скоупу состояний, выдают Intent. |
| MelViewModel | Рантайм: стек, цикл, исполнение. | Реализует MelModel для UI. |
| MelScreen | UI: content + effects + transitions. | Рисует State, шлёт Intent, исполняет Effect. |
| Эффект-контекст / роутер | Инструменты для эффектов. | Видны только блоку effects. |
Шпаргалка API
Контракты
MelStateMelIntentMelEffect MelFactsCommandKey(id)SubscriptionKey(id) MelBackMelPopRequestMelCommonEffect MelCloseEffectMelExhausted
Граф (внутри graph<S, I, E> { } и секций)
on<S, I> { s, i -> … } | клетка для пары (состояние, намерение); S/I могут быть группами |
onAny<I> { s, i -> … } | клетка-fallback для намерения в любом состоянии (в т.ч. onAny<MelBack>) |
subscribe(key, active<S>(), …, onError) { flow } | подписка со скоупом состояний (хотя бы один active) |
active<S> { predicate } | элемент скоупа подписки |
include(section) | подключить секцию |
Переходы (возврат клетки)
next(to, …effects, command, cancel) | заменить вершину |
stay(…effects, command, cancel) | не менять состояние |
push(to, …) / pop(…) | положить / снять кадр стека |
popTo<Target>(…) / popTo(Target::class.java, …) | снимать до состояния-цели |
command(key, onError) { … } | async-работа → Intent; ключ общий на всю машину |
commandWithProgress(key, onError) { emit -> … } | команда с промежуточными Intent |
Машина
MelMachine<S, I, E> | базовый класс экрана |
start() → startNext(to, …, command) | рождение экрана |
override val graph = graph<S, I, E> { … } | таблица клеток/подписок |
override val config = config { debounce<T>(window); interceptor(…) } | настройки |
MelDebugInterceptor(tag), MelLog.delegate | логи |
Section<S, I, E>: override fun GraphBuilder<S, I, E>.register() | переиспользуемая зона графа |
Рантайм и UI
melViewModel(key) { machine } | создать модель в Compose |
MelModel: state, stack, effects, send(i), pop(), handlesBack | фасад для UI |
MelScreen(model, modifier, router) { … } | корень экрана |
MelScreen(model, router = …, context = { XContext(it.router, …) }) { … } | корень с расширенным контекстом |
content { view<S>{}; overlay<S>{} } | что рисовать |
effects { on<E> { e, ctx -> … } } | как исполнять эффекты |
transitions { between<A, B>(transform) } | анимации смены базового вью (melFadeThrough, melSlideUp) |
MelEffectContext(router): только router; подкласс — XContext(router, …) : MelEffectContext(router) | инструменты эффект-слоя |
MelRouter.close(); дефолтные хендлеры MelCloseEffect и MelExhausted | закрытие экрана |
MelSnackContext(router, snackbar).showSnack(…) | снек (модуль mel-compose-material) |
Тесты
machine.graph.reduce(state, intent)!!.assert { … } | проверка одной клетки |
machine.start().toTransition().assert { … } | проверка рождения |
melGraphAssert(machine) { terminal<S>(); allowUnhandled<S,I>("…"); allowDead<I>("…") } | полнота графа |
melGraphDump(graph) | дамп графа строкой |
MelRuntime.failFast = true | строгий режим |
Правила и инварианты
Несколько принципов, которые держат код предсказуемым. MEL за большинство из них следит сам — нарушения логирует (или роняет в строгом режиме).
- Вью — чистая функция состояния. Только читает state и шлёт intent. Никаких побочных действий, никакого доступа к роутеру/снеку.
- Клетка — чистая функция. Возвращает Transition как данные, ничего не исполняет сама. Вся асинхронщина — через команды и подписки.
- Факт — в состоянии, действие — в эффекте. «Идёт загрузка» — поле состояния. «Показать снек» — эффект. Спиннеры гаснут сами, потому что это факт, который снимает следующий шаг.
- Каждое состояние имеет вью. Состояние без зарегистрированного
view/overlay— нарушение. В стеке всегда есть хотя бы один базовый (не-overlay) кадр. - Каждый эффект имеет хендлер. Эффект без
on<E>(и не закрывающийMelCloseEffect) — нарушение. - Уникальность. Две клетки на одну пару (S, I) или два одинаковых ключа подписки — граф
не соберётся. Дубль
view/overlayна состояние, хендлера на эффект, анимации на пару состояний или повторный блокcontent/effects/transitions—MelScreenупадёт при сборке. - Ключ команды — слот на всю машину. Одноимённые ключи в теле машины и в секциях
конкурируют: новый запуск отменяет прежний,
cancelостанавливает любой. - Фоновый кадр меняет только себя (
next/stay); операции над стеком делает вершина. - Зависимости — через конструктор машины и секций. Никаких глобальных синглтонов внутри клеток.
- Закрытие — данными, через роутер (
MelCloseEffect/MelExhausted). Экран не закрывает сам себя в обход эффект-слоя и не зовётrouter.close()руками вeffects { }. - Эффекты собирает только
MelScreen. Ручной сборmodel.effectsкрадёт события у эффект-слоя.
Это руководство описывает, как пользоваться MEL.
Точные инварианты реализации и формальная модель — в каноническом документе архитектуры
mel-design.html. Если код и оба документа разойдутся — источником истины считается код,
затем спека.