UIWebView的scrollView和渲染机制探究

UIWebView的理解

UIWebView

|__ _UIWebViewScrollView

|__ UIWebBrowserView

这里有两个私有的类,我们先来看看_UIWebViewScrollView,这玩意看名字都可以知道是什么的子类了,就是UIScrollView。实际上对应的就是UIWebView的scrollView属性,这也显然是为什么UIWebView可以滚动的原因。UIWebBrowserView才是真正用于渲染显示内容的视图。

UIWebView的渲染机制

为什么加载一个页面很大、内容很多的网页,UIWebView也能正常工作?

显然UIWebView也不是一次性把整个页面全部渲染的,而是把当前可见的部分再大一点的范围渲染了,在滚动的时候再一边滚动一边渲染。和UITableView的复用机制还是相当类似的,不过是在layer层级上进行的。如何证明这个结论,看下图:
jt1

这是在UIWebBrowserView的截图,可以看到渲染的部分和没渲染的部分,UIWebBrowserView的frame大小是真实的内容大小。

倘若把UIWebView的大小设置得和UIWebBrowserView一样大会怎么样?答案就是,整个UIWebBrowserView会全部渲染,然后如果内容过多的话,直接超出内存然后崩溃。

UIScrollView内嵌UIWebView

iBook和网易云阅读都是使用这种方案的,当然具体的思路还是不同的。这样子必须要解决的一个问题就是手势的问题。

如果不做额外的处理,在UIScrollView上面放一个包含UIScrollView子类的View。滚动方向还一样的话,系统会无法识别究竟滚动哪一个View。具体表现为,两个UIWebView的连接处,当滚到上一个UIWebView的末位的时候,不得不松开手指,然后再次拖动才能滚动。因此这类手势的处理还是相当麻烦的。

UIScrollView手势科普

这里还可以科普一下UIScrollView的手势处理,UIScrollView是包含以下的手势:

UIScrollViewDelayedTouchesBeganGestureRecognizer

UIScrollViewPanGestureRecognizer

UIScrollViewPagingSwipeGestureRecognizer

UIScrollViewPinchGestureRecognizer(不一定包含)

这些类都是私有的,除了第一个,其余的看名字也是可以大概猜出来是什么用途了吧?好的,那么第一个手势到底是为什么而存在呢?我们看看UIScrollViewDelayedTouchesBeganGestureRecognizer的定义:

@interface UIScrollViewDelayedTouchesBeganGestureRecognizer : UIGestureRecognizer {
    struct CGPoint { 
        float x; 
        float y; 
    } _startSceneReferenceLocation;
    UIDelayedAction *_touchDelay;
}

- (void)_resetGestureRecognizer;
- (void)clearTimer;
- (void)dealloc;
- (void)sendDelayedTouches;
- (void)sendTouchesShouldBeginForDelayedTouches:(id)arg1;
- (void)sendTouchesShouldBeginForTouches:(id)arg1 withEvent:(id)arg2;
- (void)touchesBegan:(id)arg1 withEvent:(id)arg2;
- (void)touchesCancelled:(id)arg1 withEvent:(id)arg2;
- (void)touchesEnded:(id)arg1 withEvent:(id)arg2;
- (void)touchesMoved:(id)arg1 withEvent:(id)arg2;

@end

可以看到一个UIDelayedAction的玩意,再看UIDelayedAction的定义:

@interface UIDelayedAction : NSObject {
    SEL m_action;
    BOOL m_canceled;
    double m_delay;
    NSString *m_runLoopMode;
    id m_target;
    NSTimer *m_timer;
    id m_userInfo;
}

- (void)cancel;
- (void)dealloc;
- (double)delay;
- (id)initWithTarget:(id)arg1 action:(SEL)arg2 userInfo:(id)arg3 delay:(double)arg4;
- (id)initWithTarget:(id)arg1 action:(SEL)arg2 userInfo:(id)arg3 delay:(double)arg4 mode:(id)arg5;
- (BOOL)scheduled;
- (void)setTarget:(id)arg1;
- (id)target;
- (void)timerFired:(id)arg1;
- (void)touch;
- (void)touchWithDelay:(double)arg1;
- (void)unschedule;
- (id)userInfo;

@end

看到NSTimerinitWithTarget:action:userInfo:delay:mode:,应该都可以猜个七八成,但是有些东西最好还是不要靠猜,能确定的可以确定一下。看看-[UIScrollViewDelayedTouchesBeganGestureRecognizer touchesBegan:withEvent:]的伪码:

void -[UIScrollViewDelayedTouchesBeganGestureRecognizer touchesBegan:withEvent:](void * self, void * _cmd, void * var_10, void * var_14) {
    edi = self;
    [edi clearTimer];
    esi = @selector(view);
    eax = [edi view];
    eax = [eax delaysContentTouches];
    if (eax != 0x0) {
            var_10 = [UIDelayedAction alloc];
            var_14 = @selector(sendDelayedTouches);
            eax = [edi view];
            objc_msgSend_fpret(eax, @selector(_touchDelayForScrollDetection));
            eax = *NSRunLoopCommonModes;
            eax = *eax;
            asm{ fstp       qword [ss:esp+0x14] };
            edi->_touchDelay = [var_10 initWithTarget:edi action:var_14 userInfo:0x0 delay:stack[27] mode:stack[26]];
            esi = *_OBJC_IVAR_$_UIScrollViewDelayedTouchesBeganGestureRecognizer._startSceneReferenceLocation;
            eax = [edi _activeTouchesForEvent:arg_C];
            eax = [edi _centroidOfTouches:eax excludingEnded:0x0];
            *(edi + esi + 0x4) = edx;
            *(edi + esi) = eax;
    }
    else {
            [edi sendTouchesShouldBeginForTouches:arg_8 withEvent:arg_C];
    }
    return;
}

显然UIScrollViewDelayedTouchesBeganGestureRecognizerUIDelayedAction的关系还是比较清晰了。然而光说这个也没什么说服力,直接用hook的方式把[UIDelayedAction initWithTarget:action:userInfo:delay:mode:]hook了看看。参数如下图:

jt3

也就是UIScrollView在0.15秒内不能识别出手势就会向touch事件发出消息。

如果根据不同的手势控制返回会怎么样,如下面代码:

- (id)init__WithTarget:(id)arg1 action:(SEL)arg2 userInfo:(id)arg3 delay:(double)arg4 mode:(id)arg5
{
    if ([arg1 isKindOfClass:NSClassFromString(@"UIScrollViewDelayedTouchesBeganGestureRecognizer")]) {
        return nil;
    } else
        return [self init__WithTarget:arg1 action:arg2 userInfo:arg3 delay:arg4 mode:arg5];
}

如果UIDelayedAction失效的话,那么UIScrollView的一系列的touch事件都无法捕捉了,就是这些方法都不会调用了:

- (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event;
- (void)touchesMoved:(NSSet *)touches withEvent:(UIEvent *)event;
- (void)touchesEnded:(NSSet *)touches withEvent:(UIEvent *)event;
- (void)touchesCancelled:(NSSet *)touches withEvent:(UIEvent *)event;

然而知道了这个东西还是无法解决那个问题,因为分发这些touchEvent是在系统更高层的[UIWindow _sendGesturesForEvent:]内进行,那里还涉及了其它几个类,这条路不是太好走。

UIWebView的渲染时机

这个时候,如果想到把UIWebView的frame设置为和内容一样大,的确可以解决手势问题,但又会出现渲染和内存问题。

好吧,那回来看看_UIWebViewScrollView,发现其实现的方法也不多,因为真正实现东西比较多的还是在UIWebView内部。UIWebView是实现了UIScrollViewDelegate的协议的,所以更多的逻辑在UIWebView上而不是在_UIWebViewScrollView

好的,之前说到UIWebView是根据当前滚动的情况来实现可视部分范围的渲染,那么这个东西具体是在那里进行呢?一般来说,大家首先想到的是UIScrollViewDelegatescrollViewDidScroll:,不过_UIWebViewScrollView没实现,而是在UIWebView里面实现了。伪码太多了不列出来了,虽然UIWebView实现了scrollViewDidScroll:,但是不涉及UIWebBrowserView的渲染操作,只是一些其他view的操作。

那么现在就要把关注点放在UIWebBrowserViewUIWebBrowserView的继承关系是这样的,UIWebBrowserView->UIWebDocumentView->UIWebTiledView。实际上UIWebBrowserViewUIWebTiledView的实现还不算是太多API(相对于UIWebDocumentView来说),不过作为渲染这类基础的东西,在基类UIWebTiledView能找到答案的机率会更大一些。

最后锁定在_didScroll这么一个方法,实现很简单:

void -[UIWebTiledView _didScroll](void * self, void * _cmd) {
    var_8 = self;
    var_4 = *0xd65ed4;
    [[var_8 super] setNeedsLayout];
    return;
}

好的,那我先来详解一下为什么_didScroll会被调用,调用链如下:

-[UIScrollView setContentOffset]->-[UIScrollView _notifyDidScroll]->-[UIView _didScroll]

然后,在_notifyDidScroll里面实际上把_scrollNotificationViews数组内的每个view都调用_didScroll_didScrollUIView的一个虚方法,默认不做任何事情。

回到UIWebTiledView的渲染问题上,setNeedsLayout以后,对应的-[UIWebTiledView layoutSubviews]就会调用。里面才是各种渲染的方法,不过也不是真正的渲染代码,真正彻底的渲染操作实际是在WebCore.framework私有framework里面进行的。我到这里就暂时止步了。

慎重起见,我还是hook了_didScroll进行了验证,如果在_didScroll不实现任何东西的话,滚动了以后UIWebView就会出现后面部分都没渲染的情况。这和上面分析的情况还是吻合的。

jt2

从上面的分析,已经可以得知,UIWebBrowserView的渲染是不依赖UIWebView或者是特定的scrollView的。换句话说,把UIWebBrowserView单独拿出来,放到其他的scrollView之上也是不存在渲染问题的。

UIWebView重载

然而直接把UIWebBrowserView加到UIScrollView上,每次加载新页面的时候还会出现UIScrollViewcontentOffset被重置的情况。因此,还得把_restoreScrollPointForce:屏蔽。

Class NEWebBrowserView = Nil;

+ (void)initialize
{
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        // 动态创建新类NEWebBrowserView
        NSString *superClass = [@"UIWeb" stringByAppendingString:@"BrowserView"];
        NEWebBrowserView = objc_allocateClassPair(NSClassFromString(superClass), "NEWebBrowserView", 0);
        objc_registerClassPair(NEWebBrowserView);

        // 添加新方法
        Method tmpMethod = class_getInstanceMethod(NEBookWebView.class, @selector(NE_restoreScrollPointForce:));
        IMP func = method_getImplementation(tmpMethod);
        class_addMethod(NEWebBrowserView, @selector(NE_restoreScrollPointForce:), func, method_getTypeEncoding(tmpMethod));

        // 交换方法
        Method newMethod = class_getInstanceMethod(NEWebBrowserView, @selector(NE_restoreScrollPointForce:));
        Method oldMethod = class_getInstanceMethod(NEWebBrowserView, NSSelectorFromString([@"_restore" stringByAppendingString:@"ScrollPointForce:"]));
        method_exchangeImplementations(newMethod, oldMethod);
    });
}

最后修改一下:

// 动态改变browser的类为NEWebBrowserView
object_setClass(self.realContentView, NEWebBrowserView);

这样就可以“安全”地使用定制好的NEWebBrowserView了。

至于,UIWebBrowserView是怎么找到父视图的UIScrollView呢?其实是用-[UIView _scroller]找出来的,因为内部也涉及了一个私有方法,所以就不展开来说了。

事后PS

上面也说了UIWebView实现了UIScrollViewDelegate的方法,那么问题来了,我们平时对_UIWebViewScrollView也就是UIWebView.scrollView设置delegate真的没问题吗?

答案当然是没问题,但是为什么呢?

@interface _UIWebViewScrollView : UIWebScrollView {
    BOOL _bouncesSetExplicitly;
    UIWebBrowserView *_browserView;
    _UIWebViewScrollViewDelegateForwarder *_forwarder;
}

- (void)_setWebView:(id)arg1;
- (void)_weaklySetBouncesHorizontally:(BOOL)arg1;
- (void)dealloc;
- (id)delegate;
- (id)initWithFrame:(struct CGRect { struct CGPoint { float x_1_1_1; float x_1_1_2; } x1; struct CGSize { float x_2_1_1; float x_2_1_2; } x2; })arg1;
- (void)setBounces:(BOOL)arg1;
- (void)setBouncesHorizontally:(BOOL)arg1;
- (void)setBouncesVertically:(BOOL)arg1;
- (void)setContentInset:(struct UIEdgeInsets { float x1; float x2; float x3; float x4; })arg1;
- (void)setDelegate:(id)arg1;

@end

看到_UIWebViewScrollViewDelegateForwarder的成员变量,再看其定义:

@interface _UIWebViewScrollViewDelegateForwarder : NSObject <UIScrollViewDelegate> {
    <UIScrollViewDelegate> *_delegate;
    UIWebView *_webView;
}

@property(copy,readonly) NSString * debugDescription;
@property <UIScrollViewDelegate> * delegate;
@property(copy,readonly) NSString * description;
@property(readonly) unsigned int hash;
@property(readonly) Class superclass;
@property UIWebView * webView;

- (id)delegate;
- (void)forwardInvocation:(id)arg1;
- (id)methodSignatureForSelector:(SEL)arg1;
- (BOOL)respondsToSelector:(SEL)arg1;
- (void)setDelegate:(id)arg1;
- (void)setWebView:(id)arg1;
- (id)webView;

@end

很熟悉是吧,也就是典型的消息转发,实现细节就不研究,也是八九不离十。