IOS网络:IOS和JS的交互

原创:知识进阶型文章
无私奉献,为国为民,创作不易,请珍惜,之后会持续更新,不断完善
个人比较喜欢做笔记和写总结,毕竟好记性不如烂笔头哈哈,这些文章记录了我的IOS成长历程,希望能与大家一起进步
温馨提示:由于简书不支持目录跳转,大家可通过command + F 输入目录标题后迅速寻找到你所需要的内容

目录

  • 一、前言
  • 二、UIWebView的使用
    • 1、UIWebView的课堂小知识
    • 2、OC 调用 JS中的弹框方法
    • 3、UIWebView中JS调用OC的原理就是拦截URL
    • 4、UIWebViewDelegate汇总
  • 二、JavaScriptCore的使用
    • OC与JS的简单交互
    • JSContext提供的异常处理机制
    • JS中操作OC中的类对象
    • 使用手机相册来实战一下
  • WKWebView的使用
    • 1、WKWebView的基本用法
    • 2、交互概览
    • 拦截网页的跳转链接
    • OC 调用 JS方法
    • WKScriptMessageHandler
    • WKNavigationDelegate汇总
    • WKWebView问题汇总
  • JavascriptBridge
    • 基本配置
    • JS调用OC中的方法
    • OC 调用 JS中的方法
    • WKUIDelegate
  • Demo
  • 参考文献

一、前言

Web页面中的 JS 与 iOS Native 如何交互是每个 iOS 猿必须掌握的技能。而说到 Native与 JS 交互,就不得不提一嘴Hybrid

Hybrid Mobile App我对它的理解为通过Web网络技术(如 HTMLCSSJavaScript)与Native相结合的混合移动应用程序。

因为 Hybrid 的灵活性(更改 Web页面不必重新发版)以及通用性(一份 H5 玩遍所有平台)再加上门槛低(前端猿可以无痛上手开撸)的优势,所以在非核心功能模块使用 Web通过 Hybrid 的方式来实现可能从各方面都会优于 Native。而 Native则可以在核心功能和设备硬件的调用上为 JS 提供强有力的支持。

H5 渗入 Mobile App开发

Native APP开发中有一个 WKWebview 的组件,这个组件可以加载 Html文件。

Hybrid 现状

虽然目前已经出现了 RNWeex 这些使用 JS 写 Native App的技术,但是 Hybrid仍然没有被淘汰,市面上大多数应用都不同程度的引入了Web页面。


二、UIWebView的使用

控制台输出.png

运行过程.gif

1、UIWebView的课堂小知识

a、加载方法
- (void)loadRequest:(NSURLRequest *)request;
- (void)loadHTMLString:(NSString *)string baseURL:(nullable NSURL *)baseURL;
- (void)loadData:(NSData *)data MIMEType:(NSString *)MIMEType textEncodingName:(NSString *)textEncodingName baseURL:(NSURL *)baseURL;

如果需要监听页面加载的结果,或者需要判断是否允许打开某个URL,那需要设置UIWebViewdelegate,代理只需要遵循<UIWebViewDelegate>协议,并且在代理中实现下面的这些可选方法就可以。

@optional
- (BOOL)webView:(UIWebView *)webView shouldStartLoadWithRequest:(NSURLRequest *)request navigationType:(UIWebViewNavigationType)navigationType;
- (void)webViewDidStartLoad:(UIWebView *)webView;
- (void)webViewDidFinishLoad:(UIWebView *)webView;
- (void)webView:(UIWebView *)webView didFailLoadWithError:(nullable NSError *)error;

b、OC调用JS的方式
self.navigationItem.title = [webView stringByEvaluatingJavaScriptFromString:@"document.title"];

c、缺点如下
  1. 该方法不能判断调用了一个js方法之后,是否发生了错误。当错误发生时,返回值为nil,而当调用一个方法本身没有返回值时,返回值也为nil,所以无法判断是否调用成功了。

  2. 返回值类型为NSString *,就意味着当调用的js方法有返回值时,都以字符串返回,不够灵活。当返回值是一个jsArray时,还需要解析字符串,比较麻烦。


d、通过使用 JavaScriptCore 解决缺点

JSPatch被禁事件中,最核心的就是它了。因为JavaScriptCoreJSOC的映射,可以替换各种js方法成oc方法,所以其动态性(配合runtime的不安全性)也就成为了JSPatchApple禁掉的最主要原因。

其实WebKit都有一个内嵌的jsa 环境,一般我们在页面加载完成之后,获取js上下文,然后通过JSContextevaluateScript:方法来获取返回值。因为该方法得到的是一个JSValue对象,所以支持JavaScriptArrayNumberString、对象等数据类型。该方法解决了stringByEvaluatingJavaScriptFromString:返回值只是NSString的问题。

- (void)webViewDidFinishLoad:(UIWebView *)webView
{
    //更新标题,这是上面的讲过的方法
    //self.navigationItem.title = [webView stringByEvaluatingJavaScriptFromString:@"document.title"];
    
    //获取该UIWebView的javascript上下文
    JSContext *jsContext = [self.webView valueForKeyPath:@"documentView.webView.mainFrame.javaScriptContext"];
    //这也是一种获取标题的方法。
    JSValue *value = [self.jsContext evaluateScript:@"document.title"];
    //更新标题
    self.navigationItem.title = value.toString;
}

那么如果我执行了一个不存在的方法,比如:

[self.jsContext evaluateScript:@"document.titlexxxx"];

那么必然会报错,报错了,可以通过@property (copy) void(^exceptionHandler)(JSContext *context, JSValue *exception);,设置该block来获取异常。

//在调用前,设置异常回调
[self.jsContext setExceptionHandler:^(JSContext *context, JSValue *exception){
        NSLog(@"%@", exception);
}];
//执行方法
JSValue *value = [self.jsContext evaluateScript:@"document.titlexxxx"];

该方法,也很好的解决了stringByEvaluatingJavaScriptFromString:调用js方法后,出现错误却捕获不到的缺点。


e、JS调用OC
1. Custom URL Scheme(拦截URL)

比如页面中一个a标签,链接如下:

<a href="darkangel://smsLogin?username=12323123&code=892845">短信验证登录</a>

Objective-C中,只要遵循了UIWebViewDelegate协议,那么每次打开一个链接之前,都会触发方法:

- (BOOL)webView:(UIWebView *)webView shouldStartLoadWithRequest:(NSURLRequest *)request navigationType:(UIWebViewNavigationType)navigationType;

在该方法中,捕获该链接,并且返回NO(阻止本次跳转),从而执行对应的OC方法:

- (BOOL)webView:(UIWebView *)webView shouldStartLoadWithRequest:(NSURLRequest *)request navigationType:(UIWebViewNavigationType)navigationType
{
    //标准的URL包含scheme、host、port、path、query、fragment等
    NSURL *URL = request.URL;    
    if ([URL.scheme isEqualToString:@"darkangel"]) {
        if ([URL.host isEqualToString:@"smsLogin"]) {
            NSLog(@"短信验证码登录,参数为 %@", URL.query);// 参数为 username=12323123&code=892845
            return NO;
        }
    }
    return YES;
}

参数可以是一个json格式并且URLEncode过的字符串,这样就可以实现复杂参数的传递(比如WebViewJavascriptBridge)。

优点:泛用性强,可以配合h5实现页面动态化。比如页面中一个活动链接到活动详情页,当native尚未开发完毕时,链接可以是一个h5链接,等到native开发完毕时,可以通过该方法跳转到native页面,实现页面动态化。且该方案适用于AndroidiOS,泛用性很强。

缺点:无法直接获取本次交互的返回值,比较适合单向传参,且不关心回调的情景,比如h5页面跳转到native页面等。

其实,WebViewJavascriptBridge使用的方案就是拦截URL,为了解决无法直接获取返回值的缺点,它采用了将一个名为callbackfunction作为参数,通过一些封装,传递到OCjs->oc 传递参数和callback),然后在OC端执行完毕,再通过block来回调callback(`oc->js1,传递返回值参数),实现异步获取返回值。

//JS调用OC的分享方法(当然需要OC提前注册)share为方法名,shareData为参数,后面的为回调function
WebViewJavascriptBridge.callHandler('share', shareData, function(response) {
   //OC端通过block回调分享成功或者失败的结果
   alert(response);   
});
2、JavaScriptCore

看见个例子写得比较好,清晰易懂:有则替换,无则添加。

js文件

//该方法传入两个整数,求和,并返回结果
function testAddMethod(a, b) {
    //需要OC实现a+b,并返回
    return a + b;
}
//js调用
console.log(testAddMethod(1, 5));   //output  6

oc直接替换该方法

self.jsContext[@"testAddMethod"] = ^NSInteger(NSInteger a, NSInteger b) {
      return a + b;
};

那么当在js调用

//js调用
console.log(testAddMethod(1, 5));   //output  6, 方法为 a + b

如果oc替换该方法为两数相乘

self.jsContext[@"testAddMethod"] = ^NSInteger(NSInteger a, NSInteger b) {
      return a * b;
};

再次调用js

console.log(testAddMethod(1, 5));   //output  5,该方法变为了 a * b。

2、OC 调用 JS中的弹框方法

  1. 在扩展中支持委托,声明UIWebView
@interface UIWebViewViewController ()<UIWebViewDelegate>

@property (nonatomic, strong) UIWebView *webView;

@end
  1. 初始化 webView,调用loadRequest方法加载htmlURL
- (void)viewDidLoad
{
    [super viewDidLoad];
    self.navigationItem.rightBarButtonItem = [[UIBarButtonItem alloc] initWithTitle:@"OC调用showAlert" style:UIBarButtonItemStylePlain target:self action:@selector(didClickRightItemAction)];

    self.webView = [[UIWebView alloc] initWithFrame:self.view.bounds];
    self.webView.delegate = self;
    [self.view addSubview:self.webView];
    
    NSURL *url = [[NSBundle mainBundle] URLForResource:@"index.html" withExtension:nil];;
    NSURLRequest *request = [[NSURLRequest alloc] initWithURL:url];
    [self.webView loadRequest:request];
}
  1. 点击导航栏右边按钮调用JS中的弹出提示框方法,传入参数HELLO
- (void)didClickRightItemAction
{
    NSString *result = [self.webView stringByEvaluatingJavaScriptFromString:@"showAlert('HELLO')()"];
    NSLog(@"result == %@",result);
}
  1. JS中相应代码如下,需要注意的是嵌套了一个函数,内层函数拿到外层的参数,弹出提示框,并返回修改后的值给OC
function showAlert(messgae){
    // 嵌套了一个函数
    return function handle(){
        alert('我是一个可爱的弹框,拿到了OC传来的消息: \n'+messgae);
        return messgae + '传回给OC中的result';
    }
}

3、UIWebView中JS调用OC的原理就是拦截URL

实现UIWebViewDelegate中的shouldStartLoadWithRequest方法,该方法会加载所有请求数据,以及控制是否加载,可在此处拦截URL,实现JS调用OC。

a、首先需要理解几个概念
  • URL.scheme:我们自己定的标识,一般用来进行APP跳转
  • URL.host:主机名,此处指方法名
  • URL.pathComponents:参数

可以打印出来看看

NSLog(@"%@",request.URL.scheme); // 标识
NSLog(@"%@",request.URL.host);   // 方法名
NSLog(@"%@",request.URL.pathComponents);  // 参数
  • NavigationType:JS响应的样式,包括
UIWebViewNavigationTypeLinkClicked,        点击
UIWebViewNavigationTypeFormSubmitted,      提交
UIWebViewNavigationTypeBackForward,        返回
UIWebViewNavigationTypeReload,             刷新
UIWebViewNavigationTypeFormResubmitted,    重复提交
UIWebViewNavigationTypeOther               其他
b、理解了后那么我们便拦截URL吧
if ([request.URL.scheme isEqualToString:@"tzedu"])
c、拦截之后自然是进入我们的主菜,JS 调用 OC
NSArray *args = request.URL.pathComponents;
NSString *methodName = args[1];
// args[1] = JSCallOC:  args[2] = hello word
if ([methodName isEqualToString:@"JSCallOC:"])
{
    [self JSCallOC:args[2]];
}

此处随意写了个输出方法

- (void)JSCallOC:(NSString *)str
{
    // 打印结果为:这是一个OC中的方法,被JS调用了:hello word
    NSLog(@"这是一个OC中的方法,被JS调用了:%@",str);
}
d、js中相应代码为
<a href="tzedu:///JSCallOC:/hello word">点击跳转响应OC方法</a>
<script>
    var sbmt = document.getElementById('sbmt');
    sbmt.onclick = function(event) {
        window.location.href = "tzedu:///JSCallOC:/hello word";
    };
</script>

4、UIWebViewDelegate汇总

开始加载
- (void)webViewDidStartLoad:(UIWebView *)webView{
    NSLog(@"****************华丽的分界线****************");
    NSLog(@"开始加载咯!!!!");
}
加载完成
- (void)webViewDidFinishLoad:(UIWebView *)webView{
    
    NSString *titlt = [webView stringByEvaluatingJavaScriptFromString:@"document.title"];
    self.title = titlt;
    
    NSLog(@"****************华丽的分界线****************");
    NSLog(@"加载完成了咯!!!!");
}
加载失败
- (void)webView:(UIWebView *)webView didFailLoadWithError:(NSError *)error{
    NSLog(@"****************华丽的分界线****************");
    NSLog(@"加载失败了咯,为什么:%@",error);
}

二、JavaScriptCore的使用

控制台输出.png

运行效果.gif

OC与JS的简单交互

  1. 引入头文件#import <JavaScriptCore/JavaScriptCore.h>

  2. 在扩展中支持委托,声明UIWebViewJSContext

@interface ViewController ()<UIWebViewDelegate>

@property (nonatomic, strong) UIWebView *webView;
@property (nonatomic, strong) JSContext *jsContext;

@end

列举一下JavaScriptCore常见的对象及协议:

  • JSContext是 JS 执行上下文,你可以把它理解为 JS 运行的环境。
  • JSValue 是对 JavaScript 值的引用,任何 JS 中的值都可以被包装为一个 JSValue
  • JSManagedValu 是对 JSValue 的包装,加入了“conditional retain”
  • SVirtualMachine 表示 JavaScript 执行的独立环境。
  • JSExport 协议:实现将 Objective-C 类及其实例方法,类方法和属性导出为 JavaScript 代码的协议。

一般情况下我们不用手动去创建JSVirtualMachine。因为当我们获取 JSContext时,获取到的JSContext从属于一个JSVirtualMachine

每个JSVirtualMachine可以包含多个上下文,允许在上下文之间传递值(JSValue对象)。 但是,每个 JSVirtualMachine是不同的,即我们不能将一个JSVirtualMachine中创建的值传递到另一个JSVirtualMachine中的上下文。

JavaScriptCore API是线程安全的 —— 例如,我们可以从任何线程创建JSValue对象或运行 JS 脚本 - 但是,尝试使用相同 JSVirtualMachine 的所有其他线程将被阻塞。 要在多个线程上同时(并发)运行JavaScript脚本,请为每个线程使用单独的JSVirtualMachine实例。

注意:JavaScriptCore只能在UIWebView中使用,不可以在WKWebView中使用,但是UIWebView在IOS12之后就已经弃用了哈哈,所以大家还是直接学习WKWebView

  1. 初始化webView,调用loadRequest方法加载htmlURL
- (void)viewDidLoad {
    [super viewDidLoad];
    \\ 初始化webView
    self.webView = [[UIWebView alloc] initWithFrame:self.view.bounds];
    self.webView.delegate        = self;
    [self.view addSubview:self.webView];
    
    \\ 调用loadRequest方法加载html的URL
    NSURL *url = [[NSBundle mainBundle] URLForResource:@"index.html" withExtension:nil];;
    NSURLRequest *request = [[NSURLRequest alloc] initWithURL:url];
    [self.webView loadRequest:request];
}
  1. 完成UIWebViewDelegatewebViewDidFinishLoad方法的实现,该方法会在html加载完成的时候调用

OC 调用 JS的变量和方法:
变量:设置当前网页标题为html中的document.title

    NSString *titlt = [webView stringByEvaluatingJavaScriptFromString:@"document.title"];
    self.title = titlt;

方法:点击导航栏右边按钮调用JS中的弹出提示框方法

[self.jsContext evaluateScript:@"showAlert()"];

JS中相关的方法

        function showAlert(){
            alert("哈喽各位");
            showMessage("你刚刚点击了弹框按钮...",arr);
        }

接下来是JS调用OC中方法的具体实现的方式:
首先需要获取到JSContext,通过webViewvalueForKeyPath获取的,其路径为documentView.webView.mainFrame.javaScriptContext

    // 获取JSContext  
    JSContext *jsContext = [self.webView valueForKeyPath:@"documentView.webView.mainFrame.javaScriptContext"];
    self.jsContext = jsContext;

接下来通过jsContext,往js中添加变量和方法

    // 在js中添加全局变量arr
    [self.jsContext evaluateScript:@"var arr = [5,'关羽','赵云'];"];

    // 在js中添加方法
    NSString *jsFunction = @"function add(a,b) {return a+b}";
    [self.jsContext evaluateScript:jsFunction];

添加了,自然要取出来用

    jsContext[@"showMessage"] = ^{
        NSLog(@"调用OC中的showMessage具体实现");
        
        // 因为刚才我们设置的是全局变量 可以直接获取
        JSValue *arrValue = weakSelf.jsContext[@"arr"];
        NSLog(@"arrValue == %@",arrValue);
        int num = [[arrValue.toArray objectAtIndex:0] intValue];
        num += 10;
        NSLog(@"arrValue == %@  : num == %d",arrValue.toArray,num);
        
        // 调用刚才设置的方法
        JSValue *addResult = [self.jsContext[@"add"] callWithArguments:@[@2, @3]];
        NSLog(@"addResult = %@", @([addResult toInt32]));// 5

        // 还可以在OC 调用 JS 方法,并且传入参数
        NSDictionary *dict = @{@"name":@"刘备",@"age":@22};
        [[JSContext currentContext][@"ocCalljs"] callWithArguments:@[dict]];
    };

这里我们通过OC中的Block代码块,给js中的showMessage方法添加了具体实现,并且通过JSContext取到了JS中的变量和方法,是不是很神奇呀哈哈,让我们来看看JS中的相关代码:

        <input type="button" value="弹框" onclick="showAlert()" /><br/>
        // 只有调用没有实现哦
        function showAlert(){
            showMessage();
        }

        // OC 调用 JS 方法,并且传入参数
        function ocCalljs(dict){
            var name = dict['name'];
            var age  = dict['age'];
            alert(name + age);

            // 传回去
            showDict(dict)
        }

最后一步我们将JS中的方法回传过来的字典值展示到label上

    self.jsContext[@"showDict"] = ^(JSValue *value) {// 拿到回传值的方法
        // JS中的方法回传过来的字典值展示到label上
        NSArray *args = [JSContext currentArguments];
        JSValue *dictValue = args[0];
        NSDictionary *dict = dictValue.toDictionary;
        NSLog(@"JS中的方法回传过来的字典值:%@",dict);
        dispatch_async(dispatch_get_main_queue(), ^{
            weakSelf.showLabel.text = dict[@"name"];
        });
    };

JSContext提供的异常处理机制

JSContext也提供了异常处理机制,这里简单提下

    // 异常处理
    self.jsContext.exceptionHandler = ^(JSContext *context, JSValue *exception) {
        context.exception = exception;
        NSLog(@"exception == %@",exception);
    };

JS中操作OC中的类对象

可以在JS中操作OC中的类对象,并且调用类对象的方法,很棒的功能,因为这样你就不用一个个方法去转化了,只需要转化一个类,然后调用类中方法即可

首先新建一个类JSCoreMoreObject,在该类中必须实现协议JSExport,因为在协议中声明的API都会在JS中暴露出来,才能调用,该协议中的方法需要放入JSExportAs中,其有两个参数,参数一:PropertyName可以随便起 ,参数二:Selector

// 在协议中声明的API都会在JS中暴露出来,才能调用
@protocol JSCoreMoreProtocol <JSExport>

- (void)letShowImage;
// PropertyName随便起 - Selector
JSExportAs(getSum, -(int)getSumWithNum1:(int)num1 num2:(int)num2);

@end

@interface JSCoreMoreObject : NSObject<JSCoreMoreProtocol>

@end

然后让类实现该协议

@implementation JSCoreMoreObject

- (int)getSumWithNum1:(int)num1 num2:(int)num2
{
    return num1+num2;
}

@end

最后在webViewDidFinishLoad方法中实例化这个类,再试试调用它的方法

    // JS 操作对象
    JSCoreMoreObject *object = [[JSCoreMoreObject alloc] init];
    self.jsContext[@"object"] = object;
    NSLog(@"JS 操作对象: object == %d",[object getSumWithNum1:20 num2:40]);

相关JS代码如下

        // JS 操作对象
        function testObject(){
            alert(JSCoreMoreObject.getSum(10,20));
        }

使用手机相册来实战一下

完成了基本概念的理解,接下来我们通过使用手机相册来实战一下
(1)首先先添加选取相册图片和进行拍照的视图控制器的委托和成员变量

<UIWebViewDelegate,UIImagePickerControllerDelegate,UINavigationControllerDelegate>

@property (nonatomic,strong) UIImagePickerController *imagePicker;

(2)接下来实现UIImagePickerControllerDelegate中的didFinishPickingMediaWithInfo回调方法,
在讲解流程的过程中也顺便罗列了几个小知识点:

a、通过info信息读取图片的数据,其中info包含的键如下

NSString *const  UIImagePickerControllerMediaType ;指定用户选择的媒体类型
NSString *const  UIImagePickerControllerOriginalImage ;原始图片
NSString *const  UIImagePickerControllerEditedImage ;修改后的图片
NSString *const  UIImagePickerControllerCropRect ;裁剪尺寸
NSString *const  UIImagePickerControllerMediaURL ;媒体的URL
NSString *const  UIImagePickerControllerReferenceURL ;原件的URL
NSString *const  UIImagePickerControllerMediaMetadata;当来数据来源是照相机的时候这个值才有效

所以相应代码如下

UIImage *resultImage = [info objectForKey:@"UIImagePickerControllerEditedImage"];

b、图片压缩方式
日常工作中推荐使用的是UIImageJPEGRepresentation图压缩后节省内存,减少避免图片过多造成的卡顿现象。当然如果对图片画质有极高的要求的话,还是使用UIImagePNGRepresentation

偶然发现使用UIImageJPEGRepresentation 一些无背景的白色图案经过压缩之后会变成白色背景无图案的图片。因为像素低,再经压缩后,就会导致图片失真, 成为白底无图案的图片。这种情况只能使用 UIImagePNGRepresentation用原图展示,当然这不是最好的方法。

// 0.01为这里设置的压缩系数compressionQuality
NSData *imageData = UIImageJPEGRepresentation(resultImage, 0.01);

c、先将字符串转为Data再进行64位编码

NSString *encodedImageStr = [imageData base64EncodedStringWithOptions:NSDataBase64Encoding64CharacterLineLength];

d、但是编码后的字符串存在空格、回车、换行,唉,看来还得写个方法把他们移除掉

NSString *imageString = [self removeSpaceAndNewline:encodedImageStr];

// 移除空格、回车、换行
- (NSString *)removeSpaceAndNewline:(NSString *)str
{
    NSString *tempStr;
    // 移除空格
    tempStr = [str stringByReplacingOccurrencesOfString:@" " withString:@""];
    // 移除回车
    tempStr = [tempStr stringByReplacingOccurrencesOfString:@"\r" withString:@""];
    // 移除换行
    tempStr = [tempStr stringByReplacingOccurrencesOfString:@"\n" withString:@""];
    
    return tempStr;
}

e、哈哈,终于到了激动人心的时刻了,OC调用JS中显示图片的方法,并且向JS中传入OC的图片参数

    NSString *jsFunctStr = [NSString stringWithFormat:@"showImage('%@')",imageString];
    [self.jsContext evaluateScript:jsFunctStr];

对应JS文件中用来显示图片的showImage方法的代码如下

        function showImage(imageDataStr){
            var tz_img = document.getElementById("tz_image");
            tz_img.innerHTML = "<image style='width:200px;' src='data:image/png;base64,"+imageDataStr+"'>";
        }

f、打完收工,退出相册选择器

[self dismissViewControllerAnimated:YES completion:nil];

(3)哈哈,你真以为这样就完了?年轻人,你还是太嫩了(滑天下之大稽),你忘了imagePicker我们还没初始化呢!还有点击哪个按钮来调用打开相册方法?

我们在JS文件中定义了一个按钮用来打开相册

        <input type="button" value="打开相册" onclick="openAlbumImage()" /><br />
        function openAlbumImage(){
            // 这里只是调用,OC中具体实现
            getImage();
        }

然后在OC实现的方法里顺便初始化了,直接展示,需要注意imagePicker需要在主线程调用,而我们的webView的加载中处于子线程,所以需要切换回主线程,否则会报紫色线程错误

    // 打开相册
    // Block中存在强持有self,所以自然要打破循环引用
    __weak typeof(self) weakSelf = self;
    self.jsContext[@"getImage"] = ^{
        // imagePicker需要在主线程调用,而我们的webView的加载中处于子线程
        dispatch_async(dispatch_get_main_queue(), ^{
            weakSelf.imagePicker = [[UIImagePickerController alloc] init];
            weakSelf.imagePicker.delegate = weakSelf;
            weakSelf.imagePicker.allowsEditing = YES;
            weakSelf.imagePicker.sourceType = UIImagePickerControllerSourceTypePhotoLibrary;
            [weakSelf presentViewController:weakSelf.imagePicker animated:YES completion:nil];
        });
    };

WKWebView的使用

控制台输出.png

运行过程.gif

1、WKWebView的基本用法

a、简介
优势

WKWebView是Apple于iOS 8.0推出的WebKit中的核心控件,用来替代UIWebView

  • 更多的支持HTML5的特性
  • 高达60fps的滚动刷新率以及内置手势
  • Safari相同的JavaScript引擎
  • UIWebViewDelegate拆成了一个跳转的协议WKNavigationDelegate和一个关于UI的协议WKUIDelegate
  • 可以获取加载进度:estimatedProgress
新属性

可以通过KVO观察这些值的变化,以便于我们做出最友好的交互。

@property (nullable, nonatomic, readonly, copy) NSString *title;// 页面的title,终于可以直接获取了
@property (nullable, nonatomic, readonly, copy) NSURL *URL;// 当前webView的URL
@property (nonatomic, readonly, getter=isLoading) BOOL loading;// 是否正在加载
@property (nonatomic, readonly) double estimatedProgress;// 加载的进度
@property (nonatomic, readonly) BOOL canGoBack;// 是否可以后退,跟UIWebView相同
@property (nonatomic, readonly) BOOL canGoForward;// 是否可以前进,跟UIWebView相同
[_webView reload];// 刷新
[_webView stopLoading];// 停止加载
[_webView goBack];// 后退函数
[_webView goForward];// 前进函数
[_webView canGoBack];// 是否可以后退
[_webView canGoForward];// 向前
[_webView isLoading];// 是否正在加载
_webView.allowsBackForwardNavigationGestures = YES;// 允许左右划手势导航
b、使用
功能点
// WebView不仅可以加载HTML页面,还支持pdf、word、txt、各种图片等等的显示
- (void)createWebView
{
    NSString *htmlPath = [[NSBundle mainBundle] pathForResource:@"WKWebView" ofType:@"html"];
    
    // 加载HTML页面
    NSError *error = nil;
    NSString *html = [[NSString alloc] initWithContentsOfFile:htmlPath encoding:NSUTF8StringEncoding error:&error];
    NSURL *baseURL = [NSURL URLWithString:@"https://"];
    [self.webView loadHTMLString:html baseURL:baseURL];
    
    // Data
    NSData *htmlData = [[NSData alloc] initWithContentsOfFile:htmlPath];
    [self.webView loadData:htmlData MIMEType:@"text/html" characterEncodingName:@"UTF-8" baseURL:baseURL];
    
    // Request
    NSURL *url = [NSURL URLWithString:@""];
    NSURLRequest *request = [NSURLRequest requestWithURL:url];
    [self.webView loadRequest:request];
    
    // 加载本地文件
    NSURL *localURL = [NSURL fileURLWithPath:@""];
    [_webView loadFileURL:localURL allowingReadAccessToURL:url];
        
    [_webView reload];// 刷新
    [_webView stopLoading];// 停止加载
    [_webView goBack];// 后退函数
    [_webView goForward];// 前进函数
    [_webView canGoBack];// 是否可以后退
    [_webView canGoForward];// 向前
    [_webView isLoading];// 是否正在加载
    _webView.allowsBackForwardNavigationGestures = YES;// 允许左右划手势导航
    
    self.webView.navigationDelegate = self;
}
代理
// 在发送请求之前,决定是否跳转
- (void)webView:(WKWebView *)webView decidePolicyForNavigationAction:(WKNavigationAction *)navigationAction decisionHandler:(void (^)(WKNavigationActionPolicy))decisionHandler
{
    NSString *urlString = [[navigationAction.request URL] absoluteString];
    urlString = [urlString stringByRemovingPercentEncoding];
    
    // 用://截取字符串
    NSArray *urlComps = [urlString componentsSeparatedByString:@"://"];
    if (urlComps.count > 0)
    {
        // 获取协议头
        NSString *protocolHead = [urlComps objectAtIndex:0];
        NSLog(@"protocolHead = %@",protocolHead);
    }
    decisionHandler(WKNavigationActionPolicyAllow);
}

// 协议-开始加载时调用
- (void)webView:(WKWebView *)webView didStartProvisionalNavigation:(WKNavigation *)navigation
{
    NSLog(@"开始加载");
}

// 协议-当内容开始返回时调用
- (void)webView:(WKWebView *)webView didCommitNavigation:(WKNavigation *)navigation
{
    NSLog(@"内容开始返回");
}

// 协议-加载完成之后调用
- (void)webView:(WKWebView *)webView didFinishNavigation:(WKNavigation *)navigation
{
    NSLog(@"加载完成");
}

// 协议-加载失败时调用
- (void)webView:(WKWebView *)webView didFailNavigation:(WKNavigation *)navigation withError:(NSError *)error
{
    NSLog(@"加载失败 error :  %@", error.localizedDescription);
}

2、交互概览

a、OC -> JS
//执行一段js,并将结果返回,如果出错,error则不为空
- (void)evaluateJavaScript:(NSString *)javaScriptString completionHandler:(void (^ _Nullable)(_Nullable id result, NSError * _Nullable error))completionHandler;

该方法很好的解决了之前文章中提到的UIWebView使用stringByEvaluatingJavaScriptFromString:方法的两个缺点(1. 返回值只能是NSString 2. 报错无法捕获)。比如我想获取页面中的title,除了直接self.webView.title外,还可以通过这个方法:

[self.webView evaluateJavaScript:@"document.title" completionHandler:^(id _Nullable title, NSError * _Nullable error) {
        NSLog(@"调用evaluateJavaScript异步获取title:%@", title);
}];
b、JS -> OC

法一:UIWebView介绍到的URL拦截方法一致,都是通过自定义Scheme,在链接激活时,拦截该URL,拿到参数,调用OC方法。
法二:在OC中添加一个scriptMessageHandler,则会在all frames中添加一个jsfunctionwindow.webkit.messageHandlers.<name>.postMessage(<messageBody>)。那么当我在OC中通过如下的方法添加了一个handler,如:

[controller addScriptMessageHandler:self name:@"currentCookies"]; //这里self要遵循协 WKScriptMessageHandler

则当我在js中调用下面的方法时:

window.webkit.messageHandlers.currentCookies.postMessage(document.cookie);

在OC中将会收到WKScriptMessageHandler的回调:

- (void)userContentController:(WKUserContentController *)userContentController didReceiveScriptMessage:(WKScriptMessage *)message {
    if ([message.name isEqualToString:@"currentCookies"]) {
        NSString *cookiesStr = message.body;    //message.body返回的是一个id类型的对象,所以可以支持很多种js的参数类型(js的function除外)
        NSLog(@"当前的cookie为: %@", cookiesStr);
    }
}

当然,记得在适当的地方调用removeScriptMessageHandler,这样就完成了一次完整的JS -> OC的交互。

- (void)dealloc {
    //记得移除
    [self.webView.configuration.userContentController removeScriptMessageHandlerForName:@"currentCookies"];
}
c、WKWebViewConfiguration中的userContentController
@interface WKUserContentController : NSObject <NSCoding>

//读取添加过的脚本
@property (nonatomic, readonly, copy) NSArray<WKUserScript *> *userScripts;
//添加脚本
- (void)addUserScript:(WKUserScript *)userScript;
//删除所有添加的脚本
- (void)removeAllUserScripts;
//通过window.webkit.messageHandlers.<name>.postMessage(<messageBody>) 来实现js->oc传递消息,并添加handler
- (void)addScriptMessageHandler:(id <WKScriptMessageHandler>)scriptMessageHandler name:(NSString *)name;
//删除handler
- (void)removeScriptMessageHandlerForName:(NSString *)name;

@end

拦截网页的跳转链接

  1. 引入头文件#import <WebKit/WebKit.h>
  2. 在扩展中支持委托,声明WKNavigationDelegateWKUIDelegate
@interface WKWebViewViewController ()<WKNavigationDelegate, WKUIDelegate>

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

@end
  1. 初始化WKWebView,调用loadRequest方法加载htmlURL
    WKWebViewConfiguration *configuration = [[WKWebViewConfiguration alloc] init];
    // 调整按钮大小适应APP页面
    configuration.userContentController = [self wkwebViewScalPreferences];

    // 创建wkWebView
    self.wkWebView = [[WKWebView alloc] initWithFrame:self.view.frame configuration:configuration];
    self.wkWebView.UIDelegate  = self;
    self.wkWebView.navigationDelegate = self;
    [self.view addSubview:self.wkWebView];

    // 加载js文件
    NSString *urlStr = [[NSBundle mainBundle] pathForResource:@"index.html" ofType:nil];
    NSURL *fileURL = [NSURL fileURLWithPath:urlStr];
    [self.wkWebView loadFileURL:fileURL allowingReadAccessToURL:fileURL];

这里有个需要注意的地方,js的按钮在WKWebView中不像UIWebView那样合适,而是默认看起来很小的,所以我们需要实现一个调整按钮大小适应APP页面的方法即wkwebViewScalPreferences,实现如下:

// 调整按钮大小适应APP页面
- (WKUserContentController *)wkwebViewScalPreferences
{
    // js注入: json调整按钮大小脚本
    NSString *jScript = @"var meta = document.createElement('meta'); meta.setAttribute('name', 'viewport'); meta.setAttribute('content', 'width=device-width'); document.getElementsByTagName('head')[0].appendChild(meta);";
    
    /** WKUserScript就是帮助我们完成JS注入的类,它能帮助我们在页面填充前或js填充完成后调用
     * 参数1:脚本的源代码
     * 参数2:脚本应注入网页的时间,是个枚举,End表示:在文档完成加载之后,但在其他子资源完成加载之前插入脚本
     * 参数3:是否加入所有框架,还是只加入主框架
     */
    WKUserScript *wkUserScript = [[WKUserScript alloc] initWithSource:jScript injectionTime:WKUserScriptInjectionTimeAtDocumentEnd forMainFrameOnly:YES];
    WKUserContentController *wkUserContentController = [[WKUserContentController alloc] init];
    [wkUserContentController addUserScript:wkUserScript];
    
    return wkUserContentController;
}
  1. 实现WKNavigationDelegate中的decidePolicyForNavigationAction方法,其用于拦截网页的跳转链接

a.首先我们需要获取请求的URL,测试的URL = @"lgedu://jsCallOC?username=Cooci&password=123456"

NSURL *URL = navigationAction.request.URL;

b. 接着需要获取URL Scheme,其可以用来拦截网页的跳转链接,也就是我们前面URL中的lgedu

NSString *scheme = [URL scheme];
if ([scheme isEqualToString:@"lgedu"])// // 拦截lgedu

c. 然后获取host即主机(这里指方法名),判断方法名是否为jsCallOC,如果是的话就调用
这里简单解释下几个概念:

  • url是地址, 在某个主机目录下的文件的唯一标识符(统一资源定位符url)
  • domain是域名, 比如baidu.com就是域名,而其对应的ip地址指向了百度的服务器
  • host是主机
  • 默认情况http协议是80端口 https协议是443端口
        NSString *host = [URL host];
        // 方法名为jsCallOC
        if ([host isEqualToString:@"jsCallOC"])
        {
              // 解析URL
        }
        else
        {
            NSLog(@"不明方法 %@",host);
        }

d. 有点儿累了,好困啊~,最后我们需要解析一下传入的URL,此处需要写一个方法decoderUrl用来解析,具体实现如下:

// 解析URL地址
- (NSMutableDictionary *)decoderUrl:(NSURL *)URL
{
    // URL = @"lgedu://jsCallOC?username=谢佳培&password=123456"
    // 分开参数
    NSArray *params = [URL.query componentsSeparatedByString:@"&"];
    NSMutableDictionary *tempDic = [NSMutableDictionary dictionary];
    
    for (NSString *param in params)
    {
        // 分开键值对
        NSArray *dictArray = [param componentsSeparatedByString:@"="];
        
        if (dictArray.count > 1)
        {
            // 取值, 中文解码
            NSString *decodeValue = [dictArray[1] stringByRemovingPercentEncoding];
            // 解码后放入tempDic
            [tempDic setObject:decodeValue forKey:dictArray[0]];
        }
    }
    
    return tempDic;
}

简单解释下,该函数首先获取URL.query即请求参数,将其根据&来分开,然后遍历每对参数,再根据=分开键值对

此处存在一个小问题,因为网络请求会拼接中文参数,用户名登陆等很多地方会用到中文,所以需要针对中文进行编码和解码,举两个比较清晰的例子:

编码:
NSString* hStr =@"你好啊";
NSString* hString = [hStr stringByAddingPercentEncodingWithAllowedCharacters:[NSCharacterSet URLQueryAllowedCharacterSet]];
NSLog(@"hString === %@",hString); // hString === %E4%BD%A0%E5%A5%BD%E5%95%8A
解码:
NSString*str3 =@"\u5982\u4f55\u8054\u7cfb\u5ba2\u670d\u4eba\u5458\uff1f";
NSString*str5 = [str3 stringByRemovingPercentEncoding];
NSLog(@"str5 ==== %@",str5);// str5 ==== 如何联系客服人员?

看懂了上面的例子就很容易理解[dictArray[1] stringByRemovingPercentEncoding];这句代码了,就是中文解码后再取值,最后根据key放入tempDic并返回

e. 好,完成了支线任务,回到主线情节,来到我们之前decidePolicyForNavigationAction方法中解析URL的地方,这时候就知道该从何下手了,即取出解析后的值,使用即可(打印出我的大名哈哈😄)

            // 解析URL
            NSMutableDictionary *temDict = [self decoderUrl:URL];
            NSString *username = [temDict objectForKey:@"username"];
            NSString *password = [temDict objectForKey:@"password"];
            // 用户名和密码:谢佳培------123456
            NSLog(@"用户名和密码:%@------%@",username,password);
  1. JS中相关代码如下:
        <input type="button" value="拦截导航" onclick="interceptNavigation()" /> <br />
        function interceptNavigation(){
            alert('点击了拦截按钮,调用loadURL方法获得URL');
            loadURL("lgedu://jsCallOC?username=谢佳培&password=123456");
        }
        function loadURL(url) {
            var iFrame;
            iFrame = document.createElement("iframe");
            iFrame.setAttribute("src", url);
            iFrame.setAttribute("style", "display:none;");
            iFrame.setAttribute("height", "0px");
            iFrame.setAttribute("width", "0px");
            iFrame.setAttribute("frameborder", "0");
            document.body.appendChild(iFrame);
            iFrame.parentNode.removeChild(iFrame);
            iFrame = null;
        }

OC 调用 JS方法

前提:
实现WKNavigationDelegate中的didFinishNavigation方法,其在页面加载完成之后调用,我们在其中定义了一个全局变量arr

- (void)webView:(WKWebView *)webView didFinishNavigation:(WKNavigation *)navigation
{
    // 导航栏标题
    self.title = webView.title;
    // 定义一个全局变量 arr
    NSString *jsStr = @"var arr = [5, '谢佳培', 'xiejiapei']; ";
    [self.wkWebView evaluateJavaScript:jsStr completionHandler:^(id _Nullable result, NSError * _Nullable error) {
        // 页面加载完成之后调用, 定义一个全局变量 arr: (null)----(null)
        NSLog(@"页面加载完成之后调用, 定义一个全局变量 arr: %@----%@",result, error);
    }];
}
  1. 首先我们添加了个导航栏右按钮,点击它来通过OC调用showAlert方法
self.navigationItem.rightBarButtonItem = [[UIBarButtonItem alloc] initWithTitle:@"OC调用showAlert" style:UIBarButtonItemStylePlain target:self action:@selector(didClickRightItemAction)];
  1. 接着实现didClickRightItemAction方法

解释下下面的方法:
a、showAlert中调用了js的alert('我是一个可爱的弹框 \n'+messgae+'\n'+arr[1]);该方法会弹出js的提示框,显示相应信息,其中arr[1]来自在我们在页面加载完成后定义的全局变量arr,最终输出的信息是我是一个可爱的弹框 嗨,这是通过OC调用showAlert方法哦 谢佳培

b、通过evaluateJavaScript来使OC 调用 JS方法,会输出token,解释下这个东东

Token是服务端生成的一串字符串,以作客户端进行请求的一个令牌,当第一次登录后,服务器生成一个Token便将此Token返回给客户端,以后客户端只需带上这个Token前来请求数据即可,无需再次带上用户名和密码。

使用Token的目的:为了减轻服务器的压力,减少频繁的查询数据库,使服务器更加健壮。

- (void)didClickRightItemAction
{
    // JS方法
    NSString *jsStr = [NSString stringWithFormat:@"showAlert('%@')",@"嗨,这是通过OC调用showAlert方法哦"];
    
    // OC 调用 JS方法
    [self.wkWebView evaluateJavaScript:jsStr completionHandler:^(id _Nullable result, NSError * _Nullable error) {
        NSLog(@"OC 调用 JS方法: %@----%@",result, error);
    }];
}
  1. JS中相应代码
        function showAlert(messgae){
            alert('我是一个可爱的弹框 \n'+messgae+'\n'+arr[1]);
            return "token";
        }
  1. 补充一点,因为JS的弹出框的样式之丑陋简直令人发指,所以此处使用OC样式的弹出警告框,覆盖JS的丑陋样式,实现WKUIDelegate中的runJavaScriptAlertPanelWithMessage方法即可
// 警告框
- (void)webView:(WKWebView *)webView runJavaScriptAlertPanelWithMessage:(NSString *)message initiatedByFrame:(WKFrameInfo *)frame completionHandler:(void (^)(void))completionHandler
{
    // 使用OC样式的弹出警告框,覆盖JS的丑陋样式
    UIAlertController *alert = [UIAlertController alertControllerWithTitle:@"提醒" message:message preferredStyle:UIAlertControllerStyleAlert];
    UIAlertAction *knowAction = [UIAlertAction actionWithTitle:@"知道了" style:UIAlertActionStyleDefault handler:^(UIAlertAction * _Nonnull action) {
        // 回调完成
        completionHandler();
    }];
    [alert addAction:knowAction];
    [self presentViewController:alert animated:YES completion:nil];
}

WKScriptMessageHandler

  1. 加了一个新的委托WKScriptMessageHandler
<WKNavigationDelegate, WKUIDelegate, WKScriptMessageHandler>
  1. 敲黑板!!!特别重要的关键点来了!

a、html5中需要添加window.webkit.messageHandlers.<name>.postMessage(<messageBody>)方法,来实现JS与OC之间的桥梁,此处向JS发送消息,messgaeOC为用于JS和OC沟通的消息名称,消息内容在JS中

b、但是postMessage方法有个问题,即循环引用:self - webView - configuration - userContentController - self,所以需要我们根据name移除所注入的scriptMessageHandler来打破循环引用, 否则不会调用到dealloc方法

// 循环引用:self - webView - configuration - userContentController - self
- (void)viewWillAppear:(BOOL)animated
{
    [super viewWillAppear:animated];
    
    // html5中需要添加window.webkit.messageHandlers.<name>.postMessage(<messageBody>)方法,来实现js与oc之间的桥梁
    [self.wkWebView.configuration.userContentController addScriptMessageHandler:self name:@"messgaeOC"];
}

// 需要根据name移除所注入的scriptMessageHandler来打破循环引用, 否则不会调用到dealloc方法
- (void)viewWillDisappear:(BOOL)animated{
    [super viewWillDisappear:animated];
    
    [self.wkWebView.configuration.userContentController removeScriptMessageHandlerForName:@"messgaeOC"];
}

- (void)dealloc
{
    NSLog(@"dealloc:溜了溜了");
}
  1. 最后一步,实现WKScriptMessageHandler中的didReceiveScriptMessage方法接收JS中的消息
// WKWebView收到ScriptMessage时回调此方法
- (void)userContentController:(WKUserContentController *)userContentController didReceiveScriptMessage:(WKScriptMessage *)message
{
    if (message.name)
    {
        // OC 层面的消息
    }
    // message == messgaeOC --- 青春气息
    NSLog(@"消息是:%@ --- %@",message.name,message.body);
}
  1. JS中相关代码如下
        <input type="button" value="messgaeHandle" onclick="messgaeHandle()" /><br />
        function messgaeHandle(){
            alert('给messageHandlers页面发送了“青春气息”');
            window.webkit.messageHandlers.messgaeOC.postMessage("青春气息");
        }

WKNavigationDelegate汇总

啊嗷嗷,总算结束了!写文章真是件累人的活啊,特别是技术博客

 \\ 请求之前,决定是否要跳转:用户点击网页上的链接,需要打开新页面时,将先调用这个方法
 - (void)webView:(WKWebView *)webView decidePolicyForNavigationAction:(WKNavigationAction *)navigationAction decisionHandler:(void (^)(WKNavigationActionPolicy))decisionHandler;

  \\ 接收到相应数据后,决定是否跳转
 - (void)webView:(WKWebView *)webView decidePolicyForNavigationResponse:(WKNavigationResponse *)navigationResponse decisionHandler:(void (^)(WKNavigationResponsePolicy))decisionHandler;

 \\  页面开始加载时调用
 - (void)webView:(WKWebView *)webView didStartProvisionalNavigation:(null_unspecified WKNavigation *)navigation;

  \\ 主机地址被重定向时调用
 - (void)webView:(WKWebView *)webView didReceiveServerRedirectForProvisionalNavigation:(null_unspecified WKNavigation *)navigation;

  \\ 页面加载失败时调用
 - (void)webView:(WKWebView *)webView didFailProvisionalNavigation:(null_unspecified

  \\ 当内容开始返回时调用
 - (void)webView:(WKWebView *)webView didCommitNavigation:(null_unspecified WKNavigation*)navigation;

  \\ 页面加载完毕时调用
 - (void)webView:(WKWebView *)webView didFinishNavigation:(null_unspecified WKNavigation *)navigation;

  \\ 跳转失败时调用
 - (void)webView:(WKWebView *)webView didFailNavigation:(null_unspecified WKNavigation *)navigation withError:(NSError *)error;

 \\  如果需要证书验证,与使用AFN进行HTTPS证书验证是一样的
 - (void)webView:(WKWebView *)webView didReceiveAuthenticationChallenge:(NSURLAuthenticationChallenge *)challenge completionHandler:(void (^)(NSURLSessionAuthChallengeDisposition disposition, NSURLCredential *__nullable credential))completionHandler;

 \\  web内容处理中断时会触发
 - (void)webViewWebContentProcessDidTerminate:(WKWebView *)webView NS_AVAILABLE(10_11, 9_0);

WKWebView问题汇总

这些问题我没有遇到过哈,只是先提前罗列下,以后遇到了再回过头细细品味:

白屏问题
WKWebView加载的网页占用内存过大时,会出现白屏现象。解决方案是:

- (void)webViewWebContentProcessDidTerminate:(WKWebView *)webView {
    [webView reload];   //刷新就好了
}

有时白屏,不会调用该方法,具体的解决方案是:

比如,最近遇到在一个高内存消耗的H5页面上 present 系统相机,拍照完毕后返回原来页面的时候出现白屏现象(拍照过程消耗了大量内存,导致内存紧张,WebContent Process 被系统挂起),但上面的回调函数并没有被调用。在WKWebView白屏的时候,另一种现象是 webView.titile会被置空, 因此,可以在 viewWillAppear的时候检测webView.title是否为空来reload页面。(出自WKWebView 那些坑

evaluateJavaScript:completionHandler: 只支持异步问题
该方法是异步回调,这个一看方法的声明便知。可能有小伙伴就是需要同步获取返回值,有没有办法呢?答案是没有。可能你会说用信号量dispatch_semaphore_t。好吧,可能你会这么写~

__block id cookies;
dispatch_semaphore_t semaphore = dispatch_semaphore_create(0);
[self.webView evaluateJavaScript:@"document.cookie" completionHandler:^(id _Nullable result, NSError * _Nullable error) {
    cookies = result;
    dispatch_semaphore_signal(semaphore);
}];
//等待三秒,接收参数
dispatch_semaphore_wait(semaphore, dispatch_time(DISPATCH_TIME_NOW, 3 * NSEC_PER_SEC));
//打印cookie,肯定为空,因为足足等了3s,dispatch_semaphore_signal都没有起作用
NSLog(@"cookie的值为:%@", cookies);

笔者故意只等待了3s,如果你等待DISPATCH_TIME_FOREVER,恭喜你,程序不会Crash,但界面卡死了。笔者测试的结果是,NSLog的触发时间要早于completionHandler回调,不论你等多久,它都会打印null。所以当你永久等待时,就卡死了。这里的缘由,笔者不太清楚,有搞清楚的小伙伴可以帮忙指点一下,谢谢~所以还是老实的接受异步回调吧,不要用信号来搞成同步,会卡死的。

自定义contentInset刷新时页面跳动的bug
这么写就OK了,通过KVC设置私有变量的值,笔者用了半年了,过Apple审核没问题,不用担心。

self.webView.scrollView.contentInset = UIEdgeInsetsMake(64, 0, 49, 0);
//史诗级神坑,为何如此写呢?参考https://opensource.apple.com/source/WebKit2/WebKit2-7600.1.4.11.10/ChangeLog  
[self.webView setValue:[NSValue valueWithUIEdgeInsets:self.webView.scrollView.contentInset] forKey:@"_obscuredInsets"]; //kvc给WKWebView的私有变量_obscuredInsets设置值

NSURLProtocol问题
WKWebView不同于UIWebView,其实并不支持NSURLProtocol。如果想拦截,可以通过调用私有Api

+ [WKBrowsingContextController registerSchemeForCustomProtocol:]

此方法缺点也很多,笔者这里不推荐大家使用,毕竟调用私有ApiApple禁止的。况且,真的必须使用NSURLProtocol的话,还是用UIWebView吧。

WKScriptMessageHandler问题
1、该方法还是没有办法直接获取返回值。
2、通过window.webkit.messageHandlers.<name>.postMessage(<messageBody>)传递的messageBody中不能包含js的function,如果包含了function,那么 OC端将不会收到回调。

对于问题1,我们可以采用异步回调的方式,将返回值返回给js
对于问题2,一般js的参数中包含function是为了异步回调,这里我们可以把jsfunction转换为字符串,再传递给OC。

比如js端实现了如下的方法:

  /**
   * 分享方法,并且会异步回调分享结果
   * @param  {对象类型} shareData 一个分享数据的对象,包含title,imgUrl,link以及一个回调function
   * @return {void}  无同步返回值
   */
  function shareNew(shareData) {
    
    //这是该方法的默认实现,上篇文章中有所提及
    var title = shareData.title;
    var imgUrl = shareData.imgUrl;
    var link = shareData.link;
    var result = shareData.result;
    //do something
    //这里模拟异步操作
    setTimeout(function() {
        //2s之后,回调true分享成功
        result(true);
    }, 2000);

    //用于WKWebView,因为WKWebView并没有办法把js function传递过去,因此需要特殊处理一下
    //把js function转换为字符串,oc端调用时 (<js function string>)(true); 即可
    shareData.result = result.toString();
    window.webkit.messageHandlers.shareNew.postMessage(shareData);
  }

  function test() {
     //清空分享结果
    shareResult.innerHTML = "";
    
    //调用时,应该
    shareNew({
        title: "title",
        imgUrl: "http://img.dd.com/xxx.png",
        link: location.href,
        result: function(res) {
            //这里shareResult 等同于 document.getElementById("shareResult")
            shareResult.innerHTML = res ? "success" : "failure";
        }
    });
  }

html页面中我定义了一个a标签来触发test()函数:

<a href="javascript:void(0);" onclick="test()">测试新分享</a>

在OC端,实现如下:

//首先别忘了,在configuration中的userContentController中添加scriptMessageHandler
[controller addScriptMessageHandler:self name:@"shareNew"]; //记得适当时候remove哦


//点击a标签时,则会调用下面的方法
#pragma mark - WKScriptMessageHandler 

- (void)userContentController:(WKUserContentController *)userContentController didReceiveScriptMessage:(WKScriptMessage *)message {
    if ([message.name isEqualToString:@"shareNew"]) {
        NSDictionary *shareData = message.body;
        NSLog(@"shareNew分享的数据为: %@", shareData);
        //模拟异步回调
        dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(4 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
            //读取js function的字符串
            NSString *jsFunctionString = shareData[@"result"];
            //拼接调用该方法的js字符串
            NSString *callbackJs = [NSString stringWithFormat:@"(%@)(%d);", jsFunctionString, NO];    //后面的参数NO为模拟分享失败
            //执行回调
            [self.webView evaluateJavaScript:callbackJs completionHandler:^(id _Nullable result, NSError * _Nullable error) {
                if (!error) {
                    NSLog(@"模拟回调,分享失败");
                }
            }];
        });
    }
}

那么当我点击a标签时,html页面上过2s,会显示success,然后再过2s,会显示failure

我们来简单分析一下,点击之后,触发了test()函数,test()中封装了对share()函数的调用,且传了一个对象作为参数,对象中result字段对应的是个匿名函数,紧接着share()函数调用,其中的实现是2s过后,result(true);模拟js异步实现异步回调结果,分享成功。同时share()函数中,因为通过scriptMessageHandler无法传递function,所以先把shareData对象中的result这个匿名function转成String,然后替换shareData对象的result属性为这个String,并回传给OC,OC这边对应JS对象的数据类型是NSDictionary,我们打印并得到了所有参数,同时,把result字段对应的js function String取出来。这里我们延迟4s回调,模拟Native分享的异步过程,在4s后,也就是js中显示success的2s过后,调用js的匿名function,并传递参数(分享结果)。调用一个js function的方法是 functionName(argument); ,这里由于这个js的function已经是一个String了,所以我们调用时,需要加上(),如(functionString)(argument);因此,最终我们通过OC -> JS 的evaluateJavaScript:completionHandler:方法,成功完成了异步回调,并传递给js一个分享失败的结果。

上面的描述看起来很复杂,其实就是先执行了JS的默认实现,后执行了OC的实现。上面的代码展示了如何解决scriptMessageHandler的两个问题,并且实现了一个 JS -> OC、OC -> JS 完整的交互流程。

JavascriptBridge的使用

控制台输出.png

运行过程.gif

懒总是第一创新力,是不是觉得之前的三种方式操作起来都稍微有点麻烦?于是乎,我们在这里介绍一个框架JavascriptBridge,它的强大之处就在于底部实现了navgationDelegate中的拦截机制,而且支持WKWebView & UIWebView,我们只需要处理自己的业务逻辑就行了,先简单介绍下:(官方语言总是绕口令又那么死板僵硬,大学的离散数学把我都给绕晕了唉)

JavaScript 是运行在一个单独的 JS Context 中(如WebViewWebkit引擎、IOSJavaScriptCore),这些 Context 与原生运行环境的天然隔离。WebViewJavaScriptBridge实现NativeJS通信的基本原理是:

把 OC 的方法注册到桥梁中,让 JS 去调用。
把 JS 的方法注册在桥梁中,让 OC 去调用。

下文以WKWebView为例:

基本配置

  1. 导入框架<WebKit/WebKit.h><WebViewJavascriptBridge.h>
  2. 在扩展中支持委托WKUIDelegate,注意不用支持WKNavigationDelegate哦,声明WKWebViewWebViewJavascriptBridge
@interface JavascriptBridgeViewController ()<WKUIDelegate>

@property (strong, nonatomic) WKWebView *wkWebView;
@property (strong, nonatomic) WebViewJavascriptBridge *webViewJavascriptBridge;

@end
  1. 初始化 webView,调用loadRequest方法加载html的URL,和之前一样就不解释了,不懂的可以看 WKWebView部分
    WKWebViewConfiguration *configuration = [[WKWebViewConfiguration alloc] init];
    configuration.userContentController = [self wkwebViewScalPreferences];
    
    self.wkWebView = [[WKWebView alloc] initWithFrame:self.view.frame configuration:configuration];
    self.wkWebView.UIDelegate = self;
    [self.view addSubview:self.wkWebView];
    
    NSString *urlStr = [[NSBundle mainBundle] pathForResource:@"index.html" ofType:nil];
    NSURL *fileURL = [NSURL fileURLWithPath:urlStr];
    [self.wkWebView loadFileURL:fileURL allowingReadAccessToURL:fileURL];
  1. 接下来主角帅气登场,传入wkWebView来初始化webViewJavascriptBridge
    // 传入wkWebView来初始化webViewJavascriptBridge,框架会自动判断传入的是WKWebView还是UIWebView作出处理
    self.webViewJavascriptBridge = [WebViewJavascriptBridge bridgeForWebView:self.wkWebView];

这里稍微解释下,关于传入后框架会自动判断传入的是WKWebView还是UIWebView作出处理
框架中相应源代码如下:

//  外部调用的方法
+ (instancetype)bridgeForWebView:(id)webView {
    return [self bridge:webView];
}
// 内部实际实现的方法
+ (instancetype)bridge:(id)webView {
#if defined supportsWKWebView
    // 判断是否是WKWebView
    if ([webView isKindOfClass:[WKWebView class]]) {
        return (WebViewJavascriptBridge*) [WKWebViewJavascriptBridge bridgeForWebView:webView];
    }
#endif
    // 判断是否是UIWebView
    // #define WVJB_WEBVIEW_TYPE UIWebView
    if ([webView isKindOfClass:[WVJB_WEBVIEW_TYPE class]]) {
        WebViewJavascriptBridge* bridge = [[self alloc] init];
        [bridge _platformSpecificSetup:webView];
        return bridge;
    }

JS调用OC中的方法

  1. 在之前的WKWebView中此处是通过NavgationDelegate中的拦截机制实现的,而在这里我们只需要注册想要调用的方法名,然后在handler中实现该方法即可,框架内部帮我们实现了该代理并且会自动切换到主线程让我们拿到数据后更新UI,其中data是JS回传给我们的该方法会用到的实际数据,其实就是入参,responseCallback是调用完OC之后的回调
    // JS调用OC中的方法
    [self.webViewJavascriptBridge registerHandler:@"jsCallsOC" handler:^(id data, WVJBResponseCallback responseCallback) {
        // 自动切换到主线程让我们拿到数据后更新UI
        NSLog(@"currentThread == %@",[NSThread currentThread]);
        
        // `data`是JS回传给我们的该方法会用到的实际数据
        //  responseCallback在JS中对应的是alert(response);
        NSLog(@"data == %@ -- %@",data,responseCallback);
    }];

此处需要注意两点:
a、封装SDK时候的思想:封装SDK不要让外界出现安全隐患,最好回调信息后便将其置于主线程,这样虽然慢点,但是安全,不会让使用者认为该框架有安全隐患,因为如果还处于子线程,外界不小心用拿到的数据更新了UI出错了就是你框架背锅。

b、让我们做个好奇宝宝探究下该框架内部是如何实现拦截的,浅尝辄止不深究

首先也是通过实现NavgationDelegate中的拦截方法shouldStartLoadWithRequest来实现的

// 也实现了NavgationDelegate中的拦截方法shouldStartLoadWithRequest
- (BOOL)webView:(UIWebView *)webView shouldStartLoadWithRequest:(NSURLRequest *)request navigationType:(UIWebViewNavigationType)navigationType {
    if (webView != _webView) { return YES; }
    NSURL *url = [request URL];
    // 该方法拦截了URL
    if ([_base isWebViewJavascriptBridgeURL:url]) {

再进入isWebViewJavascriptBridgeURL一探究竟

- (BOOL)isBridgeLoadedURL:(NSURL*)url {
    // 也是判断host和Scheme
    NSString* host = url.host.lowercaseString;
    return [self isSchemeMatch:url] && [host isEqualToString:kBridgeLoaded];
}

最后看看isSchemeMatch是怎么判断Scheme

- (BOOL)isSchemeMatch:(NSURL*)url {
    NSString* scheme = url.scheme.lowercaseString;
    return [scheme isEqualToString:kNewProtocolScheme] || [scheme isEqualToString:kOldProtocolScheme];
}

其中kNewProtocolSchemekOldProtocolScheme如下:

#define kOldProtocolScheme @"wvjbscheme"
#define kNewProtocolScheme @"https"

很多人就纳闷了,漏了一个很关键的地方,我们的URL在哪呢,之前都在JS文件里添加的,现在没影了?不知道URL,我怎么比对SchemeHost?所以该框架在JS中需要实现该方法

        // 创建一个不可见的iframe来加载初始化链接URL(https://_bridge_loaded_)
        function setupWebViewJavascriptBridge(callback) {
            if (window.WebViewJavascriptBridge) { return callback(WebViewJavascriptBridge); }
            if (window.WVJBCallbacks) { return window.WVJBCallbacks.push(callback); }
            window.WVJBCallbacks = [callback];// 创建一个 WVJBCallbacks 全局属性数组,并将 callback 插入到数组中。
            var WVJBIframe = document.createElement('iframe');// 创建一个 iframe 元素
            // display = 'none'创建一个不可见的iframe
            WVJBIframe.style.display = 'none';// 不显示
            WVJBIframe.src = 'wvjbscheme://__BRIDGE_LOADED__';//设置 iframe 的 src 属性
            document.documentElement.appendChild(WVJBIframe);/ 把 iframe 添加到当前文导航上
            setTimeout(function() { document.documentElement.removeChild(WVJBIframe) }, 0)
        }

看着很复杂对吧,对你来说只需要复制粘贴即可哈哈,如果想吃透框架原理的可以研究下,大致就是创建一个不可见的iframe来加载初始化链接URL(https://_bridge_loaded_),贴张流程图:

WebViewJavascriptBridge流程图.jpg

我真是太尽职尽责了,为自己感动到哭泣!
总的来说,还是和我们之前WKWebView那做的操作一样,只是多封装了几层,考虑了更多情况更完善罢了

  1. 支线任务做完了,回到主角的剧情路线,看看JS中的相应代码部分

通过callHandler调用OC中jsCallsOC方法,并传入参数

         <input type="button" value="JS调用OC中方法" onclick="click()" /> <br />
         // JS调用OC
         function click(){
             // function(response)是个返回信息
             WebViewJavascriptBridge.callHandler('jsCallsOC', {'谢佳培': '22'}, function(response) {
                  alert(response);
              })
         }

OC 调用 JS中的方法

分三种情况,对应使用即可

    // 如果不需要参数,不需要回调,使用这个
    [self.webViewJavascriptBridge callHandler:@"OCCallJSFunction"];
    // 如果需要参数,不需要回调,使用这个
    [self.webViewJavascriptBridge callHandler:@"OCCallJSFunction" data:@"今天天气要下雨了吧,我看外面在打雷"];
    // 如果既需要参数,又需要回调,使用这个
    [self.webViewJavascriptBridge callHandler:@"OCCallJSFunction" data:@"今天天气要下雨了吧,我看外面在打雷" responseCallback:^(id responseData) {
        NSLog(@"currentThread == %@",[NSThread currentThread]);
        NSLog(@"调用完JS后的回调:%@",responseData);
    }];

相应JS中的Code如下:

        // OC调用JS
        setupWebViewJavascriptBridge(function(bridge) {
         // ‘OCCallJSFunction’作为标识符,找到在JS中想要调用的方法
            bridge.registerHandler('OCCallJSFunction', function(data, responseCallback) {
                alert('JS方法被调用:'+data);
                responseCallback('js执行过了');
            })
         })

WKUIDelegate

完美,最后再来点扩展,既然NavgationDelegate被框架自动实现了,那么WKUIDelegate也就是说我们还是可以自己实现的啰,所以如果你要在VC中实现 UIWebView的代理方法 就实现下面的代码(否则省略)

[self.webViewJavascriptBridge setWebViewDelegate:self];

此处我们用它来实现了弹出框的OC式

- (void)webView:(WKWebView *)webView runJavaScriptAlertPanelWithMessage:(NSString *)message initiatedByFrame:(WKFrameInfo *)frame completionHandler:(void (^)(void))completionHandler
{
    
    UIAlertController *alert = [UIAlertController alertControllerWithTitle:@"提醒" message:message preferredStyle:UIAlertControllerStyleAlert];
    [alert addAction:[UIAlertAction actionWithTitle:@"知道了" style:UIAlertActionStyleCancel handler:^(UIAlertAction * _Nonnull action) {
        completionHandler();
    }]];
    
    [self presentViewController:alert animated:YES completion:nil];
}

Demo

Demo在我的Github上,欢迎下载。
IOSAdvancedDemo

推荐阅读的相关Demo
富文本ZSSRichTextEditor
iOS中UIWebView与WKWebView、JavaScript与OC交互、Cookie管理看我就够

参考文献

iOS中UIWebView与WKWebView、JavaScript与OC交互、Cookie管理看我就够

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
禁止转载,如需转载请通过简信或评论联系作者。
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,222评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,455评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,720评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,568评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,696评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,879评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,028评论 3 409
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,773评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,220评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,550评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,697评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,360评论 4 332
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,002评论 3 315
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,782评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,010评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,433评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,587评论 2 350

推荐阅读更多精彩内容