不知不覺,Hybird App已經成了目前比較主流的一種開發方式。
對于用戶體驗要求較高或者與硬件交互較多的功能我們一般都會采用Native原生的方式來實現。
而用戶交互少,偏展示類,活動類的功能我們則通常采用H5的方式來實現,
例如新聞類的app,詳情展示頁一般就是H5的頁面
- 一方面圖文排版上web有著先天的優勢,同時純展示類的頁面在目前的移動設備上,性能體驗已經很難讓用戶分辨是網頁還是原生了;
- 另一方面,H5的頁面跨平臺,方便在原生客戶端上實現分享功能,擁有較強的傳播性,我們平時常見的活動頁面也擁有這樣的優勢,所以你看到的活動頁面也基本都是H5,只需輕輕一點就能分享到各個平臺;
- 同時,H5的頁面開發降低了開發成本,一套代碼,web,android,ios都能訪問。(然而實際開發過程中,H5的適配也都是各種淚)
既然Hybird 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界面的原生標題設置
如圖所示,
一般情況下,我們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](https://developer.android.google.cn/reference/android/webkit/WebViewClient.html#onPageFinished(android.webkit.WebView, java.lang.String)) 方法,在 [onPageFinished](https://developer.android.google.cn/reference/android/webkit/WebViewClient.html#onPageFinished(android.webkit.WebView, java.lang.String)) 中對界面標題進行設置。
因為不管是歷史記錄的返回還是點擊跳轉都會觸發頁面加載,
當頁面加載完成時(不包括js動態創建以及img圖片加載完畢)都會觸發 [onPageFinished](https://developer.android.google.cn/reference/android/webkit/WebViewClient.html#onPageFinished(android.webkit.WebView, java.lang.String)) 這個方法,
此時我們去獲取 <title> 的標題內容不會有任何問題,可以確保在頁面返回時能夠獲取到正確的標題。
mWebView.setWebViewClient(new WebViewClient(){
//Web頁面每次加載并完成時會觸發該方法
@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 {
/** Android 5.0以下版本的文件選擇回調 */
protected ValueCallback<Uri> mFileUploadCallbackFirst;
/** Android 5.0及以上版本的文件選擇回調 */
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 {
// Android 2.2 (API level 8)到Android 2.3 (API level 10)版本選擇文件時會觸發該隱藏方法
@SuppressWarnings("unused")
public void openFileChooser(ValueCallback<Uri> uploadMsg) {
openFileChooser(uploadMsg, null);
}
// Android 3.0 (API level 11)到 Android 4.0 (API level 15))版本選擇文件時會觸發,該方法為隱藏方法
public void openFileChooser(ValueCallback<Uri> uploadMsg, String acceptType) {
openFileChooser(uploadMsg, acceptType, null);
}
// Android 4.1 (API level 16) -- Android 4.3 (API level 18)版本選擇文件時會觸發,該方法為隱藏方法
@SuppressWarnings("unused")
public void openFileChooser(ValueCallback<Uri> uploadMsg, String acceptType, String capture) {
openFileInput(uploadMsg, null, false);
}
// Android 5.0 (API level 21)以上版本會觸發該方法,該方法為公開方法
@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) {
//Android 5.0以下版本
if (mFileUploadCallbackFirst != null) {
mFileUploadCallbackFirst.onReceiveValue(null);
}
mFileUploadCallbackFirst = fileUploadCallbackFirst;
//Android 5.0及以上版本
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) {
//Android 5.0以下版本
if (mFileUploadCallbackFirst != null) {
mFileUploadCallbackFirst.onReceiveValue(intent.getData());
mFileUploadCallbackFirst = null;
}
else if (mFileUploadCallbackSecond != null) {//Android 5.0及以上版本
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 {
//這里mFileUploadCallbackFirst跟mFileUploadCallbackSecond在不同系統版本下分別持有了
//WebView對象,在用戶取消文件選擇器的情況下,需給onReceiveValue傳null返回值
//否則WebView在未收到返回值的情況下,無法進行任何操作,文件選擇器會失效
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中的web頁面調用系統選擇器或者相機導致app進入后臺被系統釋放
眾所周知,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并不會保存界面的數據。
所以,對于表單數據的恢復,我們只能自己想辦法了,我們這里采用了兩套方案:
- 通過WebView與JS交互,在onSaveInstance的時候觸發界面保存數據,保存數據的方式也大體分為兩種,
一種使用H5自帶的localStorage來進行數據存儲,頁面銷毀重建的時候H5頁面判斷本地localStorage數據是否有值,有就將值重新填充到頁面表單,提交數據后清除本地localStorage的數據。
這種方式需要給WebView開啟對localStorage的支持。
WebSettings settings = mWebView.getSettings();
settings.setDomStorageEnabled(true);
另一種則提供JS接口將數據傳遞給原生,通過原生代碼將數據保存到本地,在頁面重建渲染完成時,web頁面通過JS接口調用原生方法拉取數據判斷是否有值,有則填充表單,無則不做操作,提交數據后調用JS接口調用原生方法清空本地數據。
- 由web端自己處理,在表單頁面文本輸入失去焦點時自動保存數據,頁面銷毀重建時,自己拉取數據進行判斷。
這種方式對原生的依賴較低,個人更傾向這種方式,當然最終由于項目的特殊情況,我們還是采用了第一種方式。
以上是表單數據的恢復方案,
而對于從系統文件選擇器選擇的文件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()](https://developer.android.google.cn/reference/android/webkit/WebView.html#loadData(java.lang.String, java.lang.String, java.lang.String)) 和 [loadDataWithBaseURL()](https://developer.android.google.cn/reference/android/webkit/WebView.html#loadDataWithBaseURL(java.lang.String, java.lang.String, java.lang.String, java.lang.String, java.lang.String)) 都是直接將數據加載進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' />
- 通過媒體庫查詢出來的content uri地址展示
<img src = 'content://media/external/images/media/102610' />
- 通過FileProvider轉換的content uri地址展示
<img src = 'content://com.test.myfileprovider/dcim/test/IMG_20170105_093405.jpg'/>
可當你使用loadUrl(String url)加載服務端的http地址時,以上三種方法將均無法使用,經過各種嘗試,目前找到兩種方案來提供給web端進行圖片顯示:
由原生代碼處理,將文件流轉換為Base64之后通過JS接口回傳給web;
重寫WebViewClient里的shouldInterceptRequest方法,每當頁面發生資源請求的時候就會觸發這個方法,我們可以過濾請求,判斷請求是否為本地文件,通過攔截請求轉換為二進制流回傳回去,
示例代碼如下:
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;
}
WebView JS注入漏洞
要想讓原生跟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();//暫停所有WebView的頁面布局、解析以及JavaScript的定時器操作
}
@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自身滾動條問題
紅米上WebView內部的Web頁面的div由于內容高度大于div,產生了基于div的滾動條(WebView滾動條已禁用的情況下),通過設置div的css樣式來禁用div滾動條
Html dom元素ID或class:: -webkit-scrollbar {display:none}
WebView內部web頁面px跟dp的關系
經測試發現,WebView內部web頁面的px值會在內部自動轉換為dp,且1px=1dp,跟ppi值無關,這點跟原生開發中的1dp = 設備ppi/160 * px換算關系nveou