本記事はこの記事の日本語訳です。翻訳許可をいただいております。
以下翻訳:
もし Cocoa 開発やソフトウェアビジネスのブートストラップについての最新の記事を常にキャッチアップしたいなら、ぜひ Twitter で私をフォローするかメールリストを購読してください。
開発者として、パフォーマンスの良さは我々のユーザにワクワクと嬉しさを与えるのに評価しきれないほど貴重なものです。iOS ユーザの目は非常に高く、そのためもしあなたのアプリが動作がモサモサしたり、すぐにメモリプレッシャーでクラッシュしたりすると、彼らはあなたのアプリを削除するか、最悪悪いレビューまで残してしまうでしょう。
私はアップルに 6 年間を在籍し、その歳月を Cocoa フレームワークやファーストパーティーのアプリに費やしてきましいた。私が手掛けたものには Spotlight、iCloud、app extensions、そして最近は Files などがあります
そして私は 20% だけの時間を使って 80% のパフォーマンス向上が達成できるとても手軽なパターンがあることに気づきました。
さてここからこれらのパフォーマンスについてのアドバイスを書きますので、あなたのお役に立てたら幸いです。
1. UILabel
のコストはあなたの想像を超える
我々はラベルはメモリ利用量的にとても軽いと想像しがちです。何せ、彼らは単純にテキストの表示だけですので。しかし、UILabel
は実はビットマップとして保存されており、そのためメモリ利用が簡単に何メガバイトにも達してしまいます
ありがたいことに、UILabel
の実装は非常に賢いです、彼は必要最低限のことしかしません
- もしあなたのラベルはモノクロなら、
UILabel
は自動的にCALayerContentsFormat
の設定をkCAContentsFormatGray8Uint
(1 バイト/ピクセル)にし、それ以外(例えば"🥳it’s party time"
のテキスト、もしくはマルチカラーなNSAttributedString
を表示するラベル)ならkCAContentsFormatRGBA8Uint
(4 バイト/ピクセル)にします。
つまり、モノクロのラベルなら最大 width * height * contentScale^2 * (1 バイト/ピクセル)
のバイト数を必要とするが、カラーのラベルはそれの 4 倍:つまり width * height * contentScale^2 * (4 バイト/ピクセル)
が必要とします。
例えば、iPhone 11 Pro Max に、414 * 100
のポイントサイズのラベルはこれだけのメモリ量が必要とします:
- 414 * 100 * 3^2 * 1 = 372.6kB(モノクロ)
- 414 * 100 * 3^2 * 4 = ~1.49MB(カラー)
編集:
UIKit エンジニアとの Twitter でのディスカッションの末、注意書きを一つ足すことにしました:
必ず先にメモリ計測しましょう、そしてあなたのパフォーマンスの問題は本当にラベルによるメモリプレッシャーが起因としたものだと断定できる場合のみ下記の変更を行ってください。
UIKit の @Inferis から:
例として:仮に将来 UILabel のバッキングストアの(再)利用最適化がアップデートされた場合、あなたの今行った最適化は物事を(潜在的にかなり)悪くしてるだけ。
一つのよくあるアンチパターンは UITableView/UICollectionView
のセルが再利用キューに入った時,セルラベルのテキスト内容をそのままにしてセルラベルを生かしておくことです。セルがリサイクルされた時、ラベルのテキスト内容が変わる可能性が非常に高いです、そのためラベルを保存するのは非常に無駄です。
メガバイトレベルのメモリを空けるためには:
- ラベルを隠して、ほんのたまにしか表示しない時はラベルの
text
を nil 代入しましょう。 -
UITableView/UICollectionView
のセルに表示してるラベルは、下記のメソッドでtext
を nil 代入しましょう:
tableView(_:didEndDisplaying:forRowAt:)
collectionView(_:didEndDisplaying:forItemAt:)
2. 常にシリアルキューを利用し、コンカレントキューは最後の手段として保留せよ
一つのよくあるアンチパターンは、UI に影響しないブロックを、メインキューからグローバルなコンカレントキューにディスパッチすることです。
例えば:
func textDidChange(_ notification: Notification) {
let text = myTextView.text
myLabel.text = text
DispatchQueue.global(qos: .utility).async {
self.processText(text)
}
}
もしここでアプリを中断すると:
あなたがブロックをコンカレントキューに dispatch_async
した時、GCD はスレッドプールからアイドリングしているスレッドを探し、そのスレッドでブロックを動かそうとします。そしてもしアイドリングなスレッドが見つからなかった時、仕方ないのでその仕事のために新しいスレッドを作るしかありません。そのため、速いペースでブロックをコンカレントキューにディスパッチすると、速いペースで新しいスレッドを作らなくてはならないことになりかねないです。
忘れないでください:
- スレッドの作成はただではありません。もしあなたが出したブロックの仕事量が少ない(< 1ms)なら、新しいスレッドの作成は非常に無駄です、なぜならそれには実行コンテキスト、CPU サイクル、そして dirty メモリ等のスイッチングが発生するからです。
- GCD は喜んであなたにスレッドを作成しちゃいます、結果的にスレッド爆発が起こります。
一般的には、常に数の限られたシリアルキューからスタートすべきで、それぞれのキューにあなたのアプリのサブコンポーネント(DB キュー、テキスト処理キュー、などなど…)を現すべきです。そして自分自身のシリアルキューを持つ小さいオブジェクトには、dispatch_set_target_queue
を使って先ほどのサブコンポーネントキューにターゲットセットすればいいです。
あなたのボトルネックが追加の並列化で解決できる時のみ、あなたが作ったコンカレントキュー(dispatch_get_global_queue
ではなく)を使い、そして dispatch_apply
の利用を考えましょう。
dispatch_get_global_queue
についての注意:
あなたが dispatch_get_global_queue
から取得したコンカレントキューは、システムへの QoS1 情報の転送が苦手であり、回避すべきです。
libdispatch の Pierre Habouzit からの引用:
dispatch_get_global_queue()
は実践上 dispatch API が提供してるものの中に最悪の部類の一つです、なぜならランタイムがどれだけ尽力しても、実行時にあなたのオペレーション/アクター/などなど…についての情報が足りないため、あなたがやろうとしてることを理解できず最適化もできない。
libdispatch の効率化アドバイスについてのもっと詳しい情報は、ぜひこちらの文章をご確認ください。
3. そう見えるほど、コードが悪くないかもしれない
さてあなたができる限りのメモリ利用量の最適化をしてきました、しかしそれでもしばらくアプリを使うとメモリ利用量が上がったまま下がりません。
焦らないでください、一部のシステムコンポーネントはメモリワーニングをもらわない限りメモリを解放しないだけです。
例えば、UICollectionView
は(iOS 13 から)-didReceiveMemoryWarning
に反応し、メモリが足りなくなったら再利用キューのメモリをクリアします
メモリワーニングをシミュレーションするには:
- iOS Simulator の場合、メニューから
Simulate Memory Warning
を選びます - 実機の場合、このプライベート API を呼び出します(App Store には提出しないように)
[[UIApplication sharedApplication] performSelector:@selector(_performMemoryWarning)];
4. dispatch_semaphore_t
を使って非同期作業を待たないように
ここには一つよくあるアンチパターンがあります:
let sem = DispatchSemaphore(value: 0)
makeAsyncCall {
sem.signal()
}
sem.wait()
これの問題は makeAsyncCall
を呼び出したスレッドから、実際の処理が行われているスレッドに優先度の情報が伝えられず、優先順位の逆転に繋がることです。
- 例えばメインキューから
makeAsyncCall
を呼び出し、QoS がQOS_CLASS_UTILITY
の DB キューに作業をディスパッチしたとします。 -
makeAsyncCall
がメインキューからdispatch_async
を呼び出したおかげで、実際の DB キューの QoS はQOS_CLASS_USER_INITIATED
に上げられます。 - セマフォでメインキューをブロックすると言うのは、メインキューは(自身の QoS である
QOS_CLASS_USER_INTERACTIVE
よりも順位が低い)QOS_CLASS_USER_INITIATED
の仕事が終わるまで待たなくてはいけず2、すなわち優先順位の逆転が発生してしまいます。
XPC
3 についての補足です:
もしあなたが既に XPC を利用しており(macOS アプリか、もしくは [NSFileProviderService](https://developer.apple.com/documentation/foundation/nsfileproviderservice)
を利用しているなら)、そして同期な呼び出しを行いたい場合は、セマフォを使うのではなく、代わりに下記のコードを使って同期プロキシにあなたのメッセージを送りましょう:
-[NSXPCConnection synchronousRemoteObjectProxyWithErrorHandler:]
5. UIView
の tag を使わないように
これは悪い実践であり、臭うコードを示します。そしてパフォーマンス的にも悪いです。
私は最近こんなコードと出会いました、そのコードは一つのビューをタップしたら、そのビューの小ビューの色をそれらの tag の値に応じて変更するものです。
UIKit は tag の実装に objc_get/setAssociatedObject()
を使っています、それはつまりあなたが tag をゲット/セットする度に、実は辞書参照を行っており、実行中だと Instruments ではこのように表示されるのです:
編集:これはせいぜい微々たる最適化にすぎません。私が伝えたいことは:1)驚くべきことに、-[UIView tag]
は Objective-C の associated objects に依存しているのと、2)これはパフォーマンスに厳しいコードに多用する時だけインパクトを与えます。
終わりに
あなたがこれらのアドバイスを読み終わったら、今日新しい知見を持ち帰れたと祈ります。いつもと同じ、必ずパフォーマンス調整する前に必ず計測してください。
**何か質問?他のパフォーマンスアドバイスを共有したい?**ぜひコメントで私に知らせてください!
追伸
ここで私の素敵な Mac ユーティリティを確認できます。
編集
UICollectionView
/UITableView
を利用した時のラベルの nil 代入の内容について修正を入れてくれた Paul Hudson に感謝します。
訳者後書:私に本記事についての内容について問い合わせても、努力はしますが必ずしも答えられるわけではありませんのでご了承ください。英語で直接本文著者に問い合わせることをお勧めします。
-
Quality of Service。GCD が制御するキューの優先順位を表す型です。詳しくは公式資料をご参照ください。 ↩
-
原文が全部大文字になっているためちょっと微妙に紛らわしいように見えますが、よく見てください。Swift の構文に直すと、メインキューの QoS は
.userInteractive
であり、作られた DB キューの QoS は.utility
→.userInitiated
です。Interactive と Initiated の違いです。 ↩ -
サンドボックスをアクセスするなど、プロセス間のコミュニケーションを安全に行うための管理ライブラリーです。詳しくは公式資料をご参照ください。 ↩