关于调整 cordova.js 注入时机的讨论


Cordova 是一个轻量级的移动端混合开发框架,在公司项目中担任桥接器的作用,为前端提供一些原生底层能力,在“插件”中以异步的形式将执行结果返回给前端。考虑到前端的页面可能在本地、远程、跨各种域等等问题,一般的做法是把
www/cordova.js
www/cordova_plugins.js…
www/plugins/*.js

这一连串的 js 放在原生的工程中,由原生动态注入到页面中。功能方面没什么问题,但是插件的调用性能却一直被前端的同事诟病:目前只能等到 cordova 发出 “deviceready” 通知以后,才可以正常调用插件功能。这导致页面想使用插件的信息预加载某些内容时(例如提前做认证等等)没办法正常访问到插件,会提示 Undefine…肉眼可见的效果就是页面前期会白页一两秒。

这两天抽空做了一下 Android 端的尝试,想要的效果就是尽量提前完成 cordova 相关 js 的注入。Demo 中达到了我预期的效果,高兴的一晚上没睡着觉,但是,Android 和前端我只是略懂皮毛,不确定测试的方案有没有问题、应对复杂的场景是否可行,所以写在这里做一个讨论,希望有共同需求的朋友一起探讨一下。

Android 端所作的尝试

Demo 在此。

尝试前

搜了一下 Android 端注入 js 的方式,大部分都是采用下面的方案(目测此方案是对标自 GitHub 上的一个 InjectCordova 插件):
1、监听 WebView 的 onPageFinished 事件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
//监听 onPageFinished 事件
public class MainActivity extends CordovaActivity
{
...
@Override
public Object onMessage(String id, Object data) {
if (id.equals("onPageFinished")) {
injectCordova(mEngine); //标签注入
}

return null;
}
...
}

2、拼接一个 <script> 标签 - 通过 document.createElement() 方法创建一个 <script> 标签,标签内容是所有要注入的 js 源码拼接而成的字符串,再使用 appendChild() 方法把 <script> 拼接在 <head> 标签的后面:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
public class MainActivity extends CordovaActivity
{
...
private final ArrayList<String> preInjectionFileNames = new ArrayList<String>();

private void injectCordova(CordovaWebViewEngine engine) {
List<String> jsPaths = new ArrayList<String>();
for (String path: preInjectionFileNames) {
jsPaths.add(path);
}

jsPaths.add("www/cordova.js");
jsPaths.addAll(jsPathsToInject(getResources().getAssets(), "www/plugins"));
jsPaths.add("www/cordova_plugins.js");

StringBuilder jsToInject = new StringBuilder();
for (String path: jsPaths) {
jsToInject.append(readFile(getResources().getAssets(), path));
}
String jsUrl = "javascript:var script = document.createElement('script');";
jsUrl += "script.src=\"data:text/javascript;charset=utf-8;base64,";

jsUrl += Base64.encodeToString(jsToInject.toString().getBytes(), Base64.NO_WRAP);
jsUrl += "\";";

jsUrl += "document.getElementsByTagName('head')[0].appendChild(script);";

//webView.getEngine().loadUrl(jsUrl, false);
engine.loadUrl(jsUrl, false);//false参数表示,不重启plugin,用于加载js库文件
}

private String readFile(AssetManager assets, String filePath) {
...
//详见 Demo。
...
}

private List<String> jsPathsToInject(AssetManager assets, String path) {
...
//详见 Demo。
...
}
...
}

3、使用 WebView.loadUrl() 方法注入拼接后的 js。

上面的方案涉及到了获取、拼接标签元素,如果想在 WebView 加载完成之前提前注入似乎是不太现实的,因为时机太早的话,是拿不到标签的。

尝试后

读了一下 Android WebView 的 API 文档,貌似能够实现 js 注入的方法只有两个:loadUrl(String url)evaluateJavascript(String, ValueCallback)。考虑到 cordova 相关 js 中一般不会针对前端标签做什么操作,是在页面中声明一个名为 cordova 的“模块”,这个模块又定义了若干个插件模块和方法,即使是自己写的插件,目的也是为了能让前端使用原生能力(相机、相册等等),那是否不需要等到 WebView 把 DOM 的内容都渲染完成才可以开始注入?于是做了如下的尝试:

与之前的方案相似,依旧是循环读取各个 js 中的源码,但不再拼接 <script> 标签,而是使用 evaluateJavascript(String, ValueCallback) 将每段 js 注入到页面中,并且注入时机提前到 onPageStarted

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
public class MainActivity extends CordovaActivity
{
...
@Override
public Object onMessage(String id, Object data) {
if (id.equals("onPageStarted")) {
evaluateCordovaJavascript(mWebView); //执行 js 注入
}

return null;
}

public void evaluateCordovaJavascript(WebView webView) {
List<String> jsPaths = new ArrayList<String>();
for (String path: preInjectionFileNames) {
jsPaths.add(path);
}

jsPaths.add("www/cordova.js");
jsPaths.addAll(jsPathsToInject(getResources().getAssets(), "www/plugins"));
jsPaths.add("www/cordova_plugins.js");

for (String path: jsPaths) {
String js = readFile(getResources().getAssets(), path);
webView.evaluateJavascript(js, null);
}
}
...
}

接下来我们做验证,在 index.js 中添加如下测试代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
//正常情况下需监听到 cordova 的 deviceready 通知后,才可以调用插件方法:
document.addEventListener('deviceready', onDeviceReady, false);

function onDeviceReady() {
// Cordova is now initialized. Have fun!
console.log('YY: Running cordova - ' + cordova.platformId + '@' + cordova.version);
document.getElementById('deviceready').classList.add('ready');
testMyPlugin('Device Ready: ');
}

//这里我们用一个 IIFE - 立即调用函数,使得 index.js 加载的时候立即执行这个方法来调用 cordova 的插件:
(
function () {
console.log('YY: Just do IT!');
testMyPlugin('Device Start: ');
}
)();

function testMyPlugin(tag) {
MPKeyChain.getValueForKey(
'username',
function success(result) {
console.log('YY: '+ tag + result);
},
function error(error) {
console.log('YY: '+ tag + "获取用户信息失败");
}
);
MPKeyChain.getServerUrl(
function success(result) {
console.log('YY: '+ tag + result);
},
function error(error) {
console.log('YY: '+ tag + "获取服务器信息失败");
}
);
}

根据 log 输出的结果,确实像我所预期的效果,是可以提前注入的:

1
2
3
4
5
6
"YY: Just do IT!", source: https://localhost/js/index.js (39)
"YY: Device Start: zhengyt", source: https://localhost/js/index.js (49)
"YY: Device Start: https://tommygirl.cn", source: https://localhost/js/index.js (59)
"YY: Running cordova - android@10.1.1", source: https://localhost/js/index.js (70)
"YY: Device Ready: zhengyt", source: https://localhost/js/index.js (49)
"YY: Device Ready: https://tommygirl.cn", source: https://localhost/js/index.js (59)

进行到这一步的时候,有两点我不太确定:一个是 Android WebView 提供的 onPageStarted、onPageFinished 事件与 DOM 的各个生命周期 DOMContentLoaded、Load 事件之间的关系,二是在 DOM 的生命周期完成之前注入 js 是否存在什么我不懂的问题。

在 index.js 中增加了对 DOM 生命周期的监听,打印了一下这些事件的顺序:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
2022-04-15 11:25:26.259 I/System.out: YY: onProgressChanged - 10
2022-04-15 11:25:26.321 I/System.out: YY: onMessage - onPageStarted
2022-04-15 11:25:26.355 I/System.out: YY: onMessage - spinner
2022-04-15 11:25:26.361 I/chromium: [INFO:CONSOLE(39)] "YY: Just do IT!", source: https://localhost/js/index.js (39)
2022-04-15 11:25:26.363 I/chromium: [INFO:CONSOLE(49)] "YY: Device Start: zhengyt", source: https://localhost/js/index.js (49)
2022-04-15 11:25:26.363 I/chromium: [INFO:CONSOLE(59)] "YY: Device Start: https://tommygirl.cn", source: https://localhost/js/index.js (59)
2022-04-15 11:25:26.364 I/chromium: [INFO:CONSOLE(33)] "YY: Ready state change!", source: https://localhost/js/index.js (33)
2022-04-15 11:25:26.364 I/chromium: [INFO:CONSOLE(25)] "YY: DOMContentLoaded", source: https://localhost/js/index.js (25)
2022-04-15 11:25:26.364 I/System.out: YY: onProgressChanged - 80
2022-04-15 11:25:26.365 I/chromium: [INFO:CONSOLE(33)] "YY: Ready state change!", source: https://localhost/js/index.js (33)
2022-04-15 11:25:26.365 I/chromium: [INFO:CONSOLE(29)] "YY: Load", source: https://localhost/js/index.js (29)
2022-04-15 11:25:26.365 I/System.out: YY: onProgressChanged - 100
2022-04-15 11:25:26.365 I/System.out: YY: onProgressChanged - 100
2022-04-15 11:25:26.365 I/chromium: [INFO:CONSOLE(70)] "YY: Running cordova - android@10.1.1", source: https://localhost/js/index.js (70)
2022-04-15 11:25:26.366 I/System.out: YY: onMessage - onPageFinished
2022-04-15 11:25:26.367 I/chromium: [INFO:CONSOLE(49)] "YY: Device Ready: zhengyt", source: https://localhost/js/index.js (49)
2022-04-15 11:25:26.367 I/chromium: [INFO:CONSOLE(59)] "YY: Device Ready: https://tommygirl.cn", source: https://localhost/js/index.js (59)

顺序可见:onPageStarted -> IIFE 中可以调用插件 -> DOMContentLoaded -> Load -> onPageFinished -> cordova 通知 deviceready。

那这样是否可以证明提前完成注入是可行的呢?又或者我进入了“越无知越拥有莫名其妙勇气”的误区?🤓哈哈哈,求路过的各位大佬赐教、讨论。

iOS - UIWebView

cordova 4.0 之后,在 UIWebView 中注入 JS 也需要自己手动完成,但 4.0 之前是怎么样的我记不清了…Demo 在此。 借助 InjectCordova 的插件监听 CDVPageDidLoadNotification 通知,在收到通知后使用 UIWebView 的 stringByEvaluatingJavaScriptFromString: 方法把 JS 注入进去。其实完全不用插件,在继承自 CDVViewController 的子类中,做这个监听也是可以的。

UIWebView 中目前不知道怎么可以提前完成注入,貌似 iOS 中 webViewDidStartLoadwebViewDidFinishLoad 的概念,同 Android 中的概念不是一回事儿。但…管它呢,升级 WKWebView 吧,美滋滋~~

iOS - WKWebView

如果升级至 WKWebView 的话完全不用纠结上面的内容,之前的文章中我们也提到过了,通过 WKUserScript、WKUserScriptInjectionTimeAtDocumentStart 可以轻松实现提前注入,关于注入的 js 作用域的问题,也欢迎在博客中讨论。在此不再赘述。