SwiftのメモリリークとREALITYでの対策
最近、色々なプロテインを試してみた結果、結局?SAVASのココア味が一番好みだったiOSエンジニアのあおやまです。
はじめに
Swiftで開発をしている中で最も注意するべき事の1つにメモリリークがあります。
SwiftはARC(Automatic Reference Counting)でメモリを管理しているため、ついつい循環参照してメモリリークするコードを書いてしまいがちです。
そこで、このnoteではやってしまいがちなSwiftでメモリリークするコードと、REALITYで行われているメモリリークの対策を紹介します。
詳しいメモリリークの原因や改善すべき理由は、下記のWWDC21の動画の解説がとても分かりやすいです。
やってしまいがちなSwiftでメモリリークするコード
全てのパターンを網羅的に紹介することは難しいため、実際に過去にREALITYでやってしまった/やってしまいがちなパターンを中心に紹介します。
delegateでの強参照
fugaのプロパティのhogeのdelegateとしてfuga自身を渡す事が多々あります。
下記のようにdelegateが強参照になっていると、fuga → hoge → fugaの順番で強参照する事になり、循環参照が発生します。
protocol HogeDelegate: AnyObject {
}
class Hoge {
var delegate: HogeDelegate?
}
class Fuga: HogeDelegate {
let hoge = Hoge()
init() {
hoge.delegate = self
}
}
let fuga = Fuga()
この場合、delegateを弱参照にすることで、hogeからfugaへの参照がカウントされなくなり、循環参照を防ぐ事が可能です。
protocol HogeDelegate: AnyObject {
}
class Hoge {
weak var delegate: HogeDelegate? // weakをつける
}
class Fuga: HogeDelegate {
let hoge = Hoge()
init() {
hoge.delegate = self
}
}
let fuga = Fuga()
escaping closureでselfを強参照
このパターンは、RxSwiftやCombineなどを利用しているとよくやってしまいがちです。
下記の例では、sinkのescaping closure内でselfを強参照しているため、self → publisher → selfで循環参照が発生しています。
class Fuga {
let publisher = PassthroughSubject<Void, Never>()
.eraseToAnyPublisher()
var cancellables: Set<AnyCancellable> = []
init() {
publisher.sink { _ in
self.doSomething()
}.store(in: &cancellables)
}
func doSomething() {
}
}
let fuga = Fuga()
closure内でselfを弱参照することで、この問題は解決できます。
class Fuga {
let publisher = PassthroughSubject<Void, Never>()
.eraseToAnyPublisher()
var cancellables: Set<AnyCancellable> = []
init() {
publisher.sink { [weak self] _ in // weakをつける
self?.doSomething()
}.store(in: &cancellables)
}
func doSomething() {
}
}
let fuga = Fuga()
escaping closureで暗黙的closureがselfを強参照
上のパターンと似ていますが、escaping closureに関数のポインタを渡しているかのように見えます。
class Fuga {
let publisher = PassthroughSubject<Void, Never>()
.eraseToAnyPublisher()
var cancellables: Set<AnyCancellable> = []
init() {
publisher
.sink(receiveValue: doSomething)
.store(in: &cancellables)
}
func doSomething() {
}
}
let fuga = Fuga()
しかし、これは下記のコードのように、closureに暗黙的に展開され、selfが強参照されてしまいます。
init() {
publisher
.sink(receiveValue: { self.doSomething() }) // 暗黙的に展開される
.store(in: &cancellables)
}
そこで、上のパターンの改善例と同じように、closure内でselfを弱参照して解決します。
init() {
publisher.sink { [weak self] _ in // weakをつける
self?.doSomething()
}.store(in: &cancellables)
}
superを強参照
これは私がやってしまったパターンです。selfに注意を向けていたため、superを見逃してしまいました。
class Piyo {
func doSomething() {
}
}
class Fuga: Piyo {
let publisher = PassthroughSubject<Void, Never>()
.eraseToAnyPublisher()
var cancellables: Set<AnyCancellable> = []
override init() {
super.init()
publisher
.sink { _ in
super.doSomething()
}
.store(in: &cancellables)
}
}
let fuga = Fuga()
[weak super] という表現ができないため、superの関数をラップした関数を弱参照したselfから実行します。
class Piyo {
func doSomething() {
}
}
class Fuga: Piyo {
let publisher = PassthroughSubject<Void, Never>()
.eraseToAnyPublisher()
var cancellables: Set<AnyCancellable> = []
override init() {
super.init()
publisher
.sink { [weak self] _ in // [weak super]は不可能
self?.superDoSomething()
}
.store(in: &cancellables)
}
func superDoSomething() {
super.doSomething()
}
}
let fuga = Fuga()
REALITYでのメモリリーク対策
XcodeのMemory Graphはメモリリークの原因の特定、改善のためには非常に強力なツールですが、普段の開発からMemory Graphで状態を確認する事は大変です。
そこでREALITYではメモリリーク対策で次の3つに取り組んでいます。
SwiftLintを使ったweak selfの記述忘れの検出
→ コード実装時に気付く仕組み
DeallocationCheckerを使ったメモリリークの検出
→ 実機で実行時に気付く仕組み
XCTAssertNoLeakを使ったメモリリークの検出
→ UnitTestで気付く仕組み
順番に紹介します。
SwiftLintを使ったweak selfの記述忘れの検出
SwiftLintのcustom_rulesを使い、正規表現でclosure内部で[weak self]を書いていないself句を特定しています。
詳しくは下記noteをご参照ください。
DeallocationCheckerを使ったメモリリークの検出
DeallocationCheckerというOSSを使ったメモリリークの検出をmethod swizzlingを用いて全ての画面に導入しました。
ランタイムで検出可能なので、実装中や動作確認中などランタイムでメモリリークを検出できるようになりました。
dismissやpopなどで、UIViewControllerのviewDidDisappearが実行されてから1秒後にそのViewControllerのインスタンスが解放されているかチェックして解放されていなければアラートを表示します。
実装する上で下記のスライドを大変参考にさせていただきました。
XCTAssertNoLeakを使ったメモリリークの検出
XCTAssertNoLeakを使ってUnitTestでメモリリークを検出しています。
ターゲットのインスタンスのプロパティを再帰的にMirrorを使って探索し、それぞれのインスタンスがviewControllerのdismissが完了した後に解放されているかを確認してくれます。
実際にViewControllerに表示して再起的にプロパティのメモリリークを検査するため、SwiftLintやDeallocationCheckerでは気付けないメモリリークも気付く事が可能です。
func testHogeViewControllerNoLeak() {
XCTAssertNoLeak { context in
let rootViewController = UIApplication.shared.keyWindow!.rootViewController!
let viewController = HogeViewController()
rootViewController.present(viewController, animated: true, completion: {
context.traverse(viewController)
rootViewController.dismiss(animated: true, completion: {
context.completion()
})
})
}
}
まとめ
やってしまいがちなSwiftでメモリリークするコードと、REALITYで行われているメモリリークの対策を紹介しました。
XCTAssertNoLeakを拡充する事が最も確実ですが、追加でSwiftLintとDeallocationCheckerを使って、実装中やランタイムにもメモリリークに気付ける仕組みを用意する事で効率的かつ安全な実装を目指しています。
REALITYではメモリリークに気を付けながら、一緒にREALITYを作ってくれる仲間を募集中です!
カジュアル面談も実施しているため、お気軽にお声かけください。