Article by: Phil Niedertscheider
モバイル向け Session Replay を一般提供(GA)にした後、採用は急速に進み、より多くのフィードバックが私たちのもとに届くようになりました。
あまり良くない話ですが、Apple SDK のユーザーから、古い iOS デバイスでの Session Replay のパフォーマンスオーバーヘッドにより、アプリが使い物にならなくなったという報告がありました。
そこで私たちは原因を突き止めるための旅に出て、ベンチマークで 4〜5 倍良いパフォーマンスを得られる解決策を見つけました。モバイルの Session Replay の内部で何が起きているのかを理解するために、技術的な詳細に入る前に、まずモバイルの画面録画がどのように動作するのかを見ていきましょう。
フレームレートをひと言で言うと
画面録画とは、フレームと呼ばれる高速で表示される多数の画像から成る動画です。人間の目は 1 秒あたり約 60 フレーム(フレームレート)を処理でき、これはヘルツ(1 Hz = 1 秒あたり 1 単位)で測定され、動いている映像の錯覚を生み出します。フレームレートは用途によって異なり、映画では 24 Hz、ゲーミング向けの PC ディスプレイでは 144 Hz まであります。
より高いフレームレートはより滑らかな動画を生みますが、重大なトレードオフを伴います。
- 同じ動画の長さでも、ストレージとネットワーク帯域の消費が増える
- 1 秒あたりに処理すべきフレームが増え、性能を維持するにはより強力なハードウェアが必要になる
フレームレートを最小まで下げると、録画はストップモーション動画のように見えます。このスタイルのとても良い例は、この YouTube 動画で見ることができます。フレームは単に連続した写真にすぎませんが、それでも動いている写真のように感じられる、つまり動画です。
これは本質的に、私たちがモバイルの Session Replay で行っていることです。
リソース負荷の大きい完全な画面録画を行う代わりに、私たちは 1 秒ごとにスクリーンショットを 1 枚キャプチャします。そして 5 秒ごとに、これらのフレームを 1 Hz の動画セグメントにまとめ、ストップモーションのような録画を作ります。これらの動画セグメントは Sentry にアップロードされ、つなぎ合わされて完全なセッションリプレイになります。そして開発者としては、低いオーバーヘッドで、ユーザーが行った操作とアプリがどう反応したかについてのインサイトを得られます。デバッグツールとしては良いトレードオフです。
フレームレートの仕組みが分かったところで、次は私たちが取り組む必要があった実際の問題をより深く掘り下げていきます。
フレームが問題
私たちの調査は、制御された環境でパフォーマンス問題を再現することから始まりました。ユーザーが最も深刻なパフォーマンス問題を報告していた古いハードウェアを代表するものとして、iOS 15.7 を搭載した iPhone 8 を主要なテスト端末として使用しました。
Sentry Apple SDK のサンプルアプリケーションで Xcode Instruments を使うと、報告されていたパフォーマンス問題をすぐに再現でき、毎秒一貫してメインスレッドがハングするというパターンに気づきました。

Xcode Instruments では、アプリのハング警告が毎秒表示されていました。
iOS アプリケーションは、UI のビュー階層全体を扱うために、ちょうど 1 本のスレッド(メインスレッド)を使用します。ビューの変更が発生すると、この階層はシステムのレンダーサービスによって処理され、論理的なビュー構造が画面表示用のピクセルデータに変換されます。
やがて Apple は、操作中は最大 120Hz までリフレッシュレートを上げ、アイドル時は 10Hz まで下げる ProMotion ディスプレイを導入しました。つまり、フレームレートはもはや一定ではありません。
1 フレームあたりのレンダリング時間(ミリ秒、ms)にどのような影響があるのかをより正確に理解するために、120Hz のリフレッシュレートを達成するには 1 フレームあたりの時間が狭く、1000ms / 120 = 約 8.3ms の間にビュー階層の更新とレンダリングを行う必要があると考えてみてください。対照的に 60Hz では、利用できる時間を倍にして、1000ms / 60 = 約 16.7ms になります。
リフレッシュレートと所要時間の完全な表は、Apple のドキュメントで確認できます。

青いボックスは、ビューを計算してレンダリングするために利用できる時間の量を表しています。
これは、1 フレームあたりの実行時間の上限、つまり、グラフィックスデータを画面に送る前に、メインスレッド上でロジック、計算、レンダリング(作業負荷)を実行するためにどれだけの時間があるかを定義します。
作業負荷がその範囲内、たとえば 4ms であれば問題なく、次の更新を待ちます。しかし作業負荷にそれ以上の時間がかかる、たとえば 25ms の場合は問題です。メインスレッドがブロックされ、アプリがハングします。
アニメーションはフレームではなく時間に結び付いているため、アプリはその後に単純に次のフレームのレンダリングを続けることはできません。たとえば、ロード中のインジケーターが 1 秒あたりちょうど 1 回転すると期待している場合、フレームのレンダリング時間が長くなるとアニメーションは遅くなります。
タイミングを補正するために、システムはこの時点までにすでにレンダリングされているはずのフレームをスキップします(ドロップされたフレーム)。この補正の挙動は一般にフレームドロップとして知られており、望ましくない副作用として、視覚的な引っかかり(つまり滑らかにスクロールしないこと)として気づくことのある断続的なフレームレート変化を引き起こします。

作業負荷が利用可能な時間より長くかかる場合、フレームをドロップする必要があります。
フレームドロップは、Session Replay の以前の実装でも意図しない副作用として発生していました。というのも、1 秒に 1 回のスクリーンショットが単純に時間がかかりすぎていたからです。
PII を意識してスクリーンショットを撮る
|
120サンプル |
Redact |
Render |
合計 |
|---|---|---|---|
|
最小(Min) |
3.0583ミリ秒 |
145.3815ミリ秒 |
148.4398ミリ秒 |
|
平均(Avg) |
5.8453ミリ秒 |
149.8243ミリ秒 |
155.6696ミリ秒 |
|
p50 |
6.0484ミリ秒 |
149.2103ミリ秒 |
155.2587ミリ秒 |
|
p75 |
6.1136ミリ秒 |
151.9487ミリ秒 |
158.0623ミリ秒 |
|
p95 |
6.2567ミリ秒 |
155.3496ミリ秒 |
161.6063ミリ秒 |
|
最大(Max) |
6.5138ミリ秒 |
155.8338ミリ秒 |
162.3476ミリ秒 |
Redact の所要時間は約 6.3ms と比較的小さいため、最適化は将来的に行い、当面は Render の改善に注力します。
ビューレンダラーの最適化
私たちの調査により、レンダリング段階がスクリーンショット 1 枚あたり約 155 ミリ秒を消費しており、これが主なパフォーマンスのボトルネックであることが分かりました。元の実装は、Apple の高レベルな UIGraphicsImageRenderer API に依存していました。この API は便利な抽象化を提供する一方で、私たちのユースケースでは大きなオーバーヘッドを生みます。
以下が、パフォーマンス問題を引き起こしていたベースライン実装です。

この実装は、2 つの主要な関数ブロックで構成されており、それぞれに最適化の余地があります。
- Setup:レンダリング操作の「キャンバス」となるグラフィカルなビットマップコンテキストを作成し、描画完了後に結果のビットマップデータを UIImage に変換します。
- Draw:Setup で作成したコンテキストに、ビュー階層を描画します。
さらに考慮しなければならない複雑さとして、論理的なポイントと物理的なピクセルの間で座標系が一致しない点があります。iOS はレイアウトにポイントベースの座標系を使用しますが、実際のディスプレイはピクセルで動作します。たとえば iPhone 8 の画面は論理的には 375 × 667 ポイントですが、2 倍のスケール係数により、物理的な表示解像度は 750 × 1334 ピクセルになります。
要点は、スケーリングは計算負荷の高い処理なので、グラフィックスデータを不要にスケーリングすることは避け、適切なスケールを使うべきだということです。興味深いことに、ベースライン分析では画像のスケーリングは大きな影響を与えませんでしたが、他のパフォーマンステストでは影響がありました。
アイデア 1:UIGraphicsImageRenderer を再利用する
元の実装は、Apple の UIKit フレームワークが提供するヘルパークラスである UIGraphicsImageRenderer を使用しており、低レベルのビットマップコンテキストのセットアップと画像への変換を担っています。
私たちのデフォルトのビューレンダラーは、render メソッドが呼ばれるたびに毎回新しいインスタンスを作成しています。Apple のドキュメントでは、これは組み込みのキャッシュを使用すると述べており、そのためインスタンスの再利用を推奨しています。
”画像レンダラーを初期化した後、同じ構成で複数の画像を描画するためにそれを使用できます。画像レンダラーは Core Graphics のコンテキストのキャッシュを保持するため、同じレンダラーを再利用する方が、新しいレンダラーを作成するよりも効率的になる場合があります。”
ベンチマークテストの出力を見ると、UIGraphicsImageRenderer のキャッシュ(UIGIR Cache)はベースライン(Base)と比べて有意な変化はありません。この推奨は役に立たなかったため、私たちは採用しませんでした。
|
120サンプル |
ベースライン |
UIGIR Cache |
± 時間 |
± % |
|---|---|---|---|---|
|
最小(Min) |
145.3815ミリ秒 |
146.9310ミリ秒 |
1.5495ミリ秒 |
+1.07% |
|
平均(Avg) |
149.8243ミリ秒 |
149.5189ミリ秒 |
-0.3054ミリ秒 |
-0.20% |
|
p50 |
149.2103ミリ秒 |
148.0545ミリ秒 |
-1.1558ミリ秒 |
-0.77% |
|
p75 |
151.9487ミリ秒 |
151.6945ミリ秒 |
-0.2542ミリ秒 |
-0.17% |
|
p95 |
155.3496ミリ秒 |
155.3220ミリ秒 |
-0.0276ミリ秒 |
-0.02% |
|
最大(Max) |
155.8338ミリ秒 |
156.0019ミリ秒 |
0.1681ミリ秒 |
+ 0.11% |
アイデア 2:カスタムビューレンダラー
前述のとおり、UIGraphicsImageRenderer は UIKit のクラスで、CoreAnimation と CoreGraphics(QuartzCore とも呼ばれます)の上に構築されています。これらの多くはクローズドソースですが、その内部実装をよりよく理解するために、iOS におけるグラフィカルなコンテキストへのレンダリングの歴史を振り返ることができます。
このクラスが iOS 10 で導入される以前、多くの開発者は前身となる UIGraphicsBeginImageContextWithOptions を使ってビットマップコンテキストを作成し、UIGraphicsGetImageFromCurrentImageContext を使ってそれを画像に変換していました。
これらのメソッドも CoreGraphics に支えられているため、さらに踏み込み、CoreGraphics の CGContext を直接扱うことができます。そうすることで、高レベルな「ヘルパー」メソッドや内部のキャッシュロジックの大部分をスキップできます。
これが、私たちが新しい SentryGraphicsImageRenderer(SGIR)でまさに行っていることです。
私たちの解決策は、UIKit の高レベルな抽象化を迂回し、CoreGraphics のコンテキストを直接扱います。このアプローチにより、メモリアロケーションのパターンをきめ細かく制御でき、UIKit の内部キャッシュやコンテキスト管理ロジックによって導入されるオーバーヘッドを排除できます。

どのように動作するのかを大まかに説明するために、このコードスニペットを見てください。

SentryGraphicsImageRenderer の完全な実装は GitHub で確認できます。
|
120サンプル |
ベースライン |
SGIR(2x スケール) |
ベースラインとの差(時間) |
ベースラインとの差(%) |
|---|---|---|---|---|
|
最小(Min) |
145.38ミリ秒 |
14.76ミリ秒 |
-130.62ミリ秒 |
-89.95% |
|
平均(Avg) |
149.82ミリ秒 |
25.42ミリ秒 |
-124.41ミリ秒 |
-83.04% |
|
p50 |
149.21ミリ秒 |
24.56ミリ秒 |
-124.65ミリ秒 |
-83.54% |
|
p75 |
151.95ミリ秒 |
27.34ミリ秒 |
-124.61ミリ秒 |
-82.01% |
|
p95 |
155.35ミリ秒 |
30.32ミリ秒 |
-125.03ミリ秒 |
-80.48% |
|
最大(Max) |
155.83ミリ秒 |
32.58ミリ秒 |
-123.26ミリ秒 |
-79.09% |
最初の成果が出て、平均時間を約 125ms、つまり約 80% 短縮できました。
先ほどのコードスニペットでお気づきかもしれませんが、私たちはウィンドウのスケールを明示的に宣言する必要がありました。画面ネイティブのスケールである 2.0 ではなくウィンドウスケールを 1.0 にするとパフォーマンス低下が確認できたため、これは重要でした。
|
120サンプル |
SGIR(2x スケール) |
SGIR(1x スケール) |
2x スケールとの差(時間) |
2x スケールとの差(%) |
|---|---|---|---|---|
|
最小(Min) |
14.76ミリ秒 |
27.05ミリ秒 |
+ 12.29ミリ秒 |
+ 83.28 % |
|
平均(Avg) |
25.42ミリ秒 |
38.80ミリ秒 |
+ 13.39ミリ秒 |
+ 52.67% |
|
p50 |
24.56ミリ秒 |
38.47ミリ秒 |
+ 13.92ミリ秒 |
+ 56.67% |
|
p75 |
27.34ミリ秒 |
40.37ミリ秒 |
+ 13.02ミリ秒 |
+ 47.63% |
|
p95 |
30.32ミリ秒 |
44.42ミリ秒 |
+ 14.10ミリ秒 |
+ 46.50 % |
|
最大(Max) |
32.58ミリ秒 |
48.66ミリ秒 |
+ 16.08ミリ秒 |
+ 49.37% |
アイデア 3:view.drawHierarchy(in:afterScreenUpdates:) を置き換える
Setup のブロックを最適化できたので、次は Draw を改善します。
インスタンスメソッド view.drawHierarchy(in:afterScreenUpdates:) を使うと、現在のコンテキストに完全なビュー階層のスナップショットを簡単にレンダリングできます。
ビュー階層は UIView インスタンスのツリーで構成されており、それぞれが CALayer インスタンスのツリーに支えられていて、そこから layer.render(in:) というメソッドを使うことで、自身とサブレイヤーを指定したコンテキストへ直接レンダリングできます。

パフォーマンステストを実行すると、ベースラインと比べてレンダリング時間が速くなっていることも確認できます。
|
120サンプル |
ベースライン |
SGIR + layer.render |
ベースラインとの差(時間) |
ベースラインとの差(時間) |
|---|---|---|---|---|
|
最小(Min) |
145.38ミリ秒 |
18.53ミリ秒 |
-126.85ミリ秒 |
-87.25% |
|
平均(Avg) |
149.82ミリ秒 |
20.74ミリ秒 |
-129.08ミリ秒 |
-86.16% |
|
p50 |
149.21ミリ秒 |
19.84ミリ秒 |
-129.37ミリ秒 |
-86.70% |
|
p75 |
151.95ミリ秒 |
22.42ミリ秒 |
-129.53ミリ秒 |
-85.25% |
|
p95 |
155.35ミリ秒 |
24.66ミリ秒 |
-130.69ミリ秒 |
-84.13% |
|
最大(Max) |
155.83ミリ秒 |
24.92ミリ秒 |
-130.92ミリ秒 |
-84.01% |
しかし、さらに興味深いのは view.drawHierarchy と比較した場合です。
|
120サンプル |
SGIR(2x)+ view.drawHierarchy |
SGIR(2x)+ layer.render |
view.drawHierarchy との差(時間) |
view.drawHierarchy との差(%) |
|---|---|---|---|---|
|
最小(Min) |
14.76ミリ秒 |
18.53ミリ秒 |
+3.77ミリ秒 |
+25.57% |
|
平均(Avg) |
25.42ミリ秒 |
20.74ミリ秒 |
-4.68ミリ秒 |
-18.40% |
|
p50 |
24.56ミリ秒 |
19.84ミリ秒 |
-4.72ミリ秒 |
-19.20% |
|
p75 |
27.34ミリ秒 |
22.42ミリ秒 |
-4.92ミリ秒 |
-18.01% |
|
p95 |
30.32ミリ秒 |
24.66ミリ秒 |
-5.66ミリ秒 |
-18.67% |
|
最大(Max) |
32.58ミリ秒 |
24.92ミリ秒 |
-7.66ミリ秒 |
-23.52% |
メインスレッド上の時間をさらに約 18〜19% 削れそうです。出来すぎた話に聞こえますよね?
実際に出来すぎなのです。
テスト中、layer.render(in:) を使ったレンダリングは不完全であることに気づきました。特に、タブバーで使われているアイコンが、レンダリングされたスクリーンショットにまったく表示されませんでした。

layer.render(in:) のアプローチを使うと、タブバーのアイコンが欠落する
欠落した UI の影響は完全には明らかではなく、私たちはその正確な原因を特定できませんでした(未検証の推測としては、SF Symbols とフォントのレンダリング方法の副作用である可能性があります)。近い将来にわたってこの挙動を新しいデフォルトのレンダリング方法として採用することは見送り、代わりに drawHierarchy を使い続けることにしました。
完全にレンダリングされたフレームよりも、さらに速いレンダリング時間を好む方もいるかもしれません。その場合でも、options.sessionReplay.enableFastViewRendering = true を設定することで、引き続きオプトインできます。
数字がすべてを物語る
この最適化により大幅なパフォーマンス改善が達成され、とりわけ古いハードウェアで最も劇的な効果が得られました。iPhone 8 では、フレームドロップが 1 秒あたり 9〜10 フレームから約 2 フレームへと減少し、これは大きな成果です。
メインスレッドのブロッキング時間は、1 フレームあたり約 155 ミリ秒から約 25 ミリ秒へと改善しました。約 80% の削減であり、リソースが限られたデバイスでも Session Replay のオーバーヘッドを許容できるパフォーマンス予算の範囲内に十分収められます。
より詳細な情報を求めるチーム向けに、分析結果の完全版は GitHub のプルリクエスト #4940 で公開されています。
最適化されたビューレンダラーは、問題を抱えるユーザーに対してパフォーマンス上の利点を最大化しつつ、リスクを最小化するよう設計された慎重なロールアウトプロセスを通じて導入されました。
Sentry Apple SDK v8.48.0 から、新しい実装は options.sessionReplay.enableExperimentalViewRenderer フラグで制御される実験的機能として利用可能になりました。
この実験的アプローチにより、早期導入者は本番環境で新しいレンダラーをテストできました。前向きなフィードバックとテレメトリーデータに基づき、最適化されたレンダラーは v8.50.2 でデフォルト実装になりました。
Session Replay の次は
新しいレンダラーは大多数のシナリオでパフォーマンスを改善しますが、この新しい実装には特定の制約があることを示す新たな報告もすでに届いています。特に、グラフィックス負荷の高いアニメーションを扱う場合や、HDR コンテンツをキャプチャする際に別のカラースペースをサポートする、といった特定のユースケースで顕著です。
これらはすべて非常に良いフィードバックであり、今後のリリースで対応できるよう取り組んでいます。更新情報は GitHub のリリースやブログをご覧ください。あるいは最新の SDK を試して、違いをぜひ確かめてみてください。
Original Page: Boosting Session Replay performance on iOS with View Renderer V2
IchizokuはSentryと提携し、日本でSentry製品の導入支援、テクニカルサポート、ベストプラクティスの共有を行なっています。Ichizokuが提供するSentryの日本語サイトについてはこちらをご覧ください。またご導入についての相談はこちらのフォームからお気軽にお問い合わせください。

