不知不覺,Hybrid App已經成了目前比較主流的一種開發方式。
對於用戶體驗要求較高或者與硬體交互較多的功能我們一般都會採用Native原生的方式來實現。
而用戶交互少,偏展示類,活動類的功能我們則通常採用H5的方式來實現,
例如新聞類的app,詳情展示頁一般就是H5的頁面
一方面圖文排版上web有著先天的優勢,同時純展示類的頁面在目前的行動裝置上,性能體驗已經很難讓用戶分辨是網頁還是原生了;
另一方面,H5的頁面跨平臺,方便在原生客戶端上實現分享功能,擁有較強的傳播性,我們平時常見的活動頁面也擁有這樣的優勢,所以你看到的活動頁面也基本都是H5,只需輕輕一點就能分享到各個平臺;
同時,H5的頁面開發降低了開發成本,一套代碼,web,android,ios都能訪問。(然而實際開發過程中,H5的適配也都是各種淚)
既然Hybrid App有這麼多優勢,那在Android中我們通過什麼樣的方式在原生項目中嵌入H5頁面呢?
那就不得不提到我們的WebVew了,作為官方唯一用來顯示web的組件,
展示網頁這樣的任務也只能交給它了。
A View that displays web pages. This class is the basis upon which you can roll your own web browser or simply display some online content within your Activity. It uses the WebKit rendering engine to display web pages and includes methods to navigate forward and backward through a history, zoom in and out, perform text searches and more.
引用官方文檔的一句話:
WebView是一個用來在Activity中顯示我們網頁的視圖組件,它通過webkit渲染引擎渲染和顯示我們的web頁面,並且包含了web的歷史導航操法,頁面放大縮小,文本搜索等方法。
我們首先來看一下WebView的基本用法:
WebView的基本用法關於WebView的基本用法,大部分人也是輕車熟路,
本來也是寫了一部分,無意中發現有位博主的博客對WebView的介紹實在太過詳細,像我這樣的懶人,有更好的文章是不會自己去寫的,
所以刪了自己寫的,將大牛博主的博客分享出來,感興趣同學的可以一起看一看:
Android WebView 開發詳解(一)
Android WebView 開發詳解(二)
Android WebView 開發詳解(三)
了解完WebView的基本用法,那就來總結下最近項目中遇到的關於WebView的坑
項目中使用WebView遇到的問題WebView界面的原生標題設置Picture如圖所示,
一般情況下,我們WebView所在界面由頂部帶標題的原生導航欄跟WebView的內容部分組成,
而WebView中的界面可能在點擊後還會再跳其他Web頁面(如圖點擊請假會在當前WebView跳轉請假的Web頁面)。
由於點擊內容的不確定性,所以通常情況下,最簡單的做法就是捕獲h5頁面的<title>標籤來進行標題設置。
對於捕獲<title>標籤內容的方式,WebView也很好地提供了支持,我們可以通過繼承WebChromeClient的onReceivedTitle來進行獲取:
private class WebViewChromeClient extends WebChromeClient {
@Override
public void onReceivedTitle(WebView view, String title) {
super.onReceivedTitle(view, title);
mTitleText.setTitle(String.valueOf(view.getTitle()));
}
}
然而這樣的方式在實際使用中有一個問題:
當通過 webView.goBack() 方式返回上一級Web頁面的時候不會觸發這個方法,因此會導致標題無法跟隨歷史記錄返回上一級頁面。
所以在項目中,我們可以通過重寫 WebViewClient 的 onPageFinished 方法,在 onPageFinished 中對界面標題進行設置。
因為不管是歷史記錄的返回還是點擊跳轉都會觸發頁面加載,當頁面加載完成時(不包括js動態創建以及img圖片加載完畢)都會觸發onPageFinished 這個方法,
此時我們去獲取<title>的標題內容不會有任何問題,可以確保在頁面返回時能夠獲取到正確的標題。
mWebView.setWebViewClient(new WebViewClient(){
@Override
public void onPageFinished(WebView view, String url) {
super.onPageFinished(view, url);
mToolbar.setTitle(String.valueOf(view.getTitle()));
Log.i (LOG_TAG, "onPageFinished");
}
});
注: 這種做法有一個缺陷,就是返回上一個界面的時候,等頁面加載完成的時候標題才會顯示出來,為了更好地優化,我們可以創建一個集合用來保存我們的標題,加載url的時候把標題添加進集合,當返回上一級頁面的時候,從集合中取出標題進行顯示,同時從集合中移除標題。
WebView中的Web頁面存在 <input type='file'>標籤時無法打開文件選擇器在我們的手機瀏覽器中,當web頁面中有<input type='file'> 按鈕標籤的時候點擊會自動打開系統的文件選擇器,然而這個功能在主流系統的WebView中沒有被默認實現,
因此,為了讓<input type='file'> 點擊時能夠打開系統的文件選擇器,我們必須通過重寫 WebChromeClient 來實現點擊 <input type='file'>打開系統文件選擇器。
代碼如下:
public class MainActivity extends AppCompatActivity {
protected ValueCallback<Uri> mFileUploadCallbackFirst;
protected ValueCallback<Uri[]> mFileUploadCallbackSecond;
protected static final int REQUEST_CODE_FILE_PICKER = 51426;
protected String mUploadableFileTypes = "image/*";
private WebView mWebView;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
initWebView();
}
private void initWebView() {
mWebView = (WebView) findViewById(R.id.my_webview);
mWebView.loadUrl("file:///android_asset/index.html");
mWebView.setWebChromeClient(new OpenFileChromeClient());
}
private class OpenFileChromeClient extends WebChromeClient {
@SuppressWarnings("unused")
public void openFileChooser(ValueCallback<Uri> uploadMsg) {
openFileChooser(uploadMsg, null);
}
public void openFileChooser(ValueCallback<Uri> uploadMsg, String acceptType) {
openFileChooser(uploadMsg, acceptType, null);
}
@SuppressWarnings("unused")
public void openFileChooser(ValueCallback<Uri> uploadMsg, String acceptType, String capture) {
openFileInput(uploadMsg, null, false);
}
@SuppressWarnings("all")
public boolean onShowFileChooser(WebView webView, ValueCallback<Uri[]> filePathCallback, WebChromeClient.FileChooserParams fileChooserParams) {
if (Build.VERSION.SDK_INT >= 21) {
final boolean allowMultiple = fileChooserParams.getMode() == FileChooserParams.MODE_OPEN_MULTIPLE;
openFileInput(null, filePathCallback, allowMultiple);
return true;
}
else {
return false;
}
}
}
@SuppressLint("NewApi")
protected void openFileInput(final ValueCallback<Uri> fileUploadCallbackFirst, final ValueCallback<Uri[]> fileUploadCallbackSecond, final boolean allowMultiple) {
if (mFileUploadCallbackFirst != null) {
mFileUploadCallbackFirst.onReceiveValue(null);
}
mFileUploadCallbackFirst = fileUploadCallbackFirst;
if (mFileUploadCallbackSecond != null) {
mFileUploadCallbackSecond.onReceiveValue(null);
}
mFileUploadCallbackSecond = fileUploadCallbackSecond;
Intent i = new Intent(Intent.ACTION_GET_CONTENT);
i.addCategory(Intent.CATEGORY_OPENABLE);
if (allowMultiple) {
if (Build.VERSION.SDK_INT >= 18) {
i.putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true);
}
}
i.setType(mUploadableFileTypes);
startActivityForResult(Intent.createChooser(i, "選擇文件"), REQUEST_CODE_FILE_PICKER);
}
public void onActivityResult(final int requestCode, final int resultCode, final Intent intent) {
if (requestCode == REQUEST_CODE_FILE_PICKER) {
if (resultCode == Activity.RESULT_OK) {
if (intent != null) {
if (mFileUploadCallbackFirst != null) {
mFileUploadCallbackFirst.onReceiveValue(intent.getData());
mFileUploadCallbackFirst = null;
}
else if (mFileUploadCallbackSecond != null) {
Uri[] dataUris = null;
try {
if (intent.getDataString() != null) {
dataUris = new Uri[] { Uri.parse(intent.getDataString()) };
}
else {
if (Build.VERSION.SDK_INT >= 16) {
if (intent.getClipData() != null) {
final int numSelectedFiles = intent.getClipData().getItemCount();
dataUris = new Uri[numSelectedFiles];
for (int i = 0; i < numSelectedFiles; i++) {
dataUris[i] = intent.getClipData().getItemAt(i).getUri();
}
}
}
}
}
catch (Exception ignored) { }
mFileUploadCallbackSecond.onReceiveValue(dataUris);
mFileUploadCallbackSecond = null;
}
}
}
else {
if (mFileUploadCallbackFirst != null) {
mFileUploadCallbackFirst.onReceiveValue(null);
mFileUploadCallbackFirst = null;
}
else if (mFileUploadCallbackSecond != null) {
mFileUploadCallbackSecond.onReceiveValue(null);
mFileUploadCallbackSecond = null;
}
}
}
}
}
註:當用戶點擊input file彈出文件選擇器後,點擊取消或者返回按鈕沒有執行選擇時,必須在onActivityResult裡給valueCallback的onReceiveValue傳null,因為valueCallback持有的是WebView,在onReceiveValue沒有回傳值的情況下,WebView無法進行下一步操作,會導致取消選擇文件後,點擊input file不會再響應:
if (mFileUploadCallbackFirst != null) {
mFileUploadCallbackFirst.onReceiveValue(null);
mFileUploadCallbackFirst = null;
}
else if (mFileUploadCallbackSecond != null) {
mFileUploadCallbackSecond.onReceiveValue(null);
mFileUploadCallbackSecond = null;
}
示例demo地址:
https://github.com/cjpx00008/FileChooser4WebViewDemo
眾所周知,WebView基於webkit內核來渲染web頁面,因此使用起來相當於一個小型瀏覽器,即使頁面內容不複雜,只要使用WebView也會佔用大量的內存。
而Android的內存回收機制,在系統內存不足的情況下會優先釋放內存佔用較大的app從而回收內存資源,此時正在使用WebView的運行在後臺的app肯定是首當其衝被回收的。
因此,當WebView通過input file調用系統文件選擇器,或者通過文件選擇器調用了相機時,我們的app就進入了後臺,在部分低端Android設備(尤其紅米這類手機,默認的神隱模式會在app進入後臺的時候較大概率的釋放app)或者系統內存資源不足的情況下,我們的app就會優先被釋放掉,導致文件選擇完畢後,回到上一界面時,app的界面重新走了onCreate,web頁面也因此重建了。
對於部分需要填寫大量表單的web頁面來說,用戶填寫的數據會隨著界面的銷毀重建而丟失,而選擇的文件也因為頁面的重建而無法回傳給input file,這對於用戶的體驗來說肯定是不友好的。
也許你會說,重寫onSaveInstance保存數據就是啦。
這也是我一開始考慮的,
我們的WebView也提供了 saveState 以及 restoreState 來保存狀態。
然而悲催的是,這兩個方法並不會保存web頁面內的數據,它只保存了WebView加載的頁面,前進後退的歷史狀態等數據。
引用官方文檔的描述:
Saves the state of this WebView used in onSaveInstanceState(Bundle)
. Please note that this method no longer stores the display data for this WebView. The previous behavior could potentially leak files if restoreState(Bundle)
was never called.
Please note that this method no longer stores the display data for this WebView
WebView的saveState並不會保存界面的數據。
所以,對於表單數據的恢復,我們只能自己想辦法了,我們這裡採用了兩套方案:
WebSettings settings = mWebView.getSettings();
settings.setDomStorageEnabled(true);
另一種則提供JS接口將數據傳遞給原生,通過原生代碼將數據保存到本地,在頁面重建渲染完成時,web頁面通過JS接口調用原生方法拉取數據判斷是否有值,有則填充表單,無則不做操作,提交數據後調用JS接口調用原生方法清空本地數據。
以上是表單數據的恢復方案,
而對於從系統文件選擇器選擇的文件web頁面是無法直接接收並處理了,這裡我們提供了一個JS接口在web頁面加載完成時,進行觸發,並將數據傳遞給web頁面。
說到這裡,不得不提另外一個問題
WebView調用服務端頁面如何訪問本地文件上面我們提到了通過JS接口將選擇的文件數據傳遞給web頁面,
然而由於安全原因,WebView限制了遠程url頁面訪問本地文件,
如果我們加載的url是服務端的頁面,那我們沒有任何辦法直接通過文件地址來訪問客戶端本地的文件
我們知道,WebView用來加載網頁的方式主要有三種:
loadUrl(String url)
loadUrl(String url, Map<String, String> additionalHttpHeaders)
loadData(String data, String mimeType, String encoding)
loadDataWithBaseURL(String baseUrl, String data, String mimeType, String encoding, String historyUrl)
loadData()和loadDataWithBaseURL()都是直接將數據加載進WebView中,相當於顯示的一個本地Web
loadUrl也可以通過訪問本地的文件地址(例如本地asset目錄下的存放了index.html頁面,可以通過loadUrl("file:///android_asset/index.html")來顯示web頁面)
對於這樣的三種加載本地內容的方式,我們可以使用多種方式來傳遞路徑供web頁面傳遞,這裡以圖片為例(相冊目錄下test/IMG_20170105_093405.jpg):
<img src = 'file:///storage/emulated/0/dcim/test/IMG_20170105_093405.jpg' />
<img src = 'content://media/external/images/media/102610' />
<img src = 'content://com.test.myfileprovider/dcim/test/IMG_20170105_093405.jpg'/>
可當你使用loadUrl(String url)加載服務端的http地址時,以上三種方法將均無法使用,經過各種嘗試,目前找到兩種方案來提供給web端進行圖片顯示:
mWebView.setWebViewClient(new WebViewClient(){
@Override
public WebResourceResponse shouldInterceptRequest(WebView view, String url) {
if (url.startsWith("http://")&&url.endWith(".jpg") {
return getWebResourceResponse("/storage/emulated/0/dcim/trinaic/IMG_20170105_093405.jpg", "image/jpeg", ".jpg");
}
return super.shouldInterceptRequest(view, url);
}
}
private WebResourceResponse getWebResourceResponse(String url, String mime, String style) {
WebResourceResponse response = null;
try {
response = new WebResourceResponse(mime, "UTF-8", new FileInputStream(new File(url)));
} catch (FileNotFoundException e) {
e.printStackTrace();
}
return response;
}
要想讓原生跟JS進行交互,按照官方提供的方法就得使用addJavaScriptInterface
class JsObject {
@JavascriptInterface
public String toString() { return "injectedObject"; }
}
webView.addJavascriptInterface(new JsObject(), "injectedObject");
webView.loadData("", "text/html", null);
webView.loadUrl("javascript:alert(injectedObject.toString())");
Injects the supplied Java object into this WebView. The object is injected into the JavaScript context of the main frame, using the supplied name. This allows the Java object's methods to be accessed from JavaScript. For applications targeted to API level JELLY_BEAN_MR1
and above, only public methods that are annotated with JavascriptInterface
can be accessed from JavaScript. For applications targeted to API level JELLY_BEAN
or below, all public methods (including the inherited ones) can be accessed, see the important security note below for implications.
引用官方api的說明,在Android 4.2以下,會有被注入的風險,4.2以上版本可以通過@JavascriptInterface的註解來處理這個問題。
具體的注入方式,我找了篇博客,如果有不清楚的同學可以了解下:
Android WebView的Js對象注入漏洞解決方案
在之前烏雲平臺報出的漏洞中,
android/webkit/webview中默認內置的一個searchBoxJavaBridge_ 接口同時存在遠程代碼執行漏洞
在於android/webkit/AccessibilityInjector.java中,調用了此組件的應用在開啟輔助功能選項中第三方服務的安卓系統中會造成遠程代碼執行漏洞。這兩個接口分別是"accessibility" 和"accessibilityTraversal" ,此漏洞原理與searchBoxJavaBridge_接口遠程代碼執行相似,均為未移除不安全的默認接口,不過此漏洞需要用戶啟動系統設置中的第三方輔助服務,利用條件較複雜。
因此,一般情況下我們通過removeJavaScripteInterface來移除這幾個接口
if (Build.VERSION.SDK_INT < 17) {
mAdvanceWebView.removeJavascriptInterface("searchBoxJavaBridge_");
mAdvanceWebView.removeJavascriptInterface("accessibility");
mAdvanceWebView.removeJavascriptInterface("accessibilityTraversal");
}
除此之外也有通過onJsPrompt的方式來實現WebView原生跟JS交互功能的,github上的開源項目JSBridge就是採用這種方法:
https://github.com/lzyzsd/JsBridge
之前拜讀過大名鼎鼎的cordova的源碼,它內部的原生JS交互也是採用onJsPrompt的方式,不過在此基礎上做了更強大的封裝。
WebView後臺耗電問題當我們的WebView的web頁面在解析或者播放視頻再或者有js定時器在執行的時,
如果我們把應用退到後臺,不做任何處理的情況下,以上的操作還會在後臺繼續執行,導致WebView在後臺持續耗電,因此一般我們會做以下處理
@Override
protected void onPause() {
super.onPause();
mWebView.onPause();
mWebView.pauseTimers();
}
@Override
protected void onResume() {
super.onResume();
mWebView.onResume();
mWebView.resumeTimers();
}
對於WebView的使用,在處理問題的過程中發現一個不錯的開源庫:
https://github.com/delight-im/Android-AdvancedWebView
基本上上面我提到的或者沒提到的問題它都做了一定的封裝處理,並且考慮了一些版本適配的問題,可以直接拿來使用,也可以拿來參考學習。
如果你覺得問題還是太多的話也可以考慮使用騰訊瀏覽服務,基於QQ瀏覽器X5內核,適配了Android全部主流平臺,可以在所有Android手機上使用Blink的技術能力,具有更好的H5/CSS3支持和性能,目前微信、qq都在使用它。
唯一的缺陷就是它不提供打包內核版的SDK,第一次使用時,它會自動到騰訊服務端去下載內核,下載完畢後會彈窗提示用戶是否重啟app,重啟之後就能正常使用x5瀏覽服務了,如果你不介意這樣的用戶體驗,可以考慮直接使用騰訊瀏覽服務。
(補充)
WebView混淆問題如果app打包混淆之後發現提供給web頁面的JS接口失效了,記得檢查是否添加了JavaScriptInterface的混淆配置:
-keepclassmembers class * {
@android.webkit.JavascriptInterface <methods>;
}
紅米上WebView內部的Web頁面的div由於內容高度大於div,產生了基於div的滾動條(WebView滾動條已禁用的情況下),通過設置div的css樣式來禁用div滾動條
Html dom元素ID或class:: -webkit-scrollbar {display:none}
經測試發現,WebView內部web頁面的px值會在內部自動轉換為dp,且1px=1dp,跟ppi值無關,這點跟原生開發中的1dp = 設備ppi/160 * px換算關係nveou