wandres.dev
WIDGETS CON GLANCE · fuera de la app

Qué es un widget: tu interfaz ejecutándose en un proceso ajeno

Un widget de pantalla de inicio parece una parte más de la aplicación, pero es lo contrario: es una descripción de interfaz que tu proceso fabrica, serializa y entrega al lanzador para que este la infle y la pinte dentro de su propia jerarquía de vistas y con su propio ciclo de vida. Esta lección desmonta esa arquitectura pieza a pieza —el proveedor que en realidad es un receptor de emisiones, el gestor que hace de intermediario, y el objeto RemoteViews que viaja por IPC— y deriva de ella el inventario completo de limitaciones que todo el resto del nivel dará por supuesto: el catálogo cerrado de vistas permitidas, la imposibilidad de vistas propias, el límite de la transacción de Binder, la ausencia de manejadores de eventos ordinarios y el periodo mínimo de actualización que el sistema impone.

⏱ 18 min

Durante veintisiete niveles todo lo que has dibujado ocurría dentro de tu proceso: tú creabas los objetos, tú los medías, tú los pintabas y tú recibías los eventos que provocaban. Un widget de pantalla de inicio rompe esa unidad de un golpe. La superficie sobre la que aparece pertenece al lanzador, un proceso distinto, escrito por otro equipo, con su propia memoria, su propio hilo principal y su propia idea de cuándo merece la pena redibujar algo. Tu aplicación no dibuja ahí: describe. Fabrica un objeto que enumera qué vistas hay, con qué texto, qué imagen y qué acciones, lo serializa, lo entrega al sistema y se calla. Lo que el usuario ve es esa descripción reconstruida por un desconocido. Toda la incomodidad de esta tecnología, todas sus limitaciones aparentemente arbitrarias y toda la disciplina que exige nacen de esa única frontera entre procesos, y quien la interioriza deja de pelear con la API y empieza a diseñar contra ella.

🎯 Al terminar esta lección sabrás
  • Situar los cuatro actores de un widget: aplicación, sistema, lanzador anfitrión y usuario.
  • Entender RemoteViews como descripción serializable y no como jerarquía de vistas.
  • Declarar un proveedor y sus metadatos, y reconocer que es un receptor de emisiones.
  • Derivar el inventario de limitaciones a partir de la frontera entre procesos.

Cuatro actores y una frontera

El primer actor es tu aplicación, que aporta el proveedor: una clase que hereda de AppWidgetProvider y que, contra toda intuición, no es una vista ni un controlador, sino un BroadcastReceiver. Vive lo que dura la atención de una llamada y muere. El segundo es el sistema, representado por AppWidgetManager, que mantiene el registro de qué widgets existen, a qué identificadores corresponden y quién los aloja. El tercero es el anfitrión, casi siempre el lanzador, que reserva un hueco en su cuadrícula y se compromete a inflar lo que reciba. El cuarto es el usuario, que coloca, redimensiona y toca.

Lo importante es que entre el primero y el tercero no hay memoria compartida, ni referencias, ni retorno de llamada directo. Solo hay mensajes.

sequenceDiagram
participant U as Usuario
participant L as Lanzador anfitrion
participant S as AppWidgetManager
participant A as Tu proceso
U->>L: Coloca el widget en la cuadricula
L->>S: Reserva un identificador
S->>A: Emision de actualizacion al proveedor
A->>A: Construye la descripcion de interfaz
A->>S: Entrega el objeto serializado
S->>L: Reenvia la descripcion
L->>L: Infla las vistas en su jerarquia
L->>U: Pinta el resultado

Merece la pena fijarse en quién falta en ese reparto: no hay ningún objeto que represente al widget dentro de tu aplicación. Lo único que existe por tu parte es un identificador entero que el sistema te entrega y que designa una instancia colocada en algún anfitrión. No puedes preguntarle nada a ese identificador salvo a través del gestor, no puedes conservarlo indefinidamente porque se recicla cuando el usuario retira el widget, y no puedes suponer que corresponde a algo visible, porque el anfitrión pudo haberlo apartado a una página del escritorio que nadie mira desde marzo.

Cada flecha de ese diagrama que cruza de tu proceso hacia fuera es una transacción de Binder, y cada transacción tiene un coste, un límite de tamaño y una latencia. Cuando más adelante te preguntes por qué no puedes pasar un mapa de bits enorme, por qué no puedes actualizar cada segundo o por qué no puedes registrar un escuchador de desplazamiento, la respuesta estará siempre en alguna de esas flechas.

RemoteViews: una interfaz que cabe en un mensaje

RemoteViews no contiene vistas. Contiene la identidad de un recurso de diseño y una lista ordenada de operaciones diferidas del tipo aplica este texto a la vista con este identificador. El anfitrión recibe ese paquete, infla el diseño usando los recursos de tu paquete y ejecuta las operaciones una por una. De ahí se sigue una propiedad que conviene enunciar sin rodeos: el anfitrión ejecuta código de dibujo del sistema sobre recursos tuyos, pero jamás ejecuta código tuyo.

class MiProveedor : AppWidgetProvider() {

    override fun onUpdate(
        context: Context,
        manager: AppWidgetManager,
        ids: IntArray,
    ) {
        val vistas = RemoteViews(context.packageName, R.layout.widget_contador)
        vistas.setTextViewText(R.id.titulo, "Pendientes")
        vistas.setTextViewText(R.id.valor, contarPendientes(context).toString())
        ids.forEach { id -> manager.updateAppWidget(id, vistas) }
    }
}

El registro se hace en el manifiesto y añade un fichero de metadatos que el sistema lee antes incluso de que tu proceso arranque, porque el selector de widgets tiene que mostrar tamaño, previsualización y descripción sin ejecutarte.

<receiver
    android:name=".MiProveedor"
    android:exported="true">
    <intent-filter>
        <action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
    </intent-filter>
    <meta-data
        android:name="android.appwidget.provider"
        android:resource="@xml/mi_widget_info" />
</receiver>
ℹ️
El proveedor no es el widget: es quien responde por él

Resulta tentador pensar en AppWidgetProvider como en una Activity diminuta y guardar estado en sus campos. No lo hagas. Es un receptor de emisiones y obedece sus reglas: se instancia para atender una llamada, dispone de unos pocos segundos de hilo principal y desaparece. Cualquier cosa que sobreviva entre actualizaciones debe estar en almacenamiento persistente, y cualquier trabajo que tarde debe delegarse. Un proveedor que consulta la red desde onUpdate es un error de diseño que además puede terminar en aplicación no responde.

El ciclo de vida de una instancia

El proveedor no recibe una sola llamada sino un repertorio, y cada una marca un momento distinto del ciclo de vida de las instancias que el usuario coloca. Conviene conocerlo entero porque casi todos los errores de recursos huérfanos vienen de ignorar la mitad.

class MiProveedor : AppWidgetProvider() {

    override fun onEnabled(context: Context) { /* primera instancia colocada */ }

    override fun onUpdate(context: Context, m: AppWidgetManager, ids: IntArray) { }

    override fun onAppWidgetOptionsChanged(
        context: Context,
        m: AppWidgetManager,
        id: Int,
        opciones: Bundle,
    ) { /* el usuario redimensiono esta instancia */ }

    override fun onDeleted(context: Context, ids: IntArray) { /* limpia su estado */ }

    override fun onDisabled(context: Context) { /* ya no queda ninguna */ }
}

La distinción entre la primera y la última llamada es la que ordena el trabajo periódico: registrar un trabajador cuando aparece la primera instancia y cancelarlo cuando desaparece la última evita el caso clásico de una aplicación que sigue despertándose cada cuarto de hora meses después de que el usuario retirase el widget del escritorio. La llamada de borrado es igual de importante porque el estado asociado a un identificador que ya no existe se queda para siempre en el disco si nadie lo elimina, y esos identificadores se reciclan.

Hay además un momento anterior a todos: la actividad de configuración. Si el widget necesita que el usuario elija algo antes de mostrar nada, se declara una actividad que el anfitrión lanza en el instante de la colocación y cuyo resultado decide si la instancia se conserva o se descarta. Cancelarla debe dejar el escritorio como estaba, y devolver un resultado correcto es obligatorio para que el widget llegue a aparecer.

El inventario de limitaciones

Todas las restricciones siguientes son consecuencias mecánicas de la frontera. Ninguna es caprichosa y ninguna va a desaparecer.

📋

Catálogo cerrado de vistas

El anfitrión solo sabe inflar un conjunto fijo anotado como remoto: contenedores lineales y relativos, texto, imagen, botones, progreso y poco más. Una vista propia no existe fuera de tu proceso y por tanto no puede aparecer.

📦

Límite de la transacción

Todo el paquete viaja por Binder, cuyo búfer ronda un megabyte compartido por proceso. Un mapa de bits grande incrustado revienta la transacción y el widget se queda en blanco o muestra el error del anfitrión.

👆

Solo intents pendientes

No hay escuchadores de clic ni gestos. Un toque solo puede disparar un PendingIntent, que es la única forma de darle a otro proceso permiso para actuar en tu nombre más tarde.

⏱️

Periodo mínimo de refresco

El campo de periodo del fichero de metadatos se ignora por debajo de treinta minutos, y aun así el sistema puede retrasarlo. Nada de lo que declares te dará un reloj con segundero.

A esas cuatro conviene añadir una restricción de contexto que no aparece en ninguna lista oficial: no hay ciclo de vida propio y por tanto no hay ningún punto del código que pueda decir ahora el widget es visible. El anfitrión no informa de si su página del escritorio está en pantalla, ni de si el usuario tiene el dispositivo bloqueado, ni de si tu widget quedó tapado por una carpeta abierta. Cualquier diseño que dependa de saber si alguien está mirando es inviable.

Hay una quinta limitación que suele descubrirse tarde y que conviene anticipar: las listas. Una lista con contenido dinámico no cabe en la descripción, porque el anfitrión necesita pedir elementos según desplaza. Para eso existe un servicio propio que actúa de fábrica de filas y al que el anfitrión llama por IPC elemento a elemento; es funcional, es lento y su ciclo de vida es una fuente clásica de datos obsoletos. La generación siguiente de esta tecnología, que estudiarás en la lección próxima, esconde ese mecanismo pero no lo elimina.

La distancia entre procesos convierte tu interfaz en un protocolo, y un protocolo se diseña de otra manera

Merece la pena detenerse en lo que realmente cambia cuando la interfaz cruza una frontera de proceso, porque no es una molestia técnica sino un cambio de categoría. Dentro de tu aplicación, la interfaz es una función del estado que se evalúa continuamente: el estado cambia, la interfaz se recompone, el usuario ve la consecuencia en el fotograma siguiente. Ese bucle es tan barato y tan inmediato que se te olvida que existe, y toda la programación declarativa moderna se apoya en olvidarlo. Un widget no tiene ese bucle. Tiene, en su lugar, un intercambio de mensajes entre dos partes que no comparten nada: tú emites una fotografía completa del estado visible y el anfitrión la muestra hasta que le llegue otra. Entre las dos fotografías no ocurre nada, aunque el mundo haya cambiado veinte veces. Eso es un protocolo, no un bucle de renderizado, y los protocolos se diseñan con criterios distintos. En un protocolo importa el tamaño del mensaje, porque hay un límite duro y superarlo no degrada el rendimiento sino que rompe la comunicación. Importa la frecuencia, porque cada mensaje despierta procesos y consume batería de un presupuesto que el usuario percibe aunque no sepa a qué atribuirlo. Importa la idempotencia, porque el mismo mensaje puede reenviarse tras un reinicio del anfitrión y no puedes suponer que tu descripción se pintó una sola vez. Y sobre todo importa la latencia percibida, porque entre el toque del usuario y tu reacción hay un despertar de proceso, y esa espera de cientos de milisegundos es visible y hay que diseñar para ella en vez de fingir que no está. La consecuencia práctica que ordena todo el nivel es esta: un widget no es una pantalla pequeña de tu aplicación, es una vista materializada de tu dominio, en el sentido exacto que ese término tiene en bases de datos. Se calcula en un momento concreto, se almacena fuera, se consulta muchas veces sin volver a calcularla y se invalida mediante señales explícitas que tú tienes que emitir. Quien la trata así escribe widgets que se sienten instantáneos y no gastan batería. Quien la trata como una pantalla escribe widgets que refrescan a destiempo, muestran datos viejos y aparecen en la lista de aplicaciones que consumen energía en segundo plano, que es la vía más rápida para que un usuario desinstale sin dar explicaciones.

⚔️ Toca la frontera con las manos
  1. Declara un proveedor mínimo que solo pinte texto fijo, colócalo en la pantalla de inicio y confirma en el registro que tu proceso arranca solo cuando llega la emisión.
  2. Añade un registro con la marca de tiempo dentro del proveedor y observa cuántas veces se te llama al reiniciar el lanzador o al rotar el dispositivo.
  3. Intenta incrustar en la descripción un mapa de bits de varios megapíxeles y documenta el fallo exacto que produce la transacción.
  4. Fija el periodo de actualización en cinco minutos, deja el dispositivo quieto una hora y mide el intervalo real entre llamadas.
  5. Escribe en un campo de la clase del proveedor un contador que incremente en cada actualización, y explica por qué su valor nunca es el que esperabas.