Shorebird en Flutter: ejercicio resuelto con code push y actualizaciones OTA

Shorebird en Flutter: ejercicio resuelto con code push

Shorebird es el servicio de code push para Flutter: permite enviar actualizaciones de código Dart directamente a los usuarios sin pasar por revisión de Google Play ni App Store. Fue creado por el equipo original de ingeniería de Flutter.

El concepto es el mismo que hot fixes de React Native CodePush: compilas un parche de los cambios en Dart, lo subes a Shorebird y la app lo descarga e instala la próxima vez que se abre.

Enunciado

Configura un proyecto Flutter con Shorebird que:

  • Inicialice la integración con shorebird init.
  • Compruebe si hay una actualización disponible al arrancar.
  • Descargue e instale el parche en segundo plano.
  • Informe al usuario del estado con una UI mínima.
  • Muestre el número de parche actual del lado del cliente.

Dependencias

1
2
3
4
dependencies:
  flutter:
    sdk: flutter
  shorebird_code_push: ^1.5.0

Configuración

1. Instalar la CLI de Shorebird:

1
2
3
curl --proto '=https' --tlsv1.2 \
  https://raw.githubusercontent.com/shorebirdtech/shorebird/main/install.sh \
  -sSf | bash

Verifica la instalación con shorebird --version.

2. Crear cuenta y autenticarse:

1
shorebird login

3. Inicializar Shorebird en el proyecto Flutter:

1
2
cd mi_app_flutter
shorebird init

Esto añade un shorebird.yaml con el app_id del proyecto.

4. Crear la primera release (build de producción que se sube a Shorebird):

1
2
3
4
5
# Android
shorebird release android

# iOS
shorebird release ios

Distribuye este build en Google Play / App Store como cualquier release normal.

5. Cuando tengas cambios de código que quieras enviar como parche:

1
2
3
4
5
# Android
shorebird patch android

# iOS
shorebird patch ios

Los usuarios recibirán el parche la próxima vez que abran la app.

Solución completa

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
import 'package:flutter/material.dart';
import 'package:shorebird_code_push/shorebird_code_push.dart';

Future<void> main() => runApp(const MyApp());

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Shorebird Demo',
      theme: ThemeData(useMaterial3: true, colorSchemeSeed: Colors.green),
      home: const UpdatePage(),
    );
  }
}

class UpdatePage extends StatefulWidget {
  const UpdatePage({super.key});

  @override
  State<UpdatePage> createState() => _UpdatePageState();
}

class _UpdatePageState extends State<UpdatePage> {
  final _updater = ShorebirdUpdater();
  UpdateStatus _status = UpdateStatus.upToDate;
  int? _currentPatch;
  bool _checking = false;
  bool _downloading = false;

  @override
  void initState() {
    super.initState();
    _init();
  }

  Future<void> _init() async {
    // isAvailable es false en debug y en simuladores de iOS
    if (!_updater.isAvailable) return;

    final info = await _updater.readCurrentPatch();
    if (mounted) setState(() => _currentPatch = info?.number);

    await _checkForUpdate();
  }

  Future<void> _checkForUpdate() async {
    if (!_updater.isAvailable) return;

    setState(() => _checking = true);
    try {
      final status = await _updater.checkForUpdate();
      if (mounted) setState(() => _status = status);
    } catch (e) {
      debugPrint('Error al comprobar actualización: $e');
    } finally {
      if (mounted) setState(() => _checking = false);
    }
  }

  Future<void> _downloadUpdate() async {
    setState(() => _downloading = true);
    try {
      await _updater.update();
      if (mounted) {
        ScaffoldMessenger.of(context).showSnackBar(
          const SnackBar(
            content: Text('Parche instalado. Reinicia la app para aplicarlo.'),
            duration: Duration(seconds: 4),
          ),
        );
        setState(() => _status = UpdateStatus.upToDate);
      }
    } catch (e) {
      if (mounted) {
        ScaffoldMessenger.of(context)
            .showSnackBar(SnackBar(content: Text('Error: $e')));
      }
    } finally {
      if (mounted) setState(() => _downloading = false);
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Shorebird Updates'),
        actions: [
          IconButton(
            icon: const Icon(Icons.refresh),
            tooltip: 'Comprobar actualización',
            onPressed: _checking ? null : _checkForUpdate,
          ),
        ],
      ),
      body: Center(
        child: Padding(
          padding: const EdgeInsets.all(24),
          child: Column(
            mainAxisAlignment: MainAxisAlignment.center,
            children: [
              if (_currentPatch != null)
                Text('Parche actual: #$_currentPatch',
                    style: Theme.of(context).textTheme.bodySmall)
              else
                const Text('Release base (sin parches)',
                    style: TextStyle(color: Colors.grey)),
              const SizedBox(height: 32),
              if (!_updater.isAvailable)
                const _StatusCard(
                  icon: Icons.info_outline,
                  title: 'Shorebird no disponible',
                  subtitle:
                      'Solo funciona en builds de release. En debug y simulador está deshabilitado.',
                  color: Colors.grey,
                )
              else if (_checking)
                const Column(children: [
                  CircularProgressIndicator(),
                  SizedBox(height: 16),
                  Text('Comprobando actualizaciones...'),
                ])
              else if (_status == UpdateStatus.outdated)
                _StatusCard(
                  icon: Icons.system_update,
                  title: 'Actualización disponible',
                  subtitle: 'Hay un parche nuevo listo para instalar.',
                  color: Colors.green,
                  action: FilledButton.icon(
                    onPressed: _downloading ? null : _downloadUpdate,
                    icon: _downloading
                        ? const SizedBox(
                            width: 16,
                            height: 16,
                            child: CircularProgressIndicator(
                                strokeWidth: 2, color: Colors.white),
                          )
                        : const Icon(Icons.download),
                    label: Text(_downloading ? 'Descargando...' : 'Instalar parche'),
                  ),
                )
              else
                _StatusCard(
                  icon: Icons.check_circle,
                  title: 'App actualizada',
                  subtitle: 'Estás en la última versión disponible.',
                  color: Colors.teal,
                ),
            ],
          ),
        ),
      ),
    );
  }
}

class _StatusCard extends StatelessWidget {
  final IconData icon;
  final String title;
  final String subtitle;
  final Color color;
  final Widget? action;

  const _StatusCard({
    required this.icon,
    required this.title,
    required this.subtitle,
    required this.color,
    this.action,
  });

  @override
  Widget build(BuildContext context) {
    return Card(
      child: Padding(
        padding: const EdgeInsets.all(20),
        child: Column(children: [
          Icon(icon, size: 48, color: color),
          const SizedBox(height: 12),
          Text(title,
              style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
          const SizedBox(height: 6),
          Text(subtitle,
              textAlign: TextAlign.center,
              style: const TextStyle(color: Colors.grey)),
          if (action != null) ...[const SizedBox(height: 16), action!],
        ]),
      ),
    );
  }
}

Conceptos clave

ConceptoDetalle
shorebird releaseBuild completo que se distribuye en las stores
shorebird patchParche solo con cambios de código Dart
ShorebirdUpdaterCliente Flutter para comprobar e instalar parches
updater.isAvailablefalse en debug y simuladores (solo funciona en release)
updater.checkForUpdate()Consulta al servidor si hay un parche más nuevo
updater.readCurrentPatch()Número del parche instalado actualmente
updater.update()Descarga e instala el parche (se aplica al reiniciar)
UpdateStatus.outdatedHay un parche disponible
UpdateStatus.upToDateApp en la última versión

Limitaciones importantes

  • Solo Dart: Shorebird parchea código Dart compilado. Cambios en código nativo (Kotlin/Swift), assets, imágenes o dependencias de pub.dev no se pueden enviar como parche.
  • El parche se aplica al reiniciar: updater.update() descarga el parche, pero se activa la próxima vez que el usuario abre la app.
  • iOS: Shorebird cumple con las políticas de Apple; solo modifica código Dart, no código nativo.
  • Plan gratuito: incluye un número limitado de parches al mes; revisa los precios actuales en shorebird.dev.

Errores frecuentes

  • isAvailable siempre false en desarrollo: correcto por diseño. Prueba siempre con flutter build + distribución interna.
  • Cambiar assets y esperar que lleguen con el parche: no funciona. Solo Dart.
  • Olvidar hacer shorebird release antes del primer shorebird patch: Shorebird necesita una release base contra la que calcular el diff del parche.

Aplicación práctica

Shorebird es especialmente valioso para apps con ciclos de review lentos en Apple, correcciones urgentes de bugs en producción o equipos con pipelines de despliegue frecuentes. Combínalo con feature flags para activar nuevas funcionalidades de forma controlada sin re-publicar en las stores.

Siguiente ejercicio recomendado

Práctica guiada y siguiente paso

FAQ

¿Shorebird viola las políticas de Apple?

No. Shorebird solo modifica código Dart compilado (AOT patches), no código nativo. Apple permite actualizar la lógica de la aplicación mientras no cambies la funcionalidad principal sin revisión.

¿Cuánto tarda el parche en llegar a los usuarios?

Los parches se descargan la próxima vez que la app se abre con conexión. Se aplican al siguiente arranque. El tiempo total para la mayoría de usuarios es de horas, no días.

¿Puedo revertir un parche?

Sí. Desde el panel de Shorebird puedes promover una release anterior como activa, lo que hace que las apps descarten el parche defectuoso y vuelvan al estado anterior.