干Android开发这些年,要说哪个API天天都在用、又天天有人翻车,Context绝对排得上号。Context模式在Android里不是像策略模式那样可以随意复用的代码结构,它是应用组件和系统服务之间的通道:Activity能弹出Dialog、Service能后台下载、Application能全局初始化,靠的都是各自的Context去和系统打交道。可就是这根“通道”,藏了不少坑——单例里乱存Activity Context导致内存泄漏,Dialog里传了Application Context直接崩,两个页面共用一个全局Context结果主题、Toast全都串味。这篇就围绕context-mode这个话题,把Context的设计逻辑、选型规则、封装手法和排障经验一次性讲清楚,适合刚入门的Android初学者,也适合写了两三年代码还是被Context坑过的中级开发者。
我见过太多同事一遇到Context问题就东改一行西改一行,最后靠重启App糊弄过去。其实Context这套机制是有清晰规律的,搞懂它背后的继承关系和生命周期,很多问题根本不会发生。下面按我自己平时排查问题的思路,从设计原理讲到代码落地,最后给一份可以直接抄的避坑清单。
1. Context到底是个什么东西:设计与职责拆解
1.1 一句话理解Context的本质
Context直译是“上下文”,但这个词在Android里更像一个“环境引用”。你可以把它理解为一份能调系统资源的通行证:拿到它,才能启动Activity、发广播、取数据库、读SharedPreferences、拿包名版本号,才能new出一个View并让它知道自己被谁持有、继承什么主题。
回到源码层面,Context是个抽象类,里面定义了大量抽象方法,比如startActivity、getSystemService、getSharedPreferences等等。真正干活的实现类是ContextImpl,它内部持有ActivityManager、PackageManager、WindowManager等系统服务代理,通过Binder与系统进程通信。而开发者在Activity、Service里拿到的this,其实是通过一系列包装层层包裹后的对象。这个“抽象接口 + 实现类 + 包装类”的组合,就是我理解中的context-mode:一种让组件复用系统能力,又保留各自身份的机制。
设计上为什么要搞这么一层,而不是直接全局单例给个静态方法?因为不同组件的生命周期、视觉表现、任务栈归属都不一样。如果所有地方都塞同一个Context,你分不清当前界面属于哪个Activity,也就没法正确处理返回栈、主题、语言和Configuration变更。Context在这里的作用,既是一张通行证,也是一张身份证。
1.2 三种Context背后是三种身份与生命周期
Android里最常见的三种Context来源是Application、Activity和Service。Application跟着整个App进程走,从fork进程到进程结束一直在。Activity的Context随着Activity的创建和销毁而生死,旋转屏幕、退出页面都会让它失效。Service的Context则跟Service生命周期绑定,和Activity最大的区别是没有界面、没有主题,也不建议做UI操作。
这句话听起来简单,但实际工程里很容易犯的错是:把一个Activity引用到处存。Activity被finish了,那份引用还在,系统想回收Activity却回收不了,连锁反应就是整个页面对象树、以及它持有的View、Bitmap、资源全部泄漏。所以选Context的第一原则是:谁的生命周期长,就用谁的。要全局初始化、缓存数据,用Application Context;要弹窗、打开新页面、绑定UI主题,优先Activity Context;要在后台做长时间任务,Service Context和Application Context都是合理的,关键看你的任务是否依赖Service本身的生命周期。
这里还有一个容易忽略的问题:同一个应用里,Activity、Service、Application是三个不同的Context对象,它们在包名、资源、系统服务上都一致,但在主题、Token、任务栈上差异很大。你用Application Context去启动一个Activity,系统会因为不知道要放进哪个任务栈而崩溃,这个后面会展开。
1.3 类的继承关系决定了你会踩哪些坑
很多人把Context当成一个普通接口看,忽略了继承关系对行为的影响。源码层级是这样的:Context是抽象基类;ContextImpl是唯一真正的实际实现;ContextWrapper继承Context,内部持有一个mBase引用,所有方法都转发给mBase;ContextThemeWrapper再继承ContextWrapper,额外负责主题;Activity继承ContextThemeWrapper,Service继承ContextWrapper,Application也继承ContextWrapper。
这个结构的直接后果:你拿到的每一个Context对象,行为都取决于它被谁包着。比如调用Activity.this.getResources()和applicationContext.getResources(),在大多数情况下返回相同资源,但如果你启用了多语言或夜间模式,Activity的资源会先做对应Configuration处理,Application不一定跟你同步。再比如inflate一个布局,用Activity的Context,子View能正确拿到Activity主题里的属性(比如actionBar、colorAccent),用Application的Context,很多主题相关的属性就丢了。
理解了这条继承链,你才能明白为什么“换一个Context传参”就可能引发完全不同的行为。别觉得这是玄学,后面所有问题都可以从这条链上找原因。
2. 三种Context怎么选:核心细节与实操要点
2.1 一张表看懂Context选型
我先把最常用的场景和推荐Context列成一张表,方便你贴到项目README或者团队Wiki里,日常开发直接对着查。
| 使用场景 | 推荐Context | 理由 |
|---|---|---|
| 启动Activity | 当前Activity Context | 有任务栈归属,无需额外Flag |
| 非Activity环境启动Activity | Application/Service Context | 必须加FLAG_ACTIVITY_NEW_TASK |
| 弹出Dialog | Activity Context | 依赖Activity窗口Token,随Activity生命周期 |
| 创建Toast | Activity优先 | 长生命周期下部分定制主题Toast会异常 |
| inflate布局 | 当前界面Context | 保住主题/资源适配 |
| 单例/全局缓存 | Application Context | 与应用同生命周期,安全不泄漏 |
| 获取系统服务 | 任意Context | 系统服务本身是单例,Context只是一把钥匙 |
| 创建BroadcastReceiver | Application Context | 避免与页面生命周期耦合 |
这张表不是绝对真理,但覆盖了90%以上的工程场景。核心逻辑就一句话:只管应用全局的,用Application;跟界面有交互的,用Activity;拿不准就对查这张表,至少能避开最常见的崩溃。
2.2 什么时候必须用Activity Context
有三个场景,用Application Context几乎必炸。
第一个是Dialog。Dialog需要拿到所在Activity的Window Token,Application Context没有Token,创建时就抛BadTokenException之类的异常。就算手动加了一些特殊类型的Window,弹出来的样式和层级也会脱离页面控制,用户旋转屏幕后问题更多。所以Dialog必须用Activity Context,最好用当前栈顶Activity的引用。
第二个是startActivity。从Activity里直接启动新页面没问题,但从Application或Service里startActivity,系统不知道新页面放在哪个任务栈,必须手动加上Intent.FLAG_ACTIVITY_NEW_TASK。这个Flag的意思是让系统为这个Activity新建一个任务栈,否则直接Crash。工程里常见的推送跳转、桌面Widget点击跳转,都是在这个坑里翻滚过。
第三个是依赖主题属性的View操作。比如你用LayoutInflater.from(applicationContext)去inflate一个带Material组件的布局,组件会去找Application主题里到底绑没绑定colorPrimary、colorSecondary这些属性。既然Application不是UI界面,默认主题往往是设备默认主题,结果就是你看到一个样式完全不对的控件,或者在某些品牌ROM上直接崩。所以涉及UI的东西,优先把Activity Context传下去,别图省事传全局的。
2.3 getApplicationContext与getBaseContext别搞混
Activity类里能调用getApplication()拿到Application对象,也能调用getApplicationContext()拿到Application的Context,这两个在绝大多数情况返回同一个东西,但语义上有细微差别。getApplication()返回的是Application实例,你可以调用方法、访问自定义成员;getApplicationContext()返回的是Context接口,是接口视图。而getBaseContext()返回的是ContextWrapper持有的mBase,也就是内部被包装的那个原始ContextImpl,日常业务代码里基本用不到。
工程里更容易踩的是另一种情况:你写了一个自定义View、ViewModel或工具类,内部用mContext.getApplicationContext()去拿全局Context,结果mContext本身已经是一个失效的Activity。虽然getApplicationContext()按说还能拿到Application对象,但这个调用链上如果代码不小心写成了直接持有Activity,问题就已经埋下了。我见过不少团队把getApplicationContext当万能保险砖用,最后该泄漏还是泄漏,因为没有理解“保险砖”救不了“提前持有”的错。
正确姿势是:如果需要全局Context,在Application的onCreate里统一存成静态变量,后续所有地方直接用那个静态变量。谁也不要私自把Activity传来传去。
2.4 判断当前Context类型的实用手法
有时候你在调试一个通用方法,想确认传进来的Context到底是Activity还是Application,可以这样判断:
public static String resolveContextType(Context context) { if (context instanceof Activity) { return "Activity"; } else if (context instanceof Service) { return "Service"; } else if (context instanceof Application) { return "Application"; } else if (context instanceof ContextWrapper) { // 递归拆包装,找到最内层的实现 return resolveContextType(((ContextWrapper) context).getBaseContext()); } return "Unknown"; }为什么要递归拆包装?因为很多第三方框架会把Context再包一层ContextWrapper,直接instanceof判断会误判。递归到base里,才能真正识别实际身份。这个方法在排查别人传进来的Context来源时特别好用,配合堆栈日志一眼就能看出是谁在作妖。
3. 手把手实战:Context的正确注入与封装
3.1 自定义Application和全局ContextHolder
最简单也最常用的做法是自定义Application,在onCreate里把自己存下来:
public class App extends Application { private static App sInstance; @Override public void onCreate() { super.onCreate(); sInstance = this; } public static App getInstance() { return sInstance; } }注意一下:这里用静态变量持有Application是安全的,因为Application的生命周期等于整个进程,进程结束它才结束,不存在“Long-lived对象持有短生命周期对象”的泄漏问题。但如果哪天你把这个静态变量的类型写成Activity,那恭喜,你亲手写了一个教科书级内存泄漏。
有人会把App.getInstance()封装成一个ContextHolder:
public final class ContextHolder { private static Application sApplication; private ContextHolder() {} public static void init(Application application) { sApplication = application; } public static Context get() { if (sApplication == null) { throw new IllegalStateException("ContextHolder not initialized"); } return sApplication; } }然后MainApplication里调一下ContextHolder.init(this),任何地方都能ContextHolder.get()。这套方案的好处是解耦:业务类不需要依赖具体Application类型,只依赖一个纯静态工具类。坏处是你的单元测试里需要先init,否则get()会抛异常,所以封装里最好加一个判空,用明确的报错提示方便查问题。
3.2 ContextWrapper到底能干什么
ContextWrapper这个名字很多人只在源码里见过,但实际工程里它非常有用。它是一个“包装器模式”的经典实现:持有原始Context,然后把所有方法转发给原始Context。你想在不动原始Context的情况下,局部修改它的行为,继承ContextWrapper重写想要的方法就行。
一个实际例子:夜间模式。应用里想为某几个页面强制换一套主题,又不想动这个页面内部所有View的Context传参,可以做一个ThemedContextWrapper,重写getTheme()返回指定主题,然后用它去inflate布局、创建Dialog。另外,动态替换资源目录、拦截系统服务调用,也都可以通过继承ContextWrapper来做。
public class ThemeContextWrapper extends ContextWrapper { private final int mThemeResId; public ThemeContextWrapper(Context base, int themeResId) { super(base); mThemeResId = themeResId; } @Override public Resources.Theme getTheme() { Resources.Theme theme = getResources().newTheme(); theme.applyStyle(mThemeResId, true); return theme; } }使用的时候:
Context context = new ThemeContextWrapper(activity, R.style.NightTheme); LayoutInflater.from(context).inflate(R.layout.fragment_home, parent);这样这个布局里的所有View都会基于NightTheme来解析属性。要注意的是,ContextWrapper包装后的Context并不是Activity本身,有些依赖Activity身份的操作(比如从一个包装Context再去startActivityForResult)会拿不到Activity的调用结构,所以它更适合用在纯UI构建、资源解析类场景。
3.3 系统服务获取的正确姿势与ContextCompat
Context.getSystemService(String)是老接口,每次传入字符串名,返回Object,用完还得强转,容易写错名字。从API 23开始有了getSystemService(Class)泛型版本,比如Context.WIFI_SERVICE改成context.getSystemService(WifiManager.class),代码干净多了。
再进一步,AndroidX提供了ContextCompat.getSystemService(context, WifiManager.class),好处是它会自动处理API版本差异,同时内部有缓存逻辑,不会每次都走一遍Binder调用。我建议能用ContextCompat就用ContextCompat,别手搓字符串。常见系统服务如LocationManager、NotificationManager、SensorManager、LayoutInflater、ConnectivityManager,都可以走这个入口。
这里有个认知要纠正:getSystemService返回的是系统全局的单例服务,跟Context对象没有一对一关系。无论你用Activity还是Application去取,拿到的都是同一个服务实例。Context在这里只是一张取货券,不是服务本身。所以不要在系统服务这个点上纠结“该用哪个Context”,重点是别把取到的服务存到不该存的地方。
3.4 防泄漏的单例写法示例
单例持有Context是内存泄漏重灾区。最简单安全的写法是只在单例内部使用Application Context:
public class DataManager { private static volatile DataManager sInstance; private final Context mAppContext; private DataManager(Context context) { mAppContext = context.getApplicationContext(); } public static DataManager get(Context context) { if (sInstance == null) { synchronized (DataManager.class) { if (sInstance == null) { sInstance = new DataManager(context); } } } return sInstance; } }get方法里即使调用方传的是Activity,构造函数里一行getApplicationContext()就把作用域锁死到进程级别了。但要注意,构造函数里别写成直接赋值context,那样Activity就被静态单例持有了。
还有一种情况:组件偶尔需要Activity引用,但又不想长期持有,可以用WeakReference:
public class UiHelper { private final WeakReference<Activity> mActivityRef; public UiHelper(Activity activity) { mActivityRef = new WeakReference<>(activity); } public void showDialog(String message) { Activity activity = mActivityRef.get(); if (activity == null || activity.isFinishing()) { return; } new AlertDialog.Builder(activity) .setMessage(message) .show(); } }WeakReference能保证不阻碍Activity回收,但是在使用前一定要判空,Activity可能已经被回收了。养成“弱引用 + 判空 + 检查isFinishing/isDestroyed”的习惯,Context相关的内存问题能少一半。
4. 高频踩坑实录:内存泄漏、崩溃与定位手段
4.1 内存泄漏:Context最常见的死法
Context内存泄漏的经典剧本有几个。
剧本一:静态View。一个静态变量持有View,可View内部持有Activity的Context,Activity销毁时View还挂在静态变量上,整个页面就泄漏了。修复很简单:能不用静态View就不用,非要用也得在onDestroy里清掉。
剧本二:Handler延迟任务。在Activity里new一个Handler,postDelayed一个长时间任务,或者网络回调回来时Activity已经销毁,回调还在执行。由于Handler不是静态内部类,它隐式持有外部Activity,导致任务不结束页面不回收。修复思路要么在onDestroy里removeCallbacks,要么用静态Handler + WeakReference持有Activity,要么直接用生命周期感知的ViewModel + LiveData/Flow。
剧本三:注册未注销。比如在onCreate里注册了BroadcastReceiver或传感器监听,忘了在onDestroy里反注册。系统那边还持有着这个Context,页面销毁不了。这种错特别隐蔽,因为不报错,只涨内存。
剧本四:单例传参随手写。一个外部传入Activity给Util类存成成员变量,Util本身又是长生命周期的,等于Activity间接被长期持有。修复参照上一节DataManager的写法,要么只取Application Context,要么弱引用。
4.2 Inflate与弹窗的经典崩溃现场
Inflate崩溃最常见的一种是“非法在非UI线程操作View”,这个通常不是Context直接问题。另一种跟Context强相关的崩溃是:用Application Context去inflate一个引用自定义style的布局,在Android低版本上会直接抛异常,说找不到styleable属性。原因是Application Context的主题不是你想的那个主题,里面没有对应属性。遇到这种崩溃,别去改全局主题(影响面太大),把inflate的Context换成Activity Context就解决了。
弹窗崩溃另一种高频形态是:Dialog在Activity销毁后才show。用户点了一个按钮,异步网络请求回调回来,这时页面已经finish,代码里直接dialog.show(),就崩了。修复方案是在show之前检查Activity是否isFinishing()或isDestroyed()。这个问题不是Context传错,而是生命周期时序没管好,但表现上特别像“Context失效崩溃”。
还有一个容易被忽视的是PopupWindow和View的attach时机。不要在onCreate里直接show,要等页面resumed之后,用view.post或者监听onWindowFocusChanged,确保窗口已经attach,否则报WindowManager$BadTokenException之类的错。
4.3 疑难杂症速查表
我整理了一些平时答疑总结的问题速查表,遇到类似现象可以直接对照:
| 现象 | 根因 | 处理建议 |
|---|---|---|
| 用Application Context启动Activity崩溃 | 缺FLAG_ACTIVITY_NEW_TASK | 加Flag或用Activity启动 |
| Dialog报BadToken | 窗口Token失效或Context非Activity | 确保Dialog持有Activity Context,show前判生命周期 |
| 布局样式不符合预期 | inflate时用了全局Context | 换成当前页面Context |
| 全局主题改了但Dialog不生效 | Dialog绑定了页面主题或默认主题 | 用ContextThemeWrapper指定主题 |
| 静态持有Activity导致内存只升不降 | 长生命周期对象持Activity | 换成Application Context或弱引用 |
| getSystemService返回null | 服务名写错或权限缺失 | 用ContextCompat并检查权限 |
| 切语言后部分页面不刷新 | 使用了Application的Resources | 用Activity的Resources或enableOnConfigurationChange |
这些坑我都在不同项目里实际见过,有的排查花了一整天,最后发现就是一个Context传错位置。
4.4 排查工具与定位思路
排查Context相关内存泄漏,最直接的工具是LeakCanary。接入后,当Activity或Fragment泄漏时,它会自动在通知栏弹出一条分析结果,直指持有它的引用链。如果看到某条链路指向你的单例类或静态变量,那基本就能锁定是Context被不恰当持有了。
没有LeakCanary的情况下,用Android Studio自带的Memory Profiler也能查。操作路径是:运行App -> Profiler -> 选Memory -> 手动GC -> Dump Java heap -> 按包名过滤,看Activity实例数量。如果某个Activity被销毁后实例数仍大于0,说明有泄漏。点开实例看References到堆栈引用,再用Find Usages去找是谁持有。
再补充一个很多人不知道的定位技巧:在复现泄漏前,先把开发机上的“不保留活动”开发者选项打开,模拟Activity被系统销毁的场景,很多在正常退出时不一定触发的泄漏会被放大。配合日志里的GC和PSS曲线,能更快暴露问题。
5. 进阶:工程规范与团队落地方案
5.1 组件化项目里的Context约定
组件化架构下,各业务模块独立编译,如果各写各的Context获取方式,很容易出现某个模块用静态上下文Builder传入、某个模块直接靠SDK提供全局Context,最终维护成本很高。可以在底座公共库中定义ContextProvider接口,由主工程Application实现并注入。各模块不依赖具体Application类型,只依赖接口。这样模块单独调试时有自己的Mock实现,集成时有主工程实现。
public interface ContextProvider { Context getAppContext(); }主工程里:
public class MainApplication extends Application implements ContextProvider { private static ContextProvider sProvider; public static ContextProvider getProvider() { return sProvider; } @Override public void onCreate() { super.onCreate(); sProvider = this; } @Override public Context getAppContext() { return this; } }各模块里统一用MainApplication.getProvider().getAppContext()获取全局Context,禁止在模块间互相传Activity。这个约束不一定需要强依赖,只要在Code Review时盯着点,就能避免模块间Context串味。
5.2 Context相关的Code Review自查清单
分享一个我们团队Review时用的清单:
- 单例和静态成员是否只允许持有Application Context?
- Activity是否通过弱引用传递,且使用前判空?
- Handler回调里是否有removeCallbacks操作?
- Dialog/Toast是否在show前检查Activity生命周期?
- 有没有在onCreate里直接弹窗或inflate?
- 自定义View里使用Context时,是否区分了UI和工具类?
- 传给第三方SDK的Context,是否优先传Application?
- 异步回调里是否访问了已销毁页面的View?
这条清单不是理论,都是从事故里一条条攒出来的。每条背后都对应一个线上或灰度期的崩溃,对照着走一遍,能省掉大量线上排查时间。
5.3 与Jetpack架构结合的Context处理
如果项目已经用了ViewModel和LiveData,Context问题会少很多。ViewModel不应该持有Activity引用,它内部可以通过AndroidViewModel拿到Application。数据请求、SharedPreferences操作都放仓库层,UI层从ViewModel暴露的数据去渲染,避免在回调里手动持Activity。网络回调回来只更新LiveData,Activity处于STOPPED状态时不会触发UI更新,等于从架构层面消灭了一类Context崩溃。
举个具体例子:之前一个同事在Activity里发起网络请求,回调里直接updateView(),快速退出重进后偶尔闪退。改成LiveData后,回调只改LiveData的value,UI层自动感知生命周期,从根上规避了“页面已销毁还在操作View”的问题。如果你还在用回调地狱式的写法,Context问题只会一直反复出现。
我个人在实际项目里养成了一个习惯:每个方法需要Context参数时,先问一句“这个Context能不能换成Application?”能换就换,不能换就限定成Activity并写清楚生命周期。别小看这一句提问,它逼着我把Context选型变成肌肉记忆,而不是每次等到崩溃日志出来才开始找原因。另外,团队里可以约定一个工具类,比如BaseApplication提供getAppContext(),依赖全局Context的代码统一走它,ActivityContext只允许出现在View层和交互层,从架构上挤压掉乱传Context的空间。
最后再分享一个小技巧:如果你不确定某个第三方库会不会长时间持有你传进去的Context,就传Application Context。这个习惯至少能帮你躲掉大半库引起的泄漏问题。Context模式说穿了就是生命周期匹配的问题,把这条理清了,Android入门路上最大的几个坑,你也就跨过去了。