【Android】Androidプロジェクトでjavax.annotation.processing(APT)を使う

Sqliteを使っていると一々テーブル名やカラム名を文字列で書かないといけません。コード補完も効かなくて面倒です。

そんなわけで無理矢理コード補完が使えるよう、「テーブルのメタ情報だけを持ったクラス」を作成し、そっから参照するようにします。

が、そうすると次はちょっとテーブルのメタ情報を書き換えたり、データを呼び出してPOJOに変換する時に同期をとるのが面倒になってきます。

もお全自動でやってくれないかな、ってことでjavax.annotation.processingを使用したコード生成をやってみたいと思います。

参考資料

javax.annotation.processing(長いので以下APT)はどのクラスのどのメソッドからどんな情報がとれるのかイマイチわかりにくいです。

javax.annotation.processingのドキュメントはもちろんのこと、色んなサイトの情報を事前にかき集めておいた方がいいかもしれません。と言うわけで参考にしたサイトを事前に列挙しておきます。

当然のことながらアノテーションに関する知識もないといけません。APTだけでなくリフレクションでも扱えるようになっておくと、更に色々なことに応用できます。

また、さりげなくgithubにもアップしてます。アノテーションをリフレクションで扱う実例なんかもあります。まぁ、読まなくてもいいですよ。どうせ説明するので。

アノテーションを作る

そんなわけでまずはアノテーションを作ってしまいましょう。

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
public @interface SqliteTable {
    String value();
    boolean hasPrimaryId() default false;
}
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.FIELD)
public @interface SqliteField {
    int type();
    String name();
    boolean notNull() default false;
    boolean primary() default false;
    boolean autoincrement() default false;
    boolean unique() default false;
    String indexName() default "";
    String defaultValue() default "";
}

はいはいちゃんと説明もしますよ。

まずは前提となる知識ですが、アノテーションを作成する場合は

public @interface AnnotationHoge {

}

とこんな感じに宣言する必要があります。

アノテーションにはメンバを持たせることもできます。このメンバはリフレクションを使用することでデータを取得することができます。

public @interface AnnotationHoge {
    String piyo();
}

デフォルト値を与えることも可能です。

public @interface AnnotationHoge {
    String piyo() default "piyopiyo";
}

呼び出し側はこんな感じ。

public class Hoge {
    @AnnotationHoge(piyo="piyo")
    private String fuga;
}

デフォルト値を持ってないメンバには必ず何らかの値を渡してあげる必要があります。

public @interface AnnotationHoge {
    String piyo() default "piyopiyo";
    int foo();
}

public class Hoge {
    // fooを渡してないのでコンパイルエラーがでる
    @AnnotationHoge(piyo="piyo")
    private String fuga;
}

また、メンバ名をvalueとするとわざわざ名前を指定しなくても値を渡すことができます。

public @interface AnnotationHoge {
    int value();
}

public class Hoge {
    @AnnotationHoge(0)
    private String fuga;
}

ただし、アノテーション内に複数のメンバがいる場合はvalueを使えません。

public @interface AnnotationHoge {
    int value();
    String foo();
}

public class Hoge {
    // value=0としないと怒られる
    @AnnotationHoge(0, foo="foobar")
    private String fuga;
}

value以外の全メンバにデフォルト値がある場合は一応こんなこともできます。

public @interface AnnotationHoge {
    int value();
    String foo() default "foobar";
}

public class Hoge {
    // fooに値を渡す時は「value=0, foo=""」としないと結局怒られるのだが…
    @AnnotationHoge(0)
    private String fuga;
}

じゃあ細かいところも見て行きましょう。まずはRetentionです。valueにRetentionPolicyと言うenumを受け取るみたいですね。

これはアノテーションの値etcを実行時に保持するかどうかを指定するオプション的なものです。今回はリフレクションで使用するため、破棄されると困ります。なのでRetentionPolicy.RUNTIMEを指定しておきます。

次にTargetです。これはどこにアノテーションを宣言できるかを定義出来ます。

受け取るvalueはElementTypeの配列ですね。

どの値を渡せばいいかはドキュメントを読むだけで十分だと思うので、アノテーションに配列を渡す方法だけ紹介しておきます。と言ってもJavaで配列にデフォルト値を与える時と一緒ですが。

@Target({ElementType.TYPE, ElementType.FIELD})
public @interface AnnotationHoge {
    int value();
    String foo() default "foobar";
}

@AnnotationHoge(0)
public class Hoge {
    // AnnotationHogeのTargetにElementType.CONSTRUCTORがないので
    // ここはコンパイルエラーになる
    @AnnotationHoge(1)
    public Hoge(){
        
    }
    
    @AnnotationHoge(2)
    private String piyo;
}

それじゃあ念のためもう一度今回作成するアノテーションを紹介しておきましょう。先ほどの知識があればサクサク読めると思います。

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
public @interface SqliteTable {
    String value();
    boolean hasPrimaryId() default false;
}
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.FIELD)
public @interface SqliteField {
    int type();
    String name();
    boolean notNull() default false;
    boolean primary() default false;
    boolean autoincrement() default false;
    boolean unique() default false;
    String indexName() default "";
    String defaultValue() default "";
}

リフレクションで値を取得してみる

ジェネリクスで制限をかけやすいように事前にこんなインターフェースを作成しておきます。ついでに、SqliteFieldのtypeの定数はここに持たせておきます。

public interface ISqlite {
    public int FIELD_TEXT = 0;
    public int FIELD_INTEGER = 1;
    public int FIELD_REAL = 2;
    public int FIELD_BLOB = 3;
    public int FIELD_NULL = 4;
}

じゃあこのアノテーションを適当なPOJOに突っ込んでみましょう。

@SqliteTable(value = "AccountMst", hasPrimaryId = true)
public class Account implements Serializable, ISqlite {

    private static final long serialVersionUID = 4899939627875291362L;

    @SqliteField(name = "ScreenName", type = FIELD_TEXT, notNull = true)
    private String screenName;

    @SqliteField(name = "UserId", type = FIELD_TEXT, notNull = true)
    private Long userId;

    @SqliteField(name = "AccessToken", type = FIELD_TEXT, notNull = true)
    private String accessToken;

    @SqliteField(name = "AccessSecret", type = FIELD_TEXT, notNull = true)
    private String accessSecret;

    @SqliteField(name = "IconUrl", type = FIELD_TEXT, notNull = true)
    private String iconUrl;

    @SqliteField(name = "UseFlag", type = FIELD_INTEGER, defaultValue = "0")
    private boolean use;

    // getterとsetterは省略
}

アノテーションをリフレクションで取得するにはClass#getAnnotation(Class<A> annotationClass)を使用します。これによって該当するアノテーションインスタンスが手に入るので、そこからメンバを呼び出すことで値を取得できます。

まずは簡単な例から。SqliteTableからテーブル名を取得してみます。

public static String getTableName(Class<? extends ISqlite> clazz) {
    return clazz.getAnnotation(SqliteTable.class).value();
}

次にフィールドにつけられたSqliteFieldからカラム名を取得してみましょう。

まずはClass#getDeclaredFields()でフィールドを全部取得してしまいます。

取得したFieldのgetAnnotationメソッドアノテーションを手に入れてしまえば、後は同じです。

public static List<String> getColumnNames(Class<? extends ISqlite> clazz) {
    List<String> columnNames = new ArrayList<String>();
    
    for(Field f : clazz.getDeclaredFields()) {
        SqliteField attr = f.getAnnotation(SqliteField.class);
        
        // アノテーションがない場合はnullが返ってくる
        if(attr == null) continue;
        
        columnNames.add(attr.name());
    }
    
    return columnNames;
}

この二つを応用すれば、アノテーションを設定するだけでCREATE文を作成したりとか出来るわけですね。例をあげると長すぎるので、興味があればここのgetCreateTableQueryと言うメソッドを見てみて下さい。

APTを使ってアノテーションを取得する

以上がアノテーションの基礎知識です。が、APTではリフレクションの知識がさほど必要ありません。裏ではやってるんでしょうけど。

APTで必要なものは、極端なことを言ってしまえばAbstractProcessorを継承したクラスだけです。そしてその中でabstractなメソッドprocessだけです。ワーオ。簡単だね!

と言えれば良かったんですが、そんなに上手くはいきません。じっくりとやっていきましょう。

とりあえず最低限のものを用意します。

@SupportedAnnotationTypes({"inujini_.sqlite.meta.annotation.SqliteTable"})
@SupportedSourceVersion(SourceVersion.RELEASE_6)
public class PropertyFactory extends AbstractProcessor {
    @Override
    public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv) {
        return true;
    }
}

ここで最重要なのはSupportedAnnotationTypesと言うアノテーションです。

ここで指定したアノテーションがprocessのSet<? extends TypeElement> annotationsに入ってきます。コンパイル中に該当するアノテーションが見つからなかったらそもそもprocessメソッド自体が呼ばれません。

今回はSqliteTableとSqliteFieldの二つのアノテーションを対象に…と言いたいところなんですが、この二つは1-Nの関係です。欲を言えばアノテーション単位ではなく、「SqliteTableが設定されているクラス」の情報が欲しいです。と言うわけで、こんな形にします。

@SupportedAnnotationTypes({"inujini_.sqlite.meta.annotation.SqliteTable"})
@SupportedSourceVersion(SourceVersion.RELEASE_6)
public class PropertyFactory extends AbstractProcessor {
    @Override
    public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv) {
        
        // SupportedAnnotationTypesで指定されたアノテーションを取得
        for(TypeElement annotation : annotations) {
            // RoundEnvironment#getElementsAnnotatedWith(TypeElement a)で
            // アノテーションが付加されている要素
            // (それはクラスかもしれないし、フィールドかもしれないし、コンストラクタかも…?)
            // を取得
            for (Element element : roundEnv.getElementsAnnotatedWith(annotation)) {
                // 要素の種類はElement#getKind()で判定できる
                if(element.getKind() == ElementKind.CLASS) {
                    SqliteTable tableAttr = element.getAnnotation(SqliteTable.class);
                    
                    // Element#getEnclosedElements()で
                    // 「その要素に内包されている要素(それはクラスかもしれないし、(省略))」
                    // を取得できる
                    for(Element innerElement : element.getEnclosedElements()){
                        if(innerElement.getKind() == ElementKind.FIELD) {
                            SqliteField fieldAttr = innerElement.getAnnotation(SqliteField.class);
                            if(fieldAttr == null) continue;
                        }
                    }
                }
            }
        }
        
        return true;
    }
}

もしくは、こんな形にします。(GitHubにあがってるのはこっちです)

どっちがいいのかはなんとも言えません。調べてないです。

@SupportedAnnotationTypes({"inujini_.sqlite.meta.annotation.SqliteTable"})
@SupportedSourceVersion(SourceVersion.RELEASE_6)
public class PropertyFactory extends AbstractProcessor {
    @Override
    public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv) {
        
        // 処理対象となった要素をすべて取得する
        Set<? extends Element> roots = roundEnv.getRootElements();
        
        for (Element root : roots) {
            // 処理対象がクラスかどうかを判定
            if (root.getKind() == ElementKind.CLASS) {
                TypeElement target = (TypeElement) root;
                // クラスに含まれているアノテーション(SqliteTable)を取得する
                SqliteTable tblAttr = target.getAnnotation(SqliteTable.class);
                
                // 中のFieldの情報を取得する
                for(Element innerElement : target.getEnclosedElements()){
                    if(innerElement.getKind() == ElementKind.FIELD) {
                        SqliteField fieldAttr = innerElement.getAnnotation(SqliteField.class);
                        if(fieldAttr == null) continue;
                    }
                }
            }
        }
        
        return true;
    }
}

APTでファイルを自動生成する

AbstractProcessorのprotectedなメンバであるProcessingEnvironment processingEnvgetFilerと言うメソッドがあります。

これをゴニョゴニョすることでアノテーションの値etcを用いてファイルを自動生成することができます。

Filer filer = processingEnv.getFiler();
Writer writer = null;
String fileName = "com.example.Test" // com.exampleパッケージにTest.javaが出来る

try {
    JavaFileObject fileObject = filer.createSourceFile(fileName);
    writer = fileObject.openWriter()
    writer.write("hoge");
    writer.flush();
} catch(IOException e) {
    
} finnaly {
    try {
        if(writer != null) writer.close();
    } catch(IOException e) {
        
    }
}

もうちょっと細かいところはGitHubをですね…。

AndroidプロジェクトにAPTの設定をする

以下の二つの記事を真似たら出来ました。(丸投げ)

まぁ、Androidプロジェクトで注意するところは「Factory側でAndroidのクラスにさわらない」と「ソースの自動生成先をsrcにする」ぐらいです。

まとめ

全然Androidの話でもなんでもないですねこれ。