アノテーションプロセッサ(apt)をAndroidプロジェクトに適用する

アノテーションプロセッサを実装するための"Pluggable Annotation Processing API"はJava6から実装された機能であり、残念ながらAndroidプラットホームでは使えない。がしかしEclipse自体はJava6で動作しており、プラグイン、コンパイラ等は全てJava6上で動作しているため、以下の条件を満たせばAndroidプロジェクトに対してアノテーションプロセッサを適用することが可能である。

    • Androidのクラス群にアクセスしない(Dalvikランタイムにアクセスしない)

これだけだ。Annotation Processorの起動は適切な設定を行うことでEclipseがやってくれる。(プラグインorg.eclipse.jdt.apt.pluggable.coreが担当する)

アノテーションプロセッサを実装するための"Pluggable Annotation Processing API"はJava6から実装された機能であり、残念ながらAndroidプラットホームでは使えない。がしかしEclipse自体はJava6で動作しており、プラグイン、コンパイラ等は全てJava6上で動作しているため、Androidプロジェクトに対してアノテーションプロセッサを適用することが可能である。

今回実装するAnnotation Processorは以下の仕様を実装する。

  1. 解析中のソースコード/クラス/メソッドで@Executeアノテーションを見つけたならば、メソッドが定義されているクラスはアクションであると判定し、そのクラス名をマーク。
  2. 解析が完了したならばマークされたクラス名を配列で返すクラスとしてJavaソースコードを生成。

前回のエントリで書いたように本来起動時にclasses.dexをトラバースして行っていた処理をコンパイルタイムに実施することで、アプリケーションの起動時間を短縮するのがその狙い。もっと凝ったこともできるが、まずは自分が一番やりたいことをシンプルに実現することにする。

以下、ソースコードとその解説。

public final class AptConst {
    public static final String KEY_OPTIONS_OUTPUT_PACKAGE = "outputPackage";
    public static final String KEY_OPTIONS_GENERATE_CLASS = "generateClass";
    
    public static final String VALUE_OPTIONS_OUTPUT_PACKAGE = "org.kazzz.autoregister";
    public static final String VALUE_OPTIONS_GENERATE_CLASS = "GeneratedAutoRegister";
}

説明の必要は無いだろう。後述する@SupportedOptionsで指定するオプションパラメタのキーとそのデフォルト値を定数で指定している。

    • ActionAnnotationProcessor.java (その1)
@SupportedSourceVersion(SourceVersion.RELEASE_6)
@SupportedAnnotationTypes("org.kazzz.annotation.Execute")
@SupportedOptions({KEY_OPTIONS_OUTPUT_PACKAGE, KEY_OPTIONS_GENERATE_CLASS})
public class ActionAnnotationProcessor extends AbstractProcessor {

@SupportedSourceVersionはサポートするJavaソースコードのバージョンを指定する。Androidは基本的にはJava5ソースコードに準拠しているが、一部Java6の仕様も取込んでいるので、RELEASE_6が良いだろう。
@SupportedAnnotationTypesはこのAnnotationProcessorにおいて処理対象とするアノテーションの名前を配列で指定する。この値は同クラス内部get@SupportedAnnotationTypesメソッドの戻り値のSetで参照することができる。

@SupportedOptionsは非常に重要だ。今回の場合ソースコードを生成するのはaptが対象とするプロジェクトのソースディレクトリ(Ecliseで設定できる)だが、出力するパッケージはプロジェクトによって違うため、外部から対象のプロジェクトを設定してやる必要がある。

@SupportedOptionsをjavacやaptのコマンドラインから設定する場合は-Aで始まるコマンドラインオプションとして使用するが、Eclipseではプロジェクト=>プロパティ=>Javaコンパイラ=>注釈処理で直接設定することができる。(結局はコマンドラインに充当される訳だが)

(生成されるソース・ディレクトリも"src"とプロジェクトのソースディレクトリのルートにしていることに注意)

ここで指定したキーと値は以下のようにMap<キー、値>の戻り値で参照できる。

Map options = this.processingEnv.getOptions();

この例ではパッケージ名の他、生成するクラス名も指定することができるようにしてある(デフォルト値が用意されているので指定は必須ではない)

    • ActionAnnotationProcessor.java (その2)
    ArrayList targetElement = new ArrayList();

    Filer filer;
    JavaFileObject fileObject;
    Writer writer;
    String outputPackage;
    String getnerateClass;
    
    @Override
    public synchronized void init(ProcessingEnvironment processingEnv) {
        super.init(processingEnv);
        
        //オプションパラメタの取得(指定がなければデフォルト値を使う)
        Map options = this.processingEnv.getOptions();
        this.outputPackage = options.containsKey(KEY_OPTIONS_OUTPUT_PACKAGE) 
            ? options.get(KEY_OPTIONS_OUTPUT_PACKAGE)
            : VALUE_OPTIONS_OUTPUT_PACKAGE;

        this.getnerateClass =  options.containsKey(KEY_OPTIONS_GENERATE_CLASS) 
            ? options.get(KEY_OPTIONS_GENERATE_CLASS)
            : VALUE_OPTIONS_GENERATE_CLASS;
        try {
            this.filer = processingEnv.getFiler();
            this.fileObject = 
                this.filer.createSourceFile(this.outputPackage + "." + this.getnerateClass);
            this.writer = this.fileObject.openWriter();
        } catch (Exception e ) {
            Messager messager = processingEnv.getMessager();
            messager.printMessage(Kind.ERROR, e.toString());
        }
    }

initメソッドのオーバライドで先ほど指定したオプションパラメタの取得の他、ファイルを生成するためのFilerオブジェクトやJavaFileObjectを予め生成して早々にJavaソースコードファイル(GeneratedAutoRegister.java)を生成している。なお、既に同名のファイルが存在している場合は生成前に自動的に削除してくれるため、存在の検査と削除処理は不要だ。

ファイル系のオブジェクトは一般的なプログラミングの作法ならばできるだけ後で生成したいものだが、わざわざinitメソッド(一度しか呼ばれない)で生成するのには訳がある。

    • ActionAnnotationProcessor.java (その3)
    @Override
    public boolean process(Set annotations,
            RoundEnvironment roundEnv) {
        // 処理対象のルート要素を全て取得
        Set roots = roundEnv.getRootElements();
        for (Element root : roots) {
            // ルート要素がクラスである場合
            if (root.getKind() == ElementKind.CLASS) {
                //@SupportedAnnotationTypesで指定した分のアノテーション
                TypeElement typeElement = (TypeElement) root;
                this.checkAnnotatedMethod(typeElement);
            }
        } 
        
        if ( this.targetElement.size() > 0 ) {
            this.generateJavaSource();
        }
        
        return true;
    }

いよいよメインの処理となるprocessメソッドのオーバライドだ。といっても大した処理はなくソースコードを解釈した結果生成されたエレメントの種類(クラス、メソッド、アノテーションなど)を判定して、Javaソースコードを出力していくだけである。

なお、processメソッドは「ラウンド」と呼ばれるAnnotationプロセッサからのリクエストにより繰り返し呼ばれる可能性がある。(ソースコードの生成、整形は一度の処理では終わらない可能性があるからだ)先ほど、initメソッドでファイラオブジェクトを生成するには訳があると書いたのはそういう理由からだ。

    • ActionAnnotationProcessor.java (その4)
    private void checkAnnotatedMethod(TypeElement typeElement) {
        // クラスで定義されている要素を取得
        List children = typeElement.getEnclosedElements();

        try {
            //クラス化の要素全てを取得
            for (Element child : children) {
                // 要素がメソッド
                if ( child.getKind() == ElementKind.METHOD ) {
                    //メソッドを注釈しているアノテーションを全て取得
                    for ( AnnotationMirror m : child.getAnnotationMirrors()) {
                        //対象の型が@SupportedAnnotationTypesで指定した型に含まれていたら対象
                        String annotationName = m.getAnnotationType().asElement().toString();
                        if ( this.getSupportedAnnotationTypes().contains(annotationName)) {
                            this.targetElement.add(typeElement);
                        }
                    }
                }
            }
        } catch (Exception e) {
           Messager messager = processingEnv.getMessager();
           messager.printMessage(Kind.ERROR, e.toString(), typeElement);
        }
    }

処理対象として@SupportedAnnotationTypesで指定したアノテーションがメソッドに適用されていたならば現在のエレメント(クラス)を退避する。

    • ActionAnnotationProcessor.java (その5)
    private void generateJavaSource() {
        try {
            try {
                //コメント出力
                this.writeComment();

                //クラス定義部を出力
                this.writeClassDefinition();
                
                //フィールド部を出力
                this.writeFieldDefinition();
                
                //メソッド定義部を出力
                this.writeMethodDefinition();

                this.writeEndBrace();
                
                this.writer.flush();
            } finally {
                this.writer.close();
            }

        } catch (IOException e) {
            processingEnv.getMessager().printMessage(Kind.ERROR,
                    e.toString());
        }
    }
    private void writeComment() {
        this.format("/*\n");
        this.format(" * このファイルはアノテーションプロセッサ(" 
                + this.getClass().getName() + ")により自動生成されました。\n");
        this.format(" * file : %s;\n" 
                , this.outputPackage + "." + this.getnerateClass + ".java");
        this.format(" */\n");
    }
    private void writeClassDefinition() {
        this.format("package %s;\n" , this.outputPackage);
        this.format("\n");
        this.format("public class %s { \n"
                , this.getnerateClass );
    }
    private void writeFieldDefinition() {
        this.format("    static final String registerClassNames = new String{\n");

        Set keys = new HashSet();
        Iterator i = this.targetElement.iterator();
        while ( i.hasNext() ) {
            String s = i.next().toString();
            if ( !keys.contains(s)) {
                keys.add(s);
                this.format("        \"%s\"", s);
                if ( i.hasNext() ) {
                    this.format(", \n");
                } else {
                    this.format("\n");
                }
            }
        }
        this.format("    };\n");
    }
    private void writeMethodDefinition() {
        this.format("\n");
        this.format("    @Override\n");
        this.format("    public String[] getRegisterClassNames() {\n");
        this.format("        return registerClassNames;\n");
        this.format("    }\n");
    }
    private void writeEndBrace() {
        this.format("}\n");
    }
    private void format(String content, Object...args) {
        String formatted = String.format(content, args);// MessageFormat.format(content, arg);
        try {
            this.writer.write(formatted);
        } catch (IOException e) {
            Messager messager = processingEnv.getMessager();
            messager.printMessage(Kind.ERROR, e.toString());
       }
    }

generateJavaSourceメソッドから呼ばれる一連のwrite〜メソッドは、出力する部位毎にWriterオブジェクトに大してソースコードを出力しているだけである。アノテーションプロセッサが動作する際は標準出力を使用できないため、例外が発生した場合や何かの通知をユーザに通知したい場合Messagerと呼ばれるオブジェクトを使用する。

なお、アノテーションプロセッサをプラガブルにして他のプロジェクトから実行可能とするためには、jarアーカイブ中のMETA-INF/servicesディレクトリに"javax.annotation.processing.Processor"という名前のファイルを作成し、aptが実行するAnnotation Processorのクラス名を記述しておく必要がある。

aptプロジェクトの構造とjavax.annotation.processing.Processorの内容。


以上ActionAnnotationProcessorを前回のエントリの方法を使ってデバッグ〜テストが完了したならば同aptをマニフェストに含めたサービスファイルと共にjarにエクスポート後、対象のプロジェクトに適用しておくと、コンパイルが時に以下のJavaソースコードが指定したパッケージ、クラス名で出力されるのを確認できる。

  • org.kazzz.autoregister.GeneratedAutoRegister.java
/*
 * このファイルはアノテーションプロセッサ(org.kazzz.annotation.ActionAnnotationProcessor)により自動生成されました。
 * file : org.kazzz.autoregister.GeneratedAutoRegister.java;
 */
package org.kazzz.autoregister;

public class GeneratedAutoRegister {
    static final String registerClassNames = new String{
        "org.kazzz.attendroid.action.WorkingTimeInputAction", 
        "org.kazzz.attendroid.action.CalendarSelectionAction", 
        "org.kazzz.attendroid.action.WebScrapeAction"
    };

    @Override
    public String[] getRegisterClassNames() {
        return registerClassNames;
    }
}

このようにEclipseと"Pluggable"なアノテーションプロセッサのおかげでAndroidプロジェクトにアノテーションプロセッサを適用できた。

結果、claasses.dexをトラバースして上記の3つのクラスを探していた時に比べてAndroidアプリケーションの起動時間は1/3に抑えることが出来た。この手の最適化としては大成功といえる。


オンザフライでコードを生成したり型を拡張する方法はいろいろあるが、Androidのような制限の多いプラットホームではjavassistやCGLIB等のバイトコードエンハンサ等、動的な方法をとるのは性能の観点で問題がある。またコンシュマー相手のコードを書く場合に必須となるProguardのような難読化ツールでは動的に生成したコードは上手く動かなかったり、そもそも対象外としなければならなかったりするため、今回のアノテーションプロセッサのように静的な方法が向いているだろう。