MEL — примеры переноса
Сюда складываем разборы «как это было в MVI v3 и во что превращается в MEL» — по одному паттерну на раздел. Каждый пример: код «до», код «после», и развилка с вопросами, чтобы выбрать вариант для своего экрана.
Слово «эффект» в v3 и в MEL означает разное. В v3 эффект — почти god-объект: его
хендлер performEffect(state, effect) через Mutator умеет читать стейт,
слать интенты (send(Intent)), слать другие эффекты и ходить в navigator.
В MEL эффект (E) — это только односторонний выход наружу (навигация, снек,
закрытие): хендлер on<E> { e, ctx -> … } получает лишь сам эффект и
router, возвращает Unit и не может отправить интент.
Поэтому перенос — это не «переименовать», а расщепить: то, что один v3-эффект делал внутри себя, в MEL раскладывается по четырём ролям.
- Читать стейт и решать → клетка
on<S, I>(reducer). - Порождать интенты → команда (одноразово) или подписка (поток).
- Побочный выход (навигация / снек / close) → эффект
E. - Async-работа живёт в команде/подписке — клетка остаётся чистой функцией.
Listen-эффект, который шлёт интент
В sbbol-овском MVI v3 распространён паттерн: эффект-«слушатель» делает асинхронную работу
(запрос или подписку на поток) и через send(Intent) возвращает результат обратно в
reducer. «Listen» — лишь соглашение об именовании; фреймворк трактует такой эффект как любой
другой BaseStateEffect.
Как это устроено в v3
Хендлер наследует Mutator и получает доступ ко всему сразу:
interface Mutator : SendIntent, SendEffect, MutatorHolder {
val navigator: NavigationStack
}
// performEffect(state, effect) → может: читать state, send(Intent), send(Effect), navigator
// Запуск — трекаемый отменяемый Job; isActive(effect) — guard от дублей;
// onStateUniqueChanged() рубит все эффекты, кроме помеченных KeepAliveEffect.
Развилка: во что это превращается
Решает один вопрос — что именно делал Listen-эффект:
Listen-эффект, который НЕ слал интентов, остаётся эффектом E. Те, что слали, — это команды и подписки.
1a. Одноразовый Listen → Command
Реальный ListenSberRatingStatuses из mass-sign: подгрузить статусы и отдать их интентом.
было — v3
class ListenSberRatingStatusesStateEffect(
private val interactor: ISberRatingInteractor,
) : BaseStateEffect<State, ListenSberRatingStatuses>() {
override suspend fun performEffect(
state: State, effect: ListenSberRatingStatuses,
) {
if (isActive(effect) ||
!interactor.widgetEnabled()) return
val st = interactor.loadStatuses(effect.payments)
send(UpdateSberRatingStatuses(st)) // → intent
}
}
стало — MEL
// решение и условие — в клетке (видит стейт)
on<List, PaymentsLoaded> { _, i ->
if (!interactor.widgetEnabled()) next(List(i.payments))
else next(
List(i.payments),
command = command(
onError = { RatingFailed(it.msg()) },
) {
UpdateSberRatingStatuses(
interactor.loadStatuses(i.payments),
) // → intent
},
)
}
on<List, UpdateSberRatingStatuses> { s, i ->
next(s.copy(statuses = i.statuses))
}
Что изменилось: isActive-guard не нужен (повторный запуск с тем же
CommandKey отменяет предыдущий); проверка widgetEnabled() переехала в
клетку, потому что это решение по стейту; тело команды только возвращает интент.
1b. Поток-Listen → Subscription
Реальный ListenRefresh: пока экран жив, слушать шину и слать Refresh на каждое событие.
было — v3
class ListenRefreshStateEffect(
private val bus: PayOrderEventBus,
) : BaseStateEffect<State, ListenRefresh>() {
override suspend fun performEffect(
state: State, effect: ListenRefresh,
) {
if (isActive(effect)) return // уже слушаем
bus.resultChannel().skip(1).asFlow()
.collectLatest { r ->
if (r.isChanged) send(Refresh()) // → intent
}
}
}
стало — MEL
subscribe(
SubscriptionKey("refresh"),
active<MassSignList>(), // скоуп = где жива
) {
bus.resultChannel().asFlow()
.filter { it.isChanged }
.map { Refresh() } // событие → intent
}
Старт при входе в скоуп, отмена при выходе — вместо
isActive и onStateUniqueChanged(). Падение источника — авто-переподписка
с бэкоффом (или onError, см. раздел 3).
Пилот qr-sign (sample/migration/.../QrSignMachine.kt) портирован из
sbbol-овского QRSignMviModel. Бывший Listen-эффект на серверный поток обновлений
документа стал подпиской — почти дословно как 1b:
subscribe(
SubscriptionKey("docUpdates"),
active<Content>(), active<Confirm>(), // два стейта = переживает push(Confirm)
onError = { ContentIntent.UpdatesBroken(it.userMessage()) },
) {
docUpdates.flow(args.operationId).map(ContentIntent::DocUpdated)
}
А бывшие эффекты load / sign / rescan (делали работу и
диспатчили результат) стали командами.
Главная грабля
Если v3-хендлер внутри себя читал state или делал send(Effect) /
ходил в navigator — это не едет в тело команды/подписки. Тело только
возвращает интент. Решение по стейту и побочный выход переезжают в клетку, которая ловит этот
интент: она читает state и кладёт в переход эффект E (навигация — это
данные эффекта, исполняет роутер).
Варианты команды — по вопросам
Выбрал команду (одноразовая работа → интент)? Пройди вопросы — они задают форму вызова.
Обязательно задай onError — это не опция.
Исключение в block не валит цикл, а превращается в интент.
command = command(onError = { LoadFailed(it.userMessage()) }) {
Loaded(interactor.getDocument(id)) // успех → Loaded, бросок → LoadFailed
}
Несколько → commandWithProgress: emit(I)
кладёт интент в очередь по ходу, финальный return — последним.
command = commandWithProgress(onError = { Failed(it.userMessage()) }) { emit ->
uploader.parts(file).collect { p -> emit(Progress(p.percent)) } // промежуточные
Done(file.id) // финальный
}
Да → дай CommandKey. Повторный запуск с тем
же ключом отменяет предыдущий; явная отмена — cancel = setOf(CommandKey(...)) в
любом переходе. Это замена v3-шных isActive(effect) и cancel(effect).
on<Content, Rescan> { s, _ ->
next(s.copy(isReloading = true), command = command(
key = CommandKey("load"), // частые тапы схлопнутся в один live-запрос
onError = { RescanFailed(it.userMessage()) },
) { Rescanned(interactor.getDocument(id)) })
}
on<Content, CancelRescan> { s, _ -> stay(cancel = setOf(CommandKey("load"))) }
Если v3 гасил частые тапы строковым DebounceMutator — в MEL это конфиг по типу
интента: config { debounce<Rescan>(500.milliseconds) }. Ключ команды и дебаунс — про разное и сочетаются.
state или ветвился по нему?Тогда это решение — в клетку. Тело команды
state не видит. Клетка читает текущий стейт, решает, нужна ли команда вообще, и кладёт
её в переход (см. widgetEnabled() в примере 1a).
send(Effect) / навигацию?Не в команду. Команда вернёт интент, его поймает клетка —
и уже она положит эффект E в переход рядом с новым стейтом.
on<Confirm, Signed> { _, _ -> pop(ShowSnack("Документ подписан")) } // интент команды → эффект из клетки
Варианты подписки — по вопросам
Выбрал подписку (поток событий → интенты, пока экран в нужном состоянии)? Вопросы задают её скоуп и поведение.
Минимум один active<State>() — обязателен,
иначе граф не соберётся (скоуп пуст = подписка мертва). Старт при входе в скоуп, отмена при выходе —
это замена ручного lifecycle и onStateUniqueChanged().
subscribe(SubscriptionKey("timer"), active<Running>()) { ticker().map { Tick } }
Да → перечисли несколько стейтов. Скоуп — union по
стеку: push поверх не рвёт подписку. Это прямой аналог v3 KeepAliveEffect.
subscribe(SubscriptionKey("docUpdates"), active<Content>(), active<Confirm>()) { … }
// push(Confirm) поверх Content подписку не дёргает — она в скоупе обоих
Да → предикат в active. Нарушился факт —
отмена; вернулся — рестарт. Предикат типизирован по своему стейту.
subscribe(SubscriptionKey("poll"), active<Content> { it.isOnline }) { poller().map(::DocUpdated) }
Есть осмысленный интент-ответ → onError (упавший источник маппится в интент,
подписка завершается). Иначе MEL сам переподпишется с удваивающимся бэкоффом (1с→30с); в
failFast-режиме — упадёт.
subscribe(SubscriptionKey("docUpdates"), active<Content>(),
onError = { UpdatesBroken(it.userMessage()) }) { docUpdates.flow(id).map(::DocUpdated) }
source — это лямбда () -> Flow<I>, пересоздаётся на
каждый (ре)старт. Замыкай зависимости из конструктора машины (args,
интерактор). Важно: текущий стейт она не получает — если v3-стрим
параметризовался стейтом, зафиксируй параметр в args/стартовом стейте, либо подними
решение в клетку.
Нет «глобальной без стейта». Повесь active на
корневой/базовый стейт-класс — он есть в стеке всегда, значит подписка жива весь экран.
subscribe(SubscriptionKey("connectivity"), active<ScreenState>()) { net.online().map(::Connectivity) }
// ScreenState — общий супертип всех состояний экрана: всегда в стеке
Шпаргалка соответствий
| MVI v3 | MEL |
|---|---|
Listen-эффект: работа → send(intent) | Command command { … } возвращает интент |
Listen-эффект: flow.collectLatest { send(intent) } | Subscription subscribe(active<S>) { flow.map(::I) } |
| Эффект только навигирует/снек/close (интент не шлёт) | остаётся Effect E + on<E> в эффект-слое |
isActive(effect) guard от дублей | CommandKey (релонч отменяет) / подписка диффится по ключу |
авто-отмена на onStateUniqueChanged() | выход из active<>-скоупа |
KeepAliveEffect (переживает смену стейта) | подписка со скоупом из нескольких active<S> |
ручной cancel(effect) | cancel = setOf(CommandKey(...)) / выход из скоупа |
несколько промежуточных send за операцию | commandWithProgress { emit -> … } |
хендлер читал state / ветвился | решение — в клетку on<S, I>; тело команды стейт не видит |
хендлер делал send(Effect) / navigator | клетка, ловящая интент, кладёт E в переход |
строковый DebounceMutator | config { debounce<I>(…) } по типу интента |
Соседние документы: учебное «как пользоваться» —
mel-guide.html; каноническая архитектура и инварианты — mel-design.html.
Если код и документы разойдутся — источник истины код, затем спека.