By 37Design |

iOS Share Extension 2秒後崩潰問題:OOM Kill 的診斷與解決方法

在開發 SnapPress(一款從 iPhone 直接上傳照片到 WordPress 的應用程式)時,我新增了一個 Share Extension,讓使用者可以直接從相片應用程式分享照片,而不需要先開啟 SnapPress。

Extension 可以運作,但不是每次都正常。有時它開啟後,顯示約 2 秒的載入轉圈,然後無聲無息地消失。Xcode 中沒有崩潰日誌,沒有堆疊追蹤,沒有錯誤訊息,就是消失了。

Share Extension 與 Action Extension 的差異

Action Extension(com.apple.ui-services)顯示在分享面板的應用程式圖示列中,設計用於就地轉換或處理內容。

Share Extension(com.apple.share-services)顯示在分享面板的活動清單中,設計用於將內容傳送到某處(社群媒體、CMS、筆記應用程式等)。它作為完全獨立的迷你應用程式運作,擁有自己的 UI、自己的程序和自己的記憶體限制。

崩潰原因:「Invalidation Requested」

第一個線索來自 Mac 上的 Console.app。連接 iPhone 並依 Extension 程序名稱篩選後,在 sharingd 中找到以下記錄:

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

以及來自 Photos 的記錄:

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

Invalidation requested 表示 Extension 程序意外終止。totalShareTimeMs: 2700 表示它只存活了 2.7 秒。這是 OOM kill 的特徵。iOS 會在程序超過記憶體限制時,不留下任何崩潰日誌或訊號地悄悄終止它。

根本原因:UIImage(data:) 在記憶體中解碼為完整解析度

Share Extension 從 NSItemProvider 載入圖片,處理後上傳到 WordPress。處理步驟呼叫了這個方法:

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

問題在於 UIImage(data: imageData)。這會在進行任何處理之前,將壓縮的圖片檔案解碼為完整解析度的像素緩衝區。一張 1200 萬像素的 iPhone 照片(4032 × 3024 像素)會展開為:

4032 × 3024 × 4 位元組 (RGBA) = 約 47 MB

加上調整尺寸的副本、JPEG 輸出緩衝區、SwiftData 容器和縮略圖預覽,單次上傳可能達到 60-80 MB。iOS 給予 Share Extension 約 120-150 MB 的記憶體。幾張大型 HEIC 照片就很容易超過這個限制。

解決方案:使用 CGImageSource 直接解碼至目標尺寸

CGImageSource 可以在解碼時進行降採樣,直接生成目標尺寸的圖片,而不需要在記憶體中建立完整解析度的緩衝區。

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

每張圖片的記憶體使用量從約 60 MB 降至約 6 MB,減少了 10 倍。自此之後 Share Extension 再也沒有崩潰過。

總結

如果你的 iOS Share Extension 在 2-3 秒後崩潰且沒有崩潰日誌,幾乎可以確定是 OOM kill。將 UIImage(data:) 替換為 CGImageSourceCreateThumbnailAtIndex,並將 kCGImageSourceThumbnailMaxPixelSize 設定為目標尺寸。

SnapPress 現已在 App Store 上架。