
摘要
-
Native是如何給Web頁(yè)面提供可供Web調(diào)用的原生方法的 -
Web在執(zhí)行完Native提供的方法之后如何知道結(jié)果,回調(diào)數(shù)據(jù)怎么傳給Web -
Web端如何優(yōu)雅的使用Native提供的方法
背景
移動(dòng)端在原生和網(wǎng)頁(yè)的混合開(kāi)發(fā)模式下難免會(huì)有在網(wǎng)頁(yè)上調(diào)用原生能力的業(yè)務(wù)場(chǎng)景,比如操作相冊(cè)、本地文件,訪問(wèn)攝像頭等。如果原生和前端同學(xué)互相不了解對(duì)方的提供的方法的執(zhí)行機(jī)制,就很容易出現(xiàn)類似下面這些情況:
原生說(shuō)他提供了,前端說(shuō)沒(méi)有,調(diào)不到你的方法??
前端說(shuō)你的方法有問(wèn)題,你執(zhí)行完了都沒(méi)回調(diào)我,原生說(shuō)我回調(diào)你了啊??
原生或前端都會(huì)說(shuō):你怎么給了我一個(gè)字符串啊,我需要對(duì)象啊??
然后再一通調(diào)試,寫了各種看不下去的兼容代碼,終于能摘下痛苦面具了,趕緊測(cè)試完上線吧……
所以原因還是在雙方對(duì)彼此不了解導(dǎo)致的,下面就給大家伙兒把這里面的門道給說(shuō)明白!
Native是如何給Web頁(yè)面提供可供Web調(diào)用的原生方法的
Android和iOS的可供網(wǎng)頁(yè)調(diào)用的方法的方式是不一樣的,這里只對(duì)Android的webkit.WebView - addJavascriptInterface和iOS的WKWebView - evaluateJavaScript進(jìn)行剖析。這一段前端的同學(xué)可得搬個(gè)小板凳,拿個(gè)小本本好好記下來(lái)~
Android:webkit.WebView - addJavascriptInterface
首先拿Android上舉例吧,其實(shí)前端同學(xué)寫的網(wǎng)頁(yè)在App里面的運(yùn)行時(shí)就是一個(gè)WebView,通常情況下原生提供給前端的JS方法會(huì)維護(hù)一個(gè)專門給前端提供的有很多不同方法的一個(gè)類,端上會(huì)定義一個(gè)命名空間的字符串,把所有的這個(gè)類里面的方法都放到這個(gè)命名空間下面,然后把這個(gè)命名空間掛載到網(wǎng)頁(yè)的window對(duì)象也就是全局對(duì)象上,來(lái)段簡(jiǎn)單的例子代碼:
// ... 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 {
// 設(shè)置開(kāi)啟JavaScript能力
settings.javaScriptEnabled = true
// 添加提供給網(wǎng)頁(yè)的js方法,并把這些方法注入到AppInterface這個(gè)全局對(duì)象里面
addJavascriptInterface(WebAppFunctions(context, webview), "AppInterface")
// 指定URI,加載網(wǎng)頁(yè)
loadUrl("https://www.baidu.com")
}
}
}
// 一個(gè)提供可供網(wǎng)頁(yè)調(diào)用js方法的類
class WebAppFunctions(private val mContext: Context) {
/** 帶有這個(gè)@JavascriptInterface注解的方法都是提供給網(wǎng)頁(yè)調(diào)用的方法 */
/** 展示Toast */
@JavascriptInterface
fun showToast(toast: String) {
Toast.makeText(mContext, toast, Toast.LENGTH_SHORT).show()
}
}
當(dāng)這個(gè)WebviewActivity被創(chuàng)建之后,就會(huì)將所有的WebAppFunctions里面的有@JavascriptInterface注解的方法注入到網(wǎng)頁(yè)的window.AppInterface對(duì)象上,這個(gè)命名空間AppInterface就是上面我們addJavascriptInterface方法的第二個(gè)參數(shù),這個(gè)應(yīng)該是原生和網(wǎng)頁(yè)約定好的一個(gè)命名空間字符串,這個(gè)時(shí)候我們?cè)诰W(wǎng)頁(yè)上就可以通過(guò)這樣來(lái)調(diào)用原生提供給我們的showToast方法了:
window.AppInterface.showToast("Hi, I'm a Native's Toast!")
iOS:WKWebView - evaluateJavaScript
同樣的,前端的同學(xué)也要好好看下iOS的。相對(duì)于WKUserContentController可以給網(wǎng)頁(yè)注入方法,evaluateJavaScript既可以給網(wǎng)頁(yè)注入方法,也可以執(zhí)行網(wǎng)頁(yè)的回調(diào),所以一般使用evaluateJavaScript來(lái)處理和網(wǎng)頁(yè)的交互,舉個(gè)簡(jiǎn)單的??:
let userContent = WKUserContentController.init()
// 推薦約定一個(gè)命名空間,在這個(gè)命名空間下,通過(guò)解析Web端傳遞過(guò)來(lái)的參數(shù)中的方法名、數(shù)據(jù)和回調(diào)來(lái)處理不同的邏輯
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)實(shí)現(xiàn)發(fā)送到這里
func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
// WKScriptMessage有兩個(gè)屬性,一個(gè)是name一個(gè)是bady,name就是我們之前約定的AppInterface, body里面就是方法名(必選)、數(shù)據(jù)、回調(diào)網(wǎng)頁(yè)的方法名
if message.name == "AppInterface" {
let params = message.body
// 這里推薦約定args里面有兩個(gè)參數(shù),arg0、arg1,分別是參數(shù)和回調(diào)網(wǎng)頁(yè)的方法名(可選)
if (params["functionName"] == "showToast") {
// 執(zhí)行showToast操作
}
}
}
iOS中這種注入的方式提供給網(wǎng)頁(yè)上調(diào)用跟Android不同,需要前端這么來(lái)調(diào)用:
window.webkit.messageHandlers.AppInterface.postMessage({ functionName: "showToast" })
也就是說(shuō)前面的這部分window.webkit.messageHandlers.AppInterface.都是一樣的,調(diào)用的方法名、數(shù)據(jù)參數(shù)還有提供給原生回調(diào)我們的方法名都通過(guò)約定的postMessage中的參數(shù)進(jìn)行傳遞。
Web在執(zhí)行完Native提供的方法之后如何知道結(jié)果,回調(diào)數(shù)據(jù)怎么傳給Web
網(wǎng)頁(yè)和原生的交互除了這種簡(jiǎn)單直接的告訴原生你要干什么之外,還有其他的一些情況,比如選取本地相冊(cè)中的一個(gè)或者多個(gè)照片,這個(gè)時(shí)候問(wèn)題就變得復(fù)雜了,首先我可能需要有選取照片的類型,比如我只選1張照片和選多張照片是不同的,而且多張照片的情況下應(yīng)該有個(gè)上限,比如類似微信的最多選取9張這種,并且選取成功之后,網(wǎng)頁(yè)上還需要展示出來(lái)這些照片,這個(gè)時(shí)候就需要原生在選完照片之后告訴網(wǎng)頁(yè)選的都是哪些照片了。
舉個(gè)簡(jiǎn)單的例子:判斷一個(gè)對(duì)象中有沒(méi)有name這個(gè)屬性
Android:
// 同上面的...
class WebAppFunctions(private val mContext: Context, private val webview: WebView) {
/**
* 是否有name屬性
* @param obj: 傳進(jìn)來(lái)的序列化后的對(duì)象
* @param cbName: 執(zhí)行完成后回調(diào)js的方法名
* @return Boolean
*/
@JavascriptInterface
fun hasName(obj: String, cbName: String) {
// 將序列化后的對(duì)象反序列化為JSON對(duì)象
val data = JSONObject(obj)
// 判斷對(duì)象是否有name屬性
val result = data.has("name")
webview.post {
// 執(zhí)行JavaScript中的回調(diào)方法并將回調(diào)數(shù)據(jù)傳過(guò)去,執(zhí)行成功后打印日志
webview.evaluateJavascript("javascript:$cbName(${result})") {
Log.i("callbackExec", "success")
}
}
}
}
在網(wǎng)頁(yè)中的怎么調(diào)用這個(gè),怎么拿到回調(diào):
// 首先定義一個(gè)回調(diào)方法
window.nativeCallback = (res) => console.log(typeof res, res)
// 然后調(diào)用`AppInterface`上的`hasName`方法并按照約定將判斷的數(shù)據(jù)序列化后和回調(diào)方法名一并傳給原生
const params = JSON.stringify({ age: 18, name: 'ldl' })
window.AppInterface.hasName(params, 'nativeCallback')
// 執(zhí)行成功之后,回調(diào)就會(huì)回調(diào)我們的回調(diào)并打印相應(yīng)的結(jié)果
boolean true
iOS
原生代碼跟Android邏輯相同,比較簡(jiǎn)單的這里就忽略了。
在網(wǎng)頁(yè)中的怎么調(diào)用這個(gè),怎么拿到回調(diào):
// 同樣的先定義回調(diào)方法,并將數(shù)據(jù)序列化
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'
}
})
到這里,想必原生和網(wǎng)頁(yè)的同學(xué)都大致了解了對(duì)方的情況了,尤其是前端的同學(xué)應(yīng)該知道怎么調(diào)用原生的方法了,但是Android和iOS上調(diào)用同一個(gè)方法的寫法還不同,如果每次都要通過(guò)UA判斷再執(zhí)行不同的代碼也太麻煩了,而且回調(diào)都是掛在全局的window上的還有命名沖突和內(nèi)存泄漏的風(fēng)險(xiǎn)。所以我們最后聊一下如何在將調(diào)用Android、iOS的方法調(diào)用差異抹平,讓前端同學(xué)可以更加優(yōu)雅的調(diào)用原生方法!
Web端如何優(yōu)雅的使用Native提供的方法
根據(jù)我們之前的規(guī)范,所有原生提供的方法都屬于以下四種類型
- 無(wú)任何參數(shù)
- 僅有數(shù)據(jù)參
- 僅有回調(diào)參
- 既有數(shù)據(jù)參,也有回調(diào)參
我們要針對(duì)以上四種類型來(lái)做底層封裝,首先我們要解決哪些問(wèn)題:
- 不同端類型調(diào)用方式不同,如何通過(guò)封裝抹平這個(gè)差異
- 每次調(diào)用有回調(diào)的原生方法都需要在全局聲明一個(gè)函數(shù)供原生調(diào)用,會(huì)有命名沖突和內(nèi)存泄漏風(fēng)險(xiǎn)
- 回調(diào)我們的方法聲明在全局,需要在內(nèi)部處理很多判斷,我們?nèi)绾?strong>把回調(diào)的內(nèi)容抽離出來(lái)在不同的方法中處理
- 我們?cè)谡{(diào)試的時(shí)候怎么看到我調(diào)用的是什么方法,傳的參數(shù)是什么有沒(méi)有問(wèn)題,如何設(shè)計(jì)一個(gè)調(diào)用日志
首先我們把鍋燒熱(bushi
- 首先我們定義一個(gè)枚舉維護(hù)所有的原生提供的方法
export const enum NativeMethods {
/** 展示toast */
SHOW_TOAST: 'showToast',
/** 是否有name屬性 */
HAS_NAME: 'hasName',
// ....
}
- 維護(hù)一個(gè)原生方法和數(shù)據(jù)相關(guān)的類型聲明文件native.d.ts, 并聲明一個(gè)
iOS上的需要傳遞給postMessage方法的參數(shù)類型
declare name NATIVE {
type SimpleDataType = string | number | boolean | symbol | null | undefined | bigint
/** iOS原生方法參數(shù)接口 */
interface PostiOSNativeDataInterface {
functionName: NativeMethods
args?: {
arg0?: SimpleDataType
arg1?: string
}
}
}
- 定義一個(gè)
nativeFunctionWrapper方法,這個(gè)方法有三個(gè)參數(shù),第一個(gè)參數(shù)funcionName是方法名,第二個(gè)params是數(shù)據(jù)參數(shù),第三個(gè)是hasCallback是否有回調(diào),我們通過(guò)這個(gè)方法將不同端的方法調(diào)用差異抹平:
export function nativeFunctionWrapper(functionName: NativeMethods, params?: unknown, hasCallback?: boolean) {
const iOS = Boolean(navigator.userAgent.match(/\(i[^;]+;( U;)? CPU.+Mac OS X/))
// 如果有數(shù)據(jù)切數(shù)據(jù)是引用類型就將其序列化為字符串
let data = params
if (params && typeof params === 'object') data = JSON.stringify(params)
// 如果data不是undefined就是有參數(shù),void 0是為了得到安全的undefined, callbackName是提供給原生回調(diào)我們的方法名
const hasParams = data !== void 0,
callbackName = 'nativeCallback'
if (hasCallback) {
window[callbackName] = (res) => console.log(res)
}
if (isiOS) {
const postData: NATIVE.PostiOSNativeDataInterface = { functionName }
// 根據(jù)不同的情況構(gòu)建不同的參數(shù)
if (hasParams) {
postData.args = { arg0: data }
if (hasCallback) postData.args.arg1 = callbackName
} else if (hasCallback) postData.args = { arg0: callbackName }
// 判斷只有在真機(jī)上才執(zhí)行,我們?cè)陔娔X上的Chrome中調(diào)試的時(shí)候就不必調(diào)用執(zhí)行原生方法了
if (window.webkit) {
window.webkit.messageHandlers.AppInterface.postMessage(postData)
}
} else {
// 同樣的如果宿主環(huán)境沒(méi)有AppInterface就return
if (!window.AppInterface) return
// 根據(jù)不同的參數(shù)情況 走不同的執(zhí)行調(diào)用邏輯
if (hasData) {
hasCallback ? window.AppInterface[functionName](data, callbackName) : window.AppInterface[functionName](data)
} else if (hasCallback) {
window.AppInterface[functionName](callbackName)
} else {
window.AppInterface[functionName]()
}
}
}
- 上一步我們通過(guò)
nativeFunctionWrapper解決了我們的第一個(gè)問(wèn)題,抹平了不同端同個(gè)方案的調(diào)用差異,直接可以通過(guò)調(diào)用nativeFunctionWrapper指定方法名、參數(shù)和是否有回調(diào)即可調(diào)用不同端的方法。其實(shí)第二步里面我們還是將原生回調(diào)我們的方法寫死了,這樣肯定是有問(wèn)題的,我們現(xiàn)在來(lái)解決后面的問(wèn)題:
// 我們通過(guò)動(dòng)態(tài)的設(shè)置我們的回調(diào)函數(shù)的方法名來(lái)解決這個(gè)問(wèn)題,最后跟上時(shí)間戳拼接是為了防止有些方法可能調(diào)用的很頻繁,導(dǎo)致后面的回調(diào)數(shù)據(jù)還是走到第一個(gè)回調(diào)里面
const callbackName = `NativeFun_${functionName}_callback_${Date.now()}`
- 但是我們這么做又會(huì)有內(nèi)存泄漏,因?yàn)檎{(diào)用一次原生方法,就要往
window上添加一個(gè)函數(shù),我們來(lái)改造下回調(diào)函數(shù)體的內(nèi)容
const callbackName = `NativeFun_${functionName}_callback_${Date.now()}`
if (hasCallback) {
window[callbackName] = (res) => {
console.log(res)
// 釋放掛載的臨時(shí)函數(shù)
window[callbackName] = null
// 刪除臨時(shí)函數(shù)全局對(duì)象并返回undefined
void delete window[callbackName]
}
}
- 接下來(lái)我們來(lái)解決第三個(gè)問(wèn)題,把回調(diào)之后的邏輯抽離出來(lái),因?yàn)槲覀儸F(xiàn)在的方式,針對(duì)不同的回調(diào)拿到數(shù)據(jù)還是需要在
window[callbackName]內(nèi)部進(jìn)行判斷,這樣很不優(yōu)雅,我們來(lái)通過(guò)Promise對(duì)我們的nativeFunctionWrapper進(jìn)行改造:
export function nativeFunctionWrapper(functionName: NativeMethods, params?: unknown, hasCallback?: boolean) {
const iOS = Boolean(navigator.userAgent.match(/\(i[^;]+;( U;)? CPU.+Mac OS X/)),
const errInfo = `當(dāng)前環(huán)境不支持!`
return new Promise((resolve, reject) => {
// 如果有數(shù)據(jù)切數(shù)據(jù)是引用類型就將其序列化為字符串
let data = params
if (params && typeof params === 'object') data = JSON.stringify(params)
// 如果data不是undefined就是有參數(shù),void 0是為了得到安全的undefined, callbackName是提供給原生回調(diào)我們的方法名
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 }
// 根據(jù)不同的情況構(gòu)建不同的參數(shù)
if (hasParams) {
postData.args = { arg0: data }
if (hasCallback) postData.args.arg1 = callbackName
} else if (hasCallback) postData.args = { arg0: callbackName }
// 判斷只有在真機(jī)上才執(zhí)行,我們?cè)陔娔X上的Chrome中調(diào)試的時(shí)候就不必調(diào)用執(zhí)行原生方法了
if (window.webkit) {
window.webkit.messageHandlers.AppInterface.postMessage(postData)
if (!hasCallback) resolve(null)
} else reject(errInfo)
} else {
// 同樣的如果宿主環(huán)境沒(méi)有AppInterface就return
if (!window.AppInterface) return
// 根據(jù)不同的參數(shù)情況 走不同的執(zhí)行調(diào)用邏輯
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)
}
}
})
}
- 通過(guò)上面的這步改造,我們就將回調(diào)的邏輯抽離到Promise里面了,直接在
.then中拿原生回調(diào)我們的數(shù)據(jù)即可,到這里我們就幾乎完成所有的封裝工作了,最后我們給他添加一個(gè)調(diào)用日志打印的功能:
/** 原生方法調(diào)用日志 */
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))
這樣在你調(diào)用原生的方法的時(shí)候就可以看到詳細(xì)的調(diào)用信息了,是不是很nice~
經(jīng)過(guò)上面的改造,我們來(lái)看看我們現(xiàn)在該怎么調(diào)用
// 最終一步封裝后直接提供給各業(yè)務(wù)代碼調(diào)用
export function hasNameAtNative(params: unknown) {
return nativeFunctionWrapper(NativeMethods.HAS_NAME, params, true): Promise<boolean>
}
// 調(diào)用
const data = { age: 18, name: 'ldl' }
hasNameAtNative(data).then(res => {
console.log(`data is or not has name attr: `, res)
})
如果你和原生交互的數(shù)據(jù)類型比較復(fù)雜也可以在我們之前維護(hù)的native.d.ts文件中維護(hù)與原生交互的數(shù)據(jù)類型
總結(jié)
其實(shí)原生和網(wǎng)頁(yè)之間的交互沒(méi)有什么特別難搞的東西,但是想要把這部分內(nèi)容給規(guī)范化,工程化,還是要做不少工作的。也希望原生網(wǎng)頁(yè)一家親,大家核和平相處!大家如果有其他比較好的規(guī)范化這部分的方案也可以在評(píng)論里說(shuō)一下,如果對(duì)你有幫助,還望不要吝嗇你的三連。最后,有用請(qǐng)點(diǎn)贊,喜歡請(qǐng)關(guān)注,我是Senar(公號(hào)同名),謝謝各位!