Usando Retrofit para Consumir API
La comunicación con servicios web y APIs REST es fundamental en el desarrollo Android. Retrofit simplifica las llamadas de red, Coroutines manejan la asincronía, y un patrón Repositorio/ViewModel estructura el acceso a datos y la lógica de negocio. Para la UI, Jetpack Compose ofrece un enfoque moderno y declarativo para construir interfaces de usuario nativas con Kotlin.
En este post, construiremos una app que muestra la lista de repositorios públicos del usuario de GitHub enelramon. Utilizaremos Retrofit para la llamada a la API, un Repositorio para abstraer la fuente de datos, un ViewModel con StateFlow para gestionar el estado, y Jetpack Compose para dibujar la interfaz de usuario de forma reactiva.
¿Por qué Retrofit, Repositorio, ViewModel y Jetpack Compose?
- Retrofit: Simplifica las llamadas HTTP, maneja la conversión de JSON y se integra perfectamente con Coroutines.
- Repositorio: Abstrae la fuente de datos (red). Mejora la separación de responsabilidades y la testeabilidad.
- ViewModel con StateFlow: Mantiene la lógica de UI y el estado (como
RepoUiState
), sobreviviendo a cambios de configuración.StateFlow
expone el estado de forma eficiente para ser observado por Compose. - Coroutines: Manejan operaciones asíncronas (red) de forma estructurada y legible.
- Jetpack Compose: Es el toolkit moderno de Android para construir UI nativas. Utiliza un enfoque declarativo: describes cómo debería ser tu UI para un estado determinado, y Compose se encarga de actualizarla cuando el estado cambia. Es totalmente interoperable con Kotlin y reduce el boilerplate comparado con XML y Vistas.
Configuración Necesaria
Necesitamos configurar Retrofit, Coroutines, Lifecycle y las dependencias de Jetpack Compose.
Paso 1: Añadir Dependencias de Gradle
Asegúrate de incluir Retrofit, Kotlinx Serialization, OkHttp, Coroutines, Lifecycle (ViewModel) y las dependencias de Jetpack Compose en app/build.gradle.kts
.
// build.gradle.kts (Nivel de App)
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
// Plugin de serialización
id("org.jetbrains.kotlin.plugin.serialization") version "1.9.23" // Usa tu versión de Kotlin
// Hilt (opcional, si se usa)
// id("com.google.dagger.hilt.android") version "2.51.1"
// id("kotlin-kapt") // O ksp
}
android {
namespace = "com.example.myapp"
compileSdk = 34 // O la última versión estable
defaultConfig {
applicationId = "com.example.myapp"
minSdk = 24 // Compose generalmente requiere minSdk 21+
targetSdk = 34
versionCode = 1
versionName = "1.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
useSupportLibrary = true
}
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = "1.8"
}
buildFeatures {
// Habilitar Jetpack Compose
compose = true
}
composeOptions {
// Asegúrate que la versión del compilador de Compose coincida con tu versión de Kotlin
// Consulta la tabla de compatibilidad: https://developer.android.com/jetpack/androidx/releases/compose-kotlin
kotlinCompilerExtensionVersion = "1.5.11" // Versión compatible con Kotlin 1.9.23
}
packaging {
resources {
excludes += "/META-INF/{AL2.0,LGPL2.1}"
}
}
}
dependencies {
// --- Core Android ---
implementation("androidx.core:core-ktx:1.13.1")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0") // Para lifecycleScope
implementation("androidx.activity:activity-compose:1.9.0") // Para setContent en Activity
// --- Jetpack Compose ---
// Bill of Materials (BOM) para gestionar versiones de Compose
val composeBom = platform("androidx.compose:compose-bom:2024.04.01") // Usa la última BOM estable
implementation(composeBom)
androidTestImplementation(composeBom)
// Dependencias de Compose (sin especificar versión, gestionadas por BOM)
implementation("androidx.compose.ui:ui")
implementation("androidx.compose.ui:ui-graphics")
implementation("androidx.compose.ui:ui-tooling-preview") // Para @Preview
implementation("androidx.compose.material3:material3") // O androidx.compose.material:material si usas Material 2
// Integración con ViewModel
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.7.0")
// Tooling para previews y pruebas
debugImplementation("androidx.compose.ui:ui-tooling")
androidTestImplementation("androidx.compose.ui:ui-test-junit4")
debugImplementation("androidx.compose.ui:ui-test-manifest")
// --- Networking (Retrofit, OkHttp, Kotlinx Serialization) ---
implementation("com.squareup.retrofit2:retrofit:2.11.0")
implementation("com.jakewharton.retrofit:retrofit2-kotlinx-serialization-converter:1.0.0")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3")
implementation("com.squareup.okhttp3:okhttp:4.12.0")
implementation("com.squareup.okhttp3:logging-interceptor:4.12.0") // Opcional para logs
// --- Coroutines ---
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
// --- Lifecycle (ViewModel) ---
implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.7.0")
// --- Hilt (Opcional) ---
// implementation("com.google.dagger:hilt-android:2.51.1")
// kapt("com.google.dagger:hilt-compiler:2.51.1") // O ksp("...")
// --- Tests ---
testImplementation("junit:junit:4.13.2")
androidTestImplementation("androidx.test.ext:junit:1.1.5")
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")
}
Nota: Verifica y usa las últimas versiones estables compatibles, especialmente la BOM de Compose y la versión del compilador de Compose correspondiente a tu versión de Kotlin. La fecha actual es Sábado, 19 de Abril de 2025. Sincroniza Gradle.
Paso 2: Permiso de Internet
En AndroidManifest.xml
(sin cambios):
<manifest xmlns:android="http://schemas.android.com/apk/res/android" ...>
<uses-permission android:name="android.permission.INTERNET" />
<application ...> ... </application>
</manifest>
Paso 3: Clase de Datos (Data Class)
Modelo GitHubRepo
(sin cambios):
package com.example.myapp.data.model
// ... (código de GitHubRepo igual que antes)
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class GitHubRepo(
@SerialName("id") val id: Long,
@SerialName("name") val name: String,
@SerialName("full_name") val fullName: String? = null,
@SerialName("description") val description: String?,
@SerialName("html_url") val htmlUrl: String?,
@SerialName("stargazers_count") val stars: Int,
@SerialName("forks_count") val forks: Int,
@SerialName("language") val language: String?
)
Paso 4: Interfaz de la API (Retrofit Service)
Interfaz GitHubApiService
(sin cambios):
package com.example.myapp.data.network
// ... (código de GitHubApiService igual que antes)
import com.example.myapp.data.model.GitHubRepo
import retrofit2.http.GET
import retrofit2.http.Path
interface GitHubApiService {
@GET("users/{user}/repos")
suspend fun getUserRepos(@Path("user") username: String): List<GitHubRepo> // Escaped: List
}
Paso 5: Instancia de Retrofit
Objeto singleton RetrofitClient
(sin cambios):
package com.example.myapp.data.network
// ... (código de RetrofitClient igual que antes)
import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory
// ... resto de imports ...
import retrofit2.Retrofit
object RetrofitClient {
// ... (código interno igual que antes) ...
val gitHubApiService: GitHubApiService by lazy {
retrofit.create(GitHubApiService::class.java)
}
}
Capa de Repositorio con Flow
GithubReposRepository
devuelve un Flow
(sin cambios respecto a la versión anterior de Flow):
package com.example.myapp.data.repository
// ... (código de GithubReposRepository igual que antes)
import kotlinx.coroutines.flow.Flow
// ... resto de imports ...
@Singleton // Ejemplo
class GithubReposRepository @Inject constructor(
private val githubApiService: GitHubApiService
) {
fun getUserRepositories(username: String): Flow<List<GitHubRepo>> = flow { // Escaped: Flow>
val repos = githubApiService.getUserRepos(username)
emit(repos)
}.flowOn(Dispatchers.IO)
}
ViewModel con StateFlow
El RepoViewModel
gestiona el estado con StateFlow
(sin cambios respecto a la versión anterior de Flow):
package com.example.myapp.ui
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.example.myapp.data.model.GitHubRepo
import com.example.myapp.data.repository.GithubReposRepository
import dagger.hilt.android.lifecycle.HiltViewModel // Ejemplo
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import javax.inject.Inject // Ejemplo
// Definición de RepoUiState (igual que antes)
sealed class RepoUiState {
object Idle : RepoUiState()
object Loading : RepoUiState()
data class Success(val repos: List<GitHubRepo>) : RepoUiState() // Escaped: List
data class Error(val message: String) : RepoUiState()
}
@HiltViewModel // Ejemplo
class RepoViewModel @Inject constructor(
private val repository: GithubReposRepository
) : ViewModel() {
private val _uiState = MutableStateFlow<RepoUiState>(RepoUiState.Idle) // Escaped: MutableStateFlow
val uiState: StateFlow<RepoUiState> = _uiState.asStateFlow() // Escaped: StateFlow
fun fetchUserRepositories(username: String) {
viewModelScope.launch {
repository.getUserRepositories(username)
.onStart { _uiState.value = RepoUiState.Loading }
.catch { e -> _uiState.value = RepoUiState.Error("Error: ${e.message}") }
.collect { repoList -> _uiState.value = RepoUiState.Success(repoList) }
}
}
fun fetchEnelramonRepositories() {
// Evitar recargar si ya está cargando o tiene éxito
if (_uiState.value is RepoUiState.Idle || _uiState.value is RepoUiState.Error) {
fetchUserRepositories("enelramon")
}
}
}
Construyendo la UI con Jetpack Compose
Ahora reemplazamos la Activity/Fragment basada en Vistas con Composables.
Paso 1: Configurar la Activity Principal
En tu MainActivity
, usa setContent
para establecer el contenido de la UI con Compose.
package com.example.myapp.ui
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme // O material.* si usas M2
import androidx.compose.material3.Surface // O material.* si usas M2
import androidx.compose.ui.Modifier
// import dagger.hilt.android.AndroidEntryPoint // Si usas Hilt
// @AndroidEntryPoint // Si usas Hilt
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
// Aplicar tema (Material 3 o 2) - Asegúrate de tener un Composable de Tema definido
// Ejemplo: MyAppTheme { ... }
MaterialTheme { // Usando Material 3 por defecto
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background // M3: colorScheme
// color = MaterialTheme.colors.background // M2: colors
) {
// Llama a tu Composable principal de pantalla
RepoScreen()
}
}
}
}
}
Paso 2: Crear los Composables de la Pantalla
Definimos los Composables para mostrar la pantalla principal, la lista, cada item, el estado de carga y los errores.
package com.example.myapp.ui
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Star // Ejemplo de icono
import androidx.compose.material3.* // O material.* para M2
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel // Import para viewModel()
import com.example.myapp.data.model.GitHubRepo
// Composable principal de la pantalla
@Composable
fun RepoScreen(
// Inyecta el ViewModel usando la extensión de lifecycle-viewmodel-compose
// Si usas Hilt, esto funciona automáticamente con @HiltViewModel
viewModel: RepoViewModel = viewModel()
) {
// Recolecta el StateFlow como un State de Compose. Se recompone automáticamente
val uiState by viewModel.uiState.collectAsState()
// Efecto para llamar a la carga inicial solo una vez cuando el Composable entra en la composición
// o si el estado inicial es Idle/Error.
LaunchedEffect(key1 = uiState is RepoUiState.Idle || uiState is RepoUiState.Error) {
if (uiState is RepoUiState.Idle || uiState is RepoUiState.Error) {
viewModel.fetchEnelramonRepositories()
}
}
// Decide qué mostrar basado en el estado actual
when (val state = uiState) {
is RepoUiState.Idle -> {
// Podrías mostrar un indicador inicial o nada
LoadingIndicator() // Opcional: Mostrar loading mientras se dispara el LaunchedEffect
}
is RepoUiState.Loading -> {
LoadingIndicator()
}
is RepoUiState.Success -> {
if (state.repos.isEmpty()) {
EmptyState()
} else {
RepoList(repos = state.repos)
}
}
is RepoUiState.Error -> {
ErrorState(message = state.message)
}
}
}
// Composable para el indicador de carga
@Composable
fun LoadingIndicator() {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
}
// Composable para el estado de error
@Composable
fun ErrorState(message: String) {
Box(
modifier = Modifier.fillMaxSize().padding(16.dp),
contentAlignment = Alignment.Center
) {
Text(
text = message,
style = MaterialTheme.typography.bodyLarge, // M3: typography
// style = MaterialTheme.typography.body1, // M2: typography
color = MaterialTheme.colorScheme.error // M3: colorScheme
// color = MaterialTheme.colors.error // M2: colors
)
}
}
// Composable para el estado vacío
@Composable
fun EmptyState() {
Box(
modifier = Modifier.fillMaxSize().padding(16.dp),
contentAlignment = Alignment.Center
) {
Text(
text = "No se encontraron repositorios para este usuario.",
style = MaterialTheme.typography.bodyLarge
)
}
}
// Composable para mostrar la lista de repositorios
@Composable
fun RepoList(repos: List<GitHubRepo>) { // Escaped: List
// LazyColumn es el equivalente a RecyclerView en Compose
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(vertical = 8.dp)
) {
items(
items = repos,
key = { repo -> repo.id } // Clave única para cada item (mejora rendimiento)
) { repo ->
RepoItem(repo = repo)
Divider(thickness = 0.5.dp, color = MaterialTheme.colorScheme.outlineVariant) // M3 Divider
// Divider(thickness = 0.5.dp, color = Color.LightGray) // M2 Divider simple
}
}
}
// Composable para mostrar un único repositorio en la lista
@Composable
fun RepoItem(repo: GitHubRepo) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 12.dp) // Ajusta el padding
) {
Text(
text = repo.name,
style = MaterialTheme.typography.titleMedium, // M3
// style = MaterialTheme.typography.h6, // M2
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = repo.description ?: "Sin descripción",
style = MaterialTheme.typography.bodyMedium, // M3
// style = MaterialTheme.typography.body2, // M2
maxLines = 2,
overflow = TextOverflow.Ellipsis,
color = LocalContentColor.current.copy(alpha = 0.7f) // Color secundario
)
Spacer(modifier = Modifier.height(8.dp))
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp) // Espacio entre elementos de la fila
) {
// Estrellas
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
imageVector = Icons.Default.Star,
contentDescription = "Estrellas",
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant // M3
// tint = LocalContentColor.current.copy(alpha = 0.6f) // M2
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text = repo.stars.toString(),
style = MaterialTheme.typography.bodySmall // M3
// style = MaterialTheme.typography.caption // M2
)
}
// Lenguaje (si existe)
repo.language?.let { lang ->
Text(
text = lang,
style = MaterialTheme.typography.bodySmall // M3
// style = MaterialTheme.typography.caption // M2
)
}
}
}
}
N
Comentarios
Publicar un comentario