こんにちは。事業開発部の岡村 (@iceman5499) です。 普段はクックパッドアプリ(iOS)を開発しています。
先日、アプリケーションが特定の条件で意図せぬ状態に陥り、アプリケーションが重くなって端末が発熱する、というバグが発見されました。 調査の結果、このバグはメモリリークが原因で発生していました。 この反省を踏まえメモリリークを検知するテストを導入したため、本記事ではその事例を紹介したいと思います。
(本記事ではクックパッドアプリとはiOS版の「クックパッド」アプリのことを指すものとします)
クックパッドアプリにおけるメモリリークの影響
クックパッドアプリはレシピの検索をコア機能としています。 検索は重い処理ですがAPIを通してサーバ上で行われるため、アプリは結果を表示するだけです。そのためメモリを多く必要としません。 これまでにも何度かメモリリークが発生している状況はありましたが、メモリを多く必要としないため多少の無駄があってもアプリの動作に影響がありませんでした。
クックパッドアプリで用いられているクラスの大半は自力で動くようなことはせず、RunLoop等のイベントループによって動作します。 UIKitを使用しているとインスタンスのRunLoopからの除去は自然に実現できるため、メモリリークが起こってもそのインスタンスは止まっていて、無害な状態であることが多いです。
しかしながら、今回は不運にも単独でイベントループを発生させるインスタンスがメモリリークしてしまいました。 本来はそのインスタンスがメモリから解放されたタイミングでイベントループが止まるはずでしたが、メモリから解放されなかったことにより停止されないイベントループが永遠に無駄な処理を続けていました。 その結果、そのインスタンスが多数メモリリークしてしまうとアプリケーションの動作に影響するほど負荷がかかってしまいました。
どのようにしてメモリリークが起こってしまうのか
iOSアプリケーション開発の現場で起こるメモリリークは大抵、循環参照が原因となっています。 循環参照によるメモリリークは参照カウント方式のガベージコレクション環境において発生しうる問題で、SwiftやObjective-Cを使っていると起こりうるものです。(なお、ここでは循環参照がどのような状態であるかの説明は省略します。) クックパッドアプリではRxSwiftというライブラリを多用しているため、クロージャを経由してうっかりメモリリークする形の循環参照を引き起こしてしまうケースが多いです。
よくある循環参照の例
実際にクックパッドアプリで起こっていた循環参照の実装の例を紹介します。
自身が所持するObservableのobserverとして自身が所持されるパターン
ちょっとタイトルがややこしいですが、最もシンプルなタイプのものです。
// ViewController.swiftletstream:PublishSubject<Void>letdisposeBag= DisposeBag() funcbind() { stream.subscribe(onNext: { self.doSomething() // selfがキャプチャされている }) .disposed(by:disposeBag) }
RxSwiftでは Observable
を購読するとその購読がobserverオブジェクトとして Observable
に保持されます。(例の PublishSubject
は Observable
の一種です。)
この例ではobserverはさらに onNext:
に渡されているクロージャを保持します。そしてさらにクロージャは self
を保持しています。ここで self
はこの実装を持つ ViewController
クラスであるとします。
その結果、
self → stream → observer → クロージャ → self
として循環参照となります。
この循環参照の解決策としては、次のように self
を弱参照でキャプチャする方法が挙げられます。
stream.subscribe(onNext: { [weak self] inself?.doSomething() })
暗黙クロージャ渡しパターン
次の例はどうでしょうか。これもたまにある例で、気づくことが難しい循環参照です。
// ViewController.swiftletstream:PublishSubject<Int>letdisposeBag= DisposeBag() funcbind() { stream.subscribe(onNext:doSomething) // この場合もselfが強参照されてしまう .disposed(by:disposeBag) } funcdoSomething(_ value:Int) { ... }
onNext:
にクロージャを使わずに関数を渡すことで、すっきりとした表記になっています。
ところがこの doSomething
、一見関数ポインタを渡しているように見えますが、実際はコンパイラが裏側で self
をキャプチャしたクロージャを生成しているため、次のコードと同じ意味になっています。
stream.subscribe(onNext: { self.doSomething($0) })
これは先程と同じパターンの循環参照となります。
対処法としては先程と同じように [weak self]
を用いるのが良いでしょう。
あるいは、 doSomething
の処理が self
に依存していないならばそれをstatic関数にしてしまうという手もあります。(static関数になった場合その関数の実行に self
が必要なくなるため、上で紹介したコンパイラが裏側でやる処理が無くなります。)
funcbind() { stream.subscribe(onNext:Self.doSomething) .disposed(by:disposeBag) } staticfuncdoSomething(_ value:Int) { ... }
複数クラスにまたがって循環しているパターン
// Presenter.swiftclassPresenter { letvalue:Observable<Int>init(view:View, interactor:Interactor) { value = interactor.stream .filter { view.isXXX } // ここで view を強参照でキャプチャしている .map { ... } } } // View.swiftclassViewController:View { varpresenter:Presenter!varisXXX:Bool { ... } letdisposeBag= DisposeBag() funcbind(presenter:Presenter) { presenter.value.subscribe(...) .disposed(by:disposeBag) ...self.presenter = presenter } }
これは複数のクラスにまたがる例です。
Presenter
が生成する value
には view
がキャプチャされており、その川を view
である ViewController
クラスが購読します。
つまり ViewController
からみて、
self → presenter → value → filterの内部で使われるオブジェクト → クロージャ → view(== self)
として参照関係が発生し、循環参照が成立します。
self
がクロージャにキャプチャされている場合はなんとなくアンテナが反応しやすいのですが、そうでないケースはうっかり見過ごされやすいです。
これの対応も同様に弱参照を用いることになります。
.filter { [weak view] _ in view?.isXXX ==true }
メモリリークを検知するテストの導入
「よくある循環参照の例」を見てわかるように、循環参照はうっかり見逃しやすいため人目のレビューをすり抜けてしまいます。 またコンパイラによって検知することもできません。
そこで、XCTAssertNoLeakを使ってテストを書くことにしました。
XCTAssertNoLeakは対象のインスタンス内でメモリリークが発生しているかを検知する機能を提供するテスト用ライブラリです。 2019年のtry!Swiftで発表されたライブラリで、メモリリークを検知するテストを書くことができます。
https://github.com/tarunon/XCTAssertNoLeakのREADME.mdより
ただ引数にオブジェクトを渡すだけで、簡単にリークしているオブジェクトをリストしてくれる素敵なライブラリです。
XCTAssertNoLeakの動作原理
XCTAssertNoLeakはどのようにしてメモリリークを検知しているのでしょうか。
基本的な戦略としては、インスタンスをweakポインタに格納し、スコープが変化したタイミングでポインタの中身が nil
になっているかどうかで判定をしています。
インスタンスが持つプロパティ群に格納された子インスタンスや、そのさらに孫インスタンスを全て確保するためには Mirror
が用いられています。
Mirrorを使い全プロパティを探索して参照型の値を全てweakポインタに確保し、スコープを抜けたあとそのweakポインタがちゃんと nil
になっているか確認する、という感じです。
テスト記述に関する注意点
XCTAssertNoLeakは非常に簡単に利用できるようになっていますが、仕組み上いくつか気をつけないといけない部分があります。
ローカル変数のスコープに注意する
XCTAssertNoLeak
は引数に渡したオブジェクトが検査されますが、ローカル変数にオブジェクトを保持してしまうとそのローカル変数が参照を持つためにテストに用いられるweakポインタが nil
にならず、メモリリーク扱いになってしまいます。
// NGletviewController= MyViewController() XCTAssertNoLeak(viewController) // faild!
回避するためには XCTAssertNoLeak
の引数にオブジェクトを右辺値として渡す必要があります。
クックパッドアプリでは次のようにしてテストを記述しています。
funcbuild() ->AnyObject { RecipeDetailsViewBuilder.build(....) // 初期化処理 } XCTAssertNoLeak(build())
初期化処理をローカル関数にラップし、返り値をそのまま XCTAssertNoLeak
に放り込むことでローカル変数にテスト対象のインスタンスを保持しないようにしています。
シングルトンは例外設定
次のテストを見てみましょう。
NotificationCenterがリークしていると怒られています。
シングルトンは開放されないため、XCTAssertNoLeakから見るとメモリリークしているものとして判定されます。
このような状況に対応するために CustomTraversable
というプロトコルが用意されています。
extensionNotificationCenter:CustomTraversable { varignoreAssertion:Bool { true } }
メモリリークしていると判定されるクラスに対してextensionで ignoreAssertion: Bool
を実装することで、そのエラーを無視することができます。
CustomTraversable
にはこの手のケースに対応するための口がいくつか用意されています。
シングルトンが保持するオブジェクト
シングルトンを無視設定するところまでは良かったですが、 ignoreAssertion
するだけではそのオブジェクトに連なっているオブジェクトがさらにリーク判定されてしまいます。( ignoreAssertion
はそのインスタンスの子プロパティ群ごと無視はしません)
classAwesomeObject {} classMySingleton { staticletshared= MySingleton() privateletobject= AwesomeObject() } extensionMySingleton:CustomTraversable { varignoreAssertion:Bool { true } } classMyViewController:UIViewController { letobject= AwesomeObject() letdependency= MySingleton.shared } classNoLeakTests:XCTestCase { functestMyViewController() { // failed - 1 object occured memory leak.// - self.dependency.object XCTAssertNoLeak(MyViewController()) } }
このコードの例の場合、 self.dependency.object
がリークしたという判定になります。
あまりこのようなケースはありませんが、現実のアプリケーションは複雑であるためまれにこのケースに遭遇します。
これの対応を考えることは難しいです。例えば次のようにシングルトンに持たれた AwesomeObject
のみ例外設定することを考えます。
extensionAwesomeObject { varignoreAssertion:Bool { self=== MySingleton.shared.object // コンパイルエラー: 'object' is inaccessible due to 'private' protection level } }
このように書きたいところですが、アクセス修飾子の関係でこの処理は記述することができません。 対象のオブジェクトがアプリケーション内に実装されていればやりようはあるかもしれませんが、外部のライブラリなどとなると難しくなってきます。
クックパッドアプリではこのケースは諦めて AwesomeObject
の ignoreAssertion
は常に true
を返すようにしています。
まとめ
XCTAssertNoLeakのおかげで、メモリリークを検知するテストを実現することができました。 このテストを実装してすぐ、僕の変更で循環参照を引き起こしてしまいテストに怒られてしまったので早速効果を発揮しました。 テストを導入する過程で見つかったメモリリークもいくつかあり、今まで見つかってなかったメモリリークも浮き彫りにすることができました。
このようにしてiOSアプリのメモリリークは解消しましたが、モバイルアプリの品質安定にはまだまだ手が足りていない状況です。 クックパッドではモバイルアプリの品質を安定させたいiOS/Androidエンジニアを募集しています。 https://info.cookpad.com/careers/