Kealdish's Studio.

H5游戏接入技术小结(下)

字数统计: 3.4k阅读时长: 13 min
2016/05/20 Share

概览

在上一篇H5游戏接入技术小结(上)中,主要介绍了H5游戏资源的下载和运行。本篇文章主要是介绍app与H5游戏的交互以及在线与其他玩家对战的技术总结。

app与H5游戏交互

app与游戏交互本质上就是app与浏览器进行交互,交互的媒介自然是要靠JS。交互的内容倒并不复杂,复杂的在于iOS版本的适配。iOS中webview有两个类UIWebViewWKWebView,性能方面来说WKWebView会更好一些(如果你对UIWebViewWKWebView的区别不太了解,可以看这篇博文),但WKWebView是iOS 8之后才引入的,故iOS 8之前的版本只能采用UIWebView。JS与OC交互的类库用的较多的是JavaScriptCoreWebViewJavaScriptBridge,其中JavaScriptCore是iOS 7之后苹果开放的类库,而WebViewJavaScriptBridge则是第三方类库,并且作者也说明了对于WKWebView的支持上还存在bug。综合来看最佳的解决方案应该是WKWebViewJavaScriptCore的组合,然而在WKWebView中我们无法获取context,也就是说WKWebView不支持JavaScriptCore。综合考虑后,还是决定采用UIWebView与JavaScriptCore组合(iOS 7)与WKWebView与原生JS组合(iOS 8及以上)的方案。

UIWebView与JavaScriptCore交互

相比于WebViewJavaScriptBridgeJavaScriptCore的学习成本要更低一些。JavaScriptCore中包含了以下几个类:

  • JSContext
  • JSManagedValue
  • JSValue
  • JSVirtualMachine

JSContext对象代表了JS的运行环境,我们可以创建和使用JS上下文来执行OC或者swift代码中的JS脚本,访问JS中定义或者计算出的值,让JS可以访问原生的对象,方法或者函数。JSValue是对JS中的值的引用。你可以用JSValue类在JS和OC(或swift)进行值类型的转换来互相传递数据。你还可以用该类创建JS对象封装原生的自定义类的对象或方法。JSManagedValue对象是对JSValue对象的封装,增加了“条件持有”的行为来对值进行自动内存管理。该类最基本的使用场景是将JS值存储在OC(或swift)对象中并将其传递给JS代码。JSVirtualMachine对象代表的是独立的JS运行环境。使用这个类有两个目的:支持JS并发执行和管理在JS和OC(或swift)桥接对象的内存。

OC调用JS

OC中调用JS代码主要依赖JSContextJSValue类,一个 JSContext 是一个全局环境的实例。调用JS代码主要调用JSContextevaluteScript方法,创建一个 JSContext 后,可以很容易地运行 JavaScript 代码来创建变量,做计算,甚至定义方法:

1
2
3
4
5
JSContext *context = [[JSContext alloc] init];
[context evaluateScript:@"var num = 5 + 5"];
[context evaluateScript:@"var names = ['Grace', 'Ada', 'Margaret']"];
[context evaluateScript:@"var triple = function(value) { return value * 3 }"];
JSValue *tripleNum = [context evaluateScript:@"triple(num)"];

代码的最后一行,任何出自JSContext的值都被包裹在一个JSValue对象中。像JavaScript 这样的动态语言需要一个动态类型,所以JSValue包装了每一个可能的JavaScript值:字符串和数字;数组、对象和方法;甚至错误和特殊的JavaScript值诸如null和undefined。如果想将JSValue类型转为OC(或swift)下的数据类型,可以调用JSValue相关的方法实现:

若想调用JS代码中的方法,可以使用下标方式或者调用objectForKeyedSubscript:方法来实现。例如,我在JS文件中定义了求阶乘的方法:

1
2
3
4
5
6
7
var factorial = function(n) {
if (n < 0)
return;
if (n === 0)
return 1;
return n * factorial(n - 1)
};

在OC中我们可以这样去调用计算阶乘的方法:

1
2
3
4
5
6
7
8
9
JSContext context = [[JSContext alloc] init];
// 执行指定路径下JS文件中的代码
[context evaluateScript:[NSString stringWithContentsOfFile:path encoding:NSUTF8StringEncoding error:nil]];
// 1.下标访问
JSValue *function = self.context[@"factorial"];
// 2.方法访问
JSValue *function = [self.context objectForKeyedSubscript:@"factorial"];
// 调用阶乘方法并传递参数
JSValue *result = [function callWithArguments:@[@5]];

同时,JSContext也提供对于引用和异常本身的回调处理——通过设置context的exceptionHandler属性。

1
2
3
context.exceptionHandler = ^(JSContext *context, JSValue *exception){
NSLog(@"JS Error: %@", exception);
};
JS调用OC

JS访问客户端代码的方式主要有两种:block和JSExport协议。

先说第一种:block。当一个block被赋给JSContext里的一个标识符,JavaScriptCore会自动的把block封装在JS函数里。这使得在JS中可以简单的使用Foundation和Cocoa类,所有的桥接都为你做好了。例如下面代码中,弹出alertView的block被赋给context的”alert”,如果想要在JS中调用这个block,只需要调用alert()方法并传入参数即可。

1
2
3
4
5
6
context[@"alert"] = ^(NSString *str){
UIAlertView *alert = [[UIAlertView alloc]initWithTitle:@"msg from js" message:str delegate:nil cancelButtonTitle:@"ok" otherButtonTitles:nil];
[alert show];
};
NSLog(@"%@", [context evaluateScript:@"alert('hello')"]);

block方式使用起来很简单,但也有需要注意的地方。由于block对变量是强引用,而JSContext也强引用所有的变量,因此很容易会产生循环引用问题。这里有些注意点总结如下:

1.在block中不要直接使用context,而是用[JSContext currentContext]得到当前上下文。

1
2
3
4
5
6
7
8
// 错误示范
self.context[@"name"] = ^(){
JSValue *value = [JSValue valueWithObject:@"aaa" inContext:self.context];
};
// 正确做法
self.context[@"name"] = ^(){
JSValue *value = [JSValue valueWithObject:@"aaa" inContext:[JSContext currentContext]];
};

2.不要在block中引用外部变量,而是将其作为参数进行传递。

1
2
3
4
5
6
7
8
9
JSValue *value = [JSValue valueWithObject:@"ssss" inContext:self.context];
// 错误
self.context[@"name"] = ^(){
NSLog(@"%@",value);
};
// 正确
self.context[@"name"] = ^(NSString *str){
NSLog(@"%@",str);
};

再说第二种:JSExport协议。这个协议可以让JS代码访问OC的类实例,实例方法,类方法和属性。以下以Person类为例来说明。首先,需要定义PersonJSExpoert协议继承JSContext,这个协议指定哪些属性和方法在JS中是可用的。然后,Person类需要去遵守并实现PersonJSExpoert协议。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// Person.h
@class Person;
@protocol PersonJSExports <JSExport>
@property (nonatomic, copy) NSString *firstName;
@property (nonatomic, copy) NSString *lastName;
@property NSInteger ageToday;
- (NSString *)getFullName;
// create and return a new Person instance with `firstName` and `lastName`
+ (instancetype)createWithFirstName:(NSString *)firstName lastName:(NSString *)lastName;
@end
@interface Person : NSObject <PersonJSExports>
@property (nonatomic, copy) NSString *firstName;
@property (nonatomic, copy) NSString *lastName;
@property NSInteger ageToday;
@end
// Person.m
@implementation Person
- (NSString *)getFullName {
return [NSString stringWithFormat:@"%@ %@", self.firstName, self.lastName];
}
+ (instancetype) createWithFirstName:(NSString *)firstName lastName:(NSString *)lastName {
Person *person = [[Person alloc] init];
person.firstName = firstName;
person.lastName = lastName;
return person;
}
@end

这样在JS中就可以创建新的Person实例,在这个例子中,Objective-C的方法createWithFirstName:lastName:变成了在JavaScript中的createWithFirstNameLastName()

1
2
3
4
5
6
7
8
9
10
var loadPeopleFromJSON = function(jsonString) {
var data = JSON.parse(jsonString);
var people = [];
for (i = 0; i < data.length; i++) {
var person = Person.createWithFirstNameLastName(data[i].first, data[i].last);
person.birthYear = data[i].year;
people.push(person);
}
return people;
}

JavaScriptCore介绍完了,那么在UIWebView中若想要拿到JSContext对象,就要调用下面这个方法:

1
[webView valueForKeyPath:@"documentView.webView.mainFrame.javaScriptContext"]

这里涉及到UIWebView创建JSContext上下文环境的时机问题,那么什么时候UIWebView会创建JSContext的上下文环境呢?
默认情况下,在渲染网页时遇到<script>标签时,就会创建JSContext上下文环境去运行JS代码。还有就是调用[webView valueForKeyPath:@"documentView.webView.mainFrame.javaScriptContext"]去获取JSContext环境时,这时无论是否遇到<script>标签,都会去创造出来一个JSContext环境,而且和遇到<script>标签创造的环境是同一个。

这时候又牵扯到何时注入JSContext的问题。我通常都会在- (void)webViewDidFinishLoad:(UIWebView *)webView中去注入交互对象,但是这时候网页还没加载完,JavaScript那边已经调用交互方法,这样就会调不到原生应用的方法而出现问题。如果改成在- (void)viewDidLoad中去注入交互对象,这样倒是解决了上面的问题,但是同时又引起了一个新的问题就是在一个网页内部点击链接跳转到另一个网页的时候,第二个页面需要交互,这时JSContext环境已经变化,但是- (void)viewDidLoad仅仅加载一次,跳转的时候,没有再次注入交互对象,这样就会导致第二个页面没法进行交互。当然你可以在- (void)viewDidLoad和- (void)webViewDidFinishLoad:(UIWebView *)webView都注入一次。

WKWebView与原生JS交互

采用WKWebView与原生JS交互的原因在于WKWebView相比于UIWebView,其与JS交互的能力明显增强,并且在加载速度及内存占用上都有显著提升。WKWebView与JS交互主要依赖于两个概念:user scripts和script messages。

先说user scripts。简单来说,它是在WKWebView加载时被注入到web页面中的JS代码块。当然,user scripts也可以在页面内容(DOM)加载完成之前或者加载完成之后注入和运行。user scripts可以做到JS在页面中能做到的所有事情,包括操作DOM和调用页面中存在的已加载的任何JS方法。user scripts是原生app调用JS的方式。

再说script messages。script messages是web页面调用原生app的方式。一个script message与页面中的JS方法绑定,你需要在原生app中定义处理方法来负责处理web页面传过来的消息。script message可以由user script或者任何其他加载进页面的脚本触发。

以下我会以一个小demo来说明WKWebView与JS如何交互的。在demo中我要做到的是如何通过再原生app中调用JS方法来改变DOM元素的颜色并且同时监听html页面中的JS发送给原生app的异步消息。

为了能让web页面和WKWebView交互,我们需要两个主要的元素:

1.带有可以被WKWebView加载的JS代码的web页面
2.可以与web页面进行通信交互的WKWebView原生app

Web页面

web页面的构成很简单,包含两个文件:index.html和main.js,index.html是页面文件,main.js被index.html调用。main.js文件的内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function callNativeApp () {
try {
webkit.messageHandlers.callbackHandler.postMessage("Hello from JavaScript");
} catch(err) {
console.log('The native context does not exist yet');
}
}
setTimeout(function () {
callNativeApp();
}, 5000);
function redHeader() {
document.querySelector('h1').style.color = "red";
}

当这段脚本代码被加载时,它会延迟5秒后调用callNativeApp方法。我们要注意的是”callbackHandler”,这是我们后面在原生app中定义的script message handler的名字。另外,我将回调”webkit.messageHandlers…..”封装进try-catch中来避免当脚本不再原生app的context下运行时会产生错误。最后还有一个改变html页面中h1标签颜色为红色的方法,后面我们会用到它。我将从本地服务器去加载它来模拟更真实的环境。

原生app

关于WKWebView的使用在此就不赘述了,不了解的可以查阅苹果相关文档。首先我们要做的是创建WKWebView对象并指定测试的URL地址。

1
2
3
4
self.webView = WKWebView()
var url = NSURL(string:"http://localhost/WKJSDemo/")
var req = NSURLRequest(URL:url)
self.webView!.loadRequest(req)
原生app调用JS

WKWebView的初始化方法有一个参数叫configuration,接收的是WKWebViewConfiguration实例,通过这个实例我们可以设置webView是否开启调用JS。这里我们需要设置的是WKWebViewConfigurationuserContentController属性。这个属性接收WKUserContentController实例。该实例有个方法叫做addUserScript,我们调用该方法添加user script。下面给出示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
var contentController = WKUserContentController();
var userScript = WKUserScript(
source: "redHeader()",
injectionTime: WKUserScriptInjectionTime.AtDocumentEnd,
forMainFrameOnly: true
)
contentController.addUserScript(userScript)
var config = WKWebViewConfiguration()
config.userContentController = contentController
self.webView = WKWebView(
frame: self.containerView.bounds,
configuration: config
)

WKUserScript初始化方法中redHeader()对应的JS文件中的同名方法。”injectionTime”参数告诉user script当HTML页面的body加载完时运行脚本代码。”forMainFrameOnly”参数是说脚本只会被注入HTML页面的主帧中。

现在我们运行代码,就会发现HTML页面的头部文字是红色的。

JS调用原生app

我们已经知道原生app中如何调用JS代码,那么JS如何调用原生app呢?正如我们之前说过的,这要用到”script messages”。

为了能接收到来自JS的事件,控制器需要遵守WKScriptMessageHandler协议。这意味着两点,我们要继承WKScriptMessageHandler并且实现userContentController的代理方法。

1
2
3
4
5
func userContentController(userContentController: WKUserContentController!, didReceiveScriptMessage message: WKScriptMessage!) {
if(message.name == "callbackHandler") {
println("JavaScript is sending a message \(message.body)")
}
}

我们注意到代理方法会检查消息的名字是否为”callbackHandler”,回想之前的JS代码中有一行是这样的:”webkit.messageHandlers.callbackHandler.postMessage…”。原生app的方法会检查接收到的script message是否是想要的,如果是想要的,则在控制台打印消息的所有信息。

接下来,我们需要告诉webview开始监听来自JS的事件。这需要我们给contentController添加”script handler”。

1
contentController.addScriptMessageHandler( self, name: "callbackHandler")

第一个参数self是指script message的代理是ViewController。如果你想在另一个类中处理接收到的消息,需要将该类的实例传入。第二个参数传入的是在JS中调用原生userContentController代理方法的名字。

写完后编译运行,5秒后控制台会打印出信息。

代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// index.html
<!DOCTYPE html>
<html>
<head>
<style type="text/css">
body {
padding-top: 40px;
}
</style>
<title>WKWebView Demo</title>
<meta charset="UTF-8">
</head>
<body>
<h1>WKWebView Test</h1>
<script type="text/javascript" src="main.js"></script>
</body>
</html>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// main.js
function callNativeApp () {
try {
webkit.messageHandlers.callbackHandler.postMessage("Hello from JavaScript");
} catch(err) {
console.log('The native context does not exist yet');
}
}
setTimeout(function () {
callNativeApp();
}, 5000);
function redHeader() {
document.querySelector('h1').style.color = "red";
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
// ViewController.swift
import UIKit
import WebKit
class ViewController: UIViewController, WKScriptMessageHandler {
@IBOutlet var containerView : UIView! = nil
var webView: WKWebView?
override func loadView() {
super.loadView()
var contentController = WKUserContentController();
var userScript = WKUserScript(source: "redHeader()", injectionTime: WKUserScriptInjectionTime.AtDocumentEnd, forMainFrameOnly: true
)
contentController.addUserScript(userScript)
contentController.addScriptMessageHandler( self, name: "callbackHandler")
var config = WKWebViewConfiguration()
config.userContentController = contentController
self.webView = WKWebView(frame: self.containerView.bounds,configuration: config)
self.view = self.webView!
}
override func viewDidLoad() {
super.viewDidLoad()
var url = NSURL(string:"http://localhost/~jornki/tests/WKDemo/")
var req = NSURLRequest(URL:url)
self.webView!.loadRequest(req)
}
func userContentController(userContentController: WKUserContentController!, didReceiveScriptMessage message: WKScriptMessage!) {
if(message.name == "callbackHandler") {
println("JavaScript is sending a message \(message.body)")
}
}
}
CATALOG
  1. 1. 概览
  2. 2. app与H5游戏交互
  3. 3. UIWebView与JavaScriptCore交互
    1. 3.0.1. OC调用JS
    2. 3.0.2. JS调用OC
  • 4. WKWebView与原生JS交互
    1. 4.0.1. Web页面
    2. 4.0.2. 原生app
    3. 4.0.3. 原生app调用JS
    4. 4.0.4. JS调用原生app
  • 5. 代码