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

Реальный 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 (навигация — это данные эффекта, исполняет роутер).

Развилка

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

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

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

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

Тогда это решение — в клетку. Тело команды state не видит. Клетка читает текущий стейт, решает, нужна ли команда вообще, и кладёт её в переход (см. widgetEnabled() в примере 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)?

Да → перечисли несколько стейтов. Скоуп — union по стеку: push поверх не рвёт подписку. Это прямой аналог v3 KeepAliveEffect.

subscribe(SubscriptionKey("docUpdates"), active<Content>(), active<Confirm>()) { … }
// push(Confirm) поверх Content подписку не дёргает — она в скоупе обоих
Вопрос 3Активность зависит не от типа стейта, а от его поля?

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

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

Есть осмысленный интент-ответ → onError (упавший источник маппится в интент, подписка завершается). Иначе MEL сам переподпишется с удваивающимся бэкоффом (1с→30с); в failFast-режиме — упадёт.

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

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