Android 中经常需要开启子线程进行耗时操作,而在耗时操作结束后,我们又常需要使用 Toast 告知用户,但如果直接在子线程中调用 Toast,往往不能如愿,得到如下 Warn 级报错:

W/System.err: java.lang.RuntimeException: Can't create handler inside thread that has not called Looper.prepare()
W/System.err:     at android.os.Handler.<init>(Handler.java:200)
W/System.err:     at android.os.Handler.<init>(Handler.java:114)
W/System.err:     at android.widget.Toast$TN$2.<init>(Toast.java:340)
W/System.err:     at android.widget.Toast$TN.<init>(Toast.java:340)
W/System.err:     at android.widget.Toast.<init>(Toast.java:103)
W/System.err:     at android.widget.Toast.makeText(Toast.java:256)
W/System.err:     at com.example.liarr.View.Activity.LoginActivity$4.run(LoginActivity.java:209)
W/System.err:     at java.lang.Thread.run(Thread.java:760)

那么问题就来了,在我印象中 Toast 是不需要依赖于 UI 线程的,所以不需要 runOnUiThread() 来加载,同样也不需要像『Android 使用 Dialog 样式的 Activity』中提到的 Dialog 一样需要依赖于 Activity,那到底是什么原因导致 Toast 不能正常显示呢?

从这个错误中,我们可以看到,是因为 Looper 对象的原因。

下面分析一下 Toast 在主线程与子线程运行的缺失项,来看看 Toast 的部分源码:

/**
 * Show the view for the specified duration.
 */
public void show() {
    if (mNextView == null) {
        throw new RuntimeException("setView must have been called");
    }

    INotificationManager service = getService();
    String pkg = mContext.getOpPackageName();
    TN tn = mTN;
    tn.mNextView = mNextView;
    final int displayId = mContext.getDisplayId();

    try {
        service.enqueueToast(pkg, tn, mDuration, displayId);
    } catch (RemoteException e) {
        // Empty
    }
}

private static INotificationManager sService;
static private INotificationManager getService() {
    if (sService != null) {
        return sService;
    }
    sService = INotificationManager.Stub.asInterface(ServiceManager.getService("notification"));
    return sService;
}

Toast 内部有两类 IPC 过程,第一类是 Toast 访问 NotificationManagerService,第二类是 NotificationManagerService 回调 Toast 里面的 TN 接口,这里涉及到 AIDL 进程间通讯,TN 继承自远程 NotificationService 的代理类,实际弹 Toast 的工作是在 TN 这个类里面完成的:

private static class TN extends ITransientNotification.Stub {
    ...
    final Handler mHandler;
    TN(String packageName, @Nullable Looper looper) {
        ...
        if (looper == null) {
            // Use Looper.myLooper() if looper is not specified.
            looper = Looper.myLooper();
            if (looper == null) {
                throw new RuntimeException("Can't toast on a thread that has not called Looper.prepare()");
            }
        }
        mHandler = new Handler(looper, null) {
            @Override
            public void handleMessage(Message msg) {
                switch (msg.what) {
                    case SHOW: {
                        IBinder token = (IBinder) msg.obj;
                        handleShow(token);
                        break;
                    }
                    case HIDE: {
                        handleHide();
                        // Don't do this in handleHide() because it is also invoked by handleShow()
                        mNextView = null;
                        break;
                    }
                    case CANCEL: {
                        handleHide();
                        // Don't do this in handleHide() because it is also invoked by handleShow()
                        mNextView = null;
                        try {
                            getService().cancelToast(mPackageName, TN.this);
                        } catch (RemoteException e) {}
                        break;
                    }
                }
            }
        };
    }

    /**
     * schedule handleShow into the right thread
     */
    @Override
    public void show(IBinder windowToken) {
        if (localLOGV) Log.v(TAG, "SHOW: " + this);
        mHandler.obtainMessage(SHOW, windowToken).sendToTarget();
    }

    /**
     * schedule handleHide into the right thread
     */
    @Override
    public void hide() {
        if (localLOGV) Log.v(TAG, "HIDE: " + this);
        mHandler.obtainMessage(HIDE).sendToTarget();
    }

    public void cancel() {
        if (localLOGV) Log.v(TAG, "CANCEL: " + this);
        mHandler.obtainMessage(CANCEL).sendToTarget();
    }

    public void handleShow(IBinder windowToken) {
        ...
    }

    public void handleHide() {
        ...
    }
}

可以看到,TN 里的 show() 运行在 Binder 线程池中,所以需要使用 Handler 将其切换到当前线程中,实际上 show()hide() 方法实际上都是 TN 类通过 Handler当前线程发送消息,执行相应的任务。

这里又涉及到 Handler 的消息机制。Handler 的消息机制涉及到三个重要的类:HandlerMessageQueueLooper,该消息机制要完整运行这三个必不可少,而 Handler 的创建过程中:

/**
 * Use the {@link Looper} for the current thread with the specified callback interface and set whether the handler should be asynchronous.
 *
 * Handlers are synchronous by default unless this constructor is used to make one that is strictly asynchronous.
 *
 * Asynchronous messages represent interrupts or events that do not require global ordering with respect to synchronous messages.  Asynchronous messages are not subject to the synchronization barriers introduced by {@link MessageQueue#enqueueSyncBarrier(long)}.
 *
 * @param callback The callback interface in which to handle messages, or null.
 * @param async If true, the handler calls {@link Message#setAsynchronous(boolean)} for each {@link Message} that is sent to it or {@link Runnable} that is posted to it.
 *
 * @hide
 */
public Handler(@Nullable Callback callback, boolean async) {
    if (FIND_POTENTIAL_LEAKS) {
        final Class<? extends Handler> 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 " + Thread.currentThread() + " that has not called Looper.prepare()");
    }
    mQueue = mLooper.mQueue;
    mCallback = callback;
    mAsynchronous = async;
}

必要条件之一是需要一个 Looper, 这里就已经看到开头的那个报错了。

没错,就是因为缺少 Looper。那为什么主线程不会报错呢?因为主线程在创建的时候已经默认执行了 Looper.prepare() 这个方法并且调用 Looper.loop() 使其开始轮询工作。而子线程是默认没有初始化这个 Looper 的,这也就解释了为什么 Looper.myLooper() 返回的是一个空的对象。

扒源码扒了这么久,简单来说,Android 的主 UI 线程中其 Android 框架已经默认给出了一个 Looper 对象, 而我们自己创建的子线程中,Looper 对象需要自己给构建出来。这也就是为什么我们在子线程中直接使用 Toast 的时候会报出上面异常。

既然已经知道了问题是如何产生的,那么我们现在就只需要在我们的子线程中把 Toast 所需要的 Looper 对象给构建出来就可以了。

new Thread(new Runnable() {
    @Override
    public void run() {
        ...
        Looper.prepare();
        Toast.makeText(context, "Toast Content", Toast.LENGTH_SHORT).show();
        Looper.loop();
    }
}).start();

在子线程里面初始化一个 Looper 对象并开始轮询,这样 Toast 就可以加入到消息队列中,才能够进行输出。

当然,也可以使用 runOnUiThread() 来解决:

new Thread(new Runnable() {
    @Override
    public void run() {
        ...
        runOnUiThread(new Runnable() {
            @Override
            public void run() {
                Toast.makeText(context, "Toast Content", Toast.LENGTH_SHORT).show();
            }
        });
    }
}).start();

Toast 的代码创建在 Runnable 中,然后在需要 Toast 时,把这个 Runnable 对象传给 runOnUiThread(),这样 Runnable 对象就能在 UI 线程中被调用了。

我在『简书』上留意到曾任新浪高级架构师、58同城项目负责人的 @初一十五a 在文章『Android进阶:六、在子线程中直接使用 Toast 及其原理』中封装一个可以在任何线程中使用 Toast 的工具类,其代码如下:

@初一十五a 封装的 ToastUtil

@初一十五a 还解释道:初始化 Toast 之前先判断当前线程的 Looper 是否为空,为空则初始化一个新的 myLooper,然后在调用 Toastshow() 方法之后让 Looper 启动起来即可。因为 Looper.loop() 方法是无限循环的,为了防止 Looper 阻塞线程,导致内存泄漏应该及时退出 Looper

相信很多人一看,觉得挺有道理,拿来即用,并不会考虑太多,实际上这段代码依然是有 Bug 的,可见 @初一十五a 的测试用例并不完善,可能是他太多注重在子线程的兼容,以至于遗漏了最简单的情况:

public class MainActivity extends AppCompatActivity {
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        ToastUtil.showToast(MainActivity.this, "Toast Content");
    }
}

实际效果是这样的:

测试 @初一十五a 的 ToastUtil 导致 Crash

一开始看到 Toast 被正常弹出,你可能会觉得这是没问题的,但是实际上整个 App 都处于白屏状态,当你按下 Back 键之后你就知道程序被卡死了。

问题出在 Looper.loop() 中,其实我们应当判断当前线程是主线程还是子线程,再决定需不需要构建新的 Looper

public class ToastUtil {
    public static void showToast(Context context, String text) {
        if (isMainThread()) {
            Toast.makeText(context, text, Toast.LENGTH_SHORT).show();
        } else {
            Looper mLooper = Looper.myLooper();
            if (mLooper == null) {
                Looper.prepare();
                mLooper = Looper.myLooper();
            }
            Toast.makeText(context, text, Toast.LENGTH_SHORT).show();
            if (mLooper != null) {
                Looper.loop();
                mLooper.quit();
            }
        }
    }
    private static boolean isMainThread() {
        return Looper.getMainLooper() == Looper.myLooper();
    }
}

这里可以使用之前『Android 判断当前是否在主线程』一文中提到的方法来判断当前线程,如果是主线程的话,那就直接弹出 Toast 即可,如果是子线程,则构建 Looper

但你依然需要了解,Looper.loop() 是会阻塞线程的,也就是说 Looper.loop() 后面的语句并不会执行(不过许多情况下弹 Toast 的确是当前逻辑的最后一步操作),即使你调用了 quit() 也无济于事,所以我个人还是更加喜欢用 Handler 来辅助封装:

public class ToastUtil {
    public static void showToast(final Context context, final String text) {
        if (isMainThread()) {
            Toast.makeText(context, text, Toast.LENGTH_SHORT).show();
        } else {
            Handler handler = new Handler(Looper.getMainLooper());
            handler.post(new Runnable() {
                @Override
                public void run() {
                    Toast.makeText(context, text, Toast.LENGTH_LONG).show();
                }
            });
        }
    }
    private static boolean isMainThread() {
        return Looper.getMainLooper() == Looper.myLooper();
    }
}

跟之前一样,如果是主线程那就直接弹出 Toast,如果不是,则通过 Looper.getMainLooper() 获取主线程的 LooperHandler 会关联 Looper 对应的 MessageQueue,于是这里就将 Toast 放到主线程的 MessageQueue 中进行处理。这种方法其实和 runOnUiThread() 的原理是相似的。

其实很多入门者会认为 Toast 在子线程中不能正常弹出是因为子线程中进行 UI 更新是非安全操作,但实际上并不是抛出异常的真正原因,因为根本还没有执行到更新 UI 那一步。

Toast 本质上是一个 Window,跟 Activity 是平级的,checkThread() 只是 Activity 维护的 View 树的行为。Toast 操作的是 Window,不属于 checkThread() 抛非主线程不能更新 UI 异常的管理范畴,它用 Handler 只是为了用队列和时间控制排队显示 Toast 罢了。