Tech Racho エンジニアの「?」を「!」に。
  • Ruby / Rails以外の開発一般

Android のログ出力に関わる処理コストをリリースビルド時に 0 にする

子供の頃にゲームをやりたくて親にPCをねだったら Mac を買ってこられた頃から Mac が嫌いなのですが、ついに iOS ビルドのために会社から MacBook が送られてきて憂鬱な今日このごろです。

アプリを起動すると Dock 上のアプリアイコンがポヨンポヨンし始めるのを見ただけでストレスが溜まって仕方がありません。
※という感想を日報に書いたら複数名から設定でアニメーション消せるよ、と教えていただきました、ありがとうございます。

そんなわけで気分を変えるべく久々に Android 記事を書いてみます。

はじめに

何故この記事を書こうと思ったか。

自分の普段の作業がEPUBビューアのSDK開発だったり、前職もパフォーマンスをかなり気にする製品だったりしたため、普段から非効率な処理をなるべく減らすように心がけています。

が、途中から引き継いだコードの保守作業だったりで、かなりアレなコードを目にする機会も多いです。
今時の端末で一般向けの Android アプリなら多少非効率なコードでも大きな問題にはならないと思いますが、出来れば多少なり早い方が良いですし、精神衛生上も良くないのでそういうコードを目にする機会は減らしたいところです。

ということで、どんなプロジェクトでも利用するであろうログ出力について、布教も目的としてこの記事を書いてみることにしました。

順序立てて説明を行っていきますが、結論だけ知りたいという方は Kotlin のインライン関数を利用する だけ読んで頂いても問題ありません。

Android でのログ出力について

通常以下のような感じでログ出力を行うと思います。

Log.i("TAG", "message")

この辺りは皆さんご存知だと思いますので細かいことは割愛させていただきますが、ここで発生する実行時のコストは主に以下の2つになります。

  1. ログ出力自体のコスト
  2. message の構築コスト

リリースビルドを行う際は上記のコストを可能な限り、出来れば 0 になるようにしたいです。

ログ出力自体のコストの削減

ログ出力自体のコストを削減するにはどうしたら良いのでしょうか?

前述の例で言えばリリースビルドの際は Log.i("TAG", "message") が呼ばれないようにします。
まあ当然といえば当然ですね。

しかし、すべての箇所を if で囲うなんてことは面倒臭すぎてやってられません。

対策となる手法はいくつかあると思いますが、後の説明にも影響するためここでは有名所のライブラリである Timber を使った手法を説明します。

このライブラリではログ出力の事前準備として Timber.plant() を呼び出す必要があり、このメソッドに渡す引数でログ出力の内容をカスタマイズすることができます。
ログ出力時は Timber.i() メソッドなどを利用します。

class MyApplication : Application() {
    override fun onCreate() {
        super.onCreate()
        if (BuildConfig.DEBUG) {
            Timber.plant(Timber.DebugTree())
        }
    }
}

class MainActivity : Activity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        Timber.i("ログメッセージ")
   }
}

例えば上記のような形にしておけば、リリースビルドの場合は Timber.plant() が呼び出されないため、Timber.i("ログメッセージ") を通ってもログは出力されなくなります。

これだけでログ出力によるパフォーマンス低下問題は大体改善しますが、さらなる向上を目指して次の項目へ進みましょう。

message の構築コストの削減

この項目の説明でも Timber を継続して利用していきます。

前述の説明でリリースビルド時にはログ出力が行われなくなりましたが、例えば以下の Kotlin コードを実行した場合どうなるでしょうか?

fun outputLog() {
    Timber.i("result=${getIntValue()}, ${getStringValue()}")
}

分かりやすく説明するため、ここでは Kotlin コードのコンパイル結果である jar を JD-GUI で逆コンパイルした結果を記載します。

public static final void outputLog() {
    Timber.Forest forest = Timber.Forest;
    StringBuilder stringBuilder = new StringBuilder();
    stringBuilder.append("result=");
    stringBuilder.append(getIntValue());
    stringBuilder.append(", ");
    stringBuilder.append(getStringValue());
    forest.i(stringBuilder.toString(), new Object[0]);
}

こんな感じになりました。
たとえ最後の forest.i() の呼び出しコストがほぼ 0 だったとしても、その手前の文字列生成処理が非常に無駄ですね。

String.format 形式の指定を利用する

幸いにも TimberString.format の形式での記述に対応しており、以下のように書くこともできます。

fun outputLog() {
    Timber.i("result=%d, %s", getIntValue(), getStringValue())
}

上記コードをコンパイル&逆コンパイルした結果は以下のようになります。

public static final void outputLog() {
    Timber.Forest.i("result=%d, %s", new Object[] { Integer.valueOf(getIntValue()), getStringValue() });
}

可変長引数のせいで new Object[] の生成は発生してしまっていますが、文字列生成処理が消えました。
デバッグビルドでログ出力される際は仕方ないですが、リリースビルド時は Timber.i の中で文字列生成が行われることもありません。

この際に出来るだけ効率よく処理するポイントとして toString を実装する、という方法があります。

%s にオブジェクトをそのまま渡すと、最終的にそのオブジェクトの toString の結果が出力されます。
これにより、toString の中でどれだけ複雑な処理を行っていたとしても Timber.i("%s", object) を呼び出しただけではコストがほぼ発生しなくなります。

ただ toString の実装ではいい感じのログ出力を生成しづらかったり、そもそも実装工数的に大変だったりします。

それでも以前は実際に上記手法を採用して開発を行っていました。主に Java 時代の話です。

しかし、現在は大Kotlin時代。
処理コスト、実装コストまとめて解決するために新たな手法を模索しました。

Kotlin のインライン関数を利用する

お待たせしました。この記事でやりたかったことの結論がこの項目になります。今までの説明は全て前振りです。

Kotlin にはインライン関数という機能があります。

そして重要なのが、このインライン関数に引数としてラムダを渡すと、実行箇所にラムダ本体のコードがインライン展開されるという点です。

ラムダは通常であれば無名クラスにコンパイルされますが、インライン関数にするとその無名クラスの生成コストを削減することができます。
が、今回はそれ以上の意味を持つことになります。

試しに以下のようなコードをコンパイルしてみます。

class Logger {
    companion object {
        inline fun i(message: () -> String) {
            Timber.i(message())
        }
    }
}

fun outputLog() {
    Logger.i { "result=${getIntValue()}, ${getStringValue()}" }
}

逆コンパイルした outputLog は以下のようになります。

public static final void outputLog() {
    Logger.Companion companion = Logger.Companion;
    Timber.Forest forest = Timber.Forest;
    StringBuilder stringBuilder = new StringBuilder();
    stringBuilder.append("result=");
    stringBuilder.append(getIntValue());
    stringBuilder.append(", ");
    stringBuilder.append(getStringValue());
    forest.i(stringBuilder.toString(), new Object[0]);
}

Logger.i とラムダ本体のコードがインライン展開された結果、最初の方に記載したコードの逆コンパイル結果とほぼ同じになりました。

次に、Logger のコードを以下のように修正してみます。

const val IS_DEBUG = BuildConfig.BUILD_TYPE == "debug"

class Logger {
    companion object {
        inline fun i(message: () -> String) {
            if (IS_DEBUG) {
                Timber.i(message())
            }
        }
    }
}

デバッグビルドの場合は変化はありませんが、リリースビルドの場合は outputLog の逆コンパイル結果が以下のようになります。

public static final void outputLog() {
    Logger.Companion companion = Logger.Companion;
}

今までは Timber.i の呼び出し前に行われていた文字列生成処理について、インライン関数とその引数にラムダを利用したことで丸ごと消すことに成功しました。

ここでログ出力を囲む if を if (BuildConfig.DEBUG) としていないことに注意してください。

BuildConfig.DEBUG はデバッグビルドの場合 public static final boolean DEBUG = Boolean.parseBoolean("true"); という風に定義されます。
const val IS_DEBUG = BuildConfig.DEBUG と定義しようとしていただければ分かると思うのですが、これはコンパイルエラーとなります。

つまり、コンパイル時点で値が確定せずコンパイル時の最適化が行われないということになります。

一応リリースビルド時は public static final boolean DEBUG = false; になっており、const val に代入してもエラーにならなくなっていました。
しかし、ビルドスクリプトなどの環境依存でコンパイル結果に不要なデータが含まれる可能性を回避すべく、const val な Boolean 定数を定義し、その値を利用したほうが安全だと思います。
(コードが残ってもリリースビルド時は if の中の処理は実行されないとはいえ、最適化が行われないと apk などが無駄に大きくなってしまう)

まとめ

長々と説明してきましたが、まとめとしては「ログ出力を Kotlin のインライン関数として定義し、文字列生成は引数のラムダで行う」となります。

いかがでしたでしょうか。
ログ関数自体の実装コストも大してかからず、実際のログ出力箇所の記述も通常と変わらない実装量で書けるようになりました。

それどころか通常のログ関数の引数に文字列を指定する場合、見やすいコードを書くためにコードの折返しなどで悩むこともあるのですが、ラムダ内の記述であればコード記述の自由度も上がります。

気に入っていただけましたら、皆さんにも採用していただけると嬉しいです。

クレジット

Android ロボットは、Google が作成および提供している作品から複製または変更したものであり、クリエイティブ・コモンズ表示 3.0 ライセンスに記載された条件に従って使用しています。



CONTACT

TechRachoでは、パートナーシップをご検討いただける方からの
ご連絡をお待ちしております。ぜひお気軽にご意見・ご相談ください。