visible true

技術的なメモを書く

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')

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 にぶち込もうかと思います。グーメン。