0%

Fragment 开发推荐实践

Fragment 视图内嵌套 Fragment 时,使用 getChildFragmentManager() 而非 getActivity().getSupportFragmentManager()

如果子 Fragment 视图是在某个 Fragment 视图内创建的,那么应该使用该 FragmentChildFragmentManager 管理其状态。

FragmentManager 不仅负责 Fragment 状态切换,还负责在重建 Activity 时恢复 Fragment 状态。在恢复 Fragment 状态时,如果使用了错误的 FragmentManager,可能会导致 Fragment 状态恢复错误,引起界面异常、崩溃或逻辑错误。

考虑以下错误情形:

  1. 使用视图容器 id 添加 SubFragmentMainFragment 的子视图,但使用了 MainFragment 对应 ActivityFragmentManager
  2. 对应 Activity 动态添加了有相同 id 的视图到视图树(例如 ViewPager
  3. 恢复 Fragment 状态时,从视图树找到了动态添加的视图,而非先前作为容器的视图,造成界面异常或崩溃。

此外,Lifecycle 相关 API 支持锁定 FragmentmaxLifeCycle,此设置会应用到其 ChildFragmentManager 管理的子 Fragment。如果使用了错误的 FragmentManager,会导致生命周期表现不符合预期,造成意外的 LiveData 更新、业务逻辑错误等问题。

创建 Fragment 时,先尝试复用已有的 Fragment

由于 FragmentManager 保存了 Fragment 的状态,在配置变更造成 ActivityFragment 重建时,会先从保存的状态创建 Fragment,并试图关联到对应的视图层级。

如果不加判断地创建和添加 Fragment,轻则丢失保存的状态,重则重复添加 Fragment 导致 crash。

如果实际添加到视图的 Fragment 实例是由系统恢复的,而业务逻辑依赖的 Fragment 实例是自己创建的,则可能造成难以复现和排查的问题。

为此,在创建 Fragment 时,尝试复用已有的 Fragment。如果判断已有的 Fragment 不能复用,要显式移除该 Fragment,避免内存泄露。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// 错误示范
@Override
protected void onViewCreated(View view, @Nullable Bundle savedInstanceState) {
// ...
containerPageFragment = ContainerFragment.newInstance(containerBundle);
// 这里用 add 的话,重建会出错,正是由于系统已经帮忙恢复了 Fragment,再 add 到同一个容器会抛异常。用 replace 相当于丢弃了系统恢复的实例
getSupportFragmentManager()
.beginTransaction()
.replace(R.id.fragment_container, containerPageFragment)
.commit();
}

// 推荐实践
@Override
protected void onViewCreated(View view, @Nullable Bundle savedInstanceState) {
// ...
FragmentManager fm = getSupportFragmentManager();
containerPageFragment = getOrCreateFragment(fm, R.id.fragment_container);
}

private ContainerFragment getOrCreateFragment(FragmentManager fm, int id) {
Fragment fragment = fm.findFragmentById(id);
if (fragment instanceof ContainerPageFragment) {
return (ContainerPageFragment) fragment; // 直接返回可以复用的 Fragment
}
FragmentTransaction ft = fm.beginTransaction();
if (fragment != null) {
ft.remove(fragment); // 移除不能复用的 Fragment
}
ContainerFragment containerPageFragment = ContainerFragment.newInstance(containerBundle);
// 前面已经移除了可能在这个 id 上的 Fragment,这里可以放心用 add
ft.add(id, containerPageFragment)
.commit();
return containerPageFragment;
}

androidx.fragment.app 1.2.0 以上的版本引入了基于类的 add()replace() 方法,推荐使用这些方法替代显式创建新实例的方式。

1
2
3
4
5
FragmentManager fm = fragment.getChildFragmentManager();
Bundle args = new Bundle();
args.putLong("userId", getUser().getUserId());
// 可以使用该方法一并传入 arguments bundle,省去构造工厂方法
fm.beginTransaction().add(id, ContainerPageFragment.class, args).commit();

不可以在构造工厂方法内为 Fragment 注入 Bundle Arguments 以外的依赖

由于 Fragment 重建机制的存在,在构造工厂方法里注入 Bundle Arguments 以外的依赖是不可靠的:Fragment 重建时,只会恢复保存的 Bundle Arguments,并不会恢复其他依赖。

虽然可以对这种情况判断并单独注入依赖,但相对更好的方式是在 FragmentLifecycleCallbacks 统一处理。

1
2
3
4
5
6
7
8
9
10
11
// 不推荐的做法
MyBusinessLogic logic;
ContainerFragment containerFragment = ContainerFragment.newInstance(containerBundle, logic);

// Fragment 构造工厂里,对于不能保存在 Bundle Arguments 里的依赖项,不应该进行注入
public static ContainerFragment newInstance(Bundle bundle, MyBusinessLogic logic) {
ContainerFragment fragment = new ContainerFragment();
fragment.setArguments(bundle); // 系统重建 Fragment 时,会恢复保存的 Arguments
fragment.setBusinessLogic(logic); // 系统重建 Fragment 时,可不会帮你注入这个依赖
return fragment;
}
1
2
3
4
5
6
7
8
9
10
11
// 使用 FragmentLifecycleCallbacks 注入依赖
MyBusinessLogic logic;
FragmentManager fm = parent.getChildFragmentManager();
fm.registerFragmentLifecycleCallbacks(new FragmentManager.FragmentLifecycleCallbacks() {
@Override
public void onFragmentPreAttached(FragmentManager fm, Fragment f, Context context) {
if (f instanceof ContainerFragment) {
((ContainerFragment) f).setBusinessLogic(logic);
}
}
}, false);

也可以使用 Hilt 之类的依赖注入框架,这样开发者不必自己考虑如何注入依赖的问题。

最后,建议使用 androidx.fragment.app 1.2.0 以上版本引入的基于类的 add()replace() 方法,逐步弃用传入 Fragment 实例的 add()replace() ,最终完全弃用 Fragment 构造工厂方法,以避免潜在风险。

提交 FragmentTransaction 时,优先使用 commit() 而非 commitNow()

使用 commit() 而非 commitNow() 有助于 FragmentManager 合并执行多个 FragmentTransaction,重排其内部操作的顺序、消除冗余操作。

考虑使用 commit() 前检查状态,而非无脑使用 commitAllowingStateLoss()

开发过程中不时遇到使用 commit() 时抛出 IllegalStateException 异常,为应对该异常,许多开发者倾向于使用 try-catch 捕获,或使用 commitAllowingStateLoss() 代替 commit()。但这样做是有其副作用的,最好是理解框架抛异常的原因,并针对性地应对。

commit() 时抛出 IllegalStateException 异常的原因有以下几种:

  1. 当前 FragmentManager 已经保存了 Fragment 状态(宿主调用了 onSaveInstanceState),并且还没有重新恢复(还没有调用 onCreate/onStart/onResume ),因此此后的提交操作不会记录到保存的状态中,因此也不会在重建时恢复;
  2. 当前 FragmentManager 对应的 LoaderManager 已经完成了加载或正在重置,在 onLoadFinishedonLoaderReset回调返回前,不允许执行 commit,因为官方认为这个回调可能在任何时间返回(包括 Fragment 状态已保存或已被销毁),因此并不安全。(我个人认为这属于设计失误,用户应该自己确保调用安全)

对于 Fragment 状态保存后抛出异常,其实是框架的预期行为,旨在提醒开发者确认并正确处理方法调用时机。

考虑以下使用场景:

  1. 在网络数据返回时,创建并展示 Fragment,更新某个 LiveData 状态值,表示该 Fragment 处于展示状态;
  2. 如果此时用户实际上已经离开 app,onSaveInstanceState 已经调用,此时直接调用 commit() 添加了该 Fragment,应用将抛出异常;
  3. 如果此时改为使用commitAllowingStateLoss() 添加了该 Fragment ,将不会抛出异常。
    1. 如果用户返回 app 时,app 没有被回收,页面没有被重建,表现也符合预期:Fragment 正常展示,LiveData 的值正常更新;
    2. 但如果用户返回 app 时,页面经历了重建过程,将出现不符合预期的表现:Fragment 没有正常展示,但 LiveData 的值被更新了,导致状态不一致,进而引起功能逻辑异常。这样的问题由于偶发性,也比较难以排查。

因此,调用 commit() 时抛出异常,是框架在提醒开发者,Fragment 状态的变更不会在重建之后恢复,当页面重建时可能会出现不一致的问题。对此开发者有两种应对方式:

  1. 事前预防:尽量不要在启动生命周期回调(onCreate/onStart/onResume)之外,直接调用 FragmentTransaction.commit()
    在需要在用户点击时、网络数据返回等异步回调中展示 Fragment 的场景,通过设置 LiveData ,将调用延迟到合适的时机。

    如果不想或不适合使用 LiveData,则在调用 commit() 前,需要使用 FragmentManager#isStateSaved()检查状态是否已经被保存:如果状态尚未被保存,则调用 commit() 提交变更;如果状态已经被保存,则不调用 commit() 提交变更,并自行处理恢复逻辑。

  2. 事后处理:使用 commitAllowingStateLoss() 方法。
    这意味着:开发者充分了解 Fragment 状态丢失的可能结果,并确认该情况不影响业务流程,或针对该情况调整了业务逻辑。
    一种处理方案是:在处理 Fragment 状态变更前,使用 ViewModel + LiveData 记录将要进行的操作:在使用与 Fragment 状态相关的逻辑前,检查 Fragment 状态是否符合预期:如果不符合预期,则要根据之前的记录,进行必要的操作,以恢复预期的状态。
    这种方案的维护成本较高,容易出错,因为往往不能确定在业务逻辑的何处会判断 Fragment 的状态,因此不推荐使用。

对于 LoaderManager 引起的问题,Android 官方文档已经废弃 LoaderManager,建议是在新代码中不再使用 LoaderManager ,并尽快将已有的相关业务逻辑迁移到使用 ViewModel + LiveData 开发。

1
2
3
4
5
6
7
// 不推荐在回调用 commitAllowingStateLoss
public void onCallback(Data data) {
getChildFragmentManager().beginTransaction()
.replace(R.id.placeholder,MyFragment.newInstance(data))
.commitAllowingStateLoss();
pageShowStatus.setValue(true);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 推荐:使用 LiveData 推送变更,系统会在合适的时机回调
public void onCallback(Data data) {
pendingData.setValue(data);
}
// LiveData 只会在页面活跃状态下推送变更,因此可以直接 commit()
pendingData.addObserver(data -> {
if (data == null) {
return;
}
FragmentManager fm = getChildFragmentManager();
if (fm.isStateSaved()) {
// 怎么会走到这里来呢?就不处理了吧
return;
}
getChildFragmentManager().beginTransaction()
.replace(R.id.placeholder, MyFragment.newInstance(data))
.commit();
pageShowStatus.setValue(true);
pendingData.setValue(null);
});

FAQ

使用 commit() 提交事务时,Fragment 状态变更是异步进行的。如果调用 commit() 方法前尚未保存状态(因此可以提交),但在事务真正执行前,插入了 onSaveInstanceState 调用,是否会触发异常?

答:不会。在 onSaveInstanceState 调用之前,会先执行已提交但尚未执行的事务,因此这些事务的状态会正确保存,也不会触发异常。