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

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

作者: QQ音乐技术团队

题记

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

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

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

  1. Content 节点生成
  2. 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 消息的时候,将走以下的流程:

  1. 调用 performResumeActivity 回调 Activity 的 onResume 函数
  2. 调用 Window 的 getDecorView 生成 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。因此我们在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.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 绘制。因此,我们只需要在 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 函数实现了以下流程:

  1. 调用 dispatchAttachedToWindow 通知子控件树当前控件被 attach 到窗口中
  2. 执行一个命令队列 getRunQueue
  3. 执行 meausre 测量指令
  4. 执行 layout 布局函数
  5. 执行绘制 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 系统主要依赖于两个类:

  1. SnackBar 作为门面,与业务程序交互
  2. 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 控件中去。

这里有人或许会有疑问,这里使用强引用,会不会造成一段时间内的内存泄漏呢?
假如你现在弹了 10 个 Toast ,每个 Toast 的显示时间是 2s 。也就是说你的最后一个 SnackBar 将被 SnackBarManager 持有至少 20s。而 SnackBar 中又存在有父控件 mTargetParent 的强引用。相当于在这20s内, 你的mTargetParent 和它所持有的 Context (一般是 Activity)无法释放

这个其实是不会的,原因在于 SnackBarManager 在管理这种回调 callback 的时候,采用了弱引用。

private static class SnackbarRecord {
        final WeakReference 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,toast,api,java,开发)