În ultimii ani, MVI a trecut de la nișă la practic standard pe Android. Modelăm ecranele ca state + intents, le legăm de ViewModel‑uri și lăsăm Compose să facă render din flows. Este un pas clar peste MVVM‑ul clasic, unde state‑ul este împrăștiat (uneori chiar la nivel de view) și lipsesc intenții + o structură de reducer explicită. Privit așa, o singură stare la nivel de aplicație este pasul natural pe axa MVVM → MVI → stare unificată. Totuși, majoritatea codebase‑urilor se opresc la granița ecranului: fiecare ecran își deține state‑ul și bucla lui, în timp ce navigarea și regulile cross‑screen locuiesc în altă parte. Rezultatul: multe insulițe de "adevăr", cablaj custom pentru orice traversează un ecran și puține șanse de replay sau time‑travel la nivel de app.
Propun un pas mai departe: păstrezi un Single State Object (SSO) pentru toată aplicația și îl conduci cu un singur event loop. Ideea nu este nouă - The Elm Architecture (TEA) și Redux folosesc un singur store de ani buni - dar pe Android încă nu este foarte răspândită. Cu Kotlin, Coroutines și Compose, implementarea devine directă și robustă.
Model = stare imuabilă
View = o funcție de stare
Intent = evenimente user/sistem
funcție pură (state, intent) -> newState (+ effects)
Reducerele sunt pure: nu cheamă network/DB/UI. În schimb, returnează stări noi și effects (descrieri ale side‑effecturilor) pe care store‑ul le va rula.
De ce separăm effects de state?
Replay: hrănești aceleași intents, obții aceleași schimbări de stare, fără să re‑execuți side‑effecturi.
Un ViewModel per screen funcționează local, dar comportamentul la nivel de app (auth, navigare, totaluri, badge‑uri, sync) ajunge să trăiască în multe state‑uri și multe bucle. Coordonarea devine cablaj custom.
Ce vrem în schimb:
Un singur global state (Single State Object)
Un singur event loop (store) pentru toată aplicația
Replay/Time‑travel: înregistrezi fluxul exact de intents și stări, apoi îl rulezi înapoi în store ca să reproduci o sesiune. Pentru că reducerele sunt pure și effects sunt separate, replay‑ul nu re‑execută I/O. Excelent pentru demo‑uri și regresii.
Crash reports cu context exact: salvezi ultima stare și ultimul intent înainte de crash; poți reproduce scenariul 1:1 și debugezi rapid.
import arrow.core.raise.Raise
typealias Effect =
suspend Raise.(Env) -> Intent
typealias Reducer =
(State, Intent) -> Update
data class Update(
val state: State,
val effects: List> =
emptyList()
)
Effect - o funcție suspendată care rulează la granița cu lumea externă (Environment). Poate face I/O și apoi returnează următorul Intent care se întoarce în buclă. Dacă ceva este în neregulă, poate raise‑ui un Problem prin Raise
Reducer - funcție pură. Fără I/O. Primește (state, intent) și calculează un Update.
Ca să rămânem modulari, împărțim aplicația în feature‑uri. Un feature conține:
un state simplu
un Intent sealed
Exemplu (app de tracking tranzacții):
RouteFeature
(ce pagină arătăm)
TransactionListFeature
(lista de tranzacții)
AddTransactionFeature
(formularul de adăugare)
Feature‑urile pot conține sub‑feature‑uri. Așa păstrăm modularitatea: fiecare feature își deține state‑ul și reducerul, plus opțional sub‑feature‑uri care sunt compuse împreună.
data class AppFeature(
val route: RouteFeature = RouteFeature(),
val list: TransactionListFeature =
TransactionListFeature(),
val add: AddTransactionFeature =
AddTransactionFeature()
)
Reducerele rămân locale fiecărui feature; la root le compunem cu Arrow Optics, astfel încât fiecare "copil" actualizează doar felia lui.
Combinarea reducerelor cu optics are doi pași mici:
Focus cu pullback(Lens
- transformi un reducer de copil într‑un reducer la nivel de părinte, focalizat pe felia copilului. Reducerul întors actualizează doar felia respectivă din părintele mare.
liftBySubtype()
- adaptezi reducerul copil astfel încât să ruleze doar pentru intents care îi aparțin (ChildIntent : ParentIntent). Pentru alte intents este no‑op. Effects rezultate de la copil sunt păstrate ca atare.Pentru comoditate, folosim embed, un one‑liner care face pullback + lift dintr‑un foc:
fun
Reducer.pullback(lens: Lens) =
Reducer { big, intent ->
val u = this(lens.get(big), intent)
Update(lens.set(big, u.state), u.effects)
}
inline fun
Reducer.liftBySubtype()
: Reducer where ChildI : ParentI = { s, pi ->
// Dacă intentul din părinte e un ChildI, rulează
// reducerul copil; altfel no‑op.
val ci = pi as? ChildI
if (ci == null) {
Update(s)
} else {
this(s, ci)
}
}
inline fun
Reducer.embed(lens:
Lens)
: Reducer where ChildI :
ParentI =
pullback(lens).liftBySubtype()
Compunem reducer‑ul final cu
combineReducers(...):
fun combineReducers(vararg reducers: Reducer)
: Reducer =
{ s, i ->
reducers.fold(Update(s)) { acc, r ->
val u = r(acc.state, i)
Update(u.state, acc.effects + u.effects)
}
}
Reducerele nu fac I/O. Ele emit effects pe care store‑ul le execută cu un Environment ce conține sursele de side‑effect (repo‑uri, use case‑uri etc.). Asta păstrează funcția de update pură și ușor de testat.
data class Env(
val txRepo: TransactionRepository,
val newId: () -> TransactionId,
val now: () -> Instant
)
interface TransactionRepository {
suspend fun save(tx: Transaction)
suspend fun all(): List
}
// în interiorul AddTransactionFeature.reducer - ramura Submit
Update(
state.copy(isSaving = true, error = null),
{ env ->
val tx = Transaction(
id = env.newId(),
date = requireNotNull(date),
from = requireNotNull(from),
to = requireNotNull(to),
amount = requireNotNull(amount)
)
env.txRepo.save(tx)
AddTxIntent.Saved(tx)
}
)
Store‑ul interpretează effects, mapează erorile (dacă apar) în intents și trimite rezultatele în aceeași buclă.
The Elm Architecture (TEA) și Redux au popularizat ideea de un singur store, actualizări pure, side‑effecturi descrise. Aplic aceleași concepte în Kotlin. Pe Android, un global store unic încă nu e foarte comun, dar cu Compose se potrivește natural.
Un Single State Object extinde MVI‑ul dincolo de limitele fiecărui ecran: un singur event loop, o stare coerentă și același model mental peste tot. Practic, înseamnă mai puțin cablaj, debug reproductibil și o bază stabilă pentru evoluții viitoare. Dincolo de eleganța arhitecturală, câștigurile sunt și operaționale: crashuri reproduse la milimetru, intent stream ca audit log și "replay" pentru regresii.