はじめに
こんにちは、iOSエンジニアの内田です。
本記事では、一部端末でのみ発生したクラッシュ、およびその原因となっていた描画パフォーマンスの問題と対策について紹介します。 一見無害なシンプルなビューの組み合わせが複合要因により予期しない問題を引き起こし、原因の特定が困難だった事例のため、同様の問題に遭遇する可能性がある開発者の方々の参考になれば幸いです。
問題の本質 - シンプルな組み合わせが引き起こす複合要因
発生した問題
一部端末においてアプリを起動しても画面が真っ白で何も表示されず、しばらくするとクラッシュしてアプリが利用できなくなる不具合が発生しました。 当初は手元の環境では再現せず原因不明でしたが、その後低電力モードで再現することが判明し調査を進めた結果、iOS 18.4の低リソース状態の端末において、以下の画面構成で描画パフォーマンスが大幅に悪化することが判明しました。
NavigationStack {
Text(longJapaneseTextIncludingLineBreaks) // 改行を含む長い日本語の文章
NavigationLink {...} label: {...}
}
NavigationStack- 改行を含む長い日本語の文章を表示する
Text NavigationLink
- 改行を含む長い日本語の文章を表示する
という一見どこにでもありそうで無害に思える画面構成ですが、この組み合わせだけで描画パフォーマンスが著しく悪化することがわかりました。 長い日本語文章というだけでなく、改行が含まれるという点も重要です。
クラッシュの仕組み
iOSには、アプリの起動時間が長い場合にOSがアプリを強制終了する仕様があります。
参考: Addressing watchdog terminations
上記の画面構成が起動時の最初の画面だったため、起動に時間がかかるようになっていました。 そして一部の低リソース状態の端末では起動時間が閾値を超え、クラッシュという結果になっていたのです。
低リソースの時のみ再現するという点に加え、起動時Firebaseにログを送り始める前のタイミングでクラッシュしておりログが送られていませんでした。 発生割合が低いためか、App Store Connectのクラッシュレポートにもそれらしきものはなく、原因特定に時間を要しました。
対策
まず暫定対策をリリースして効果を確認した後、恒久対策を実施するという2段階のアプローチを取りました。 ここでは恒久対策の内容とその検討過程を紹介します。 恒久対策を考える上で、以下の点を重視し検討を進めました。
- 推測するな計測せよ: 感覚による推測ではなく定量的なデータに基づいて判断する
- できる限りの根拠を集める: 根本原因を理解し説明責任を持てる対策を行う
Instrumentsを用いた計測
まずはパフォーマンスを悪化させている根本原因を特定するため、まずInstrumentsを用いて計測しました。 基本的な手順はInstruments Tutorials | Apple Developer DocumentationのChapter 1と同じです。
計測の結果、初期フレーム描画に時間がかかっていることは判明しましたが、明確なボトルネックは見つかりませんでした。 当初は大きなボトルネックがあると推測していましたが、実際には複合要因でパフォーマンスが悪化していることがわかりました。
次に、初期フレーム描画までの各処理にかかっている時間を地道に確認した結果、最も処理時間を占めるメソッドを特定することができました。
[_NSOptimalLineBreaker _calculateOptimalWrappingWithLineBreakFilter:]という処理でしたが、ここで問題が。
このメソッドは非公開APIでありドキュメントが存在しないため、詳細がわからないのです。
AndroidのText描画の仕組みを参考に推測
非公開APIである以上、クラス名・メソッド名から問題を推測するしかありません。 改行し最適なレイアウトを計算する処理だと推測できますが、ここでさらにAndroidのText描画の仕組みも問題を理解するための補助となりました。
これらの情報を基に、
- Textで改行を含む長い日本語の文章の描画に時間がかかる
- iOS 18.4でNavigationStackの挙動が変わり、Textの描画にさらに時間がかかるようになった
という仮説に、最大限の根拠を持つことができるようになりました。 また複数の修正案がありましたが、それらの修正案が問題に適切に対処しているであろうことも確認できました。
恒久対策の決定
複数の修正案がありましたが、ADR (Architectural Decision Record) を作成して、テキストを改行ごとに分割しそれぞれTextで表示する方式を採用することを決めました。
パフォーマンスの改善度合いに加え、なるべくApple純正のコンポーネントを標準通り利用し、OS機能への適合性を重視することにしたためです。
NavigationStack {
let lines = longJapaneseTextIncludingLineBreaks.components(separatedBy: .newlines)
ForEach(lines, id: \.self) { line in
Text(line)
}
NavigationLink {...} label: {...}
}
まとめ
本記事では、NavigationStack + 改行を含む長い日本語Text + NavigationLinkという一見無害な組み合わせが、低リソース状態の端末で描画パフォーマンスを悪化させ、起動時のクラッシュを引き起こした問題とその対策を紹介しました。
Instrumentsによる定量的な計測により根本原因を特定し、テキストを改行ごとに分割して表示する方式で恒久対策を実施しました。
この事例が皆様の参考になれば幸いです。