一、引言
本文主要針對H5與原生混合開發(fā)中的交互問題進行討論,當然,這僅僅是鄙人的見解,求同存異。
本文主要針對以下問題進行總結:
- 如何實現(xiàn)
JS與Andriod的交互? - 針對
WebView啟動慢問題,如何優(yōu)化? - 如果存在多個
H5模塊包,如何實現(xiàn)模塊包的完全更新與部分更新? - 針對以上問題的,如何建立一個公用的工具集(框架?)?
- 遇到的問題及解決辦法。
OK, 開始吧!
二、交互
關于如何實現(xiàn)JS與Android交互,其實看官方的 Building web apps in WebView 這篇文章就夠了,如果你覺得英文不好理解,那也沒關系,因為接下來的內(nèi)容會覆蓋這些技術點。
- 交互模型:

其實這里可以進一步將Webview抽象化,那么就得到了如下圖關系:

顯然這里的問題就是如何實現(xiàn)JsExecutor和JsInterfaces了。
對于JsExecutor而言(Android調(diào)用JS),其實是比較固定的寫法,比如,如果我們想要動態(tài)獲取網(wǎng)頁中某個標簽的html,那么會這么寫:
// 先假設id參數(shù)為content
Stirng elementId = "content";
String jsCode = "javascript:document.getElementById(\" + elementId +\").innerHtml";
webView.evaluateJavascript(jsCode, new ValueCallback<String>() {
@Override
public void onReceiveValue(String html) {
// ...
}
});
這種寫法是固定的,但是方法參數(shù)比較多時就比較蛋疼了,拼湊方法名和多個參數(shù)是很煩人的,且容易出錯,因而我們可以抽象出以下工具類:
/**
* @Author horseLai
* CreatedAt 2018/10/22 17:42
* Desc: JS 代碼執(zhí)行器,包含通過WebView執(zhí)行JS代碼的通用方法。
* Update:
*/
public final class JsExecutor {
private static final String TAG = "JsExecutor";
private JsExecutor() {
}
/**
* JS方法不帶參,且無返回值時用此方法
*
* @param webView
* @param jsCode
*/
public static void executeJsRaw(@NonNull WebView webView, @NonNull String jsCode) {
executeJsRaw(webView, jsCode, null);
}
/**
* JS方法帶參,且有返回值時用此方法
*
* @param webView
* @param jsCode
* @param callback
*/
public static void executeJsRaw(@NonNull WebView webView, @NonNull String jsCode, @Nullable ValueCallback<String> callback) {
if (Build.VERSION.SDK_INT >= 19) {
webView.evaluateJavascript(jsCode, callback);
} else {
// 注意這里,這種方式?jīng)]有直接的結果回調(diào),不過可以迂回解決,比如我們可以
// 執(zhí)行JS的一個固定的方法,并傳入類型參數(shù),然后在JS方法中根據(jù)這個類型參
// 數(shù)去匹配方法并執(zhí)行,執(zhí)行完成后再調(diào)用我們注入的相應回調(diào)方法將結果傳回
// 來,這樣就可以解決結果回調(diào)問題了,如果要適配 Android 4.4 以下的版本則可以這么做。
webView.loadUrl(jsCode);
}
}
/**
* JS方法帶參,且有返回值時用此方法
*
* @param webView
* @param methodName
* @param callback
* @param params
*/
public static void executeJs(@NonNull WebView webView, @NonNull CharSequence methodName, @Nullable ValueCallback<String> callback, @NonNull CharSequence... params) {
StringBuilder sb = new StringBuilder();
sb.append("javascript:")
.append(methodName)
.append("(");
if (params != null && params.length > 0) {
for (int i = 0; i < params.length; i++) {
sb.append("\"")
.append(params[i])
.append("\"");
if (i < params.length - 1)
sb.append(",");
}
}
sb.append(");");
Log.i(TAG, "executeJs: " + sb);
executeJsRaw(webView, sb.toString(), callback);
}
/**
* JS方法帶參,且無返回值時用此方法
*
* @param webView
* @param methodName
* @param params
*/
public static void executeJs(@NonNull WebView webView, @NonNull CharSequence methodName, @NonNull CharSequence... params) {
executeJs(webView, methodName, null, params);
}
}
這里直接將WebView視為我們執(zhí)行JS代碼的工具,如下示例是給H5傳遞當前網(wǎng)絡類型,由于整合了JS代碼的拼接過程,因此只需要傳入具體方法名稱和方法的字符串參數(shù)即可。
JsExecutor.executeJs(webView, "onNetStatusChanged", netType);
-
對于
JsInterfaces(JS調(diào)用Android) , 我們需要在我們需要注入的方法前加上注解@JavascriptInterface才能將方法暴露出去,然后將包含此方法的類對象注入進去,如下一個實際場景,H5需要從Android原生中獲取用戶的賬號信息,那么可以這么寫:
先注入包含對應方法的H5JsStorage類對象:
H5JsStorage h5JsStorage = new H5JsStorage(this, mUser);
webView.addJavascriptInterface(h5JsStorage, "h5JsStorage");
其中getUserAccountInfo的聲明如下:
public class H5JsStorage implements IH5JsStorage {
// ...
@JavascriptInterface
public String getUserAccountInfo(){
return String.format("{\"userAccount\":\"%s\", \"password\":\"%s\", \"userIncrId\":\"%s\", \"orgId\":\"%s\"}", mUser.getUserAccount(), mUser.getPassword(), mUser.getUserIncrId(), mUser.getOrgId());
}
// ...
}
以上便是H5與原生交互的交互過程,具體代碼在文章末尾會給出GitHub地址。
三、WebView 啟動速度優(yōu)化、多模塊包自動更新
1. WebView啟動速度優(yōu)化
我們先來做個實驗,測試一下包含WebView的Activity在優(yōu)化前后的啟動速度,可以這么做:根據(jù)Activity的生命周期,在onCreate的第一行處記錄下初始時間,在onStart最后一行記錄下結束時間,然后計算時間差,作為衡量啟動速度的參照,多次測試,記錄時間差。結果如下:
//---------------------------------------------
// 不做任何處理, Mi6 android 8.0
I/Main2Activity: onStart: total cost:150 ms
I/Main2Activity: onStart: total cost:44 ms
I/Main2Activity: onStart: total cost:33 ms
I/Main2Activity: onStart: total cost:54 ms
I/Main2Activity: onStart: total cost:35 ms
I/Main2Activity: onStart: total cost:34 ms
I/Main2Activity: onStart: total cost:34 ms
// 優(yōu)化后 初始化耗時
I/MyWebViewHolder: prepareWebView: total cost: 131 ms
I/MyWebViewHolder: prepareWebView: total cost: 121 ms
I/MyWebViewHolder: prepareWebView: total cost: 121 ms
I/MyWebViewHolder: prepareWebView: total cost: 117 ms
I/MyWebViewHolder: prepareWebView: total cost: 110 ms
I/MyWebViewHolder: prepareWebView: total cost: 116 ms
I/MyWebViewHolder: prepareWebView: total cost: 116 ms
// 之后耗時
I/Main2Activity: onStart: total cost:26 ms
I/Main2Activity: onStart: total cost:20 ms
I/Main2Activity: onStart: total cost:22 ms
I/Main2Activity: onStart: total cost:17 ms
I/Main2Activity: onStart: total cost:19 ms
I/Main2Activity: onStart: total cost:21 ms
//---------------------------------------------
// 模擬器 android 9.0
I/Main2Activity: onStart: total cost:292 ms
I/Main2Activity: onStart: total cost:50 ms
I/Main2Activity: onStart: total cost:49 ms
I/Main2Activity: onStart: total cost:54 ms
I/Main2Activity: onStart: total cost:43 ms
I/Main2Activity: onStart: total cost:47 ms
I/Main2Activity: onStart: total cost:39 ms
I/Main2Activity: onStart: total cost:41 ms
// 優(yōu)化后 初始化耗時
I/MyWebViewHolder: prepareWebView: total cost: 177 ms
I/MyWebViewHolder: prepareWebView: total cost: 169 ms
I/MyWebViewHolder: prepareWebView: total cost: 183 ms
I/MyWebViewHolder: prepareWebView: total cost: 159 ms
// 之后 耗時
I/Main2Activity: onStart: total cost:40 ms
I/Main2Activity: onStart: total cost:27 ms
I/Main2Activity: onStart: total cost:34 ms
I/Main2Activity: onStart: total cost:34 ms
I/Main2Activity: onStart: total cost:33 ms
I/Main2Activity: onStart: total cost:30 ms
//---------------------------------------------
// MT6592 android 4.4 不做處理
I/Main2Activity: onStart: total cost:141 ms
I/Main2Activity: onStart: total cost:46 ms
I/Main2Activity: onStart: total cost:43 ms
I/Main2Activity: onStart: total cost:42 ms
I/Main2Activity: onStart: total cost:44 ms
I/Main2Activity: onStart: total cost:46 ms
// 優(yōu)化后 初始化耗時
I/MyWebViewHolder: prepareWebView: total cost: 182 ms
I/MyWebViewHolder: prepareWebView: total cost: 50 ms
I/MyWebViewHolder: prepareWebView: total cost: 54 ms
I/MyWebViewHolder: prepareWebView: total cost: 53 ms
I/MyWebViewHolder: prepareWebView: total cost: 54 ms
I/MyWebViewHolder: prepareWebView: total cost: 56 ms
// 之后耗時
I/Main2Activity: onStart: total cost:36 ms
I/Main2Activity: onStart: total cost:34 ms
I/Main2Activity: onStart: total cost:30 ms
I/Main2Activity: onStart: total cost:31 ms
I/Main2Activity: onStart: total cost:32 ms
根據(jù)以上結果可以看出,優(yōu)化后要比優(yōu)化前的啟動速度快個10~20毫秒,且抖動較小??梢宰⒁獾狡渲邪粋€叫做prepareWebView的時間差,據(jù)此,聰明的你肯定能想到我所謂的優(yōu)化是做了什么操作。嗯~,其實就是使用WebView之前,在合適的地方和時機先將其初始化,之后復用這個創(chuàng)建好的實例,這里我是這么寫的:
/**
* @Author horseLai
* CreatedAt 2018/12/10 10:11
* Desc: 用于持有MyWebView實例,減少每次都重新創(chuàng)建和銷毀造成的開銷
* Update:
*/
public final class MyWebViewHolder {
private static final String TAG = "MyWebViewHolder";
private MyWebView mWebView;
private static MyWebViewHolder sMyWebViewHolder;
private View pageNoneNet;
private boolean mShouldClearHistory = false;
public boolean shouldClearHistory() {
return mShouldClearHistory;
}
public void shouldClearHistory(boolean shouldClearHistory) {
this.mShouldClearHistory = shouldClearHistory;
}
private MyWebViewHolder() {
}
public static MyWebViewHolder getHolder() {
if (sMyWebViewHolder != null) return sMyWebViewHolder;
synchronized (MyWebViewHolder.class) {
if (sMyWebViewHolder == null) {
sMyWebViewHolder = new MyWebViewHolder();
}
}
return sMyWebViewHolder;
}
/**
* 務必在使用WebView前調(diào)用此方法進行初始化
*
* @param context
*/
public void prepareWebView(Context context) {
long start = System.currentTimeMillis();
if (mWebView != null) return;
synchronized (this) {
if (mWebView == null) {
mWebView = new MyWebView(context);
}
}
Log.i(TAG, "prepareWebView: total cost: " + (System.currentTimeMillis() - start) + " ms");
Log.d(TAG, "prepare MyWebView OK...");
}
public MyWebView getMyWebView() {
return mWebView;
}
public void detach() {
if (mWebView != null) {
Log.d(TAG, "detach MyWebView, but not destroy...");
((ViewGroup) mWebView.getParent()).removeView(mWebView);
mWebView.removeAllViews();
mWebView.clearAnimation();
mWebView.clearFormData();
// mWebView.clearHistory();
mShouldClearHistory = true;
mWebView.getSettings().setJavaScriptEnabled(false);
}
}
public void attach(ViewGroup parent, int index) {
if (mWebView != null) {
Log.d(TAG, "attach MyWebView, index of ViewGroup is " + index);
WebSettings settings = mWebView.getSettings();
// 不加此配置會無法加載顯示界面
settings.setDomStorageEnabled(true);
settings.setSupportZoom(false);
settings.setJavaScriptEnabled(true);
settings.setUseWideViewPort(true);
mWebView.setLayoutParams(new ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT));
mWebView.setVerticalScrollBarEnabled(false);
mWebView.setHorizontalScrollBarEnabled(false);
// 在WebView上層覆蓋一個用于提示如錯誤等信息的布局層,
FrameLayout frameLayout = new FrameLayout(parent.getContext());
frameLayout.setLayoutParams(new FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT));
frameLayout.addView(mWebView, new FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT));
pageNoneNet = LayoutInflater.from(parent.getContext()).inflate(R.layout.layout_null_net, frameLayout, false);
frameLayout.addView(pageNoneNet);
pageNoneNet.setVisibility(View.GONE);
pageNoneNet.findViewById(R.id.btn_try).setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
pageNoneNet.setVisibility(View.GONE);
mWebView.reload();
}
});
parent.addView(frameLayout, index);
}
}
public void showNoneNetPage() {
if (pageNoneNet != null)
pageNoneNet.setVisibility(View.VISIBLE);
}
public void hideNoneNetPage() {
if (pageNoneNet != null)
pageNoneNet.setVisibility(View.GONE);
}
public void attach(ViewGroup parent) {
attach(parent, parent.getChildCount());
}
public void destroy() {
if (mWebView != null) {
Log.d(TAG,"destroy MyWebView...");
mWebView.destroy();
}
}
public void pause() {
if (mWebView != null) {
Log.d(TAG,"pause MyWebView...");
mWebView.onPause();
}
}
public void resume() {
if (mWebView != null) {
Log.d(TAG,"resume MyWebView...");
mWebView.onResume();
}
}
public void removeJSInterfaces(String... names) {
if (names == null || names.length == 0) return;
for (String name : names) {
Log.d(TAG,String.format("removeJSInterfaces:: %s ..", name));
mWebView.removeJavascriptInterface(name);
}
}
}
然后在合適的地方初始化:
@Override
protected void onCreate(Bundle savedInstanceState) {
// ...
MyWebViewHolder.getHolder().prepareWebView(this);
}
添加到布局中:
LinearLayout parent= findViewById(R.id.parent);
MyWebViewHolder.getHolder().attach(parent);
在onDestroy時從界面中解除綁定:
@Override
protected void onDestroy() {
// ...
MyWebViewHolder.getHolder().detach();
}
2. 多模塊包自動更新
支持多模塊自動更新的目的是方便更新維護,減少用戶升級所帶來的流量開支,每個模塊包之間可以是相互獨立的,也方便于團隊開發(fā),僅需要和前端約定好文件目錄即可。
先來看看H5模塊的自動更新流程(完整更新):

上面是模塊包的完整更新過程,還可以進行補丁更新,而所謂補丁更新就是,下載的更新包中僅僅包含需要更新的文件,因而對應于上面流程而言,就是少了刪除本地舊版本文件的過程,而直接解壓替換對應文件。這種更新方式有以下優(yōu)缺點:
- 可以極大的減少更新時對用戶的流量消耗,且速度極快。
- 但是需要前端明確抽取所更新的文件,否則會出現(xiàn)問題,可能這個過程會繁瑣點。
- 如果使用類似于
VueJs這種模板框架編寫的界面,因為需要編譯為JS代碼,然后僅剩一個index.html入口,導致抽取定位繁瑣,且每次編譯出來的文件名可能不一樣,因此不能使用補丁更新這種方式,只能分包,然后進行完整更新。
具體代碼比較多,就補貼了,請看 github這里, 其中H5ManagerSettings是H5Manager配置信息與無關邏輯的抽離類。
四、建立公用工具集
上面已經(jīng)逐個介紹了混合開發(fā)中交互與更新的邏輯,工具集已經(jīng)放到 github的H5MixDevelopTools,感興趣的童鞋可以看看,雖然這里我并沒有把JS接口和html界面放上去。

遇到的實際問題與解決辦法:(以項目中使用VueJs作為模板引擎來編寫H5頁面為例)
1. 界面加載不出來,顯示空白,怎么辦?
解決辦法:給WebView加上下面配置即可
mWebView.getSettings().setDomStorageEnabled(true);
2. 聯(lián)調(diào)時發(fā)現(xiàn)總是找不到定義的交互接口方法,怎么辦?
原因與解決辦法:首先,默認情況下,VueJs在對代碼進行混淆處理,因此如果你遇到了這個問題,那么請手動配置以關閉混淆(具體做法請自行查找吧)。如果已經(jīng)不混淆了,但是依然找不到對應的方法,怎么辦?我和我的小伙伴是將接口文件放到components中將其視作一個組件來使用的,然后具體到接口方法的話,將方法掛到window對象下,如下示例:
// 掛載方法
window.showToast = function(msg){
UI.showToast(msg);
}
// 掛載變量,掛載在window的變量可以在全局直接引用
window.userInfo = {name:"horseLai"}
3. 圖片選擇問題,怎么選擇和預覽圖片?
先來個具體場景:比如說我們項目中有個評論功能,這個功能是用H5寫的,然后每次評論時可以選擇數(shù)量小于3張的評論圖片,附帶文字上傳至服務器。
此時你會發(fā)現(xiàn)直接使用<input type="file">沒法調(diào)用起系統(tǒng)相片圖庫和相機,更沒法在旁邊顯示預覽圖,這時你可能需要這些配置:
settings.setJavaScriptEnabled(true);
settings.setAllowFileAccess(true);
settings.setAllowFileAccessFromFileURLs(true);
settings.setAllowUniversalAccessFromFileURLs(true);
settings.setAllowContentAccess(true);
接著就是選擇圖片有兩種方案:
- 通過復寫
WebChromeClientc#onShowFileChooser和WebChromeClient#openFileChooser,但是openFileChooser方法已經(jīng)變?yōu)橄到y(tǒng)Api了,所以沒法直觀的找到它,但是,即使找到了,你也會發(fā)現(xiàn)去適配不同的機型也是坑的很??梢韵瓤纯?android-4-4-webview-file-chooser-not-opening, 而因為我不是直接調(diào)用圖庫選擇,而是先開啟一個BottomSheetDialog來選擇是通過相機還是圖庫取圖,這樣帶來的問題就是,如果我僅僅是開啟了BottomSheetDialog,然后不做任何選擇地關閉掉它,不調(diào)用ValueCallback#onReceiveValue傳值的話,那么<input>只能啟當一次彈窗,之后再點就沒反應了,而如果我每次關閉BottomSheetDialog時通過ValueCallback#onReceiveValue傳個null,那么連續(xù)啟動兩次后又會異常閃退,嗯,這坑我就不跳了,我選擇第二種方案。 - 第二種方案就是直接建立
JS交互接口,點擊圖片選擇控件后調(diào)用建立好的原生圖片選擇接口取圖,當我們選好圖之后在onActivityResult方法中執(zhí)行JS方法將圖片的本地路徑傳給JS處理,嗯,到這里的話好說,這個流程咱都熟悉。那么來說說如何在<img>上預覽,以及如何將這個路徑的圖片作為文件上傳。
下面是選完圖片后我們將圖片路徑回調(diào)到JS的方法。
/**
* 相冊中獲取圖片、相機拍照結果回調(diào)
* @param {Number} type 類型: 0->圖庫, 1->相機
* @param {String} imgFilePath
*/
window.selectedImgFile = []; // 模擬<input>選擇文件后的存儲形式,用于上傳
window.selectedImgFileUrls = []; // 將圖片路徑轉換成<img>能夠預覽的路徑
window.onPictureResult = function (type, imgFilePath) {
// 注意這里
selectedImgFile.push(new File([""], imgFilePath, {type:"image/*"}));
selectedImgFileUrls.push({
imgUrl: "file://" + imgFilePath
});
}
上面selectedImgFile,selectedImgFileUrls這兩個掛載到window的變量,這兩個數(shù)組可以直接在全局引用了,記得在使用后清空,不然會影響到下次使用。
嗯,看起來很完美,選圖、預覽很完美,但很快你就會發(fā)現(xiàn)這實際是個BUG,BUG在哪里呢?注意到上面的new File([""], imgFilePath, {type:"image/*"}),這么使用會導致上傳到服務器的圖片大小為 0kb, 為啥呢?因為第一個參數(shù)[""]實際是圖片的實際數(shù)據(jù)(字節(jié)數(shù)組),它的長度代表著文件的大小,因此,上面這樣做雖然能夠預覽,但是無法僅僅直接通過一個本地路徑就讀取到文件流數(shù)據(jù),也就不能上傳成功了。
怎么辦呢?思考了很久,發(fā)現(xiàn)自己一直困在JS如何通過一個本地路徑建立File并上傳的思維當中,于是找前端和后臺的小伙伴交流,最終確定的方案是:選擇圖片后先將圖片編碼成Base64字符串再注入到JS處理,JS端收到數(shù)據(jù)后進行圖片數(shù)據(jù)綁定,以及上傳到服務器,服務器端進行Base64解碼處理,然后保存成本地圖片。
于是可以稍微修改成這樣:
window.selectedImgFile = [];
window.selectedImgFileUrls = []; // 將圖片路徑轉換成<img>能夠預覽的路徑
window.onPictureResult = function (type, imgFilePath, base64Data) {
selectedImgFile.push( base64Data );
selectedImgFileUrls.push({
imgUrl: "data:image/jpg;base64," + base64Data
});
}
不過這里依然可能存在一些問題,比如內(nèi)存溢出,因為圖片本身可能很大,尤其是使用相機直接拍照取圖的情況,一張圖片可能會有3~10M,直接編碼為圖片本身會比較耗時,而編碼出來的字符串會存在于內(nèi)存中,因此很有可能會導致Android端出現(xiàn)內(nèi)存溢出的情況,因此這里可以考慮先壓縮后編碼,這樣可以降低內(nèi)存耗盡的幾率。
五、總結
本文基于實際項目,介紹了混合開發(fā)中JS與原生交互的實現(xiàn),然后以一個小實驗測試了含WebView的Activity的啟動速度,優(yōu)化,然后測試優(yōu)化后的啟動速度,接著介紹了H5分模塊更新的邏輯,最后整理了一套工具集,感興趣的童鞋可以看看 H5MixDevelopTools,歡迎指正。
使用H5混合開發(fā)確實能夠提升開發(fā)速度,但是實際體驗確實一般,適合非常追求開發(fā)速度的場景。