[Android] Toast问题深度剖析(二)

204
原文链接: mp.weixin.qq.com

题记

Toast 作为 Android 系统中最常用的类之一,由于其方便的api设计和简洁的交互体验,被我们所广泛采用。但是,伴随着我们开发的深入,Toast 的问题也逐渐暴露出来。本系列文章将分成两篇:第一篇,我们将分析 Toast 所带来的问题第二篇,将提供解决 Toast 问题的解决方案(注:本文源码基于Android 7.0)

1.回顾

上一篇  [[Android] Toast问题深度剖析(一)] 笔者解释了:

  1. Toast 系统如何构建窗口(通过系统服务NotificationManager来生成系统窗口)

  2. Toast 异常出现的原因(系统调用 Toast的时序紊乱)

而本篇的重点,在于解决我们第一章所说的 Toast 问题。

2.解决思路

基于第一篇的知识,我们知道,Toast 的窗口属于系统窗口,它的生成和生命周期依赖于系统服务 NotificationManager。一旦 NotificationManager 所管理的窗口生命周期跟我们本地的进程不一致,就会发生异常。那么,我们能不能不使用系统的窗口,而使用自己的窗口,并且由我们自己控制生命周期呢?事实上, SnackBar 就是这样的方案。不过,如果不使用系统类型的窗口,就意味着你的Toast 界面,无法在其他应用之上显示。(比如,我们经常看到的一个场景就是你在你的应用出调用了多次 Toast.show函数,然后退回到桌面,结果发现桌面也会弹出 Toast,就是因为系统的 Toast 使用了系统窗口,具有高的层级)不过在某些版本的手机上,你的应用可以申请权限,往系统中添加 TYPE_SYSTEM_ALERT 窗口,这也是一种系统窗口,经常用来作为浮层显示在所有应用程序之上。不过,这种方式需要申请权限,并不能做到让所有版本的系统都能正常使用。如果我们从体验的角度来看,当用户离开了该进程,就不应该弹出另外一个进程的 Toast 提示去干扰用户的。Android 系统似乎也意识到了这一点,在新版本的系统更新中,限制了很多在桌面提示窗口相关的权限。所以,从体验上考虑,这个情况并不属于问题。

“那么我们可以选择哪些窗口的类型呢?”

  1. 使用子窗口: 在 Android 进程内,我们可以直接使用类型为子窗口类型的窗口。在 Android 代码中的直接应用是 PopupWindow 或者是 Dialog 。这当然可以,不过这种窗口依赖于它的宿主窗口,它可用的条件是你的宿主窗口可用

  2. 采用 View 系统: 使用 View 系统去模拟一个 Toast 窗口行为,做起来不仅方便,而且能更加快速的实现动画效果,我们的 SnackBar 就是采用这套方案。这也是我们今天重点讲的方案

“如果采用 View 系统方案,那么我要往哪个控件中添加我的 Toast 控件呢?”

Android进程中,我们所有的可视操作都依赖于一个 ActivityActivity 提供上下文(Context)和视图窗口(Window) 对象。我们通过 Activity.setContentView 方法所传递的任何 View对象 都将被视图窗口( Window) 中的 DecorView 所装饰。而在 DecorView 的子节点中,有一个 idandroid.R.id.contentFrameLayout 节点(后面简称 content 节点) 是用来容纳我们所传递进去的 View 对象。一般情况下,这个节点占据了除了通知栏的所有区域。这就特别适合用来作为 Toast 的父控件节点。

“我什么时机往这个content节点中添加合适呢?这个 content 节点什么时候被初始化呢?”

根据不同的需求,你可能会关注以下两个时机:

  1. Content 节点生成

  2. Content 内容显示

实际我们只需要将我们的 Toast 添加到 Content 节点中,只要满足第一条即可。如果你是为了完成性能检测,测量或者其他目的,那么你可能更关心第二条。 那么什么情况下 Content 节点生成呢?刚才我们说了,Content 节点包含在我们的 DecorView 控件中,而 DecorView 是由 ActivityWindow对象所持有的控件。 WindowAndroid 中的实现类是 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 消息的:(注: AcitivityThreadAndroid 进程的入口类, 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 消息的时候,将走以下的流程:

  1. 调用 performResumeActivity 回调 ActivityonResume 函数

  2. 调用 WindowgetDecorView 生成 DecorView 对象和 content 节点

  3. DecorView纳入 WindowManager (进程内服务)的管理

  4. 调用 Activity.makeVisible 显示当前 Activity

按照上述的流程,在 Activity.onResume 回调之后,才将控件纳入本地服务 WindowManager 的管理中。也就是说, Activity.onResume 根本没有显示任何东西。我们不妨写个代码验证一下:

//code DemoActivity.java

public 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。因此我们在 onResumepost一个消息就可以检测 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.445  2562  2562 D cdw     : onResume :0

从结果上看,我们在 WindowManager.addView 之后,也并没有绘制界面。那么,Android的绘制是什么时候开始的?又是到什么时候结束?

Android 系统中,每一次的绘制都是通过一个 16ms 左右的 VSYNC 信号控制的,这种信号可能来自于硬件也可能来自于软件模拟。每一次非动画的绘制,都包含:测量,布局,绘制三个函数。而一般触发这一事件的的动作有:

  1. View 的某些属性的变更

  2. View 重新布局Layout

  3. 增删 View 节点

当调用 WindowManager.addView 将空间添加到 WM 服务管理的时候,会调用一次Layout请求,这就触发了一次 VSYNC 绘制。因此,我们只需要在 onResumepost 一个帧回调就可以检测绘制开始的时间:

 @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 函数实现了以下流程:

  1. 调用 dispatchAttachedToWindow 通知子控件树当前控件被 attach 到窗口中

  2. 执行一个命令队列 getRunQueue

  3. 执行 meausre 测量指令

  4. 执行 layout 布局函数

  5. 执行绘制 draw

这里我们看到一句方法调用:

getRunQueue().executeActions(attachInfo.mHandler);

这个函数将执行一个延时的命令队列,在 View 对象被 attachView树之前,通过调用 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 系统主要依赖于两个类:

  1. SnackBar 作为门面,与业务程序交互

  2. SnackBarManager 作为时序管理器, SnackBarSnackBarManager 的交互,通过 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 控件中去。

这里有人或许会有疑问,这里使用强引用,会不会造成一段时间内的内存泄漏呢?假如你现在弹了 10Toast ,每个 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 对象发送一条消息,并在 mHandlerhandleMessage 函数中执行。 当NotificationManager 通知 TN 对象隐藏的时候,将通过 mHandler.post(mHide) 方法,发送隐藏指令。不论采用哪种方式发送的指令,都将执行 HandlerdispatchMessage(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 的问题而崩溃。