1. Qiita
  2. 投稿
  3. Android

Androidでaptのライブラリを作るときの高速道路

  • 191
    いいね
  • 1
    コメント
この記事は最終更新日から1年以上が経過しています。

はじめに

Android界隈で最近aptを使ったライブラリが増えてきていると感じています。しかしapt自身、特にAndroidでの書き方を解説している日本語の記事は多くありません。ライブラリを書かないとしても、どのように動くかを知っておくことでエラーに対応しやすくなったり、プルリクを送りやすくなったり良いことがあると思うので、私の経験を踏まえてaptのライブラリを作るときの知見を共有したいと思います。

apt(Annotation Processing Tool)とは

Jake WhartonのDroidconのスライド(Annotation Processing Boilerplate Destruction)を見てくれという感じなのですが、枚数が多いので簡単と説明すると、Javaはコンパイル時に処理を割り込む仕組みがあり、そのときに渡されるソースコードのメタデータの塊を操作してコードを生成したりすることができます。ある言語におけるマクロを定義しておくとコンパイル時にASTが入ってきてコードをいじれるみたいな感じです。

aptはコンパイル時に実行されるので、リフレクションと比べると型安全で実行時のオーバーヘッドがないことが知られています。そのためリフレクションを使って書かれたコードがのちほどaptで書き直されるということが稀によくあります。

aptを使ったライブラリ

aptを使っていて有名なライブラリと言えば DaggerButterKnife ですが、これらはmavenを使っています。Androidアプリを開発している多くの開発者はgradleを使っていると思うので、gradleのプロジェクトで参考になりそうなものをいくつかピックアップしました。

aptを使ったライブラリを作る

プロジェクトの構成

aptを使ったライブラリの多くはコンパイル時に処理を行うモジュールと、アプリから参照するモジュールの2つが定義されています。たとえば上記のライブラリでは以下のような構成になっています。

コンパイル時 アプリから参照
IntentBuilder compier api
flender plugin runtime
DeepLinkDispatch processor -
DBFlow compiler -

- はプロジェクト名がそのままモジュール名になっているという意味で、DeepLinkDispatchであれば

apt 'com.airbnb:deeplinkdispatch-processor:x.y.z'
compile 'com.airbnb:deeplinkdispatch:x.y.z'

このようになります。好みの問題ではありますが、私はこの ライブラリ名ライブラリ名 + processor のペアが一番自然かなと思います。

プロジェクトを始めるときは、まずAndroidアプリケーションを選んで、作られたappモジュールをリネームして動作確認用のサンプルアプリにして、aptを行うためのJavaライブラリと、アプリから使うためのAndroidライブラリの合計3つのモジュールを作ります。

// settings.gradle
include ':sample' // サンプルのAndroidアプリ
include ':processor' // aptのJavaライブラリ
include ':library' // Androidライブラリ

Processorを定義する

javacがフックできるようにProcessorのエントリポイントを定義します。main/resources/META-INF/services に自分で定義する方法と、AutoService というライブラリを使って自動生成する方法があります。私はAutoServiceを使っていますが、大した作業ではないのでどちらでもいいと思います。
ちなみにAutoService自体もaptでマニフェストを生成しています。このようにaptはJava以外のファイルを生成することもできます。

Processorの定義はこのようにします。

@AutoService(Processor.class)
public class MyProcessor extends AbstractProcessor {
    ...

    @Override
    public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment env) {
        ...
    }
}

processメソッドにRoundEnvironmentというメタデータの塊が入ってくるので、そこからアノテーションを見てフィールド、メソッド、クラスを取得して処理します。

Javaコードを生成する

Javaのコード生成にはsquare/javapoet を使うのが主流だと思います。以下のようにフィールド、メソッド、クラスを定義してファイルに書き出します。

MethodSpec main = MethodSpec.methodBuilder("main")
    .addModifiers(Modifier.PUBLIC, Modifier.STATIC)
    .returns(void.class)
    .addParameter(String[].class, "args")
    .addStatement("$T.out.println($S)", System.class, "Hello, JavaPoet!")
    .build();

TypeSpec helloWorld = TypeSpec.classBuilder("HelloWorld")
    .addModifiers(Modifier.PUBLIC, Modifier.FINAL)
    .addMethod(main)
    .build();

JavaFile javaFile = JavaFile.builder("com.example.helloworld", helloWorld)
    .build();

javaFile.writeTo(System.out);

基本的にはこれだけです。

gradleの小ネタ

Bintrayへのアップロード

BintrayへライブラリをアップロードするときにBintrayの公式のライブラリである bintray/gradle-bintray-plugin を使うと以下のようにする必要があります(READMEより)。

bintray {
    user = 'bintray_user'
    key = 'bintray_api_key'

    configurations = ['deployables'] //When uploading configuration files
    // - OR -
    publications = ['mavenStuff'] //When uploading Maven-based publication files
    // - AND/OR -
    filesSpec { //When uploading any arbitrary files ('filesSpec' is a standard Gradle CopySpec)
        from 'arbitrary-files'
        into 'standalone_files/level1'
        rename '(.+)\\.(.+)', '$1-suffix.$2'
    }
    dryRun = false //Whether to run this as dry-run, without deploying
    publish = true //If version should be auto published after an upload
    //Package configuration. The plugin will use the repo and name properties to check if the package already exists. In that case, there's no need to configure the other package properties (like userOrg, desc, etc).
    pkg {
        repo = 'myrepo'
        name = 'mypkg'
        userOrg = 'myorg' //An optional organization name when the repo belongs to one of the user's orgs
        desc = 'what a fantastic package indeed!'
        websiteUrl = 'https://github.com/bintray/gradle-bintray-plugin'
        issueTrackerUrl = 'https://github.com/bintray/gradle-bintray-plugin/issues'
        vcsUrl = 'https://github.com/bintray/gradle-bintray-plugin.git'
        licenses = ['Apache-2.0']
        labels = ['gear', 'gore', 'gorilla']
        publicDownloadNumbers = true
        attributes= ['a': ['ay1', 'ay2'], 'b': ['bee'], c: 'cee'] //Optional package-level attributes
        //Optional version descriptor
        version {
            name = '1.3-Final' //Bintray logical version name
            desc = //Optional - Version-specific description'
            released  = //Optional - Date of the version release. 2 possible values: date in the format of 'yyyy-MM-dd'T'HH:mm:ss.SSSZZ' OR a java.util.Date instance
            vcsTag = '1.3.0'
            attributes = ['gradle-plugin': 'com.use.less:com.use.less.gradle:gradle-useless-plugin'] //Optional version-level attributes
            //Optional configuration for GPG signing
            gpg {
                sign = true //Determines whether to GPG sign the files. The default is false
                passphrase = 'passphrase' //Optional. The passphrase for GPG signing'
            }
            //Optional configuration for Maven Central sync of the version
            mavenCentralSync {
                sync = true //Optional (true by default). Determines whether to sync the version to Maven Central.
                user = 'userToken' //OSS user token
                password = 'paasword' //OSS user password
                close = '1' //Optional property. By default the staging repository is closed and artifacts are released to Maven Central. You can optionally turn this behaviour off (by puting 0 as value) and release the version manually.
            }            
        }
    }
}

このような感じで長くなってしまいます。そこで私は最近 novoda/bintray-release に乗り換えました(READMEより)。

publish {
    userOrg = 'novoda'
    groupId = 'com.novoda'
    artifactId = 'bintray-release'
    publishVersion = '0.3.0'
    desc = 'Oh hi, this is a nice description for a project, right?'
    website = 'https://github.com/novoda/bintray-release'
}

公式のライブラリの方が(コードを読んでいないのでなんとも言えませんがおそらく)細かく設定できるのですが、bintray-releaseで十分な場合はこちらの方が簡潔に書けて良いと思いました。

バージョン関連

IntentBuilderは開発自体は活発ではないのですが、ビルドの設定になるほどなというテクニックが使われていたので共有します。

バージョン情報

version.properties というファイルにバージョン情報が保存されています。

#Sun, 28 Jun 2015 19:01:31 -0700
major=0
minor=13
patch=0

ビルドファイルに以下のように書いておくと VERSION で参照することができて良さそうでした。

ext {
    VERSION = version()
}

task version << {
    println version()
}

def String version() {
    def versionPropsFile = file('version.properties')
    def Properties versionProps = new Properties()
    versionProps.load(new FileInputStream(versionPropsFile))

    return versionProps['major'] + "." + versionProps['minor'] + "." + versionProps['patch']
}

バージョンアップ

./gradlew bumpMajor
./gradlew bumpMinor
./gradlew bumpPatch

上のタスクを実行することでそれぞれ version.properties の数値をインクリメントしてくれます。bumpMajorbumpMinor は自分より小さい桁の数字をゼロに戻すので、たとえば 0.1.5 の状態で bumpMajor を実行すると 1.0.0 になります。

READMEの更新

ライブラリを使う人がコピペして使えるようにUsageに以下のように書くことがあると思います。

dependencies {
    compile 'se.emilsjolander:intentbuilder-api:0.13.0'
    apt 'se.emilsjolander:intentbuilder-compiler:0.13.0'
}

このバージョンの更新が面倒で x.y.zlatest-version と書いてあるライブラリがあったりしますが、使う側からすると最新のバージョンがいくつなのか調べるのが手間になったりします。
IntentBuilderでは README.md.template というファイルに記述して ./gradlew genReadMe タスクを実行することで埋め込まれたバージョン情報を置き換えるということをしていました。bumpVersion => genReadMe => upload を一連のタスクにすることで開発者の手間が減らせてライブラリを使う側にも優しくなるのでいいなと思いました。

おわりに

Androidのプロジェクトは少し特殊なので、私がライブラリを作ったときはどのようなプロジェクト構成にしたらいいのか、どうやって参照したらいいのか、どうやってjarにすればいいのかなど、色々引っかかったので知見を共有できればと思いました。

拙作のライブラリはこちらになります: rejasupotaro/kvs-schema
SharedPreferencesにアクセスするクラスを生成するライブラリで、SharedPreferencesを使っているときにどんなファイル名で保存されていたか、どんなキーが保存されていたか分からなくなるという問題の解決をモチベーションに作りました。

ちなみにこのライブラリを書き始めたときはaptについてあまりよく分かっていなかったので、JavaWriterという古い方のライブラリを使ってしまってあとで書き直すことになったり、processorの方をcompilerという名前で登録してしまい(間違いではありませんが違和感がある)、変更するのが面倒でそのままになっていたりするので、これからライブラリを作ろうと思っている人のお役に立てれば幸いです。