尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Android Fragment从入门到实战:生命周期、状态管理与手机平板屏幕适配

Android Fragment从入门到实战:生命周期、状态管理与手机平板屏幕适配 这一章我打算专心聊聊Fragment。说实话在整理自己项目笔记时我把屏幕适配和模块化布局单独记成了“第5章”而这一章里绕不开的核心就是Fragment。无论你是刚开始接触Android、被Activity和Fragment之间的切换绕晕还是已经在手机平板适配里挣扎了一段时间这一篇都能帮你把Fragment从“会用”提升到“用得明白”的层次。手机、平板、折叠屏都要兼顾的项目里Fragment至今仍是官方推荐的标准姿势所以我打算把原理、实操和踩坑一次性讲透。1. 为什么在手机和平板之间必须聊聊Fragment1.1 屏幕尺寸越来越大布局却越来越难写过去做App一个Activity撑起一整个页面很多老项目都是这样过来的登录一个Activity列表一个Activity详情又一个Activity。那时候手机屏幕就那么几个尺寸适配压力不大Activity之间用Intent跳来跳去也够用。但现在的设备环境完全变了。竖屏手机、横屏平板、折叠屏的内屏外屏还有车机、电视这种更宽的屏幕。你完全没法预测用户会在什么宽度上打开你的App。如果还在用“一屏一个Activity”的思路遇上平板就会露怯同样的列表和详情手机上只能一页一页跳平板上却完全可以左右分栏显示一个Activity就没法优雅地表达“左边列表、右边详情”这种结构。我自己最早做平板适配时试过用多个Activity加判断屏幕宽度的方式去跳转结果代码里全是if和else维护起来极其痛苦。后来切到Fragment才真正体会到“一个宿主多个模块”带来的清爽感。1.2 Fragment的本质可重复利用的界面模块Fragment可以理解成Activity里的“界面模块”。Activity是宿主是实际承载窗口和生命周期的载体Fragment是被嵌入到Activity中的一个小型UI组件。一个Activity可以装好几个Fragment每个Fragment又有自己的布局、逻辑和一部分生命周期但它本身没办法独立存活必须挂在某个Activity或另一个Fragment上面。我用一个浅显的类比来解释Activity像一套房子Fragment像房子里可自由组合的家具。你可以在客厅放沙发也可以只放一个茶几桌子今天摆在客厅明天可以搬到书房。每次搬动不需要把整个房子拆掉重盖只需要调整家具的位置就行。Fragment就是在界面上做这种“调整”的最小单元。它在手机和平板之间最大的价值就是复用。同一套代码手机上只显示列表Fragment点击后跳转详情Fragment平板上同时显示列表Fragment和详情Fragment。两套布局、一个Fragment类不用重复写两份逻辑。1.3 用Fragment还是用View关键看这三点有些同学会问既然自定义View也能做模块化为什么还要用Fragment我的判断标准很简单三个问题过一遍就清楚了。第一这个模块需不需要局部切换并保留自己的状态Fragment在Activity切换时可以被系统自动保存和恢复状态而自定义View需要你自己写完整的保存恢复逻辑工作量大且容易漏。第二需不需要响应系统返回键的界面栈Fragment自带回退栈BackStack按返回键可以逐层回退这在多级页面里几乎是刚需。而自定义View没有这种机制View之间的切换返回都要手动handle。第三宿主Activity需不需要根据屏幕尺寸动态决定显示哪些模块这是手机平板兼顾场景的核心。Fragment可以把“显示哪些模块”的决定权交给ActivityActivity读一下布局资源就知道当前屏幕宽度够不够放两个Fragment。当这三个条件占两个以上用Fragment就是更合适的选择。如果只是一个静态展示的小组件或者不需要状态管理的简单视图那确实没必要上Fragment自定义View反而更轻量。对比维度Fragment自定义View多个Activity状态保存恢复系统自动管理需手动实现需靠Intent/ViewModel传递界面切换支持事务和回退栈麻烦需自研支持但跳转成本高屏幕适配能力可同时组合多个模块组合能力有限需要大量分支判断代码复用性高可在不同宿主复用中低2. Fragment的生命周期与状态管理是绕不开的门槛2.1 生命周期到底是怎么跑的很多初学者第一次接触Fragment就懵在生命周期上因为Fragment的回调比Activity多出好几个。我先贴一段最核心的生命周期对应关系再用实际场景说明。Activity回调Fragment回调说明onCreateonAttach / onCreateFragment首次挂载初始化数据onStartonCreateView / onViewCreated / onStart创建界面视图控件在这个阶段可访问onResumeonResume用户可见可交互onPauseonPause部分可见不能交互onStoponStop完全不可见onDestroyonDestroyView / onDestroy / onDetach销毁视图和实例这里最需要关注的是onCreateView和onViewCreated。onCreateView里负责加载布局并返回View对象onViewCreated是在视图已经创建完成后立刻回调的你在这个方法里做findViewById和初始化控件是最稳妥的。我见过不少新手在onCreate里找控件结果抛空指针原因就是视图还没创建。class DetailFragment : Fragment() { override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View { return inflater.inflate(R.layout.fragment_detail, container, false) } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) // 在这里拿到控件初始化点击事件绑定数据 val titleView view.findViewByIdTextView(R.id.tv_title) titleView.text 详情页 } }Fragment还拆出了onDestroyView和onDestroy。在配置变化比如旋转屏幕或者Fragment被移除时视图先被销毁但Fragment实例可能还活着。所以凡是持有View引用的变量建议在onDestroyView里置空避免内存泄漏。2.2 事务、回退栈与状态保存Fragment的操作核心是FragmentTransaction也就是事务。事务支持的动作有add、remove、replace、hide、show、attach、detach其中add和replace的差异是我每次都要强调的重点。replace会销毁旧的Fragment视图并创建新的如果旧的Fragment没有加入回退栈它的状态就没了。而hide/show只是控制视图的可见性Fragment实例还活着状态自然保留。如果你在列表页和详情页之间频繁切换并且希望保留列表的滚动位置用hide/show比replace更省力。supportFragmentManager.beginTransaction() .hide(listFragment) .show(detailFragment) .addToBackStack(null) .commit()addToBackStack的作用是把当前事务记录到回退栈里。按系统返回键时事务会被逆序回滚相当于自动支持了“返回上一页”。如果你不加这一步Activity会直接销毁或退出这和Fragment的设计初衷相违背。关于提交事务commit是异步提交会等到主线程空闲时才执行commitNow是同步执行commitAllowingStateLoss则是允许在Activity状态已经保存之后还要提交。最后这个要谨慎使用它确实能避免崩溃但代价是可能丢失页面状态用多了会变成一种“掩耳盗铃”式的代码债。2.3 状态保存我怎么记都不乱状态保存方面Fragment自带一套机制。Activity被系统回收前FragmentManager会保存所有Fragment的实例状态重建时每个Fragment会收到之前的savedInstanceState。这里面有几个要点不要在Fragment的构造方法里传业务参数系统重建时会调用无参构造器参数会丢。应该用arguments传参配合newInstance模式。临时界面状态比如输入框的内容由系统自动保存不需要手动处理。跨配置变更需要长期持有的数据放ViewModel里才是最靠谱的而不是塞进savedInstanceState。需要在Activity或Fragment之间通信的数据优先考虑Fragment Result API或共享ViewModel而不是持有彼此引用。companion object { fun newInstance(itemId: Int): DetailFragment { return DetailFragment().apply { arguments bundleOf(item_id to itemId) } } }记住那句老话Fragment的arguments是给它传一次性参数的通道ViewModel是让它长期持有数据的保险箱savedInstanceState只是它劫后余生的日记本。3. 实操用Fragment做一套兼顾手机和平板的界面这一部分我准备从零开始搭一个主从结构项目也就是最常见的“列表详情”双栏布局。它能直接套用到新闻客户端、邮件应用、设置页等很多场景。耐心跟着做一遍你对Fragment的理解会比看十篇理论文章都深。3.1 搭建工程并引入依赖新建一个Android项目包名随意。在app模块的build.gradle里加上Fragment的Kotlin扩展依赖dependencies { implementation androidx.fragment:fragment-ktx:1.6.2 }然后准备两个Fragment类ItemListFragment和DetailFragment。前者负责展示可点击的列表后者负责展示被选中的条目详情。ListFragment的布局里放一个RecyclerViewandroidx.recyclerview.widget.RecyclerView android:idid/rv_list android:layout_widthmatch_parent android:layout_heightmatch_parent /DetailFragment的布局里放一个TextViewTextView android:idid/tv_detail android:layout_widthmatch_parent android:layout_heightmatch_parent android:gravitycenter android:textSize18sp /3.2 手机端一个容器动态切换Fragment手机端只有一块屏幕所以Activity布局只需要一个FragmentContainerView当作容器androidx.fragment.app.FragmentContainerView android:idid/fragment_container android:layout_widthmatch_parent android:layout_heightmatch_parent /MainActivity在onCreate中把ItemListFragment放进容器if (savedInstanceState null) { supportFragmentManager.beginTransaction() .add(R.id.fragment_container, ItemListFragment()) .commit() }这一步很关键。加上savedInstanceState null的判断后当屏幕旋转或者进程被系统恢复时系统会自动还原原来的Fragment不会因为重复add而出现叠影。列表项的点击事件由ItemListFragment内部触发跳转详情逻辑通过回调接口交给MainActivity处理interface OnItemSelectedListener { fun onItemSelected(itemId: Int) } class ItemListFragment : Fragment() { var listener: OnItemSelectedListener? null override fun onAttach(context: Context) { super.onAttach(context) listener context as? OnItemSelectedListener } override fun onDetach() { super.onDetach() listener null } }MainActivity实现这个接口在手机端跳转到新的DetailFragmentoverride fun onItemSelected(itemId: Int) { val detailFragment DetailFragment.newInstance(itemId) supportFragmentManager.beginTransaction() .replace(R.id.fragment_container, detailFragment) .addToBackStack(null) .commit() }这样手机端的行为就是标准的“点列表项进入详情页按返回键回列表”。3.3 平板端一套代码做出双栏布局平板端的核心区别在于布局。我在res目录下新建layout-sw600dp文件夹意思是宽度大于等于600dp的设备会优先使用这个文件夹下的布局。这是Android资源限定符最常用的适配手段之一。平板布局activity_main.xml改成左右双容器LinearLayout xmlns:androidhttp://schemas.android.com/apk/res/android android:layout_widthmatch_parent android:layout_heightmatch_parent android:orientationhorizontal FrameLayout android:idid/fragment_container_list android:layout_width0dp android:layout_heightmatch_parent android:layout_weight1 / FrameLayout android:idid/fragment_container_detail android:layout_width0dp android:layout_heightmatch_parent android:layout_weight2 / /LinearLayoutMainActivity在onCreate时先判断右侧容器是否存在这个判断方式比读取屏幕宽度更优雅因为布局资源已经帮你分流好了override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) val detailContainer findViewByIdView(R.id.fragment_container_detail) val isTablet detailContainer ! null if (savedInstanceState null) { supportFragmentManager.beginTransaction() .add(R.id.fragment_container_list, ItemListFragment()) .commit() if (isTablet) { supportFragmentManager.beginTransaction() .add(R.id.fragment_container_detail, PlaceholderFragment()) .commit() } } }在平板上点击列表项时不是replace右边的容器而是更新右侧DetailFragment里的数据。最省事的方式是先检查右侧是否已有DetailFragment有就更新数据没有就add一个新的override fun onItemSelected(itemId: Int) { val detailContainer findViewByIdView(R.id.fragment_container_detail) if (detailContainer ! null) { val existingFragment supportFragmentManager .findFragmentById(R.id.fragment_container_detail) if (existingFragment is DetailFragment) { existingFragment.updateContent(itemId) } else { supportFragmentManager.beginTransaction() .replace(R.id.fragment_container_detail, DetailFragment.newInstance(itemId)) .commit() } } else { // 手机逻辑replace addToBackStack } }这段代码就是“手机平板兼顾”的精髓活动代码不需要写一堆if和else判断屏幕尺寸只需要检查布局里有没有细节容器就把两种形态都覆盖了。3.4 Fragment之间的通信方案选型在多Fragment页面里通信方案选不好就会变成一团乱麻。我自己试过很多种方案最终沉淀出下面这套选择逻辑。先说Activity与Fragment之间的通信。Activity向Fragment传参数用newInstance加arguments即可。Fragment向Activity发事件用接口回调是最直观的但它要求Activity在onAttach时转型成接口一旦宿主不对就会空指针所以要在onDetach时把listener置空。Fragment与Fragment之间的通信我强烈推荐Fragment Result API。它是官方提供的轻量级结果回传方案不需要让两个Fragment互相持有引用。// 发送方列表Fragment中 setFragmentResult(item_selected, bundleOf(item_id to itemId)) // 接收方详情Fragment中 override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) parentFragmentManager.setFragmentResultListener(item_selected, viewLifecycleOwner) { _, result - val itemId result.getInt(item_id) updateContent(itemId) } }如果两个Fragment需要共享一份同样的数据模型比如登录状态、用户信息、购物车共享ViewModel会是更好的选择。ViewModel的作用域可以绑定到Activity这样同一个Activity下的所有Fragment都能获得同一个实例数据互相同步还天然支持配置变更。class SharedViewModel : ViewModel() { private val _selectedItem MutableLiveDataInt() val selectedItem: LiveDataInt _selectedItem fun selectItem(itemId: Int) { _selectedItem.value itemId } } // 在Fragment中获取同一个ViewModel val viewModel by activityViewModelsSharedViewModel()这种方案尤其适合“列表选条目详情响应变化”的典型场景。详情Fragment观察selectedItem每次变化自动刷新UI完全不用关心是谁发起的修改。3.5 需要避免的深层嵌套Fragment嵌套Fragment是一个很容易失控的功能。Fragment里再放FragmentAndroid是支持的子Fragment由父Fragment的getChildFragmentManager管理。但如果嵌套楼层太深状态恢复、回退栈、触摸事件分发都会变得异常复杂。我曾经在一个三级嵌套的结构里排查过一个Bug外层Fragment切换时内层Fragment明明还在回退栈里但界面上消失了日志没有任何报错。最后发现是外层Fragment的视图被销毁导致依赖外层View存活的内层Fragment无法显示。这种情况调试起来非常痛苦。现在我的原则是最多只嵌套一层。如果遇到需要多层级页面栈的直接用Navigation组件来代替手写Fragment事务它本身就是官方为了减少这类心智负担推出的方案。4. 常见问题排查与避坑清单这一部分会记录我实际开发中踩过的坑每个都是真实事故还原。如果你正好也遇到类似现象可以直接对照排查。4.1 Fragment重叠这个坑我至少跳过两次表现从后台切回App或者旋转屏幕后界面出现了两层一模一样的列表。原因是Activity被系统重建后FragmentManager会自动恢复原Fragment而你又没判断savedInstanceState、直接重新add了一个导致同一个Fragment被添加了两遍。解决办法很简单就是前面代码里写的if (savedInstanceState null) { supportFragmentManager.beginTransaction() .add(R.id.fragment_container, ItemListFragment()) .commit() }这段判断不只出现在MainActivityFragment第一次添加的任何地方都应该带上。它在逻辑上表达的是一次全新的创建而不是恢复现场。4.2 commit在onSaveInstanceState之后崩溃表现点击列表项后App崩溃Log里能看到Can not perform this action after onSaveInstanceState。这不一定是你点击时出了问题很可能是异步回调在Activity状态保存后才触发事务提交比如网络请求回来、延迟任务执行等。解决办法有两种。一种是使用commitAllowingStateLoss但它会接受状态丢失的后果不建议当作默认选项。另一种我给你我的做法把需要提交事务的时机控制在用户操作入口不要放在异步回调里。万一无法避免再结合Lifecycle判断当前是否处于可提交状态。if (lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)) { supportFragmentManager.beginTransaction() .replace(...) .commit() }4.3 getActivity()突然返回null表现在Fragment里调用getActivity()时一直好好的某个操作后变成null然后空指针崩溃。原因通常是Fragment已经被销毁或detach但异步任务还在执行任务回调里又访问了Activity。我的规避方案是在Fragment里不要长期持有Activity引用所有需要Activity的地方通过requireActivity()或requireContext()临时获取配合viewLifecycleOwner来做生命周期感知让异步回调在视图销毁时自动取消。viewLifecycleOwner.lifecycleScope.launch { // 网络请求或数据库操作 }这样等页面销毁后回调就会自动终止不会再因为这个崩溃。4.4 懒加载失效到底是谁加载了在ViewPager2 Fragment的组合里初次进入页面时相邻页面可能一起被加载这就是“懒加载”问题的来源。老方案是在setUserVisibleHint里判断可见性但API已经废弃了。现在更推荐的方案是setMaxLifecycle可以精确控制Fragment被启动到哪个生命周期状态fragmentManager.beginTransaction() .setMaxLifecycle(fragment, Lifecycle.State.RESUMED) .commit()也可以直接在页面切换时通过FragmentManager找到当前可见的Fragment再触发数据刷新逻辑。我把几个高频问题整理成了一张速查表方便你直接定位现象常见原因处理方式界面重叠重复add未判断savedInstanceStatesavedInstanceState为空时才添加切换崩溃在状态保存后提交事务检查生命周期状态避免异步提交getActivity()为空Fragment已销毁异步回调还在执行用viewLifecycleOwner lifecycleScope数据丢失Fragment实例被销毁状态没保留数据放ViewModel临时状态靠savedInstanceStateViewPager页面提前加载fragment生命周期未受限制setMaxLifecycle控制最高状态4.5 现在的Fragment建议这样用Navigation组件聊到这里我很想提醒你一个方向如果你的项目是从零开始或者还没有历史包袱优先考虑Jetpack Navigation而不是手写FragmentTransaction。Navigation本质上是Fragment事务的一层高级封装它把目标Fragment、参数、转场动画、返回栈全部用资源文件统一管理。用Navigation之后页面跳转变成这样findNavController().navigate(R.id.action_list_to_detail, bundleOf(item_id to itemId))回退栈、动画、状态恢复都由NavController统一处理。单个Activity 多个Fragment的架构在这种模式下会变得非常顺滑手机平板适配的思路依然成立只是把Fragment切换交给了框架。不过我还是要强调Navigation只是一层更舒适的事务封装底层仍然是Fragment。你该理解的生命周期、状态保存、通信方案一个都跑不掉。把这一章内容吃透再去看Navigation的源码和文档你会觉得一切都很自然。我个人在实际项目里体会到最深的一点是Fragment不是用来炫技的它是解决屏幕适配和模块复用的务实工具。如果项目只做固定尺寸的单手手机App你完全可以不用它但只要你的App需要上平板或者折叠屏早一点把它学扎实后面迭代时就少一些返工。最后再分享一个小技巧调试Fragment问题时把每个生命周期回调都打上日志看一遍执行顺序一目了然很多疑难杂症其实都是生命周期的理解偏差导致的。搞清楚这一层Fragment对你来说就不会再是什么玄学了。
返回列表