wandres.dev
NOTIFICACIONES · locales y push

Extensiones: interceptar el contenido y dibujar la vista

Entre la llegada del payload y su aparición en pantalla hay una ventana de unos segundos en la que un proceso tuyo puede descifrar, enriquecer y reescribir el mensaje; y cuando la persona mantiene pulsado el aviso, otro proceso distinto puede sustituir la tarjeta por una interfaz completa. Esta lección estudia las dos extensiones de notificación, sus presupuestos duros y el diseño defensivo que exige ejecutar fuera de la app.

⏱ 19 min

Las notificaciones dejan de ser un canal pasivo en el momento en que se descubre que hay dos procesos propios capaces de intervenir en su presentación. La extensión de servicio despierta al llegar el payload, antes de que nada aparezca, y dispone de unos segundos para descifrar, descargar, traducir o reescribir lo que se va a mostrar. La extensión de contenido se activa al expandir la notificación y sustituye la tarjeta estándar por una vista propia, con controles interactivos si se desea. Ambas comparten dos rasgos que gobiernan todo su diseño: corren en procesos separados con presupuestos de tiempo y memoria estrictos, y su ejecución no está garantizada. Programarlas bien consiste, en esencia, en asumir esas dos verdades desde la primera línea.

🎯 Al terminar esta lección sabrás
  • Implementar una extensión de servicio que enriquezca el contenido y ceda con elegancia al agotarse el presupuesto.
  • Adjuntar medios remotos y descifrar cargas extremo a extremo sin exceder los límites de memoria del proceso.
  • Construir una extensión de contenido con vista propia, acciones y controles interactivos.
  • Compartir código y estado entre app y extensiones mediante paquetes locales y grupo de aplicaciones.

Interceptar antes de mostrar

La extensión de servicio se activa únicamente si el payload incluye mutable-content con valor uno y el envío es de tipo alerta. Su punto de entrada recibe el contenido tal como llegó y un cierre al que debe entregarse la versión final. La estructura es engañosamente simple y esconde el detalle más importante de toda la API: existe un segundo método, invocado cuando el sistema está a punto de matar el proceso, y es obligatorio implementarlo. Si el presupuesto expira sin que se haya llamado al cierre, iOS muestra el contenido original sin avisar de nada.

final class ServicioNotificacion: UNNotificationServiceExtension {
    private var entregar: ((UNNotificationContent) -> Void)?
    private var mejor: UNMutableNotificationContent?

    override func didReceive(_ peticion: UNNotificationRequest,
                             withContentHandler handler: @escaping (UNNotificationContent) -> Void) {
        entregar = handler
        mejor = peticion.content.mutableCopy() as? UNMutableNotificationContent
        guard let mejor else { return handler(peticion.content) }

        Task {
            if let texto = try? await Cripto.descifrar(peticion.content.userInfo) {
                mejor.body = texto
            }
            if let url = peticion.content.userInfo["imagen_url"] as? String,
               let adjunto = try? await Medios.descargar(url) {
                mejor.attachments = [adjunto]
            }
            handler(mejor)
        }
    }

    override func serviceExtensionTimeWillExpire() {
        entregar?(mejor ?? UNMutableNotificationContent())
    }
}

El presupuesto ronda los treinta segundos de reloj, pero razonar sobre ese número es un error: lo que hay que asumir es que el trabajo puede interrumpirse en cualquier instante. De ahí se deriva el patrón de la mejora incremental. Se parte de una copia mutable del contenido original, que ya es presentable, y cada tarea que termina la va mejorando en su sitio. Si el proceso muere a mitad, lo que se entrega es la mejor versión alcanzada hasta ese momento en lugar de un contenido vacío. Diseñar la extensión como una secuencia de pasos que solo produce resultado al final es la causa número uno de notificaciones que aparecen sin imagen o con el texto cifrado a la vista.

⚠️
La extensión puede no ejecutarse en absoluto

Con la batería muy baja, bajo presión de memoria o si la extensión ha fallado repetidamente, el sistema puede omitir la invocación y mostrar el payload tal cual. La consecuencia de diseño es innegociable: el contenido original debe ser presentable por sí solo. En una app de mensajería cifrada, eso significa enviar un texto genérico como mensaje nuevo en el cuerpo original y dejar que la extensión lo sustituya por el descifrado cuando pueda, jamás enviar un cuerpo vacío confiando en que alguien lo rellenará.

Adjuntos, cifrado y presupuesto de memoria

El caso de uso más común es adjuntar medios. UNNotificationAttachment se construye a partir de un fichero local, de modo que la extensión debe descargar primero al contenedor temporal y crear el adjunto desde esa ruta. El sistema copia el fichero a su propio almacén al aceptarlo, por lo que no hay que conservarlo después. Los formatos admitidos abarcan imagen, audio y vídeo, con límites de tamaño distintos por tipo y con la particularidad de que un adjunto inválido no degrada: invalida la asignación entera.

enum Medios {
    static func descargar(_ url: String) async throws -> UNNotificationAttachment {
        let (temporal, _) = try await URLSession.shared.download(from: URL(string: url)!)
        let destino = temporal.deletingLastPathComponent()
            .appendingPathComponent("adjunto.jpg")
        try FileManager.default.moveItem(at: temporal, to: destino)
        return try UNNotificationAttachment(identifier: "media", url: destino)
    }
}

El límite de memoria del proceso es del orden de veinticuatro megabytes, muy por debajo del de la app, y ese techo dicta las decisiones. Descargar una fotografía a resolución completa y decodificarla para redimensionarla agota el presupuesto y provoca la muerte del proceso, con el resultado ya conocido: se muestra el contenido original. La práctica correcta es que el servidor genere una miniatura del tamaño exacto que la notificación necesita y que la extensión se limite a descargar bytes y escribirlos en disco, sin decodificar imagen alguna en memoria.

El descifrado extremo a extremo es el otro gran uso legítimo, y explica por qué esta API existe. Permite que el servidor transporte un cuerpo cifrado que ni él ni APNs pueden leer, y que la clave viva únicamente en el llavero compartido del grupo de aplicaciones, accesible tanto para la app como para la extensión. Es el mecanismo que hace posible una mensajería cifrada con notificaciones legibles sin comprometer la propiedad de que el proveedor no ve el contenido.

Una vista propia para el aviso expandido

La extensión de contenido opera en un momento distinto: cuando la persona expande la notificación. Se declara asociada a una o varias categorías mediante claves del Info.plistUNNotificationExtensionCategory la enlaza, UNNotificationExtensionInitialContentSizeRatio fija la proporción inicial, UNNotificationExtensionDefaultContentHidden oculta la tarjeta estándar y UNNotificationExtensionUserInteractionEnabled habilita los controles— y su controlador adopta el protocolo UNNotificationContentExtension.

🖼️

Vista rica

Mapa de un pedido, previsualización de una publicación, gráfica de una medida. Todo lo que se entiende mejor mirándolo que leyéndolo.

🎛️

Interacción

Botones, deslizadores y campos que resuelven la tarea sin abrir la app. Exigen habilitar la interacción de usuario en el Info.plist.

🔄

Respuesta en sitio

Al manejar una acción se puede actualizar la vista y decidir si la notificación se descarta o permanece, mostrando el resultado.

📦

Estado compartido

El grupo de aplicaciones da acceso al mismo contenedor y llavero que la app, lo que permite leer caché y escribir cambios pendientes.

El manejo de acciones dentro de la extensión de contenido es lo que la separa de una simple decoración. Al recibir una respuesta se puede ejecutar la operación, refrescar la interfaz y devolver una opción de descarte que decide el destino de la tarjeta: mantenerla abierta con el resultado a la vista, cerrarla, o cerrarla y abrir la app. Ese último grado de control convierte la notificación en una superficie de interacción de primera clase en lugar de un simple enlace.

func didReceive(_ respuesta: UNNotificationResponse) async
    -> UNNotificationContentExtensionResponseOption {
    switch respuesta.actionIdentifier {
    case "COMPLETAR":
        await Almacen.compartido.marcarCompletada(idDe(respuesta))
        etiquetaEstado.text = "Completada"
        return .doNotDismiss
    case "ABRIR":
        return .dismissAndForwardAction
    default:
        return .dismiss
    }
}
flowchart TB
a[Payload con mutable-content] --> b{Extension de servicio disponible}
b -->|no| c[Se muestra el contenido original]
b -->|si| d[Descifrar y descargar por pasos]
d --> e{Presupuesto agotado}
e -->|si| f[serviceExtensionTimeWillExpire entrega la mejor version]
e -->|no| g[Contenido enriquecido]
f --> h[Tarjeta en pantalla]
g --> h
h --> i{La persona expande}
i -->|si| j[Extension de contenido con vista propia]
j --> k[Accion resuelta sin abrir la app]

Depurar lo que corre en otro proceso

Trabajar con extensiones exige aceptar que ya no hay un solo binario. El código compartido debe vivir en un paquete local de SPM enlazado por ambos objetivos, nunca duplicado por copia, y el estado común debe pasar por el contenedor del grupo de aplicaciones. Un fallo recurrente en equipos que empiezan es usar UserDefaults.standard en la extensión: apunta a un dominio distinto del de la app y devuelve siempre valores vacíos, sin error alguno que lo delate.

La depuración requiere adjuntar el depurador al proceso de la extensión, que se selecciona como esquema propio en lugar del de la app. Los mensajes de registro escritos con el sistema unificado aparecen en la consola con el subsistema de la extensión, y conviene etiquetarlos de forma distinguible porque se mezclan con los de la app. La regla más útil para el día a día es instrumentar de forma explícita los dos desenlaces posibles del presupuesto —entrega normal y entrega por expiración— y contar cada uno en producción: una proporción alta de expiraciones indica que estás haciendo demasiado trabajo antes de mejorar el contenido.

La extensión es la app renunciando a su monopolio sobre su propio contenido

Hay un cambio de modelo mental que ocurre al escribir la primera extensión de servicio y que después reordena bastantes decisiones de arquitectura. Hasta ese momento la app es el único lugar donde vive la lógica del producto: un proceso, un espacio de memoria, un ciclo de vida bajo control. La extensión rompe ese monopolio. Aparece un segundo proceso que ejecuta código tuyo, que puede leer tus secretos, que decide qué ve el usuario, que corre cuando la app está muerta y que puede no correr en absoluto. Y de esa ruptura se desprende un principio de diseño que trasciende las notificaciones: el código que puede no ejecutarse no puede ser el único que produce corrección. Es la misma disciplina que rige los sistemas tolerantes a fallos, trasladada al interior de un producto móvil. La extensión de servicio no debe hacer que la notificación sea correcta, sino que sea mejor; la extensión de contenido no debe ser el único camino a una acción, sino el más rápido. Cuando un equipo interioriza esa asimetría, el diseño del payload cambia de raíz: el contenido original deja de ser un esqueleto que alguien rellenará y pasa a ser una versión completa y digna, y el trabajo de la extensión se descompone en pasos independientes que aportan valor uno a uno. El resultado no es solo una app más robusta, sino una app cuyo comportamiento degradado nadie percibe como avería, porque nunca prometió lo que no podía garantizar.

📝
Lo esencial

La extensión de servicio exige mutable-content y obliga a implementar la expiración: parte de una copia mutable presentable y mejórala por pasos, para que la muerte del proceso entregue siempre algo digno. Descarga bytes al disco sin decodificar imágenes, porque el techo de memoria es de unos veinticuatro megabytes, y reserva el descifrado a claves guardadas en el llavero del grupo. La extensión de contenido se enlaza por categoría desde el Info.plist, permite interacción real y decide con su opción de respuesta si la tarjeta se cierra. Comparte código con un paquete local y estado con el contenedor del grupo, nunca con los valores por defecto estándar.

⚔️ Enriquecer sin depender del enriquecimiento
  1. Añade una extensión de servicio a tu app y comprueba, apagando el proceso a la mitad, qué se muestra cuando expira el presupuesto sin haber llamado al cierre.
  2. Reescribe el contenido original del payload para que sea completo y presentable por sí solo, y convierte la extensión en una mejora incremental de tres pasos independientes.
  3. Adjunta una imagen remota generada por el servidor al tamaño exacto de la tarjeta y mide el pico de memoria del proceso durante la descarga.
  4. Crea una extensión de contenido para una categoría existente, oculta la tarjeta estándar y resuelve la acción principal sin abrir la app.
  5. Instrumenta el contador de entregas normales frente a expiraciones y lleva la proporción de expiraciones por debajo del cinco por ciento en dispositivos reales.