sumioの技術メモ

Androidについての記事が多くなると思います。

第1回Android Test Casual Talks でuiautomatorの話をしました #androidtest

12/13(金)に開催された Android Test Casual Talks #1 でLTをしました。以下が発表資料です。

uiautomatorを使うときのひと工夫」というタイトルで、 拙作のUiautomator Unicode Input Helperの紹介をベースに、 日本語環境でuiautomatorを快適に利用する工夫について発表しました。

勉強会では初めての発表だったということもあり、 かなりドキドキしていたのですが、皆さんに興味を持って聴いていただけたようで、 ほっとしています。

LTの補足

発表の中で、 UiObject.clearTextField() の実装を日本語ロケール向けにローカライズを行った、 というお話もさせていただきました。 それについて、帰宅後に少し調べてみたところ、 意外と簡単に国際化対応できそうな事に気付きましたので、 このブログで補足させてください。

このような対応が必要となる原因は、 UiObject.clearTextField()実装で、 以下のように"Select all"がハードコーディングされているからでした。

public void clearTextField() throws UiObjectNotFoundException {
    // (省略)
    getInteractionController().longTapNoSync(rect.left + 20, rect.centerY());
    // check if the edit menu is open
    UiObject selectAll 
        = new UiObject(new UiSelector().descriptionContains("Select all"));
    if(selectAll.waitForExists(50))
        selectAll.click();
    // wait for the selection
    SystemClock.sleep(250);
    // delete it
    getInteractionController().sendKey(KeyEvent.KEYCODE_DEL, 0);
}

"Select all"を表すリソースIDは R.string.selectAll なので、通常のAndroidアプリであれば"Select all"context.getString(android.R.string.selectAll) (contextContextインスタンス) に置き換えれば自然に国際化できるのですが、 uiautomatorのテストコードからはContextを取得する手段が無いため、 getString()できないだろうと思い込んでいました。

ところが、その思い込みは間違いで、システムリソース (android.R.から始まるリソース)に限り Resources.getSystem() を使えば、Context無しにリソースにアクセスできることが分かりました*1。 恥ずかしながら、このようなAPIが有ることを今まで知りませんでした。

手持ちの以下の2つの端末で確認してみた感じでは、 uiautomatorから上記を呼び出すと、端末の言語設定に応じて、 適切な文字列を返してくれているようです。

ですので、原理的には、以下のように書き換えれば、 端末の言語設定にかかわらず、clearTextField()が動作するはずです。 AOSPへのバグ報告は別途行いたいと思います。

public void clearTextField() throws UiObjectNotFoundException {
    // (省略)
    String selectAllDesc 
        = Resources.getSystem().getString(android.R.string.selectAll);
    UiObject selectAll 
        = new UiObject(new UiSelector().descriptionContains(selectAllDesc));
    // (省略)
}

各自で回避する場合の多言語対応版は以下のようになります。

public void myClearTextField(UiObject editText)
         throws UiObjectNotFoundException {
    editText.longClickTopLeft();
    // "Select all"のリソース文字列を取得
    String selectAllDesc 
            = Resources.getSystem().getString(android.R.string.selectAll);
    UiObject selectAll 
            = new UiObject(new UiSelector().descriptionContains(selectAllDesc));
    if (selectAll.waitForExists(50))
        selectAll.click();
    SystemClock.sleep(250);
    getUiDevice().pressDelete();
}

さいごに

他の皆さんのLTも、どれも新しい発見があり、とても興味深く聴かせていただきました。 これからも、このような勉強会で引き続き情報交換していきたいです。

今回の勉強会を主催し、LTに誘って下さった@hotchemiさんをはじめとする運営の皆さん、 コメント下さった参加者の皆さん、本当にありがとうございました。

*1:実は、「Contextを取得できない」というのも勘違いの可能性が高いです。Android4.3からはUiAutomatorTestCaseがInstrumentationTestCaseを継承するようになっていました。ということは、getInstrumentation().getContext()が呼び出せるかもしれません。今度調べてみようと思います。

KitKatの文字コード変換バグを回避する

はじめに

先日、手許のNeuxs7にもKitKatが降ってきました。
早速アップグレードしてみたところ、「Uiautomatorで日本語を入力する」で紹介した、拙作のuiautomator-unicode-input-helper(以後、UUIHと略します)が全く動作しなくなってしまいました…。

原因を調べたところ、KitKatには、Java文字コード変換を担っているCharsetEncoderにバグがあることが分かりました。一応の回避策も判明したので、書き留めておこうと思います。

事象

KitKatでUUIHを使うときに、以下のように、自作のヘルパーライブラリを使って日本語を入力しようとすると、文字列によって、全部入力できたり、一部だけ入力できたり、全く入力できなかったりします。

UiObject editText = ...;
editText.setText(Utf7ImeHelper.e("こんにちは!UiAutomatorで入力しています。"));
// うまく入力できない!

ここで、Utf7ImeHelper.e(String)の実装は以下のような単純なものです。

private static final Charset CHARSET_MODIFIED_UTF7 
        = new CharsetProvider().charsetForName("X-MODIFIED-UTF-7");

public static String e(String text) {
    byte[] encodedByte = text.getBytes(CHARSET_MODIFIED_UTF7);
    return new String(encodedByte, Charset.forName("US-ASCII"));
}

ところが、手動でModified UTF-7エンコードしたものを指定すると、全く問題なく入力できます。

UiObject editText = ...;
// 「こんにちは!UiAutomatorで入力しています。」を修正UTF-7でエンコードしたもの
String e = "&MFMwkzBrMGEwb,8B-UiAutomator&MGdRZVKbMFcwZjBEMH4wWTAC-";
editText.setText(e);
// 問題なく入力できる!

そこで、この文字列とUtf7ImeHelper.e(...)の変換結果と比べてみると、text.getBytes(CHARSET_MODIFIED_UTF7)した時点で、既に末尾の"-"が欠けていることが判明しました。

次に、似た事象を検索しみたところ、こちらの記事ではSJISで似たような事象が起きていることがわかりました。これはどうもKitKat文字コード変換まわりに問題がありそうです。

解析

String.getBytes(Charset)が内部で呼び出していると思われる、CharsetEncoder.encode(CharBuffer)ソースコードをあたってみたところ、Android4.4では以下のようになっていました。

public final ByteBuffer encode(CharBuffer in) throws CharacterCodingException {
    ...
    reset();

    while (in.hasRemaining()) {
        CoderResult result = encode(in, out, true);
        if (result == CoderResult.UNDERFLOW) {
            break;
        } else if (result == CoderResult.OVERFLOW) {
            out = allocateMore(out);
            continue;
        } else {
            checkCoderResult(result);
        }

	// resultがUNDERFLOWかOVERFLOWである限り、ここに到達することはない!

        result = flush(out);
        ....
    }
    ....
}

このコードから呼び出されているCharsetEncoder.encode(CharBuffer in, ByteBuffer out, boolean endOfInput)
が、文字コード変換の要となるメソッドです。APIリファレンスによると、このメソッドを呼び出す時には、以下のプロトコルに従う必要があります。

  • まずreset()を呼び出す
  • encode(CharBuffer in, ByteBuffer out, boolean endOfInput)を呼び出してエンコードする。
    • 一度に全入力を渡さず、何回かに分けて呼び出す場合には、一番最後の呼び出しのときだけ、第3引数endOfInputtrueにして呼び出す。そうでない場合には第3引数にはfalseを指定する。
    • 1回で全入力を渡せる場合は、いきなり第3引数にtrueを指定して呼び出せばよい。
    • 入力が壊れていない場合、encode(CharBuffer, ByteBuffer, boolean)の結果は、CoderResult.UNDERFLOWか、CoderResult.OVERFLOWのいずれかになる。OVERFLOWの場合は、出力バッファのサイズが足りていないので、出力バッファを拡張して、encode(CharBuffer, ByteBuffer, boolean)を呼び直さなければならない。UNDERFLOWは正常終了を表す。
  • 変換がおわったらflush(ByteBuffer)を呼ぶ

ところが、上記コードを見ると、encode(CharBuffer, ByteBuffer, boolean)の結果がOVERFLOWとUNDERFLOWしか取らない場合では、(必ずbreakcontinueに到達するので)絶対にflush(ByteBuffer)が呼び出されることはありません。

自分がModified UTF-7への変換に使っているライブラリjutf7は、flush(ByteBuffer)呼び出し時に'-'を出力する実装になっていたので、末尾の'-'が欠ける結果となっていたのでした。

回避策

以上をふまえると、きちんと最後にflush(ByteBuffer)を呼び出してあげれば良いことがわかりますので、AOSPのソースコードから、バグの無いところを抜粋して、以下のように書き換えてみたところ、KitKatでも動作するようになりました。

/*
 * このコードは、
 *
 * https://android.googlesource.com/platform/libcore/+/master
 *
 * の以下のファイルから一部抜粋し、改変したものです。
 * - libdvm/src/main/java/java/lang/String.java
 * - luni/src/main/java/java/nio/charset/CharsetEncoder.java
 *
 * この部分のライセンスはApache License, Version 2.0とします。
 */

/**
  * Modified UTF-7形式にエンコードした文字列に変換する。
  */
public static String e(String text) {
    byte[] encoded = getBytes(text, CHARSET_MODIFIED_UTF7);
    return new String(encoded, Charset.forName("US-ASCII"));
}

/**
  * String.getBytes(Charset)を、
  * 自作の{@link #encode(CharBuffer, CharsetEncoder)}
  * を呼び出すように改造したもの。
  */
public static byte[] getBytes(String input, Charset charset) {
    CharBuffer chars = CharBuffer.wrap(input.toCharArray());
    CharsetEncoder encoder = charset.newEncoder()
            .onMalformedInput(CodingErrorAction.REPLACE)
            .onUnmappableCharacter(CodingErrorAction.REPLACE);
    ByteBuffer buffer;
    buffer = encode(chars.asReadOnlyBuffer(), encoder);
    byte[] bytes = new byte[buffer.limit()];
    buffer.get(bytes);
    return bytes;
}

/**
  * CharsetEncoder.encode(CharBuffer) のバグ修正版。
  * ただし、{@link #getBytes(String, Charset)}
  * からしか呼び出されない前提で、処理を簡略化している。
  */
private static ByteBuffer encode(CharBuffer in, CharsetEncoder encoder) {
    int length = (int) (in.remaining() * (double) encoder.averageBytesPerChar());
    ByteBuffer out = ByteBuffer.allocate(length);

    encoder.reset();
    CoderResult flushResult = null;

    while (flushResult != CoderResult.UNDERFLOW) {
        CoderResult encodeResult = encoder.encode(in, out, true);
        if (encodeResult == CoderResult.OVERFLOW) {
            out = allocateMore(out);
            continue; // No point trying to flush to an already-full buffer.
        }
        // getBytes()から呼び出されるときは、CodingErrorActionは
        // REPLACEなので、ここでエラーチェックする必要はない。

        flushResult = encoder.flush(out);
        if (flushResult == CoderResult.OVERFLOW) {
            out = allocateMore(out);
        }
        // getBytes()から呼び出されるときは、CodingErrorActionは
        // REPLACEなので、ここでエラーチェックする必要はない。
    }

    out.flip();
    return out;
}

private static ByteBuffer allocateMore(ByteBuffer output) {
    System.out.println("allocateMore()");
    if (output.capacity() == 0) {
        return ByteBuffer.allocate(1);
    }
    ByteBuffer result = ByteBuffer.allocate(output.capacity() * 2);
    output.flip();
    result.put(output);
    return result;
}

SJISへの変換がおかしくなる問題も、この方法で回避できましたので、お困りの方はお試しください。具体的には、上記コードをコピペした上で、以下のように使ってみてください。

// 「あ」をSJISに変換する。
byte[] sjisEncoded = getBytes("あ", Chrset.forName("Shift_JIS"));

github上のUUIHは近いうちに更新しようと思います。

バグ報告について

この事象について、AOSPにバグ報告しようと調べてみたところ、既に韓国語でバグ報告がなされていることがわかりました。

これによると、もうmasterブランチには修正版コミットされており、次回の4.4のアップデートでは配信される見込みのようです。もうしばらくの辛抱ですね…。

UiautomatorでASCII文字だけを確実に入力する

はじめに

前回の記事で、uiautomatorで日本語を入力できるようにするためにIMEを作った、という記事を書きました。

このIMEを入れれば、uiautomatorで日本語などの非ASCII文字を入力できるのはもちろん、英数字のようなASCII文字も確実に入力することができるのですが、中には「英数字だけ入力できればいいよ」という方もいるかも知れません。

実は、英数字(ASCII文字)だけ確実に入力できれば良いのであれば、Androidソースコードの中に、それを実現するためのIMEがこっそり同梱されています。
https://android.googlesource.com/platform/frameworks/testing/+/master/uiautomator/utils/
にある「DummyIME」というのがそれです。

このDummyIMEは、ソフトウェアキーボードを表示せず、ひたすらキー入力をEditTextにスルーするように実装されているので、UiObject.setText(String)で設定した通りの英数字を、確実に入力することができます。

この記事では、DummyIMEを使う方法について説明します。

DummyIMEのインストール

  • git cloneします。
git clone https://android.googlesource.com/platform/frameworks/testing
  • uiautomator/utils/DummyIMEへ移動します。
cd testing/uiautomator/utils/DummyIME
  • build.xmlを生成します。-t android-18の部分は、実行したい端末と同じOSバージョンのものにしておくと良いでしょう。
android update project -p . -t android-18
  • テスト対象の端末を接続し、antを使ってビルド・インストールします。
ant clean debug install

Uiautomatorの実行

さきほどの手順で、DummyIMEをインストールしておけば、uiautomatorコマンドの隠しオプションで、DummyIMEを有効化した上でテストを実行してくれます。
具体的には、下記のように-e disable_ime trueオプションを追加して実行してください。

adb shell uiautomator runtest <JARファイル名> -e disable_ime true -c <実行クラス名> 

このオプションはとても強力で、自動的にDummyIMEをデフォルトのIMEに設定してから、テストを実行してくれるのですが、テスト終了後に元のIMEに戻すことはしてくれません。
ソースコードを見ると、そもそも元のIMEに戻す手段が無いような雰囲気です。

まとめ

Androidソースコードにこっそり格納されているDummyIMEについて紹介しました。
英数字を入力したいだけであれば、DummyIMEを使うのもひとつの方法です。

ただ、前回の記事で紹介したUiautomator Unicode Input Helperを使えば、英数字に限らず、任意のUnicode文字を入力することができますので、是非お試しください。

Uiautomatorで日本語を入力する

はじめに

Android 4.1から使えるようになった、GUIの自動テストツールであるuiautomatorですが、以下のような特徴があり、とても便利です。

  • MonkeyRunnerのように、自分が書いたアプリ以外も操作することができる。
  • MonkeyRunnerと異なり、タップ位置を絶対座標で指定する必要がないため、画面サイズの異なる端末でも同一シナリオが適用できる。

ただ、EditTextへの文字入力に多少難があります。
Uiautomatorには、UiObject.setText(String)という、EditTextに文字列を設定するメソッドがあるのですが、ここに

UiObject editText = ...;
editText.setText("こんにちは");

のように書いても、何も入力されないのです。

このようになってしまう理由は、Uiautomatorが、ハードウェアキーボードのキーイベントを擬似的に発生させることで、文字入力を実現しているからのようです。
そのため、逆に、日本語IMEが有効な状態で

UiObject editText = ...;
editText.setText("aiueo");

とすると、ローマ字入力が有効になってしまい、「あいうえお」と入力されてしまいます…。

これを解決するために、ASCII図形文字だけで、任意のUnicode文字が入力できるようなIMEを実装してみました。これを使えば、EditTextへの文字入力は思いのままです!
GitHubに公開しましたので、テストのお供にご利用ください。

使い方

README.mdをご覧ください。

このIMEを有効にした状態で、以下のように書くと、「こんにちは!UiAutomatorで入力しています。」と入力することができます。

UiObject editText = ...;
// 「こんにちは!UiAutomatorで入力しています。」を修正UTF-7でエンコードしたもの
String e = "&MFMwkzBrMGEwb,8B-UiAutomator&MGdRZVKbMFcwZjBEMH4wWTAC-";
editText.setText(e);

もちろん、このままでは使いにくいので、以下のように書けるヘルパーライブラリもあわせて公開しています。

UiObject editText = ...;
editText.setText(Utf7ImeHelper.e("こんにちは!UiAutomatorで入力しています。"));

動作確認について

  • Nexus7 (2013) で動作確認しています。
  • Android4.2.2のエミュレータでも動作しますが、サンプルは動作しません。UiObjectの検索条件を書き換えれば動作します。

さいごに

Uiautomatorで日本語を入力するためのツールを作りました。

IMEとしては、ASCII文字の範囲で任意のUnicodeが表現できれば良いため、BASE64やQuoted-Printableなど、色々な変換方式が考えられたのですが、できるだけASCII文字はそのまま入力できるようにしたかったため、修正UTF-7 (RFC 3501)を採用しました。
修正UTF-7では、&以外のASCII文字であれば、ASCIIそのままの文字を使うことができます。

修正UTF-7をサポートするライブラリには、jutf7を使わせていただきました。

興味がありましたら使ってみてください。コメント歓迎です!

ビルドタイプ・プロダクトフレーバーのカスタマイズ可能項目一覧

はじめに

前回の記事で、ビルドタイプもプロダクトフレーバーも、リソースやソースコードの一部を差し替えることができる点は同じであると書きました。

ビルドタイプでもプロダクトフレーバーでも

src/<ビルドタイプ名 or プロダクトフレーバー名>/

配下にソースコードやリソースの差分を置くことができます。その点では、ビルドタイプとプロダクトフレーバーに機能差分はありません。

ところが、build.gradleでカスタマイズすることのできる項目は、ビルドタイプとプロダクトフレーバーに違いがあります。本家のユーザーガイドの末尾には、

BuildType and Product Flavor property reference

という見出しがあるのですが、現状では「coming soon」としか書かれていません。

そこで、ビルドタイプとプロダクトフレーバーに、それぞれどのような設定項目があるのか調べてみました。

調べ方

ビルドタイプも、プロダクトフレーバーも単なるGroovyクラス(のインスタンス)なので、それぞれのメソッド一覧をリストアップしてみました。リストアップした結果から、以下を取り除いたものを、設定可能項目と推定しています。
実際に設定が反映されるかまでの調査はしていませんので、その点はご注意ください。

  • equalstoStringなどの明らかに無関係なメソッド
  • getからはじまるもの

また、setDebuggabledebuggableのように、プロパティと同名のメソッドが定義されている場合は、後者のみを列挙しています。

調査したプラグインのバージョンは0.5.1です。

参考までに、調査したbuild.gradleファイルの一部を掲載しておきます。

// buildscript { ... } とapplyは省略

android {
    compileSdkVersion 17
    buildToolsVersion "17.0"

    // 適当にプロダクトフレーバーを宣言する
    productFlavors {
        f1 {}
        f2 {}
    }
}

// 「debug」ビルドタイプのインスタンスから、メソッドの一覧を出力する
println "buildTypes = ["
android.buildTypes.debug.metaClass.methods*.name.sort().unique().each {
    println "    ${it}"
}
println "]"

// 「f1」フレーバーのインスタンスから、メソッドの一覧を出力する
println "flavors = ["
android.productFlavors.f1.metaClass.methods*.name.sort().unique().each {
    println "    ${it}"
}
println "]"

カスタマイズ可能項目一覧

上記スクリプトの出力結果を、

  • ビルドタイプ・プロダクトフレーバー共通で利用できるもの
  • ビルドタイプでのみ利用できるもの
  • プロダクトフレーバーでのみ利用できるもの

の順に並べて表にしたものを以下に示します。

メソッド名 Build Type Product Flavor 備考
buildConfig  
proguardFile  
proguardFiles  
signingConfig  
debuggable ×  
init × 通常では利用しない?
initWith × 新規ビルドタイプ生成時に利用
jniDebugBuild ×  
packageNameSuffix × cf. packageName
renderscriptDebugBuild ×  
renderscriptOptimLevel ×  
runProguard ×  
versionNameSuffix × cf. versionName
zipAlign ×  
flavorGroup × 所属するフレーバーグループの宣言に使う
minSdkVersion ×  
packageName × cf. packageNameSuffix
renderscriptTargetApi ×  
targetSdkVersion ×  
testInstrumentationRunner ×  
testPackageName ×  
versionCode ×  
versionName × cf. versionNameSuffix

以上を見てみると、やはり、ビルドタイプは「デバッグ時とリリース時で設定が異なるべきものが設定できる」という位置付けのようです。一方、プロダクトフレーバーでは、minSdkVersionのようにサポートする機種ごとにapkを分けるための仕組みや、フレーバーごとにテスト関連の設定を変更できるような仕組みが用意されていることがわかります。

また、以下の点にも興味を引きました。

  • ProGuard関連や署名関連は、ビルドタイプだけでなく、プロダクトフレーバーでも設定可能。たとえば、有料アプリだけProGuardを有効にしたり、有料アプリと無料アプリで異なる署名をつけることができる。
  • マニフェストに設定するpackageNameversionNameは、ビルドタイプでは接尾辞のみが設定でき、プロダクトフレーバーでは文字列全体のみが設定できる。

まとめ

build.gradle上でカスタマイズ可能な項目について、ビルドタイプとプロダクトフレーバーの違いを列挙してみました。

  • ビルドタイプはデバッグ用(テスト用)とリリース用の違いを設定するために使う
  • その他の違いはプロダクトフレーバーでカスタマイズする

という用途で使い分ければ、カスタマイズしたくなるような項目は大体カバーされているのではないかと思います。

本家ドキュメントの補足として参考にしていただけると嬉しいです。

Android Gradleプラグインにおけるビルドタイプ・フレーバー・フレーバーグループ

Android Gradleプラグインには、「同じような機能を持つけれども少しだけ異なるアプリ」を、便利に開発するための機能が備わっています。
そのためには以下の3つの概念を理解しておく必要があるのですが、すぐに分からなくなってしまうので、ここにメモしておこうと思います。

  • ビルドタイプ
  • フレーバー
  • フレーバーグループ

ビルドタイプ

ビルドタイプは「デバッグ版」「リリース版」を分けるのに使います。デフォルトでは、デバッグ版を表すdebugと、リリース版を表すreleaseの2つのタイプが定義されています。
デバッグ版にはデバッグ署名が付けられ、リリース版には、build.gradleに書いた設定にしたがって、リリース向けの署名が付けられる、といった具合です。

もちろん、必要であれば第3のビルドタイプ、第4のビルドタイプを定義することもできますし、ビルドタイプごとに、ソースコードを差し替えたり、リソースを差し替えたりすることもできます。

なお、デフォルトでは、テストは「デバッグ版」に対してしか実行されません。試験対象を他のビルドタイプに変更することはできますが、複数のビルドタイプを試験対象にすることはできません*1

フレーバー

フレーバーは、アプリケーションの一部の機能や振舞いをカスタマイズするために使います。たとえば「広告あり」フレーバーと「広告なし」フレーバーを定義する、といった具合です。
フレーバーは、build.gradleに定義することではじめて作られますが、1つもフレーバーが定義されていない場合は、暗黙的に名前のない1つのフレーバーが定義されているとみなされます。

ビルドタイプと同様に、フレーバーについても、それぞれソースコードを差し替えたり、リソースを差し替えたりすることができます。

こちらは、ビルドタイプとは異なり、全てのフレーバーが試験対象となります。そのため、テストコードもフレーバーごとに差し替えることが可能になっています。

フレーバーグループ

フレーバーが複数定義されている場合、gradle assembleコマンドによってビルドされるapkは、ビルドタイプの集合とフレーバーの集合の全組み合わせになります。
たとえば、ビルドタイプとフレーバーが、それぞれ以下のように定義されているとします。

  • ビルドタイプ: debug, release
  • フレーバー: f1, f2, fa, fb

この場合にビルドされるapkは、以下の8種類になります。

  • ビルドタイプがdebugで、かつ、フレーバーがf1のもの
  • ビルドタイプがdebugで、かつ、フレーバーがf2のもの
  • ビルドタイプがdebugで、かつ、フレーバーがfaのもの
  • ビルドタイプがdebugで、かつ、フレーバーがfbのもの
  • ビルドタイプがreleaseで、かつ、フレーバーがf1のもの
  • ビルドタイプがreleaseで、かつ、フレーバーがf2のもの
  • ビルドタイプがreleaseで、かつ、フレーバーがfaのもの
  • ビルドタイプがreleaseで、かつ、フレーバーがfbのもの

フレーバーグループは、上記のようなapk生成時の組み合わせを制御するためのもので、フレーバーを複数のグループに分割するために利用します。たとえば、この4つのフレーバーを、以下のように2つのフレーバーグループに所属させてみます。

  • フレーバーグループ1: f1, f2
  • フレーバーグループ2: fa, fb

このようにグループ分けを行うと、ビルドされるapkは、ビルドタイプの集合と、各フレーバーグループの集合の全組み合わせとなります。この場合だと{build, release} × {f1, f2} × {fa, fb}という組み合わせになりますので、以下の8種類のapkがビルドされることになります。

  • ビルドタイプがdebugで、かつ、フレーバーがf1、かつfaのもの
  • ビルドタイプがdebugで、かつ、フレーバーがf1、かつfbのもの
  • ビルドタイプがdebugで、かつ、フレーバーがf2、かつfaのもの
  • ビルドタイプがdebugで、かつ、フレーバーがf2、かつfbのもの
  • ビルドタイプがreleaseで、かつ、フレーバーがf1、かつfaのもの
  • ビルドタイプがreleaseで、かつ、フレーバーがf1、かつfbのもの
  • ビルドタイプがreleaseで、かつ、フレーバーがf2、かつfaのもの
  • ビルドタイプがreleaseで、かつ、フレーバーがf2、かつfbのもの

このように、フレーバーグループを適切に使うことで、いくつもの差分を積み重ねたapkを一気にビルド・テストすることができるようになります。

優先順位

これらの機能を駆使して、差分を積み重ねたapkがビルドできるようになると、各差分に同一の定義がある場合(たとえば同じ名前のstringリソースが、debugf1faに定義されている場合など)に、何が優先されるのか、ということが問題になります。

それもきちんと決まっていて、以下のリストのうち、上にある方がより優先度が高いことになっています。言い換えると、リストの下から順に上書きされていきます。

  • ビルドタイプ
  • フレーバーグループのうち、1つめに宣言したもの
  • フレーバーグループのうち、2つめに宣言したもの
  • ...

このように、フレーバーグループの宣言順序によって優先度が決まってしまうので、フレーバーグループの宣言には気を使う必要がありあます。なお、フレーバーグループの宣言は、以下のような感じで行います。

android {
    ...
    // "group1"の方が"group2"より優先度が高い
    flavorGroups "group1", "group2"
    ...
}

まとめ

特に分かりにくいと思われる点をざっくりまとめてみます。

  • ビルドタイプもフレーバーも、ソースコードやリソースの一部を差し替えることが出来る、という点では同じですが、ビルドタイプは1つしかテスト対象にできない点に注意が必要です。
  • ビルドされるapkは、ビルドタイプとフレーバーの組み合わせになりますが、フレーバーグループを定義することで、組み合わせ方法を制御することができます。
  • フレーバーグループの宣言順序によって、差分適用の優先度が変化することに注意が必要です。

*1:adt-devのやりとりによると、近い将来にこの制限は撤廃されそうな雰囲気です。

Androidのテスト実行も含めてEclipseとGradleを共存させてみる

Androidの新しいビルドシステムで使われているgradleのプラグインは、同じ機能を持つけど少しだけソースが異なるアプリ(フレーバー)を一度にビルド・テストできるなど、Eclipse+ADTやantには無かった素晴しい機能を持っています。

ただ、その機能を享受するためには、従来のAndroid開発とはかけ離れたディレクトリ構成にしなければならず、Eclipse+ADTで開発をしつつ、gradleも使ってみるためには工夫が必要です。

アプリケーションの開発に限って言えば、Eclipse+ADTと共存する方法は、本家のドキュメントや、日経ソフトウェア2013年6月号の「特集4  Androidに新ビルド ・ ツール現る」などに載っているのですが、テストも共存させるための情報は見付けられませんでした。

後者の記事をヒントに、色々と試行錯誤した結果、うまく行く方法を見付けたので、紹介したいと思います。

Eclipseでプロジェクトを作る

まず、Eclipseのウィザードで、アプリケーションプロジェクトと、そのアプリケーションに対するテストプロジェクトを作ります。こんな感じになると思います。
f:id:sumio_tym:20130623220104p:plain

ここでは、アプリケーションプロジェクトの名前を「MyApplication」に、テストプロジェクトの名前を「MyApplicationTest」にしてみました。

もちろん、既に開発中のプロジェクトを利用することもできます。その時は「MyApplication」「MyApplicationTest」を、実際のプロジェクト名に読み替えてください。

作ったら、とりあえずワークスペースから削除してしまいます(ファイルは削除してはいけません)。

ディレクトリ構成をgradle対応にする

適当な場所に

MyApplicationGradle/src

ディレクトリを作成してから、今作成したsrcディレクトリの下に、Eclipseで作成した2つのプロジェクトをコピーします。
この状態で、アプリケーションプロジェクトのディレクトリ名を「main」に、テストプロジェクトのディレクトリ名を「instrumentTest」に変更します。

f:id:sumio_tym:20130623220747p:plain

ここまで出来たら、srcディレクトリ配下をEclipseでインポートしてください。MyApplicationプロジェクトとMyApplicationTestプロジェクトが復活したはずです。これで、Eclipse側ではいつも通り開発することができます。もちろんテスト結果もEcliseのGUIで確認できます。

f:id:sumio_tym:20130623223500p:plain

build.gradleを作る

MyApplicationGradleディレクトリの直下に、以下のようなbuild.graldeファイルを作って保存します。

buildscript {
    repositories {
        mavenCentral()
    }
    dependencies {
        classpath 'com.android.tools.build:gradle:0.4.2'
    }
}
apply plugin: 'android'

android {
    compileSdkVersion 17
    buildToolsVersion "17.0"
}

android.sourceSets.each {
    it.java.srcDirs = [ "src/${it.name}/src" ]
}

これで、gradleでもビルドすることができるようになりました。以下のコマンドで、アプリケーションのビルドとテストを実行できます。複数個の端末が接続されている場合は、全端末でテストが実行されますのでとても便利です。

gradle connectedCheck

なお、テストの実行結果は以下のディレクトリにHTMLとして保存されます。

build/reports/instrumentTests/connected/

まとめと課題

この方法を使えば、少なくとも「main」と「instrumentTest」のソースセットについては、Eclipseで開発とテストを継続することができそうです。

もちろん、フレーバーを追加しはじめると色々と課題がでてきますが、上記構成はAndroid Studioへもそのままインポートできますので、別のフレーバーについて開発する時だけはAndroidStudioを使う、というのも手かもしれません。

しかしながら、Eclipseからはフレーバーを認識しませんので、「main」や「instrumentTest」のソースセットだけでコンパイルエラーが出ないように注意する必要があります。たとえば、フレーバーごとに差し替えたいクラスが有る場合は「片方のフレーバーだけlinked sourceとしてEclipseに登録しておく」という手が使えそうです。

ただ、まだ試していないことも多いので、どこまでこの構成で耐えられるのかは分かりません。たとえば、

  • リソースをフレーバーごとに変えたい場合
  • マニフェストをフレーバーごとに変えたい場合

など、です。これから色々なパターンを試してみたいと思います。

追記

上記build.gradleの末尾に、以下のようなコードを追加することで、プロジェクト名を変更しなくても良いことが分かりました。コメント下さった id:kimukou_26 さん、どうもありがとうございました。

// 'MyApplication' や 'MyApplicationTest' は正しいディレクトリ名にしてください。
[main:'MyApplication', instrumentTest:'MyApplicationTest'].each { key, value ->
    android.sourceSets[key].setRoot("src/${value}")
}