一个网页除了可以运行在公共的浏览器上,也可以运行在 APP 端内的 WebView 组件上。由于这些 Hybrid Web 网页运行在一个相对封闭的环境里,所以 APP 本身可以向 WebView 中注入一些 JS 代码,对 Web 页面做定向增强(最典型的运用就是 JSBridge,提供了一道 Web <--> Native 通信的桥梁)。
英文版本:How to Locate the JS Code Injected into Hybrid Web Pages by Native
在绝大多数情况下,业务开发并不需要感知这些 Native 注入的代码,但是在一些 性能优化/链路排查 的情况下,就需要感知这些 Native 注入代码的时机和运行情况了,从而更好的定位问题。
由于 Chrome/Safari 的 debug 调试工具基本上是为 纯 Web 服务的,而且这个需求很小众,所以这个能力支持的并不是很好。这个小需求网络上没什么总结性的文章,ChatGPT 回答的也差强人意,正好这段时间也做了一些相关的工作,所以顺势就记下来,帮助某个有缘人。
直接查看 Native 代码
如果你对 Native WebView 的封装代码很熟悉,或者有一定的 Native 经验,直接阅读源码是最快的方式。这里我说几个最常用的 JS 注入 API:
iOS
iOS 主要关注这 3 个 API:
addScriptMessageHandler
- (void)addScriptMessageHandler:(id<WKScriptMessageHandler>)scriptMessageHandler
name:(NSString *)name;
通过这个方法可以给WKWebView环境中添加一个指定 name 的 JS 对象,前端可以调用该对象的 postMessage 方法,向客户端发送消息。前端类似于这样调用:
window.webkit.messageHandlers.<name>.postMessage(<messageBody>)
addUserScript
// WKUserContentController
- (void)addUserScript:(WKUserScript *)userScript;
可以用这个函数注入 JS 脚本字符串到 WKWebView 中。
evaluateJavaScript
// WKWebView
- (void)evaluateJavaScript:(NSString *)javaScriptString
completionHandler:(void (^)(id, NSError *error))completionHandler;
这个函数也可以在 WKWebView 上下文中运行一段 JS 代码。
其实还有很多注入函数,但常用的就这 3 个,其它的函数就是和他们有些细微的差别,感兴趣的可以直接看官方文档。
另外还需注意的是 JS 代码注入的时机,页面加载前还是页面加载后注入代码,带来的影响可能是大不一样的。而且这个 API 也特别多,可参考文档:WKNavigationDelegate,重点关注 didStartProvisionalNavigation 和 didFinishNavigation。
Android
Android 主要关注这 2 个 API:
addJavascriptInterface
/** Instantiate the interface and set the context. */
class WebAppInterface(private val mContext: Context) {
// 通过 @JavascriptInterface 注解,向 WebView 暴露 showToast 方法
@JavascriptInterface
fun showToast(toast: String) {
Toast.makeText(mContext, toast, Toast.LENGTH_SHORT).show()
}
}
val webView: WebView = findViewById(R.id.webview)
// "Android" 将会暴露在 Webview 的 window 变量上
webView.addJavascriptInterface(WebAppInterface(this), "Android")
然后前端直接调用即可:
<input type="button" value="Say hello" onClick="showAndroidToast('Hello Android!')" />
<script type="text/javascript">
function showAndroidToast(toast) {
window.Android.showToast(toast);
}
</script>
evaluateJavascript
public void evaluateJavascript (String script, ValueCallback<String> resultCallback)
类似于 iOS,也是在 WebView 上下文中运行一段 JS 脚本。
同样的,Android 也要注意 JS 代码注入的时机。API 太多了,参考这个链接:WebViewClient#public-methods,重点关注 onPageStarted 和 onPageFinished。
有能力阅读 Native 源代码是最理想的情况,但是现实一般很残酷:
- 绝大部分前端同学不懂 Native 代码
- 现在还存留的高流量 APP,基本都迭代 5 年以上了,代码一层一层糊成 💩 山,老师傅都得在里面绕半天
- 降本增笑,老师傅都没了
所以其实还有第 2 种基于观测的方案:利用 WebView Devtool 工具定位。
用 Web 调试工具定位
前面也说了,查看注入的 JS 代码是一个很小众的需求,所以调试工具并没有提供独立的查看面板,所有的能力都是拼拼凑凑起来的,而且部分能力 iOS 和 Android 互为补集 😓,整体上还有有些凌乱的。
如何远程调试 Web 页面,可以参考这篇文章:各种「真机远程调试」方法汇总
想远程调试 APP WebView 网页,需要在 Native 层开启调试能力
- iOS:16.4 以上版本需要设置 webView.isInspectable = true 开启远程调试
- Android:设置 setWebContentsDebuggingEnabled(true) 开启远程调试
Common
iOS 和 Android 都通用的方案有这么两种:
Debug
我们可以通过 debug 到关键代码,然后查看调用栈,找到注入代码:
iOS 利用 Safari Devtool 调试,查看方式如下:
Android 利用 Chrome Devtool 调试,查看方法如下:
Log
还有一种方法是在 Native 注入的 JS 代码中,加入 console.log
的调用,这样在注入代码运行的时候,可以从 Console 面板的资源引用找到注入脚本。
但是这个问题有个悖论:
- 一般注入的脚本为了不增加运行时性能负担,是不会加 log 调用的,所以一般没法用
- 如果开发者主动去注入的 JS 代码中加入 log,那说明他有一定的 native 经验,那为什么不直接看 native 代码呢?
所以这样方法更像一种辅助方案,用来配合其它方案一起排查问题。
iOS
iOS 有两种方式看注入的代码。
第一种是在「来源」的「附加脚本」里,可以在这里看到 Native 通过 addUserScript
注入的脚本,整整齐齐的,还是比较方便查看的。但有个问题是这里并不会列出 evaluateJavaScript
注入的代码。
这里就介绍第二种方法,那就是「全局搜索」。
Safari 的全局搜索功能,可以同时搜索 addUserScript
/evaluateJavaScript
/正常加载的资源
里的代码,所以如果你知道注入代码的一些关键信息,可以通过搜索的方式定位代码。
Android
Android 这里使用 Chrome Devtool 查看注入代码。用了这么久的 Chrome Devtool,我是第一次发现它做的不如 Safari 的地方,那就是上面 Safari Devtool 有的东西它都没有。
但是 Chrome Devtool Performance 可以曲线救国一下,我们可以通过性能录制得到一份性能分析火焰图,然后查看主线程的代码执行情况,一般是 Evalutae Script
阶段,然后再定位可能的执行时机,通过点击 Bar 展开的 Summary 面板,一般有个 VM
开头的文件,打开就可显示注入的 JS 代码:
总结
从上面内容可以看出定位「注入 JS 代码」还是挺麻烦的,需要用各种手段曲线救国,而且很多情况下各个技巧需要交叉使用才能定位到。希望我这篇文章可以帮助到一些开发者,减少 debug 内耗的时间。