Desarrollo Android con Kotlin
Guía técnica completa que cubre fundamentos de Kotlin, Android Jetpack, Material Design 3, inyección de dependencias, networking, sistema de build Gradle, automatización con Fastlane, testing, ProGuard/R8, apps white-label y despliegue en Play Store.
Índice de Contenidos
- Fundamentos de Kotlin para Android
- Activities, Fragments y Navegación
- Jetpack: Compose, ViewModel, LiveData, Room, WorkManager, DataStore
- Material Design 3
- Inyección de Dependencias: Hilt y Koin
- Networking: Retrofit y OkHttp
- Sistema de Build Gradle
- Automatización con Fastlane
- Testing: JUnit, Espresso y Compose Testing
- Optimización con ProGuard y R8
- Builds de Apps White-Label
- Despliegue en Play Store
- Últimas Funcionalidades (2025-2026)
1. Fundamentos de Kotlin para Android
Null Safety y Sistema de Tipos
El sistema de tipos de Kotlin distingue tipos nullable y non-nullable en tiempo de compilación, eliminando la mayoría de NullPointerExceptions. El operador ?, safe calls (?.) y el operador Elvis (?:) proporcionan manejo conciso de nulos.
// Nullable type declaration
var name: String? = null
// Safe call chain with Elvis default
val length = name?.trim()?.length ?: 0
// Smart cast after null check
if (name != null) {
println(name.length) // compiler knows it's non-null
}
// let scope function for nullable
name?.let { nonNullName ->
println("Name is $nonNullName")
}
Coroutines y Concurrencia Estructurada
Las coroutines de Kotlin proporcionan concurrencia liviana. Android usa viewModelScope y lifecycleScope para vincular el ciclo de vida de las coroutines a los componentes de UI, previniendo leaks.
// ViewModel with coroutine scope
class UserViewModel : ViewModel() {
private val _users = MutableStateFlow<List<User>>(emptyList())
val users: StateFlow<List<User>> = _users.asStateFlow()
fun loadUsers() {
viewModelScope.launch {
val result = withContext(Dispatchers.IO) {
userRepository.fetchAll()
}
_users.value = result
}
}
}
// Structured concurrency with supervisorScope
suspend fun fetchDashboard() = supervisorScope {
val profile = async { api.getProfile() }
val stats = async { api.getStats() }
DashboardData(profile.await(), stats.await())
}
Funciones de Extensión y DSLs
Las funciones de extensión permiten agregar comportamiento a clases existentes sin herencia. Combinadas con lambdas y receivers, habilitan lenguajes de dominio específico (DSLs) expresivos.
// Extension function on View
fun View.visible() { visibility = View.VISIBLE }
fun View.gone() { visibility = View.GONE }
// Extension with generic constraint
fun <T : Comparable<T>> List<T>.isSorted(): Boolean =
zipWithNext().all { (a, b) -> a <= b }
// DSL-style builder
val dialog = alertDialog(context) {
title = "Confirm"
message = "Delete this item?"
positiveButton("Delete") { deleteItem() }
negativeButton("Cancel") { dismiss() }
}
Sealed Classes y Data Classes
Las sealed classes restringen jerarquías de clases a un conjunto finito de subtipos, habilitando expresiones when exhaustivas. Las data classes auto-generan equals, hashCode, copy y toString, siendo ideales para modelado de estado y DTOs.
// Sealed class for navigation events
sealed class NavigationEvent {
data class GoToDetail(val itemId: Long) : NavigationEvent()
data class GoToProfile(val userId: String) : NavigationEvent()
object GoBack : NavigationEvent()
object GoHome : NavigationEvent()
}
// Exhaustive when — compiler enforces all branches
fun handleNavigation(event: NavigationEvent) = when (event) {
is NavigationEvent.GoToDetail -> navigateToDetail(event.itemId)
is NavigationEvent.GoToProfile -> navigateToProfile(event.userId)
NavigationEvent.GoBack -> popBackStack()
NavigationEvent.GoHome -> navigateToHome()
}
// Data class with copy for immutable updates
data class UserProfile(
val name: String,
val email: String,
val avatarUrl: String? = null,
val isPremium: Boolean = false
)
val updated = profile.copy(isPremium = true)
2. Activities, Fragments y Navegación
Ciclo de Vida de Activity y Fragment
Entender el ciclo de vida es crítico para prevenir memory leaks y pérdida de datos. Las Activities pasan por onCreate → onStart → onResume → onPause → onStop → onDestroy. Los Fragments agregan onCreateView y onDestroyView.
class MainActivity : AppCompatActivity() {
private lateinit var binding: ActivityMainBinding
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)
// Lifecycle-aware observer
lifecycle.addObserver(AnalyticsObserver())
}
}
// Fragment with ViewBinding
class ProfileFragment : Fragment(R.layout.fragment_profile) {
private var _binding: FragmentProfileBinding? = null
private val binding get() = _binding!!
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?): View {
_binding = FragmentProfileBinding.inflate(inflater, container, false)
return binding.root
}
override fun onDestroyView() {
super.onDestroyView()
_binding = null // prevent memory leak
}
}
Componente de Navegación Jetpack
El componente de Navigation proporciona un framework para navegación in-app con editor visual de grafos, safe args para paso de argumentos type-safe y soporte de deep links.
<!-- nav_graph.xml -->
<navigation xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
app:startDestination="@id/homeFragment">
<fragment android:id="@+id/homeFragment"
android:name="com.example.HomeFragment">
<action android:id="@+id/action_home_to_detail"
app:destination="@id/detailFragment">
<argument android:name="itemId" app:argType="long" />
</action>
</fragment>
</navigation>
// Navigate with safe args
val action = HomeFragmentDirections
.actionHomeToDetail(itemId = 42L)
findNavController().navigate(action)
3. Jetpack: Compose, ViewModel, LiveData, Room, WorkManager, DataStore
Jetpack Compose
Compose es el toolkit de UI declarativo moderno de Android. Reemplaza layouts XML con funciones composable que describen la UI como función del estado. La recomposición actualiza automáticamente solo las partes de la UI que cambiaron.
@Composable
fun UserList(viewModel: UserViewModel = viewModel()) {
val users by viewModel.users.collectAsState()
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(16.dp)
) {
items(users, key = { it.id }) { user ->
UserCard(user = user, onClick = { viewModel.select(user) })
}
}
}
@Composable
fun UserCard(user: User, onClick: () -> Unit) {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp)
.clickable(onClick = onClick),
elevation = CardDefaults.cardElevation(2.dp)
) {
Column(modifier = Modifier.padding(16.dp)) {
Text(user.name, style = MaterialTheme.typography.titleMedium)
Text(user.email, style = MaterialTheme.typography.bodySmall)
}
}
}
ViewModel y StateFlow
ViewModel sobrevive cambios de configuración (rotación, cambio de tema). Combinado con StateFlow de Kotlin, proporciona un pipeline de datos reactivo y seguro para el ciclo de vida desde el repositorio hasta la UI.
class ProductViewModel(
private val repository: ProductRepository,
private val savedStateHandle: SavedStateHandle
) : ViewModel() {
private val searchQuery = savedStateHandle.getStateFlow("query", "")
val products: StateFlow<UiState<List<Product>>> =
searchQuery.flatMapLatest { query ->
repository.search(query)
.map<List<Product>, UiState<List<Product>>> { UiState.Success(it) }
.catch { emit(UiState.Error(it.message ?: "Unknown error")) }
.onStart { emit(UiState.Loading) }
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), UiState.Loading)
fun updateQuery(query: String) {
savedStateHandle["query"] = query
}
}
sealed interface UiState<out T> {
object Loading : UiState<Nothing>
data class Success<T>(val data: T) : UiState<T>
data class Error(val message: String) : UiState<Nothing>
}
Base de Datos Room
Room proporciona una capa de abstracción sobre SQLite con verificación de queries en tiempo de compilación, queries reactivas basadas en Flow y soporte de migración automática.
@Entity(tableName = "workouts")
data class Workout(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
@ColumnInfo(name = "name") val name: String,
@ColumnInfo(name = "duration_min") val durationMin: Int,
@ColumnInfo(name = "created_at") val createdAt: Long = System.currentTimeMillis()
)
@Dao
interface WorkoutDao {
@Query("SELECT * FROM workouts ORDER BY created_at DESC")
fun getAll(): Flow<List<Workout>>
@Query("SELECT * FROM workouts WHERE id = :id")
suspend fun getById(id: Long): Workout?
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsert(workout: Workout): Long
@Delete
suspend fun delete(workout: Workout)
}
@Database(entities = [Workout::class], version = 2, exportSchema = true)
@TypeConverters(Converters::class)
abstract class AppDatabase : RoomDatabase() {
abstract fun workoutDao(): WorkoutDao
}
WorkManager
WorkManager maneja trabajo en background diferible y garantizado que persiste a través de reinicios de la app y del dispositivo. Soporta constraints (red, batería, almacenamiento), encadenamiento, trabajo periódico y backoff exponencial.
// Define a Worker
class SyncWorker(
context: Context,
params: WorkerParameters,
private val repository: SyncRepository
) : CoroutineWorker(context, params) {
override suspend fun doWork(): Result {
return try {
repository.syncPendingChanges()
Result.success()
} catch (e: Exception) {
if (runAttemptCount < 3) Result.retry()
else Result.failure()
}
}
}
// Schedule periodic sync with constraints
val syncRequest = PeriodicWorkRequestBuilder<SyncWorker>(
repeatInterval = 1, repeatIntervalTimeUnit = TimeUnit.HOURS
).setConstraints(
Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.setRequiresBatteryNotLow(true)
.build()
).setBackoffCriteria(
BackoffPolicy.EXPONENTIAL,
WorkRequest.MIN_BACKOFF_MILLIS, TimeUnit.MILLISECONDS
).build()
WorkManager.getInstance(context)
.enqueueUniquePeriodicWork("sync", ExistingPeriodicWorkPolicy.KEEP, syncRequest)
DataStore
DataStore reemplaza SharedPreferences con una API basada en coroutines y type-safe. Preferences DataStore almacena pares clave-valor, mientras que Proto DataStore usa Protocol Buffers para objetos tipados con evolución de esquema.
// Preferences DataStore
val Context.settingsDataStore by preferencesDataStore(name = "settings")
object PrefsKeys {
val DARK_MODE = booleanPreferencesKey("dark_mode")
val LOCALE = stringPreferencesKey("locale")
val ONBOARDING_DONE = booleanPreferencesKey("onboarding_done")
}
class SettingsRepository(private val dataStore: DataStore<Preferences>) {
val darkMode: Flow<Boolean> = dataStore.data.map { prefs ->
prefs[PrefsKeys.DARK_MODE] ?: false
}
suspend fun setDarkMode(enabled: Boolean) {
dataStore.edit { prefs ->
prefs[PrefsKeys.DARK_MODE] = enabled
}
}
}
// Proto DataStore for complex typed settings
val Context.userProtoDataStore by dataStore(
fileName = "user_prefs.pb",
serializer = UserPreferencesSerializer
)
4. Material Design 3
Color Dinámico y Theming
Material Design 3 (Material You) introduce color dinámico, que extrae un esquema de colores del fondo de pantalla del usuario. La librería Compose Material 3 proporciona un sistema completo de theming con roles de color, escalas tipográficas y tokens de forma.
@Composable
fun AppTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
dynamicColor: Boolean = true,
content: @Composable () -> Unit
) {
val colorScheme = when {
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
val context = LocalContext.current
if (darkTheme) dynamicDarkColorScheme(context)
else dynamicLightColorScheme(context)
}
darkTheme -> darkColorScheme(
primary = Color(0xFF6E56CF),
secondary = Color(0xFF0EA5E9),
background = Color(0xFF0B0F1A)
)
else -> lightColorScheme(
primary = Color(0xFF6E56CF),
secondary = Color(0xFF0EA5E9),
background = Color(0xFFF8FAFC)
)
}
MaterialTheme(
colorScheme = colorScheme,
typography = AppTypography,
shapes = AppShapes,
content = content
)
}
Componentes Material 3 en Compose
Material 3 proporciona componentes actualizados incluyendo top app bars con comportamiento de scroll, barras de navegación, barras de búsqueda y selectores de fecha/hora. Cada componente se adapta automáticamente al esquema de colores activo.
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MainScreen(navController: NavHostController) {
val scrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior()
Scaffold(
topBar = {
TopAppBar(
title = { Text("MyApp") },
scrollBehavior = scrollBehavior,
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface
)
)
},
bottomBar = {
NavigationBar {
NavigationBarItem(
selected = currentRoute == "home",
onClick = { navController.navigate("home") },
icon = { Icon(Icons.Filled.Home, contentDescription = "Home") },
label = { Text("Home") }
)
NavigationBarItem(
selected = currentRoute == "workouts",
onClick = { navController.navigate("workouts") },
icon = { Icon(Icons.Filled.FitnessCenter, contentDescription = "Workouts") },
label = { Text("Workouts") }
)
}
},
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection)
) { innerPadding ->
NavHost(navController, startDestination = "home",
modifier = Modifier.padding(innerPadding)) {
composable("home") { HomeScreen() }
composable("workouts") { WorkoutsScreen() }
}
}
}
5. Inyección de Dependencias: Hilt y Koin
Hilt (basado en Dagger)
Hilt es el framework de DI recomendado para Android. Construido sobre Dagger, proporciona validación de dependencias en tiempo de compilación, scopes específicos de Android (@Singleton, @ViewModelScoped, @ActivityScoped) e integración fluida con componentes Jetpack.
@HiltAndroidApp
class MyApp : Application()
@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {
@Provides
@Singleton
fun provideOkHttpClient(): OkHttpClient =
OkHttpClient.Builder()
.addInterceptor(AuthInterceptor())
.addInterceptor(HttpLoggingInterceptor().apply {
level = HttpLoggingInterceptor.Level.BODY
})
.connectTimeout(30, TimeUnit.SECONDS)
.build()
@Provides
@Singleton
fun provideRetrofit(client: OkHttpClient): Retrofit =
Retrofit.Builder()
.baseUrl(BuildConfig.API_BASE)
.client(client)
.addConverterFactory(MoshiConverterFactory.create())
.build()
}
@HiltViewModel
class WorkoutViewModel @Inject constructor(
private val repository: WorkoutRepository
) : ViewModel() {
val workouts = repository.getAll()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
}
Koin (Alternativa Liviana)
Koin es un framework de DI pragmático y liviano que usa Kotlin DSL en vez de procesamiento de anotaciones. Tiene tiempos de build más rápidos que Hilt pero valida dependencias en runtime en vez de en tiempo de compilación.
// Koin module definitions
val networkModule = module {
single {
OkHttpClient.Builder()
.addInterceptor(AuthInterceptor(get()))
.build()
}
single {
Retrofit.Builder()
.baseUrl(BuildConfig.API_BASE)
.client(get())
.addConverterFactory(MoshiConverterFactory.create())
.build()
}
single { get<Retrofit>().create(ApiService::class.java) }
}
val repositoryModule = module {
single { WorkoutRepository(get(), get()) }
single { UserRepository(get()) }
}
val viewModelModule = module {
viewModel { WorkoutViewModel(get()) }
viewModel { params -> DetailViewModel(get(), params.get()) }
}
// Start Koin in Application
class MyApp : Application() {
override fun onCreate() {
super.onCreate()
startKoin {
androidContext(this@MyApp)
modules(networkModule, repositoryModule, viewModelModule)
}
}
}
6. Networking: Retrofit y OkHttp
Capa de API con Retrofit
Retrofit convierte APIs HTTP en interfaces Kotlin con definiciones de endpoints basadas en anotaciones. Combinado con Moshi o Kotlin Serialization para parsing de JSON y soporte de coroutines, proporciona una capa de networking type-safe y concisa.
interface ApiService {
@GET("v2/workouts")
suspend fun getWorkouts(
@Query("page") page: Int = 1,
@Query("per_page") perPage: Int = 20
): PaginatedResponse<Workout>
@GET("v2/workouts/{id}")
suspend fun getWorkout(@Path("id") id: Long): Workout
@POST("v2/workouts")
suspend fun createWorkout(@Body workout: CreateWorkoutRequest): Workout
@Multipart
@PUT("v2/users/avatar")
suspend fun uploadAvatar(@Part image: MultipartBody.Part): UserProfile
@Streaming
@GET
suspend fun downloadFile(@Url url: String): ResponseBody
}
// Repository with error handling
class WorkoutRepository(
private val api: ApiService,
private val dao: WorkoutDao
) {
fun getAll(): Flow<List<Workout>> = flow {
// Emit cached data first
emitAll(dao.getAll())
}.onStart {
// Refresh from network in background
try {
val remote = api.getWorkouts()
dao.upsertAll(remote.data)
} catch (_: Exception) { /* use cache */ }
}
}
Interceptores y Configuración de OkHttp
Los interceptores de OkHttp manejan concerns transversales como autenticación, logging, caching y lógica de reintentos. Los interceptores de aplicación se ejecutan una vez por llamada lógica, mientras que los interceptores de red se ejecutan por request físico incluyendo redirecciones.
class AuthInterceptor(
private val tokenProvider: TokenProvider
) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val token = tokenProvider.accessToken
val request = chain.request().newBuilder()
.addHeader("Authorization", "Bearer $token")
.addHeader("Accept", "application/json")
.build()
val response = chain.proceed(request)
// Handle 401 with token refresh
if (response.code == 401) {
response.close()
val newToken = runBlocking { tokenProvider.refreshToken() }
val retryRequest = request.newBuilder()
.header("Authorization", "Bearer $newToken")
.build()
return chain.proceed(retryRequest)
}
return response
}
}
// OkHttp cache configuration
val client = OkHttpClient.Builder()
.cache(Cache(cacheDir, 10L * 1024 * 1024)) // 10 MB
.addInterceptor(AuthInterceptor(tokenProvider))
.addNetworkInterceptor(HttpLoggingInterceptor())
.certificatePinner(
CertificatePinner.Builder()
.add("api.example.com", "sha256/AAAA...")
.build()
)
.build()
7. Sistema de Build Gradle
Build Variants y Product Flavors
El sistema de build variants de Gradle combina build types (debug, release) con product flavors para producir múltiples APKs desde un solo codebase. Esta es la base para builds white-label.
// build.gradle.kts (app module)
android {
namespace = "com.example.app"
compileSdk = 36
defaultConfig {
applicationId = "com.example.app"
minSdk = 24
targetSdk = 36
versionCode = 120
versionName = "3.4.0"
}
buildTypes {
release {
isMinifyEnabled = true
isShrinkResources = true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
signingConfig = signingConfigs.getByName("release")
}
}
flavorDimensions += "brand"
productFlavors {
create("main") {
applicationId = "com.example.app"
resValue("string", "app_name", "MyApp")
buildConfigField("String", "API_BASE", "\"https://api.example.com\"")
}
create("gymplus") {
applicationId = "com.gymplus.app"
resValue("string", "app_name", "GymPlus")
buildConfigField("String", "API_BASE", "\"https://api.gymplus.com\"")
}
}
}
Gestión de Dependencias con Version Catalogs
Los Version Catalogs de Gradle (libs.versions.toml) centralizan versiones de dependencias en proyectos multi-módulo, reemplazando el patrón antiguo de ext block.
# gradle/libs.versions.toml
[versions]
kotlin = "2.4.0"
compose-bom = "2026.05.01"
room = "2.8.4"
hilt = "2.56.2"
[libraries]
compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "compose-bom" }
compose-ui = { group = "androidx.compose.ui", name = "ui" }
compose-material3 = { group = "androidx.compose.material3", name = "material3" }
room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" }
room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" }
room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" }
[plugins]
android-application = { id = "com.android.application", version = "9.2.1" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
Plugins Gradle Personalizados y Convention Plugins
Los convention plugins extraen lógica de build compartida en script plugins precompilados reutilizables. En proyectos multi-módulo, eliminan configuración duplicada entre feature modules e imponen configuraciones de build consistentes.
// build-logic/convention/src/main/kotlin/AndroidLibraryConventionPlugin.kt
class AndroidLibraryConventionPlugin : Plugin<Project> {
override fun apply(target: Project) = with(target) {
pluginManager.apply("com.android.library")
pluginManager.apply("org.jetbrains.kotlin.android")
extensions.configure<LibraryExtension> {
compileSdk = 36
defaultConfig.minSdk = 24
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
}
}
}
// build-logic/convention/build.gradle.kts
gradlePlugin {
plugins {
register("androidLibrary") {
id = "app.android.library"
implementationClass = "AndroidLibraryConventionPlugin"
}
}
}
// feature/workouts/build.gradle.kts — minimal config
plugins {
id("app.android.library")
id("app.android.hilt")
}
8. Automatización con Fastlane
Configuración del Fastfile
Fastlane automatiza la construcción, testing, captura de screenshots y despliegue en Play Store. Los lanes definen workflows reutilizables que pueden ser disparados desde pipelines CI/CD.
# fastlane/Fastfile
default_platform(:android)
platform :android do
desc "Run unit tests"
lane :test do
gradle(task: "testDebugUnitTest")
end
desc "Build release AAB for specific brand"
lane :build do |options|
brand = options[:brand] || "main"
gradle(
task: "bundle",
flavor: brand,
build_type: "Release",
properties: {
"android.injected.signing.store.file" => ENV["KEYSTORE_PATH"],
"android.injected.signing.store.password" => ENV["KEYSTORE_PASSWORD"],
"android.injected.signing.key.alias" => ENV["KEY_ALIAS"],
"android.injected.signing.key.password" => ENV["KEY_PASSWORD"]
}
)
end
desc "Deploy to Play Store internal track"
lane :deploy_internal do |options|
brand = options[:brand] || "main"
build(brand: brand)
upload_to_play_store(
track: "internal",
aab: lane_context[SharedValues::GRADLE_AAB_OUTPUT_PATH],
package_name: brand_package_name(brand),
json_key: ENV["PLAY_STORE_JSON_KEY"]
)
end
desc "Promote internal to production"
lane :promote_production do |options|
upload_to_play_store(
track: "internal",
track_promote_to: "production",
package_name: brand_package_name(options[:brand]),
json_key: ENV["PLAY_STORE_JSON_KEY"]
)
end
end
9. Testing: JUnit, Espresso y Compose Testing
Testing Unitario con JUnit y Turbine
Los tests unitarios validan lógica de negocio de forma aislada. Se usa JUnit 5 para estructura de tests, MockK o Mockito-Kotlin para mocking y Turbine para testear emisiones de Kotlin Flow. La librería kotlinx-coroutines-test proporciona runTest para testing determinístico de coroutines.
@ExtendWith(MockKExtension::class)
class WorkoutViewModelTest {
@MockK lateinit var repository: WorkoutRepository
private lateinit var viewModel: WorkoutViewModel
@BeforeEach
fun setup() {
every { repository.getAll() } returns flowOf(
listOf(Workout(1, "Push Day", 45), Workout(2, "Pull Day", 50))
)
viewModel = WorkoutViewModel(repository)
}
@Test
fun `loadWorkouts emits expected list`() = runTest {
viewModel.workouts.test {
val result = awaitItem()
assertEquals(2, result.size)
assertEquals("Push Day", result[0].name)
cancelAndConsumeRemainingEvents()
}
}
@Test
fun `deleteWorkout calls repository`() = runTest {
coEvery { repository.delete(any()) } just Runs
viewModel.delete(Workout(1, "Push Day", 45))
coVerify { repository.delete(match { it.id == 1L }) }
}
}
Testing de UI con Espresso
Los tests de Espresso corren en un dispositivo real o emulador y verifican interacciones de UI. Usan un mecanismo de sincronización que espera a que el UI thread y las tareas async estén idle antes de hacer assertions.
@HiltAndroidTest
@RunWith(AndroidJUnit4::class)
class WorkoutListTest {
@get:Rule(order = 0)
val hiltRule = HiltAndroidRule(this)
@get:Rule(order = 1)
val activityRule = ActivityScenarioRule(MainActivity::class.java)
@Test
fun workoutList_displaysItems() {
onView(withId(R.id.recycler_workouts))
.check(matches(isDisplayed()))
onView(withText("Push Day"))
.check(matches(isDisplayed()))
}
@Test
fun clickWorkout_navigatesToDetail() {
onView(withText("Push Day")).perform(click())
onView(withId(R.id.workout_detail_title))
.check(matches(withText("Push Day")))
}
@Test
fun swipeToDelete_removesItem() {
onView(withText("Push Day"))
.perform(swipeLeft())
onView(withText("Push Day"))
.check(doesNotExist())
}
}
Testing de UI con Compose
Compose proporciona su propio framework de testing con matching de nodos basado en semántica. Los tests usan ComposeTestRule para establecer contenido composable e interactuar vía matchers semánticos, haciendo los tests resilientes a cambios de layout.
class UserCardTest {
@get:Rule
val composeRule = createComposeRule()
@Test
fun userCard_displaysNameAndEmail() {
val user = User(id = 1, name = "Ana Garcia", email = "[email protected]")
composeRule.setContent {
AppTheme {
UserCard(user = user, onClick = {})
}
}
composeRule.onNodeWithText("Ana Garcia").assertIsDisplayed()
composeRule.onNodeWithText("[email protected]").assertIsDisplayed()
}
@Test
fun userCard_clickTriggersCallback() {
var clicked = false
val user = User(id = 1, name = "Ana Garcia", email = "[email protected]")
composeRule.setContent {
AppTheme {
UserCard(user = user, onClick = { clicked = true })
}
}
composeRule.onNodeWithText("Ana Garcia").performClick()
assertTrue(clicked)
}
}
10. Optimización con ProGuard y R8
Configuración de R8 y Reglas Keep
R8 es el shrinker de código, ofuscador y optimizador por defecto de Android que reemplaza a ProGuard. Elimina código no usado (tree shaking), renombra símbolos (ofuscación) y aplica optimizaciones. Las reglas keep preservan clases necesarias en runtime vía reflexión, serialización o JNI.
# proguard-rules.pro
# Keep Retrofit interfaces (reflection-based)
-keep,allowobfuscation interface * {
@retrofit2.http.* <methods>;
}
-dontwarn retrofit2.**
# Keep Moshi JSON adapters
-keep class * extends com.squareup.moshi.JsonAdapter
-keepclassmembers class * {
@com.squareup.moshi.Json <fields>;
}
# Keep data classes used with serialization
-keepclassmembers class com.example.app.data.model.** {
<init>(...);
<fields>;
}
# Keep WorkManager worker classes
-keep class * extends androidx.work.Worker
-keep class * extends androidx.work.ListenableWorker
# Keep enum values (used by Gson/Moshi)
-keepclassmembers enum * {
public static **[] values();
public static ** valueOf(java.lang.String);
}
# Remove log statements in release
-assumenosideeffects class android.util.Log {
public static int v(...);
public static int d(...);
public static int i(...);
}
Depuración de Builds Minificados
R8 genera un archivo de mapeo (mapping.txt) que mapea nombres ofuscados a nombres originales. Se sube este archivo a Google Play Console y Firebase Crashlytics para deofuscar stack traces de crashes en producción.
// build.gradle.kts — generate mapping for each build
android {
buildTypes {
release {
isMinifyEnabled = true
isShrinkResources = true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
}
// Upload mapping to Firebase Crashlytics
// The Google Services plugin handles this automatically:
// app/build/outputs/mapping/{variant}/mapping.txt
# Verify what R8 removed (useful for debugging ClassNotFoundException)
# Run with -printusage to see removed classes:
# ./gradlew assembleMainRelease --info | grep "R8"
# Deobfuscate a stack trace locally
retrace mapping.txt stacktrace.txt
11. Builds de Apps White-Label
Arquitectura para Apps Multi-Marca
Las apps white-label comparten un codebase central pero varían en branding, endpoints de API, feature flags y assets. La combinación de product flavors de Gradle, overlays de recursos y un sistema de configuración de marca permite construir docenas de apps distintas desde un solo repositorio.
// Brand configuration loaded at runtime
data class BrandConfig(
val brandId: String,
val displayName: String,
val primaryColor: Long,
val apiBaseUrl: String,
val features: Set<Feature>
)
enum class Feature {
LIVE_CLASSES, NUTRITION_TRACKING, WEARABLE_SYNC,
SOCIAL_FEED, IN_APP_PURCHASE, PUSH_NOTIFICATIONS
}
// Directory structure for brand overlays
// app/src/main/res/ -> brand-specific resources
// app/src/gymplus/res/ -> GymPlus brand resources
// app/src/main/res/ -> shared default resources
// CI/CD loop for 41 brands
# build-all-brands.sh
BRANDS=$(cat brands.json | jq -r '.[].id')
for brand in $BRANDS; do
echo "Building $brand..."
bundle exec fastlane build brand:$brand
bundle exec fastlane deploy_internal brand:$brand
done
12. Despliegue en Play Store
Tracks de Release y Rollouts Escalonados
Google Play soporta múltiples tracks de release: testing interno (hasta 100 testers), testing cerrado (grupos específicos), testing abierto (cualquiera puede unirse) y producción. Los staged rollouts permiten lanzar a un porcentaje de usuarios.
# Staged rollout: 10% -> 25% -> 50% -> 100%
lane :staged_rollout do |options|
percentage = options[:percentage] || 0.1
upload_to_play_store(
track: "production",
rollout: percentage.to_s,
package_name: options[:package],
json_key: ENV["PLAY_STORE_JSON_KEY"],
skip_upload_apk: true,
skip_upload_aab: true,
version_code: options[:version_code]
)
end
Firma de Apps y Seguridad
Google Play App Signing gestiona la clave de firma de la app en la infraestructura de Google. Firmas el upload con una clave de upload y Google re-firma con la clave de firma de la app. Esto protege contra pérdida de claves y permite rotación de claves.
# Generate upload keystore
keytool -genkeypair -v \
-keystore upload-keystore.jks \
-keyalg RSA -keysize 2048 \
-validity 10000 \
-alias upload-key \
-storepass "${STORE_PASS}" \
-keypass "${KEY_PASS}"
# Gradle signing config (from environment)
android {
signingConfigs {
create("release") {
storeFile = file(System.getenv("KEYSTORE_PATH"))
storePassword = System.getenv("KEYSTORE_PASSWORD")
keyAlias = System.getenv("KEY_ALIAS")
keyPassword = System.getenv("KEY_PASSWORD")
}
}
}
Integración con Pipeline CI/CD
Un pipeline completo de GitLab CI/CD para builds, tests y despliegue de Android. Cada stage corre en un contenedor Docker con el Android SDK pre-instalado.
# .gitlab-ci.yml
stages:
- test
- build
- deploy
variables:
GRADLE_OPTS: "-Dorg.gradle.daemon=false"
test:
stage: test
image: cimg/android:2026.03
script:
- ./gradlew testDebugUnitTest
- ./gradlew lintDebug
artifacts:
reports:
junit: app/build/test-results/**/*.xml
build:release:
stage: build
image: cimg/android:2026.03
script:
- bundle exec fastlane build brand:${BRAND}
artifacts:
paths:
- app/build/outputs/bundle/
parallel:
matrix:
- BRAND: [brand-a, brand-b, brand-c]
deploy:internal:
stage: deploy
image: cimg/android:2026.03
script:
- bundle exec fastlane deploy_internal brand:${BRAND}
when: manual
only:
- main
13. Últimas Funcionalidades (2025-2026)
Kotlin Multiplatform (KMP)
Comparte lógica de negocio entre Android, iOS, desktop y web desde un solo codebase Kotlin. KMP es ahora production-stable y oficialmente soportado por Google para desarrollo Android. Escribe capas compartidas de networking, datos y dominio una sola vez, con UI específica por plataforma. La exportación a Swift está habilitada por defecto en Kotlin 2.2, haciendo la interoperabilidad con iOS transparente. Las declaraciones expect/actual manejan diferencias de plataforma en tiempo de compilación.
Compose Multiplatform
Compose Multiplatform 1.11 (mayo de 2026) es estable para Android, iOS, desktop y web. Comparte UI a nivel de componentes entre plataformas usando las mismas APIs de Compose que ya conoces. La versión 1.11 habilita rendering concurrente en iOS por defecto, introduce un input de texto nativo experimental construido sobre UIView con handles de selección y menú contextual del sistema, renueva el procesamiento táctil en web para un scrolling casi nativo, y eleva la versión mínima soportada de iOS a 14. El soporte de Hot Reload habilita preview en tiempo real de cambios de UI durante desarrollo.
Kotlin 2.4.0 (junio de 2026)
Kotlin 2.4.0 (junio de 2026) es la última versión estable. Los context parameters y los explicit backing fields ahora son estables, los collection literals llegan como funcionalidad experimental, la API de UUID se estabiliza y Kotlin/JVM agrega soporte para Java 26. Kotlin/Native gana paquetes Swift como dependencias y el garbage collector CMS habilitado por defecto, Kotlin/Wasm activa la compilación incremental por defecto y el plugin de Gradle es compatible con Gradle 9.5. Se construye sobre Kotlin 2.3.20 (marzo de 2026), que introdujo el checker de valores de retorno no usados y exportación Swift mejorada para KMP.
Android 16
Android 16 introduce requisitos de layout adaptativo para pantallas grandes, requiriendo que las apps soporten layouts responsivos en tablets y plegables. Live Updates es una nueva clase de notificaciones con una plantilla ProgressStyle para actividades en curso como seguimiento de viajes, entregas y navegación. Health Connect ahora soporta registros médicos FHIR para datos de salud interoperables. La ubicación por Wi-Fi 6 (802.11az) incorpora cifrado AES-256 y protección MITM en dispositivos compatibles. El profiling disparado por el sistema captura datos de rendimiento reales de dispositivos de usuarios, reemplazando benchmarks sintéticos con métricas de producción. Android 17 alcanzó la Beta 4 en abril de 2026, con el release estable esperado para junio de 2026.
Actualizaciones de Jetpack Compose
Compose 1.11.0 es estable desde el release de abril de 2026, con el Compose BOM 2026.04.01 mapeando los módulos UI, Foundation, Material, Animation y Runtime a 1.11.0. La composición pausable está habilitada por defecto, permitiendo al framework interrumpir y reanudar trabajo de composición para una UI más fluida, y el rendimiento de scroll iguala al de Views en benchmarks. Las APIs de testing v2 ahora son el default (v1 queda deprecada), el input de trackpad se trata como eventos de mouse, y las nuevas APIs experimentales incluyen la Style API para personalización de componentes basada en estado, Grid para layouts bidimensionales y FlexBox para layouts con wrapping. Material 3 v1.4 con componentes actualizados completa el stack. Estas actualizaciones reducen boilerplate y mejoran rendimiento sin requerir cambios de código.
Gemini Nano: AI On-Device
Android 16 expande el servicio de sistema AICore para inferencia on-device con Gemini Nano. La API ML Kit Inference permite a las apps ejecutar inferencia LLM localmente sin conectividad a internet ni claves API. El modelo corre como un singleton a nivel de sistema -- las apps solicitan acceso a través de una API estructurada en vez de empaquetar los pesos del modelo. Los dispositivos con los últimos chipsets vienen con Gemini Nano 2.0 o modelos de lenguaje pequeños equivalentes como estándar, abriendo nuevas posibilidades para funcionalidades AI offline.
Material 3 Expressive
Material 3 Expressive es un rediseño mayor del lenguaje visual de Android, desplegado a través de Android 16 QPR1. Introduce animaciones basadas en física con springs para interacciones más fluidas, paletas de color dinámico más ricas con separación más clara entre tonos primarios, secundarios y terciarios, y 15 componentes de UI nuevos o renovados incluyendo grupos de botones, split buttons, barras de herramientas e indicadores de carga actualizados. El feedback háptico ahora está acoplado con las transiciones visuales -- descartar una notificación produce tanto un efecto visual de spring como una vibración háptica satisfactoria. Los desarrolladores deben actualizar las dependencias de Material 3 Compose a v1.4+ para acceder a la nueva biblioteca de componentes.
Modo Escritorio para Tablets
Android 16 introduce un modo escritorio para tablets y pantallas conectadas, construido sobre la base de Samsung DeX. Cuando un dispositivo se conecta a un monitor externo, presenta una interfaz tipo escritorio con barra de tareas, barra de estado, soporte de ventanas libres y movimiento del mouse entre pantallas. Android 16 QPR1 agrega tiling flexible de ventanas, gestión de múltiples instancias y persistencia del escritorio. Las apps que ya soportan layouts adaptativos funcionan de inmediato; las apps enfocadas en pantallas grandes deben testear el comportamiento de redimensionado de ventanas libres y escenarios multi-instancia.