Android与WebView交互新解

我相信从事Android开发的小伙伴们,都完成过native和WebView中js通信的相关开发任务。

最常见通信方案

Android中开发JavascriptInterface代码:

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
45
46
47
48
49
50
51
52
53
public class JSWebView extends WebView {

public JSWebView(Context context) {
super(context);
init(context);
}

public JSWebView(Context context, AttributeSet attrs) {
super(context, attrs);
init(context);
}

public JSWebView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
init(context);
}

@SuppressLint("SetJavaScriptEnabled")
private void init(Context context) {
setVerticalScrollBarEnabled(false);
setHorizontalScrollBarEnabled(false);
getSettings().setJavaScriptEnabled(true);
getSettings().setDomStorageEnabled(true);
getSettings().setAppCacheEnabled(true);
getSettings().setAllowContentAccess(true);
getSettings().setJavaScriptCanOpenWindowsAutomatically(true);

addJavascriptInterface(this, "farseer");
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
WebView.setWebContentsDebuggingEnabled(true);
}
}

@JavascriptInterface
public void getUserToken(String json) {
try {
JSONObject params = new JSONObject(json);
String userId = params.getString("userId");
String callback = params.getString("callback");

String userToken = String.format("%s 's Token", userId);
JSONObject result = new JSONObject();
result.put("userId", userId);
result.put("userToken", userToken);

//执行javascript回调
loadUrl("javascript:" + callback + "(" + result.toString() + ")");

} catch (JSONException e) {
e.printStackTrace();
}
}
}

Html和javascript代码:

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
<!DOCTYPE HTML>
<html>
<head>
<meta charset="utf-8">
<title>AndroidJSSDK</title>
</head>

<script type="text/javascript">

function getTokenCallback(params) {
console.log(JSON.parse(params).token);
}
</script>


<body>
<div class="blog-header">
<h3>最常见通信方案</h3>
</div>
<ul class="entry">
<li>
获取用户Token测试用例<br/>
<button onclick="farseer.getUserToken(JSON.stringify({'userId':'farseer','callback':'getUserTokenCallback'}));">测试</button>
</li>
</ul>

</body>
</html>

通过以上两段测试代码,我们大Android程序员便可以轻松的实现native与javascript通信。
一般的开发流程为:

  1. 定义以及实现native的接口方法farseer.getUserToken(json),以及参数规范;
  2. 定义以及实现javascript的回调方法getTokenCallback,以及参数规范;
  3. 两端联调,自测;

以上方式存在缺点如下:

  • javascript对native过度依赖,面对JavascriptInterface中的方法,native程序员连修改方法名称的权利都被剥夺;
  • 两端通信的参数规范需要统一;
  • native需要关注回调javascript方法,没有从javascript中解脱出来,在复杂业务中,回调情况也可能不统一;

总结起来,native开发人员和js程序员交叉过多,依赖过重,这种模式下,极大的限制了我们开发同学的发挥空间。

可以尝试的想法

我们可以尝试建立一套协议,native和js都面向通信协议编程,彼此保持黑盒状态。同时也可以通过协议更方便的完成单元测试。

javascript代码:

1
2
3
4
5
6
7
8
9
//定义callback缓存
var callbackCache = new Object();

//通信协议方法
function reqeustUserToken(params, callback) {
//保留callback
callbackCache.reqeustUserToken = callback;
nativeInterface(params);
}

html代码:

1
2
3
<button onclick="reqeustUserToken(JSON.stringify({'userId':'farseer'}), function(params){
console.log('response:' + JSON.parse(params).token);
});">
测试</button>

在上述代码中,html直接调用通讯协议方法reqeustUserToken,第一个参数为数据参数params,第二个参数为回调callback。reqeustUserToken先把callback保存到callbackCache中,之后调用native方法nativeInterface。暂时我们可以把nativeInterface看做native向js开放的接口方法的伪代码。至于callback的调用,可以通过webView来完成调用。

看到上述通信协议,你可能存在疑问,这样与最常见的通信方案,差别不是很大。的确如此,那么让我们来进一步升级。

我们完全可以把通信协议方法和callbackCache通过js注入的方式完成初始化,我们可以通过浏览器控制台动态的注入js来验证。
控制台验证

上图是测试下来的结果,在浏览器中完全可以动态的注入js,html使用动态注入的js完成具体业务。那么,在手机端WebView是否可行呢?

等等…还有一个问题:为什么需要动态的注入js,这样做意义是什么呢?
我们使用动态注入技术的js方法都是通信协议方法。我的期望是通信协议的具体代码对前端开发者不可见,也没有必要了解,前端开发者唯一要做的是根据通信协议文档了解如果使用native开放出的功能。面对通信协议开发,把两端的开发人员彼此解放出来。

移动端初步实践

直接上段代码来看看。

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
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
public class JsFormator {

public static String getEvalJavascript(String javascriptContent) {
return String.format("javascript:%s;", javascriptContent);
}

/**
* 获得生成命名空间方法的javascript.
*
* if (typeof Namespace == "undefined") {
* var Namespace = new Object();
* }
*
* if (typeof Namespace.register == "undefined") {
* Namespace.register = function(path) {
* var arr = path.split(".");
* var ns = "";
* for (var i = 0; i < arr.length; i++) {
* if (i > 0) {
* ns += ".";
* }
* ns += arr[i];
* eval("if(typeof(" + ns + ") == 'undefined') " + ns + " = new Object();");
* }
* }
* }
*
* Namespace.register("com.farseer");
*
* @return 生成命名空间方法的javascript
*/

public static String getNamespaceJavascript() {

return "if (typeof Namespace == \"undefined\") {\n" +
"\tvar Namespace = new Object();\n" +
"}\n" +
"if (typeof Namespace.register == \"undefined\") {\n" +
"\tNamespace.register = function(path) {\n" +
"\t var arr = path.split(\".\");\n" +
"\t var ns = \"\";\n" +
"\t for (var i = 0; i < arr.length; i++) {\n" +
"\t if (i > 0) {\n" +
"\t ns += \".\";\n" +
"\t }\n" +
"\t ns += arr[i];\n" +
"\t eval(\"if(typeof(\" + ns + \") == \\\"undefined\\\") \" + ns + \" = new Object();\");\n" +
"\t }\n" +
"\t}\t\n" +
"}\n";
}

/**
* 获得执行注册命名空间的javascript.
*
* 例如:
* Namespace.register('ITOMIX.Dialog');
* if (typeof DialogCallbackCache == 'undefined') {
* var DialogCallbackCache = new Object();
* }
*
* @param namespace 命名空间
* @param moduleName 模块名称
*
* @return 注册命名空间的javascript
*/

public static String getRegisterNamespaceJavascript(String namespace, String moduleName) {
// CommonCallbackCache
String jsCallbackCacheName = getJsCallbackCacheName(moduleName);
return String.format("Namespace.register('%s.%s');\n" +
"if (typeof %s == 'undefined') {\n" +
" var %s = new Object();\n" +
"}", namespace, moduleName, jsCallbackCacheName, jsCallbackCacheName);
}

/**
* 获得注入js方法的javascript.
*
* 例如:
* if (typeof cn.farseer.Common.toast == 'undefined') {
* var cn.farseer.Common.toast = function(params, callback) {
* console.log('Common cn.farseer.Common.toast inject begin');
* Common_callbacks['cn.farseer.Common.toast'] = callback;
* Common.toast(params);
* console.log('Common cn.farseer.Common.toast inject end');
* };
* }
*
* @param namespace 命名空间
* @param moduleName 模块名称
* @param functionName js方法名称
*
* @return 注入js方法的javascript
*/

public static String getJsFunctionInitJavascript(String namespace, String moduleName, String functionName) {

// com.farseer.Common.toast
String jsVariableName = getJsVariableName(namespace, moduleName, functionName);
// CommonCallbackCache
String jsCallbackCacheName = getJsCallbackCacheName(moduleName);
// Common.toast
String nativeFunctionName = getNativeFunctionName(moduleName, functionName);

return String.format("if (typeof %s == 'undefined') {\n" +
"\t%s = function(params, callback) \n" +
"\t{\n" +
"\t\tconsole.log('%s inject begin'); \n" +
"\t\t%s['%s'] = callback;\n" +
"\t\t%s(params); \n" +
"\t\tconsole.log('%s inject end');\n" +
"\t};\n" +
"}", jsVariableName, jsVariableName, jsVariableName, jsCallbackCacheName, functionName, nativeFunctionName, jsVariableName);
}

/**
* 获得执行callback方法的javascript.
*
* 例如:
* if (typeof Common_callbacks['toast'] != "undefined") {
* Common_callbacks['toast'](params);
* delete Common_callbacks['%s'];
* }
*
* @param moduleName 模块名称
* @param functionName js方法名称
* @param json 回调参数,数据格式为json
*
* @return callback方法的javascript
*/

public static String getCallbackJavascript(String moduleName, String functionName, String json) {
// CommonCallbackCache
String jsCallbackCacheName = getJsCallbackCacheName(moduleName);
return String.format("if (typeof %s['%s'] != 'undefined') {\n" +
"\t%s['%s'](%s);\n" +
"\tdelete %s['%s'];\n" +
"}\n", jsCallbackCacheName, functionName, jsCallbackCacheName, functionName, json, jsCallbackCacheName, functionName);
}

/**
* 获得js方法的完整名称.
*
* 例如:com.farseer.Common.toast
*
* @param namespace 命名空间
* @param moduleName 模块名称
* @param functionName js方法名称
*
* @return js方法的完整名称.
*/

private static String getJsVariableName(String namespace, String moduleName, String functionName) {
StringBuilder builder = new StringBuilder();
builder.append(namespace);
builder.append(".");
builder.append(moduleName);
builder.append(".");
builder.append(functionName);
return builder.toString();
}

/**
* 获得js callback变量名称.
*
* 例如: CommonCallbackCache
*
* @param moduleName 模块名称
*
* @return js callback变量名称
*/

private static String getJsCallbackCacheName(String moduleName) {
StringBuilder builder = new StringBuilder();
builder.append(moduleName);
builder.append("CallbackCache");
return builder.toString();

}

/**
* 获得本地方法完整名称.
*
* 例如:Common.toast
*
* @param moduleName 模块名称
* @param functionName js方法名称
*
* @return 本地方法完整名称
*/

private static String getNativeFunctionName(String moduleName, String functionName) {
StringBuilder builder = new StringBuilder();
builder.append(moduleName);
builder.append(".");
builder.append(functionName);
return builder.toString();
}
}

这段代码主要是动态注入js的模板,具体源码地址,简述下动态注入js时需要考虑的细节:

  • 按模块注入通信方法,每个通信方法保持独立;
  • 按模块区分通信方法的回调;
  • 支持多次注入;
  • 支持命名空间,防止命名污染;

移动端最终实现

基于上述native与js通信想法,完成AndroidJSSDKCore,我们可以基于AndroidJSSDKCore快速开发出多个native与js通信的模块。

基于AndroidJSSDKCore,设计完成的AndroidJSSDKCommonAndroidJSSDKDialog
我们可以AndroidJSSDKDialog简单描述下,如何基于AndroidJSSDKCore快速完成一个模块。

定义通信模块

模块名称为Dialog,默认命名空间为com.farseer

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@Module(name = "Dialog", namespace = "com.farseer")
public class DialogJsModule extends JSModule {

public DialogJsModule(Context context, Dispatcher dispatcher, String moduleName, String namespace) {
super(context, dispatcher, moduleName, namespace);
}

public void init() {
for (String function : Constants.FUNCTION_LIST) {
addJSFunction(function);
}
}

@JavascriptInterface
public void normalDialog(String json) {
NormalDialogEvent dialogEvent = new NormalDialogEvent(getName(), Constants.NOOMAL_DIALOG);
dialogEvent.processData(json);
postEvent(dialogEvent);
}
}

AndroidJSSDKCore中定义注解Module和基类JSModule。normalDialog是模块Dialog真正向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
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
public class NormalDialogEvent extends JSEvent {

private Request request;

public NormalDialogEvent(String module, String function) {
super(module, function);
}


public Request getRequest() {
return request;
}


@Override
public void processData(String data) {
log(data);
request = JsonTool.fromJsonString(data, new TypeToken<Request>() {}.getType());
if (request == null || !request.check()) {
LogTool.error(String.format("normalDialog 's params of the module named %s are not support", getModule()));
}
}

public static class Request {
@SerializedName("title")
private String title;
@SerializedName("content")
private String content;
@SerializedName("positiveText")
private String positiveText;
@SerializedName("negativeText")
private String negativeText;

public String getTitle() {
return title;
}

public void setTitle(String title) {
this.title = title;
}

public String getContent() {
return content;
}

public void setContent(String content) {
this.content = content;
}

public String getPositiveText() {
return positiveText;
}

public void setPositiveText(String positiveText) {
this.positiveText = positiveText;
}

public String getNegativeText() {
return negativeText;
}

public void setNegativeText(String negativeText) {
this.negativeText = negativeText;
}

public boolean check() {
if (!TextUtils.isEmpty(title)
&& !TextUtils.isEmpty(content)
&& !TextUtils.isEmpty(positiveText)
&& !TextUtils.isEmpty(negativeText)) {
return true;
}
return false;
}
}

public static class Response {
@SerializedName("result")
private int result;
@SerializedName("message")
private String message;

public int getResult() {
return result;
}

public void setResult(int result) {
this.result = result;
}

public String getMessage() {
return message;
}

public void setMessage(String message) {
this.message = message;
}
}
}

在通信载体通信事件中,以Request,Response的形式来把客户端看做S端,把js看做C端,使模块开发者更够更好的理解以及使用。

定义通信事件处理器

通信事件处理器负责接收通信事件,完成客户端对事件的具体处理,以及完成具体的回调处理。

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
45
46
47
48
49
50
@Processor(name = "Dialog")
public class DialogProcessor extends JSProcessor {
private MaterialDialog materialDialog = null;

public DialogProcessor(Context context, JSInvoker jsInvoker, String name) {
super(context, jsInvoker, name);
}

@Subscribe
public void processNormalDialogEvent(final NormalDialogEvent event) {
NormalDialogEvent.Request data = event.getRequest();
hideDialog();
materialDialog = new MaterialDialog.Builder(getContext())
.title(data.getTitle())
.content(data.getContent())
.positiveText(data.getPositiveText())
.negativeText(data.getNegativeText())
.onPositive(new MaterialDialog.SingleButtonCallback() {
@Override
public void onClick(@NonNull MaterialDialog dialog, @NonNull DialogAction which) {
LogTool.debug("onPositive");
NormalDialogEvent.Response result = new NormalDialogEvent.Response();
result.setMessage("sure");
result.setResult(1);
getJsInvoker().onJsInvoke(event.getCallback(JsonTool.toJsonString(result)));
}
})
.onNegative(new MaterialDialog.SingleButtonCallback() {
@Override
public void onClick(@NonNull MaterialDialog dialog, @NonNull DialogAction which) {
LogTool.debug("onNegative");
NormalDialogEvent.Response result = new NormalDialogEvent.Response();
result.setMessage("cancel");
result.setResult(0);
getJsInvoker().onJsInvoke(event.getCallback(JsonTool.toJsonString(result)));
}
})
.build();
materialDialog.show();
}

private void hideDialog() {
if (materialDialog != null) {
if (materialDialog.isShowing()) {
materialDialog.dismiss();
}
materialDialog = null;
}
}
}

AndroidJSSDKCore中同时定义注解Processor和基类JSProcessor。目前通过匹配注解Module和注解Processor的属性name来功能对接,所以DialogModule和DialogProcessor的name都为Dialog。

命名空间

默认的命名空间为com.farseer,当然也可以通过JSSDK完成自定义命名空间。

1
JSSDK.init(this, BuildConfig.APPLICATION_ID, "ITOMIX", "com.farseer.jssdk");

其中参数ITOMIX为自定义的命名空间。

前端使用

1
<button onclick="ITOMIX.Dialog.normalDialog(JSON.stringify({'title': 'title','content': 'content','positiveText': 'Sure','negativeText': 'Cancel'}), function(params){ITOMIX.Common.toast(JSON.stringify({'content':JSON.stringify(params)}), function(){});});">测试</button>

了解通信协议后,前端开发同学可以很轻松的完成开发任务。

效果图

Common ModuleDialog Module

总结

再多的总结不如去阅读开源项目,去尝试自己实践的模块开发。