彻底理解Toast原理和解决小米MIUI系统上没法弹Toast的问题
1、Toast的基本使用
Toast在Android中属于系统消息通知,用来提示用户完成了什么操作、或者给用户一个必要的提醒。Toast的官方定义是这样的:
A toast provides simple feedback about an operation in a small popup. It only fills the amount of space required for the message and the current activity remains visible and interactive.
它仅仅用作一个简单的反馈机制。使用也比较简单:
Context context = getApplicationContext(); CharSequence text = "Hello toast!"; int duration = Toast.LENGTH_SHORT; Toast toast = Toast.makeText(context, text, duration); toast.show();
一般情况下,我们传入一个String就基本上满足大多数的需求。但要想自定义一个View,然后通过Toast进行显示,也仅仅多了设置View的操作。
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" android:id="@+id/toast_layout_root" android:orientation="horizontal" android:layout_width="fill_parent" android:layout_height="fill_parent" android:padding="8dp" android:background="#DAAA" > <ImageView android:src="@drawable/droid" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginRight="8dp" /> <TextView android:id="@+id/text" android:layout_width="wrap_content" android:layout_height="wrap_content" android:textColor="#FFF" /> </LinearLayout>
我们把这个文件命名为toast_layout.xml,然后在代码中加载它。
LayoutInflater inflater = getLayoutInflater(); View layout = inflater.inflate(R.layout.toast_layout, (ViewGroup) findViewById(R.id.toast_layout_root)); TextView text = (TextView) layout.findViewById(R.id.text); text.setText("This is a custom toast"); Toast toast = new Toast(getApplicationContext()); toast.setGravity(Gravity.CENTER_VERTICAL, 0, 0); toast.setDuration(Toast.LENGTH_LONG); toast.setView(layout); toast.show();
其实就是这么简单。
2、Toast原理解剖
但现实是,产品需求说你给我控制Toast显示的时间。咋一看好像也不难嘛。
不是有个setDuration方法么?当你翻看源码的时候,你会发现它的描述参数只有以下两种:
LENGTH_SHORT
LENGTH_LONG
这两个常量对应着2秒和3.5秒,你传个其它数字进入,效果并不是你所预料。其实这两个常量仅仅是个flag,并不是我们想的多少秒。官方API文档告诉我们:
This time could be user-definable.
但,它又不提供一个公开的方法让你设置。抓狂!先看一下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; try { service.enqueueToast(pkg, tn, mDuration); } catch (RemoteException e) { // Empty } }
/** * Close the view if it's showing, or don't show it if it isn't showing yet. * You do not normally have to call this. Normally view will disappear on its own * after the appropriate duration. */ public void cancel() { mTN.hide(); try { getService().cancelToast(mContext.getPackageName(), mTN); } catch (RemoteException e) { // Empty } }
理解这两个方法,需要深挖getService()到底调用了那个类做enqueueToast的操作?TN类是干什么的?继续跟踪代码。
static private INotificationManager getService() { if (sService != null) { return sService; } sService = INotificationManager.Stub.asInterface(ServiceManager.getService("notification")); return sService; }
看到Stub.asInterface,我们知道这是利用Binder进行跨进程调用了。而TN类就是遵循AIDL的实现。
private static class TN extends ITransientNotification.Stub
TN类内部使用Handler机制:post一个mShow和mHide:
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; } };
再来看handleShow()方法的实现:
public void handleShow() { if (localLOGV) Log.v(TAG, "HANDLE SHOW: " + this + " mView=" + mView + " mNextView=" + mNextView); 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) { if (localLOGV) Log.v(TAG, "REMOVE! " + mView + " in " + this); mWM.removeView(mView); } if (localLOGV) Log.v(TAG, "ADD! " + mView + " in " + this); mWM.addView(mView, mParams); trySendAccessibilityEvent(); } }
大概意思就是通过WindowManager的addView方法实现Toast的显示。其中trySendAccessibilityEvent()方法会把当前的类名、应用的包名通过AccessibilityManager来做进一步的分发,以供后续的处理。
private void trySendAccessibilityEvent() { AccessibilityManager accessibilityManager = AccessibilityManager.getInstance(mView.getContext()); if (!accessibilityManager.isEnabled()) { return; } // treat toasts as notifications since they are used to // announce a transient piece of information to the user AccessibilityEvent event = AccessibilityEvent.obtain( AccessibilityEvent.TYPE_NOTIFICATION_STATE_CHANGED); event.setClassName(getClass().getName()); event.setPackageName(mView.getContext().getPackageName()); mView.dispatchPopulateAccessibilityEvent(event); accessibilityManager.sendAccessibilityEvent(event); }
先回到前面的enqueueToast方法,看它做了什么事情。前面的INotificationManager service = getService()返回的就是NotificationManagerService,所以enqueueToast方法的最终实现在NotificationManagerService类中。
@Override public void enqueueToast(String pkg, ITransientNotification callback, int duration) { if (DBG) { Slog.i(TAG, "enqueueToast pkg=" + pkg + " callback=" + callback + " duration=" + duration); } if (pkg == null || callback == null) { Slog.e(TAG, "Not doing toast. pkg=" + pkg + " callback=" + callback); return ; } final boolean isSystemToast = isCallerSystem() || ("android".equals(pkg)); if (ENABLE_BLOCKED_TOASTS && !noteNotificationOp(pkg, Binder.getCallingUid())) { if (!isSystemToast) { Slog.e(TAG, "Suppressing toast from package " + pkg + " by user request."); 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 { // Limit the number of toasts that any given package except the android // package can enqueue. Prevents DOS attacks and deals with leaks. if (!isSystemToast) { int count = 0; final int N = mToastQueue.size(); for (int i=0; i<N; i++) { final ToastRecord r = mToastQueue.get(i); if (r.pkg.equals(pkg)) { count++; if (count >= MAX_PACKAGE_NOTIFICATIONS) { Slog.e(TAG, "Package has already posted " + count + " toasts. Not showing more. Package=" + pkg); return; } } } } 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); } } }
static final int MAX_PACKAGE_NOTIFICATIONS = 50;
static final int LONG_DELAY = 3500; // 3.5 seconds static final int SHORT_DELAY = 2000; // 2 seconds
这段代码主要做了以下几件事情:
- 获取当前进程的Id。
- 查看这个Toast是否在队列中,有的话直接返回,并更新显示时间。
- 如果是非系统的Toast(通过应用包名进行判断),且Toast的总数大于等于50,不再把新的Toast放入队列。
- 最后通过keepProcessAliveLocked(callingPid)方法来设置对应的进程为前台进程,保证不被销毁。
- 如果index = 0,说明Toast就处于队列的头部,直接进行显示。
- 我们在NotificationManagerService类中确认了前面提到的LENGTH_SHORT和LENGTH_LONG的显示时长。
关于上述的第四点,我们通过Toast类型的定义来印证代码:
/** * Window type: transient notifications. * In multiuser systems shows only on the owning user's window. */ public static final int TYPE_TOAST = FIRST_SYSTEM_WINDOW+5;
所以一旦应用被销毁,它对应的Toast也将不会再显示:shows only on the owning user's window. 再来看这个keepProcessAliveLocked方法:
// lock on mToastQueue void keepProcessAliveLocked(int pid) { int toastCount = 0; // toasts from this pid ArrayList<ToastRecord> list = mToastQueue; int N = list.size(); for (int i=0; i<N; i++) { ToastRecord r = list.get(i); if (r.pid == pid) { toastCount++; } } try { mAm.setProcessForeground(mForegroundToken, pid, toastCount > 0); } catch (RemoteException e) { // Shouldn't happen. } }
其中mAm是一个ActivityManagerService实例,所以调用最终进入到ActivityManagerService的setProcessForeground方法进行再次处理。下面我用一张序列图展示整个调用流程:
其中第八步的scheduleTimeoutLocked()实质上就是利用Handler延时发送一个Message,回调TN类的hide()方法,最终通过WindowManager的removeView()来隐藏之前显示的Toast。
private void scheduleTimeoutLocked(ToastRecord r) { mHandler.removeCallbacksAndMessages(r); Message m = Message.obtain(mHandler, MESSAGE_TIMEOUT, r); long delay = r.duration == Toast.LENGTH_LONG ? LONG_DELAY : SHORT_DELAY; mHandler.sendMessageDelayed(m, delay); }
至此,Toast的显示和隐藏已经分析完毕。原理搞清楚了,让我们回到一开始提到的问题,如何控制Toast的显示时长?
思路1:通过反射的方式调用TN类中的show和hide方法。
代码大概像这样:
Object obj = message.obj; Method method = obj.getClass().getDeclaredMethod("hide", null); method.invoke(obj, null);
但是很可惜,Method method = obj.getClass().getDeclaredMethod("hide", null); 这种方法在4.0之上已经不适用了。
思路2:不让Toast进入系统队列,我们自己维护一个队列。
这种方式其实仿照一下TN类中的实现,结合LinkedBlockingQueue和WindowManager就可以了。关于如何实现,后面有相应的源码链接。
3、Toast在某些系统无法显示问题
此问题常见于小米系统。MIUI上可能是出于“绿化”的考虑,在维护Toast队列的时候,Toast只能在自己进程运行在顶端的时候才能弹出来,否则就“invisible to user”。乱改系统行为,简直丧心病狂有木有,最终苦的是广大Android开发人员。不过有了上面的理论准备,要解决也是没有问题的,参照思路2。
对于这个问题,已经有人给出了源码实现,请参考问题描述:解决小米MIUI系统上后台应用没法弹Toast的问题,Github源码地址:https://github.com/zhitaocai/ToastCompat
本来到这里就可以结束了,但笔者在实际开发中遭遇了一个小小的坑。
mWindowManager = (WindowManager) mContext.getSystemService(Context.WINDOW_SERVICE);
这个坑就是上面的mContext,它必须是ApplicationContext,不然在小米3或小米Note(Android 4.4.4)无法起作用!
以上。
参考:
Toast相关源码