Tema 7: Comunicación con APIs REST con Retrofit2

Objetivos de este tema

  • Entender qué es una API REST y cómo comunicarse desde app Android.
  • Configurar y usar Retrofit2 para realizar peticiones HTTPS desde una app Android.
  • Convertir respuestas JSON en objetos Kotlin usando Gson.
  • Gestionar errores en las llamadas a la API.
  • Integrar Retrofit2 con ViewModel, ROOM (para caché offline) y el patrón MVVM.
  • Usar corutinas para gestionar llamadas asíncronas sin bloquear la interfaz de usuario.

7.1. Introducción a APIs REST

Una API REST es un servicio que facilita una serie de mecanismos para obtener información de un cliente externo, generalmente una base de datos que nutra la aplicación. Las peticiones que pueden hacerse a una API REST son los siguientes:

  • GET, devuelven información, puede pasársele parámetros a través de la URL, pero es poco segura.
  • POST, similar a GET, pero más segura, los parámetros no se pasan en la URL.
  • PUT, se utilizará para crear registros en la base de datos.
  • DELETE, permite eliminar registros de la base de datos.

La información devuelva por una API REST estará por lo general en formato JSON. Como norma general, para poder modificar el contenido mediante una API REST, será necesario algún tipo de autenticación, aunque muchas son utilizadas como consulta (GET) y no requieren de este sistema de seguridad.

Ejemplo de una URL de API REST: https://jsonplaceholder.typicode.com/posts. Esta URL devuelve una lista de publicaciones en formato JSON.

7.2. Retrofit2

Retrofit2 es una librería de Square que convierte una API REST en una interfaz de Java o Kotlin, facilitando mucho las llamadas HTTP en Android de una manera relativamente sencilla. Esta biblioteca permite el consumo de APIs REST, además se combinará con corrutinas y con el uso de Flows.

7.2.1. Dependencias necesarias

 1// ViewModel
 2implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.9.3")
 3implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.9.3")
 4
 5// Retrofit2
 6implementation("com.squareup.retrofit2:retrofit:3.0.0")
 7
 8// Conversor para JSON (Gson)
 9implementation("com.squareup.retrofit2:converter-gson:3.0.0")
10
11// Corutinas (para llamadas asíncronas)
12implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2")

Recuerda también añadir el permiso de uso de Internet al Manifest, importante para el consumo de API REST.

1<uses-permission android:name="android.permission.INTERNET" />

7.3. Modelo

Para obtener un listado de los posts mostrados por jsonplaceholder se creará el siguiente modelo. Se creará dentro del package model.

1// Post.kt
2data class Post(
3    val id: Int,
4    val userId: Int,
5    val title: String,
6    val body: String
7)

En este caso se trata de una data class sencilla, pero puedes encontrarte con JSONs más complejos. Existen en Android Studio un plugin que puede ayudarte para estas situaciones, JSON To Kotlin Class.

7.4. Configuración de Retrofit2

Para ordenar el código, la configuración de Retrofit2 se hará dentro del package data, concretamente se creará el fichero RetrofitClient.kt en el que se ubicará un object para tener una única instancia de Retrofit2 (patrón Singleton) y la interfaz para utilizar las anotaciones que definirán las peticiones a la API.

 1import retrofit2.Response
 2import retrofit2.Retrofit
 3import retrofit2.converter.gson.GsonConverterFactory
 4import retrofit2.http.GET
 5import retrofit2.http.Path
 6
 7// RetrofitClient.kt
 8object RetrofitClient {
 9    private const val BASE_URL = "https://jsonplaceholder.typicode.com/"
10
11    val apiService: ApiService by lazy {
12        Retrofit.Builder()
13            .baseUrl(BASE_URL)
14            .addConverterFactory(GsonConverterFactory.create())
15            .build()
16            .create(ApiService::class.java)
17    }
18}
19
20interface ApiService {
21    @GET("posts")
22    suspend fun getPosts(): Response<List<Post>>
23
24    @GET("posts/{id}")
25    suspend fun getPostById(@Path("id") id: Int): Response<Post>
26}
Información
  • suspend: permite usar corutinas (asíncrono).
  • Response<T>: incluye código de estado, mensaje y cuerpo. Ideal para manejar errores.
  • addConverterFactory(GsonConverterFactory.create()): convierte automáticamente el JSON a objetos Kotlin y viceversa. Retrofit no procesa JSON por sí solo, necesita un convertidor. El más usado es GsonConverter, importado al inicio del tema.

7.4.1. Manejo de errores y excepciones

Cuando se hacen llamadas a una API pueden producirse errores que hay que controlar:

  • No hay conexión a Internet.
  • Servidor caído (500).
  • Recurso no encontrado (404).
  • Respuesta vacía.

Se utilizará try-catch y se analizará el Response obtenido para gestionarlos.

7.5. Creación del flujo e integración con ViewModel

En aplicaciones profesionales, se recomienda guardar los datos de manera local con ROOM y utilizar la API solo si no hay datos en caché o si el usuario refresca.

Flujo recomendado (Clean Architecture + MVVM)

Información

UI (Compose) <-> ViewModel <-> Repository <-> Datasource <-> [API (Retrofit) o DB (ROOM)]

Se creará en primer lugar la obtención de la información de la API, como ya se dispone del interface para la API, se pasará a crear el Datasource en el package data.

 1// RemoteDatasource.kt
 2class RemoteDatasource {
 3    // Servicio API utilizando Retrofit.
 4    private val apiService = RetrofitClient.apiService
 5
 6    // Funciones para obtener datos desde la API.
 7    suspend fun getPosts() = apiService.getPosts()
 8
 9    // Obtener un post por su ID.
10    suspend fun getPostById(id: Int) = apiService.getPostById(id)
11}

Observa como quedaría ahora el repositorio, controlando posibles errores y haciendo uso del Datasource remoto.

 1// Repository.kt
 2class Repository(private val remoteDatasource: RemoteDatasource) {
 3    // Manejo de errores básico con try-catch.
 4    // En caso de error, se devuelve una lista vacía.
 5    suspend fun getPosts(): List<Post>? {
 6        return try {
 7            val response = remoteDatasource.getPosts()
 8            if (response.isSuccessful) {
 9                val posts = response.body() ?: emptyList()
10                posts
11            } else {
12                Log.e("Repository", "Error response: ${response.code()} - ${response.message()}")
13                emptyList()
14            }
15        } catch (e: Exception) {
16            Log.e("Repository", e.message, e)
17            throw e // Lanzar la excepción para que el ViewModel pueda manejarla.
18        }
19    }
20
21    // Obtener un post por su ID con manejo de errores.
22    // En caso de error, se devuelve null.
23    suspend fun getPostById(id: Int): Post? {
24        return try {
25            val response = remoteDatasource.getPostById(id)
26            if (response.isSuccessful) {
27                val post = response.body()
28                post
29            } else {
30                Log.e("Repository", "Error response: ${response.code()} - ${response.message()}")
31                null
32            }
33        } catch (e: Exception) {
34            Log.e("Repository", "Error fetching post by ID", e)
35            throw e
36        }
37    }
38}

El siguiente paso será crear el ViewModel que se encargará de facilitar la información a la UI.

 1import androidx.lifecycle.ViewModel
 2import androidx.lifecycle.viewModelScope
 3import kotlinx.coroutines.flow.MutableStateFlow
 4import kotlinx.coroutines.flow.StateFlow
 5import kotlinx.coroutines.launch
 6
 7class MainViewModel : ViewModel() {
 8    // Se inicializa el repositorio y el datasource.
 9    private val repository: Repository
10    private val remoteDatasource: RemoteDatasource
11
12    // Estado para la lista de posts, estado de carga y errores.
13    private val _posts = MutableStateFlow<List<Post>>(emptyList())
14    val posts: StateFlow<List<Post>> = _posts
15
16    // Estado de carga y errores.
17    private val _loading = MutableStateFlow(false)
18    val loading: StateFlow<Boolean> = _loading
19
20    // Estado de error.
21    private val _error = MutableStateFlow<String?>(null)
22    val error: StateFlow<String?> = _error
23
24    init {
25        remoteDatasource = RemoteDatasource()
26        repository = Repository(remoteDatasource)
27    }
28
29    fun fetchPosts() {
30        viewModelScope.launch {
31            _loading.value = true
32            _error.value = null
33
34            try {
35                val posts = repository.getPosts()
36                _posts.value = posts ?: emptyList()
37            } catch (e: Exception) {
38                _error.value = "ERROR: ${e.message}"
39            } finally {
40                _loading.value = false
41            }
42        }
43    }
44}
Información
  • Se utiliza StateFlow para exponer los datos a la UI.
  • viewModelScope.launch ejecuta la corutina en el contexto del ViewModel.

Para cargar los datos desde la UI, puede utilizarse un Composable como el siguiente:

 1@OptIn(ExperimentalMaterial3Api::class)
 2@Composable
 3fun PostScreen(viewModel: MainViewModel = viewModel()) {
 4    val posts: List<Post> by viewModel.posts.collectAsState()
 5    val loading by viewModel.loading.collectAsState()
 6    val error by viewModel.error.collectAsState()
 7
 8    Scaffold(
 9        topBar = { TopAppBar({ Text("Documentation T7.1") }) },
10        modifier = Modifier.fillMaxSize()
11    ) { paddingValues ->
12        Column(modifier = Modifier.padding(paddingValues)) {
13            if (loading) {
14                CircularProgressIndicator(
15                    modifier = Modifier
16                        .padding(16.dp)
17                        .align(Alignment.CenterHorizontally)
18                )
19            } else if (error != null) {
20                Text(text = "Error: $error", color = Color.Red, modifier = Modifier.padding(16.dp))
21            } else {
22                LazyColumn {
23                    items(posts) { post ->
24                        Card(modifier = Modifier.padding(8.dp)) {
25                            Text("Título: ${post.title}", Modifier.padding(8.dp))
26                            Text("Cuerpo: ${post.body}", modifier = Modifier.padding(8.dp))
27                        }
28                    }
29                }
30            }
31
32            Button(
33                modifier = Modifier.fillMaxWidth().padding(8.dp),
34                onClick = { viewModel.fetchPosts() }) {
35                Text("Actualizar")
36            }
37        }
38    }
39}

Este Composable muestra en pantalla un botón que el usuario debe pulsar para cargar la información, si prefieres que se cargen automáticamente, basta con añadir un LaunchedEffect.

1LaunchedEffect(posts) {
2    if (posts.isEmpty() && !loading && error == null) {
3        viewModel.fetchPosts()
4    }
5}

7.5.1. PullToRefreshBox en Jetpack Compose

En una aplicación móvil es muy común deslizar hacia abajo (pull down) para que se actualice el contenido (por ejemplo, nuevos correos, noticias o publicaciones). En Android con vistas tradicionales (XML) se usaba SwipeRefreshLayout, pero en Jetpack Compose, desde 2024, se utiliza PullToRefreshBox, que es parte de Material 3 y ofrece una experiencia más fluida y moderna. En versiones anteriores, debía añadirse pullRefresh como un modificador de un contenedor Box.

Para aplicarlo, se modificará el Composable PostScreen creado en el punto anterior. Se reutilizará el estado loadingy se simplificará el código, ya no hará falta utilizar CircularProgressIndicator.

 1@OptIn(ExperimentalMaterial3Api::class)
 2@Composable
 3fun PostScreen(viewModel: MainViewModel = viewModel()) {
 4    val posts: List<Post> by viewModel.posts.collectAsState()
 5    val loading by viewModel.loading.collectAsState() // Estado de carga.
 6    val error by viewModel.error.collectAsState()
 7
 8    // Estado del pull-to-refresh.
 9    val refreshState = rememberPullToRefreshState()
10
11    LaunchedEffect(posts) {
12        if (posts.isEmpty() && !loading && error == null) {
13            viewModel.fetchPosts()
14        }
15    }
16
17    Scaffold(
18        topBar = { TopAppBar({ Text("Documentation T7.1") }) },
19        modifier = Modifier.fillMaxSize()
20    ) { paddingValues ->
21        Column(modifier = Modifier.padding(paddingValues)) {
22            if (error != null) {
23                Text(text = "Error: $error", color = Color.Red, modifier = Modifier.padding(16.dp))
24
25                Button(
26                    modifier = Modifier.fillMaxWidth().padding(8.dp),
27                    onClick = { viewModel.fetchPosts() }) {
28                    Text("Actualizar")
29                }
30            } else {
31                // Implementación de Pull to Refresh.
32                PullToRefreshBox(
33                    isRefreshing = loading, // Usa el estado de carga del ViewModel.
34                    state = refreshState, // Estado del pull-to-refresh.
35                    modifier = Modifier.fillMaxSize(),
36                    onRefresh = { viewModel.fetchPosts() } // Acción al refrescar.
37                ) {
38                    // Contenido que se puede refrescar
39                    LazyColumn {
40                        items(posts){ post ->
41                            Card(modifier = Modifier.padding(8.dp).fillMaxWidth()) {
42                                Text("Título: ${post.title}", Modifier.padding(8.dp))
43                                Text("Cuerpo: ${post.body}", modifier = Modifier.padding(8.dp))
44                            }
45                        }
46                    }
47                }
48            }
49        }
50    }
51}

En este caso se deja el botón para forzar la actualización en caso de producirse algún error.

7.6. Integración con ROOM

Para realizar la integración con ROOM, se seguirán los pasos vistos en el tema anterior para la configuración del proyecto (ver aquí).

7.6.1. Modelo

Se modificará la data class que representa el modelo para que pueda utilizarse con ROOM.

1// Post.kt
2@Entity(tableName = "posts")
3data class Post(
4    @PrimaryKey(autoGenerate = true) val id: Int,
5    val userId: Int,
6    val title: String,
7    val body: String
8)
Información
  • @Entity: indica que esta clase será una tabla en la base de datos.
  • @PrimaryKey: el campo id es la clave primaria (obligatorio en Room).

7.6.2. Configuración de la BD y DAO

También será necesario definir la base de datos y crear el DAO.

 1// AppDatabase.kt
 2@Database(
 3    entities = [Post::class],
 4    version = 1,
 5    exportSchema = true // Importante para migraciones
 6)
 7abstract class AppDatabase : RoomDatabase() {
 8    abstract fun postsDAO(): PostsDAO // Conexión con DAO de Posts.
 9
10    companion object {
11        @Volatile
12        private var INSTANCE: AppDatabase? = null
13
14        fun getInstance(context: Context): AppDatabase {
15            return INSTANCE ?: synchronized(this) {
16                val instance = Room.databaseBuilder(
17                    context.applicationContext,
18                    AppDatabase::class.java,
19                    "Posts.db"
20                ).fallbackToDestructiveMigration(true) // Solo en desarrollo.
21                    .build()
22
23                INSTANCE = instance // Asigna la instancia a la variable volátil.
24                instance // Devuelve la instancia de la base de datos.
25            }
26        }
27    }
28}
 1// PostsDAO.kt
 2@Dao
 3interface PostsDAO {
 4    // Obtiene todos los posts como un Flow para observar cambios en tiempo real.
 5    @Query("SELECT * FROM posts")
 6    fun getPosts(): Flow<List<Post>>
 7
 8    // Inserta una lista de posts. Si ya existen, los reemplaza.
 9    @Insert(onConflict = OnConflictStrategy.REPLACE)
10    suspend fun insertAllPosts(posts: List<Post>)
11}
Información

Flow<List<Post>>: devuelve un flujo de datos que se actualizará automáticamente cuando los datos cambien en la base de datos (ideal para Jetpack Compose).

7.6.3. Datasource local

 1// LocalDatasource.kt
 2class LocalDatasource(private val dao: PostsDAO) {
 3
 4    // Obtiene todos los posts desde la base de datos local.
 5    fun getPosts(): Flow<List<Post>> = dao.getPosts()
 6
 7    // Inserta una lista de posts en la base de datos local.
 8    suspend fun insertAllPosts(posts: List<Post>) {
 9        dao.insertAllPosts(posts)
10    }
11}

Como se puede observar, la clase LocalDatasource.kt es bastante sencilla y separa la lógica del Datasource local del remoto.

7.6.4. Repository

Ahora el Repository deberá inyectar ambos Datasources.

1class Repository(
2    private val remoteDatasource: RemoteDatasource,
3    private val localDatasource: LocalDatasource
4) ...

Y el método getPosts() se modificará de la siguiente forma para almacenar los posts en la BD y devolver de esta cuando se produzca algún error de la API.

 1suspend fun getPosts(): List<Post>? {
 2    return try {
 3        val response = remoteDatasource.getPosts()
 4        if (response.isSuccessful) {
 5            val posts = response.body() ?: emptyList()
 6            // Almacenar los posts obtenidos en la base de datos local.
 7            localDatasource.insertAllPosts(posts)
 8            posts
 9        } else {
10            Log.e("Repository", "Error response: ${response.code()} - ${response.message()}")
11            localDatasource.getPosts().first() // Se obtienen los posts almacenados localmente.
12        }
13    } catch (e: Exception) {
14        Log.e("Repository", e.message, e)
15        val dbdata = localDatasource.getPosts().first()
16        if (dbdata.isNotEmpty())
17            dbdata
18        else throw e // Lanzar la excepción para que el ViewModel pueda manejarla.
19    }
20}
Información
  • localDatasource.insertAllPosts(posts) guarda los datos obtenidos de la API en la BD, de esta manera, aunque el usuario esté sin internet, podrá ver los datos almacenados.
  • first() se utiliza para obtener el primer valor emitido por el flujo y luego cancelar la suscripción. Se utiliza cuando no se necesita observar cambios continuamente.

Observa que se controla en el bloque del catch si se debe lanzar la excepción o no, si hay datos en la BD no se lanzará.

7.6.5. ViewModel

Por último, habrá que modificar el ViewModel de la siguiente forma.

 1class MainViewModel(application: Application) : AndroidViewModel(application) {
 2    // Se inicializa el repositorio y el datasource.
 3    private val repository: Repository
 4    private val remoteDatasource: RemoteDatasource
 5    private val localDatasource: LocalDatasource
 6
 7    ...
 8
 9    init {
10        // Se inicializa la base de datos local y el DAO.
11        val database = AppDatabase.getInstance(application)
12        val dao = database.postsDAO()
13
14        remoteDatasource = RemoteDatasource()
15        localDatasource = LocalDatasource(dao)
16        repository = Repository(remoteDatasource, localDatasource)
17    }
18
19    ...
20}

Básicamente se añade el LocalDatasource y se establece la conexión a la BD en el contructor init. También se modifica la declaración de la clase.

Código completo

Fuentes