1
Scope Hierarchy — NutriTrack
Concurrencia estructurada
Jerarquía de scopes en NutriTrack
Application└── ViewModelScope (DashboardViewModel)
└── coroutineScope { } — loadDashboard()
├── async { } — fetchTodayMacros
├── async { } — fetchHistory
└── async { } — fetchActiveStreak
└── ViewModelScope (FoodSearchViewModel)
└── LaunchedEffect (Composable, search flow)
└── ServiceScope (SyncService)
└── supervisorScope { } — syncAll()
├── launch { } — syncMeals
├── launch { } — syncWeight
└── launch { } — syncGoals
Regla: cada corrutina vive dentro de un scope ligado a un ciclo de vida. Si el ViewModel se destruye, todos sus jobs se cancelan automáticamente. Nunca GlobalScope.
2
Dashboard — Carga Paralela
coroutineScope + async/await
DashboardRepository.kt
GOOD — parallel
// data/repository/DashboardRepository.kt
class DashboardRepository(
private val mealsDao: MealsDao,
private val weightDao: WeightDao,
private val streakApi: StreakApi,
private val profileDao: ProfileDao
) {
/**
* Carga todos los datos del dashboard en paralelo.
* Total: max(t_meals, t_weight, t_streak, t_profile) en vez de suma.
*/
suspend fun loadDashboard(date: LocalDate): DashboardData =
coroutineScope {
val todayMacros = async { mealsDao.getMacrosForDate(date) }
val history = async { mealsDao.getLastNDays(7) }
val activeStreak = async { streakApi.getCurrentStreak() }
val profile = async { profileDao.getCurrent() }
DashboardData(
macros = todayMacros.await(),
history = history.await(),
streak = activeStreak.await(),
profile = profile.await()
)
}
}
DashboardViewModel.kt
StateFlow + loading state
@HiltViewModel
class DashboardViewModel @Inject constructor(
private val repo: DashboardRepository
) : ViewModel() {
private val _state = MutableStateFlow<DashboardUiState>(DashboardUiState.Loading)
val state: StateFlow<DashboardUiState> = _state.asStateFlow()
init {
loadDashboard()
}
fun loadDashboard() {
viewModelScope.launch {
_state.update { DashboardUiState.Loading }
try {
val data = repo.loadDashboard(LocalDate.now())
_state.update { DashboardUiState.Success(data) }
} catch (e: Exception) {
_state.update { DashboardUiState.Error(e.message ?: "Error cargando dashboard") }
}
}
}
sealed interface DashboardUiState {
data object Loading : DashboardUiState
data class Success(val data: DashboardData) : DashboardUiState
data class Error(val msg: String) : DashboardUiState
}
}
3
StateFlow — UI State con WhileSubscribed
survives config change
WhileSubscribed(5_000) mantiene el upstream activo 5 s tras el último subscriber. Sobrevive a rotaciones de pantalla sin relanzar la query de Room.
NutritionProgressViewModel.kt
stateIn — WhileSubscribed
@HiltViewModel
class NutritionProgressViewModel @Inject constructor(
observeProgress: ObserveUserProgressUseCase
) : ViewModel() {
// combine combina 3 flows Room/API en uno solo
val uiState: StateFlow<ProgressUiState> = combine(
observeProgress.dailyMacros(),
observeProgress.weeklyCalories(),
observeProgress.userGoal()
) { macros, calories, goal ->
ProgressUiState(
macros = macros,
calories = calories,
goal = goal,
pctCalories = ((calories.total / goal.dailyCal) * 100).coerceAtMost(100.0)
)
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = ProgressUiState.EMPTY
)
}
4
Búsqueda de Alimentos — debounce + retry
flatMapLatest · distinctUntilChanged · retryWhen
FoodSearchViewModel.kt
debounce 300ms + retry backoff
@HiltViewModel
class FoodSearchViewModel @Inject constructor(
private val foodRepo: FoodRepository
) : ViewModel() {
private val _query = MutableStateFlow("")
val query: StateFlow<String> = _query.asStateFlow()
val results: StateFlow<SearchState> = _query
.debounce(300) // espera 300ms de silencio
.distinctUntilChanged() // omite duplicados
.flatMapLatest { q -> // cancela búsqueda anterior
if (q.length < 2)
flowOf(SearchState.Idle)
else
foodRepo
.searchFood(q)
.retryWhen { cause, attempt ->
if (cause is IOException && attempt < 3) {
delay(1000L * (1 shl attempt.toInt()))
true
} else false
}
.map { SearchState.Results(it) }
.catch { emit(SearchState.Error("Sin conexión")) }
}
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), SearchState.Idle)
fun onQueryChange(q: String) { _query.update { q } }
sealed interface SearchState {
data object Idle : SearchState
data class Results(val foods: List<Food>) : SearchState
data class Error(val msg: String) : SearchState
}
}
5
Sync Background — supervisorScope
fallo independiente por job
Con
coroutineScope normal, si syncMeals lanza excepción, cancela syncWeight y syncGoals. Con supervisorScope cada job falla de forma independiente.
NutriSyncService.kt
supervisorScope — fallo independiente
class NutriSyncService @Inject constructor(
private val mealsSync: MealsSyncUseCase,
private val weightSync: WeightSyncUseCase,
private val goalsSync: GoalsSyncUseCase
) {
suspend fun syncAll() = supervisorScope {
val jobMeals = launch { mealsSync.run() }
val jobWeight = launch { weightSync.run() }
val jobGoals = launch { goalsSync.run() }
// Esperamos y recogemos errores sin cancelar el batch
jobMeals.join()
jobWeight.join()
jobGoals.join()
}
// Cancelación cooperativa: processItems para listas largas
suspend fun bulkImport(entries: List<FoodEntry>) = coroutineScope {
for (entry in entries) {
ensureActive() // coopera con la cancelación
withContext(Dispatchers.IO) {
mealsSync.insertLocal(entry)
}
}
}
}
6
One-Shot Events — SharedFlow
snackbars · navegación
LogMealViewModel.kt + LogMealScreen.kt
SharedFlow — no replay
// ViewModel
class LogMealViewModel : ViewModel() {
private val _effects = MutableSharedFlow<Effect>()
val effects: SharedFlow<Effect> = _effects.asSharedFlow()
sealed interface Effect {
data class ShowSnackbar(val message: String) : Effect
data object NavigateToDashboard : Effect
data class LogError(val code: Int) : Effect
}
fun logMeal(meal: Meal) {
viewModelScope.launch {
try {
repo.insert(meal)
_effects.emit(Effect.ShowSnackbar("✓ ${meal.name} registrado"))
_effects.emit(Effect.NavigateToDashboard)
} catch (e: Exception) {
_effects.emit(Effect.LogError(500))
}
}
}
}
// Composable — colecta efectos one-shot
@Composable
fun LogMealScreen(vm: LogMealViewModel, navController: NavController) {
val snackbar = remember { SnackbarHostState() }
LaunchedEffect(Unit) {
vm.effects.collect { effect ->
when (effect) {
is Effect.ShowSnackbar -> snackbar.showSnackbar(effect.message)
is Effect.NavigateToDashboard -> navController.navigate("dashboard")
is Effect.LogError -> snackbar.showSnackbar("Error: ${effect.code}")
}
}
}
}
7
Testing — Turbine + TestCoroutineDispatcher
runTest · advanceUntilIdle · awaitItem
FoodSearchViewModelTest.kt
Turbine — StateFlow
class FoodSearchViewModelTest {
private lateinit var viewModel: FoodSearchViewModel
private lateinit var fakeRepo: FakeFoodRepository
@BeforeTest
fun setUp() {
fakeRepo = FakeFoodRepository()
viewModel = FoodSearchViewModel(fakeRepo)
}
@Test
fun `debounce filters rapid keystrokes and returns results`() = runTest {
fakeRepo.setResults(listOf(Food("Avena integral", 389)))
viewModel.results.test {
assertEquals(SearchState.Idle, awaitItem())
// Simular keystrokes rápidos (debounce 300ms)
viewModel.onQueryChange("a")
viewModel.onQueryChange("av")
viewModel.onQueryChange("ave")
advanceTimeBy(350) // avanzamos el tiempo virtual
val result = awaitItem()
assertTrue(result is SearchState.Results)
assertEquals(1, (result as SearchState.Results).foods.size)
cancelAndIgnoreRemainingEvents()
}
}
}
// Fake para tests — sin Room, sin red
class FakeFoodRepository : FoodRepository {
private var _results = MutableStateFlow<List<Food>>(emptyList())
override fun searchFood(q: String): Flow<List<Food>> = _results
fun setResults(r: List<Food>) { _results.value = r }
}
DashboardRepositoryTest.kt
parallel load timing
@Test
fun `loadDashboard fetches all sources in parallel`() = runTest {
val repo = DashboardRepository(
mealsDao = FakeMealsDao(),
weightDao = FakeWeightDao(),
streakApi = FakeStreakApi(),
profileDao = FakeProfileDao()
)
val data = repo.loadDashboard(LocalDate.now())
advanceUntilIdle()
assertNotNull(data.macros)
assertNotNull(data.history)
assertNotNull(data.streak)
assertNotNull(data.profile)
}
8
Anti-Patterns — NutriTrack migration checklist
Errores encontrados en el código original
| Anti-patrón | Riesgo | Fix | Estado |
|---|---|---|---|
GlobalScope.launch { fetchData() } |
LEAK No cancela con ViewModel | viewModelScope.launch |
Fixed |
Collect Flow en init { } sin scope |
CRASH IllegalStateException | viewModelScope.launch { flow.collect {} } |
Fixed |
MutableStateFlow(mutableListOf()) |
BUG State no reactivo | _state.update { it.copy(list = it.list + item) } |
Fixed |
catch (e: CancellationException) { } |
WARN Cancellation swallowed | No capturar CancellationException; relanzarla |
Fixed |
Crear Flow en @Composable sin remember |
PERF Recreación en cada recomposición | val f = remember { viewModel.flow } |
Pendiente |
| Callbacks de Retrofit sin coroutines | DEBT No cancelable, no structured | Suspend functions + suspendCancellableCoroutine |
En progreso |
9
Dispatchers — Preparación KMP (iOS Q4 2026)
Android vs KMP
DispatcherProvider.kt (KMP-ready)
DI para testing
// shared/src/commonMain/kotlin/
interface DispatcherProvider {
val main: CoroutineDispatcher
val default: CoroutineDispatcher
val io: CoroutineDispatcher // JVM/Android; = Default en iOS
}
// androidMain
class AndroidDispatcherProvider : DispatcherProvider {
override val main = Dispatchers.Main
override val default = Dispatchers.Default
override val io = Dispatchers.IO
}
// iosMain
class IosDispatcherProvider : DispatcherProvider {
override val main = Dispatchers.Main
override val default = Dispatchers.Default
override val io = Dispatchers.Default // IO no existe en iOS
}
// Testing
class TestDispatcherProvider(val testDispatcher: TestCoroutineDispatcher) : DispatcherProvider {
override val main = testDispatcher
override val default = testDispatcher
override val io = testDispatcher
}