阅读 673

Android WebView与Native通信总结

当前移动端App的开发很多都需要内嵌WebView来方便业务的快速开展,特别是电商App中,业务变化快,活动多。仅仅依靠native的开发方式难以满足快速的业务发展,于是混合开发模式便出现。当前比较知名的有Cordova, Ionic, 国内的有Appcan, APICloud开发平台,这几种都是依赖于WebView的实现。而Facebook的React Native和阿里的Weex是混合开发的另一种实现,React NativeWeex可以让原生开发者像H5开发一样写前端的代码,然后通过自己的SDK渲染成原生的组件,不依赖于WebView。本文主要总结一下当前WebView和native的交互方式。

Android中WebViewJavaScript的交互,其实就是Android native与网页中的Javascript之间的交互, 所以搞清楚了它们之间数据是如何传递的就明白了。以下从两个方面进行介绍:

Native 向 Javascript 发送数据

Native 向 JavaScript发送数据有两种方式, 一种是evaluateJavascript 另一种是loadUrl。区别在于evaluateJavascriptloadUrl更高效,evaluateJavascript在android 4.4之后才能用,该方法的执行不会使页面刷新, 而loadUrl则会。所以通常我们如下使用:

if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
    evaluateJavascript(jsCommand, null);
} else {
    loadUrl(jsCommand);
}
复制代码

当然,如果想要直接获得javascript代码的执行结果,我们可以这样写:

String command = "ABC";
webView.evaluateJavascript("(function() { return " + command + "; })();", new ValueCallback<String>() {
    @Override
    public void onReceiveValue(String result) {
        // 此处的result便是 ABC
    }
});
复制代码

Javascript 向 Native 发送数据

Javascript向Native发送数据有4种方式,第一种方式是借助webChromClient中的onJsAlert(), onJsPromot()的方法来获取Javascript相关数据。第二种方式是采用覆盖shouldOverrideUrlLoading方法,拦截url协议。第三种是最方便的,也就是@JavascriptInterface方案, 现在大多数App都会用到这种方式, 后面会详细介绍。最后一种是利用在webView中嵌入iframe的方式,通过更新iframe的url。比较出名的混合框架JsBridge之前就是采用这种方式,现已改成采用@JavascriptInterface这种方式了。以下简单介绍一下各种方式的使用。

onJsPrompt

webChromeClient中提供了onJsAlert, onJsPrompt方法,方便开发者重写Javascript中的alert, prompt方法对应的行为。我们可以在这两个方法中任选一个做为native和js进行交互的桥梁。通常我们借助于onJsPrompt 方法来实现, 就是因为在js中,这个方法通常我们用得比较少。而对于onJsAlert(), 当调用js中的alert()时会触发,我们可以通过重写这个方法来实现自定义的提示View

但是这种方式对传入的数据量有限制,和手机的WebView版本有关,以我的测试机为例,在oppo reno手机 android 10上面, 其传递数据最多只能是10k。 而用@JavascriptInterface 方案, 传递的数据最多可达20 - 30M

我们来看前端网页的写法, 直接调用prompt函数

var data = prompt("native://getUserInfo?id=1");
console.log('data:' + data);
复制代码

在为WebView设置WebChromeClient的时候重写onJsPrompt方法,如下:


 @Override
public boolean onJsPrompt(WebView view, String url, String message, String defaultValue, JsPromptResult result) {
    Uri uri = Uri.parse(message);
    //如果是调nativeAPI.
    if (url.startsWith("native://")) {
        result.confirm("call natvie api success");
        return true;
    }
    return super.onJsPrompt(view, url, message, defaultValue, result);
}
复制代码

shouldOverrideUrlLoading

前端页面的Js代码:

document.location="native://getUserInfo?id=1";
复制代码

native层面在为WebView设置WebViewClient对象时,我们需要重写shouldOverrideUrlLoading方法。需要注意的是,WebViewClient中有两个shouldOverrideUrlLoading方法的定义:

  • public boolean shouldOverrideUrlLoading(WebView view, String url)
  • public boolean shouldOverrideUrlLoading(WebView view, WebResourceRequest request)

其中上面一个在sdk中已被标记Deprecated, 下面一个是在android 7.0中才引入的,所以为了避免兼容性问题。在使用时,建议这两个方法都重写。

@Override
public boolean shouldOverrideUrlLoading(WebView view, String url) {
    //如果是调nativeAPI.
    if (url.startsWith("native://")) {
        Log.i("CommonWebViewClient", "shouldOverrideUrlLoading execute------>")
        return true;
    }
    return super.shouldOverrideUrlLoading(view, url);
}
复制代码

@JavascriptInterface

在 Android 4.2以下有安全漏洞, 但目前我们的app大部份最小支持版本都已经升到5.0了,这个可以忽略,当然感兴趣可以自己搜索。

在native层面,我们需为要WebView注入一个对象,用来处理两边的数据交互。注入方式如下:

  • 首先定义一个类来处理两边的交互:
public class HybridAPI {
    public static final String TAG = "HybridAPI";

    @JavascriptInterface
    public void sendToNative(final String message) {
        Log.i(TAG, "get data from js------------>" + message);

    }
}
复制代码
  • WebView中注入这个类的实例
HybridAPI hybridAPI = new HybridAPI();
webview.addJavascriptInterface(hybridAPI, "HybridAPI")
复制代码

在网页中直接用如下代码便可以将数据发送到native端

 HybridAPI.sendToNative('Hello');
复制代码

iframe

我们还可以利用iframe进行请求伪造向native端发送数据的。思路是向网页中添加一个iframe控件,通过修改其src属性,触发native端的shouldOverrideUrlLoading方法的执行, 同样,native端通过重写该方法,去拿到js端传过来的数据。具体操作方式如下:

var iframe = document.createElement('iframe');
iframe.style.display = 'none';
document.documentElement.appendChild(iframe);
iframe.src="native://getUserInfo?id=1";
复制代码

在操作完成后,我们再从当前的dom结构中移除这个组件。

setTimeout(function() {
    iframe && iframe.parentNode && iframe.parentNode.removeChild(iframe);
}, 100);
复制代码

具体实践

在前面总结了WebView和Native交互的几种方案。但距离实际项目使用还有一段距离,在实际项目开发中还有很多问题需要考虑。如:

  • 交互的规则如何定义
  • 数据如何传递
  • 调用之后,如何拿到回调的结果
  • 对于Javascript的请求,native端应该如何设计?
  • ....

native端向JavaScript发送消息只有loadUrl, evaluateJavascript这两种方式。Javascript向native端发送信息可以利用onJsPrompt, @JavascriptInterface, shouldOverrideUrlLoading等几种方案,以下 我们通过采用@JavascriptInterface这种方式(也就是大家通常说的注解方案)为例来看看如何解决实际项目开发中碰到的问题。

交互的规则

首先我们来定义两端的交互规则。

Javascript向native发数据:

我们约定在H5中采用HybridAPI.sendToNative方法向native端发送数据,于是我们需要在native端做如下支持:

  • 定义一个HybridAPI类,并向WebView中注册
HybridAPI hybridAPI = new HybridAPI(this);
webview.addJavascriptInterface(hybridAPI, "HybridAPI");
复制代码
  • HybridAPI类中定义一个方法sendToNative, 该方法暴露给Javascript用来给native发送数据
@JavascriptInterface
public void sendToNative(final String message) {
    Log.i(TAG, "get data from js------------>" + message);

}
复制代码

native层向Javascript发数据:

public final String TO_JAVASCRIPT_PREFIX = "javascript:HybridAPI.onReceiveData('%s')";

public void sendToJavaScript(Map<String, Object> message) {
    String str = new Gson().toJson(message);
    final String jsCommand = String.format(TO_JAVASCRIPT_PREFIX, escapeString(str));

    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
        evaluateJavascript(jsCommand, null);
    } else {
        loadUrl(jsCommand);
    }
}
复制代码

在H5中,我们这样写, 当native向Javascript发送数据时,便会触发Javascript中的Hybrid.onReceiveData方法, 该方法就能接收到native层传过来的数据

HybridAPI.onReceiveData = function(message) {
    console.log('[response from native]' + message);
}
复制代码

数据结构的定义

在上面我们已经基于@JavascriptInterface方案完成了native与WebView间通信机制的实现,双方可以交换数据,但开发的时候需要考虑更多问题。比如,如果是Javascript向native发送数据,需要将数据转换成一个字符串,然后再将字符串发给native, native再去解析这个字符串,找到对应的处理方法,提取出相关的业务参数,再进行相应的处理。所以我们需要定义这个字符串的数据结构。

在上面我们已经约定了,H5端可以采用HybridAPI.sendToNative向native发送数据,该方法只有一个字符串参数, 以获取用户信息这个业务功能为例,我们的字符串参数是native://getUserInfo?id=1,这个字符串中的getUserInfo表示当前通信的目的或行为(为了拿用户信息), ? 后面的id=1 表示的是参数(用户id为1), 如果参数多了,这个字符串会更长,再如果上面涉及到中文的转码,其可读性会大大降低,所以这种交互方式不够直观和友好,我们期望用户采用下面这个方法去与native通信:

HybridAPI.invoke(methodName, params, callbackFun)

  • methodName: 当前通信的行为
  • params: 传递的参数
  • callbackFun: 接收native端的返回数据

于是,我们在js层面进行一层的封装

var callbackId = 0;
var callbackFunList = {}
HybridAPI.invoke = function(method, params, callbackFun) {
    var message = {
        method,
        params
    }
    if (callbackFun) {
        callbackId  = callbackId + 1;
        message.id = 'Hybrid_CB_' + callbackId;
        callbackFunList[callbackId] = callbackFun
    }
    HybridAPI.sendToNative(JSON.stringify(message));
}
复制代码

最终还是调用的是sendToNative与native层进行通信,但是采用HybridAPI.invoke方法对开发者更加友好。

由于需要在执行成功后调用回调函数。为此在发送消息的时候先把callbackFun保存起来,在执行成功后再响应。 当Javascript请求发送到native层时,会触发sendToNative方法,在该方法中, 我们来解析前端的数据:

@JavascriptInterface
public void sendToNative(final String message) {
    JSONObject object = DataUtil.str2JSONObject(message);
    if (object == null) {
        return;
    }
    final String callbackId = DataUtil.getStrInJSONObject(object, "id");
    final String method = DataUtil.getStrInJSONObject(object, "method");
    final String params = DataUtil.getStrInJSONObject(object, "params");

    handleAPI(method, params, callbackId);
}

private void handleAPI(String method, String params, String callbackId)  {
    if ("getDeviceInfo".equals(method)) {
        getDeviceInfo();
    } else if ("getUserInfo".equals(method)) {
        getUserInfo();
    } else if ('login'.equals(method)) {
        login();
    }
    ....
}
复制代码

native端在处理完成后,再调用evaluateJavascriptloadUrl方法,反馈给前端。操作流程示例:

//指定了js端的接收入口 
public final String TO_JAVASCRIPT_PREFIX = "javascript:HybridAPI.onReceiveData('%s')";


public void callJs() {
    Map<String, Object> responseData = new HashMap<>();
    responseData.put("error", error);
    responseData.put("data", result);
    //回调函数的id标识,返回给js,这样才能找到对应的回调函数
    responseData.put("id", callbackId);
    sendToJavaScript(responseData);
}

public void sendToJavaScript(Map<String, Object> message) {
    String str = new Gson().toJson(message);
    final String jsCommand = String.format(TO_JAVASCRIPT_PREFIX, escapeString(str));

    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
        evaluateJavascript(jsCommand, null);
    } else {
        loadUrl(jsCommand);
    }
}

// 转义
private String escapeString(String javascript) {
    String result;
    result = javascript.replace("\\", "\\\\");
    result = result.replace("\"", "\\\"");
    result = result.replace("\'", "\\\'");
    result = result.replace("\n", "\\n");
    result = result.replace("\r", "\\r");
    result = result.replace("\f", "\\f");
    return result;
}
复制代码

在上面的callJs方法中组织好相关的数据,然后利用Gson进行序列化,再转进行字符串的转义,最终调用evaluateJavascript或者loadUrl来传递给js。于是js端便可以利用HybridAPI.onReceiveData来接收到。

还记得这段代码中定义的callbackFunList吗?在上面native给js返回数据的时候,会带上一个id, 我们可以根据这个id找到本次通信的回调函数,然后将数据回调过去。

var callbackId = 0;
var callbackFunList = {} //看这里
HybridAPI.invoke = function(method, params, callbackFun) {
   var message = {
       method,
      params
   }
   if (callbackFun) {
       callbackId  = callbackId + 1;
       message.id = 'Hybrid_CB_' + callbackId;
       callbackFunList[callbackId] = callbackFun
   }
   HybridAPI.sendToNative(JSON.stringify(message));
}
复制代码

所以,我们js端接收数据,可能是这样子:

HybridAPI.onReceiveData = function(message) {
    var callbackFun = this.callbackFunList[message.id];
    if (callbackFun) {
      callbackFun(message.error || null, message.data);
    }
    delete this.callbackFunList[message.id];
}
复制代码

再回到我们上面的获取用户信息这个业务功能,我们的写法就会是这样子了:

HybridAPI.invoke('getUserInfo', {"id": "1"}, function(error, data) {
    if (error) {
        console.log('获取用户信息失败');
    } else {
        console.log('username:' + data.username + ', age:' + data.age);
    }
});
复制代码

至此,我们就将一具完整的数据通信流程实现了,由js端用HybridAPI.invoke(method, params, callbackFun)来向native端来发送数据,native处理完毕后,js端通过callbackFun来接收数据。

改进

在上面的java代码中,我们可以看到,native层的入口是sendToNative方法,该方法中解析传入的字符串,再交给handleAPI方法来处理

@JavascriptInterface
public void sendToNative(final String message) {
    JSONObject object = DataUtil.str2JSONObject(message);
    if (object == null) {
        return;
    }
    final String callbackId = DataUtil.getStrInJSONObject(object, "id");
    final String method = DataUtil.getStrInJSONObject(object, "method");
    final String params = DataUtil.getStrInJSONObject(object, "params");

    handleAPI(method, params, callbackId);
}

private void handleAPI(String method, String params, String callbackId)  {
    if ("getDeviceInfo".equals(method)) {
        getDeviceInfo();
    } else if ("getUserInfo".equals(method)) {
        getUserInfo();
    } else if ('login'.equals(method)) {
        login();
    }
    ....
}
复制代码

我们会发现,随着业务的发展,项目的迭代,js端可能会需要native提供越来越多的能力,所以我们的handleAPI方法中就会有越来越多的if...else if...了。

于是,我们可以按业务来划分,新建一个UserController类来处理getUserInfo, login, logout这种与用户相关的native 接口。新建一个DeviceController来处理类似于getDeviceInfo, getDeviceXXX,... 等与设备信息相关的接口。然后我们再维护一个controller list, 每次调用js api的时候从这个list里面去找对应的 controller中的方法处理。

这样,就可以把具体的业务处理方法抽取出来。然而即便这样,还是避免不了在每个Controller中去写一段这个if...else if ...这种代码。于是,其实我们可以很自然的想到用反射来做点事。

我们和H5开发约定好了,如果需要获取用户的信息,就调用getUserInfo方法,这个方法名始终不变。同时,我们在Java端这样定义UserController:

public class UserController implements IController{

    private volatile static UserController instance;
    private UserController() {}

    public static UserController getInstance() {
        if (instance == null) {
            synchronized(UserController.class) {
                if (instance == null) {
                    instance = new UserController();
                }
            }
        }
        return instance;
    }

    @APIMethod
    public UserInfo getUserInfo(Map<String, Object> params, String callbackId) {
        //TODO
    }

    @APIMethod
    public void login(Map<String, Object> params, INativeCallback callback) {
        //TODO
    }

    @APIMethod
    public boolean logout(Map<String, Object> params, INativeCallback callback) {
        //TODO
    }
}
复制代码

我们将该UserController添加到上面提到的controller list中,然后我们在handleAPI方法中:

private void handleNativeAPI(String methodName,  String params, String callback) {
    for (IController controller : controllerList) {
        Method[] methods = controller.getClass().getDeclaredMethods();
        for (Method method : methods) {
            Annotation[] annotations = method.getAnnotations();
            for (Annotation annotation : annotations) {
                // 获取注解的具体类型
                Class<? extends Annotation> annotationType = annotation.annotationType();
                if (method.getName().equals(methodName) &&  APIMethod.class == annotationType) {
                    try {
                        Map<String, Object> map = DataUtil.jsonStr2Map(params);
                        method.invoke(controller, map, callback);
                    } catch (IllegalAccessException e) {
                        e.printStackTrace();
                    } catch (InvocationTargetException e) {
                        e.printStackTrace();
                    }
                    return;
                }
            }
        }

    }
}
复制代码

后面,每当新增一个交互的方法时,我们只需要在对应的java类中写一个方法,并用@APIMethod标识就可以。

以上我们总结了WebView与native通信的几种方式,并结合具体实践给出相应的实现思路,当然因为篇幅原因,这里并没有面面俱到。比如:

  • 如何实现H5端监听native端的某个事件的功能?
  • H5端监听native事件后,进行相应的操作,如何将操作的结果再返给native?
  • 如果js端调了一个不存的native的方法,应该如何处理?
  • ...

如果仔细理解了前面介绍的两端通信方式,实现上面的这些功能应该不是问题。但如果想把代码更好的封装,使开发者用起来更舒服,那就需要下一点功夫了。

文章分类
Android
文章标签