LINEヤフー Tech Blog

LINEヤフー株式会社のサービスを支える、技術・開発文化を発信しています。

LINE iOSアプリの快適なデバッグ体験を求めて - LLDBの安定性とパフォーマンス改善

LINEアプリ開発本部 モバイル・ディベロッパーエクスペリエンスチームの@giginetです。

皆さん、デバッグしてますか? LINE iOSアプリでは、Xcode 16から、デバッガの式評価が失敗したり、非常に長い時間がかかる現象が報告されています。この記事では、その問題に対処すべく巨大なプロジェクトにおけるデバッグ体験の改善について行った取り組みをいくつかご紹介します。

LINEアプリにおける、LLDBの速度と安定性の課題

LINEのiOSアプリは、非常に巨大なプロジェクトです。それ故、単純なデバッグ体験においてもパフォーマンス上の問題が発生しています。例えば、単にある実行ステップにブレークポイントを張り、変数を評価するだけでも、数分程度の時間がかかることがあります。

また、LINE固有の依存管理による課題もあります。LINEは、外部の依存関係が非常に多く、通常のパッケージ管理システムではうまくスケールしないため、外部ライブラリをビルド済みのXCFrameworkとして管理しています。これによって、ビルド時間の大きな削減には成功しましたが、デバッグ含め、開発者体験に影響を及ぼしている点がありました。

LINEアプリのデバッグ体験で起きている問題として、以下が挙げられます。

  • ビルド済みフレームワークのコードをスタックトレース上で確認できない
  • LLDB上の式評価でそもそも解決がうまくいかない
  • 式評価に非常に長い時間がかかる

これらの問題について、1つずつ原因と解決の過程を見ていきましょう。

LLDBの構造 - デバッガと式評価

まず、それぞれの問題を理解するための前提知識として、LLDBの動作原理について見てみましょう。LLDBは、ビルド済みのアプリケーションの挙動を観察するデバッガとしての動作のほか、式評価を実現するためにコンパイラのような機能も有しています。

例えば、デバッガ上でわれわれが日常的に利用するpo(print object)コマンドはその一例です。

(lldb) po self.calculate()
42

このように、変数の表示のみならず、コンソール上でメソッドの実行など、REPLのように扱えるのは、LLDBがコンパイラとしての機能も有しているからです。

LLDBは、デバッグ体験の向上のために、内部ではかなり複雑な仕組みを有しています。そのため、LINEのような複雑な構成を取るプロジェクトでは、その最適化がうまく機能しないことがあります。

healthcheckとロギング

LLDBの問題を検出するのに役に立つコマンドはswift-healthcheckです。

(lldb) swift-healthcheck

もし、プロジェクトの構成やデバッグシンボルの解析に問題がある場合は、このコマンドで問題の検出を行うことができます。

また、LLDBのログを精査してみることも、パフォーマンスの計測や、エラーが出ていないことを確認するために役立ちました。

ログはlog enableコマンドで有効にすることができます。

(lldb) log enable lldb default -T -f /tmp/lldb.log 
(lldb) log enable dwarf default -T -f /tmp/dwarf.log

-Tオプションで、ログの各行にタイムスタンプを追加することができます。これはパフォーマンスの計測に有用です。

この設定では、大量のログが流れるため、tailコマンドでアプリの実行時に確認するのがオススメです。

$ tail -f /tmp/lldb.log /tmp/dwarf.log

ログはカテゴリを指定することで、特定のログのみを出力することもできます。

毎回コンソールで実行するのは手間なので、LLDBの起動時に読み込まれる.lldbinitで指定しておくと便利です。.lldbinitは、Xcodeのビルドスキームで指定することができます。

.lldbinitのXcodeからの設定方法

ちなみに、LLDBは設定の一部をキャッシュするようで、設定を変更して試行錯誤しても前の設定が残ってしまっていることがありました。.lldbinitの冒頭で設定を全てクリアすることで、不要なハマりを防ぐことができました。

# .lldbinit
# 最初にデバッガの設定を全てクリアする
settings clear -a

リモートキャッシュシステムによるデバッガへの影響

ここからは、実際にLINEプロジェクトで起きていた問題と、解決策について見ていきましょう。

スタックトレース上でソースコードが見られない

ビルド済みXCFrameworkの利用は依存関係のビルドが不要になる反面、ビルドしたマシンと、ビルド済みのフレームワークを利用するマシンの間での環境(例えばソースコードのパス)の違いにより、問題が起きることがあります。

例えばシンボリケーションへの影響です。LLDBを用いてブレークポイントで停止したときに、ビルド済みフレームワーク上の実装が不明なアドレスとして表示される場合があります。

リモートビルドしたバイナリはスタックトレース上でソースを見ることができない

これでは、開発中にライブラリ内部の実装を見たいときに不便です。このとき、何が起きているのでしょうか。ビルド済みバイナリの中身をのぞいてみましょう。

dsymutilは、バイナリ中のデバッグシンボルを抽出するためのユーティリティです。このコマンドにより、バイナリ中にビルド時のソースパスが埋め込まれていることがわかります。

$ dsymutil -s Algorithms.xcframework/ios-arm64/Algorithms.framework/Algorithms | grep N_SO
[  5750] 0005f46a 64 (N_SO         ) 00     0000   0000000000000000 '/buildserver/path/.build/checkouts/swift-algorithms/Sources/Algorithms/'
[  5751] 0005f4b7 64 (N_SO         ) 00     0000   0000000000000000 'Chunked.swift'

これはバイナリに埋め込まれたシンボルと、実際のソースコード上の位置を紐付けるための情報です。これにより、ブレークポイントで停止したとき、スタックトレース上で対応するソースコードを閲覧することができます。これを修正するには、デバッガに実行時の環境にあるソースコードのパスを伝えなくてはなりません。

ここで利用するコンパイラオプションが-file-prefix-map(-debug-prefix-map)です。これをビルド時に指定することにより、埋め込まれるパスのprefixを任意の文字列に設定することができます。まず、これを利用して、置換用の文字列をバイナリに埋め込みます。

ビルド時(ビルドサーバー)

OTHER_SWIFT_FLAGS="-file-prefix-map $PWD=/XCFRAMEWORK_BUILDROOT"

今度は逆にLLDBの実行時に、この任意の文字列に実行環境でのcheckoutディレクトリを指定してパスを解決します。これには通常、.lldbinitでオプションを指定します。

実行時(開発マシン)

(lldb) settings set target.source-map /XCFRAMEWORK_BUILDROOT/.build/checkouts/ /path/to/repository/.build/checkouts/

source-mapを指定し、ソースと対応づけられた

これによって、スタックトレース上の表示を正しく修正することができました。

式評価の際に再コンパイルがうまくいかない

式評価時にもリモートキャッシュの利用が問題になることがあります。ある式を評価したときに、以下のようなエラーが起きることがありました。

(lldb) po result
error: Couldn't IRGen expression, no additional error

これもリモートキャッシュに起因する問題です。LLDBは、通常、フレームワークに含まれる*.swiftmoduleを読み込み、ビルド時のオプションを再現して再コンパイルを行います。しかし、ここにも、ビルド時のFramework Search Pathなど、ビルドサーバーの絶対パスが含まれているため、利用側の環境ではうまく動作しないことがあります。

そこで、ビルド時にSWIFT_SERIALIZE_DEBUGGING_OPTIONS=NOに設定することで、パスなどのメタデータをフレームワーク中にシリアライズしないようにします。

フレームワークの利用側では、取り除いたオプションの代わりに、LLDBに実行マシン上でのパスを代わりに渡す必要があります。

# .lldbinit
settings set target.swift-extra-clang-flags "-F/path/to/repository/XCFrameworks"
settings set target.swift-framework-search-paths /path/to/repository/XCFrameworks
settings set target.swift-module-search-paths ...

これにより、リモートキャッシュを利用した場合でも、LLDBによる式評価のビルドを修正することができます。

こちらの記事で、同様の状況が詳しく解説されています。

How to Fix LLDB: Couldn't IRGen Expression | steipete's blog

また、これらの手法は、WWDC22のセッション、Debug Swift Debugging with LLDBでも紹介されています。

モジュール情報の欠損によるデバッグ情報解析の失敗

Xcode 16以降、何カ所かのブレークポイントで変数を評価してみると、以下のエラーが出力されることがありました。

error: type for self cannot be reconstructed: type for typename "$..." was not found (cached)
error: Couldn't realize Swift AST type of self. Hint: using `v` to directly inspect variables and fields may still work.

ここで、先ほど紹介したswift-healthcheckを実行してみると、以下のようなエラーが確認できます。

SwiftASTContextForExpressions(module: "ModuleName", cu: "MyFile.swift")::MyMethod() -- Missing Swift module or Clang module found for "ModuleName", "imported" via SwiftDWARFImporterDelegate. Hint: Register Swift modules with the linker using -add_ast_path.

このエラーによると、LLDBが*.swiftmoduleを見つけられず、デバッグシンボルの解析に失敗しているようです。最近のXcodeから、この問題が頻発するようになりました。

大抵は、特に何もせずとも、Xcodeが自動的にモジュール情報をリンクしてくれるのですが、プロジェクト構成によっては、うまくリンクされていないことがあるようです。

この状態を解消するために、ヒント通りにリンカ(ld)のフラグに-add_ast_pathを指定してswiftmoduleを与えてみましょう。

OTHER_LDFLAGS = -Wl,-add_ast_path,$(TARGET_BUILD_DIR)/.framework/Modules/.swiftmodule/$(NATIVE_ARCH_ACTUAL)-$(LLVM_TARGET_TRIPLE_VENDOR)-$(SHALLOW_BUNDLE_TRIPLE).swiftmodule  

LINEプロジェクトでは、プロジェクト内のtargetや、XCFramework併せて800個ほどのモジュールがありました。そのため、リンクされるモジュールをあらかじめ探索し、XCConfigを生成し、全てのswiftmoduleをリンク時に結合することにしました。

このオプションが効いているかは、最終的なアプリのバイナリの中身を探索することで確認できます。

dsymutils -s $PRODUCT_DIR/LINE.app/LINE | grep ".swiftmodule"

これに似た事例は、以下の記事でも紹介されています。

解决XCode lldb的问题问题: Xcode lldb 调试 po时报错 error: type for self - 掘金

これにより、ある程度は上記のエラーを抑制できましたが、まだいくつかのケースで同様の問題が起きていて、完全には解決できていません。Apple Forum上の議論によると、Xcode 16以降のバグである可能性が高く、16.3 Beta時点でも解消していないようです。

Breakpoint issue: 'self cannot be … | Apple Developer Forums

LLDB設定によるデバッグ速度の改善

最後に、LLDBの式評価が非常に低速な問題について改善していきましょう。

ここまで見てきたように、LLDBは柔軟な式評価を実現するために、コンパイラのサブセットを持っているため、そもそものビルドが遅い場合、この挙動はある程度は避けられません。しかし、あまりにも体験が悪いため、LLDBのオプションを変更することで高速化が図れないか探ってみました。

いくつか有効そうなオプションを探してみましょう。settings listコマンドで、LLDBのオプション一覧を確認できます。

(lldb) settings list

LLDBのオプション例

これらの多くのオプションは資料がないため、ログを駆使しながら、泥臭くそれぞれのパフォーマンスを計測していく必要があります。

こちらの記事によると、target.memory-module-load-levelsymbols.use-swift-clangimporterの設定で効果が見られたようです。

ios - Swift first debugging breakpoint slow problem solution | Youku Swift practice - 个人文章 - SegmentFault 思否

LLDBは、内部的にシンボルの探索にSpotlightを使うなど、非常に複雑な手順を踏んでデバッグ情報を補完しようとしています。これらの挙動がパフォーマンスに影響している可能性もあります。各オプションについて、詳細にパフォーマンスを計測するところまでは踏み込めませんでしたが、有効なオプションを見つけたらぜひ教えてください。

デバッグ体験改善の道は遠い

今回紹介した試行錯誤の結果、デバッガの安定性向上には一定の効果が出ましたが、Xcode 16からの速度のデグレーションには大きな効果を得られませんでした。Appleによるデバッガの改善を待つしかない部分もあるので、今後の動向を注視していきたいと思います。

最後に、これらは似たような概念が多く、混同しやすいので今回紹介した手法をもう一度まとめておきます。ぜひ参考にしてみてください。

  • リモートキャッシュ利用時の挙動を改善する
    • デバッガの挙動改善 -file-prefix-map, source-mapを利用して、ビルドサーバーのファイルパスを置き換える
    • 式評価の挙動改善
      • SWIFT_SERIALIZE_DEBUGGING_OPTIONSNOに設定して、ビルド時のオプションをバイナリに埋め込まない
      • lldbinitで代替のビルドコンテキストを与える
  • モジュール情報の欠損によるデバッガの安定性、速度低下を改善する
    • -add_ast_path を利用して、欠損しているswiftmoduleを与える
  • LLDBの設定変更により、デバッグ速度を改善する

LINEヤフーは、4/9〜4/11に開催されるtry! Swift Tokyo 2025にブースを出展します。ぜひ現地で情報交換をしましょう!

try! Swift Tokyo