Espressoが特定条件下でフリーズするのでどうにか気合で解決した話

# Espressoとは

AndroidのUI関係をUnitTestするとき、多くの場合は [Espresso](https://developer.android.com/training/testing/ui-testing/espresso-testing.html) を使うことになると思います。

ですが、EspressoのKeyPress系処理が何かしらの条件下（Pagerとか複雑なView構造？）でフリーズする不具合に見舞われたので、どうにか回避して想定通りのテストが行えるようにしました。

# Espresso.onView().perform()がフリーズする

どういう条件下は不明で、perform()メソッド内部のwait（実行完了チェック？）が無限ループに陥るようです。Espressoはそれなりに複雑な処理なので、追っかけている途中で原因究明を諦めました。

ざっくりとした条件は `Fragment + Tab + ChildFragmentを使った複雑なView` なのかなとは思いますが、細かいことは不明です。端末再起動で一回だけ成功したりとかもしましたが、UnitTestの度に再起動は現実的に無理なので諦めました。

# 代替手段を模索

かといってUIテストを諦めて人力テストOnlyはココロが折れるので、どうにか変わりの手段を探すことにしました。

# UIAutomatorを利用する

現時点で最もマシなのは、UIAutomatorでView Click系の処理を再現することだろう、と思いたって、それをすることにしました。幸いにも、UIAutomatorには「戻るボタンタップ」や「スクリーン座標をピクセル単位で指定してクリック」機能が揃ってるので、とりあえずViewの座標さえわかればクリックできるだろうという判断です。

# UiDeviceの基本

UiDeviceは簡単に扱えます。

staticメソッドになっているのでどこでもインスタンスが取得できるのはTestの構成を考えるのが楽でいいですね。

```java
UiDevice device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation());
device.click(screenX, screenY);  // スクリーン座標をクリック
device.pressBack();               // 戻るボタンをクリック
```

# UiDeviceとUiSelectorでざっくりとポチポチする

ある程度ざっくりとした指示（特定のTextが設定されたView位置をクリック）であれば、UiDevice.findObject()とUiSelectorを組み合わせれば簡単にできます。

```java
UiDevice.getInstance(InstrumentationRegistry.getInstrumentation())
             .findObject(new UiSelector().textMatches(".*@gmail.com")).click();
```

# Activity内部のクリック

Activity内部のViewはグローバル座標が簡単に得られるので、苦労せずに再現できます。グローバル座標を得る処理は[こちら](http://malta-yamato.hatenablog.com/entry/2016/07/30/135055)を参考にしました。

```java
Rect area = new Rect();
int[] viewInWindow = new int[2];
int[] viewOnScreen = new int[2];
int[] windowOnScreen = new int[2];

view.getLocationInWindow(viewInWindow);
view.getLocationOnScreen(viewOnScreen);
windowOnScreen[0] = viewOnScreen[0] - viewInWindow[0];
windowOnScreen[1] = viewOnScreen[1] - viewInWindow[1];

view.getGlobalVisibleRect(area);
deviceclick(area.centerX(), area.centerY());  // Viewの中心座標をクリック!
```

# DialogやPopUpWindowに対応する

## Viewの探索

苦労したのは、DialogやPopUpWindowのようにActivityと分離されているViewです。とりあえずActivity.getWindow()のDecorViewから探索できるかと思っていたのですが、これは最前面Activityのみを保持しているので、ダイアログは検索できませんでした。

ドキュメントもいくらか読んでみましたが、外部からDialogを抱えているViewを正しい手段で取り出すことはできなさそうでした。

## AOSPソースコードを追いかけた

AOSPの [Dialog.java](https://github.com/android/platform_frameworks_base/blob/master/core/java/android/app/Dialog.java) を追いかけると、Dialog.show()でWindowManagerのaddViewを呼び出しているようです。

WindowManagerクラスの実体である [WindowManagerImpl.java](https://android.googlesource.com/platform/frameworks/base/+/master/core/java/android/view/WindowManagerImpl.java) を追いかけると、内部では[WindowManagerGlobal](https://android.googlesource.com/platform/frameworks/base/+/0e40462e11d27eb859b829b112cecb8c6f0d7afb/core/java/android/view/WindowManagerGlobal.java)という非公開クラスが全てのViewを統括しているようでした。

```java
public final class WindowManagerImpl implements WindowManager {
    private final WindowManagerGlobal mGlobal = WindowManagerGlobal.getInstance();
```

さらに追いかけると、WindowManagerGlobalはmViewsというprivate変数に全てのDecorViewを抱えており、それがアプリの画面を構成しているようです。

ですので、無理やりその変数からView一覧をとってくればDialogやPopupを含めて探索可能であろうと予想しました。

```java
    List<View> getRootViewList() {
        try {
            Class WindowManagerGlobal = Class.forName("android.view.WindowManagerGlobal");
            Object WindowManagerGlobal_instance = WindowManagerGlobal.getMethod("getInstance").invoke(WindowManagerGlobal);

            Field WindowManagerGlobal_mViews = WindowManagerGlobal.getDeclaredField("mViews");
            WindowManagerGlobal_mViews.setAccessible(true);
            List<View> views = (List<View>) WindowManagerGlobal_mViews.get(WindowManagerGlobal_instance);
            return views;
        } catch (Throwable e) {
            e.printStackTrace();
            fail();
            throw new Error();
        }
    }
```

## 何故かViewがない位置をタップした

上記でうまくいくかと思ったのですが、たまに目標のViewではない位置でクリックするという不具合がありました。

Viewのグローバル座標変換が間違っているかと思いましたが、原因は`非表示のActivity`でした。例えば次のようにActivityをスタックしていくと、上記の方法では現在非表示になっているActivityのDecorViewも探索対象にしてしまうようです。

 1. Activity1を起動
 2. Activity2を起動
 3. 上記の処理でDecorViewを取得
 4. Activity1に条件に合致したViewがあると、そちらの位置をクリックしようとしてしまう

対策として、DecorViewが非表示の場合はfindViewの対象外とすることで期待通りの動作になりました。また、indexが後ろのDecorView（後から追加されたDecorView）を優先して探索することで、より想定に近い動作をさせるようにしています。

```java
    List<View> getRootViewList() {
        try {
            Class WindowManagerGlobal = Class.forName("android.view.WindowManagerGlobal");
            Object WindowManagerGlobal_instance = WindowManagerGlobal.getMethod("getInstance").invoke(WindowManagerGlobal);

            Field WindowManagerGlobal_mViews = WindowManagerGlobal.getDeclaredField("mViews");
            WindowManagerGlobal_mViews.setAccessible(true);
            List<View> views = (List<View>) WindowManagerGlobal_mViews.get(WindowManagerGlobal_instance);
            List<View> result = new ArrayList<>(); // 直接削除するとFrameworkの不整合が起きるので、データをクローンしておく

            // 見えていないDecorView（隠れているActivity等）は除外する
            for (View view : views) {
                if (view.getVisibility() == View.VISIBLE) {
                    result.add(view);
                }
            }
            return result;
        } catch (Throwable e) {
            e.printStackTrace();
            fail();
            throw new Error();
        }
    }
```

# 最後に

Espressoがフリーズする条件とか、正しい回避策とか、どこかにドキュメント無いですかねぇ(´・ω・｀)