概览
在上一篇H5游戏接入技术小结(上)中,主要介绍了H5游戏资源的下载和运行。本篇文章主要是介绍app与H5游戏的交互以及在线与其他玩家对战的技术总结。
app与H5游戏交互
app与游戏交互本质上就是app与浏览器进行交互,交互的媒介自然是要靠JS。交互的内容倒并不复杂,复杂的在于iOS版本的适配。iOS中webview
有两个类UIWebView
和WKWebView
,性能方面来说WKWebView
会更好一些(如果你对UIWebView
和WKWebView
的区别不太了解,可以看这篇博文),但WKWebView
是iOS 8之后才引入的,故iOS 8之前的版本只能采用UIWebView
。JS与OC交互的类库用的较多的是JavaScriptCore
和WebViewJavaScriptBridge
,其中JavaScriptCore
是iOS 7之后苹果开放的类库,而WebViewJavaScriptBridge
则是第三方类库,并且作者也说明了对于WKWebView
的支持上还存在bug。综合来看最佳的解决方案应该是WKWebView
与JavaScriptCore
的组合,然而在WKWebView
中我们无法获取context,也就是说WKWebView
不支持JavaScriptCore
。综合考虑后,还是决定采用UIWebView与JavaScriptCore组合(iOS 7)与WKWebView与原生JS组合(iOS 8及以上)的方案。
UIWebView与JavaScriptCore交互
相比于WebViewJavaScriptBridge
,JavaScriptCore
的学习成本要更低一些。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代码主要依赖JSContext
和JSValue
类,一个 JSContext 是一个全局环境的实例。调用JS代码主要调用JSContext
的evaluteScript
方法,创建一个 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]; [context evaluateScript:[NSString stringWithContentsOfFile:path encoding:NSUTF8StringEncoding error:nil]]; JSValue *function = self.context[@"factorial"]; 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
| @class Person; @protocol PersonJSExports <JSExport> @property (nonatomic, copy) NSString *firstName; @property (nonatomic, copy) NSString *lastName; @property NSInteger ageToday; - (NSString *)getFullName; + (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 @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。这里我们需要设置的是WKWebViewConfiguration
的userContentController
属性。这个属性接收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
| 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
| 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)") } } }
|