Android Native-Web交互框架
Hybrid是目前App开发的主流模式,它兼具Native良好的用户交互性能,以及Web良好的页面扩展和跨平台特性。如FaceBook的React-Native,微信的小程序开发等都是Hybrid模式。本文要探讨的问题就是Hybrid模式中Native和Web的交互问题,并介绍一下自我摸索实现的Native-Web交互框架。
WebView Js交互技术原理
Web-Native之间的通信是双向的,即Native端和Web端互为调用者和被调用者。在Android上,Native端使用WebView来展现Web页,WebView自身提供了与Web交互的接口。
Native调用Web
Native主动调用WebView,可以通过WebView注入JS方式实现Web接口的调用。涉及到两个接口:
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
webView.evaluateJavascript(trigger, null);
} else {
webView.loadUrl(trigger);
}
WebView可以load一个url,进而打开相关的页面,用法如下:
webView.loadUrl("http://app.dev.dajiazhongyi.com");
应用更加灵活广泛的则是Native可以向WebView注入js代码,用法如下:
String trigger = "javascript:dj.callback({"content":"测试一下","callbackname":"djapi_callback_1492767300785_4195"})";
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
webView.evaluateJavascript(trigger, null);
} else {
webView.loadUrl(trigger);
}
说明:假设js代码中我们提供了一个dj.callback(para) 方法,那就可以通过以上方式来注入这段js并执行。其他的扩展也是如此
Web调用Native
JavascriptInterface方式
Native端作为被调用者,它是通过WebView的addJavascriptInterface接口实现Web端对它的回调。WebView内核层会为页面的window对象添加了一个属性,并将这个属性绑定到Native端的一个Java对象,页面使用这个属性访问到Java对象的方法,实现Web端对Native端的调用。
举个例子:
在Native端,定义一个JsInterface类,并定义了了一个方法 post 供Web进行调用
public final class JsInterface {
...
private final Handler mHandler = new Handler();
@JavascriptInterfacepublic void post(String cmd, String param) {
mHandler.post(() -> {
Toast.makeText(sContext, cmd+param, Toast.LENGTH_LONG).show();
});
}
...
}
初始化WebView的时候注册JavascriptInterface对象:
protected JsInterface jsInterface;
@SuppressLint({"SetJavaScriptEnabled", "JavascriptInterface", "AddJavascriptInterface"})
private void initWebView() {
webView.addJavascriptInterface(jsInterface, "webview");
}
Web中Js调用方式:
window.webview.post(cmd, JSON.stringify(para));
JavascriptInterface的方式是有限制的,在4.2版本之后回调方法加上@JavascriptInterface注解即可解决漏洞问题。在Android4.2版本之前漏洞问题【待续】
Native-Web交互框架
该交互框架主要包含以下几个方面:
- 数据结构
- Native层
- Web层
数据结构
Native定义的JavascriptInterface方式是post(String command, String para), command是事件类型,以字符串区分,形如“showToast”、“showDialog”; para是JSON格式的数据
Native层
Native层提供了Web层调用的方法,主要提供了3个能力:
- WebView初始化
- Js事件定义
- Js事件分发
WebView的初始化指的是添加JavascriptInterface到WebView; Js事件定义则是在框架结构内添加Native支持的事件接口;Js分发表示Native通过post方式接收到WebView的事件,接收到事件后同统一进行分发。
具体代码如下:
public final class JsInterface {
private final Context mContext;
private final Handler mHandler = new Handler();
private final Map mCommands = Maps.newHashMap();
public JsInterface(Context context) {
mContext = context;
}
@JavascriptInterface
public void post(String cmd, String param) {
mHandler.post(() -> {
final Command command = mCommands.get(cmd);
if (command != null) {
if (TextUtils.isEmpty(param) || param.equals("undefined")) {
command.exec(mContext, null);
} else {
command.exec(mContext, new Gson().fromJson(param, Map.class));
}
}
});
}
public void registerCommand(Command command) {
mCommands.put(command.name(), command);
}
public void unregisterCommand(Command command) {
mCommands.remove(command.name());
}
public void unregisterAllCommands() {
mCommands.clear();
}
public interface Command {
String name();
void exec(Context context, Map params);
}
}
public abstract class BaseWebViewFragment extends BaseFragment {
@BindView(R.id.web_view)
protected DWebView webView;
@Inject
protected JsInterface jsInterface;
@Override
public void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
component().inject(this);
}
@Override
public void onDestroy() {
super.onDestroy();
}
@Nullable
@Override
public View onCreateView(LayoutInflater inflater, @Nullable ViewGroup container, @Nullable
Bundle savedInstanceState) {
View view = inflater.inflate(getLayoutRes(), container, false);
ButterKnife.bind(this, view);
return view;
}
@Override
public void onViewCreated(View view, @Nullable Bundle savedInstanceState) {
super.onViewCreated(view, savedInstanceState);
initWebView();
registBaseCommands();
}
@Override
public void onDestroyView() {
super.onDestroyView();
unregistBaseCommands();
}
@SuppressLint({"SetJavaScriptEnabled", "JavascriptInterface", "AddJavascriptInterface"})
private void initWebView() {
final WebSettings settings = webView.getSettings();
settings.setUserAgentString(HttpHeaderUtils.getUserAgent());
webView.addJavascriptInterface(jsInterface, "webview");
}
protected void loadJS(String trigger) {
if (!TextUtils.isEmpty(trigger)) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
webView.evaluateJavascript(trigger, null);
} else {
webView.loadUrl(trigger);
}
}
}
@LayoutRes
protected abstract int getLayoutRes();
protected abstract void registerCommands();
/**
* 注册基本的command,使之具备基本的native交互能力
*/
private void registBaseCommands() {
registerCmd4JsInterface(pageLoadCompletedCommand);
registerCmd4JsInterface(showToastCommand);
registerCmd4JsInterface(showDialogCommand);
registerCommands();
}
protected final void registerCmd4JsInterface(JsInterface.Command cmd) {
jsInterface.registerCommand(cmd);
}
private void unregistBaseCommands() {
jsInterface.unregisterAllCommands();
}
public void loadJS(String cmd, Object param) {
if (webView != null) {
String trigger = "javascript:" + cmd + "(" + new Gson().toJson(param) + ")";
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
webView.evaluateJavascript(trigger, null);
} else {
webView.loadUrl(trigger);
}
}
}
public void dispatchEvent(String name) {
Map param = Maps.newHashMapWithExpectedSize(1);
param.put("name", name);
loadJS("dj.dispatchEvent", param);
}
/**************** Native callback interface define *******************/
/**
* web页面加载完成回调
*/
private JsInterface.Command pageLoadCompletedCommand = new JsInterface.Command() {
@Override
public String name() {
return "pageLoadComplete";
}
@Override
public void exec(Context context, Map params) {
if (null != params.get("callback")) {
String functionName = params.get("callback").toString();
onFrameworkLoadCompleted(functionName);
} else {
onFrameworkLoadCompleted(null);
}
}
};
protected void onFrameworkLoadCompleted(String functionName) {
if (StringUtils.isNotNullOrEmpty(functionName)) {
final Map param = new HashMap<>();
loadJS("dj.callback", param);
}
}
/**
* Native回调web处理
*/
public void handleCallback(String functionName, HashMap hashMap) {
if (StringUtils.isNotNullOrEmpty(functionName)) {
hashMap.put("callbackname", functionName);
loadJS("dj.callback", hashMap);
}
}
/**
* 显示Toast信息
*/
private final JsInterface.Command showToastCommand = new JsInterface.Command() {
@Override
public String name() {
return "showToast";
}
@Override
public void exec(Context context, Map params) {
Toast.makeText(context, String.valueOf(params.get("message")), Toast.LENGTH_SHORT).show();
}
};
private final JsInterface.Command showDialogCommand = new JsInterface.Command() {
@Override
public String name() {
return "showDialog";
}
@Override
public void exec(Context context, Map params) {
if (CollectionUtils.isNotNull(params)) {
String title = (String) params.get("title");
String content = (String) params.get("content");
int canceledOutside = 1;
if (params.get("canceledOutside") != null) {
canceledOutside = (int) (double) params.get("canceledOutside");
}
List
此处用到了一个自定义的DWebView,其继承自WebView,主要是为了设定WebView的相关特性,比如WebSettings配置、WebViewClient设置、ActionMode.CallBack的处理。考虑到篇幅问题,此处不再展开。
Web层
Web层的核心提供了一下能力:
- 为页面模块封装快捷方法,使之可以通过window.webview.post(String command, String para); 快捷的调用native提供的接口;
- 接收native的回调事件,并进行分发处理,细心的你应该可以发现,在上述代码中有这么一段:
/**
* Native回调web处理
*/
public void handleCallback(String functionName, HashMap hashMap) {
if (StringUtils.isNotNullOrEmpty(functionName)) {
hashMap.put("callbackname", functionName);
loadJS("dj.callback", hashMap);
}
}
所以web核心层需要提供“dj.callback”的处理。
- Webview自定义事件,及事件触发,这个主要用于Hybrid开发中,WebView自定义Menu,用户点击menu可以直接触发相关事件
具体的js代码如下,使用时每个页面模块需要单独引入:
var dj = {};
dj.os = {};
dj.os.isIOS = /iOS|iPhone|iPad|iPod/i.test(navigator.userAgent);
dj.os.isAndroid = !dj.os.isIOS;
dj.callbackname = function(){
return "djapi_callback_" + (new Date()).getTime() + "_" + Math.floor(Math.random() * 10000);
};
dj.callbacks = {};
dj.addCallback = function(name,func,userdata){
delete dj.callbacks[name];
dj.callbacks[name] = {callback:func,userdata:userdata};
};
dj.callback = function(para){
var callbackobject = dj.callbacks[para.callbackname];
if (callbackobject !== undefined){
if (callbackobject.userdata !== undefined){
callbackobject.userdata.callbackData = para;
}
if(callbackobject.callback != undefined){
var ret = callbackobject.callback(para,callbackobject.userdata);
if(ret === false){
return
}
delete dj.callbacks[para.callbackname];
}
}
};
dj.post = function(cmd,para){
if(dj.os.isIOS){
var message = {};
message.meta = {
cmd:cmd
};
message.para = para || {};
window.webview.post(message);
}else if(window.dj.os.isAndroid){
window.webview.post(cmd,JSON.stringify(para));
}
};
dj.postWithCallback = function(cmd,para,callback,ud){
var callbackname = dj.callbackname();
dj.addCallback(callbackname,callback,ud);
if(dj.os.isIOS){
var message = {};
message.meta = {
cmd:cmd,
callback:callbackname
};
message.para = para;
window.webview.post(message);
}else if(window.dj.os.isAndroid){
para.callback = callbackname;
window.webview.post(cmd,JSON.stringify(para));
}
};
dj.dispatchEvent = function(para){
if (!para) {
para = {"name":"webviewLoadComplete"};
}
var evt = {};
try {
evt = new Event(para.name);
evt.para = para.para;
} catch(e) {
evt = document.createEvent("HTMLEvents");
evt.initEvent(para.name, false, false);
}
window.dispatchEvent(evt);
};
dj.addEventListener = window.addEventListener;
dj.stringify = function(obj){
var type = typeof obj;
if (type == "object"){
return JSON.stringify(obj);
}else {
return obj;
}
};
window.dj = dj;
关于callback的机制,简单说明一下:
- WebView调用Native需要callback时,会生成callbackname,并以callbackname为key将callback函数记录起来,WebView将callbackname一并传给native;
- native通过loadJS("dj.callback", hashMap);回调,回调时将callbackname和回调的内容一并封装到hashmap传给WebView,WebView根据callbackname获取记录中的callback函数,进而实现回调
以上是Android中Web-Native交互框架的主要内容,该框架Native层可以方便的进行native接口的扩展,Web层提供了接口调用和事件回调的方法,也可以进一步扩展一些通用的接口以方便上层业务模块进行调用。
补充:
addJavascriptInterface在Android 4.2版本一下漏洞及解决方式
漏洞说明
addJavascriptInterface的本质是向webview注入一个Java对象,如上所示,注入了JsInterface对象。根据Java对象的反射机制,就可以通过该对象获取到java.lang.Runtime 的实例,并通反射执行getRuntime(String command) 方法,从而窃取了信息
Js获取Runtime方法如下:
function execute(cmdArgs) {
for (var obj in window) {
if ("getClass" in window[obj]) {
alert(obj);
return window[obj].getClass().forName("java.lang.Runtime")
.getMethod("getRuntime",null).invoke(null,null).exec(cmdArgs);
}
}
}
感兴趣的用户可以尝试在js中调用:execute("ls /mnt/sdcard/")
漏洞解决
Web端调用Native端使用WebChromeClient的onJsPrompt回调, onJsPrompt回调接口是页面弹出提示用的,页面JS调用prompt方法时,WebView内核会将内容回传到onJsPrompt接口,这样Native可以弹出本地提示框。我们可以利用这个接口来实现Web端对Native端的调用,只需要将prompt的内容约定为特定的格式,Web端按照这个格式生成内容,Native端在onJsPrompt接收到内容后按照这个格式进行解析,如果内容符合约定的格式,则作为Web-Native交互逻辑处理,否则作为增加的提示逻辑处理。
具体的处理如下:
1、 指定Android4.2以下版本的处理方式
@SuppressLint({"SetJavaScriptEnabled", "JavascriptInterface", "AddJavascriptInterface"})
private void initWebView() {
final WebSettings settings = webView.getSettings();
settings.setUserAgentString(HttpHeaderUtils.getUserAgent());
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) {
webView.addJavascriptInterface(jsInterface, "webview");
} else {
webView.removeJavascriptInterface("searchBoxJavaBridge_");
webView.setWebChromeClient(new DWebChromeClient());
}
}
2、在onPageStarted的时候,注入js代码,定义window.webview
public void onPageStarted(WebView view, String url, Bitmap favicon) {
super.onPageStarted(view, url, favicon);
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.JELLY_BEAN_MR1) {
view.loadUrl("JavaScript:if(window.webview == undefined){window.webview={call:function(command,para){prompt('{\"command\":' + command + ',\"param\":' + param + '}')}}};");
}
}
3、分发处理Web端的调用事件
public class DWebChromeClient extends WebChromeClient {
@Override
public boolean onJsPrompt(WebView view, String url, String message, String defaultValue, JsPromptResult result) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.JELLY_BEAN_MR1) {
new Handler().post(() -> {
// TODO: 2017/4/23
});
}
return true;
}
}
参考文章
Android Developer WebView
JS 与 Native 安全交互浅析,两种方式实现