得物H5容器野指針疑難問題排查 & 解決
1、背景
得物 iOS 4.9.x 版本 上線后,一些帶有橫向滾動(dòng)內(nèi)容的h5頁面,有一個(gè)webkit 相關(guān)crash增加較快。通過Crash堆棧判斷是UIScrollview執(zhí)行滾動(dòng)動(dòng)畫過程中內(nèi)存野指針導(dǎo)致的崩潰。
2、前期排查
通過頁面瀏覽日志,發(fā)現(xiàn)發(fā)生崩潰時(shí)所在的頁面都是在h5 web容器內(nèi),且都是在頁面的生命周期方法viewDidDisappear方法調(diào)用后才發(fā)生崩潰,因此推測(cè)崩潰是在h5 頁面返回時(shí)發(fā)生的。
剛好交易的同事復(fù)現(xiàn)了崩潰證實(shí)了我們的推測(cè)。因此可以基本確定:崩潰的原因是頁面退出后,頁面內(nèi)存被釋放,但是滾動(dòng)動(dòng)畫繼續(xù)執(zhí)行,這時(shí)崩潰堆棧中scrollview的delegate沒有置空,系統(tǒng)繼續(xù)執(zhí)行delegate的相關(guān)方法,訪問了已經(jīng)釋放的對(duì)象的內(nèi)存(野指針問題)。
同時(shí)發(fā)生crash h5 頁面都存在一個(gè)特點(diǎn),就是頁面內(nèi)存在可以左右橫滑的tab視圖。
操作手勢(shì)側(cè)滑存在體驗(yàn)問題,左右橫滑的tab視圖也會(huì)跟著滾動(dòng)(見下面視頻)。關(guān)聯(lián)bugly用戶行為日志,判斷這個(gè)體驗(yàn)問題是和本文中的crash有相關(guān)性的。
3、不完美的解決方案
經(jīng)過上面的分析,修復(fù)思路是在h5頁面手勢(shì)側(cè)滑返回時(shí),將h5容器頁面內(nèi)tab的橫滑手勢(shì)禁掉(同時(shí)需要在 h5 web容器的viewWillAppear方法里將手勢(shì)再打開,因?yàn)槭謩?shì)側(cè)滑是可以取消在返回頁面)。
具體代碼如下(這樣在操作頁面?zhèn)然祷貢r(shí),頁面的手勢(shì)被禁掉,不會(huì)再滾動(dòng)):
@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
}
經(jīng)過測(cè)試,h5 web容器側(cè)滑時(shí)出現(xiàn)的tab頁面左右滾動(dòng)的體驗(yàn)問題確實(shí)被解決。這樣既可以解決體驗(yàn)問題,又可以解決側(cè)滑離開頁面導(dǎo)致的崩潰問題,但是這樣并沒有定位crash的根因。修復(fù)代碼上線后,crash量確實(shí)下降,但是每天還是有一些crash出現(xiàn),且收到了個(gè)別頁面極端操作下偶現(xiàn)卡住的問題反饋。因此需要繼續(xù)排查crash根因,將crash根本解決掉。
繼續(xù)看文章開始的crash堆棧,通過Crash堆棧判斷崩潰原因是UIScrollview執(zhí)行滾動(dòng)動(dòng)畫過程中回調(diào)代理方法(見上圖)時(shí)訪問被釋放的內(nèi)存。常規(guī)解決思路是在退出頁面后,在頁面生命周期的dealloc方法中,將UIScrollview的delegate置空即可。WKWebView確實(shí)有一個(gè)scrollVIew屬性,我們?cè)诤茉绲陌姹揪蛯⑵鋎elegate屬性置空,但是崩潰沒有解決。
deinit { scrollView.delegate = nil scrollView.dataSource = nil }
deinit {
scrollView.delegate = nil
scrollView.dataSource = nil
}
因此崩潰堆棧里的Scrollview代理不是這里的WKWebView的scrollVIew的代理。那崩潰堆棧中的scrollView代理到底屬于哪個(gè)UIScrollview呢?幸運(yùn)的是蘋果webkit 是開源的,我們可以將webkit源碼下載下來看一下。
4、尋找崩潰堆棧中的ScrollViewDelegate
崩潰堆棧中的ScrollViewDelegate是WKScrollingNodeScrollViewDelegate。首先看看WKWebView的scrollview的 delegate是如何實(shí)現(xiàn)的,因?yàn)槲覀儾孪脒@個(gè)scrollview的delegate除了我們自己設(shè)置的,是否還有其他delegate(比如崩潰堆棧中的WKScrollingNodeScrollViewDelegate)。
通過對(duì)Webkit源碼一番研究,發(fā)現(xiàn)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 代理實(shí)現(xiàn)
首先看到WKWebView的scrollview的類型其實(shí)是WKScrollView(UIScrollview的子類),他除了繼承自父類的delegate屬性,還有一個(gè)internalDelegate屬性,那么這個(gè)internalDelegate屬性是不是我們要找的WKScrollingNodeScrollViewDelegate 呢?
@interface WKScrollView : UIScrollView
@property (nonatomic, assign) WKWebView <UIScrollViewDelegate> *internalDelegate;
@end
@interface WKScrollView : UIScrollView
@property (nonatomic, assign) WKWebView <UIScrollViewDelegate> *internalDelegate;
@end
通過閱讀源碼后發(fā)現(xiàn)不是這樣的(代碼有刪減,感興趣可自行閱讀源碼)。
- (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()];
}
}
這個(gè)internalDelegate的作用是讓W(xué)KWebView 監(jiān)聽scrollview的滾動(dòng)回調(diào),同時(shí)也可以讓開發(fā)者在外部監(jiān)聽WKWebView的scrollview回調(diào)。如何實(shí)現(xiàn)的呢?可以查看WKScrollViewDelegateForwarder的實(shí)現(xiàn)。
- (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()];
}
通過復(fù)寫- (void)forwardInvocation:(NSInvocation *)anInvocation 方法,在消息轉(zhuǎn)發(fā)時(shí)實(shí)現(xiàn)的。
4.2 猜想 & 驗(yàn)證
既然WKScrollingNodeScrollViewDelegate 不是WKScrollview的屬性,那說明崩潰堆棧中的scrollview不是WKScrollview,那頁面上還有其他scrollview么。我們看源碼WKScrollingNodeScrollViewDelegate 是在哪里設(shè)置的。
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的源碼,發(fā)現(xiàn)創(chuàng)建WKScrollingNodeScrollViewDelegate的位置只有一處。但是webkit的源碼太過于復(fù)雜,無法通過閱讀源碼的方式知道WKScrollingNodeScrollViewDelegate屬于哪個(gè)scrollview。
為此我們只能換一種思路,我們通過xcode調(diào)試的方式查看當(dāng)前webview加載的頁面是否還有其他scrollview。
頁面上剛好還有一個(gè)scrollview:WKChildScrollview
這個(gè)WKChildScrollview 是否是崩潰堆棧中的scrollview呢,如果我們能確定他的delegate是WKScrollingNodeScrollViewDelegate,那就說明這個(gè)WKChildScrollview 是崩潰堆棧中的scrollview。
為了驗(yàn)證這個(gè)猜想,我們首先找到源碼,源碼并沒有太多,看不出其delegate類型。
@interface WKChildScrollView : UIScrollView <WKContentControlled>@end
@interface WKChildScrollView : UIScrollView <WKContentControlled>
@end
我們只能轉(zhuǎn)換思路在運(yùn)行時(shí)找到WKWebView的類型為WKChildScrollView的子view(通過OC runtime & 視圖樹遍歷的方式),判斷他的delegate是否為WKScrollingNodeScrollViewDelegate 。
我們運(yùn)行時(shí)找到類型為 WKChildScrollView 的子view后,獲取其delegate類型,確實(shí)是WKScrollingNodeScrollViewDelegate。至此我們找到了崩潰堆棧中的scrollview。
確定了崩潰堆棧中的scrollview的類型,那么修復(fù)起來也比較容易了。在頁面生命周期的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 在內(nèi)部采用的是分層的方式進(jìn)行渲染,它會(huì)將 WebKit 內(nèi)核生成的 Compositing Layer(合成層)渲染成 iOS 上的一個(gè) WKCompositingView,這是一個(gè)客戶端原生的 View,不過可惜的是,內(nèi)核一般會(huì)將多個(gè) DOM 節(jié)點(diǎn)渲染到一個(gè) Compositing Layer 上,因此合成層與 DOM 節(jié)點(diǎn)之間不存在一對(duì)一的映射關(guān)系。當(dāng)把一個(gè) DOM 節(jié)點(diǎn)的 CSS 屬性設(shè)置為 overflow: scroll (低版本需同時(shí)設(shè)置 -webkit-overflow-scrolling: touch)之后,WKWebView 會(huì)為其生成一個(gè) WKChildScrollView,與 DOM 節(jié)點(diǎn)存在映射關(guān)系,這是一個(gè)原生的 UIScrollView 的子類,也就是說 WebView 里的滾動(dòng)實(shí)際上是由真正的原生滾動(dòng)組件來承載的。WKWebView 這么做是為了可以讓 iOS 上的 WebView 滾動(dòng)有更流暢的體驗(yàn)。雖說 WKChildScrollView 也是原生組件,但 WebKit 內(nèi)核已經(jīng)處理了它與其他 DOM 節(jié)點(diǎn)之間的層級(jí)關(guān)系,這一特性可以用來做小程序的同層渲染。(「同層渲染」顧名思義則是指通過一定的技術(shù)手段把原生組件直接渲染到 WebView 層級(jí)上,此時(shí)「原生組件層」已經(jīng)不存在,原生組件此時(shí)已被直接掛載到 WebView 節(jié)點(diǎn)上。你幾乎可以像使用非原生組件一樣去使用「同層渲染」的原生組件,比如使用 view、image 覆蓋原生組件、使用 z-index 指定原生組件的層級(jí)、把原生組件放置在 scroll-view、swiper、movable-view 等容器內(nèi)等等)。
5、蘋果的修復(fù)方案
本著嚴(yán)謹(jǐn)?shù)膽B(tài)度,我們想是什么導(dǎo)致了最開始的崩潰堆棧呢?是我們開發(fā)過程中的功能還是系統(tǒng)bug?如果是系統(tǒng)bug,其他公司也可能遇到,但是互聯(lián)網(wǎng)上搜不到其他公司或開發(fā)者討論崩潰相關(guān)信息。我們繼續(xù)看一下崩潰堆棧的top 函數(shù)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);
}
崩潰在這個(gè)函數(shù)里,查看這個(gè)函數(shù)的commit記錄:
簡(jiǎn)單描述一下就是scrollingTreeNodeDidScroll方法中使用的m_scrollingCoordinatorProxy 對(duì)象改成weak指針,并進(jìn)行判空操作。這種改變,正是解決m_scrollingCoordinatorProxy 內(nèi)存被釋放后還在訪問的方案。
這個(gè)commit是2023年2月28號(hào)提交的,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.
至此,我們基本確認(rèn),這個(gè)崩潰堆棧是webkit內(nèi)部實(shí)現(xiàn)的一個(gè)bug,蘋果內(nèi)部開發(fā)者最終使用弱引用的方式解決。
同時(shí)修復(fù)上線后,這個(gè)crash的崩潰量也降為0。
6、總結(jié)
本文中的crash從出現(xiàn)到解決歷時(shí)近一年,一開始根據(jù)線上日志判斷是h5 頁面返回 & h5 頁面滾動(dòng)導(dǎo)致的問題,禁用手勢(shì)后雖然幾乎解決問題,但是線上還有零星crash上報(bào),因此為了保證h5 離線功能的線上穩(wěn)定性,需要完美解決問題。
本文的crash 似曾相識(shí),但是經(jīng)過驗(yàn)證和閱讀源碼后發(fā)現(xiàn)并不是想象的那樣,繼續(xù)通過猜想+閱讀源碼的方式尋找到了崩潰堆棧中的真正scrollview代理對(duì)象,從而在app 側(cè)解決問題。最后發(fā)現(xiàn)是蘋果webkit的bug。
本文中的崩潰問題本質(zhì)上是野指針問題,那么野指針問題定位有沒有通用的解決方案呢?