プログラマーのメモ書き

伊勢在住のプログラマーが気になることを気ままにメモったブログです

【Android】 UIスレッドとは別スレッドからのToastの表示

SQLiteとCursorLoaderの使い方(3/3)』を書いた際に、テスト用に埋め込んだToastがUIスレッド以外のスレッドから呼び出せてしまった。最初はたまたまかと思ったが何度やっても、いろいろと操作していても表示できる。常々、ToastはUIスレッドからしか呼べないと思っていたので、これは非常に不思議でした。

なので、なぜエラーにならずに呼び出せるのか、ちょっと調べてみました。

 

Toastを表示したスレッドについて

まず最初に、今回の現象が起きた別スレッドの処理について調べてみます。

今回の現象は、AsyncQueryHandler という Content Resolver を経由してデータ取得を非同期に行う処理に関連して見つかりました。このAsyncQueryHandler クラスを調べると、コンストラクタでHandlerThread というインスタンスを生成してそれを使っていることがわかります。

AsyncQueryHandlerのコンストラクタ

 

    public AsyncQueryHandler(ContentResolver cr) {
        super();
        mResolver = new WeakReference(cr);
        synchronized (AsyncQueryHandler.class) {
            if (sLooper == null) {
                HandlerThread thread = new HandlerThread("AsyncQueryWorker");
                thread.start();

                sLooper = thread.getLooper();
            }
        }
        mWorkerThreadHandler = createHandler(sLooper);
    }

 

 

HandlerThreadとは?

このHandlerThreadがポイントなんだろうなと思ったので、これについて調べてみました。HandlerThread は内部にlooperを持つスレッドということのようです。looperってなんだったっけ?と思い出すと、スレッドの内部でメッセージキューを処理するものだったように思います(詳しくはAndroid のHandlerとは何か?などを参考にしてください)。

次に、普通に別スレッドでToastを表示したときのエラーを確認すると、

Can't create handler inside thread that has not called Looper.prepare()

とログに出力されていると思います(『現在のスレッドではLooper.prepareが呼び出されていないのでHandlerが作れない』、の意味)。

それではと、Handlerのソースを見てみると、

 

    public Handler() {
        if (FIND_POTENTIAL_LEAKS) {
            final Class klass = getClass();
            if ((klass.isAnonymousClass() || klass.isMemberClass() || klass.isLocalClass()) &&
                    (klass.getModifiers() & Modifier.STATIC) == 0) {
                Log.w(TAG, "The following Handler class should be static or leaks might occur: " +
                    klass.getCanonicalName());
            }
        }

        mLooper = Looper.myLooper();
        if (mLooper == null) {
            throw new RuntimeException(
                "Can't create handler inside thread that has not called Looper.prepare()");
        }
        mQueue = mLooper.mQueue;
        mCallback = null;
    }

 

とあり、Looperが無い場合はエラーで落としています。

Looper.mylooper()を見ると、実行中のスレッドのLooperインスタンスを取得するもののようです。また、Looperの説明を見ると、通常のスレッドはlooperがないけど、prepare()を呼び出すことで、そのスレッドにlooperを準備するということのようです。

ということで、上記のエラーはlooperがないスレッドからToastを表示しようとして発生しており、一方HandlerThreadはlooperがあるので、問題なく表示できた、ということになりそうです。

(参考サイト)

 

Toastの振る舞い

では、Toastはどうなっているのかをみてみます。ToastのstaticメソッドのmakeTextを見ると、最初にToastオブジェクトを生成しています。

    public static Toast makeText(Context context, CharSequence text, int duration) {
        Toast result = new Toast(context);

        LayoutInflater inflate = (LayoutInflater)
                context.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
        View v = inflate.inflate(com.android.internal.R.layout.transient_notification, null);
        TextView tv = (TextView)v.findViewById(com.android.internal.R.id.message);
        tv.setText(text);
        
        result.mNextView = v;
        result.mDuration = duration;

        return result;
    }

Toastのコンストラクタの処理は下記の通りでした。

    public Toast(Context context) {
        mContext = context;
        mTN = new TN();
        mTN.mY = context.getResources().getDimensionPixelSize(
                com.android.internal.R.dimen.toast_y_offset);
    }

ここで、TNはprivate宣言された内部クラスであり、これを見ると、

    private static class TN extends ITransientNotification.Stub {
        final Runnable mShow = new Runnable() {
            public void run() {
                handleShow();
            }
        };

        final Runnable mHide = new Runnable() {
            public void run() {
                handleHide();
                // Don't do this in handleHide() because it is also invoked by handleShow()
                mNextView = null;
            }
        };

        private final WindowManager.LayoutParams mParams = new WindowManager.LayoutParams();
        final Handler mHandler = new Handler();    

        int mGravity = Gravity.CENTER_HORIZONTAL | Gravity.BOTTOM;

とあるように、メンバ変数でHandlerを引数なしでnewしています。

どうもこのために、UIスレッド以外のlooperがないスレッドで、Toastを表示しようとするとnew Handler() の箇所でエラーになってるように思われます。

 

サンプルコード

上記のことから、Looper を持つスレッド上であれば、UI スレッドではなくてもToast が表示できるのではないかと推測されます。そこで、下記のようなサンプルコードを作成してみました。

package com.mori_soft.android.toasttest;

import android.os.Bundle;
import android.os.Handler;
import android.os.HandlerThread;
import android.app.Activity;
import android.util.Log;
import android.view.Menu;
import android.view.View;
import android.widget.Button;
import android.widget.Toast;

public class MainActivity extends Activity {

    @Override
    public void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        
        Button button1 = (Button) this.findViewById(R.id.button1);
        Button button2 = (Button) this.findViewById(R.id.button2);
        Button button3 = (Button) this.findViewById(R.id.button3);
        Button button4 = (Button) this.findViewById(R.id.button4);
        
        // ボタンを押したら、Toastを表示する
        
        
        // (1)UIスレッドから直接呼び出しの場合
        button1.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                displayToast();
            }
        });
        
        // (2)別スレッドから呼び出しの場合(エラー発生)
        button2.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                displayToastNotUiThread();
            }
        });
                
        // (3)別スレッドからHandler経由で呼び出し場合(成功)
        button3.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                displayToastThroughHandler();
            }
        });
        
        // (4)別スレッドでの呼び出しの場合
        button4.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                displayToastThroughHandlerThread();
            }
        });
    }

    private void displayToast() {
        final long id = Thread.currentThread().getId();
        final String thName = Thread.currentThread().getName();
        
        Toast.makeText(MainActivity.this, "Toast displayed. thread id:" + id + ", name:" + thName, Toast.LENGTH_LONG).show();       
        Log.d("MainActivity", "Toast displayed. thread id:" + id + ", name:" + thName);
    }
    
    private void displayToastNotUiThread() {
        new Thread(new Runnable() {
            @Override
            public void run() {
                displayToast();
            }
        }, "TestThread#1").start();     
    }
    
    private void displayToastThroughHandler() {
        final Handler h = new Handler();
        new Thread(new Runnable() {

            @Override
            public void run() {
                Log.d("MainActivity", "thread run() called. thread name:" + Thread.currentThread().getName());
                h.post(new Runnable() {
                    @Override
                    public void run() {
                        displayToast();
                    }
                });
            }
        }, "TestThread#2").start();
    }
    
    private void displayToastThroughHandlerThread() {
        final HandlerThread ht = new HandlerThread("TestThread#3");
        ht.start();
        
        Handler h = new Handler(ht.getLooper());
        h.post(new Runnable() {
            @Override
            public void run() {
                displayToast();
            }
        });
                
        // 別スレッドを停止
        //ht.quit();
    }
    
    @Override
    public boolean onCreateOptionsMenu(Menu menu) {
        getMenuInflater().inflate(R.menu.activity_main, menu);
        return true;
    }
}

Activityにボタンを4つ配置し、ボタンを押すと異なる方法でToastを表示するものです。Toast表示時にスレッドのidと名前を表示します。

(1)がUIスレッド、(2)が別スレッドだけどHandlerを使わないのでエラーになる方法、(3)が別スレッドからHandler経由で表示する方法、(4)が別スレッド上で表示する方法になります。

 

実験

では、試してみましょう。(1)はUIスレッドで、(3)は別スレッドからUIスレッドにToastを投げて表示します。どちらの場合も画面は

のようにUIスレッド(mainスレッド)でToastが処理され、表示されます。一方、(3)の場合は実行時のログを見ると、

のように、Handler#run()は別スレッド(スレッド名が、TestThread#2)で動いていますが、Toast表示処理はUIスレッドになっていることがわかります。

(2)は上記で示したようなエラーになります。(4)は下記画面に示すように、別スレッド(スレッド名がTestThread#3)上でToastが処理されていることがわかります。

と、このようにあっけないぐらい簡単に別スレッドから表示できました。

なお、上記のサンプルでは、HandlerThread#quit() を呼び出していませんが、適切なタイミングでquitを呼びスレッドを終了させるほうがよいと思います。

 

まだ疑問が・・・・

Toastを別スレッドで表示することができましたが、とはいえ、UIに関する処理をしているのに本当にこれで良いのか?なぜ別スレッドで処理できる?という疑問が残ります。

Toastの内部では、NotificationManagerService というサービスを呼出しており(呼び出す際の名称は"notification")、Toast#show()でこのサービスのキューに表示する情報を入れています。

なので、このあたりでよしなにやってくれてるんだろうと思うのですが、これ以上追いかけれませんでした。また、時間ができたら見てみたいと思います。

 

あと、現時点では、上記で見たようにLooper を持つ別スレッドでToastが表示できるとしても、これが効率的なやり方なのか、推奨のやり方なのかという点は正直よくわかりませんし、アプリの種類によってはうまくいかなかったりする可能性もないとは言い切れません。

なので、こういうことができた、という参考程度に捉えておいてもらえるとありがたいです。

 

なお、下記にもHandlerThraedを使ったToast表示の例が載ってました。

Can't create handler inside thread that has not called Looper.prepare()