Uso de Build context en Flutter desde funciones asíncronas

martes, 25 de octubre de 2022
Tiempo de lectura 8 minutos

Nota: Todos los vínculos que he puesto en esta publicación se encuentran en inglés ya que al parecer la mayor parte de la documentación de flutter no ha sido traducida.

Hace meses, inicié con la aventura de aprender un poco de Dart y Flutter para investigar sobre las posibilidades de ambos, lenguaje y framework, al crear aplicaciones multiplataforma, aunque principalmente centradas en móviles. La idea de estos artículos, que imagino serán mucho más breves de lo habitual, no es otra que la de ir poniendo por aquí cosas que me he ido encontrando a lo largo del tiempo y que no he visto del todo claras en la documentación oficial, que por cierto es muy buena. Como tengo una rara tendencia a olvidar un montón de cosas, creo que esto le podría ser muy útil a mi yo del futuro.

Hoy he encontrado la solución a algo que quizá es muy simple, pero llevaba un día dando problemas. Resulta que, para algo de código que había estado haciendo como parte de una aplicación de uso interno, tenía código que mostraba diálogos (de información y de confirmación). Esos diálogos se mostraban gracias a una función que mostraba un diálogo informativo, y otra que mostraba un diálogo de confirmación. El problema era que en Flutter necesitas pasar un atributo llamado Build Context para que el constructor (la función showDialog) pueda generar el diálogo y mostrarlo en pantalla.

Ayer, luego de pasar la herramienta del propio lenguaje para depurar código, me ha aparecido un mensaje que indicaba, básicamente, que debería evitar pasar un Build Context desde una función asíncrona.

Esto ponía las cosas difíciles, pues para no hacer el texto tan largo, básicamente mi aplicación necesita hacer llamadas a una API, para lo que Flutter recomienda utilizar funciones asíncronas. Pero después de hacer la llamada a la API, también necesitaba mostrar el resultado de esa llamada, para lo que Flutter recomienda que si pienso usar un diálogo, no debería utilizar funciones asíncronas. Así que la cosa estaba en que necesito usar funciones asíncronas para que se pueda llamar a la API (que tiene respuestas que pueden demorar un par de segundos, por lo que si no la llamo de forma asíncrona Flutter penaliza eso con tiempo de bloqueo de la app), pero también necesito pasar el Build Context a la función que crea los diálogos, y justamente el Build Context ni siquiera sería recomendable recibirlo desde una función asíncrona.

¿Qué es el Build Context?

Flutter funciona gracias a varias capas de abstracción. En términos generales, se compone de 3 importantes capas que se relacionan entre sí para formar el estado de una aplicación.

  • Widgets: Los controles de alto Nivel son llamados Widgets. Normalmente un widget puede ser entendido como un botón, una etiqueta de texto simple, incluso una aplicación se define como un tipo específico de Widget. A Flutter le resulta fácil crear Widgets, es por eso que cuando hay una actualización (por ejemplo, un contador aumenta de número o hay nueva información disponible en un texto), Flutter redibuja todo el Widget (pero únicamente el widget actualizado) o reconstruye su posición en el árbol de widgets. Los widgets son baratos de crear y son inmutables, es por eso que se reconstruyen al actualizar su estado. Algunos Widgets pueden tener una parte mutable, que puede cambiar (por ejemplo, almacenar variables dentro de la parte mutable de un widget y poder cambiarlas en la ejecución), pero eso es otra cuestión. Cuando un Widget es creado, Flutter crea automáticamente un objeto Render y un element y los coloca respectivamente en el árbol de Widgets, en el de elements y en el de render objects. Un dato curioso de los Widgets es que no saben nunca su posición ni sus propiedades con respecto a otros widgets.
  • Render Object: Este objeto es creado como parte de cualquier widget y normalmente no se interactúa directamente con él. Representa toda la parte de ubicación y realiza los cálculos para que el Widget se coloque correctamente en pantalla en las posiciones correspondientes. Es mutable y se suele actualizar (asociándolo con un nuevo Widget, si su widget asociado es reconstruido) o puede ser eliminado. A diferencia de los Widgets, los render objects son muy costosos de crear y normalmente Flutter intentará reciclarlos lo más que se pueda.
  • element object: Los elements son, se podría decir, el pegamento que permite unir un widget con su render object. Normalmente flutter encapsula los elements en un objeto que se utiliza para muchas otras cosas, llamado Build Context. Al crear un Widget nuevo, Flutter asigna, dentro de su función build (obligatoria para todo widget), un nuevo Build Context. El Build Context contiene la configuración del Widget (el objeto element) y permite ubicarlo con respecto a otros widgets y dar una posición de pantalla. También se utiliza para acceder al objeto navigator, muy útil al usar funciones que envíen a otras pantallas en una aplicación, y puede ser usado también para encontrar widgets anteriores en el árbol, por ejemplo.

Un poco de código de ejemplo

Esto es el código de una aplicación Flutter muy básica que muestra un diálogo dentro de una función asíncrona:

import 'package:flutter/material.dart';

void main() => runApp(
  MaterialApp(
    home: HomePage(),
  ),
);

class HomePage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(),
      body: SafeArea(
        child: Center(
          child: Text('test')
        )
      ),
      floatingActionButton: FloatingActionButton(
        child: Text("Click here"),
        onPressed: () => showMyDialog(context),
      ),
    );
  }
}

void showMyDialog(BuildContext context) async {
  showDialog(
    context: context,
    builder: (context) => Center(
      child: Material(
        color: Colors.transparent,
        child: Text('Hello'),
      ),
    )
  );
}

En esta aplicación lo que pasa es muy sencillo. El Widget Home, al que se llama como parte de una MaterialApp, muestra un botón que al ser pulsado mostrará un diálogo. Este diálogo se ve perfectamente, pero es llamado dentro de una función asíncrona. En este ejemplo, la función se puede cambiar sin problema (no hace nada que requiera que sea asíncrona), pero en el mundo real, puede ser la carga de una API, el procesamiento de información en un equipo, etc.

Esto ha empezado a aparecer como error en el Linter de dart y aunque ahora mismo aparece como experimental, se recomienda evitar pasar el Build Context a funciones asíncronas ya que esto aparentemente es la causa de muchos fallos y es algo difícil de diagnosticar. Lo que recomiendan no es necesariamente la solución que he decidido utilizar, sino he preferido definir una clave para el objeto navegador.

Usando navigatorKey

El objeto navigator nos permite, pues, navegar mediante las diferentes pantallas de la aplicación actual. A esta serie de pantallas se les llama stack, ya que se pueden imaginar como si una pantalla se va superponiendo a otra, y una más nueva se coloca justo arriba de las demás, y así sucesivamente. De ahí que cuando nos movamos hacia atrás, por ejemplo en Android, el Stack vaya removiendo una pantalla tras otra hasta llegar al principio de nuestra aplicación. El navegador tiene formas para incluir nuevas pantallas al principio del Stack (lo que hace que se muestren), eliminar la última pantalla añadida (lo que hace que un diálogo o ventana se cierre), o conseguir alguna información del widget que se ubica en primer plano, como su Build Context. Esto es importante ya que a nuestro diálogo le tenemos que pasar el Build Context del Widget que lo llamó, y de ningún otro, ya que si no se hace así es probable que no se pueda mostrar correctamente.

Ahora bien, para poder acceder a estas propiedades del objeto navigator, es necesario registrarlo de manera global. Para esta clase de cosas, Flutter utiliza las claves o “keys”. Un widget, el que sea, al ser creado, recibe una clave. Si el widget no será reutilizado o no nos interesa acceder al mismo Widget, validar su contenido o hacer nada con él, no necesitamos tener idea de la clave que tiene cada Widget. Pero hay ocasiones, como ahora, en la que necesitamos mantener la posibilidad de acceder a este Widget desde otras partes de la aplicación. Para esto, definiremos un tipo especial de clave, llamado navigatorKey, que nos permitirá, bien definido y pasado como parámetro al crear nuestra MaterialApp, acceder al navegador desde donde sea que nos encontremos. Para llevar a cabo este proceso, hay qué hacer lo siguiente:

  1. Crea otra librería en el paquete donde reside la app Flutter, por ejemplo un fichero llamado navigator_key.dart. No olvides que la librería tendría que estar ubicada dentro del directorio lib de cualquier paquete de aplicación.
  2. Agrega este contenido al nuevo archivo:
import 'package:flutter/material.dart';  

final GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>();
  1. En el fichero donde se cree el objeto material app, importa el nuevo archivo usando “import ‘package:myapp/navigator_key.dart’;”, si el nombre de tu paquete en dart fuera myapp.
  2. Pasa la clave como argumento al crear la MaterialApp:
void main() => runApp(
  MaterialApp(
    home: HomePage(),
    navigatorKey: navigatorKey,
  ),
);
  1. Ahora, siempre que necesites un objeto de tipo Build Context, asegúrate de tener la librería navigator_key importada y podrás acceder al objeto navigatorKey.currentContext.

Código correcto

finalmente, aquí se muestra una versión del fichero main.dart, suponiendo que ya se ha guardado la librería navigator_key.dart y que el paquete se llama prueba.

import 'package:flutter/material.dart';
import 'package:prueba/navigator_key.dart';

void main() => runApp(
  MaterialApp(
    home: HomePage(),
    navigatorKey: navigatorKey,
  ),
);

class HomePage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(),
      body: SafeArea(
        child: Center(
          child: Text('test')
        )
      ),
      floatingActionButton: FloatingActionButton(
        child: Text("Click here"),
        onPressed: () => showMyDialog(),
      ),
    );
  }
}

void showMyDialog() async {
  showDialog(
    context: navigatorKey.currentContext!,
    builder: (context) => Center(
      child: Material(
        color: Colors.transparent,
        child: Text('Hello'),
      ),
    )
  );
}

Este código ya permite usarse en funciones asíncronas sin problemas, sin la necesidad de recurrir a otro tipo de librerías o paquetes de terceros para hacer justamente que el último Build Context se encuentre disponible de manera global.

Programación

BuildContext async Flutter Dart