Intelligent Technology's Technical Blog

株式会社インテリジェントテクノロジーの技術情報ブログです。

LeakCanaryでメモリリークを検知する

こんにちは、間藤です。

今回はAndroidアプリのメモリリーク検知をサポートするライブラリLeakCanaryについて取り上げます。メモリリークはテストをしていてもなかなか表面化しないので、見つけられないままになっているケースも多いのではないかと思います。iOSであればInstrumentsを利用できます。AndroidでもMATを利用するという選択肢もありますが、手順がなかなか煩雑で効率的とは言えないと思います。(私がMATに慣れてないだけかもしれませんが・・・)

LeakCanaryを導入すると、手間なくメモリリークが検知できるようになります。なお、今回利用したLeakCanaryのバージョンは1.3.1です。

導入

GitHubのReadmeに書かれている通りの手順を踏むだけですが、念のため触れておきます。
まず、LeakCanaryをプロジェクトに取り込みます。Gradleのスクリプトに依存関係を記述します。

dependencies {
    debugCompile project(':leakcanary-android')
    releaseCompile project(':leakcanary-android-no-op');
}

名前から察しはつくと思いますが、releaseビルドではLeakCanaryのメモリリーク検知が機能しないよう、別ライブラリを指定しています。Build Variantをカスタマイズしているのであれば、それに合わせて使い分けてください。
次にApplicationの派生クラスを用意して、LeakCanaryクラスのinstallメソッドをコールします。(AndroidManifest.xmlの修正も忘れずに)

public class ExampleApplication extends Application {

  @Override public void onCreate() {
    super.onCreate();
    LeakCanary.install(this);
  }
}

メモリリーク検知のためにアプリのプログラムに若干の修正が必要になるわけですが、前述の通りリリースビルド用のライブラリ(空実装)が提供されていますので、ラッパークラスを用意するといった面倒なことをする必要はありません。

サンプルを試す

では、メモリリークが検知される様子をサンプルを使って見ていきます。
GitHubからLeakCanaryのリポジトリをクローンします。(これにサンプルが付いてます)

$ git clone https://github.com/square/leakcanary.git

Android Studioでプロジェクトを開いて実行すると、サンプルが試せるようになっています。
サンプルは、以下のようにボタンが1つ配置された画面構成で、このボタンを押下した後に画面を回転すると、メモリリークが発生するようになっています。

f:id:IntelligentTechnology:20150814154109p:plain:h400

以下、問題のコード抜粋です。

  private void startAsyncTask() {
    // This async task is an anonymous class and therefore has a hidden reference to the outer
    // class MainActivity. If the activity gets destroyed before the task finishes (e.g. rotation),
    // the activity instance will leak.
    /*
    */
    new AsyncTask<Void, Void, Void>() {
      @Override protected Void doInBackground(Void... params) {
        // Do some slow work in background
        SystemClock.sleep(20000);
        return null;
      }
    }.execute();
  }

内部クラスがMainActivityクラスのインスタンスを掴んでしまうため、回転によりアクティビティが再生成されても、元のインスタンスがGCの対象にならずリークが発生します。
リークを検知すると、LeakCanaryが以下のようなログを出力します。

08-14 15:32:08.089  26263-26423/com.example.leakcanary D/LeakCanary﹕ In com.example.leakcanary:1.0:1.
08-14 15:32:08.089  26263-26423/com.example.leakcanary D/LeakCanary﹕ * com.example.leakcanary.MainActivity has leaked:
08-14 15:32:08.089  26263-26423/com.example.leakcanary D/LeakCanary﹕ * GC ROOT thread java.lang.Thread.<Java Local> (named 'AsyncTask #1')
08-14 15:32:08.089  26263-26423/com.example.leakcanary D/LeakCanary﹕ * references com.example.leakcanary.MainActivity$2.this$0 (anonymous class extends android.os.AsyncTask)
08-14 15:32:08.089  26263-26423/com.example.leakcanary D/LeakCanary﹕ * leaks com.example.leakcanary.MainActivity instance
08-14 15:32:08.089  26263-26423/com.example.leakcanary D/LeakCanary﹕ [ 08-14 15:32:08.089 26263:26423 D/LeakCanary ]
・・・(以下長いので省略)

これだけではなく、端末側にも通知が表示されます。

f:id:IntelligentTechnology:20150814155137j:plain:h100

通知をタップすると、リーク情報を確認するための画面(DisplayLeakActivity)が起動します。

f:id:IntelligentTechnology:20150814164155p:plain:h400

LeakCanaryがやってること

詳細までは追えていませんが、LeakCanaryが何をやっているのかをざっくり確認してみました。

  • LeakCanaryのinstallメソッドを呼び出します。
  • そうするとActivityRefWatcherというクラスが、Activityが破棄されるのをウォッチするようになります。
  • ActivityRefWatcherがActivityの破棄を検知すると、RefWatcherクラスのwatchメソッドを呼び出します。引数には破棄されたActivityのインスタンスが渡されます。
  • watchメソッドは、AndroidWatchExecutor経由でensureGoneメソッドをバックグランドで呼び出します。なお、デバッガにアタッチしているときは処理を中断します。デバッガにアタッチされていると、メモリリークの解析を正しく行えないからだと思われます。また、ensureGoneメソッドの呼び出しは5秒遅延されるようになっていました。解析対象のActivityインスタンスはWeakReferenceで保持されます。
  • ensureGoneメソッドでは、ReferenceQueueを利用してfinalizeされたWeakReferenceを検知します。そうすることで、解析対象のActivityインスタンスの状態を確認します。また、1度チェックしてリファレンスが残っているようなら、GCを明示的に実行して再度チェックしています。そして、リークが疑われるということであれば、AndroidHeapDumperクラスのdumpHeapメソッドを呼び出します。
  • dumpHeapメソッドでは、Debug#dumpHprofData() メソッドを呼び出して、外部ストレージ上にHPROFファイルを生成します。その間以下のようなToastを表示します。(「重たい処理を実行するからアプリフリーズするかも」って警告ですね)

f:id:IntelligentTechnology:20150814155044p:plain:h250

  • HeapAnalyzerServiceクラス(IntentServiceです)が生成されたHPROFファイルを解析します。
  • DisplayLeakServiceクラス(これもIntentServiceです)が解析結果の表示を担当します。通知の作成も行います。

私がコードを読み間違えていなければ、およそこのような流れになっています。Activityの破棄をきっかけにして解析を行うこと、Activityのインスタンスがリークしていないかをチェックしていることがポイントではないかと。

使いどころとしては、「普段使い」ということなのかもしれません。普段の開発からMATで解析するのは現実的ではないと思います。不幸にもOOMが発生してしまったらMATを使ってがっつり解析しなければならないかもしれませんが、開発時にLeakCanaryを導入することで、さほどコストをかけずに開発時のメモリリーク作り込みを防ぐことができそうです。

おまけ(その1)

サンプルアプリで検知されたメモリリークに対処して、LeakCanaryがリークを検知しなくなるか確認してみます。

  private void startAsyncTask() {
    new SampleTask(this).execute();
  }

  private static class SampleTask extends AsyncTask<Void, Void, Void> {
    WeakReference<Activity> activityRef;

    public SampleTask(Activity activity) {
      this.activityRef = new WeakReference<Activity>(activity);
    }

    @Override
    protected Void doInBackground(Void... params) {
      // Do some slow work in background
      SystemClock.sleep(20000);
      return null;
    }

    @Override
    protected void onPostExecute(Void result) {
      Log.d("LeakcanarySample", "Task completion...");
      Activity activity = activityRef.get();
      if (activity != null) {
        Toast.makeText(activity, "Task completion...", Toast.LENGTH_SHORT).show();
      }
    }
  }

このコードであれば、タスク実行中にActivityが再生成されても、LeakCanaryは何も警告しなくなりました。
ちなみにですが、AsyncTaskで陥りやすい罠について書かれた以下のスライドがとてもわかりやすいので紹介しておきます。

AsyncTask アンチパターン

おまけ(その2)

まったくLeakCanaryとは関係ないのですが、この記事を書いている最中にしばらく放置していたAndroid Studioのバージョンアップ(1.3.1)を行いました。そうしたところビルドが失敗するようになってしまいました。
以下のようなダイアログが出るのですが、

f:id:IntelligentTechnology:20150814190118j:plain

YESを選択した際のgradle.propertiesの更新が不十分のようです。以下のようにHTTPS用の設定を追加することで対処できました。

systemProp.https.proxyHost=(host)
systemProp.https.proxyPort=(port)