住手!你們不要再打了啦!Native和Web應該和平相處啊

Senar發表於2022-03-12

摘要

  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的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:WKWebView - evaluateJavaScript

同樣的,前端的同學也要好好看下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(公號同名),謝謝各位!

相關文章