公司的项目在最近遇到了一个与 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 的基础知识

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

完成

在第一版的完成中,采取了如下方案。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
    Noviewfoundforid0x7f094914(ctrip.android.view:id/a)forfragmentHRNFragment{7a69d36}id=0x7f094914JDrawerView}
    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 数量大幅下降,但仍有少数存量。这让我不解。所以只能持续剖析。

第2次修正

我发现仍然存在一些我没有考虑到的场景。例如,即使修正上线后,当用户开端滑动时,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 并不是什么新常识,但现已把握的某些常识细节会因为平常作业不会遇到相关的问题而变的生疏或被遗忘,而杂乱的实践出产代码又会在不知不觉间掩盖一些潜在的极点景象。因而,实践的线上问题往往是将“常识”转化为“经验”的最好关键。