1. 程式人生 > >hybrid通訊方式:h5的js與native的通訊方式

hybrid通訊方式:h5的js與native的通訊方式

原文地址:http://zjutkz.net/2016/04/17/%E5%A5%BD%E5%A5%BD%E5%92%8Ch5%E6%B2%9F%E9%80%9A%EF%BC%81%E5%87%A0%E7%A7%8D%E5%B8%B8%E8%A7%81%E7%9A%84hybrid%E9%80%9A%E4%BF%A1%E6%96%B9%E5%BC%8F/?nsukey=IJWfIBzXilYCL2Eqx6CVMi6MLmVFRSwLScdbby3aBmA4qzu21sLPthxfXWeorWE8dl8atdKAYCgm3ZZfTxqSxjrVISJRCaBFLfpr1Kuhd2xjSri3oo8E664vSPmq8dq3K7Bf5L2NEaswvAmRUk64dlRfIYnW4DInEnSSnsSw8Oj1OXQAPhVQTcmQgdBOLsDO

說起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