UIWebView基础分析
1 引言
根据App Store 审核指南,App浏览网页必须使用WebKit框架。因此在iOS上开发浏览器只能使用UIWebView
或者WebKit.framework
中的WKWebView
(iOS8开始支持)或者SafariServices.framework
中的SFSafariViewController
(iOS9开始支持),本篇文章中主要分析UIWebView,后续文章中再分析其他两项。
2 UIWebView.h文件分析
UIWebView.h中的代码很少,只有一个Class和一个Protocol,即UIWebView和UIWebViewDelegate.下文会一一解释其属性和方法。
2.1 UIWebView
NS_CLASS_AVAILABLE_IOS(2_0) __TVOS_PROHIBITED @interface UIWebView : UIView <NSCoding, UIScrollViewDelegate>
//UIWebView的代理
@property (nullable, nonatomic, assign) id <UIWebViewDelegate> delegate;
//UIWebView的子视图,实际是_UIWebViewScrollView类型
@property (nonatomic, readonly, strong) UIScrollView *scrollView NS_AVAILABLE_IOS(5_0);
//加载网页请求
- (void)loadRequest:(NSURLRequest *)request;
/**加载本地网页
* @param string HTML内容
* @param baseURL HTML中有用到相对路径时,需要设置
*/
- (void)loadHTMLString:(NSString *)string baseURL:(nullable NSURL *)baseURL;
/**加载本地网页
* @param data MIMEType类型的数据
* @param MIMEType MIME类型
* @param textEncodingName 编码方式
* @param baseURL HTML中有用到相对路径时,需要设置
*/
- (void)loadData:(NSData *)data MIMEType:(NSString *)MIMEType textEncodingName:(NSString *)textEncodingName baseURL:(NSURL *)baseURL;
// 当前请求
@property (nullable, nonatomic, readonly, strong) NSURLRequest *request;
//刷新
- (void)reload;
//停止加载
- (void)stopLoading;
//后退
- (void)goBack;
//前进
- (void)goForward;
//能否后退
@property (nonatomic, readonly, getter=canGoBack) BOOL canGoBack;
//能否前进
@property (nonatomic, readonly, getter=canGoForward) BOOL canGoForward;
//加载状态
@property (nonatomic, readonly, getter=isLoading) BOOL loading;
/** JS注入
* @param script JavaScript脚本
* @return script脚本执行的返回值
*/
- (nullable NSString *)stringByEvaluatingJavaScriptFromString:(NSString *)script;
//自动缩放页面以适应屏幕
@property (nonatomic) BOOL scalesPageToFit;
//数据检测类型,系统自动将相应类型的内容转换为可点击的URL,默认是UIDataDetectorTypePhoneNumber
@property (nonatomic) UIDataDetectorTypes dataDetectorTypes NS_AVAILABLE_IOS(3_0);
//允许在网页内播放媒体(iPhone上默认是NO,会打开全屏播放,iPad默认是YES,在网页内播放)
@property (nonatomic) BOOL allowsInlineMediaPlayback NS_AVAILABLE_IOS(4_0); // iPhone Safari defaults to NO. iPad Safari defaults to YES
//HTML5视频点击播放还是自动播放
@property (nonatomic) BOOL mediaPlaybackRequiresUserAction NS_AVAILABLE_IOS(4_0); // iPhone and iPad Safari both default to YES
//是否允许Air Play
@property (nonatomic) BOOL mediaPlaybackAllowsAirPlay NS_AVAILABLE_IOS(5_0); // iPhone and iPad Safari both default to YES
//是否阻止增量渲染
@property (nonatomic) BOOL suppressesIncrementalRendering NS_AVAILABLE_IOS(6_0); // iPhone and iPad Safari both default to NO
//是否允许网页内容通过代码打开键盘
@property (nonatomic) BOOL keyboardDisplayRequiresUserAction NS_AVAILABLE_IOS(6_0); // default is YES
//分页模式,即改变网页内容的布局方式,网页内容被拆成若干页显示。默认是UIWebPaginationModeUnpaginated不分页
@property (nonatomic) UIWebPaginationMode paginationMode NS_AVAILABLE_IOS(7_0);
//断页模式
@property (nonatomic) UIWebPaginationBreakingMode paginationBreakingMode NS_AVAILABLE_IOS(7_0);
//单个page的长度
@property (nonatomic) CGFloat pageLength NS_AVAILABLE_IOS(7_0);
//page之间的空隙
@property (nonatomic) CGFloat gapBetweenPages NS_AVAILABLE_IOS(7_0);
//page的数量
@property (nonatomic, readonly) NSUInteger pageCount NS_AVAILABLE_IOS(7_0);
//是否允许画中画媒体播放
@property (nonatomic) BOOL allowsPictureInPictureMediaPlayback NS_AVAILABLE_IOS(9_0);
//允许链接预览(即3DTouch操作),pop操作会打开Safari
@property (nonatomic) BOOL allowsLinkPreview NS_AVAILABLE_IOS(9_0); // default is NO
@end
2.2 UIWebViewDelegate
__TVOS_PROHIBITED @protocol UIWebViewDelegate <NSObject>
@optional
/**
* 决定webView是否加载一个frame
* @param webview
* @param request 待加载的请求
* @param navigationType 加载类型
* @return 是否加载request
*/
- (BOOL)webView:(UIWebView *)webView shouldStartLoadWithRequest:(NSURLRequest *)request navigationType:(UIWebViewNavigationType)navigationType;
//webView开始加载一个frame
- (void)webViewDidStartLoad:(UIWebView *)webView;
//webView完成加载一个frame
- (void)webViewDidFinishLoad:(UIWebView *)webView;
//webView加载frame时发生错误
- (void)webView:(UIWebView *)webView didFailLoadWithError:(NSError *)error;
@end
官方文档给出的提示要求,在销毁UIWebView之前应该设置其delegate为nil。
3 UIWebView体验优化
UIWebView的体验与Safari相比,并不太好,可以对其进行优化
3.1 支持滑动返回
WKWebView和SFSafariViewController都支持滑动返回,但是UIWebView并不支持。要使得UIWebView支持滑动返回的方法至少有两种,包括截图的方式,以及使用多个控制器加载UIWebView的方式。
WKWebView和Safari应该都是用的截图方式来实现滑动返回,QQ浏览器个人猜测是使用的多个子控制器(或者多个UIWebView)来实现。这两种方法相比,截图方式比较节省资源,使用多个控制器实现比较简单。下面介绍采用截图方式的实现的方法。
3.1.1 解决与系统侧滑手势冲突
首先给webView添加UIScreenEdgePanGesture
[self.webView addGestureRecognizer:self.screenEdgePanGesture];
- (UIScreenEdgePanGestureRecognizer *)screenEdgePanGesture
{
if(!_screenEdgePanGesture)
{
_screenEdgePanGesture = [[UIScreenEdgePanGestureRecognizer alloc] initWithTarget:self action:@selector(screenEdgePanned:)];
_screenEdgePanGesture.edges = UIRectEdgeLeft;
_screenEdgePanGesture.delegate = self;
}
return _screenEdgePanGesture;
}
如果要自定义返回按钮的话实现UIGestureRecognizerDelegate协议,避免与系统滑动返回手势冲突
- (void)viewDidAppear:(BOOL)animated
{
[super viewDidAppear:animated];
if(self.navigationController)
{
self.nav = self.navigationController;
self.nav.interactivePopGestureRecognizer.delegate = self;
}
}
- (void)viewDidDisappear:(BOOL)animated
{
[super viewDidDisappear:animated];
if(self.nav)
{
self.nav.interactivePopGestureRecognizer.delegate = nil;
}
}
- (BOOL)gestureRecognizerShouldBegin:(UIGestureRecognizer *)gestureRecognizer
{
//无法后退时,返回NO,系统右滑手势可成功识别
if(gestureRecognizer == self.screenEdgePanGesture && !self.webView.canGoBack)
{
return NO;
}
return YES;
}
有一种方法可以不必考虑与系统返回的冲突,就是在不能goBack时使用系统默认的返回按钮,在可以goBack时使用自定义按钮
self.navigationItem.leftBarButtonItems = self.webView.canGoBack ? @[self.backButtonItem,self.closeButtonItem] : nil;
3.1.2 截图时机
在webView:shouldStartLoadWithRequest:navigationType:
中去截图。有三个条件需要判断:
- 是否是mainFrame,
[request.mainDocumentURL isEqual:request.URL];
- 并判断navigationType,后退和刷新不需要截图,
- 页内跳转不截图
3.1.3 滑动处理
在screenEdgePan手势识别成功时,将相应截图视图插入在当前视图下方,在动画完成时,将截图视图移除。其实就是模拟系统滑动返回的过程。
3.1.4 设置PageCache
由于UIWebView默认没有PageCache,返回到上一页,页面会刷新,并不能停留在之前浏览的位置(除非前端有处理),使用以下代码可以设置PageCache,可以后退不刷新。详细解释见解决UIWebView 前进、后退刷新的坑
//私有方法,请慎用
((void *(*)(id,SEL,...))objc_msgSend)(NSClassFromString(@"WebView"),NSSelectorFromString(@"_setCacheModel:"),2);
3.2 进度条
这边介绍UIWebView进度条的三种方法
- 使用假的进度,进度条加载到一定进度时,则不更新进度,直到完成时继续加载。
- 使用NJKWebViewProgress,就四个文件,集成很简单,看懂了也可以自己写。
- 监听私有通知
WebProgressEstimatedProgressKey
,在回调的notification的userInfo里有一个KeyWebProgressEstimatedProgressKey
对应的value即为进度(P.S. 可能它并不是针对从开始请求到加载完成过程的估算,所以这个进度不是很准,这种东西也没发精准,不是么?而且是私有通知,请慎用)
3.3 标题
获取网页标题的方法很简单,执行代码NSString *title = [webview stringByEvaluatingJavaScriptFromString:@"document.title"];
即可。
不过有时在webViewDidFinishLoad:
中获取不到当前title,因为前端可能用AJAX去请求数据再修改title,而这个过程原生是捕获不到的。所以个人想了个有点复杂的方法,就是获取documentView.webView.mainFrame.javaScriptContext
,监听readystatechange
和DOMSubtreeModified
事件。
- (void)webViewDidFinishLoad:(UIWebView *)webView
{
[webView stringByEvaluatingJavaScriptFromString:@"document.documentElement.style.webkitTouchCallout = 'none';"];
self.context = [webView valueForKeyPath:@"documentView.webView.mainFrame.javaScriptContext"];
__weak typeof(self)weakSelf = self;
self.context[@"OCDocumentReady"] = ^(){
[weakSelf onDocumentReady];
};
self.context[@"OCDocumentChange"] = ^(){
[weakSelf onDocumentChange];
};
if([[self.context evaluateScript:@"document.readyState == 'complete'"] toBool])
{
[self onDocumentReady];
}
else
{
[self.context evaluateScript:@"document.addEventListener('readystatechange',function(){if(document.readyState == 'complete'){OCDocumentReady();}},false)"];
}
}
- (void)onDocumentChange
{
NSString *host = [[self.context evaluateScript:@"location.host"] toString];
NSString *title = [[self.context evaluateScript:@"document.title"] toString];
self.title = title.length ? title : host;
self.navigationItem.leftBarButtonItems = self.webView.canGoBack ? @[self.backButtonItem,self.closeButtonItem] : nil;
}
- (void)onDocumentReady
{
self.loadingFinished = YES;
[self.context evaluateScript:@"document.documentElement.addEventListener('DOMSubtreeModified', function(e) {OCDocumentChange();}, false);"];
[self onDocumentChange];
}
这样每次DOM树发生变化时,都会检测title,有些冗余,应该有更好的方法,还请不吝赐教。
如果不了解Objective-C与JavaScript的交互方法的话,可以查看Objective-C与JavaScript交互的那些事
3.4 错误页面
其实很多应用的内置浏览器都没有错误页面,个人觉得还是应该弄一下。需要注意的是mainFrame出错时,才应该显示错误页面。
- (void)webView:(UIWebView *)webView didFailLoadWithError:(NSError *)error
{
if([error.domain isEqual:NSURLErrorDomain] && error.code == NSURLErrorCancelled)
{//后退或者取消加载,不需要处理
return ;
}
else if([error.userInfo[@"NSErrorFailingURLStringKey"] isEqualToString:self.currentMainDocumentURL])
{//mainFrame加载出错时显示错误页面,这里currentMainDocumentURL,是在请求时记录的
//错误处理
}
}
如果设计的错误页面是网页的话,并且仍然用当前webView加载,则需要先执行[self loadRequest:[NSURLRequest requestWithURL:[NSURL URLWithString:@"about:blank"]]];
再加载错误页面,这样后退时才能回到上一个页面,因为UIWebView加载本地网页时,会把上一个页面覆盖掉。
4 坑
个人发现UIWebView有一个BUG,当在页面A执行location.replace(B)
,再从B页面跳转到C,这时再返回,居然是到页面A。。。(location.replace(B)
的意思是用B页面取代A页面,正确结果应该回到B,A页面就不应该存在于浏览队列中)。
UIWebView还有各种莫名的Bug,还是使用WKWebView吧,上述优化功能都自带了,而且体验好很多。
5 写在最后
第一次在简书上发文章,才疏学浅,如果发现有错误,烦请指出。
如果要更深入理解UIWebView,可以查看
UIWebView体系架构