小试Toast封装

0x00 前言

Toast想必大家都很熟悉了。我们经常用Toast对用户进行一些不需要交互的提示。可以说是app开发肯定会用到的。可是原始的Toast还不够友好,或者说,对用户不够友好。在app安全方面也有一些不够完善的点。但是通过对Toast的封装,能够尽可能的避免这些问题,为用户带来更好的体验。

尽管Toast封装的博客已经非常多了,但是这几天查阅了很多,发现要么就是只是简单的封装了调用的方法,并没有解决实质性的问题;要么就是一个源文件了事,却对其中实现的原理只字不提,让人摸不到头脑。所以本文在一篇源码的基础上进行分析,明白其中的原理,希望对读者能够有所帮助。

0X01 Toast探究

Toast是直接依托于Windows来实现的。Toast所创建的视图不属于任何Activity,甚至不属于任何Application,它是直接在整个系统window之上进行创建的。Toast的生命周期也不会依赖于Activity和Application。所以我们经常会看到这样的现象:即使在退出应用后,Toast仍然会持续显示,直到它的duration耗尽。

另外还有一个现象大家也很熟悉,当连续地显示几个Toast的时候,后来的Toast并不会直接覆盖前面的toast,而是会等待之前的Toast显示完毕,耗尽其duration之后才会显示,处在后面的Tosat显示就会被滞后。若是只有两三的Toast还好一点,若是连续七八个,十几个,我想没人搞的清楚到底哪个Toast是对应刚才操作的提示吧。从其先被触发的先显示的逻辑来看,我们猜测其底层实现应该是队列的形式。那么就深入去看看。

首先是makeText()方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public static Toast makeText(Context context, CharSequence text, @Duration int duration) {
Toast result = new Toast(context);

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的显示也是用一个布局加载解析而成的。所以Toast的显示应该是支持自定义布局的。但这并不是我们此时想要研究的。继续看show()方法:

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

虽然不知道mNextView代表什么,但是看到enqueueToast()这个方法立刻就能让我联想到队列,看来之前的猜测已经对了一半,从代码来看,Toast将其有关的内容通过INotificationManager 这个AIDL接口加入了一个队列中,再想深入暂时就没办法了。

既然可以加入队列,那么就有对应的出队的操作。Toast的有个方法cancle()可以取消Toast的显示:

1
2
3
4
5
6
7
8
9
public void cancel() {
mTN.hide();

try {
getService().cancelToast(mContext.getPackageName(), mTN);
} catch (RemoteException e) {
// Empty
}
}

对比其中的cancelToast()方法与上面的enqueueToast()的参数,发现二者的前两个参数是基本一致的。那么我推测cancelToast()的底层应该就是出队操作。

通过以上分析,Toast的显示是可以取消的,所以是有办法让Toast实时刷新的。接下来看另外一个问题。

一般我们在使用Toast的时候会使用Activity类型的Context去创建一个Toast。这就会造成一些问题。首先在一个子线程中当需要展示一个Toast的时候,就无法提供一个Context。聪明一点的会在当前的Activity中存储一个静态Context并引用自己,来供当前Activity下的子线程引用。然而这样会造成一个更危险的问题,就是内存泄漏。

考虑一个场景,app的当前Activity刚刚创建了一个Toast并把它展示了出来。然而很不幸,这个Activity因为某种原因意外退出了。上面我们提到,Toast的生命周期是不依赖于Activity的,即使创建它的Avtivity被关闭了,Toast仍然会按照它预定的duration显示。这个时候,需要明确一点,Toast中仍然保持着对这个Acticity的Context的引用。这样子导致的结果就是,这个Activity现在无法被正常关闭。因为Toast仍然持有Activity的context,所以GC按照其回收原则是无法销毁这个Activity的。那么这个Activity所占用的内存也就无法被释放。也就是发生了内存泄漏了。

0x02 Toast封装

通过以上几个方面的了解,无论是从用户体验来讲,还是从应用安全的角度,封装Toast都是很有必要的。下面就开始对Toast进行封装。

首先要解决Toast的内存泄漏问题。这个问题是由于Toast持有本应该被销毁的Activity的Context的引用,所以导致Activity无法正常被回收。考虑一下,在一个app中,Toast是由一些预设的条件被用户或场景触发,而对用户进行提示。那么同一个app中,不会同时需要显示一个以上的Toast,即使有连续的Toast需要显示,那么肯定也是存在先后顺序的。因此,我们可以让整个app全局只存在一个Toast对象,当需要显示新的Toast的时候,只要先将之前的Toast取消,再创建新的Toast就可以了。这应该就是传说中的单例设计模式了。

同时默认不使用Activity类型的Context去创建Toast,而是使用Application的Context来创建Toast。根据Android官方的介绍,Application是一个全局对象,它是在这个应用/包被创建的时候进行实例化。如果某个单例需要引用context,就可以在自定义的Application对象的初始化方法中进行引用。

/**
 * Base class for those who need to maintain global application state.In
 * most situation, static singletons can provide the same functionality in a
 * more modular way.  If your singleton needs a global context (for example
 * to register broadcast receivers), the function to retrieve it can be
 * given a {@link android.content.Context} which internally uses
 * {@link android.content.Context#getApplicationContext() Context.getApplicationContext()}
 * when first constructing the singleton.
 */

而此时我们正好是单例的引用需要,所以大胆地使用Application的Context。但是在非Activity和非Application是不能直接使用getApplicationContext()这个方法获取Application的context的,所以直接在Application中创建一个静态对象存储Application的context,这样任何时候都可以直接调用这个context了。

新建MyApplication :

1
2
3
4
5
6
7
8
public class MyApplication extends Application {
public static Context context;
@Override
public void onCreate() {
super.onCreate();
context = this;
}
}

然后修改manifest文件:

<application
    android:allowBackup="true"
    android:icon="@mipmap/ic_launcher"
    android:label="@string/app_name"
    android:supportsRtl="true"
    android:name=".MyApplication"
    android:theme="@style/AppTheme">
    <activity android:name=".MainActivity">
        <intent-filter>
            <action android:name="android.intent.action.MAIN" />
            <category android:name="android.intent.category.LAUNCHER" />
        </intent-filter>
    </activity>
</application>

前面我们提到,Toast是被加入到一个队列中,然后按照先进先出的顺序被显示。既然被放进了队列中,肯定要有人去取出来才能够显示,尽管Toast的cancle()方法或许有类似的功效,但是很明显,这个方法只能出队,并不能显示Toast。《Android开发艺术探索》中提到,Toast的实现底层是基于Handler消息机制来完成。我们之前发现的enqueueToast()方法就是将Toast加入到对应的Toast队列中。既然是Handler的实现,那么肯定少不了Looper,否则谁去把队列中的内容拿出来进行分发呢(参考Android消息机制浅析)。所以我们需要创建一个可以用来处理 main 线程的 Handler 对象来处理 Looper 取出的 Toast。为什么是 main 线程呢,为了使全局都可以任意使用Toast,显然将显示Toast的操作放在main线程里是最合适的。另外还有一点需要知道,我们在初始化Handler的时候,其构造方法内部已经帮助我们调用了Looper.prepare()和Loop.loop()方法。在没有调用这两个方法的线程里,是无法为当前线程建立MessageQueue并且进行消息轮询的。

Handler的两种构造方法:

mHandler = new Handler(Looper.myLooper()); //可产生用来处理当前线程的Handler对象。
mHandler = new Handler(Looper.getMainLooper()); //可产生用来处理main线程的Handler对象。

其中new Handler(Looper.myLooper())new Handler() 是等价的。

接下来构造一个ToastUtils工具类对Toast进行封装:

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
public class ToastUtils {
/**
* 全局Toast对象
*/
private static Toast mToast;
//创建可以处理main线程的Handler对象
private static Handler handler = new Handler(Looper.getMainLooper()) {
@Override
public void handleMessage(Message msg) {
//先取消正在显示的Toast
if (mToast != null) {
mToast.cancel();
}
String message = (String) msg.obj;
mToast = Toast.makeText(MyApplication.context, message, msg.arg2);
mToast.show();
}
};

public static void toast(String message, int duration) {
//将Toast需要的参数发送到消息队列
handler.sendMessage(handler.obtainMessage(0, 0, duration, message));
}

public static void toast(String message) {
if (!TextUtils.isEmpty(message)) {
toast(message, Toast.LENGTH_SHORT);
}
}
}

这样一来,当需要显示Toast的时候,只要调用ToastUtils.toast()方法就可以尽情显示了,并且不用怕显示滞后,和内存泄漏的问题了。同时由于Toast的显示和取消都是在main线程里,所以在子线程里也可以尽情使用Toast进行提示,并且调用方法与主线程里一模一样。

本文有几处文字表达或许并不是非常正确,如果你发现了表述错误或者其他不合理的地方,恳请留言批评指正。万分感谢。