Android Native-Web交互框架

824

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个能力:

  1. WebView初始化
  2. Js事件定义
  3. 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<String, Command> 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<String, String> 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<String, Object> 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<Map<String, String>> buttons = (List<Map<String, String>>) params.get("buttons");
                String callbackName = (String) params.get("callback");

                if (!TextUtils.isEmpty(content)) {
                    AlertDialog dialog = new AlertDialog.Builder(getContext())
                            .setTitle(title)
                            .setMessage(content)
                            .create();
                    dialog.setCanceledOnTouchOutside(canceledOutside == 1 ? true : false);

                    if (CollectionUtils.isNotNull(buttons)) {
                        for (int i = 0; i < buttons.size(); i++) {
                            Map<String, String> button = buttons.get(i);
                            int buttonWhich = getDialogButtonWhich(i);

                            if (buttonWhich == 0) return;

                            dialog.setButton(buttonWhich, button.get("title"), (dialog1, which) -> {
                                button.put("callbackname", callbackName);
                                loadJS("dj.callback", button);
                            });
                        }
                    }

                    dialog.show();
                }
            }
        }

        private int getDialogButtonWhich(int index) {
            switch (index) {
                case 0:
                    return DialogInterface.BUTTON_POSITIVE;
                case 1:
                    return DialogInterface.BUTTON_NEGATIVE;
                case 2:
                    return DialogInterface.BUTTON_NEUTRAL;
            }
            return 0;
        }
    };
}

此处用到了一个自定义的DWebView,其继承自WebView,主要是为了设定WebView的相关特性,比如WebSettings配置、WebViewClient设置、ActionMode.CallBack的处理。考虑到篇幅问题,此处不再展开。

Web层

Web层的核心提供了一下能力:

  1. 为页面模块封装快捷方法,使之可以通过window.webview.post(String command, String para); 快捷的调用native提供的接口;
  2. 接收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”的处理。

  3. 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的机制,简单说明一下:

  1. WebView调用Native需要callback时,会生成callbackname,并以callbackname为key将callback函数记录起来,WebView将callbackname一并传给native;
  2. 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.Native={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;
        }
    }