はじめに
iPhoneは年々高性能化していますが、同時にモバイルアプリ自体も高機能化し、求められるものも多くなってます。ただ機能を満たすだけでなくアプリとしての体験をより良くしていくためにはパフォーマンスの改善が必要です。一口にパフォーマンスを改善するといっても色んな観点がありますが、その一つにメモリ管理があります。
最近メモリリークの調査で結構苦労したのでその過程で得た知見をまとめてみました。同じくiOSアプリのメモリリークに苦しんでいる人の助けになれば幸いです。
環境
本記事で紹介する内容は以下の環境を前提にしています。
- Xcode13.1
- Swift5.5
メモリリークとは
メモリが解放されないまま残り続けることです。
メモリが解放されないのは参照カウントが0にならなくなってしまった時であり、それは循環参照という問題が発生している際に起こります。
この参照カウントや循環参照という概念を知るためにはSwiftの**ARC(Automatic Reference Counting)**という仕組みを知る必要があります。
SwiftではARCという仕組みにより、参照の数をカウントし、0になったタイミングでメモリを解放してくれます。
しかしインスタンスが相互に強参照し合うと参照カウントが0になることはありません。
その結果発生するのがメモリリークです。
なぜメモリリークを修正すべきなのか
メモリリークの原因や改善方法に関する知見は結構見ますが、意外と述べられていないのがなぜメモリリークを修正する必要があるのかということです。
2021年のWWDCの「Detect and diagnose memory issues」では、僕達がメモリを気にすべき理由はユーザー体験の向上に繋がるからとしています。
具体的には以下の4つの観点からアプリの体験の向上が期待できます。
- Faster application activations
- アプリの起動が早くなります。
- Responsive experience
- アプリの応答性が良くなります。
- Complex workflows
- より複雑な機能をアプリに搭載できます
- Wider device compatibily
- 幅広いデバイスに体験の良いアプリを提供できます。
逆に適切にメモリを解放していないと、上記とは真逆の要因でアプリの体験を損なうことに繋がると理解しました。
メモリリークの発生パターン
メモリリークは主に循環参照によって発生しますが、いくつか発生パターンがあります。
Delegate使用時の相互強参照
参照型同士でお互いを参照したくなるのがDelegateというデザインパターンを使う時です。
Delegateを使用する時は処理の移譲元と移譲先を用意し、移譲元のdelegateプロパティとして移譲先が参照を持っておくことで移譲を受け取れるようにします。
例えばメンバーを管理している管理者がメンバーから登録完了を受け取りたい場合を考えてみます。
protocol MemberDelegate: AnyObject {
func registerFinished(id: String)
}
class Member {
var delegate: MemberDelegate?
deinit {
print("Memberが解放された!")
}
}
class Administrator {
var managedMember: Member
init(managedMember: Member) {
self.managedMember = managedMember
self.managedMember.delegate = self
}
deinit {
print("Administratorが解放された!")
}
}
extension Administrator: MemberDelegate {
func registerFinished(id: String) {
print("receive the registration! id: \(id)")
}
}
var member: Member? = Member()
var administrator: Administrator? = Administrator(managedMember: member!)
member?.delegate = administrator
member?.delegate?.registerFinished(id: UUID().uuidString) // receive the registration! id: foo
member = nil
administrator = nil
上記の例ではDelegateを使ってMemberからの通知をAdministoratorが受け取ることができました。
しかしmemberとadministratorそれぞれにnilを代入してもdeinitが呼ばれません。
これはつまり変数としてのmemberやadminitratorは不要になったけど、インスタンスとそのメモリは残ってしまっているということです。
これはadministratorとmemberが循環参照を起こしているからです。
これを回避するにはdelegateをweakで宣言し、delegateに対する参照カウントが加算されないようにします。
protocol MemberDelegate: AnyObject {
func registerFinished(id: String)
}
class Member {
weak var delegate: MemberDelegate?
deinit {
print("Memberが解放された!")
}
}
....
member = nil // Memberが解放された!
administrator = nil // Administratorが解放された!
selfによって保持されるclosure内でのselfの強参照
closureを利用している際にも注意が必要です。
closureは参照型なので、closure内で参照しているインスタンスがclosureを参照するとメモリリークが発生します。
class Member {
var name: String = ""
var changeName: (String) -> Void = { _ in }
init() {
changeName = {
self.name = $0
}
}
deinit {
print("解放された!")
}
}
var member: Member? = Member()
member = nil // nilを入れてもdeinitは呼ばれない。。。
これを避けるためにはclosure内で強参照したくないインスタンスをキャプチャする際にweakをつけます。
class Member {
var name: String = ""
var changeName: (String) -> Void = { _ in }
init() {
changeName = { [weak self] name in
guard let self = self else { return }
self.name = name
}
}
deinit {
print("解放された!")
}
}
var member: Member? = Member()
member = nil // 解放された!
これはDelegateで循環参照を避けるためにweakをつけて弱参照で宣言した時と同じことをclosureでやっています。
ただclosureを利用してselfを使用する際に必ずリークするというわけではなく、その判断は結構難しいです。
まずStructやenumのような値型のインスタンスを使用する際はそもそも参照型じゃないのでリークは起こらないです。他にもDispatchQueueを利用した非同期処理やUIViewのanimationのように、closureが実行された後にそのclosureが破棄される場合は内部でselfを参照していてもメモリのリークは起こらないみたいです。
ちなみに僕は参照型でclosureを扱う時はUIViewのanimationなどを除き、全て[weak self]をつけるようにしています。
weakをつけるとoptionalでselfを扱う必要があるのでguardでハンドリングしたりする手間は増えますが、もしweakをつけていなくてリークが発生した場合はユーザーの体験を損なう可能性があるという恐ろしいデメリットがあるので、迷ったらweakにしておいた方が良いのではと思ってます。
メモリリークの検知方法
いくつか方法があります。
Memory graphを利用する
Memory graphを使うとアプリが確保しているメモリをグラフィカルに表示できます。
上記はアプリで表示中のViewControllerのメモリを表示した画像です。
Memory graphではメモリを確保したインスタンスとその参照元を表示してくれます。
そのため、メモリを確保する→解放するという想定になる操作を行なった後にこのMemory graphで対象のインスタンスのメモリを確認し、インスタンスのメモリの数が増えていないか確認することで、メモリリークが発生していないか確認できます。
例えばmodalで表示したViewのメモリはdismiss後に不要になるので解放されていて欲しいです。
そこでmodalでViewを表示→dismissということを繰り返した後に、対象のViewが増え続けていないかを確認します。
modal表示中 | dissmiss後 |
---|---|
そしてそのあとMemory graphを表示すると
あー表示していたViewControllerとViewModelのインスタンスのメモリが残り続けていました。
今回2回表示と非表示を繰り返しましたが、しっかり2個分残っています。
これで該当のViewControllerがメモリリークしていることが分かりました。
また、Memory graphを表示した際にWarningとしてリークを教えてくれることもあります。
ただこのWarningは発生していないけど実はリークが発生しているということが多々あるので、直接インスタンスのメモリを確認するのがおすすめです。
リークしているインスタンスが分かったらリークの原因となっているコードを探していきます。
schemeからMalloc Stack LoggingをONにすれば、Memory graphで表示しているメモリからコードのスタックを確認できます。
これをONにした状態でMemory graphを表示すると対象のメモリを選択した際にXcodeの右窓のMemory inspectorからそのメモリが確保されたまでのスタックを確認できます。
そしてStackからは該当のコードに飛ぶことができます。
このスタックを見つつclosureに利用箇所や参照型の変数の利用でリークが発生していないか確認できます。
しかしこのスタックに表示されていない箇所が原因になっていることも多く、僕は結局対象のインスタンスのコードの中でclosureの利用箇所をしらみ潰しに見て行くことになることが多いです。。。
Debug NavigatorからMemoryを確認する
Debug NavigatorからMemoryの使用量を見ることでもリークを確認できます。
Memory graphの時同様に、メモリの確保→解放となる想定の操作を行なった後にmemoryのグラフが増加→低下の動きをしていればリークしていないことを確認できます。
逆に言えば、Memoryが増加する一方で下がっていない場合はリークしている可能性が高いです。
Memory graphほど正確にリーク対象のインスタンスを把握できませんが、リークしてそうかどうかを把握したい場合や、メモリの使用量を把握したい場合に役立ちます。
InstrumentsのLeaksを利用する
Leaksを使うとアプリを操作していてメモリリークを検知した時に教えてくれます。
Targetを選んで左上のRecodingボタンを押すとアプリのLeakの記録を開始します。
その状態でアプリを操作しているとメモリリークを検知したタイミングで✖️マークが付きます。
その直前に行なった操作に関連するclassでメモリリークが発生していることが分かります。
✖️マークの箇所にカーソルを合わせることで、クラッシュ発生時のObjectとそのObjectのスタックを表示できます。
Memory graphの時同様このスタックからリークの原因となっているコードを調査できます。
ただ、ここで表示されているObjectにはリークの対象以外も含まれるため、結局どのObjectがリークしてるかLeaksだと分からないと思いました。。。
なので個人的にはLeaksはアプリの操作から想定外のメモリリークの発生を検知するために使用し、インスタンスごとの詳細な調査はMemory graphを使った方が効率的なのではと思います。
僕がLeaksを使いこなせていないだけかもしれないので、もっと良い使い方が是非教えてください
テストによって確認する
テストによってメモリリークが発生していないか確認することもできます。
一度テストを作っておけば意図せずリークが発生した際にテストで検知できるのがメリットだと思います。
以下のライブラリが気になってますが未使用です。。。
対象のインスタンスを生成して検証用のメソッドに渡すとリークしているかどうかを検証し、リークしている箇所を教えてくれるみたいです。
おわりに
他にもメモリリークの検知や修正で良い方法があれば是非教えて欲しいです。。。
次は同じパフォーマンス改善の観点でUIの改善をまとめたいです。