Toast源码解析
Android版本: 基于API源码29,Android版本10.0。
Toast是一种弱提示浮窗,实质上是一个视图,它为用户提供一条简短的消息。当Toast视图显示给用户时,是以浮动视图的形式展示在应用程序之上。它永远不会受到关注。用户可能正在输入其他东西。其思想是尽可能不引人注目,同时仍然向用户显示您希望他们看到的信息。例如音量控制,以及提示设置已保存的简短信息。
Toast有以下特点:
- 系统
Window。 - 不会获取焦点。其视图
View接收不到用户输入事件。 - 展示和隐藏由
NotificationManagerService管控。
Toast的使用很简单:
Toast.makeText(getApplicationContext(), "Toast", Toast.LENGTH_SHORT).show();
Toast源码分析:
Toast对象的创建可以通过makeText方法,也可以直接通过new 关键字直接创建:
//Toast.java
@hide
public static Toast makeText(Context context,Looper looper,CharSequence text,int duration) {
//创建Toast对象。
Toast result = new Toast(context, looper);
//加载系统布局。
LayoutInflater inflate = (LayoutInflater)
context.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
View v = inflate.inflate(com.android.internal.R.layout.transient_notification, null);
//获取文本View。
TextView tv = (TextView)v.findViewById(com.android.internal.R.id.message);
//设置文本。
tv.setText(text);
//赋值。
result.mNextView = v;
result.mDuration = duration;
return result;
}
Toast中makeToast方法有三个重载方法,其中带Looper参数的方法是外界不能直接调用的,它使用了@hiden注解注释。在方法中,获取了系统内置的LayoutInflater对象,来加载系统布局:
//transient_notification
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:paddingHorizontal="@dimen/screen_percentage_05"
android:orientation="vertical">
<TextView
android:id="@android:id/message"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_weight="1"
android:minHeight="48dp"
android:paddingHorizontal="16dp"
android:paddingVertical="8dp"
android:gravity="center"
android:textAppearance="@style/TextAppearance.Toast"
android:background="?android:attr/toastFrameBackground"
/>
</LinearLayout>
Toast显示的文本就是布局中id为message的TextView展示的。默认情况下Toast的背景色就是当前应用设置的主题色,但在不同的Android版本中背景色的取值又有所不同,这边不再深入分析。当然,除了使用系统布局之外,Toast也支持自定义布局,但要使用new直接创建Toast对象。分析下Toast构造方法的源码:
//Toast.java
public Toast(@NonNull Context context, @Nullable Looper looper) {
mContext = context;
//创建TN对象。
mTN = new TN(context.getPackageName(), looper);
//Y轴偏移值。
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对象:
private static class TN extends ITransientNotification.Stub {
}
它是Toast私有的静态内部类,继承自ITransientNotification.Stub类。ITransientNotification是系统定义的AIDL文件,这必定是用来进行跨进程通讯的。
/frameworks/java/android/android/app/ITransientNotification.aidl
package android.app;
/** @hide */
oneway interface ITransientNotification {
void show(IBinder windowToken);
void hide();
}
ITransientNotification.aidl中只定义的两个关键的方法,在TN对象中会有对应的实现。了解过Toast的源码之后,会明白Toast的展示跟隐藏,与系统服务NotificationManagerService简称NMS相关。且该服务运行在系统进程中,与NMS交互就必然要涉及到IPC通讯。结合NMS的源码分析,TN就是NMS服务与Toast通讯的桥梁。就像ViewRootImpl类中定义的W类,是WindowManagerService与ViewRootImpl之间通讯的桥梁一样。这也暗示着Toast的展示跟隐藏并不是自身管理的,而是交给系统服务统一管理的。
既然TN类负责与NMS交互,那Toast干脆将Window相关的事情,都交给TN。在TN的构造方法中会初始化WindowManager.LayoutParams的基本属性,包括窗口的两个重要属性type和flag:
//Toast$TN.java
TN(String packageName, @Nullable Looper looper) {
//mParams是预创建好的对象。
final WindowManager.LayoutParams params = mParams;
//设置宽高。
params.height = WindowManager.LayoutParams.WRAP_CONTENT;
params.width = WindowManager.LayoutParams.WRAP_CONTENT;
params.format = PixelFormat.TRANSLUCENT;
//Window动画。
params.windowAnimations = com.android.internal.R.style.Animation_Toast;
//设置窗口类型。
params.type = WindowManager.LayoutParams.TYPE_TOAST;
params.setTitle("Toast");
//设置窗口标记。
params.flags = WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON
| WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE
| WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE;
mPackageName = packageName;
//获取主进程的Looper,如果在子线程中展示Toast的话,会出现异常。
if (looper == null) {
looper = Looper.myLooper();
if (looper == null) {
throw new RuntimeException(
"Can't toast on a thread that has not called Looper.prepare()");
}
}
//创建主线程Handler。
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();
mNextView = null;
break;
}
case CANCEL: {
handleHide();
mNextView = null;
try {
getService().cancelToast(mPackageName, TN.this);
} catch (RemoteException e) {
}
break;
}
}
}
};
}
TN的构造方法中,初始化了Window的一些基本属性。Toast的Window类型为TYPE_TOAST,属于系统窗口,在添加到WMS中时,不需要检查应用是否开启悬浮窗权限,但需要验证window token。另外重要的是设置给window的几个flag属性,第一个为FLAG_KEEP_SCREEN_ON,表示只要这个窗口对用户可见,保持设备的屏幕打开和明亮。第二个为FLAG_NOT_FOCUSABLE,表示这个窗口永远不会得到键输入焦点,所以用户不能发送键或其他按钮事件给它,事件将会转发给它背后的window。第三个为FLAG_NOT_TOUCHABLE,表示这个窗口接收不到Touch事件。总结一下就是,Toast虽然是系统Window,但无法获取输入焦点和响应Touch事件。也就是Toast中的View压根接收不到任何输入事件,所以别指望给Toast中的View添加点击事件。Toast的作用仅仅是一种弱提示浮窗,系统设计的目的就是不会让Toast去过多的吸引用户的注意跟用户的操作。如果执意要让Toast响应点击事件的,只有通过反射来动态修改window的flag属性。
除此之外,构造方法中创建了主线程Handler,用来从Binder线程切换到主线程,然后执行Toast的相关操作。
那么Toast的重点肯定就在show()跟hide()方法中:
//Toast.java
public void show() {
//Toast的根布局。
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();
//交给系统服务处理Toast。
try {
service.enqueueToast(pkg, tn, mDuration, displayId);
} catch (RemoteException e) {
// Empty
}
}
show方法是Toast类中定义的方法。方法中并没有明显的添加window的逻辑,而是通过getService获取INotificationManager对象,然后执行了enqueueToast方法。先来分析下getService方法:
//Toast.java
static private INotificationManager getService() {
if (sService != null) {
return sService;
}
sService = INotificationManager.Stub.asInterface(ServiceManager.getService("notification"));
return sService;
}
ServiceManager类被@hide注解标记,对于应用层是不可见的,只能下载Android源码查看。熟悉Binder机制应该对ServiceManager不陌生。在Binder机制中所有的服务端,都需要在Servicemanager服务中注册,但注册的并不是服务端创建的真实的IBinder对象,而是Binder驱动为之创建的代理IBinder对象。然后将服务端的名称跟代理IBinder对象一并发送给Servicemanager,ServiceManager接收到数据之后,将数据保存在Map集合中。服务端的名称为key,代理IBinder对象为value保存下来。并提供了getService静态方法,来通过服务端的名称,比如说notification,来获取服务端的Binder代理对象。
回到源码中,ServiceManager.getService("notification")方法,是获取注册在ServiceManager中的名字为notification的服务端代理对象IBinder,通过asInterface()方法获取服务端的代理对象。在系统服务NotificationManagerService类中,创建了属性名为mService的INotificationManager.Stub对象。在服务启动之后会执行onStart方法,方法中会通过SystemService的publishBinderService方法,将mService表示的本地代理对象注册到ServiceManager中。但要区分一下的是,通过SM获取的代理对象,跟mService表示的对象并非同一个对象。在注册为了Binder服务端的过程中,Binder驱动保存了真实的INotificationManager.Stub对象,然后创建一个代理对象,将代理对象跟注册服务的名称一并交给ServiceManager。
所以Toast#getService()方法最终获取的是NotificationManagerService类中创建的INotificationManager.Stub对象,enqueueToast方法真正的实现也在该代理对象中:
//NotificationManagerService.java
final IBinder mService = new INotificationManager.Stub() {
@Override
public void enqueueToast(String pkg, ITransientNotification callback, int duration,
int displayId){
//判断展示Toast的进程是否是系统进程。
final boolean isSystemToast = isCallerSystemOrPhone()
|| PackageManagerService.PLATFORM_PACKAGE_NAME.equals(pkg);
//省略代码
try {
//判断是否需要展示Toast。
if (ENABLE_BLOCKED_TOASTS && !isSystemToast && ((notificationsDisabledForPackage && !appIsForeground) || isPackageSuspended)) {
return;
}
}
synchronized (mToastQueue) {
int callingPid = Binder.getCallingPid();
long callingId = Binder.clearCallingIdentity();
try {
ToastRecord record;
int index = indexOfToastLocked(pkg, callback);
//如果当前要展示的Toast存在队列中,则更新Toast的展示时间。
if (index >= 0) {
record = mToastQueue.get(index);
record.update(duration);
} else {
//判断是否是系统Toast。一般应用创建的Toast都不是系统Toast。
if (!isSystemToast) {
int count = 0;
final int N = mToastQueue.size();
for (int i=0; i<N; i++) {
final ToastRecord r = mToastQueue.get(i);
//判断同一个应用展示Toast的个数,超过25个
if (r.pkg.equals(pkg)) {
count++;
if (count >= MAX_PACKAGE_NOTIFICATIONS) {
return;
}
}
}
}
//创建Binder对象。
Binder token = new Binder();
//想WindowManager添加Token。
mWindowManagerInternal.addWindowToken(token, TYPE_TOAST, displayId);
//创建ToastRecord对象,包含了该Toast的信息。
record = new ToastRecord(callingPid, pkg, callback, duration, token,displayId);
//添加到队列中。
mToastQueue.add(record);
//获取当前添加的Toast的小标。
index = mToastQueue.size() - 1;
keepProcessAliveIfNeededLocked(callingPid);
}
//如果当前添加的Toast就是第一个Toast,则显示该Toast。
if (index == 0) {
showNextToastLocked();
}
} finally {
Binder.restoreCallingIdentity(callingId);
}
}
}
int indexOfToastLocked(String pkg, ITransientNotification callback){
//获取`Toast`在NMS服务中的代理对象IBinder。
IBinder cbak = callback.asBinder();
ArrayList<ToastRecord> list = mToastQueue;
int len = list.size();
for (int i=0; i<len; i++) {
ToastRecord r = list.get(i);
//比较包名跟代理对象。
if (r.pkg.equals(pkg) && r.callback.asBinder() == cbak) {
return i;
}
}
return -1;
}
enqueueToast()方法决定了当前创建的Toast是否需要展示和是否需要马上展示。在try语句一开始的if语句中,判断了什么情况下不展示Toast,比如说创建Toast的进程被挂起时,不展示Toast窗口。在Toast符合需要展示的条件之后,接着判断当前Toast是否在等待队列中。这种情况还是比较常见的,当应用频繁的触发同一个Toast对象的show()方法。另外系统判断两个Toast相同的条件,就是创建Toast的包名和TN对象相等。包名相等说明是同一个应用展示的,TN对象相等说明是同一个Toast对象,因为Toast对象跟TN对象是一一对应的。当在mToastQueue集合中找到相同的Toast的时候,其返回的index大于等于0,说明此时已有一个Toast窗口正在屏幕中展示,然后又使用同一个Toast对象执行show方法。虽然Toast允许这样的操作,但不同的Android版本中,对这种情况的处理逻辑会有所不同,这将影响Toast的展示效果。比如说,单例Toast在小于展示时间之内,多次调用其show方法时,在Android 10.0版本和在Android 9.0版本下会有完全不同的展示效果:
-
Android版本10.0及其除了9.0之外的版本(效果一样,源码可能会有一点的差异)://enqueueToast方法。 if (index >= 0) { record = mToastQueue.get(index); record.update(duration); }当前要展示的
Toast在队列中存在的时候,仅更新Toast的显示时间。这样的效果就是,在LENGTH_LONG或者LENGTH_SHORT时间范围内,多次调用Toast#show()方法时,Toast在设置的时间范围内仅展示一次,继续点击将无任何反应 。需等到该次Toast的Window移除之后,再点击才有效果,该源码稍后会在分析。 -
Android版本9.0://enqueueToast方法。 if (index >= 0) { record = mToastQueue.get(index); record.update(duration); try { record.callback.hide(); } catch (RemoteException e) {} record.update(callback); }当前要展示的
Toast在队列中存在的时候,先更新其展示时间,再执行hide()方法将Toast隐藏起来,在更新callback回调。这样的效果就是,在LENGTH_LONG或者LENGTH_SHORT时间范围内,**多次调用Toast#show()方法时,已经展示的Toast会马上消失掉,但并不会重新展示新的Toast,继续点击将无任何反应。**需等到该次Toast的Window移除之后,再点击才有效果。
这也提示开发者,在封装Toast工具类时,应根据Toast的使用场景来选择是否采用单例模式。尽大可能的避免在需要多次展示Toast的场景下,Toast就是不展示的问题。
回到源码中,接着分析else之后的逻辑。这里在展示Toast之前,如果当前的Toast并不是系统进程展示的Toast,一般应用创建的都不是系统Toast。所以会先判断当前队列中缓存的同包名下的Toast,是否已经超过了系统设置的上限。在Android 10.0中默认上限是25。如果超过了上限,则不会处理此时Toast的展示请求。NotificationManagerService是系统界别的服务,手机中的所有应用在展示Toast的时候,都需要它来管理是否展示跟消失。如果对应用没有任何限制的话,那么手机屏幕中的Toast就会无规则的展示,可能你当前看到的提示内容,并不是来自你当前交互的应用。这也是服务为什么要将Toast加入到队列中的一个原因。
接着分析,当符合以上的条件之后,就需要保存当前Toast的数据,并准备好window token。Toast属于系统Window,在添加到WMS中时,WMS会检查其window token的正确性。通过验证token是否已经在WMS中注册过,只有注册过Token的Window才能展示View。所以在Toast展示之前会先创建一个Binder对象,作为当前Toast的window token。接着执行addWindowToken()方法,将当前的Binder对象注册在WMS中。注册之后再将创建的Binder对象,以及当前Toast的信息,保存在ToastRecord对象中。ToastRecord在NMS服务中就代表了一个Toast,就跟ActivityRecord在系统服务中代表Activity一样。ToastRecord对象保存了Toast的基本信息,包括当前Toast在服务端中的代理对象ITransientNotification,也就是Toast中创建的TN代理对象 。毕竟此时代码的运行环境还在系统进程中,NMS需要借助ToastRecord对象中的TN对象,来给创建Toast的进程通讯。接着执行showNextToastLocked()方法,来通知客户端应用展示Toast View:
//NotificationManagerService.java
void showNextToastLocked() {
ToastRecord record = mToastQueue.get(0);
while (record != null) {
//执行`TN`对象的show(Binder windowToken)方法。
try {
record.callback.show(record.token);
scheduleDurationReachedLocked(record);
return;
} catch (RemoteException e) {
//如果IPC通讯中断的话,就移除掉当前的Toast。
int index = mToastQueue.indexOf(record);
if (index >= 0) {
mToastQueue.remove(index);
}
keepProcessAliveIfNeededLocked(record.pid);
//继续找下一个Toast显示。
if (mToastQueue.size() > 0) {
record = mToastQueue.get(0);
} else {
record = null;
}
}
}
}
从mToastQueue队列中获取第一个要展示的Toast,然后调用callback.show()方法。callback表示的ITransientNotification对象,之前已经解释过了,就是Toast中创建的TN在NMS中的代理对象,之后调用其show方法来通知创建Toast的客户端,展示Toast。这属于IPC通讯,如果代码捕获到RemoteException异常,表示此次跨进程通讯失败,则放弃该Toast的显示,从队列中取出下一个ToastRecord继续展示。以上代码逻辑比较简单,接着回到Toast#TB中分析下show(Binder)方法:
//Toast$TN.java
public void show(IBinder windowToken) {
mHandler.obtainMessage(SHOW, windowToken).sendToTarget();
}
IPC通讯时,服务端跟客户端通讯的代码逻辑都执行在Binder线程池中。所以当前TN#show()代码的执行环境是在Binder线程中,而ViewRootImpl绘制View的线程要求是在UI线程中,在其requestLayout()方法中会检查线程。所以,这里就需要使用主线程的Handler,将代码执行的环境切换到主线程中,之后再执行handleShow逻辑:
//Toast$TN.java
public void handleShow(IBinder windowToken) {
if (mHandler.hasMessages(CANCEL) || mHandler.hasMessages(HIDE)) {
return;
}
f (mView != mNextView) {
//如果Toast更换了`View`需要先将之前的View隐藏掉。
handleHide();
//赋值。mView一开始为null。
mView = mNextView;
//获取ApplicationCOntext。
Context context = mView.getContext().getApplicationContext();
//获取应用包名。
String packageName = mView.getContext().getOpPackageName();
if (context == null) {
context = mView.getContext();
}
//获取系统WindowmanagerImpl。注意是获取预置在系统中的。
mWM = (WindowManager)context.getSystemService(Context.WINDOW_SERVICE);
//省略代码。。。
//设置包名。
mParams.packageName = packageName;
//Toast消失的时间。
mParams.hideTimeoutMilliseconds = mDuration ==
Toast.LENGTH_LONG ? LONG_DURATION_TIMEOUT : SHORT_DURATION_TIMEOUT;
//token赋值。
mParams.token = windowToken;
//如果当前View已经跟ViewRootImpl建立了联系的话,就先移除掉。
if (mView.getParent() != null) {
mWM.removeView(mView);
}
//由于通知管理器服务在通知我们取消toast之后立即取消了令牌,因此存在一个固有的竞争,我们可以尝试在令牌无效之后添加一个窗口。让我们来对冲一下。
try {
//添加Window。
mWM.addView(mView, mParams);
trySendAccessibilityEvent();
} catch (WindowManager.BadTokenException e) {
/* ignore */
}
}
}
handleShow方法的主要逻辑就是添加window窗口。不过源码中有一个小细节,在获取WindowManager对象的时候采用的是ApplicationContext,并没有直接使用在创建Toast对象时传进来的mContext对象。这样做的目的是确保通过Context对象获取到的WindowManager对象是系统预置的。我们知道在创建Toast的时候传入的Context对象一般会使用Activity对象,这样的话直接使用content.getSystemService的话,会导致获取的WindowManager对象,是Activity自身创建的带有Token的WindowManager对象。而Toast作为系统窗口,根本不需要应用token,所以为了避免发生不必要的问题,这里统一使用了ApplicationContext。
注意一下handleShow方法的参数是一个IBinder对象,该对象在NMS#enqueueToast()方法中创建的。之前已经讲过,Toast在展示之前,NMS会创建一个Binder对象,作为Toast窗口在WMS中的令牌。调用 mWM.addView()方法之后,WMS会检查window token的正确性,但这只是在Android 8.0以及之后的版本中才会检查。Android 8.0版本之前,系统是允许展示没有token的Toast,在添加window时不会去检查token。到8.0版本之后,系统要求Toast必须拥有token,所以应用程序不能直接添加Toast,因为令牌是由NMS添加的。这也是为什么Toast的展示要交给NMS来处理。
mWM.addView()方法执行之后,Toast中的跟View就跟ViewRootImpl建立了联系,接着就会执行View的绘制流程,Toast就展示在屏幕中了。展示的逻辑这边就分析结束了,那Toast是怎么自动消失的呢?问题的答案还要回到NMS#showNextToastLocked()方法中:
//NotificationManagerService.java
void showNextToastLocked() {
ToastRecord record = mToastQueue.get(0);
while (record != null) {
try {
//展示Toast。
record.callback.show(record.token);
//开启倒计时。
scheduleDurationReachedLocked(record);
return;
} catch (RemoteException e) {
//省略源码。。。
}
}
}
private void scheduleDurationReachedLocked(ToastRecord r){
mHandler.removeCallbacksAndMessages(r);
Message m = Message.obtain(mHandler, MESSAGE_DURATION_REACHED, r);
int delay = r.duration == Toast.LENGTH_LONG ? LONG_DELAY : SHORT_DELAY;
//省略掉开启手机“辅助功能”的逻辑。
mHandler.sendMessageDelayed(m, delay);
}
在执行完show逻辑之后,就往NMS#mHandler中发送了一个延迟消息。该延迟的时间由Toast的Duration决定,只有两个取值,LENGTH_LONG = 3.5s LENGTH_SHORT = 2s。对于Toast而言,展示的时间由系统控制,一般来说不需要对展示的时间有定制化需求,否则的话就只能使用反射动态的修改了。当延迟时间到了之后,最终会执行NMS#cancelToastLocked()方法:
//NotificationManagerService.java
void cancelToastLocked(int index) {
//根据index获取ToastRecord对象。
ToastRecord record = mToastQueue.get(index);
try {
//调用隐藏方法。
record.callback.hide();
} catch (RemoteException e) {
}
ToastRecord lastToast = mToastQueue.remove(index);
mWindowManagerInternal.removeWindowToken(lastToast.token, false,
lastToast.displayId);
//我们将“removeWindows”传递为“false”,这样客户端就有时间停止渲染(因为上面的隐藏是一个单向消息),否则我们可能会导致客户端崩溃,因为客户端正在使用令牌生成的表面。然而,我们需要安排一个超时,以确保令牌最终以某种方式被杀死。
scheduleKillTokenTimeout(lastToast);
keepProcessAliveIfNeededLocked(record.pid);
if (mToastQueue.size() > 0) {
//开启下一个Toast的展示。
showNextToastLocked();
}
}
NMS中的ToastRecord对象就表示了Toast对象,在NMS中的方法中传递,控制着Toast的显示跟隐藏。方法中参数index是在handleDurationReached()方法中,根据ToastRecord的pkg、callback获取的。然后执行hide()方法,这部分逻辑后续在分析。Toast隐藏之后,系统需要回收当前Toast的显示区域,注意这里调用removeWindowToken()方法的第二个参数是false,表示先不清除window token,先发送一个延迟消息,确保token最终肯定会被杀死。继续执行showNextToastLocked()方法,从队列中获取下一个Toast展示。
以下重点分析下hide()方法。该方法调用还是一次IPC通讯,最终会调用到Toast#handleHide()方法中:
//Toast.java
public void handleHide() {
if (mView != null) {
//注意:检查parent()只是为了确保视图已经被添加…我见过视图还没有添加的情况,所以让我们尽量不要崩溃。
if (mView.getParent() != null) {
mWM.removeViewImmediate(mView);
}
//既然我们已经删除了视图,服务器就可以安全地释放资源了。
try {
getService().finishToken(mPackageName, this);
} catch (RemoteException e) {
}
mView = null;
}
}
接收到隐藏消息之后,就通知NMS来立马移除掉window。NMS先在队列中将该Toast对应的ToastRecord移除掉,接着会再次执行removeWindowToken()方法,方法中的第二个参数传的是true,也就是将token跟显示区域一并的清除掉。最后将mView变量置为null,这样就可以使用同一个Toast对象多次显示。
结尾:
有兴趣的话可以先加入qq交流群684891631,再拉入微信群哦~