Quantcast
Channel: クックパッド開発者ブログ
Viewing all articles
Browse latest Browse all 726

iOSアプリのメモリリークを発見、改善する技術

$
0
0

こんにちは。事業開発部の岡村 (@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に保持されます。(例の PublishSubjectObservableの一種です。) この例では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を使ってテストを書くことにしました。

github.com

XCTAssertNoLeakは対象のインスタンス内でメモリリークが発生しているかを検知する機能を提供するテスト用ライブラリです。 2019年のtry!Swiftで発表されたライブラリで、メモリリークを検知するテストを書くことができます。

f:id:iceman5499:20200303111804p:plain
XCTAssertNoLeak

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に放り込むことでローカル変数にテスト対象のインスタンスを保持しないようにしています。

シングルトンは例外設定

次のテストを見てみましょう。 f:id:iceman5499:20200303111725p:plain

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
    }
}

このように書きたいところですが、アクセス修飾子の関係でこの処理は記述することができません。 対象のオブジェクトがアプリケーション内に実装されていればやりようはあるかもしれませんが、外部のライブラリなどとなると難しくなってきます。

クックパッドアプリではこのケースは諦めて AwesomeObjectignoreAssertionは常に trueを返すようにしています。

まとめ

XCTAssertNoLeakのおかげで、メモリリークを検知するテストを実現することができました。 このテストを実装してすぐ、僕の変更で循環参照を引き起こしてしまいテストに怒られてしまったので早速効果を発揮しました。 テストを導入する過程で見つかったメモリリークもいくつかあり、今まで見つかってなかったメモリリークも浮き彫りにすることができました。

このようにしてiOSアプリのメモリリークは解消しましたが、モバイルアプリの品質安定にはまだまだ手が足りていない状況です。 クックパッドではモバイルアプリの品質を安定させたいiOS/Androidエンジニアを募集しています。 https://info.cookpad.com/careers/


Viewing all articles
Browse latest Browse all 726

Trending Articles