本文译自《Context, What Context?》
注:文中提到的“导入布局”,即是指利用LayoutInflater来inflate layout的操作。
Context类对于做Android开发的同学肯定不陌生,但或许许多同学都没有正确地使用Context实例。
Context实例非常常见,在许多的情境下(加载资源、启动一个Activity、取得一个系统级的Service、取得应用独有的文件存储路径还有创建View等)都需要用到一个Context实例,但如果不加区分地使用任意的Context实例,很容易会导致一些没意料到的状况发生。
Context的种类
并不是所有的Context实例都是一样的构造流程。常见的Context子类如下所列:
Application——在你的应用进程中单例存在的一个实例。可以通过Activity或Service的getApplication()方法或者其他任意Context子类的getApplicationContext()方法来取得。不论是在哪里以及何时取得的Application实例,它都是进程唯一的。
Activity/Service——继承自ContextWrapper类,它们实现了与Context类同样的API,但代理了所有的方法到一个对外不可见的Context实例,也就是它们的base Context。每当系统框架创建一个新的Activity或者Service实例时,它同时也会创建一个ContextImpl实例去执行不同的组件所需要做的不同逻辑。每个Activity或Service,以及它们相应的base context,都是实例唯一的。
BroadcastReceiver——这并不是一个Context子类。但每个Receiver都会实现onReceive(Context context, Intent intent)这个回调方法,每次系统发送通知都是调用到这个回调方法,这里就给Receiver传入了一个Context实例。这里传入的Context实例又与其他的Context实例不一样,这里传入的Context实例是不能调用registerReceiver()方法和bindService()方法的。每次发送一个通知的时候,这里传入的Context实例都是不一样的。
ContentProvider——这同样也不是一个Context子类。但它内部持有一个Context实例,这个实例可以通过getContext()方法取得。如果ContentProvider与调用者是运行在同一个进程中,那么它的getContext()方法返回的Context实例其实就是这个进程里的始终单例的Application Context。不过如果ContentProvider与调用者是运行在不同的进程中的,如应用A去调用应用B的ContentProvider,那么这时候ContentProvider的getContext()方法返回的则是应用B里的Application Context。
引用的保存
呐,我们先来说说非常常见的一种保存Context实例的引用从而导致内存泄漏的情形:一个实例或一个类,它保存了一个生命周期比自己短的Context实例,这就会导致内存泄漏。举个例子,创建一个需要依赖一个Context实例的单例类来进行一些通用操作如加载资源、调用一个ContentProvider,并把当前Activity或者Service作为它依赖的Context实例设置进去。
错误单例的示范
public class CustomManager {
private static CustomManager sInstance;
public static CustomManager getInstance(Context context) {
if (sInstance == null) {
sInstance = new CustomManager(context);
}
return sInstance;
}
private Context mContext;
private CustomManager(Context context) {
mContext = context;
}
}
这段代码最大的问题是我们并不知道传入的Context参数是啥Context,所以对于我们这个单例来说直接保存这个Context的引用是很危险的(例如这里的Context是一个Activity或者Service的时候)。因为单例里面的对象是静态的,这就会导致它引用的所有资源都不会被系统GC回收掉,假设这里的Context是一个Activity的话,我们这样做就会导致这个Activity相关的View啊还有别的占内存的对象一直不能被系统回收掉,进而导致了内存泄漏。
为了避免这种情况,我们在下面的单例中改为始终是保存Application Context的引用。
正确单例的示范
public class CustomManager {
private static CustomManager sInstance;
public static CustomManager getInstance(Context context) {
if (sInstance == null) {
//不管什么Context,都改为取Application Context
sInstance = new CustomManager(context.getApplicationContext());
}
return sInstance;
}
private Context mContext;
private CustomManager(Context context) {
mContext = context;
}
}
这样我们就不用关心传入的Context到底是什么了,因为我们现在持有的引用是Application Context。就像前文提到的,Application Context是在整个应用程序中进程单例的,所以哪怕我们在代码中对它持有静态引用也不会导致什么内存泄漏。
那,为什么我们不能总是使用Application Context来完成各处需要Context的逻辑呢?这样不就可以永不担心Context相关的内存泄漏了吗?原因其实很简单,就像我在一开头就提到的——一个Context实例并不一定能与另一个Context实例等同。
不同种类的Context的能力区别
直接参考下表即可:
|Application | Activity | Service | ContentProvider | BroadcastReceiver
---|---|---|---|---|---
构造展示一个Dialog | NO | YES | NO | NO | NO
启动一个Activity | NO1 | YES | NO1 | NO1 | NO1
导入布局文件 | NO2 | YES | NO2 | NO2 | NO2
启动一个Service | YES | YES | YES | YES | YES
绑定到一个Service | YES | YES | YES | YES | NO
发送一个广播 | YES | YES | YES | YES | YES
注册一个BroadcastReceiver | YES | YES | YES | YES | NO3
加载资源数值 | YES | YES | YES | YES | YES
附注:
- 一个非Activity的Context可以用于启动一个Activity,但这样启动的Activity需要新创建一个Activity堆叠栈。这个在某些特定情形下或许会适用,但这种设计一般来说都不太好。
- 这个其实也是可以的,但是这样导入的布局会用当前系统的默认主题来设置,而不是用你在你的应用程序中设定的主题来设置的。
- 在Android 4.2及以上的系统里,如果receiver是null,那这也是可以的。这样做是为了取得一个严格广播的当前值。
用户交互界面
从上表可以看出好些操作不适合使用Application Context来执行,而这些操作无一例外地全都是和用户交互界面直接相关的。适合执行这些与用户交互界面直接相关的操作的Context只有一种,那就是Activity;其他的Context其实和Application Context的功能都差不多。
不过其实这些个与UI相关的操作其实大多数时候都是在Activity中才会有执行的机会。假设使用一个非Activity的Context来调用展示一个Dialog,在调用Dialog实例的show()方法时就会报以下的错误直接崩溃:
Caused by: android.view.WindowManager$BadTokenException: Unable to add window -- token null is not for an application
又或者使用一个非Activity的Context来启动另一个Activity,同样也会报错崩溃:
Caused by: android.util.AndroidRuntimeException: Calling startActivity() from outside of an Activity context requires the FLAG_ACTIVITY_NEW_TASK flag. Is this really what you want?
但如果是使用一个非Activity的Context来导入布局,应用并不会报错崩溃。详细的流程可以参见我之前写的《布局的导入》。此时,Android框架会默默地返回你需要的布局文件对应的View,其中的各个View的层次关系都是正确正常的,只是你在应用程序中设定的主题和样式(在AndroidManifest.xml中设定的值)不会被应用到此时导入布局文件而产生的View中去,而是应用了系统默认的主题。这是因为在Manifest中定义的主题实际上是仅仅绑定到Activity这种Context上的,所以如果使用非Activity的Context实例来导入布局,那就只会应用系统默认的主题,从而导入了一个可能并不是你所期望的布局样式。
但上述规则是不是有不完善的地方?
有些同学在开发的时候会发现,依照目前的程序设计,我们的程序就是要长时间的持有一个Context实例,而且这个实例还必须是Activity,因为在这长时间的持有过程中,会涉及到UI相关的操作逻辑。那么假设真的有这种情况,我强烈建议你们重新审视你们的程序的设计,因为这种情形完全就是在对抗Android系统框架。
经验总结
在大多数情形下,代码是跑在哪类Context内就使用当前可获得的这类Context即可。只要这个Context类引用并不会超脱出它所引用的组件的生命周期,那你完全可以在你的逻辑代码中持有这个引用。但是如果你需要长时持有一个Context引用,这个引用甚至会超脱你的Activity或Service的生命周期,哪怕仅仅是短暂地超脱出生命周期,也务必要把这个Context引用改为Application引用。
译者说两句
这段时间断更了抱歉。
这篇文章虽然是2013年的老博文了,但在我看来还是非常有学习价值的。这是我第一次翻译技术类文章,所以可能表述得不太好,我日后会继续努力提升翻译水平的。
依文中所说,在需要Context的时候,直接取能取到的“最近”的Context实例即可,一般情形下是不会导致内存泄漏的。举个例子,在一个Activity A里有个Fragment a,然后Fragment a里面有Adapter View,那这时候就需要透传Context实例来构造Adapter View里面的Item View了,那这时候,其实大胆地在a里面透传A的引用到Adapter中其实是没有问题的,只要不要把持有的A的引用声明为静态就好。
再比如,在后台有个定时任务或者什么的,在特定时机要往SharedPreferences里面写数据啊或者要读取资源文件中的string字符串啥的,这时候就可以在定时任务的代码中长期持有一个Application Context的引用来执行相关的操作,这样也是不会引发内存泄漏的。