如何设计通用WebAPI之Swift实现(一)

在App开发中,Web和Native的协作可谓是密不可分,因为web能快速迭代,更新,试错。在这之中,除了纯展示的页面,其他的几乎都会涉及到与Native的通信,调用native的功能实现业务需求。

常用做法

业界常用的做法就是web起个iframe,iframe设置src = 'xxx',通过自定义scheme,传入方法名,参数,来调起native的方法。比如要调起App的登录页,可能是这样写:

var i = document.createElement('iframe');
i.style.display = 'none';
i.src = 'myApp://gotoLogin?p={}';
document.body.appendChild(i);
   
if (i && i.parentNode) {
  //destory the iframe
  i.parentNode.removeChild(i);
}     

然后在webView的回调中(一般是写在vc中),做处理:

func webView(_ webView: UIWebView, shouldStartLoadWith request: URLRequest, navigationType: UIWebViewNavigationType) -> Bool {
 if (request.url.scheme == "myApp") {
    // 解析request.url,解出方法名及参数 
    // ...
  // 调用perfomSelector/NSInvocation来触发方法
  // ...
 }
}

对,这样就调起了登录页。但是如果web需要调起支付页面呢?这还不简单,iframe设置新的src,然后在oc中加个gotoPay的接口不就得了。

myApp://gotoPay

又有新接口,好,再加...

然后,在vc中包含webView的页面就会充斥着各种处理web call oc的url解析,及方法定义,就像下面这样。

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

#pragma mark - webAPi
- (void)gotoPay {}
- (void)webAPI1 {}

在web调用native的接口不多的时候,这样写都不是事。但是大体量的app,webAPI接口很多的时候,会有股浓浓的忧伤。这时候就需要考虑如何抽离出webAPI的解析及调用了。

需要解决的问题

  1. webAPI集中管理及通用性
  • 以上的方式十分的散乱,不利于管理。如果在其他的页面也要调用到这个webAPi,会需要再copy一遍。所以,我们需要在某个集中的地方处理这些webAPI的调用,并且可以复用,write once,can be called anywhere。
  • 要实现通用性,在native实现webAPI的参数应该是固定的,很简单,NSDictionary搞定,(⊙o⊙)…,或许还需要callback,下面会说到。
  1. web调用的封装性及通用性
  • web通过iframe调用oc的方式也需要封装起来,而不是在每个调用的地方都写上一段创建iframe的代码。只需引入一个js文件,调用其中已经封装好的callNative方法就好。
  • callNative需要定义一套通用规范,适合所有的场景。
  1. callNative的回调
    有时候,js在调用native方法后,会等待native的结果返回后,执行自己的处理。那么如何在oc中回调到js?

其实2,3在WebViewJavascriptBridge中都有实现,不过我还是想用swift给实现一遍。

解决方案

webAPI集中管理

在yy中,webAPI非常多。根据单一职责,需要单独的类来管理相关的接口,每个webAPI类会对应一个module。举个栗子,DataWebAPI,module=data,专门处理跟数据相关的东西,比如获取userId,获取appVersion等;UIWebAPI,module=ui,处理UI相关的,如跳转登录页,直播间,设置活动条frame等。

有了这些WebAPI,需要管理起来。这里采用注册的方式,将module与webAPI instance以key-value方式存储起来。在需要调用某个api时,找到对应module的WebAPI实例,进行分发。

func registerWebAPI(_ module: String, _ api: WebAPIProtocol) {}

当然,不注册也行,根据web传过来的module=UIWebAPI,利用runtime生成UIWebAPI实例,来调用。只不过我觉得注册会使得映射关系比较清晰,并且不依赖于类名。


webAPIManager.png
通用性
  • web调用接口的通用性
    web调用webAPI只需要按一个固定模式调用,传入相应的参数。上面提到了module的概念,所以web这边调用,只需传入module,method,param,callback就好。这里的callback其实是指的callbackId,下面会说到。
invokeClientMethod: function(module, name, parameters, callback) {}
  • native调用js接口通用性
invokeWebMethod: function(callback, params) {}
  • webAPI实现的通用性
    在native层实现的webAPI,也需要通用性,参数固定。考虑到有回调,所以webAPI接口参数是params+callback。
typealias SLCallback = (_ parameter: [String: Any]?) -> Void
func test(_ params:[String: AnyObject]?, callback: SLCallback?) {}
关于web调用的封装性

这里的封装性,比较简单。上面说到,只需要引入js文件(名字定为bridge.js),就可以调用webAPI。所以,只需要把对应的功能函数放到bridge.js文件就好。

如何调用callback

web调用native之后的回调,就是在调完webAPI后,native这边执行js function。因为没法把callback通过url的方式,传给native。所以做法是生成全局callbackId,将callbackId与function映射起来。然后传给native,native会生成一个block,参数为NSDictionary。执行完webAPI,通过invokeWebMethod回调js方法的时候,再把callbackId传回来,js这边找到对应的function执行。

js生成callbackId:

createGlobalFuncForCallback: function(callback){
   if (callback) {
       var name = '__GLOBAL_CALLBACK__' + (SLWebBridge.__GLOBAL_FUNC_INDEX__++);
       window[name] = function(){
           var args = arguments;
           var func = (typeof callback == "function") ? callback : window[callback];
           //we need to use setimeout here to avoid ui thread being frezzen
           setTimeout(function(){ func.apply(null, args); }, 0);
       };
       return name;
   }
   return null;
},

Native生成callback:

//cb,在js端是个id,根据id找到对应的function
let callbackId = url.objectForKey("cb")
if let callbackId = callbackId {
  // 生成callback
  callback = { result in
      guard let result = result else {
          return
      }
      
      // 将result-->string
      do {
        // 参数序列化成json
          let jsonData = try JSONSerialization.data(withJSONObject: result as Any, options: JSONSerialization.WritingOptions.prettyPrinted)
          
          var jsonString = String(data: jsonData, encoding: String.Encoding.utf8)
          
          jsonString = jsonString ?? "{}"
          
          let script = String(format: "SLWebBridge.invokeWebMethod(%@,%@);", callbackId, jsonString!)
          
          // 执行js方法
          webView.stringByEvaluatingJavaScript(from: script)
      } catch {
          print("json to string error")
      }
  }
}
自定义scheme

根据调用native的参数,module,method,param,callback可得到如下url。

myApp://module/method?p={\"a\":2}&cb='xxxx'

最终,web在调用invokeClientMethod的时候,将parameter转成string并encode,会生成callbackId。拼成url。

invokeClientMethod: function(module, name, parameters, callback) {
   var url = 'slwebbridge://' + module + '/' + name + '?p=' + encodeURIComponent(JSON.stringify(parameters || {}));
   
   if (callback) {
       var name;
       if (typeof callback == "function") {
         // 生成全局callbackId
           name = SLWebBridge.createGlobalFuncForCallback(callback);
       } else {
           name = callback;
       }
       
       url = url + '&cb=' + name;
   }
   console.log('[API]' + url);
   var r = SLWebBridge._openURL(url);
   return r ? r.result : null;
}

调用如下:

invokeClientMethod('ui','gotoLogin',{},function(params) {
 alert(params);
});

Native层的解析

webView回调方法中,如果判断是我们定义的scheme,则进行解析处理。
url的规范定义是scheme://host:port/path?query

  1. 解析module
    module对应起来就是host。
let module = url.host
  1. 解析method
    method对应为path。可通过pathComponent取出。
let pathComponents = url.pathComponents

pathComponents得到的是["/","path"],取pathComponents[1]即为method。

  1. 解析parameter
    parameter+callback对应为query。我写了个NSURL的extension,返回dict,可取出url query中的任意参数。
func scanParameters() -> [String: String]? {
       guard !self.isFileURL else {
           return nil
       }

       let scanner = Scanner(string: self.absoluteString)
       scanner.charactersToBeSkipped = CharacterSet(charactersIn:"&?")
       scanner.scanUpTo("?", into: nil)
       
       var dict = [String: String]()
       
       var temp: NSString?

       while scanner.scanUpTo("&", into: &temp) {
           let array = temp?.components(separatedBy: "=")
           if let array = array, array.count >= 2 {
               let key = array[0].removingPercentEncoding
               let value = array[1].removingPercentEncoding
               
               dict[key!] = value
           }
       }
       
       return dict
   }

parameter可以这样直接取。然后将jsonString转换成dict,便可得到具体的参数。

let jsonString = url.objectForKey("p")
  1. 解析callbackId
    由上一步的extension,可以取出callbackId。
let callbackId = url.objectForKey("cb")

若callbackId存在,在会在native端生成个SLCallback,传入到webAPI的callback参数中。在webAPI执行完后,将要返回给js的参数传入callback,再执行。

WebAPI的调用

在url中解析出了module,method,parameter,callbackId,如何调用呢?很显然,借助runtime,一切都解决了。在swift中,是没有runtime能力的,需要借助oc,所以这里我们定义的webAPI的类都是继承于NSObject。

上面我们说过,webAPI的module名和instance一一对应。只要通过module名,可取到webAPI的instance,然后得到方法的函数指针,传入参数进行调用即可。

取出webAPI instance:

//MARK: get webAPI
func webAPI(_ module: String) -> WebAPIProtocol? {
   let obj = apiDict[module]
   return obj
}

函数调用:

func callNativeMethod(name: String, parameter: [String: AnyObject]?, callback: SLCallback?) {
   let sel = name + ":callback:"
   let seletor = NSSelectorFromString(sel)
   
   guard self.responds(to: seletor) else {
       print("\(self) not responds \(sel)")
       return
   }
   
   let imp = self.method(for: seletor)
   
   if let imp = imp {
     // 定义函数类型
       typealias function = @convention(c) (AnyObject, Selector, [String: AnyObject]?, SLCallback?) -> Void
       
       // 转换类型
       let call = unsafeBitCast(imp, to: function.self)
       
       // 函数调用
       call(self, seletor, parameter, callback)
   }
   
   if let callback = callback {
       callback(nil)
   }
}

@convention(c),修饰函数类型,它指出了函数调用的约定,声明这是个c函数调用。

最后附上一张总的调用图。

bridge.png

End

至此,主要的流程就说完了。下一篇将细说下webAPI的定义,及这套方案如何融合到webView中。

github地址:SLWebBridge

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

推荐阅读更多精彩内容