By 37Design |

iOS Share Extensionが2秒で落ちる原因(OOM killの診断と解決)

結論を先に書く。iOS Share Extensionがクラッシュログを残さず2〜3秒で消えるなら、ほぼOOM killだ。原因はUIImage(data:)が圧縮写真をフル解像度のピクセルバッファに展開して120〜150MBの上限を踏み抜くこと。CGImageSourceCreateThumbnailAtIndexに置き換えるとピークメモリが約60MBから約6MBに下がる。SnapPressで実際にやった話を書く。

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で公開中だ。同じハマり方をした人の参考になれば嬉しい。