摘要
Native 是如何给Web 页面提供可供Web 调用的原生方法的Web 在执行完Native 提供的方法之后如何知道结果,回调数据怎么传给Web Web 端如何优雅的使用Native 提供的方法
背景
移动端在原生和网页的混合开发模式下难免会有在网页上调用原生能力的业务场景,比如操作相册、本地文件,访问摄像头等。如果原生和前端同学互相不了解对方的提供的方法的执行机制,就很容易出现类似下面这些情况:
原生说他提供了,前端说没有,调不到你的方法😖
前端说你的方法有问题,你执行完了都没回调我,原生说我回调你了啊😠
原生或前端都会说:你怎么给了我一个字符串啊,我需要对象啊😭
然后再一通调试,写了各种看不下去的兼容代码,终于能摘下痛苦面具了,赶紧测试完上线吧……
所以原因还是在双方对彼此不了解导致的,下面就给大家伙儿把这里面的门道给说明白!
Native 是如何给Web 页面提供可供Web 调用的原生方法的
Android 和iOS 的可供网页调用的方法的方式是不一样的,这里只对Android 的webkit.WebView - addJavascriptInterface 和iOS 的WKWebView - evaluateJavaScript 进行剖析。这一段前端的同学可得搬个小板凳,拿个小本本好好记下来~
Android :webkit.WebView - addJavascriptInterface
首先拿Android 上举例吧,其实前端同学写的网页在App 里面的运行时就是一个WebView ,通常情况下原生提供给前端的JS 方法会维护一个专门给前端提供的有很多不同方法的一个类,端上会定义一个命名空间的字符串,把所有的这个类里面的方法都放到这个命名空间下面,然后把这个命名空间挂载到网页的window 对象也就是全局对象上,来段简单的例子代码:
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.run {
settings.javaScriptEnabled = true
addJavascriptInterface(WebAppFunctions(context, webview), "AppInterface")
loadUrl("https://www.baidu.com")
}
}
}
class WebAppFunctions(private val mContext: Context) {
@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()
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")!))
...
func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
if message.name == "AppInterface" {
let params = message.body
if (params["functionName"] == "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) {
@JavascriptInterface
fun hasName(obj: String, cbName: String) {
val data = JSONObject(obj)
val result = data.has("name")
webview.post {
webview.evaluateJavascript("javascript:$cbName(${result})") {
Log.i("callbackExec", "success")
}
}
}
}
在网页中的怎么调用这个,怎么拿到回调:
window.nativeCallback = (res) => console.log(typeof res, res)
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'
}
})
到这里,想必原生和网页的同学都大致了解了对方的情况了,尤其是前端的同学应该知道怎么调用原生的方法了,但是Android 和iOS 上调用同一个方法的写法还不同,如果每次都要通过UA 判断再执行不同的代码也太麻烦了,而且回调都是挂在全局的window上的还有命名冲突和内存泄漏的风险。所以我们最后聊一下如何在将调用Android 、iOS 的方法调用差异抹平,让前端同学可以更加优雅的调用原生方法!
Web 端如何优雅的使用Native 提供的方法
根据我们之前的规范,所有原生提供的方法都属于以下四种类型
- 无任何参数
- 仅有数据参
- 仅有回调参
- 既有数据参,也有回调参
我们要针对以上四种类型来做底层封装,首先我们要解决哪些问题:
- 不同端类型调用方式不同,如何通过封装抹平这个差异
- 每次调用有回调的原生方法都需要在全局声明一个函数供原生调用,会有命名冲突和内存泄漏风险
- 回调我们的方法声明在全局,需要在内部处理很多判断,我们如何把回调的内容抽离出来在不同的方法中处理
- 我们在调试的时候怎么看到我调用的是什么方法,传的参数是什么有没有问题,如何设计一个调用日志
首先我们把锅烧热(bushi
- 首先我们定义一个枚举维护所有的原生提供的方法
export const enum NativeMethods {
SHOW_TOAST: 'showToast',
HAS_NAME: 'hasName',
}
- 维护一个原生方法和数据相关的类型声明文件native.d.ts, 并声明一个
iOS 上的需要传递给postMessage 方法的参数类型
declare name NATIVE {
type SimpleDataType = string | number | boolean | symbol | null | undefined | bigint
interface PostiOSNativeDataInterface {
functionName: NativeMethods
args?: {
arg0?: SimpleDataType
arg1?: string
}
}
}
- 定义一个
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)
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 }
if (window.webkit) {
window.webkit.messageHandlers.AppInterface.postMessage(postData)
}
} else {
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]()
}
}
}
- 上一步我们通过
nativeFunctionWrapper 解决了我们的第一个问题,抹平了不同端同个方案的调用差异,直接可以通过调用nativeFunctionWrapper 指定方法名、参数和是否有回调即可调用不同端的方法。其实第二步里面我们还是将原生回调我们的方法写死了,这样肯定是有问题的,我们现在来解决后面的问题:
const callbackName = `NativeFun_${functionName}_callback_${Date.now()}`
- 但是我们这么做又会有内存泄漏,因为调用一次原生方法,就要往
window 上添加一个函数,我们来改造下回调函数体的内容
const callbackName = `NativeFun_${functionName}_callback_${Date.now()}`
if (hasCallback) {
window[callbackName] = (res) => {
console.log(res)
window[callbackName] = null
void delete window[callbackName]
}
}
- 接下来我们来解决第三个问题,把回调之后的逻辑抽离出来,因为我们现在的方式,针对不同的回调拿到数据还是需要在
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)
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 }
if (window.webkit) {
window.webkit.messageHandlers.AppInterface.postMessage(postData)
if (!hasCallback) resolve(null)
} else reject(errInfo)
} else {
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)
}
}
})
}
- 通过上面的这步改造,我们就将回调的逻辑抽离到Promise里面了,直接在
.then 中拿原生回调我们的数据即可,到这里我们就几乎完成所有的封装工作了,最后我们给他添加一个调用日志打印的功能:
function NativeMethodInvokedLog(clientType: unknown, functionName: unknown, params: unknown, callbackName: unknown) {
this.clientType = clientType
this.functionName = functionName
this.params = params
this.calllbackName = callbackName
}
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 (公号同名),谢谢各位!
|