今日の共有内容は:基礎篇—— リリース段階で、主にクラッシュ、カクつき、メモリ、ログ、パフォーマンス、スレッド🧵、およびバッテリー🔋の監視についてです。
12 | iOS のクラッシュは千差万別、どのように包括的に監視するか?#
まずはいくつかの一般的なクラッシュ原因を見てみましょう:
- 配列の問題:配列の範囲外アクセス、または、配列に nil 要素を追加した場合。
- マルチスレッドの問題:サブスレッドで UI を更新するとクラッシュが発生する可能性があります。例えば、一つのスレッドがデータを空にしている間に、別のスレッドがそのデータを読み取る場合です。
- メインスレッドの応答なし:メインスレッドが応答しない時間がシステムの規定を超えると、Watchdog によって強制終了され、対応する異常コードは 0x8badf00d です。
- 野良ポインタへのアクセス:野良ポインタは削除されたオブジェクトを指しており、最も一般的でありながら最も特定が難しいクラッシュの状況です。
プログラムのクラッシュはユーザーにとって最大の損害をもたらすため、クラッシュ率(一定期間内のクラッシュ回数と起動回数の比率)は最も優先度の高い技術指標となります。
信号キャッチが可能かどうかに基づいて、クラッシュ情報は二つのカテゴリに分けられます:
- 信号キャッチが可能:配列の範囲外アクセス、野良ポインタ、KVO の問題、NSNotification スレッドの問題などのクラッシュ情報。
- 信号キャッチが不可能:バックグラウンドタスクのタイムアウト、メモリが爆発、メインスレッドのカクつきが閾値を超えた場合などの情報。
信号キャッチ可能なクラッシュログの収集#
簡単な方法:Xcode > Product > Archive で、「Upload your app’s symbols to receive symbolicated reports from Apple」にチェックを入れれば、以後 Xcode の Archive でシンボル化されたクラッシュログを見ることができます。
サードパーティのオープンソースライブラリ:PLCrashReporter、Fabric、Bugly。最初のものは自分のサーバーが必要で、後の二つはサーバー開発能力がないか、データに敏感でない会社に適しています。
- 監視の原理:さまざまな信号を登録し、異常信号をキャッチした後、処理メソッド handleSignalException 内で backtrace_symbols メソッドを使用して現在のスタック情報を取得し、スタック情報をローカルに保存します。次回起動時にクラッシュログをアップロードできます。
信号キャッチできないクラッシュ情報の収集#
背景知識:システムの制限により、システムによって強制終了された信号はキャッチできません。
5 つの質問を持って:バックグラウンドでクラッシュしやすい理由は何ですか?バックグラウンドでのクラッシュを避けるにはどうすればよいですか?バックグラウンドで信号キャッチできないクラッシュ情報を収集するにはどうすればよいですか?他に信号キャッチできないクラッシュの状況はありますか?他の信号キャッチできないクラッシュ情報をどのように監視しますか?
(一)バックグラウンドでクラッシュしやすい理由は何ですか?
まず、iOS バックグラウンドの生存方法を 5 つ紹介します:
- バックグラウンドモード:通常、地図、音楽、VoIP 系のアプリのみが審査を通過できます。
- バックグラウンドフェッチ:ウェイクアップ時間が不安定で、ユーザーはシステム設定でオフにできるため、使用シーンは少ないです。
- サイレントプッシュ:サイレントプッシュは、バックグラウンドでアプリを 30 秒間起動します。優先度は低く、application:didReceiveRemoteNotifiacation:fetchCompletionHandler: というデリゲートを呼び出します。通常のリモートプッシュ通知と同様です。
- PushKit:バックグラウンドでアプリを 30 秒間起動します。主に VoIP アプリの体験を向上させるために使用されます。
- バックグラウンドタスク:アプリがバックグラウンドに退くと、デフォルトでこの方法が使用されるため、使用頻度が高いです。
バックグラウンドタスクのこの方法では、システムは beginBackgroundTaskWithExpirationHandler メソッドを提供してバックグラウンド実行時間を延長します。使用方法は以下の通りです:
- (void)applicationDidEnterBackground:(UIApplication *)application {
self.backgroundTaskIdentifier = [application beginBackgroundTaskWithExpirationHandler:^(void) {
[self yourTask];
}];
}
このコードでは、yourTask タスクは最大 3 分間実行され、タスクが完了するとアプリがサスペンドされます。しかし、タスクが 3 分以内に完了しない場合、システムはプロセスを強制終了し、これがアプリがバックグラウンドでクラッシュしやすい理由です。
(二)バックグラウンドでのクラッシュを避けるにはどうすればよいですか?
バックグラウンドでのデータの読み書き操作を厳密に制御します。例えば、処理するデータのサイズを判断し、データが大きすぎる場合、つまりバックグラウンドの制限時間内に処理できない場合は、次回の起動時またはバックグラウンドでのアプリのウェイクアップ時に再処理を検討します。
(三)バックグラウンドで信号キャッチできないクラッシュ情報を収集するにはどうすればよいですか?
バックグラウンドタスク方式を使用する場合、まずタイマーを設定し、3 分に近づいたとき(beginBackgroundTaskWithExpirationHandler がバックグラウンドを 3 分間維持)にバックグラウンドプログラムがまだ実行中かどうかを判断します。まだ実行中であれば、そのプログラムがバックグラウンドでクラッシュしようとしていると判断し、すぐに報告・記録を行います。
(四)他に信号キャッチできないクラッシュの状況はありますか?
主にメモリが爆発した場合と、メインスレッドのカクつきがタイムアウトして Watchdog によって強制終了される場合です。
(五)他の信号キャッチできないクラッシュ情報をどのように監視しますか?
バックグラウンドでのクラッシュ監視と似ており、閾値に近づいたときに処理を行います。詳細は次の 2 つのレッスンで説明します~
収集したクラッシュ情報をどのように分析し、クラッシュ問題を解決しますか?#
クラッシュログには、主に以下の情報が含まれています:
- 異常情報:異常の種類、異常コード、異常が発生したスレッド;
- スレッドのトレース:クラッシュ時のメソッド呼び出しスタック。
- プロセス情報:クラッシュレポートの一意の識別子、一意のキー、デバイス識別子;
- 基本情報:クラッシュが発生した日付、iOS バージョン;
通常の分析プロセス:
- 「異常情報」の異常スレッドを分析し、「スレッドのトレース」で異常スレッドのメソッド呼び出しスタックを分析します。シンボル化されたメソッド呼び出しスタックから、メソッド呼び出しのプロセスを完全に見ることができ、スタックの頂点がクラッシュを引き起こしたメソッド呼び出しです。
- 異常コードを参考にします。ここでは44 種類の異常コードを列挙していますが、一般的な 3 つは:0x8badf00d(アプリが一定時間応答しないために watchdog によって強制終了された、詳細は次のレッスン)、0xdeadfa11(アプリがユーザーによって強制終了された)、0xc00010ff(アプリの実行によりデバイスの温度が高くなりすぎて強制終了された、詳細は 18 のバッテリー最適化のレッスン)。
⚠️:いくつかの問題はスタックだけでは分析できない場合があります。この場合、クラッシュ前のユーザーの関連行動やシステム環境状況のログを利用してさらに分析します。詳細は 15 のログ監視のレッスンで説明します。
考え:どのようにしてクラッシュ情報の収集効率を高め、喪失率を低くすることができるか?どのようにしてより多くのクラッシュ情報を収集できるか?特にシステムによる強制終了によるクラッシュについて。
13 | RunLoop の原理を利用してカクつきを監視するには?#
カクつきの問題とは、メインスレッドがユーザーのインタラクションに応答できない問題で、その原因には:UI の描画量が多すぎる;メインスレッドでネットワークの同期リクエストを行い、大量の I/O 操作を行う;計算量が多く、CPU が持続的に高い占有率を持つ;デッドロックとメイン・サブスレッドのロック競合があります。
NSRunLoopから始めると(スレッドのメッセージイベントは NSRunLoop に依存しています)、メインスレッドで呼び出されたメソッドを知ることができます。NSRunLoop の状態を監視することで、呼び出されたメソッドの実行時間が長すぎるかどうかを発見し、カクつきの状況を監視できます。
次に RunLoop の原理を紹介します~
RunLoop の原理#
目的:処理すべきイベントがあるときはスレッドを忙しく保ち、処理すべきイベントがないときはスレッドをスリープさせます。
タスク:入力ソースを監視し、スケジュール処理を行います。
受け取る 2 種類の入力ソース(入力デバイス、ネットワーク、周期的な時間、遅延時間、非同期コールバック):
- 別のスレッドまたは異なるアプリの非同期メッセージ;
- 予約された時間または繰り返し間隔の同期イベント。
アプリケーションの例:重い、緊急でない、大量の CPU を占有するタスク(例えば画像の読み込み)を空いている RunLoop モードで実行し、RunLoop モードが UITrackingRunLoopMode のときに実行を避けます。 UITrackingRunLoopModeモード:
- ユーザーがスクロール操作を行うときに切り替わる RunLoop モード;
- このモードで重い CPU タスクを実行しないことで、ユーザーの操作体験を向上させることができます。
動作原理:
iOS では、RunLoop オブジェクトは CFRunLoop によって実装されており、全体のプロセスは以下の図にまとめられています。具体的にはCFRunLoop のソースコードを参照してください。
loop の 6 つの状態#
コード定義は以下の通りです:
typedef CF_OPTIONS(CFOptionFlags, CFRunLoopActivity) {
kCFRunLoopEntry , // loopに入る
kCFRunLoopBeforeTimers , // タイマーコールバックをトリガー
kCFRunLoopBeforeSources , // Source0コールバックをトリガー
kCFRunLoopBeforeWaiting , // mach_portメッセージを待つ
kCFRunLoopAfterWaiting , // mach_portメッセージを受信
kCFRunLoopExit , // loopから退出
kCFRunLoopAllActivities // loopのすべての状態変更
}
RunLoop のスレッドがブロックされる状況:
- スリープ前のメソッド実行時間が長すぎる(スリープに入れない);
- スレッドがウェイクアップした後、メッセージ受信時間が長すぎる(次のステップに進めない)。
このスレッドがメインスレッドである場合、カクつきとして現れます。
したがって、RunLoop の原理を利用してカクつきを監視するには、次の 2 つの loop 状態に注目する必要があります:
- kCFRunLoopBeforeSources:スリープに入る前に Source0 コールバックをトリガー;
- kCFRunLoopAfterWaiting:ウェイクアップ後に mach_port メッセージを受信。
カクつきをどのようにチェックしますか?#
三つのステップ:
- CFRunLoopObserverContext オブザーバを作成します;
- オブザーバをメインスレッド RunLoop の共通モードで監視するために追加します;
- メインスレッドの RunLoop 状態を監視するために持続的なサブスレッドを作成します。カクつきの判断:スリープ前の kCFRunLoopBeforeSources 状態、またはウェイクアップ後の kCFRunLoopAfterWaiting 状態が設定された時間閾値内で変化しない場合。
次に、スタック情報をダンプし、カクつきの原因をさらに分析できます。
⚠️:カクつきを引き起こす時間閾値は、WatchDog メカニズムに基づいて設定できます。
- 起動(Launch):20 秒;
- 復帰(Resume):10 秒;
- サスペンド(Suspend):10 秒;
- 終了(Quit):6 秒;
- バックグラウンド(Background):3 分(iOS 7 以前は、毎回 10 分を申請;その後、毎回 3 分を申請し、最大 10 分まで連続申請可能)。
PS:カクつきを引き起こす時間閾値は WatchDog の制限時間より小さくする必要があります。
カクつきのメソッドスタック情報をどのように取得しますか?#
1)システム関数を直接呼び出す(signal を使用してエラー情報を取得)。利点は性能消費が少ないこと;欠点は簡単な情報しか取得できず、dSYM(シンボルテーブルファイル)と組み合わせて問題のコードを特定できないことです。性能が良いため、大規模なカクつき状況の監視には適していますが、カクつきの原因を特定する具体的なシーンには適していません。
2)PLCrashReporterオープンソースライブラリを直接使用します。特徴は問題のコードの具体的な位置を特定でき、性能消費に関しても最適化が行われています。
考え:なぜカクつき監視をオンラインで行う必要があるのか?主に問題をより広範囲に収集するためです。カクつきの問題は、少数のユーザーのデータ異常によって引き起こされることがあります。
関連資料:RunLoop を深く理解する——ibireme
14 | OOM に近づいたとき、詳細なメモリ割り当て情報を取得し、メモリ問題を分析するには?#
OOM(Out of Memory):アプリがメモリを占有し、システムが単一のアプリに対して占有するメモリの上限に達した後、システムによって強制終了される現象です。
- これは iOS の Jetsam メカニズム(オペレーティングシステムがメモリリソースの過度使用を制御するために採用したリソース管理メカニズム)によって引き起こされる「異常な」クラッシュです;
- ログは信号でキャッチできません。
JetsamEvent ログを通じてメモリ制限値を計算する#
携帯電話の JetsamEvent で始まるシステムログを確認します(携帯電話設定 > プライバシー > 分析と改善 > 分析データ)ことで、異なる機種で異なるシステムバージョンにおけるアプリのメモリ制限を理解できます。
上記のシステムログでクラッシュ原因が per-process-limit に対応する rpages に注目します:
- per-process-limit:アプリが占有するメモリがシステムの単一アプリに対するメモリ制限を超えた;
- rpages:アプリが占有するメモリページの数。
⚠️:
- メモリページサイズの値は、ログ内の pageSize 値です。
- 強制終了されたアプリはシステムレベルのログを取得できず、オフラインデバイスを通じてのみ取得できます。
iOS システムの Jetsam 監視:
- システムは優先度の高いスレッド vm_pressure_monitor を起動し、システムのメモリ圧力状況を監視します。すべてのアプリのプロセスを維持するためにスタックを管理します。また、各プロセスのメモリページの消費状況を保存するためのメモリスナップショットテーブルも維持します。
- vm_pressure_monitor スレッドが特定のアプリのメモリに圧力がかかっていることを発見すると、通知を発出し、メモリに圧力がかかっているアプリは対応する didReceiveMemoryWarning デリゲートを実行します(これはメモリを解放する機会であり、アプリがシステムによって強制終了されるのを避けることができます)。
優先度判断基準(システムはアプリを強制終了する前に優先度を判断します):
- カーネル用 > オペレーティングシステム用 > アプリ用;
- フロントグラウンドアプリ > バックグラウンドで実行されているアプリ;
- スレッド使用優先度の際、CPU 占有率が高いスレッドの優先度は低下します。
XNU を通じてメモリ制限値を取得する#
XNU のマクロを通じて memorystatus_priority_entry という構造体を取得することで、プロセスの優先度とメモリ制限値を得ることができます。
⚠️:XNU のマクロを通じてメモリ制限を取得するには root 権限が必要であり、アプリ内の権限は不十分なため、通常の状況下ではアプリ開発者はこの情報を見ることができません...
メモリ警告を通じてメモリ制限値を取得する#
didReceiveMemoryWarning というメモリ圧力デリゲートイベントを利用してメモリ制限値を動的に取得します。デリゲートイベント内で:
- まず、iOS システムが提供する task_info 関数を通じて、現在のタスクの情報(task_info_t 構造体)を取得します;
- 次に、task_info_t 構造体内の resident_size フィールドを通じて、現在のアプリが占有するメモリを取得します。
メモリ問題情報収集の特定#
メモリ使用量を取得するだけでは不十分で、誰がメモリを割り当てたのかを知る必要があります。そうすれば、問題の核心を正確に特定できます。すべての大きなメモリの割り当ては、外部関数がどのようにラッピングされていても、最終的にはmalloc_logger関数を呼び出します。
- メモリ割り当て関数 malloc や calloc などは、デフォルトで nano_zone を使用します;
- nano_zone は 256B 以下の小さなメモリの割り当てを行い、256B を超えるメモリの割り当ては scalable_zone を使用します;
- scalable_zone でメモリを割り当てる関数はすべて malloc_logger 関数を呼び出し、システムはそれを通じてメモリの割り当て状況を統計し管理します。
したがって、fishhook を使用してこの関数をフックし、自分の統計記録を追加することで、メモリの割り当て状況を把握できます。
PS:メモリが大きすぎてシステムによって強制終了される以外にも、以下の 3 つのメモリ問題があります:
- 割り当てられていないメモリへのアクセス:XNU は EXC_BAD_ACCESS エラーを報告し、信号は SIGSEGV Signal #11 です。
- 権限を守らずにメモリにアクセス:メモリページの権限基準は UNIX ファイル権限に似ています。読み取り専用権限のメモリページに書き込もうとするとエラーが発生し、XNU は SIGBUS Signal #7 信号を発出します。
- 割り当てられたがコミットされていないメモリへのアクセス:XNU は物理メモリの割り当てをブロックし、問題のあるスレッドがメモリページを割り当てるとフリーズします。
最初の 2 つの問題はクラッシュログを通じて取得でき、12 のクラッシュのレッスンを参照してください。
15 | ログ監視:アプリ内の全量ログを取得するには?#
背景:前回はクラッシュ、カクつき、メモリ問題の監視について共有しました。問題を監視したら、問題の詳細情報を記録し、ログとして開発者に通知する必要があります。そうすれば、開発者はこれらのログから問題を特定できます。
全量ログの定義:アプリ内で記録されたすべてのログ、ユーザー行動や重要な操作のログを含みます。
全量ログの役割:開発者がさまざまな複雑な問題を迅速かつ正確に特定できるようにし、問題解決の効率を向上させます。
しかし、アプリは複数のチームによって共同開発・維持される可能性が高く、異なるチームが使用するログライブラリは歴史的な理由から異なる場合があります。自分たちで開発したものか、サードパーティのログライブラリを使用しているかのいずれかです。では、どのようにして侵入せずにアプリ内のすべてのログを取得するのでしょうか?
以下にNSLogとCocoaLumberjackログの取得方法を紹介します。この 2 つのログ出力方法は、ほとんどのシーンをカバーしています。
NSLog のログを取得する#
NSLog は実際には C 関数void NSLog(NSString *format, ...);
であり、標準のエラーコンソールとシステムログに情報を出力する役割を持っています。
**NSLog のログをどのように取得しますか?** 方法は 3 つあります:
1)ASL が提供するインターフェースを使用します。
iOS 10 以前、NSLog 内部で使用されていたのは ASL(Apple System Logger、Apple が独自に実装したログ出力システム)API であり、ログメッセージは直接ディスクに保存されていました。
サードパーティライブラリCocoaLumberjackの [DDASLLogCapture start] コマンドを利用して、すべての NSLog のログをキャッチし、CocoaLumberjack のログとして記録します。
キャッチの原理:
- ログが ASL のデータベースに保存されると、syslogd(システム内でログメッセージを受信・配信するためのデーモンプロセス)が通知を発出します。
- notify_register_dispatch を通じてこの通知を登録します。com.apple.system.logger.message(kNotifyASLDBUpdate マクロ)で、ログが ASL データベースに保存されるときに発出されるプロセス間のシステム通知です。
- 通知を受信した後、ASL が提供するインターフェース(CocoaLumberjack が asl_search、asl_next、aslMessageReceived: などのメソッドをラップしています)を利用して新しいログをすべて反復処理し、最終的に CocoaLumberjack のログとして記録します。
主なメソッドのコード実装は以下の通りです:
+ (void)captureAslLogs {
@autoreleasepool {
...
notify_register_dispatch(kNotifyASLDBUpdate, ¬ifyToken, dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_HIGH, 0),^(int token) {
@autoreleasepool {
...
// プロセス識別を利用して、シミュレーターの状況で他のプロセスのログ無効通知に対応
[self configureAslQuery:query];
// すべての新しいログを反復処理(この通知で発生する可能性のある複数のログ)
aslmsg msg;
aslresponse response = asl_search(NULL, query);
while ((msg = asl_next(response))) {
// ログを記録(CocoaLumberjackのログとして記録、デフォルトはVerboseレベル)
[self aslMessageReceived:msg];
lastSeenID = (unsigned long long)atoll(asl_get(msg, ASL_KEY_MSG_ID));
}
asl_release(response);
asl_free(query);
if (_cancel) {
notify_cancel(token);
return;
}
}
});
PS:
- CocoaLumberjack のログとして記録された後、さらに取得が容易になります。詳細は次のセクションを参照してください。ログレベルには 2 つのカテゴリがあります:最初のカテゴリは Verbose と Debug で、デバッグレベルに属します。2 番目のカテゴリは Info、Warn、Error で、正式なレベルに属し、永続的な保存が必要で、より重要な情報の記録に適しています。ここではデフォルトで Verbose レベルです。
- NSLog デバッグを使用すると、I/O ディスク操作が発生するため、頻繁に NSLog を使用することは性能に悪影響を及ぼします。
- システムディスクスペースが不足している場合など、他にも多くのプロセス間通知があります。com.apple.system.lowdiskspace 通知(kNotifyVFSLowDiskSpace マクロ)が発出されます。
2)fishhook を使用して NSLog メソッドをフックします。
iOS 10 以降、ログを記録するために新しい統一ログシステム(Unified Logging System)が使用され、ASL の方法が全面的に置き換えられました。
統一ログシステム:
- ログはメモリとデータベースに集中して保存され、システムのすべてのレベルのメッセージ伝達を取得するための単一、高効率、高性能のインターフェースを提供します;
- ただし、ASL のようなすべてのログを取得するためのインターフェースはありません。
したがって、新しい統一ログシステムに対応するために、NSLog ログの出力をリダイレクトする必要があります。また、NSLog 自体は C 関数であり、Objective-C メソッドではないため、fishhookを使用してリダイレクト作業を完了します:
struct rebinding
を使用して元のメソッドとリダイレクトメソッドを定義します。- リダイレクトメソッド内で:
- まず、自分の処理を行い、ログの出力を永続的なストレージシステムに再出力します;
- 次に、NSLog も呼び出す NSLogv メソッドを呼び出して元の NSLog メソッドを呼び出します。また、fishhook が提供する元のメソッド呼び出し方式を使用することもできます。
3)dup2 関数を使用して STDERR ハンドルをリダイレクトします。
NSLog が最終的にファイルに書き込むときのハンドルは STDERR(standard error、システムのエラーログはすべて STDERR ハンドルを通じて記録されます)。Apple は NSLog の定義をエラー情報を記録することとしています。
dup2 関数はファイルリダイレクト専用で、STDERR ハンドルをリダイレクトすることができます。重要なコードは以下の通りです:
int fd = open(path_to_file, (O_RDWR | O_CREAT), 0644);
dup2(fd, STDERR_FILENO);
ここで、path_to_file
は自分で定義したリダイレクト出力のファイルアドレスです。
さて、各システムバージョンの NSLog ログが取得できるようになりました。では、他の方法で記録されたログはどのように取得するのでしょうか?
次に、主流のサードパーティログライブラリ CocoaLumberjack ログの取得方法について説明します。他のサードパーティライブラリも CocoaLumberjack をラップしているため、考え方は似ています。
CocoaLumberjack ログを取得する#
CocoaLumberjack は以下の図のように構成されています:
- DDLogFormatter:ログのフォーマットを整形するためのもの。
- DDLogMessage:ログメッセージのラッピング。
- DDLog:グローバルなシングルトンで、DDLogger プロトコルに準拠するロガーを保存します。
- DDLogger プロトコル:DDAbstractLogger によって実装されています。4 つのロガーが DDAbstractLogger を継承しています:
- DDTTYLogger:ログをコンソールに出力します。
- DDASLLogger:NSLog を ASL データベースに記録するログをキャッチします。
- DDFileLogger:ログをファイルに保存します。
[fileLogger.logFileManager logsDirectory]
を通じて保存されたファイルパスを取得し、CocoaLumberjack のすべてのログを取得できます。 - DDAbstractDatabaseLogger:データベース操作の抽象インターフェース。
全量ログを収集することで、分析と問題解決の効率を向上させることができます。さあ、試してみてください?
16 | パフォーマンス監視:アプリの品質を測るその尺#
目的:積極的かつ効率的にパフォーマンス問題を発見し、アプリの品質が無監視の失控状態に陥るのを避けること。
監視方法:オフライン、オンライン。
オフラインパフォーマンス監視:公式の王牌 Instruments#
Instruments は Xcode に統合されており、以下の図のように、消費電力、メモリリーク、ネットワーク状況など、さまざまなパフォーマンス検出ツールを含んでいます:
全体のアーキテクチャから見ると、Instruments は Standard UI(標準インターフェース)と Analysis Core(分析コア)の 2 つのコンポーネントを含んでおり、すべてのツールはこれらの 2 つのコンポーネントに基づいて開発されています。これらの 2 つのコンポーネントに基づいて、カスタム Instruments ツール(Instruments 10+)を開発することもできます:
- Xcode > File > New > Project > macOS > Instruments Package で、.instrpkg ファイルを生成します;
- このファイルを設定し、最も重要なのは Standard UI と Analysis Core の設定を完了することです;
- Apple が公式に提供する大量のコードスニペットを参考にしてください。詳細はInstruments Developer Helpを参照してください。
Analysis Core の動作原理:
主にデータを収集し処理するプロセスで、3 つのステップに分かれています:
1)設定した XML データテーブル(可視化表示用)を処理し、ストレージスペース store を申請します。
2)store は相応のデータプロバイダーを探し、直接見つからない場合は他の store の入力信号を通じて合成します。
⚠️:os_signpost APIを使用してデータを取得します。詳細はWWDC 2018 Session 410: Creating Custom Instrumentsの例を参照してください。
3)store がデータソースを取得すると、データ処理プロセスを最適化するために Binding Solution 作業を行います。
PS:Instruments は XML 標準データインターフェースを通じて表示と分析データの考え方を分離しているため、学ぶ価値があります。
オンラインパフォーマンス監視#
二つの原則:
- ビジネスコードに侵入しない;
- パフォーマンス消費をできるだけ小さくする。
主な指標:
CPU 使用率#
現在のアプリの CPU 使用率、つまりアプリ内の各スレッドの CPU 使用率の合計です。したがって、定期的に各スレッドを巡回し、各スレッドの cpu_usage 値を累積すればよいのです。
⚠️:
task_threads(mach_task_self(), &threads, &threadCount)
メソッドを使用して、現在のプロセス内のすべてのスレッドの配列 threads とスレッドの総数 threadCount を取得できます。thread_info((thread_act_t)threads[i], THREAD_BASIC_INFO, (thread_info_t)threadInfo, &threadInfoCount)
メソッドを使用して、スレッド threads [i] の基本情報 threadInfo を取得できます。cpu_usage
は iOS システム > usr/include/mach/thread_info.h > thread_basic_info 構造体に定義されています。
メモリ#
CPU 使用率と同様に、メモリ情報も専用の構造体に記録されており、iOS システム > usr/include/mach/task.info.h > task_vm_info 構造体に定義されています。その中の phys_footprint が物理メモリの使用を示します。
⚠️:
task_info(mach_task_self(), TASK_VM_INFO, (task_info_t) &vmInfo, &vmInfoCount
メソッドを使用して、現在のプロセスのメモリ情報 vmInfo を取得できます。- 物理メモリは
phys_footprint
で示され、resident_size(常駐メモリ:プロセスの仮想メモリ空間にマッピングされた物理メモリ)ではありません。
FPS#
FPS が低いと、アプリがスムーズでないことを示します。
簡単な実装:CADisplayLink に登録されたメソッド内で、リフレッシュ時間とリフレッシュ回数を記録することで、1 秒間に画面がリフレッシュされる回数、つまり FPS を得ることができます。
⚠️:画面がリフレッシュされるたびに、CADisplayLink に登録されたメソッドが 1 回呼び出されます。
ヒント:
- 推奨されるサードパーティ監視プラットフォーム:アリババのモバイル開発プラットフォーム mPaaS。
- Apple 自身のライブラリやツールに多くの注目を払い、ここにある設計思想や進化から学ぶべき知識が豊富にあります。
17 | 想像以上のマルチスレッドの落とし穴#
現象:AFNetworking 2.0(ネットワークフレームワーク)、FMDB(サードパーティデータベースフレームワーク)などの一般的な基盤ライブラリは、マルチスレッド技術を使用する際に非常に慎重です。特に UIKit はマルチスレッド技術を使用せず、単にスレッドセーフでなく、主スレッドで操作する必要があります。
なぜこのような現象があるのでしょうか?以下にマルチスレッド技術の一般的な二つの大きな落とし穴を見てみましょう:常駐スレッドと並行問題。
常駐スレッド#
定義:停止せず、メモリ内に常に存在するスレッド。
どこから来たのか?
NSRunLoop のrun
メソッドを使用してそのスレッドに runloop を追加すると、そのスレッドはメモリ内に常に存在することになります。
例:AFNetworking 2.0で常駐スレッドを作成するコードは以下の通りです:
+ (void)networkRequestThreadEntryPoint:(id)__unused object {
@autoreleasepool {
// まずNSThreadを使用してスレッドを作成します
[[NSThread currentThread] setName:@"AFNetworking"];
// runメソッドを使用してrunloopを追加します
NSRunLoop *runLoop = [NSRunLoop currentRunLoop];
[runLoop addPort:[NSMachPort port] forMode:NSDefaultRunLoopMode];
[runLoop run];
}
}
AFNetworking 2.0 は各リクエストを NSOperationQueue にラップし、上記の常駐スレッドを特別に作成して NSOperationQueue のコールバックを受け取ります。
常駐スレッドの落とし穴を避けられなかった理由は、ネットワークリクエストで使用されるNSURLConnection の設計に欠陥があるためです:NSURLConnection がリクエストを発起した後、そのスレッドは NSURLConnectionDelegate のコールバックメソッドを受け取るために常に生存している必要があります。しかし、ネットワークの応答時間は不確定であるため、リクエストのコールバックを処理するために常駐スレッドが必要です。しかし、主スレッドを使用せずに単独でスレッドを作成するのは、主スレッドが大量の UI やインタラクション作業を処理する必要があるためです。
🎉:しかし、AFNetworking 3.0 では、Apple が新たに提供した NSURLSessionが NSURLConnection に置き換えられ、NSURLSession はコールバックを NSOperationQueue に指定できるため、リクエストのコールバックを待つために常駐スレッドを必要としなくなりました。
どうやって避けるか?
常駐スレッドが多すぎると、CPU の利用率を高めることはできず、逆にプログラムの実行効率を低下させます。
常駐スレッドを作成しないことが最善ですが、もしスレッドを一定期間生存させる必要がある場合は、次の方法を選択できます:
- NSRunLoop の他の 2 つのメソッド
runUntilDate:
とrunMode:beforeDate:
を使用してスレッドの生存時間を指定し、スレッドの生存時間を予測可能にします。 - CFRunLoopRef の CFRunLoopRun と CFRunLoopStop メソッドを使用して runloop を開始および停止し、スレッドを一定期間生存させる目的を達成します。
⚠️:NSRunLoop に runloop を追加する方法にはrun
、runUntilDate:
、runMode:beforeDate:
の 3 つがあります。その中で、run
メソッドで追加された runloop は、runMode:beforeDate:
メソッドを繰り返し呼び出して停止しないようにします。
並行#
どこから来たのか? 複数のスレッドを同時に作成しました。
iOS の並行プログラミング技術の中で、GCD(Grand Central Dispatch)の使用率が最も高く、Apple が開発したマルチコアプログラミングソリューションです。
- 利点:インターフェースがシンプルで使いやすく、複雑なスレッド(作成、解放のタイミングなど)を管理しやすいです。
- 欠点:リソース使用にリスクがあります。例えば、データベースの読み書きシーンでは:
- 読み書き操作がディスクの応答を待っている間に、GCD がタスクを発起します;
- CPU の最大利用を目指して、GCD はディスクの応答を待っている間に新しいスレッドを作成します。
- そして、GCD が発起したこれらの新しいタスクがすべてディスクの応答を待つタスクである場合、タスク数が増えるにつれて、GCD が作成する新しいスレッドがどんどん増え、メモリリソースがますます逼迫します。
- ディスクが応答を開始すると、データを再読み込みするのにさらに多くのメモリを占有し、最終的にメモリ管理が失控します。
どうやって避けるか?
データベースのように頻繁にディスク操作を行うタスクでは、できるだけシリアルキューを使用して管理し、マルチスレッドの並行によるメモリ問題を避けるべきです。
推奨:オープンソースのサードパーティデータベースフレームワークFMDBは、そのコアクラス FMDatabaseQueue がデータベースの読み書きに関連するディスク操作をすべてシリアルキューに配置して実行します。
⚠️:スレッドが多すぎると、メモリとCPUの消費が大きくなります。
- システムはスレッドスタックとして一定のメモリを割り当てる必要があります。iOS 開発では、主スレッドのスタックサイズは 1MB で、新しく作成されたサブスレッドのスタックサイズは 512KB(スタックサイズは 4KB の倍数)です。
- CPU はスレッドのコンテキストを切り替える際に、アドレス指定を通じてレジスタを更新する必要があり、アドレス指定のプロセスは大きな CPU 消費を伴います。
ヒント:マルチスレッド技術におけるロックの問題は最も簡単に見つけられますが、逆に注意すべきは、背後に隠れていてシステムリソースを徐々に消耗する問題です。
18 | アプリの電力消費を減らすには?#
過剰な電力消費の可能性のある原因:位置情報をオンにしている;頻繁なネットワークリクエスト;定期タスクの時間間隔が小さすぎる...
特定の位置を見つけるために排除法を使用します:機能を一つずつコメントアウトし、電力消費の変化を観察します。
しかし、言うまでもなく、まず電力を取得しなければ、電力問題を発見することはできません。
電力を取得するには?#
システムが提供する batteryLevel プロパティを使用します。コードは以下の通りです:
- (float)getBatteryLevel {
// 電力を監視するには、必ず許可を与える必要があります
[[UIDevice currentDevice] setBatteryMonitoringEnabled:YES];
// 0.0(電池なし)、1.0(満充電)、–1.0(バッテリーモニタリングがオフ)
float batteryLevel = [[UIDevice currentDevice] batteryLevel];
NSLog(@"残りのバッテリー比率:%@", [NSString stringWithFormat:@"%f", batteryLevel * 100]);
return batteryLevel;
}
参考:batteryLevel —— Apple 公式文書。
PS:電力変化通知にオブザーバを追加し、電力が変化したときにカスタムメソッドを呼び出すことで電力を監視することもできます。参考:UIDeviceBatteryLevelDidChangeNotification —— Apple 公式文書。
電力問題を診断するには?#
上記の排除法を使用しても問題が見つからない場合、この消費電力は他のスレッドによって引き起こされている可能性が高く、その消費電力スレッドが作成された場所はサードパーティライブラリまたは二次ライブラリ(社内の他のチームが開発したライブラリ)である可能性があります。
このような状況に直面した場合、どのスレッドが問題を抱えているかを直接観察します。例えば、特定のスレッドのCPU 使用率が長時間 90%を超えている場合、それが問題であると推測できます。この時、そのメソッドスタックを記録すれば、根本原因を追跡できます。
- CPU 使用率を観察するには、16 のレッスン | オンラインパフォーマンス監視部分を参照してください。
- メソッドスタックを記録するには、13 のレッスン | カクつきのメソッドスタックを取得する部分を参照してください。
電力を最適化するには?#
CPU の観点から#
CPU に余計なことをさせないようにします。
- 大量のデータの複雑な計算は、サーバーに処理させます。
- アプリ内で処理する必要がある複雑な計算は、GCD の
dispatch_block_create_with_qos_class
メソッドを使用してキューの Qos を QOS_CLASS_UTILITY に指定し、その計算作業をこのキューのブロックに配置します。なぜなら、この Qos モードでは、大量のデータの複雑な計算に対してシステムが電力最適化を行っているからです。
I/O の観点から#
すべての I/O 操作は、低消費電力状態を破壊します。
- 断片化されたデータのディスクストレージ操作を遅延させ、まずメモリ内で集約し、その後ディスクストレージを行います。
- システムが提供する NSCache を使用して、メモリ内でデータを集約できます:
- スレッドセーフです。
- 事前に設定されたキャッシュスペース値に達するとキャッシュをクリアし、
cache:willEvictObject:
コールバックメソッドをトリガーし、このコールバック内で I/O 操作を行います。
関連ケース:SDWebImage画像読み込みフレームワークがキャッシュ画像を読み取ります。
- (UIImage *)imageFromMemoryCacheForKey:(NSString *)key {
return [self.memCache objectForKey:key];
}
- (UIImage *)imageFromDiskCacheForKey:(NSString *)key {
// NSCache内に画像データが存在するか確認します
UIImage *image = [self imageFromMemoryCacheForKey:key];
if (image) {
// もしあれば
return image;
}
// もしなければ、ディスクから読み込みます
UIImage *diskImage = [self diskImageForKey:key];
if (diskImage && self.shouldCacheImagesInMemory) {
NSUInteger cost = SDCacheCostForImage(diskImage);
// そしてNSCacheに保存します
[self.memCache setObject:diskImage forKey:key cost:cost];
}
return diskImage;
}
- 画像を読み込むたびに、NSCache に画像データが存在するかを確認します。
- もしあれば、NSCache から直接読み込みます;
- もしなければ、I/O を通じてディスクキャッシュ画像を読み込み、取得した画像データを NSCache に保存します。
Apple の参考#
- “Energy Efficiency Guide for iOS Apps”:Apple が特別に維持している電力最適化ガイドで、CPU、デバイスのウェイクアップ、ネットワーク、グラフィックス、アニメーション、ビデオ、位置情報、加速度計、ジャイロスコープ、磁力計、Bluetooth など、さまざまな要因から電力最適化に関する提案を行っています。
- “Writing Energy Efficient Apps”:Apple が 2017 年の WWDC で発表した、エネルギー効率の良いアプリを作成する方法に関するセッション 238 です。
19 | ホットな問題の Q&A(二):基礎モジュールの問題 Q&A#
RunLoop 原理の学習順序#
- 孫源のオフラインシェア | RunLoop:RunLoop の全体像を大まかに理解します。
- RunLoop 公式文書:Apple が設計した RunLoop メカニズムを全面的かつ詳細に理解し、RunLoop をどのように利用して問題を解決するかを学びます。
- ibireme | RunLoop を深く理解する:底層 CFRunLoop のソースコードを組み合わせて、RunLoop メカニズムを深く分析します。
dlopen () を使用すると審査に通るか?#
dlopen()
を使用してリモートダイナミックライブラリを読み込むことは、Apple の審査に通りません。
Apple は 2018 年 11 月に 718 のアプリを一斉に下線した際、dlopen()
、dlsym()
、respondsToSelector:
、performSelector:
、method_exchangeImplementations()
などのメソッドを使用してリモートスクリプトを実行することは許可されていないと述べました。なぜなら:
- これらのメソッドはリモートリソースと結びついており、プライベートフレームワークやプライベートメソッドを読み込む可能性があり、アプリの動作に重大な変化をもたらすため、審査時の状況と異なることになります。
- 使用されるリモートリソース自体が悪意のあるものでなくても、それらは簡単にハッキングされ、アプリケーションにセキュリティの脆弱性をもたらし、ユーザーに予期しない損害を与える可能性があります。
matrix-iOS#
WeChat がオープンソースのカクつき監視システムです。
matrix-iOS はアプリの性能損失を最小限に抑えるための 4 つの詳細を持っています:
- サブスレッド監視検出の時間間隔:カクつきを監視するサブスレッドは NSThread を通じて作成され、正常な状況下では監視の時間間隔は 1 秒ですが、カクつきが発生した場合、間隔時間はアニーリングアルゴリズムの影響を受け、フィボナッチ数列に従って増加し、カクつきがなくなると 1 秒に戻ります。
- サブスレッド監視のアニーリングアルゴリズム:同じカクつきを繰り返し取得する状況を避けます。
- RunLoop カクつきの時間閾値設定:2 秒。
- CPU 使用率閾値設定:単一コア CPU の使用率が 80%を超えると、CPU 占有が高すぎると判断します。
参考:
- matrix for iOS/macOS/Android、主要なコードはmatrix/WCBlockMonitorMgr.mmファイルにあります;
- matrix-iOS カクつき監視原理;
- 初期の WeChat iOS カクつき監視システムの提案。
教えることによって学び、復習することで新たな知識を得る~