年末はTransform APIで遊ぼう

  • このエントリーをはてなブックマークに追加

Yahoo! JAPAN Tech Advent Calendar 2016の11日目の記事です。一覧はこちら

こんにちは、久々に記事を書きます。Androidエンジニアの森です。
ヤフオク!アプリの開発を行う傍ら、ヤフーの黒帯制度にてAndroidの黒帯としても活動しています。

突然ですが、皆さんはライブラリを作っていますか?
年末年始は休みも長いので、普段は作れない、何かちょっとしたものを開発するチャンスです。普段の開発を振り返って、面倒くさいところ、定型的な作業があれば、それを自動化するライブラリを書いて、GitHubにあげてみるのもいいかもしれません。

さて、弊社の田中が書いた、Rollout.ioというiOS向けのHot-patchツールに関する記事を読まれたでしょうか。
リリースしたアプリに不具合が見つかった場合、特にその不具合が致命的なものだと、できるだけ素早くユーザーに修正版を届けたいと思うはずです。
しかしiOSでは、Appleの審査を経なければ修正版をリリースできない、という制約があります。
一刻を争っている状況では、その審査期間こそが課題となります。
Rolloutは、Hot-patchという、アプリのリリースとアップデートを伴わないコード修正を可能にする技術によって、こうした課題を解決するためのツールです。

どのようなアプリを世に出すか、またどのように使ってもらうかも大切なのですが、リリース後のユーザーへのケアも同じように重要なことです。
そこで、Androidでも同じようなことができるように、ちょっとしたライブラリを作ってみましたので、そこで使っている技術と合わせてご紹介したいと思います。
実用レベルのものではありませんが、こういうこともできるんだ、という参考にしていただければと思います。

実行結果

メタプログラミング

ある処理を実現したい場合に、直接そのためのコードを書くのではなく、そうしたコードを生成するためのコードを書き、それを使って機械的に生成する手法のことを、メタプログラミングと呼びます。コードそれ自体をデータとして扱い、解析・生成・修正する技術といえます。
例えば、ある1箇所にログを出力するコードを記述することは容易ですが、プロジェクトの全メソッドの冒頭に、受け取った引数の値をログに出力させるとなると大変です。
手作業で入れていくと、日が暮れてしまいます。ちょっとそういう作業に時間を費やしたくはありません。
こうした場合に、「メソッドの冒頭にログ出力処理を挿入するよう修正する」プログラムをつくり、コンパイル時に動作させる等を行うことによって、自動的に目的の処理を埋め込むことができます。
また、これによる利点は手間の削減だけではありません。
ログ出力のように、本質的な処理とは異なる処理がメソッド内に紛れ込んでしまうと、そのメソッドが本来何をするためのものなのか、理解しづらくなってしまいます。
そのような処理をコンパイル時に挿入するようにし、開発時にコード上では見えなくすることによって、可読性を向上させることができます。また、一定の処理の強制という意味でも効果があるでしょう。

メタプログラミングは強力です。コード全体に容易に影響を与えることができます。
しかし、行いすぎると逆にコードを理解することも、デバッグすることも難しくなります。下手に設計されたライブラリを使用すると、思わぬ不具合を引き起こしてしまい、それを探るために「このライブラリ内では何を行っているのだろう」と開発者が探らなければならない事態に陥ってしまいます。
しかし、EventBusやRealm、ButterKnifeなど、優れたOSSライブラリのなかには、コードの自動生成やバイトコードの修正を上手に利用しているものが多くあります。

Annotation Processor

Androidでの開発において、コードを自動生成する場合にまず選択肢として挙がるのは、apt(Annotation Processing Tool)です。
AbstractProcessorを実装したクラスを作成し、モジュールレベルのbuild.gradleのなかで指定しておくと、コンパイル時に呼び出されます。指定したアノテーションをつけたクラスやメソッドなどの要素が渡されてきますので、それらの情報を元にJavaファイルを生成し、生成したファイルも含めてコンパイルした結果を、最終生成物(apkファイル)に含めることができます。
もともとはJavaで使用されてきたjavax.annotation.processingパッケージのクラス群で、これをAndroidで使用できるようにするために、hvisser/android-aptのようなOSSプラグインが使われてきました。
Android Gradle Plugin 2.2からは、この機能は公式でサポートされるようになり、別途プラグインを入れる必要もなくなりました。また、Androidの新しいツールチェイン/ビルド環境であるJackでもサポートされています。
これらの事情を鑑みると、Annotation Processingは将来に渡って使用可能であろうと思われます。
ただ、Annotation Processingでは、新しいクラスを生成することはできるのですが、既存のクラスに修正を加えることは、(Lombokのように特殊なことをしない限り)基本的にはできません。そのため、上に挙げた「既存のメソッドにログ出力処理を挿入する」といったようなことは、通常はできません。この制限のため、多くの場合ではコードを自動生成するモジュールとは別に、アプリから参照され、自動生成したコードを呼び出すandroid libraryモジュールとセットにする構成をとることが多いようです。
Annotation Processingは多くのライブラリで使用されています。ButterKnifeDaggerなどとの相性はすばらしく、Reflectionを使わないために高速に動作します。

Transform API

これに対して、Android Gradle Plugin 1.5から提供されたTransform APIは、コンパイルされたクラスファイルへの修正に特化した機能を提供します。とはいえ、Transform API単体で使用されることはあまりなく、通常はJavassistなどのバイトコード操作用のライブラリと併用されるようです。
なお、Android Studio 2.0で導入されたInstant Run機能は、このTransform APIを利用して実現しています。
Transform APIは、現状ではJackツールチェインには対応していません。そもそもJackはアプリのコンパイル中に中間コードを生成しないため、中間コードを操作するツールは併用できないのです。Jackツールチェインを有効化すると、Instant Runが機能しなくなる理由はここにあります。
ただ、だからといってサポートが断念されたというわけではなく、AOSPのIssuesを見る限り、対応するつもりはあるようです。この発言も5月のことですので、そろそろ続報を聞きたいところですが…。
Transform APIを使用した場合、新規にクラスを作成するだけでなく、既存のクラスやメソッドに修正を加えることができます。そのため、Annotation Processingと比べると、かなり柔軟にコードを組み替えることができますし、アプリにライブラリのクラスを呼び出して貰う必要もないため、作ることのできる機能の幅も広がります。
Transform APIはその有用さの割には、それほど多くのOSSライブラリで使用されているわけではありません。メジャーなところで挙げるのなら、RealmはAnnotation ProcessingとともにTransform APIを使用し、Javassistも併用しています。

Transform APIの使い方

今回はせっかくなので、このTransform APIを使用してみましょう。
なお、サンプルコードはこちらに置いてあります。
Transform APIは、com.android.build.api.transform.Transformクラスを実装し、モジュールレベルのbuild.gradle内で、そのインスタンスを登録します。
すると、Transform#transform()メソッドが呼ばれますので、そこで渡されたメタデータをもとにバイトコードを操作し、修正後のファイルを書き出します。
修正後のファイルは、かならず出力しなければならないことに注意して下さい。

全体構成

サンプル全体は、以下のような構成になっています。

TransformApiSample
    ├ Sample1 ・・・1
    │    ├logger ・・・2
    │    ├SampleApp1 ・・・3
    │    └archives ・・・4
    │
    ├ Sample2 ・・・5
    │    ├PatchTransformer ・・・6
    │    ├SampleApp2 ・・・7
    │    └archives ・・・8
    │
    └ PatchServer ・・・9
         ├main.go ・・・10
         └patches ・・・11

1~4は、プロジェクト内で実装したクラスのメソッドに、ログ出力処理を挿入するライブラリと、それを使ったサンプルアプリです。
2のloggerがそのライブラリで、プラグインとTransformクラス、アノテーション定義が入っています。
これをパッケージしたものをローカルリポジトリにアップロードして使用するのですが、そのファイルの置き場所が4のarchivesです。3はこのライブラリを実際に使用しているサンプルアプリです。
5~8のサンプルも同じ構成になっています。サーバからパッチをダウンロードしてきて、実行時に任意のメソッドの内容を置き換えるというものなのですが、6がそのライブラリ、8がローカルリポジトリで、7が実際に使用しているサンプルアプリです。また、本当は分割すればよかったのですが、7にはパッチをダウンロードしたり、スクリプトを実行したりするためのAndroidライブラリモジュールも含まれています。
9~11は、2つ目のサンプルで使用するための、簡易的なファイルサーバです。
エミュレータ上からアクセスするために、localhost:8080で動作します。
テスト用途のために作ったものですので、ローカルで動かすにとどめておいてください。
10がそのサーバプログラムです。goの実行環境を整えた上で

go get github.com/gorilla/mux
go build main.go
./main

で実行できます。
11はパッチファイルの置き場所です。すぐに使えるよう、すでにパッチファイルを置いています。

この2つのサンプルは、どちらも基本的な仕組みは同じです。

  • Gradle Plugin
    Transform APIが使用可能かを確認した上で、バイトコード操作を行うTransformクラスのインスタンスを登録します。

  • Transform API
    Transform#transform()内でJavassistを使用して、バイトコード操作を行います。
    その後、クラスファイルを出力します。

  • Android Library
    2つ目のサンプルの、SampleApp2のプロジェクト内に含まれています。本当は分けたほうが良かったですね。PatchServerと通信してスクリプトをダウンロードする機能と、RhinoというJavaで書かれたJavaScript実装を利用して、スクリプトを実行する機能が用意されています。

これらのうち、Gradle Pluginは、Android Studio上で開発を行うことができませんので、IntelliJ IDEAを使用して開発しました。

サンプル1

ソースコードはまるっと載せるには長すぎるため、重要なところだけ抜き出していきます。
まず、Gradle Pluginを作成して、この後に登場するLoggerTransformerのインスタンスを、バイトコード操作を行うクラスとして登録します。
なお、複数のTransformを登録する場合や、他のライブラリでもTransformが使用されている場合、適用される順序を決めることはできません。
プラグインの内容を以下に示します。サンプル2でも、登録するTransformが異なることを除いて、全く同じことを行っています。

class Logger implements Plugin<Project>{

    @Override
    void apply(Project project) {
        // Android Applicationまたは、ライブラリであることを確認する ・・・1
        def isAndroidApp = project.plugins.withType(AppPlugin)
        def isAndroidLib = project.plugins.withType(LibraryPlugin)
        if (!isAndroidApp && !isAndroidLib) {
            throw new GradleException("'com.android.application' or 'com.android.library' plugin required.")
        }

        // Android Gradle Plugin 2.0以降であることを確認する ・・・2
        if (!isTransformAvailable()) {
            throw new GradleException('Logger gradle plugin only supports android gradle plugin 2.0.0 or later')
        }

        // バイトコード変換を行うTransformerを登録する ・・・3
        project.android.registerTransform(new LoggerTransformer(project))
    }

    private static boolean isTransformAvailable() {
        try {
            Class transform = Class.forName('com.android.build.api.transform.Transform')
            Method transformMethod = transform.getMethod("transform", [TransformInvocation.class] as Class[])
            return transformMethod != null
        } catch (Exception ignored) {
            return false
        }
    }
}
  1. Androidアプリ、またはAndroidライブラリであることを確認します。
    ログ出力にandroid.util.Logを使用しているため、通常のJavaプログラムでは使用できません。

  2. Android Gradle Pluginが2.0以降であることを確認します。
    Transform API自体は1.5以降で使用可能なのですが、従来のTransform#transform()メソッドが2.0でdeprecatedになってしまったため、こうなっています。

  3. バイトコード操作を行うTransform(ここではLoggerTransformer)のインスタンスを登録します。
    実際にコード修正を行ってくれるのは、このLoggerTransformerクラスです。

logger/LoggerTransformer.groovyの、transform()でバイトコード操作を行っています。具体的には、modify()がそれに当たります。
それ以外の部分に関しては、他のライブラリを作成する場合でも同じことをするでしょうから、使いまわしが可能です。

@Override
void transform(TransformInvocation transformInvocation) throws TransformException, InterruptedException, IOException {
    super.transform(transformInvocation)

    def outputProvider = transformInvocation.getOutputProvider()

    // 修正ファイルの出力先ディレクトリ
    def outputDir = outputProvider.getContentLocation('logger',
            getInputTypes(), getScopes(), Format.DIRECTORY)

    // プロジェクト内のクラス名の一覧を作成する
    def inputs = transformInvocation.getInputs()
    def classNames = getClassNames(inputs)

    // クラスプールを作る。修正対象を探す際に必要になる
    def mergedInputs = inputs + transformInvocation.getReferencedInputs()
    ClassPool classPool = getClassPool(mergedInputs)

    // 対象となるクラスファイルを選定し、修正を加える
    modify(classNames, classPool)

    // スコープ内のクラスをファイルに出力する
    classNames.each {
        def ctClass = classPool.getCtClass(it)
        ctClass.writeFile(outputDir.canonicalPath)
    }
}

以下がmodify()の内容です。
プロジェクト内で実装しているクラスの名前の一覧から、@Loggingというアノテーションのついたクラスを探し、そのクラスで実装されているメソッドの一覧から、native、interface、abstractを除外した上で、ログ出力処理をメソッドの冒頭と最後に追加しています。
なお、似たようなことを行う、より優れたOSSライブラリとして、JakeWharton/Hugoがあります。
このように、バイトコード操作を行うと言っても、実際には文字列でコードを記述し、バイトコードに変換・挿入するところはJavassistが行ってくれるため、容易に実装することができます。
CtMethod#insertAfter()で対象メソッドの最後に処理を追加できるのですが、これはfinally節として実行されるので、戻り値のことを心配する必要もありません。
なお、Transformの段階では、もうimport文を追加することはできませんので、java.langパッケージのようにimport不要のクラス以外は、パッケージをつけたかたちで参照しましょう。

private static void modify(Set<String> classNames, ClassPool classPool) {
        classNames.collect{ classPool.getCtClass(it) }
                .findAll{ it.hasAnnotation(Logging.class) }
                .each {
            // アノテーションからタグを得る
            def annotation = (Logging)it.getAnnotation(Logging.class)
            def tag = annotation.value()

            // 各対象クラスが実装しているメソッドを集め、
            // その冒頭に、ロギング処理を追加する
            it.declaredMethods.findAll {
                // native、interface、abstractは除外
                !Modifier.isNative(it.getModifiers()) \
                        && !Modifier.isInterface(it.getModifiers()) \
                        && !Modifier.isAbstract(it.getModifiers())
            }.each {
                // importは追加できないので、クラスはフルパスで指定すること
                // メソッド開始のログ。メソッド名と引数の値を出力する
                def startLog = new StringBuilder()
                startLog.append("StringBuilder sb = new StringBuilder(\"(\");")
                        .append("for(int i = 0; i < \$args.length; i++) {")
                        .append("    if (\$args[i] != null) {")
                        .append("        sb.append(\$args[i].toString())")
                        .append("          .append(\",\");")
                        .append("    }")
                        .append("}")
                        .append("sb.append(\")\");")
                        .append("android.util.Log.v(\"$tag\", \"${it.getLongName()}\" + sb.toString());")

                // メソッド冒頭に処理を追加
                it.insertBefore(startLog.toString())

                // メソッド終了のログ。戻り値を出力する
                def resultLog = new StringBuilder()
                def returnValue
                if (it.getReturnType().getName() == "void") {
                    // 戻り値がvoidの場合
                    returnValue = "\"void\""
                } else if (it.getReturnType().isPrimitive()) {
                    // primitiveの場合
                    returnValue = "\"returns \" + \$_"
                } else {
                    // Objectの場合
                    resultLog.append("String resultValue = \"empty\";")
                             .append("if (\$_ != null) resultValue = \$_.toString();")
                    returnValue = "\"returns \" + resultValue"
                }

                resultLog.append("android.util.Log.v(\"$tag\", \"${it.getLongName()} \" + $returnValue);")

                it.insertAfter(resultLog.toString())

            }
        }
    }

また、今回のようにGradleプラグインを作成する場合、
resources/META-INF/gradle-plugins/
にpropertiesファイルを置くことを忘れないようにしましょう。それでけっこうはまっていました。

サンプル2

サーバからパッチをダウンロードしてきて、それによって任意のメソッドの内容を置き換える、というライブラリです。
もし実用化可能なレベルのものができたら、不具合が見つかった場合や、A/Bテストなどに利用できそうです。
スクリプトのダウンロードや実行は、SampleApp2のAndroidライブラリモジュール内に含まれています。

hotpatchandroidlib
    ├ ScriptDownloader ・・・1
    ├ ScriptRepository ・・・2
    └ ScriptRunner ・・・3

1のScriptDownloaderで、PCのlocalhost上に立てたファイルサーバから、スクリプトファイルをダウンロードします。
なお、PCのlocalhostは、AndroidエミュレータからはIPアドレス”10.0.2.2”でアクセスできます。
Androidアプリでlocalhostを指定すると、それはアプリが動作しているAndroidシステム自体のことをさすため、注意して下さい。
2はダウンロードしてきたスクリプトファイルを読み込んで、メモリ上に保持します。
3が実際にスクリプトを実行するクラスです。JavaScriptを実行するために、Rhinoを使用しています。privateフィールドやメソッドにもアクセスできるように設定しており、さらにAndroidのContextも渡しているため、組み込んだアプリができることであれば、なんでもできてしまいます。
そのような状態で、スクリプトファイルをhttpでやりとりしているため、このライブラリをこのまま使うのは危険です。
あくまでサンプルとして、試してみるくらいにしておいて下さい。

Transformを行っているのは、patcher/PatcherTransformer.groovyです。
プロジェクト内で実装している、Applicationクラス、interface、inner class以外のクラスの各メソッドに対して、次のようなロジックを冒頭に挿入しています。

if (対応するスクリプトが存在している) {
  return (メソッドの戻り値の型)スクリプト実行;
}

つまり、呼び出されるメソッドに対応するスクリプトが存在する場合、それを実行し、その戻り値をキャストして返しています。
メソッドの戻り値がvoidの場合は、キャストせず、単にスクリプトを実行して、”return;”としています。リンク

スクリプトファイルは、PatchServer/patches内に配置することで、パッチサーバからアクセス可能になります。
すでにサンプル用に2つ作って置いてあります。
ファイル名は、
jp.co.yahoo.android.sampleapp2.MainActivity.increment().patch
のように、”パッケージ、クラス、メソッド名.patch”として下さい。引数の型を含めていないので、メソッドのオーバーロードを行っていると、全てに適用されてしまいますね。
スクリプトの内容は、

function apply() {
    instance.current += 30;
    instance.showNumber(instance.current);
    return -1;
}

のように、apply()という名前の関数を定義し、戻り値を元のメソッドの戻り値と型を合わせて下さい。
そのメソッドが現在実行されているインスタンスが、”instance”という変数名で参照可能です。
この場合、instanceの型は”MainActivity”です。
その他に、メソッドの引数がarg0、arg1、arg2…のかたちで参照可能となります。
実際に実行してみてください。
スクリプトを書き換えて再ダウンロードすると、動作に反映されているのがわかります。

実行結果

Hot Fixについて

Hot Fixは不具合の修正に限らず、多くの用途に応用可能です。
例えばA/Bテストについて考えてみると、これまでの方法は、ユーザーにバケットを割り当て、アプリに各テストパターンに応じたの実装を行った上でリリースし、ある程度行き渡るのを待ちながら数値を確認する方法が主流でした。
テストの結果を踏まえて、さらにテストを繰り返すためには、また修正を行ったバージョンをリリースし、ユーザーが更新してくれるのを待つ必要があります。
Hot Fixを利用した場合、直接スクリプトでAとB向けの挙動を書き、FCM(Firebase Cloud Messaging)のペイロードにスクリプトファイルへのURLを載せて送信し、即座に挙動を変えることも可能になります。
json2viewのようなOSSと併用すれば、レイアウトさえ自由に変更できます。
一方で、スクリプトに従ってネイティブオブジェクトを自由に操作できるということは、それ自体が脆弱性の原因ともなりかねません。もし実際に使用するのであれば、今回のサンプルのようなものではなく、よりセキュアな方法に設計し直す必要があるでしょう。
また、自由に変更ができると言っても、あまりに一方的でユーザーに同意のない変更を繰り返していると、ユーザーから不信感を持たれかねません。
不具合の修正ではなく、グロースの目的で利用するのであれば、ユーザーを困惑させないよう、適用する範囲と頻度を検討するべきです。

まとめ

後半はHot Fixの話になってしまいましたが、Transform APIを使用したライブラリのテンプレートとしては、サンプル1のほうが使いやすいものとなっています。
LoggerTransformer#modify()に手を加えるだけで、いろいろな動作を試すことができます。
まずは、VERBOSEに出力しているログを、DEBUGに変えてみてはいかがでしょうか。
プラグインやTransformクラスに修正を行った場合には、ターミナルから

./gradlew clean uploadArchives

としてローカルリポジトリにアップロードし、サンプルをリビルドすると、反映されます。
年末年始の連休中に、いろいろつついて、遊んでみていただければ幸いです。
なお、Rollout.ioは現在、Android版を開発中です。

Yahoo! JAPANでは情報技術を駆使して人々や社会の課題を一緒に解決していける方を募集しています。詳しくは採用情報をご覧ください。

  • このエントリーをはてなブックマークに追加