从一个线上 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。这是一个技术大前提。

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

实现

在第一版的实现中,采取了如下方案。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

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:/a/30345.html

如若内容造成侵权/违法违规/事实不符,请联系我们进行投诉反馈qq邮箱809451989@qq.com,一经查实,立即删除!

相关文章

【RabbitMQ教程】第四章 —— RabbitMQ - 交换机

💧 【 R a b b i t M Q 教 程 】 第 四 章 — — R a b b i t M Q − 交 换 机 \color{#FF1493}{【RabbitMQ教程】第四章 —— RabbitMQ - 交换机} 【RabbitMQ教程】第四章——RabbitMQ−交换机💧 🌷 仰望天空,妳我亦是…

共创开源生态 | 小米肖翔荣获“2023中国开源优秀人物”奖

6月15-16日,以“开源创新 数字化转型 智能化重构”为主题的“第十八届开源中国・开源世界高峰论坛”在北京成功召开。小米工程师肖翔凭借其在 Apache 基金会的开源贡献及在操作系统领域内的技术突破,荣获“2023中国开源优秀人物”奖。 Xiaomi …

使用VitePress创建个人网站并部署到GitHub

网站在线预览 参考文档: VitePress 创建 GitHub 远程仓库 克隆远程仓库到本地 git clone gitgithub.com:themusecatcher/front-end-notes.git进入 front-end-notes/ 目录,添加 README.md 并建立分支跟踪 echo "# front-end-notes" >>…

nand flash 介绍

flash名称由来 Flash的擦除操作是以block块为单位的,与此相对应的是其他很多存储设备,是以bit位为最小读取/写入的单位,Flash是一次性地擦除整个块:在发送一个擦除命令后,一次性地将一个block,常见的块的大…

FAQ页面在SaaS产品中的应用

随着云计算和软件即服务(SaaS)的快速发展,越来越多的企业选择将业务迁移到云端,以更好地管理和运营他们的业务。在这种背景下,SaaS产品的出现成为了企业管理和运营的新趋势。SaaS产品通过云端的方式,为企业…

Godot 4 源码分析 - 命令行参数

粗看Godot 4的源码&#xff0c;稍微调试一下&#xff0c;发现一大堆的命令行参数。在widechar_main中 Error err Main::setup(argv_utf8[0], argc - 1, &argv_utf8[1]); Main::setup中&#xff0c;各命令行参数加入到List<Stirng> args中&#xff0c;并通过OS::get…

腾讯云服务器地域有什么区别?怎么选比较好

腾讯云服务器地域有什么区别&#xff1f;云服务器地域怎么选择&#xff1f;地域是指云服务器所在机房的地理位置&#xff0c;用户距离地域越近网络延迟越低&#xff0c;速度越快&#xff0c;所以地域就近选择即可。广州上海北京等地域网站域名需要备案&#xff0c;中国香港或其…

基于三种机器学习模型的岩爆类型预测及Python实现

写在前面 由于代码较多&#xff0c;本文仅展示部分关键代码&#xff0c;需要代码文件和数据可以留言 然而&#xff0c;由于当时注释不及时&#xff0c;且时间久远&#xff0c;有些细节笔者也记不清了&#xff0c;代码仅供参考 0 引言 岩爆是深部岩土工程施工过程中常见的一种地…

实现Vue3和UE5.2进行通信(Pixel Streaming)

文章目录 1. 从UE5.2到前端页面的通信1.1 编写蓝图脚本1.2 编写前端的响应函数1.3 功能验证 2. 从Vue3到UE5.2的信息发送2.1 UE5.2蓝图的设计2.2 前端发送消息功能的实现2.3 功能验证 3. 参考资源 这篇文章简单讲解一下如何实现vue3和UE5进行数据的通信。 如果有同学还不清楚如…

微服务链路追踪SkyWalking的介绍和部署

skywalking和链路追踪 SkyWalking介绍 首先我们要明白一点&#xff0c;在微服务的架构中&#xff0c;为什么要做链路追踪&#xff1f;解决问题的痛点在哪里&#xff1f;其实无外乎是如下几个问题&#xff1a; 如何将整个调用链路串起来&#xff0c;并能够快速定位问题&#…

通过GPIO子系统编写LED驱动,应用程序控制LED灯亮灭

1、在内核设备树中添加设备信息&#xff1a; LED1的设备树编写需要参考内核的帮助文档&#xff1a; linux-5.10.61/Documentation/devicetree/bindings/gpio 在根节点内部添加led灯设备树节点 :~/linux-5.10.61/arch/arm/boot/dts $ vi stm32mp157a-fsmp1a.dts myled.c #in…

选择排序 - C语言实现

目录 &#x1f970;前言 ✅选择排序 &#x1f95d;基本思想 &#x1f95d;实现逻辑 &#x1f95d;动图演示 复杂度分析 &#x1f60d;代码实现 &#x1f6a9;优化改进-->二元选择排序 &#x1f60d; 改进代码 前言 &#x1f970;在学数据结构的第一节课就知道了数据结…

React 通过一个输入内容加入列表案例熟悉 Hook 基本使用

我们创建一个react项目 在src下创建components文件夹 在下面创建一个index.jsx index.jsx 参考代码如下 import React, { useState } from "react";const useInputValue (initialValue) > {const [value,setValue] useState(initialValue);return {value,onCha…

19-递归的理解、场景

一、递归 &#x1f32d;&#x1f32d;&#x1f32d;在函数内部&#xff0c;可以调用其他函数。如果一个函数在内部调用自身本身&#xff0c;这个函数就是递归函数 核心思想是把一个大型复杂的问题层层转化为一个与原问题相似的规模较小的问题来求解 一般来说&#xff0c;递归…

算法刷题-字符串-左旋转字符串

反转个字符串还有这么多用处&#xff1f; 题目&#xff1a;剑指Offer58-II.左旋转字符串 力扣题目链接 字符串的左旋转操作是把字符串前面的若干个字符转移到字符串的尾部。请定义一个函数实现字符串左旋转操作的功能。比如&#xff0c;输入字符串"abcdefg"和数字2…

generator和promise和async的异同

一、generator(生成器)是ES6标准引入的新数据类型,他和promise一样都是异步事件的解决方案 //generator函数生成斐波那契// generator(生成器)是ES6标准引入的新数据类型,async就是 Generator 函数的语法糖//本质&#xff1a;用来处理异步事件的对象/包含异步操作的容器functio…

校园外卖平台怎么做

校园外卖小程序是一款基于智能手机的移动应用&#xff0c;提供订餐、支付、配送等服务。它能为顾客提供丰富的美食选择&#xff0c;为商家提供进一步发展业务的机会&#xff0c;同时骑手也有机会赚取额外的收入。 一、 用户端功能介绍 1. 地图定位&#xff1a;用户可以利用小…

网络安全学术顶会——CCS '22 议题清单、摘要与总结(中)

注意&#xff1a;本文由GPT4与Claude联合生成。 81、HammerScope: Observing DRAM Power Consumption Using Rowhammer 内存单元尺寸的不断缩小使得内存密度提高&#xff0c;功耗降低&#xff0c;但同时也影响了其可靠性。Rowhammer攻击利用这种降低的可靠性在内存中引发比特翻…

计算机网络基础学习指南

前言 计算机网络基础是研发/运维工程师都需掌握的知识&#xff0c;但往往会被忽略。 今天&#xff0c;我将对计算机网络基础学习进行详细阐述&#xff0c;涵盖 TCP / UDP协议、Http协议、Socket等&#xff0c;希望你们会喜欢。 1、计算机网络体系结构 1.1 简介 定义 计算机…

数据库的事务处理

文章目录 前言一、事务的概念二、事务的特性三、隔离级别四、并发控制五、总结 前言 在现代信息化时代&#xff0c;大量的数据不断地被创建、修改、删除和查询。 为了保证数据的准确性和一致性&#xff0c;数据库的事务处理成为了必不可少的一个重要组成部分。 本文将针对数据…