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-работа живёт в команде/подписке — клетка остаётся чистой функцией.
Пример 1

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-эффект:

Только навигация / снек / closeинтент не шлёт
→
остаётся Effect Eon<E> в эффект-слое
Одноразовая работа → один интентload / sign / запрос
→
Commandcommand { … }
Поток событий → интент на каждоеeventBus / Flow / сокет
→
Subscriptionsubscribe(active<S>) { … }

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

Развилка

Варианты команды — по вопросам

Выбрал команду (одноразовая работа → интент)? Пройди вопросы — они задают форму вызова.

Вопрос 1Что произойдёт, если упадёт с исключением?

Обязательно задай onError — это не опция. Исключение в block не валит цикл, а превращается в интент.

command = command(onError = { LoadFailed(it.userMessage()) }) {
    Loaded(interactor.getDocument(id))      // успех → Loaded, бросок → LoadFailed
}
Вопрос 2Один результат или несколько промежуточных интентов (прогресс)?

Несколько → commandWithProgress: emit(I) кладёт интент в очередь по ходу, финальный return — последним.

command = commandWithProgress(onError = { Failed(it.userMessage()) }) { emit ->
    uploader.parts(file).collect { p -> emit(Progress(p.percent)) }   // промежуточные
    Done(file.id)                                                   // финальный
}
Вопрос 3Может прилететь второй запуск, пока первый бежит? Нужна отмена?

Да → дай 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(…)); интенты команд и подписок идут в очередь мимо него. Ключ команды и дебаунс — про разное и сочетаются.

Вопрос 4v3-хендлер внутри читал state или ветвился по нему?

Тогда это решение — в клетку. Тело команды state не видит. Клетка читает текущий стейт, решает, нужна ли команда вообще, и кладёт её в переход (гейт isSigning из вопроса 3). Вызовы интерактора (флаги, запросы) — наоборот, в тело команды: клетка чистая и синхронная (см. loadPartnersStatuses(…) в примере 1a).

Вопрос 5v3-эффект внутри ещё и делал send(Effect) / навигацию?

Не в команду. Команда вернёт интент, его поймает клетка — и уже она положит эффект E в переход рядом с новым стейтом.

on<Confirm, Signed> { _, _ -> pop(ShowSnack("Документ подписан")) }   // интент команды → эффект из клетки
Развилка

Варианты подписки — по вопросам

Выбрал подписку (поток событий → интенты, пока экран в нужном состоянии)? Вопросы задают её скоуп и поведение.

Вопрос 1На каких состояниях подписка должна быть активна?

Минимум один active<State>() — обязателен, иначе граф не соберётся (скоуп пуст = подписка мертва). Старт при входе в скоуп, отмена при выходе — это замена ручного lifecycle и onStateUniqueChanged().

subscribe(SubscriptionKey("timer"), active<Running>()) { ticker().map { Tick } }
Вопрос 2Должна пережить overlay/пуллер поверх (push) или смену стейта (next)?

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 подписку не рвёт — оба стейта в скоупе
Вопрос 3Активность зависит не от типа стейта, а от его поля?

Да → предикат в active. Нарушился факт — отмена; вернулся — рестарт. Предикат типизирован по своему стейту.

subscribe(SubscriptionKey("poll"), active<Content> { it.isOnline }) { poller().map(::DocUpdated) }
Вопрос 4Что при падении источника (исключение во Flow)?

Есть осмысленный интент-ответ → 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) }
Вопрос 5Источнику нужен параметр (id, фильтр)?

source — это лямбда () -> Flow<I>, пересоздаётся на каждый (ре)старт. Замыкай зависимости из конструктора машины (args, интерактор). Важно: текущий стейт она не получает — если v3-стрим параметризовался стейтом, зафиксируй параметр в args/стартовом стейте, либо подними решение в клетку.

Вопрос 6«Слушать всегда, на всём экране»?

Нет «глобальной без стейта». Повесь active на корневой/базовый стейт-класс — он есть в стеке всегда, значит подписка жива весь экран.

subscribe(SubscriptionKey("connectivity"), active<ScreenState>()) { net.online().map(::Connectivity) }
// ScreenState — общий супертип всех состояний экрана: всегда в стеке

Шпаргалка соответствий

MVI v3MEL
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 в переход
строковый DebounceMutatorconfig { debounce<I>(…) } по типу интента

Соседние документы: учебное «как пользоваться» — mel-guide.html; каноническая архитектура и инварианты — mel-design.html. Если код и документы разойдутся — источник истины код, затем спека.