WKWebview

11 8月

WebView本质上就是个App内置浏览器,用于展示web的组件。

Apple在ios2.0就提供了UIWebView用于加载网页,但它存在设计上的缺陷,导致内存泄漏问题,稳定性较差,而且随着前端的发展对HTML5,CSS3的支持开始变慢。所以Apple在ios8开始提供了全新的WKWebview用于取代之前的UIWebView,它们对外的功能是一样的,但底层实现几乎重写。

WKWebview的优势很明显,具有独立进程和内存,所以哪怕crash了也不影响主App,对前端的支持也更给力,执行JS的效率也更高。

作为Web前端,先不要陷入WKWebview的内存优化,cookie传值,黑屏问题等细节无法自拔,这些是专业的native开发者优先要关注的事。

Web前端对native侧的了解应该先聚焦在 js <==> native 通信,UI层表现上:

  • 初始化 & 加载页面 & 导航:在View中初始化一个WKWebView,并让它加载一个web页面,该web页面支持前进后退等导航操作
  • oc 2 js:在oc里使用 evaluateJavaScript 方法执行 js,以实现 oc => js 间的通信
  • js 2 oc:分两个层面:交互型web UI在native侧的展示,js通知oc(或oc捕获js)
  • 音频 & 视频:在WKWebView中播放视频

demo代码仓库

初始化(Initializing a Web View)

ios中WKWebView相关的类由WebKit提供,所以先要import:

#import <WebKit/WebKit.h>

初始化依赖WKWebView类的init方法。

大多数初始化参数配置在WKWebViewConfiguration里,包括:基本属性,视口缩放,渲染方式,媒体设置,其他高(bu)级(dong)配置。

剩余参数在 WKWebView 类里属性,如设置Delegate(WKUIDelegate,WKNavigationDelegate)等。

@property (nonatomic, strong, readwrite) WKWebView *webView;

- (void)viewDidLoad {
    [super viewDidLoad];
		...
    [self.view addSubview:self.webView];   // 将 WKWebView 加入到当前 View
}

#pragma mark -- Getter
- (WKWebView *)webView
{
    if(_webView == nil) {
        // 创建 WKWebView 的初始化的配置项
        WKWebViewConfiguration *config = [[WKWebViewConfiguration alloc] init];

        // 设置请求的 User-Agent 中应用程序名称(iOS9+可用)
        config.applicationNameForUserAgent = @"WKWebView-demo";
        
        // 偏好配置
        WKPreferences *preference = [[WKPreferences alloc] init];
        preference.minimumFontSize = 0;     // 最小字体大小,当将 javaScriptEnabled 属性设为 NO 时,可以看到明显的效果
        preference.javaScriptCanOpenWindowsAutomatically = YES; // iOS默认为NO,MacOS默认为YES,是否允许不经过用户交互由 JS 自动打开窗口
        preference.javaScriptEnabled = YES; // 是否允许页面执行 js
        config.preferences = preference;
        
        // 媒体设置
        config.allowsInlineMediaPlayback = YES; // 设为YES用H5的视频播放器在线播放,设为NO用内置的native播放器全屏播放
        config.requiresUserActionForMediaPlayback = YES;    // 设为YES需要用户手动播放H5视频,设置为NO允许自动播放H5视频
        config.allowsPictureInPictureMediaPlayback = YES;   // 是否允许H5视频中播放画中画(在特定设备上有效)
    
        WKUserContentController *wkUController = [[WKUserContentController alloc] init];
        [wkUController addScriptMessageHandler:self name:@"js2coPostMessage"];
        config.userContentController = wkUController;
        
        // 可以在初始化解决注入一些 JavaScript 
        NSString *jSString = @"var meta = document.createElement('meta'); meta.setAttribute('name', 'viewport'); meta.setAttribute('content', 'width=device-width'); document.getElementsByTagName('head')[0].appendChild(meta);";
        WKUserScript *wkUScript = [[WKUserScript alloc] initWithSource:jSString injectionTime:WKUserScriptInjectionTimeAtDocumentEnd forMainFrameOnly:YES];
        [config.userContentController addUserScript:wkUScript];

        _webView = [[WKWebView alloc] initWithFrame:CGRectMake(0, 0, SCREEN_WIDTH, SCREEN_HEIGHT) configuration:config];
        
        _webView.UIDelegate = self;
        _webView.navigationDelegate = self;
        _webView.navigationDelegate = self;
        
        _webView.allowsBackForwardNavigationGestures = YES; // 是否允许手势左滑返回上一级, 类似导航控制的左滑返回
//        WKBackForwardList *backForwardList = [_webView backForwardList];    // webview 的 back-forward 列表, 存储已打开过的网页

        NSString *path = [[NSBundle mainBundle] pathForResource:@"JStoOC.html" ofType:nil];
        NSString *htmlString = [[NSString alloc]initWithContentsOfFile:path encoding:NSUTF8StringEncoding error:nil];
        [_webView loadHTMLString:htmlString baseURL:[NSURL fileURLWithPath:[[NSBundle mainBundle] bundlePath]]];
    }
    return _webView;
}

加载页面(Loading Content)

WKWebView类里提供了两种方式加载web页面:load web网址,直接load HTML字符串。

func loadRequest(NSURLRequest *) -> WKNavigation?:通过web网址加载页面

 [_webView loadRequest:[NSURLRequest requestWithURL:[NSURL URLWithString:@"https://zxljack.com/"]]];

func loadHTMLString(String, baseURL: URL?) -> WKNavigation?:将html文件内容以字符串形式加载进webView。(demo代码仓库里为调试方便,就采用了这种方式)

// 假设本地html是JStoOC.html
NSString *path = [[NSBundle mainBundle] pathForResource:@"JStoOC.html" ofType:nil];
NSString *htmlString = [[NSString alloc]initWithContentsOfFile:path encoding:NSUTF8StringEncoding error:nil];
[_webView loadHTMLString:htmlString baseURL:[NSURL fileURLWithPath:[[NSBundle mainBundle] bundlePath]]];

其他方法如 reload,stopLoading 等看名字就知道干啥的,不赘述。

导航(Navigating)

WKWebView类里提供了web页面间的导航回调,所有浏览的历史会被存储在WKBackForwardList里。

var canGoBack: Bool
func goBack() -> WKNavigation?:这两个通常通常结合起来使用,让页面后退

UIButton *backBtn = [UIButton buttonWithType:UIButtonTypeCustom];
...
[backBtn addTarget:self action:@selector(goBackAction:) forControlEvents:UIControlEventTouchUpInside];

- (void)goBackAction:(id)sender{
    if ([_webView canGoBack]) {   // 是否还能后退,即 back-forward 列表是否已经后退到尽头了
       [_webView goBack];
    }
}

var canGoForward: Bool
func goForward() -> WKNavigation?:这两个通常通常结合起来使用,让页面前进

UIButton *forwardBtn = [UIButton buttonWithType:UIButtonTypeCustom];
...
[forwardBtn addTarget:self action:@selector(goForwardAction:) forControlEvents:UIControlEventTouchUpInside];

- (void)goForwardAction:(id)sender{
    if ([_webView canGoForward]) {   // 是否还能前进,即 back-forward 列表是否已经前进到尽头了
       [_webView goForward];
    }
}

也可以导航到指定处用go方法,不赘述

oc 2 js(Executing JavaScript)

evaluateJavaScript:func evaluateJavaScript(String, completionHandler: ((Any?, Error?) -> Void)?):执行参数传入的js字符串。本质上就是js代码注入webview执行。

// 调用 js 里自定义方法
NSString *jsStr1 = @"oc2jsChangeColor()";
[_webView evaluateJavaScript:jsStr1 completionHandler:^(id _Nullable data, NSError * _Nullable error) {
    NSLog(@"改变HTML的背景色");
}];

// 也可以自己撸一串 js 代码执行
NSString *jsStr2 = [NSString stringWithFormat:@"document.getElementsByTagName('body')[0].style.webkitTextSizeAdjust= '%d%%'", arc4random()%99 + 100];
[_webView evaluateJavaScript:jsStr2 completionHandler:^(id _Nullable data, NSError * _Nullable error) {
    NSLog(@"改变HTML的字体大小");
}];

js 2 oc

分两个层面:1.交互型web UI在native侧的展示,2.js通知oc(或oc捕获js)。前者通过WKUIDelegate,后者通过WKNavigationDelegate和WKScriptMessageHandler

WKUIDelegate

WKUIDelegate就是一份native侧的人机交互用的代理,例如alert,comfirm,prompt等,将交互型web UI转换成native的UI。

func webView(WKWebView, runJavaScriptAlertPanelWithMessage: String, initiatedByFrame: WKFrameInfo, completionHandler: () -> Void):将js的alert转成native的形式显示(支持转成弹出窗形式,或actionSheet形式)

- (void)webView:(WKWebView *)webView runJavaScriptAlertPanelWithMessage:(NSString *)message initiatedByFrame:(WKFrameInfo *)frame completionHandler:(void (^)(void))completionHandler {
    UIAlertController *alertController = [UIAlertController alertControllerWithTitle:@"捕捉到了js的alert" message:message?:@"" preferredStyle:UIAlertControllerStyleActionSheet];
    [alertController addAction:([UIAlertAction actionWithTitle:@"OK" style:UIAlertActionStyleDefault handler:^(UIAlertAction * _Nonnull action) {
        completionHandler();
    }])];
    [self presentViewController:alertController animated:YES completion:nil];
}

func webView(WKWebView, runJavaScriptConfirmPanelWithMessage: String, initiatedByFrame: WKFrameInfo, completionHandler: (Bool) -> Void):将js的confirm转成native的形式显示(支持转成弹出窗形式,或actionSheet形式)

- (void)webView:(WKWebView *)webView runJavaScriptConfirmPanelWithMessage:(NSString *)message initiatedByFrame:(WKFrameInfo *)frame completionHandler:(void (^)(BOOL))completionHandler{
    UIAlertController *alertController = [UIAlertController alertControllerWithTitle:@"" message:message?:@"" preferredStyle:UIAlertControllerStyleActionSheet];
    [alertController addAction:([UIAlertAction actionWithTitle:@"Cancel" style:UIAlertActionStyleCancel handler:^(UIAlertAction * _Nonnull action) {
        completionHandler(NO);
    }])];
    [alertController addAction:([UIAlertAction actionWithTitle:@"OK" style:UIAlertActionStyleDefault handler:^(UIAlertAction * _Nonnull action) {
        completionHandler(YES);
    }])];
    [self presentViewController:alertController animated:YES completion:nil];
}

func webView(WKWebView, runJavaScriptTextInputPanelWithPrompt: String, defaultText: String?, initiatedByFrame: WKFrameInfo, completionHandler: (String?) -> Void):将JS的prompt转成native的形式显示

- (void)webView:(WKWebView *)webView runJavaScriptTextInputPanelWithPrompt:(NSString *)prompt defaultText:(NSString *)defaultText initiatedByFrame:(WKFrameInfo *)frame completionHandler:(void (^)(NSString * _Nullable))completionHandler{
    UIAlertController *alertController = [UIAlertController alertControllerWithTitle:prompt message:@"" preferredStyle:UIAlertControllerStyleAlert];
    [alertController addTextFieldWithConfigurationHandler:^(UITextField * _Nonnull textField) {
        textField.text = defaultText;
    }];
    [alertController addAction:([UIAlertAction actionWithTitle:@"OK" style:UIAlertActionStyleDefault handler:^(UIAlertAction * _Nonnull action) {
        completionHandler(alertController.textFields[0].text?:@"");
    }])];
    [self presentViewController:alertController animated:YES completion:nil];
}

func webView(WKWebView, runOpenPanelWith: WKOpenPanelParameters, initiatedByFrame: WKFrameInfo, completionHandler: ([URL]?) -> Void):将JS的upload转成native的形式显示

func webView(WKWebView, createWebViewWithConfiguration: WKWebViewConfiguration, for: WKNavigationAction, windowFeatures: WKWindowFeatures) -> WKWebView?:新创建个WebView。例如当h5标签上有_blank时IOS无法跳转,解决方案就是:放弃掉原来的点击事件,强制让 webView 加载打开的链接

- (WKWebView *)webView:(WKWebView *)webView createWebViewWithConfiguration:(WKWebViewConfiguration *)configuration forNavigationAction:(WKNavigationAction *)navigationAction windowFeatures:(WKWindowFeatures *)windowFeatures {
    if (!navigationAction.targetFrame.isMainFrame) {
        [webView loadRequest:navigationAction.request];
    }
    // 该方法的本意是让程序员return个新的webview,但这里只是解决个跳转的问题,所以不需要新的webview,return nil
    return nil;
}

WKNavigationDelegate

WKNavigationDelegate是一份native侧的导航的代理,例如跳转链接前后的回调,跳转成功或异常后的回调,重定向的回调,拦截特殊协议头等。

捕捉导航 decidePolicyForNavigationAction:func webView(_ webView: WKWebView, decidePolicyForNavigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void):拦截即将跳转的HTTP请求,根据protocol,或header头信息来决定是否放行跳转

// 例如,H5里跳转自定义协议:
<a href="mymapi://callName_?https://zxljack.com/">oc捕获到自定义协议头的请求</a>

// native 侧拦截自定义协议
- (void)webView:(WKWebView *)webView decidePolicyForNavigationAction:(WKNavigationAction *)navigationAction decisionHandler:(void (^)(WKNavigationActionPolicy))decisionHandler {
    NSString *urlStr = navigationAction.request.URL.absoluteString;
    // 指定想捕获的的协议头
    if([urlStr hasPrefix:@"mymapi://"]) {
        UIAlertController *alertController = [UIAlertController alertControllerWithTitle:@"oc捕获到了指定的协议头" message:@"继续访问?" preferredStyle:UIAlertControllerStyleAlert];
        [alertController addAction:([UIAlertAction actionWithTitle:@"No" style:UIAlertActionStyleCancel handler:^(UIAlertAction * _Nonnull action) {

        }])];
        [alertController addAction:([UIAlertAction actionWithTitle:@"Yes" style:UIAlertActionStyleDefault handler:^(UIAlertAction * _Nonnull action) {
            NSURL *newUrl = [NSURL URLWithString:[urlStr stringByReplacingOccurrencesOfString:@"mymapi://callName_?" withString:@""]];
            [[UIApplication sharedApplication] openURL:newUrl];    // 用 Safari 打开链接
        }])];
        [self presentViewController:alertController animated:YES completion:nil];
        decisionHandler(WKNavigationActionPolicyCancel);
    } else {
        decisionHandler(WKNavigationActionPolicyAllow);     // 允许跳转
    }
}

捕捉响应 decidePolicyForNavigationResponse:func webView(_ webView: WKWebView, decidePolicyForNavigationResponse: WKNavigationResponse, decisionHandler: @escaping (WKNavigationResponsePolicy) -> Void):拦截收到的服务器的响应response头来决定是否放行跳转

捕捉中断 webViewWebContentProcessDidTerminate:func webViewWebContentProcessDidTerminate(WKWebView):当WKWebView总体内存占用过大,页面即将白屏的时候,系统会调用该回调函数,在这里可以reload解决白屏问题。

- (void)webViewWebContentProcessDidTerminate:(WKWebView *)webView{
    [_webView reload];
}

WKScriptMessageHandler

允许webview里的js post消息给native

捕捉消息 userContentController:func userContentController(WKUserContentController, didReceive: WKScriptMessage):捕捉webview里js post过来的消息

JS侧:定义一个名为【js2coPostMessage】的消息(消息名可以自定义)

function js2coFunc() {
    window.webkit.messageHandlers.js2coPostMessage.postMessage({"name": "Jack"});
}

IOS侧:捕捉这个名为【js2coPostMessage】的消息

- (void)userContentController:(WKUserContentController *)userContentController didReceiveScriptMessage:(WKScriptMessage *)message {
    if([message.name isEqualToString:@"js2coPostMessage"]){
        NSLog(@"name:%@\n body:%@\n frameInfo:%@\n", message.name, message.body, message.frameInfo);
        NSDictionary *params = message.body;
        UIAlertController *alertController = [UIAlertController alertControllerWithTitle:@"oc捕获到了js post的消息" message:params[@"name"] preferredStyle:UIAlertControllerStyleAlert];
        [alertController addAction:([UIAlertAction actionWithTitle:@"OK" style:UIAlertActionStyleDefault handler:^(UIAlertAction * _Nonnull action) {
        }])];
        [self presentViewController:alertController animated:YES completion:nil];
    }
}

webview销毁时,需要用WKUserContentController移除注册的消息:

- (void)dealloc{
    [[_webView configuration].userContentController removeScriptMessageHandlerForName:@"jsToOcNoPrams"];
}

音频 & 视频

Video

常用属性:

controls:显示播放控件,如果默认播放控件不好看,可以不设用自定义的播放控件
poster:封面(pc上不设默认是第一帧,但APP没这么智能,不设就没有封面)
playsinline:是否小窗播放(不设是全屏播放)
autoplay:该属性是无效的,自动播放需要设置webview的属性。

<video width="384" height="288" controls="controls" preload="auto" playsinline poster="https://zxljack.com/wp-content/uploads/2019/11/timg.jpeg">
     <source src="https://zxljack.com/wp-content/uploads/2019/11/cdbfc8b0-25db-44ba-ad5e-f8f2c2bf1e19.mp4" type="video/mp4" />
</video>

PS:光解决ios的播放问题是不够的,还有Android也要考虑。

最后

如果不需要和js交互,就更简单了,一个简单的例子:

#import <WebKit/WebKit.h>

@interface testViewController ()<WKNavigationDelegate>
@property(nonatomic, strong, readwrite) WKWebView *webView;
@end

[self.view addSubview:({
    self.webView = [[WKWebView alloc] initWithFrame:CGRectMake(0, 88, self.view.frame.size.width, self.view.frame.size.height - 88)];
    self.webView.navigationDelegate = self;
    self.webView;
})];

[self.webView loadRequest:[NSURLRequest requestWithURL:[NSURL URLWithString:@"https://zxljack.com/"]]];

- (void)webView:(WKWebView *)webView decidePolicyForNavigationAction:(WKNavigationAction *)navigationAction decisionHandler:(void (^)(WKNavigationActionPolicy))decisionHandler {
    // 可以处理是否跳转 url
    decisionHandler(WKNavigationActionPolicyAllow);
}
- (void)webView:(WKWebView *)webView didFinishNavigation:(null_unspecified WKNavigation *)navigation {
    // url 加载完后的处理
    NSLog(@"didFinishNavigation");
}

但webview并不能完全取代native,做不到和native一样的丝滑感受,尤其是在一些音视频方面,h5的video标签并不支持左滑右滑等手势。

可以在一个页面中webview和native并存,展示性的放在webview中,复杂交互性的放在native中。

但混用时,手势滚动是个问题,有一些常见的解决方案:

1.整个页面是UIScrollView,上半部分是webview,下半部分是UITableView。

2.整个页面是个UITableView,在header中展示webview。

可以使用开源项目,例如HybirdPagekit

发表评论

电子邮件地址不会被公开。 必填项已用*标注