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
}
// Что на экране может произойти. Группируем по состоянию, которому событие принадлежит.
sealed interface DocIntent : MelIntent {
data class Loaded(val doc: Doc) : DocIntent // сервер ответил
data class LoadFailed(val message: String) : DocIntent
data object Refresh : DocIntent // клик «обновить»
data class Refreshed(val doc: Doc) : DocIntent
data object SignClick : DocIntent
data object ConfirmSign : DocIntent
data class Signed(val doc: Doc) : DocIntent
data object CancelSign : DocIntent
data object Retry : DocIntent
}
// Разовые действия наружу.
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.
Одна клетка = одно правило «состояние + событие → переход».
Как машина выбирает клетку
Когда приходит намерение, машина ищет клетку, чья пара (тип состояния, тип намерения) подходит к текущему состоянию и пришедшему намерению. Если подходящих несколько — выбирается самая специфичная (по точному типу). Если клетки нет вовсе — намерение просто игнорируется (тихо логируется как «drop»). Это нормально: значит, событие неактуально в этом состоянии.
Варианты записи клеток
| Форма | Когда |
|---|---|
on<S, I> { s, i -> … } | Обычная клетка: конкретное состояние + конкретное намерение. |
onAny<I> { s, i -> … } | Намерение I обрабатывается в любом состоянии (например, глобальное «закрыть»). |
if (state is …) и приведений типов
Внутри клетки state и intent уже нужного типа — проверки и касты
делать не надо. Это и есть «диспетчеризация по типам»: разбор вариантов вынесен в саму
структуру таблицы клеток.
Переход: что возвращает клетка
Клетка всегда возвращает Transition — описание того, что должно произойти. Это просто данные (никаких немедленных действий), которые складываются из четырёх частей:
Transition = операция над стеком + эффекты + (опц.) команда + (опц.) отмена команд.
Операции над стеком
Главная часть — что станет с состоянием. Внутри клетки доступны функции-строители:
| Функция | Что делает |
|---|---|
next(to) | Заменить текущее состояние на новое (тот же «экран», новые данные). |
stay() | Состояние не меняется. Удобно, когда нужно только выпустить эффект или запустить команду. |
push(to) | Положить новое состояние поверх текущего (открыть оверлей/подэкран). См. шаг 4. |
pop() | Снять верхнее состояние и вернуться к тому, что было под ним. |
popTo<Target>() | Снимать состояния, пока на вершине не окажется состояние типа Target. |
Эффекты и команды — теми же вызовами
Любой строитель принимает эффекты позиционно и команду именованным аргументом. Поэтому «перейти и заодно показать снек» — это одна строка:
// перейти в 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 под оверлеем. Так оверлей и фон не мешают друг другу.
Команды: асинхронная работа
Клетки синхронны и чисты — они не умеют «сходить в сеть». Для этого есть команды.
Команда — это кусок suspend-работы, который машина запустит за вас, а результат
вернёт обратно в виде намерения. Так цикл остаётся замкнутым: всё, что приходит в машину, —
это намерения, неважно, от клика они или от сети.
Команда уходит работать в фон, а возвращается обычным намерением — и снова через reduce.
Как объявить команду
Внутри клетки доступна функция command(...). Вы задаёте, что делать в успехе
(блок возвращает намерение) и что делать в ошибке (onError превращает исключение
в намерение). MEL сам ловит исключения — падать в фоне нечему.
on<Content, Refresh> { state, _ ->
next(
state.copy(refreshing = true), // факт «идёт обновление»
command = command(
key = CommandKey("load"), // см. ниже про ключи
onError = { LoadFailed(it.message ?: "Ошибка") },
) {
Refreshed(repo.get(docId)) // suspend-вызов; результат → Intent
},
)
}
Ключ команды: замена, отмена, защита от дублей
CommandKey — это имя «слота». В один слот помещается одна живая команда:
- Перезапуск. Запустили команду с тем же ключом — предыдущая с этим ключом автоматически отменяется. Не будет двух параллельных загрузок.
- Явная отмена. В любом переходе можно передать
cancel = setOf(CommandKey("load"))— остановить команду, ничего не запуская. - Без ключа — команда живёт сама по себе, её нельзя отменить и она ни с чем не конкурирует.
Команды с прогрессом
Если работа должна слать несколько промежуточных намерений (проценты загрузки, стадии),
используйте 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 = { LoadFailed(it.message ?: "…") },
) {
docUpdates.flow(docId).map(::Refreshed) // Flow<Doc> → Flow<Intent>
}
Скоуп: где подписка живёт
Аргументы active<…>() задают скоуп — список состояний, при которых
подписка должна работать. MEL сам следит за стеком: как только в стеке появляется подходящее
состояние — подписка стартует; как только все подходящие исчезли — она останавливается.
Пока в стеке есть Content или Confirm, поток жив и кормит машину намерениями.
Можно сузить скоуп предикатом: active<Content> { it.refreshing.not() } —
подписка активна только когда документ не в процессе обновления.
Надёжность
- Если у подписки задан
onError— упавший поток превращается в намерение (вы решаете, что показать). - Если
onErrorнет — MEL сам переподпишется с нарастающей паузой (1с → 2с → … → 30с). Поток восстановится, когда источник оживёт.
Команда запускается переходом, отрабатывает и заканчивается (один-два результата). Подписка привязана к состоянию и живёт, пока экран в нужном состоянии, выдавая поток событий. Команда — «сделай и доложи», подписка — «следи и сообщай».
Машина целиком
Теперь соберём кирпичики. Экран описывается классом-наследником 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 = loadCommand(CommandKey("load")))
}
on<Content, Refreshed> { s, i -> next(s.copy(doc = i.doc, refreshing = false)) }
on<Content, SignClick> { s, _ -> push(Confirm(s.doc)) }
// подтверждение (оверлей)
on<Confirm, ConfirmSign> { s, _ ->
next(s.copy(signing = true), command = command(
key = CommandKey("sign"),
onError = { LoadFailed(it.message ?: "…") },
) { Signed(repo.sign(s.doc)) })
}
on<Confirm, CancelSign> { _, _ -> pop() }
on<Confirm, Signed> { _, _ -> pop(DocEffect.ShowMessage("Подписано")) }
// живые обновления с сервера — пока на экране Content или Confirm
on<Content, Refreshed> // (см. клетку выше — обновляет doc)
subscribe(SubscriptionKey("docUpdates"), active<Content>(), active<Confirm>()) {
docUpdates.flow(docId).map(::Refreshed)
}
}
private fun loadCommand(key: CommandKey? = null) =
command(key, onError = { LoadFailed(it.message ?: "Ошибка") }) {
Loaded(repo.get(docId))
}
}
start() — это, по сути, нулевой переход: «появись в таком-то состоянии и,
если надо, запусти такую-то команду». startNext(Loading, command = …) делает ровно
это. Дальше всё крутит цикл из клеток.
Машина не хранит состояние сама — состоянием владеет рантайм (шаг 9). Машина — это только описание: правила, старт, настройки. Поэтому одну и ту же машину легко проверять и переиспользовать.
Секции: переиспользуемые куски графа
Когда граф разрастается или часть поведения повторяется на разных экранах, её выносят в
секцию — самостоятельную зону графа со своими клетками, подписками и зависимостями.
Секция подключается в граф одной строкой include(...).
// Зона «подписание»: своя ответственность, своя зависимость.
class SignSection(
private val repo: DocRepository,
) : Section<DocState, DocIntent, DocEffect>() {
override fun GraphBuilder<DocState, DocIntent, DocEffect>.register() {
on<Confirm, ConfirmSign> { s, _ -> /* … */ }
on<Confirm, CancelSign> { _, _ -> pop() }
on<Confirm, Signed> { _, _ -> pop() }
}
}
// В графе машины:
override val graph = graph {
on<Loading, Loaded> { _, i -> next(Content(i.doc)) }
// …
include(SignSection(repo)) // подключили всю зону подписания
}
- Секция — такой же «строитель графа», как и тело машины: те же
on,subscribe, те жеnext/push/pop. - Зависимости секции — её собственные (через конструктор).
- Если две клетки случайно столкнутся на одной паре (состояние, намерение) — граф не соберётся и подскажет, где дубль. То же с дублями ключей подписок.
Маленький экран целиком умещается в теле машины. Секции — инструмент для больших экранов и для повторяющихся зон, которые хочется подключать в несколько машин.
Рантайм и модель
Машина — это описание. Чтобы оно «ожило» и закрутило цикл, нужен рантайм.
Рантайм — это MelViewModel (наследник Android ViewModel): он держит стек,
гоняет клетки, исполняет команды и подписки. UI же работает не с конкретным рантаймом,
а с интерфейсом MelModel.
MelModel — что видит вью
| Член | Что это |
|---|---|
state: StateFlow<S> | Текущая вершина стека — то, что рисуем. |
stack: StateFlow<List<S>> | Весь стек (нужен для оверлеев). |
effects: Flow<E> | Поток эффектов — их разбирает эффект-слой (шаг 11). |
send(intent: I) | Отправить намерение в машину. |
pop() | Запросить «назад» (шаг 12). |
handlesBack: StateFlow<Boolean> | Владеет ли машина кнопкой «назад» сейчас (шаг 12). |
Как создать модель
Чаще всего — фабрикой melViewModel { … } прямо в Compose. Она создаёт и кеширует
рантайм по правилам ViewModel (переживает поворот экрана):
val model = melViewModel {
DocMachine(docId = "doc-42", repo, docUpdates)
}
Лямбда вызывается один раз при рождении модели. Если под одним владельцем
(одним ViewModelStoreOwner) живут два разных экрана — давайте им разные
key, чтобы они не «слиплись».
Новый key → новая модель → новый вызов start() → экран рождается
заново. Так, например, повторное открытие того же экрана с другим документом начинает всё с чистого
листа.
Экран: MelScreen
MelScreen — это корневой Composable, который связывает модель с UI. Внутри него
вы декларативно описываете три вещи: что рисовать, как исполнять эффекты и
(опционально) как анимировать переходы.
Обязателен только content. Остальное — по необходимости.
MelScreen(model = model, router = router) {
// 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).overlay<S>— наложение поверх базы: по умолчанию выезжает снизу и уходит вниз с анимацией. Соответствуетpush/popв стеке.
transitions — точечные анимации
По умолчанию смена базового вью — кроссфейд. Хотите другое между конкретными состояниями —
опишите between<A, B>(transform). Готовые трансформы: melCrossfade,
melFadeThrough, melSlideUp.
Эффект-слой и роутер
Эффекты — это данные, которые вернула клетка. Кто-то должен их исполнить: показать снек,
перейти на другой экран, закрыться. Это делает блок effects { }, а инструменты для
исполнения он получает из эффект-контекста.
ctx.showSnack(…)
Эффект-слой — единственное место, где экран «делает что-то снаружи».
Роутер — навигация наружу
Базовый контекст несёт один универсальный инструмент — router с методом
close(). Передайте свою реализацию в MelScreen(router = …):
через неё экран закрывается, а вы решаете, что значит «закрыть» (снять с навстека, вернуть
результат и т.п.).
Готовые эффекты закрытия
Два эффекта обрабатываются сами, без строчки в effects { }:
MelCloseEffect— пометьте им свой эффект (какDocEffect.Close), и дефолтный хендлер вызоветrouter.close().MelExhausted— служебный сигнал «стек опустел» (сделалиpopна последнем кадре). Тоже закрывает экран через роутер.
Свои инструменты — через расширение контекста
Базовый контекст знает только про роутер — это сделано нарочно, чтобы ядро не тащило в себя
Material и прочее. Нужен снек, диалоги, пейджер? Расширьте контекст. Готовое расширение для
снеков — MelSnackContext из модуля :core:mel-compose-material:
val snackbar = remember { SnackbarHostState() }
MelScreen(
model = model,
router = router,
context = { defaults -> MelSnackContext(defaults.router, snackbar) }, // расширили контекст
) {
content { /* … */ }
effects {
on<DocEffect.ShowMessage> { e, ctx -> ctx.showSnack(e.text) } // ctx — теперь MelSnackContext
}
}
Инструменты (роутер, снек) получает только блок effects. Вью их не видит —
и не должно: вью это чистая функция состояния (state in, intent out). Любое «действие наружу»
проходит ровно через одно место — эффект-слой.
Кнопка «назад»
Системная кнопка «назад» в MEL — это просто намерение MelBack. MelScreen
ловит её и решает, что с ней делать, по простому правилу:
- Стек глубже одного кадра (открыт оверлей/подэкран): «назад» снимает верхний кадр —
то есть делает
pop(). Это работает всегда, автоматически. - На корневом кадре (стек из одного состояния): «назад» перехватывается, только если
у машины есть клетка на
MelBackдля текущего состояния. Тогда «назад» — это решение машины (например, показать «выйти без сохранения?»). Иначе «назад» отдаётся системе (экран закрывается обычным образом).
Чтобы машина «владела» корневым back, добавьте клетку:
on<Content, MelBack> { _, _ -> stay(DocEffect.ShowMessage("Нажмите ещё раз для выхода")) }
Флаг model.handlesBack отражает, владеет ли машина back прямо сейчас — на нём
построен перехват. Вам обычно не нужно читать его вручную, но он есть.
Глубокий стек → «назад» закрывает верхний слой. Корень → back ваш, только если вы написали про него клетку; иначе он системный.
Конфиг: дебаунс, перехватчики, логи
Дебаунс — гасим частые тапы
Если намерение прилетает слишком часто (двойной клик «обновить», ввод в поиск), его можно дебаунсить — обрабатывать только после паузы. Указывается по типу намерения:
override val config = config {
debounce<Refresh>(500.milliseconds) // схлопываем серию «обновить»
}
Перехватчики — наблюдение за машиной
Перехватчик (MelInterceptor) видит каждый переход и каждое «потерянное» намерение —
удобно для логов, аналитики, отладки. Готовый — MelDebugInterceptor, печатает
переходы в лог:
override val config = config {
interceptor(MelDebugInterceptor("Doc"))
}
// в логе: [Doc] Content + Refresh -> next(Content) command=load
Логирование
Внутренние сообщения MEL идут через MelLog. По умолчанию это
android.util.Log, но можно подменить делегата (например, на свой логгер в проекте):
MelLog.delegate = MyAppLogger
Строгий режим в тестах
Флаг MelRuntime.failFast = true превращает «нарушения» (эффект без хендлера,
состояние без вью, исключение в клетке) из логов в исключения. Включайте его в тестах —
чтобы ошибки контракта падали громко, а не тонули в логе.
Тестирование
Поскольку клетка — чистая функция, тест клетки — это «дал вход, проверил выход». Модуль
:core:mel-test даёт две удобные проверки.
Transition.assert — проверка одной клетки
Прогоните клетку через graph.reduce(state, intent) и проверьте переход по фактам
(прямой assertEquals не годится — внутри команды лежит лямбда):
graph.reduce(Content(doc), Refresh)!!.assert {
to<Content> { it.refreshing } // перешли в Content с флагом
command("load") // запустилась команда «load»
noEffects() // эффектов не было
}
Доступные проверки: to<T>{}, pushed<T>{},
popped(), poppedTo<T>(), stayed(),
command(key) / noCommand(), effect<E>{} /
noEffects(), cancels(...). Само тело команды (suspend-работу)
тестируют отдельно как обычный suspend-код.
melGraphAssert — страж полноты графа
Один тест на машину, который проверяет, что в графе нет дыр: не забыта ли клетка для нового намерения, нет ли «мёртвых» намерений без клеток, нет ли состояний без поведения. Сознательные пропуски помечаются с причиной — чтобы они были видны в ревью.
@Test fun graph() = melGraphAssert(DocMachine("doc", fakeRepo, fakeUpdates)) {
terminal<Error>() // у Error нет клеток — сознательно
allowUnhandled<Confirm, Refresh>("под оверлеем не обновляем")
}
Логика экрана проверяется без 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)
Граф (внутри graph { } и секций)
on<S, I> { s, i -> … } | клетка для пары (состояние, намерение) |
onAny<I> { s, i -> … } | клетка для намерения в любом состоянии |
subscribe(key, active<S>(), …) { flow } | подписка со скоупом состояний |
active<S> { predicate } | элемент скоупа подписки |
include(section) | подключить секцию |
Переходы (возврат клетки)
next(to, …effects, command, cancel) | заменить вершину |
stay(…effects, command, cancel) | не менять состояние |
push(to, …) / pop(…) | положить / снять кадр стека |
popTo<Target>(…) | снимать до состояния-цели |
command(key, onError) { … } | async-работа → Intent |
commandWithProgress(key, onError) { emit -> … } | команда с промежуточными Intent |
Машина
MelMachine<S, I, E> | базовый класс экрана |
start() → startNext(to, …, command) | рождение экрана |
val graph = graph { … } | таблица клеток/подписок |
val config = config { debounce<I>(…); interceptor(…) } | настройки |
Section<S, I, E> { register() } | переиспользуемая зона графа |
Рантайм и UI
melViewModel(key) { machine } | создать модель в Compose |
MelModel: state, stack, effects, send(i), pop(), handlesBack | фасад для UI |
MelScreen(model, router, context) { … } | корень экрана |
content { view<S>{}; overlay<S>{} } | что рисовать |
effects { on<E> { e, ctx -> … } } | как исполнять эффекты |
transitions { between<A, B>(transform) } | анимации |
MelRouter.close(), MelCloseEffect, MelExhausted | закрытие экрана |
MelSnackContext(router, snackbar).showSnack(…) | снек (модуль mel-compose-material) |
Тесты
graph.reduce(state, intent)!!.assert { … } | проверка одной клетки |
melGraphAssert(machine) { terminal<S>(); allowUnhandled<S,I>("…") } | полнота графа |
MelRuntime.failFast = true | строгий режим |
Правила и инварианты
Несколько принципов, которые держат код предсказуемым. MEL за большинство из них следит сам — нарушения логирует (или роняет в строгом режиме).
- Вью — чистая функция состояния. Только читает state и шлёт intent. Никаких побочных действий, никакого доступа к роутеру/снеку.
- Клетка — чистая функция. Возвращает Transition как данные, ничего не исполняет сама. Вся асинхронщина — через команды и подписки.
- Факт — в состоянии, действие — в эффекте. «Идёт загрузка» — поле состояния. «Показать снек» — эффект. Спиннеры гаснут сами, потому что это факт, который снимает следующий шаг.
- Каждое состояние имеет вью. Состояние без зарегистрированного
view/overlay— нарушение. - Каждый эффект имеет хендлер. Эффект без
on<E>(и не из готовых закрывающих) — нарушение. - Уникальность. Две клетки на одну пару (S, I) или два одинаковых ключа подписки — граф не соберётся.
- Зависимости — через конструктор машины и секций. Никаких глобальных синглтонов внутри клеток.
- Закрытие — только через роутер (
MelCloseEffect/MelExhausted). Экран не закрывает сам себя в обход эффект-слоя.
Это руководство описывает, как пользоваться MEL.
Точные инварианты реализации и формальная модель — в каноническом документе архитектуры
mel-design.html. Если код и оба документа разойдутся — источником истины считается код,
затем спека.