1. 为什么要写这份BaseActivity:一个被重复代码逼出来的决定
今年接手一个维护了大半年的项目,里里外外跑了一遍代码,最让我难受的不是业务逻辑多复杂,也不是第三方SDK接得多乱,而是那9个Activity里几乎都躺着一份一模一样的showLoading()方法。有的用了对话框,有的用了遮罩View,有的干脆弹Toast,加载中的样式五花八门。后来上了ViewBinding,每个页面又都要写一遍binding = ActivityMainBinding.inflate(layoutInflater),然后setContentView(binding.root),页面多了之后这行代码就变得格外刺眼。
动了封装BaseActivity的念头,不是为了炫技,也不是为了让代码看起来"架构很清晰",纯粹是被这些重复劳动磨得没脾气了。如果你也是独立开发,或者在一个小团队里维护多个业务模块,应该能理解这种处境:每个新页面都要重复处理绑定、权限、加载态、跳转动画,这些跟具体业务半毛钱关系都没有的事,却每天都在消耗时间。
我期望中的基类应该做到几件事:第一,子类继承之后,几乎不用感知ViewBinding的初始化过程,拿来就用;第二,动态权限的申请和回调能够收敛到一个统一入口,不要在业务代码里散落一堆onRequestPermissionsResult的判断;第三,加载中弹窗只需要一行代码就能控制显示和隐藏;第四,页面跳转动画全局统一,新页面默认就带上转场效果,不需要每个页面单独写。这四个点,就是这篇分享的核心。适合谁看呢?初级Android开发想把代码写规整的,以及中小型项目中想减少重复代码但又不想引入太重框架的人。下面我就按实际编码的顺序,把每个部分的思路和踩过的坑展开聊。
2. 基类骨架与ViewBinding绑定:先把地基打牢
2.1 为什么绑定工作必须收敛到基类,而不是让子类自己写
先明确一个观点:ViewBinding本身解决的是findViewById的繁琐和类型不安全问题,但如果每个Activity里都自己写一遍初始化,那等于把这种繁琐搬了个地方。真正舒服的写法是,子类只管提供布局文件,基类负责把binding对象准备好,子类直接通过binding.xxx访问控件。
实现这个目标需要用到泛型。定义一个抽象基类:
abstract class BaseActivity<VB : ViewBinding> : AppCompatActivity() { lateinit var binding: VB private set override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) binding = createBinding() setContentView(binding.root) initView() initData() } protected abstract fun createBinding(): VB protected open fun initView() {} protected open fun initData() {} }这里有个设计上的取舍需要讲清楚:为什么不用反射,而是要求子类实现createBinding()方法?反射当然可以做到"子类只传一个layoutId",比如拿到布局id后配合DataBindingUtil去inflate,但ViewBinding生成的绑定类名和包名并不总是规规矩矩的,加上混淆、R8之后更容易出问题。与其在运行时搞这些花活,不如让子类明确返回绑定对象,编译期就能保证类型正确,IDE自动补全也方便。
实际使用的时候,子类长这样:
class MainActivity : BaseActivity<ActivityMainBinding>() { override fun createBinding(): ActivityMainBinding { return ActivityMainBinding.inflate(layoutInflater) } override fun initView() { binding.tvTitle.text = "首页" binding.btnCommit.setOnClickListener { ... } } }三行声明,一个创建方法,剩下的就是业务。这套东西看着简单,但解决了几个很实际的痛点:binding实例不会因为Activity重建而丢失,ViewModel和liveData的配合也顺畅了;子类里不会再出现!::binding.isInitialized这类防御代码。
2.2 关于onDestroy时解除绑定的问题:别把binding变量留着养老
有经验的同事看到lateinit var binding可能会提醒你:Activity销毁了,binding还持有View引用,会不会内存泄漏?这里我要先纠正一个常见的误解:Activity和它的View是互相引用的,当Activity销毁时,ViewRootImpl会把这个View从Window上移除,View的mContext指向Activity,GC完全可以通过"从GC Roots不可达"来判断整棵View树可以回收。所以Activity里的ViewBinding并不会导致Activity泄漏,真正会泄漏的是"静态变量持有了View/Activity引用"或者"生命周期比Activity长的对象持有Activity引用"这类情况。
不过,不泄漏不代表什么都不用做。Fragment场景下binding的泄漏风险其实比Activity高得多,因为Fragment的View在onDestroyView之后仍然可能被其他对象持有,比如长时间运行的任务回调还在往UI上写。所以我在基类里保留了onDestroy的清理动作,统一释放实例状态:
override fun onDestroy() { super.onDestroy() // 清理还在显示的loading弹窗 dismissLoading() // 如果有需要解绑的监听、广播,在这里统一处理 }Activity的binding我不建议在onDestroy里置空,原因很简单:置空之后如果某个异步回调在Activity销毁后触发了binding.xxx的访问,就是一个空指针崩溃,而Activity销毁后View本身不再刷新,访问binding其实是无害的。真出了问题(异步回调更新UI)那是业务侧的设计问题,不该靠基类的"技巧"来掩盖。
2.3 基类里放ActivityResult API,申请权限的前置准备
现在的startActivityForResult已经过时了,我在基类里也统一封装了一套ActivityResult注册入口。为什么要在基类里做这件事?因为权限申请在AndroidX下最推荐的方式就是registerForActivityResult,而它必须在onCreate之前注册,否则会抛异常。把注册动作放到基类,子类就不用记这个"注册时机"的约束了。
private val permissionLauncher = registerForActivityResult( ActivityResultContracts.RequestMultiplePermissions() ) { result -> handlePermissionResult(result) } protected fun requestPermission(permissions: Array<String>) { // 过滤掉已经被授予的权限 val needRequest = permissions.filter { checkSelfPermission(it) != PackageManager.PERMISSION_GRANTED }.toTypedArray() if (needRequest.isEmpty()) { onPermissionGranted(permissions) return } permissionLauncher.launch(needRequest) }RequestMultiplePermissions这个契约的好处是,一次性可以传多个权限,回调里拿到的是一个Map,key是权限名,value是是否授予。配合基类统一处理,子类调一个方法就行。这个部分其实只是准备工作,真正的权限逻辑封装,放到下一节细说。
3. PermissionUtils动态权限:把最啰嗦的代码收进基类
3.1 动态权限的痛点:API在变,需求也在变
Android 6.0之后动态权限成了标配,到了Android 11、12,存储权限、定位权限、通知权限的申请逻辑又各玩各的。最烦的不是"申请权限"本身,而是申请之后的一堆回调处理:用户同意了做什么、拒绝了做什么、勾选了"不再询问"又该怎么办。这些逻辑如果每个页面各写一份,排查问题的时候简直想死。
我自己不推荐直接在项目里引入权限三方库,像RxPermissions这类库虽然历史悠久,但RxJava的依赖链太重了,而且很多核心能力通过ActivityResult API本身就能实现。自己写一个PermissionUtils,控制在两三百行,完全够用,还不用背第三方库的维护负担。
3.2 把权限结果回调成一个接口:基于语义而不是基于权限名
我设计的方案是,PermissionUtils只做两件事:检查权限、发起申请。至于"申请之后干什么",通过回调接口传出去。关键设计在于,回调不叫onPermissionResult(String permission, boolean granted),而是拆成三个更贴近业务的回调:
interface PermissionCallback { // 全部授权通过 fun onGranted() // 部分或全部被拒绝(不包含"不再询问") fun onDenied(deniedPermissions: List<String>) // 被拒绝且勾选了"不再询问",此时只能去设置页手动开启 fun onDeniedWithAskNeverAgain(deniedPermissions: List<String>) }这样设计的好处,子类拿到回调的时候,脑子里想的是"我现在该引导用户去设置页,还是提示用户权限被拒绝了",而不是再去解析Map里的每个布尔值。举个例子,申请相机权限去扫码:
requestPermission( arrayOf(Manifest.permission.CAMERA), object : PermissionCallback { override fun onGranted() { startScan() } override fun onDenied(deniedPermissions: List<String>) { toast("相机权限被拒绝,无法扫码") } override fun onDeniedWithAskNeverAgain(deniedPermissions: List<String>) { showDialogToSettings() } } )PermissionUtils内部要维护一个"本次请求的权限清单和回调"的一一对应关系,因为系统回调是异步的,而且一次只能有一个请求在途。我的实现里用一个PendingRequest数据类,把当前请求的权限列表和回调绑在一起,收到结果之后立刻消费掉,防止连续请求时回调错乱。
3.3 特殊权限不在这套体系里,但可以预留接口
通知权限(Android 13)、悬浮窗权限、精确闹钟权限,这些属于特殊权限,申请方式和普通运行时权限不一样。PermissionUtils里我留了一个requestSpecialPermission的扩展入口,子类需要的时候自己传一个Intent跳转设置页,回调结果可以用ActivityResultRegistry来监听。说实话,特殊权限的场景在普通业务App里不多,不要一开始就把基类做成一坨无所不包的"瑞士军刀",真到了需要的时候再扩展也来得及。
3.4 实测中容易踩的坑:权限弹窗只能弹一次?
这里要明确一个系统行为:权限弹窗第一次弹出来,用户如果点了"拒绝",下次再申请时弹窗还是会出来,但弹窗上会多一个"不再询问"的勾选框;一旦用户勾了,以后requestPermissions再调用就不会弹窗了,直接返回拒绝。
要想判断"是否被勾选了不再询问",必须通过shouldShowRequestPermissionRationale这个方法。但这个方法有个脾气——如果应用从未申请过某个权限,它返回false;如果用户拒绝过但没勾"不再询问",它返回true;如果用户勾了"不再询问",它又返回false。所以正确逻辑是:
if (!checkSelfPermission(permission)) { if (shouldShowRequestPermissionRationale(permission)) { // 用户拒绝过,但还能再弹窗,需要解释为什么要这个权限 } else { // 要么是第一次申请,要么是被勾了“不再询问” // 区分方法:查一下AppOps或者看之前是否记录过申请历史 } }我第一次封装的时候就被这个逻辑绕晕了,后来在Demo里用了一个简单粗暴的记法:在SharedPreferences里记录"是否申请过该权限",如果申请过但shouldShowRequestPermissionRationale返回false,那基本可以认定勾了"不再询问"。这套逻辑虽然不是官方推荐,但在实际项目里跑得很稳。
4. 加载弹窗:从能用、好用再到可定制
4.1 为什么选择DialogFragment,而不是Dialog或者自定义View
加载弹窗的实现方案,我在不同的项目里分别用过ProgressDialog(已废弃)、AlertDialog加自定布局、PopupWindow、DialogFragment。最后沉淀下来的结论是:普通页面用DialogFragment,全屏遮罩式的加载态用自定义Layout更合适。
为什么要用DialogFragment?因为show()和dismiss()走上的是FragmentManager的生命周期管理,当Activity因为配置变更(比如旋转屏幕)重建时,DialogFragment会被FragmentManager自动保存和恢复,不会出现"对话框还开着但Activity已经死了"的崩溃。而ProgressDialog悬浮在Window上,Activity销毁后你还想dismiss的话,轻则报WindowLeaked,重则直接崩掉。
4.2 基类里的调用方式:一行代码解决
我在BaseActivity里定义的对外方法很简单,子类可不关心内部实现:
protected fun showLoading() { showLoading(null) } protected fun showLoading(message: String?) { dismissLoading() loadingDialog?.let { if (it.isAdded) return } loadingDialog = LoadingDialog.newInstance(message) loadingDialog?.show(supportFragmentManager, LoadingDialog.TAG) } protected fun dismissLoading() { loadingDialog?.let { if (it.isAdded) { it.dismissAllowingStateLoss() } } loadingDialog = null }dismissAllowingStateLoss()这里非常关键,用dismiss()在某些异常状态下会抛IllegalStateException: Can not perform this action after onSaveInstanceState,用了dismissAllowingStateLoss()规避状态丢失的异常,安全性高很多。不过代价是极端情况下对话框的关闭状态可能不会被保存,为了稳定性这点小瑕疵可以接受。
4.3 弹窗的交互细节:防抖、取消策略、超时
实际开发中,加载弹窗有几个容易被忽视的细节:
第一,防抖。如果业务代码里有多个并发请求,每个请求都在onStart里调用showLoading(),那么会出现"第一次弹窗还没消失,第二次又调show"的尴尬。我的做法是在showLoading()入口先执行dismissLoading(),确保同一时刻只有一个弹窗实例。这个操作很粗暴,但很有效。
第二,取消策略。默认setCancelable(false),防止用户误触关闭弹窗后,页面上还有半截请求在跑。但也有些场景需要支持取消,比如上传大文件时用户想主动终止,所以我在LoadingDialog里加了一个可配置参数。
第三,超时兜底。网络异常时可能出现"弹窗一直挂着"的现象,虽然底层的网络库有超时,但UI层面最好也加一道保险。我在showLoading()里默认开了一个30秒的Handler.postDelayed,到时间自动隐藏弹窗并回调一个onLoadingTimeout的钩子,子类可以借此做重试逻辑或者提示用户。
4.4 自定义样式的设计思路
加载弹窗的UI,默认我提供一种简洁风格:居中一个圆环转圈动画,下方一行提示文字,背景半透明。但不同App的视觉风格差异很大,所以LoadingDialog的设计不应该写死,而是支持从外部传入样式配置:
data class LoadingConfig( val message: String? = null, val cancelable: Boolean = false, val dimAmount: Float = 0.3f, val layoutRes: Int? = null, val gravity: Int = Gravity.CENTER )layoutRes允许业务方完全替换弹窗内容布局,gravity支持把弹窗摆到底部或者顶部。这套配置下来,既有默认的省心体验,又给特殊场景留了口子。我在实际项目里遇到过个别页面要求加载弹窗必须靠底部显示,而且不能遮挡标题栏,这些需求如果没有配置项,就得专门写一个新Dialog子类,维护成本翻倍。
5. 跳转动画与页面切换的全局统一
5.1 默认跳转的生硬感,和覆写的正确姿势
Android默认的Activity切换动画是"从右滑入、从右滑出",说实话在大多数App里都能接受,但如果你接的是设计稿严格、想要统一品牌感的项目,默认动画往往达不到要求。在基类里处理跳转动画,我采用的方式是覆盖startActivity和finish,而不是在子类每个跳转处再加一行overridePendingTransition。
基类里可以这样覆写:
override fun startActivity(intent: Intent?) { super.startActivity(intent) overridePendingTransition(enterAnim, exitAnim) } override fun finish() { super.finish() overridePendingTransition(enterAnim, exitAnim) }enterAnim和exitAnim可以是基类默认值,也允许子类通过一个getTransitionAnim()方法覆盖。比如需要在特定页面使用特殊转场效果(比如图片预览页从底部弹出),子类只需要重写这个方法,而不用关心overridePendingTransition的调用时机。
5.2 动画资源怎么选:文件方式还是代码方式?
动画资源我推荐放在res/anim目录下用XML定义,因为动画参数(时长、插值器、位移距离)后续大概率会被设计稿调整,改XML方便太多,不用重新编译代码。举例两个常见的:
<!-- slide_in_right.xml --> <set xmlns:android="http://schemas.android.com/apk/res/android" android:interpolator="@android:interpolator/decelerate_quad"> <translate android:duration="250" android:fromXDelta="100%p" android:toXDelta="0" /> </set><!-- slide_out_right.xml --> <set xmlns:android="http://schemas.android.com/apk/res/android" android:interpolator="@android:interpolator/accelerate_quad"> <translate android:duration="250" android:fromXDelta="0" android:toXDelta="100%p" /> </set>这里有一个容易踩的坑:fromXDelta如果写"100%p",表示的是父容器宽度的100%,也就是整个屏幕宽度;如果写"100%",表示的是控件自身宽度的100%。Activity转场动画里,我们想要的是整个页面平移,必须用%p,我之前顺手写成"100%"后,动画效果就像鬼畜一样只平移了半个屏幕,排查了好久才发现是百分比后缀的问题。
5.3 路由跳转、Fragment切换时动画的边界
要特别说明一下:这种基于Activity的跳转动画,对Fragment切换是不生效的。Fragment的转场要用setCustomAnimations()配合FragmentTransaction来做。我在BaseActivity里也封装了一个switchFragment方法,统一Fragment的切换动画,比如淡入淡出或者左右滑动,这样模块内的页面切换也能保持和Activity跳转一致的手感。
还有一类特殊情况:如果你用了Navigation组件或者ARouter这类路由框架,跳转实际上不经过startActivity,这时候在基类里覆写startActivity是拦不到动画的。常见做法是在路由框架的navigation回调里统一处理overridePendingTransition,或者在目标Activity的onCreate结尾处理。这些小细节,等集成的时候自然会遇到,提前知道能省不少事。
6. 继承链之外的细节:懒加载、内存泄漏与团队规范
6.1 这个基类也适合Fragment吗?说点实话
很多朋友会问:BaseActivity封装了,Fragment要不要也写一套BaseFragment?我的答案是:要写,但别照抄Activity的基类。
Fragment的写法比Activity更麻烦,因为它有个"View的生命周期和Fragment实例生命周期不一致"的问题。onCreateView里创建binding,onDestroyView里必须释放,否则轻则泄漏,重则在使用ViewPager2的场景下出现"Fragment视图已经被销毁,但代码还在访问旧View"的错乱。
Fragment中的懒加载也是一个重头戏。配合ViewPager预加载,用户滑到第二个Tab时,第一个Tab的onResume已经跑过了,setUserVisibleHint(旧版)或者onHiddenChanged(新版)才是判断"页面真正对用户可见"的正确时机。我在BaseFragment里统一封装了一个onFirstVisible()方法,模拟"首次真正可见时加载数据"的经典需求:
override fun onResume() { super.onResume() if (isVisibleToUser && !hasLoadedOnce) { hasLoadedOnce = true onFirstVisible() } }这个逻辑看起来简单,但实际项目里"第一次进入页面的请求"和"从后台切回来时的刷新"往往不能用同一个回调,很多Crash就是这两个时机的判断没处理好。
6.2 内存泄漏:基类代码里最常见的几个雷
我写基类的时候,踩得最疼的几个雷,在这里一起说了:
一是静态方法持有Activity引用。很多人喜欢写一个UiUtils.toast(Context context),然后从Activity里传入this,如果UiUtils里用静态变量存了这个context,就等着泄漏吧。基类不该引导这种用法,我自己的处理是:toast这类方法全部通过基类实例方法提供,比如showToast(),内部直接用applicationContext,不持有Activity。
二是Handler和Runnable没有remove。加载弹窗的30秒超时那个Handler,我在onDestroy里做了removeCallbacksAndMessages(null)清理,不然Activity销毁后Runnable还在排队,容易误触发UI操作。
三是监听器注册/反注册不对称。很多基类喜欢在onStart注册广播、在onStop注销,这没问题,但如果你在onCreate注册了一份,又忘记在onDestroy里注销,那就会以指数级叠加的方式泄漏。我在基类里预留了统一的注册/反注册钩子,确保配对使用。
6.3 这份基类承载的不只是代码,更是团队的默契
项目进展到多人协作阶段后,我越来越觉得,BaseActivity的实际价值不在代码量,而在"约束"。有了统一的权限回调语义,新人写权限申请就不会再把回调逻辑塞到业务里;有了统一的弹窗入口,测试也不会再遇到"两个页面两种Loading样式"的困惑。
不过也要说一句大实话:任何封装都有成本,不要为封装而封装。如果你项目里就三五个Activity,而且都快写完了,那花一整天重构基类可能不太划算。但如果是新项目,或者刚接手一个需要长期迭代的老项目,趁早把这层地基打好,后面每个人都会受益。
我在实际使用中还有一个体会:基类不要太厚。很多人追求"基类里面把所有事情都做了",结果就是子类重写方法几十个,文档比代码还长,新人看着根本不知道从哪下手。BaseActivity和BaseFragment的核心价值,是帮子类解决"所有页面共性"的那些事,至于页面级差异,应当通过组合、扩展函数、或者子类直接覆写来落地,而不是全都挤进基类里。
如果团队里有人问"我新写的页面该继承哪个类、需要实现哪些方法",我希望答案是:看一眼基类里的抽象方法和已有的模板,立刻就知道该干什么。做到这一步,这份代码就算真正值回票价了。