メインコンテンツへスキップ
  1. 記事一覧/

iOSアプリのデバッグ:Xcodeとシミュレータで普段やっていること

· loading · loading ·
仁才徳
著者
仁才徳
韓国ソウル在住のリーダー兼ソフトウェアエンジニア
目次

デバッグには本当に時間を取られます。誰でもそうだと思います。最近は自分のアプリのバグ追いでXcodeとシミュレータにこもる日が多く、そのたびに思うのは、Xcodeにはほとんど誰も触らない強力なデバッグ機能が山ほどある、ということです。この記事では、長年かけて落ち着いた自分のデバッグ手順のうち、実際に時間の節約になっている部分をまとめます。

ブレークポイントは思っているより強力
#

ほとんどの人は、ガターをクリックしてブレークポイントを置いて終わりです。でも右クリックしてみると、デバッグのやり方が変わるくらいのオプションが出てきます。

条件付きブレークポイント
#

1000件を処理するループで、347番目だけがおかしいとします。346回もブレークポイントで止まるのは時間の無駄です。

for i in 0..<users.count {
    processUser(users[i])  // ここにブレークポイントを設定、条件: i == 347
}

ブレークポイントを右クリックして条件に i == 347 を入れれば、Xcodeは条件が真のときだけ止まります。userId == "abc123"error != nil のような条件でも同じです。

シンボリックブレークポイント
#

メソッドがどこから呼ばれているのか分からないときに使います。ブレークポイントナビゲータ(Cmd+8)で「+」ボタンをクリックし、「Symbolic Breakpoint」を選びます。

どのビューコントローラがいつロードされるか全部見たければ、シンボルを viewDidLoad にします。Xcodeがすべての呼び出しで止まります。うるさすぎるなら MyViewController.viewDidLoad のように絞り込みましょう。

私が常設しているのはこの3つです。

  • UIViewAlertForUnsatisfiableConstraints:Auto Layoutの競合をその場で捕まえる
  • objc_exception_throw:クラッシュする前にObjective-Cの例外で止まる
  • malloc_error_break:メモリ割り当ての問題を見つける

例外ブレークポイント
#

新しいプロジェクトで最初にやるべき設定だと思います。ブレークポイントナビゲータで「+」→「Exception Breakpoint」を追加しておくと、アプリが落ちた後ではなく、何かがスローされた瞬間にXcodeが止まってくれます。

コンソールに謎のクラッシュとしてしか出てこないnilアンラップの問題を、これで何度も捕まえてきました。

ブレークポイントアクション
#

ブレークポイントは実行を止めなくてもいいんです。ブレークポイントを編集して「Add Action」をクリックすると、こんなことができます。

  • 止めずに変数を出力する:"User count: @(users.count)@"
  • LLDBコマンドを自動実行する
  • シェルスクリプトを実行する
  • 音を鳴らす(めったに通らないコードパスの確認に使っています)

「Automatically continue after evaluating actions」にチェックを入れれば、ブレークポイントはフローを止めないロガーになります。

LLDB:実際に使うコンソールコマンド
#

ブレークポイントで止まったとき、下のコンソールはクラッシュログを眺めるだけの場所ではありません。あれはLLDBで、本当に便利です。

基本
#

(lldb) po user
▿ User
  - id: "123"
  - name: "John Doe"
  - email: "john@example.com"

po はオブジェクトを読みやすい形で出力します。私のLLDB利用の9割はこれです。

もう少し詳しく見たいときは p を使います。

(lldb) p user.name
(String) $R0 = "John Doe"

ローカル変数を一度に全部見るなら、こうです。

(lldb) frame variable

実行中に値を変える
#

LLDBの本領はここです。再コンパイルせずに変数を書き換えられます。

(lldb) expr user.name = "Jane Doe"
(lldb) expr index = 0

バグを見つけて、リビルドせずに修正を試したい。そんなときは変数を変えてそのまま続行すればいいわけです。エッジケースを絞り込むとき、いつもこれで値を差し替えています。

メソッドの呼び出しもできます。

(lldb) po self.refreshUI()
(lldb) expr navigationController?.popViewController(animated: true)

ナビゲーション
#

(lldb) bt              # コールスタックを表示
(lldb) frame select 3  # 別のスタックフレームにジャンプ
(lldb) continue        # 実行を続ける(または単に 'c')
(lldb) n               # ステップオーバー(次の行)
(lldb) s               # 関数にステップイン

ウォッチポイント
#

変数がいつ変わるのか知りたければウォッチポイントです。

(lldb) watchpoint set variable user.isLoggedIn

コードのどこから変更されても、その瞬間に実行が止まります。想定外の状態変更を追うときには手放せません。

Console.app:隠れたデバッグツール
#

Xcodeのコンソールで十分な場面は多いですが、システムログもクラッシュレポートも全部見たい、となったらConsole.appの出番です。

OSLogを使っていれば(使うべきです)、Console.appでログを検索したりフィルタしたりできます。

import os.log

let logger = Logger(subsystem: "com.example.app", category: "networking")

logger.info("Starting API request")
logger.debug("Request URL: \(url.absoluteString)")
logger.error("Failed to decode: \(error.localizedDescription)")

Console.app側の手順はこうです。

  1. シミュレータか接続中のデバイスを選ぶ
  2. サブシステムで絞る:subsystem:com.example.app
  3. レベルで絞る:subsystem:com.example.app AND level:error

よく使うフィルタ述語は保存できます。私はネットワークリクエスト用、データベース操作用、エラーと警告だけを出す用の3つを持っています。

print()ではなくOSLogを使う理由
#

OSLogは、print() をあちこちに撒くよりずっとましです。構造化されているのでレベルやカテゴリで絞り込めますし、ログ自体が最適化されていてアプリを遅くしません。本番では機密データを自動でマスクしてくれて、アプリが落ちた後もログは残ります。

とっさの確認には print() で構いません。後で見返す可能性があるものはOSLogに残しましょう。

Console.appの高度なフィルタリング
#

Console.appはかなり凝った述語クエリに対応しています。私がよく使うのはこのあたりです。

# アプリ内のすべてのエラーと障害
subsystem:com.example.app AND (level:error OR level:fault)

# 失敗したネットワークリクエスト
subsystem:com.example.app AND category:networking AND eventMessage CONTAINS "failed"

# 過去5分間のすべて
subsystem:com.example.app AND timestamp >= now(-5m)

ログはエクスポートしてバグレポートに添付することもできます。Xcodeのコンソールからコピペするよりずっと楽です。

ビューデバッグ
#

UIが壊れているのに原因が分からないとき、いちばん早いのはビューデバッガです。

アプリを実行して壊れた画面まで進み、Xcodeのデバッグバーで「Debug View Hierarchy」をクリックします(またはCmd+Shift+D)。ビュー階層全体が3Dで分解表示されます。

回してみると、いろいろ見えてきます。

  • 他のビューの裏でレンダリングされているビュー
  • 画面のはるか外にいるビュー
  • サイズがゼロのビュー
  • 選択したビューの制約チェーン全体

私はこれで、タップイベントを吸い込んでいた透明なボタンや、0x0でレンダリングされていたラベル、いつの間にか幅10,000ピクセルになっていた画像を見つけたことがあります。

Auto Layoutのデバッグ
#

ビュー階層に紫の警告アイコンが出ていたら制約の競合です。クリックすると、どの制約同士がぶつかっているのかXcodeが正確に教えてくれます。

制約には識別子を付けておきましょう。

heightConstraint.identifier = "ProfileImageHeight"

制約が壊れたとき、エラーにメモリアドレスではなく "ProfileImageHeight" が出るようになります。ログの読みやすさが段違いです。

LLDBから制約ツリーを出力することもできます。

(lldb) po view.hasAmbiguousLayout
(lldb) po view._autolayoutTrace()

時間を節約してくれるシミュレータ機能
#

スローアニメーション
#

Debug → Slow Animations で、すべてが1/10の速度になります。トランジション中に何が起きているのか、アニメーションがなぜ変に見えるのかを確かめるのにちょうどいいです。

メモリ警告のシミュレート
#

Debug → Simulate Memory Warning。メモリ不足のときのアプリの挙動をテストできます。これで画像キャッシュのバグをいくつも見つけました。メモリ警告が来るまでは普通に動いているのに、来た途端に画像が全部消えるやつです。

Network Link Conditioner#

Xcode → Open Developer Tools → Network Link Conditioner

3GやLTE、高パケットロス、完全オフラインをシミュレートできます。WiFiでは平気なAPIリクエストがモバイル回線でタイムアウトする、という経験があるなら、原因はこれで再現できます。

私は遅延500ms・パケットロス10%の「Bad Network」プロファイルを常備しています。速いオフィスWiFiでは絶対に出ない、タイムアウトのバグやローディング表示の問題が引っかかります。

ステータスバーのオーバーライド
#

シミュレータのステータスバーを右クリックすると、時刻、バッテリー残量、電波強度、キャリアを上書きできます。スクリーンショットを揃えたいときや、バッテリー残量ごとのUI確認に便利です。

位置情報のシミュレーション
#

Debug → Location で、机から動かずにいろいろな場所を再現できます。任意の座標、街歩き、高速道路の走行まで一通り揃っています。位置情報を使う機能のテストや、特定の地域でしか起きない不具合の調査で重宝します。

メモリデバッグ
#

Debug Memory Graph
#

Debug Memory Graphボタン(Cmd+Shift+M)を押すと、メモリ上のすべてのオブジェクトとその参照関係、そして肝心の「どれがリークしているか」をXcodeが見せてくれます。

紫のビックリマークがリークです。クリックすると保持サイクルが見えます。

いちばんよく見かけるリークはこれです。

class ViewController: UIViewController {
    var onComplete: (() -> Void)?

    func setupHandler() {
        onComplete = {
            self.dismiss(animated: true)  // ❌ selfを強く参照
        }
    }
}

weak selfで直します。

onComplete = { [weak self] in
    self?.dismiss(animated: true)  // ✓ 保持サイクルなし
}

デリゲートもweakにしておきます。

weak var delegate: ManagerDelegate?  // 単なる 'var' ではなく

Instruments
#

本腰を入れてメモリを調べるならInstrumentsです(Product → Profile、またはCmd+I)。

Allocationsを使うと、オブジェクトの割り当てすべて、時間経過に伴うメモリの増え方、どのクラスがメモリを食っているか、割り当てが起きた場所のスタックトレースまで見えます。

私が探すのはだいたい、時間に比例して増え続けるメモリ(ほぼリークです)、想定外に大きい割り当て(画像1000枚をうっかり一気に読んでいないか?)、解放されているはずなのに残っているオブジェクト、この3つです。

操作の前後で世代をマークして(小さな旗のボタン)、残ってはいけないものが残っていないか確認しましょう。

パフォーマンスプロファイリング
#

Time Profiler
#

Product → Profile → Time Profiler。アプリで重い操作をしながら記録して、コールツリーを読みます。

「Self Weight」でソートするとボトルネックが見つかります。ある関数のself weightが40%なら、時間はそこに消えています。

以前、JSONのパース関数が毎秒1000回呼ばれているのを見つけたことがあります。バックグラウンドキューに移したら、UIのカクつきが止まりました。

Main Thread Checker
#

バックグラウンドスレッドでのUI更新は、Xcodeが自動で捕まえてくれます。こんな表示が出たら、

Main Thread Checker: UI API called on a background thread: -[UILabel setText:]

こういうことをやってしまっています。

URLSession.shared.dataTask(with: url) { data, response, error in
    self.label.text = "Loaded"  // ❌ クラッシュ!
}

直すとこうなります。

URLSession.shared.dataTask(with: url) { data, response, error in
    DispatchQueue.main.async {
        self.label.text = "Loaded"  // ✓
    }
}

ランタイム診断
#

Edit Scheme → Run → Diagnostics。ここに並んでいるチェックボックスは、手作業では絶対に見つからないバグを捕まえてくれます。

Address Sanitizer
#

メモリ破壊を見つけます。use-after-free、バッファオーバーフロー、メモリリーク。アプリは2〜3倍遅くなりますが、他の方法ではほぼ追跡不可能なバグを捕まえてくれます。たまにしか起きないクラッシュを追うときにオンにします。

Thread Sanitizer
#

データ競合とスレッド絡みの問題を捕まえます。有効にしてアプリを普通に触っているだけで、競合状態があればThread Sanitizerが見つけてくれます。

Address SanitizerとThread Sanitizerは同時に使えないので、妙なクラッシュを追うときは交互に切り替えています。

Zombie Objects
#

解放済みオブジェクトへのメッセージを捕まえます。有効にしておくと、解放されたメモリに触った瞬間をXcodeが正確に教えてくれます。

*** -[MyViewController viewDidLoad]: message sent to deallocated instance 0x600001234000

つけっぱなしは禁物です。オブジェクトが解放されなくなるので、メモリは増える一方になります。ただ、特定のuse-after-freeバグを突き止めるには最高です。

よくあるデバッグシナリオ
#

起動時にクラッシュする
#

まず例外ブレークポイントを追加します。たいていはこれで捕まります。

だめなら、Info.plistのキー漏れ、初期化コードでの強制アンラップ、依存性注入の失敗あたりを疑います。

UIが更新されない
#

毎回、原因はこのどれかです。

  1. 更新がメインスレッドで行われていない
  2. アウトレットがつながっていない
  3. ビューがそもそも表示されていない(LLDBで po view.window を確認。nilなら階層に入っていません)

テーブルビューやコレクションビューなら、reloadData() の呼び忘れです。

メモリ警告でアプリが落ちる
#

Instruments Allocationsで何がメモリを掴んでいるか確認します。たいていは、キャッシュから解放されない画像、解放されないビューコントローラ(保持サイクル)、際限なく育つ配列や辞書のどれかです。

シミュレータでメモリ警告をシミュレートして、クリーンアップ処理をテストしておきましょう。

原因不明のクラッシュ
#

サニタイザーを全部オンにします。間欠的ならたぶんスレッドの問題なのでThread Sanitizerで、システムフレームワーク内で落ちているならたぶんメモリ破壊なのでAddress Sanitizerで走らせます。

シミュレータでOTA更新をデバッグする
#

私のアプリはover-the-air更新を配信しているのですが、このフロー全体のデバッグにはシミュレータがいちばん手軽です。

まずネットワークトラフィックを見ます。アプリのサブシステムで絞ったConsole.appに、マニフェストのリクエストとレスポンスがリアルタイムで流れてくるので、どのヘッダーを送っていてサーバーが何を返しているかが正確に分かります。

次に、わざとネットワークを悪くします。Network Link Conditionerで、遅い回線やパケットロスのときに更新フローがどう振る舞うかを確かめます。アプリは固まらないか。ローディング表示は出るか。ちゃんとフォールバックするか。

更新チェックの周辺にはログを仕込みます。チェック開始時、更新があったとき、ダウンロード開始時、新しいバンドルのロード時、それぞれで記録しています。

import os.log

let logger = Logger(subsystem: "com.example.app", category: "updates")

logger.info("Checking for updates...")
let update = try await Updates.checkForUpdateAsync()
logger.info("Update available: \(update.isAvailable)")

最後に、マニフェストのエンドポイント単体もテストしておくと安心です。アプリが送るのと同じヘッダーでcurlできます。

curl -H "expo-protocol-version: 1" \
     -H "expo-platform: ios" \
     -H "expo-runtime-version: 1.0.0" \
     https://your-server.com/api/expo-updates/manifest/

クライアント側を掘り始める前に、サーバーが正しいデータを返しているかをここで確認できます。

サードパーティツール
#

Charles Proxy / Proxyman
#

ネットワークトラフィックの全表示、リクエストとレスポンスの改変、エラー条件のテスト。APIデバッグには欠かせません。

ProxymanはCharlesよりUIがよく、macOSに馴染む感じがします。去年乗り換えて、戻る気はありません。

Reveal
#

Xcodeのビューデバッガに似ていますが、もっと強力です。Xcodeにつながずに実機で動くので、テスターの端末でレイアウト問題を追うのに向いています。

実機でのデバッグ
#

ワイヤレスデバッグ
#

一度だけUSBでつなぎ、Window → Devices and Simulators で「Connect via network」にチェックを入れて、ケーブルを抜きます。同じWiFiにいる限り、デバイスはXcodeから見え続けます。

Device Console
#

Window → Devices and Simulators → Open Console。システムログ、アプリログ、クラッシュレポートと、何でも見えます。Xcode側のコンソールよりずっと詳細です。

テスターから「アプリが落ちた」と言われたら、必ずデバイスをつないでコンソールログを取らせてもらいます。すべてを説明してくれるシステムレベルのエラーが残っていることが多いんです。

実際のデバッグ手順
#

  1. 例外ブレークポイントがなければ追加する
  2. バグを再現する
  3. 怪しい場所の近くにブレークポイントを置く
  4. LLDBの po で値を確認する
  5. 変数を書き換えて、再コンパイルせずに修正を試す
  6. UI絡みならビュー階層を見る
  7. パフォーマンス絡みならInstrumentsでプロファイルする
  8. メモリかスレッド絡みならサニタイザーをオンにする

コツは症状から逆算することです。クラッシュ?例外ブレークポイント。遅い?Time Profiler。メモリ?Instruments。UIが変?ビューデバッガ。

もっと早く知りたかったこと
#

アサーションを使いましょう。ロジックの誤りがバグになる前に捕まります。

assert(users.count > 0, "Users array should never be empty here")

デバッグを始める前にコミットしておくこと。仮説を試すためにあちこち書き換え始めると、必ず戻したくなります。

終わったらprint文は消すこと。少なくとも #if DEBUG で囲んでおきましょう。古いデバッグログは、次のバグを見えにくくします。

LLDBはちゃんと覚えること。アプリを20回リビルドするより速いです。

そして、シミュレータは本物の電話ではありません。実機でしか出ないバグは、ほぼ例外なくスレッドかメモリの問題です。あるいはパフォーマンスの問題。シミュレータはiPhoneのチップではなくMacのCPUで動いていますから。

常に開いているツール
#

  • Xcode(当然)
  • アプリのサブシステムで絞ったConsole.app
  • ネットワークデバッグ用のProxyman
  • 事態が深刻になってきたらInstruments

どのツールも万能ではありません。ビューデバッガはメモリリークを見つけてくれませんし、InstrumentsはAuto Layoutの競合を解いてくれません。結局のところ、腕の見せどころは症状に合ったツールを選ぶことで、それは回数をこなすうちに身につきます。保持サイクルを何度も踏めば、スタックトレースを見た瞬間に匂いで分かるようになります。デバッグが楽しくなることはたぶんないですが、少なくとも苦行ではなくなります。