Interacción: del toque en el escritorio a tu código
Un widget que solo se mira es un cartel; uno que responde al toque es una superficie de trabajo, y la diferencia entre ambos está en un mecanismo estrecho y muy concreto que esta lección desmonta. Se recorre el catálogo completo de acciones de Glance —abrir una actividad, lanzar un servicio, emitir una difusión y ejecutar una devolución de llamada propia—, se detalla actionRunCallback con sus parámetros tipados, su hilo de ejecución y su obligación de repintar, y se estudian los controles interactivos con estado y el modo en que las versiones modernas de la plataforma permiten que el anfitrión responda visualmente antes de que tu proceso llegue a despertarse. Termina con la latencia percibida y con la seguridad de un punto de entrada que cualquiera puede invocar.
Todo lo que un usuario puede hacer con un widget cabe en una frase: tocar una zona y provocar que se dispare un intent pendiente. No hay más. No hay escuchadores, no hay eventos de puntero, no hay arrastres ni pulsaciones largas ni desplazamiento con inercia gobernado por tu código. Esa pobreza aparente resulta desconcertante hasta que se recuerda de dónde viene: el toque ocurre en el proceso del lanzador, y la única manera de que un proceso ajeno actúe en tu nombre sin tenerte cargado en memoria es que le hayas entregado de antemano un permiso diferido con la forma exacta de lo que puede hacer. Eso es un PendingIntent, y todo el vocabulario de interacción de Glance no es más que una capa de tipos razonables sobre él. Aprender esta lección es aprender a expresar interacciones ricas con una primitiva pobre, y a diseñar para una latencia que no puedes eliminar porque incluye despertar un proceso.
- Elegir entre abrir una actividad, lanzar un servicio, emitir una difusión y ejecutar una devolución propia.
- Implementar
ActionCallbackcon parámetros tipados y repintado explícito al terminar. - Construir controles interactivos con estado y entender qué resuelve el anfitrión por su cuenta.
- Diseñar para la latencia de despertar y proteger un punto de entrada invocable desde fuera.
El catálogo de acciones
Las acciones se aplican con GlanceModifier.clickable o se pasan directamente a los componibles que las admiten. Hay cuatro familias y elegir mal es la causa habitual de widgets que parecen no funcionar.
Abrir una actividad es la acción por defecto y la única que traslada al usuario fuera del escritorio. Debe llevar información suficiente para que la pantalla se construya sola, exactamente igual que un enlace profundo, porque llega sin contexto previo. Lanzar un servicio en primer plano sirve para procesos largos que necesitan notificación visible. Emitir una difusión es la vía de integración con receptores propios ya existentes. Y ejecutar una devolución de llamada es la opción específica de Glance para el caso más frecuente: hacer algo pequeño y volver a pintar el widget sin sacar al usuario de donde está.
Column {
Text(
text = titulo,
modifier = GlanceModifier.clickable(
actionStartActivity<DetalleActivity>(
actionParametersOf(ClaveId.id to tarea.id),
),
),
)
Button(
text = "Completar",
onClick = actionRunCallback<CompletarAccion>(
actionParametersOf(ClaveId.id to tarea.id),
),
)
}
Solo el toque del usuario legitima la apertura de una pantalla. Intentar lanzar una actividad desde un trabajador, desde una devolución de llamada o desde una actualización periódica choca de frente con los límites de inicio en segundo plano que estudiaste en el nivel de servicios: el sistema lo bloqueará en silencio y en el registro aparecerá una línea que casi nadie lee. Si el widget necesita reclamar atención sin que nadie lo toque, la herramienta correcta es una notificación, no una actividad.
actionRunCallback, en detalle
La devolución de llamada es una interfaz de un solo método suspendido que recibe el contexto, el identificador de la instancia tocada y los parámetros que empaquetaste al construir la acción. Que sea suspendida importa: puedes escribir en la base de datos o llamar a la red sin bloquear nada, dentro de un ámbito que el sistema mantiene vivo durante la atención.
class CompletarAccion : ActionCallback {
override suspend fun onAction(
context: Context,
glanceId: GlanceId,
parameters: ActionParameters,
) {
val id = parameters[ClaveId.id] ?: return
repositorio.marcarCompletada(id)
updateAppWidgetState(context, glanceId) { prefs ->
prefs[Claves.pendientes] = repositorio.contarPendientes()
}
TareasWidget().update(context, glanceId)
}
}
Las tres obligaciones que se olvidan son siempre las mismas. La primera es que el repintado no es automático: si no llamas a update, el trabajo se hará y el widget seguirá mostrando lo de antes, con lo que el usuario volverá a tocar y duplicará la operación. La segunda es que los parámetros viajan dentro de un intent pendiente y por tanto deben ser identificadores pequeños y serializables, jamás objetos de dominio ni datos sensibles. La tercera es que el ámbito de ejecución es breve; cualquier cosa que pueda tardar debe delegarse en un trabajador y el widget debe pasar de inmediato a un estado visible de en curso.
sequenceDiagram participant U as Usuario participant L as Lanzador participant S as Sistema participant A as Tu proceso U->>L: Toca el boton del widget L->>S: Dispara el intent pendiente S->>A: Despierta el proceso y entrega los parametros A->>A: onAction escribe en el dominio A->>A: Actualiza el estado del widget A->>S: Nueva descripcion tras update S->>L: Reenvia la descripcion L->>U: Repinta con el resultado
Todo ese recorrido tarda, en un dispositivo modesto y con el proceso muerto, entre trescientos milisegundos y más de un segundo. Es tiempo que el usuario percibe como que el botón no ha hecho nada, y la reacción natural es volver a tocar. De ahí que cualquier acción destructiva o no idempotente deba protegerse frente a la doble ejecución, y que el diseño deba dar señal visual inmediata por algún medio que no dependa de tu proceso.
Listas y trabajo que no cabe en la devolución
Dentro de una lista perezosa cada elemento necesita su propia acción, y la única forma correcta de distinguirlos es el parámetro: una sola clase de devolución de llamada que recibe el identificador de la fila tocada. Crear una clase por elemento es imposible, y cerrar sobre la variable del bucle no funciona porque lo que viaja es el paquete de parámetros, no el ámbito léxico.
LazyColumn {
items(tareas, itemId = { it.id }) { tarea ->
Row(
modifier = GlanceModifier.clickable(
actionRunCallback<CompletarAccion>(
actionParametersOf(ClaveId.id to tarea.id),
),
),
) {
CheckBox(checked = tarea.completada, onCheckedChange = null)
Text(text = tarea.titulo)
}
}
}
Cuando la operación no es instantánea —subir una foto, sincronizar con el servidor, descargar un adjunto— la devolución de llamada no es el sitio para ejecutarla. Su ámbito es corto y su fracaso es invisible. El patrón correcto tiene tres pasos: escribir en el estado del widget que la operación está en curso, repintar de inmediato para que el usuario vea que su toque llegó, y encolar un trabajador que haga el trabajo real y vuelva a repintar al terminar con el resultado o con el error.
Sin señal visible, el usuario que toca un botón lento concluye que no ha pasado nada y vuelve a tocar. Con señal visible, espera. La diferencia entre las dos versiones no está en la velocidad de la operación, que es idéntica, sino en si el widget admite o no que está trabajando; y esa admisión hay que escribirla y enviarla, porque nadie la deduce por ti.
Controles interactivos y la ilusión de inmediatez
Ese medio existe. CheckBox, Switch y RadioButton se traducen a controles compuestos que, en las versiones modernas de la plataforma, cambian su apariencia dentro del anfitrión en el mismo fotograma del toque, antes de que tu proceso exista siquiera. Tu devolución de llamada llega después y confirma el cambio escribiendo el estado real. El usuario ve una respuesta instantánea; la verdad se establece un instante más tarde.
Respuesta local del anfitrión
El control cambia de aspecto sin cruzar la frontera. Es la única inmediatez disponible y conviene aprovecharla en todo lo que sea conmutable.
Idempotencia obligatoria
Dos toques rápidos producen dos invocaciones. Diseña la operación para que repetirla no duplique nada, o descártala por identificador.
Estado en curso visible
Si la operación tarda, escribe el estado en curso y repinta antes de empezar. Un indicador de progreso vale más que la operación más rápida.
Punto de entrada expuesto
La devolución llega por una difusión al receptor. Valida siempre los parámetros y no confíes en que solo tu widget los envía.
Ese último punto merece énfasis. El mecanismo termina en un receptor exportado, y aunque el intent pendiente lleva su propia protección, la disciplina correcta es la misma que aplicabas a un enlace profundo: tratar los parámetros como entrada no confiable, comprobar que el identificador pertenece al usuario actual y no derivar de ellos ninguna operación que no soportarías ver invocada por una aplicación cualquiera del dispositivo.
Hay algo profundo en el hecho de que un widget no pueda tener escuchadores y deba conformarse con intents pendientes, y no se agota en la limitación técnica. Un escuchador ordinario es una llamada de función: la intención del usuario y el efecto sobre el estado ocurren en el mismo instante, en la misma pila y en el mismo proceso, hasta el punto de que el lenguaje ni siquiera los distingue. Un intent pendiente los separa por construcción. Lo que se envía no es la ejecución sino la intención de ejecutar, empaquetada en un mensaje que viajará por un canal, sobrevivirá a la muerte de quien lo creó, despertará un proceso que quizá no existía y se ejecutará en un contexto que ya no es el del toque. Cualquiera que haya trabajado con colas de mensajes reconoce inmediatamente el patrón, y con él reconoce el catálogo de problemas que trae de regalo: entregas duplicadas cuando el usuario toca dos veces porque no vio respuesta, entregas fuera de orden cuando dos toques rápidos despiertan trabajos que compiten, mensajes obsoletos cuando la acción se disparó contra una versión del estado que ya no existe, y la imposibilidad de distinguir desde el receptor si el remitente sigue esperando o si hace rato que bloqueó la pantalla. Ninguno de esos problemas aparece en una interfaz local, y todos son inevitables en cuanto la interacción cruza una frontera. La consecuencia de diseño es que las acciones de un widget deben especificarse con el mismo rigor con el que se especifica un mensaje de un sistema distribuido, y eso significa tres compromisos concretos. Primero, toda acción debe ser idempotente o llevar un identificador de operación que permita descartar repeticiones, porque la duplicación no es un caso raro sino el comportamiento normal de un usuario impaciente. Segundo, toda acción debe expresarse en términos absolutos y no relativos: marcar la tarea con este identificador como completada es seguro, mientras que incrementar el contador se corrompe en cuanto llegan dos mensajes que se creían solos. Y tercero, toda acción debe validar contra el estado actual y no contra el que el widget mostraba, porque entre la fotografía que el usuario vio y el momento en que su intención se ejecuta el mundo ha tenido tiempo de cambiar. Quien adopta estos tres compromisos descubre que su widget deja de tener errores intermitentes imposibles de reproducir, y descubre también que acaba de aprender, por una puerta lateral, la disciplina exacta que hace falta para escribir cualquier cliente que hable con un servidor.
- Implementa una acción que incremente un contador y púlsala rápidamente diez veces con el proceso muerto; documenta el valor final y explícalo.
- Reescribe esa acción en términos absolutos con un identificador de operación y repite la prueba hasta que el resultado sea estable.
- Elimina la llamada a
updateal final de la devolución y describe con precisión qué ve el usuario y qué acaba haciendo. - Sustituye un botón por un
Switchy mide con una grabación de pantalla la diferencia de latencia percibida entre ambos. - Dispara la difusión de tu devolución de llamada desde el terminal con parámetros inventados y comprueba que tu validación resiste.