同期&待機

先日書いたエントリで、Androidのユーザインタフェースはシングルスレッドモデルを使用しており、UIスレッド以外のスレッドでウイジェット(GUI)を操作することは禁止されていることと、その回避策を書いた。

Instrumentationによるユニットテストでは直接ビューを触ってはいけない

この例では単にUIスレッドと同期させておしまいにしたのだが、更に困ったことが発生する。
マルチスレッドで処理を書いたことのあるプログラマであれば、既に気がついていると思うのだが、この場合、 UIスレッド上での処理はHandler、ひいてはLooper上でディスパッチされてから処理されるため、処理を依頼した側(この例ではInstrumentationスレッド)からは終了を知ることができない。

つまりユニットテスト中で

activity.runOnUiThread(new Runnable(){
     @Override
    public void run() {
        Button button = (Button)activity.findViewById(R.id.Button01);
        button.performClick();
        button.setText("done!")
    }
});

Button button = (Button)activity.findViewById(R.id.Button01);
assertEquals("done!", button.getText());

などとした場合、InstrumentationスレッドはRunnable#runの完了を待たずしてテストをすることになるため、最後のassertは成功したり失敗したりと結果が不定になるはずだ(失敗しない環境ではrunメソッドの最後にカレントスレッドをウエイトさせると再発しやすい)

テストに限らず、マルチスレッド処理の場合、委譲した処理の完了を待ち合せしたいケースがある。このサンプルの場合でもボタンのテキストが書き換える処理を待たないとテストは成功しない。つまり、なんらかのスレッド待ち合せ機構が必要な訳だ。

スレッドの同期/待機はそれこそいろいろやり方があるのだが、今回はサンプルが既にあったので、それを参考にjava.util.concurrentパッケージのCountDownLatchを使う方法を使ってみた。

  • AwaitInvoker.java
public final class AwaitInvoker implements Runnable {
    private Runnable[] runnables;
    private CountDownLatch signal;

    @Override
    public void run() {
        for ( Runnable r : this.runnables ) {
            try {
                r.run();
            } finally {
                this.signal.countDown();
            }
        }
    }

    public void invokeAndWait(Handler handler, Runnable... runnables) {
        this.runnables = runnables;

        if( Looper.myLooper() == handler.getLooper() ){
            for ( Runnable r : this.runnables ) {
                r.run();
            }
            return;
        }
        this.signal = new CountDownLatch(runnables.length); 
        handler.post(this);
        try {
            this.signal.await(); //ブロック
        } catch (InterruptedException e) {
            //省略
        } 
    }
}

invokeAndWaitメソッドには幾つでもRunnableを渡せる。内部ではRunnableの数に合わせてCountDownLatchが初期化されており、Runnableが全て終了するまで同メソッドはブロックされる。

先ほどのテストに適用してみよう。

Runnable runnable = new Runnable(){
    @Override
    public void run() {
        Button button = (Button)activity.findViewById(R.id.Button01);
        button.performClick();
        button.setText("done!")
    }
});
Handler handler = new Handler(activity.getMainLooper());
AwaitInvoker invoker = new AwaitInvoker();
invoker.invokeAndWait(handler, runnable);

Button button = (Button)activity.findViewById(R.id.Button01);
assertEquals("done!", button.getText());

GUIはテストの自動化が困難だと言うが、シングルスレッドモデルが多分に影響している訳だ。


※以下のサンプルを参考にさせて頂いた
android情報まとめ @ ウィキ - メモ-Looper