0%

Fragment 开发推荐实践

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

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

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

考虑以下错误情形:

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

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

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

由于 FragmentManager 保存了 Fragment 的状态,在配置变更造成 Activity 和 Fragment 重建时,会先从保存的状态创建 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 已经完成了加载或正在重置,在 onLoadFinished 或 onLoaderReset回调返回前,不允许执行 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 调用之前,会先执行已提交但尚未执行的事务,因此这些事务的状态会正确保存,也不会触发异常。