一卷旌收千骑虏,万全身出百重围。这篇文章主要讲述[Android] Toast问题深度剖析相关的知识,希望能为你提供帮助。
欢迎大家前往云+社区,获取更多腾讯海量技术实践干货哦~
作者: QQ音乐技术团队题记
Toast
作为 android
系统中最常用的类之一,由于其方便的api设计和简洁的交互体验,被我们所广泛采用。但是,伴随着我们开发的深入,Toast
的问题也逐渐暴露出来。 本系列文章将分成两篇: 第一篇,我们将分析 Toast
所带来的问题 第二篇,将提供解决 Toast
问题的解决方案 (注:本文源码基于Android 7.0)1.回顾上一篇 [[Android] Toast问题深度剖析(一)] 笔者解释了:
Toast
系统如何构建窗口(通过系统服务NotificationManager来生成系统窗口)Toast
异常出现的原因(系统调用Toast
的时序紊乱)
Toast
问题。2.解决思路基于第一篇的知识,我们知道,
Toast
的窗口属于系统窗口,它的生成和生命周期依赖于系统服务 NotificationManager
。一旦 NotificationManager
所管理的窗口生命周期跟我们本地的进程不一致,就会发生异常。那么,我们能不能不使用系统的窗口,而使用自己的窗口,并且由我们自己控制生命周期呢?事实上, SnackBar
就是这样的方案。不过,如果不使用系统类型的窗口,就意味着你的Toast
界面,无法在其他应用之上显示。(比如,我们经常看到的一个场景就是你在你的应用出调用了多次 Toast.show
函数,然后退回到桌面,结果发现桌面也会弹出 Toast
,就是因为系统的 Toast
使用了系统窗口,具有高的层级)不过在某些版本的手机上,你的应用可以申请权限,往系统中添加 TYPE_SYSTEM_ALERT
窗口,这也是一种系统窗口,经常用来作为浮层显示在所有应用程序之上。不过,这种方式需要申请权限,并不能做到让所有版本的系统都能正常使用。 如果我们从体验的角度来看,当用户离开了该进程,就不应该弹出另外一个进程的 Toast
提示去干扰用户的。Android
系统似乎也意识到了这一点,在新版本的系统更新中,限制了很多在桌面提示窗口相关的权限。所以,从体验上考虑,这个情况并不属于问题。“那么我们可以选择哪些窗口的类型呢?”
- 使用子窗口: 在
Android
进程内,我们可以直接使用类型为子窗口类型的窗口。在Android
代码中的直接应用是PopupWindow
或者是Dialog
。这当然可以,不过这种窗口依赖于它的宿主窗口,它可用的条件是你的宿主窗口可用 - 采用
View
系统: 使用View
系统去模拟一个Toast
窗口行为,做起来不仅方便,而且能更加快速的实现动画效果,我们的SnackBar
就是采用这套方案。这也是我们今天重点讲的方案
在
Android
进程中,我们所有的可视操作都依赖于一个 Activity
。 Activity
提供上下文(Context)和视图窗口(Window) 对象。我们通过 Activity.setContentView
方法所传递的任何 View
对象 都将被视图窗口( Window
) 中的 DecorView
所装饰。而在 DecorView
的子节点中,有一个 id
为 android.R.id.content
的 FrameLayout
节点(后面简称 content
节点) 是用来容纳我们所传递进去的 View
对象。一般情况下,这个节点占据了除了通知栏的所有区域。这就特别适合用来作为 Toast
的父控件节点。“我什么时机往这个
content
节点中添加合适呢?这个 content
节点什么时候被初始化呢?”根据不同的需求,你可能会关注以下两个时机:
Content
节点生成Content
内容显示
Toast
添加到 Content
节点中,只要满足第一条即可。如果你是为了完成性能检测,测量或者其他目的,那么你可能更关心第二条。 那么什么情况下 Content
节点生成呢?刚才我们说了,Content
节点包含在我们的 DecorView
控件中,而 DecorView
是由 Activity
的 Window
对象所持有的控件。Window
在 Android
中的实现类是 PhoneWindow
,(这部分代码有兴趣可以自行阅读) 我们来看下源码://code PhoneWindow.java @Override public void setContentView(int layoutResID) { if (mContentParent == null) { //mContentParent就是我们的 content 节点 installDecor(); //生成一个DecorView } else { mContentParent.removeAllViews(); } mLayoutInflater.inflate(layoutResID, mContentParent); final Callback cb = getCallback(); if (cb != null & & !isDestroyed()) { cb.onContentChanged(); } }
PhoneWindow
对象通过 installDecor
函数生成 DecorView
和 我们所需要的 content
节点(最终会存到 mContentParent
) 变量中去。但是, setContentView
函数需要我们主动调用,如果我并没有调用这个 setContentView
函数,installDecor
方法将不被调用。那么,有没有某个时刻,content
节点是必然生成的呢?当然有,除了在 setContentView
函数中调用installDecor
外,还有一个函数也调用到了这个,那就是://code PhoneWindow.java @Override public final View getDecorView() { if (mDecor == null) { installDecor(); } return mDecor; }
而这个函数,将在
Activity.findViewById
的时候调用://code Activity.java public View findViewById(@IdRes int id) { return getWindow().findViewById(id); } //code Window.java public View findViewById(@IdRes int id) { return getDecorView().findViewById(id); }
因此,只要我们只要调用了
findViewById
函数,一样可以保证 content
被正常初始化。这样我们解释了第一个”就绪”(Content
节点生成)。我们再来看下第二个”就绪”,也就是 Android
界面什么时候显示呢?相信你可能迫不及待的回答不是 onResume
回调的时候么?实际上,在 onResume
的时候,根本还没处理跟界面相关的事情。我们来看下 Android
进程是如何处理 resume
消息的: (注: AcitivityThread
是 Android
进程的入口类, Android
进程处理 resume
相关消息将会调用到 AcitivityThread.handleResumeActivity
函数)//code AcitivityThread.java void handleResumeActivity(...) { ... ActivityClientRecord r = performResumeActivity(token, clearHide); // 之后会调用call onResume ... View decor = r.window.getDecorView(); //调用getDecorView 生成 content节点 decor.setVisibility(View.INVISIBLE); .... if (r.activity.mVisibleFromClient) { r.activity.makeVisible(); //add to WM 管理 } ... } //code Activity.java void makeVisible() { if (!mWindowAdded) { ViewManager wm = getWindowManager(); wm.addView(mDecor, getWindow().getAttributes()); mWindowAdded = true; } mDecor.setVisibility(View.VISIBLE); }
Android
进程在处理 resume
消息的时候,将走以下的流程:- 调用
performResumeActivity
回调Activity
的onResume
函数 - 调用
Window
的getDecorView
生成DecorView
对象和content
节点 - 将
DecorView
纳入WindowManager
(进程内服务)的管理 - 调用
Activity.makeVisible
显示当前Activity
Activity.onResume
回调之后,才将控件纳入本地服务 WindowManager
的管理中。也就是说, Activity.onResume
根本没有显示任何东西。我们不妨写个代码验证一下://code DemoActivity.javapublic DemoActivity extends Activity { private View view ; @Override protected void onCreate( Bundle savedInstanceState) { super.onCreate(savedInstanceState); view = new View(this); this.setContentView(view); } @Override protected void onResume() { super.onResume(); Log.d("cdw","onResume :" +view.getHeight()); // 有高度是显示的必要条件 } }
这里,我们通过在
onResume
中获取高度的方式验证界面是否被绘制,最终我们将输出日志:D cdw: onResume :0
那么,界面又是在什么时候完成的绘制呢?是不是在
WindowManager.addView
之后呢?我们在 onResume
之后会调用Activity.makeVisible
,里面会调用 WindowManager.addView
。因此我们在onResume
里post
一个消息就可以检测WindowManager.addView
之后的情况:@Override protected void onResume() { super.onResume(); this.runOnUiThread(new Runnable() { @Override public void run() { Log.d("cdw","onResume :" +view.getHeight()); } }); }//控制台输出: 01-02 21:30:27.44525622562 D cdw: onResume :0
从结果上看,我们在
WindowManager.addView
之后,也并没有绘制界面。那么,Android的绘制是什么时候开始的?又是到什么时候结束?在
Android
系统中,每一次的绘制都是通过一个 16ms
左右的 VSYNC
信号控制的,这种信号可能来自于硬件也可能来自于软件模拟。每一次非动画的绘制,都包含:测量,布局,绘制三个函数。而一般触发这一事件的的动作有:View
的某些属性的变更View
重新布局Layout- 增删
View
节点
WindowManager.addView
将空间添加到 WM
服务管理的时候,会调用一次Layout请求,这就触发了一次 VSYNC
绘制。因此,我们只需要在 onResume
里 post
一个帧回调就可以检测绘制开始的时间:文章图片
@Override protected void onResume() { super.onResume(); Choreographer.getInstance().postFrameCallback(new Choreographer.FrameCallback() { @Override public void doFrame(long frameTimeNanos) { //TODO 绘制开始 } }); }
我们先来看下
View.requestLayout
是怎么触发界面重新绘制的://code View.java public void requestLayout() { .... if (mParent != null) { ... if (!mParent.isLayoutRequested()) { mParent.requestLayout(); } } }
View
对象调用 requestLayout
的时候会委托给自己的父节点处理,这里之所以不称为父控件而是父节点,是因为除了控件外,还有 ViewRootImpl
这个非控件类型作为父节点,而这个父节点会作为整个控件树的根节点。按照我们上面说的委托的机制,requestLayout
最终将会调用到 ViewRootImpl.requestLayout
。//code ViewRootImpl.java @Override public void requestLayout() { if (!mHandlingLayoutInLayoutRequest) { checkThread(); mLayoutRequested = true; scheduleTraversals(); //申请绘制请求 } }void scheduleTraversals() { if (!mTraversalScheduled) { mTraversalScheduled = true; .... mChoreographer.postCallback( Choreographer.CALLBACK_TRAVERSAL, mTraversalRunnable, null); //申请绘制 .... } }
ViewRootImpl
最终会将 mTraversalRunnable
处理命令放到 CALLBACK_TRAVERSAL
绘制队列中去:final class TraversalRunnable implements Runnable { @Override public void run() { doTraversal(); //执行布局和绘制 } }void doTraversal() { if (mTraversalScheduled) { mTraversalScheduled = false; ... performTraversals(); ... } }
mTraversalRunnable
命令最终会调用到 performTraversals()
函数:private void performTraversals() { final View host = mView; ... host.dispatchAttachedToWindow(mAttachInfo, 0); //attachWindow ... getRunQueue().executeActions(attachInfo.mHandler); //执行某个指令 ... childWidthMeasureSpec = getRootMeasureSpec(desiredWindowWidth, lp.width); childHeightMeasureSpec = getRootMeasureSpec(desiredWindowHeight, lp.height); host.measure(childWidthMeasureSpec, childHeightMeasureSpec); //测量 .... host.layout(0, 0, host.getMeasuredWidth(), host.getMeasuredHeight()); //布局 ... draw(fullRedrawNeeded); //绘制 ... }
performTraversals
函数实现了以下流程:- 调用
dispatchAttachedToWindow
通知子控件树当前控件被attach
到窗口中 - 执行一个命令队列
getRunQueue
- 执行
meausre
测量指令 - 执行
layout
布局函数 - 执行绘制
draw
getRunQueue().executeActions(attachInfo.mHandler);
这个函数将执行一个延时的命令队列,在
View
对象被 attach
到 View
树之前,通过调用 View.post
函数,可以将执行消息命令加入到延时执行队列中去://code View.java public boolean post(Runnable action) { Handler handler; AttachInfo attachInfo = mAttachInfo; if (attachInfo != null) { handler = attachInfo.mHandler; } else { // Assume that post will succeed later ViewRootImpl.getRunQueue().post(action); return true; } return handler.post(action); }
getRunQueue().executeActions
函数执行的时候,会将该命令消息延后一个UI线程消息执行,这就保证了执行的这个命令消息发生在我们的绘制之后://code RunQueue.java void executeActions(Handler handler) { synchronized (mActions) { ... for (int i = 0; i < count; i++) { final HandlerAction handlerAction = actions.get(i); handler.postDelayed(handlerAction.action, handlerAction.delay); //推迟一个消息 } } }
所以,我们只需要在视图被
attach
之前通过一个 View
来抛出一个命令消息,就可以检测视图绘制结束的时间点://code DemoActivity.java @Override protected void onResume() { super.onResume(); Choreographer.getInstance().postFrameCallback(new Choreographer.FrameCallback() { @Override public void doFrame(long frameTimeNanos) { start = SystemClock.uptimeMillis(); log("绘制开始:height = "+view.getHeight()); } }); }@Override protected void onCreate( Bundle savedInstanceState) { super.onCreate(savedInstanceState); view = new View(this); view.post(new Runnable() { @Override public void run() { log("绘制耗时:"+(SystemClock.uptimeMillis()-start)+"ms"); log("绘制结束后:height = "+view.getHeight()); } }); this.setContentView(view); } //控制台输出: 01-03 23:39:27.251 27069 27069 D cdw: ---> 绘制开始:height = 0 01-03 23:39:27.295 27069 27069 D cdw: ---> 绘制耗时:44ms 01-03 23:39:27.295 27069 27069 D cdw: ---> 绘制结束后:height = 1232
我们带着我们上面的知识储备,来看下SnackBar是如何做的呢:
3.Snackbar
文章图片
SnackBar
系统主要依赖于两个类:SnackBar
作为门面,与业务程序交互SnackBarManager
作为时序管理器,SnackBar
与SnackBarManager
的交互,通过Callback
回调对象进行
SnackBarManager
的时序管理跟 NotifycationManager
的很类似不再赘述SnackBar
通过静态方法 make
静态构造一个 SnackBar
:public static Snackbar make(@NonNull View view, @NonNull CharSequence text, @Duration int duration) { Snackbar snackbar = new Snackbar(findSuitableParent(view)); snackbar.setText(text); snackbar.setDuration(duration); return snackbar; }
这里有一个关键函数
findSuitableParent
,这个函数的目的就相当于我们上面的 findViewById(R.id.content)
一样,给 SnackBar
所定义的 Toast
控件找一个合适的容器:private static ViewGroup findSuitableParent(View view) { ViewGroup fallback = null; do { if (view instanceof CoordinatorLayout) { return (ViewGroup) view; } else if (view instanceof FrameLayout) { if (view.getId() == android.R.id.content) {//把 `Content` 节点作为容器 ... return (ViewGroup) view; } else { // It‘s not the content view but we‘ll use it as our fallback fallback = (ViewGroup) view; } } ... } while (view != null); // If we reach here then we didn‘t find a CoL or a suitable content view so we‘ll fallback return fallback; }
我们发现,除了包含
CoordinatorLayout
控件的情况, 默认情况下, SnackBar
也是找的 Content
节点。找到的这个父节点,作为 Snackbar
构造器的形参:private Snackbar(ViewGroup parent) { mTargetParent = parent; mContext = parent.getContext(); ... LayoutInflater inflater = LayoutInflater.from(mContext); mView = (SnackbarLayout) inflater.inflate( R.layout.design_layout_snackbar, mTargetParent, false); ... }
Snackbar
将生成一个 SnackbarLayout
控件作为 Toast
控件。最后当时序控制器 SnackBarManager
回调返回的时候,通知 SnackBar
显示,即将 SnackBar.mView
增加到 mTargetParent
控件中去。【[Android] Toast问题深度剖析】这里有人或许会有疑问,这里使用强引用,会不会造成一段时间内的内存泄漏呢? 假如你现在弹了
10
个 Toast
,每个 Toast
的显示时间是 2s
。也就是说你的最后一个 SnackBar
将被 SnackBarManager
持有至少 20s
。而 SnackBar
中又存在有父控件 mTargetParent
的强引用。相当于在这20s内, 你的mTargetParent
和它所持有的 Context
(一般是 Activity
)无法释放这个其实是不会的,原因在于
SnackBarManager
在管理这种回调 callback
的时候,采用了弱引用。private static class SnackbarRecord { final WeakReference< Callback> callback; .... }
但是,我们从
SnackBar
的设计可以看出,SnackBar
无法定制具体的样式: SnackBar
只能生成 SnackBarLayout
这种控件和布局,可能并不满足你的业务需求。当然你也可以变更 SnackBarLayout
也能达到目的。不过,有了上面的知识储备,我们完全可以写一个自己的 Snackbar
。4.基于Toast的改法从第一篇文章我们知道,我们直接在
Toast.show
函数外增加 try-catch
是没有意义的。因为 Toast.show
实际上只是发了一条命令给 NotificationManager
服务。真正的显示需要等 NotificationManager
通知我们的 TN
对象 show
的时候才能触发。NotificationManager
通知给 TN
对象的消息,都会被 TN.mHandler
这个内部对象进行处理//code Toast.java
private static class TN {final Runnable mHide = new Runnable() {// 通过 mHandler.post(mHide) 执行 @Override public void run() { handleHide(); mNextView = null; } }; final Handler mHandler = new Handler() { @Override public void handleMessage(Message msg) { IBinder token = (IBinder) msg.obj; handleShow(token); // 处理 show 消息 } }; }
在
NotificationManager
通知给 TN
对象显示的时候,TN
对象将给 mHandler
对象发送一条消息,并在 mHandler
的 handleMessage
函数中执行。 当NotificationManager
通知 TN
对象隐藏的时候,将通过 mHandler.post(mHide)
方法,发送隐藏指令。不论采用哪种方式发送的指令,都将执行 Handler
的 dispatchMessage(Message msg)
函数://code Handler.java public void dispatchMessage(Message msg) { if (msg.callback != null) { handleCallback(msg); // 执行 post(Runnable)形式的消息 } else { ... handleMessage(msg); // 执行 sendMessage形式的消息 } }
因此,我们只需要在
dispatchMessage
方法体内加入 try-catch
就可以避免 Toast
崩溃对应用程序的影响:public void dispatchMessage(Message msg) { try { super.dispatchMessage(msg); } catch(Exception e) {} }
因此,我们可以定义一个安全的
Handler
装饰器:private static class SafelyHandlerWarpper extends Handler {private Handler impl; public SafelyHandlerWarpper(Handler impl) { this.impl = impl; }@Override public void dispatchMessage(Message msg) { try { super.dispatchMessage(msg); } catch (Exception e) {} }@Override public void handleMessage(Message msg) { impl.handleMessage(msg); //需要委托给原Handler执行 } }
由于
TN.mHandler
对象复写了 handleMessage
方法,因此,在 Handler
装饰器里,需要将 handleMessage
方法委托给 TN.mHandler
执行。定义完装饰器之后,我们就可以通过反射往我们的 Toast
对象中注入了:public class ToastUtils {private static Field sField_TN ; private static Field sField_TN_Handler ; static { try { sField_TN = Toast.class.getDeclaredField("mTN"); sField_TN.setAccessible(true); sField_TN_Handler = sField_TN.getType().getDeclaredField("mHandler"); sField_TN_Handler.setAccessible(true); } catch (Exception e) {} }private static void hook(Toast toast) { try { Object tn = sField_TN.get(toast); Handler preHandler = (Handler)sField_TN_Handler.get(tn); sField_TN_Handler.set(tn,new SafelyHandlerWarpper(preHandler)); } catch (Exception e) {} }public static void showToast(Context context,CharSequence cs, int length) { Toast toast = Toast.makeText(context,cs,length); hook(toast); toast.show(); } }
我们再用第一章中的代码测试一下:
public void showToast(View view) { ToastUtils.showToast(this,"hello", Toast.LENGTH_LONG); try { Thread.sleep(10000); } catch (InterruptedException e) {} }
等 10s 之后,进程正常运行,不会因为
Toast
的问题而崩溃。相关阅读[Android] Toast问题深度剖析(一)
Android基础:Fragment,看这篇就够了
Android图像处理 - 高斯模糊的原理及实现
此文已由作者授权云加社区发布,转载请注明文章出处
推荐阅读
- 备份Android机上的照片
- Android与H5混合开发
- csharp: mappings using Dapper-Extensions+Dapper.net.
- Testin实验室(陌陌APP通过率为94.92% 基本满足移动社交需求)
- Android自定义控件练手——波浪效果
- Android项目实战(四十)(在线生成按钮Shape的网站)
- 纯净版xp系统安装虚拟机里面后连不上网怎样办
- win xp系统下打开不了pps文件怎样办|xp系统下打开pps文件的办法
- XP系统多了lpk.dll文件怎样办|XP系统查杀lpk.dll病毒的办法