Introducción
En el competitivo mundo del desarrollo móvil, las aplicaciones necesitan actualizarse constantemente para mantenerse relevantes y ofrecer experiencias de usuario innovadoras. ¿Qué pasaría si pudieras modificar la interfaz de tu aplicación sin pasar por el proceso de revisión de las tiendas de aplicaciones? ¿Y si pudieros implementar A/B testing, lanzar nuevas funcionalidades y corregir errores sin necesidad de desplegar una nueva versión?
Esta es la promesa del Server-Driven UI (SDUI), una arquitectura que está transformando radicalmente cómo desarrollamos aplicaciones móviles nativas. En este artículo, exploraremos en profundidad cómo Server-Driven UI con Flutter está emergiendo como la tendencia más importante del año para el desarrollo móvil, permitiendo a los equipos crear aplicaciones más flexibles, mantenibles y adaptables a las necesidades cambiantes del mercado.
Server-Driven UI no es simplemente una nueva biblioteca o framework, sino una filosofía de arquitectura que coloca la lógica de presentación en el servidor, permitiendo que las aplicaciones móviles se comporten más como sitios web en términos de actualizabilidad, pero manteniendo el rendimiento y la experiencia nativa que los usuarios esperan.
A lo largo de este artículo, aprenderemos los conceptos fundamentales, implementaremos ejemplos prácticos, exploraremos casos de uso avanzados y descubriremos por qué esta tendencia está revolucionando el desarrollo móvil nativo.
Prerrequisitos
Antes de sumergirnos en el mundo del Server-Driven UI, asegúrate de contar con los siguientes conocimientos y herramientas:
- Conocimientos básicos de Flutter: Debes estar familiarizado con el desarrollo de aplicaciones móviles en Flutter, incluyendo widgets básicos, estados, navegación y manejo de datos.
- Habilidades en Dart: Comprensión de programación asíncrona, manejo de JSON y estructuras de datos.
- Experiencia con APIs REST o GraphQL: Para comunicarte eficazmente con tu backend.
- Conocimientos básicos de arquitecturas de software: Entender los patrones MVC, MVVM o Clean Architecture será muy útil.
- Herramientas necesarias:
¿Qué es Server-Driven UI?
Definición conceptual
Server-Driven UI (SDUI) es un patrón arquitectónico donde la estructura y el contenido de la interfaz de usuario son generados dinámicamente por un servidor y enviados al cliente para ser renderizados. En lugar de tener la UI completamente definida en el código de la aplicación, los componentes visuales, sus propiedades y su comportamiento se especifican a través de datos generalmente en formato JSON.
Este enfoque combina lo mejor de ambos mundos: la experiencia nativa y el rendimiento de las aplicaciones móviles con la flexibilidad y capacidad de actualización de las web apps.
¿Cómo funciona la arquitectura SDUI?
El flujo de trabajo de una aplicación Server-Driven UI sigue estos pasos:
- El usuario inicia la aplicación y navega a una pantalla específica
Este ciclo permite que cualquier cambio en la interfaz se implemente simplemente actualizando el JSON en el servidor, sin necesidad de modificar y desplegar una nueva versión de la aplicación.
Comparación con enfoques tradicionales
#### Client-Driven UI (Tradicional)
- Ventajas: Máximo rendimiento, experiencia consistente, control total sobre la UI
- Desventajas: Ciclos de desarrollo largos, actualizaciones lentas, difícil A/B testing
#### Web Apps
- Ventajas: Actualizaciones instantáneas, fácil A/B testing, desarrollo rápido
- Desventajas: Rendimiento inferior, experiencia no nativa, limitaciones del navegador
#### Server-Driven UI
- Ventajas: Actualizaciones rápidas, A/B testing nativo, mantenimiento centralizado, control de versiones
- Desventajas: Complejidad inicial, dependencia de red, necesidad de un backend robusto
La combinación de estos factores hace que SDUI sea la solución ideal para aplicaciones móviles que necesitan evolucionar rápidamente y ofrecer experiencias personalizadas.
Arquitectura de una aplicación Server-Driven UI
Componentes fundamentales
Una implementación exitosa de Server-Driven UI requiere varios componentes clave trabajando en armonía:
#### 1. Cliente (Flutter App)
La aplicación Flutter es responsable de:
- Interpretar las especificaciones JSON recibidas del servidor
- Renderizar los widgets correspondientes
- Manejar la interacción del usuario con los widgets
- Gestionar estados y transiciones
- Optimizar el rendimiento del rendering
#### 2. Servidor de UI
Este componente se encarga de:
- Generar las especificaciones JSON de la UI
- Gestionar diferentes versiones de la UI
- Implementar lógica de negocio relacionada con la presentación
- Realizar A/B testing y experimentos
- Proveer caché y optimización
#### 3. Base de Datos
Almacena las definiciones de UI, configuraciones y experimentos. Puede incluir:
- Plantillas de UI
- Configuraciones de widgets
- Reglas de visualización
- Datos para mostrar
#### 4. Sistema de Configuración
Gestiona aspectos como:
- Configuración remota
- Parámetros de visualización
- Reglas de negocio
- Funcionalidades habilitadas/deshabilitadas
Flujo de datos
El flujo de datos en una arquitectura SDUI sigue este patrón:
Usuario → App Flutter → API Gateway → Servidor UI → Base de Datos
↑ ↓
← ← ← ← ← Widgets Renderizados ← ← ← ← ←Este flujo bidireccional permite que la aplicación no solo muestre contenido dinámico, sino que también envíe retroalimentación al servidor sobre el comportamiento del usuario.
Patrones de comunicación
Existen varios patrones para la comunicación entre el cliente y el servidor:
#### 1. API-First
El servidor expone endpoints RESTful que retornan JSON con la especificación de la UI.
#### 2. GraphQL
Permite consultas más eficientes donde el cliente solicita exactamente los datos que necesita.
#### 3. WebSockets
Para comunicación en tiempo real donde los cambios en el servidor se reflejan inmediatamente en el cliente.
#### 4. Streaming
Permite enviar chunks de datos para mejorar el rendimiento en conexiones lentas.
Implementación práctica: Creando nuestro primer SDUI con Flutter
Configuración del proyecto
Vamos a crear una aplicación Flutter básica que implemente Server-Driven UI. Primero, inicializa un nuevo proyecto:
flutter create sdui_example
cd sdui_exampleAhora, instala las dependencias necesarias que nos ayudarán a implementar nuestro SDUI:
pubspec.yaml
dependencies:
flutter:
sdk: flutter
json_annotation: ^4.8.1
retrofit: ^4.0.3
dio: ^5.3.2
flutter_bloc: ^8.1.3
equatable: ^2.0.5Definiendo el contrato JSON
El corazón de cualquier sistema SDUI es el contrato JSON que define cómo se estructuran los widgets. Vamos a definir una especificación básica:
{
"screen": {
"type": "screen",
"children": [
{
"type": "container",
"properties": {
"padding": 16,
"color": "#FFFFFF"
},
"children": [
{
"type": "text",
"properties": {
"text": "Bienvenido a nuestra app",
"style": "headlineLarge",
"align": "center"
}
},
{
"type": "image",
"properties": {
"url": "https://example.com/logo.png",
"width": 120,
"height": 120,
"fit": "contain"
}
},
{
"type": "button",
"properties": {
"text": "Comenzar",
"style": "primary",
"onPress": "navigate_to_home"
}
}
]
}
]
}
}Mapeo JSON a Widgets
Ahora, vamos a crear el sistema de mapeo en Flutter. Primero, definimos las clases modelo que representarán nuestro JSON:
// lib/models/ui_model.dart
import 'package:json_annotation/json_annotation.dart';
part 'ui_model.g.dart';
@JsonSerializable()
class ScreenModel {
@JsonKey(name: 'type')
final String type;
@JsonKey(name: 'children')
final List<WidgetModel> children;
ScreenModel({required this.type, required this.children});
factory ScreenModel.fromJson(Map<String, dynamic> json) => _$ScreenModelFromJson(json);
Map<String, dynamic> toJson() => _$ScreenModelToJson(this);
}
@JsonSerializable()
class WidgetModel {
@JsonKey(name: 'type')
final String type;
@JsonKey(name: 'properties')
final Map<String, dynamic>? properties;
@JsonKey(name: 'children')
final List<WidgetModel>? children;
WidgetModel({required this.type, this.properties, this.children});
factory WidgetModel.fromJson(Map<String, dynamic> json) => _$WidgetModelFromJson(json);
Map<String, dynamic> toJson() => _$WidgetModelToJson(this);
}Generamos el código de serialización con:
flutter pub run build_runner buildAhora, creamos el sistema de renderizado dinámico:
// lib/widgets/dynamic_widget.dart
import 'package:flutter/material.dart';
import '../models/ui_model.dart';
class DynamicWidget extends StatelessWidget {
final WidgetModel widgetModel;
const DynamicWidget({Key? key, required this.widgetModel}) : super(key: key);
@override
Widget build(BuildContext context) {
switch (widgetModel.type) {
case 'screen':
return _buildScreen(context);
case 'container':
return _buildContainer(context);
case 'text':
return _buildText(context);
case 'image':
return _buildImage(context);
case 'button':
return _buildButton(context);
case 'column':
return _buildColumn(context);
case 'row':
return _buildRow(context);
case 'list':
return _buildList(context);
default:
return const SizedBox.shrink();
}
}
Widget _buildScreen(BuildContext context) {
return Scaffold(
body: ListView.builder(
itemCount: widgetModel.children?.length ?? 0,
itemBuilder: (context, index) {
return DynamicWidget(widgetModel: widgetModel.children![index]);
},
),
);
}
Widget _buildContainer(BuildContext context) {
final properties = widgetModel.properties ?? {};
return Container(
padding: _parsePadding(properties['padding']),
margin: _parseMargin(properties['margin']),
color: _parseColor(properties['color']),
child: widgetModel.children != null
? Column(
children: widgetModel!.children!.map((child) => DynamicWidget(widgetModel: child)).toList(),
)
: null,
);
}
Widget _buildText(BuildContext context) {
final properties = widgetModel.properties ?? {};
final text = properties['text'] ?? '';
final style = _getTextStyle(properties['style'] ?? 'bodyMedium');
final align = _parseTextAlign(properties['align'] ?? 'start');
return Text(
text,
style: style,
textAlign: align,
maxLines: properties['maxLines'],
overflow: _parseTextOverflow(properties['overflow']),
);
}
Widget _buildImage(BuildContext context) {
final properties = widgetModel.properties ?? {};
final url = properties['url'] ?? '';
final width = properties['width']?.toDouble() ?? double.infinity;
final height = properties['height']?.toDouble() ?? 200;
final fit = _parseBoxFit(properties['fit'] ?? 'cover');
return Image.network(
url,
width: width,
height: height,
fit: fit,
errorBuilder: (context, error, stackTrace) {
return Container(
width: width,
height: height,
color: Colors.grey[200],
child: const Icon(Icons.broken_image),
);
},
);
}
Widget _buildButton(BuildContext context) {
final properties = widgetModel.properties ?? {};
final text = properties['text'] ?? '';
final style = _parseButtonStyle(properties['style'] ?? 'text');
final onPressed = properties['onPress'];
return ElevatedButton(
style: style,
onPressed: onPressed != null ? () => _handleButtonPress(context, onPressed) : null,
child: Text(text),
);
}
Widget _buildColumn(BuildContext context) {
final properties = widgetModel.properties ?? {};
final mainAxisAlignment = _parseMainAxisAlignment(properties['mainAxisAlignment'] ?? 'start');
final crossAxisAlignment = _parseCrossAxisAlignment(properties['crossAxisAlignment'] ?? 'center');
final spacing = properties['spacing']?.toDouble() ?? 0;
return Column(
mainAxisAlignment: mainAxisAlignment,
crossAxisAlignment: crossAxisAlignment,
children: widgetModel!.children!.map((child) => DynamicWidget(widgetModel: child)).toList(),
);
}
Widget _buildRow(BuildContext context) {
final properties = widgetModel.properties ?? {};
final mainAxisAlignment = _parseMainAxisAlignment(properties['mainAxisAlignment'] ?? 'start');
final crossAxisAlignment = _parseCrossAxisAlignment(properties['crossAxisAlignment'] ?? 'center');
final spacing = properties['spacing']?.toDouble() ?? 0;
return Row(
mainAxisAlignment: mainAxisAlignment,
crossAxisAlignment: crossAxisAlignment,
children: widgetModel!.children!.map((child) => DynamicWidget(widgetModel: child)).toList(),
);
}
Widget _buildList(BuildContext context) {
final properties = widgetModel.properties ?? {};
final itemCount = properties['itemCount'] ?? 0;
final itemBuilder = properties['itemBuilder'];
if (itemBuilder == null || itemCount <= 0) {
return const SizedBox.shrink();
}
return ListView.builder(
itemCount: itemCount,
itemBuilder: (context, index) {
// Aquí normalmente se haría una llamada al servidor para obtener cada item
// Por simplicidad, usamos widgets estáticos
return DynamicWidget(widgetModel: widgetModel!.children!.first);
},
);
}
// Métodos de ayuda para parsear propiedades
EdgeInsets _parsePadding(dynamic padding) {
if (padding is int) {
return EdgeInsets.all(padding.toDouble());
}
if (padding is List) {
if (padding.length == 4) {
return EdgeInsets.fromLTRB(
padding[0].toDouble(),
padding[1].toDouble(),
padding[2].toDouble(),
padding[3].toDouble(),
);
}
if (padding.length == 2) {
return EdgeInsets.symmetric(
vertical: padding[0].toDouble(),
horizontal: padding[1].toDouble(),
);
}
}
return EdgeInsets.zero;
}
EdgeInsets _parseMargin(dynamic margin) {
return _parsePadding(margin);
}
Color? _parseColor(dynamic color) {
if (color is String) {
if (color.startsWith('#')) {
return Color(int.parse(color.substring(1), radix: 16) + 0xFF000000);
}
// Mapeo a colores de Material Design
switch (color) {
case 'primary': return Theme.of(context).primaryColor;
case 'secondary': return Theme.of(context).secondaryHeaderColor;
case 'background': return Theme.of(context).scaffoldBackgroundColor;
case 'surface': return Theme.of(context).colorScheme.surface;
case 'error': return Theme.of(context).colorScheme.error;
default: return Colors.transparent;
}
}
return null;
}
TextStyle _getTextStyle(String style) {
switch (style) {
case 'headlineLarge':
return Theme.of(context).textTheme.headlineLarge!;
case 'headlineMedium':
return Theme.of(context).textTheme.headlineMedium!;
case 'headlineSmall':
return Theme.of(context).textTheme.headlineSmall!;
case 'titleLarge':
return Theme.of(context).textTheme.titleLarge!;
case 'titleMedium':
return Theme.of(context).textTheme.titleMedium!;
case 'titleSmall':
return Theme.of(context).textTheme.titleSmall!;
case 'bodyLarge':
return Theme.of(context).textTheme.bodyLarge!;
case 'bodyMedium':
return Theme.of(context).textTheme.bodyMedium!;
case 'bodySmall':
return Theme.of(context).textTheme.bodySmall!;
case 'labelLarge':
return Theme.of(context).textTheme.labelLarge!;
case 'labelMedium':
return Theme.of(context).textTheme.labelMedium!;
case 'labelSmall':
return Theme.of(context).textTheme.labelSmall!;
default:
return Theme.of(context).textTheme.bodyMedium!;
}
}
TextAlign _parseTextAlign(String align) {
switch (align) {
case 'left': return TextAlign.left;
case 'right': return TextAlign.right;
case 'center': return TextAlign.center;
case 'justify': return TextAlign.justify;
case 'start': return TextAlign.start;
case 'end': return TextAlign.end;
default: return TextAlign.start;
}
}
TextOverflow _parseTextOverflow(String overflow) {
switch (overflow) {
case 'clip': return TextOverflow.clip;
case 'fade': return TextOverflow.fade;
case 'ellipsis': return TextOverflow.ellipsis;
case 'visible': return TextOverflow.visible;
default: return TextOverflow.clip;
}
}
BoxFit _parseBoxFit(String fit) {
switch (fit) {
case 'fill': return BoxFit.fill;
case 'contain': return BoxFit.contain;
case 'cover': return BoxFit.cover;
case 'fitHeight': return BoxFit.fitHeight;
case 'fitWidth': return BoxFit.fitWidth;
case 'scaleDown': return BoxFit.scaleDown;
default: return BoxFit.cover;
}
}
ButtonStyle _parseButtonStyle(String style) {
switch (style) {
case 'text':
return TextButton.styleFrom();
case 'outlined':
return OutlinedButton.styleFrom();
case 'primary':
default:
return ElevatedButton.styleFrom();
}
}
MainAxisAlignment _parseMainAxisAlignment(String alignment) {
switch (alignment) {
case 'start': return MainAxisAlignment.start;
case 'end': return MainAxisAlignment.end;
case 'center': return MainAxisAlignment.center;
case 'spaceBetween': return MainAxisAlignment.spaceBetween;
case 'spaceAround': return MainAxisAlignment.spaceAround;
case 'spaceEvenly': return MainAxisAlignment.spaceEvenly;
default: return MainAxisAlignment.start;
}
}
CrossAxisAlignment _parseCrossAxisAlignment(String alignment) {
switch (alignment) {
case 'start': return CrossAxisAlignment.start;
case 'end': return CrossAxisAlignment.end;
case 'center': return CrossAxisAlignment.center;
case 'stretch': return CrossAxisAlignment.stretch;
case 'baseline': return CrossAxisAlignment.baseline;
default: return CrossAxisAlignment.center;
}
}
void _handleButtonPress(BuildContext context, String action) {
// Aquí implementaríamos la lógica de manejo de acciones
print('Button pressed: $action');
switch (action) {
case 'navigate_to_home':
Navigator.of(context).pushReplacementNamed('/home');
break;
case 'show_dialog':
showDialog(
context: context,
builder: (context) => const AlertDialog(
title: Text('Acción ejecutada'),
content: Text('La acción se ha ejecutado correctamente'),
),
);
break;
default:
print('Unknown action: $action');
}
}
}Consumiendo APIs y renderizando la UI
Ahora, vamos a crear el servicio para obtener las especificaciones del servidor:
// lib/services/api_service.dart
import 'package:dio/dio.dart';
import '../models/ui_model.dart';
class ApiService {
final Dio _dio;
final String _baseUrl;
ApiService({required String baseUrl})
: _baseUrl = baseUrl,
_dio = Dio(BaseOptions(baseUrl: baseUrl));
Future<ScreenModel> getScreenSpecification(String screenId) async {
try {
final response = await _dio.get('/screens/$screenId');
if (response.statusCode == 200) {
return ScreenModel.fromJson(response.data);
} else {
throw Exception('Failed to load screen specification');
}
} on DioError catch (e) {
throw Exception('API Error: ${e.message}');
}
}
Future<ScreenModel> getHomeScreen() async {
return getScreenSpecification('home');
}
Future<ScreenModel> getProductScreen(String productId) async {
return getScreenSpecification('product_$productId');
}
Future<ScreenModel> getCategoryScreen(String categoryId) async {
return getScreenSpecification('category_$categoryId');
}
}Ahora, creamos un widget que gestione el estado y la carga de las especificaciones:
// lib/widgets/screen_loader.dart
import 'package:flutter/material.dart';
import '../models/ui_model.dart';
import 'dynamic_widget.dart';
import '../services/api_service.dart';
class ScreenLoader extends StatefulWidget {
final String screenId;
final ApiService apiService;
const ScreenLoader({
Key? key,
required this.screenId,
required this.apiService,
}) : super(key: key);
@override
_ScreenLoaderState createState() => _ScreenLoaderState();
}
class _ScreenLoaderState extends State<ScreenLoader> {
late Future<ScreenModel> _futureScreen;
@override
void initState() {
super.initState();
_futureScreen = widget.apiService.getScreenSpecification(widget.screenId);
}
@override
Widget build(BuildContext context) {
return FutureBuilder<ScreenModel>(
future: _futureScreen,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Scaffold(
body: Center(
child: CircularProgressIndicator(),
),
);
}
if (snapshot.hasError) {
return Scaffold(
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.error_outline, size: 64),
const SizedBox(height: 16),
Text('Error: ${snapshot.error}'),
const SizedBox(height: 16),
ElevatedButton(
onPressed: () {
setState(() {
_futureScreen = widget.apiService.getScreenSpecification(widget.screenId);
});
},
child: const Text('Reintentar'),
),
],
),
),
);
}
if (snapshot.hasData) {
return DynamicWidget(widgetModel: snapshot.data!);
}
return const Scaffold(
body: Center(
child: Text('No data available'),
),
);
},
);
}
}Implementando la navegación
Para manejar la navegación entre diferentes pantallas generadas por el servidor, creamos un sistema de navegación simple:
// lib/navigation/navigation_service.dart
import 'package:flutter/material.dart';
import '../services/api_service.dart';
import '../widgets/screen_loader.dart';
class NavigationService {
static final NavigationService _instance = NavigationService._internal();
factory NavigationService() => _instance;
NavigationService._internal();
final GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>();
Future<dynamic> navigateTo(String route, {Map<String, dynamic>? arguments}) {
return navigatorKey.currentState!.pushNamed(
route,
arguments: arguments,
);
}
Future<dynamic> navigateAndRemoveUntil(String route, {Map<String, dynamic>? arguments}) {
return navigatorKey.currentState!.pushNamedAndRemoveUntil(
route,
(route) => false,
arguments: arguments,
);
}
void goBack() {
return navigatorKey.currentState!.pop();
}
}Ahora, creamos las rutas de nuestra aplicación:
// lib/main.dart
import 'package:flutter/material.dart';
import 'navigation/navigation_service.dart';
import 'services/api_service.dart';
import 'widgets/screen_loader.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Server-Driven UI Demo',
theme: ThemeData(
primarySwatch: Colors.blue,
visualDensity: VisualDensity.adaptivePlatformDensity,
),
navigatorKey: NavigationService().navigatorKey,
initialRoute: '/',
routes: {
'/': (context) => const HomeScreen(),
'/home': (context) => const HomeScreen(),
'/product': (context) => const ProductScreen(),
'/category': (context) => const CategoryScreen(),
'/settings': (context) => const SettingsScreen(),
},
);
}
}
class HomeScreen extends StatelessWidget {
const HomeScreen({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
final apiService = ApiService(baseUrl: 'https://api.example.com');
return Scaffold(
appBar: AppBar(
title: const Text('Server-Driven UI Demo'),
actions: [
IconButton(
icon: const Icon(Icons.settings),
onPressed: () {
NavigationService().navigateTo('/settings');
},
),
],
),
body: ScreenLoader(
screenId: 'home',
apiService: apiService,
),
);
}
}
class ProductScreen extends StatelessWidget {
const ProductScreen({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
final apiService = ApiService(baseUrl: 'https://api.example.com');
return Scaffold(
appBar: AppBar(
title: const Text('Product Details'),
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => NavigationService().goBack(),
),
),
body: ScreenLoader(
screenId: 'product_details',
apiService: apiService,
),
);
}
}
class CategoryScreen extends StatelessWidget {
const CategoryScreen({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
final apiService = ApiService(baseUrl: 'https://api.example.com');
return Scaffold(
appBar: AppBar(
title: const Text('Category'),
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => NavigationService().goBack(),
),
),
body: ScreenLoader(
screenId: 'category_list',
apiService: apiService,
),
);
}
}
class SettingsScreen extends StatelessWidget {
const SettingsScreen({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
final apiService = ApiService(baseUrl: 'https://api.example.com');
return Scaffold(
appBar: AppBar(
title: const Text('Settings'),
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => NavigationService().goBack(),
),
),
body: ScreenLoader(
screenId: 'settings',
apiService: apiService,
),
);
}
}Widget specifications avanzadas
Widgets personalizados
Además de los widgets básicos, podemos extender nuestro sistema para soportar componentes más complejos:
{
"type": "screen",
"children": [
{
"type": "product_card",
"properties": {
"productId": "123",
"title": "Producto Ejemplo",
"price": 99.99,
"discount": true,
"rating": 4.5,
"imageUrl": "https://example.com/product.jpg"
}
},
{
"type": "carousel",
"properties": {
"items": [
{
"type": "image",
"properties": {
"url": "https://example.com/slide1.jpg",
"fit": "cover"
}
},
{
"type": "image",
"properties": {
"url": "https://example.com/slide2.jpg",
"fit": "cover"
}
}
],
"autoPlay": true,
"interval": 3000
}
},
{
"type": "form",
"properties": {
"title": "Contacto",
"fields": [
{
"type": "text_input",
"properties": {
"label": "Nombre",
"placeholder": "Ingrese su nombre",
"required": true,
"validation": "min:3,max:50"
}
},
{
"type": "email_input",
"properties": {
"label": "Correo Electrónico",
"placeholder": "correo@ejemplo.com",
"required": true,
"validation": "email"
}
},
{
"type": "textarea",
"properties": {
"label": "Mensaje",
"placeholder": "Escriba su mensaje aquí...",
"required": true,
"maxLines": 5
}
}
],
"onSubmit": "submit_form"
}
}
]
}Implementemos estos widgets avanzados:
// lib/widgets/advanced_widgets.dart
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'dynamic_widget.dart';
class ProductCard extends StatelessWidget {
final Map<String, dynamic> properties;
const ProductCard({Key? key, required this.properties}) : super(key: key);
@override
Widget build(BuildContext context) {
final productId = properties['productId'] ?? '';
final title = properties['title'] ?? '';
final price = properties['price']?.toDouble() ?? 0.0;
final discount = properties['discount'] ?? false;
final rating = properties['rating']?.toDouble() ?? 0.0;
final imageUrl = properties['imageUrl'] ?? '';
return Card(
elevation: 2,
margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
child: InkWell(
onTap: () => _handleProductTap(context, productId),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
AspectRatio(
aspectRatio: 1,
child: Image.network(
imageUrl,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) {
return Container(
color: Colors.grey[200],
child: const Icon(Icons.image_not_supported),
);
},
),
),
Padding(
padding: const EdgeInsets.all(8.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: Theme.of(context).textTheme.titleSmall,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 4),
Row(
children: [
if (discount)
Text(
'\$${price.toStringAsFixed(2)}',
style: TextStyle(
color: Colors.red[700],
fontWeight: FontWeight.bold,
decoration: TextDecoration.lineThrough,
decorationColor: Colors.red[400],
),
),
const SizedBox(width: 4),
Text(
'\$${(price * 0.8).toStringAsFixed(2)}',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
color: Theme.of(context).primaryColor,
),
),
],
),
const SizedBox(height: 4),
Row(
children: [
Icon(
Icons.star,
color: Colors.amber,
size: 16,
),
const SizedBox(width: 2),
Text(
rating.toString(),
style: Theme.of(context).textTheme.bodySmall,
),
const SizedBox(width: 4),
Text(
'(128 reviews)',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Colors.grey[600],
),
),
],
),
],
),
),
],
),
),
);
}
void _handleProductTap(BuildContext context, String productId) {
// Aquí implementaríamos la navegación a la pantalla de producto
print('Navigate to product: $productId');
}
}
class CarouselWidget extends StatefulWidget {
final Map<String, dynamic> properties;
const CarouselWidget({Key? key, required this.properties}) : super(key: key);
@override
_CarouselWidgetState createState() => _CarouselWidgetState();
}
class _CarouselWidgetState extends State<CarouselWidget> {
late PageController _pageController;
int _currentPage = 0;
@override
void initState() {
super.initState();
_pageController = PageController();
}
@override
void dispose() {
_pageController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final items = widget.properties['items'] ?? [];
if (items.isEmpty) {
return const SizedBox.shrink();
}
return Column(
children: [
SizedBox(
height: 200,
child: PageView.builder(
controller: _pageController,
onPageChanged: (index) {
setState(() {
_currentPage = index;
});
},
itemCount: items.length,
itemBuilder: (context, index) {
return DynamicWidget(widgetModel: items[index]);
},
),
),
const SizedBox(height: 8),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: List.generate(
items.length,
(index) => AnimatedContainer(
duration: const Duration(milliseconds: 300),
margin: const EdgeInsets.symmetric(horizontal: 4),
width: _currentPage == index ? 24 : 8,
height: 8,
decoration: BoxDecoration(
color: _currentPage == index
? Theme.of(context).primaryColor
: Colors.grey[300],
borderRadius: BorderRadius.circular(4),
),
),
),
),
],
);
}
}
class DynamicForm extends StatefulWidget {
final Map<String, dynamic> properties;
const DynamicForm({Key? key, required this.properties}) : super(key: key);
@override
_DynamicFormState createState() => _DynamicFormState();
}
class _DynamicFormState extends State<DynamicForm> {
final GlobalKey<FormState> _formKey = GlobalKey<FormState>();
final Map<String, TextEditingController> _controllers = {};
@override
void initState() {
super.initState();
// Inicializar controladores para cada campo
for (var field in widget.properties['fields'] ?? []) {
_controllers[field['properties']['label']] = TextEditingController();
}
}
@override
void dispose() {
_controllers.values.forEach((controller) => controller.dispose());
super.dispose();
}
@override
Widget build(BuildContext context) {
final title = widget.properties['title'] ?? 'Form';
final fields = widget.properties['fields'] ?? [];
final onSubmit = widget.properties['onSubmit'];
return Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 16),
...fields.map((field) {
final fieldProperties = field['properties'] ?? {};
final label = fieldProperties['label'] ?? '';
final placeholder = fieldProperties['placeholder'] ?? '';
final required = fieldProperties['required'] ?? false;
final validation = fieldProperties['validation'] ?? '';
switch (field['type']) {
case 'text_input':
return TextFormField(
controller: _controllers[label],
decoration: InputDecoration(
labelText: label,
hintText: placeholder,
border: const OutlineInputBorder(),
suffix: required
? const Text('*', style: TextStyle(color: Colors.red))
: null,
),
validator: (value) => _validateField(value, required, validation),
);
case 'email_input':
return TextFormField(
controller: _controllers[label],
decoration: InputDecoration(
labelText: label,
hintText: placeholder,
border: const OutlineInputBorder(),
suffix: required
? const Text('*', style: TextStyle(color: Colors.red))
: null,
),
keyboardType: TextInputType.emailAddress,
validator: (value) => _validateEmail(value, required, validation),
);
case 'textarea':
return TextFormField(
controller: _controllers[label],
decoration: InputDecoration(
labelText: label,
hintText: placeholder,
border: const OutlineInputBorder(),
suffix: required
? const Text('*', style: TextStyle(color: Colors.red))
: null,
),
maxLines: fieldProperties['maxLines'] ?? 3,
validator: (value) => _validateField(value, required, validation),
);
default:
return const SizedBox.shrink();
}
}).toList(),
const SizedBox(height: 16),
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: () => _handleSubmit(onSubmit),
child: const Text('Enviar'),
),
),
],
),
);
}
String? _validateField(String? value, bool required, String validation) {
if (required && (value == null || value.isEmpty)) {
return 'Este campo es obligatorio';
}
if (value != null && value.isNotEmpty) {
switch (validation) {
case 'min:3':
if (value.length < 3) return 'Mínimo 3 caracteres';
break;
case 'min:50':
if (value.length < 50) return 'Mínimo 50 caracteres';
break;
case 'max:50':
if (value.length > 50) return 'Máximo 50 caracteres';
break;
}
}
return null;
}
String? _validateEmail(String? value, bool required, String validation) {
if (required && (value == null || value.isEmpty)) {
return 'Este campo es obligatorio';
}
if (value != null && value.isNotEmpty) {
if (!RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$').hasMatch(value)) {
return 'Ingrese un email válido';
}
}
return null;
}
void _handleSubmit(String? onSubmit) {
if (_formKey.currentState!.validate()) {
// Recopilar datos del formulario
final formData = <String, String>{};
_controllers.forEach((key, controller) {
formData[key] = controller.text;
});
// Aquí normalmente se enviaría al servidor
print('Form submitted with data: $formData');
// Mostrar mensaje de éxito
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Formulario enviado correctamente'),
backgroundColor: Colors.green,
),
);
// Limpiar formulario
_controllers.values.forEach((controller) => controller.clear());
}
}
}Implementación de A/B Testing
Conceptos básicos de A/B Testing
El A/B Testing es una de las funcionalidades más poderosas del Server-Driven UI. Permite probar diferentes versiones de la interfaz para determinar cuál produce mejores resultados en términos de engagement, conversiones, etc.
Arquitectura de A/B Testing
Para implementar A/B Testing en nuestro sistema SDUI, necesitamos:
- Definición de experimentos: El servidor debe poder definir diferentes variantes de una pantalla
Implementación práctica
Primero, definimos los modelos para el sistema de A/B Testing:
// lib/models/ab_testing_model.dart
import 'package:json_annotation/json_annotation.dart';
part 'ab_testing_model.g.dart';
@JsonSerializable()
class Experiment {
@JsonKey(name: 'id')
final String id;
@JsonKey(name: 'name')
final String name;
@JsonKey(name: 'description')
final String description;
@JsonKey(name: 'variants')
final List<Variant> variants;
@JsonKey(name: 'traffic_percentage')
final double trafficPercentage;
@JsonKey(name: 'start_date')
final DateTime startDate;
@JsonKey(name: 'end_date')
final DateTime? endDate;
@JsonKey(name: 'is_active')
final bool isActive;
Experiment({
required this.id,
required this.name,
required this.description,
required this.variants,
required this.trafficPercentage,
required this.startDate,
this.endDate,
required this.isActive,
});
factory Experiment.fromJson(Map<String, dynamic> json) => _$ExperimentFromJson(json);
Map<String, dynamic> toJson() => _$ExperimentToJson(this);
}
@JsonSerializable()
class Variant {
@JsonKey(name: 'id')
final String id;
@JsonKey(name: 'name')
final String name;
@JsonKey(name: 'weight')
final double weight;
@JsonKey(name: 'configuration')
final Map<String, dynamic> configuration;
Variant({
required this.id,
required this.name,
required this.weight,
required this.configuration,
});
factory Variant.fromJson(Map<String, dynamic> json) => _$VariantFromJson(json);
Map<String, dynamic> toJson() => _$VariantToJson(this);
}
@JsonSerializable()
class UserAssignment {
@JsonKey(name: 'user_id')
final String userId;
@JsonKey(name: 'experiment_id')
final String experimentId;
@JsonKey(name: 'variant_id')
final String variantId;
@JsonKey(name: 'assigned_at')
final DateTime assignedAt;
UserAssignment({
required this.userId,
required this.experimentId,
required this.variantId,
required this.assignedAt,
});
factory UserAssignment.fromJson(Map<String, dynamic> json) => _$UserAssignmentFromJson(json);
Map<String, dynamic> toJson() => _$UserAssignmentToJson(this);
}Ahora, creamos el servicio de A/B Testing:
// lib/services/ab_testing_service.dart
import 'dart:convert';
import 'package:dio/dio.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../models/ab_testing_model.dart';
class ABTestingService {
final Dio _dio;
final String _baseUrl;
final String _userId;
ABTestingService({
required String baseUrl,
required this._userId,
}) : _baseUrl = baseUrl,
_dio = Dio(BaseOptions(baseUrl: baseUrl));
Future<Variant> getVariantForExperiment(String experimentId) async {
// Primero intentar obtener la asignación desde caché
final prefs = await SharedPreferences.getInstance();
final cachedAssignment = prefs.getString('ab_testing_assignment_$experimentId');
if (cachedAssignment != null) {
try {
final assignment = UserAssignment.fromJson(jsonDecode(cachedAssignment));
return await _getVariantById(assignment.variantId);
} catch (e) {
// Si el caché está corrupto, continuamos
}
}
// Si no está en caché, obtener del servidor
try {
final response = await _dio.get(
'/ab-testing/assign',
queryParameters: {
'user_id': _userId,
'experiment_id': experimentId,
},
);
if (response.statusCode == 200) {
final assignment = UserAssignment.fromJson(response.data);
// Guardar en caché
await prefs.setString('ab_testing_assignment_$experimentId', jsonEncode(assignment));
return await _getVariantById(assignment.variantId);
} else {
throw Exception('Failed to assign variant');
}
} on DioError catch (e) {
throw Exception('A/B Testing Error: ${e.message}');
}
}
Future<Variant> _getVariantById(String variantId) async {
// Aquí normalmente se obtendría la variante desde una fuente de datos
// Por simplicidad, retornamos una variante por defecto
return Variant(
id: variantId,
name: 'Variant $variantId',
weight: 1.0,
configuration: {},
);
}
Future<Map<String, dynamic>> getScreenConfigurationWithABTesting(
String screenId,
String experimentId,
) async {
final variant = await getVariantForExperiment(experimentId);
// Combinar la configuración base con la variante A/B
return {
'screen_id': screenId,
'ab_variant': variant.configuration,
'base_config': {}, // Aquirir la configuración base
};
}
Future<void> trackEvent(String experimentId, String variantId, String eventType, Map<String, dynamic> eventData) async {
try {
await _dio.post(
'/ab-testing/events',
data: {
'user_id': _userId,
'experiment_id': experimentId,
'variant_id': variantId,
'event_type': eventType,
'event_data': eventData,
'timestamp': DateTime.now().toIso8601String(),
},
);
} on DioError catch (e) {
// No lanzar excepción para evitar romper la UI
print('Failed to track A/B testing event: ${e.message}');
}
}
Future<List<Experiment>> getActiveExperiments() async {
try {
final response = await _dio.get('/ab-testing/experiments/active');
if (response.statusCode == 200) {
final experiments = (response.data as List)
.map((json) => Experiment.fromJson(json))
.toList();
return experiments.where((exp) => exp.isActive).toList();
} else {
throw Exception('Failed to load experiments');
}
} on DioError catch (e) {
throw Exception('A/B Testing Error: ${e.message}');
}
}
}Configuración remota y gestión de features
Implementación del sistema de configuración remota
// lib/models/remote_config_model.dart
import 'package:json_annotation/json_annotation.dart';
part 'remote_config_model.g.dart';
@JsonSerializable()
class RemoteConfig {
@JsonKey(name: 'configs')
final Map<String, ConfigValue> configs;
@JsonKey(name: 'version')
final String version;
@JsonKey(name: 'last_updated')
final DateTime lastUpdated;
RemoteConfig({
required this.configs,
required this.version,
required this.lastUpdated,
});
factory RemoteConfig.fromJson(Map<String, dynamic> json) => _$RemoteConfigFromJson(json);
Map<String, dynamic> toJson() => _$RemoteConfigToJson(this);
ConfigValue? getConfig(String key) {
return configs[key];
}
bool getBool(String key, {bool defaultValue = false}) {
final config = configs[key];
if (config == null) return defaultValue;
return config.value as bool? ?? defaultValue;
}
double getDouble(String key, {double defaultValue = 0.0}) {
final config = configs[key];
if (config == null) return defaultValue;
return config.value as double? ?? defaultValue;
}
int getInt(String key, {int defaultValue = 0}) {
final config = configs[key];
if (config == null) return defaultValue;
return config.value as int? ?? defaultValue;
}
String getString(String key, {String defaultValue = ''}) {
final config = configs[key];
if (config == null) return defaultValue;
return config.value as String? ?? defaultValue;
}
}
@JsonSerializable()
class ConfigValue {
@JsonKey(name: 'value')
final dynamic value;
@JsonKey(name: 'type')
final ConfigType type;
@JsonKey(name: 'description')
final String description;
@JsonKey(name: 'last_updated')
final DateTime lastUpdated;
ConfigValue({
required this.value,
required this.type,
required this.description,
required this.lastUpdated,
});
factory ConfigValue.fromJson(Map<String, dynamic> json) => _$ConfigValueFromJson(json);
Map<String, dynamic> toJson() => _$ConfigValueToJson(this);
}
enum ConfigType {
bool,
number,
string,
json,
}// lib/services/remote_config_service.dart
import 'dart:convert';
import 'package:dio/dio.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../models/remote_config_model.dart';
class RemoteConfigService {
static const String _configKey = 'remote_config';
static const Duration _cacheDuration = Duration(minutes: 30);
final Dio _dio;
final String _baseUrl;
final String _appId;
final String _appVersion;
RemoteConfig? _cachedConfig;
DateTime? _lastFetchTime;
RemoteConfigService({
required String baseUrl,
required this._appId,
required this._appVersion,
}) : _baseUrl = baseUrl,
_dio = Dio(BaseOptions(baseUrl: baseUrl));
Future<RemoteConfig> getConfig({bool forceRefresh = false}) async {
// Si tenemos caché válido y no se fuerza actualización, retornarlo
if (!forceRefresh && _cachedConfig != null && _lastFetchTime != null) {
final cacheAge = DateTime.now().difference(_lastFetchTime!);
if (cacheAge < _cacheDuration) {
return _cachedConfig!;
}
}
// Forzar actualización desde el servidor
try {
final response = await _dio.get(
'/config/mobile',
queryParameters: {
'app_id': _appId,
'app_version': _appVersion,
'platform': 'flutter',
},
);
if (response.statusCode == 200) {
final config = RemoteConfig.fromJson(response.data);
// Guardar en caché
await _cacheConfig(config);
_cachedConfig = config;
_lastFetchTime = DateTime.now();
return config;
} else {
throw Exception('Failed to fetch remote config');
}
} on DioError catch (e) {
// Si falla la red, intentar obtener desde caché
final cached = await _getCachedConfig();
if (cached != null) {
print('Using cached remote config');
_cachedConfig = cached;
_lastFetchTime = DateTime.now();
return cached;
}
throw Exception('Remote Config Error: ${e.message}');
}
}
Future<void> _cacheConfig(RemoteConfig config) async {
final prefs = await SharedPreferences.getInstance();
final configJson = jsonEncode(config.toJson());
await prefs.setString(_configKey, configJson);
// Guardar timestamp de caché
await prefs.setString('${_configKey}_timestamp', DateTime.now().toIso8601String());
}
Future<RemoteConfig?> _getCachedConfig() async {
final prefs = await SharedPreferences.getInstance();
final configJson = prefs.getString(_configKey);
if (configJson == null) return null;
final timestampStr = prefs.getString('${_configKey}_timestamp');
if (timestampStr == null) return null;
final timestamp = DateTime.parse(timestampStr);
final cacheAge = DateTime.now().difference(timestamp);
if (cacheAge > _cacheDuration) {
await prefs.remove(_configKey);
await prefs.remove('${_configKey}_timestamp');
return null;
}
return RemoteConfig.fromJson(jsonDecode(configJson));
}
// Métodos convenientes para obtener valores de configuración
bool getBool(String key, {bool defaultValue = false}) {
if (_cachedConfig == null) return defaultValue;
return _cachedConfig!.getBool(key, defaultValue: defaultValue);
}
double getDouble(String key, {double defaultValue = 0.0}) {
if (_cachedConfig == null) return defaultValue;
return _cachedConfig!.getDouble(key, defaultValue: defaultValue);
}
int getInt(String key, {int defaultValue = 0}) {
if (_cachedConfig == null) return defaultValue;
return _cachedConfig!.getInt(key, defaultValue: defaultValue);
}
String getString(String key, {String defaultValue = ''}) {
if (_cachedConfig == null) return defaultValue;
return _cachedConfig!.getString(key, defaultValue: defaultValue);
}
// Configuraciones comunes
bool get showDebugMenu => getBool('show_debug_menu', defaultValue: false);
bool get enableAnalytics => getBool('enable_analytics', defaultValue: true);
bool get enableCrashReporting => getBool('enable_crash_reporting', defaultValue: true);
double get animationSpeed => getDouble('animation_speed', defaultValue: 1.0);
String get appTheme => getString('app_theme', defaultValue: 'light');
}Optimización y rendimiento
Sistema de caché avanzado
// lib/services/cache_service.dart
import 'dart:convert';
import 'package:shared_preferences/shared_preferences.dart';
import '../models/ui_model.dart';
class CacheService {
static const String _cachePrefix = 'sdui_cache_';
static const Duration _defaultCacheDuration = Duration(hours: 1);
static Future<void> cacheScreen(String screenId, ScreenModel screen) async {
final prefs = await SharedPreferences.getInstance();
final cacheData = {
'data': screen.toJson(),
'timestamp': DateTime.now().toIso8601String(),
};
await prefs.setString('${_cachePrefix}$screenId', jsonEncode(cacheData));
}
static Future<ScreenModel?> getCachedScreen(String screenId) async {
final prefs = await SharedPreferences.getInstance();
final cachedData = prefs.getString('${_cachePrefix}$screenId');
if (cachedData == null) return null;
final decoded = jsonDecode(cachedData) as Map<String, dynamic>;
final timestamp = DateTime.parse(decoded['timestamp']);
final duration = DateTime.now().difference(timestamp);
if (duration > _defaultCacheDuration) {
await prefs.remove('${_cachePrefix}$screenId');
return null;
}
return ScreenModel.fromJson(decoded['data']);
}
static Future<void> clearCache() async {
final prefs = await SharedPreferences.getInstance();
final keys = prefs.getKeys();
for (final key in keys) {
if (key.startsWith(_cachePrefix)) {
await prefs.remove(key);
}
}
}
}Prefetching de pantallas
// lib/services/prefetch_service.dart
import 'dart:async';
import '../services/api_service.dart';
import '../models/ui_model.dart';
class PrefetchService {
final ApiService _apiService;
final Map<String, ScreenModel> _prefetchedScreens = {};
final Set<String> _prefetchingScreens = {};
PrefetchService(this._apiService);
void prefetchScreen(String screenId) {
if (_prefetchedScreens.containsKey(screenId) || _prefetchingScreens.contains(screenId)) {
return; // Ya está prefetcheado o en proceso
}
_prefetchingScreens.add(screenId);
_apiService.getScreenSpecification(screenId).then((screen) {
_prefetchedScreens[screenId] = screen;
_prefetchingScreens.remove(screenId);
}).catchError((error) {
_prefetchingScreens.remove(screenId);
});
}
void prefetchScreens(List<String> screenIds) {
for (final screenId in screenIds) {
prefetchScreen(screenId);
}
}
ScreenModel? getPrefetchedScreen(String screenId) {
return _prefetchedScreens[screenId];
}
bool isPrefetched(String screenId) {
return _prefetchedScreens.containsKey(screenId);
}
void clearPrefetch() {
_prefetchedScreens.clear();
_prefetchingScreens.clear();
}
}Casos de uso y mejores prácticas
Caso de uso 1: E-commerce con SDUI
// lib/examples/ecommerce_example.dart
import 'package:flutter/material.dart';
import '../models/ui_model.dart';
class EcommerceSDUI extends StatelessWidget {
const EcommerceSDUI({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
// Especificación JSON para una página de producto
final productScreenSpec = {
"type": "screen",
"children": [
{
"type": "app_bar",
"properties": {
"title": "Producto",
"show_back": true
}
},
{
"type": "product_details",
"properties": {
"product_id": "12345",
"show_images": true,
"show_info": true,
"show_actions": true
}
},
{
"type": "related_products",
"properties": {
"title": "Productos Relacionados",
"limit": 4,
"scrollable": true
}
}
]
};
return Scaffold(
body: DynamicWidget(widgetModel: ScreenModel.fromJson(productScreenSpec)),
);
}
}Caso de uso 2: Feed de noticias
// lib/examples/news_feed_example.dart
import 'package:flutter/material.dart';
import '../models/ui_model.dart';
class NewsFeedSDUI extends StatelessWidget {
const NewsFeedSDUI({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
final feedSpec = {
"type": "screen",
"children": [
{
"type": "app_bar",
"properties": {
"title": "Noticias",
"show_search": true
}
},
{
"type": "category_tabs",
"properties": {
"categories": ["Últimas", "Tecnología", "Deportes", "Entretenimiento"],
"initial_category": "Últimas"
}
},
{
"type": "news_feed",
"properties": {
"loading_state": "loading",
"refresh_enabled": true,
"infinite_scroll": true
}
}
]
};
return Scaffold(
body: DynamicWidget(widgetModel: ScreenModel.fromJson(feedSpec)),
);
}
}Preguntas frecuentes (FAQ)
1. ¿Cuándo debo usar Server-Driven UI?
SDUI es ideal cuando:
- Necesitas lanzar actualizaciones de UI sin desplegar nueva versión
- Quieres implementar A/B testing nativo
- Tu equipo backend es más rápido que el móvil
- Necesitas personalización masiva de la interfaz
- Tú app tiene contenido muy dinámico
2. ¿Cuál es el impacto en el rendimiento?
El impacto depende de:
- Tamaño de las especificaciones JSON
- Velocidad de la red
- Implementación del caché
- Optimizaciones del renderizado
Con buen caching y prefetching, el impacto es mínimo.
3. ¿Cómo manejo la compatibilidad entre versiones?
Usa versionado en tus contratos JSON:
- Incluye un campo `version` en cada especificación
- Implementa fallbacks para versiones anteriores
- Usa parsers que soporten backwards compatibility
4. ¿Es seguro para producción?
Sí, si implementas:
- Validación estricta del JSON
- Rate limiting
- Cifrado de datos sensibles
- Monitoreo y alertas
5. ¿Cuánto cuesta implementar?
El costo inicial es mayor debido a:
- Desarrollo del backend de UI
- Sistema de caching
- Infraestructura de configuración remota
- Monitoreo y métricas
Pero a largo plazo reduce costos de despliegue y acelera la innovación.
6. ¿Cómo manejo el estado de la aplicación?
Usa patrones establecidos:
- Bloc/Cubit para estado complejo
- Provider para estado simple
- Persiste críticos usando Hive/SharedPreferences
7. ¿Qué alternativas existen a SDUI?
Alternativas:
- Client-Driven UI tradicional
- WebViews nativos
- Headless CMS con personalización
- Configuración remota básica
Conclusiones y próximos pasos
Resumen de beneficios
Server-Driven UI con Flutter ofrece:
- Agilidad en desarrollo: Cambios de UI sin despliegue
Próximos pasos recomendados
- Empieza pequeño: Implementa SDUI en una pantalla no crítica
Recursos adicionales
Documentación oficial
- [Flutter Documentation](https://flutter.dev/docs)
- [Server-Driven UI Patterns](https://medium.com/flutter/server-driven-ui-patterns-with-flutter-1e76d8f6f4a2)
- [JSON to Widgets Implementation](https://github.com/flutter/flutter/wiki/JSON-to-Widgets)
Librerías útiles
- [json_serializable](https://pub.dev/packages/json_serializable)
- [retrofit](https://pub.dev/packages/retrofit)
- [bloc](https://pub.dev/packages/flutter_bloc)
- [shared_preferences](https://pub.dev/packages/shared_preferences)
Casos de estudio
- [Shopify’s Mobile Architecture](https://shopify.engineering/mobile-app-architecture-at-shopify)
- [Spotify’s Design System](https://labs.spotify.com/2019/04/11/mobile-spotify-architecture/)
- [Airbnb’s Mobile Journey](https://medium.com/airbnb-engineering/our-mobile-journey-to-100-server-driven-ui-af9e29a799f1)
Path de aprendizaje
Principiante (1-2 meses)
- Conceptos básicos de Flutter
- JSON parsing y serialización
- Patrones de arquitectura básicos
- Primeros pasos con SDUI simple
Intermedio (2-3 meses)
- Caching avanzado
- Manejo de errores y fallbacks
- Integración con state management
- A/B Testing básico
Avanzado (3-4 meses)
- Sistemas complejos de configuración remota
- Optimización de rendimiento avanzada
- Monitoreo y métricas
- Arquitecturas a gran escala
Reto de implementación
Implementa un sistema SDUI completo con los siguientes requisitos:
- Arquitectura base:
- Sistema de mapeo JSON a Widgets
- Caching inteligente con expiración
- Manejo de errores robusto
- Features avanzadas:
- A/B Testing con múltiples variantes
- Configuración remota para todos los widgets
- Pre-carga de pantallas basada en patrones de navegación
- Rendimiento:
- Métricas de rendimiento (tiempo de carga, uso de memoria)
- Optimizaciones para listas largas
- Sistema de depuración para SDUI
- Seguridad:
- Validación de JSON
- Manejo de datos sensibles
- Rate limiting en el cliente
- Monitorización:
- Dashboard de rendimiento
- Reporte de errores
- Métricas de A/B Testing
Criterios de evaluación:
- Calidad del código y pruebas
- Rendimiento y experiencia de usuario
- Documentación y mantenibilidad
- Escalabilidad y robustez
—
Este artículo te ha proporcionado una guía completa para implementar Server-Driven UI con Flutter. La clave del éxito está en comenzar con un enfoque incremental, medir el impacto y mejorar continuamente tu sistema. ¡Ahora es tu turno de transformar tu aplicación móvil con el poder del Server-Driven UI!


