はじめに
こんにちは、後藤です。
株式会社レコチョクでiOSアプリの開発をしています。
最近のブームはTOMOOの地下鉄モグラロードです。
先月の武道館ライブも最高でした。
さて、以前の記事【iOS】iOSアプリの「もっさり感」を追いかける!MetricKitの活用法では、
MetricKitを用いてアプリの「もっさり感」の原因を調査し、楽曲取得処理におけるハング時間の増加が主な原因であることを突き止めました。
今回は、その原因をどのように改善し、どの程度効果があったのかを定量的なデータとともに紹介します。
実行環境
ツール | バージョン |
---|---|
Xcode | 16.3 |
iOS | 18.5 |
開発中のハング時間の確認には、デベロッパーモードのHang Detectionを使って検出しました。
Detect hangs and hang risks
ボトルネックの発見と問題点
改善前の楽曲取得処理は次のフローでした。
- APIから楽曲リストを取得
- 返ってきた楽曲と同じ楽曲IDを持つ楽曲をCoreDataから検索
- 差分があればCoreDataを更新し保存
簡略化したサンプルコードで示します。
// APIから取得した楽曲一覧 let apiTracks: [Track] = ... for apiTrack in apiTracks { if let storedTrack = coreDataStore.fetchTrack(id: apiTrack.ID) { // CoreDataから保存している楽曲を検索 storedTrack.update(with: apiTrack) // 差分を反映 coreDataStore.save() // 保存 } } |
この処理には次のような問題がありました。
- 1曲ごとにCoreDataから個別にフェッチしていたため、1000曲であれば1000回のI/Oが発生
- 保存処理がメインスレッドで行われていたため、UIがブロックされる
改善内容
楽曲取得処理フローの最適化
次のように、楽曲取得フローを改善しました。
// APIから取得した楽曲一覧 let apiTracks: [Track] = ... // APIから取得した楽曲IDを抽出 let apiTrackIDs = apiTracks.map { $0.id } // CoreDataから、同じIDを持つ楽曲のみを一括で取得 let storedTracks = coreDataStore.fetchTracks(withIDs: apiTrackIDs) // O(1)参照にするために辞書化する // StoredTrackはNSManagedObjectのサブクラス var trackMap: [Track.ID: StoredTrack] = [:] for storedTrack in storedTracks { trackMap[storedTrack.ID] = storedTrack } // ループ中はハッシュ参照だけで更新する for apiTrack in apiTracks { let targetTrack = trackMap[apiTrack.ID] // 更新対象の楽曲を検索 if let targetTrack = trackMap[apiTrack.ID] { // 対象楽曲があれば更新する targetTrack.update(with: apiTrack) } } // 変更分をまとめて1回だけ保存 coreDataStore.save() |
改善前の実装では、1曲ごとにCoreDataからフェッチ → 保存という処理を繰り返していました。
つまり、1000曲のデータがあれば1000回のディスクI/Oが発生しており、処理時間の増加につながっていました。
この処理を1件ずつ取得するのではなく、一括で楽曲情報を取得するように改善しました。
また、CoreDataから取得した楽曲情報とAPIから返却された楽曲情報を照合する際の計算量も工夫しました。
たとえば、first(where:)を使用して、更新処理をシンプルに記述できます。
for apiTrack in apiTracks { let targetTrack = storedTracks.first { $0.ID == apiTrack.ID } // 更新対象の楽曲を検索 targetTrack.update(with: track) } |
しかし、 first(where:)はAppleの公式ドキュメントにある通り、O(n)の線形探索です。
O(n), where n is the length of the sequence.
そこで、SwiftのDictionaryに着目しました。
// O(1)参照にするために辞書化する var trackMap: [Track.ID: StoredTrack] = [:] |
こちらの辞書はハッシュテーブルを用いており、キーを元にO(1)で高速に要素を検索できます。
A dictionary is a type of hash table
そのため、辞書を用いて検索することでパフォーマンスをさらに向上させることができました。
上記の「一括取得」と「辞書作成」の改善の結果、1000件の楽曲取得処理で4秒ほどかかっていた処理が、0.5秒以下に抑えることができるようになりました。
💡 Dictionaryはハッシュテーブルベースのため、要素の順序は保証されません。
今回の楽曲更新処理では順序を意識する必要がなかったため問題ありませんでした。
しかし、順序が重要な場合は別のデータ構造を検討する必要があります。
💡 検索回数が1〜2回など少数の場合は、
first(where:)を使ったコードの方が簡潔で読みやすくなります。
そのため、可読性を優先した方がメリットが多いと個人的には考えています。
最適化は「繰り返し規模」と「処理対象の数」に応じて、バランスを取ることが重要です。
更新処理をバックグラウンドスレッドで行う
改善前の実装では、次のようにメインスレッド上のviewContextを使って楽曲情報を更新していました。
let context = CoreDataStore.shared.viewContext LocalMusicStore.updateTracks( // CoreData更新処理 with: tracksInfo, in: context ) |
このviewContextは、CoreDataの永続コンテナが提供するメインスレッド用のコンテキストで、次のように定義されています。
var viewContext: NSManagedObjectContext { persistentContainer.viewContext } |
このままでは、データの保存処理がメインスレッドで行われるため、
大量のデータを扱うと「もっさり感」の原因となってしまいます。
そこで、次のように処理をバックグラウンドスレッドへ移行しました。
CoreDataStore.performBackgroundTask { context in LocalMusicStore.updateTracks( with: tracksInfo, in: context ) { // 保存完了を通知する処理など } } |
このように、performBackgroundTaskを使ってバックグラウンドコンテキストで更新処理を行い、
必要な処理だけを後からメインスレッドに戻して実行することで、UIの応答性を保っています。
CoreDataStoreでは、バックグラウンドコンテキストを扱うためのメソッドを次のように定義しています。
/// バックグラウンドコンテキストで非同期処理を実行する static func performBackgroundTask(_ block: @escaping (NSManagedObjectContext) -> Void) { CoreDataManager.shared.persistentContainer.performBackgroundTask { context in // 同一オブジェクトが競合した場合は、保存側のプロパティを優先する context.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump block(context) } } |
この変更によって、メインスレッドをブロックすることがなくなり、
Hang Detectionで検出されない(0.25秒未満)レベルにまでハング時間を短縮できました。
💡 大量のデータを扱いつつ、単純なデータ更新(たとえばフラグを一括でtrueにするなど)の場合は、NSBatchUpdateRequestを使う方法もあります。
NSBatchUpdateRequestはCoreDataのオブジェクトをメモリに読み込まず、直接データベースを更新できるため、非常に高速です。
更新の目的やデータの特性に応じて、APIを使い分けることで、より効率的なパフォーマンス改善が可能になります。
結果
上記の改善後アプリをリリースした結果、次の改善がみられました。
バージョンAは以前までのアプリで、バージョンBは今回改善したアプリです。
楽曲取得処理速度の改善
全ユーザーの90%において、バージョンAで楽曲取得処理に最大約10秒ほどかかっていたものが、
バージョンBにおいて最大約2.5秒ほどに処理時間が短縮されました(データを小さい順に並べたとき、初めから数えて全体の90%に位置する値を90パーセンタイルといいます)。
バージョン | 中央値 | 90パーセンタイル |
---|---|---|
A | 3.85秒 | 10.01秒 |
B | 1.33秒 | 2.48秒 |
アプリの1日あたりのハング時間
上の図はアプリが応答しなくなる時間(ハング時間)が、1日あたりどれくらい発生しているかを、バージョンAとバージョンBで比較しています。
バージョンAでは179195件のデータを、バージョンBでは174526件のデータを用いました。
バージョンAでは5秒以上ハングするケースが多く見られました。しかし、処理を改善した結果、バージョンBではほとんどのユーザーにおいてハング時間を5秒以内に抑えることができました。
楽曲登録件数とハング時間の関係
バージョンAの相関 | バージョンBの相関 |
---|---|
![]() |
![]() |
上の図は、楽曲登録件数とアプリが応答しなくなる時間(ハング時間)の関係を示しています。
バージョンAでは、登録件数が多くなるほど1日あたりのハング時間が増加する傾向が見られました。一方で、バージョンBでは登録件数に関係なく、ほとんどのユーザーでハング時間が短く保たれています。
そのほか、XやApp Storeでアプリが以前よりも快適に動くようになったという声をいただきました。
まとめ
長年運用しているアプリであっても、パフォーマンス面には見直しの余地があると改めて実感しました。
とくに次の点は重要だと感じています。
- 計算量の意識
- メインスレッドを避けた処理設計
- MetricKitなどによる定量的な性能可視化
今後も、「計測 → 仮説 → 実装 → 検証」を繰り返し、
ユーザーにとって快適な体験ができるようにアプリを作成したいと感じました。
この記事を書いた人

最近書いた記事
2025.06.26【iOS】MetricKit で見つけたボトルネックをこう直した!「もっさり感」解消までの実装と効果
2024.12.23【iOS】iOSアプリの「もっさり感」を追いかける!MetricKitの活用法
2024.06.14SwiftでUIViewにaccessibilityIdentifierを簡単に設定する
2023.12.15【iOS】音楽アプリで使えるCarPlayのUIについて