MEL — примеры переноса
Сюда складываем разборы «как это было в MVI v3 и во что превращается в MEL» — по одному паттерну на раздел. Каждый пример: код «до», код «после», и развилка с вопросами, чтобы выбрать вариант для своего экрана.
Слово «эффект» в v3 и в MEL означает разное. В v3 эффект — почти god-объект: его
хендлер performEffect(state, effect) через Mutator умеет читать стейт,
слать интенты (send(Intent)), слать другие эффекты и ходить в navigator.
В MEL эффект (E) — это только выход наружу (навигация, снек, диалог,
закрытие): хендлер on<E> { e, context -> … } получает сам эффект и контекст
эффект-слоя (у базового MelEffectContext — только router, у подкласса
контекста — свои инструменты экрана: навигатор, снек-хост, лаунчеры), стейта не видит и живёт
в UI-скоупе (пока экран STARTED). Слать интенты в машину он не умеет — send в
контексте нет. Ответ пользователя (диалог, выбор файла) возвращается интентом из кода экрана —
model.send(…) во вью или в колбэке лаунчера (см. шпаргалку), а асинхронная работа с
результатом-интентом — это команда или подписка.
Поэтому перенос — это не «переименовать», а расщепить: то, что один 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
Реальный ListenSberRatingStatusesStateEffect из mass-sign: подгрузить статусы и отдать
их интентом (маппинг платежа в SberRatingPartnerOrgModel сокращён до toPartner()).
было — v3
class ListenSberRatingStatusesStateEffect(
private val interactor: ISberRatingPublicInteractor,
) : BaseStateEffect<MassSignScreenState, ListenSberRatingStatuses>() {
override suspend fun performEffect(
state: MassSignScreenState,
effect: ListenSberRatingStatuses,
) {
if (isActive(effect)) return
val partners = effect.payments.map { it.toPartner() }
val st = interactor.loadPartnersStatuses(
partners, MASS_SIGN,
)
send(UpdateSberRatingStatuses(st)) // → intent
}
}
стало — MEL
// вызов интерактора — в тело команды
on<MassSignList, PaymentsLoaded> { _, i ->
next(
MassSignList(i.payments),
command = command(
key = CommandKey("sberRating"),
onError = { RatingFailed(it.userMessage()) },
) {
UpdateSberRatingStatuses(
interactor.loadPartnersStatuses(
i.payments.map { it.toPartner() }, MASS_SIGN,
),
) // → intent
},
)
}
on<MassSignList, UpdateSberRatingStatuses> { s, i ->
next(s.copy(statuses = i.statuses))
}
Что изменилось: isActive-guard не нужен (повторный запуск с тем же
CommandKey отменяет предыдущий — побеждает последний список); запрос
loadPartnersStatuses(…) — это вызов интерактора, а не чтение стейта, поэтому он едет в
тело команды: клетка остаётся чистой синхронной функцией, а в неё переезжают только решения
по стейту (см. вопрос 4 раздела 2). Тело команды возвращает интент, исключение в нём — интент из
onError.
1b. Поток-Listen → Subscription
Реальный ListenRefresh: пока экран жив, слушать шину и слать Refresh на каждое событие.
было — v3
class ListenRefreshStateEffect(
private val bus: PayOrderEventBus,
) : BaseStateEffect<MassSignScreenState, ListenRefresh>() {
override suspend fun performEffect(
state: MassSignScreenState, effect: ListenRefresh,
) {
if (isActive(effect)) return // уже слушаем
bus.listenPayOrderResultChannel().skip(1).asFlow()
.collectLatest { r ->
if (r.isChanged) send(Refresh()) // → intent
}
}
}
стало — MEL
subscribe(
SubscriptionKey("refresh"),
active<MassSignList>(), // скоуп = где жива
) {
bus.listenPayOrderResultChannel().skip(1).asFlow()
.filter { it.isChanged }
.map { Refresh() } // событие → intent
}
Старт при входе в скоуп, отмена при выходе — вместо
isActive и onStateUniqueChanged(). .skip(1) из v3 сохраняем:
шина (BehaviorSubject) реплеит последнее событие, а подписка стартует заново при каждом входе в скоуп.
Падение источника — авто-переподписка с бэкоффом; для шины приложения (.asFlow()) это
норма, onError не нужен (см. раздел 3, вопрос 4).
В MEL-экранах sbbol загрузки — команды с CommandKey:
AccountRestrictionsMachine (ограничения счёта), LightBankChargesPullerMachine
(пуллер начислений), CreationPullerMachine (пуллер создания),
BankSearchPullerMachine (поиск банка). В последнем вместе работают ключ, явная отмена и
дебаунс из вопроса 3 раздела 2:
override val config = config {
debounce<QueryChanged>(SEARCH_DEBOUNCE_TIME.milliseconds)
}
on<BankSearchPullerState, QueryChanged> { s, i ->
when {
i.query == s.query -> stay()
i.query.isBlank() -> next(BankSearchPullerState(query = i.query, result = Hint), cancel = setOf(SEARCH_KEY))
else -> next(BankSearchPullerState(query = i.query, result = Loading), command = searchCommand(i.query))
}
}
private fun searchCommand(query: String): Command<BankSearchPullerIntent> =
command(key = SEARCH_KEY, onError = { SearchFailed(query, errorData(it)) }) {
BanksLoaded(query, accountInteractor.getBanksByBic(query))
}
Главная грабля
Если v3-хендлер внутри себя читал state или делал send(Effect) /
ходил в navigator — это не едет в тело команды/подписки. Тело только
возвращает интент. Решение по стейту и побочный выход переезжают в клетку, которая ловит этот
интент: она читает state и кладёт в переход эффект E (навигация — это
данные эффекта, исполняет хендлер on<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(...)) в
переходе. Ключ общий для всей машины: одинаковый ключ в разных секциях — это одна и та же команда
(запуск в одной секции отменяет бегущую команду другой, cancel из любой клетки достаёт
её), так что ключи давай уникальные. Команду без ключа не отменить ничем — она доживёт до конца или до
смерти ViewModel. Это замена 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: isActive-guard оставлял бегущий запуск и выкидывал новый, ключ —
отменяет бегущий. Если перезапуск недопустим (подпись, отправка), гейт — фактом в клетке:
if (s.isSigning) stay(), иначе next(s.copy(isSigning = true), command = …).
Отмена не отзывает интент, который команда успела положить в очередь до неё. Если опоздавший ответ
вреден — сверяй его в клетке с текущим стейтом: так BanksLoaded в
BankSearchPullerMachine сверяет query и делает stay() на ответ к
уже сменённому запросу.
Если v3 гасил частые тапы строковым DebounceMutator — в MEL это конфиг по типу
интента: config { debounce<Rescan>(500.milliseconds) }. Дебаунсится только вход от
вью (model.send(…)); интенты команд и подписок идут в очередь мимо него. Ключ команды и
дебаунс — про разное и сочетаются.
state или ветвился по нему?Тогда это решение — в клетку. Тело команды
state не видит. Клетка читает текущий стейт, решает, нужна ли команда вообще, и кладёт
её в переход (гейт isSigning из вопроса 3). Вызовы интерактора (флаги, запросы) —
наоборот, в тело команды: клетка чистая и синхронная (см. loadPartnersStatuses(…) в примере 1a).
send(Effect) / навигацию?Не в команду. Команда вернёт интент, его поймает клетка —
и уже она положит эффект E в переход рядом с новым стейтом.
on<Confirm, Signed> { _, _ -> pop(ShowSnack("Документ подписан")) } // интент команды → эффект из клетки
Варианты подписки — по вопросам
Выбрал подписку (поток событий → интенты, пока экран в нужном состоянии)? Вопросы задают её скоуп и поведение.
Минимум один active<State>() — обязателен,
иначе граф не соберётся (скоуп пуст = подписка мертва). Старт при входе в скоуп, отмена при выходе —
это замена ручного lifecycle и onStateUniqueChanged().
subscribe(SubscriptionKey("timer"), active<Running>()) { ticker().map { Tick } }
push — ничего не нужно: скоуп — union по всему стеку,
а push оставляет нижний стейт в стеке, так что подписка живёт под пуллером.
next в другой класс стейта убирает прежний из стека — чтобы подписка его
пережила, перечисли несколько стейтов. Это прямой аналог v3 KeepAliveEffect.
subscribe(SubscriptionKey("docUpdates"), active<Content>()) { … }
// push(Confirm) поверх Content подписку не дёргает — Content остаётся в стеке
subscribe(SubscriptionKey("refresh"), active<Content>(), active<Empty>()) { … }
// next(Empty) вместо Content подписку не рвёт — оба стейта в скоупе
Да → предикат в active. Нарушился факт —
отмена; вернулся — рестарт. Предикат типизирован по своему стейту.
subscribe(SubscriptionKey("poll"), active<Content> { it.isOnline }) { poller().map(::DocUpdated) }
Есть осмысленный интент-ответ → onError (упавший источник маппится в интент,
подписка завершается; заново стартует только после выхода из скоупа и повторного входа — так же, как
источник, завершившийся сам). Иначе MEL сам
переподпишется с удваивающимся бэкоффом (1с→30с); в failFast-режиме — упадёт.
Где onError обязателен, линтеры (detekt MelSubscribeOnError, граф-линтер
плагина SUB_NO_ON_ERROR) решают по источнику в лямбде — эвристически, по именам вызовов:
- холодный падучий — всё, что не попало ниже (запрос,
flow { … },docUpdates.flow(id)), —onErrorтребуют; - шина приложения —
.asFlow()от канала/Rx, методыlisten*/observe*: неконечна и легитимна,onErrorне нужен, страхует бэкофф (пример 1b); .catchвыше по цепочке илиflowOf/emptyFlowбез лямбд — упасть нечему,onErrorне нужен;- горячий поток, собранный в самой лямбде (
MutableStateFlow,stateIn,shareIn…) — отдельное предупреждение (MelHotFlowSubscriptionSource/SUB_HOT_SOURCE): подписка не завершится, нужен холодный Flow.
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(key, active<S>()) { flow.map(::I) } |
| Эффект только навигирует/снек/close (интент не шлёт) | остаётся Effect E + on<E> в эффект-слое |
| Эффект показывает диалог и шлёт ответ пользователя интентом | диалог — часть стейта (флаг в стейте), вью рисует его и шлёт ответ model.send(…) — выход с несохранёнными реквизитами в SelfRequisitesMachine: isExitDialogShown → ExitDialogSave / ExitDialogDiscard / ExitDialogDismissed; системный пикер — Effect E, инструмент контекста запускает лаунчер, его колбэк в экране шлёт model.send(…) — эффект PickImport1CFile → CreationPullerContext.pickImport1CFile() → FileSelected / FileNotSelected |
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.
Если код и документы разойдутся — источник истины код, затем спека.