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
}

// Что на экране может произойти. Группируем по состоянию, которому событие принадлежит
// (на эту группировку опирается страж полноты графа, шаг 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 при этом выпустить.

Шаг 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))

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

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

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

Шаг 3

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

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

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

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

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

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

Фоновый кадр может менять только себя: его клетка возвращает next или stay. push/pop/popTo из фоновой клетки — нарушение, такой переход игнорируется целиком.

Шаг 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 = { 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). Спиннер гаснет «по построению» — отдельного эффекта «спрятать спиннер» не существует.

Шаг 6

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

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

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

Пока в стеке есть 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.
Команда 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 = 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 даёт по ребру на ветку, а хвостовой вызов функции машины/секции разворачивает в её переходы.

Шаг 8

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

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

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

Шаг 9

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

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

Шаг 10

Экран: MelScreen

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

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

Обязателен только 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-группу), действует объявленное первым. Блок управляет только сменой базовых вью — на оверлеи он не влияет.

Шаг 11

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

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

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

Шаг 12

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

Системная кнопка «назад» в 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 всегда главнее дефолта ядра.

Шаг 13

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

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

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

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

Шаг 14

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

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

Итог

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

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

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) 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. Если код и оба документа разойдутся — источником истины считается код, затем спека.