摘要

  1. Native是如何给Web页面提供可供Web调用的原生办法的
  2. Web在执行完Native提供的办法之后如何晓得后果,回调数据怎么传给Web
  3. Web端如何优雅的应用Native提供的办法

背景

挪动端在原生和网页的混合开发模式下难免会有在网页上调用原生能力的业务场景,比方操作相册、本地文件,拜访摄像头等。如果原生和前端同学相互不理解对方的提供的办法的执行机制,就很容易呈现相似上面这些状况:

原生说他提供了,前端说没有,调不到你的办法

前端说你的办法有问题,你执行完了都没回调我,原生说我回调你了啊

原生或前端都会说:你怎么给了我一个字符串啊,我须要对象啊

而后再一通调试,写了各种看不下去的兼容代码,终于能摘下苦楚面具了,连忙测试完上线吧……

所以起因还是在单方对彼此不理解导致的,上面就给大家伙儿把这外面的门道给说明确!

Native是如何给Web页面提供可供Web调用的原生办法的

AndroidiOS的可供网页调用的办法的形式是不一样的,这里只对Androidwebkit.WebView - addJavascriptInterfaceiOSWKWebView - evaluateJavaScript进行分析。这一段前端的同学可得搬个小板凳,拿个小本本好好记下来~

Androidwebkit.WebView - addJavascriptInterface

首先拿Android上举例吧,其实前端同学写的网页在App外面的运行时就是一个WebView,通常状况下原生提供给前端的JS办法会保护一个专门给前端提供的有很多不同办法的一个类,端上会定义一个命名空间的字符串,把所有的这个类外面的办法都放到这个命名空间上面,而后把这个命名空间挂载到网页的window对象也就是全局对象上,来段简略的例子代码:

// ... import pageage// webview的Activityclass WebviewActivity : AppCompatActivity() {    override fun onCreate(savedInstanceState: Bundle?) {        super.onCreate(savedInstanceState)        setContentView(R.layout.activity_webview)        WebView.setWebContentsDebuggingEnabled(true)        val webview = WebView(this)        val context = this        setContentView(webview)        // 指定webview都要干什么        webview.run {            // 设置开启JavaScript能力            settings.javaScriptEnabled = true            // 增加提供给网页的js办法,并把这些办法注入到AppInterface这个全局对象外面            addJavascriptInterface(WebAppFunctions(context, webview), "AppInterface")            // 指定URI,加载网页            loadUrl("https://www.baidu.com")        }    }}// 一个提供可供网页调用js办法的类class WebAppFunctions(private val mContext: Context) {    /**  带有这个@JavascriptInterface注解的办法都是提供给网页调用的办法 */        /** 展现Toast */    @JavascriptInterface    fun showToast(toast: String) {        Toast.makeText(mContext, toast, Toast.LENGTH_SHORT).show()    }}

当这个WebviewActivity被创立之后,就会将所有的WebAppFunctions外面的有@JavascriptInterface注解的办法注入到网页的window.AppInterface对象上,这个命名空间AppInterface就是下面咱们addJavascriptInterface办法的第二个参数,这个应该是原生和网页约定好的一个命名空间字符串,这个时候咱们在网页上就能够通过这样来调用原生提供给咱们的showToast办法了:

window.AppInterface.showToast("Hi, I'm a Native's Toast!")

iOS:WKWebView - evaluateJavaScript

同样的,前端的同学也要好好看下iOS的。绝对于WKUserContentController能够给网页注入办法,evaluateJavaScript既能够给网页注入办法,也能够执行网页的回调,所以个别应用evaluateJavaScript来解决和网页的交互,举个简略的:

let userContent = WKUserContentController.init()// 举荐约定一个命名空间,在这个命名空间下,通过解析Web端传递过去的参数中的办法名、数据和回调来解决不同的逻辑userContent.add(self, name: "AppInterface")let config = WKWebViewConfiguration.init()config.userContentController = userContentlet wkWebView: WKWebView = WKWebView.init(frame: UIScreen.main.bounds, configuration: config)wkWebView.navigationDelegate = selfwkWebView.uiDelegate = selfview.addSubview(wkWebView)view.insertSubview(wkWebView, at: 0)wkWebView.load(URLRequest.init(url: URL.init(string: "https://www.baidu.com")!))...// 代理办法,window.webkit.messageHandlers.AppInterface.postMessage(xxx)实现发送到这里func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {    // WKScriptMessage有两个属性,一个是name一个是bady,name就是咱们之前约定的AppInterface, body外面就是办法名(必选)、数据、回调网页的办法名    if message.name == "AppInterface" {        let params = message.body        // 这里举荐约定args外面有两个参数,arg0、arg1,别离是参数和回调网页的办法名(可选)        if (params["functionName"] == "showToast") {          // 执行showToast操作        }    }}

iOS中这种注入的形式提供给网页上调用跟Android不同,须要前端这么来调用:

window.webkit.messageHandlers.AppInterface.postMessage({ functionName: "showToast" })

也就是说后面的这部分window.webkit.messageHandlers.AppInterface.都是一样的,调用的办法名、数据参数还有提供给原生回调咱们的办法名都通过约定的postMessage中的参数进行传递。

Web在执行完Native提供的办法之后如何晓得后果,回调数据怎么传给Web

网页和原生的交互除了这种简略间接的通知原生你要干什么之外,还有其余的一些状况,比方选取本地相册中的一个或者多个照片,这个时候问题就变得复杂了,首先我可能须要有选取照片的类型,比方我只选1照片和选多张照片是不同的,而且多张照片的状况下应该有个下限,比方相似微信的最多选取9这种,并且选取胜利之后,网页上还须要展现进去这些照片,这个时候就须要原生在选完照片之后通知网页选的都是哪些照片了。

举个简略的例子:判断一个对象中有没有name这个属性

Android:

// 同下面的...class WebAppFunctions(private val mContext: Context, private val webview: WebView) {        /**     * 是否有name属性     * @param obj: 传进来的序列化后的对象     * @param cbName: 执行实现后回调js的办法名     * @return Boolean     */    @JavascriptInterface    fun hasName(obj: String, cbName: String) {        // 将序列化后的对象反序列化为JSON对象        val data = JSONObject(obj)        // 判断对象是否有name属性        val result = data.has("name")        webview.post {            // 执行JavaScript中的回调办法并将回调数据传过来,执行胜利后打印日志            webview.evaluateJavascript("javascript:$cbName(${result})") {                Log.i("callbackExec", "success")            }        }    }}

在网页中的怎么调用这个,怎么拿到回调:

// 首先定义一个回调办法window.nativeCallback = (res) => console.log(typeof res, res)// 而后调用`AppInterface`上的`hasName`办法并依照约定将判断的数据序列化后和回调办法名一并传给原生const params = JSON.stringify({ age: 18, name: 'ldl' })window.AppInterface.hasName(params, 'nativeCallback')// 执行胜利之后,回调就会回调咱们的回调并打印相应的后果boolean true

iOS

原生代码跟Android逻辑雷同,比较简单的这里就疏忽了。

在网页中的怎么调用这个,怎么拿到回调:

// 同样的先定义回调办法,并将数据序列化window.nativeCallback = (res) => console.log(typeof res, res)const params = JSON.stringify({ age: 18, name: 'ldl' })window.webkit.messageHandlers.AppInterface.postMessage({  functionName: 'hasName',  args: {    arg0: params,    arg1: 'nativeCallback'  }})

到这里,想必原生和网页的同学都大抵理解了对方的状况了,尤其是前端的同学应该晓得怎么调用原生的办法了,然而AndroidiOS上调用同一个办法的写法还不同,如果每次都要通过UA判断再执行不同的代码也太麻烦了,而且回调都是挂在全局的window上的还有命名抵触和内存透露的危险。所以咱们最初聊一下如何在将调用AndroidiOS的办法调用差别抹平,让前端同学能够更加优雅的调用原生办法!

Web端如何优雅的应用Native提供的办法

依据咱们之前的标准,所有原生提供的办法都属于以下四种类型

  1. 无任何参数
  2. 仅有数据参
  3. 仅有回调参
  4. 既有数据参,也有回调参

咱们要针对以上四种类型来做底层封装,首先咱们要解决哪些问题:

  1. 不同端类型调用形式不同,如何通过封装抹平这个差别
  2. 每次调用有回调的原生办法都须要在全局申明一个函数供原生调用,会有命名抵触内存透露危险
  3. 回调咱们的办法申明在全局,须要在外部解决很多判断,咱们如何把回调的内容抽离进去在不同的办法中解决
  4. 咱们在调试的时候怎么看到我调用的是什么办法,传的参数是什么有没有问题,如何设计一个调用日志

首先咱们把锅烧热(bushi

  1. 首先咱们定义一个枚举保护所有的原生提供的办法

    export const enum NativeMethods {  /** 展现toast */  SHOW_TOAST: 'showToast',  /** 是否有name属性 */  HAS_NAME: 'hasName',  // ....}
  2. 保护一个原生办法和数据相干的类型申明文件native.d.ts, 并申明一个iOS上的须要传递给postMessage办法的参数类型

    declare name NATIVE {  type SimpleDataType = string | number | boolean | symbol | null | undefined | bigint  /** iOS原生办法参数接口 */  interface PostiOSNativeDataInterface { functionName: NativeMethods args?: {   arg0?: SimpleDataType   arg1?: string }  }}
  3. 定义一个nativeFunctionWrapper办法,这个办法有三个参数,第一个参数funcionName是办法名,第二个params是数据参数,第三个是hasCallback是否有回调,咱们通过这个办法将不同端的办法调用差别抹平:
export function nativeFunctionWrapper(functionName: NativeMethods, params?: unknown, hasCallback?: boolean) {  const iOS = Boolean(navigator.userAgent.match(/\(i[^;]+;( U;)? CPU.+Mac OS X/))  // 如果有数据切数据是援用类型就将其序列化为字符串  let data = params  if (params && typeof params === 'object') data = JSON.stringify(params)  // 如果data不是undefined就是有参数,void 0是为了失去平安的undefined, callbackName是提供给原生回调咱们的办法名  const hasParams = data !== void 0,    callbackName = 'nativeCallback'  if (hasCallback) {    window[callbackName] = (res) => console.log(res)  }      if (isiOS) {    const postData: NATIVE.PostiOSNativeDataInterface = { functionName }    // 依据不同的状况构建不同的参数    if (hasParams) {      postData.args = { arg0: data }      if (hasCallback) postData.args.arg1 = callbackName    } else if (hasCallback) postData.args = { arg0: callbackName }    // 判断只有在真机上才执行,咱们在电脑上的Chrome中调试的时候就不用调用执行原生办法了    if (window.webkit) {      window.webkit.messageHandlers.AppInterface.postMessage(postData)    }  } else {    // 同样的如果宿主环境没有AppInterface就return    if (!window.AppInterface) return    // 依据不同的参数状况 走不同的执行调用逻辑    if (hasData) {      hasCallback ? window.AppInterface[functionName](data, callbackName) : window.AppInterface[functionName](data)    } else if (hasCallback) {      window.AppInterface[functionName](callbackName)    } else {      window.AppInterface[functionName]()    }  }}
  1. 上一步咱们通过nativeFunctionWrapper解决了咱们的第一个问题,抹平了不同端同个计划的调用差别,间接能够通过调用nativeFunctionWrapper指定办法名、参数和是否有回调即可调用不同端的办法。其实第二步外面咱们还是将原生回调咱们的办法写死了,这样必定是有问题的,咱们当初来解决前面的问题:

    // 咱们通过动静的设置咱们的回调函数的办法名来解决这个问题,最初跟上工夫戳拼接是为了避免有些办法可能调用的很频繁,导致前面的回调数据还是走到第一个回调外面const callbackName = `NativeFun_${functionName}_callback_${Date.now()}`
  2. 然而咱们这么做又会有内存透露,因为调用一次原生办法,就要往window上增加一个函数,咱们来革新下回调函数体的内容

    const callbackName = `NativeFun_${functionName}_callback_${Date.now()}`if (hasCallback) {  window[callbackName] = (res) => { console.log(res) // 开释挂载的长期函数 window[callbackName] = null // 删除长期函数全局对象并返回undefined void delete window[callbackName]  }}
  3. 接下来咱们来解决第三个问题,把回调之后的逻辑抽离进去,因为咱们当初的形式,针对不同的回调拿到数据还是须要在window[callbackName]外部进行判断,这样很不优雅,咱们来通过Promise对咱们的nativeFunctionWrapper进行革新:

    export function nativeFunctionWrapper(functionName: NativeMethods, params?: unknown, hasCallback?: boolean) {  const iOS = Boolean(navigator.userAgent.match(/\(i[^;]+;( U;)? CPU.+Mac OS X/)), const errInfo = `以后环境不反对!`  return new Promise((resolve, reject) => { // 如果有数据切数据是援用类型就将其序列化为字符串 let data = params if (params && typeof params === 'object') data = JSON.stringify(params) // 如果data不是undefined就是有参数,void 0是为了失去平安的undefined, callbackName是提供给原生回调咱们的办法名 const hasParams = data !== void 0,   callbackName = `NativeFun_${functionName}_callback_${Date.now()}` if (hasCallback) {   window[callbackName] = (res: string) => {     resolve(res)     window[callbackName] = null     void delete window[callbackName]   } } if (isiOS) {   const postData: NATIVE.PostiOSNativeDataInterface = { functionName }   // 依据不同的状况构建不同的参数   if (hasParams) {     postData.args = { arg0: data }     if (hasCallback) postData.args.arg1 = callbackName   } else if (hasCallback) postData.args = { arg0: callbackName }   // 判断只有在真机上才执行,咱们在电脑上的Chrome中调试的时候就不用调用执行原生办法了   if (window.webkit) {     window.webkit.messageHandlers.AppInterface.postMessage(postData)     if (!hasCallback) resolve(null)   } else reject(errInfo) } else {   // 同样的如果宿主环境没有AppInterface就return   if (!window.AppInterface) return   // 依据不同的参数状况 走不同的执行调用逻辑   if (hasData) {     hasCallback ? window.AppInterface[functionName](data, callbackName) : window.AppInterface[functionName](data)   } else if (hasCallback) {     window.AppInterface[functionName](callbackName)   } else {     window.AppInterface[functionName]()     resolve(null)   } }  })}
  4. 通过下面的这步革新,咱们就将回调的逻辑抽离到Promise外面了,间接在.then中拿原生回调咱们的数据即可,到这里咱们就简直实现所有的封装工作了,最初咱们给他增加一个调用日志打印的性能:

    /** 原生办法调用日志 */function NativeMethodInvokedLog(clientType: unknown, functionName: unknown, params: unknown, callbackName: unknown) {  this.clientType = clientType  this.functionName = functionName  this.params = params  this.calllbackName = callbackName}// 在`nativeFunctionWrapper`中判断是否是`iOS`的后面加上上面这句代码console.table(new NativeMethodInvokedLog(`${isiOS ? 'iOS' : 'Android'}`, functionName, data, callbackName))

    这样在你调用原生的办法的时候就能够看到具体的调用信息了,是不是很nice~

通过下面的革新,咱们来看看咱们当初该怎么调用

// 最终一步封装后间接提供给各业务代码调用export function hasNameAtNative(params: unknown) {  return nativeFunctionWrapper(NativeMethods.HAS_NAME, params, true): Promise<boolean>}// 调用const data = { age: 18, name: 'ldl' }hasNameAtNative(data).then(res => {  console.log(`data is or not has name attr: `, res)})

如果你和原生交互的数据类型比较复杂也能够在咱们之前保护的native.d.ts文件中保护与原生交互的数据类型

总结

其实原生和网页之间的交互没有什么特地难搞的货色,然而想要把这部分内容给规范化,工程化,还是要做不少工作的。也心愿原生网页一家亲,大家和平相处!大家如果有其余比拟好的规范化这部分的计划也能够在评论里说一下,如果对你有帮忙,还望不要悭吝你的三连。最初,有用请点赞,喜爱请关注,我是Senar(公号同名),谢谢各位!