2013年11月26日火曜日

Espresso で EditTextPreference に文字を入力する方法

Espresso で EditText に日本語を入力する方法」と「Espresso で Preference をクリックさせる」の応用です。

EditTextPreference はダイアログ内の EditText をコードから生成し、Id として com.android.internal.R.id.edit をセットしています。

http://tools.oesf.biz/android-4.4.0_r1.0/xref/frameworks/base/core/java/android/preference/EditTextPreference.java#53 45 public class EditTextPreference extends DialogPreference { 46 /** 47 * The edit text shown in the dialog. 48 */ 49 private EditText mEditText; 50 51 private String mText; 52 53 public EditTextPreference(Context context, AttributeSet attrs, int defStyle) { 54 super(context, attrs, defStyle); 55 56 mEditText = new EditText(context, attrs); 57 58 // Give it an ID so it can be saved/restored 59 mEditText.setId(com.android.internal.R.id.edit); 60 61 /* 62 * The preference framework and view framework both have an 'enabled' 63 * attribute. Most likely, the 'enabled' specified in this XML is for 64 * the preference framework, but it was also given to the view framework. 65 * We reset the enabled state. 66 */ 67 mEditText.setEnabled(true); 68 } internal なので Id を指定する方法は使えません。そこで、isAssignableFrom()を使います。(withClassName()でも実現できます) public void testEditTextPreference() { // EditTextPreference をクリック onData(withPreferenceKey("edit_text_pref1")).perform(click()); onView(isAssignableFrom(EditText.class)).perform(clearText(), new InputTextAction("エスプレッソよりカフェラテ派")); onView(withText("OK")).perform(click()); // 例えば入力値が summary に表示されるような実装になっているなら、それをチェックできる onData(withPreferenceKey("edit_text_pref1")) .onChildView(withId(android.R.id.summary)) .check(matches(withText("エスプレッソよりカフェラテ派"))); }


Espresso で EditText に日本語を入力する方法

Espresso には、テキストをタイプする ViewActions.typeText() というメソッドが用意されています。
このメソッドは、引数で渡されたテキストの各文字に対応する KeyCode を入力するものです。
そのため、typeText("日本語") としても"日本語"は入力されませんし、ソフトキーボードが日本語キーボードのときに typeText("andoroido") とすると、"あんどろいど" と入力されます。
また困ったことに、ソフトキーボードが日本語キーボードのときに typeText("12345") とすると、全角で入力されます。orz


日本語を入力するには、setText() で直接セットするしかありません。
そのための ViewAction を実装したクラスを用意しました。

public void testInputJapanese() { onView(R.id.editText1).perform(clearText(), new InputTextAction("日本語")); onView(R.id.editText1).check(matches(withText("日本語"))); } public final class InputTextAction implements ViewAction { private final String mText; public InputTextAction(String text) { checkNotNull(text); mText = text; } @SuppressWarnings("unchecked") @Override public Matcher getConstraints() { return allOf(isDisplayed(), isAssignableFrom(EditText.class)); } @Override public void perform(UiController uiController, View view) { ((EditText) view).setText(mText); } @Override public String getDescription() { return "set text"; } }


ちなみに typeText() の実体は TypeTextAction クラスです。

ViewActions.java public final class ViewActions { ... public static ViewAction typeText(String stringToBeTyped) { return new TypeTextAction(stringToBeTyped); } } こちらでは UiController の injectString() を利用しています。また、SearchView にも入力できるみたいです。

TypeTextAction.java public final class TypeTextAction implements ViewAction { private static final String TAG = TypeTextAction.class.getSimpleName(); private final String stringToBeTyped; /** * Constructs {@link TypeTextAction} with given string. If the string is empty it results in no-op * (nothing is typed). * * @param stringToBeTyped String To be typed by {@link TypeTextAction} */ public TypeTextAction(String stringToBeTyped) { checkNotNull(stringToBeTyped); this.stringToBeTyped = stringToBeTyped; } @SuppressWarnings("unchecked") @Override public Matcher getConstraints() { Matcher matchers = allOf(isDisplayed()); if (Build.VERSION.SDK_INT < Build.VERSION_CODES.HONEYCOMB) { return allOf(matchers, supportsInputMethods()); } else { // SearchView does not support input methods itself (rather it delegates to an internal text // view for input). return allOf(matchers, anyOf(supportsInputMethods(), isAssignableFrom(SearchView.class))); } } @Override public void perform(UiController uiController, View view) { // No-op if string is empty. if (stringToBeTyped.length() == 0) { Log.w(TAG, "Supplied string is empty resulting in no-op (nothing is typed)."); return; } // Perform a click. new GeneralClickAction(Tap.SINGLE, GeneralLocation.CENTER, Press.PINPOINT) .perform(uiController, view); uiController.loopMainThreadUntilIdle(); try { if (!uiController.injectString(stringToBeTyped)) { Log.e(TAG, "Failed to type text: " + stringToBeTyped); throw new PerformException.Builder() .withActionDescription(this.getDescription()) .withViewDescription(HumanReadables.describe(view)) .withCause(new RuntimeException("Failed to type text: " + stringToBeTyped)) .build(); } } catch (InjectEventSecurityException e) { Log.e(TAG, "Failed to type text: " + stringToBeTyped); throw new PerformException.Builder() .withActionDescription(this.getDescription()) .withViewDescription(HumanReadables.describe(view)) .withCause(e) .build(); } } @Override public String getDescription() { return "type text"; } } UiController はインタフェースで、実装クラスは UiControllerImpl.java です。

UiControllerImpl.java @Override public boolean injectString(String str) throws InjectEventSecurityException { checkNotNull(str); checkState(Looper.myLooper() == mainLooper, "Expecting to be on main thread!"); initialize(); // No-op if string is empty. if (str.length() == 0) { Log.w(TAG, "Supplied string is empty resulting in no-op (nothing is typed)."); return true; } boolean eventInjected = false; KeyCharacterMap keyCharacterMap = getKeyCharacterMap(); // TODO(user): Investigate why not use (as suggested in javadoc of keyCharacterMap.getEvents): // http://developer.android.com/reference/android/view/KeyEvent.html#KeyEvent(long, // java.lang.String, int, int) KeyEvent[] events = keyCharacterMap.getEvents(str.toCharArray()); checkNotNull(events, "Failed to get events for string " + str); Log.d(TAG, String.format("Injecting string: \"%s\"", str)); for (KeyEvent event : events) { checkNotNull(event, String.format("Failed to get event for character (%c) with key code (%s)", event.getKeyCode(), event.getUnicodeChar())); eventInjected = false; for (int attempts = 0; !eventInjected && attempts < 4; attempts++) { attempts++; // We have to change the time of an event before injecting it because // all KeyEvents returned by KeyCharacterMap.getEvents() have the same // time stamp and the system rejects too old events. Hence, it is // possible for an event to become stale before it is injected if it // takes too long to inject the preceding ones. event = KeyEvent.changeTimeRepeat(event, SystemClock.uptimeMillis(), 0); eventInjected = injectKeyEvent(event); } if (!eventInjected) { Log.e(TAG, String.format("Failed to inject event for character (%c) with key code (%s)", event.getUnicodeChar(), event.getKeyCode())); break; } } return eventInjected; }


Espresso で Preference をクリックさせる

Matcher 書かないとダメっぽかったです。
PreferenceMatcher が用意されていたので利用しました。
import static com.google.common.base.Preconditions.checkNotNull; ... public class EspressoTest extends ActivityInstrumentationTestCase2<MainPreferenceActivity> { public EspressoTest() { super(MainPreferenceActivity.class); } @Override public void setUp() throws Exception { super.setUp(); // Espresso will not launch our activity for us, we must launch it via // getActivity(). getActivity(); } // Preference のキーを指定して、対応するビューをクリックする public void testPreference() { onData(withPreferenceKey("pref-key")).perform(click()); } public static Matcher<Object> withPreferenceKey(final Matcher<Preference> preferenceMatcher) { checkNotNull(preferenceMatcher); return new BoundedMatcher<Object, Preference>(Preference.class) { @Override public void describeTo(Description description) { description.appendText("with preference key: "); preferenceMatcher.describeTo(description); } @Override protected boolean matchesSafely(Preference pref) { return preferenceMatcher.matches(pref); } }; } public static Matcher<Object> withPreferenceKey(String expectedText) { checkNotNull(expectedText); return withPreferenceKey(PreferenceMatchers.withKey(expectedText)); } }

応用で、Preference をクリック → なんかする → summary が適切な値になっていることをチェック public void testPreference() { // Preference をクリック onData(withPreferenceKey("pref-key")).perform(click()); // クリック先でごにょごにょ // summary が適切な値になっていることをチェック // summary の Id が android.R.id.summary であることを利用 onData(withPreferenceKey("pref-key")) .onChildView(withId(android.R.id.summary)) .check(matches(withText("correct summary value"))); }


2013年11月21日木曜日

Android で mockito を使う : すでにあるインスタンスから mock を作る

忘れるので、メモ。

Mockito.spy() を使う。

mockito と Espresso を組み合わせて使う場合、ActivityInstrumentationTestCase2 の setActivity() を Override してモック化した Activity を super.setActivity() に渡すようにしても、UIの操作が実際に行われるのはモック化前の生のActivityインスタンスに対してでした。 なので、ActivityInstrumentationTestCase2 の対象の Activity のメソッド呼び出しを mockito で検証するのは無理っぽいです。。。残念。。。


Android UI Testing framework の Espresso を使う

とりあえず、android-test-kit : Espresso の動画を見ましょう。

以下では Eclipse での設定方法を紹介します。
Android Studio での設定方法は Espresso のプロジェクトページ(上記のリンク)にあるので読んでください。

1. Developer options の設定

アニメーションを Off にしましょう。

設定(Settings) → 開発者向けオプション(Developer options)→
以下の3つを全て「アニメーションオフ(Animation off)」にする
  • ウィンドウアニメスケール (Window animation scale)
  • トランジションアニメスケール(Transition animation scale)
  • Animator再生時間スケール(Animator duration scale)


コードからやる方法


2. Espresso をテストプロジェクトに追加する

Espresso には、依存ライブラリとかも含めて1つの jar になっている standalone 版と、依存ライブラリが別になっている dependencies 版があります。

mockito と一緒に使う場合は、hamcrest がかぶってエラーになるので、dependencies 版を使います。

standalone 版を使う場合:git clone するなり、zip をダウンロードするなりして、 espresso-1.0-SNAPSHOT-bundled.jar を取得して、テストプロジェクトの libs フォルダに追加します。

dependencies 版を使う場合: dependencies 版 にある jar を全部 libs フォルダに入れます。
mockito (mockito-all-1.9.5.jar) と一緒に使う場合は、hamcrest-core-1.1.jar と hamcrest-integration-1.1.jar は libs に入れないでください。



テストプロジェクトの AndroidManifest.xml に <instrumentation android:name="com.google.android.apps.common.testing.testrunner.GoogleInstrumentationTestRunner" android:targetPackage="$YOUR_TARGET_APP_PACKAGE"/> を追加します。 AndroidManifest.xml の例 <?xml version="1.0" encoding="utf-8"?> <manifest xmlns:android="http://schemas.android.com/apk/res/android" package="com.example.espresso.test" android:versionCode="1" android:versionName="1.0" > <uses-sdk android:minSdkVersion="8" android:targetSdkVersion="19" /> <application> <uses-library android:name="android.test.runner" /> </application> <instrumentation android:name="com.google.android.apps.common.testing.testrunner.GoogleInstrumentationTestRunner" android:targetPackage="com.example.espresso" /> </manifest> ウィザードから Android Test Project を作ると、android.test.InstrumentationTestRunner の instrumentation タグが作られますが、消しても大丈夫です。 <instrumentation android:name="android.test.InstrumentationTestRunner" android:targetPackage="com.example.espresso" />

GoogleInstrumentationTestRunner を介してテストが走るように Eclipse を設定します。
Eclipse の [Run] - [Run Configurations...]を選択



Run all tests in the selected project, or package にチェックして、 Instrumetation runner: に com.google.android.apps.common.testing.testrunner.GoogleInstrumentationTestRunner を選択して Apply をクリックします。



* クラス単体を対象とした場合(Run a single Test をチェック)、Instrumetation runner: に com.google.android.apps.common.testing.testrunner.GoogleInstrumentationTestRunner を選択すると、 The instrumentation runner must be of type android.test.InstrumentationTestRunner とエラーが出て怒られます。
Espresso ではクラス単体でテストを走らせることはできないってことなのかしら?

Espresso はいくつかの解析データを収集しています。
収集されたくない場合は、disableAnalytics という引数に true を指定して GoogleInstrumentationTestRunner に渡すことでオプトアウトすることができるとドキュメントには書いてあるのですが、方法がよくわかりませんでした。。。


3. Espresso を使う

例として、ログイン画面(MainActivity)でIDとパスワードを入力してボタンを押すと、Welcome画面(MainActivity2)に遷移するアプリを用意しました。 public class MainActivity extends Activity { @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); final EditText idView = (EditText) findViewById(R.id.editText1); final EditText passView = (EditText) findViewById(R.id.editText2); final TextView statusView = (TextView) findViewById(R.id.textView3); findViewById(R.id.button1).setOnClickListener(new View.OnClickListener() { @Override public void onClick(View v) { statusView.setText(""); String id = idView.getText().toString(); if (TextUtils.isEmpty(id)) { statusView.setText("IDが入力されていません"); return; } String pass = passView.getText().toString(); if (TextUtils.isEmpty(pass)) { statusView.setText("Passwordが入力されていません"); return; } if (check(id, pass)) { Intent intent = new Intent(MainActivity.this, MainActivity2.class); intent.putExtra("id", id); startActivity(intent); } else { statusView.setText("IDとPasswordの組み合わせが違います"); } } }); } boolean check(String id, String pass) { // dummy return true; }; } ログイン画面(MainActivity)では、IDやパスワードが空の場合はステータス用のTextViewにメッセージが表示されます。
つまり
・ID入力用のEditTExtが空のときにステータス用のTextViewにメッセージが表示されるか
・Password入力用のEditTextが空のときにステータス用のTextViewにメッセージが表示されるか
をテストできます。



ログインできる場合は、Intentのextraにidを入れて、Welcome画面(MainActivity2)を開いています。 public class MainActivity2 extends Activity { @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main2); String id = getIntent().getStringExtra("id"); TextView tv = (TextView) findViewById(R.id.textView1); tv.setText("ようこそ" + id + "さん"); } } Welcome画面(MainActivity2)では、渡されたidをTextView に表示しています。
ここでは、
・ログイン画面で入力されたIDがWelcome画面に表示されるか
をテストできます。




では、テストクラスを作っていきます。

Espresso, ViewActions, ViewMatchers, ViewAssertions, Matchers などの主要 static メソッドを import static で定義しておきましょう。
Espresso のドキュメントに載っているサンプルコードはみな import static した後のコードです。そのことを知ってないとコードをみてもよくわからないでしょう。
ドキュメントのコードをコピペするときにも不便なので、以下の import static をテストクラスにコピペしておきましょう。

import static com.google.android.apps.common.testing.ui.espresso.Espresso.onData; import static com.google.android.apps.common.testing.ui.espresso.Espresso.onView; import static com.google.android.apps.common.testing.ui.espresso.Espresso.pressBack; import static com.google.android.apps.common.testing.ui.espresso.action.ViewActions.click; import static com.google.android.apps.common.testing.ui.espresso.action.ViewActions.closeSoftKeyboard; import static com.google.android.apps.common.testing.ui.espresso.action.ViewActions.typeText; import static com.google.android.apps.common.testing.ui.espresso.assertion.ViewAssertions.matches; import static com.google.android.apps.common.testing.ui.espresso.matcher.ViewMatchers.withId; import static com.google.android.apps.common.testing.ui.espresso.matcher.ViewMatchers.withText; import static org.hamcrest.Matchers.allOf; import static org.hamcrest.Matchers.containsString; import static org.hamcrest.Matchers.instanceOf; import static org.hamcrest.Matchers.is;

Espresso は Activity を起動してくれないので、setUp() で getActivity() を呼んで Activity を起動する必要があります。

package com.example.espresso; import static com.google.android.apps.common.testing.ui.espresso.Espresso.onView; import static com.google.android.apps.common.testing.ui.espresso.action.ViewActions.click; import static com.google.android.apps.common.testing.ui.espresso.action.ViewActions.typeText; import static com.google.android.apps.common.testing.ui.espresso.assertion.ViewAssertions.matches; import static com.google.android.apps.common.testing.ui.espresso.matcher.ViewMatchers.withId; import static com.google.android.apps.common.testing.ui.espresso.matcher.ViewMatchers.withText; import android.test.ActivityInstrumentationTestCase2; public class MainActivityTest extends ActivityInstrumentationTestCase2<MainActivity> { public MainActivityTest() { super(MainActivity.class); } @Override public void setUp() throws Exception { super.setUp(); // Espresso will not launch our activity for us, we must launch it via // getActivity(). getActivity(); } public void testEmptyId() { // IDを空のままログインボタンをクリック onView(withId(R.id.button1)).perform(click()); // ステータス用のTextViewにメッセージが表示されるかチェック onView(withId(R.id.textView3)).check(matches(withText("IDが入力されていません"))); } public void testEmptyPassword() { // IDを入力 onView(withId(R.id.editText1)).perform(typeText("yanzm")); // Passwordを空のままログインボタンをクリック onView(withId(R.id.button1)).perform(click()); // ステータス用のTextViewにメッセージが表示されるかチェック onView(withId(R.id.textView3)).check(matches(withText("Passwordが入力されていません"))); } public void testLogin() { // IDを入力 onView(withId(R.id.editText1)).perform(typeText("yanzm")); // Passwordを入力 onView(withId(R.id.editText2)).perform(typeText("1234567890")); // ログインボタンをクリック onView(withId(R.id.button1)).perform(click()); // Welcome画面に表示されるかチェック onView(withId(R.id.textView1)).check(matches(withText("ようこそyanzmさん"))); } }

こんな感じです。

他にも、ListView や Spinner などの特定の行の View を指定するために使う onData() などがあります。


参考




2013年11月20日水曜日

Device Art Generator ではめ込み画像を作る

Android Developers に Device Art Generator という便利なものがあることに最近気づきました。



デイバス名の下にはめ込む画像のピクセルサイズが書かれています。
このサイズの画像をデバイス画像のところにドラッグ&ドロップします。
(サイズの違う画像だと怒られます)

・影
・画面の光沢

の有り無しを設定できるほか、回転させることもできます。

影あり・画面光沢あり


影なし・画面光沢あり


影なし・画面光沢なし


回転





2013年11月19日火曜日

Android Facebook SDK で share する

Facebook アプリは ACTION_SEND を受けとるくせに、Facebook に投稿してくれません。
ひどいです。ちゃんと処理しないなら ACTION_SEND 受け取らないでほしいです。。。

Facebook に投稿したければ Facebook SDK 使えよ、ということだそうです。
でもドキュメントがわちゃーでわかりにくかったので、自分ためにメモっておきます。



1. Facebook Apps を作る

https://developers.facebook.com/apps


右上の + Create New App から





2. Debug key の key hash を登録する

Debug key のパスワードは android
$[ keytool -exportcert -alias androiddebugkey -keystore ~/.android/debug.keystore | openssl sha1 -binary | openssl base64
Enter keystore password:  android
pnw+gKvPF3Y+pP9nbguTOPw3s1g=




3. Facebook SDK for Android をダウンロードして展開する

https://developers.facebook.com/docs/android/

現在は v3.5.2





4. facebook-android-sdk-3.5.2/facebook/ を指定してインポートする



FacebookSDK の libs に含まれる android-support-v4.jar が古いので、新しいので上書きする。



5. 1.で指定したパッケージ名のアプリと Activity を作る





6. App ID を AndroidManifest.xml に宣言する

res/values/strings.xml APP_ID AndroidManifest.xml <?xml version="1.0" encoding="utf-8"?> <manifest xmlns:android="http://schemas.android.com/apk/res/android" ... > ... <application ... > ... <meta-data android:value="@string/app_id" android:name="com.facebook.sdk.ApplicationId"/> </application> </manifest>

7. FacebookSDK のライブラリプロジェクトを追加する





8. Activity に Share を実装する

ShareDialogBuilder を使う public class MainActivity extends Activity { private UiLifecycleHelper uiHelper; @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); uiHelper = new UiLifecycleHelper(this, new Session.StatusCallback() { @Override public void call(Session session, SessionState state, Exception exception) { Log.i("Activity", "SessionState : " + state); } }); uiHelper.onCreate(savedInstanceState); findViewById(R.id.button1).setOnClickListener(new View.OnClickListener() { @Override public void onClick(View v) { share(); } }); } private void share() { if (FacebookDialog.canPresentShareDialog(getApplicationContext(), FacebookDialog.ShareDialogFeature.SHARE_DIALOG)) { try { String name = "名前"; String url = "http://developer.android.com"; String description = "詳細"; // Fragment で発行するときは setFragment() を呼ぶ FacebookDialog shareDialog = new FacebookDialog.ShareDialogBuilder(this).setDescription(description) .setName(name).setLink(url).build(); uiHelper.trackPendingDialogCall(shareDialog.present()); } catch (FacebookException e) { Toast.makeText(this, "Facebook でエラーが発生しました。", Toast.LENGTH_SHORT).show(); } } } @Override protected void onResume() { super.onResume(); uiHelper.onResume(); } @Override protected void onSaveInstanceState(Bundle outState) { super.onSaveInstanceState(outState); uiHelper.onSaveInstanceState(outState); } @Override public void onPause() { super.onPause(); uiHelper.onPause(); } @Override public void onDestroy() { super.onDestroy(); uiHelper.onDestroy(); } @Override protected void onActivityResult(int requestCode, int resultCode, Intent data) { super.onActivityResult(requestCode, resultCode, data); uiHelper.onActivityResult(requestCode, resultCode, data, new FacebookDialog.Callback() { @Override public void onError(FacebookDialog.PendingCall pendingCall, Exception error, Bundle data) { Log.e("Activity", String.format("Error: %s", error.toString())); } @Override public void onComplete(FacebookDialog.PendingCall pendingCall, Bundle data) { Log.i("Activity", "Success!"); } }); } }



9. Release Key の key hash を登録する

keytool -exportcert -alias <RELEASE_KEY_ALIAS> -keystore <RELEASE_KEY_PATH> | openssl sha1 -binary | openssl base64


10. Facebook Apps の設定の Sandbox Mode を Disabled にする







参考



Android Volley の NetworkImageView で Bitmap の最大サイズを指定する

Volley の NetworkImageView は便利なのですが、Bitmap のサイズを最適化してくれません。

NetworkImageView で画像のダウンロードを開始するのが loadImageIfNecessary() です。

https://android.googlesource.com/platform/frameworks/volley/+/master/src/com/android/volley/toolbox/NetworkImageView.java public class NetworkImageView extends ImageView { ... /** Local copy of the ImageLoader. */ private ImageLoader mImageLoader; ... public void setImageUrl(String url, ImageLoader imageLoader) { mUrl = url; mImageLoader = imageLoader; // The URL has potentially changed. See if we need to load it. loadImageIfNecessary(false); } ... /** * Loads the image for the view if it isn't already loaded. * @param isInLayoutPass True if this was invoked from a layout pass, false otherwise. */ private void loadImageIfNecessary(final boolean isInLayoutPass) { ... // The pre-existing content of this view didn't match the current URL. Load the new image // from the network. ImageContainer newContainer = mImageLoader.get(mUrl, new ImageListener() { @Override public void onErrorResponse(VolleyError error) { if (mErrorImageId != 0) { setImageResource(mErrorImageId); } } @Override public void onResponse(final ImageContainer response, boolean isImmediate) { // If this was an immediate response that was delivered inside of a layout // pass do not set the image immediately as it will trigger a requestLayout // inside of a layout. Instead, defer setting the image by posting back to // the main thread. if (isImmediate && isInLayoutPass) { post(new Runnable() { @Override public void run() { onResponse(response, false); } }); return; } if (response.getBitmap() != null) { setImageBitmap(response.getBitmap()); } else if (mDefaultImageId != 0) { setImageResource(mDefaultImageId); } } }); // update the ImageContainer to be the new bitmap container. mImageContainer = newContainer; } ... } ここで ImageLoader の get(url, imageListener) を呼んでいます。
ImageLoader には引数が4つの get(url, imageLoader, maxWidth, maxHeight) もあり、引数が2つの get() を呼んだ場合は、maxWidth, maxHeight には 0 が渡され、生成される Bitmap は実際の画像サイズになります。 public class ImageLoader { ... public ImageContainer get(String requestUrl, final ImageListener listener) { return get(requestUrl, listener, 0, 0); } public ImageContainer get(String requestUrl, ImageListener imageListener, int maxWidth, int maxHeight) { // only fulfill requests that were initiated from the main thread. throwIfNotOnMainThread(); final String cacheKey = getCacheKey(requestUrl, maxWidth, maxHeight); // Try to look up the request in the cache of remote images. Bitmap cachedBitmap = mCache.getBitmap(cacheKey); if (cachedBitmap != null) { // Return the cached bitmap. ImageContainer container = new ImageContainer(cachedBitmap, requestUrl, null, null); imageListener.onResponse(container, true); return container; } // The bitmap did not exist in the cache, fetch it! ImageContainer imageContainer = new ImageContainer(null, requestUrl, cacheKey, imageListener); // Update the caller to let them know that they should use the default bitmap. imageListener.onResponse(imageContainer, true); // Check to see if a request is already in-flight. BatchedImageRequest request = mInFlightRequests.get(cacheKey); if (request != null) { // If it is, add this request to the list of listeners. request.addContainer(imageContainer); return imageContainer; } // The request is not already in flight. Send the new request to the network and // track it. Request<?> newRequest = new ImageRequest(requestUrl, new Listener<Bitmap>() { @Override public void onResponse(Bitmap response) { onGetImageSuccess(cacheKey, response); } }, maxWidth, maxHeight, Config.RGB_565, new ErrorListener() { @Override public void onErrorResponse(VolleyError error) { onGetImageError(cacheKey, error); } }); mRequestQueue.add(newRequest); mInFlightRequests.put(cacheKey, new BatchedImageRequest(newRequest, imageContainer)); return imageContainer; } } maxWidth と maxHeight は ImageRequest のコンストラクタに渡されています。 public class ImageRequest extends Request<Bitmap> { ... private final int mMaxWidth; private final int mMaxHeight; ... public ImageRequest(String url, Response.Listener<Bitmap> listener, int maxWidth, int maxHeight, Config decodeConfig, Response.ErrorListener errorListener) { super(Method.GET, url, errorListener); setRetryPolicy( new DefaultRetryPolicy(IMAGE_TIMEOUT_MS, IMAGE_MAX_RETRIES, IMAGE_BACKOFF_MULT)); mListener = listener; mDecodeConfig = decodeConfig; mMaxWidth = maxWidth; mMaxHeight = maxHeight; } ... /** * The real guts of parseNetworkResponse. Broken out for readability. */ private Response<Bitmap> doParse(NetworkResponse response) { byte[] data = response.data; BitmapFactory.Options decodeOptions = new BitmapFactory.Options(); Bitmap bitmap = null; if (mMaxWidth == 0 && mMaxHeight == 0) { decodeOptions.inPreferredConfig = mDecodeConfig; bitmap = BitmapFactory.decodeByteArray(data, 0, data.length, decodeOptions); } else { // If we have to resize this image, first get the natural bounds. decodeOptions.inJustDecodeBounds = true; BitmapFactory.decodeByteArray(data, 0, data.length, decodeOptions); int actualWidth = decodeOptions.outWidth; int actualHeight = decodeOptions.outHeight; // Then compute the dimensions we would ideally like to decode to. int desiredWidth = getResizedDimension(mMaxWidth, mMaxHeight, actualWidth, actualHeight); int desiredHeight = getResizedDimension(mMaxHeight, mMaxWidth, actualHeight, actualWidth); // Decode to the nearest power of two scaling factor. decodeOptions.inJustDecodeBounds = false; // TODO(ficus): Do we need this or is it okay since API 8 doesn't support it? // decodeOptions.inPreferQualityOverSpeed = PREFER_QUALITY_OVER_SPEED; decodeOptions.inSampleSize = findBestSampleSize(actualWidth, actualHeight, desiredWidth, desiredHeight); Bitmap tempBitmap = BitmapFactory.decodeByteArray(data, 0, data.length, decodeOptions); // If necessary, scale down to the maximal acceptable size. if (tempBitmap != null && (tempBitmap.getWidth() > desiredWidth || tempBitmap.getHeight() > desiredHeight)) { bitmap = Bitmap.createScaledBitmap(tempBitmap, desiredWidth, desiredHeight, true); tempBitmap.recycle(); } else { bitmap = tempBitmap; } } if (bitmap == null) { return Response.error(new ParseError(response)); } else { return Response.success(bitmap, HttpHeaderParser.parseCacheHeaders(response)); } } } ImageRequest の doParse() で、mMaxWidth == 0 && mMaxHeight == 0 のときはバイト配列をそのまま Bitmap にしているのがわかりますね。
それ以外のときは BitmapFactory.Options の inJustDecodeBounds や inSampleSize を使って Bitmap をスケールしています。

以下では、View のサイズがわかっている場合はそのサイズを使い、わからないときは画面サイズを指定するようにしてみました。 public class NetworkImageView extends ImageView { ... private void loadImageIfNecessary(final boolean isInLayoutPass) { int width = getWidth(); int height = getHeight(); ... DisplayMetrics metrics = getResources().getDisplayMetrics(); int w = width > 0 ? width : metrics.widthPixels; int h = height > 0 ? height : metrics.heightPixels; // The pre-existing content of this view didn't match the current URL. Load the new image // from the network. ImageContainer newContainer = mImageLoader.get(mUrl, new ImageListener() { @Override public void onErrorResponse(VolleyError error) { if (mErrorImageId != 0) { setImageResource(mErrorImageId); } } @Override public void onResponse(final ImageContainer response, boolean isImmediate) { // If this was an immediate response that was delivered inside of a layout // pass do not set the image immediately as it will trigger a requestLayout // inside of a layout. Instead, defer setting the image by posting back to // the main thread. if (isImmediate && isInLayoutPass) { post(new Runnable() { @Override public void run() { onResponse(response, false); } }); return; } if (response.getBitmap() != null) { setImageBitmap(response.getBitmap()); } else if (mDefaultImageId != 0) { setImageResource(mDefaultImageId); } } }, w, h); // update the ImageContainer to be the new bitmap container. mImageContainer = newContainer; } }



Volley で大きい画像を処理してはいけない

Google I/O 2013 のセッションでも言われてましたね。

ネットワークのレスポンスは com.android.volley.toolbox.BasicNetwork の performRequest() で処理されて、entity は entityToBytes() で一旦バイト配列に格納されます。

https://android.googlesource.com/platform/frameworks/volley/+/master/src/com/android/volley/toolbox/BasicNetwork.java @Override public NetworkResponse performRequest(Request<?> request) throws VolleyError { ... // Some responses such as 204s do not have content. We must check. if (httpResponse.getEntity() != null) { responseContents = entityToBytes(httpResponse.getEntity()); } else { // Add 0 byte response as a way of honestly representing a // no-content request. responseContents = new byte[0]; } ... } ... /** Reads the contents of HttpEntity into a byte[]. */ private byte[] entityToBytes(HttpEntity entity) throws IOException, ServerError { PoolingByteArrayOutputStream bytes = new PoolingByteArrayOutputStream(mPool, (int) entity.getContentLength()); byte[] buffer = null; try { InputStream in = entity.getContent(); if (in == null) { throw new ServerError(); } buffer = mPool.getBuf(1024); int count; while ((count = in.read(buffer)) != -1) { bytes.write(buffer, 0, count); } return bytes.toByteArray(); } finally { try { // Close the InputStream and release the resources by "consuming the content". entity.consumeContent(); } catch (IOException e) { // This can happen if there was an exception above that left the entity in // an invalid state. VolleyLog.v("Error occured when calling consumingContent"); } mPool.returnBuf(buffer); bytes.close(); } } entityToBytes() では、PoolingByteArrayOutputStream の write() を呼んでいます。 https://android.googlesource.com/platform/frameworks/volley/+/master/src/com/android/volley/toolbox/PoolingByteArrayOutputStream.java public class PoolingByteArrayOutputStream extends ByteArrayOutputStream { ... /** * Ensures there is enough space in the buffer for the given number of additional bytes. */ private void expand(int i) { /* Can the buffer handle @i more bytes, if not expand it */ if (count + i <= buf.length) { return; } byte[] newbuf = mPool.getBuf((count + i) * 2); System.arraycopy(buf, 0, newbuf, 0, count); mPool.returnBuf(buf); buf = newbuf; } @Override public synchronized void write(byte[] buffer, int offset, int len) { expand(len); super.write(buffer, offset, len); } } PoolingByteArrayOutputStream の write() では、バッファのサイズが足りない場合 mPool.getBuf() で現在の2倍の配列を確保しようとします。

このように、(Bitmap化する際に縮小する場合でも)いったん元サイズのまま byte 配列に確保されるため、これを並列処理で行ったりすると OutOfMemory Error になることがあります(特に古いデバイスでは)。

Honeycomb (API Level 11) で AsyncTask の実行がシングルスレッドに戻ったのって、こういうメモリエラー回避のためなのかなとか思ったり思わなかったり。ちなみに AsyncTask は API Level 3 で追加されたのですが、追加されたときはシングルスレッドでの実行でした。スレッドプールによる並列処理になったのは Donut (API Level 4) からです。

「2.x のデバイス + Volley + 大きい画像 + AsyncTask」は危険!ということですね。



2013年11月18日月曜日

Android 7 inch 用にスケールアップする場合の文字サイズ

スマホのレイアウトをそのまま 7 inch で表示すると多少スカスカになります。
レイアウトを最適化するほどではない場合、全体的なサイズを大きくして調整することがよくあります。
こういうときは、だいたい1.5倍にするといい感じになります。

文字サイズの場合は、1.5倍にすると大きすぎるので、 デフォルトの文字サイズに対応する私なりの値をメモっておきます。

res/values/dimens.xml 22sp 18sp 14sp

res/values-sw600dp/dimens.xml 30sp 25sp 20sp

res/values/styles.xml




2013年11月14日木曜日

KitKat (Android 4.4) の UI について

Android 4.4 KitKat 冬コミ原稿リレーの 11/14 分です。

Android 4.4 KitKat の API の内、User Interface に関わる部分を取り上げます。


■ Immersive full-screen mode

昔々、2.x まではフルスクリーンモードというものがありました。このときはホームキーやバックキーがハードキー(ハードボタン)だったため、ステータスバーが隠れ、画面全体がアプリの領域になるというものでした。

3.x になると、画面下部がナビゲーションバーというものになり、ステータスバーの情報はナビゲーションバーの右側に、ホームキーやバックキーは左側に移動しました。 このナビゲーションバーは、これまでのフルスクリーンモードを指定しても表示されたままでした。

4.0 ICS (API Level 14) になると、スマホにもナビゲーションバーが導入されました。
ハードキーよ、さようなら。
ナビゲーションバーを暗くしたり、インタラクションがない間(動画を見てるなど)非表示にすることができるようになりました。
  • SYSTEM_UI_FLAG_VISIBLE (0x00000000)
  • SYSTEM_UI_FLAG_LOW_PROFILE (0x00000001)
    ナビゲーションバーを暗くする(オーバーフローメニューを表示するとなぜかクリアされる。Action Item のクリックではクリアされない)
  • SYSTEM_UI_FLAG_HIDE_NAVIGATION (0x00000002)
    インタラクションがない間ナビゲーションバーを非表示にする (ちなみに、SYSTEM_UI_FLAG_LOW_PROFILE と SYSTEM_UI_FLAG_HIDE_NAVIGATION を両方指定すると、ナビゲーションバーは非表示になり、インタラクションがあって表示された瞬間は暗くなっていて、すぐに明るくなる)

4.1 Jelly Bean (API Level 16) では、ナビゲーションバーやステータスバー(これらを合わせてシステムバーとドキュメントでは書かれている)の見た目を制御するためのフラグがいくつか追加されました。
  • SYSTEM_UI_FLAG_FULLSCREEN (0x00000004)
    ステータスバーを非表示にする
    SYSTEM_UI_FLAG_LOW_PROFILE や SYSTEM_UI_FLAG_HIDE_NAVIGATION と組み合わせて指定すると、ナビゲーションバーが表示されるタイミングでステータスバーも表示される
    単体で指定した場合はインタラクションがあっても非表示のまま
  • SYSTEM_UI_FLAG_LAYOUT_STABLE (0x00000100)
  • SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION (0x00000200)
    ナビゲーションバーが非表示であるかのようにビューをレイアウトする
  • SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN (0x00000400)
    ステータスバーが非表示であるかのようにビューをレイアウトする
SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN だけを指定した状態


4.4 KitKat (API Level 19) では、インタラクションがあってもシステムバーを非表示のままにできるようになりました。
  • SYSTEM_UI_FLAG_IMMERSIVE (0x00000800)
  • SYSTEM_UI_FLAG_IMMERSIVE_STICKY (0x00001000)
* immersive は没入とか没頭という意味です。

SYSTEM_UI_FLAG_HIDE_NAVIGATION フラグや SYSTEM_UI_FLAG_FULLSCREEN フラグと組み合わせて setSystemUiVisibility() に指定します。
これらを指定すると、ステータスバー(SYSTEM_UI_FLAG_FULLSCREEN)やナビゲーションバー(SYSTEM_UI_FLAG_HIDE_NAVIGATION)が非表示になり、画面全体をアプリの領域にできます。
システムバーを表示するには、「システムバーが表示されるべき領域から内側に向かってフリック」します。
ユーザーがこの操作を行うと、SYSTEM_UI_FLAG_HIDE_NAVIGATION フラグと SYSTEM_UI_FLAG_FULLSCREEN フラグがクリアされるので、全画面表示ではなくなります。
SYSTEM_UI_FLAG_IMMERSIVE を指定した場合は、そのまま全画面表示が解除されたままになり、SYSTEM_UI_FLAG_IMMERSIVE_STICKY を指定すると数秒後に再び全画面表示に戻ります。

なぜか、オーバーフローメニューを表示すると immersive mode がクリアされてしまいます。。。


SYSTEM_UI_FLAG_IMMERSIVE | SYSTEM_UI_FLAG_FULLSCREEN | SYSTEM_UI_FLAG_HIDE_NAVIGATION

わーい。全画面だー。

SYSTEM_UI_FLAG_IMMERSIVE_STICKY | SYSTEM_UI_FLAG_FULLSCREEN | SYSTEM_UI_FLAG_HIDE_NAVIGATION で「システムバーが表示されるべき領域から内側に向かってフリック」したとき

View の上に半透明で表示されます

初めて全画面表示したときは、こんなポップアップが自動で出ました。




■ Translucent system bars

システムバーを透明にすることができるようになりました。

システムバーが透明のテーマが用意されています。
  • Theme.Holo.NoActionBar.TranslucentDecor
  • Theme.Holo.Light.NoActionBar.TranslucentDecor
ActionBar とは併用できないんですかね。。。

Theme.Holo.NoActionBar.TranslucentDecor

黒わからん。。。w

Theme.Holo.Light.NoActionBar.TranslucentDecor
1592 ステータスバーを透明にする属性が android:windowTranslucentStatus
ナビゲーションバーを透明にする属性が android:windowTranslucentNavigation
です。

試しに ActionBar と併用してみました。 としたら、こうなりました。。。ひどいw


android:fitsSystemWindows="true" を指定すると、システムバー分のパディングがセットされます。 ただし、android:paddingXXX で指定したパディングが上書きされるので注意が必要。 ... 左右の padding もなくなってしまった。。。



■ Enhanced notification listener

API Level 18 で追加された NotificationListenerService が拡張されました。

Notification に新しく extras というフィールドが増えて、この Bundle 用のキーがいろいろ追加されました。 また、新しく actions というフィールドも増えました。このフィールドは Notification.Action の配列で、Notification.Builder の addAction() で格納されます。



■ Scenes and transitions

新しく android.transtion フレームワークが提供されるようになりました。

ユーザーインタフェースの異なる状態間のアニメーションを促進するためのものだそうです。 Scene.getSceneForLayout() を使って Scene を作ります。 レイアウトを切り替える領域の ViewGroup を第1引数に、切り替えるレイアウトを第2引数に、第3引数には Context を指定します。 あとは、TransitionManager.go() を呼べば、いい感じにアニメーションでレイアウトが切り替わります。 findViewById(R.id.button1).setOnClickListener(new View.OnClickListener() { @Override public void onClick(View v) { ViewGroup view = (ViewGroup) findViewById(android.R.id.content); Scene scene = Scene.getSceneForLayout(view, R.layout.scene2, MainActivity.this); TransitionManager.go(scene); } }); レイアウトを切り替える領域の ViewGroup を指定して TransitionManager.beginDelayedTransition() を呼ぶと、ViewGroup の子 View が変わったときに自動でいい感じにアニメーションしてくれます。 この方法だと Scene を作る必要はありません。

# やってみたけど、アニメーションしてくれない。。。 final ViewGroup view = (ViewGroup) findViewById(android.R.id.content); TransitionManager.beginDelayedTransition(view); findViewById(R.id.button1).setOnClickListener(new View.OnClickListener() { @Override public void onClick(View v) { View inflate = LayoutInflater.from(MainActivity.this).inflate(R.layout.scene2, view, false); view.addView(inflate); } }); 特定のアニメーションを指定するには、Transition を継承したクラスのインスタンスを指定します。 指定されていないときは AutoTransition が利用されます。 Fade や ChangeBounds、Visibility などいくつかのクラスがあらかじめ用意されています。 Scene scene = Scene.getSceneForLayout(view, R.layout.scene2, MainActivity.this); TransitionManager.go(scene, new ChangeBounds()); res/transition/ に定義した XML ファイルに対して TransitionInflater.inflateTransition() を使うことでも Transition のインスタンスを作成することができます。 XML については TransitionManager のドキュメントの説明を読むのがいいです。



# 今のところ、これだ!という使い道がわかってません。。。w



明日は @checkela さんです!

2013年11月11日月曜日

ViewPager で Volley の NetworkImageView を使うときの注意点

ViewPager の子要素は、ページが変わったときにも onLayout() が呼ばれます。

整理すると、ページが切り替わると
・新しく生成されたページ(PagerAdapter の instantiateItem() が呼ばれるところ)では onLayout(true, ...) が呼ばれる
・現在の子要素全てで onLayout(false, ...) が呼ばれる


Volley の NetworkImageView (2013年11月11日)では、 @Override protected void onLayout(boolean changed, int left, int top, int right, int bottom) { super.onLayout(changed, left, top, right, bottom); loadImageIfNecessary(true); } のようになっています。

これだとページを切り替えるたびに画像の読み込みが実行されてしまいます。

changed が true のときだけにすれば、新しく生成されたときだけ実行されるようになります。 @Override protected void onLayout(boolean changed, int left, int top, int right, int bottom) { super.onLayout(changed, left, top, right, bottom); if (changed) { loadImageIfNecessary(true); } }