By 37Design |

iOS Share Extension Travava Após 2 Segundos: A Solução para o OOM Kill

Ao desenvolver o SnapPress (um app para fazer upload de fotos do iPhone diretamente para o WordPress), adicionei uma Share Extension para que os usuários pudessem compartilhar fotos do app Fotos sem precisar abrir o SnapPress primeiro.

A extensão funcionava. Na maioria das vezes. Às vezes ela abria, mostrava um spinner de carregamento por cerca de 2 segundos, e desaparecia sem deixar rastro. Sem crash log no Xcode. Sem stack trace. Sem mensagem de erro. Simplesmente sumia.

Share Extension vs Action Extension

Uma Action Extension (com.apple.ui-services) aparece na linha de ícones do share sheet. É projetada para transformar ou agir sobre conteúdo no momento.

Uma Share Extension (com.apple.share-services) aparece na lista de atividades do share sheet. É projetada para enviar conteúdo para algum lugar (uma rede social, um CMS, um app de notas). Ela roda como um mini-app independente com sua própria UI, seu próprio processo e seus próprios limites de memória.

O Crash: "Invalidation Requested"

A primeira pista veio do Console.app no Mac. Com o iPhone conectado e filtrando pelo nome do processo da extensão, encontrei isso no sharingd:

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

E no Photos:

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

Invalidation requested significa que o processo da extensão foi encerrado inesperadamente. totalShareTimeMs: 2700 significa que viveu exatamente 2,7 segundos. Esta é a assinatura de um OOM kill. O iOS encerra silenciosamente processos que excedem o limite de memória, sem crash log ou sinal.

Causa Raiz: UIImage(data:) Decodifica em Resolução Completa na Memória

A Share Extension carrega imagens do NSItemProvider, processa e faz upload para o WordPress. O passo de processamento chamava 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)
}

O problema é UIImage(data: imageData). Isso decodifica o arquivo de imagem comprimido em um buffer de pixels em resolução completa. Uma foto de 12 megapixels do iPhone (4032 × 3024 pixels) expande para:

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

Com a cópia redimensionada, o buffer de saída JPEG, o container SwiftData e as miniaturas, um único upload pode chegar a 60 a 80 MB. O iOS dá às Share Extensions aproximadamente 120 a 150 MB. Com algumas fotos HEIC grandes, esse limite é facilmente ultrapassado.

A Solução: Decodificar Diretamente para o Tamanho Alvo com CGImageSource

CGImageSource pode fazer downsampling durante a decodificação, produzindo a imagem no tamanho alvo sem nunca criar o buffer em resolução completa na memória.

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)
}

O uso de memória por imagem cai de ~60 MB para ~6 MB. Uma redução de 10x. A Share Extension não travou mais desde então.

Resumo

Se sua Share Extension iOS falha após 2 a 3 segundos sem crash log, quase certamente é um OOM kill. Substitua UIImage(data:) por CGImageSourceCreateThumbnailAtIndex e defina kCGImageSourceThumbnailMaxPixelSize para a sua dimensão alvo.

SnapPress está disponível na App Store.