说起hybrid大家不会陌生,主要意思就是native和h5混合开发。为什么要这样做呢?大家可以想象一下针对于同一个活动,如果使用纯native的开发方式,Android和iOS两边都要维护同一套界面甚至是逻辑,这样开发和维护的成本会很大,而使用hybrid的开发方式的话,让前端的同学去写一套界面和逻辑,对于native端来说只要使用对应的容器去展示就可以了(对于Android来说这个容器当然就是WebView)。那为什么不所有的页面都使用这种方式开发呢?因为使用h5来展示界面的话用户体验始终是不如native的,所以在这两者之间我们需要一个权衡。

介绍完了何为hybrid,我们来思考下面几个场景。

场景1,前端那边的页面有一个按钮,点击这个按钮需要显示一个native的组件(比如一个toast),或者点击这个按钮需要去在native端执行一个耗时的任务。

场景2,还是前端页面有一个按钮,点击这个按钮的逻辑是:如果登录了,则跳转到相应的界面,如果没有登录,则跳转到登录界面。而这个登录界面是我们native维护的。

看完上面两个场景,相信大家也发现了一个问题,hybrid这样的开发方式有一个问题需要解决,那就是前端和本地的通信。

下面让我带大家了解一下几种常见的通信方式吧。

前言

在看这篇文章之前你要确保你有那么一点点的js知识,没错只需要一点点,能看懂最简单的代码就可以。如果你之前没接触过js的话。。也没关系,我会把其中对应的逻辑用语言表达出来。

为什么需要用到js呢,因为前端体系中,像我们说的点击按钮这样的逻辑都是放在js脚本中执行的,有点像我们Android中的model层。(由于本人对前端的知识也只是略知一二,这个比方可能不太恰当,见谅见谅)。所以说到hybrid通信,主要就是前端的js和我们Android端的通信。

传统的JSInterface

首先先介绍一下最普通的一种通信方式,就是使用Android原生的JavascriptInterface来进行js和java的通信。具体方式如下:

首先先看一段html代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="zh-CN" dir="ltr">
<head>
    <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />

    <script type="text/javascript">
        function showToast(toast) {
            javascript:control.showToast(toast);
        }
        function log(msg){
            console.log(msg);
        }
    </script>

</head>

<body>
<input type="button" value="toast"
       onClick="showToast('Hello world')" />
</body>
</html>

很简单,一个button,点击这个button就执行js脚本中的showToast方法。

jsinterface

而这个showToast方法做了什么呢?

1
2
3
function showToast(toast) {
    javascript:control.showToast(toast);
}

可以看到control.showToast,这个是什么我们等下再说,下面看我们java的代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:orientation="vertical"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:fitsSystemWindows="true"
    tools:context="zjutkz.com.tranditionaljsdemo.MainActivity">

    <WebView
        android:id="@+id/webView"
        android:layout_width="match_parent"
        android:layout_height="match_parent">

    </WebView>

</LinearLayout>
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
public class MainActivity extends AppCompatActivity {

    private WebView webView;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        webView = (WebView)findViewById(R.id.webView);

        WebSettings webSettings = webView.getSettings();

        webSettings.setJavaScriptEnabled(true);

        webView.addJavascriptInterface(new JsInterface(), "control");

        webView.loadUrl("file:///android_asset/interact.html");
    }

    public class JsInterface {

        @JavascriptInterface
        public void showToast(String toast) {
            Toast.makeText(MainActivity.this, toast, Toast.LENGTH_SHORT).show();
            log("show toast success");
        }

        public void log(final String msg){
            webView.post(new Runnable() {
                @Override
                public void run() {
                    webView.loadUrl("javascript: log(" + "'" + msg + "'" + ")");
                }
            });
        }
    }
}

首先界面很简单,一个WebView。在对应的activity中做的事也就几件,首先打开js通道。

1
2
3
WebSettings webSettings = webView.getSettings();

webSettings.setJavaScriptEnabled(true);

然后通过WebView的addJavascriptInterface方法去注入一个我们自己写的interface。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
webView.addJavascriptInterface(new JsInterface(), "control");

public class JsInterface {

        @JavascriptInterface
        public void showToast(String toast) {
            Toast.makeText(MainActivity.this, toast, Toast.LENGTH_SHORT).show();
            log("show toast success");
        }

        public void log(final String msg){
            webView.post(new Runnable() {
                @Override
                public void run() {
                    webView.loadUrl("javascript: log(" + "'" + msg + "'" + ")");
                }
            });
        }
    }

可以看到这个interface我们给它取名叫control。

最后loadUrl。

1
webView.loadUrl("file:///android_asset/interact.html");

好了,让我们再看看js脚本中的那个showToast()方法。

1
2
3
function showToast(toast) {
            javascript:control.showToast(toast);
        }

这里的control就是我们的那个interface,调用了interface的showToast方法

1
2
3
4
5
@JavascriptInterface
public void showToast(String toast) {
    Toast.makeText(MainActivity.this, toast, Toast.LENGTH_SHORT).show();
    log("show toast success");
}

可以看到先显示一个toast,然后调用log()方法,log()方法里调用了js脚本的log()方法。

1
2
3
function log(msg){
    console.log(msg);
}

js的log()方法做的事就是在控制台输出msg。

这样我们就完成了js和java的互调,是不是很简单。但是大家想过这样有什么问题吗?如果你使用的是AndroidStudio,在你的webSettings.setJavaScriptEnabled(true);这句函数中,AndroidStudio会给你一个提示。warning

这个提示的意思呢,就是如果你使用了这种方式去开启js通道,你就要小心XSS攻击了,具体的大家可以参考wooyun上的这篇文章

虽然这个漏洞已经在Android 4.2上修复了,就是使用@JavascriptInterface这个注解。但是你得考虑兼容性啊,你不能保证,尤其在中国这样碎片化严重的地方,每个用户使用的都是4.2+的系统。所以基本上我们不会再利用Android系统为我们提供的addJavascriptInterface方法或者@JavascriptInterface注解来实现js和java的通信了。那怎么办呢?方法都是人想出来的嘛,下面让我们看解决方案。

JSBridge

JSBridge,顾名思义,就是和js沟通的桥梁。其实这个技术在Android中已经不算新了,相信有些同学也看到过不少实现方案,这里说一种我的想法吧。其实说是我的想法,实际是公司里的大牛实现的,我现在做的就是维护并且扩展,不过这里还是拿出来和大家分享一下。

思路

首先先说思路,有经验的同学可能都知道Android的WebView中有一个WebChromeClient类,这个类其实就是用来监听一些WebView中的事件的,我们发现其中有三个这样的方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Override
public boolean onJsPrompt(WebView view, String url, String message, String defaultValue, JsPromptResult result) {
    return super.onJsPrompt(view, url, message, defaultValue, result);
}

@Override
public boolean onJsAlert(WebView view, String url, String message, JsResult result) {
    return super.onJsAlert(view, url, message, result);
}

@Override
public boolean onJsConfirm(WebView view, String url, String message, JsResult result) {
    return super.onJsConfirm(view, url, message, result);
}

这三个方法其实就对应于js中的alert(警告框),comfirm(确认框)和prompt(提示框)方法,那这三个方法有什么用呢?前面我们说了JSBridge的作用是提供一种js和java通信的框架,其实我们可以利用这三个方法去完成这样的事。比如我们可以在js脚本中调用alert方法,这样对应的就会走到WebChromeClient类的onJsAlert()方法中,我们就可以拿到其中的信息去解析,并且做java层的事情。那是不是这三个方法随便选一个就可以呢?其实不是的,因为我们知道,在js中,alert和confirm的使用概率还是很高的,特别是alert,所以我们最好不要使用这两个通道,以免出现不必要的问题。

好了,说到这里我们前期的准备工作也就做好了,其实就是通过重写WebView中WebChromeClient类的onJsPrompt()方法来进行js和java的通信。

有了实现方案,下面就是一些具体的细节了,大家有没有想过,怎么样才能让java层知道js脚本需要调用的哪一个方法呢?怎么把js脚本的参数传递进来呢?同步异步的方式又该怎么实现呢?下面提供一种我的思路。

首先大家都知道http是什么,其实我们的JSBridge也可以效仿一下http,定义一个自己的协议。比如规定sheme,path等等。下面来看一下一些的具体内容:

hybrid://JSBridge:1538351/method?{“message”:”msg”}

是不是和http协议有一点像,其实我们可以通过js脚本把这段协议文本传递到onPropmt()方法中并且进行解析。比如,sheme是hyrid://开头的就表示是一个hybrid方法,需要进行解析。后面的method表示方法名,message表示传递的参数等等。

有了这样一套协议,我们就可以去进行我们的通信了。

代码

先看一下我们html和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
<!DOCTYPE HTML>

<html>
<head>
  <meta charset="utf-8">
  <script src="file:///android_asset/jsBridge.js" type="text/javascript"></script>
</head>

<body>
<div class="blog-header">
  <h3>JSBridge</h3>
</div>
<ul class="entry">

    <br/>
    <li>
        toast展示<br/>
        <button onclick="JsBridge.call('JSBridge','toast',{'message':'我是气泡','isShowLong':0},function(res){});">toast</button>
    </li>

    <br/>
    <li>
        异步任务<br/>
        <button onclick="JsBridge.call('JSBridge','plus',{'data':1},function(res){console.log(JSON.stringify(res))});">plus</button>
    </li>

    <br/>
    <br/>
</ul>

</body>
</html>
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
(function (win, lib) {
    var doc = win.document;
    var hasOwnProperty = Object.prototype.hasOwnProperty;
    var JsBridge = win.JsBridge || (win.JsBridge = {});
    var inc = 1;
    var LOCAL_PROTOCOL = 'hybrid';
    var CB_PROTOCOL = 'cb_hybrid';
    var CALLBACK_PREFIX = 'callback_';

    //核心功能,对外暴露
    var Core = {

        call: function (obj, method, params, callback, timeout) {
            var sid;

            if (typeof callback !== 'function') {
                callback = null;
            }

            sid = Private.getSid();

            Private.registerCall(sid, callback);
            Private.callMethod(obj, method, params, sid);

        },

        //native代码处理 成功/失败 后,调用该方法来通知js
        onComplete: function (sid, data) {
            Private.onComplete(sid, data);
        }
    };

    //私有功能集合
    var Private = {
        params: {},
        chunks: {},
        calls: {},

        getSid: function () {
            return Math.floor(Math.random() * (1 << 50)) + '' + inc++;
        },

        buildParam: function (obj) {
            if (obj && typeof obj === 'object') {
                return JSON.stringify(obj);
            } else {
                return obj || '';
            }
        },

        parseData: function (str) {
            var rst;
            if (str && typeof str === 'string') {
                try {
                    rst = JSON.parse(str);
                } catch (e) {
                    rst = {
                        status: {
                            code: 1,
                            msg: 'PARAM_PARSE_ERROR'
                        }
                    };
                }
            } else {
                rst = str || {};
            }

            return rst;
        },

        //根据sid注册calls的回调函数
        registerCall: function (sid, callback) {
            if (callback) {
                this.calls[CALLBACK_PREFIX + sid] = callback;
            }
        },

        //根据sid删除calls对应的回调函数,并返回call对象
        unregisterCall: function (sid) {
            var callbackId = CALLBACK_PREFIX + sid;
            var call = {};

            if (this.calls[callbackId]) {
                call.callback = this.calls[callbackId];
                delete this.calls[callbackId];
            }

            return call;
        },

        //生成URI,调用native功能
        callMethod: function (obj, method, params, sid) {
            // hybrid://objectName:sid/methodName?params
            params = Private.buildParam(params);

            var uri = LOCAL_PROTOCOL + '://' + obj + ':' + sid + '/' + method + '?' + params;

            var value = CB_PROTOCOL + ':';
            window.prompt(uri, value);
        },

        onComplete: function (sid, data) {
            var callObj = this.unregisterCall(sid);
            var callback = callObj.callback;

            data = this.parseData(data);

            callback && callback(data);
        }
    };

    for (var key in Core) {
        if (!hasOwnProperty.call(JsBridge, key)) {
            JsBridge[key] = Core[key];
        }
    }
})(window);

有前端经验的同学应该能很轻松的看懂这样的代码,对于看不懂的同学我来解释一下,首先看界面。

jsinterface

可以看到有两个按钮,对应着html的这段代码

1
2
3
4
5
6
7
8
9
10
11
12
13
<br/>
<li>
    toast展示<br/>
    <button onclick="JsBridge.call('JSBridge','toast',{'message':'我是气泡','isShowLong':0},function(res){});">toast</button>
</li>

<br/>
<li>
    异步任务<br/>
    <button onclick="JsBridge.call('JSBridge','plus',{'data':1},function(res){console.log(JSON.stringify(res))});">toast</button>
</li>

<br/>

点击按钮会执行js脚本的这段代码

1
2
3
4
5
6
7
8
9
10
11
12
13
call: function (obj, method, params, callback, timeout) {
    var sid;

    if (typeof callback !== 'function') {
        callback = null;
    }

    sid = Private.getSid();

    Private.registerCall(sid, callback);
    Private.callMethod(obj, method, params, sid);

}

它其实就是一个函数,名字叫call,括号里的是它的参数(obj, method, params, callback, timeout)。那这几个参数是怎么传递的呢?回过头看我们的html代码,点击第一个按钮,会执行这个语句

1
<button onclick="JsBridge.call('JSBridge','toast',{'message':'我是气泡','isShowLong':0},function(res){});">toast</button>

其中括号(‘JSBridge’,’toast’,{‘message’:’我是气泡’,’isShowLong’:0},function(res){})里的第一个参数’JSBridge’对应着前面的obj,’toast’对应着method,以此类推。第二个按钮也是一样。

然后在call这个方法内,会执行Private类的registerCall和callMethod,我们来看callMehod()。

1
2
3
4
5
6
7
8
9
10
//生成URI,调用native功能
callMethod: function (obj, method, params, sid) {
    // hybrid://objectName:sid/methodName?params
    params = Private.buildParam(params);

    var uri = LOCAL_PROTOCOL + '://' + obj + ':' + sid + '/' + method + '?' + params;

    var value = CB_PROTOCOL + ':';
    window.prompt(uri, value);
}

注释说的很清楚了,就是通过传递进来的参数生成uri,并且调用window.prompt()方法,这个方法大家应该很眼熟吧,没错,在调用这个方法之后,程序就会相应的走到java代码的onJsPrompt()方法中。而生成的uri则是我们上面说过的那个我们自己定义的协议格式。

好了,我们总结一下这两个前端的代码。其实很简单,以界面的第一个按钮toast为例,点击这个按钮,它会执行相应的js脚本代码,然后就会像我们前面所讲的那样,走到onJsPrompt()方法中,下面让我们看看对应的java代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class InjectedChromeClient extends WebChromeClient {
    private final String TAG = "InjectedChromeClient";

    private JsCallJava mJsCallJava;

    public InjectedChromeClient() {
        mJsCallJava = new JsCallJava();
    }

    @Override
    public boolean onJsPrompt(WebView view, String url, String message, String defaultValue, JsPromptResult result) {
        result.confirm(mJsCallJava.call(view, message));
        return true;
    }
}

这是对应的WebChromeClient类,可以看到在onJsPrompt()方法中我们只做了一件事,就是丢给JsCallJava类去解析,再看JsCallJava类之前,我们可以先看看onJsPrompt()这个方法到底传进来了什么。

jsmessage

可以看到,我们传给JsCallJava类的那个message,就像我们前面定义的协议一样。sheme是hybrid://,表示这是一个hybrid方法,host是JSBridge,方法名字是toast,传递的参数是以json格式传递的,具体内容如图。不知道大家有没有发现,这里我有一个东西没有讲,就是JSBridge:后面的那串数字,这串数字是干什么用的呢?大家应该知道,现在我们整个调用过程都是同步的,这意味着我们没有办法在里面做一些异步的操作,为了满足异步的需求,我们就需要定义这样的port,有了这串数字,我们在java层就可以做异步的操作,等操作完成以后回调给js脚本,js脚本就通过这串数字去得到对应的callback,有点像startActivity中的那个requestCode。大家没听懂也没关系,后面我会在代码中具体讲解。

好了,下面我们可以来看JsCallJava这个类的具体代码了。

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
public class JsCallJava {
    private final static String TAG = "JsCallJava";

    private static final String BRIDGE_NAME = "JSBridge";

    private static final String SCHEME="hybrid";

    private static final int RESULT_SUCCESS=200;
    private static final int RESULT_FAIL=500;


    private ArrayMap<String, ArrayMap<String, Method>> mInjectNameMethods = new ArrayMap<>();

    private JSBridge mWDJSBridge = JSBridge.getInstance();

    public JsCallJava() {
        try {
            ArrayMap<String, Class<? extends IInject>> externals = mWDJSBridge.getInjectPair();
            if (externals.size() > 0) {
                Iterator<String> iterator = externals.keySet().iterator();
                while (iterator.hasNext()) {
                    String key = iterator.next();
                    Class clazz = externals.get(key);
                    if (!mInjectNameMethods.containsKey(key)) {
                        mInjectNameMethods.put(key, getAllMethod(clazz));
                    }
                }
            }
        } catch (Exception e) {
            Log.e(TAG, "init js error:" + e.getMessage());
        }
    }

    private ArrayMap<String, Method> getAllMethod(Class injectedCls) throws Exception {
        ArrayMap<String, Method> mMethodsMap = new ArrayMap<>();
        //获取自身声明的所有方法(包括public private protected), getMethods会获得所有继承与非继承的方法
        Method[] methods = injectedCls.getDeclaredMethods();
        for (Method method : methods) {
            String name;
            if (method.getModifiers() != (Modifier.PUBLIC | Modifier.STATIC) || (name = method.getName()) == null) {
                continue;
            }
           Class[] parameters=method.getParameterTypes();
           if(null!=parameters && parameters.length==3){
               if(parameters[0]==WebView.class && parameters[1]==JSONObject.class && parameters[2]==JsCallback.class){
                   mMethodsMap.put(name, method);
               }
           }
        }
        return mMethodsMap;
    }


    public String call(WebView webView, String jsonStr) {
        String methodName = "";
        String name = BRIDGE_NAME;
        String param = "{}";
        String result = "";
        String sid="";
        if (!TextUtils.isEmpty(jsonStr) && jsonStr.startsWith(SCHEME)) {
            Uri uri = Uri.parse(jsonStr);
            name = uri.getHost();
            param = uri.getQuery();
            sid = getPort(jsonStr);
            String path = uri.getPath();
            if (!TextUtils.isEmpty(path)) {
                methodName = path.replace("/", "");
            }
        }

        if (!TextUtils.isEmpty(jsonStr)) {
            try {
                ArrayMap<String, Method> methodMap = mInjectNameMethods.get(name);

                Object[] values = new Object[3];
                values[0] = webView;
                values[1] = new JSONObject(param);
                values[2]=new JsCallback(webView,sid);
                Method currMethod = null;
                if (null != methodMap && !TextUtils.isEmpty(methodName)) {
                    currMethod = methodMap.get(methodName);
                }
                // 方法匹配失败
                if (currMethod == null) {
                    result = getReturn(jsonStr, RESULT_FAIL, "not found method(" + methodName + ") with valid parameters");
                }else{
                    result = getReturn(jsonStr, RESULT_SUCCESS, currMethod.invoke(null, values));
                }
            } catch (Exception e) {
                e.printStackTrace();
            }
        } else {
            result = getReturn(jsonStr, RESULT_FAIL, "call data empty");
        }

        return result;
    }



    private String getPort(String url) {
        if (!TextUtils.isEmpty(url)) {
            String[] arrays = url.split(":");
            if (null != arrays && arrays.length >= 3) {
                String portWithQuery = arrays[2];
                arrays = portWithQuery.split("/");
                if (null != arrays && arrays.length > 1) {
                    return arrays[0];
                }
            }
        }
        return null;
    }

    private String getReturn(String reqJson, int stateCode, Object result) {
        String insertRes;
        if (result == null) {
            insertRes = "null";
        } else if (result instanceof String) {
            //result = ((String) result).replace("\"", "\\\"");
            insertRes = String.valueOf(result);
        } else if (!(result instanceof Integer)
                && !(result instanceof Long)
                && !(result instanceof Boolean)
                && !(result instanceof Float)
                && !(result instanceof Double)
                && !(result instanceof JSONObject)) {    // 非数字或者非字符串的构造对象类型都要序列化后再拼接
            insertRes = result.toString();//mGson.toJson(result);
        } else {  //数字直接转化
            insertRes = String.valueOf(result);
        }
        //String resStr = String.format(RETURN_RESULT_FORMAT, stateCode, insertRes);
        Log.d(TAG, " call json: " + reqJson + " result:" + insertRes);
        return insertRes;
    }
}

有点长,不过其实逻辑很好理解。首先我们调用的是call这个方法。它里面做了什么呢

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 String call(WebView webView, String jsonStr) {
    String methodName = "";
    String name = BRIDGE_NAME;
    String param = "{}";
    String result = "";
    String sid="";
    if (!TextUtils.isEmpty(jsonStr) && jsonStr.startsWith(SCHEME)) {
        Uri uri = Uri.parse(jsonStr);
        name = uri.getHost();
        param = uri.getQuery();
        sid = getPort(jsonStr);
        String path = uri.getPath();
        if (!TextUtils.isEmpty(path)) {
            methodName = path.replace("/", "");
        }
    }

    if (!TextUtils.isEmpty(jsonStr)) {
        try {
            ArrayMap<String, Method> methodMap = mInjectNameMethods.get(name);

            Object[] values = new Object[3];
            values[0] = webView;
            values[1] = new JSONObject(param);
            values[2]=new JsCallback(webView,sid);
            Method currMethod = null;
            if (null != methodMap && !TextUtils.isEmpty(methodName)) {
                currMethod = methodMap.get(methodName);
            }
            // 方法匹配失败
            if (currMethod == null) {
                result = getReturn(jsonStr, RESULT_FAIL, "not found method(" + methodName + ") with valid parameters");
            }else{
                result = getReturn(jsonStr, RESULT_SUCCESS, currMethod.invoke(null, values));
            }
        } catch (Exception e) {
            e.printStackTrace();
        }
    } else {
        result = getReturn(jsonStr, RESULT_FAIL, "call data empty");
    }

    return result;
}

可以看到其实就是通过js脚本传递过来的参数得到了方法名字,sid(前面说的那串数字)等等内容。下面看这段代码

1
ArrayMap<String, Method> methodMap = mInjectNameMethods.get(name);

通过name去得到一个map,这里的name是我们刚刚解析得到了,对应实际情况就是JSBridge,那这个mInjectNameMethods又是什么呢?

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
private ArrayMap<String, ArrayMap<String, Method>> mInjectNameMethods = new ArrayMap<>();

private JSBridge mJSBridge = JSBridge.getInstance();

public JsCallJava() {
    try {
        ArrayMap<String, Class<? extends IInject>> externals = mJSBridge.getInjectPair();
        if (externals.size() > 0) {
            Iterator<String> iterator = externals.keySet().iterator();
            while (iterator.hasNext()) {
                String key = iterator.next();
                Class clazz = externals.get(key);
                if (!mInjectNameMethods.containsKey(key)) {
                    mInjectNameMethods.put(key, getAllMethod(clazz));
                }
            }
        }
    } catch (Exception e) {
        Log.e(TAG, "init js error:" + e.getMessage());
    }
}

private ArrayMap<String, Method> getAllMethod(Class injectedCls) throws Exception {
    ArrayMap<String, Method> mMethodsMap = new ArrayMap<>();
    //获取自身声明的所有方法(包括public private protected), getMethods会获得所有继承与非继承的方法
    Method[] methods = injectedCls.getDeclaredMethods();
    for (Method method : methods) {
        String name;
        if (method.getModifiers() != (Modifier.PUBLIC | Modifier.STATIC) || (name = method.getName()) == null) {
            continue;
        }
       Class[] parameters=method.getParameterTypes();
       if(null!=parameters && parameters.length==3){
           if(parameters[0]==WebView.class && parameters[1]==JSONObject.class && parameters[2]==JsCallback.class){
               mMethodsMap.put(name, method);
           }
       }
    }
    return mMethodsMap;
}

可以看到我们有一个JSBridge类,在JsCallJava的构造函数中,我们通过JSBridge这个类的getInjectPair()方法得到了一个String和class的映射关系,并且把class中符合标准的方法拿出来存放到mInjectNameMethods中,以便我们在call方法中调用。下面来看看JSBridge类。

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
public class JSBridge {
    public static final String BRIDGE_NAME = "JSBridge";

    private static JSBridge INSTANCE = new JSBridge();

    private boolean isEnable=true;

    private ArrayMap<String, Class<? extends IInject>> mClassMap = new ArrayMap<>();

    private JSBridge() {
        mClassMap.put(BRIDGE_NAME, JSLogical.class);
    }

    public static JSBridge getInstance() {
        return INSTANCE;
    }

    public boolean addInjectPair(String name, Class<? extends IInject> clazz) {
        if (!mClassMap.containsKey(name)) {
            mClassMap.put(name, clazz);
            return true;
        }
        return false;
    }

    public boolean removeInjectPair(String name,Class<? extends IInject> clazz) {
        if (TextUtils.equals(name,BRIDGE_NAME)) {
            return false;
        }
        Class clazzValue=mClassMap.get(name);
        if(null!=clazzValue && (clazzValue == clazz)){
            mClassMap.remove(name);
            return true;
        }
        return false;

    }


    public ArrayMap<String, Class<? extends IInject>> getInjectPair() {
        return mClassMap;
    }
}

它的getInjectPair方法其实就是得到了mClassMap,这个map在JSBridge类初始化的时候就有一个默认的值了。

1
2
3
4
5
public static final String BRIDGE_NAME = "JSBridge";

private JSBridge() {
    mClassMap.put(BRIDGE_NAME, JSLogical.class);
}

key是”JSBridge”,value是我们的JSLogincal类。

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
public class JSLogical implements IInject {

    /**
     * toast
     *
     * @param webView 浏览器
     * @param param   提示信息
     */
    public static void toast(WebView webView, JSONObject param, final JsCallback callback) {
        String message = param.optString("message");
        int isShowLong = param.optInt("isShowLong");
        Toast.makeText(webView.getContext(), message, isShowLong == 0 ? Toast.LENGTH_SHORT : Toast.LENGTH_LONG).show();
        if (null != callback) {
            try {
                JSONObject object = new JSONObject();
                object.put("result", true);
                invokeJSCallback(callback, object);
            } catch (Exception e) {
                e.printStackTrace();
            }
        }
    }

    /**
     * 加一
     *
     * @param webView
     * @param param
     * @param callback
     */
    public static void plus(WebView webView, final JSONObject param, final JsCallback callback) {
        new Thread(new Runnable() {
            @Override
            public void run() {
                try {
                    Thread.sleep(2000);
                    int original = param.optInt("data");
                    original = original + 1;
                    if (null != callback) {
                        JSONObject object = new JSONObject();
                        object.put("after plussing", original);
                        invokeJSCallback(callback, object);
                    }
                } catch (Exception e) {
                    e.printStackTrace();
                }
            }
        }).start();
    }

    private static void invokeJSCallback(JsCallback callback, JSONObject objects) {
        invokeJSCallback(callback, true, null, objects);
    }

    public static void invokeJSCallback(JsCallback callback, boolean isSuccess, String message, JSONObject objects) {
        try {
            callback.apply(isSuccess, message, objects);
        } catch (JsCallback.JsCallbackException e) {
            e.printStackTrace();
        }
    }

对这个类上面的两个方法有没有很眼熟?名字和js脚本中的那两个方法一样有木有。我们调用链最后就会走到相应的同名方法中!

上面就是js调js的整个过程了,其实吧,不应该放这么多的代码的,搞得像是源码分析一样,不过我觉得这样还是有一定好处的,至少跟着代码走一遍能加深印象嘛。

我们还是来捋一捋整个过程。

(1) 在js脚本中把对应的方法名,参数等写成一个符合协议的uri,并且通过window.prompt方法发送给java层。
(2) 在java层的onJsPrompt方法中接受到对应的message之后,通过JsCallJava类进行具体的解析。
(3) 在JsCallJava类中,我们解析得到对应的方法名,参数等信息,并且在map中查找出对应的类的方法。

这里多说一句,还记得我们定义的协议中的host是什么吗?

hybrid://JSBridge:875725/toast?{“message”:”我是气泡”,”isShowLong”:0}

是JSBridge,而我们在JsCallJava类中是通过这个host去查找对应的类的,我们可以看到在JSBridge类中

1
2
3
4
5
public static final String BRIDGE_NAME = "JSBridge";

private JSBridge() {
    mClassMap.put(BRIDGE_NAME, JSLogical.class);
}

这意味着,如果你可以更换你的host,叫aaa都没关系,只要你在对应的map中的key也是aaa就可以了。

可能有的同学会说何必这么麻烦,直接在JsCallJava类中定义方法不就好了,这样还省的去写那么多的逻辑。可是大家有想过如果你把所有js脚本想要调用的方法都写在JsCallJava类中,这个类会有多难扩展和维护吗?而像我这样,如果你的js脚本处理的是登录相关逻辑,你可以写一个LoginLogical.class,如果是业务相关,你可以写一个BizLogical.class,这样不仅清晰,而且解耦。

当然,如果你仔细的看过代码,会发现其实在native层的那些同名函数其实是有规范的。

首先必须要是public static的,因为这样调用会更方便。

其次参数也有要求,有且仅有三个参数,WebView,JsonObject和一个Callback。WebView用来提供可能需要的context,另外java执行js方法也需要WebView对象。JsonObject是js脚本传递过来的参数。而Callback则是java用于回调js脚本的。

可能你会发现JSBridge里处处都是规范,协议需要规范,参数需要规范。这些其实都是合理的,因为规范所以安全。

#####(4) 在得到对应的方法之后,就去调用它,以我们的toast为

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
/**
 * toast
 *
 * @param webView 浏览器
 * @param param   提示信息
 */
public static void toast(WebView webView, JSONObject param, final JsCallback callback) {
    String message = param.optString("message");
    int isShowLong = param.optInt("isShowLong");
    Toast.makeText(webView.getContext(), message, isShowLong == 0 ? Toast.LENGTH_SHORT : Toast.LENGTH_LONG).show();
    if (null != callback) {
        try {
            JSONObject object = new JSONObject();
            object.put("result", true);
            invokeJSCallback(callback, object);
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

拿到对应的信息,直接makeToast就好了。

以上就是全部js调用java的过程,那我们java执行完逻辑以后,怎么回调js呢?这里我们以另外一个按钮的例子来说。

1
<button οnclick="JsBridge.call('JSBridge','plus',{'data':1},function(res){console.log(JSON.stringify(res))});">plus</button>

js脚本传递的一个json的参数,{“data”:1},从名字可以看出是先要java执行一个加逻辑。

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
/**
 * 加一
 *
 * @param webView
 * @param param
 * @param callback
 */
public static void plus(WebView webView, final JSONObject param, final JsCallback callback) {
    new Thread(new Runnable() {
        @Override
        public void run() {
            try {
                Thread.sleep(2000);
                int original = param.optInt("data");
                original = original + 1;
                if (null != callback) {
                    JSONObject object = new JSONObject();
                    object.put("after plussing", original);
                    invokeJSCallback(callback, object);
                }
            } catch (Exception e) {
                e.printStackTrace();
            }
        }
    }).start();
}

这里我们模拟一下耗时操作,可以帮助大家更好的理解JSBridge中的异步操作。对应java层的方法执行完+1的操作之后,把结果封装成一个jsonObject,并且调用invokeJSCallback方法。

1
2
3
4
5
6
7
public static void invokeJSCallback(JsCallback callback, boolean isSuccess, String message, JSONObject objects) {
    try {
        callback.apply(isSuccess, message, objects);
    } catch (JsCallback.JsCallbackException e) {
        e.printStackTrace();
    }
}

invokeJSCallback方法中直接调用了callback的apply方法。

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
private static final String CALLBACK_JS_FORMAT = "javascript:JsBridge.onComplete('%s', %s);";

public void apply(boolean isSuccess, String message, JSONObject object) throws JsCallbackException {
    if (mWebViewRef.get() == null) {
        throw new JsCallbackException("the WebView related to the JsCallback has been recycled");
    }
    if (!mCouldGoOn) {
        throw new JsCallbackException("the JsCallback isn't permanent,cannot be called more than once");
    }
    JSONObject result = new JSONObject();

    try {
        JSONObject code=new JSONObject();
        code.put("code", isSuccess ? 0 : 1);
        if(!isSuccess && !TextUtils.isEmpty(message)){
            code.putOpt("msg",message);
        }
        if(isSuccess){
            code.putOpt("msg", TextUtils.isEmpty(message)?"SUCCESS":message);
        }
        result.putOpt("status", code);
        if(null!=object){
            result.putOpt("data",object);
        }
    } catch (Exception e) {
        e.printStackTrace();
    }
    final String jsFunc = String.format(CALLBACK_JS_FORMAT, mSid, String.valueOf(result));

    if (mWebViewRef != null && mWebViewRef.get() != null) {
        mHandler.post(new Runnable() {
            @Override
            public void run() {
                mWebViewRef.get().loadUrl(jsFunc);
            }
        });

    }
}

在apply方法中,我们直接拼装了一个jsonObject,里面包括了我们想要返回给js脚本的结果,并且直接调用了js的onComplete方法。

1
2
3
4
5
6
7
8
onComplete: function (sid, data) {
    var callObj = this.unregisterCall(sid);
    var callback = callObj.callback;

    data = this.parseData(data);

    callback && callback(data);
}

可以看到js的onComplete通过sid(那一串数字)拿到对应的callback并执行,而我们plus的callback里做了什么呢?

1
function(res){console.log(JSON.stringify(res))}

直接在控制台中输出结果。

所以当我们点击plug按钮以后,过两秒我们就可以在logcat中看到如下输出

output

好了,至此所有和JSBridge相关的代码就分析完了。其实原理非常的简单,通过js的window.prompt方法将事先定义好的协议文本传输到java层,然后java层进行解析并调用相应的方法,最后通过callback将结果返回给js脚本。中间我们使用的那些类可以更好的解耦,如果你有心,甚至可以把所用逻辑相关代码抽离出来,把剩余的代码写成JSBridge.core作为库来使用。这样你想加什么功能直接写,不用改任何的源码。

UrlRouter

其实严格的说,UrlRouter不算是js和java的通信,它只是一个通过url来让前端唤起native页面的框架。不过千万不要小看它的作用,如果协议定义的合理,它可以让前端,Android和iOS三端有一个高度的统一,十分方便。

思路

其实吧,这个思路比JSBridge还要简单,就是我们通过自己实现的框架去拦截前端同学写的url,发现如果是符合我们UrlRouter的协议的话,就跳转到相应的页面。

至于怎么拦截呢?当然是通过WebViewClient类的shouldOverrideUrlLoading方法咯。

代码

首先我们还是先看一个html代码

1
2
3
4
<html>
<title>Login</title>
<input type="button" value="login" onclick="javascript:location.href='http://login.h5.zjutkz.net/'">
</html>

很简单,有一个按钮,通过点击这个按钮,会加载一个url,这个url是http://login.h5.zjutkz.net/。

这里多说一句,如果你也想用UrlRouter这样的形式的话,协议的sheme最好是http这样开头的,不要自己去重新定义,因为这样可以保证前端同学逻辑的清晰。如果你想着自己定义一个sheme叫shemeA,公司做别的app的同学也定义一个sheme叫shemeB,加上本来就要的http,前端的同学可能脑子都昏了。。。

下面来看看WebViewClient类。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class NavWebViewClient extends WebViewClient {

    private Context context;

    public NavWebViewClient(Context context){
        this.context = context;
    }

    @Override
    public boolean shouldOverrideUrlLoading(WebView view, String url) {
        if( Nav.from(context).toUri(url)){
            return true;
        }

        view.loadUrl(url);
        return true;
    }
}

很简单,在shouldOverrideUrlLoading方法中先拦截url交给Nav类处理,如果返回true则表示需要拦截,直接return true,否则交给WebView去loadUrl。

接下去看看Nav。

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
ublic class Nav {

    private static final String TAG = "Nav";

    public static Nav from(final Context context) {
        return new Nav(context);
    }

    public boolean toUri(final String uri) {
        if(TextUtils.isEmpty(uri)) return false;
        return toUri(Uri.parse(uri));
    }

    public boolean toUri(final Uri uri) {

        Log.d(TAG, uri.toString());

        final Intent intent = to(uri);

        for (;;) try {

            intent.setPackage(mContext.getPackageName());

            PackageManager pm = mContext.getPackageManager();

            final ResolveInfo info = pm.resolveActivity(intent, PackageManager.MATCH_DEFAULT_ONLY);
            if(info == null) {
                throw new ActivityNotFoundException("No Activity found to handle " + intent);
            } else {
                intent.setClassName(info.activityInfo.packageName, info.activityInfo.name);
            }

            mContext.startActivity(intent);
            return true;

        } catch (final ActivityNotFoundException e) {

            return false;
        }
    }

    @TargetApi(Build.VERSION_CODES.HONEYCOMB)
    private void startActivities(final Intent[] intents) {
        mContext.startActivities(intents);
    }

    private Intent to(final Uri uri) {
        mIntent.setData(uri);

        return mIntent;
    }

    private Nav(final Context context) {
        mContext = context;
        mIntent = new Intent(Intent.ACTION_VIEW);
    }

    private final Context mContext;
    private final Intent mIntent;
}

我们在NavWebViewClient类中是这样调用的

1
Nav.from(context).toUri(url)

from方法创建了一个Nav类的实例,下面来看看toUri方法

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
public boolean toUri(final String uri) {
    if(TextUtils.isEmpty(uri)) return false;
    return toUri(Uri.parse(uri));
}

public boolean toUri(final Uri uri) {

    Log.d(TAG, uri.toString());

    final Intent intent = to(uri);

    for (;;) try {

        intent.setPackage(mContext.getPackageName());

        PackageManager pm = mContext.getPackageManager();

        final ResolveInfo info = pm.resolveActivity(intent, PackageManager.MATCH_DEFAULT_ONLY);
        if(info == null) {
            throw new ActivityNotFoundException("No Activity found to handle " + intent);
        } else {
            intent.setClassName(info.activityInfo.packageName, info.activityInfo.name);
        }

        mContext.startActivity(intent);
        return true;

    } catch (final ActivityNotFoundException e) {

        return false;
    }
}

private Intent to(final Uri uri) {
    mIntent.setData(uri);

    return mIntent;
}

在toUri方法中调用了to方法,to方法做的就是将uri以setData的方式注入到intent中。

接着通过一系列PackageManager的方法去判断有没有符合uri的activity,如果有则直接startActivity。

是不是很简单,下面我以文中最开头的场景2为例子。

我们native端需要一个LoginActivity,并且根据上面的代码我们知道,这个LoginActivity必须要配置上对应的data才行。

1
2
3
4
5
6
7
8
9
10
<activity android:name=".activity.LoginActivity">
    <intent-filter>
        <action android:name="android.intent.action.VIEW"/>
        <category android:name="android.intent.category.DEFAULT"/>
        <category android:name="android.intent.category.BROWSABLE"/>
        <category android:name="${NAV_CATEGORY}"/>
        <data android:scheme="${NAV_SCHEMA}"/>
        <data android:host="${NAV_HOST}"/>
    </intent-filter>
</activity>
1
2
3
4
5
6
7
8
defaultConfig {
    applicationId "zjutkz.com.navigationdemo"
    minSdkVersion 15
    targetSdkVersion 23
    versionCode 1
    versionName "1.0"
    manifestPlaceholders = ["NAV_SCHEMA": "http", "NAV_HOST": "login.h5.zjutkz.net","NAV_CATEGORY": "zjutkz.net"]
}

这是我们的manifest文件,可以看到已经通过gradle配置了对应的data。

这里我为什么要用grdle去配置呢?想象如果你有十几个页面,你难道要在manifest中都写一遍吗?用我这种方式,直接在build.gradle中写一遍就可以了。这里我是想给大家传递一个思想:

使用gradle我们可以做很多自动化的事,千万不要自己给自己找麻烦了。

看到这儿大家肯定会觉得,就这么简单?是的,大体的框架就这么简单,但是如果你想真正的用好它,还需要做很多工作。

比如在跳转到native页面,做完响应的逻辑之后,你怎么通知前端去更新呢?这里你可以使用startActivityForResult,也可以使用广播,甚至是eventBus。这需要你在你的框架内做好封装。

再比如,上面的例子是最简单的,但是如果前端的同学想在跳到对应的native页面的时候加上一些参数呢?你的intent该怎么处理?

还有,如果你想你的框架鲁棒性够强,是不是得提供一个hook工具呢?让调用者可以hook掉你内部的那个intent,从而添加自己想要添加的数据。

这些都是要解决的问题,这里我就不给大家上具体的代码了。毕竟只有你自己去实现了以后才会有更深的理解

Logo

权威|前沿|技术|干货|国内首个API全生命周期开发者社区

更多推荐