Server-Driven UI con Flutter: La tendencia del año para desarrollo móvil nativo

Interfaz móvil renderizada dinámicamente desde servidor en Flutter, mostrando cambios en tiempo real

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:

  1. 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_example

Ahora, 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.5

Definiendo 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 build

Ahora, 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:

  1. 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:

  1. Agilidad en desarrollo: Cambios de UI sin despliegue

Próximos pasos recomendados

  1. 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)

  1. Conceptos básicos de Flutter
  2. JSON parsing y serialización
  3. Patrones de arquitectura básicos
  4. Primeros pasos con SDUI simple

Intermedio (2-3 meses)

  1. Caching avanzado
  2. Manejo de errores y fallbacks
  3. Integración con state management
  4. A/B Testing básico

Avanzado (3-4 meses)

  1. Sistemas complejos de configuración remota
  2. Optimización de rendimiento avanzada
  3. Monitoreo y métricas
  4. Arquitecturas a gran escala

Reto de implementación

Implementa un sistema SDUI completo con los siguientes requisitos:

  1. Arquitectura base:
  • Sistema de mapeo JSON a Widgets
  • Caching inteligente con expiración
  • Manejo de errores robusto
  1. 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
  1. Rendimiento:
  • Métricas de rendimiento (tiempo de carga, uso de memoria)
  • Optimizaciones para listas largas
  • Sistema de depuración para SDUI
  1. Seguridad:
  • Validación de JSON
  • Manejo de datos sensibles
  • Rate limiting en el cliente
  1. 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!

Deja un comentario

Scroll al inicio

Discover more from Creapolis

Subscribe now to keep reading and get access to the full archive.

Continue reading