
在Android开发圈子混久了你会发现一个很有意思的现象很多项目里Activity长得都差不多——onCreate里findViewById重复几十遍initView、initData复制粘贴换了个类名什么时候加载数据、什么时候弹Toast、什么时候处理状态栏每个页面都有一套自己的写法。我一直觉得Activity的基类封装是每个Android开发者迟早要迈过的一道坎。BaseActivity这个名字听起来简单但真正把它设计好了后续十几个、几十个页面的开发效率完全是两个量级。这篇文章我想把我这些年沉淀下来的封装思路、代码实现、以及踩过的坑完整地聊一遍适合那些已经写过一阵子Android、开始觉得重复工作太多的朋友参考。1. 先说清楚BaseActivity到底封的是什么1.1 没有基类的日子所有页面都在重复同一件事我见过太多业务项目一个简单的列表页面从onCreate到数据返回代码能写两百行其中一百行是每个页面都差不多的模板代码。布局加载、控件初始化、状态栏设置、Loading显示、Toast提示、网络请求的进度管理页面退出时的资源清理这些东西在每个Activity里都是重复的但很多人都硬生生地复制粘贴了过来。最难受的不是写第一遍而是后续改动。今天产品说要给所有页面加一个统一的加载失败重试按钮你就要打开所有Activity改一遍漏掉任何一个页面线上就会出问题。明天UI说标题栏左边距要统一调整又是一轮全量改动。没有基类的情况下这种跨页面的统一需求全靠人肉去排查效率低还容易出错。还有一类重复是逻辑层面的比如界面销毁之后回调才返回这时候你需要在isFinishing或者isDestroyed判断防崩溃比如多个页面都要在onResume里做埋点比如每个页面都要在onDestroy里解绑某些监听器。这些代码单个页面看不多架不住页面多时间长了维护成本就相当可观。1.2 基类封装最终解决的四个核心问题我做了几年基类封装总结下来BaseActivity主要解决四个问题。第一是消除重复代码把布局加载、控件初始化的流程固定下来子类只关心自己差异化的部分。配合ViewBinding之后这块的重复率能下降很大一截。第二是统一交互体验。弹出的Toast样式、Loading动画、错误提示、空页面占位这些都是产品层面要求一致的东西。封装在基类里就保证了无论哪个开发写了新页面它的交互默认就是符合规范的。第三是提供生命周期安全管理。网络回调、Handler消息、第三方SDK的回调经常出现Activity已经销毁、回调才回来的情况。基类可以统一处理这个网络请求生命周期绑定让子类开发者不用每次都写一堆判断。第四是规范开发流程。基类定义了initView、initData、initListener这些标准流程新成员照着写就行代码风格会自然统一起来review代码也轻松很多。1.3 封装前的取舍判断不是所有东西都该进基类这里我要先泼一盆冷水基类不是越胖越好。我见过有的项目BaseActivity三百行起步什么方法都往里塞图片加载、JSON解析、数据库操作全在基类里结果就是每个页面都背负着一个巨大的父类改起来小心翼翼子类之间互相影响最后谁都不敢动。我的原则是基类只放通用能力不塞业务逻辑。什么是通用能力生命周期流程、UI容器、状态栏/系统栏、Loading与Toast、ActivityResult与权限请求这些是每个页面都用得到的。什么是业务逻辑用户登录状态的判断、订单数据的加载、购物车角标刷新这些内容是页面级别的放进基类就耦合了。另外要考虑一个原则抽象出来的方法一定是大多数子类都需要覆写的而不是少数页面才用的。比如整个项目只有一个页面需要全屏展示那就不该在基类里留一个abstract方法让所有页面都去实现更好的做法是让那个页面自己做特殊处理或者提供一个默认实现。基类封装的价值在于让80%的页面变得更快而不是为了那20%的特殊情况把基类搞得很复杂。2. 基类架构设计从包结构到抽象时机2.1 一个我落地过多次的包结构封装BaseActivity不是单独写一个类那么简单它要跟工具类、对话框、适配器等配合否则基类还是在裸奔。我这里给一个稳定的包结构实战项目里可以直接参考com.example.project ├── base │ ├── BaseActivity.java │ ├── BaseFragment.java │ ├── BaseViewModel.java │ └── BaseApplication.java ├── ui │ ├── toast │ │ └── ToastUtils.java │ ├── loading │ │ └── LoadingDialog.java │ └── statusbar │ └── StatusBarUtils.java ├── utils │ ├── LogUtils.java │ └── KeyboardUtils.java └── widget ├── LoadingLayout.java └── EmptyLayout.javabase目录放基类ui目录放UI辅助组件utils放通用工具widget放自定义控件。BaseActivity依赖这些组件但组件不依赖BaseActivity。这个结构的核心思想是让BaseActivity成为一个组装者把通用组件按固定流程组合起来而不是一个大杂烩容器。我见到不少人的BaseActivity自己实现Toast、自己写了Loading显示逻辑、自己处理状态栏结果每次改动基类所有页面都得回归一遍。拆开之后每一块都可以单独测试和调整基类只负责编排流程稳定性和灵活性都更好。2.2 模板方法模式在基类里的落地BaseActivity的另一个关键设计思路是模板方法模式。所谓模板方法就是父类定义好算法的骨架把某些步骤延迟到子类实现。在我们这个场景里骨架就是onCreate之后的完整执行流程子类需要实现的就是布局、控件、数据这些具体内容。用生活化的例子来说就像是快餐店的出餐流程接单、备餐、打包、交付是固定的但具体是哪一种汉堡、要不要加辣由顾客决定。BaseActivity把流程固定具体内容交给子类覆写。这里的关键在于钩子方法的设计。我认可的基类设计是调用链清晰、覆写点明确Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); initIntentData(savedInstanceState); initViewBinding(); setContentView(binding.getRoot()); initStatusBar(); observeViewModel(); initView(); initData(); initListener(); }子类只需要关心initView、initData、initListener这三个入口。而且这三个方法我给的默认实现都是空的子类按需覆写。这样新来的同事很容易理解打开BaseActivity看onCreate的执行顺序就知道自己应该在哪个方法里写什么。2.3 核心钩子方法的职责划分我们把几个钩子方法的边界说清楚这是容易混淆的地方。initIntentData用来处理Intent传参和 savedInstanceState 的数据恢复。这个方法的执行顺序在initView之前因为很多时候控件的初始值要依赖Intent里的数据。如果先初始化控件再去Intent取数据提示文案就要临时二次设置。initView只做一件事找控件、设置控件的基础属性。比如设置RecyclerView的LayoutManager设置下拉刷新控件的颜色给TextView设置字体大小。不要在这个方法里写业务判断更不要加载数据。initData负责数据的加载和填充。包括从网络拉数据、从数据库读数据、组装数据到控件上。我见过不少开发把initData和initView写在一起短平快的时候没什么问题但页面一复杂几个操作堆在一起调试的时候你就分不清是控件没初始化还是数据没回来。initListener负责事件回调的注册包括点击事件、滑动监听、列表项点击。单独分出来的好处是如果你想在某个页面暂时屏蔽所有交互只需要注释掉一行。最后是observeViewModel这个方法是给MVVM架构做准备的把LiveData或者StateFlow的观察者统一注册在这里保证数据回调时的UI安全和生命周期安全。2.4 关于继承层级和中间基类的处理有些项目里会再包一层比如BaseActivity下面是BusinessBaseActivity然后才是各个业务页面。BusinessBaseActivity可以放一些业务通用逻辑比如登录校验、埋点上报。但要注意一个常见问题层数越多覆写点越分散新成员越难搞懂应该在那一层覆写什么。我的建议是两层就够了BaseActivity存放项目级通用能力BusinessBaseActivity存放业务级通用能力不要层层继承。如果发现需要第三层先想想是不是设计上出了问题。另外如果中间层覆写了基类方法一定要保留super调用链我在实际项目中遇到过继承链断裂导致初始化流程没走完的严重事故这个问题我在后面的避坑章节还会详细说。3. UI初始化流程封装基于ViewBinding的实践3.1 为什么我最终选了ViewBinding早期开发无非是两条路findViewById写到手软或者上ButterKnife。我项目里ButterKnife用了很久它通过注解和APT技术把findViewById省掉了直接靠绑定的字段名操作控件用起来确实舒服。ButterKnife后续维护状态不稳之后Google官方推出的ViewBinding成了更加可靠的选择。ViewBinding的好处有几点。按类型安全编译期生成绑定类字段类型不会出错。按空安全如果布局里某个控件只在某个变体里存在绑定类里会有空判断避免直接解引用空指针。按性能相比DataBindingViewBinding不引入表达式引擎编译速度更快运行期没有反射开销。还有一点很重要ViewBinding是Google持续维护的方向跟Jetpack生态集成度好新项目我优先推荐它。BaseActivity通过泛型把ViewBinding的创建过程吸收了子类只需要提供一个方法返回绑定类。3.2 基类完整代码实现下面这一段是我项目里BaseActivity的核心骨架已经过多个版本迭代思路可以作为参考。public abstract class BaseActivityT extends ViewBinding extends AppCompatActivity { protected T binding; protected LoadingDialog loadingDialog; Override protected void onCreate(Nullable Bundle savedInstanceState) { super.onCreate(savedInstanceState); initIntentData(savedInstanceState); initBinding(); setContentView(binding.getRoot()); initStatusBar(); observeViewModel(); initView(); initData(); initListener(); } private void initBinding() { // 泛型反射获取实际绑定类 Type genericSuperclass getClass().getGenericSuperclass(); if (genericSuperclass instanceof ParameterizedType) { Type[] types ((ParameterizedType) genericSuperclass).getActualTypeArguments(); Class? bindingClass (Class?) types[0]; try { Method inflateMethod bindingClass.getMethod(inflate, LayoutInflater.class); binding (T) inflateMethod.invoke(null, getLayoutInflater()); } catch (Exception e) { throw new RuntimeException(ViewBinding inflate failed, e); } } else { throw new IllegalArgumentException(BaseActivity requires ViewBinding type parameter); } } /** * 在该方法中处理Intent参数和状态恢复 */ protected void initIntentData(Bundle savedInstanceState) {} /** * 初始化页面内控件设置基础属性 */ protected void initView() {} /** * 加载数据并填充到界面 */ protected void initData() {} /** * 注册事件监听器 */ protected void initListener() {} /** * 观察ViewModel的数据变化 */ protected void observeViewModel() {} /** * 初始化状态栏 */ protected void initStatusBar() {} Override protected void onDestroy() { super.onDestroy(); binding null; dismissLoading(); } }注意initBinding用了泛型反射来获取实际的ViewBinding类。有人可能会问为什么不直接在子类里写一行binding ActivityMainBinding.inflate(getLayoutInflater())因为在基类里统一创建整个流程就完全标准化了子类一行都不用管绑定创建。这就是基类封装的意义。3.3 initView、initData、initListener的执行节奏这三个方法为什么要分开我从一次重构的体会聊起。早期我把它们合并成一个init方法当时觉得页面生命周期简单没必要拆。后来页面复杂了RecyclerView的adapter要先创建adapter的数据要等网络回来网络回来之后要刷新列表刷新时又要先隐藏Loading整个流程揉在一起代码顺序稍微错一点就出问题。拆开之后顺序就固定了先有控件再有数据最后有交互。你绝对不会遇到adapter还没创建就给列表设置数据的问题也不会遇到数据还没回来就注册点击监听的尴尬。这类问题在代码review的时候就天然减少了。实际上initData这个方法名可能会让新人误解以为必须在这里发起网络请求。其实不一定initData更准确的理解是准备数据如果页面是本地数据直接读库填充如果是远程数据在这里触发请求并订阅回调如果一个页面有两种数据来源也应该在这里统一发起。网络数据真正返回之后一般会走一个renderData方法我习惯再加一个renderData子类按需覆写把数据渲染的逻辑放进去。这里是可选的按项目复杂程度来决定这里不强制但方法职责要单一。3.4 老项目从ButterKnife迁移到ViewBinding的注意事项如果你的老项目还依赖ButterKnife又想让BaseActivity统一用到ViewBinding迁移不是改写所有页面那么恐怖而是有新页面用新方式老页面保持现状过渡期两个共存就够了。有几个坑提前提示。第一ViewBinding的类名是根据布局文件名生成的例如activity_main.xml对应的绑定类是ActivityMainBinding如果布局文件名带下划线生成的类名会把下划线去掉并转驼峰。第二include标签的布局也需要单独创建绑定类然后在父类绑定中通过binding.xxxBinding访问。第三有些老布局里有merge标签这种情况下ViewBinding生成的方式略有不同建议先处理掉merge否则绑定类创建代码要调整。4. 高频功能模块的封装细节4.1 Toast统一别一个按钮连点出十个提示Toast在很多项目里是最不受重视的但恰恰是体验上最容易翻车的地方。我见过有的页面连点十次按钮Toast排队弹十次用户等半分钟都点不完。基类里统一Toast能力之后这类问题就能依靠工具层的单例机制解决。我的做法是基类里暴露showToast方法内部转给ToastUtils的静态方法处理ToastUtils内部用应用上下文持有唯一的Toast实例每次show之前取消旧Toastpublic final class ToastUtils { private static Toast mToast; public static void show(String message) { if (mToast null) { mToast Toast.makeText(AppContext.get(), message, Toast.LENGTH_SHORT); } else { mToast.setText(message); } mToast.show(); } }当然这只是最基础的版本实际项目中Toast往往要定制样式、指定位置、区分成功失败类型你可以在此基础上扩展。但核心思路不变全局唯一实例。4.2 Loading对话框别把Dialog写到页面里Loading是另一个容易失控的点。有的页面用ProgressDialog有的页面用一个自定义Dialog有的页面直接用一个View盖在上层风格乱七八糟。在BaseActivity里封装一个加载对话框问题就统一了。我在基类里维护了一个loadingDialog字段暴露showLoading和dismissLoading两个方法里面做了防重复、防泄露处理。关键的一个处理是显示Loading的时候要判断Activity是否还活着销毁之后不弹、不崩溃protected void showLoading() { if (isFinishing() || isDestroyed()) return; if (loadingDialog null) { loadingDialog new LoadingDialog(this); } if (!loadingDialog.isShowing()) { loadingDialog.show(); } }onDestroy里调用dismissLoading是为了防止Dialog持有Activity的引用导致泄漏。特别注意如果你在请求里通过runOnUiThread弹窗回调时Activity已经销毁这段判断能直接拦住这也是基类的价值所在。在Loading样式上我建议用局部的View加载状态而不是全局Dialog优先级更高。特别是列表页下拉刷新的时候本地LoadingLayout和全屏Loading最好能区分开。基类可以暴露两种方式一种是dialog式一种是layout式。layout式我把整套LoadingLayout、ErrorLayout、EmptyLayout做成了组合控件基类里保留一个setLoadState方法子类可以随用随调。4.3 状态栏与沉浸式处理状态栏这块几乎是每个Android项目都要处理的。各家ROM的差异、刘海屏适配、状态栏字体颜色深浅如果不封装每个页面都要跟系统API打交道。基类里统一处理之后可以支持两类页面一类是普通页面在统一背景色上让状态栏跟随变色另一类是图片头页面需要状态栏透明、内容延伸到状态栏底下。推荐的做法是抽一个StatusBarUtils工具类基类里的initStatusBar读取子类配置来决定调哪个方法。我的基类里会提供一个setupStatusBar方法子类可以通过重写它或者传标志位来控制。这里有个经验状态栏字体颜色的问题在MIUI、Flyme上有私有的API抽取工具类时把这两个特殊处理也包进去。坦白说各家厂商的适配代码我常年维护一个工具类更新频率不低这个真的不能省设备覆盖率太高了。4.4 返回键与页面关闭的统一处理产品里总有一个需求连续按两次返回键退出应用很多页面都要支持。与其在每个页面里覆写onBackPressed不如在基类里做一个通用方案Override public void onBackPressed() { if (supportDoubleBackExit) { long currentTime System.currentTimeMillis(); if (currentTime - lastBackTime 2000) { lastBackTime currentTime; showToast(再按一次退出应用); return; } } super.onBackPressed(); }这里我更喜欢把onBackPressed的现代版本写出来因为新项目里要兼容API 33的预测性返回整体逻辑还是以系统API为准。基类封装的思路是给你一个开关部分页面不需要双击退出直接默认super。这比每个页面写一遍判断要省心太多。5. 进阶设计ActivityResult、运行时权限请求与泄漏防护5.1 用ActivityResultLauncher替代startActivityForResult老代码里常见startActivityForResult和onActivityResult这套API已经过时了新的ActivityResultLauncher更安全、注册时机更早。在基类里封装Result回调的一个好处是子类不需要关心Activity里的回调分发只需要注册回调函数。后续在ActivityResultApi场景下倒是不用再写onActivityResult这个重方法回调直接通过launcher拿到结果。我常用的模式是把Launcher注册放在基类里通过一个结果回调接口分发private ActivityResultLauncherIntent activityResultLauncher; Override protected void onCreate(Nullable Bundle savedInstanceState) { super.onCreate(savedInstanceState); activityResultLauncher registerForActivityResult( new ActivityResultContracts.StartActivityForResult(), new ActivityResultCallbackActivityResult() { Override public void onActivityResult(ActivityResult result) { handleActivityResult(result.getResultCode(), result.getData()); } } ); } protected void startActivityForResult(Intent intent, ActivityResultCallback callback) { // 简化写法实际项目里通过key回调分发 activityResultLauncher.launch(intent); }一个要注意的坑registerForActivityResult必须在onCreate里调用一旦Activity被重建Launcher会再次注册所以不要把它放在页面业务逻辑的方法里调用。我见过在这个问题上热修复硬生生修出野指针的案例。ActivityResultLauncher的注册跟生命周期绑定越早越好这是API设计上的约束。5.2 权限请求在基类中的统一封装运行时权限在Android 6.0之后成为开发标配到了Android 13、Android 14之后权限请求之间已经是数量级的大坑访问相册、访问白名单、精准定位等等每个版本的权限表现还不一样。如果基类里做一层封装子类可以这样调requestPermission( Manifest.permission.CAMERA, new PermissionCallback() { Override public void onGranted() { // 启动扫描 } Override public void onDenied(boolean canAskAgain) { if (!canAskAgain) { showToast(权限被拒绝请到设置中开启); } } } );基类内部用一个ActivityResultLauncher去请求权限并把回调接口缓存起来这样权限结果会集中到一处处理。这不仅减少了子类的工作量还避免了一个隐患权限回调发生时Activity已经被销毁此时不能弹窗、不能跳设置页基类统一判断一次比在几十个页面分别判断要可靠得多。5.3 内存泄漏防护Handler、单例与退出清理Activity内存泄漏是Android的顽疾非静态内部类隐式持有外部类的引用导致Activity无法被回收。Java的Handler、Kotlin协程、第三方SDK回调都是泄漏高危区。BaseActivity里可以做几件事来防护。第一提供一个安全的Handler。子类用这个Handler发消息基类在onDestroy里清空所有消息消除延迟消息对Activity的持有protected Handler safeHandler new Handler(Looper.getMainLooper()) { Override public void handleMessage(NonNull Message msg) { BaseActivity.this.handleMessage(msg); } }; protected void handleMessage(Message msg) {} Override protected void onDestroy() { safeHandler.removeCallbacksAndMessages(null); super.onDestroy(); }第二统一解绑注册。比如EventBus、LiveDataBus、某些监听器可以要求子类在onDestroy里调一个unRegisterListener基类本身把通用解绑做了剩下的业务级别的解绑子类自行处理。第三协程方面我建议子类统一使用lifecycleScope基类不用额外封装lifecycleScope最大的优点就是页面销毁时自动取消协程不需要手写cancel。Kotlin项目里这个优势很明显Java项目里就只有手写cancel这一条路。6. 常见问题与排查实录6.1 子类忘记调用super导致初始化断裂我遇到最多的问题是子类覆写onCreate时忘记了super.onCreate(savedInstanceState)结果基类里的全套初始化流程都没有执行页面一打开就是白屏有的页面连崩溃信息都没有定位了半天。另一种常见情况是子类覆写onDestroy时漏了super.onDestroy导致绑定类没有置空、对话框没有取消重新进入页面时旧引用还在产生各种诡异现象。排查思路给BaseActivity的每个模板方法加一行日志输出方便在Logcat观察调用链。这个方法调试期非常有用发布前把日志关掉就行Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); LogUtils.d(BaseActivity, getClass().getSimpleName() onCreate start); ... }6.2 ViewBinding子类生成的类找不到ViewBinding类是通过布局文件名生成的比如activity_main.xml生成的类叫ActivityMainBinding。如果你把布局文件删了、改了名或者build目录被清理IDE有时候没及时同步编译就会报找不到符号。常规解决办法是Build - Clean Project然后Build - Rebuild Project。如果还不行看下buildFeatures的配置android { buildFeatures { viewBinding true } }如果项目里既用Kotlin又用Java位置略有不同但你只要确认gradle里开启viewBinding开关就行。注意ViewBinding默认对所有布局生成绑定类如果某个布局不需要绑定可以在根标签加tools:viewBindingIgnoretrue来跳过。6.3 泛型擦除导致的强转异常BaseActivity 这种写法在Java里会存在类型擦除如果子类声明的方式不对运行时获取的泛型参数可能不是预期的绑定类强转就直接ClassCastException。比如有人写了个中间层没把泛型透传下去到了真正的页面处就丢了。我的建议是子类必须直接继承BaseActivity 中间层如果要包一层也要把泛型参数原样传下去。还有一种更稳妥的做法不通过反射让子类直接返回绑定类实例protected abstract T createBinding(LayoutInflater inflater);这个方法虽然让子类多写一行代码但完全避开反射和泛型擦除的坑。两种方案都能用如果项目对性能比较敏感或者你不想维护反射代码我推荐子类传入绑定类实例的方式实测下来更稳。6.4 状态栏和系统栏冲突沉浸式适配最常踩的坑就是内容布局被状态栏遮挡或者状态栏背景和页面背景不一致出现刺眼的分割线。封装之后这类问题往往集中在初始化顺序上必须先设置状态栏再绑定布局否则布局没有预留系统栏高度内容就会跑到状态栏底下。我的方案里initStatusBar放在setContentView之后但紧接着就会在initView之前执行。这套顺序配合RootView设置fitsSystemWindows或者View设置paddingTop几千行的状态栏适配逻辑就被封装成一个方法了子类几乎感觉不到。体验上有个细节如果基类设置了两个布尔开关比如lightStatusBar控制状态栏图标深浅transStatusBar控制是否透明状态栏子类只需要在initStatusBar里调用相关方法。产品之后的百变需求就压缩到开关切换这是BaseActivity该有的姿态。6.5 继承结构里多了中间层怎么办有些项目喜欢在BaseActivity和具体页面之间再插一个BusinessBaseActivity放登录判断、放埋点逻辑这个我可以理解。但问题在于中间层的存在让模板方法的调用链变得更长稍不留神就会出问题。比如BusinessBaseActivity覆写了onCreate但忘记调用super.onCreate那基类的绑定流程就断了或者BusinessBaseActivity里加了abstract方法结果新增页面漏实现一编译就报错反而不利于快速开发。我建议中间层要么不去覆写基类流程只增加protected方法让子类按需调用要么严格按照super链完整保留。而且中间层的方法命名要沿用一个风格不然review代码的人根本分不清哪些是基类流程、哪些是业务逻辑。如果你问我多长时间可以把一个BaseActivity打磨到稳定可用我的体会是骨架构建一天就能完成但让它真正适应团队业务节奏需要两到三个版本的迭代沉淀。重点不是模仿别人写一个看起来很全的基类而是从自己项目的痛点里抽象出真正通用的方法这样封装出来的BaseActivity才是团队自己的基础设施而不是一份抄来抄去的代码模板。最后再分享一个小技巧基类写完以后不要急着把老页面全部迁移过来。先拿两三个典型页面试点确认流程没问题、扩展点够用再逐步铺开。封装这件事好比修桥方向对了桥墩稳了后续所有过河的人都受益方向没想清楚就动工返工成本只会越来越高。希望这篇文章里提到的设计思路和踩坑记录能帮你把BaseActivity这条桥修得更稳一些。