toast源码解读

概述

Toast(吐司),这个控件的用词相当准确,它所表现出来的特征正是吐司似的弹出效果,使用非常简单便捷。
而且Toast开发时比较常用的一个类了,用它给用户弹提示信息和界面反馈,有时候也用它来作为辅助调试的手段。但是用得多了,有时候自然会遇到各种各样的问题,比如重复点击问题,弹框位置,显示时间等,这时候我们就要对运行机制以及源码进行了解。

特点

1.Toast不是View,它用于帮助创建并展示包含一条小消息的View;

2.它的设计理念是尽量不惹眼,但又能展示想让用户看到的信息;

3.被展示时,浮在应用界面之上;

4.永远不会获取到焦点;

5.大小取决于消息的长度;

6.超时后会自动消失;

7.可以自定义显示在屏幕上的位置(默认左右居中显示在靠近屏幕底部的位置;

8.可以使用自定义布局,也只有在自定义布局的时候才需要直接调用Toast的构造方法,其它时候都是使用 makeText方法来创建Toast;

9.Toast弹出后当前Activity会保持可见性和可交互性;

10.使用cancel方法可以立即将已显示的Toast关闭让未显示的Toast不再显示;

11.Toast 也算是一个「通知」,如果弹出状态消息后期望得到用户响应,应该使用 Notification。

源码解析

创建

new Toast

Toast.class

这里面初始化了TN,TN是什么?一会下面会讲道

1
2
3
4
5
6
7
8
9
10
11
12
/**
* Constructs an empty Toast object. If looper is null, Looper.myLooper() is used.
* @hide
*/
public Toast(@NonNull Context context, @Nullable Looper looper) {
mContext = context;
mTN = new TN(context.getPackageName(), looper);
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);
}

Toast.makeText

Toast.class

这里面蕴含了很多的信息,XML布局,text显示文案,显示时长等等

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
Toast.class

/**
* Make a standard toast to display using the specified looper.
* If looper is null, Looper.myLooper() is used.
* @hide
*/
public static Toast makeText(@NonNull Context context, @Nullable Looper looper,
@NonNull CharSequence text, @Duration int duration) {
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);
TextView tv = (TextView)v.findViewById(com.android.internal.R.id.message);
tv.setText(text);

result.mNextView = v;
result.mDuration = duration;

return result;
}

显示

Toast.class

这里是show一个Toast,显示的时候把tn,和包名相关信息传递给service,将我们需要显示的toast放到这个服务的队列中进行显示。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/**
* 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
}
}

NotificationManagerService.class

Toast入系统队列的方法:在Toast的TN对象中,会调用service.enqueueToast(String pkg,ItransientNotification callback,int duaraion)来将创建出来的Toast放入NotificationManagerService的ToastRecord队列中。
NotificationManagerService是一个运行在SystemServer进程中的一个守护进程,Android大部分的IPC通信都是通过Binder机制,这个守护进程像一个主管一样,所有的下面的人都必须让它进行调度,然后由它来进行显示或者是隐藏。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
@Override
public void enqueueToast(String pkg, ITransientNotification callback, int duration)
{
.........................................

synchronized (mToastQueue) {
int callingPid = Binder.getCallingPid();
long callingId = Binder.clearCallingIdentity();
try {
ToastRecord record;
int index;
// All packages aside from the android package can enqueue one toast at a time
//查看这个toast是否在当前队列中,有的话就返回索引
if (!isSystemToast) {
index = indexOfToastPackageLocked(pkg);
} else {
index = indexOfToastLocked(pkg, callback);
}

// If the package already has a toast, we update its toast
// in the queue, we don't move it to the end of the queue.
//如果这个index大于等于0,说明这个toast已经在这个队列中了,只需要更新显示时间就可以了
//当然这里callback是一个对象,pkg是一个String,所以比较的时候是对象的比较
if (index >= 0) {
record = mToastQueue.get(index);
record.update(duration);
try {
record.callback.hide();
} catch (RemoteException e) {
}
record.update(callback);
} else {
//将这个toast封装成ToastRecord对象,放到队列中
Binder token = new Binder();
mWindowManagerInternal.addWindowToken(token, TYPE_TOAST, DEFAULT_DISPLAY);
record = new ToastRecord(callingPid, pkg, callback, duration, token);
mToastQueue.add(record);
index = mToastQueue.size() - 1;
}
keepProcessAliveIfNeededLocked(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.
//如果返回的索引是0,说明当前的这个存在的toast就在对头,直接显示
if (index == 0) {
showNextToastLocked();
}
} finally {
Binder.restoreCallingIdentity(callingId);
}
}
}

void showNextToastLocked() {
ToastRecord record = mToastQueue.get(0);
....................................
record.callback.show(record.token);
scheduleTimeoutLocked(record);
return;
...................................
}
}

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);
}

private void handleTimeout(ToastRecord record)
{
int index = indexOfToastLocked(record.pkg, record.callback);
if (index >= 0) {
cancelToastLocked(index);
}

}

取消

NotificationManagerService.class

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
void cancelToastLocked(int index) {
ToastRecord record = mToastQueue.get(index);
try {
record.callback.hide();
} catch (RemoteException e) {
}

ToastRecord lastToast = mToastQueue.remove(index);
mWindowManagerInternal.removeWindowToken(lastToast.token, true, DEFAULT_DISPLAY);

keepProcessAliveIfNeededLocked(record.pid);
if (mToastQueue.size() > 0) {
// Show the next one. If the callback fails, this will remove
// it from the list, so don't assume that the list hasn't changed
// after this point.
showNextToastLocked();
}
}

TN

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
private static class TN extends ITransientNotification.Stub {
private final WindowManager.LayoutParams mParams = new WindowManager.LayoutParams();
.................................................................

TN(String packageName, @Nullable Looper looper) {
// XXX This should be changed to use a Dialog, with a Theme.Toast
// defined that sets up the layout params appropriately.
final WindowManager.LayoutParams params = mParams;
params.height = WindowManager.LayoutParams.WRAP_CONTENT;
params.width = WindowManager.LayoutParams.WRAP_CONTENT;
params.format = PixelFormat.TRANSLUCENT;
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;

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) {
.................................................
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;
mParams.hideTimeoutMilliseconds = mDuration ==
Toast.LENGTH_LONG ? LONG_DURATION_TIMEOUT : SHORT_DURATION_TIMEOUT;
mParams.token = windowToken;
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);
// Since the notification manager service cancels the token right
// after it notifies us to cancel the toast there is an inherent
// race and we may attempt to add a window after the token has been
// invalidated. Let us hedge against that.
try {
mWM.addView(mView, mParams);
trySendAccessibilityEvent();
} catch (WindowManager.BadTokenException e) {
/* ignore */
}
}
}

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);
}

public void handleHide() {
if (localLOGV) Log.v(TAG, "HANDLE HIDE: " + this + " mView=" + mView);
if (mView != null) {
// note: checking parent() just to make sure the view has
// been added... i have seen cases where we get here when
// the view isn't yet added, so let's try not to crash.
if (mView.getParent() != null) {
if (localLOGV) Log.v(TAG, "REMOVE! " + mView + " in " + this);
mWM.removeViewImmediate(mView);
}

mView = null;
}
}
}

显示时长

1
2
3
static final int LONG_DELAY = PhoneWindowManager.TOAST_WINDOW_TIMEOUT;
public static final int TOAST_WINDOW_TIMEOUT = 3500; // 3.5 seconds
static final int SHORT_DELAY = 2000; // 2 seconds

超时时长

1
2
static final long SHORT_DURATION_TIMEOUT = 4000;
static final long LONG_DURATION_TIMEOUT = 7000;

问题解答

能否在非 UI 线程调用

1
2
3
4
5
6
new Thread(new Runnable() {
@Override
public void run() {
Toast.makeText(getContext(), "Call toast on non-UI thread", Toast.LENGTH_SHORT).show();
}
}).start();

异常提示:Can’t create handler inside thread that has not called Looper.prepare()

1
2
3
4
5
6
7
8
new Thread(new Runnable() {
@Override
public void run() {
Looper.prepare();
Toast.makeText(getContext(), "Call toast on non-UI thread", Toast.LENGTH_SHORT).show();
Looper.loop();
}
}).start();

Toast数量

1
static final int MAX_PACKAGE_NOTIFICATIONS = 50;

Service中能否显示Toast

可以。但是网上说IntentService不可以,因为IntentService运行在独立的线程中,所以Toast不正常需要通过Handler运行于主线程之上,但是我打印IntentService也是main线程。

1
2
3
4
5
6
Handler handler = new Handler(Looper.getMainLooper());  
handler.post(new Runnable() {
public void run() {
dialog.show();
}
}

遇到问题

源码差异

8 以下

1
2
3
4
if (index >= 0) {
record = mToastQueue.get(index);
record.update(duration);
}

9

1
2
3
4
5
6
7
8
9
if (index >= 0) {
record = mToastQueue.get(index);
record.update(duration);
try {
record.callback.hide();
} catch (RemoteException e) {
}
record.update(callback);
}

如果9,快速点击,当toast弹框,再次点击立刻消失,然后到事件后,清除队列后才能再次显示。而8以下,则是toast弹框到时间消失后,再次点击才能显示。两者toast每次显示的时间不同。

系统问题

某些手机系统源码做了修改,比如红米5p,如果一直点击,在toast消失后,则不会在弹提示。停下一段时间后,log会输入异常提示。初步认定是快速点击时的toast机制做了修改。这个困扰了我好长时间,最后根据日志,和其他的手机才发现这个问题。

1
2
3
4
5
6
7
8
9
10
2019-11-08 17:41:33.311 1632-1632/? W/WindowManager: Attempted to remove non-existing token: [email protected]
2019-11-08 17:41:33.315 1632-2685/? W/WindowManager: Failed looking up window
java.lang.IllegalArgumentException: Requested window [email protected] does not exist
at com.android.server.wm.WindowManagerService.windowForClientLocked(WindowManagerService.java:9651)
at com.android.server.wm.WindowManagerService.windowForClientLocked(WindowManagerService.java:9642)
at com.android.server.wm.WindowManagerService.removeWindow(WindowManagerService.java:2420)
at com.android.server.wm.Session.remove(Session.java:202)
at android.view.IWindowSession$Stub.onTransact(IWindowSession.java:242)
at com.android.server.wm.Session.onTransact(Session.java:145)
at android.os.Binder.execTransact(Binder.java:567)

总结

整个调研过程比较长,期间主要卡在了国内手机厂商的系统修改上,今后在遇到同样问题,还是要做测试,尽量早发现问题,今早提出解决和方案。

参考资料

Android 源码分析 —— 从 Toast 出发

NotificationManagerService

WindowManager

Toast

PhoneWindowManager

关于Binder中clearCallingIdentity()与restoreCallingIdentity()的作用及如何实现权限认证