Clases abstractas en StatefulWidget y su uso mediante la herencia en Flutter

- Andrés Cruz - EN In english

Video thumbnail

Te voy a mostrar cómo puedes implementar la herencia de clases, específicamente clases abstractas, sobre un StatefulWidget en Flutter con Dart y esto es para seguir las buenas prácticas como lo son, el uso de patrones de diseño como el Observer en Flutter.

Lo interesante o lo diferente respecto a la herencia tradicional es que, en Flutter, tenemos dos clases:

  • La clase del StatefulWidget.
  • La clase del estado, que se va actualizando cada vez que hacemos el setState.

Esto es lo que hace interesante cómo podemos heredar esta estructura y cómo debemos formarla.

Comparación con la herencia tradicional

A diferencia del esquema tradicional, aquí le pedí a Gemini que me generara un ejemplo sencillo.
En cualquier lenguaje orientado a objetos, esto se vería así:

abstract class Animal {
  void hacerSonido(); 

  void moving() {
    print('Moving');
  }
}

class Dog extends Animal {
  @override
  void sound() {
    print('Dog: Guau!');
  }
}

class Cat extends Animal {
  @override
  void sound() {
    print('Cat: Miau!');
  }
}

Tenemos una clase marcada como abstracta.

Definimos un método obligatorio y otros métodos que no necesariamente debemos sobrescribir.

Cuando heredamos, usamos override solo en lo que queremos modificar; lo demás queda tal cual:

  @override
  void sound() {
    print('Cat: Miau!');
  }

Este caso es sencillo porque se trata de una herencia simple.
Pero en el StatefulWidget, recordemos que la estructura es distinta, como veremos a continuación.

Clase abstracta para los StatefulWidget

Esto forma parte de la aplicación de academia, concretamente del visor, donde tenemos dos bloques principales:

  • El panel de opciones (fullscreen, notas, zoom, etc.).
  • El contenido (en un visor nativo o en un visor web con WebView).
    • El visor nativo traduce elementos HTML a widgets directamente, mientras que el visor web carga HTML dentro de un WebView.

Ambos comparten características en común, y por eso conviene usar una estructura heredada con clases abstractas.

Definición de la clase base

abstract class BaseVisorPage extends StatefulWidget {
  final BookModel book;

  const BaseVisorPage({super.key, required this.book});

  // @override
  // BaseVisorPageState createState();
}

abstract class BaseVisorPageState<T extends BaseVisorPage> extends State<T> {
  // modo pantalla completo
  bool fullScreen = false;

  ***
  // other properties

  late AppModel appModel;

  // Estos métodos deben ser implementados por las subclases
  // opciones de la barra
  Widget buildBarOptions();
  // titulos del libro
  Widget buildOptionsAndTitlesSection(BuildContext context);

  @override
  Widget build(BuildContext context) {
    Size size = MediaQuery.of(context).size;

    return CustomScaffold(
      titleAppBar,
      Stack(
        children: [
          ***
        ],
      ),
    );
  }

  // Trae la sección del libro activa
  getSectionActive() async {
    // seccion del libro
    bookSectionModel = await AcademyHelper.getSectionByBook(
      widget.book.id,
      bookSectionActive,
      sizeText.toInt(),
      appModel.userToken,
    );
  }

  @override
  void dispose() {
    ***
    super.dispose();
  }
}

Primero, creamos la clase BaseVisorPage, que hereda de StatefulWidget.

Esta clase se define como abstracta porque:

  1. No quiero que se pueda instanciar directamente.
  2. Solo quiero conservar la estructura, no el comportamiento completo.

Luego, en el estado, definimos la clase BaseVisorPageState, también abstracta.
Aquí es donde entra lo interesante:

  • Usamos genéricos (T) para indicar que el estado depende de la clase padre.
  • T puede ser cualquier cosa, pero debe heredar de BaseVisorPage, ya que necesitamos que corresponda al mismo widget.
  • Con esto logramos que la estructura de State<T> se mantenga correctamente.
  • Implementación de visores concretos.

Ahora, su uso es muy sencillo y debemos de implementar los métodos que no se encuentran definidos como buildBarOptions() y buildOptionsAndTitlesSection(); también, podemos aprovechar comportamientos comunes entre estas clases como lo seria, obtener la fuente de datos mediante el método getSectionActive():

class VisorPageWeb extends BaseVisorPage {
  static const ROUTE = "/visor";

  const VisorPageWeb({required super.book});

  @override
  State<VisorPageWeb> createState() => _VisorPageWebState();
}

class _VisorPageWebState extends BaseVisorPageState<VisorPageWeb> {
  WebViewController? _webViewController;

  @override
  void initState() {
    super.initState();
    appModel = Provider.of<AppModel>(context, listen: false);
    ***
  }
  
  @override
  SingleChildScrollView buildOptionsAndTitlesSection(BuildContext context) {
    return SingleChildScrollView(
      child: Card(
        margin: const EdgeInsets.symmetric(vertical: 12, horizontal: 8),
        ***
  }
}  

Podemos sobrescribir métodos para aprovechar su funcionamiento actual y agregar (en este ejemplo, obtener datos) y agregar lógica adicional:

@override
getSectionActive() async {
 await super.getSectionActive();
 htmlNoteBook = await AcademyHelper.getBooksNoteGet(
   // bookSectionActive,
   bookSectionModel.id,
   appModel.userToken,
 );
 titleAppBar = bookSectionModel.title;
 if (bookSectionActive == 0 && widget.book.image != '') {
   // image = '${BlogHelper.baseUrlBookImage}${book.path}/${book.image}';
   bookSectionModel.content =
       '<img src="${BlogHelper.baseUrlBookImage}${widget.book.path}/${widget.book.image}" />${bookSectionModel.content}';
 }
}

Para eso, hacemos llamados a la super clase, para invocar ya sea propiedades:

super.book

Como en este ejemplo es el de la propiedad libro, necesaria para poder crear instancias de estas clases, o del método mencionado:

await super.getSectionActive();

@override
getSectionActive() async {
  await super.getSectionActive();

  htmlNoteBook = await AcademyHelper.getBooksNoteGet(
    // bookSectionActive,
    bookSectionModel.id,
    appModel.userToken,
  );

  ***
}

Ventajas del esquema

La gran ventaja es que:

  • Tenemos una estructura modularizada.
  • Reutilizamos código común entre visores.
  • Podemos mantener comportamientos compartidos (ejemplo: getSectionActive) y sobrescribir solo lo que cambia.
  • Así, cada visor mantiene sus particularidades, pero ambos heredan la base necesaria para funcionar.

Las 2 claves en la Modularización en Flutter, Clases y Métodos por parámetro

Video thumbnail

Quiero ofrecerte una clase que considero muy interesante. Cuando desarrollamos en Flutter, que es intrínsecamente modular (ya que todo en Flutter es un widget), podemos caer en la falsa creencia de que nuestra aplicación, por pequeña o mediana que sea, estará modularizada automáticamente.

Según mi juicio, es más bien todo lo contrario. Dada la facilidad con la que, por ejemplo, Visual Studio Code nos permite empezar a anidar widgets, podemos romper fácilmente la modularización de nuestra aplicación. Esto, como bien sabes, genera problemas graves: perdemos escalabilidad, mantenimiento, y el código se convierte en un código espagueti.

Si esto es para un cliente final que planea seguir desarrollando la aplicación, el siguiente desarrollador no entenderá nada de lo que estamos haciendo, ya que el código es muy difícil de seguir.

Primer Consejo: Métodos vs. Clases para Devolver Widgets

Este es el contexto de por qué es crucial modularizar. Aquí te daré mi primer consejo práctico: cuándo emplear métodos y cuándo emplear clases (como un StatelessWidget) para devolver contenido.

Yo emplearía métodos para devolver un widget si el componente es extremadamente sencillo. Por ejemplo, para un componente de publicaciones simple como el que tenemos:

  • Tenemos una imagen.
  • Tenemos un texto.
  • Tenemos el título.

En este caso, emplearía perfectamente un método para reutilizar ese widget fácilmente.

Modularización de clases

El widget que vamos a analizar, es el visor web de la app de Academia para Flutter/Android, la cual tiene los siguientes elementos, es decir, lo primero que debemos hacer en estos casos es dividir funcionalmente lo que queramos implementar, en este ejemplo, un visor nativo con opciones de selección de texto y crear notas.

  • Traducir HTML: HtmlToWidgets
  • Selección de textos y mostrar burbuja: CustomTextSelectionOverlay
  • Creación de notas: CreateBookNoteForm

Estos son los módulos que tienen nuestra app, y los módulos tienen que recibir los datos justamente necesarios. Te dejo mi implementación en base a clases:

HtmlToWidgets(
                            html: bookSectionModel.content,
                            sizeFactText: sizeText,
                            htmlNoteBook: htmlNoteBook,
                            callback: noteAdd,
                            setKeys: setKeys,
                          ),
***
class HtmlToWidgets extends StatelessWidget {
  ***
  /*const*/
  HtmlToWidgets({
    super.key,
    required this.html,
    required this.sizeFactText,
    this.callback,
    this.setKeys,
    this.htmlNoteBook = const {},
  });
***
_hightlightText(
                "   ${element.text}",
                element.attributes['id'] ?? idChild2,
                16 * sizeFactText,
                context,
                htmlNoteBook[element.attributes['id'] ?? idChild2],
                'SUSE'
              ),
***
Widget _hightlightText(
    String text,
    String idHTML,
    double fontSize,
    BuildContext context,
    HtmlNoteModel? htmlNoteBook, [
    String fontFamily = 'IBMPlexMono',
  ]) {
    ***
        if (tokenText == "") {
          return CustomTextSelectionOverlay(
            text,
            tokenText,
            idHTML,
            fontSize,
            callback!,
            // () => callback(id, tokenText),
          );
        }
      }
}
class CustomTextSelectionOverlay extends StatefulWidget {
  ***
}

class _CustomTextSelectionOverlayState
    extends State<CustomTextSelectionOverlay> {
  ***
                            child: CreateBookNoteForm(saveNote),

Como puedes ver en el código anterior, lo importante es ver las firmas de las clases recibe lo necesario para poder realizar su trabajo, y para las operaciones finales, se envía un la función como parámetro.

Un ejemplo de una implementación errónea, en donde una función recibe parámetros que NO va a usar directamente:

List<Widget> showSectionBookByHTML(
  String html,
  String tokenUser,
  int bookSectionId,
  Function noteAdd,
  BuildContext context, [
  Map<String, BookSectionNoteModel> bookSectionNotesModel = const {},
  double sizeText = 1.0,
]) {

En el ejemplo anterior, que es una implementación errónea, al no emplear clases no solamente se complica su gestión, al tener un montón de clases en un mismo nivel con distintos nombres, si no, también aquí están recibiendo parámetros que no son empleadas justamente por la función; por ejemplo la de notas del usuario.

La función lo que hace es mostrar el contenido HTML, pero, las comentarios del usuario en las notas NO es una funcionalidad directa del visor, es un anexo u opcional de un visor nativo.

Gracias al a modulaoización con clases, pasamos como parámetros los métodos:

HtmlToWidgets(
                            html: bookSectionModel.content,
                            sizeFactText: sizeText,
                            htmlNoteBook: htmlNoteBook,
                            callback: noteAdd,
                            setKeys: setKeys,
                          ),

Conclusión

Este esquema de clases abstractas en StatefulWidget permite crear componentes más claros, reutilizables y escalables.

En mi caso lo aplico al visor de libros en la aplicación de academia, donde necesito:

  • Controlar secciones, notas, zoom, fullscreen, etc.
  • Compartir lógica entre visores nativo y web.

Ya que, si haz empleado la app, veras que cuando se emplea el visor nativo, existen más opciones sobre los widgets como variar el tamaño o seleccionar texto, a diferencia del visor HTML, pero, por más que sea, ambos tienen una misma estructura y comportamientos en comunes.

Siguiente tarea, te doy mi opinión de que es mejor, métodos vs clases para definir widgets en Flutter.

Te muestro como implementar las dos clases abstractas que conforman el StatefulWidget y su uso mediante la herencia.


Únete a la comunidad de desarrolladores que han decidido dejar de picar código y empezar a construir productos reales. Recibe mis mejores trucos de arquitectura cada semana:

Acepto recibir anuncios de interes sobre este Blog.