「Android NでJava8がサポートされた」とはどういうことか

追記: StreamやOptionalはpreview-2で実装されたようです。

gfx.hatenablog.com


Android N previewが公開されましたね!このバージョンではJava8のサポートがあると発表されています。また、標準クラスライブラリがOpenJDKベースの実装になったことで、Java8との互換性が高まるのではないかという前評判もありました。

First Preview of Android N: Developer APIs & Tools | Android Developers Blog

本エントリでは、この「Android NでJava8」について解説します。

三行まとめ

  • Android N runtimeはOpenJDK7ベースで、Java8クラスライブラリは一部のみ移植されている
  • Android N SDKに同梱されているJackコンパイラはlambdaなどの一部のJava8の構文を古いAndroid向けのdexにコンパイルできる
  • JackコンパイラができることはRetrolambdaとほぼおなじで、Android Nと直接は関係ない

解説

さて、「Android Nで」というのは、まず2つの意味があります。

  • Android Nの世代のAndroid SDKで開発するとき
    • compileSdkVersion=N かつ targetSdkVersion=N を要求することもあるものの、基本的には動作するAndroidのバージョンにはそれほど依存しない
  • Androidアプリケーションを動かすOSがAndroid N以上のとき
    • つまりNランタイムのうえで動作するとき

この2つを混同してはいけません。この記事では前者を「N SDK」、後者を「N runtime」と呼ぶことにします。

またさらに、公式サイトによれば、一部のJava8の機能は新しいJava Android Compiler Kit "Jack" でのみ提供されるとあります。これは、フルスクラッチで開発された*1Android専用のJavaコンパイラで、直接dexファイルを出力できます。ただし、Jackはまだannotation processingをサポートしていないなどの制限があるため、実際のアプリケーションで採用するのは難しいでしょう。正規版までにannotation processingサポートが入るといいのですが。

ところで現在すでにRetrolambdaで一部のJava8構文が使えます。Retrolambdaを採用したプロジェクトはすでにいくつかありますし、NとRetrolambdaを比較検討したいところです。

よって、考慮すべきは「ビルドがN SDKかJackかRetrolambdaか」の3パターン、実行環境としては、古いAndroid OSの代表として「ICS」、そして「N」の2パターン、この2つの組み合わせで考える必要があります。

それぞれで、Java8の構文とクラスライブラリの幾つかをピックアップして使用可能かどうかを表にしてみました。

※ Nはpreview-1版であり、正規版では結果は異なります。minSdkVersion=14が効かない問題があり、動作はさせていません。下記の表は単なるビルドの成否などから推定している部分もあります。

N SDK x ICS runtime N SDK x N runtime Jack x ICS runtime Jack x N runtime Retrolambda x ICS runtime Retrolambda x N runtime
lambda expressions no no yes yes yes yes
default interface methods no no yes yes yes yes
static methods on interfaces no no yes yes yes yes
repeatable annotations no no yes yes yes yes
optional no no no no no no
java.util.function.* no yes* no yes no yes
java.util.stream.* no no no no no no
  • Java7モードでビルドしてlambda式などを使わずにjava.util.function.* を使用することだけならできます。

純正のN SDKだとほとんど何も使用できませんが、これはN SDKのdexコマンドがJava8用に出力されたクラスライブラリのバージョン(52)を処理できないためです。

なおいくつかのAOSPのクラスをOpenJDK7 / OpenJKD8と比較したところ*2、基本的にはOpenJDK7をベースとしており、 List#forEach() などの一部のメソッドのみOpenJDK8から移植しているようです。

以上を踏まえると、Android N preview-1で使えるJava8は「Java 7.5」ともいうべき中途半端な状態ですね。もちろん正規版が近づいたらまた状況は変わるかもしれないので、あくまでも「N preview-1では」ということですが。

そしてクラスライブラリ以外の新構文は、実はNとは関係ありません。First Preview of Android N: Developer APIs & Toolsのエントリでも、Jackで提供される言語機能はGingerbread (Android 2.3)でも使えると書いてあります。このあたりをまとめて「NでJava8をサポート」と発表するのは開発者に混乱を招くのでやめていただきたいですね。

関連エントリ

コード生成でGsonをMoshiより高速化する

高速化しました。ざっくり実装しただけなのでリリースはしていません それなりに有効そうなのでjcenterにリリースしました。*1

ベンチマークについて

まずスコアをみていただきましょう。dynamic gsonがGoogle Gson v2.6.2、static gsonが今回最適化したもの、moshiが Square Moshi v1.1.0 です。ベンチマーク環境はXperia Z4 / Android 5.0.2です。(2016/03/09 初版よりデータ構造を複雑にして結果を更新。最新データはStaticGsonのREADMEを参照してください。)

D/XXX     : start benchmarking Dynamic Gson
D/XXX     : Dynamic Gson in serialization: 449ms
D/XXX     : Dynamic Gson in deserialization: 387ms

D/XXX     : start benchmarking Static Gson
D/XXX     : Static Gson in serialization: 198ms
D/XXX     : Static Gson in deserialization: 233ms

D/XXX     : start benchmarking Moshi
D/XXX     : Moshi in serialization: 270ms
D/XXX     : Moshi in deserialization: 656ms

serializeもdeserializeもstatic gsonはdybamic gsonよりも高速ですし、またmoshiよりも高速です。というか、moshiのdeserializeはgsonより遅くてどうしたんだという感じですが。これは繰り返しとっても変わらないので、実際に高速化できていると思います。

ターゲットとなるクラスはなるべくリアルなものがいいので、 droidkaigi2016 から拝借して少し改変したSession.javaです。

StaticGson/Session.java at master · gfx/StaticGson · GitHub

ベンチマークコードはこちら。

StaticGson/MainActivity.java at master · gfx/StaticGson · GitHub

N = 3000 はXperia Z4でGCが起きないような値です。つまり上のスコアのときGCは起きていません。他の端末の場合、GCが起きないように調整してスコアを取らないと正確にはならないので注意してください。

なぜ高速なのか

Annotation ProcessingでモデルごとのJsonAdapterFactoryを生成しています。これは、Gsonがもともとリフレクションで行っていることを静的に行うようにしたものです。リフレクションは通常のフィールドアクセスと比較して非常に遅いので、リフレクションを使わないだけで該当箇所は数倍高速になります。ただし、文字列解析や文字列生成が高速になるわけではないので、現実的にはせいぜい数割スコアが上がる程度です。

また、コード生成の副作用としてProGuardで壊れることがなくなるというのもメリットです。ProGuardが壊すのはリフレクションを使ったコードだけです。

生成されるコードは次のようになります。これはまさに、GsonのストリーミングAPIを使って手書きでTypeAdapterを書くとこうなるだろう、というコードです。

// ... imports ...

/**
 * Generated by {@code StaticGsonProcessor}
 */
@Keep
public class Session_TypeAdapterFactory implements TypeAdapterFactory {
  @Override
  public <T> TypeAdapter<T> create(final Gson gson, final TypeToken<T> typeToken) {
    return (TypeAdapter<T>) new TypeAdapter<Session>() {
      @Override
      public void write(JsonWriter writer, Session value) throws IOException {
        writer.beginObject();
        writer.name("id");
        writer.value(value.id);
        writer.name("title");
        writer.value(value.title);
        writer.name("description");
        writer.value(value.description);
        writer.name("speakerId");
        writer.value(value.speakerId);
        writer.name("stime");
        writer.value(value.stime);
        writer.name("etime");
        writer.value(value.etime);
        writer.name("categoryId");
        writer.value(value.categoryId);
        writer.name("placeId");
        writer.value(value.placeId);
        writer.name("languageId");
        writer.value(value.languageId);
        writer.name("slideUrl");
        writer.value(value.slideUrl);
        writer.name("movieUrl");
        writer.value(value.movieUrl);
        writer.name("shareUrl");
        writer.value(value.shareUrl);
        writer.name("checked");
        writer.value(value.checked);
        writer.endObject();
      }

      @SuppressWarnings("unchecked")
      @Override
      public Session read(JsonReader reader) throws IOException {
        Session object = new Session();
        reader.beginObject();
        while (reader.hasNext()) {
          switch (reader.nextName()) {
            case "id":
            object.id = (int) reader.nextLong();
            break;
            case "title":
            object.title = reader.nextString();
            break;
            case "description":
            object.description = reader.nextString();
            break;
            case "speakerId":
            object.speakerId = reader.nextLong();
            break;
            case "stime":
            object.stime = reader.nextLong();
            break;
            case "etime":
            object.etime = reader.nextLong();
            break;
            case "categoryId":
            object.categoryId = reader.nextLong();
            break;
            case "placeId":
            object.placeId = reader.nextLong();
            break;
            case "languageId":
            object.languageId = reader.nextString();
            break;
            case "slideUrl":
            object.slideUrl = reader.nextString();
            break;
            case "movieUrl":
            object.movieUrl = reader.nextString();
            break;
            case "shareUrl":
            object.shareUrl = reader.nextString();
            break;
            case "checked":
            object.checked = reader.nextBoolean();
            break;
            default: break;
          }
        }
        reader.endObject();
        return (Session) object;
      }
    };
  }
}

*1:というか、互換性を失わない高速化なのでGson本体に入るのが望ましいと思います。まずGsonメンテナと交渉をしてみるつもりです。 | 2016/03/09 追記: 独立したライブラリのほうがいいだろう、ということだったのでこのまま育てることにしました。

JSON-P / JSON-BのAndroid Backportがほしい

いまはJSON処理用ライブラリが乱立していて、しかもそれぞれインターフェイスが違うので選ぶのが難しい。JSON-Object mappingはJSON-BがあるのでこれがAndroidにもbackportされればいいのにと思っている。

JSON-B (JSR367) のほうはJava8用で、参照実装もインターフェイスのデフォルト実装をバリバリつかっているのでそのままbackportするのはむずかしいのだけど、インターフェイスだけbackportすることはできるはず。そしてそのインターフェイスを実装するライブラリいくつかある、という状態が望ましいのかなと思う。

Orma v2の新機能と今後の展望

Ormaの v2.1をリリースしました。Ormaはセマンティックバージョニングを採用しているので、"v2"は単に"v1"と互換性のない変更を行った、というだけの意味です。とはいえ機能もいくつか追加しているので紹介します。

なお Orma入門はv2.1の内容にアップデートしています。

マイグレーション

v2.0, v2.1でマイグレーションの起動条件が変わりました。以前はリリースビルドとデバッグビルドでマイグレーション起動条件が違っていてデバッグが難しかったのですが、この変更によってリリースビルドとデバッグビルドで処理を同じにできたので、より安定して開発できるはずです。

これに伴い、内部もかなり変わっています。いままではSQLiteOpenHelperに依存していたのですが、v2.1ではもはや内部的にSQLiteOpenHelperは使っていません。OrmaDatabaseはOrmaConnectionにDBのopenを任せ、OrmaConnectionは単に MigrationEngine#start() を呼び出し、 MigrationEngine の実装が実際の処理を全て行います。

なお、マイグレーション起動のロジック(正確にはManualStepMigrationのロジック)の仕様自体はSQLiteOpenHelperが使う SQLiteDatabase#version (実体はSQLiteの PRAMGA user_version)に依存しているため、v1.xから移行する際、または他のSQLiteOpenHelperを使うORMから移行する際も問題にはならないはずです。

また OrmaDatabase#migrate() というメソッドでマイグレーションを明示的に起動できるようになりました。

現状の課題としては、現状はSchemaDiffMigrationでカバーできないスキーマの変更(具体的にはカラムやテーブルのリネーム)がやはりまだ難しいです。「あるカラムの名前が現状よりも古い場合に最新の名前に変える」という動的に状況を確認しつつマイグレーションを行えるようになれば改善すると思われますので、近いうちに実装するつもりです。

has-one関係の直接的な記述(direct associations)

v2.0の目玉機能です。Ormaのモデルクラスは、他のモデルクラスを直接持つことができるようになりました。

droidkaigi/konifar/Session.java がまさにこの機能を使っています。Speaker, Category, Placeが他のOrmaモデルクラスで、見ての通りJavaコード的には普通のクラスとおなじように持っているだけですね。内部的にはSessionテーブルは Speaker#id などのプライマリキーだけを持っていて、SELECT 時にまとめてとってくるようになっています。

これはまだ制限があり、v2.1.0現在は1つのモデルにつき1つだけしか持てません。2つ以上のモデルを持つためには、JOINのための複雑なテーブルエイリアスの管理が必要だからです。

モデルの関連は非常に機能が多いので、今後については不明ですが、一つ一つ実装するしかないかなーと思っています。

その他

ライブラリの新機能というわけではないですが、最近exampleアプリでOrmaが生成するコードをリポジトリにいれるようにしました。

特に *_Schema.java はモデルとSQLiteDatabaseのアダプタとなるクラスで、 Schema#bindArgs()Schema#newModelFromCursor() をみて「自分で書くのと同じだな」と思えるならOrmaを使うほうがいいということになります。

またexample/MainActivity.java にはマイグレーションのデモがあります。

RxJavaのリソース管理: イベント放出の際にisUnsubscribed()をチェックすべきだった!

f:id:gfx:20160228114108p:plain

いままでずっと勘違いしてましたが、チェックすべきなんですね。

きっかけはこれ:

これは Observable.create(OnSubscribe)onNext()onComplete() でイベントを放出する際に、Subscriber#isUnsubscribed() でsubscriberの生死をチェックすべきではないかという話です。RxJava official wikiがそうしているから、そうするべきではないかというのが議論の発端ですが、釈然としないので自分でも考えたり検証してみました。

議論の対象となるコードは Selector#executeAsObservable() です。

@NonNull
public Observable<Model> executeAsObservable() {
    return Observable.create(new Observable.OnSubscribe<Model>() {
        @Override
        public void call(final Subscriber<? super Model> subscriber) {
            forEach(new Action1<Model>() {
                @Override
                public void call(Model item) {
                    subscriber.onNext(item);
                }
            });
            subscriber.onCompleted();
        }
    });
}

これをsubscribe()してすぐunsubscribe()すると、subscribe()した側のsubscriberのonNext() などは呼ばれません。つまりこのコードは動いているように見えます。

一方で、この Observable.OnSubscribe#call() が中断されるわけではないので、イベントは送り続けます。その結果、subscribe()する側からみると、subscribe()前のmap() などの関数は依然として呼ばれます。ここが釈然としないところで、unsubscribe() によって OnSubscribe#call() が中断されるものと思っていました。

これが実際に問題になるケースは多くないと思われますが、適切にunsubscribe()をハンドルしないと無駄にCPUやメモリを使うことになるし、HTTP requestなどでは unsubscribe() の時にキャンセルしたいでしょうし、 SQLiteDatabaseも API level 16 から CancellationSignalでクエリのキャンセルを行えるので、それなりの対応は必要と思われます。

というわけで、 https://github.com/gfx/Android-Orma/pull/209 でその対応を行いました。

最初の対応は Cursor#close()Cursor#isClosed() でのハンドリングでしたが:

@NonNull
public Observable<Model> executeAsObservable() {
    return Observable.create(new Observable.OnSubscribe<Model>() {
        @Override
        public void call(final Subscriber<? super Model> subscriber) {
            final Cursor cursor = execute();
            subscriber.add(Subscriptions.create(new Action0() {
                @Override
                public void call() {
                    cursor.close();
                }
            }));
            try {
                for (int pos = 0; cursor.moveToPosition(pos); pos++) {
                    if (cursor.isClosed()) {
                        return;
                    }
                    subscriber.onNext(newModelFromCursor(cursor));
                }
            } finally {
                if (!cursor.isClosed()) {
                    cursor.close();
                }
            }
            if (cursor.isClosed()) {
                return;
            }
            subscriber.onCompleted();
        }
    });
}

複雑すぎてバグを引き起こしやすいので((実際、コードにはバグがあります。finallyのなかで Cursor#close() するので onComplete() が決して呼ばれないのです。)) isUnsubscribed() を使うコードに置き換えました。

@NonNull
public Observable<Model> executeAsObservable() {
    return Observable.create(new Observable.OnSubscribe<Model>() {
        @Override
        public void call(final Subscriber<? super Model> subscriber) {
            final Cursor cursor = execute();
            try {
                for (int pos = 0; !subscriber.isUnsubscribed() && cursor.moveToPosition(pos); pos++) {
                    subscriber.onNext(newModelFromCursor(cursor));
                }
                if (!subscriber.isUnsubscribed()) {
                    subscriber.onCompleted();
                }
            } finally {
                cursor.close();
            }
        }
    });
}

OnSubscribe#call()のなかでunsubscribe() というイベントを得る方法としては、isUnsubscribed()でイベントをpullするよりも Subscriber#add() にコールバックを設定してイベントをpushで受け取るほうが筋がいいと思いますが、今回はシンプルに書ける isUnsubscribed() を採用することにしました。

android.support.v4.util.PoolsでStringBuilderをプールしても意味なし

Pools | Android Developers

これがちょっと気になっていて、たとえば new StringBuilder() を数多く実行するケースでもしかしたら速くなるのでは?と思って試してみました。

10万回のStringBuilderを生成して文字列連結などをしています。

StringBuilderPool/MainActivity.java at master · gfx/StringBuilderPool · GitHub

しかし結果は以下のとおり、4.2.2だと多少の効果はありますが、ARTだと逆に遅くなります。今後はARTが増えていくので、StringBuilder 程度だと気にする必要はありません。そういうわけで、「もしかしたらここはPoolsで速くなるかも…」などと心配せず、安心して使うたびに new StringBuilder() したり文字列リテラルとの連結をしてください。

// MainActivity.java 内のメモ

// on Xperia A (Android 4.2.2)
// D/XXX: SimplePool: 294ms
// D/XXX: SynchronizedPool: 341ms
// D/XXX: No pool: 382ms
//
// on Xperia Z4 (Android 5.0.2)
// D/XXX: SimplePool: 152ms
// D/XXX: SynchronizedPool: 153m
// D/XXX: No pool: 116ms

Ormaのユニットテストをどう書くか、あるいはMockitoは諸刃の剣という話

短い答え

OrmaはRobolectric上でほぼ完全に動作するので、 OrmaDatabase.builder(context).name(null).build() でオンメモリDBを作ってそれを使うDAOをテストすればよい。

長い答え

Orma を使ったユニットテストは、Ormaを使うコードが正しいかどうかをテストするものであるべきです。たとえば、konifar/droidkaigi2016ではSessionDaoというクラスをつくり、Session / Speaker / Category / PlaceなどのデータをSessionというクラスを中心に操作するためのDAO: Data Access Object としています。複雑なモデルの関係があるときは、このようにDAOを作って操作するというのはいいパターンです。

このとき、DAOはロジックを含んでおり、そのロジックはテストが必要です。しかしバックエンドはOrmaなため、Ormaが動く環境を用意するか、pure JVMであればMockito*1などのモックライブラリでAndroid Frameworkをモックする必要があります。

Ormaの場合は、Robolectricの提供するSQLiteDatabaseでほぼ完全に*2動作します。実際、OrmaのすべてのユニットテストはRobolectricでもパスします。そこでDAOのロジックのテストを書くためには、単にオンメモリのOrmaDatabaseを用意してそのうえでDAOを通じた操作を行うことにすればいいでしょう。

かくして、konifar/droidkaigi2016に次のようなテストを追加しました。この場合は、アプリ内で使用している現実のデータ(session_ja.json)をロードしてなんとなくデータが構築されているかどうかをみる、というだけのテストです。開発時にこのテストがあれば、「お気に入り」まわりのロジックはTDDしたほうが開発効率はよかったと思います。

さて、このテストではMockitoを使っていません。たしかにユニットテストではMockitoを使ったほうが書きやすい事があり、とくにプラットフォーム*3が例外を投げるケースはMockitoを使わないとテストが困難なこともあります。DAOのテストも、たとえばディスクフルなどの状態をテストしたいのであればMockitoが必要でしょう。

しかし、Mockitoを使わずに書けるテストをMockitoを使って書くのは避けるべきです。Mockitoはコードの一部しか実行しないため、本当はパスしないような誤ったコードを書くことが簡単にできてしまうからです。また、Mockitoはその性質上ホワイトボックステストになり、メソッドの入出力ではなくメソッドの実装の詳細を固定してしまうことになりがちです。なので、そもそもなるべくMockitoを使わなくてすむように設計し、Mockitoを使わないようにテストを書くほうがいいと思っています。

*1:Java用のモックライブラリ

*2:ほぼというのは、一部のSQL実行時の戻り値がAndroid Frameworkのものと違うことがあるからです。しかし、普通に使う分には問題ありません。

*3:AndroidにせよJVMにせよ

Android Support Libraryをビルドする

DroidKaigi 2016 お疲れ様でした。私は今回もスタッフとして参加して、両日ともRoom Aの司会などを努めました。

2日目の基調講演は余裕があったので聞いていて、いろいろ知見があってよかったと思います。そのときのメモとあとでarakiさんと話したときのメモはこんな感じ:

  • Support Libraryへの要望やバグ報告は b.android.com へ。ちゃんと見てます
  • Support LibraryはIntelliJ IDEAで開発している
  • CL (Change List) は気軽に送ってよい
  • Support Libraryにはテストもあるよ。ただしCIにテストは必須ではない
  • 作業の衝突があるといけないので、Support Libraryの修正作業の前に起票することを勧める

資料にあるサポートライブラリのビルドもやってみました。ライブラリのビルドはうまくいきましたが、 ./gradlew connectedAndroidTest は通らず。とはいえビルドができるようになったのは大きな一歩です。

mkdir android-support-library
cd android-support-library

# support library
repo init -u https://android.googlesource.com/platform/manifest
repo sync frameworks/support

# dependencies
repo sync external/proguard
repo sync external/doclava
repo sync external/antlr
repo sync prebuilts/gradle-plugin
repo sync prebuilts/tools
repo sync prebuilts/maven_repo/android
repo sync prebuilts/sdk
repo sync tools/external/gradle
repo sync development
repo sync build

# build
cd frameworks/support
./gradlew assembleDebug

GitHubのマイルストーンを個人プロジェクトで設定してみたがイマイチだった

三行まとめ

  • ライブラリのセマンティックバージョニングは使う側にとって分かりやすいので採用したい
  • GitHubのマイルストーンは「互換性を失うバグフィクス」に弱いので、セマンティックバージョニングを採用しているライブラリには不向き
  • モバイルアプリはセマンティックバージョニングと無縁だが基本的には関係者とコミュニケーションのためのものなので個人プロジェクトではそれほど意味がない

解説

仕事でGitHubマイルストーンを使っていたので、個人プロジェクトで便利かなと思って使ってみたらそうでもなかったなという話です。

Ormaはマイルストーンを設定して開発してみたのですが、そもそも個人プロジェクトだと他の人とコミュニケーションする必要はないのでマイルストーン設定によって特に何かがやりやすくなるわけではないので、ほとんど意味はありません。むしろ、マイルストーンをきちんと管理しようと思うと「互換性を失うが重要なバグフィクス」をしたくなったときにセマンティックバージョニングを尊重してメジャーバージョンを上げる、ということが心理的にやりにくくなってしまいます。これはマイルストーンの設定が煩雑であるという以上のデメリットかなと思います。

なおモバイルアプリの場合はセマンティックバージョニングを採用する理由がないので、単にissueの優先度を示すものとしてマイルストーンを使うのはありかもしれません。とはいいえマイルストーンは基本的には関係者とのコミュニケーションのためかなと思います。

たとえばdroidkaigi2016 appはわりとうまく使っているようにみえますね。

Android ORMでオブジェクトの埋め込みはどのように実装するか

Table of Contents:


概要

大抵のORMは特定の型をシリアライズしてカラムに埋め込む機能があります。この機能を type adapterと呼ぶことにします。このtype adapterについて、既存のORMの実装とOrmaでの実装を紹介します。

たとえば、Google Play Services のLatLngなどはtype adapterを使ってカラムに埋め込むとすると、ORMに以下の情報を伝える必要があります。

  • シリアライズ元の型(LatLngなど)
  • シリアライズ先の型(Stringなど)
  • DBのストレージ型(SQLiteの TEXT など)
  • シリアライズとデシリアライズをどのように行うか

実例

いくつか実例をみてみます。評価ポイントは、上記の情報をどうやって伝えるかと、type adapterをどのように使うかです。特に、動的にtype adapterを検索して呼び出すのか、静的に呼び出せるのかは実行速度に影響が出るので重要です。

GreenDAO

  • Custom Types
  • type adapterは PropertyConverter<P, D> を実装したクラスのインスタンス
  • 型とtype adapterの関係は静的に解決される

DBFlow

  • Type Converters
  • @com.raizlabs.android.dbflow.annotation.TypeConverter で注釈して com.raizlabs.android.dbflow.converter.TypeConverter<DataClass, ModelClass> を継承するとtype adapterにできる
  • packageの異なる同名のTypeConverterを使っているので非常に記述が煩雑。どうしてこうなった…。
  • 型とtype adapterの関係は動的に解決される

ActiveAndroid

  • Type serializers
  • type adapterは TypeSerializerを継承したクラスのインスタンス
  • 型とtype adapterの関係は静的に解決される

Ormaでの実装

v1.0では、interface TypeAdapter<SourceType> を実装したクラスを OrmaDatabase.Builder に与えるという方式でした(dynamic type adapter)。これは強制的にStringにシリアライズしていたので、バイナリを埋め込むことができませんでした。

そこでこれを改善すべくv1.2で実装したtype adaptersは、POJOに @StaticTypeAdapter で注釈して静的メソッド serialize()deserialize() を実装する方式にしました。

このtype adapterは、annotation processingで型とtype adapterの関係を静的に解決し、生成するコードにserialize()deserialize()の呼び出しが埋め込まれるというものです。SQLiteの適切な型とメソッド(たとえばBLOBCursor#getBlob())を扱えるようになったので、バイナリを埋め込めるようになりました。

Ormaの実装はさらにオブジェクトインスタンスを介さず静的メソッドを呼び出すので、実行速度の点で有利です。

実装例:

@StaticTypeAdapter(
        targetType = LatLng.class,
        serializedType = String.class
)
public class LatLngAdapter {

    // SerializedType serialize(TargetType source)
    @NonNull
    public static String serialize(@NonNull LatLng source) {
        return source.latitude + "," + source.longitude
    }

    // TargetType deserialize(SerializedType serialized)
    @NonNull
    public static Location deserialize(@NonNull String serialized) {
        String[] values = serialized.split(",");
        return new LatLng(
            Double.parseDouble(values[0]),
            Double.parseDouble(values[1]));
    }
}

v1.0のときの動的なtype adapterもまだ残していますが、2.0で削除予定です。そうなると、コンパイルタイムに適切なtype adapterがなければコンパイルエラーを出すようにできるはずです。