Flutter: Consumiendo APIs REST de Forma Segura y Eficiente con Dio y Freezed
Este tutorial te guiará paso a paso en el proceso de consumir APIs REST en tus aplicaciones Flutter. Aprenderás a configurar Dio para manejar peticiones HTTP, intercepción y errores, y a usar Freezed para generar modelos de datos inmutables y seguros, mejorando la calidad y mantenibilidad de tu código.
🚀 Introducción a la Interacción con APIs REST en Flutter
En el desarrollo de aplicaciones móviles modernas, la interacción con servicios backend a través de APIs REST es una funcionalidad fundamental. Flutter, con su potente ecosistema, ofrece diversas herramientas para lograrlo de manera efectiva. En este tutorial, nos enfocaremos en dos librerías clave que simplificarán y robustecerán este proceso: Dio para la gestión de peticiones HTTP y Freezed para la generación de modelos de datos inmutables y con gestión de JSON.
¿Por qué Dio y Freezed? 🤔
- Dio: Es un cliente HTTP muy popular para Dart, que ofrece una API potente para enviar peticiones, manejar respuestas, interceptores para logging, autenticación, gestión de errores y mucho más. Es más completo y configurable que el
httppackage básico. - Freezed: Una librería para generar código que nos permite definir clases inmutables, union types (sealed classes), serialización JSON y métodos de utilidad (como
copyWith,equals,hashCode,toString) con una sintaxis concisa. Esto elimina gran parte del boilerplate y reduce la probabilidad de errores en nuestros modelos de datos.
🛠️ Configuración Inicial del Proyecto Flutter
Antes de sumergirnos en el código, necesitamos configurar nuestro proyecto Flutter añadiendo las dependencias necesarias.
1. Crear un Nuevo Proyecto Flutter
Si aún no tienes un proyecto, puedes crear uno con el siguiente comando:
flutter create api_consumption_app
cd api_consumption_app
2. Añadir Dependencias 📦
Abre tu archivo pubspec.yaml y añade las siguientes dependencias en la sección dependencies y dev_dependencies:
dependencies:
flutter:
sdk: flutter
dio: ^5.4.0 # Cliente HTTP
freezed_annotation: ^2.4.1 # Anotaciones para Freezed
json_annotation: ^4.8.1 # Anotaciones para serialización JSON
dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^3.0.0
build_runner: ^2.4.8 # Para generar código
freezed: ^2.4.7 # Generador de código Freezed
json_serializable: ^6.7.1 # Generador de código para JSON
Después de añadir las dependencias, guarda el archivo y ejecuta en tu terminal:
flutter pub get
Esto descargará todas las librerías necesarias para nuestro proyecto.
📋 Definiendo los Modelos de Datos con Freezed
Freezed nos permite definir modelos de datos inmutables con una sintaxis muy declarativa. Usaremos un ejemplo de una API pública para obtener una lista de publicaciones. Para este tutorial, usaremos la API de JSONPlaceholder (https://jsonplaceholder.typicode.com/posts). Cada Post tendrá un userId, id, title y body.
1. Crear el Archivo del Modelo
Crea una nueva carpeta models dentro de lib y un archivo post.dart dentro de ella (lib/models/post.dart).
2. Definir el Modelo Post con Freezed
En lib/models/post.dart, añade el siguiente código:
import 'package:freezed_annotation/freezed_annotation.dart';
part 'post.freezed.dart';
part 'post.g.dart';
@freezed
class Post with _$Post {
const factory Post({
required int userId,
required int id,
required String title,
required String body,
}) = _Post;
factory Post.fromJson(Map<String, dynamic> json) => _$PostFromJson(json);
}
Explicación:
part 'post.freezed.dart';ypart 'post.g.dart';: Estos son archivos generados automáticamente porfreezedyjson_serializablerespectivamente.build_runnerse encarga de crearlos.@freezed: Esta anotación indica afreezedque genere código para esta clase.class Post with _$Post:_$Postes una mixin generada porfreezedque contiene la lógica de la inmutabilidad y los métodos utilitarios.const factory Post({...}) = _Post;: Esta es la definición de la clase, indicando sus propiedades (userId,id,title,body). El prefijo_en_Postes una convención de Freezed.factory Post.fromJson(Map<String, dynamic> json) => _$PostFromJson(json);: Este es el factory constructor que Freezed generará para deserializar JSON. Depende dejson_serializable.
3. Generar el Código de Freezed y JSON Serializable
Ahora, necesitamos ejecutar el build_runner para generar los archivos post.freezed.dart y post.g.dart. Abre tu terminal en la raíz del proyecto y ejecuta:
flutter pub run build_runner build
Si necesitas que el generador de código esté siempre activo y reconstruya automáticamente los archivos al guardar cambios, puedes usar:
flutter pub run build_runner watch
Verás que se generan los dos archivos part en la carpeta lib/models. ¡Ahora tu modelo Post está listo para ser usado de forma segura e inmutable!
🌐 Configurando Dio para Peticiones HTTP
Dio es un cliente HTTP potente y flexible. Vamos a configurarlo para que sea fácil de usar en nuestra aplicación, incluyendo un interceptor para ver las peticiones y respuestas.
1. Crear el Servicio API
Crea una nueva carpeta services dentro de lib y un archivo api_service.dart dentro de ella (lib/services/api_service.dart).
2. Implementar ApiService
import 'package:dio/dio.dart';
import 'package:api_consumption_app/models/post.dart';
class ApiService {
final Dio _dio;
final String _baseUrl = 'https://jsonplaceholder.typicode.com';
ApiService() : _dio = Dio() {
_dio.options.baseUrl = _baseUrl;
_dio.options.connectTimeout = const Duration(seconds: 5); // 5 segundos
_dio.options.receiveTimeout = const Duration(seconds: 3); // 3 segundos
_dio.interceptors.add(LogInterceptor(requestBody: true, responseBody: true));
}
Future<List<Post>> getPosts() async {
try {
final response = await _dio.get('/posts');
if (response.statusCode == 200) {
// Dio devuelve data directamente como Map<String, dynamic> o List<dynamic>
final List<dynamic> data = response.data as List<dynamic>;
return data.map((json) => Post.fromJson(json as Map<String, dynamic>)).toList();
} else {
throw DioException(requestOptions: response.requestOptions, response: response, message: 'Error al obtener posts: ${response.statusCode}');
}
} on DioException catch (e) {
// Manejo de errores específicos de Dio
if (e.type == DioExceptionType.connectionTimeout) {
throw Exception('Conexión expirada. Inténtalo de nuevo.');
} else if (e.type == DioExceptionType.receiveTimeout) {
throw Exception('Tiempo de respuesta del servidor excedido.');
} else if (e.type == DioExceptionType.badResponse) {
throw Exception('Error del servidor: ${e.response?.statusCode}. Mensaje: ${e.response?.data}');
} else if (e.type == DioExceptionType.unknown) {
throw Exception('Error de red desconocido. Verifica tu conexión a internet.');
}
throw Exception('Error al obtener posts: ${e.message}');
} catch (e) {
// Otros errores no relacionados con Dio
throw Exception('Un error inesperado ocurrió: $e');
}
}
}
Explicación:
_dio = Dio(): Se inicializa la instancia de Dio._dio.options.baseUrl: Establece la URL base para todas las peticiones, lo que simplifica las llamadas posteriores (/postsen lugar dehttps://jsonplaceholder.typicode.com/posts).connectTimeoutyreceiveTimeout: Configuran tiempos de espera para la conexión y la recepción de datos, respectivamente. Es una buena práctica para evitar que la aplicación se congele indefinidamente._dio.interceptors.add(LogInterceptor()): Añade un interceptor de logging que imprime por consola los detalles de las peticiones y respuestas HTTP. Muy útil para depuración.getPosts(): Método asíncrono que realiza una petición GET a/posts.- Manejo de Errores: Se usa un bloque
try-on-catchpara capturarDioException(errores específicos de Dio) yException(otros errores). Es crucial manejar diferentes tipos de errores (tiempo de espera, errores de servidor, etc.) para proporcionar feedback útil al usuario. - Deserialización: La respuesta de la API (que es una lista de objetos JSON) se mapea a una lista de objetos
Postusando elPost.fromJsonquefreezednos ha generado.
📱 Integrando el Servicio en la Interfaz de Usuario (UI)
Ahora que tenemos nuestros modelos y nuestro servicio API, vamos a integrarlos en la interfaz de usuario para mostrar los posts.
1. Modificar main.dart
Abre lib/main.dart y reemplaza su contenido con lo siguiente. Crearemos un PostListScreen que será el encargado de mostrar los datos.
import 'package:flutter/material.dart';
import 'package:api_consumption_app/services/api_service.dart';
import 'package:api_consumption_app/models/post.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'API Consumption App',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
useMaterial3: true,
),
home: const PostListScreen(),
);
}
}
class PostListScreen extends StatefulWidget {
const PostListScreen({super.key});
@override
State<PostListScreen> createState() => _PostListScreenState();
}
class _PostListScreenState extends State<PostListScreen> {
late Future<List<Post>> _postsFuture;
final ApiService _apiService = ApiService();
@override
void initState() {
super.initState();
_postsFuture = _apiService.getPosts();
}
Future<void> _refreshPosts() async {
setState(() {
_postsFuture = _apiService.getPosts();
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Publicaciones'),
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
actions: [
IconButton(
icon: const Icon(Icons.refresh),
onPressed: _refreshPosts,
),
],
),
body: FutureBuilder<List<Post>>(
future: _postsFuture,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
} else if (snapshot.hasError) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.error_outline, color: Colors.red, size: 48),
const SizedBox(height: 16),
Text(
'Error: ${snapshot.error}',
textAlign: TextAlign.center,
style: const TextStyle(color: Colors.red, fontSize: 16),
),
const SizedBox(height: 16),
ElevatedButton(
onPressed: _refreshPosts,
child: const Text('Reintentar'),
),
],
),
);
} else if (snapshot.hasData) {
final posts = snapshot.data!;
return RefreshIndicator(
onRefresh: _refreshPosts,
child: ListView.builder(
itemCount: posts.length,
itemBuilder: (context, index) {
final post = posts[index];
return Card(
margin: const EdgeInsets.all(8.0),
elevation: 2,
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
post.title,
style: Theme.of(context).textTheme.headlineSmall,
),
const SizedBox(height: 8),
Text(
post.body,
style: Theme.of(context).textTheme.bodyMedium,
),
const SizedBox(height: 8),
Align(
alignment: Alignment.bottomRight,
child: Text(
'User ID: ${post.userId}',
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: Colors.grey[600]),
),
),
],
),
),
);
},
),
);
} else {
return const Center(child: Text('No hay publicaciones disponibles.'));
}
},
),
);
}
}
Explicación:
MyApp: La clase principal de la aplicación que configura el tema y definePostListScreencomo la página de inicio.PostListScreen: UnStatefulWidgetque contendrá la lógica y la UI para mostrar la lista de posts._postsFuture: Una variableFuture<List<Post>>que almacenará el resultado de la llamada a la API. Se inicializa eninitState.ApiService _apiService: Una instancia de nuestro servicio API para realizar las peticiones._refreshPosts(): Método para recargar los posts, útil para la funcionalidad de 'pull-to-refresh' y el botón de reintentar.FutureBuilder: Widget fundamental para trabajar conFutures en Flutter. Se encarga de reconstruir su UI cuando elFuturecambia de estado (pendiente, completado con datos, completado con error).ConnectionState.waiting: Muestra unCircularProgressIndicatormientras se cargan los datos.snapshot.hasError: Muestra un mensaje de error y un botón para reintentar si la llamada a la API falla.snapshot.hasData: Si los datos se cargaron correctamente, se usaListView.builderpara mostrar cadaPosten unaCard.
RefreshIndicator: Permite al usuario deslizar hacia abajo para recargar la lista de publicaciones.
✨ Ejecutando y Probando la Aplicación
Con todo configurado, es hora de probar nuestra aplicación.
- Asegúrate de que el
build_runnerhaya generado los archivos.freezed.darty.g.dart(flutter pub run build_runner build). - Conecta un dispositivo o inicia un emulador.
- Ejecuta la aplicación desde tu IDE o con el comando:
flutter run
Verás la lista de publicaciones cargándose desde la API de JSONPlaceholder. Si hay algún error de red o de la API, la aplicación mostrará un mensaje de error claro y la opción de reintentar.
💡 Mejoras y Próximos Pasos
Este tutorial te ha proporcionado una base sólida para consumir APIs REST en Flutter usando Dio y Freezed. Aquí tienes algunas ideas para llevar tu aplicación al siguiente nivel:
- Inyección de Dependencias: Usa un paquete como
get_itoproviderpara inyectar la instancia deApiServiceen lugar de crearla directamente en elStatefulWidget. Esto mejora la capacidad de prueba y la modularidad. - Gestión de Estado: Para aplicaciones más complejas, integra una solución de gestión de estado como BLoC, Riverpod o Provider para manejar el estado de las llamadas a la API y la UI de forma más reactiva.
- Autenticación: Si tu API requiere autenticación (JWT, OAuth2), puedes añadir interceptores a Dio para incluir automáticamente tokens en los headers de las peticiones.
- Manejo de Paginación: Si la API devuelve grandes cantidades de datos, implementa paginación para cargar los datos en bloques, mejorando el rendimiento y la experiencia del usuario.
- Testing: Escribe pruebas unitarias para tus modelos Freezed y pruebas de integración para tu
ApiServicey los widgets de la UI. - Variables de Entorno: Usa
flutter_dotenvpara gestionar URLs de API y claves sensibles, diferenciando entre entornos de desarrollo y producción.
¿Por qué Freezed es mejor que escribir los modelos manualmente?
Freezed automatiza la generación de código para:- Inmutabilidad: Asegura que una vez creado un objeto, no pueda ser modificado, lo que previene errores sutiles en el estado.
- Serialización/Deserialización JSON: Genera los métodos
fromJsonytoJsonautomáticamente, evitando errores manuales. copyWith: Permite crear nuevas instancias de un objeto con valores modificados de forma sencilla.equalsyhashCode: Implementa correctamente la comparación de objetos por valor, esencial para colecciones y testing.toString: Genera una representación de cadena útil para depuración.- Union Types/Sealed Classes: Para estados más complejos, Freezed permite definir una clase base y varias subclases para representar estados distintos de forma segura y expresiva. (Ej:
LoadingState,LoadedState,ErrorState).
Has completado un viaje crucial en el desarrollo de Flutter, dominando la comunicación con APIs externas de forma robusta y moderna. ¡Felicidades!
Tutoriales relacionados
- Navegación Avanzada en Flutter: Rutas Dinámicas y Deep Linking con GoRouterintermediate18 min
- Flutter: Integración de Pagos con Stripe para E-commerce Móvilintermediate15 min
- Flutter al Detalle: Animaciones Implícitas y Explícitas para Interfaces de Usuario Fluidas y Atractivasintermediate15 min
- Flutter para Principiantes: Creando Widgets Reutilizables y Temas Personalizadosbeginner25 min
- Flutter para Principiantes: Construyendo tu Primera Aplicación Interfaz de Usuario Moderna con Widgetsbeginner20 min
Comentarios (0)
Aún no hay comentarios. ¡Sé el primero!