【翻訳】あなたの（多分）まだ知らない iOS パフォーマンスアドバイス（元アップルエンジニアから）

本記事は[この記事](https://www.fadel.io/blog/posts/ios-performance-tips-you-probably-didnt-know)の日本語訳です。翻訳許可を[いただいております](https://www.fadel.io/blog/posts/ios-performance-tips-you-probably-didnt-know/#comment-4822809038)。

以下翻訳：

---

もし Cocoa 開発やソフトウェアビジネスのブートストラップについての最新の記事を常にキャッチアップしたいなら、ぜひ [Twitter で私をフォロー](https://twitter.com/ronyfadel)するか[メールリストを購読](https://mailchi.mp/bc3065c5892d/fadelio)してください。

![陸上トラック](https://www.fadel.io/blog/public/racetrack.jpg)

開発者として、パフォーマンスの良さは我々のユーザにワクワクと嬉しさを与えるのに評価しきれないほど貴重なものです。iOS ユーザの目は非常に高く、そのためもしあなたのアプリが動作がモサモサしたり、すぐにメモリプレッシャーでクラッシュしたりすると、彼らはあなたのアプリを削除するか、最悪悪いレビューまで残してしまうでしょう。

私はアップルに 6 年間を在籍し、その歳月を Cocoa フレームワークやファーストパーティーのアプリに費やしてきましいた。私が手掛けたものには **Spotlight**、**iCloud**、**app extensions**、そして最近は **Files** などがあります

そして私は **20% だけの時間を使って 80% のパフォーマンス向上が達成できる**とても手軽なパターンがあることに気づきました。

さてここからこれらのパフォーマンスについてのアドバイスを書きますので、あなたのお役に立てたら幸いです。

# 1. `UILabel` のコストはあなたの想像を超える

![UILabel](https://www.fadel.io/blog/public/uilabel-bordered.png "よく見かける `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 でのディスカッション](https://twitter.com/Inferis/status/1229872050837184512)の末、注意書きを一つ足すことにしました：

必ず[先にメモリ計測](https://developer.apple.com/library/archive/documentation/AnalysisTools/Conceptual/instruments_help-collection/Chapter/Chapter.html)しましょう、そしてあなたのパフォーマンスの問題は本当にラベルによるメモリプレッシャーが起因としたものだと断定できる場合のみ下記の変更を行ってください。

UIKit の [@Inferis](https://twitter.com/Inferis/status/1229872436801228801) から：

> 例として：仮に将来 UILabel のバッキングストアの（再）利用最適化がアップデートされた場合、あなたの今行った最適化は物事を（潜在的にかなり）悪くしてるだけ。

一つのよくあるアンチパターンは **`UITableView/UICollectionView` のセルが再利用キューに入った時，セルラベルのテキスト内容をそのままにしてセルラベルを生かしておくこと**です。セルがリサイクルされた時、ラベルのテキスト内容が変わる可能性が非常に高いです、そのためラベルを保存するのは非常に無駄です。

メガバイトレベルのメモリを空けるためには：

- ラベルを隠して、ほんのたまにしか表示しない時はラベルの `text` を nil 代入しましょう。
- `UITableView/UICollectionView` のセルに表示してるラベルは、下記のメソッドで `text` を nil 代入しましょう：

```swift
tableView(_:didEndDisplaying:forRowAt:)
collectionView(_:didEndDisplaying:forItemAt:)
```

# 2. 常にシリアルキューを利用し、コンカレントキューは最後の手段として保留せよ

一つのよくあるアンチパターンは、UI に影響しないブロックを、メインキューからグローバルなコンカレントキューにディスパッチすることです。

例えば：

```swift
func textDidChange(_ notification: Notification) {
    let text = myTextView.text
    myLabel.text = text
    DispatchQueue.global(qos: .utility).async {
        self.processText(text)
    }
}
```

もしここでアプリを中断すると：

![スレッド爆発](https://www.fadel.io/blog/public/thread-explosion.jpg "🙀 GCD が我々が提出した全てのブロックに対しそれぞれ一つのスレッドを作りやがった")

あなたがブロックをコンカレントキューに `dispatch_async` した時、GCD はスレッドプールからアイドリングしているスレッドを探し、そのスレッドでブロックを動かそうとします。そしてもしアイドリングなスレッドが見つからなかった時、仕方ないのでその仕事のために**新しいスレッドを作る**しかありません。そのため、速いペースでブロックをコンカレントキューにディスパッチすると、速いペースで新しいスレッドを作らなくてはならないことになりかねないです。

忘れないでください：

- スレッドの作成は[ただではありません](https://developer.apple.com/library/archive/documentation/Cocoa/Conceptual/Multithreading/CreatingThreads/CreatingThreads.html#//apple_ref/doc/uid/10000057i-CH15-SW7)。もしあなたが出したブロックの仕事量が少ない（< 1ms）なら、新しいスレッドの作成は非常に無駄です、なぜならそれには実行コンテキスト、CPU サイクル、そして dirty メモリ等のスイッチングが発生するからです。
- GCD は喜んであなたにスレッドを作成しちゃいます、結果的に[スレッド爆発](https://twitter.com/pedantcoder/status/773903697474486273)が起こります。

一般的には、常に**数の限られたシリアルキューからスタートすべき**で、それぞれのキューにあなたのアプリのサブコンポーネント（DB キュー、テキスト処理キュー、などなど…）を現すべきです。そして自分自身のシリアルキューを持つ小さいオブジェクトには、`dispatch_set_target_queue` を使って先ほどのサブコンポーネントキューにターゲットセットすればいいです。

あなたのボトルネックが**追加の並列化で解決できる時**のみ、あなたが作ったコンカレントキュー（`dispatch_get_global_queue` ではなく）を使い、そして [`dispatch_apply` の利用を考えましょう](https://developer.apple.com/documentation/dispatch/1453050-dispatch_apply)。

`dispatch_get_global_queue` についての注意：

あなたが `dispatch_get_global_queue` から取得したコンカレントキューは、**システムへの QoS[^qos] 情報の転送が苦手であり、回避すべき**です。

[^qos]: Quality of Service。GCD が制御するキューの優先順位を表す型です。詳しくは[公式資料](https://developer.apple.com/documentation/dispatch/dispatchqos)をご参照ください。

libdispatch の Pierre Habouzit からの[引用](https://lists.swift.org/pipermail/swift-evolution/Week-of-Mon-20170828/039368.html)：

> `dispatch_get_global_queue()` は実践上 dispatch API が提供してるものの中に最悪の部類の一つです、なぜならランタイムがどれだけ尽力しても、実行時にあなたのオペレーション／アクター／などなど…についての情報が足りないため、あなたがやろうとしてることを理解できず最適化もできない。

libdispatch の効率化アドバイスについてのもっと詳しい情報は、ぜひ[こちらの文章](https://gist.github.com/tclementdev/6af616354912b0347cdf6db159c37057)をご確認ください。

# 3. そう見えるほど、コードが悪くないかもしれない

さてあなたができる限りのメモリ利用量の最適化をしてきました、しかしそれでもしばらくアプリを使うとメモリ利用量が上がったまま下がりません。

焦らないでください、**一部のシステムコンポーネントはメモリワーニングをもらわない限りメモリを解放しないだけです**。

例えば、`UICollectionView` は（iOS 13 から）`-didReceiveMemoryWarning` に反応し、メモリが足りなくなったら再利用キューのメモリをクリアします

メモリワーニングをシミュレーションするには：

- iOS Simulator の場合、メニューから `Simulate Memory Warning` を選びます
- 実機の場合、この[プライベート API](https://stackoverflow.com/questions/2784892/simulate-memory-warnings-from-the-code-possible) を呼び出します（App Store には提出しないように）

```objc
[[UIApplication sharedApplication] performSelector:@selector(_performMemoryWarning)];
```

# 4. `dispatch_semaphore_t` を使って非同期作業を待たないように

ここには一つよくあるアンチパターンがあります：

```swift
let sem = DispatchSemaphore(value: 0)
makeAsyncCall {
	sem.signal()
}
sem.wait()
```

これの問題は `makeAsyncCall` を呼び出したスレッドから、実際の処理が行われているスレッドに優先度の情報が伝えられず、[優先順位の逆転](https://twitter.com/pedantcoder/status/1175062243806863360)に繋がることです。

- 例えばメインキューから `makeAsyncCall` を呼び出し、QoS が `QOS_CLASS_UTILITY` の DB キューに作業をディスパッチしたとします。
- `makeAsyncCall` がメインキューから `dispatch_async` を呼び出したおかげで、実際の DB キューの QoS は `QOS_CLASS_USER_INITIATED` に上げられます。
- セマフォでメインキューをブロックすると言うのは、メインキューは（自身の QoS である `QOS_CLASS_USER_INTERACTIVE` よりも順位が低い）`QOS_CLASS_USER_INITIATED` の仕事が終わるまで待たなくてはいけず[^qos_ui]、すなわち優先順位の逆転が発生してしまいます。

[^qos_ui]: 原文が全部大文字になっているためちょっと微妙に紛らわしいように見えますが、よく見てください。Swift の構文に直すと、メインキューの QoS は `.userInteractive` であり、作られた DB キューの QoS は `.utility` → `.userInitiated` です。Interactive と Initiated の違いです。

`XPC`[^xpc] についての補足です：

[^xpc]: サンドボックスをアクセスするなど、プロセス間のコミュニケーションを安全に行うための管理ライブラリーです。詳しくは[公式資料](https://developer.apple.com/documentation/foundation/xpc)をご参照ください。

もしあなたが既に XPC を利用しており（macOS アプリか、もしくは `[NSFileProviderService](https://developer.apple.com/documentation/foundation/nsfileproviderservice)` を利用しているなら）、そして同期な呼び出しを行いたい場合は、セマフォを使うのではなく、代わりに下記のコードを使って同期プロキシにあなたのメッセージを送りましょう：

```objc
-[NSXPCConnection synchronousRemoteObjectProxyWithErrorHandler:]
```

# 5. `UIView` の tag を使わないように

**これは悪い実践であり、[臭うコード](https://martinfowler.com/bliki/CodeSmell.html)を示します。そしてパフォーマンス的にも悪いです。**

私は最近こんなコードと出会いました、そのコードは一つのビューをタップしたら、そのビューの小ビューの色をそれらの tag の値に応じて変更するものです。

UIKit は tag の実装に `objc_get/setAssociatedObject()` を使っています、それはつまりあなたが tag をゲット／セットする度に、実は辞書参照を行っており、実行中だと Instruments ではこのように表示されるのです：

![Time Profiler トレース](https://www.fadel.io/blog/public/tag_time_profiler.jpg "-[UIView tag] はタッチイベントの処理に貴重なミリ秒を消費するのです。")

編集：これはせいぜい微々たる最適化にすぎません。私が伝えたいことは：1）驚くべきことに、`-[UIView tag]` は Objective-C の associated objects に依存しているのと、2）これはパフォーマンスに厳しいコードに多用する時だけインパクトを与えます。

# 終わりに

あなたがこれらのアドバイスを読み終わったら、今日新しい知見を持ち帰れたと祈ります。いつもと同じ、必ずパフォーマンス調整する前に必ず[計測](https://developer.apple.com/library/archive/documentation/AnalysisTools/Conceptual/instruments_help-collection/Chapter/Chapter.html)してください。

**何か質問？他のパフォーマンスアドバイスを共有したい？**ぜひコメントで私に知らせてください！

# 追伸

[ここ](https://fadel.io/)で私の素敵な Mac ユーティリティを確認できます。

# 編集

`UICollectionView`/`UITableView` を利用した時のラベルの nil 代入の内容について修正を入れてくれた [Paul Hudson](https://twitter.com/twostraws/status/1229761505425530882) に感謝します。

---

訳者後書：私に本記事についての内容について問い合わせても、努力はしますが必ずしも答えられるわけではありませんのでご了承ください。英語で直接本文著者に問い合わせることをお勧めします。