Android BaseActivity封装:整合ViewBinding、权限申请与加载弹窗

📅 发布时间:2026/9/8 13:55:39
Android BaseActivity封装:整合ViewBinding、权限申请与加载弹窗
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 BaseActivityVB : 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 : BaseActivityActivityMainBinding() { 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指向ActivityGC完全可以通过从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: ArrayString) { // 过滤掉已经被授予的权限 val needRequest permissions.filter { checkSelfPermission(it) ! PackageManager.PERMISSION_GRANTED }.toTypedArray() if (needRequest.isEmpty()) { onPermissionGranted(permissions) return } permissionLauncher.launch(needRequest) }RequestMultiplePermissions这个契约的好处是一次性可以传多个权限回调里拿到的是一个Mapkey是权限名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: ListString) // 被拒绝且勾选了不再询问此时只能去设置页手动开启 fun onDeniedWithAskNeverAgain(deniedPermissions: ListString) }这样设计的好处子类拿到回调的时候脑子里想的是我现在该引导用户去设置页还是提示用户权限被拒绝了而不是再去解析Map里的每个布尔值。举个例子申请相机权限去扫码requestPermission( arrayOf(Manifest.permission.CAMERA), object : PermissionCallback { override fun onGranted() { startScan() } override fun onDenied(deniedPermissions: ListString) { toast(相机权限被拒绝无法扫码) } override fun onDeniedWithAskNeverAgain(deniedPermissions: ListString) { 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:androidhttp://schemas.android.com/apk/res/android android:interpolatorandroid:interpolator/decelerate_quad translate android:duration250 android:fromXDelta100%p android:toXDelta0 / /set!-- slide_out_right.xml -- set xmlns:androidhttp://schemas.android.com/apk/res/android android:interpolatorandroid:interpolator/accelerate_quad translate android:duration250 android:fromXDelta0 android:toXDelta100%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里创建bindingonDestroyView里必须释放否则轻则泄漏重则在使用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的核心价值是帮子类解决所有页面共性的那些事至于页面级差异应当通过组合、扩展函数、或者子类直接覆写来落地而不是全都挤进基类里。如果团队里有人问我新写的页面该继承哪个类、需要实现哪些方法我希望答案是看一眼基类里的抽象方法和已有的模板立刻就知道该干什么。做到这一步这份代码就算真正值回票价了。