標籤:

Android中,在子線程使用Toast會報錯?

在子線程中使用Toast拋出異常,提示錯誤顯示:Can"t create handler inside thread that has not called Looper.prepare()


我覺得大家還應該再翻翻源碼,就會發現,Toast.show並不是所謂的更新UI操作!(```逃)

Toast,Handler,分析為什麼拋異常。

ActivityThread和ViewRootImpl分析到底什麼叫子線程不能更新UI。

Toast本質上是一個window,跟activity是平級的,checkThread只是Activity維護的View樹的行為。

Toast使用的無所謂是不是主線程Handler,吐司操作的是window,不屬於checkThread拋主線程不能更新UI異常的管理範疇。它用Handler只是為了用隊列和時間控制排隊顯示吐司。

即使是子線程,先Looper.prepare,再show吐司,再Looper.loop一樣可以吐出來,只不過loop操作會阻塞這個線程,沒人這麼玩罷了,都是讓Toast用主線程的Handler,這個是在ActivityThread里初始化的,本來就是阻塞處理所有的UI交互邏輯。

貼個代碼吧還是~~

new Thread(){
public void run(){
Looper.prepare();//給當前線程初始化Looper
Toast.makeText(getApplicationContext(),"你猜我能不能彈出來~~",0).show();//Toast初始化的時候會new Handler();無參構造默認獲取當前線程的Looper,如果沒有prepare過,則拋出題主描述的異常。上一句代碼初始化過了,就不會出錯。
Looper.loop();//這句執行,Toast排隊show所依賴的Handler發出的消息就有人處理了,Toast就可以吐出來了。但是,這個Thread也阻塞這裡了,因為loop()是個for (;;) ...
}
}.start();

不相信,我貼個圖吧。測試環境api 24 ~~~

哎,有這功夫我給學生講兩道題多好~~~


看了下上面的回答,竟然都認為這個異常是子線程不能執行UI操作導致的,實際上這樣的的說法沒有錯,但並不是這裡拋出異常的原因,因為根本還沒有執行到更新UI那一步。

其實問題沒那麼複雜,直接從代碼分析原因即可。

先看Toast.makeText(Context,CharSequence,int)的源碼:

public static Toast makeText(Context context, CharSequence text, @Duration 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;
}

這裡就是初始化View並給Toast賦值,但是這裡並沒有涉及Handler,為什麼會出現「Can"t create handler inside thread that has not called Looper.prepare()」這樣的錯誤呢?

其實是在Toast的構造方法中:

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

注意其中的TN這個類(這個類名也是沒sei了,叫ToastNative也更好呀)的部分代碼如下:

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

final Runnable mHide = new Runnable() {
@Override
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;
int mX, mY;
float mHorizontalMargin;
float mVerticalMargin;

View mView;
View mNextView;

WindowManager mWM;

TN() {
...
}

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

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

...

}

注意其中的mHandler這個成員,final Handler mHandler=new Handler(); 會在TN的構造方法之前執行,從而導致在Toast()中拋出異常。

所以上面那些「子線程更新 UI ,需要利用 Handler 切換回到主線程進行操作」的說法沒有錯,但並不是這裡拋出異常的原因,因為根本還沒有執行到更新UI那一步。實際上這部分代碼也是可以在子線程中執行的,後面會給出我的示例。

為了找出解決方法,就看一下Android中的Toast顯示的完整過程吧。Toast#show()的代碼如下:

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;

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

而getService()的代碼如下:

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

非常典型的Binder通信,不啰嗦了,對應的NotificationManagerService代碼如下:

public void enqueueToast(String pkg, ITransientNotification callback, int duration)
{
if (pkg == null || callback == null) {
return ;
}

synchronized (mToastQueue) {
int callingPid = Binder.getCallingPid();
long callingId = Binder.clearCallingIdentity();
try {
ToastRecord record;
int index = indexOfToastLocked(pkg, callback);
// If it"s already in the queue, we update it in place, we don"t
// move it to the end of the queue.
if (index &>= 0) {
record = mToastQueue.get(index);
record.update(duration);
} else {
record = new ToastRecord(callingPid, pkg, callback, duration);
mToastQueue.add(record);
index = mToastQueue.size() - 1;
keepProcessAliveLocked(callingPid);
}
// If it"s at index 0, it"s the current toast. It doesn"t matter if it"s
// new or just been updated. Call back and tell it to show itself.
// If the callback fails, this will remove it from the list, so don"t
// assume that it"s valid after this.
if (index == 0) {
showNextToastLocked();
}
} finally {
Binder.restoreCallingIdentity(callingId);
}
}
}

顯然,就是讓Toast請求進入隊列統一管理,而顯示下一條Toast的代碼如下:

private void showNextToastLocked() {
ToastRecord record = mToastQueue.get(0);
while (record != null) {
try {
record.callback.show();
scheduleTimeoutLocked(record, false);
return;
} catch (RemoteException e) {
// remove it from the list and let the process die
int index = mToastQueue.indexOf(record);
if (index &>= 0) {
mToastQueue.remove(index);
}
keepProcessAliveLocked(record.pid);
if (mToastQueue.size() &> 0) {
record = mToastQueue.get(0);
} else {
record = null;
}
}
}
}

注意其中的record.callback.show()其實對應的就是TN中的show(),其代碼如下:

public void show() {
mHandler.post(mShow);
}

顯然會調用handleShow()方法:

public void handleShow() {
if (mView != mNextView) {
// remove the old view if necessary
handleHide();
mView = mNextView;
Context context = mView.getContext().getApplicationContext();
String packageName = mView.getContext().getOpPackageName();
if (context == null) {
context = mView.getContext();
}
mWM = (WindowManager)context.getSystemService(Context.WINDOW_SERVICE);
// We can resolve the Gravity here by using the Locale for getting
// the layout direction
final Configuration config = mView.getContext().getResources().getConfiguration();
final int gravity = Gravity.getAbsoluteGravity(mGravity, config.getLayoutDirection());
mParams.gravity = gravity;
if ((gravity Gravity.HORIZONTAL_GRAVITY_MASK) == Gravity.FILL_HORIZONTAL) {
mParams.horizontalWeight = 1.0f;
}
if ((gravity Gravity.VERTICAL_GRAVITY_MASK) == Gravity.FILL_VERTICAL) {
mParams.verticalWeight = 1.0f;
}
mParams.x = mX;
mParams.y = mY;
mParams.verticalMargin = mVerticalMargin;
mParams.horizontalMargin = mHorizontalMargin;
mParams.packageName = packageName;
if (mView.getParent() != null) {
mWM.removeView(mView);
}
mWM.addView(mView, mParams);
trySendAccessibilityEvent();
}
}

顯然,這裡才是真正顯示Toast的地方,這裡才真正涉及到了更新UI的操作。

那如果我們要在子線程中進行顯示Toast的操作要怎麼辦,很簡單的方案就是利用主線程的handler.post(...)來執行Toast.makeText(...).show()的操作。那有沒有其他的方法呢?

其實從剛剛的分析中,我們發現只要在創建Toast()時不讓它拋出異常,並且保證TN中的mHandler是基於主線程消息隊列的Handler對象即可。

由於ITransientNotification和INotificationManager對應用開發者不可見,故沒有辦法構造一個可以完成TN功能的類,那就只能從反射入手了。

如下是我的一種解決方案:

public class MainActivity extends AppCompatActivity {

@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);

new Thread(new Runnable() {
@Override
public void run() {
showToast();
}
}).start();
}
private void showToast(){
Looper.prepare();
Toast toast = Toast.makeText(MainActivity.this, "Enjoy your national day", Toast.LENGTH_LONG);
try {
Field field = toast.getClass().getDeclaredField("mTN");
field.setAccessible(true);
Object obj = field.get(toast);
setNextView(toast,obj);
changeHandlerValue(obj);
enqueueToast(toast,obj);

} catch (Exception e) {

}
Looper.loop();
}

private void setNextView(Toast toast,Object tn){
try{
Field toastNextView=toast.getClass().getDeclaredField("mNextView");
toastNextView.setAccessible(true);

Field nextViewField=tn.getClass().getDeclaredField("mNextView");
nextViewField.setAccessible(true);
nextViewField.set(tn,toastNextView.get(toast));
}catch (Exception ex){
ex.printStackTrace();
}

}

private void changeHandlerValue(Object tn){
try{
Field mHandlerField=tn.getClass().getDeclaredField("mHandler");
mHandlerField.setAccessible(true);
mHandlerField.set(tn,new Handler(Looper.getMainLooper()));
}catch (Exception ex){
ex.printStackTrace();
}
}

private void enqueueToast(Toast toast,Object tn){
try{
Method getServiceMethod=toast.getClass().getDeclaredMethod("getService",null);
getServiceMethod.setAccessible(true);
Object obj=getServiceMethod.invoke(null);
Method[]methods=obj.getClass().getDeclaredMethods();
Method enqueueMethod=null;
for(Method method:methods){
if("enqueueToast".equals(method.getName())){
enqueueMethod=method;
break;
}
}
if(enqueueMethod==null){
return;
}
enqueueMethod.setAccessible(true);
enqueueMethod.invoke(obj,"wang.imallen.toastsample",tn,Toast.LENGTH_LONG);
}catch (Exception ex){
ex.printStackTrace();
}
}

}

就三個要點:

1) 為了防止在創建TN時拋出異常,需要在子線程中使用Looper.prepare();和Looper.loop();

2)為了使TN中最終調用的Handler對象是基於主線程的,需要使用反射將其替換掉,changeHandlerValue()的作用就是這個;

3)由於ITransientNotification不可見,所以不能通過obj.getClass().getDeclaredMethod("enqueueToast",...)的方法來直接獲取到這個Method;

不過,雖然這種方法用另一種思路實現了在子線程中顯示Toast的操作,但是非常不推薦這樣做,因為對於非public成員的反射是有風險的,萬一在某個版本中這個成員的名稱換了,這種方法就會出錯。


執行Toast的線程需要有Looper


別把彈toast不當UI操作……


我的處理辦法就是在初始化的時候把主線程的handler傳進去…


主線程不做耗時操作,子線程不操作UI。


推薦閱讀:

安卓手機應用中界面切換卡頓和滑動卡頓的區別是什麼,請從專業角度解釋?或者給出一個開發者需要注意事項?
如何評價 2017 年 8 月 21 日發布的 Android Oreo (8.0)?
Android開發,剛進入時被用戶關閉定位許可權,然後怎麼又在後面打開?
PC上開發一個安卓模擬器,至少需要搭配怎樣的團隊,做到夜神、海馬、逍遙的程度有多大?
零基礎如何學習安卓應用開發?

TAG:Android開發 |