visible true

技術的なメモを書く

ここがつらいよ realm-android 0.81.1

Realmわりといいんですが「簡単!便利!スナック!」というイメージだったのでスナックボリボリする感じで適当に書いてたらガンガンクラッシュ*1して「スナックじゃないな?ぬか漬けかな?」みたいな気持ちになりました。とりあえず一旦ぬか漬け*2部分をまとめます。将来改善予定のものもあれば、自分の設計が間違ってるんだろうなぁーというものもあります。io.realm:realm-android:0.81.1を使っての感想です。iOS版の使い心地についてはわかりません。

autoincrementがない

今のところautoincrementがないです。how to set an auto increment id? · Issue #469 · realm/realm-java · GitHubとか眺めると「まだサポートしてないぜ!」との事。将来サポートされるでしょう。とりあえず現状は以下のような仕組みをつくってしのいでます。PrimaryKeyがstringの場合は使えないです。

public class AutoIncrement {
  public static long newId(Realm realm, Class<? extends RealmObject> clazz) {
    return newIdWithIdName(realm, clazz, "id");
  }

  public static long newIdWithIdName(Realm realm, Class<? extends RealmObject> clazz, String idName) {
    return realm.where(clazz).maximumInt(idName) + 1;
  }
}

こんな感じで使ってます。

realm.beginTransaction();
Item item = realm.createObject(Item.class);
item.setId(AutoIncrement.newId(Item.class));
// 略
realm.commitTransaction();

PrimaryKeyを0で保存すると次のRealm.createObjectの時点で衝突して死ぬ

Realm.createObject(Class<E>)は指定したE型の空レコードを生成して返してくれます。この時short, int, longのフィールドは0で初期化します。すでにPrimaryKey=0のレコードが存在する場合以下のエラーを吐いて死にます。

io.realm.exceptions.RealmException: Primary key constraint broken. Value already exists: 0

まぁかならずPrimaryKeyはセットしましょうって事なんですがRealm.createObject()で死ぬので最初意味がわかりませんでした。前項のautoincrementの仕組みとか使えば問題にはならないです。

mockできない

Realmクラスはmockitoでmockできません。

Realm realm = mock(Realm.class);

Realmクラスがfinalだからです。いやーmockしたい。仕方ないのでwrapperを作ってcompositionにしました。

public class RealmWrapper {

  Realm realm;

  public RealmWrapper(Context context) {
    realm = Realm.getInstance(context);
  }

  public <E extends RealmObject> List<E> copyToRealm(Iterable<E> objects) {
    return realm.copyToRealm(objects);
  }
  //以下Realmクラスが持つpublicメソッド全てをDelegateする
}

Android StudioのGenerate-Delegate Methods...で一発でいけます。

RealmWrapper realm = mock(RealmWrapper.class);
when(realm.createObject(ItemIndex.class)).thenReturn(new ItemIndex());

やったね。まぁよく考えたらRealmに直接さわるクラスを限定してそいつをmockableにしたらいいんじゃないかな?という気もしますが。そういう意味ではActivityやFragmentやThreadで直接Realmオブジェクト触るのはあんまり良くないんじゃないかなぁという印象です。

スレッド制約

Realmはスレッドの制約がつらいです。近々マルチスレッドをサポートするそうなので期待ですね。現状なにが辛いかというと以下の点です。

  • RealmオブジェクトはgetInstance()したスレッドでしか使えない。別スレの場合そのスレッドでgetInstance()しないといけない
  • where等のRealmQueryで得たRealmResultを別のスレッドに渡せない。別スレで触ると死ぬ
  • RealmResult等から取り出したオブジェクト(いわゆるmanaged object)を別スレで触ると死ぬ

例えばItemというモデルを定義した場合以下の様なクラスが生成されます。これをmanaged objectと呼ぶらしいです。

public class ItemRealmProxy extends Item
    implements RealmObjectProxy {

Realm.createObject(Classs<E>)やクエリなどで引っ張ってきたオブジェクトの実体はこのmanaged objectになります。でこのオブジェクトは内部にRealmオブジェクトを持っていてgetter、setterを実行する際にcheckIfValid()でスレッドの検査をしている為別スレッドで触れないです。あんまり深く追っかけてないですが、スレッドの検査を通過したらnative methodを呼んでいるのでgetter、setterが呼ばれた時にはじめてDBからデータの読み書きしてるのかな?という感じです。

この制約によって以下の2つの問題と戦う事になりました。

  • 別スレで取り出したmanaged objectをUIスレッドにどうやって持ってくるか?
  • Realmオブジェクトの管理をどうするか。Dagger等でinjectionする場合スコープをどうするべきなのか?
    • ActivityにRealmをInjectすると、別スレで処理する時に死ぬ。

managed objectをUIスレッドにどうやって持ってくるか

まず問題と思ったのはmanaged objectとstandalone objectの区別が付かない点でした。

Item item = new Item(); // standalone object
Item item = realm.createObject(Item.class); //managed object

両方とも型はItemなので混乱するなぁという事でstandalone objectを持つクラスを別途用意しました。別の型である方がよいのでcompositionで作りました。最初はItemを継承したんですがこれだとコンパイルが通りませんでした*3

public class ImmutableItem {
    Item item;
    //...
}

値の初期化については最初gsonを使って以下のように書いていたんですが、

public ImmutableItem(Item item) {
  String json = gson.toJson(item);
  this.item = gson.fromJson(json, Item.class);
}

managed objectのデータアクセスはgetter、setterを通さないとダメなのでスカスカのデータにしかなりませんでした。RealmとJSONライブラリ // Speaker Deckにある通りTypeAdapterを使えばgsonでもやれるんですが意味がないので愚直に書くことにしました。

public ImmutableItem(Item item) {
  this.item = new Item();
  this.item.setId(item.getId());
  // 略
}

standalone objectである事を保証する型を作ることでAPIの返却値やObservableとかで扱いやすくなりました。

public Observable<List<ImmutableItem>> items(int page, int perPage) {
  //...
  //subscribeOn(Scheduler.io())なんかもどんとこい
}

Realmオブジェクトの管理をどうするか

RealmオブジェクトをDaggerで注入とかやってたので辛かったです。幸いRealmWrapperを作っていたのでThreadLocalを使ってゴニョゴニョする事にしました。具体的にはこんな感じ。

public class RealmWrapper {

  ThreadLocal<Realm> realms = new ThreadLocal<Realm>();

  public Realm getRealm() {
    Realm realm = realms.get();
    if (realm == null) {
      realm = Realm.getInstance(context);
      realms.set(realm);
    }
    return realm;
  }

これで触るスレッドを意識しなくてよくなるんですが思いっきりメモリリークします。この辺は後述するライフサイクル周りの話でなんとかしました。が良い方針ではないのでマネしないほうがいいと思います。

ライフサイクルがよくわからない

Realmオブジェクトってどのくらいの頻度でgetInstance()すべきなのか?ドキュメントにはonDestroy()close()しろとあるのでActivityくらいのスコープでやってたらいいのかなと思ったら当然以下のコードはダメですよね。

class AwesomeActivity exetends Activity {
  Realm realm;
  @Override
  public void onCreate(Bundle savedInstanceState) {
    realm = Realm.getInstance(this); // UI Thread
    new Thread() {
        @Override
        public void run() {
          // 異なるスレッドでrealmに触れているのでクラッシュする
          RealmResults<Item> result = realm.where(Item.class).findAll();
          List<Item> entities = toImmutableItems(result);
          //...
        }
    }.start();
  }
}

前項のThreadLocalを使ったRealmWrapperなら問題にはなりません。

class AwesomeActivity exetends Activity {
  RealmWrapper realmWrapper;
  @Override
  public void onCreate(Bundle savedInstanceState) {
    realmWrapper = RealmWrapper.getInstance(this); // UI Thread
    new Thread() {
        @Override
        public void run() {
          // 内部でThreadLocalでRealmを触るのでクラッシュしない
          RealmResults<Item> result = realmWrapper.where(Item.class).findAll();
          List<Item> entities = toImmutableItems(result);
          //...
        }
    }.start();
  }
}

しかしこの場合Threadが終了してもThreadLocalにRealmオブジェクトが保持され続けてしまいます。そこで暫定的にRealmWrapperstart(), end()ってのを生やしました。

public void start() {
  realms.set(null);
}

public void end() {
  Realm realm = getRealm();
  realm.close();
  realms.set(null);
}

とりあえずこれで動いたんですが、これは良くない設計です。確実にstart()end()を忘れるケースが出るでしょう。

//こんな感じで使う
realmWrapper.start();
RealmResults<Item> result = realmWrapper.where(Item.class).findAll();
List<Item> entities = toImmutableItems(result);
realmWrapper.end();

直接Realm.getIntance(Context)を随所に書きたくない、というのが目的なのでRealmWrapperRealmWrapper getInstanceForCurrentThread()とかを生やして使い終わったらすぐclose()ってのがまだマシな気がします。スレッド周りは制約をちゃんと加味したうえで設計していかないと辛みがあります。今回のケースは完全に失敗だったなと思います。次やるときはいい感じに設計するぞ!

json制約

「managed objectをUIスレッドにどうやって持ってくるか」の所でも書きましたがjson周りに辛みがあります。RealmとJSONライブラリ // Speaker Deckに全部書いてあります。当面はtoJson()はしない方向で頑張る方針にしました。

おわりに

いろいろ触る前にJava Docs - Realm is a mobile database: a replacement for SQLite & Core Dataを熟読するべきだったなという感じです。ゲッター/セッターメソッドは RealmObject が作るプロキシクラスによって上書きされることです。 ゲッター/セッターに書かれたロジックは実行されません。とか割と重要な事サラッと書いてある。学習した今だと色々もっと設計でカバーできそうだなぁという感じです。色々つらい目にあったけどRealm良いと思うので「私はRealmを続けるよキャンペーン」です。version見たら0.82.1が既に出ていた。差分眺めた感じ今回書いた点で変更はなさそう。

*1:使い方が間違っているとクラッシュします。良いことだと思います

*2:ぬか床の手入れって大変だそうですね

*3:A RealmClass annotated object must be derived from RealmObjectと言われます

Data Binding Libraryが吐くコードがLintに怒られる時がある

問題

booleanを使ってandroid:backgroundの値を制御するようなレイアウトを書いたとします。

<data>
  <variable name="focused" type="boolean" />
</data>
<FrameLayout
  android:layout_width="wrap_content"
  android:layout_height="wrap_content"
  android:background="@{focused? @drawable/shape_focused : null}"
                >

この時Data Binding Libraryが以下のようなコードを吐きます。

this.mboundView1.setBackground((android.graphics.drawable.Drawable) FocusedAndroidDrawableShapeFocusedObjectnull);

アプリのminSdkVersionが16未満の場合Lintに以下の様に怒られてしまいます。

NewApi: Calling new methods on older versions

View#setBackground(Drawable)API Levelが16だからですね。@TargetApi等で分岐しろと言われます。

対応

さて、Bindingクラスは自動で生成されるので@TargetApiを追加できません。こういう時はBindingAdapterアノテーションを使います。BindingAdapterアノテーションは属性値のバインド処理を定義できるアノテーションです。どこか適当な場所(アプリケーションID.databindingなどが望ましいかもしれません)にViewGroupAdapterというクラスを作ります。

public class ViewGroupAdapter {
  @BindingAdapter("android:background")
  public static void setBackgroundAdapter(ViewGroup viewGroup, Drawable drawable) {
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) {
      setBackground(viewGroup, drawable);
    } else {
      setBackgroundDrawable(viewGroup, drawable);
    }
  }

  @TargetApi(Build.VERSION_CODES.JELLY_BEAN)
  public static void setBackground(ViewGroup viewGroup, Drawable drawable) {
    viewGroup.setBackground(drawable);
  }

  public static void setBackgroundDrawable(ViewGroup viewGroup, Drawable drawable) {
    viewGroup.setBackgroundDrawable(drawable);
  }
}

ViewGroupAdapterは"android:background"を処理するメソッドを定義しています。対象をViewGroupにしているのでFrameLayoutにも適用されます。ImageViewなどViewGroupと継承関係のないViewへは適用されないのでご注意ください。

このクラスを定義して再度コンパイルすると生成されるコードが以下のように変化します。これでLintに怒られずにすみますね。

アプリケーションID.databinding.ViewGroupAdapter.setBackgroundAdapter(this.mboundView1, (android.graphics.drawable.Drawable) FocusedAndroidDrawableShapeFocusedObjectnull);

最後に

BindingAdapterアノテーションはアプリケーション全体に適用されるので適当にやりすぎると確実に事故ります。用法用量を守って活用しましょう。

ところでC88でTechBooster本に参加しています。Data Binding Libraryについて根掘り葉掘り書いたのでよかったら買ってください。

techbooster.github.io

Gradleで相対パスでライブラリプロジェクトを参照する

パッと出てこなかったのでメモっとく

こんな構成の時、appからlibraryを参照したいとする。

myProject
  - app
targetLibrary
  - library

myProject/settings.gradle

include 'app', ':..:targetLibrary:library'

myProject/app/build.gradle

dependencies {
    compile project(':..:targetLibrary:library')

minSdkVersion 17未満でもleanbackを使う方法

leanbackってminSdkVersion 17なので10*1とか14*2とか16*3とかしてると怒られるんですよね。

AndroidManifest.xml

<uses-sdk tools:overrideLibrary="android.support.v17.leanback"/>

OK。TV対応待ったなし。

*1:

*2:おお

*3:よしよし

Data BindingのテストはAndroid5.0以上の環境じゃないと動かない

※ 2015/06/28 20:31追記 ART環境だったら動きました。4.4系でもRuntimeをARTにしておけばテスト動きます

問題

具体的にはこんなコードです。

@Test
public void test() {
  //XXXBindingはData Bindingで生成したクラスです
  XXXBinding binding = XXXBinding.inflate(LayoutInflater.from(
      InstrumentationRegistry.getTargetContext()
  ));
  assertThat(binding, is(notNullValue()));
}

ActivityMvvmBinding.inflate()の所で以下のエラーが出ます。

java.lang.IllegalAccessError: Class ref in pre-verified class resolved to unexpected implementation

原因?

ClassLoaderの関係で問題が起きているようです。テストコードをビルドするとテストプロジェクト側でもData Binding用のクラスが生成されます。これによりテスト側のXXXBindingとアプリケーション側のXXXBindingが異なりエラーが起こっているようです。が正確にはわかりません。

対応

issue trackerに以下の投稿がありました。

Issue 177588 - android - Android Studio AI.141 DataBinding test - Android Open Source Project - Issue Tracker - Google Project Hosting

その中で「環境の問題だったわー」という会話がされています。投稿者のSample CodeにはTest could not run Below android 5.0 !!という文言が更新されてました。

Android5.0以上で実行しなければならないようですね!!!早速エミュレータを使って実行してみます。次はこんなエラーが。

java.lang.IllegalStateException: DataBinding must be created in view's UI Thread

なるほどUIスレッドじゃないとダメなようです。これはもう簡単ですね。最終的に以下のコードとなりました。

@Test
public void test() {
  InstrumentationRegistry.getInstrumentation().runOnMainSync(new Runnable() {
    @Override
    public void run() {
      ActivityMvvmBinding binding = ActivityMvvmBinding.inflate(LayoutInflater.from(
          InstrumentationRegistry.getTargetContext()
      ));
      assertThat(binding, is(notNullValue()));
    }
  });
}

まとめ

  • Data Bindingを使ったテストはAndroid5.0以上の環境で実行するべし(※ 2015/06/28 20:31追記 ART環境だったら動きました。4.4系でもRuntimeをARTにしておけばテスト動きます)
  • XXXBindingはUI Threadで触る必要がある。Instrumentation#runOnMainSync(Runnable)などを使おう
  • XXXBindingのテストを書くとActivityとか無しでActivityの状態変化のテストができて便利ですね?

Data BindingとMultidexの兼ね合いの問題を大体倒したので実用段階待ったなし

Data Bindingを導入したかったけどmultidexの兼ね合いで躓いたので問題をまとめます。 - visible trueで死んでましたが、設定でなんとか頑張れる事がわかったのでメモします。

結論

これらを加えるだけ。

build.gradle

android {
  defaultConfig {
    multiDexEnabled = true
    multiDexKeepProguard file('multi-dex-keep.txt') //<- new!
  }
}

multi-dex-keep.txt

-keep public class * extends android.databinding.ViewDataBinding {
 *;
}

大体解決するまで

大体解決するまでこういう事しましたというメモです。長いので読まなくてもいいです。

1. gradle pluginのソースを落とす

まずはgradle pluginでmultidexの処理している所を探す必要があるなーと思ってgradle pluginのソースを落としました。

Build Overview - Android Tools Project Site

でbranchはstudio-master-devを選択しました。ビルドする気でいたのでcase-sensitiveなdisk image作ってそこに持ってきました。

2. multidexを処理してるタスクを探す

multidexというか、maindexlist.txtを作ってる所を探すわけですが、よくわからないのでとりあえずmultidexとかでgrepすると以下の場所にドンピシャっぽいタスクがあるのを見つけました。 studio-master-dev/tools/base/build-syste/gradle-core/src/main/groovy/com/android/build/gradle/internal/tasks/multidex/CreateMainDexList.groovy

中身を見るとすげーそれっぽい感じ。

@TaskAction
void output() {
  if (getAllClassesJarFile() == null) {
    throw new NullPointerException("No input file")
  }
  
  File _allClassesJarFile = getAllClassesJarFile()
  Set<String> mainDexClasses = callDx(_allClassesJarFile, getComponentsJarFile())

  File _includeInMainDexJarFile = getIncludeInMainDexJarFile()
  if (_includeInMainDexJarFile != null) {
    mainDexClasses.addAll(callDx(_allClassesJarFile, _includeInMainDexJarFile))
  }

  if (mainDexListFile != null) {
    Set<String> mainDexList = new HashSet<String>(Files.readLines(mainDexListFile, Charsets.UTF_8))
    mainDexClasses.addAll(mainDexList)
  }

  String fileContent = Joiner.on(System.getProperty("line.separator")).join(mainDexClasses)

  Files.write(fileContent, getOutputFile(), Charsets.UTF_8)
}

でもあんまり具体的な事はしてなさそうですね。mainDexClasses.addAll()に値を渡しているcallDx()が怪しそうです。

private Set<String> callDx(File allClassesJarFile, File jarOfRoots) {
  return getBuilder().createMainDexList(allClassesJarFile, jarOfRoots)
}

callDx()の中身もあんまりない...。次はgetBuilder()が返す値を探す旅に出かけます。

3. 正体はAndroidBuilder

とりあえずcreateMainDexList全文検索すると以下のクラスが引っかかりました。

studio-master-dev/tools/base/build-system/builder/src/main/java/com/android/builder/core/AndroidBuilder.java

中身はこんなの。

public Set<String> createMainDexList(
    @NonNull File allClassesJarFile,
    @NonNull File jarOfRoots) throws ProcessException {

  BuildToolInfo buildToolInfo = mTargetInfo.getBuildTools();
  ProcessInfoBuilder builder = new ProcessInfoBuilder();

  String dx = buildToolInfo.getPath(BuildToolInfo.PathId.DX_JAR);
  if (dx == null || !new File(dx).isFile()) {
    throw new IllegalStateException("dx.jar is missing");
  }

  builder.setClasspath(dx);
  builder.setMain("com.android.multidex.ClassReferenceListBuilder");

  builder.addArgs(jarOfRoots.getAbsolutePath());
  builder.addArgs(allClassesJarFile.getAbsolutePath());

  CachedProcessOutputHandler processOutputHandler = new CachedProcessOutputHandler();

  mJavaProcessExecutor.execute(builder.createJavaProcess(), processOutputHandler)
      .rethrowFailure()
      .assertNormalExitValue();

  String content = processOutputHandler.getProcessOutput().getStandardOutputAsString();

  return Sets.newHashSet(Splitter.on('\n').split(content));
}

おや〜〜?builder.setMain("com.android.multidex.ClassReferenceListBuilder");dx.jarの中のクラスを呼び出してますね。という事でこんな事つぶやいてます。

4. AOSPのソース持ってくる & ClassReferenceListBuilderのソース読む

という事でdxのソース読む為に持ってきます。Downloading the Source | Android Open Source Projectとかに書いてあります。とりあえずmasterにしました。

com.android.multidex.ClassReferenceListBuilderの実装を見ると引数で渡されたjarファイルを展開して.classを取り出してるだけっぽい事がわかります。AndroidBuilder#createMainDexList()ではjarOfRoots.getAbsolutePath()allClassesJarFile.getAbsolutePath()を渡しています。

実装を辿っていくと、これらはアプリケーションのビルド時にproject_root/module/build/intermediates/multi-dex/[flavor]/[variant]/に生成されるcomponentClasses.jarallclasses.jarである事がわかりました。

5. componentClasses.jarはどこで生成されている?

とりあえず./gradlew --debug assembleでビルドプロセスを見てどのタスクで何が生成されているのかを見ます。

[INFO] [org.gradle.api.internal.tasks.execution.SkipUpToDateTaskExecuter] Executing task ':app:shrinkMultiDexComponents' (up-to-date check took 0.002 secs) due to:
  Output file //*略*//build/intermediates/multi-dex/product/debug/componentClasses.jar has changed.

するとshrinkMultiDexComponentsタスクでcomponentClasses.jarが生成されてる事がわかりました。studio-master-devを検索すると/studio-master-dev/tools/base/build-system/gradle-core/src/main/groovy/com/android/build/gradle/tasks/factory/ProGuardTaskConfigAction.javaで実行されている事がわかりました。実装を見ると以下の箇所が。

proguardComponentsTask.configuration(scope.getManifestKeepListFile());

これはcomponentClasses.jarが生成される場所にあるmanifest_keep.txtっぽい!このファイルの中身を見ると...

-keep class com.sys1yagi.android.hoge.Application {
    <init>();
    void attachBaseContext(android.content.Context);
}
//略

という感じでproguardの設定ぽいものが書かれています。どうやらこの設定に沿ってallclasses.jarからcomponentClasses.jarを抽出してるっぽい事がわかりました。

6. manifest_keep.txtはどこで生成されている?

manifest_keep.txtに任意のkeep設定を差し込めれば勝てる気配がしてきました。ちなみにタスク名は以下の様に定義されてるみたいです。

ProGuardTaskConfigAction.java

@Override
public String getName() {
  return scope.getTaskName("shrink", "MultiDexComponents");
}

"shrink"と"MultiDexComponents"の間にflavorとvariantsが入ります。という事でMultiDexComponents全文検索すると何か引っかかるかも?という事で検索すると/studio-master-dev/tools/base/build-system/gradle-core/src/main/groovy/com/android/build/gradle/internal/tasks/multidex/CreateManifestKeepList.groovyが引っかかりました。こっちのタスク名はこう

@Override
String getName() {
  return scope.getTaskName("collect", "MultiDexComponents");
}

CreateManifestKeepList.groovyでは、AndroidManifest.xmlに定義されたコンポーネントmanifest_keep.txtに書き出してました。その他決め打ちでいくらかの設定を吐いてます。で、こんな実装が。

@Override
void execute(CreateManifestKeepList manifestKeepListTask) {
  // since all the output have the same manifest, besides the versionCode,
  // we can take any of the output and use that.
  final BaseVariantOutputData output = scope.variantData.outputs.get(0)
  ConventionMappingHelper.map(manifestKeepListTask, "manifest") {
    output.getScope().getManifestOutputFile()
  }
  manifestKeepListTask.proguardFile = scope.variantConfiguration.getMultiDexKeepProguard()
  manifestKeepListTask.outputFile = scope.getManifestKeepListFile();
  //variant.ext.collectMultiDexComponents = manifestKeepListTask
}

おや〜〜? manifestKeepListTask.proguardFile = scope.variantConfiguration.getMultiDexKeepProguard() という事でbuild.gradleに書けるのでは〜!?

build.gradle

android {
  defaultConfig {
    multiDexEnabled = true
    multiDexKeepProguard file('multi-dex-keep.txt') //<- new!
  }
}

こうして

multi-dex-keep.txt

-keep public class * extends android.databinding.ViewDataBinding {
 *;
}

こうじゃ

これでmaindexlist.txtにData Bindingによって生成されたXXXBindingクラスが出力されるようになりました!!!

おわりに

正式な設定項目なので他のケースでも何とかできそうでいいですね。

ところでC88で@mhidaka氏のAndroid本に参加します。Data Bindingについて書きまくるので買って下さい。

新たなる敵

(ヽ´ω`)…

Data Bindingを導入したかったけどmultidexの兼ね合いで躓いたので問題をまとめます。

※解決しました http://sys1yagi.hatenablog.com/entry/2015/06/17/190547

個人アプリにフォイフォイ導入して上手く行ったので仕事の方でも、と思ってたら思わぬ罠で挫折したので記録しておきます。

環境

  • com.android.tools.build :gradle:1.3.0-beta3
  • com.android.databinding:dataBinder:1.0-rc0

結論

android gradle pluginのmultidex処理系の改修を待つ。具体的にはData Binding用に生成したクラス群をmaindexlist.txtに含める様に修正されるのを待つ。or adt-devに参加して直す

問題

ViewPager内のFragmentで利用するArrayAdapterのViewをData Bindingに適用しようとした所、実行時に以下のエラーが発生した。

Could not find class 'android.databinding.ViewDataBinding$IncludedLayoutIndex[][]',
 referenced from method com.hoge.databinding.XXXBinding.<clinit>

java.lang.IllegalAccessError: tried to access class
 android.databinding.ViewDataBinding$IncludedLayoutIndex[][]
 from class com.hoge.databinding.XXXBinding

com.hoge.databinding.XXXBindingはData Bindingによって生成されたクラス。エラーが起こった箇所はAdapter内のXXXBinding.inflate()の部分。これを実行した時にクラッシュする。

@Override
public View getView(int position, View convertView, ViewGroup parent) {
  if(convertView == null) {
    XXXBinding binding = XXXBinding
                    .inflate(LayoutInflater.from(getContext()));
    //...
  }
  //...

原因

XXXBindingクラスはandroid.databinding.ViewDataBindingクラスを継承している。

public class XXXBinding extends android.databinding.ViewDataBinding {
  //...
}

これらのクラスがmultidexによって分断されてしまうと、この問題が起こる。

classes.dex  <- android.databinding.ViewDataBindingが入ってる
classes2.dex <- XXXBindingが入ってる

つまり以下の条件を満たすときData Bindingの利用は厳しくなる。

  1. multidexを使っている (multiDexEnabled = trueにしている)
  2. multidexの結果classes.dexが分割される (ビルドしたあとbuild/intermediates/dex/[flavor]/[variant]/みると分かる)。multiDexEnabled = trueにしていても65535問題をクリアしていればdexは分割されない。分割されていなければ問題は出ない。
  3. BindingをFragmentやArrayAdapterで使っている(Activityなら多分問題ない)

この条件を満たしていても動く場合もあるかもしれないけどいつぶっ壊れるか分からないので怖い。

なぜ分断されるのか??

multidexでdex分割を処理する時、前段でprimary dex入りするクラスを推論するフェーズがある。ここではApplicationやActivityの依存関係からアプリ起動時に必要なクラス群を抽出しているぽい。この時android.databinding.ViewDataBindingはprimary dexに必ず格納されるが、生成されたXXXBindingはprimary dexから漏れる場合がある。現状のpluginではその辺の条件が足りてないんだと思う。

primary dex入りするクラス群はbuild/intermediates/multi-dex/[flavor]/[variant]/maindexlist.txtで確認できる。maindexlist.txtに生成されたBindingクラスが書かれていなければ問題が起こる可能性がある。

対応(暫定)

完全には分かっていないが、primary dex入りするクラスが参照しているクラスもprimary dex入りするらしい。つまりandroid.intent.category.LAUNCHERなActivity(ここではMainActivityとします)がXXXBindingを参照している場合maindexlist.txtにXXXBindingが含まれる様になる。という事で以下の様なコードを書くとクラッシュせず動作する様になる。

public class MainActivity extends AppCompatActivity {
  //この記述をすると動作する
  static {
    Class clazz = XXXBinding.class;
  }
}

この対応の問題点

  • XXXBindingが増える度に書かなければならないので漏れる可能性がある
  • Proguardのoptimizeで消される可能性がある
  • 書いてもsecondary dex入りする可能性は否定できない

暫定で動くものの総合的には問題が出る条件を満たす場合Data Bindingを見送る方がよさそう。

試したけどダメだった事

その他いくつか試したけどダメだった事のメモ。

  • multidex.keepを書く
    • Multi-dex Support を使おう の辺りが参考になる。dexに--main-dex-listオプションでprimary dexに入れるクラス群を定義したファイルを渡せる。これを使えばXXXBindingをprimary dexに入れられるのではないかと考えたがここにはファイル一つしか渡せない。既にpluginが推論して生成するmaindexlist.txtが自動で渡る様になっているので無理。渡したら次はmaindexlist.txtが無視されるので爆死する。
  • XXXFragmentやXXXAdapterでstatic initializer
    • XXXActivityじゃなく、FragmentやAdapterでstatic initializerを書いてみたがダメだった。
  • multiDexEnabled以外に何かオプションないかな?
    • なさそう

終わりに

サイコーなDaba Bindingですがこういった落とし穴がありました。大規模なアプリへの適用はまだ厳しそうな印象です。目下は簡単な再現環境をつくって issue tracker にぶち込もうかと思います。グーメン。