ABONAMENTE VIDEO REDACȚIA
RO
EN
Numărul 159
NOU
Numărul 158
Numărul 157 Numărul 156 Numărul 155 Numărul 154 Numărul 153 Numărul 152 Numărul 151 Numărul 150 Numărul 149 Numărul 148 Numărul 147 Numărul 146 Numărul 145 Numărul 144 Numărul 143 Numărul 142 Numărul 141 Numărul 140 Numărul 139 Numărul 138 Numărul 137 Numărul 136 Numărul 135 Numărul 134 Numărul 133 Numărul 132 Numărul 131 Numărul 130 Numărul 129 Numărul 128 Numărul 127 Numărul 126 Numărul 125 Numărul 124 Numărul 123 Numărul 122 Numărul 121 Numărul 120 Numărul 119 Numărul 118 Numărul 117 Numărul 116 Numărul 115 Numărul 114 Numărul 113 Numărul 112 Numărul 111 Numărul 110 Numărul 109 Numărul 108 Numărul 107 Numărul 106 Numărul 105 Numărul 104 Numărul 103 Numărul 102 Numărul 101 Numărul 100 Numărul 99 Numărul 98 Numărul 97 Numărul 96 Numărul 95 Numărul 94 Numărul 93 Numărul 92 Numărul 91 Numărul 90 Numărul 89 Numărul 88 Numărul 87 Numărul 86 Numărul 85 Numărul 84 Numărul 83 Numărul 82 Numărul 81 Numărul 80 Numărul 79 Numărul 78 Numărul 77 Numărul 76 Numărul 75 Numărul 74 Numărul 73 Numărul 72 Numărul 71 Numărul 70 Numărul 69 Numărul 68 Numărul 67 Numărul 66 Numărul 65 Numărul 64 Numărul 63 Numărul 62 Numărul 61 Numărul 60 Numărul 59 Numărul 58 Numărul 57 Numărul 56 Numărul 55 Numărul 54 Numărul 53 Numărul 52 Numărul 51 Numărul 50 Numărul 49 Numărul 48 Numărul 47 Numărul 46 Numărul 45 Numărul 44 Numărul 43 Numărul 42 Numărul 41 Numărul 40 Numărul 39 Numărul 38 Numărul 37 Numărul 36 Numărul 35 Numărul 34 Numărul 33 Numărul 32 Numărul 31 Numărul 30 Numărul 29 Numărul 28 Numărul 27 Numărul 26 Numărul 25 Numărul 24 Numărul 23 Numărul 22 Numărul 21 Numărul 20 Numărul 19 Numărul 18 Numărul 17 Numărul 16 Numărul 15 Numărul 14 Numărul 13 Numărul 12 Numărul 11 Numărul 10 Numărul 9 Numărul 8 Numărul 7 Numărul 6 Numărul 5 Numărul 4 Numărul 3 Numărul 2 Numărul 1
×
▼ LISTĂ EDIȚII ▼
Numărul 159
Abonamente

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

Paul Orha
Android Automotive Developer @ P3 Romania



PROGRAMARE


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

LANSAREA NUMĂRULUI 159

Programarea, securitatea și AI-ul

Marți, 30 Septembrie, ora 18:00

msg systems Romania

Timișoara

Facebook Meetup StreamEvent YouTube

Conferință TSM

NUMĂRUL 158 - Software Optimization

Sponsori

  • BT Code Crafters
  • Bosch
  • Betfair
  • MHP
  • BoatyardX
  • .msg systems
  • P3 group
  • Ing Hubs
  • Cognizant Softvision
  • GlobalLogic
  • BMW TechWorks Romania

INTERVIU