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 }
@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 }
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];
}
- (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
@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()]; }}
- (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
{
//...
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]); } }
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
@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 } }}
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);}
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 RemoteScrollingCoordinatorProxyhttps://bugs.webkit.org/show_bug.cgi?id=252963rdar://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.
[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。
本文中的崩溃问题本质上是野指针问题,那么野指针问题定位有没有通用的解决方案呢?