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

news2024/11/26 9:54:07

作者: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

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

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

unittest教程__Python+unittest+ddt_实现数据驱动测试(7)

我们设计测试用例时,会出现测试步骤一样,只是其中的测试数据有变化的情况,比如测试登录时的账号密码。这个时候,如果我们依然使用一条case一个方法的话,会出现大量的代码冗余,而且效率也会大大降低。此时&a…

知网英语类专刊《中学生英语》是正规刊物吗?

知网英语类专刊《中学生英语》是正规刊物吗? 《中学生英语》是经国家新闻出版署批准在国内公开发行的教育类学术期刊,由教育部主管,华中师范大学主办的国家级期刊,是正规刊物。 《中学生英语》是中国外语教学期刊质量检测网络入…

南京贸易企业增值税居高不下,该如何解决?

南京贸易企业增值税居高不下,该如何解决? 《税筹顾问》专注于园区招商,您的贴身节税小能手,合理合规节税! 南京作为省会城市,近年来由于芯片、生物医药等产业的发展而跻身前十,随着它在教育、军…

【探索 Kubernetes|作业管理篇 系列 11】控制器的核心功能

前言 大家好,我是秋意零。 上一篇结束了 Pod 对象的内容。 今天要探讨的内容是 “控制器”,它是 Kubernetes 编排最核心的功能。理解了 “控制器”,你就能理解 Deployment、StatefulSet、DaemontSet、Job、CroJob 控制器对象。 最近搞了一…

使用parcel搭建threejs开发环境

一、什么是parcel parcel官网:https://www.parceljs.cn/ Parcel是一个快速、零配置的Web应用打包器,可将JavaScript、CSS、HTML和图像等静态文件打包到一个捆绑文件中。它的主要目标是简化Web应用程序的打包过程,使开发人员可以更快速地创建…

测试开发工是做什么的?2023年往后测试之路发展前景?

目录:导读 前言一、Python编程入门到精通二、接口自动化项目实战三、Web自动化项目实战四、App自动化项目实战五、一线大厂简历六、测试开发DevOps体系七、常用自动化测试工具八、JMeter性能测试九、总结(尾部小惊喜) 前言 现在公司企业对测…

Linux目录分类说明

在Linux根目录下一般包括如下目录,这里就这些目录的特点做一些归纳。(centos8为例) 一、/var目录 /var目录是一个包含经常变化的文件的目录。它是Variable的缩写,也有些人解释为Versioned Archives。通常包含以下内容:…

Groovy基础教程

一、概述 Groovy是一种基础JVM(Java虚拟机)的敏捷开发语言,他结合了Python、Ruby和Smalltalk的特性,Groovy代码能够于Java代码很好的结合,也能用于扩展现有代码。由于其运行在JVM的特性,Groovy可以使用其他Java语言编写法的库。 …

电源ATE测试系统-电源模块自动化测试软件ATECLOUD-Power

ATECLOUD-Power测试应用场景 研发测试、产线测试、老化测试、一测二测等 ATECLOUD-Power解决测试痛点 ☁ 人工手动测试,效率低,需要提高测试效率和准确性; ☁ 测试产品种类繁多,测试方法多样,客户需要灵活的解决方案…

六、SpringBoot集成elasticsearch

目录 官网API介绍 1、新建maven项目 2、检查elasticsearch依赖的版本 3、配置RestHighLevelClient对象 4、使用springboot-test测试API的使用 官网API介绍 Java API Client https://www.elastic.co/guide/en/elasticsearch/client/java-api-client/current/index.html …

TypeScript基础使用方法

Author: 德玛玩前端 Date: 2023-06-07 TypeScript 一、Typescript介绍 1.1、TypeScript产生的原因 旧JS是弱类型语言,一个变量先后可以保存不同类型的数据,所以不可靠。旧JS是解释执行语言,一边解释,一边执行,一些低…

python接口自动化(二)--什么是接口测试、为什么要做接口测试(详解)

简介 上一篇和大家一起科普扫盲接口后,知道什么是接口,接口类型等,对其有了大致了解之后,我们就回到主题-接口测试。 什么是接口测试 接口测试是测试系统组件间接口的一种测试。接口测试主要用于检测外部系统与系统之间以及内部各…

7.SpringCloudAlibaba 整合 Sentinel

一、分布式系统遇到的问题 1 服务雪崩效应 在分布式系统中,由于网络原因或自身的原因,服务一般无法保证 100%是可用的。如果一个服务出现了问题,调用这个服务就会出现线程阻塞的情况,此时若有大量的请求涌入,就会出现多条线程阻塞等待,进而导致调用服务瘫痪。 由于服务…

SDP协议是什么,详解SDP协议

一、SDP协议简介 SDP(Session Description Protocol)是一种会话描述协议,用于描述多媒体会话的参数。它是一种文本协议,通常用于VoIP(Voice over Internet Protocol)和视频会议等应用中。SDP协议定义了一种…

【自监督论文阅读 1】SimCLR

文章目录 一、摘要二、引言三、方法3.1 主要框架3.2 训练一个大的batchsize 四、数据增强4.1 实验一 数据增强的组合对学习好的特征表达非常重要4.2 对比学习需要更多的数据增强 五、一些实验证明5.1 大模型更有利于无监督对比学习5.2 非线性层的预测头增加了特征表示5.3可调节…

使用vtkWindow报错Debug Assertion Failed ... mfc140d.dll

环境:VS2022VTK7.1.1,还使用了MFC 报错信息 Debug Assertion Failed! Program: C:\WINDOWS SYSTEM32 mfc140d.dll File. D: a work 1 s src vctools VC7Libs ship ATLMFCnclude afxwin1.inLine: 21 For information on how your program can cause an a…

2023年智能优化算法之——能量谷优化器 Energy valley optimizer(EVO),附MATLAB代码和文献

能量谷优化器(EVO)是一种新的元启发式算法,它的算法是受到了关于稳定性和不同粒子衰变模式的先进物理原理的启发。在文献中,作者与CEC函数中最先进的算法进行了比较,并且证明该算法确实很强劲。算法原理大家请参考文献。 [1] Azizi M , Aic…

考研算法第27天:直接插入排序 【插入排序】

插入排序算法介绍 老规矩我们来模拟一遍样例: 其思想简单来说就是将旧数组的每个数放入到新数组中 但是每次放入都要遵守下面的原则:如果前面有比当前数大的数便把它放到当前数的后面去。 过程如下面这张图 https://ts1.cn.mm.bing.net/th/id/R-C.1d…

GPT-4满分通过MIT本科数学考试!这套提示词火了

量子位 | 公众号 QbitAI 万万想不到啊,MIT数学考试,被GPT-4攻破了?! 突然有人在最新论文工作中高调宣布: GPT-4在MIT的数学和EECS(电气工程和计算机科学系)本科学位考试中,表现出…

电商超卖,从业务到设计

编辑导语:超卖这一概念的定义可以从不同层面进行阐述,比如平台层面、渠道层面、仓库层面等。而假设因超卖导致订单难以履行,则容易让用户体验“打折”。为什么有时电商超卖的现象会发生?可以从哪些角度来降低超卖导致的风险&#…