By 37Design |

iOS Share Extensionが2秒で落ちる問題 — OOM killの診断と解決法

SnapPress(iPhoneからWordPressに写真を一括アップロードするアプリ)を開発中、Share ExtensionとAction Extensionを実装しました。写真アプリの共有シートからSnapPressを選ぶだけでそのままWordPressにアップロードできる機能です。

実装自体は動きました。ただし、「たいていは」という条件付きで。Share Extensionは起動してローディングスピナーが2秒ほど回った後、何も言わずに消えることがありました。Xcodeのデバイスログにクラッシュレポートなし。スタックトレースなし。エラーメッセージなし。ただ静かに消えるだけ。

この記事は、その原因の診断方法、実際に何が起きていたか、そして完全に解決した修正方法についてです。

Share ExtensionとAction Extensionの違い

クラッシュの話に入る前に、混同しやすい2つの拡張機能タイプについて整理します。

Action Extensioncom.apple.ui-services)は共有シートのアプリアイコン行に表示されます。コンテンツをその場で変換・操作するために設計されています。「アプリで開く」スタイルのボタンをイメージすると分かりやすいです。

Share Extensioncom.apple.share-services)は共有シートのアクティビティリスト(アイコン行の下)に表示されます。コンテンツをどこかに送るために設計されています。SNS、CMS、メモアプリへの投稿などが典型例です。Share Extensionは独立したミニアプリとして動作し、独自のUI・独自のプロセス・独自のメモリ制限を持ちます。

SnapPressでは両方を実装しています。Share Extensionが実際のアップロードUIを担当し、Action Extensionはメインアプリへのクイック起動エントリポイントとして機能します。

クラッシュの症状:「Invalidation requested」

症状は一貫していました。共有シートを開いてSnapPressをタップ、拡張機能が2〜3秒ローディングしたあと消える。ユーザーへのエラー表示なし。Xcodeのデバイスログにクラッシュレポートなし。

最初の手がかりはMacのConsole.appから得られました。iPhoneを接続してConsoleで拡張機能のプロセス名でフィルタリングすると、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は拡張機能のプロセスが予期せず終了したことを意味します。totalShareTimeMs: 2700は2.7秒間だけ存在したことを示しています。これはOOM killのパターンです。iOSはメモリ制限を超えたプロセスをクラッシュログなし、シグナルなし、ユーザーへの通知なしで静かに終了させます。

symptomsdにはこんなログもありました:

COSMCtrl applyPolicyDelta unexpected absence of policy
on appRecord app.snappress.SnapPress.ShareExtension

これはiOSがリソースポリシーを設定し終わる前に拡張機能が終了したときに出るログで、OOM killのもうひとつの証拠です。

根本原因: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)です。この呼び出しは、何も処理する前に圧縮された画像ファイルをフル解像度のピクセルバッファとしてメモリに展開します。iPhoneの1200万画素写真(4032×3024ピクセル)は以下のサイズになります。

4032 × 3024 × 4バイト (RGBA) = 約47MB

リサイズ後のコピー、JPEG出力バッファ、SwiftDataコンテナ、サムネイルプレビューを合わせると、1枚の画像アップロードで60〜80MBのスパイクが発生します。iOSはShare Extensionに与えるメモリを約120〜150MBに制限しています。大きな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)
}

kCGImageSourceThumbnailMaxPixelSizeを2048に設定すると、同じ1200万画素写真が直接2048pxの画像としてデコードされます。フル解像度の中間バッファは一切作られません。1枚あたりのメモリ使用量は約60MBから約5〜8MBに削減されます。

メモリ使用量の比較

アプローチ ピークメモリ(12MP HEIC)
UIImage(data:) → リサイズ → JPEG 1枚あたり約60MB
CGImageSourceCreateThumbnailAtIndex → JPEG 1枚あたり約6MB

10倍の削減です。この修正以降、Share Extensionはクラッシュしていません。

拡張機能の実装で学んだその他の教訓

NSItemProvider呼び出しには@MainActorを付ける

NSItemProvider.loadFileRepresentationloadDataRepresentationを呼ぶ関数には@MainActorを付けるべきです。バックグラウンドアクターのコンテキストからNSItemProviderを呼ぶと、Share Extensionで予測不能な動作を引き起こすことがあります。コールバック自体はバックグラウンドスレッドで実行されますが、呼び出し元はメインアクターにいるべきです。

ModelContainerを生かし続ける

Share ExtensionでSwiftDataを使う場合、ModelContainerをルートビューの@Stateプロパティに保持してください。タスク内のローカル変数にのみ参照されている場合、コンテキストがまだ使っている間に解放されてしまい、サイレントなデータ失敗が発生します。

@State private var modelContainer: ModelContainer?

Share Extensionのクラッシュをデバッグする方法

OOM killはXcodeのデバイスログには表示されません。Macのデバイスを接続してConsole.appを使い、以下のプロセス名でフィルタリングしてください。

  • sharingd: 共有シートのライフサイクルイベント
  • symptomsd: リソースポリシーとプロセス終了シグナル
  • あなたの.bundle.id.ShareExtension: 拡張機能自身のログ

sharingdInvalidation requestedsymptomsdunexpected absence of policyの組み合わせは、OOM killの確実なシグネチャです。

まとめ

iOSのShare Extensionがクラッシュログなしで2〜3秒後にクラッシュする場合、ほぼ確実にOOM killです。最も一般的な原因はUIImage(data:)が圧縮写真をメモリ上でフル解像度に展開することです。CGImageSourceCreateThumbnailAtIndexに置き換えてkCGImageSourceThumbnailMaxPixelSizeをターゲットサイズに設定してください。フル解像度バッファを完全にスキップして、出力サイズに直接デコードされます。

SnapPressはApp Storeで公開中です。