Ir al contenido principal

Usando Retrofit para Consumir API

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

Entradas populares de este blog

De Coverlet a JaCoCo: Trayendo la magia de la cobertura a Android Studio

Todo comenzó un día cualquiera, mientras revisaba un reporte de cobertura de pruebas generado por Coverlet para un proyecto en .NET. Me quedé fascinado por el nivel de detalle y claridad que proporcionaba: saber exactamente qué partes del código estaban cubiertas por las pruebas, y cuáles no. Fue entonces cuando me pregunté: ¿Y si pudiera tener algo así en mi proyecto de Android con Kotlin y Jetpack Compose? La Chispa Inicial ✨ Como desarrollador, siempre busco mejorar la calidad de mi código, y contar con herramientas que me permitan medir la cobertura de pruebas es clave. Después de una rápida búsqueda, me topé con **JaCoCo**, una herramienta muy popular para medir cobertura de código en proyectos Java y Kotlin. ¡Lo mejor de todo es que es compatible con Android Studio! Mi objetivo estaba claro: debía integrar JaCoCo en mi proyecto de Android para tener reportes detallados de cobertura, tal como lo había visto en Coverlet. El Desafío: Configurar JaCoCo en un Proyecto Android 🛠️ El p...

Guía Definitiva de Estrategias de Branching en Git: Elige la Mejor para tu Equipo

Imagina esta situación: lideras un equipo de desarrollo y todos están enviando código al mismo tiempo. Sin una estrategia de ramas (branching) clara, tu base de código se convierte rápidamente en un caos de conflictos, compilaciones rotas y desarrolladores frustrados. ¿Te suena familiar? Si alguna vez te has encontrado en este lío, no estás solo. Una estrategia de branching en Git es el mapa que tu equipo necesita para gestionar los cambios de código, colaborar de manera efectiva y entregar software de calidad. Es, en esencia, un conjunto de reglas que define cómo los desarrolladores interactúan con el repositorio, cuándo crear ramas, cómo fusionar cambios y cómo mantener la estabilidad del código en todo el ciclo de vida del desarrollo. Pero es más que solo reglas. Una buena estrategia de branching se alinea con la forma en que trabaja tu equipo: tus ciclos de lanzamiento, los flujos de trabajo de QA, tus pipelines de CI/CD e incluso la frecuencia con la que necesitas aplicar correcc...

Inyección de Dependencias usando Hilt

Inyección de Dependencias usando Hilt La Inyección de Dependencias (DI) es fundamental para construir aplicaciones Android robustas, escalables y fáciles de testear. Mientras que existen varias librerías para lograrlo, Hilt se ha establecido como la solución recomendada y estándar de Google. Construido sobre la potencia de Dagger, Hilt simplifica enormemente la implementación de DI en Android. Si buscas una forma estandarizada, con menos boilerplate que Dagger puro y con excelente integración con los componentes de Android Jetpack, Hilt es la respuesta. ¡Descubramos cómo funciona! ¿Por qué elegir Hilt? Hilt ofrece ventajas significativas para el desarrollo Android: Estándar de Android: Es la librería DI recomendada por Google, lo que asegura buena documentación, soporte y alineación con las prácticas modernas de Android. Menos Boilerplate (vs. Dagger): Reduce drásticamente el código de configuración necesario en comparación con usar Dagger directamente en Andr...