Dog_Hybird的诞生
起因
开玩笑说“iOS搞不动了”,另外一方面iOS组的哥哥们给力,少一个我也妥妥的。又听闻web前端组来了一个不得了的人物,“老司机,带带我”这种机会不能错过,1个多月前就申请转web前端了。开始是苦涩的,学习CSS、JS...... 自生自灭、自我生长。自己不懂、老司机也忙根本飞不起来。
转机是后来老司机从业务中解脱了出来,计划自己搞一套Hybird开发框架,过程中会需要Native的同事协助,我也懂了那么一点点点点H5(也许是即将懂),So强势插入。
遇到的几个问题
Dog_Hybird这个名字,是我瞎起的。同事并没有制止我。
挑几个比较具体的问题讲讲,也就是在开发过程中思考得比较多的几个点,比较零散。
JS和Native交互模式选择
拦截url变化
之前项目中也有简单的js交互,通过jsbridge实现。在UIWebView的shouldStartLoadWithRequest方法里面捕获url的变化,解析出需要的参数,然后传给一个统一的处理方法。
这里主要约定的参数有:
function ---> 需要触发的事件名
args ---> 上面方法需传入的参数
callBackId ---> 执行方法后的回调ID,会作为回传参数的一部分
统一处理方法:
handleEvent ---> 根据function判断需要触发的事件
1 func webView(webView: UIWebView, shouldStartLoadWithRequest request: NSURLRequest, navigationType: UIWebViewNavigationType) -> Bool { 2 if let requestStr = request.URL?.absoluteString { 3 if requestStr.hasPrefix("hybrid://") { 4 5 let function = XXX 6 let args = XXX 7 let callBackId = XXX 8 9 self.handleEvent(function, args: args, callbackID: callBackId) 10 } 11 } 12 return true 13 }
方法注入
拦截url的方式其实能满足绝大部分需求,至少我还没遇到不能满足的。low是low,好用。但是有个问题,如上面所说 有一个 handleEvent 方法去判断需要触发的事件,比如web页面想触发一个log方法,需要向Native端传递一串参数,解析出来 function 为log,然后去触发本地的log方法。随着事件的丰富,此方法体积必然爆炸,就算你分模块写、分文件写,也只是看上去好看一些而已,代码量在那里。
那么怎样不low?舆论一致认为“方法注入”不low,高端、优雅。
引入JavaScriptCore(需iOS7及以上)。最简单的例子,在UIWebView的webViewDidStartLoad或者webViewDidFinishLoad方法里面创建/更新JSContext,将方法注入到此context中。
1 func webViewDidFinishLoad(webView: UIWebView) { 2 self.context = webView.valueForKeyPath("documentView.webView.mainFrame.javaScriptContext") as? JSContext 3 self.context.setObject(unsafeBitCast(##需注入的方法##, AnyObject.self), forKeyedSubscript: ##注入方法对应名称##) 4 }
方法以block的形式注入,另外还有通过实现协议的方式, 此处注入方法为一个特殊的类型:
@convention(block) String -> Bool
转换方法也有一个unsafeBitCast,让人多少有一些不安,还是OC写起来看着稳妥些,有兴趣的可以查一下OC的写法。
这样简单的注入后JS代码便可直接调用注入的方法,没有了之前的一个参数转换事件的过程,臃肿的handleEvent方法直接不需要了。是不是很高端很优雅?不过在后来完善框架的过程中,遇到个问题:
注入时机。如果你在webViewDidFinishLoad方法里面注入,那么如果是加载过程中就需要执行的方法怎么办?聪明的同学想到了”那就在webViewDidStartLoad方法里面注入啊“,确实这样能满足刚提出的需求。页面刷新之后又蒙逼了,注入方法失效了,又必须在webViewDidFinishLoad里面重新注入一次。关于页面刷新注入方法失效这个问题,网上很多人提出了,但是翻了很多页都没有很机智的解决办法。所以说啊老司机还是老司机,文章开头提到的老司机想到了一个办法。
1 func webViewDidFinishLoad(webView: UIWebView) { 2 self.context = webView.valueForKeyPath("documentView.webView.mainFrame.javaScriptContext") as? JSContext 3 self.context.setObject(unsafeBitCast(##需注入的方法##, AnyObject.self), forKeyedSubscript: ##注入方法对应名称##) 4 self.myWebView.stringByEvaluatingJavaScriptFromString("Hybrid.ready();") 5 }
交互是双向的嘛!在方法注入后调用一个JS方法 Hybird.ready() 告诉web页面”我准备好了“。这样web页会在方法注入完毕后再去执行一些setting方法。缺点就是比普通的方法要慢上几十毫秒,毕竟要等待方法注入完毕。
请求、回调方式
在之前的项目中使用JSBridge,通过给web端注入session的方式来传递用户信息,web端拿着session自己去请求信息。现采用web端下达指令让Native去请求然后将数据回传的方式,如下:
1 func demoApi(args: [String: AnyObject], callbackID: String) { 2 self.callBack(args, errno: 0, msg: "success", callback: callbackID) 3 } 4 5 func callBack(data:AnyObject, errno: Int, msg: String, callback: String) { 6 let data = ["data": data, 7 "errno": errno, 8 "msg": msg, 9 "callback": callback] 10 let dataString = self.toJSONString(data) 11 self.myWebView.stringByEvaluatingJavaScriptFromString(self.HybirdEvent + "(\(dataString));") 12 }
参数解释:
data ---> 网络请求结果、本地数据等回传信息
errno ---> 错误码,需要将Native端的错误码映射为和web端约定好的错误码
msg ---> 描述
callback ---> 回调ID,web端通过此参数才知道是从哪个方法回来的
本地资源路径
在项目目录中创建文件夹Group并不影响资源文件读取时的路径,想要有特定的路径,在引入文件时记得如下图选择。
加载本地文件的方法比较简单:
1 if let htmlPath = NSBundle.mainBundle().pathForResource(##本地文件路径##, ofType: "html") { 2 let url = NSURL(fileURLWithPath: htmlPath) 3 let request = NSURLRequest(URL: url) 4 self.webView.loadRequest(request) 5 }
页面跳转
iOS本身的push操作跳转到新页面后,前面的页面会保留在内存中,后退时便能pop到之前的页面,然后根据pop前的操作更新当前展示页面。然后web的所谓后退到前一页面其实都是通过 forward 指令下达的,都是新开一个UIWebView。但是这里需要达到和iOS本身pop一样的体验,所以需要自定义push动画,将web”回退“操作的push动画做成和原生的pop动画一致,让用户察觉不到逻辑上的差异。
因为我们从iOS7开始支持,所以自定义动画可以用 UIViewControllerAnimatedTransitioning 轻松完成,相关的代码很容易搜到。
这里要说的一点就是定义一个AnimateType对应用户感知到的push或pop(这里定义的pop实际上是push,只是自定义动画为pop)
1 enum AnimateType { 2 case Normal 3 case Push 4 case Pop 5 } 6 7 func navigationController(navigationController: UINavigationController, animationControllerForOperation operation: UINavigationControllerOperation, fromViewController fromVC: UIViewController, toViewController toVC: UIViewController) -> UIViewControllerAnimatedTransitioning? { 8 if operation == UINavigationControllerOperation.Push { 9 if self.animateType == .Pop { 10 return HybirdTransionPush()//这个为自定义的push动画,表现为pop样式 11 } 12 else { 13 return nil 14 } 15 } else { 16 return nil 17 } 18 }
加载本地资源
要加载本地资源,就要拦截请求来判断选择加载逻辑。
通过NSURLProtocol拦截请求并处理
这里用到NSURLProtocol中2个比较重要的方法。
判断请求是否为需要拦截的请求
1 override class func canInitWithRequest(request: NSURLRequest) -> Bool { 2 //如果被标记为已处理 直接跳过 3 if let hasHandled = NSURLProtocol.propertyForKey(DogHybirdURLProtocolHandled, inRequest: request) as? Bool where hasHandled == true { 4 print("重复的url == \(request.URL?.absoluteString)") 5 return false 6 } 7 if let url = request.URL?.absoluteString { 8 if url.hasPrefix(webAppBaseUrl) { 9 //从请求中解析出path 和 type 然后在 NSBundle.mainBundle() 和 NSSearchPathDirectory.DocumentDirectory 中查找 10 if let zipPath = NSBundle.mainBundle().pathForResource(path, ofType: type) { 11 if types.contains(type) { 12 //为需要处理的类型 types = ["html","js","css","jpg","png"] 13 return true 14 } 15 } 16 else { 17 let documentPaths = NSSearchPathForDirectoriesInDomains(NSSearchPathDirectory.DocumentDirectory, NSSearchPathDomainMask.UserDomainMask, true) 18 let documentPath = documentPaths[0] 19 let newPath = ##在document目录的路径## 20 let fileData = NSFileManager.defaultManager().contentsAtPath(documentPath + "/\(newPath).\(type)") 21 if fileData?.length > 0 { 22 return true 23 } 24 } 25 } 26 } 27 return false 28 }
对需要拦截的请求进行处理
1 override func startLoading() { 2 //标记请求 防止重复处理 3 let mutableReqeust: NSMutableURLRequest = self.request.mutableCopy() as! NSMutableURLRequest 4 NSURLProtocol.setProperty(true, forKey: DogHybirdURLProtocolHandled, inRequest: mutableReqeust) 5 dispatch_async(dispatch_get_main_queue()) { 6 if let url = self.request.URL?.absoluteString { 7 if url.hasPrefix(webAppBaseUrl) { 8 let path = ##请求path## 9 let type = ##请求type## 10 let client: NSURLProtocolClient = self.client! 11 12 var typeString = "" 13 switch type { 14 case "html": 15 typeString = "text/html" 16 break 17 case "js": 18 typeString = "application/javascript" 19 break 20 case "css": 21 typeString = "text/css" 22 break 23 case "jpg": 24 typeString = "image/jpeg" 25 break 26 case "png": 27 typeString = "image/png" 28 break 29 default: 30 break 31 } 32 let localUrl = ##先在DocumentDirectory中查找,如果不存在再在NSBundle.mainBundle()中查找## 33 let fileData = NSData(contentsOfFile: localUrl) 34 let url = NSURL(fileURLWithPath: localUrl) 35 let dataLength = fileData?.length ?? 0 36 let response = NSURLResponse(URL: url, MIMEType: typeString, expectedContentLength: dataLength, textEncodingName: "UTF-8") 37 client.URLProtocol(self, didReceiveResponse: response, cacheStoragePolicy: .NotAllowed) 38 client.URLProtocol(self, didLoadData: fileData!) 39 client.URLProtocolDidFinishLoading(self) 40 41 } 42 else { 43 print(">>>>> url不符合规则 <<<<<") 44 } 45 } 46 else { 47 print(">>>>> url字符串获取失败 <<<<<") 48 } 49 } 50 }
需要注意一些细节。拦截请求后只对特定的类型替换为本地缓存。比如更新静态资源请求是下载zip包,如果本地也存在此zip包,那么更新请求会被拦截导致更新失败。还有一点先在DocumentDirectory中查找缓存文件,如果不存在再在NSBundle.mainBundle()中查找。因为NSBundle.mainBundle()是应用打包时就打入app中的资源,而DocumentDirectory是后来下载的资源,所以优先使用Document路径下的资源。前几天工作强度比较大,头有点晕,最开始把更新资源也写入到NSBundle.mainBundle()中,文件一直读不到。这里还是提醒一下这个基础知识点,NSBundle.mainBundle()路径是没有权限操作的哟!还有新建请求的MIMEType,写错了资源会以纯文本的形式读取出来。刚入前端坑伤不起啊。