从一个线上 Android Bug 回看 Fragment 的基础知识

作者:Kotlin上海用户组

公司的项目在最近遇到了一个与 Fragment 有关的线上 crash,导致这个问题的根本原因比较复杂,导致修复方案的可选项非常有限,不过这个问题的背景、crash 点,以及修复过程都非常有趣,值得记录一下。

背景

我们有一个跨多部门、多技术栈合作开发的页面 Activity A,它由基础公共团队开发;而内部它有 6 个 Fragment(B、C、D、E、F、G),这六个 Fragments 以类似 TabLayout + ViewPager 的形式展示在 Activity 中,而且它们由至少四个不同的部门开发,其中 B、C 是由我们团队开发的。各业务团队除了可以通过 Fragment 在主 Activity A 中展示内容之外,还可以通过一些方式调用 Activity 中的一些特定方法,用于展示一些浮动在 Fragment 之外的 View。

在以上背景中的页面和架构已经存在了多年的情况下,产品提了一个需求。他们要在 Activity A 中展示一个浮层页面 H(React Native 页面,由同一个部门的另一个团队开发),这个浮层页面有以下两种展示方式:

    1. 浮动展示;只有在 Activity A 的 TabLayout 展示 B 或 C 时,浮层才会展示,当切换至其他 tabs 时,浮层消失,当切换回 B 或 C 时,浮层会重新展示。此种情形下,H 会覆盖在 B/C 的上方,因此它独立于 B/C 两个 Fragment 而存在。
    1. 拼接展示;若此时 H 已经处于浮动展示模式,那么当用户在 B 或 C tabs 进行上下滑动操作时,浮层必须隐藏,当用户停止滑动时,如果 B/C 内部的 ScrollView 的状态位于其底部时,浮层 H 不再在原位置展示,而是需要拼接到 B/C 内部的 ScrollView 内的最底部,使用户可以继续滑动,直到 ScrollView 在屏幕被用户滑动到可以展示 H 的最底部。

由于公司内部对 React Native 的定制,我们只能在 Activity 或 Fragment 中展示 RN 内容,而不能使用 View。这是一个技术大前提。

是不是听完了上面的背景描述都被弄晕了,我当时听完需求之后也这么觉得。不过我大概画了两张图来帮助理解:

从一个线上 Android Bug 回看 Fragment 的基础知识_第1张图片

从一个线上 Android Bug 回看 Fragment 的基础知识_第2张图片

实现

在第一版的实现中,采取了如下方案。RN 页面 H 使用 Fragment 加载,在 H 的外层有两层 View(H 通过动态的方式添加至这两层 View 中),由内到外分别称为 I、J,这二者内外相配合用于实现一些特定的滑动、折叠效果。当 H 需要以浮层形式展示时,则调用 A 中的添加浮层 API,将 J 直接以 add View 的形式添加到浮层容器中,即可实现。当用户开始滑动时,将 J remove 掉,当用户滑动停止时,如果 ScrollView 的滑动位置符合条件,则将 I 从 J 中 remove,然后 add 到 ScrollView 的直接子 ViewGroup 中,若用户再次滑动 ScrollView,滑动至需要 J 展示的位置时,再将 I 从 ScrollView 的直接子 ViewGroup 中 remove,然后 add 到 J,再调用 A 的 API add J;如果 ScrollView 滑动停止时不在需要展示 I 的位置时,则重新调用 A 的 API add J。

在实现完毕后,测试阶段没有发现 crash 等问题,于是需求上线。

问题描述与分析

代码上线后部分用户发生了 crash,我们通过线上崩溃告警注意到这个问题。crash 信息大致为:

java.lang.IllegalArgumentException
    No view found for id 0x7f094914 (ctrip.android.view:id/a) for fragment HRNFragment{7a69d36} id=0x7f094914 JDrawerView}
    at androidx.fragment.app.FragmentStateManager.createView(FragmentStateManager.java:305)
    at androidx.fragment.app.FragmentManager.moveToState(FragmentManager.java:1185)
    at androidx.fragment.app.FragmentManager.moveToState(FragmentManager.java:1354)
    at androidx.fragment.app.FragmentManager.moveFragmentToExpectedState(FragmentManager.java:1432)
    at androidx.fragment.app.FragmentManager.moveToState(FragmentManager.java:1495)
    at androidx.fragment.app.BackStackRecord.executeOps(BackStackRecord.java:447)
    at androidx.fragment.app.FragmentManager.executeOps(FragmentManager.java:2167)
    at androidx.fragment.app.FragmentManager.executeOpsTogether(FragmentManager.java:1990)
    at androidx.fragment.app.FragmentManager.removeRedundantOperationsAndExecute(FragmentManager.java:1945)
    at androidx.fragment.app.FragmentManager.execPendingActions(FragmentManager.java:1847)
    at androidx.fragment.app.FragmentManager$4.run(FragmentManager.java:413)
    at android.os.Handler.handleCallback(Handler.java:900)
    at android.os.Handler.dispatchMessage(Handler.java:103)
    at android.os.Looper.loop(Looper.java:219)
    at android.app.ActivityThread.main(ActivityThread.java:8673)
    java.lang.reflect.Method.invoke(Native Method)
    at com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:513)
    at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:1109)

当然,以上信息经过处理,HRNFragment 指的是展示 RN 页面 H 的 Fragment,而 JDrawerView 指的是 View J。根据上报的其他信息,用户通常是在页面跳转或返回时发生 crash。比如上面这例 crash,我们看看堆栈最后这一行:

at androidx.fragment.app.FragmentStateManager.createView(FragmentStateManager.java:305)

可以看出,是在 Fragment createView 的时候 crash 了。我直接找到 FragmentStateManager 的相关源码:

void createView(@NonNull FragmentContainer fragmentContainer) {
    if (mFragment.mFromLayout) {
        // This case is handled by ensureInflatedView(), so there's nothing
        // else we need to do here.
        return;
    }
    if (FragmentManager.isLoggingEnabled(Log.DEBUG)) {
        Log.d(TAG, "moveto CREATE_VIEW: " + mFragment);
    }
    ViewGroup container = null;
    if (mFragment.mContainer != null) {
        container = mFragment.mContainer;
    } else if (mFragment.mContainerId != 0) {
        if (mFragment.mContainerId == View.NO_ID) {
            throw new IllegalArgumentException("Cannot create fragment " + mFragment
                    + " for a container view with no id");
        }
        container = (ViewGroup) fragmentContainer.onFindViewById(mFragment.mContainerId);
        if (container == null && !mFragment.mRestored) {
            String resName;
            try {
                resName = mFragment.getResources().getResourceName(mFragment.mContainerId);
            } catch (Resources.NotFoundException e) {
                resName = "unknown";
            }
            throw new IllegalArgumentException("No view found for id 0x"
                    + Integer.toHexString(mFragment.mContainerId) + " ("
                    + resName + ") for fragment " + mFragment);
        }
    }
    // 省略未展示部分......
}

崩溃点位于 throw new IllegalArgumentExceptio("No view found for id 0x" ...这一行,由此可知,Fragment 找不到其容器 ViewGroup。当 Activity 根据自身的 supportFragmentManager 来获取其内部所有已添加的 Fragment 并执行其生命周期的时候,它找到了 Fragment,却没有找到 Fragment 对应的容器。

从我们的例子中分析,其实对应的场景就是因为 J 在此时被 remove 掉了,这也正好对应用户滑动到 Fragment H 需要被“拼接展示”的情形。分析代码后我们发现,View I 内部承载着 Fragment H,但我们却将 I 使用简单的 add 或 remove 方法让其在 J 以及 ScrollView 中来回转移,这从逻辑上是有问题的。首先,Fragment 的 add 由 FragmentManager 来进行,当 Fragment H 需要被“浮动展示”时,此时的 FragmentManager 实际上是 Activity A 的 supportFragmentManager,这没有什么问题;但如果 I 被移除之后,并被重新添加到 B/C 的 ScrollView 的子 ViewGroup 中的时候,H 实际上已经被添加到 B/C 中,如果要在 Fragment 中添加子 Fragment,正确的做法是使用外层 Fragment 的 childFragmentManager,而不是 Activity 的 supportFragmentManager。但在我们的实现中,Fragment H 在 A 与 B/C 两侧转移时,没有进行任何的 Fragment remove 或 add 操作。

因此可以详细描述一下复现 crash 的场景:用户进入 B/C 页面,然后 Fragment H 添加到 ViewGroup J 并以“浮动展示”的情况出现在用户的眼前,此时用户开始向下滑动 B/C 页面,这时 J 被 remove(但 Fragment H 没有被 FragmentManager remove),用户停止滑动,逻辑代码判断此时应该以“拼接展示”的情形展示,因此装有 Fragment H 的 View I 被从 J 中移出,然后 I 被 add 到了 B/C 中的 ScrollView 内的 ViewGroup 中,此时用户向后续页面跳转并停留了较长时间(或停留在 B/C 页面,但长时间未操作手机并熄屏),此时 Android 系统回收了非前台 Activity A,当用户在较长时间后又返回 A 时,A 重新执行生命周期,并执行其内部 Fragment 的生命周期,此时因为 Fragment H 在生命周期执行时未找到它原本的容器 J,因此抛出异常并 crash。

第一次修复

将 H 在不同的容器之间互相移动逻辑复杂、容易出错,且在 B/C 滚动时由于存在 View 的 add/remove 操作,ScrollView 无法一次滚动到底部,会有一次卡顿的过程。为了一次性解决这问题并修复 crash,在充分考虑内存是否足够的情况下,我们将“浮动展示”及“拼接展示”分为两个不同的 H instances 来实现。也就是说 Fragment H 最多可能会存在 4 个 instances(B 与 C 各引用 2 个 H instances)。这听起来是一种对内存的浪费,但在内存资源足够的情况下,这是对当前问题最好的解决方案。

在该修复上线后,crash 数量大幅下降,但仍有少量存量。这让我不解。于是只能继续分析。

第二次修复

我发现仍然存在一些我没有考虑到的场景。例如,即使修复上线后,当用户开始滑动时,J 仍然会被 remove,虽然当滑动彻底停止时 J 会被重新添加,但仍然会存在极小的 Fragment H 的容器在 Activity 的 View 树中无法找到的时间空隙。其次,我忽略了 B 或 C 会切换到其他同级 Fragments 的情况(也就是 tab 切换)。A 管理着 B、C、D、E、F、G 一共 6 个 Fragments,当用户切换 tab 时,Activity A 会自动 remove 掉 J,因为装载 J 的容器是各个业务部门共享的,只有当前 Tab 展示你的 Fragment 时,你才有使用该容器添加 View 的权限。但 Activity A 的 remove 操作显然没有考虑到会有业务团队在容器内添加带有 Fragment 的 View。因此,我们必须在 A 执行 remove 之前先把 H 从 supportFragmenManager 中 remove 掉。

我们在使用 FragmentTransaction 提交 Fragment 相关操作时,最常用的方式是使用 commit 方法。commit 方法是异步的,在用户高频的滑动与停止滑动之间使用异步 API 是非常危险的,可能会造成我们尝试 add 一个还未被 remove 的 Fragment 的情况。为了使其同步,我们必须改用 commitNow 方法。修复代码再次上线后原来的 crash 彻底消失了,但是出现了一个量还不小的新问题,在排除了具体业务代码的堆栈信息后可以看到堆栈:

java.lang.IllegalStateException
    Can not perform this action after onSaveInstanceState
    at androidx.fragment.app.FragmentManager.checkStateLoss(FragmentManager.java:1689)
    at androidx.fragment.app.FragmentManager.ensureExecReady(FragmentManager.java:1792)
    at androidx.fragment.app.FragmentManager.execSingleAction(FragmentManager.java:1812)
    at androidx.fragment.app.BackStackRecord.commitNow(BackStackRecord.java:297)

第三次修复

上述问题的大致场景是 Activity 跳转后会执行 onSaveInstanceState,FragmentManager 仍然试图通过操作 Activity 的 Window 来操作 Fragment(在我们的例子中是 commitNow)。问题的根源在于 H 的展示并非是在 Activity A 一启动就展示的,而是在监听 RN 给我们的消息,只有收到 RN 的消息时才会启动,而 RN 的消息是异步的,在很多情况下还有相当长的延时,这就导致在 RN 发消息前,用户可能就已经跳转了。而对 RN 消息的监听只会在 onDestroy 时才会取消,因此当消息到达,Fragment 创建完毕并执行 commitNow 的时候,Activity 已经执行完 onSaveInstanceState 了,因此抛出异常并 crash。这时我们需要将 commitNow 替换为 commitNowAllowingStateLoss,对比一下 commitNow 和 commitNowAllowingStateLoss 的实现:

void execSingleAction(@NonNull OpGenerator action, boolean allowStateLoss) {
    if (allowStateLoss && (mHost == null || mDestroyed)) {
        // This FragmentManager isn't attached, so drop the entire transaction.
        return;
    }
    ensureExecReady(allowStateLoss);
    if (action.generateOps(mTmpRecords, mTmpIsPop)) {
        mExecutingActions = true;
        try {
            removeRedundantOperationsAndExecute(mTmpRecords, mTmpIsPop);
        } finally {
            cleanupExec();
        }
    }

    updateOnBackPressedCallbackEnabled();
    doPendingDeferredStart();
    mFragmentStore.burpActive();
}

该方法位于 FragmentManager,最终 commit 和 commitNowAllowingStateLoss 都会调用该方法,区别只是在于 commit 调用时,参数 allowStateLoss 为 false,而 commitNowAllowingStateLoss 调用时则为 true。

当然,Google 并不推荐使用 commitNowAllowingStateLoss 或 commitAllowingStateLoss,而是应该确保调用时机的状态正确。如果不使用 commitNowAllowingStateLoss,正确的做法应该是在 FragmentTransaction 调用前判断当前 Activity 的状态是否正确,若不正确则不做任何事。

总结一下

这次的 crash 一共涉及到两个基础知识点:FragmentManager 与 FragmentTransaction 的API commit/commitNow/commitNowAllowingStateLoss 的区别。

Fragment 并不是什么新知识,但已经掌握的某些知识细节会因为平时工作不会遇到相关的问题而变的生疏或被遗忘,而复杂的实际生产代码又会在不知不觉间掩盖一些潜在的极端情形。因此,实际的线上问题往往是将“知识”转化为“经验”的最好契机。

Android 学习笔录

Android 性能优化篇:https://qr18.cn/FVlo89
Android 车载篇:https://qr18.cn/F05ZCM
Android 逆向安全学习笔记:https://qr18.cn/CQ5TcL
Android Framework底层原理篇:https://qr18.cn/AQpN4J
Android 音视频篇:https://qr18.cn/Ei3VPD
Jetpack全家桶篇(内含Compose):https://qr18.cn/A0gajp
Kotlin 篇:https://qr18.cn/CdjtAF
Gradle 篇:https://qr18.cn/DzrmMB
OkHttp 源码解析笔记:https://qr18.cn/Cw0pBD
Flutter 篇:https://qr18.cn/DIvKma
Android 八大知识体:https://qr18.cn/CyxarU
Android 核心笔记:https://qr21.cn/CaZQLo
Android 往年面试题锦:https://qr18.cn/CKV8OZ
2023年最新Android 面试题集https://qr18.cn/CgxrRy
Android 车载开发岗位面试习题:https://qr18.cn/FTlyCJ
音视频面试题锦:https://qr18.cn/AcV6Ap

你可能感兴趣的:(移动开发,Android,Framework,android,bug,移动开发,面试,Framework,车载,逆向)