ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Android BaseFragment最佳实践:ViewBinding懒加载与统一状态管理

Android BaseFragment最佳实践:ViewBinding懒加载与统一状态管理 在Android项目里待得久了你会发现Fragment是最考验“基建功底”的东西之一。同一个列表页有人用ViewModel把状态管得清清楚楚有人却把加载框、空页面、接口回调全堆在Activity里一个页面两三百行十几个页面就是两三千行的重复逻辑。BaseFragment说不上是什么高深技术但它是团队工程化最容易见效的一层抽象统一onCreateView和onViewCreated的节奏约束loading、empty、error的状态切换规定哪些方法子类可以重写、哪些不允许碰。这篇内容以我做Android业务框架多年的视角聊一聊BaseFragment的基类设计思路和最佳实践。会覆盖模板方法、ViewBinding泛型、懒加载、统一状态管理、生命周期释放、常见深坑以及Compose时代的演进方向。适合正在搭建规范项目的中高级开发也适合拿到一堆历史Fragment、打算逐步收敛的团队。你不需要照抄代码关键是理解它为什么要这样拆。1. 为什么需要BaseFragment痛点和封装边界1.1 原生Fragment的六大痛点先说我观察到的原生Fragment开发痛点尤其是多人协作的中大型项目里特别明显。第一个痛点是生命周期代码重复。每个Fragment都要写onCreateView、onViewCreated、onDestroyView里面全是findViewById、setContentView、控件初始化的模板代码。写三个页面还能忍写到三十个页面你会发现所有人都在做同一件事只是局部细节不一样。第二个痛点是可见性与数据加载不同步。ViewPager2默认会预加载相邻页面如果每个Fragment都在onResume里请求数据就会出现“用户还没滑到、网络已经请求了好几个页面”的浪费如果只在onResume请求又可能出现页面切换不刷新。到底什么时候该加载数据团队里没有统一答案。第三个痛点是页面状态混乱。加载中转圈、请求失败展示、空数据提示这套逻辑每个页面都在写写出来的样子还各不相同。有人用DialogDialogFragments有人直接在布局里放一个ViewGroup切换可见性有人用第三方库。没有一个统一的出口测试和改版都很痛苦。第四个痛点是空指针和内存泄漏高发。最典型的就是网络异步回调里执行getActivity()页面切走之后直接崩或者onDestroyView之后还拿着旧的binding去更新UI结果界面无响应甚至触发异常。第五个痛点是页面和页面之间、Fragment和Activity之间的通信混乱。有人直接在Fragment里调用(activity as MainActivity).xxx()有人写一个全局单例做事件中心还有人用arguments传参时key散落在各个类里。基类如果不提供一个约束每个人都会自由发挥。第六个痛点是新人不遵守规则。没有基类约束新人写Fragment完全看个人习惯有的在onStart初始化数据有的在onActivityCreated里初始化review代码时你想统一都无从下手。有了BaseFragment代码评审至少有一个明确的对照基准。1.2 封装边界不是越厚越好BaseFragment最大的误区就是“什么都往里塞”。我见过有人把网络请求、图片加载、路由跳转、埋点统计、权限申请全部写进基类的最后基类几千行没人敢改一改就是全局事故。封装边界要守住一个原则基类只负责“页面骨架”不负责“具体业务”。页面骨架就是ViewBinding创建、生命周期流程、懒加载时机、统一状态入口、资源释放具体业务比如“这个页面是展示用户列表”“点击头像要跳详情”统统放到子类和ViewModel里。你可以把基类想象成餐厅的固定出餐流程先下单、后上菜、最后结账但具体是牛排还是炒饭由各个子类决定后厨不能规定顾客只能吃饺子。我自己的分层习惯是BaseFragment只做最通用的绑定和生命周期BaseStateFragment在它的基础上增加loading/empty/error状态页BaseListFragment再在状态页基础上增加下拉刷新和列表Adapter协议。这样职责清晰每一层只解决一个问题子类不会被沉甸甸的基类吓退。2. BaseFragment的整体设计与模板方法2.1 核心架构模板方法模式设计BaseFragment最核心的指导思想是模板方法模式。模板方法的核心思想是父类定义一个流程骨架把一些步骤延迟到子类实现子类不能改变整体流程只能覆写某些步骤。Fragment本身就是一个现成的模板方法例子系统定义好了onCreate、onCreateView、onViewCreated、onStart、onResume、onPause、onDestroyView、onDestroy这些回调的调用顺序。BaseFragment要做的是在这套生命周期之上再定义一套面向业务页面的子流程比如abstract class BaseFragmentVB : ViewBinding : Fragment() { private var _binding: VB? null protected val binding: VB get() _binding ?: throw IllegalStateException(ViewBinding已被释放不能再使用) override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View? { _binding createBinding(inflater, container) return binding.root } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) initViewModel() initView(savedInstanceState) initListener() loadDataIfNeeded() } override fun onDestroyView() { _binding null super.onDestroyView() } protected abstract fun createBinding(inflater: LayoutInflater, container: ViewGroup?): VB protected abstract fun initView(savedInstanceState: Bundle?) protected open fun initViewModel() {} protected open fun initListener() {} protected open fun loadData() {} }这套设计中initView是abstract因为它每个页面都不一样initViewModel、initListener、loadData默认是空实现子类需要时再覆写。这样设计的精髓在于子类能做的操作都被限制在明确的“填空位”里不能随意在生命周期里穿插自己的顺序。我经常用一个比喻解释这个问题模板方法就像流水线作业产品从进入流水线到出库的流程是固定的每个工位有自己的职责你不能让质检员跑去做组装也不能在喷漆环节突然插入焊接。页面开发如果每个人都按自己的节奏来时间一长代码就会变成没人能维护的“手工小作坊”。2.2 分层与分工通用基类与业务基类BaseFragment的层级不应该是一次性设计好的而是随着团队项目沉淀出来的。最开始的版本可能只需要一个通用基类后来发现大量页面都要处理网络加载状态于是抽出BaseStateFragment再后来发现很多列表页都要下拉刷新、加载更多于是又抽出BaseListFragment。这个过程是自然演化的不是一开始就设计一个六层继承体系那样反而会让人看不懂。我推荐一个相对适中的分层方案BaseFragmentVB : ViewBinding负责ViewBinding创建与释放、生命周期模板、懒加载骨架。BaseStateFragmentVB, VM在BaseFragment基础上增加Loading/Empty/Error状态管理与ViewModel的UiState对接。BaseListFragmentVB, VM, T在BaseStateFragment基础上增加RecyclerView、Adapter协议、下拉刷新、分页加载。每层职责单一每一层都可以独立被测试和理解。子类一般只需要继承BaseListFragment或BaseStateFragment具体的业务逻辑放在各自的ViewModel里。BaseStateFragment的伪代码大致是这样abstract class BaseStateFragmentVB : ViewBinding, VM : BaseViewModel : BaseFragmentVB() { protected abstract val viewModel: VM open fun isLoadingViewVisible(): Boolean false open fun isEmptyViewVisible(): Boolean false open fun isErrorViewVisible(): Boolean false protected open fun renderState(state: UiState) { when (state) { is UiState.Loading - showLoading() is UiState.Empty - showEmpty() is UiState.Error - showError() is UiState.Content - showContent() } } private fun showLoading() { /* 显示loading隐藏内容 */ } private fun showEmpty() { /* 显示空态隐藏内容 */ } private fun showError() { /* 显示错误态隐藏内容 */ } private fun showContent() { /* 隐藏loading/空/错误显示内容 */ } }这样做的好处是页面状态切换的“总开关”只在基类里子类不会出现“loading还没关内容却显示出来了”的竞态。同时每个页面的空态文案、错误图标可能不同这些可以通过预留open fun让子类自定义。2.3 ViewBinding泛型代码少的关键Java时代写BaseFragmentonCreateView返回一个View子类还得自己findViewById或者手动绑定。Kotlin ViewBinding之后泛型成了减少样板代码的关键。abstract class BaseFragmentVB : ViewBinding的意思是这个Fragment强制绑定一种ViewBinding类型。子类继承时写成class HomeFragment : BaseFragmentFragmentHomeBinding()然后只要实现createBinding一个方法override fun createBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentHomeBinding { return FragmentHomeBinding.inflate(inflater, container, false) }之后在基类里就能通过binding.xxx访问所有控件ID不需要findViewById不需要butterknife不需要任何第三方框架。binding的getter在onDestroyView之后主动抛异常这样能第一时间发现“在View销毁后继续访问控件”的代码问题。关于_binding和binding的双写法这里要特别说明一下。Fragment的View生命周期和Fragment实例生命周期不同View可能先销毁Fragment实例还活着。如果字段声明成不可空的VB系统重建Fragment时会出现访问已释放View的风险。所以_binding必须是可空的在onCreateView赋值、onDestroyView置空对外暴露的binding通过getter做保护。这是Kotlin社区里处理ViewBinding释放的标准姿势直接抄没问题。3. 关键能力实现懒加载、状态管理与通信3.1 懒加载的正确姿势setUserVisibleHint到底怎么用才不坑懒加载是BaseFragment设计里最容易踩坑的地方。在ViewPager Fragment的场景下如果不做懒加载初始化Fragment时它就会在onResume里请求数据用户还没看到这个页面网络请求已经发了好几个。传统方案是重写setUserVisibleHint(boolean)在里面判断是否对用户可见可见时才加载数据。但这里有个容易忽略的细节setUserVisibleHint的调用时机不稳定可能在onCreateView之前就被调用了。如果只在setUserVisibleHint里加载数据而当时View还没有准备好就会空指针如果只在onViewCreated里加载又可能漏掉“View已创建但还没加载数据”的情况。我自己用的方案是三个入口兜底配合两个标志位abstract class LazyFragmentVB : ViewBinding : BaseFragmentVB() { private var isViewPrepared false private var isDataLoaded false override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) isViewPrepared true initViewModel() initView(savedInstanceState) initListener() loadDataIfNeeded() } override fun setUserVisibleHint(isVisibleToUser: Boolean) { super.setUserVisibleHint(isVisibleToUser) if (isVisibleToUser) { loadDataIfNeeded() } } override fun onHiddenChanged(hidden: Boolean) { super.onHiddenChanged(hidden) if (!hidden) { loadDataIfNeeded() } } private fun loadDataIfNeeded() { if (!isViewPrepared || isDataLoaded || !userVisibleHint) return isDataLoaded true loadData() } }isViewPrepared标记View是否已经创建完成isDataLoaded标记数据是否已经加载过。loadDataIfNeeded统一做一次判断View准备好、没有被加载过、用户可见三个条件同时满足才调用loadData()。这样无论在setUserVisibleHint、onViewCreated还是onHiddenChanged哪个入口进来行为都一致。还要注意一点AndroidX在较新版本里已经将setUserVisibleHint标记为deprecated官方推荐用FragmentTransaction.setMaxLifecycle控制Fragment生命周期ViewPager2默认也会配合Lifecycle.State.RESUMED来做预加载限制。如果项目已经全面使用ViewPager2懒加载可以考虑不依赖setUserVisibleHint直接通过“Fragment真正RESUMED后再加载数据”来实现。但如果你维护的是老项目ViewPager还是旧版Adapter那上面这套兼容写法依然很实用。3.2 Loading/Empty/Error统一状态管理很多页面都有网络加载过程我可以把状态抽象成一个密封类sealed interface UiState { object Loading : UiState data class Empty(val message: String? null) : UiState data class Error(val throwable: Throwable? null) : UiState data class Content(val hasData: Boolean true) : UiState }基类里提供统一的renderState入口子类只需要往viewModel.uiState里塞数据基类负责把状态渲染到对应的View上。这样有几个好处所有页面共享同一套状态转换逻辑不会出现“这个页面用Toast提示错误那个页面用Snackbar”的混乱空态和错误态差异可以集中在基类处理产品要改全局样式时只改一个地方就行。在具体实现上我更推荐用StateFlowrepeatOnLifecycle来收集状态override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) viewLifecycleOwner.lifecycleScope.launch { viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { viewModel.uiState.collect { state - renderState(state) } } } }用viewLifecycleOwner而不是Fragment实例的lifecycleScope是为了保证只在View存活期间更新UI。如果用Fragment实例的lifecycleScopeView已经销毁但Fragment实例还活着时协程还会继续跑更新UI就会空指针或泄漏。repeatOnLifecycle(STARTED)保证页面不可见时自动暂停收集回到前台自动恢复收集这套组合是当前Android官方比较推荐的处理方式。3.3 页面通信与导航接口、Flow、ActivityResultBaseFragment还应该提供一个统一的通信协议约束。每个子类都不应该直接强转Activity去调用方法也不应该在Fragment A里直接操作Fragment B的实例。我给团队定的规矩很简单向上返回结果用监听器接口向下传递数据用arguments或SavedStateHandle跨模块事件用Flow或SharedFlow启动页面用ActivityResultLauncher。ActivityResultLauncher在基类里注册是一个比较容易被忽略的点。Fragment的registerForActivityResult在onCreate之前注册即可基类可以统一提供一个可覆写的回调方法比如protected open val startForResult registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result - onResultReceived(result.resultCode, result.data) } protected open fun onResultReceived(resultCode: Int, data: Intent?) { // 子类覆写处理 }这样每个页面都不用手动再写一遍注册逻辑拿到startForResult.launch(intent)直接启动回传结果统一收敛到onResultReceived一个方法里。3.4 生命周期释放与内存泄漏防护BaseFragment作为所有Fragment的父类必须在生命周期释放上做最严格的兜底。onDestroyView里把_binding置空是必须的这不仅仅是规范问题更是内存泄漏问题。Fragment从回退栈弹出时即使Fragment实例被回收如果还有View引用被外部持有Activity就跟着泄漏。置空binding之后任何在View销毁后访问binding的代码都会立刻抛异常属于“错误暴露在事故源头”。异步回调是另一个泄漏高发点。我在基类里强烈建议子类不要直接持有View做网络回调而是通过viewLifecycleOwner.lifecycleScope或repeatOnLifecycle发起请求。如果子类用了lifecycleScope.launch但没控制生命周期页面已经销毁协程还在跑更新UI时就是典型的崩溃场景。还有一个很多人忽略的点Fragment在后台时onResume会被调用但View可能还没有真正准备好不要依赖onResume来做UI强相关操作。这也是为什么我坚持把所有UI初始化放在onViewCreated里的原因onResume只适合做统计上报、轮询开关之类的轻逻辑。4. 实操中公认的深坑与排查方法4.1 getActivity()为空和异步回调这是我在团队里见过最多的事故类型。典型的错误代码是viewModel.result.observe(viewLifecycleOwner) { activity?.支持某方法() // 此时可能为null }当Fragment从回退栈弹出、或者Activity正在销毁时getActivity()可能已经是null直接调用方法就会NPE。更隐蔽的是在协程里延迟执行网络回调用户页面已经切走回调回来时还在用binding更新UI。排查思路很简单所有网络、数据库、Sensor等异步结果回到UI线程之前一定要确认Fragment还在活动状态。推荐做法是请求放在viewLifecycleOwner.lifecycleScope里发起配合repeatOnLifecycle页面销毁后协程自动取消如果实在要用回调先判断isAdded和isDetached再做UI操作。4.2 Fragment嵌套与FragmentManager用错嵌套Fragment是BaseFragment设计里逃不开的坑。很多人在一个Tab容器Fragment里继续用childFragmentManager去管理子页面结果又去调用requireFragmentManager()提交事务时直接抛IllegalStateException: FragmentManager is already executing transactions。这里有一条很明确的原则每个Fragment只能用childFragmentManager管理“自己的子Fragment”不能用requireFragmentManager()往父级容器里塞。如果只是给Activity发通知、访问父Fragment用parentFragmentManager或requireParentFragment()。基类可以提供统一的navigate()方法内部帮子类选好正确的Manager避免业务代码每处都纠结。4.3 setUserVisibleHint时序与ViewPager2预加载老项目从ViewPager迁移到ViewPager2之后很多人会发现原来的懒加载失效了。原因很简单ViewPager2使用的是RecyclerViewFragmentTransaction不再像旧版ViewPager那样总是先调用setUserVisibleHint。如果还依赖旧逻辑数据加载时机就会变得不稳定。排查建议是这样的如果项目里有旧代码基于setUserVisibleHint判断加载先确认它有没有兼容onViewCreated之后的二次触发如果没有按前面LazyFragment里三个入口兜底的方案补上。如果项目是全新代码直接用ViewPager2建议用onResumeisVisible判断实际可见性或者配合setMaxLifecycle不要继续依赖deprecated的setUserVisibleHint。4.4 rootView缓存复用引发的异常这是老项目里另一个经典问题。早期社区流行在BaseFragment里缓存rootView避免每次切换Tab都重新inflate布局代码大概长这样if (rootView ! null) { (rootView.parent as? ViewGroup)?.removeView(rootView) return rootView }这个写法在AndroidX下有很多隐患View状态可能错乱子View的监听事件重复绑定回退栈恢复时可能出现IllegalStateException: The specified child already has a parent。而且缓存rootView意味着onDestroyView之后View还存活在内存里和ViewBinding的释放逻辑天然冲突。我的建议是彻底放弃rootView缓存每次onCreateView都通过binding.inflate重新创建。现在的ViewBinding和布局渲染性能已经足够好这点性能开销换来的是稳定性和可维护性非常值。4.5 DialogFragment和PopupWindow的边界写BaseFragment时还要明确一个边界DialogFragment要不要继承同一个BaseFragment我的答案通常是不要。DialogFragment的生命周期和普通页面Fragment差异很大它的View创建、销毁时机、点击外部关闭、系统动画都不太一样强行塞进同一个基类会让BaseFragment的抽象层次变得混乱。我一般单独抽一个BaseDialogFragment也使用ViewBinding但懒加载、状态页、列表协议这些能力不会给它。PopupWindow更是和Fragment完全不同的体系不建议放进来。基类设计要敢于“做减法”不是把所有能力的代码都堆在一个类里才是好设计。常见问题可以整理成一张速查表症状常见原因推荐解法页面切走后再回来异步回调崩溃异步回调时Fragment已detach请求放viewLifecycleOwner.lifecycleScope里回调先判断isAddedViewPager内所有Tab同时请求数据没做懒加载或setUserVisibleHint时序漏判用loadDataIfNeeded三个入口兜底isViewPrepared isDataLoaded双标志位多层嵌套Fragment add时报IllegalStateExceptionFragmentManager用错子页面用childFragmentManager向上通信用parentFragmentManageronDestroyView后继续访问binding异步回调持有View引用binding getter抛异常请求生命周期与viewLifecycleOwner绑定Fragment重建后UI和数据不一致状态存在Fragment成员变量里页面状态放ViewModel或SavedStateHandle5. 工程化落地与进阶演进5.1 列表页基类BaseListFragment的抽取项目里最常见的页面类型就是列表页。无论是首页Feed流、消息列表还是订单列表套路基本一致下拉刷新、上拉加载更多、空态、错误态、点击某个item跳转。把这些逻辑抽成BaseListFragment是BaseFragment设计里提升效率最明显的一步。BaseListFragment的职责划分大概是继承BaseStateFragment获得状态管理能力提供RecyclerView布局和Adapter的创建入口统一绑定SwipeRefreshLayout的下拉刷新和RecyclerView的分页加载定义loadNextPage(page)这样的分页协议。abstract class BaseListFragmentVB : ViewBinding, VM : BaseViewModel, T : Any : BaseStateFragmentVB, VM() { abstract val adapter: BaseListAdapterT abstract fun requestPage(page: Int) open fun enableRefresh(): Boolean true open fun enableLoadMore(): Boolean true override fun initView(savedInstanceState: Bundle?) { binding.refreshLayout.setOnRefreshListener { page 0 requestPage(page) } binding.recyclerView.adapter adapter // 滑动到底部自动加载更多 } }子类拿到BaseListFragment之后只需要实现三件事告诉基类用哪个Adapter、告诉基类请求哪一页数据、在Adapter回调里处理点击跳转。下拉刷新、加载动画、空态切换这些团队内约定好的东西都由基类兜底每个人写的列表页都是同样的架构review成本大幅下降。5.2 团队规范命名、Review、文档BaseFragment设计得再好如果没有团队规范和文档配合也会在落地过程中走样。我给团队定了几条硬性约定布局文件统一命名fragment_xxx.xmlFragment类统一命名XxxFragmentViewModel统一命名XxxViewModel从命名上就能看出文件归属。子类不允许覆写onCreateView和onViewCreated要扩展业务逻辑只能实现基类定义的initView、initListener、loadData等模板方法。页面间传参统一使用arguments不直接在Fragment里定义public字段如果用了Navigation组件用by navArgs()或SavedStateHandle存取参数。网络请求和UI状态必须通过ViewModel中转禁止在Fragment里直接写网络代码。这些约定要写进团队的Wiki或者README里配合Code Review强制检查。一个基类如果没有配套的规则说明上线一周就会被人写漏。5.3 Compose与Navigation时代还需要BaseFragment吗这是一个很多人都在问的问题。项目逐渐转向Jetpack Compose之后Fragment和BaseFragment是否还有存在价值我的观点是Compose会改变UI的实现方式但不会消灭页面级的架构抽象。如果你用Compose依然会有一个很薄的BaseComposeFragment它可能只负责提供一个ComposeView然后把整个页面内容交给setContent {}和一个统一的BaseScreen组合函数。真正下沉的是业务状态层BaseViewModel、BaseUiState、BaseListScreen这些概念会继续存在。页面模板、状态统一、通信规范这些思想不会因为换成Compose就失效。ComposeComponent更像是“UI载体”的替换而BaseFragment背后那套“流程骨架状态统一生命周期感知”的思路在组件化、模块化、Flutter化之后依然值得保留。设计BaseFragment时不要只想着怎么把代码写得更少更要想清楚哪些是页面骨架层应该承担的责任哪些是业务层应该负责的逻辑。骨架稳定了上层怎么换都稳。我个人在实际项目里BaseFragment前后重构过好几个版本第一版把网络、埋点、路由全塞进去后来被拆掉了第二版塞了太多状态回调也被拆掉了最后留下来的只有ViewBinding泛型、生命周期模板、懒加载骨架、统一状态入口这几个“看起来很简单”的东西。这个过程中我最深的体会是基类不是越厚越好能够克制住往基类里加代码的冲动才是设计BaseFragment真正难的地方。希望这篇内容能让你少走一些我走过的弯路也帮你把团队里那堆“自由发挥”的Fragment收拢回一条清晰的主线上。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进