MEL — пошаговое руководство

MEL — это способ описать экран как маленькую машину состояний: что экран показывает, какие события на нём происходят и как одно превращается в другое. Здесь — всё по шагам, простыми словами, с разбором каждой детали и связи между ними.

Идея за минуту

Любой экран в MEL крутит один и тот же цикл из четырёх шагов:

  1. Вью показывает текущее состояние и шлёт наружу намерения («нажали кнопку»).
  2. Машина принимает намерение и решает, что делать — это называется reduce.
  3. Результат решения — переход: новое состояние плюс, при необходимости, побочные дела.
  4. Новое состояние возвращается во вью — цикл замкнулся.
Вью (Compose) рисует State, шлёт Intent Машина · reduce (State, Intent) → решение Transition новое состояние + дела Новый State вершина стека Intent — send() reduce stack op render

Однонаправленный цикл: данные идут только по кругу, в одну сторону.

Главное правило

Вью никогда не меняет данные напрямую. Оно только отправляет намерение и рисует состояние. Всё остальное — работа машины. Поэтому экран легко понять, проверить и протестировать.

Дальше мы соберём такой экран с нуля: сначала словарь, потом по одному кирпичику — переходы, стек, команды, подписки, UI и тесты. Сквозной пример — экран документа, который грузится, обновляется и подписывается.

Шаг 1

Словарь экрана: 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 при этом выпустить.

Шаг 2

Клетка: один шаг машины

Машина — это таблица правил. Каждое правило отвечает на вопрос: «если экран в состоянии X и пришло намерение Y — что делать?». Такое правило называется клеткой (cell) и пишется через on<State, Intent>:

on<Loading, Loaded> { state, intent ->
    next(Content(intent.doc))
}

Читается как фраза: «находясь в Loading, получив Loaded — перейти в Content с этим документом». Лямбда получает типизированные state и intent (ровно тех типов, что в угловых скобках), а возвращает переход — что описано в шаге 3.

StateLoading
+
IntentLoaded(doc)
клетка on<,>
Transitionnext(Content(doc))

Одна клетка = одно правило «состояние + событие → переход».

Как машина выбирает клетку

Когда приходит намерение, машина ищет клетку, чья пара (тип состояния, тип намерения) подходит к текущему состоянию и пришедшему намерению. Если подходящих несколько — выбирается самая специфичная (по точному типу). Если клетки нет вовсе — намерение просто игнорируется (тихо логируется как «drop»). Это нормально: значит, событие неактуально в этом состоянии.

Варианты записи клеток

ФормаКогда
on<S, I> { s, i -> … }Обычная клетка: конкретное состояние + конкретное намерение.
onAny<I> { s, i -> … }Намерение I обрабатывается в любом состоянии (например, глобальное «закрыть»).
Никаких if (state is …) и приведений типов

Внутри клетки state и intent уже нужного типа — проверки и касты делать не надо. Это и есть «диспетчеризация по типам»: разбор вариантов вынесен в саму структуру таблицы клеток.

Шаг 3

Переход: что возвращает клетка

Клетка всегда возвращает Transition — описание того, что должно произойти. Это просто данные (никаких немедленных действий), которые складываются из четырёх частей:

opчто сделать со стеком
effectsчто выпустить наружу
command?какую async-работу запустить
cancelкакие команды остановить

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())

Куда что уходит после возврата перехода:

Transition
op
Стек состояний обновился
effects
Эффект-слой (снек, роутер…)
command
Корутина → новый Intent
Клетка — чистая функция

Клетка ничего не делает «прямо сейчас»: она не ходит в сеть, не показывает диалоги, не трогает экран. Она только описывает переход и возвращает его как данные. Поэтому её тривиально протестировать — дал вход, проверил выход (шаг 14).

Шаг 4

Стек состояний

Состояние экрана — это не одно значение, а стек. Обычно в нём один кадр (основной экран). Но когда нужно показать что-то поверх — диалог, пуллер, шторку — вы делаете push, и новый кадр ложится сверху. pop снимает его обратно.

базаContent(doc)
оверлейConfirm(doc) ← вершина

После 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 под оверлеем. Так оверлей и фон не мешают друг другу.

Шаг 5

Команды: асинхронная работа

Клетки синхронны и чисты — они не умеют «сходить в сеть». Для этого есть команды. Команда — это кусок suspend-работы, который машина запустит за вас, а результат вернёт обратно в виде намерения. Так цикл остаётся замкнутым: всё, что приходит в машину, — это намерения, неважно, от клика они или от сети.

Клетка вернула next(…, command = …) Корутина suspend { repo.get() } Intent Loaded / LoadFailed Машина · reduce запуск результат снова в цикл

Команда уходит работать в фон, а возвращается обычным намерением — и снова через 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). Спиннер гаснет «по построению» — отдельного эффекта «спрятать спиннер» не существует.

Шаг 6

Подписки: внешние потоки

Команда — это «сделать раз и вернуть результат». А что, если данные приходят сами и непрерывно — пуши, сокет, поток из БД? Для этого есть подписки. Подписка превращает внешний 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 сам следит за стеком: как только в стеке появляется подходящее состояние — подписка стартует; как только все подходящие исчезли — она останавливается.

active<Content>скоуп подписки
источник: Flow<Doc>
map
Intent: Refreshed
reduce

Пока в стеке есть Content или Confirm, поток жив и кормит машину намерениями.

Можно сузить скоуп предикатом: active<Content> { it.refreshing.not() } — подписка активна только когда документ не в процессе обновления.

Надёжность

  • Если у подписки задан onError — упавший поток превращается в намерение (вы решаете, что показать).
  • Если onError нет — MEL сам переподпишется с нарастающей паузой (1с → 2с → … → 30с). Поток восстановится, когда источник оживёт.
Команда vs подписка — в чём разница

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

Шаг 7

Машина целиком

Теперь соберём кирпичики. Экран описывается классом-наследником 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). Машина — это только описание: правила, старт, настройки. Поэтому одну и ту же машину легко проверять и переиспользовать.

Шаг 8

Секции: переиспользуемые куски графа

Когда граф разрастается или часть поведения повторяется на разных экранах, её выносят в секцию — самостоятельную зону графа со своими клетками, подписками и зависимостями. Секция подключается в граф одной строкой 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.
  • Зависимости секции — её собственные (через конструктор).
  • Если две клетки случайно столкнутся на одной паре (состояние, намерение) — граф не соберётся и подскажет, где дубль. То же с дублями ключей подписок.
Секция не обязательна

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

Шаг 9

Рантайм и модель

Машина — это описание. Чтобы оно «ожило» и закрутило цикл, нужен рантайм. Рантайм — это 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() → экран рождается заново. Так, например, повторное открытие того же экрана с другим документом начинает всё с чистого листа.

Шаг 10

Экран: MelScreen

MelScreen — это корневой Composable, который связывает модель с UI. Внутри него вы декларативно описываете три вещи: что рисовать, как исполнять эффекты и (опционально) как анимировать переходы.

content { }state → @Composable
effects { }effect → действие
transitions { }анимации (опц.)

Обязателен только 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.

Шаг 11

Эффект-слой и роутер

Эффекты — это данные, которые вернула клетка. Кто-то должен их исполнить: показать снек, перейти на другой экран, закрыться. Это делает блок effects { }, а инструменты для исполнения он получает из эффект-контекста.

Клетка вернула эффектShowMessage / Close
effects flow
on<Effect> { e, ctx -> }хендлер
ctx
router.close()
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). Любое «действие наружу» проходит ровно через одно место — эффект-слой.

Шаг 12

Кнопка «назад»

Системная кнопка «назад» в MEL — это просто намерение MelBack. MelScreen ловит её и решает, что с ней делать, по простому правилу:

  • Стек глубже одного кадра (открыт оверлей/подэкран): «назад» снимает верхний кадр — то есть делает pop(). Это работает всегда, автоматически.
  • На корневом кадре (стек из одного состояния): «назад» перехватывается, только если у машины есть клетка на MelBack для текущего состояния. Тогда «назад» — это решение машины (например, показать «выйти без сохранения?»). Иначе «назад» отдаётся системе (экран закрывается обычным образом).

Чтобы машина «владела» корневым back, добавьте клетку:

on<Content, MelBack> { _, _ -> stay(DocEffect.ShowMessage("Нажмите ещё раз для выхода")) }

Флаг model.handlesBack отражает, владеет ли машина back прямо сейчас — на нём построен перехват. Вам обычно не нужно читать его вручную, но он есть.

Простое правило

Глубокий стек → «назад» закрывает верхний слой. Корень → back ваш, только если вы написали про него клетку; иначе он системный.

Шаг 13

Конфиг: дебаунс, перехватчики, логи

Дебаунс — гасим частые тапы

Если намерение прилетает слишком часто (двойной клик «обновить», ввод в поиск), его можно дебаунсить — обрабатывать только после паузы. Указывается по типу намерения:

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 превращает «нарушения» (эффект без хендлера, состояние без вью, исключение в клетке) из логов в исключения. Включайте его в тестах — чтобы ошибки контракта падали громко, а не тонули в логе.

Шаг 14

Тестирование

Поскольку клетка — чистая функция, тест клетки — это «дал вход, проверил выход». Модуль :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, ведь вью — чистые функции состояния.

Итог

Полная картина: части и связи

Соберём всё вместе. Слева — что вы пишете, по центру — рантайм, который это исполняет, справа — внешний мир.

MelScreen · content view / overlay по State MelMachine · graph клетки on<S,I> MelViewModel стек · цикл · исполнение MelScreen · effects on<E> → действие Команды / Подписки async → Intent Repo / источники сеть · БД · пуши Роутер / снек внешний мир send(Intent) reduce Transition State launch Intent зависимости Effect close / снек

Слева — UI (вход content и выход effects), по центру — рантайм с правилами, справа — асинхронщина, её источники и внешний мир. Стрелки внутрь рантайма — всегда намерения; стрелки наружу — состояние и эффекты.

Кто за что отвечает

ЧастьРольСвязи
State / Intent / EffectСловарь экрана.На нём говорят все остальные.
Клетка on<S,I>Правило «состояние + событие → переход».Чистая функция: вход — state+intent, выход — Transition.
TransitionОписание шага: стек + эффекты + команда.Это данные; исполняет их рантайм.
Машина / секцииВся таблица правил + старт + конфиг.Зависимости — через конструктор.
КомандыРазовая async-работа.Запускаются переходом, возвращают Intent.
ПодпискиНепрерывные внешние потоки.Живут по скоупу состояний, выдают Intent.
MelViewModelРантайм: стек, цикл, исполнение.Реализует MelModel для UI.
MelScreenUI: 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. Если код и оба документа разойдутся — источником истины считается код, затем спека.