解耦---Hybrid H5跨平台性思考

徐嘉伟 解耦---Hybrid H5跨平台性思考

跨平台,是H5最重要的能力之一。而 Hybrid H5 因强依赖于具体 app,往往不具有跨平台性。这时,将强依赖关系解耦,即可恢复 H5 的跨平台能力。近期本人负责 手Q 红包打赏项目的前端开发,因项目涉及到多 app 跨平台兼容,对 hybrid H5 的跨平台性有了一定的感悟和思考。在这里做下总结分享,希望能对大家有所收获。

Hybrid H5 跨平台性

进入正题之前,先解释下本文主题的两个名词:
①Hybrid H5,即混合了原生能力的 H5。区别于纯粹 web 端的 H5,它可调用原生的能力,强依赖于具体原生 app,与原生共同构建整个 app 的 UI 层,是 app UI 层很好的灵活性补充。微信和 手Q 上的 H5 业务一般都属于 Hybrid H5 的范畴。
② 跨平台性,即一个 H5 页面可同时运行在多个平台上。可运行平台越多,跨平台性就越强。在如今移动互联网的发展大潮中,H5 能与体验更优的原生终端齐步并进,其跨平台性可谓功不可没。
因强依赖于具体 app,Hybrid H5 往往不具有跨平台性。
本文将从 Hybrid H5 与原生的通讯原理出发,逐步探讨如何通过解耦来恢复 Hybrid H5 的跨平台性。

Hybrid H5 与原生的通讯原理

原理图

从原理图中,有4个关键点:
1个通讯媒介——原生自定义的通讯协议;
以及围绕着通讯媒介执行的3个通讯行为——触发、调用、回调。
关键点详解
①通讯媒介——原生通讯协议:原生自定义的伪协议,一般会定义成与 http 协议类似的格式:
协议名://接口路径?参数1=XXX&参数2=XXX&参数3=XXX#callback
其中:
a、协议名:app 自定义的协议名,用于H5触发行为的监控捕获,如 手Q 使用的 jsbridge://;

b、接口路径:原生具体能力路径,不同原生能力路径不同;

c、参数1=XXX&参数2=XXX&参数3=XXX#callback:H5传参与回调方法标识;
根据通讯协议规范,即可针对不同的原生能力给H5提供不同的调用地址,如:

jsbridge://method?a=2&b=3#h5MethodTag

②通讯行为——触发:能被原生监听并捕获截拦的H5行为,都可以作为原生通讯协议的触发行为。
Hybrid H5 的这类行为有 console.log、alert、confirm、prompt、location.href 等。将原生协议内容通过其中的某一行为触发,即可被原生正确捕获并解析。如:

location.href ='jsbridge://method?a=2&b=3#h5MethodTag'

H5调用后,原生终端会捕获到内容:jsbridge://method?a=2&b=3#h5MethodTag
③通讯行为——调用:原生终端根据 H5 传过来的内容,解析匹配后会路由到具体处理方法,执行原生能力逻辑。以 ios 为例(swift 语言),“调用”逻辑如下:

func webView(webView: UIWebView, shouldStartLoadWithRequest request: NSURLRequest, navigationType: UIWebViewNavigationType) - Bool {
    let url = request.URL //url
    let scheme = url ? .scheme //协议名
    let method = url ? .host //接口路径
    let query = url ? .query //参数

    if url != nil && scheme == "jsbridge" {
        /*根据method路由*/
        switch method!{
            case "method":
                    self.method()
            case "openTenpayView":
                    self.openTenpayView()
            ...其他方法...
            default:
        }
        return false
    } else {
        return true
    }
}

原生终端根据捕获到的协议内容,进行解析获取,若伪协议为原生指定的伪协议(“jsbridge”),就会根据 method 内容和 query 参数进行路由操作,寻找具体的方法执行逻辑。否则,忽略处理,按照 webview 原有跳转逻辑处理。以第②步触发的伪协议内容为例,在本例“调用”代码中被原生捕获后,会路由执行逻辑:self.method();
④通讯行为——回调:原生根据 H5 传过来的内容,捕获 js 回调函数方法名,在原生逻辑执行结束后,将执行结果带到回调函数中并执行 js 回调函数。通过在第③步“调用”执行完后,ios 会调用 js 回调函数 H5MethodTag:

/*解析到H5的回调函数名为H5MethodTag(#号后内容),回调执行js的方法*/
webview.stringByEvaluatingJavaScriptFromString("H5MethodTag(data)") 

通过以上4个关键点,即可做到 H5 与原生终端的相互通讯,完成H5对原生能力的调用。

初次解耦:app 内跨平台——创建jsapi解耦通讯逻辑、封装平台差异

由上述通讯原理了解到,Hybrid H5 直接调用定义好的原生通讯协议,即可完成通讯全过程。但这里有一个明显的问题,即 Hybrid H5 会强耦合于当前平台。不说跨 app 了,app 内跨平台(android/ios/wp)都会显示吃力。这里面有很多原因,其中一个较明显的原因在于,不同平台 app 开发团队通讯协议规范定义存在不一致。再者,H5 业务代码上满满的类似 jsonp 的协议调用,也并不好维护。
要达到 Hybrid H5 在 app 内跨平台,业界常见做法是 app 对外提供 jsapi。通过 jsapi 将各平台协议规范差异进行封装,解耦通讯逻辑,并以函数接口的方式提供给 Hybrid H5 调用。jsapi 接口一般会定义成如下格式:

ns.method({
    /*cfg参数对象*/
}, function(data) {
    /*回调*/
})

原理图

原理核心:H5 与原生通讯之间增加一层 jsapi,jsapi 完成三大行为:api 接口创建、协议 url 组装、创建 iframe 发起伪协议请求;
因 手Q 的 jsapi 相对比较成熟,下面会以 手Q jsapi 中的核心源码进行分析。
①api接口创建:js 函数接口封装、平台差异处理,方便H5函数调用

mqq.build('mqq.tenpay.openTenpayView', {
    iOS: function(options, callback) {
        var callbackName = callback ? mqq.callback(callback) : null;
        mqq.invokeClient('pay', 'openTenpayView', {
            'params': options,
            'callback': callbackName
        });
    },
    android: function(params, callback) {
        mqq.invokeClient('pay', 'openTenpayView', JSON.stringify(params), callback);
    },
    supportInvoke: true,
    support: {
        iOS: '4.6.1',
        android: '4.6.1'
    }
});
mqq.build 方法为 api 接口创建方法。通过传入待创建的 jsapi 方法名(mqq.tenpay.openTenpayView)和不同平台(android/ios)的差异处理配置。最终会生成H5可调用方法:
mqq.tenpay.openTenpayView({
    /*data*/
},function(ret){
    /*callback*/
})

②协议url组装:从接口到 url 协议的转换、回调处理,完成协议 url 创建
第①步中,不同平台差异处理都会调用 mqq.invokeClient 方法,该方法实际处理的就是原理图中与原生通讯的过程。我们先来看协议 url 组装的过程。

/*生成回调索引*/
sn = storeCallback(callback);
/*协议路径组装*/
url = 'jsbridge://' + encodeURIComponent(ns) + '/' + encodeURIComponent(method);
/*参数组装*/
argus.forEach(function(a, i) {
    if (exports.isObject(a)) {
    a = JSON.stringify(a);
    }
    if (i === 0) {
        url += '?p=';
    } else {
        url += '&p' + i + '=';
    }
    url += encodeURIComponent(String(a));
});
/*回调函数索引组装*/
url += '#' + sn;
/*链接调用*/
result = openURL(url, ns, method);

协议 url 组装的过程实际上是对传入参数按协议规范进行拼串的过程,其中包括匿名回调函数的回调索引创建、协议名&协议路径拼串、传参循环遍历拼串。
③创建 iframe 发起伪协议请求:请求触发

/*创建隐藏iframe*/
var iframe = document.createElement('iframe');
iframe.style.cssText = 'display:none;width:0px;height:0px;';

function failCallback() {
    /*错误处理*/
}
/*iframe协议调用*/
iframe.onload = failCallback;
iframe.src = url;
(document.body || document.documentElement).appendChild(iframe);

/*删除iframe*/
setTimeout(function() {
    iframe && iframe.parentNode && iframe.parentNode.removeChild(iframe);
}, 0);

通过创建 iframe 来完成协议调用,并在调用结束后将 iframe 删除,即可在不影响原 H5 流程的情况下完成调用全过程。

再次解耦:app 间跨平台—— jsapi 细化,封装 app 差异

通过上述的解耦处理,Hybrid H5 已经可以在 app 内各平台运行了。但往往这种 jsapi 是 app 级提供的 jsapi(下面简称 app jsapi),app  jsapi 并不会去兼容别的 app 的差异。而实际情况具体到某一 Hybrid H5,尤其是与 app 外部合作的 Hybrid H5,则并不仅仅只运行在一个 app上。比如信用卡还款业务,微信有,手Q 也有,功能都一样。这种情况就需要进一步的解耦,从业务侧再抽离一层 jsapi(下面简称 H5 jsapi)来处理 app 间的差异,而非每个 app 各自一套 H5。
原理图

原理核心:Hybrid H5 业务上增加多一层自维护的 H5 jsapi,H5 jsapi 完成两大行为:app jsapi 差异请求、app jsapi 差异封装。
①app jsapi 差异请求:根据当前运行环境 app 请求具体的 app jsapi
下面以 Hybrid H5 需同时运行在手Q和空间独立版的 app jsapi 差异请求处理逻辑。

<script type="text/javascript" >
    (function() {
        var ua = navigator.userAgent || "",
            isQQ = ua.match(/QQ\/([\d\.]+)/),
            isQzone = ua.match("Qzone");
        if (isQQ) {
            document.write("<script src='https://open.mobile.qq.com/sdk/qqapi.js?_bid=152'><\x2Fscript>");
        } else if (isQzone) {
            document.write("<script src='https://qzonestyle.gtimg.cn/qzone/phone/m/v4/widget/mobile/jsbridge.js'><\x2Fscript>");
        } else {
            // 不是已兼容app,跳转到兼容app上运行
            var currentHref = window.location.href;
            /*跳转到手Q打开本页面*/
            window.location.href = 'mqqapi://forward/url?url_prefix=' + btoa(currentHref) + '&version=1&src_type=web';
            /*该页面支持自定义弹层*/
            setTimeout(function() {
                var _tempBox = confirm('请在手机QQ中使用~');
                if (_tempBox == true) {
                    /*跳至手Q打开*/
                    window.location.href = 'mqqapi://forward/url?url_prefix=' + btoa(currentHref) + '&version=1&src_type=web';
                }
            }, 0)
        }
    })()
</script>

除了对需兼容的 app 进行差异请求外,还应对在不兼容的 app 运行时做跳转到主兼容 app 打开当前页面的逻辑处理,并做引导性提示,保障页面的完整可用性。
②app jsapi 差异封装:根据当前具体运行的平台调用相应的 app jsapi 接口。H5 jsapi 的接口形式尽量与主运行 app 的 jsapi 保持一致
下面以打开 QQ 钱包原生页和原生页面跳转能力为例,做 app jsapi 的差异封装。

var mod = {
    ...
    openTenpayView: function(param, callback) {
        if (isQQ) {
            var param = $.extend({
                userId: $.getCookie('uin').replace(/^o0*/, '')
            }, param);
            mqq.tenpay.openTenpayView(param, callback);
        } else {
               /*调起手Q打开中转页jump.html,由中转页打开原生功能页*/
            var targetHref = 'http://testhost.com/jump.html?go=' + param.viewTag + '&_wv=' + (1 + 2 + 1024 + 2097152 + 33554432); //跳转url
            /*跳到手Q*/
            window.location.href = 'mqqapi://forward/url?url_prefix=' + btoa(targetHref) + '&version=1&src_type=web';
        }
    },
    openUrl: function(paramObj) {
        if (isQQ) {
            mqq.ui.openUrl({
                url: paramObj.url,
                target: 1
            });
        } else if (isQzone) {
            mqq.invoke("ui", "openUrl", {
                url: paramObj.url,
                target: 1,
                style: 1
            });
        } else {
            /*兼容处理*/
            location.href = paramObj.url
        }
    },
    ...其他接口...
};
return mod;

调用 openTenpayView,页面能在 手Q 中正常调用,而在非 手Q 时则跳转回 手Q 打开处理;
调用 openUrl,对于 手Q 和空间独立版做相应的接口调用,而其他平台则直接使用 H5 的 location 跳转。

总结

H5 本质是具有跨平台性的。Hybrid H5 因混合了原生能力,强耦合于原生,不再具有跨平台性。要恢复其跨平台能力,关键在解耦,将其耦合于原生的部分解耦封装起来。
解耦是开发很重要的一项能力,Hybrid H5 跨平台性的回归正得益于解耦的处理。
因耦合而导致某项能力减弱的情况还有很多,比如 H5 的灵活性等等。遇到这种情况大家不妨也试试解耦,或许会收到意想不到的惊喜。

2016/7/14 posted in  HTML

iOS中的HTML交互简说

跟原生开发相比,H5的开发相对来一个成熟的框架和团队来讲在开发速度和开发效率上有着比原生很大的优势,至少不用等待审核。那么问题来了,H5与本地原生代码势必要有交互的,比如本地上传一些信息,H5打开本地的页面,打开本地进行微信等第三方分享等,今天就简单讲一下iOS中本地UIWebView,WKWebView与H5的交互。

DEMO地址:点击下载

UIWebView的交互

stringByEvaluatingJavaScriptFromString的使用

UIWebView在2.0时代就有的类,一直到现在(目前9.x)都可以使用的WEB容器,它的方法很简单,在iOS7.0之前JS交互的方法只有一个stringByEvaluatingJavaScriptFromString:

- (nullable NSString *)stringByEvaluatingJavaScriptFromString:(NSString *)script;

使用stringByEvaluatingJavaScriptFromString方法,需要等UIWebView中的页面加载完成之后去调用.

以下是简单的使用场景:

1、获取当前页面的url。

- (void)webViewDidFinishLoad:(UIWebView *)webView {      
    NSString *currentURL = [webView stringByEvaluatingJavaScriptFromString:@"document.location.href"];  
} 

2、获取页面title:

- (void)webViewDidFinishLoad:(UIWebView *)webView {       
        NSString *currentURL = [webView stringByEvaluatingJavaScriptFromString:@"document.location.href"];     
NSString *title = [webview stringByEvaluatingJavaScriptFromString:@"document.title"];  
} 

3、修改界面元素的值。

NSString *js_result = [webView stringByEvaluatingJavaScriptFromString:@"document.getElementsByName('q')[0].value='iOS';"]; 

4、表单提交:

NSString *js_result2 = [webView stringByEvaluatingJavaScriptFromString:@"document.forms[0].submit(); "]; 

这样就实现了在google搜索关键字:“iOS”的功能。
5、插入js代码
上面的功能我们可以封装到一个js函数中,将这个函数插入到页面上执行,代码如下:

    "script.type = 'text/javascript';"            
    "script.text = \"function myFunction() { "            
    "var field = document.getElementsByName('q')[0];"             
    "field.value='iOS';"            
    "document.forms[0].submit();"   
    "}\";"     
    "document.getElementsByTagName('head')[0].appendChild(script);"];    
    
    [webView stringByEvaluatingJavaScriptFromString:@"myFunction();"];   

看上面的代码:

a、首先通过js创建一个script的标签,type为'text/javascript'。
b、然后在这个标签中插入一段字符串,这段字符串就是一个函数:myFunction,这个函数实现google自动搜索关键字的功能。
c、然后使用stringByEvaluatingJavaScriptFromString执行myFunction函数。

6、直接调用JS函数

上面的函数调用是本地注入到H5中,然后本地调用的,那么如果H5中就有原生的JS函数:myFunction();,那么我们就可以直接执行:

[webView stringByEvaluatingJavaScriptFromString:@"myFunction();"];  

JavaScriptCore框架的使用

我们会发现stringByEvaluatingJavaScriptFromString的方法调用太笨拙,在iOS7.0中苹果公司增加了JS利器JavaScriptCore框架,框架让Objective-C和JavaScript代码直接的交互变得更加的简单方便。该框架其实只是基于webkit中以C/C++实现的JavaScriptCore的一个包装。其本身是可以单独作为一个开发库来使用,框架中有完整的数据计算逻辑,今天只讲H5与本地交互,所以不作涉及,有兴趣可以参考:iOS7新JavaScriptCore框架入门介绍

JavaScriptCore提供了很多灵活的本地OC与JS的交互方式,通过JSContextJSValue来完成的,JSContext是一个WebView中js代码运行环境,所有的JS交互都要通过- (JSValue *)evaluateScript:(NSString *)script;方法就可以执行一段JavaScript脚本。

JSValue则可以说是JavaScript和Object-C之间互换的桥梁,它提供了多种方法可以方便地把JavaScript数据类型转换成Objective-C.

具体如何交互我们先来看一段H5代码:

<!DOCTYPE html>
<html>
    <head>
        <title>测试iOS与JS之前的互调</title>
        <style type="text/css">
            * {
                font-size: 40px;
            }
        </style>
        <script type="text/javascript">
            function showAppAlertMsg(message){
                alert(message);
            }
        </script>
    </head>
    <body>
        <div style="margin-top: 100px">
            <h1>Test how to use objective-c call js</h1>
            <input type="button" value="Call ObjC system camera" onclick="callSystemCamera()">
                <input type="button" value="Call ObjC system alert" onclick="showAlertMsg('js title', 'js message')">
        </div>
        
        <div>
            <input type="button" value="Call ObjC func with JSON " onclick="callWithDict({'name': 'testname', 'age': 10, 'height': 170})">
                <input type="button" value="Call ObjC func with JSON and ObjC call js func to pass args." onclick="jsCallObjcAndObjcCallJsWithDict({'name': 'testname', 'age': 10, 'height': 170})">
        </div>
        
        <div>
            <span id="jsParamFuncSpan" style="color: red; font-size: 50px;"></span>
        </div>
    </body>
</html>

我们可以看出其中H5实现的JS代码如下:

<script type="text/javascript">
    function showAppAlertMsg(message){
        alert(message);
    }
</script>

函数showAppAlertMsg是H5实现可以由我们本地主动调用的。那么相对应的H5主动调用的方法是:

<input type="button" value="Call ObjC system camera" onclick="callSystemCamera()">

其中callSystemCamera()是H5调用的函数,我们可以看到在HTML代码中是找不到这个函数的实现的,因为这个函数是需要我们本地去实现。

接下来我们就讲一下我们本地如何调用由H5实现的函数showAppAlertMsg,本地如何实现能够右H5端调用的方法:

1.在页面加载完成之后获取JS运行环境JSContext

- (void)webViewDidFinishLoad:(UIWebView *)webView {
    JSContext *jsContext = [webView     valueForKeyPath:@"documentView.webView.mainFrame.javaScriptContext"];
}

2.利用evaluateScript声明函数并传递参数执行代码

    JSValue *jsValue = [jsContext evaluateScript:@"showAppAlertMsg"];
    [jsValue callWithArguments:@[@"这是app本地交互文案"]];

第一行代码是声明一个函数showAppAlertMsg,第二行是传递参数并执行代码callWithArguments.

以上是主动调用H5实现的函数,也可以称之为传递数据给H5。

本地实现能够让H5调用的函数:

    // 也可以通过下标的方式获取到方法
    self.jsContext[@"callSystemCamera"] = ^(){
        NSLog(@"callSystemCamera");
        
    };
    
    self.jsContext[@"showAlertMsg"] = ^(NSString *title, NSString *message){
        NSLog(@"callSystemCamera");
        
    };
    
    self.jsContext[@"callWithDict"] = ^(id jsonDic){
        NSLog(@"callWithDict%@",jsonDic);
        
    };

一目了然,通过Block传递参数和方法。

上面是利用JavaScriptCore的最基本的方法实现JS调用,还有另外一种方案利用JSExport协议进行交互.其实可以理解为通过JSExport协议实现一种把本地的实例绑定为H5中的一个对象,通过这个对象调用本地实例方法的一种交互设计。
该设计在H5端与上面的是不一样的:

   <input type="button" value="Call ObjC system camera" onclick="OCModel.callSystemCamera()">
   <input type="button" value="Call ObjC system alert" onclick="OCModel.showAlertMsg('js title', 'js message')">

我们会发现 与之前的H5代码相比 多了一个 OCModel,这个在JS中可以理解为一个对象,点击按钮之后调用对象OCModel中的函数OCModel.callSystemCamera,那么该对象如何由本地绑定呢。

1.首先声明一个JSExport协议,该协议需要声明刚才H5中的函数:

#import <JavaScriptCore/JavaScriptCore.h>

@protocol JavaScriptObjectiveCDelegate <JSExport>

// JS调用此方法来调用OC的系统相册方法
- (void)callSystemCamera;
// 在JS中调用时,函数名应该为showAlertMsg(arg1, arg2)
// 这里是只两个参数的。
- (void)showAlert:(NSString *)title msg:(NSString *)msg;
// 通过JSON传过来
- (void)callWithDict:(NSDictionary *)params;
// JS调用Oc,然后在OC中通过调用JS方法来传值给JS。
- (void)jsCallObjcAndObjcCallJsWithDict:(NSDictionary *)params;

@end

2.新建一个类,该类实现上面的协议

@interface NLJsObjCModel : NSObject <JavaScriptObjectiveCDelegate>
@property (nonatomic, weak) JSContext *jsContext;
@property (nonatomic, weak) UIWebView *webView;
@end

实现文件:

#import "NLJsObjCModel.h"

@implementation NLJsObjCModel

- (void)callWithDict:(NSDictionary *)params {
    NSLog(@"Js调用了OC的方法,参数为:%@", params);
}

// Js调用了callSystemCamera
- (void)callSystemCamera {
    NSLog(@"JS调用了OC的方法,调起系统相册");
    
    // JS调用后OC后,又通过OC调用JS,但是这个是没有传参数的
    JSValue *jsFunc = self.jsContext[@"jsFunc"];
    [jsFunc callWithArguments:nil];
}

- (void)jsCallObjcAndObjcCallJsWithDict:(NSDictionary *)params {
    NSLog(@"jsCallObjcAndObjcCallJsWithDict was called, params is %@", params);
    
    // 调用JS的方法
    JSValue *jsParamFunc = self.jsContext[@"jsParamFunc"];
    [jsParamFunc callWithArguments:@[@{@"age": @10, @"name": @"lili", @"height": @158}]];
}

- (void)showAlert:(NSString *)title msg:(NSString *)msg {
    dispatch_async(dispatch_get_main_queue(), ^{
        UIAlertView *a = [[UIAlertView alloc] initWithTitle:title message:msg delegate:nil cancelButtonTitle:@"Ok" otherButtonTitles:nil, nil];
        [a show];
    });
}
@end

3.将NLJsObjCModel实例对象绑定到JS中:

#pragma mark - UIWebViewDelegate
- (void)webViewDidFinishLoad:(UIWebView *)webView {
    self.jsContext = [webView valueForKeyPath:@"documentView.webView.mainFrame.javaScriptContext"];
    // 通过模型调用方法,这种方式更好些。
    NLJsObjCModel *model  = [[NLJsObjCModel alloc] init];
    self.jsContext[@"OCModel"] = model;
    model.jsContext = self.jsContext;
    model.webView = self.webView;
    
    self.jsContext.exceptionHandler = ^(JSContext *context, JSValue *exceptionValue) {
        context.exception = exceptionValue;
        NSLog(@"异常信息:%@", exceptionValue);
    };
}

到此结束,H5按钮点击之后就可以通过JSExport协议传递跟本地对象,本地对象就能收到相应。

WKWebView的交互使用

iOS8以后,苹果推出了新框架Wekkit,提供了替换UIWebView的组件WKWebView

先看下 WKWebView的特性:
1. 在性能、稳定性、功能方面有很大提升(最直观的体现就是加载网页是占用的内存,模拟器加载百度与开源中国网站时,WKWebView占用23M,而UIWebView占用85M);
2. 允许JavaScript的Nitro库加载并使用(UIWebView中限制);
3. 支持了更多的HTML5特性;
4. 高达60fps的滚动刷新率以及内置手势;
5. 将UIWebViewDelegate与UIWebView重构成了14类与3个协议(查看苹果官方文档);

WKWebView 的使用呢与UIWebView相同,不同的是回调和代理方法.这里简单介绍一下.

## WKNavigationDelegate
// 页面开始加载时调用
- (void)webView:(WKWebView *)webView didStartProvisionalNavigation:(WKNavigation *)navigation;
// 当内容开始返回时调用
- (void)webView:(WKWebView *)webView didCommitNavigation:(WKNavigation *)navigation;
// 页面加载完成之后调用
- (void)webView:(WKWebView *)webView didFinishNavigation:(WKNavigation *)navigation;
// 页面加载失败时调用
- (void)webView:(WKWebView *)webView didFailProvisionalNavigation:(WKNavigation *)navigation;

页面跳转的代理方法:

// 接收到服务器跳转请求之后调用
- (void)webView:(WKWebView *)webView didReceiveServerRedirectForProvisionalNavigation:(WKNavigation *)navigation;
// 在收到响应后,决定是否跳转
- (void)webView:(WKWebView *)webView decidePolicyForNavigationResponse:(WKNavigationResponse *)navigationResponse decisionHandler:(void (^)(WKNavigationResponsePolicy))decisionHandler;
// 在发送请求之前,决定是否跳转
- (void)webView:(WKWebView *)webView decidePolicyForNavigationAction:(WKNavigationAction *)navigationAction decisionHandler:(void (^)(WKNavigationActionPolicy))decisionHandler;

新的 WKUIDelegate协议

这个协议主要用于WKWebView处理web界面的三种提示框(警告框、确认框、输入框),下面是警告框的例子:

/**
 *  web界面中有弹出警告框时调用
 *
 *  @param webView           实现该代理的webview
 *  @param message           警告框中的内容
 *  @param frame             主窗口
 *  @param completionHandler 警告框消失调用
 */
- (void)webView:(WKWebView *)webView runJavaScriptAlertPanelWithMessage:(NSString *)message initiatedByFrame:(void (^)())completionHandler;

动态加载并运行JS代码(WKUserScript)

用于在客户端内部加入JS代码,并执行,示例如下

// 图片缩放的js代码
NSString *js = @"var count = document.images.length;for (var i = 0; i < count; i++) {var image = document.images[i];image.style.width=320;};window.alert('找到' + count + '张图');";
// 根据JS字符串初始化WKUserScript对象
WKUserScript *script = [[WKUserScript alloc] initWithSource:js injectionTime:WKUserScriptInjectionTimeAtDocumentEnd forMainFrameOnly:YES];
// 根据生成的WKUserScript对象,初始化WKWebViewConfiguration
WKWebViewConfiguration *config = [[WKWebViewConfiguration alloc] init];
[config.userContentController addUserScript:script];
_webView = [[WKWebView alloc] initWithFrame:self.view.bounds configuration:config];
[_webView loadHTMLString:@"<head></head><imgea src='http://www.nsu.edu.cn/v/2014v3/img/background/3.jpg' />"baseURL:nil];
[self.view addSubview:_webView];

五、webView 执行JS代码
用户调用用JS写过的代码,一般指服务端开发的:

//javaScriptString是JS方法名,completionHandler是异步回调block
[self.webView evaluateJavaScript:javaScriptString completionHandler:completionHandler];

六、JS调用App注册过的方法
在WKWebView里面注册供JS调用的方法,是通过WKUserContentController类下面的方法:

- (void)addScriptMessageHandler:(id <WKScriptMessageHandler>)scriptMessageHandler name:(NSString *)name;

scriptMessageHandler是代理回调,JS调用name方法后,OC会调用scriptMessageHandler指定的对象。

JS在调用OC注册方法的时候要用下面的方式:

window.webkit.messageHandlers.<name>.postMessage(<messageBody>)

注意,name(方法名)是放在中间的,在这里个人理解该name可以理解为一个标识,该标识将OC本地的实例对象绑定到JS中,将messageBody只能是一个对象,如果要传多个值,需要封装成数组,或者字典。整个示例如下:

//OC注册供JS调用的方法
[[_webView configuration].userContentController addScriptMessageHandler:self name:@"app"];

//OC在JS调用方法做的处理
- (void)userContentController:(WKUserContentController *)userContentController didReceiveScriptMessage:(WKScriptMessage *)message
{
    NSLog(@"JS 调用了 %@ 方法,传回参数 %@",message.name,message.body);
}

//JS调用
    window.webkit.messageHandlers.app.postMessage(null);

最后:

简单做一个总结,在iOS7以前我们只能使用UIWebView,所以在js交互中只能通过stringByEvaluatingJavaScriptFromString进行本地调用H5,而H5端调用本地多采用URLSchemes方式(解耦---Hybrid H5跨平台性思考),

iOS7之后使用UIWebView则可以使用JavaScriptCore框架,通过JSContext进行JS交互。
iOS8之后可以使用WKWebView本身的框架,毕竟WKWebView本身已经优化很多,并且提供了更多的方法和协议,不过注意一点的是在WKWebView中鉴于一些线程和进程的问题是无法获取JSContext的。

2016/7/14 posted in  HTML

JavaScriptCore框架在iOS7中的对象交互和管理教程

上一篇文章中已经简单入门了iOS7中新加的JavaScriptCore框架的基本用法,十分的简单方便而且高效,不过也仅限于数值型、布尔型、字符串、数组等这些基础类型。本文将扩展到更复杂的类型,介绍一下该强大的框架是如何让Objective-C对象和JavaScript对象进行直接互通的。

为了方便起见,以下所有代码中的JSContext对象都会添加如下的log方法和eventHandler

JSContext *context = [[JSContext alloc] init];
context.exceptionHandler = ^(JSContext *con, JSValue *exception) {
    NSLog(@"%@", exception);
    con.exception = exception;
};

context[@"log"] = ^() {
    NSArray *args = [JSContext currentArguments];
    for (id obj in args) {
        NSLog(@"%@",obj);
    }
};

键值对编程—Dictionary

JSContext并不能让Objective-C和JavaScript的对象直接转换,毕竟两者的面向对象的设计方式是不同的:前者基于class,后者基于prototype。但所有的对象其实可以视为一组键值对的集合,所以JavaScript中的对象可以返回到Objective-C中当做NSDictionary类型进行访问。

JSValue * obj = [context evaluateScript: @ "var jsObj = {number: 7, name: 'Ider'}; jsObj"];
NSLog (@ "% @,% @", obj [@ "name"], obj [@ 'number']);
NSDictionary * dic = [obj toDictionary];
NSLog (@ "% @,% @", dic [@ "name"], dic [@ 'number']);
/ / Output:
/ / Ider, 7
/ / Ider, 7
同样的,NSDicionary和NSMutableDictionary传入到JSContext之后也可以直接当对象来调用:


NSDictionary * dic = @ @ {"name": @ "Ider", @ "#": @ (21)};
context [@ "dic"] = dic;
[Context evaluateScript: @ "log (dic.name, dic ['#'])"];
/ / Output:
/ / Ider
/ / 21

语言穿梭机—JSExport协议

JavaScript可以脱离prototype继承完全用JSON来定义对象,但是Objective-C编程里可不能脱离类和继承了写代码。所以JavaScriptCore就提供了JSExport作为两种语言的互通协议。JSExport中没有约定任何的方法,连可选的(@optional)都没有,但是所有继承了该协议(@protocol)的协议(注意不是Objective-C的类(@interface))中定义的方法,都可以在JSContext中被使用。语言表述起来有点绕,还是用例子来说明会更明确一点。

@protocol PersonProtocol <JSExport>
@property (nonatomic, retain) NSDictionary *urls;
- (NSString *)fullName;
@end

@interface Person :NSObject <PersonProtocol>
@property (nonatomic, copy) NSString *firstName;
@property (nonatomic, copy) NSString *lastName;
@end;

@implementation Person

@synthesize firstName, lastName, urls;
- (NSString *)fullName {
    return [NSString stringWithFormat:@"%@ %@", self.firstName, self.lastName];
}

@end

在上边的代码中,定义了一个PersonProtocol,并让它继承了神秘的JSExport协议,在新定义的协议中约定urls属性和fullName方法。之后又定义了Person类,除了让它实现PersonProtocol外,还定义了firstName和lastName属性。而fullName方法返回的则是两部分名字的结合。
下边就来创建一个Person对象,然后传入到JSContext中并尝试使用JavaScript来访问和修改该对象。

/ / Initialize target person
Person * person = [ [ Person alloc ] init ];
context [@ "p" ] = person ;
person.firstName = @ " Idera ";
person.lastName = @ " Zheng ";
person.urls @ = @ { "site " : @ " http://www.iderzheng.com "} ;
 
/ / Ok to get FullName
[ evaluateScript context : @ " log ( p.fullName () ); " ];
/ / Can not access firstname
[ evaluateScript context : @ " log ( p.firstName ) " ];
/ / Ok to access object as dictionary
[ evaluateScript context : @ " log ( ' site: ' p.urls.site , ' blog: ' p.urls.blog ) " ];
/ / Ok to change urls property
[ evaluateScript context : @ " = { p.urls blog: ' http://blog.iderzheng.com ' }" ];
[ evaluateScript context : @ " log ( ' ------- ------- AFTER CHANGE URLS ' ) " ];
[ evaluateScript context : @ " log ( ' site: ' p.urls.site , ' blog: ' p.urls.blog ) " ];
 
/ / Affect on Objective- C side as well
NSLog (@ " % @ " , person.urls );
 
/ / Output :
/ / IDER Zheng
/ / Undefined
/ / Undefined
/ / Site :
/ / Http://www.iderzheng.com
/ / Blog:
/ / Undefined
/ / ------- ------- AFTER CHANGE URLS
/ / Site :
/ / Undefined
/ / Blog:
/ / Http://blog.iderzheng.com
/ / {
/ / Blog = " http://blog.iderzheng.com ";
/ / }

从输出结果不难看出,当访问firstName和lastName的时候给出的结果是undefined,因为它们跟JavaScript没有JSExport的联系。但这并不影响从fullName()中正确得到两个属性的值。和之前说过的一样,对于NSDictionary类型的urls,可以在JSContext中当做对象使用,而且还可以正确地给urls赋予新的值,并反映到实际的Objective-C的Person对象上。
JSExport不仅可以正确反映属性到JavaScript中,而且对属性的特性也会保证其正确,比如一个属性在协议中被声明成readonly,那么在JavaScript中也就只能读取属性值而不能赋予新的值。
对于多参数的方法,JavaScriptCore的转换方式将Objective-C的方法每个部分都合并在一起,冒号后的字母变为大写并移除冒号。比如下边协议中的方法,在JavaScript调用就是:doFooWithBar(foo, bar);

@protocol MultiArgs <JSExport>
- (void)doFoo:(id)foo withBar:(id)bar;
@end

如果希望方法在JavaScript中有一个比较短的名字,就需要用的JSExport.h中提供的宏:JSExportAs(PropertyName, Selector)。

@protocol LongArgs <JSExport>
 
JSExportAs(testArgumentTypes,
           - (NSString *)testArgumentTypesWithInt:(int)i double:(double)d 
                    boolean:(BOOL)b string:(NSString *)s number:(NSNumber *)n 
                    array:(NSArray *)a dictionary:(NSDictionary *)o
           );
 
@end

比如上边定义的协议中的方法,在JavaScript就只要用testArgumentTypes(i, d, b, s, n, a, dic);来调用就可以了。
虽然JavaScriptCore框架还没有官方编程指南,但是在JSExport.h文件中对神秘协议的表述还是比较详细的,其中有一条是这样描述的:

By default no methods or properties of the Objective-C class will be exposed to JavaScript, however methods and properties may explicitly be exported. For each protocol that a class conforms to, if the protocol incorporates the protocol JSExport, then the protocol will be interpreted as a list of methods and properties to be exported to JavaScript.

这里面有个incorporate一词值得推敲,经过验证只有直接继承了JSExport的自定义协议(@protocol)才能在JSContext中访问到。也就是说比如有其它的协议继承了上边的PersonProtocol,其中的定义的方法并不会被引入到JSContext中。从源码中也能看出JavaScriptCore框架会通过class_copyProtocolList方法找到类所遵循的协议,然后再对每个协议通过protocol_copyProtocolList检查它是否遵循JSExport协议进而将方法反映到JavaScript之中。

对已定义类扩展协议— class_addProtocol

对于自定义的Objective-C类,可以通过之前的方式自定义继承了JSExport的协议来实现与JavaScript的交互。对于已经定义好的系统类或者从外部引入的库类,她们都不会预先定义协议提供与JavaScript的交互的。好在Objective-C是可以在运行时实行对类性质的修改的。
比如下边的例子,就是为UITextField添加了协议,让其能在JavaScript中可以直接访问text属性。该接口如下:

@protocol JSUITextFieldExport <JSExport>
 
@property(nonatomic,copy) NSString *text;
 
@end

之后在通过class_addProtocol为其添加上该协议:

- (void)viewDidLoad {
    [super viewDidLoad];
 
    textField.text = @"7";
    class_addProtocol([UITextField class], @protocol(JSUITextFieldExport));
}

为一个UIButton添加如下的事件,其方法只要是将textField传入到JSContext中然后读取其text值,自增1后重新赋值:

- (IBAction)pressed:(id)sender {
    JSContext *context = [[JSContext alloc] init];
 
    context[@"textField"] = textField;
 
    NSString *script = @"var num = parseInt(textField.text, 10);"
    "++num;"
    "textField.text = num;";
    [context evaluateScript:script];
}

当运行点击UIButton时就会看到UITextField的值在不断增加,也证明了对于已定义的类,也可以在运行时添加神奇的JSExport协议让它们可以在Objective-C和JavaScript直接实现友好互通。

Garbage Collection(垃圾回收机制)

虽然Objetive-C和JavaScript都是面向对象的语言,而且它们都可以让程序员专心于业务逻辑,不用担心内存回收的问题。但是两者的内存回首机制全是不同的,Objective-C是基于引用计数,之后Xcode编译器又支持了自动引用计数(ARC, Automatic Reference Counting);JavaScript则如同Java/C#那样用的是垃圾回收机制(GC, Garbage Collection)。当两种不同的内存回收机制在同一个程序中被使用时就难免会产生冲突。

比如,在一个方法中创建了一个临时的Objective-C对象,然后将其加入到JSContext放在JavaScript中的变量中被使用。因为JavaScript中的变量有引用所以不会被释放回收,但是Objective-C上的对象可能在方法调用结束后,引用计数变0而被回收内存,因此JavaScript层面也会造成错误访问。
同样的,如果用JSContext创建了对象或者数组,返回JSValue到Objective-C,即使把JSValue变量retain下,但可能因为JavaScript中因为变量没有了引用而被释放内存,那么对应的JSValue也没有用了。
怎么在两种内存回收机制中处理好对象内存就成了问题。JavaScriptCore提供了JSManagedValue类型帮助开发人员更好地管理对象内存。

@interface JSManagedValue : NSObject
 
// Convenience method for creating JSManagedValues from JSValues.
+ (JSManagedValue *)managedValueWithValue:(JSValue *)value;
 
// Create a JSManagedValue.
- (id)initWithValue:(JSValue *)value;
 
// Get the JSValue to which this JSManagedValue refers. If the JavaScript value has been collected,
// this method returns nil.
- (JSValue *)value;
 
@end

《iOS7新JavaScriptCore框架入门介绍》有提到JSVirtualMachine为整个JavaScriptCore的执行提供资源,所以当将一个JSValue转成JSManagedValue后,就可以添加到JSVirtualMachine中,这样在运行期间就可以保证在Objective-C和JavaScript两侧都可以正确访问对象而不会造成不必要的麻烦。

@interface JSVirtualMachine : NSObject
 
// Create a new JSVirtualMachine.
- (id)init;
 
// addManagedReference:withOwner and removeManagedReference:withOwner allow 
// clients of JSVirtualMachine to make the JavaScript runtime aware of 
// arbitrary external Objective-C object graphs. The runtime can then use 
// this information to retain any JavaScript values that are referenced 
// from somewhere in said object graph.
// 
// For correct behavior clients must make their external object graphs 
// reachable from within the JavaScript runtime. If an Objective-C object is 
// reachable from within the JavaScript runtime, all managed references 
// transitively reachable from it as recorded with 
// addManagedReference:withOwner: will be scanned by the garbage collector.
// 
- (void)addManagedReference:(id)object withOwner:(id)owner;
- (void)removeManagedReference:(id)object withOwner:(id)owner;
 
@end

了解更多更多—Source Code

对于iOS7提供JavaScriptCore已经介绍的差不多了,之前也提到这其实是一个开源的框架,所以如果想要在低版本的iOS上使用,也可以很容易地自行添加源码进行编译和使用。
阅读源码也可以更加了解JavaScriptCore是怎么实现的,在开发时候也可以注意到更多的细节避免错误的发生,想要阅读框架的源码可以在这里(源码1源码2源码3)。
文章中的代码和例子都比较简单,如果想了解更多JavaScriptCore的使用方法,在这里有详细的测试案例可以提供一些线索。不过经验证并不是所有的测试案例在iOS7中都会通过,这大概是测试案例所用的JavaScriptCore是为chromium实现的而iOS7是webkit吧。

References:
1 Steamclock Software – Apple’s new Objective-C to Javascript Bridge
2 JavaScriptCore and iOS 7 » Big Nerd Ranch BlogBig Nerd Ranch Blog
3 IOS7开发~JavaScriptCore (二) – 阿福的专栏 – 博客频道 – CSDN.NET
4 API in trunk/Source/JavaScriptCore – WebKit
5 Objective-C Runtime Reference
6 Automatic Reference Counting vs. Garbage Collection – The Oxygene Language Wiki

转载:JavaScriptCore框架在iOS7中的对象交互和管理

2016/7/12 posted in  HTML

iOS7新JavaScriptCore框架介绍

这个框架其实只是基于webkit中以C/C++实现的JavaScriptCore的一个包装,在旧版本iOS开发中,很多开发者也会自行将webkit的库引入项目编译使用。不过虽然iOS7把它当成了标准库,可惜目前,我还没有在Apple Developer中找到像那样的官方文档介绍这个框架的具体使用方法。

JavaScriptCore中的类

在项目中引入JavaScriptCore后,链到头文件中,除了大段的Copyright注释可以看到里面只要引入了5个文件,每个文件里都定义跟文件名对应的类:
• JSContext
• JSValue
• JSManagedValue
• JSVirtualMachine
• JSExport

虽说代码中的注释介绍的也比较详细了,但是如同一图顶万言,对程序员来说代码更有说服力。本文就先来说说这些类相对比较好理解但又很常用的JSContext和JSValue以及它们方法的使用方式和效果。

JSContext和JSValue

JSVirtualMachine为JavaScript的运行提供了底层资源,JSContext就为其提供着运行环境,通过

- (JSValue *)evaluateScript:(NSString *)script;

方法就可以执行一段JavaScript脚本,并且如果其中有方法、变量等信息都会被存储在其中以便在需要的时候使用。而JSContext的创建都是基于JSVirtualMachine

- (id)initWithVirtualMachine:(JSVirtualMachine *)virtualMachine

如果是使用- (id)init;进行初始化,那么在其内部会自动创建一个新的JSVirtualMachine对象然后调用前边的初始化方法。
JSValue则可以说是JavaScript和Object-C之间互换的桥梁,它提供了多种方法可以方便地把JavaScript数据类型转换成Objective-C,或者是转换过去。其一一对应方式可见下表:

Objective-C JavaScript JSValue Convert JSValue Constructor
nil undefined valueWithUndefinedInContext
NSNull null valueWithNullInContext:
NSString string toString
NSNumber number, boolean toNumber,toBool,toDouble,toInt32,toUInt32 valueWithBool:inContext:,valueWithDouble:inContext:,valueWithInt32:inContext:,valueWithUInt3:inContext:
NSDictionary Object object toDictionary valueWithNewObjectInContext:
NSArray Array object toArray valueWithNewArrayInContext:
NSDate Date object toDate
NSBlock Function object
id Wrapper object toObject,toObjectOfClass: valueWithObject:inContext:
Class Constructor object

 
 

基本类型转换

先看个简单的例子:

JSContext *context = [[JSContext alloc] init];
JSValue *jsVal = [context evaluateScript:@"21+7"];
int iVal = [jsVal toInt32];
NSLog(@"JSValue: %@, int: %d", jsVal, iVal);   
//Output:
// JSValue: 28, int: 28

很简单吧,还可以存一个JavaScript变量在JSContext中,然后通过下标来获取出来。而对于Array或者Object类型,JSValue也可以通过下标直接取值和赋值。

JSContext *context = [[JSContext alloc] init];
[context evaluateScript:@"var arr = [21, 7 , 'iderzheng.com'];"];
JSValue *jsArr = context[@"arr"]; // Get array from JSContext

NSLog(@"JS Array: %@; Length: %@", jsArr, jsArr[@"length"]);
jsArr[1] = @"blog"; // Use JSValue as array
jsArr[7] = @7;

NSLog(@"JS Array: %@; Length: %d", jsArr, [jsArr[@"length"] toInt32]);

NSArray *nsArr = [jsArr toArray];
NSLog(@"NSArray: %@", nsArr);

//Output:
// JS Array: 21,7,iderzheng.com Length: 3
// JS Array: 21,blog,iderzheng.com,,,,,7 Length: 8
// NSArray: (
// 21,
// blog,
// "iderzheng.com",
// "<null>",
// "<null>",
// "<null>",
// "<null>",
// 7
 // )
 

通过Block成功的在JavaScript调用方法回到了Objective-C,而且依然遵循JavaScript方法的各种特点,比如方法参数不固定。也因为这样,JSContext提供了类方法来获取参数列表(+ (NSArray *)currentArguments;)和当前调用该方法的对象(+ (JSValue *)currentThis)。对于"this",输出的内容是GlobalObject,这也是JSContext对象方法- (JSValue *)globalObject;所返回的内容。因为我们知道在JavaScript里,所有全局变量和方法其实都是一个全局变量的属性,在浏览器中是window,在JavaScriptCore是什么就不得而知了。
Block可以传入JSContext作方法,但是JSValue没有toBlock方法来把JavaScript方法变成Block在Objetive-C中使用。毕竟Block的参数个数和类型已经返回类型都是固定的。虽然不能把方法提取出来,但是JSValue提供了- (JSValue *)callWithArguments:(NSArray *)arguments;方法可以反过来将参数传进去来调用方法。

JSContext *context = [[JSContext alloc] init];
[context evaluateScript:@"function add(a, b) { return a + b; }"];
JSValue *add = context[@"add"];
NSLog(@"Func: %@", add);

JSValue *sum = [add callWithArguments:@[@(7), @(21)]];
NSLog(@"Sum: %d",[sum toInt32]);

//OutPut:
// Func: function add(a, b) { return a + b; }
// Sum: 28

JSValue还提供- (JSValue *)invokeMethod:(NSString *)method withArguments:(NSArray *)arguments;让我们可以直接简单地调用对象上的方法。只是如果定义的方法是全局函数,那么很显然应该在JSContext的globalObject对象上调用该方法;如果是某JavaScript对象上的方法,就应该用相应的JSValue对象调用。

异常处理

Objective-C的异常会在运行时被Xcode捕获,而在JSContext中执行的JavaScript如果出现异常,只会被JSContext捕获并存储在exception属性上,而不会向外抛出。时时刻刻检查JSContext对象的exception是否不为nil显然是不合适,更合理的方式是给JSContext对象设置exceptionHandler,它接受的是JSContext *context, JSValue *exceptionValue形式的Block。其默认值就是将传入的exceptionValue赋给传入的context的exception属性:

^(JSContext *context, JSValue *exceptionValue) {
      context.exception = exceptionValue;
};

我们也可以给exceptionHandler赋予新的Block以便在JavaScript运行发生异常的时候我们可以立即知道:

JSContext *context = [[JSContext alloc] init];
context.exceptionHandler = ^(JSContext *con, JSValue *exception) {
    NSLog(@"%@", exception);
    con.exception = exception;
 };
       
[context evaluateScript:@"ider.zheng = 21"];

//Output:
// ReferenceError: Can't find variable: ider

使用Block的注意事项
从之前的例子和介绍应该有体会到Block在JavaScriptCore中起到的强大作用,它在JavaScript和Objective-C之间的转换 建立起更多的桥梁,让互通更方便。但是要注意的是无论是把Block传给JSContext对象让其变成JavaScript方法,还是把它赋给exceptionHandler属性,在Block内都不要直接使用其外部定义的JSContext对象或者JSValue,应该将其当做参数传入到Block中,或者通过JSContext的类方法+ (JSContext *)currentContext;来获得。否则会造成循环引用使得内存无法被正确释放。
比如上边自定义异常处理方法,就是赋给传入JSContext对象con,而不是其外创建的context对象,虽然它们其实是同一个对象。这是因为Block会对内部使用的在外部定义创建的对象做强引用,而JSContext也会对被赋予的Block做强引用,这样它们之间就形成了循环引用(Circular Reference)使得内存无法正常释放。
对于JSValue也不能直接从外部引用到Block中,因为每个JSValue上都有JSContext的引用 (@property(readonly, retain) JSContext *context;),JSContext再引用Block同样也会形成引用循环。

转载:iOS7新JavaScriptCore框架介绍

2016/7/12 posted in  HTML