Aptを使ったAndroidライブラリの作り方

Apt(Annotation Processing Tool) = コンパイル時にアノテーションからソースを自動生成したりするアレ。DaggerやButterKnifeが使ってます。

Aptを使ったライブラリを作ろうと思ったのですが分かりにくかったので、ゼロベースで作っていく方法をまとめました。

# 作るもの

簡単な例として、POJOにアノテーションを付けると、フィールドをログ出力してくれるようなライブラリを作ってみます。

こんな感じにモデルにアノテーションを付けると、

```java:MyModel.java
@Loggable
public class MyModel {
    @LogField
    public String foo;
    @LogField
    public int bar;

    public MyModel(String foo, int bar) {
        this.foo = foo;
        this.bar = bar;
    }
}
```

こんな感じでログが出せるライブラリ。

```java
MyModel myModel = new MyModel("Hello World", 123);

// MyModelLoggerはライブラリで自動生成されるクラス
// "foo=Hello World, bar=123" と出力する
MyModelLogger.log(myModel);
```

今回作ったものは[ここ](https://github.com/kobakei/ModelLogger)にあります

# 作り方

## Android Studioでプロジェクトを作成

- Android applicationで作成。適当にBlank Activityを作っときます。
- パッケージ名はサンプルアプリのパッケージ名になるので、`com.kobakei.modelloggerexample` としました。
- これでappというモジュールが生成されます。これはライブラリのサンプルとして使います。

## プロジェクト直下のbuild.gradleを修正

buildscriptに、android-aptを追加する

```:build.gradle
// Top-level build file where you can add configuration options common to all sub-projects/modules.

buildscript {
    repositories {
        jcenter()
    }
    dependencies {
        classpath 'com.android.tools.build:gradle:1.3.0'

        // ここを追加
        classpath 'com.neenbedankt.gradle.plugins:android-apt:1.4'
    }
}

allprojects {
    repositories {
        jcenter()
    }
}

task clean(type: Delete) {
    delete rootProject.buildDir
}
```

## libraryモジュールを追加

- **Android Library**を選んで作成します
- パッケージ名はライブラリのパッケージ名になるので、`com.kobakei.modellogger`とします
- build.gradleを修正

```:library/build.gradle
apply plugin: 'com.android.library'
// ここを追加 'com.android.library'より下に書く
apply plugin: 'android-apt'

android {
    compileSdkVersion 23
    buildToolsVersion "23.0.1"

    defaultConfig {
        minSdkVersion 15
        targetSdkVersion 23
        versionCode 1
        versionName "1.0"
    }
    buildTypes {
        release {
            minifyEnabled false
            proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
        }
    }
}

dependencies {
    compile fileTree(dir: 'libs', include: ['*.jar'])
    testCompile 'junit:junit:4.12'
    // compilerから参照するので、コメントアウト
    //compile 'com.android.support:appcompat-v7:23.1.1'
}
```

- アノテーションクラスを作成します。
    - クラスにつける`@Loggable`と、フィールドにつける`@LogField`
    - `@Target`は、そのアノテーションがどのタイプに付けれるか（TYPE = クラス、FIELD = フィールド）
    - アノテーションは引数をとったりデフォルト値を定義したりできるが、ここでは使わないので説明を省略。詳しくは http://www.ne.jp/asahi/hishidama/home/tech/java/annotation.html を参考に。

```java:Loggable.java
@Target(ElementType.TYPE)
public @interface Loggable {
}
```

```java:LogField.java
@Target(ElementType.FIELD)
public @interface LogField {
}
```


## compilerモジュールを追加

- **Java Library**を選んで作成（**Android Libraryではないので注意！**）
- パッケージ名はlibraryと同じく、`com.kobakei.modellogger`
- compiler/build.gradleを以下のように修正

```:compiler/build.gradle
apply plugin: 'java'

// ここを追加
targetCompatibility = JavaVersion.VERSION_1_7
sourceCompatibility = JavaVersion.VERSION_1_7

dependencies {
    compile fileTree(dir: 'libs', include: ['*.jar'])

    // 以下を追加
    compile project(":library")
    compile 'com.squareup:javapoet:1.2.0' // Javaのソースを出力するためのライブラリ
    compile 'com.google.auto.service:auto-service:1.0-rc2' // META-INFを自動生成するためのライブラリ
    compile 'com.google.android:android:4.1.1.4' // compilerはpure java moduleなので、Android frameworkのクラスを操作する場合はこれが必要
}

// ここを追加
// libaryモジュールにいるアノテーションクラスをcompilerで使うため
sourceSets {
    main {
        java {
            srcDirs = ['src/main/java', '../library/src/main/java']
        }
    }
}
```

- 以下のように、AbstractProcessorを継承したクラスを作成
    - アノテーション
        - META-INF出力用に、`@AutoService`を付けておく
        - `@SupportedAnnotationTypes`には、サポートするアノテーションのクラス名を渡す
        - `@SupportedSourceVersion`には、Javaのバージョンを指定（7にする）
    - processメソッドの中で、Javaのソースを出力する
        - ここでは、`@Loggable`なクラスを見つけて、その中の`@LogField`なフィールドをLogCatに出力するクラスを[square/javapoet](https://github.com/square/javapoet)を使って出力しています

```java:MyProcessor.java
@AutoService(Processor.class)
@SupportedAnnotationTypes({"com.kobakei.modellogger.Loggable", "com.kobakei.modellogger.LogField"})
@SupportedSourceVersion(SourceVersion.RELEASE_7)
public class MyProcessor extends AbstractProcessor {

    private Filer filer;
    private Messager messager;
    private Elements elements;

    @Override
    public synchronized void init(ProcessingEnvironment processingEnv) {
        super.init(processingEnv);
        this.messager = processingEnv.getMessager();
        this.filer = processingEnv.getFiler();
        this.elements = processingEnv.getElementUtils();
    }

    @Override
    public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv) {
        Class<Loggable> loggableClass = Loggable.class;
        for (Element element : roundEnv.getElementsAnnotatedWith(loggableClass)) {
            ElementKind kind = element.getKind();
            if (kind == ElementKind.CLASS) {
                try {
                    generateLogger(element);
                } catch (IOException e) {
                    this.messager.printMessage(Diagnostic.Kind.ERROR, "IO error");
                }
            } else {
                this.messager.printMessage(Diagnostic.Kind.ERROR, "Type error");
            }
        }
        return true;
    }

    /**
     * エレメントからLoggerのソースを出力する
     */
    private void generateLogger(Element element) throws IOException {

        String packageName = elements.getPackageOf(element).getQualifiedName().toString();
        ClassName modelClass = ClassName.get(packageName, element.getSimpleName().toString());

        // logメソッドの定義
        // LogFieldアノテーション付きのフィールドを見つけて、ログの作成式に追加
        Class<LogField> logFieldClass = LogField.class;
        String message = null;
        for (Element el : element.getEnclosedElements()) {
            if (el.getAnnotation(logFieldClass) != null) {
                String fieldName = el.getSimpleName().toString();
                if (message == null) {
                    message = String.format("\"%s = \" + model.%s", fieldName, fieldName);
                } else {
                    message += String.format(" + \" %s = \" + model.%s ", fieldName, fieldName);
                }
            }
        }
        String tag = element.getSimpleName().toString();
        ClassName logClass = ClassName.get("android.util", "Log");
        MethodSpec logMethod = MethodSpec.methodBuilder("log")
                .addParameter(modelClass, "model")
                .addModifiers(Modifier.PUBLIC, Modifier.STATIC)
                .addStatement("$T.v(\"" + tag + "\", " + message + ")", logClass) // Log.v("modelclass", "hoge=" + hoge)
                .build();

        // クラスの定義
        String className = element.getSimpleName() + "Logger";
        TypeSpec logger = TypeSpec.classBuilder(className)
                .addModifiers(Modifier.PUBLIC, Modifier.FINAL)
                .addMethod(logMethod)
                .build();

        // ファイルへ出力
        JavaFile.builder(packageName, logger)
                .build()
                .writeTo(filer);
    }
}
```

## appモジュール（サンプルアプリ）を修正

- build.gradleを、compiler, libraryモジュールを使うように変更

```:app/build.gradle
apply plugin: 'com.android.application'
// ここを追加
apply plugin: 'android-apt'

android {
    compileSdkVersion 23
    buildToolsVersion "23.0.1"

    defaultConfig {
        applicationId "com.kobakei.modelloggerexample"
        minSdkVersion 15
        targetSdkVersion 23
        versionCode 1
        versionName "1.0"
    }
    buildTypes {
        release {
            minifyEnabled false
            proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
        }
    }
}

dependencies {
    compile fileTree(dir: 'libs', include: ['*.jar'])
    testCompile 'junit:junit:4.12'
    compile 'com.android.support:appcompat-v7:23.1.1'

    // ここを追加
    compile project(':library')
    apt project(':compiler')
}

```

- サンプルアプリに以下のようなモデルクラスを追加

```java:MyModel.java
@Loggable
public class MyModel {
    @LogField
    public String foo;
    @LogField
    public int bar;

    public MyModel(String foo, int bar) {
        this.foo = foo;
        this.bar = bar;
    }
}
```

## ビルドしてみる

- Android Studioでビルドしてみる
- 成功すれば、`app/build/generated/source/apt/`以下にMyModelLogger.javaが生成されているはず

## 出力されたクラスを使ってみる

- MainActivityのonCreateに以下を追加して再ビルド

```java:MainActivity.java
@Override
public void onCreate(Bundle savedInstanceState) {
    //...

    MyModel myModel = new MyModel("Hello World", 123);
    MyModelLogger.log(myModel);
}
```

これでLogCatに出力されればOK！

# （おまけ）aptを使ったライブラリをJitpack.ioで配布する

[Jitpackのドキュメント](https://jitpack.io/docs/ANDROID/)の手順を、libraryとcompilerの両方に対してやればよいだけ

ライブラリのユーザーは、以下のようにして使います。コロンの位置がプロジェクト名の後ろなので注意。

```:app/build.gradle
dependencies {
    // com.github.USERNAME.PROJECTNAME:MODULENAME:VERSION
    compile 'com.github.kobakei.ModelLogger:library:0.0.2'
    apt 'com.github.kobakei.ModelLogger:compiler:0.0.2'
}

```

# 参考にしたリポジトリ

- [rejasupotaro/kvs-schema](https://github.com/rejasupotaro/kvs-schema)
    - Gradleでのプロジェクト構成がわかりやすい
- [airbnb/DeepLinkDispatch](https://github.com/airbnb/DeepLinkDispatch)
    - Javaのソース出力部分が読みやすい