eaglesakuraの技術ブログ

技術的な話題とか、メモとか。

特定のAnnotationが付いたクラスやメソッドだけをProguard対象から外す

Android NDKとProguardは相性が悪い

Proguardとは

今更説明することも無いかと思いますが、ProguardといえばAndroiderにとっては欠かせないお友達です。
Javaのコードは容易に逆コンパイルされて、簡単に内容を解析されてしまいます。

逆コンパイル自体を防ぐことはできませんが、コードの解析を難しくしてくれるツールがProguardの大きな役割です。 Proguardを使う目的は、次の点が大きいでしょう。

  1. コードを難読化して第三者による解析を困難にする
  2. 不要なコードを削除してコード全体を小さくする

どちらがメインになるかは開発者毎に違うでしょうが、そのどちらかが当てはまるのでは無いでしょうか。

Proguradの問題点

Android SDKを一般的(あなたが思う範囲が一般的です。他人の同意を得られるとは限りません)に使っている限り、Proguardはとても良いパートナーです。

ですが、例えば次のような使い方をした場合、Proguardは我々にケンカを売ってきます。

  1. リフレクションを利用してクラスやメソッドやフィールドを参照した場合
  2. JNIを利用してnativeコードを呼び出した場合

リフレクションは使わない人は全く使わないJavaの機能ですが、簡単に説明すると「クラス名」「メソッド名」「フィールド名」を文字列として参照し、制御する方法です。

JNIはC/C++で記述された関数を「文字列」としてランタイムで検索し、呼び出します。

Proguardのかかったコードは、次の点で致命的な致命傷を受けます。

  • クラス名を変えられてしまう
    • それによって、リフレクションを取得するために指定した「文字列」と「実際のクラス名」に違いが生じてしまう
    • 例えば、"com.eaglesakura.Hello"というクラスが、Proguardを通すことで"com.eaglesakura.a"という短いクラス名に変わってしまいます。
      プログラム中で"com.eaglesakura.Hello"クラスを文字列で検索しても見つからなくなってしまい、ランタイムエラーが発生します。
  • NDK側でのみ参照しているクラスやメソッドが削除されてしまう
    • Proguardは利用されていないメソッド等をチェックし、コードからまるまる削除してしまいます。
    • そのため、NDKでしか使っていないクラスやメソッドがあった場合、「不要なクラス」と判断されて削除されてしまう恐れがあります。

今回問題になった所

個人的に作っているiOS/Android同時コーディング用のライブラリでは、JavaのクラスにAnnotationをつけることでJava側のコードを呼び出すC++コードを自動出力しています。

@JCClassはコンバータに「変換対象クラス」であることを知らせるための目印で、@JCMethodは「変換対象メソッド」であることを示すための目印です。

Java側コード
クラスに"JCClass"、メソッドに"JCMethod"のannotationをつけています。

@JCClass
class   Hoge {
    @JCMethod
    static void hello() {
        System.out.println("hello world!!");
    }
}
生成されるC++コード(Hello.cpp抜粋)
Helloクラスが自動的に作成されます。

const ::jc::charactor* Hello::CLASS_SIGNATURE = "com/eaglesakura/Hello";
static jclass class_Hello = NULL;
static jmethodID methods_Hello[1];
static void initialize_Hello() {
    // loaded !
    if (class_Hello) {
        return;
    }

    CALL_JNIENV();

    // load class object
    class_Hello = env->FindClass(Hello::CLASS_SIGNATURE);
    class_Hello = (jclass)::ndk::change_globalref(env, class_Hello);

    // load methods
    {
        methods_Hello[0] = ::ndk::JniWrapper::loadMethod(class_Hello, "hello", "()V", true);
    }
}

void Hello::hello() {
    CALL_JNIENV();
    initialize_Hello();
    env->CallStaticVoidMethod(class_Hello, methods_Hello[0]);
}

C++側のコードを見ると、"com/eaglesakura/Hello"というクラス名・パッケージ名を示したシグネチャや、"loadMethod(class_Hello, "hello", "()V", true);"というJavaのメソッド検索部分が生成されているのがわかります。

Proguardをかけてしまうと、クラス名の崩壊によってこれらの文字列では呼び出せなくなってしまいます。 また、hello()がJava側で参照されていない場合、最適化と称してまるっと削除されている恐れすらあります。

しかも呼び出しは「文字列」で行なっている都合上、コンパイル時に見つけることは出来ず、実行時にしかわかりません。

タチの悪いことに、Android JUnitはDebug状態で作成されてProguardされていないため、Jenkinsの自動ビルドでは発見することが困難です。

問題解決のために

ここからが本題です。
上記の問題を解決するためにはProguardでクラス名やメソッド名が変わらないようにしなければなりません。

幸運にも今回問題になったライブラリの場合だと「自動出力のために必ず特定のannotationをつける」必要があったため、どうにか「特定のannotationの付いたクラスやメソッドをProguard対象から外す」というタイトルのようなことが無いかと資料を漁ってみました。

@sys1yagiさんのブログにAnnotation指定でProguard回避する記述があったので、それを元に次の条件でProguardを回避する設定を追加しました。

  • @JCClassのAnnotationがついたクラスはリネームされない
  • @JCMethodがついたメソッドはリネームされない
  • @JCMethodがついたメソッドは最適化で削除されない
今回proguard.cfgに追加した部分

# jointcoding
# keep annotations
-keep class com.eaglesakura.lib.jc.annotation.jnimake.**
-keep @com.eaglesakura.lib.jc.annotation.jnimake.JCClass class *
-keep @com.eaglesakura.lib.jc.annotation.jnimake.JCMethod class *

# keep Java class names
-keepnames @com.eaglesakura.lib.jc.annotation.jnimake.JCClass class *
-keep class * implements @com.eaglesakura.lib.jc.annotation.jnimake.JCClass *

# keep Java unused methods
-keepclassmembers @com.eaglesakura.lib.jc.annotation.jnimake.JCClass class * {
    @com.eaglesakura.lib.jc.annotation.jnimake.JCMethod *;
}

# keep Java method names
-keepclassmembernames class * {
    @com.eaglesakura.lib.jc.annotation.jnimake.JCMethod *;
}

解説

次の3行でAnnotation自体をProguardから守ります。

-keep class com.eaglesakura.lib.jc.annotation.jnimake.**
-keep @com.eaglesakura.lib.jc.annotation.jnimake.JCClass class *
-keep @com.eaglesakura.lib.jc.annotation.jnimake.JCMethod class *

次の2行で、@JCClassのついたクラスの名称変更を回避します。

-keepnames @com.eaglesakura.lib.jc.annotation.jnimake.JCClass class *
-keep class * implements @com.eaglesakura.lib.jc.annotation.jnimake.JCClass *

最後に、@JCMethodのついたメソッドを名称変更・最適による削除対象から外します。

# keep Java unused methods
-keepclassmembers @com.eaglesakura.lib.jc.annotation.jnimake.JCClass class * {
    @com.eaglesakura.lib.jc.annotation.jnimake.JCMethod *;
}

# keep Java method names
-keepclassmembernames class * {
    @com.eaglesakura.lib.jc.annotation.jnimake.JCMethod *;
}

まとめ

Proguardは欲しい条件でちゃんとやってくれそうだけど、指定するための条件付けを見つけるまでが苦労するよね(´・ω・`)