乐趣区

关于前端:住手你们不要再打了啦Native和Web应该和平相处啊

摘要

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

背景

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

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

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

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

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

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

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

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

Androidwebkit.WebViewaddJavascriptInterface

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

// ... import pageage

// webview 的 Activity
class 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:WKWebViewevaluateJavaScript

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

let userContent = WKUserContentController.init()
// 举荐约定一个命名空间,在这个命名空间下,通过解析 Web 端传递过去的参数中的办法名、数据和回调来解决不同的逻辑
userContent.add(self, name: "AppInterface")
let config = WKWebViewConfiguration.init()
config.userContentController = userContent

let wkWebView: WKWebView = WKWebView.init(frame: UIScreen.main.bounds, configuration: config)
wkWebView.navigationDelegate = self
wkWebView.uiDelegate = self
view.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(公号同名),谢谢各位!

退出移动版