得物 H5容器 野指针疑难问题排查 解决

news2025/4/4 5:35:42

1背景

得物 iOS 4.9.x 版本 上线后,一些带有横向滚动内容的h5页面,有一个webkit 相关crash增加较快。通过Crash堆栈判断是UIScrollview执行滚动动画过程中内存野指针导致的崩溃。

2前期排查

通过页面浏览日志,发现发生崩溃时所在的页面都是在h5 web容器内,且都是在页面的生命周期方法viewDidDisappear方法调用后才发生崩溃,因此推测崩溃是在h5 页面返回时发生的。

刚好交易的同事复现了崩溃证实了我们的推测。因此可以基本确定:崩溃的原因是页面退出后,页面内存被释放,但是滚动动画继续执行,这时崩溃堆栈中scrollview的delegate没有置空,系统继续执行delegate的相关方法,访问了已经释放的对象的内存(野指针问题)。

同时发生crash h5 页面都存在一个特点,就是页面内存在可以左右横滑的tab视图。

操作手势侧滑存在体验问题,左右横滑的tab视图也会跟着滚动(见下面视频)。关联bugly用户行为日志,判断这个体验问题是和本文中的crash有相关性的。

点击查看公众号完整视频

3不完美的解决方案

经过上面的分析,修复思路是在h5页面手势侧滑返回时,将h5容器页面内tab的横滑手势禁掉(同时需要在 h5 web容器的viewWillAppear方法里将手势再打开,因为手势侧滑是可以取消在返回页面)。

具体代码如下(这样在操作页面侧滑返回时,页面的手势被禁掉,不会再滚动):

@objc dynamic func webViewCanScroll(enable:Bool) {
        let contentView = self.webView.scrollView.subviews.first { view in
            if let className = object_getClass(view), NSStringFromClass(className) == "WKContentView" {
                return true
            }
            return false
        }
        let webTouchEventsGestureRecognizer = contentView?.gestureRecognizers?.first(where: { gesture in
            if let className = object_getClass(gesture), NSStringFromClass(className) == "UIWebTouchEventsGestureRecognizer" {
                return true
            }
            return false
        })
        webTouchEventsGestureRecognizer?.isEnabled = enable
    }

经过测试,h5 web容器侧滑时出现的tab页面左右滚动的体验问题确实被解决。这样既可以解决体验问题,又可以解决侧滑离开页面导致的崩溃问题,但是这样并没有定位crash的根因。修复代码上线后,crash量确实下降,但是每天还是有一些crash出现,且收到了个别页面极端操作下偶现卡住的问题反馈。因此需要继续排查crash根因,将crash根本解决掉。

继续看文章开始的crash堆栈,通过Crash堆栈判断崩溃原因是UIScrollview执行滚动动画过程中回调代理方法(见上图)时访问被释放的内存。常规解决思路是在退出页面后,在页面生命周期的dealloc方法中,将UIScrollview的delegate置空即可。WKWebView确实有一个scrollVIew属性,我们在很早的版本就将其delegate属性置空,但是崩溃没有解决。

deinit {
         scrollView.delegate = nil
         scrollView.dataSource = nil
    }

因此崩溃堆栈里的Scrollview代理不是这里的WKWebView的scrollVIew的代理。那崩溃堆栈中的scrollView代理到底属于哪个UIScrollview呢?幸运的是苹果webkit 是开源的,我们可以将webkit源码下载下来看一下。

4寻找崩溃堆栈中的ScrollViewDelegate

崩溃堆栈中的ScrollViewDelegate是WKScrollingNodeScrollViewDelegate。首先看看WKWebView的scrollview的 delegate是如何实现的,因为我们猜想这个scrollview的delegate除了我们自己设置的,是否还有其他delegate(比如崩溃堆栈中的WKScrollingNodeScrollViewDelegate)。

通过对Webkit源码一番研究,发现scrollview的初始化方法:

- (void)_setupScrollAndContentViews
{
    CGRect bounds = self.bounds;
    _scrollView = adoptNS([[WKScrollView alloc] initWithFrame:bounds]);
    [_scrollView setInternalDelegate:self];
    [_scrollView setBouncesZoom:YES];

}

WKWebView的scrollVIew 是WKScrollView 类型。

4.1 WKScrollView 代理实现

首先看到WKWebView的scrollview的类型其实是WKScrollView(UIScrollview的子类),他除了继承自父类的delegate属性,还有一个internalDelegate属性,那么这个internalDelegate属性是不是我们要找的WKScrollingNodeScrollViewDelegate 呢?

@interface WKScrollView : UIScrollView

@property (nonatomic, assign) WKWebView <UIScrollViewDelegate> *internalDelegate;


@end

通过阅读源码后发现不是这样的(代码有删减,感兴趣可自行阅读源码)。

- (void)setInternalDelegate:(WKWebView <UIScrollViewDelegate> *)internalDelegate
{
    if (internalDelegate == _internalDelegate)
        return;
    _internalDelegate = internalDelegate;
    [self _updateDelegate];
}

- (void)setDelegate:(id <UIScrollViewDelegate>)delegate
{
    if (_externalDelegate.get().get() == delegate)
        return;
    _externalDelegate = delegate;
    [self _updateDelegate];
}

- (id <UIScrollViewDelegate>)delegate
{
    return _externalDelegate.getAutoreleased();
}

- (void)_updateDelegate
{//......
    if (!externalDelegate)
    else if (!_internalDelegate)
    else {
        _delegateForwarder = adoptNS([[WKScrollViewDelegateForwarder alloc] initWithInternalDelegate:_internalDelegate externalDelegate:externalDelegate.get()]);
        [super setDelegate:_delegateForwarder.get()];
    }
}

这个internalDelegate的作用是让WKWebView 监听scrollview的滚动回调,同时也可以让开发者在外部监听WKWebView的scrollview回调。如何实现的呢?可以查看WKScrollViewDelegateForwarder的实现。

- (void)forwardInvocation:(NSInvocation *)anInvocation
{
    //...
    if (internalDelegateWillRespond)
        [anInvocation invokeWithTarget:_internalDelegate];
    if (externalDelegateWillRespond)
        [anInvocation invokeWithTarget:externalDelegate.get()];

}

通过复写- (void)forwardInvocation:(NSInvocation *)anInvocation 方法,在消息转发时实现的。

4.2 猜想 & 验证

既然WKScrollingNodeScrollViewDelegate 不是WKScrollview的属性,那说明崩溃堆栈中的scrollview不是WKScrollview,那页面上还有其他scrollview么。我们看源码WKScrollingNodeScrollViewDelegate 是在哪里设置的。

void ScrollingTreeScrollingNodeDelegateIOS::commitStateAfterChildren(const ScrollingStateScrollingNode& scrollingStateNode)
{
        //......
        if (scrollingStateNode.hasChangedProperty(ScrollingStateNode::Property::ScrollContainerLayer)) {
            if (!m_scrollViewDelegate)
                m_scrollViewDelegate = adoptNS([[WKScrollingNodeScrollViewDelegate alloc] initWithScrollingTreeNodeDelegate:this]);
        }
 }

搜索webkit的源码,发现创建WKScrollingNodeScrollViewDelegate的位置只有一处。但是webkit的源码太过于复杂,无法通过阅读源码的方式知道WKScrollingNodeScrollViewDelegate属于哪个scrollview。

为此我们只能换一种思路,我们通过xcode调试的方式查看当前webview加载的页面是否还有其他scrollview。

页面上刚好还有一个scrollview:WKChildScrollview

这个WKChildScrollview 是否是崩溃堆栈中的scrollview呢,如果我们能确定他的delegate是WKScrollingNodeScrollViewDelegate,那就说明这个WKChildScrollview 是崩溃堆栈中的scrollview。

为了验证这个猜想,我们首先找到源码,源码并没有太多,看不出其delegate类型。

@interface WKChildScrollView : UIScrollView <WKContentControlled>
@end

我们只能转换思路在运行时找到WKWebView的类型为WKChildScrollView的子view(通过OC runtime & 视图树遍历的方式),判断他的delegate是否为WKScrollingNodeScrollViewDelegate 。

我们运行时找到类型为 WKChildScrollView 的子view后,获取其delegate类型,确实是WKScrollingNodeScrollViewDelegate。至此我们找到了崩溃堆栈中的scrollview。

确定了崩溃堆栈中的scrollview的类型,那么修复起来也比较容易了。在页面生命周期的viewDidAppear方法里,获取类型为 WKChildScrollView的子view。然后在dealloc方法里,将其delegate置空即可。

deinit {
        if self.childScrollView != nil {
            if self.childScrollView?.delegate != nil {
                 self.childScrollView?.delegate = nil
             }
        }
}

4.3 小程序同层渲染

想完了解决方案,那么WKChildScrollView 是做啥用的呢?

WKWebView 在内部采用的是分层的方式进行渲染,它会将 WebKit 内核生成的 Compositing Layer(合成层)渲染成 iOS 上的一个 WKCompositingView,这是一个客户端原生的 View,不过可惜的是,内核一般会将多个 DOM 节点渲染到一个 Compositing Layer 上,因此合成层与 DOM 节点之间不存在一对一的映射关系。当把一个 DOM 节点的 CSS 属性设置为 overflow: scroll (低版本需同时设置 -webkit-overflow-scrolling: touch)之后,WKWebView 会为其生成一个 WKChildScrollView,与 DOM 节点存在映射关系,这是一个原生的 UIScrollView 的子类,也就是说 WebView 里的滚动实际上是由真正的原生滚动组件来承载的。WKWebView 这么做是为了可以让 iOS 上的 WebView 滚动有更流畅的体验。虽说 WKChildScrollView 也是原生组件,但 WebKit 内核已经处理了它与其他 DOM 节点之间的层级关系,这一特性可以用来做小程序的同层渲染。(「同层渲染」顾名思义则是指通过一定的技术手段把原生组件直接渲染到 WebView 层级上,此时「原生组件层」已经不存在,原生组件此时已被直接挂载到 WebView 节点上。你几乎可以像使用非原生组件一样去使用「同层渲染」的原生组件,比如使用 view、image 覆盖原生组件、使用 z-index 指定原生组件的层级、把原生组件放置在 scroll-view、swiper、movable-view 等容器内等等)。

5苹果的修复方案

本着严谨的态度,我们想是什么导致了最开始的崩溃堆栈呢?是我们开发过程中的功能还是系统bug?如果是系统bug,其他公司也可能遇到,但是互联网上搜不到其他公司或开发者讨论崩溃相关信息。我们继续看一下崩溃堆栈的top 函数RemoteScrollingTree::scrollingTreeNodeDidScroll() 源码如下:

void RemoteScrollingTree::scrollingTreeNodeDidScroll(ScrollingTreeScrollingNode& node, ScrollingLayerPositionAction scrollingLayerPositionAction)
{
    ASSERT(isMainRunLoop());

    ScrollingTree::scrollingTreeNodeDidScroll(node, scrollingLayerPositionAction);

    if (!m_scrollingCoordinatorProxy)
        return;

    std::optional<FloatPoint> layoutViewportOrigin;
    if (is<ScrollingTreeFrameScrollingNode>(node))
        layoutViewportOrigin = downcast<ScrollingTreeFrameScrollingNode>(node).layoutViewport().location();

    m_scrollingCoordinatorProxy->scrollingTreeNodeDidScroll(node.scrollingNodeID(), node.currentScrollPosition(), layoutViewportOrigin, scrollingLayerPositionAction);
}

崩溃在这个函数里,查看这个函数的commit记录:

 

简单描述一下就是scrollingTreeNodeDidScroll方法中使用的m_scrollingCoordinatorProxy 对象改成weak指针,并进行判空操作。这种改变,正是解决m_scrollingCoordinatorProxy 内存被释放后还在访问的方案。

这个commit是2023年2月28号提交的,commit log是:

[UI-side compositing] RemoteScrollingTree needs to hold a weak ref to the RemoteScrollingCoordinatorProxy
https://bugs.webkit.org/show_bug.cgi?id=252963
rdar://105949247

Reviewed by Tim Horton.

The scrolling thread can extend the lifetime of the RemoteScrollingTree via activity on that thread,
so RemoteScrollingTree needs to hold a nullable reference to the RemoteScrollingCoordinatorProxy;
use a WeakPtr.

至此,我们基本确认,这个崩溃堆栈是webkit内部实现的一个bug,苹果内部开发者最终使用弱引用的方式解决。

同时修复上线后,这个crash的崩溃量也降为0。

6总结

本文中的crash从出现到解决历时近一年,一开始根据线上日志判断是h5 页面返回 & h5 页面滚动导致的问题,禁用手势后虽然几乎解决问题,但是线上还有零星crash上报,因此为了保证h5 离线功能的线上稳定性,需要完美解决问题。

本文的crash 似曾相识,但是经过验证和阅读源码后发现并不是想象的那样,继续通过猜想+阅读源码的方式寻找到了崩溃堆栈中的真正scrollview代理对象,从而在app 侧解决问题。最后发现是苹果webkit的bug。

本文中的崩溃问题本质上是野指针问题,那么野指针问题定位有没有通用的解决方案呢?

6.1 Objective-C 野指针定位

关于野指针问题如何解决,iOS开发应该比较熟,Objective-C 中的野指针问题线上可以用 Zombie 监控:

具体原理是首先我们会 hook 基类 NSObject 的 dealloc 方法,当任意 OC 对象被释放的时候,hook 之后的那个 dealloc 方法并不会真正的释放这块内存,同时将这个对象的 ISA 指针指向一个特殊的僵尸类,因为这个特殊的僵尸类没有实现任何方法,所以这个僵尸对象在之后接收到任何消息都会 Crash,我们需要将崩溃现场这个僵尸对象的类名以及当时调用的方法名上报到后台分析。

Zombie 监控相比常规崩溃监控有啥优势呢?首先它可以直接定位到问题发生的类,而不是一些随机的崩溃调用栈;另外它可以提高偶现问题的复现概率,因为大部分偶现问题可能跟多线程的运行环境有关,如果我们能把一个偶现问题变成必现问题的话,那么开发者就可以借助 IDE 和调试器非常方便地排查问题。但是这个方案也有自己的适用范围,因为它的底层原理基于 OC 的 runtime 机制,所以它仅仅适用于 OC 对象野指针导致的内存问题。

6.2 C 和 C++ 野指针定位 

C 和 C++ 代码同样可能会出现野指针问题,在 Mach 异常和 Signal 异常中,除了内存问题之外,还有很多其他类型的异常比如 EXC_BAD_INSTRUCTION和SIGABRT。那么其他的疑难问题我们又该怎么解决呢?可以使用 Coredump。

Coredump 是由 lldb 定义的一种特殊的文件格式,Coredump 文件可以还原 App 在运行到某一时刻的完整运行状态(这里的运行状态主要指的是内存状态)。大家可以简单地理解为:Coredump文件相当于在崩溃的现场打了一个断点,并且获取到当时所有线程的寄存器信息,栈内存以及完整的堆内存。

Coredump 方案的优势是什么呢?首先因为它是 lldb 定义的文件格式,所以它天然支持 lldb 的指令调试,也就是说开发者无需复现问题,就可以实现线上疑难问题的事后调试。另外因为它有崩溃时现场的所有内存信息,这就为开发者提供了海量的问题分析素材。因此只要在崩溃发生时,将codedump上报上来就能更容易定位问题。

参考文献: iOS 8 动画执行过程中返回 Crash 同层小程序渲染原理分析 字节跳动如何系统性治理 iOS 稳定性问题 

文:Luke

 

线下活动推荐

时间:2023年6月10日(周六) 14:00-18:00

主题:得物技术沙龙总第18期-无线技术第4期

地点:杭州·西湖区学院路77号得物杭州研发中心12楼培训教室(地铁10号线&19号线文三路站G口出)

活动亮点:本次无线沙龙聚焦于最新的技术趋势和实践,将在杭州/线上为你带来四个令人期待的演讲话题,包括:《抖音创作工具-iOS功耗监控与优化》、《得物隐私合规平台建设实践》、《网易云音乐-客户端大流量活动的日常化保障方案实践》、《得物Android编译优化》。相信这些话题将对你的工作和学习有所帮助,我们期待着与你共同探讨这些令人兴奋的技术内容!

点击报名: 无线技术沙龙 

 

本文属得物技术原创,来源于:得物技术官网

未经得物技术许可严禁转载,否则依法追究法律责任!

 

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

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

相关文章

C/C++数据类型从0到内存具体分配详解

一&#xff0c;数据类型分类 1.整形家族&#xff1a;char , short , int , long , long long , unsigned int , unsigned char , unsinged short , unsigned long , unsinged long long 。&#xff08;为什么将char归入整形家族是因为字符在机器中是以Ascll码值储存的&#…

分类管理你的联系人,有效提升营销转化率!

电子邮件营销已成为外贸和跨境电商企业宣传产品和服务的必不可少的工具。在电子邮件营销中&#xff0c;电子邮件联系人列表的质量对活动的成功至关重要。提高联系人名单质量的途径之一就是对联系人进行分类管理。本文将讨论为邮件联系人为什么要分类管理&#xff1f; 1、提高活…

风险投资成功案例分析_著名的风投成功案例

风险投资成功案例分析1 转换科技公司(Transition Technology Inc.以下简称TTI)在1987年初开始寻求风险资本&#xff0c;直到212天后终于获得了3i风险投资公司(以下简称3i)等提供的300万美元风险资本。这是一个比较常规的风险投资过程&#xff0c;但其中的曲折历程也颇耐人…

Maven uber-jar(带依赖的打包插件) spring-boot-maven-plugin

文章目录 最基础的 spring-boot-maven-plugin 使用指定入口类安装部署原始 Jar 包到仓库保持原始Jar包名称&#xff0c;为 spring-boot-maven-plugin 生成的Jar包添加名称后缀打包时排除依赖建议将生成的Jar解压后了解一下整体结构与其他常用打包插件比较 本文是对 spring-boot…

04 【计算属性 侦听属性】

04 【计算属性 侦听属性】 1.计算属性 1.1插值语法实现 <title>姓名案例_插值语法实现</title><div id"root">姓&#xff1a;<input type"text" v-model"firstName"> <br/>名&#xff1a;<input type"…

Python自动人工智能训练数据增强工具 | DALI介绍(含代码)

Python自动人工智能训练数据增强工具 | DALI介绍(含代码) 文章目录 Python自动人工智能训练数据增强工具 | DALI介绍(含代码)自动数据增强方法DALI 和条件执行使用 DALI 自动增强使用 DALI 的自动增强性能尝试使用 DALI 进行自动增强 深度学习模型需要数百 GB 的数据才能很好地…

回归测试:优先级(Coverage 的适应度函数)

回归测试&#xff1a;优先级 介绍 在确定优先级时&#xff0c;我们的目标是为测试用例找到一个好的顺序。理想情况下&#xff0c;我们希望尽早发生任何故障。这可以加快整体开发过程&#xff0c;例如&#xff1a; 有时&#xff0c;一旦发现失败&#xff0c;我们就会停止测试。…

HashMap详细讲解-面试题大全

HashMap底层数据结构是什么&#xff1f;1.7和1.8有何不同 1.7是 数组 链表&#xff0c;1.8 是数组 &#xff08;链表 或者 红黑树&#xff09; 当链表的元素比较多的时候&#xff0c;链表就会转换成红黑树&#xff0c;红黑树的元素减少了&#xff0c;红黑树也会转换成链表 为…

前后分离的优势

1.可以实现真正的前后端解耦&#xff0c;前端服务器使用nginx。 前端/WEB服务器放的是css&#xff0c;js&#xff0c;图片等等一系列静态资源&#xff08;甚至你还可以css&#xff0c;js&#xff0c;图片等资源放到特定的文件服务器&#xff0c;例如阿里云的oss&#xff0c;并使…

【计算机网络】前后端分离,HTTP协议,网络分层结构,TCP,强缓存/协商缓存

❤️ Author&#xff1a; 老九 ☕️ 个人博客&#xff1a;老九的CSDN博客 &#x1f64f; 个人名言&#xff1a;不可控之事 乐观面对 &#x1f60d; 系列专栏&#xff1a; 文章目录 前后端分类HTTP协议HTTP组成HTTP的版本HTTP的请求方式HTTP请求头HTTP响应头强缓存和协商缓存 HT…

Linux ls -l输出文件信息详解

在linux中&#xff0c;我们知道一切皆为文件&#xff0c;经常我们会使用ls -l去查看文件的信息&#xff0c;今天会大家详细讲解一下ls -l输出的文件属性信息。 1.ls -l输出 命令&#xff1a; ls -l 通过ls -l命令输出&#xff0c;我们可以看到上图中的属性信息输出&#xff…

【珍藏版】生态系统NPP及碳源、碳汇模拟、土地利用变化、未来气候变化、空间动态模拟

由于全球变暖、大气中温室气体浓度逐年增加等问题的出现&#xff0c;“双碳”行动特别是碳中和已经在世界范围形成广泛影响。碳中和可以从碳排放&#xff08;碳源&#xff09;和碳固定&#xff08;碳汇&#xff09;这两个侧面来理解。陆地生态系统在全球碳循环过程中有着重要作…

五、JSP05 分页查询及文件上传

五、JSP 分页查询及文件上传 5.1 使用分页显示数据 通过网络搜索数据时最常用的操作&#xff0c;但当数据量很大时&#xff0c;页面就会变得冗长&#xff0c;用户必须拖动才能浏览更多的数据 分页是把数据库中需要展示的数据逐页分步展示给用户 以分页的形式显示数据&#xff…

Elasticsearch 8.X “图搜图”实战

1、什么是图搜图&#xff1f; "图搜图"指的是通过图像搜索的一种方法&#xff0c;用户可以通过上传一张图片&#xff0c;搜索引擎会返回类似或者相关的图片结果。这种搜索方式不需要用户输入文字&#xff0c;而是通过比较图片的视觉信息来找到相似或相关的图片。这项…

Tomcat服务器的安装即相关介绍

一、Tomcat的安装步骤 1、访问官网下载点击此处进入Tomcat官网&#xff1b; 2、在下图所示位置点击想要下载的版本下载&#xff0c;这边演示的是以Tomcat8为演示对象&#xff1b; 3、进入下载页面如下图所示&#xff0c;根据系统类型和版本选择合适的安装包&#xff1b; 4、下…

Wampsever升级增加php5.6的方法过程

1、下载wampserver2.5&#xff0c;文件包名&#xff1a;wampserver2.5-Apache-2.4.9-Mysql-5.6.17-php5.5.12-64b.exe https://sourceforge.net/projects/wampserver/files/WampServer%202/Wampserver%202.5/ 这个版本只有40M&#xff0c;包含&#xff1a; Apache-2.4.9&#x…

SpringCloud OpenFeign 学习

SpringCloud OpenFeign 文章目录 SpringCloud OpenFeign1 OpenFeign介绍2 OpenFeign-应用实例3 OpenFeign 测试 1 OpenFeign介绍 OpenFeign 是个声明式 WebService 客户端&#xff0c;使用 OpenFeign 让编写 Web Service 客户端 更简单 它的使用方法是定义一个服务接口然后在上…

【Java 抽象类抽象方法】什么是抽象类方法,如何定义,起什么作用?

博主&#xff1a;_LJaXi Or 東方幻想郷 专栏&#xff1a; Java | 从入门到入坟 Java 抽象类 & 抽象方法 抽象类的概念 &#x1f445;抽象方法的概念 &#x1f42c;抽象类和抽象方法结合使用 &#x1f984; Java中的抽象类和抽象方法是面向对象编程中的重要概念&#xff0c;…

【系统学习】Java基础4之lamda表达式和函数式接口

lamda表达式与函数式接口 lamda表达式 语法格式一&#xff1a;无参&#xff0c;无返回值 Lambda 需要一个参数&#xff0c;但是没有返回值 语法格式三&#xff1a;数据类型可以省略&#xff0c;因为可由编译器推断得出&#xff0c;称为“类型推断” 语法格式四&#xff1a;…

MySQL和Redis之间的存储区别

概述 MySQL是一种关系型数据库&#xff0c;而Redis是一种键值对存储数据库。虽然它们都是用来存储和管理数据的&#xff0c;但是它们在很多方面都有不同&#xff0c;但是它们在存储策略、日志存储方式、硬盘存储、数据恢复等方面都有一定的区别。 数据类型 MySQL支持多种数据…