Patrones de diseño con Flutter: 13— Observer

Paolo Pinto
6 min readApr 26, 2024

--

también llamado: Evento-Suscriptor, Listener

Imagina que trabajas para una tienda de videojuegos y acaban de anunciar el relanzamiento de un juego de PS1. No te explicas como, pero hay muchas personas extrañamente mayores de veinticinco preguntando por el juego. ¿Como les notificamos cuando llegara el juego sin perder clientela?

muchas personas tienen nostalgia de un juegazo

Es como cuando se notificaban con señales de humo.

El problema es que estamos notificando a todas las personas sin importar que ellas querían o no saber, lo cual nos hace perder clientes. Así el cliente pierde tiempo comprobando la disponibilidad, o la tienda desperdicia recursos notificando a los clientes equivocados.

¿Que podemos hacer con el Patrón Observer?

Observer es un patrón que te permite definir un mecanismo de suscripción para notificar a varios objetos sobre cualquier evento que le suceda al objeto que están observando.

El patrón Observer sugiere que añadas un mecanismo de suscripción a la clase notificadora(Publisher) para que los objetos individuales puedan suscribirse o cancelar su suscripción a un flujo de eventos que proviene de esa notificadora.

¡No temas! No es tan complicado como parece. En realidad, este mecanismo consiste en:

  1. un campo matriz para almacenar una lista de referencias a objetos suscriptores
  2. varios métodos públicos que permiten añadir suscriptores y eliminarlos de esa lista.

Si tu aplicación tiene varios tipos diferentes de notificadores y quieres hacer a tus suscriptores compatibles con todos ellos. Puedes hacer que todos los notificadores sigan la misma interfaz.

Esta interfaz sólo tendrá que describir algunos métodos de suscripción. La interfaz permitirá a los suscriptores observar los estados de los notificadores sin acoplarse a sus clases concretas.

¿Como lo implementaremos en Flutter?

Si nos basamos en el diagrama de clases original.

y nuestro ejemplo.

Utilizaremos el package de faker

Utilizaremos los siguientes types como constantes

enum StockChangeDirection {
falling,
growing,
}

enum StockTickerSymbol {
GME,
GOOGL,
TSLA,
}

StockTickerModel: Clase entidad para StockTickers

class StockTickerModel {
final StockTicker stockTicker;

bool subscribed = false;

StockTickerModel({
required this.stockTicker,
});

void toggleSubscribed() {
subscribed = !subscribed;
}
}

Stock: Contiene más información de las propiedades del stock.

class Stock {
const Stock({
required this.symbol,
required this.changeDirection,
required this.price,
required this.changeAmount,
});

final StockTickerSymbol symbol;
final StockChangeDirection changeDirection;
final double price;
final double changeAmount;
}

Subscriber: StockSubscriber

abstract class StockSubscriber {
late final String title;
final id = faker.guid.guid();
@protected
final StreamController<Stock> stockStreamController =
StreamController.broadcast();
Stream<Stock> get stockStream => stockStreamController.stream;
void update(Stock stock);
}

Publisher: Interface StockTicker

base class StockTicker {
late final String title;
late final Timer stockTimer;

@protected
Stock? stock;

final _subscribers = <StockSubscriber>[];

void subscribe(StockSubscriber subscriber) => _subscribers.add(subscriber);

void unsubscribe(StockSubscriber subscriber) =>
_subscribers.removeWhere((s) => s.id == subscriber.id);

void notifySubscribers() {
for (final subscriber in _subscribers) {
subscriber.update(stock!);
}
}

void setStock(StockTickerSymbol stockTickerSymbol, int min, int max) {
final lastStock = stock;
final price = faker.randomGenerator.integer(max, min: min) / 100;
final changeAmount = lastStock != null ? price - lastStock.price : 0.0;

stock = Stock(
changeAmount: changeAmount.abs(),
changeDirection: changeAmount > 0
? StockChangeDirection.growing
: StockChangeDirection.falling,
price: price,
symbol: stockTickerSymbol,
);
}

void stopTicker() => stockTimer.cancel();
}
  • GameStopStockTicker | GoogleStockTicker | TeslaStockTicker
// GameStop
final class GameStopStockTicker extends StockTicker {
GameStopStockTicker() {
title = StockTickerSymbol.GME.name;
stockTimer = Timer.periodic(
const Duration(seconds: 2),
(_) {
setStock(StockTickerSymbol.GME, 16000, 22000);
notifySubscribers();
},
);
}
}
// Google
final class GoogleStockTicker extends StockTicker {
GoogleStockTicker() {
title = StockTickerSymbol.GOOGL.name;
stockTimer = Timer.periodic(
const Duration(seconds: 5),
(_) {
setStock(StockTickerSymbol.GOOGL, 200000, 204000);
notifySubscribers();
},
);
}
}
// Tesla
final class TeslaStockTicker extends StockTicker {
TeslaStockTicker() {
title = StockTickerSymbol.TSLA.name;
stockTimer = Timer.periodic(
const Duration(seconds: 3),
(_) {
setStock(StockTickerSymbol.TSLA, 60000, 65000);
notifySubscribers();
},
);
}
}

ConcreteSubscribers B: DefaultStockSubscriber | GrowingStockSubscriber

// Default
class DefaultStockSubscriber extends StockSubscriber {
DefaultStockSubscriber() {
title = 'All stocks';
}

@override
void update(Stock stock) {
stockStreamController.add(stock);
}
}

// Growing
class GrowingStockSubscriber extends StockSubscriber {
GrowingStockSubscriber() {
title = 'Growing stocks';
}

@override
void update(Stock stock) {
if (stock.changeDirection == StockChangeDirection.growing) {
stockStreamController.add(stock);
}
}
}

Client:

Extra: StockSubscriberSelection

class StockSubscriberSelection extends StatelessWidget {
final List<StockSubscriber> stockSubscriberList;
final int selectedIndex;
final ValueSetter<int?> onChanged;

const StockSubscriberSelection({
required this.stockSubscriberList,
required this.selectedIndex,
required this.onChanged,
});

@override
Widget build(BuildContext context) {
return Column(
children: <Widget>[
for (final (i, subscriber) in stockSubscriberList.indexed)
RadioListTile(
title: Text(subscriber.title),
value: i,
groupValue: selectedIndex,
selected: i == selectedIndex,
activeColor: Colors.black,
onChanged: onChanged,
),
],
);
}
}

Extra: StockTickerSelection

class StockTickerSelection extends StatelessWidget {
final List<StockTickerModel> stockTickers;
final ValueChanged<int> onChanged;

const StockTickerSelection({
required this.stockTickers,
required this.onChanged,
});

@override
Widget build(BuildContext context) {
return Row(
children: <Widget>[
for (final (i, stockTickerModel) in stockTickers.indexed)
Expanded(
child: _TickerTile(
stockTickerModel: stockTickerModel,
index: i,
onChanged: onChanged,
),
),
],
);
}
}

class _TickerTile extends StatelessWidget {
final StockTickerModel stockTickerModel;
final int index;
final ValueChanged<int> onChanged;

const _TickerTile({
required this.stockTickerModel,
required this.index,
required this.onChanged,
});

@override
Widget build(BuildContext context) {
return Card(
color: stockTickerModel.subscribed ? Colors.black : Colors.white,
child: InkWell(
onTap: () => onChanged(index),
child: Padding(
padding: const EdgeInsets.all(32),
child: Text(
stockTickerModel.stockTicker.title,
textAlign: TextAlign.center,
style: TextStyle(
color: stockTickerModel.subscribed ? Colors.white : Colors.black,
),
),
),
),
);
}
}

Extra: StockRow

import 'package:flutter/material.dart';

import '../../constants.dart';
import '../../domain/stock.dart';


class StockRow extends StatelessWidget {
const StockRow({
required this.stock,
});

final Stock stock;

Color get color => stock.changeDirection == StockChangeDirection.growing
? Colors.green
: Colors.red;

@override
Widget build(BuildContext context) {
return Row(
children: [
SizedBox(
width: 42 * 2,
child: Text(
stock.symbol.name,
style: TextStyle(color: color),
),
),
const SizedBox(width: 32),
SizedBox(
width: 42 * 2,
child: Text(
stock.price.toString(),
style: TextStyle(color: color),
textAlign: TextAlign.end,
),
),
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 32,
),
child: Icon(
stock.changeDirection == StockChangeDirection.growing
? Icons.arrow_upward
: Icons.arrow_downward,
color: color,
),
),
Text(
stock.changeAmount.toStringAsFixed(2),
style: TextStyle(color: color),
),
],
);
}
}

ObserverExample:

class ObserverExample extends StatefulWidget {
const ObserverExample();

@override
_ObserverExampleState createState() => _ObserverExampleState();
}

class _ObserverExampleState extends State<ObserverExample> {
final _stockSubscriberList = <StockSubscriber>[
DefaultStockSubscriber(),
GrowingStockSubscriber(),
];
final _stockTickers = <StockTickerModel>[
StockTickerModel(stockTicker: GameStopStockTicker()),
StockTickerModel(stockTicker: GoogleStockTicker()),
StockTickerModel(stockTicker: TeslaStockTicker()),
];
final _stockEntries = <Stock>[];

StreamSubscription<Stock>? _stockStreamSubscription;
StockSubscriber _subscriber = DefaultStockSubscriber();
var _selectedSubscriberIndex = 0;

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

_stockStreamSubscription = _subscriber.stockStream.listen(_onStockChange);
}

@override
void dispose() {
for (final ticker in _stockTickers) {
ticker.stockTicker.stopTicker();
}

_stockStreamSubscription?.cancel();

super.dispose();
}

void _onStockChange(Stock stock) => setState(() => _stockEntries.add(stock));

void _setSelectedSubscriberIndex(int? index) {
for (final ticker in _stockTickers) {
if (ticker.subscribed) {
ticker.toggleSubscribed();
ticker.stockTicker.unsubscribe(_subscriber);
}
}

_stockStreamSubscription?.cancel();

setState(() {
_stockEntries.clear();
_selectedSubscriberIndex = index!;
_subscriber = _stockSubscriberList[_selectedSubscriberIndex];
_stockStreamSubscription = _subscriber.stockStream.listen(_onStockChange);
});
}

void _toggleStockTickerSelection(int index) {
final stockTickerModel = _stockTickers[index];
final stockTicker = stockTickerModel.stockTicker;

if (stockTickerModel.subscribed) {
stockTicker.unsubscribe(_subscriber);
} else {
stockTicker.subscribe(_subscriber);
}

setState(() => stockTickerModel.toggleSubscribed());
}

@override
Widget build(BuildContext context) {
return ScrollConfiguration(
behavior: const ScrollBehavior(),
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(
horizontal: LayoutConstants.paddingL,
),
child: Column(
children: <Widget>[
StockSubscriberSelection(
stockSubscriberList: _stockSubscriberList,
selectedIndex: _selectedSubscriberIndex,
onChanged: _setSelectedSubscriberIndex,
),
StockTickerSelection(
stockTickers: _stockTickers,
onChanged: _toggleStockTickerSelection,
),
Column(
children: [
for (final stock in _stockEntries.reversed)
StockRow(stock: stock),
],
),
],
),
),
);
}
}

y YA!

Otros articulos para esta serie

Creacionales:

Estructurales:

De Comportamiento:

Tu contribución

👏 ¡Presiona el botón de aplaudir a continuación para mostrar tu apoyo y motivarme a escribir mejor!
💬 Deje una respuesta a este artículo brindando sus ideas, comentarios o deseos para la serie.
📢 Comparte este artículo con tus amigos y colegas en las redes sociales.
➕ Sígueme en Medium.
⭐ Ve los ejemplos prácticos desde mi canal en Youtube: Pacha Code

--

--