By 37Design |

iOS Share Extension Se Cerraba Tras 2 Segundos: La Solución al OOM Kill

Mientras desarrollaba SnapPress (una app para subir fotos desde iPhone directamente a WordPress) añadí una Share Extension para que los usuarios pudieran compartir fotos desde la app de Fotos sin abrir SnapPress primero.

La extensión funcionaba. La mayoría de las veces. A veces se abría, mostraba un spinner de carga durante 2 segundos, y desaparecía sin dejar rastro. Sin crash log en Xcode. Sin stack trace. Sin mensaje de error. Solo desaparecía.

Share Extension vs Action Extension

Una Action Extension (com.apple.ui-services) aparece en la fila de iconos del share sheet. Está diseñada para transformar contenido o actuar sobre él en el momento.

Una Share Extension (com.apple.share-services) aparece en la lista de actividades del share sheet. Está diseñada para enviar contenido a algún lugar: una red social, un CMS, una app de notas. Se ejecuta como una mini-app independiente con su propia UI, su propio proceso y sus propios límites de memoria.

El Crash: "Invalidation Requested"

La primera pista llegó desde Console.app en Mac. Con el iPhone conectado y filtrando por el nombre del proceso de la extensión, encontré esto en sharingd:

MetricEvent 'com.apple.sharing.sharesheetCompleted' : {
  "success" : false,
  "totalShareTimeMs" : 2700,
  "activityType" : "app.snappress.SnapPress.ShareExtension",
}

Y en Photos:

View service session ended with error:
_UIViewServiceHostSessionErrorDomain Code=4
UserInfo={Message=Invalidation requested}

Invalidation requested significa que el proceso de la extensión terminó inesperadamente. totalShareTimeMs: 2700 significa que vivió exactamente 2.7 segundos. Esta es la firma de un OOM kill. iOS termina silenciosamente los procesos que superan el límite de memoria, sin crash log ni señal alguna.

Causa Raíz: UIImage(data:) Decodifica a Resolución Completa en Memoria

La Share Extension carga imágenes desde NSItemProvider, las procesa y las sube a WordPress. El paso de procesamiento llamaba a este método:

static func process(_ imageData: Data, quality: ImageQuality) -> Data? {
    guard let image = UIImage(data: imageData) else { return nil }
    let resized = resize(image, maxDimension: quality.maxDimension)
    return resized.jpegData(compressionQuality: quality.compressionQuality)
}

El problema es UIImage(data: imageData). Esto decodifica el archivo de imagen comprimido a un buffer de píxeles completo antes de hacer nada más. Una foto de 12 megapíxeles del iPhone (4032 × 3024 píxeles) se expande a:

4032 × 3024 × 4 bytes (RGBA) = ~47 MB

Con la copia redimensionada, el buffer de salida JPEG, el contenedor SwiftData y las miniaturas, una sola subida puede superar los 60 a 80 MB. iOS da a las Share Extensions aproximadamente 120 a 150 MB. Con unas pocas fotos HEIC grandes, ese límite se supera fácilmente.

La Solución: Decodificar Directamente al Tamaño Objetivo con CGImageSource

CGImageSource puede reducir la muestra durante la decodificación, produciendo la imagen del tamaño objetivo sin crear nunca el buffer de resolución completa en memoria.

static func process(_ imageData: Data, quality: ImageQuality) -> Data? {
    let maxDim = Int(quality.maxDimension)
    let options: [CFString: Any] = [
        kCGImageSourceCreateThumbnailFromImageAlways: true,
        kCGImageSourceThumbnailMaxPixelSize: maxDim,
        kCGImageSourceCreateThumbnailWithTransform: true,
        kCGImageSourceShouldCacheImmediately: false,
    ]
    guard
        let source = CGImageSourceCreateWithData(imageData as CFData, nil),
        let cgImage = CGImageSourceCreateThumbnailAtIndex(source, 0, options as CFDictionary)
    else { return nil }

    return UIImage(cgImage: cgImage).jpegData(compressionQuality: quality.compressionQuality)
}

El uso de memoria por imagen cae de ~60 MB a ~6 MB. Una reducción de 10x. La Share Extension no ha vuelto a fallar.

Resumen

Si tu Share Extension de iOS falla después de 2 a 3 segundos sin crash log, casi con certeza es un OOM kill. La causa más común es UIImage(data:) decodificando a resolución completa. Reemplázalo con CGImageSourceCreateThumbnailAtIndex y establece kCGImageSourceThumbnailMaxPixelSize a tu dimensión objetivo.

SnapPress está disponible en el App Store.