Об имени. MEL = M + EL: Machine + Эль(нар) — рабочее название, автор склеен в одном слове.
Второе имя, шуточное: TEMA — The Elnar Machine Architecture, прямой потомок
TEA (The Elm Architecture). Отдельное имя — осознанная отстройка от нумерации MVI: MEL не пятая версия зоопарка,
а его замена. В коде это пакет ru.mel.core.mel (разложенный по подпакетам
contract/effect/transition/graph/machine/log/runtime/compose), публикуемый под координатами ru.mel.
MEL — цикл Elm/MVU-типа поверх явной стейт-машины, реализованный в модуле :core:mel. Экран описывается
одним классом-машиной (MelMachine<S, I, E>, создаётся фабрикой с аргументами экрана — §4.4):
sealed-стейты владеют фактами, интенты привязаны к стейтам типами (интент чужого стейта рантайм дропает с логом, не CCE),
переходы — чистые быстрые функции (Transition), вся асинхронщина — декларации (Command → интент,
Subscription — функция от стейта), one-shot к UI — Effect-данные, исполняемые в композиции против
типизированных MelEffectContext.
@AssistedInject + typed routes (§4.4).is: все связи — типизированные регистрации (on<Стейт, Интент>,
subscribe<Стейт>, view<Стейт>, on<Эффект>); смарт-кастов нет.
Полноту стережёт melGraphAssert по sealed-листьям (тест) + fail-fast в debug + detekt-правило MelNoTypeChecks.Channel(UNLIMITED) + единственный consumer — гонки интентов
исчезают по построению), эффекты буферизуются (не теряются), ошибки — обычные интенты (onError; механики
errorHandler нет), подписки живут ровно в тех стейтах, где объявлены (дифф по ключам), есть стек состояний
(push/pop/popTo) для внутриэкранных оверлеев.MelScreen { content{…}; effects{…}; transitions{…} } — builder с дефолтами;
MelContent = AnimatedContent + оверлеи + SaveableStateHolder;
MelEffectHost = буфер + repeatOnLifecycle(STARTED).:core:mel-test), 8 detekt-правил
(:tools:detekt-rules), публикация в Nexus, пилот qr-sign. Тесты: :core:mel — 27,
:core:mel-test — 29, detekt — 41, пилот — 18.Каждый принцип — ответ на конкретную боль зоопарка MVI sbbol (v3: 44 модели, 897 ручных биндингов; SimpleMvi: 241 файл в ~30 фичах):
| Боль | Принцип MEL · как реализовано |
|---|---|
897 ручных @Binds @IntoMap @*Key; забытый биндинг — рантайм-краш; неверный ключ — CCE |
Нет ключей, карт и is. Диспатч — типизированные регистрации on<Стейт, Интент>
(GraphBuilder); полнота — melGraphAssert по sealed-листьям + fail-fast в debug. |
Интент в «чужом» состоянии: unchecked cast state as S → CCE или тихо-кривое поведение (стейл-клики);
SimpleMvi setStateAs<T> — тихий скип |
Интенты привязаны к стейтам типами. Стейл-интент — определённая политика: drop + лог + onDropped
интерсептору (MelViewModel.onUnrouted), а не каст и не тишина. |
Гонки параллельных send (Mutex-фикс как заплатка), updateState/errorHandler-гочи,
awaitState-цепочки |
Переходы чистые и быстрые; один последовательный цикл. Асинхронщина — только декларации Command;
её результат и её ошибка — обычные интенты. Один consumer канала — Mutex не нужен. |
Эффекты теряются (replay=0), отменяются при смене класса стейта; KeepAliveEffect-костыль;
«подписки в привязке к списку» достигаются случайно |
Два разных канала. Effect — буферизованный one-shot к UI (не теряется, собирается в
repeatOnLifecycle). Subscription — поток интентов, чей скоуп — функция от стейта:
рантайм диффит набор после каждого перехода. |
screenState-мешок: факты (showLoader, UseLoader-интент) контрабандой в инструментах;
зависшие лоадеры; OnStateChanged-танцы |
Факты — в стейте, инструменты — в MelEffectContext. Эффекты — данные, не лямбды. Лоадер гаснет по
построению: ошибка приходит клеткой (isSigning = false), а не теряется. |
DebounceMutator со списком UniqueUtils-строк; дабл-клики как UX-патч |
Дабл-клик — клетка машины (гейт фактом isSigning), debounce — типизированная декларация
config { debounce<Rescan>(500.ms) }. |
| kotlin-reflect на каждом диспатче; eager-создание ~100 обработчиков на модель | Рефлексии в горячем пути нет — lookup по Class (findCell). Рефлексия — только в тесте
полноты (перечисление sealed-листьев). Создаётся одна машина (+ её секции) на модель. |
Правило словаря: знакомые слова сохраняют смысл, новые имена — только у новой семантики.
Код разложен по подпакетам ru.mel.core.mel.* — слои без циклов:
contract · effect · transition · log →
graph → machine → runtime → compose.
Правило префикса Mel. Пакет снимает неоднозначность для компилятора; префикс —
узнаваемость и отсутствие коллизий у потребителя и в плоском контексте, где пакета не видно
(наследование, лог, трейс, автокомплит). Поэтому Mel несут: (1) маркеры контрактов,
которые реализует код экрана — MelState, MelIntent, MelEffect,
MelFacts, MelPopRequest, capability-маркеры Mel…Effect;
(2) публичная поверхность — MelMachine, MelViewModel/MelModel,
MelScreen, MelOps, MelConfig, MelEffectContext, MelRouter,
MelInterceptor, MelRuntime, MelLog, готовые эффекты
(терминальный MelExhausted, маркер MelCloseEffect). Без префикса — внутренний
словарь DSL, namespace'нутый пакетом: Graph, Cell, ActiveClause, Subscription,
ReduceScope, Command, Transition, MelStart, StackOp, Section,
типизированные ключи CommandKey/SubscriptionKey.
Третий столбец — пакет/файл в :core:mel.
| Имя | Что это | Тип/файл в :core:mel |
|---|---|---|
| MEL (шутл. TEMA) | Имя подхода: Machine + Эль(нар); потомок TEA. Замена зоопарка, не версия. | пакет ru.mel.core.mel |
State | Sealed-стейты экрана, владеют фактами. Корень реализует маркер. | MelState (маркер, contract/Contracts.kt) |
Intent | Единственный вход машины: от UI, от команд, от подписок. Привязан к стейту типом. | MelIntent (маркер) |
Effect | One-shot к UI (снек, навигация, диалог). Данные, не лямбды. | MelEffect (маркер); сквозные — MelCommonEffect |
Command | Асинхронная работа, рождённая переходом и обязанная завершиться интентом. Отмена — cancel(key) из перехода; дубль ключа = replace. Прогресс — commandWithProgress. | Command<I> (transition/Command.kt); билдеры в MelOps |
Subscription | Поток интентов с ключом и стейт-скоупом (Elm: Sub). Живёт, пока стейт её скоупа в стеке. | Subscription<I> (graph/Graph.kt); скоуп — vararg-клаузы active<…>() |
reduce / клетка | Функция перехода одной пары (стейт, интент) — юнит логики. | Cell<S, E> (graph/Graph.kt) |
Section | Связный набор клеток (+ подписок) с @Inject-зависимостями — переиспользуемый юнит (§6.7). | Section<S, I, E> (machine/Section.kt) |
Transition / MelStart | Результат клетки: (op, effects, command, cancel); операция стека — StackOp. Рождение машины — узкий MelStart (to + effects/command/cancel, toTransition()), §4.1. | Transition, MelStart, StackOp (transition/Transition.kt) |
билдеры (ReduceScope) | Словарь переходов next · stay · push · pop · popTo<S>; команды — command / commandWithProgress; рождение — startNext. Общий receiver клетки/start/графа. | ReduceScope<S, I, E> (graph/ReduceScope.kt); реализация — MelOps (machine/MelOps.kt) |
MelMachine<S, I, E> | Определение экрана: start(), graph, config. Создаётся фабрикой с аргументами. | machine/MelMachine.kt |
MelViewModel | Рантайм-цикл: дифф подписок, буфер эффектов, стек. Тонкий, общий. | runtime/MelViewModel.kt (реализует MelModel) |
MelRuntime | Режим строгости и репортер нарушений контракта (failFast, violation()) — не цикл (цикл — MelViewModel). В debug падает, в release логирует. | runtime/MelRuntime.kt |
MelInterceptor | Сквозной наблюдатель цикла (переходы, дропы). Только наблюдение; MelDebugInterceptor — из коробки. | machine/MelInterceptor.kt |
MelBack | Системный Back — высота интеграции (compose), не ядро. Реализует MelPopRequest; дефолт рантайма при отсутствии своей клетки: pop() пока стек глубже одного, иначе терминальный MelExhausted. | compose/MelBack.kt |
MelPopRequest | Нейтральный к навигации маркер: неотмаршрутизированный интент с ним → дефолтный pop() стека ядра. Ядро знает про свой стек, не про «Back». | маркер в contract/Contracts.kt |
MelExhausted | Терминальный lifecycle-эффект: попнут последний фрейм стека (MelPopRequest на дне либо pop() со дна). Трактовку «закрыть экран» даёт дефолт интеграции, не ядро. | effect/MelCommonEffect.kt |
MelScreen | Экранный builder с дефолтами (§4.7): обязателен только content{}. Вставляемый — принимает modifier, кладётся в свой Scaffold/слот. | compose/MelScreen.kt |
MelContent / MelEffectHost | Композиционные регистраторы: вью по типу стейта (view/overlay) и исполнение эффектов против MelEffectContext. | compose/MelContent.kt, compose/MelEffects.kt |
MelEffectContext / MelRouter | Типизированный контекст эффект-слоя: база несёт роутер; snackbar host и пр. — расширения на конкретных экранах. Не State. | compose/MelEffectContext.kt |
melGraphAssert / Transition.assert | Страж полноты графа и assert-DSL клеток. | :core:mel-test |
:core:mel, ~десяток небольших файлов)Машина и секции делят общую базу билдеров MelOps — клетки строят Transition одним словарём:
// MelMachine.kt — определение экрана (S:MelState, I:MelIntent, E:MelEffect — маркеры контрактов)
abstract class MelMachine<S : MelState, I : MelIntent, E : MelEffect> : MelOps<S, I, E>() {
/** Рождение машины (§4.4). Аргументы уже в конструкторе. Возвращает узкий тип MelStart
(val to:S + effects/command/cancel), а не Transition: рождение кладёт первый стейт
в пустой стек, поэтому стековая операция тут лишь одна — startNext(to, …). */
abstract fun start(): MelStart<S, E>
/** Граф: ВСЕ связи экрана — типизированные регистрации, без is/смарт-кастов. */
abstract val graph: Graph<S, I, E>
/** Декларации поведения интентов: debounce, интерсепторы — типами, не строками. */
open val config: MelConfig = MelConfig.EMPTY
}
// ReduceScope.kt — словарь переходов (interface, общий receiver клетки/start/графа).
// MelOps : ReduceScope (билдеры override), GraphBuilder : ReduceScope; Cell.reduce
// получает receiver ReduceScope, так что s,i -> next(…) одинаков в машине, секции и тесте.
// Ключи команд/подписок — типизированные value class: CommandKey/SubscriptionKey (§4.1).
fun next(to: S, vararg effects: E, command: Command<I>? = null, cancel: Set<CommandKey> = emptySet()): Transition<S, E>
fun stay(vararg effects: E, command: Command<I>? = null, cancel: Set<CommandKey> = emptySet()): Transition<S, E>
fun push(to: S, vararg effects: E, command: Command<I>? = null, cancel: Set<CommandKey> = emptySet()): Transition<S, E>
fun pop(vararg effects: E, command: Command<I>? = null, cancel: Set<CommandKey> = emptySet()): Transition<S, E>
fun popTo(target: Class<out S>, vararg effects: E, command: Command<I>? = null, cancel: Set<CommandKey> = emptySet()): Transition<S, E>
inline fun <reified T : S> popTo(vararg effects: E, command: Command<I>? = null, cancel: Set<CommandKey> = emptySet()): Transition<S, E>
/** Асинхронная работа: suspend-блок → финальный интент; ошибка — тоже интент (onError).
Дубль key = replace. Механики errorHandler нет. */
fun command(key: CommandKey? = null, onError: (Throwable) -> I, block: suspend () -> I): Command<I>
/** Команда с прогрессом: промежуточные эмиссии + ОБЯЗАТЕЛЬНЫЙ финал (возврат блока).
Отдельное имя, а не перегрузка command — чтобы трейлинг-лямбда без параметров
не давала неоднозначность перегрузок. */
fun commandWithProgress(key: CommandKey? = null, onError: (Throwable) -> I,
block: suspend (emit: suspend (I) -> Unit) -> I): Command<I>
// MelOps.kt — реализация ReduceScope (база машины и секции) + узкий билдер рождения:
// startNext(to, …): MelStart — отдельный узкий тип старта, НЕ Transition (§4.4).
protected fun startNext(to: S, vararg effects: E, command: Command<I>? = null,
cancel: Set<CommandKey> = emptySet()): MelStart<S, E>
Граф — обычное значение со списком регистраций; собирается DSL-ом graph { … }. Дубли клеток и ключей подписок —
ошибка сборки с именами секций (require в GraphBuilder):
class GraphBuilder<S : MelState, I : MelIntent, E : MelEffect> : ReduceScope<S, I, E> {
/** Клетка: (стейт, интент или sealed-группа) → обработчик. reduce — на receiver-е
ReduceScope (билдеры next/stay/… доступны как s,i -> next(…)). on СТРОГ: II : I —
только свои интенты экрана (чужой интент/опечатка = ошибка компиляции). Сквозные типы
(MelPopRequest, напр. MelBack), не подклассы контракта экрана, перехватывает onAny (II : MelIntent). */
inline fun <reified SS : S, reified II : I> on(reduce: ReduceScope<S, I, E>.(SS, II) -> Transition<S, E>)
inline fun <reified II : MelIntent> onAny(reduce: ReduceScope<S, I, E>.(S, II) -> Transition<S, E>) // сквозные (Back и т.п.)
/** Клауза скоупа подписки (§7.2): стейт + типизированный предикат именно на этот стейт.
Конструируется reified active<SS> { … }; имя 'active' (не 'on') — чтобы не сталкиваться
с регистрацией клеток. Дефолт предиката — { true }. */
inline fun <reified SS : S> active(predicate: (SS) -> Boolean = { true }): ActiveClause<S>
/** Подписка со стейт-скоупом: живёт, пока в стеке есть запись, подходящая под одну из
клауз (стейт клаузы И её предикат==true). Единый subscribe без арности — скоуп задают
vararg-клаузы active<…>(); ≥1 клауза ОБЯЗАТЕЛЬНА (пустой скоуп = мёртвая подписка =
ошибка сборки). onError обязателен для падающих источников (без него —
debug-краш / бэкофф в release). Дубль ключа — ошибка сборки. */
fun subscribe(key: SubscriptionKey, vararg clauses: ActiveClause<S>,
onError: ((Throwable) -> I)? = null, source: () -> Flow<I>)
fun include(section: Section<S, I, E>) // секция приносит свои клетки и подписки
}
Результат клетки — Transition(op, effects, command, cancel), где op — sealed
StackOp: Next(to) · Push(to) · Pop · PopTo(target) · Stay.
Прямой assertEquals упирается в лямбду внутри Command — для тестов клеток есть assert-DSL в
:core:mel-test (§8).
Прогрессная команда (форма для загрузки вложения / пакетного подписания «12 из 50»). Правило выбора против подписки: команда конечна и рождена переходом; подписка бесконечна и выведена из стейта:
on<Editing, AttachFile> { s, i -> next(
s.copy(upload = UploadFacts(i.uri, progress = 0f)),
command = commandWithProgress(key = CommandKey("upload"), onError = ::UploadFailed) { emit ->
files.upload(i.uri).collect { p -> emit(UploadProgress(p)) }
UploadDone(files.result()) // обязательный финал
},
)}
on<Editing, CancelUpload> { s, _ -> next(s.copy(upload = null), cancel = setOf(CommandKey("upload"))) } // отмена — клетка
MelViewModel, один на все экраны)Channel<MelIntent>(UNLIMITED) + единственный consumer в viewModelScope.launch(loopDispatcher).
Интенты обрабатываются строго по одному — класс гонок v3 не существует; Mutex не нужен. Канал типизирован MelIntent
(объединение I ∪ MelBack): несёт и интенты экрана, и сквозные типы (интеграционный MelBack : MelPopRequest : MelIntent).
Публичный вход — MelModel.send(intent: I) (строго типизирован экраном); тонкий MelModel.pop() кладёт в тот же канал
MelBack для BackHandler (§4.7) — отдельного нетипизированного входа нет.effects: Flow<E>: публичный поток эффектов типизирован E экрана и честен —
несёт только эффекты экрана, никаких unchecked-каст. Внутри это effectChannel: Channel<E>. Ядровой терминал
MelExhausted идёт отдельным внутренним потоком MelViewModel.coreEffects: Flow<MelCommonEffect>
(internal, не часть MelModel). Мерджит его единственный потребитель — MelEffectHost:
merge(model.effects, coreEffects) на границе общего супертипа MelEffect (дефолт-хендлеры фильтруют по классу маркера).
Так публичный effects: Flow<E> остаётся типобезопасным для любого коллектора с конкретным E (раньше подмешивание
MelExhausted через map { it as E } рвало такой коллектор CCE). Отдельного публичного host-канала нет
(мост host/child — будущий дизайн, §4.3/§11).loopDispatcher (по умолчанию
Dispatchers.Default; в тестах — StandardTestDispatcher, цикл детерминирован). Стейт — StateFlow,
UI собирает на Main. Следствие: клетки обязаны быть быстрыми — тяжёлое в командах (они конкурентны), не в переходах.Graph.findCell — единый источник истины «наиболее специфичной клетки»: лист интента → его sealed-группа →
onAny, lookup по Class). Вершина применяет переход полностью; фоновая запись может только обновлять
себя (stay/next своего индекса) — стековая операция из фона = violation. Интент без клетки —
drop + структурный лог + onDropped интерсептору, не CCE.UNLIMITED, UI собирает их объединённый effects в
repeatOnLifecycle(STARTED) через MelEffectHost. Буферизуются без подписчика — доставка гарантирована
(закрывает «эффект до подписчика теряется» и гонку холодного старта qr-сканера).cancel-набор перехода отменяет команды по ключу до запуска
команды этого же перехода.stay перехода; желаемый набор — union по
всему стеку. Ключ остался — не трогать; выпал — отмена; появился — запуск. Источник без onError при падении —
лог + переподписка с бэкоффом (1с → ×2 → потолок 30с), в debug-режиме строгости (failFast) — краш.onError-интент; в источнике подписки → её onError или
бэкофф; в reduce — программная ошибка (переходы чистые, IO запрещён конвенцией) → краш в debug, лог + поведение
stay в release. Режим строгости — MelRuntime.failFast (приложение включает в debug).Грабля, решённая в реализации (close-vs-cancel). Корутины рантайма (команды, прогресс, подписки, дебаунс) кладут
интенты не через suspend-send, а через enqueue = Channel.trySend. На UNLIMITED-канале
доставка эквивалентна, но на закрытом канале (после onCleared) trySend возвращает failure, а не
бросает ClosedSendChannelException. Это закрывает гонку «джоба завершилась и шлёт результат между close()
канала и распространением отмены scope» (подтверждена адверсариальным ревью).
Мульти-модельные хосты (в auth 8 экранов в одном компоненте) — реальный кейс. В MEL машины изолированы, поэтому связь — явный
мост: хост владеет дочерними машинами и видит их эффекты; ребёнок о хосте не знает, помечает часть эффектов маркером
«адресовано выше» (маркер вводится вместе с мостом). Ни маркера, ни рантайма моста (bridge(child){…}) в core пока нет — это первый
открытый вопрос пилота мульти-модельного экрана (§11). Пока внутриэкранные подсостояния решаются стеком (§7.3), а межэкранные —
эффектом + роутером (§7.4); ни то, ни другое моста не требует.
Аргументы — параметры рождения машины, а не интенты. В v3 экраны слали себе при старте 12 интентов Args.*
(New, ExistingById…) каналом событий с гонками cold-start — из-за DI-ограничения ViewModel-карты. В MEL
причина не существует: машина создаётся фабрикой, аргументы лежат в конструкторе к моменту start().
// пилот qr-sign: аргумент — типизированные данные (бывший интент FromOperation)
data class QrSignArgs(val operationId: String)
class QrSignMachine(
private val args: QrSignArgs, // в сэмпле — руками; в проекте @AssistedInject
private val interactor: IQrSignInteractor,
private val docUpdates: IQrDocUpdates,
) : MelMachine<QrSignState, QrSignIntent, QrSignEffect>() {
// рождение = первый переход: стартовый стейт Loading + команда загрузки от args.
// startNext(...) возвращает MelStart — узкий тип старта (§4.1), а не Transition.
override fun start() = startNext(Loading, command = loadCommand())
}
backStackEntry.toRoute<Screen.X>() в
NavHost, диплинк. Обвязка экрана достаёт typed args и отдаёт фабрике.ArgsChanged(newArgs) с обычной клеткой.QrSignMachineTest: machine.start().toTransition().assert { to<Loading>(); command() } — как любая клетка (MelStart.toTransition() разворачивает рождение в Transition для общего assert-DSL).@AssistedInject (Dagger поддерживает официально): @Assisted args в конструктор машины +
@AssistedFactory; ViewModel создаёт машину через фабрику. В сэмпле обходимся ручной передачей — DI-обвязка
ортогональна ядру.Связка «однонаправленная машина + Compose» требует сознательных решений — иначе Compose используется как XML с перерисовкой всего экрана. MEL закладывает их в ядро:
Машина эмитит новый стейт целиком, но Compose скипает поддеревья при структурном равенстве стабильных параметров. Фасетная
структура стейта — это и есть skip-механика: смена isReloading не рекомпозирует реквизиты, потому что фасет
doc (QrDocFacts) не изменился. Требования: стейты и фасеты — @Immutable-data class'ы,
коллекции — immutable, strong skipping (дефолт Kotlin 2.0+). Дисциплину держит detekt-правило MelImmutableFacts
(запрет var/мутабельных коллекций/MutableState в классе с маркером MelState/MelFacts).
MelContent — это AnimatedContent под капотом. Дефолт — Crossfade по классу вершины
(melCrossfade); точечно — типизированные пары, без is:
transitions {
between<Loading, Content>(melFadeThrough) // готовые трансформы в Transitions.kt
between<Editing, Signing>(melSlideUp) // push пуллера — выезд снизу
}
pop() не убивает вью мгновенно — exit-анимация доигрывает на уходящем стейте. Оверлеи
(overlay<S>) появляются и уходят снизу через AnimatedVisibility с MutableTransitionState,
переживающим pop (MelOverlays в compose/MelContent.kt).effects{} — suspend; каждый исполняется в своей корутине (скоуп STARTED), поэтому долгий
suspend-хендлер (снек/диалог) не паркует очередь эффектов — навигация/закрытие за ним не ждут:
on<NavigateToPayment> { e, context -> … } — нативная стыковка с suspend-API Compose. Бесплатное
следствие — диалог-подтверждение: эффект показывает suspend-диалог, результат возвращается интентом.ContentPreview/ConfirmPreview/StubPreview —
@Composable от голого стейта (QrSignContentView(Content(previewDoc), send = {})), без модели, DI и моков.Текстовый ввод design — на пилоте не проверен. Правило «буква — Compose, смысл — машина»
(TextFieldState — инструмент композиции, машина получает дебаунснутое значение/blur/submit; программная запись — факт с
ревизией) спроектировано, но qr-sign формы не имеет, поэтому в коде ещё не отработано. Это явный риск (§10) и кандидат на
следующий пилот с формой.
| Слой | Механизм | Статус |
|---|---|---|
| Аргументы | Bundle/route восстанавливает система → фабрика отрабатывает повторно (§4.4) | по построению |
| Машина | Дефолт — перерождение через start() | реализовано |
| Машина (черновик) | opt-in снапшот-эссенция: snapshot { capture<Editing> { … } } + restored как «вторые args» | design |
| Композиция | SaveableStateHolder по записям стека (ключ = индекс + класс) в MelContent | реализовано |
Дефолт честный: process death = перерождение. start() детерминирован, args восстановлены — машина заново
грузится. Это сегодняшнее поведение всего зоопарка, и для списков/деталок оно правильное. MelContent оборачивает каждую
запись стека в SaveableStateHolder с ключом записи: push не убивает remember-состояние подложки, а ушедшая
из стека запись чистится из holder'а (повторный push того же типа не воскрешает UI-мелочь). Opt-in снапшот черновика
(snapshot-блок, capture-в-момент-save, restored как вторые args) спроектирован, но не реализован — нужен экрану
вроде payorder с набитой полформой; в пилоте qr-sign черновика нет.
Архитектура масштабируется вниз хуже, чем вверх. Лечится пакетом дефолтов, оформленных единым builder-ом MelScreen:
всё опционально, кроме контента; дефолт действует, пока блок не объявлен; переопределение — видимая строчка в ревью.
@Composable
fun QrSignScreen(model: MelModel<QrSignState, QrSignIntent, QrSignEffect>, router: MelRouter) {
val snackbar = remember { SnackbarHostState() } // snackbar — инструмент ЭТОГО экрана, не core
Box(Modifier.fillMaxSize()) {
// context = ::MelSnackContext (база-роутер + snackbar-host) из :core:mel-compose-material (§5 п.1)
MelScreen(model, router, context = { MelSnackContext(it.router, snackbar) }) {
content { // единственный обязательный блок
view<Loading> { QrSignShimmer() }
view<Content> { s -> QrSignContentView(s, model::send) }
overlay<Confirm> { s -> QrSignConfirmView(s, model::send) } // оверлей поверх живого Content
view<Stub> { s -> QrSignStubView(s, model::send) }
}
effects { // закрытие — у дефолта; снек/навигация — тут
on<QrSignEffect.ShowSnack> { e, context -> context.snackbar.showSnackbar(e.text) }
on<QrSignEffect.NavigateToPayment> { e, context -> /* роутер */ }
}
transitions { between<Loading, Content>(melFadeThrough) } // точечно поверх Crossfade
}
SnackbarHost(snackbar, Modifier.align(Alignment.BottomCenter))
}
}
// модель — фабрикой из машины, без per-screen MelViewModel-подкласса (§6.5). Новый key = новая
// машина (новый start()). Per-screen VM остаётся допустимым, но не обязателен.
val model = melViewModel(key = "qr-sign-$session") { QrSignMachine(args, interactor, docUpdates) }
// тривиальный экран — это ВЕСЬ его UI-код (паритет с SimpleMvi по церемонии):
@Composable
fun TechBreakScreen(model: MelModel<…>) = MelScreen(model) {
content { view<Content> { s -> TechBreakContent(s, model::send) } }
}
| Дефолт билдера | Что даёт без единой строчки |
|---|---|
context | MelEffectContext из core: только роутер. Свои инструменты (snackbar host, пейджеры) — перегруз MelScreen(…, context = { defaults -> XContext(defaults.router, …) }). Готовое material-расширение — MelSnackContext(router, snackbar) в отдельном модуле :core:mel-compose-material (material3 не утекает в ядро, §5 п.1) |
effects | Дефолтные хендлеры MelCommonEffect из core: эффект реализует маркер MelCloseEffect (и терминальный MelExhausted) — закрытие через роутер без строчки в effects{}. Регистрация специфичнее переопределяет дефолт (легально) |
transitions | Crossfade по классу вершины; enter/exit оверлеев (§4.5) |
| Back | BackHandler гейтнут на свой стек: глубже одного → MelBack → дефолтный pop() ядра. На КОРНЕ включается ровно когда у машины есть клетка на MelBack для текущей вершины (MelModel.handlesBack) — машинно-владеемый root-back; иначе на корне Back отдаётся системе (NavHost/фрагменты). Перехват — клетка на MelBack (§7.4) |
| Сохранение | SaveableStateHolder по записям стека (§4.6) |
MelEffectContext. Тест: «есть equals по значению / нужно клетке / проверяется
в юнит-тесте» → стейт; «объект с методами show()/dismiss()» → контекст. Никаких MutableState-флагов в контексте
(detekt MelNoMutableEffectContext); никаких инструментов (TextFieldState, пейджеры) в стейте
(detekt MelNoInstrumentsInState). Контекст эффект-слоя получает только эффект-хендлер;
вью инструментов не видят (вью = чистая функция стейта, state→intent). Базовый MelEffectContext несёт
только роутер; готовое material-расширение со снек-хостом — MelSnackContext(router, snackbar) в отдельном модуле
:core:mel-compose-material (чтобы material3 не утекал в ядро :core:mel); на экране
context = { MelSnackContext(it.router, snackbar) }, а хендлеры снека/ошибки идут против context.snackbar (§4.7).isReloading, isSigning).ContentIntent : QrSignIntent), внутри — подгруппы по секциям. Сквозные
(Back) — сквозной тип интеграции (MelBack : MelPopRequest) через onAny/дефолт.UseSnackbar { … }) запрещены (detekt
MelEffectsAreData): исполнение — только в композиции, регистрациями on<Эффект> в effects{}.Loaded, SignFailed) — они такие же
клетки машины, как клики.is/as по стейтам, интентам и эффектам в коде экранов (detekt MelNoTypeChecks).
Любая связь — типизированная регистрация. Цена зафиксирована честно: exhaustiveness переезжает от компилятора к тесту
(melGraphAssert) и debug-fail-fast; взамен — один стиль диспатча на весь фреймворк и отсутствие смарт-каст-простыней.
Пилот в :sample:migration/qrsign/ — порт реального sbbol-экрана подписания по QR
(payorder/.../qr_sign: QRSignMviModel + QRSignComponent с ~9 редьюсерами и
Dagger-картой биндингов, DebounceMutator, эффекты ShowLoader/ShowShimmering). Здесь — один
класс-машина + секция, без is/as и без карт. Экран средней сложности специально: реальная подписка,
оверлей-пуллер и фоновая маршрутизация — чтобы проверить ядро в бою.
sealed interface QrSignState : MelState {
data object Loading : QrSignState // стартовый шиммер из start(); args уже в машине
data class Content( // документ показан
val doc: QrDocFacts, // фасет фактов (QrDocFacts : MelFacts)
val isReloading: Boolean = false, // бывший ShowShimmeringEffect — факт, не эффект
) : QrSignState
data class Confirm( // ОВЕРЛЕЙ-пуллер поверх Content (push, §7.3)
val doc: QrDocFacts,
val isSigning: Boolean = false, // бывший ShowLoaderEffect — факт; гаснет по построению
) : QrSignState
data class Stub(val message: String) : QrSignState // тупик с кнопкой повтора
}
sealed interface QrSignIntent : MelIntent {
sealed interface LoadingIntent : QrSignIntent {
data class Loaded(val doc: QrDocFacts) : LoadingIntent
data class LoadFailed(val message: String) : LoadingIntent
}
sealed interface ContentIntent : QrSignIntent {
data object SignClick : ContentIntent // открыть пуллер
data object Rescan : ContentIntent // перечитать (дебаунсится в config)
data class Rescanned(val doc: QrDocFacts) : ContentIntent
data class RescanFailed(val message: String) : ContentIntent
data class DocUpdated(val doc: QrDocFacts) : ContentIntent // эмиссия подписки docUpdates (фоновая)
data class UpdatesBroken(val message: String) : ContentIntent // onError подписки
data object GoToPayment : ContentIntent
data object CloseClick : ContentIntent
}
sealed interface ConfirmIntent : QrSignIntent {
data object ConfirmSign : ConfirmIntent
data object CancelSign : ConfirmIntent
data class Signed(val doc: QrDocFacts) : ConfirmIntent
data class SignFailed(val message: String) : ConfirmIntent
}
sealed interface StubIntent : QrSignIntent { data object Retry : StubIntent }
}
// Back в контракт НЕ входит: интеграционный MelBack : MelPopRequest, дефолт pop()/MelExhausted
override fun start() = startNext(Loading, command = loadCommand()) // MelStart, не Transition (§4.1)
override val graph = graph<QrSignState, QrSignIntent, QrSignEffect> {
on<Loading, Loaded> { _, i -> next(Content(i.doc)) }
on<Loading, LoadFailed> { _, i -> next(Stub(i.message)) }
on<Stub, Retry> { _, _ -> next(Loading, command = loadCommand()) } // повтор = путь рождения
on<Content, SignClick> { s, _ -> push(Confirm(s.doc)) } // пуллер: Content жив в стеке
on<Content, Rescan> { s, _ -> next(s.copy(isReloading = true),
command = command(key = CommandKey("load"), onError = { RescanFailed(it.userMessage()) }) {
Rescanned(interactor.getDocument(args.operationId))
}) }
on<Content, Rescanned> { s, i -> next(s.copy(doc = i.doc, isReloading = false)) }
on<Content, RescanFailed>{ s, i -> next(s.copy(isReloading = false), ShowError(i.message)) }
on<Content, DocUpdated> { s, i -> next(s.copy(doc = i.doc)) } // фоновая маршрутизация (§7.3)
on<Content, UpdatesBroken>{ s, i -> stay(ShowError(i.message)) }
on<Content, GoToPayment> { s, _ -> stay(NavigateToPayment(s.doc.id)) } // навигация — данные (§7.4)
on<Content, CloseClick> { _, _ -> stay(Close) }
include(SignSection(interactor)) // клетки Confirm — в секции
// подписка со скоупом из ДВУХ стейтов (§7.2): обновления нужны и в Content, и под пуллером Confirm,
// поэтому push(Confirm) её не рвёт. Скоуп — vararg-клаузы active<…>() (per-state DSL).
subscribe(SubscriptionKey("docUpdates"), active<Content>(), active<Confirm>(),
onError = { UpdatesBroken(it.userMessage()) }) {
docUpdates.flow(args.operationId).map(ContentIntent::DocUpdated)
}
}
override val config = config { debounce<ContentIntent.Rescan>(500.milliseconds) } // типом, не строкой
class SignSection(private val interactor: IQrSignInteractor) :
Section<QrSignState, QrSignIntent, QrSignEffect>() {
override fun GraphBuilder<…>.register() {
on<Confirm, ConfirmSign>(::confirmSign)
on<Confirm, CancelSign> { _, _ -> pop() }
on<Confirm, Signed> { _, _ -> pop(ShowSnack("Документ подписан")) } // этап завершён — закрываем пуллер;
// подписанный документ долетит до Content подпиской
on<Confirm, SignFailed> { s, i -> next(s.copy(isSigning = false), ShowError(i.message)) }
}
private fun confirmSign(state: Confirm, intent: ConfirmSign) =
if (state.isSigning) stay() // дабл-клик — клетка машины, не DebounceMutator
else next(
state.copy(isSigning = true), // бывший ShowLoaderEffect — факт
command = command(key = CommandKey("sign"), onError = { SignFailed(it.userMessage()) }) {
Signed(interactor.sign(state.doc))
},
)
}
Что произошло с болями экрана: зависший лоадер невозможен (ошибка — это клетка SignFailed, обязанная
вернуть стейт без isSigning); дабл-клик ConfirmSign — явный stay() вместо строкового
debounce-списка; «интент подписания во время редактирования» структурно немаршрутизируем (вью реквизитов физически не пошлёт
ConfirmIntent); state as S-кастов нет — клетки секции типизированы Confirm регистрацией.
Главная демонстрация — интент фоновой подписки долетает к записи под вершиной стека. Сценарий подписи:
[Content] подписка docUpdates запущена (скоуп Content+Confirm)
[Content, Confirm] SignClick → push: Content жив, подписка не дёргалась
ConfirmSign → isSigning=true + команда sign
... interactor.sign() эмитит подписанный документ в шину docUpdates ДО возврата ...
[Content, Confirm] Signed (финал команды) → pop(ShowSnack) — закрываем пуллер
[Content] на записи Content уже / вот-вот применится DocUpdated(signed) фоновой подпиской
Порядок Signed / DocUpdated не важен: pop() возвращает к живому Content,
чьи факты актуализирует фоновый интент (клетка on<Content, DocUpdated> применяется к записи под пуллером —
StackOp.Next по своему индексу, §7.3), а не к замороженному снапшоту. Итог детерминирован — Content(signed).
Кнопка сэмпла «Эмулировать пуш» шлёт обновление в шину снаружи экрана — экран узнаёт о нём той же подпиской, демонстрируя живой
скоуп. Всё это покрыто табличным тестом QrSignMachineTest (§8).
Создание модели — фабрикой melViewModel. Рантайм-фасад экрана — MelModel<S, I, E>
(stack/state/effects + send(I)/pop()), его реализует общий
MelViewModel. Пилот не пишет per-screen подкласс ViewModel: @Composable melViewModel(key) { machine }
(runtime/MelViewModelFactory.kt) поднимает модель прямо из машины через androidx-viewModel;
новый key = новая машина (новый start(), как key("qr-sign-$session") в сэмпле). Без явного
key дефолт выводится из типов S/I/E (иначе viewModel ключует по стёртому
MelViewModel — одинаковому для всех экранов, и два melViewModel{} под одним стором вернули бы
один инстанс); два инстанса ОДНОГО экрана различай явным ключом. Per-screen
MelViewModel-подкласс остаётся допустимым (когда нужен свой конструктор/DI), но обязательным не является.
Секция — составная часть графа: связный набор клеток (+ подписок), оформленный обычным @Inject-классом
(Section<S, I, E>, наследник MelOps). Ось секции — зона ответственности, а не стейт. В пилоте
SignSection накрывает Confirm; на большом экране у одного Editing было бы несколько секций
(поля, получатель, действия). Машина делает include(section) — и всё.
Section)copy()-ят.
Дубль клетки (стейт, интент) между секциями — ошибка сборки графа, сообщение называет обе секции.awaitState-цепочек; общая логика выносится в обычные функции, вызываемые из обеих клеток.Когда секция не нужна: это инструмент организации, не обязательный слой. В пилоте Content-зона — клетки
инлайн в машине, секция только у подписания. Эвристика: секция появляется, когда у зоны есть свои зависимости, больше ~5–7 клеток
или нужна переиспользуемость (в sbbol SignPullerModule жил в 5 компонентах → одна секция, include в N машин).
Градиент: инлайн-клетки → приватные методы машины → секция.
Нюанс переиспользуемых секций. Sealed-иерархия интентов экрана не может включать чужие подклассы (sealed требует тот же
модуль), поэтому сквозные типы (ядровый MelPopRequest, его интеграционный наследник MelBack) — не-sealed, и переиспользуемая секция регистрирует их через
onAny. Следствие: сквозные интенты не участвуют в проверке полноты по sealed-листьям — там их покрытие за дефолтным
поведением core. Параметр интента в on<SS, II> не ограничен I именно ради этого.
Дифф идёт по ключам, а не по классам стейтов: ключ, оставшийся в наборе после перехода, означает «не трогать». Подписка объявляется один раз со стейт-скоупом (повторный ключ — ошибка сборки). Ступени:
| Требование | Декларация | Поведение |
|---|---|---|
| Подписку нельзя дёргать вообще (рефреш) | Не уходить из стейта: Content(isReloading = true) |
Набор не меняется — подписка не замечает рефреша |
| Пережить промежуточный/оверлейный стейт | Скоуп из нескольких клауз: subscribe(SubscriptionKey("docUpdates"), active<Content>(), active<Confirm>()) |
push(Confirm) — Content в стеке, подписка живёт непрерывно (ровно случай пилота) |
| Перезапуститься («начать заново») | Скоуп — одна клауза: subscribe(SubscriptionKey(…), active<Content>()) |
Отмена на выходе, рестарт на входе — семантика, не баг. События за окно отсутствия теряются по построению |
Приостановиться по факту (бывший императивный cancel(X)) |
Предикат в клаузе: subscribe(SubscriptionKey("refresh"), active<List> { !it.isSigning }) |
Отмена при isSigning, рестарт при возврате факта (именно рестарт, не пауза) |
Линейная машина упирается в контекстно-слепые промежуточные стейты. Ответ — стек состояний (StackOp в
transition/Transition.kt, реализован в MelViewModel.applyOp):
next(to) // replace вершины: стейт ЗАВЕРШЁН (поведение линейной машины)
push(to) // поверх: нижний стейт ЖИВ — факты сохранены, подписки не трогаются
pop() // вернуться к живому стейту, как он был (актуализированному фоновыми интентами)
popTo<Content>() // аналог v3 backTo
Семантика (важнее API):
overlay<S> (пуллер, диалог с
собственным флоу), рисуется поверх ближайшей не-overlay записи стека: MelContent сканирует базу и рисует все
оверлеи над ней.[Content, Confirm] — docUpdates живёт, хотя на вершине Confirm.DocUpdated применяется к
Content в глубине и обновляет его факты — на pop возвращаешься к актуальному состоянию). Ограничение,
проверяемое рантаймом: фоновая запись может только обновлять себя (next/stay своего индекса);
push/pop — только с вершины (иначе violation).MelPopRequest — pop() пока стек глубже одного, иначе терминальный MelExhausted
(нав-смысл — на высоте интеграции, преемник v3 BackStateReducer).Доктрина выбора (симметрична «стейт vs поле»): push — «состояние живо, я вернусь» (факты + подписки
сохраняются); next — «состояние завершено» (подписки честно умирают). Лоадеру в стеке делать нечего: немая вершина
без клеток ломает маршрутизацию и попадает под Back — лоадер при операции это факт (isSigning), а не запись
стека. Критерий записи стека — наличие собственного жизненного цикла (интенты, флоу, подписки), как у пуллера подписания.
Принцип: машина не знает о существовании других экранов. Наружу она отдаёт данные (Effect), внутрь получает данные (Intent).
Межэкранный слой (фрагменты, NavController, IMainRouter, диплинки) MEL не трогает — он живёт за интерфейсом MelRouter в
MelEffectContext. MEL стандартизирует только край экрана.
| Уровень | Механизм | В пилоте |
|---|---|---|
| Внутри экрана (подсостояния, пуллеры) | Стек машины: push/pop/next (§7.2) | Content → пуллер Confirm |
| Внутри мульти-модельного хоста | Мост host/child design | — |
| Между экранами | Effect-данные → роутер в контексте; обратно — Intent через подписку | NavigateToPayment → роутер |
// эффекты навигации — данные, без роутера в машине
on<Content, GoToPayment> { s, _ -> stay(QrSignEffect.NavigateToPayment(s.doc.id)) }
// исполнение — в композиции; роутер — инструмент из контекста
effects { on<NavigateToPayment> { e, context -> context.router.toPayment(e.docId) } }
// ВОЗВРАТ результата — это вход, значит подписка → интент (как docUpdates в пилоте)
subscribe(SubscriptionKey("pickerResult"), active<Content>()) { savedStateHandle.getStateFlow(KEY, null).filterNotNull().map(::Selected) }
repeatOnLifecycle(STARTED) —
навигационный эффект от позднего результата команды дождётся возвращения экрана, не упадёт и не потеряется. Это паттерн, которым в
sbbol уже починили гонку QR-сканера, — здесь он дефолт для всей навигации.pop(). На дне BackHandler включён,
только если у машины есть клетка на MelBack для текущей вершины (handlesBack) —
машинно-владеемый root-back; иначе системный Back закрывает экран (NavHost/фрагменты). MelBack
без своей клетки на дне → терминальный MelExhausted → закрытие интеграцией.NavController — роутер в
контексте. Граница «destination vs стек машины»: destination — то, что живёт самостоятельно (адресуемо, диплинк); стек машины — то,
что неотделимо от экрана (флоу с его фактами).
Знание о машине собрано в значение Graph (список регистраций) — его инспектируют рантайм, тесты и debug-fail-fast.
Это избавляет MEL от собственного KSP: генерировать нечего (карт и ключей нет), а тела функций KSP всё равно не видит.
:core:mel-test, Transition.assert): прямой
assertEquals упирается в лямбду внутри Command, поэтому переход сверяется по фактам — стейт предикатом,
команда ключом, эффекты типами. Без корутин и моков фреймворка.Graph.reduce (тот же lookup, что в рантайме): список (стейт, интент) →
ожидание; стейл-клетки проверяются «интент X в стейте Y не маршрутизируется».melGraphAssert (главный страж после «без is»): перечисляет sealed-листья стейтов и
интентов и сверяет с регистрациями — частично покрытая группа, мёртвый интент, пустой стейт; падает со списком всех дыр + дамп
графа. Дубли клеток/ключей ловит сам GraphBuilder при сборке. Сознательные исключения — с обязательной причиной:
terminal<Stub>(), allowUnhandled<…>("причина"), allowDead<…>("причина").MelRuntime.violation (fail-fast в
debug) при первом непокрытом стейте/эффекте + smoke-прогон экрана.// пилот: assert-DSL клетки (TransitionAssert)
SignSection(interactor).confirmSign(Confirm(doc), ConfirmSign).assert {
to<Confirm> { it.isSigning }
command("sign")
noEffects()
}
// пилот: страж полноты — проходит без единого исключения (граф закрыт по построению)
class QrSignGraphTest {
@Test fun graph() = melGraphAssert(QrSignMachine(QrSignArgs("op-1"), interactor, docUpdates))
}
QrSignMachineTest — 17 табличных кейсов: рождение, каждая клетка Content/Confirm/Stub, гейт дабл-клика,
фоновый DocUpdated под пуллером, стейл-интент чужого стейта. Плюс QrSignGraphTest. Итого по экосистеме MEL
тестов: :core:mel — 27 (рантайм: цикл, команды, подписки, эффекты), :core:mel-test — 29 (сам граф-ассерт и
DSL), :tools:detekt-rules — 41, пилот — 18.
:tools:detekt-rules)
8 правил дисциплины, подключены к экранным модулям (:sample:migration, :app). Все правила — PSI, без
type resolution: IDE-плагин detekt не поддерживает type resolution для подсветки на лету (известное ограничение), поэтому от TR
отказались — так все 8 правил работают и в Studio на лету, и в Gradle-таске. Контракты детектятся по маркерам
(MelState/MelIntent/MelEffect/MelFacts из contract/Contracts.kt): хелпер идёт
BFS по супертипам в пределах файла в поисках маркера — легаси *State/*Effect без маркера не трогается.
| Правило | Ловит | Закрывает |
|---|---|---|
MelNoTypeChecks (error) | is/as/when-is по типам контрактов, но только внутри класса-MelMachine/Section (PSI-якорь убирает ложные на legacy SimpleMvi) | §5 п.6 |
MelImmutableFacts (error) | var, мутабельные коллекции, MutableState в классе с маркером MelState/MelFacts | §4.5 |
MelNoInstrumentsInState (error) | Инструменты в стейте: TextFieldState, SnackbarHostState, пейджеры, compose.*/android.* | §5 п.1 |
MelEffectsAreData (error) | Функциональные типы в классе с маркером MelEffect (анти-UseSnackbar { }) | §5 п.4 |
MelNoMutableEffectContext (error) | MutableState/var в MelEffectContext | §5 п.1 |
MelNoCoroutinesInMachine (error) | launch/GlobalScope/CoroutineScope в MelMachine/Section — асинхронщина только командами и подписками | §4.2 |
MelSubscribeOnError (warning) | subscribe без onError — отсутствие должно быть осознанным | §4.2 |
MelMigrationRatchet (warning) | Новые наследники SimpleMviViewModel/v3 MviModel вне allowlist | §9 |
Собрано — модуль :tools:mel-graph + IDE-плагин :tools:mel-idea-plugin:
статическая экстракция графа из PSI (MelGraphExtractor / headless-MelPsiWalker для CI);
тул-виндоу «MEL» — каталог экранов слева (фильтр по модулю, поиск, скрытие тестовых) и живая диаграмма стейтов справа
(собственный Java2D-рендер с layered-layout, без JCEF: узлы по ролям start/overlay/terminal, дашированные рёбра
команд/pop, иконки подписок); навигация из узла/ребра/инспектора в исходник (OpenFileDescriptor по SourceRef);
панель-инспектор узла (факты, подписки, локальные экшены, переходы — каждый ссылкой в код); экспорт Mermaid в буфер и PNG на диск.
Граф несёт overlay-стейты, ключи команд и их результаты, дебаунс, подписки.
Golden-сверка статика↔рантайм собрана: MelGraphCrossCheckTest в пилоте сверяет граф PSI-уокера с настоящим
QrSignMachine.graph, где рантайм — авторитет (статике расходиться нельзя) — это и закрывает риск «статический
анализ соврал». Отдельного именованного MelDiagram.golden(…) нет: роль закрыта связкой MermaidEmitter
+ MelGraphCrossCheckTest.
Ещё не сделано design: code-инспекции с quick-fix в IDE (в plugin.xml только
toolWindow; дисциплину держат detekt-правила §8.1, но «на лету» в редакторе их нет); вкладка миграции
v3/SimpleMvi → MEL в плагине. Дверь к FIR-чекеру компилятора (видит тела регистраций) сознательно закрыта из-за цены
поддержки — открывать, только если тест-таймовая полнота начнёт пропускать реальные баги.
:core:mel + :core:mel-test ничем не пересекаются с существующими
фреймворками: свой рантайм-VM, свои контракты. На одном хосте старые и новые экраны живут вместе (межэкранный слой общий, §7.3).ru.mel:core / ru.mel:test / ru.mel:detekt,
версия 0.1.0, репозиторий Nexus maven-mixed. Конфиг публикации — общий скрипт
gradle/mel-publishing.gradle.kts, применяется в 3 модулях. Развязка от sbbol-стаба: :core:mel
не зависит от логгера приложения — у него свой подключаемый MelLog (MelLog.delegate = … одной строкой).
Направление: распространять MEL артефактом, а не копированием исходников.MelMigrationRatchet).Главный вопрос к каждому v3-редьюсеру: «чем он был на самом деле?» В v3 редьюсер — единственный юнит, поэтому в нём смешаны вычисления, оркестрация IO и обработка ошибок. Таблица разложения:
| Что в v3-редьюсере | Как узнать | Куда в MEL |
|---|---|---|
| Sync-вычисление, валидация, форматирование | Нет suspend-вызовов | Тело клетки — переносится почти дословно |
| Suspend-вызов интерактора | interactor.xxx() в reduce | command(onError) { ResultIntent(…) } + клетка результата |
Цепочка awaitState(MapX(…)) as XState | Интент Map* шлётся только из редьюсеров | Типизированная функция-маппер; интент умирает, касты исчезают |
Лоадер-каскад (UseLoader/ShowLoader/HideLoader) | Интенты/эффекты без бизнес-смысла | Факт isSaving в стейте; вью рисует из факта |
cancel(ListenRefresh) посреди reduce | Императивная отмена подписки | предикат клаузы active<S> { … } подписки (§7.1) или клетка-гард |
send(X, policy = OnStateChanged) | Отложенная отправка через очередь | Не нужен: эффекты буферизованы, подписки стартуют от стейта; «после отрисовки» — LaunchedEffect во вью |
Интенты Args.* при старте | Шлются один раз из обвязки | Параметры конструктора машины + start() (§4.4) |
errorHandler / try-catch | — | Клетка error-интента: факт в стейт + Effect; страховки не нужны |
Гард if (state !is X) return state | — | Не нужен: клетка типизирована стейтом регистрацией |
Эффект-лямбда (UseSnackbar { … }) | Лямбда захватывает хэндлы | Effect-данные; исполнение — в effects{} |
Правило инвентаризации интентов: интент в MEL — только реальное событие (действие пользователя, результат команды,
эмиссия подписки). v3-интент, который посылался только из других редьюсеров (Map*, UseLoader), — это вызов
функции, замаскированный под интент: при переносе он не становится клеткой, а растворяется в функциях. В пилоте qr-sign это уже
видно: бывшие ShowShimmering/ShowLoader стали фактами isReloading/isSigning,
FromOperation — аргументом конструктора.
SimpleMvi (241 файл в ~30 фичах) — концептуальный младший брат MEL, перенос идейно проще, чем v3: та же триада
<Intent, State, Effect> (интенты уже sealed), последовательная обработка, даже буферизованный эффект уже изобретён
(setBufferedEffect).
| SimpleMvi | MEL |
|---|---|
Contract.Intent/State/Effect | Переносится; Intent уже sealed |
setInitialState() + init {} | start(); args — в конструктор (§4.4) |
handleIntent + when | Клетки on<Стейт, Интент>. Одностейтовые экраны — машина с одним стейтом |
setStateAs<T> — тихий скип | Стейт-типизация клетки: drop + лог вместо тишины |
launch { interactor…; setState {…} } | command(onError) { ResultIntent } + клетка результата |
init { bus.collect { … } } — навсегда | subscribe(key, active<Стейт>(), onError) с осознанным скоупом |
setEffect (теряется) vs setBufferedEffect | Один Effect-канал, всегда буфер — различие исчезает |
| Навигация императивно из VM, мимо Effect-канала | Роутер → MelEffectContext, клики → Effect-данные — главная работа миграции |
PagingPager-инструмент в стейте | Core-паттерн пейджинга design (§11) — единственное, где нужен новый паттерн, а не перенос |
| Риск | Митигация · статус |
|---|---|
| Новый фреймворк, а не рефакторинг: перенос экрана — ручная переукладка логики | MEL только для новых и переписываемых экранов; гайд переноса (§9); v3/simplemvi живут сколько нужно. Пилот доказал перенос среднего экрана |
| Ещё одна MVI в кодовой базе — риск зоопарка | Default для нового кода + MelMigrationRatchet (warning → error после стабилизации); v1/v2 — отдельный трек на выпил |
Правило «без is»: exhaustiveness не у компилятора | melGraphAssert (sealed-листья ↔ клетки) + fail-fast в debug + detekt MelNoTypeChecks — на CI практически эквивалент компилятора. FIR-чекер — дверь на будущее (§8.2) |
| Дисциплина «эффекты — данные», «факты не в контексте» держится на конвенции | 8 detekt-правил (§8.1), работают и в IDE, и на CI — реализовано |
| Текстовый ввод через машину — лаг эха и прыжки курсора (цикл на фоне) | Правило §4.5 «буква — Compose, смысл — машина» спроектировано, но на пилоте не проверено (qr-sign без формы). Главный незакрытый риск — нужен пилот с формой |
| Обучение команды (Elm-модель: команды/подписки вместо suspend-reduce) | Прецеденты: Elm/MVU, TCA; пилот qr-sign как живой пример; KDoc в ядре подробный. MEL Cookbook — следующий шаг |
| Композиция host/child для мульти-модельных экранов | Мост host/child не собран (§4.3) — открытый вопрос (§11) |
Итоговая карта зрелости — чтобы документ не выдавал желаемое за сделанное:
| Блок | Статус |
|---|---|
Ядро рантайма: цикл, маршрутизация по стеку, команды (+ прогресс), подписки (дифф, per-state предикаты active<…>, бэкофф), дебаунс, эффекты-буфер, дроп+лог | реализовано |
Стек состояний: push/pop/popTo/next, фоновая маршрутизация self-update | реализовано |
Compose: MelScreen builder, MelContent (AnimatedContent + оверлеи + SaveableStateHolder), MelEffectHost, дефолтные хендлеры MelCommonEffect, Back, transitions | реализовано |
Тест-набор: melGraphAssert, Transition.assert, table-driven через Graph.reduce | реализовано |
| Detekt: 8 PSI-правил по маркерам | реализовано |
Публикация ru.mel:0.1.0 (core/test/detekt) в Nexus; подключаемый MelLog | реализовано |
| Пилот qr-sign (контракт + машина + секция + UI + превью + тесты) | реализовано |
Мост host/child (bridge(child){…}): рантайм + маркер «адресовано хосту» | design |
Opt-in снапшот черновика (snapshot { capture<…> } + restored) | design |
| Core-паттерн пейджинга (страницы/флаги — факты; подгрузка — команда с ключом страницы) — без него списочные SimpleMvi не поедут | design |
Текстовый ввод (TextFieldState + факт с ревизией) — пилот с формой | не проверено |
Граф-инструмент (:tools:mel-graph + IDE-плагин): экстракция из PSI, тул-виндоу (каталог экранов + диаграмма стейтов на Java2D), навигация в код, инспектор узла, Mermaid/PNG-экспорт, golden-сверка статика↔рантайм (MelGraphCrossCheckTest) | реализовано |
| Плагин: code-инспекции с quick-fix в IDE, вкладка миграции v3/SimpleMvi → MEL; FIR-чекер компилятора | design |
Подписки с параметром из стейта ((SS) -> Flow<I> с рестартом по ключу-от-стейта) | по первому кейсу |
| MEL Cookbook (~10 рецептов «задача → код целиком») и перенос payorder/mass-sign | по итогам пилотов |
Решённые в дизайне вопросы (зафиксированы реализацией ядра): сериализация стека — стек не сохраняется, восстановление в базовый
стейт (§4.6); имя пакета — core/mel, отстройка от нумерации MVI сознательная; команды с прогрессом —
commandWithProgress с обязательным финалом + cancel(key) (§4.1); гонка close-vs-cancel — enqueue
через trySend (§4.2).
Основание: исходная спека-драфт целевой архитектуры (sbbol mel-design.html, 2026-06-13) и её реализация в
:core:mel (2026-06-14). Разборы болей — payorder (PayOrderComponent.kt: 205 биндингов,
debounce-список UniqueUtils, интент UseLoader), mass-sign (screenState-мешок, ShowLoader/ShowShimmering-эффекты,
рукописный errorHandler), QR-sign (гонка cold-start). Прецеденты архитектуры: The Elm Architecture (Cmd/Sub),
The Composable Architecture (отмена эффектов по ключам, подписки от стейта), MVIKotlin/Orbit (каналы side effect).