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

欢迎你们前往云+社区,获取更多腾讯海量技术实践干货哦~java

做者: QQ音乐技术团队 android

题记

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

1.回顾

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

  1. Toast 系统如何构建窗口(经过系统服务NotificationManager来生成系统窗口)
  2. Toast 异常出现的缘由(系统调用 Toast的时序紊乱)

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

2.解决思路

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

“那么咱们能够选择哪些窗口的类型呢?”函数

  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 的父控件节点。post

“我什么时机往这个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 的问题而崩溃。

相关阅读

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

Android基础:Fragment,看这篇就够了

Android图像处理 - 高斯模糊的原理及实现

此文已由做者受权云加社区发布,转载请注明文章出处

相关文章
相关标签/搜索