TSM - Unificarea stării într-o aplicație Android: MVI și patternul Single State Object

Paul Orha - Android Automotive Developer @ P3 Romania


Î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ă.

Recap rapid: MVI în 1 minut

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?

Ce lipsește din "MVI pe ecran" pe Android

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:

De ce merită un single global state (beneficii reale)

Algebra de bază

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

Feature‑uri, nu ecrane

Ca să rămânem modulari, împărțim aplicația în feature‑uri. Un feature conține:

Exemplu (app de tracking tranzacții):

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.

Cablarea reducer‑elor cu optics (pullback + lift)

Combinarea reducerelor cu optics are doi pași mici:

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

Effects & Environment (granița side‑effect‑urilor)

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

Elm & Redux: inspirația

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.

Concluzie

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.