こんにちは、iOSエンジニアのふじのです。
Mirrativの機能開発を進める傍ら、iOSアプリのメモリ使用量の改善に取り組んでいます。
今回は、アプリ内でメモリ使用量を増やしている問題点を見つける方法と、それらをクライアント実装で解決する具体的なアプローチについて紹介します。
Mirrativアプリが抱える課題
Mirrativアプリはライブ配信機能や視聴機能、Unityを利用したアバター機能やWebGLを利用したゲーム機能など、メモリを多く必要とする機能が複数あります。
特に、配信機能やweb view上のゲーム機能はアプリ本体とは別プロセスで動いているため、デバイス全体のメモリ使用量のバランスについても考慮する必要があり、よりシビアなメモリ管理が求められます。
実際に、Crashlyticsの上位にはOOM(out of memory)起因のクラッシュが並んでいるため、メモリ管理の問題はアプリ全体の品質に大きな影響を与えています。
メモリ不足を引き起こす原因を探していくと、iOSのスコープの中で解決できる問題の他にも、Unity起因の問題や、ライブゲーム起因の問題などがあり、一筋縄では解決しないものもありますが、各チームで対策に取り組んでいます。 この記事では、その中でも特にMirrativアプリ本体のネイティブ実装起因のものを深掘りします。
着眼点とアプローチ
まずは、Mirrativアプリでボトルネックとなっている部分を探すために、まずはXcodeやInstrumentsのAllocationsを見ながら、実際にアプリを動かしてみました。
すると、一部の画面で極端にメモリを消費していることや、それらの画面で頻繁にapplicationDidReceiveMemoryWarning
が呼ばれ、その度にメモリの解放処理が呼ばれていることが判明しました。
下の画像はある画面で、繰り返しスクロールをした際のメモリ使用量の推移です。
この記事ではこちらの例を使って、解説を進めていきます。
いくつか山がありますが、メモリ使用量が急に増えている部分が該当の画面を開いて操作している状態で、その後急に落ち込んでいる部分が、キャッシュが削除されたタイミングです。
同じ操作をしながらInstrumentsのAllocationsで確認してみると、SDWebImage
の持つUIImage
のキャッシュが多くメモリを使用していることが分かりました。
それと同時に、同じ操作を繰り返しているにも関わらず、キャッシュが削除された後の谷の部分が切り上がっているため、画像のメモリキャッシュのみが原因ではなく、どこかの処理でメモリリークを起こしていることが分かります。
アプリの機能や性質によりメモリ不足の要因は異なりますが、画像が大きな要因となるケースは少なくなく、該当の画面も大きめのUIImageView
を使っていたため、こちらを中心に対策を考えることにしました。
画像キャッシュの効率化とメモリリークの解消を、該当の画面やアプリ全体に適用していきます。
画像キャッシュの効率化
Mirrativ iOSアプリでは、画像のダウンロードとキャッシュにSDWebImage
を利用しています。
画像キャッシュは、一度デコードしたビットマップをメモリやディスクに保存することで、再度同じ画像を表示する際にはダウンロードやデコードをスキップして、描画速度を向上させる目的で利用されます。
SDWebImage
の場合は、UIImage
をそのままメモリキャッシュに保存しています。
UIImage
のメモリ上のサイズは、デコード元のファイルのサイズや圧縮率などは影響せず、画素数のみが影響します。
一般的なSRGBフォーマットの場合、1画素につきRGBAそれぞれ8ビットずつのビットマップとなるため、メモリ上のサイズは(画素数)×4
バイトとなります。
例えば、1600x900の画像データであれば、フォーマットや元々のファイルサイズ、圧縮の可逆性などによらず、一律1600*900*4=5760000
でおよそ5625KBのサイズのビットマップになります。
Mirrativアプリでは、既に一部実装でFastly Image Optimizerによる画像の縮小が行われていましたが、Bundle
内のWebP画像のキャッシュ利用や、アニメーション画像の利用など幅広い用途に対応する必要がありました。
そのため、追加でクライアントサイドでのキャッシュ前のUIImage
のリサイズと、メモリキャッシュの上限サイズ設定を実施することにしました。
UIImageのリサイズ
UIImage
のリサイズについては、SDWebImage
のキャッシュ保存する直前に、実際のUIImageView
の大きさと画面スケールを考慮した縮小処理1を挟むことで実現しました。
サイズの削減効率は端末の画素数に依存しますが、例えば、UIImageView
の縦横の画素数が、ダウンロードした画像のそれぞれ半分である場合、リサイズ後のUIImage
のサイズはおおよそ4分の1になります。
下の図の例では、赤色の部分が実際にメモリキャッシュに保存されるUIImage
で、元々1600x900の大きさの画像を、UIImageView
の800x450に事前に縮小しています。
実際の結果では、Mirrativ内で適用した箇所で最も効果が高かった部分の場合、現行のiPhoneの解像度から計算すると、リサイズ前の6.5%~23%のメモリ使用量となりました。
表示サイズが可変なものや、複数箇所で利用されているものなど、一部の画像については実装の調整が必要なため、一括で全体に適用することはできませんでしたが、メモリ消費が激しい画面については対処する事ができました。
画像のメモリキャッシュの上限サイズ設定
メモリキャッシュのサイズ上限については、実際のメモリ利用状況を見て設定しました。
SDWebImage
の場合はメモリキャッシュの実装にNSCache
を利用しているため、SDWebImage
経由でtotalCostLimit
を指定することで、上限を設定することができます。
端末の性能や他のプロセスの状況にもよりますが、例えば、平均的なメモリ4GBのiPhone端末では、Mirrativのプロセスが前項目の画像のように、800MB~900MBほど利用したところで、applicationDidReceiveMemoryWarning
が呼ばれていました。
Organizerによると、Mirrativ iOSアプリのiPhone端末では、ピーク時のメモリ使用量が50th percentileで650MBほど、90th percentileで1.3GBほどなので、通常より少し多めに使ったくらいの水準といえます。
このうち、画像キャッシュが使っているのは400MBほどで、これを超すとSDWebImage
の内部実装2でキャッシュを全て削除してしまうため、意味のあるサイズ上限にするためには、少なくともこれより低い上限を選ぶ必要があります。
一方で、キャッシュするUIImage
のサイズに対して小さすぎると、メモリキャッシュのヒット率が悪くなり、ディスクキャッシュへの読み書きが多くなるなどのオーバーヘッドが生じます。
そのため、効果的なサイズ上限を設けつつ、パフォーマンスを維持するためには、前項目のUIImage
のリサイズを同時に実施する必要がありました。
Mirrativでは、画像キャッシュ以外にもUnityのアバター機能や視聴機能、web viewなどでもメモリを多く利用するため、一般的な水準よりも厳しめの上限を設定しました。
その上で事前に適切なサイズを見積もるのは難しいため、リリース後もディスクI/Oが大きく増えていないかなどを確認する必要があります。
今回、キャッシュのパフォーマンス改善を反映したのが10.19.0ですが、90th percentileで600MBほどで、以前のバージョンからディスク書き込みが大きく増えていないことが確認できます。
メモリ使用量の多いいくつかの画面についてこれらを実施した結果として、applicationDidReceiveMemoryWarning
が呼ばれることがほぼなくなり、ピーク時のメモリ使用量が半分ほどにまで改善しました。 3
また、それ以外の画面についても、アニメーションWebPなどのメモリ上のサイズが大きいリソースについては、用途や利用頻度に応じてメモリキャッシュを保持しないようにする微調整をしています。
メモリリークへの対策
メモリリークについては、最初に記載したように、アプリ本体のみで解決しない問題もありますが、影響の大きそうな部分については適宜対応しています。
アプリ全体のメモリ使用量に影響するような、大きなメモリリークは、前述のキャッシュの問題と同じくUIImageView
が絡んでいることが多く、それを利用しているUIView
やUICollectionViewCell
などに着目すると見つかりやすいです。
メモリリークを検出するにあたり、Xcodeのmemory graph機能やInstrumentsを使ったモンキーテスト的な手法と、XCTest
による検出の2つのアプローチを利用しています。
Allocationsやmemory graphを利用した手動検出
最初のアプローチでは、キャッシュの問題と同様に、画像を多く使っている画面に対して、view controllerのライフサイクルに合わせて適切にメモリ使用量が増減しているかを、InstrumentsのAllocationsやLeaksで確認します。
そして、不自然に使用量が増えている部分があれば、memory graphやmalloc_history
コマンドを利用して、原因の調査をします。
修正前 | 修正後 |
---|---|
例えばこのケースでは、StreamHeadlineViewController
を2回開いて閉じていますが、2つともインスタンスが残ってしまっています。
ほとんどのview controllerはdismissの際に解放されるのが想定される挙動なので、インスタンスが複数残っている場合はメモリリークを起こしている可能性があります。
修正前 | 修正後 |
---|---|
また、こちらのケースでは、reuseされるUICollectionViewCell
のはずなので、インスタンスの数がある程度から増えなくなるはずですが、スクロールやview controllerを開くタイミングで増え続けていたためメモリリークに気づくことができました。
XCTestを利用したCI上での自動検出
もう一つのアプローチでは、XCTest
を利用してCIで自動で検出するアプローチです。
Mirrativ iOSアプリでは、ReactorKit
を利用していて、Rxのbind部分はメモリリークが起きやすい部分でもあります。
Viewのライフサイクルのタイミングや、bindのタイミングで起きるメモリリークを検出するために、XCTAssertNoLeak
を利用させていただき、view controller単位でテストを書ける部分から、メモリリークの修正とテストケースの追加を行いました。
導入するタイミングで、RxCollectionViewDataSourceType
を利用している箇所のretain cycleや、Timer
経由の参照など、レビューで見逃しやすいメモリリークを解消することができました。
現在は、すべてのview controllerにメモリリークのテストを書ける状態ではないのですが、アーキテクチャの厳密な適用や、DIの導入によってテスト可能な構造に移し替えるプロジェクトを並行して進めています。
改善の成果と今後の課題
これらの対策をバージョン10.19.0で実施した結果、10.18.0と比較して、iPhoneでのピークメモリ使用量(90th percentile)は150MBほど減少し、割合では11%減少しました。
一方で、50th percentileでは大きな差はでなかったものの、今回の対策ではOOMクラッシュが発生しやすいような、多くの機能を使うヘビーユーザーのメモリ使用量を削減することができ、クラッシュフリーレートも改善しました。
今回改善できた部分もありつつ、直近の課題として以下のようなものが残っています。
- 配信画面や視聴画面など残りの主要画面のテストケースの追加
- MetricKitを利用したデグレードを検知できる仕組み作り
- iPad端末のメモリパフォーマンスの改善
道半ばではありますが、今後も改善を続けていきます。
We are hiring!
ミラティブでは、パフォーマンス改善などチャレンジングな課題に取り組めるエンジニアを募集しています!
アプリ以外の領域についても絶賛募集中です!
参考リンク
-
SDWebImage
のSDImageResizingTransformer
をアップスケーリングを無効にする形で利用しています↩ - https://github.com/SDWebImage/SDWebImage/blob/507225ea04876894856186af6d3c30a9e8c1200a/SDWebImage/Core/SDMemoryCache.m↩
-
現在は
XCTMemoryMetric
などを利用した自動化が困難なため、条件には若干のブレがありますが、概ね同じ結果でした↩