NutriTrack — Kotlin Coroutines & Flows

Refactoring async architecture: concurrencia estructurada, StateFlow UI state, búsqueda reactiva y testing

Kotlin 2.0 kotlinx.coroutines 1.8 Jetpack Compose KMP-ready Anti-GlobalScope Turbine tests
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
    )
}
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
}