AND

Diseño de sistemas

Guía de Arquitectura Móvil

Un recorrido senior de diseño de sistemas móviles mapeado sobre Android moderno — Compose, MVVM/MVI, Clean Architecture, y offline-first. Este es el material de 'cómo piensas sobre construir una app Android a escala'.

01 · El marco de diseño de sistemas móviles

Usa una columna vertebral repetible para no congelarte: Clarificar → Diseño de alto nivel → Inmersión profunda → Compensaciones → Resumen.

  • Clarifica por unos minutos primero: requisitos funcionales, escala, expectativas offline, necesidades de tiempo real, SDK mínimo, y qué pantallas importan. Resiste diseñar hasta que hayas acotado.
  • DAL: bosqueja las capas — UI (Compose + ViewModel), dominio (casos de uso), datos (repository + fuentes remotas/locales).
  • Inmersión profunda en un componente que les importe (ej. el motor de sincronización o el feed).
  • Compensaciones: díglasas en voz alta — cada elección tiene un costo.
  • Resume y maneja seguimientos.
Señal senior Clarifica antes de diseñar, y narra la compensación en cada decisión. Los entrevistadores evalúan tu proceso más que las cajas-y-flechas finales.

02 · Arquitectura en capas y flujo de datos unidireccional

Tres capas, dependencias apuntando hacia adentro:

  • Capa de UI — Composables renderizan un UiState inmutable; el ViewModel lo expone como StateFlow y recibe eventos como llamadas a funciones.
  • Capa de dominio (opcional) — casos de uso puros de Kotlin conteniendo reglas de negocio, sin dependencias de Android, la capa más testeable.
  • Capa de datos — repositorios poseen una fuente única de verdad y coordinan fuentes remotas (Retrofit) + locales (Room/DataStore), mapeando DTOs a modelos de dominio.

Flujo de datos unidireccional: el estado fluye hacia arriba desde la capa de datos como Flows; los eventos fluyen hacia abajo como llamadas. La UI es una función del estado, así que es predecible y testeable.

03 · La capa de red

Un cliente robusto separa responsabilidades: OkHttp (motor, pool, caché, interceptores) debajo de Retrofit (API tipada) con Moshi/kotlinx.serialization para JSON.

  • Interceptores: a nivel de aplicación para headers de auth y logging; a nivel de red para control de caché. Un Authenticator maneja el refresh de token 401 en un solo lugar.
  • Modelado de resultados: envuelve llamadas en un tipo sealed éxito/error; nunca dejes que HttpException/IOException crudo llegue a Compose.
  • Resiliencia: timeouts razonables, retry con backoff solo en llamadas idempotentes, y cancelación vinculada al scope de coroutine del llamador.
Compensación Una caché normalizada (por entidad) refleja una actualización en todas partes pero añade complejidad; una caché más simple por solicitud más Room como fuente de verdad generalmente es suficiente en móvil.

04 · Almacenamiento, caché e invalidación

Elige almacenamiento por forma: Room para datos estructurados/relacionales y lecturas reactivas; DataStore para preferencias clave-valor/tipadas; EncryptedSharedPreferences/Keystore para secretos; archivos/MediaStore para blobs.

  • Evicción de caché (espacio libre): LRU o TTL. Invalidación de caché (corrección): basada en tiempo, basada en eventos (una mutación rompe una clave), o basada en versión.
  • Stale-while-revalidate: muestra datos cacheados instantáneamente, refresca en segundo plano, reconcilia — la razón por la que las buenas apps se sienten rápidas.
  • Los DAOs de Room que retornan Flow hacen la caché reactiva: escribe una vez, cada observador se actualiza.

05 · Offline-first y sincronización

La base de datos local es la fuente de verdad; la red solo la sincroniza. La UI siempre lee datos locales, así que funciona sin señal.

  • Actualizaciones optimistas: aplica el cambio localmente y renderiza inmediatamente; mantén el valor anterior para revertir en fallo.
  • Cola de sincronización: encola mutaciones offline y reexecútalas (vía WorkManager) cuando la conectividad regrese, con keys de idempotencia.
  • Resolución de conflictos: elige por tipo de datos — última escritura gana, servidor gana, o una fusión. Nombra la política explícitamente.
Compensación La UX optimista se siente instantánea pero necesita rollback y manejo de conflictos; para dinero/inventario puedes preferir confirmación pésimista. Di cuál y por qué.

06 · Tiempo real — WebSocket, SSE, polling y push

Iguala el transporte a la necesidad y la batería:

  • WebSocket para bidireccional, baja latencia (chat, presencia). Gestiona reconexión/backoff y ciclo de vida.
  • SSE / long-poll para streams unidireccionales servidor→cliente (feeds, marcadores en vivo).
  • Polling para actualizaciones simples y de baja frecuencia sin infraestructura de socket.
  • FCM push cuando la app está en segundo plano o muerta — la única forma confiable de despertarla.
Compensación Mantener un socket abierto para actualizaciones ocasionales agota la batería (energía de cola de radio). No mantengas una conexión viva cuando push periódico o polling harían el trabajo.

07 · Paginación a escala (Paging 3)

Para listas grandes, pagina lazy. Paging 3 proporciona un PagingSource (o RemoteMediator para red+DB), expone Flow<PagingData>, y maneja estados de carga y reintentos.

  • La paginación por cursor/keyset es el valor por defecto correcto para feeds — estable bajo inserciones, a diferencia de offset/limit.
  • RemoteMediator implementa paginación offline-first: pagina desde Room, obtén + persiste la siguiente página desde la red.
  • Recolecta con collectAsLazyPagingItems() en Compose y renderiza placeholders + spinners de carga al final.

08 · Esenciales de rendimiento y seguridad

Incorpora ambos desde el inicio:

  • Rendimiento: disciplina del hilo principal, estabilidad de Compose, Baseline Profiles, y puertas de Macrobenchmark en CI. Mide arranque en frío y jank en builds de producción.
  • Seguridad: Keystore para llaves, EncryptedSharedPreferences para secretos, Network Security Config + pinning en tránsito, Play Integrity para certificación, R8 para ofuscación.
  • Observabilidad: reporte de crashes (tasa de crash-free como métrica principal), seguimiento de ANR, y control de salud de release en lanzamientos escalonados.
Señal senior Trata un deploy como "terminado" solo cuando la tasa de crash-free se mantiene a lo largo del lanzamiento — el monitoreo es parte de distribuir.

Inmersiones

El playbook senior en una forma de concepto → ejemplo → problema → solución, para que cada idea se quede como una decisión de ingeniería real en lugar de una definición.

Estado

Recolección de Flow segura del ciclo de vida

Concepto: recolectar un Flow mantiene el upstream funcionando mientras el collector esté activo.

Problema: recolectar en onCreate con un lifecycleScope.launch simple sigue recolectando en segundo plano — trabajo desperdiciado y actualizaciones de UI obsoletas.

Solución: vincula la recolección al ciclo de vida.

// Compose val ui by viewModel.uiState.collectAsStateWithLifecycle() // Views lifecycleScope.launch { repeatOnLifecycle(Lifecycle.State.STARTED) { viewModel.uiState.collect { render(it) } } }

La recolección ahora se detiene debajo de STARTED y se reinicia al regresar — sin churn en segundo plano.

Estado

Cacheo de StateFlow con stateIn(WhileSubscribed)

Concepto: un Flow frío se reinicia por collector; quieres un stream de estado compartido y cacheado.

Problema: en rotación el suscriptor cae brevemente, y un share naive desmonta y re-obtiene.

Solución: comparte con un stop-timeout para que un cambio de configuración no reinicie el upstream.

val uiState: StateFlow<UiState> = repository.observeItems() .map { UiState(items = it) } .stateIn( scope = viewModelScope, started = SharingStarted.WhileSubscribed(5_000), initialValue = UiState(loading = true), )

La ventana de 5s conserva el valor cacheado a través de rotación mientras aún detiene el trabajo cuando la pantalla realmente se fue.

Flow

Búsqueda con flatMapLatest + debounce

Concepto: una caja de búsqueda debería cancelar la consulta anterior cuando el usuario sigue escribiendo.

Problema: disparar una solicitud por tecla desperdicia la red y puede renderizar resultados obsoletos fuera de orden.

Solución: debuncia la consulta y cambia al más reciente con flatMapLatest.

val results = queryFlow .debounce(300) .distinctUntilChanged() .flatMapLatest { q -> if (q.isBlank()) flowOf(emptyList()) else repository.search(q) // se cancela cuando q cambia } .flowOn(Dispatchers.Default)

flatMapLatest cancela el flow interno en vuelo en el momento en que llega una nueva consulta — sin carrera, sin lista obsoleta.

Compose

Arreglando sobre-recomposición con estabilidad

Concepto: Compose omite un composable solo cuando sus parámetros son estables e inmutables.

Problema: un elemento de lista toma items: List<Item>; List se trata como inestable, así que cada elemento se recomposición en cualquier cambio.

Solución: usa una colección inmutable (o anota el modelo) para que Compose pueda omitir.

// build.gradle: implementation("org.jetbrains.kotlinx:kotlinx-collections-immutable:...") @Immutable data class Item(val id: String, val title: String) @Composable fun Feed(items: ImmutableList<Item>) { LazyColumn { items(items, key = { it.id }) { ItemRow(it) } } }

Verifica con contadores de recomposición del Layout Inspector o el reporte de métricas del compilador de Compose.

Concurrencia

Seguridad del principal con withContext

Concepto: una función suspend debe ser segura para llamar desde el hilo principal.

Problema: hacer I/O bloqueante (disco, parseo JSON) en Dispatchers.Main pierde frames o dispara un ANR.

Solución: cambia dispatchers dentro de la función, y inyecta el dispatcher para testeabilidad.

class Repo(private val io: CoroutineDispatcher = Dispatchers.IO) { suspend fun load(id: String): Model = withContext(io) { val dto = api.fetch(id) // seguro para bloqueo en IO dto.toModel() } } // test: Repo(StandardTestDispatcher())

Los llamadores permanecen en Main; la función posee sus hilos. Inyectar io permite que los tests lo controlen.

Jetpack

Trabajo garantizado en segundo plano con WorkManager

Concepto: algo de trabajo debe completarse incluso a través de muerte del proceso y reinicio.

Problema: una coroutine en viewModelScope muere con la pantalla; un Service crudo es pesado y frágil para una sincronización diferida.

Solución: encadena un CoroutineWorker con restricciones.

class SyncWorker(c: Context, p: WorkerParameters) : CoroutineWorker(c, p) { override suspend fun doWork(): Result = try { repo.sync(); Result.success() } catch (e: IOException) { Result.retry() } } val req = OneTimeWorkRequestBuilder<SyncWorker>() .setConstraints(Constraints(requiredNetworkType = NetworkType.CONNECTED)) .build() WorkManager.getInstance(ctx) .enqueueUniqueWork("sync", ExistingWorkPolicy.KEEP, req)
DI

Scopes de Hilt y @Binds vs @Provides

Concepto: Hilt genera un grafo de dependencias vinculado a ciclos de vida de Android.

Problema: necesitas un binding interfaz→impl y un objeto de terceros que no posees, con los tiempos de vida correctos.

Solución: @Binds para interfaces, @Provides para tipos poseídos por otros, con scope correcto.

@Module @InstallIn(SingletonComponent::class) abstract class DataModule { @Binds @Singleton abstract fun bindRepo(impl: RepoImpl): Repo companion object { @Provides @Singleton fun retrofit(): Retrofit = Retrofit.Builder() .baseUrl(BASE).addConverterFactory(/* ... */).build() } } @HiltViewModel class FeedViewModel @Inject constructor(private val repo: Repo) : ViewModel()

Un binding faltante es un error de compilación, no un crash en tiempo de ejecución — una ventaja real que debes nombrar.

Datos

Room como la fuente única de verdad

Concepto: la UI observa la base de datos; la red solo la actualiza.

Problema: obtener directo a la UI da spinners en todas partes y copias divergentes de los datos.

Solución: lee desde un DAO Flow; el refresh escribe de vuelta a Room, que re-emite.

@Dao interface ItemDao { @Query("SELECT * FROM items ORDER BY updatedAt DESC") fun observeAll(): Flow<List<ItemEntity>> @Upsert suspend fun upsertAll(items: List<ItemEntity>) } fun observeItems(): Flow<List<Item>> = dao.observeAll().map { it.toDomain() } suspend fun refresh() { dao.upsertAll(api.fetch().toEntities()) }

La pantalla renderiza instantáneamente desde caché y se actualiza en el momento en que refresh() escribe — stale-while-revalidate, incorporado.

Seguridad

Certificate pinning (y su riesgo de rotación)

Concepto: el pinning confía solo en el certificado/clave pública de tu servidor, bloqueando man-in-the-middle vía un CA fraudulento.

Problema: si haces pin de un solo certificado y lo rotas, cada cliente antiguo se rompe.

Solución: haz pin con OkHttp, incluye un pin de respaldo, y distribuye pins antes de la rotación.

val pinner = CertificatePinner.Builder() .add("api.example.com", "sha256/AAAA…") // actual .add("api.example.com", "sha256/BBBB…") // respaldo / siguiente .build() val client = OkHttpClient.Builder().certificatePinner(pinner).build()

Prefer hacer pin del SPKI (clave pública) sobre el certificado hoja, y siempre mantén un pin de respaldo para sobrevivir la rotación.

Rendimiento

Baseline Profiles para arranque en frío

Concepto: un Baseline Profile lista código caliente para que ART lo compile AOT al momento de instalación en lugar de interpretar en la primera ejecución.

Problema: el primer lanzamiento y el primer scroll son lentos porque ese código se compila JIT en frío.

Solución: genera un perfil con un recorrido de Macrobenchmark y distribúyelo.

@Test fun generate() = baselineRule.collect( packageName = "com.example.app", ) { startActivityAndWait() // haz scroll del feed para que se capture la ruta caliente device.findObject(By.res("feed")).fling(Direction.DOWN) }

Mide el antes/después con un Macrobenchmark separado y mantenlo en CI para que la victoria no pueda tener regresión.