1. 程式人生 > >【相機】(2)——WebView中開啟相機、檔案選擇器的問題和解決方法

【相機】(2)——WebView中開啟相機、檔案選擇器的問題和解決方法

近幾年前端開發真是越來越火,H5 頁面開發的移動端頁面甚有奪我原生開發半壁江山的意思,憂傷憂傷。不過從實際情況考慮,H5 一套程式碼到處跑的特性,我們的 Android、IOS…也就只能呵呵了。然而我還是比較喜歡原生應用,對網路質量要求低,經常碰到 H5 頁面載入不出來一片空白就不由得抓狂!吐槽歸吐槽,正事不能落下。

上一篇Intent調相機的2種方式以及那些你知道的和不知道的坑中完成了對 Intent 調起系統相機、結果處理以及一些問題的應對。其實上篇文章還是因為今天的主題 WebView中呼叫系統相機 而起,因為涉及到呼叫相機本身的一些問題之前不是很明確,所以專門搞了一下,記錄下來,所以如果呼叫相機操作本身有什麼疑問或問題,請

點選跳轉到上一篇尋找答案,本篇不再重複。接下來們看看在 WebView 中呼叫相機的一些問題。

問題說明

最近有個需求是要上傳身份證正反照,說來簡單,可偏偏這部分業務是 H5 頁面處理的,所以只能通過 H5 頁面去拍照或選取本地圖片了,然而問題來了——這段H5程式碼在用瀏覽器開啟可以實現功能,但是放在 WebView 中卻沒有動作。

<!DOCTYPE html>
<html>
<head>
    <meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<title>相機呼叫</title> <script type="text/javascript"> function previewPhoto(sourceId, targetId) { var url; if (navigator.userAgent.indexOf("MSIE") >= 1) { // IE url = document.getElementById(sourceId).value; } else if
(navigator.userAgent.indexOf("Firefox") > 0) { // Firefox url = window.URL.createObjectURL(document.getElementById(sourceId).files.item(0)); } else if(navigator.userAgent.indexOf("Chrome") > 0) { // Chrome url = window.URL.createObjectURL(document.getElementById(sourceId).files.item(0)); } else if(navigator.userAgent.indexOf("Opera") > 0 || navigator.userAgent.indexOf("Oupeng") > 0) { // Oupeng url = window.URL.createObjectURL(document.getElementById(sourceId).files.item(0)); } else { url = "flower_err.jpg"; } <!--window.alert("address:" + url);--> window.alert("address:" + navigator.userAgent); var imgPre = document.getElementById(targetId); imgPre.src = url; }
</script> </head> <body> <a href="http://www.baidu.com">去百度</a> <br><br> <img id="img" width="200px" height="300px" alt="圖片預覽區"> <br> <input type="file" id="pic" name="camera" accept="image/*" onchange="previewPhoto(this.id, 'img');"/> <br><br> <input type="file" accept="image/*" multiple> </body> </html>

在瀏覽器中正常執行:

上述H5再瀏覽器中開啟

根據前人描述,是因為 Android 原始碼中將這部分遮蔽了,需要在 webView.setWebChromeClient(new WebChromeClient()) 中重寫 WebChromeClient 的 openFileChooser() 等方法,接下來我們就開啟原始碼看看。

原始碼分析

遇到問題看原始碼是最直接也是最有效的辦法,雖然通常情況下閱讀原始碼比看網上一些帖子難度要大點,但卻是問題的根本所在。可能有時候遇到很多問題不知道專門從原始碼下手,這時候就只能用問題去百度、去Google了,看看前輩們是怎麼解決這個問題的,遇到涉及原始碼時再回頭追本溯源,這樣便會對問題本身理解深刻;久而久之,可見成效。說到這裡,推薦一個線上檢視各版本原始碼的地址,畢竟你不會下載了所有版本的原始碼。閒話少敘,據說不同版本還不一樣,那就一個一個看(WebChromeClient.java在 \android\webkit包下):

(Android 2.2) 8 <= API <= 10 (Android 2.3)

以 Version 2.3.7_r1(API 10) 為例(API<8時就沒有這個方法):
Version 2.3.7_r1(API 10)部分原始碼

可以看到,openFileChooser() 方法用來告訴客戶端開啟一個檔案選擇器,只有一個入參 ValueCallback物件uploadMsg,uploadMsg 是一個回撥值,用來設定待上傳檔案的Uri,用 onReceiveValue() 方法來喚醒等待執行緒(英語不好,莫見怪);並且該方法被 Hide 了。

(Android 3.0) 11 <= API <= 15 (Android 4.0.3)

以 Version 2.3.7_r1(API 15) 為例:
Version 2.3.7_r1(API 15) 部分原始碼

可以看到,該方法也是被 Hide 了;不過 openFileChooser() 方法比上一版多了一個字串入參acceptType,H5頁面中input標籤宣告的檔案選擇器設定的 accept 屬性值,就是上邊H5程式碼中這一行:

<input type="file" id="pic" name="camera" accept="image/*" onchange="previewPhoto(this.id, 'img');"/>

(Android 4.1.2) 16 <= API <= 20 (Android 4.4W.2)

以 Version 4.4W(API 20) 為例:

/**
 * Tell the client to open a file chooser.
 * @param uploadFile A ValueCallback to set the URI of the file to upload.
 *      onReceiveValue must be called to wake up the thread.a
 * @param acceptType The value of the 'accept' attribute of the input tag
 *         associated with this file picker.
 * @param capture The value of the 'capture' attribute of the input tag
 *         associated with this file picker.
 * @hide
 */
public void openFileChooser(ValueCallback<Uri> uploadFile, String acceptType, String capture) {
    uploadFile.onReceiveValue(null);
}

同樣有 @hide 標籤;又比上一版多了一個 String 入參 capture,同樣是 input 標籤的同名屬性值(用來指定裝置比如capture=”camera”,不過好像用的很少了)。

API >= 21 (Android 5.0.1)

以 Version 5.0(API 21) 為例:

/**
 * Tell the client to open a file chooser.
 * @param uploadFile A ValueCallback to set the URI of the file to upload.
 *      onReceiveValue must be called to wake up the thread.a
 * @param acceptType The value of the 'accept' attribute of the input tag
 *         associated with this file picker.
 * @param capture The value of the 'capture' attribute of the input tag
 *         associated with this file picker.
 *
 * @deprecated Use {@link #showFileChooser} instead.
 * @hide This method was not published in any SDK version.
 */
@Deprecated
public void openFileChooser(ValueCallback<Uri> uploadFile, String acceptType, String capture) {
    uploadFile.onReceiveValue(null);
}

之前的 @hide 幹嘛用的,之前不知道,但是這裡就有說明了——This method was not published in any SDK version,也就是說這個方法沒有公開,所以不會像別的普通方法那樣 Override,那要怎麼搞?後邊說。
還有,這個方法被 @deprecated 標記了,用新方法 showFileChooser() 替換了,那我再找找showFileChooser:

/**
 * Tell the client to show a file chooser.
 *
 * This is called to handle HTML forms with 'file' input type, in response to the
 * user pressing the "Select File" button.
 * To cancel the request, call <code>filePathCallback.onReceiveValue(null)</code> and
 * return true.
 *
 * @param webView The WebView instance that is initiating the request.
 * @param filePathCallback Invoke this callback to supply the list of paths to files to upload,
 *                         or NULL to cancel. Must only be called if the
 *                         <code>showFileChooser</code> implementations returns true.
 * @param fileChooserParams Describes the mode of file chooser to be opened, and options to be
 *                          used with it.
 * @return true if filePathCallback will be invoked, false to use default handling.
 *
 * @see FileChooserParams
 */
public boolean onShowFileChooser(WebView webView, ValueCallback<Uri[]> filePathCallback,
        FileChooserParams fileChooserParams) {
    return false;
}

看,這個註釋就很用心了。onShowFileChooser() 方法和 openFileChooser() 同樣的作用,但是有更詳細的解釋——

  • 這個方法用來處理HTML表單中宣告 type=”file” 的 input 標籤,響應的時機時使用者按下“選擇檔案”按鈕
  • 如果要取消該操作(選擇檔案操作),需要呼叫 filePathCallback.onReceiveValue(null); return true;
  • 返回值的含義:返回true表示認可再該方法中重寫的對 filePathCallback 的操作,返回false表示使用預設處理(即空方法,不做任何處理)

引數 filePathCallback 泛型由原來的一個Uri變為 Uri[],說明可以支援一次選取多個檔案(當然,呼叫系統相機直接拍照的話還是隻能一張一張拍,此時Uri[]中之只有1個元素,若從相簿或檔案系統選,應該可以多選(本人沒有實現,不敢說肯定可以));

引數 FileChooserParams fileChooserParams應該和原來的是一個道理,就是input標籤的屬性集合,可以看一下原始碼:

/**
 * Parameters used in the {@link #onShowFileChooser} method.
 */
public static abstract class FileChooserParams {
    /** Open single file. Requires that the file exists before allowing the user to pick it. */
    public static final int MODE_OPEN = 0;
    /** Like Open but allows multiple files to be selected. */
    public static final int MODE_OPEN_MULTIPLE = 1;
    /** Like Open but allows a folder to be selected. The implementation should enumerate
        all files selected by this operation.
        This feature is not supported at the moment.
        @hide */
    public static final int MODE_OPEN_FOLDER = 2;
    /**  Allows picking a nonexistent file and saving it. */
    public static final int MODE_SAVE = 3;

    /**
     * Parse the result returned by the file picker activity. This method should be used with
     * {@link #createIntent}. Refer to {@link #createIntent} for how to use it.
     *
     * @param resultCode the integer result code returned by the file picker activity.
     * @param data the intent returned by the file picker activity.
     * @return the Uris of selected file(s) or null if the resultCode indicates
     *         activity canceled or any other error.
     */
    public static Uri[] parseResult(int resultCode, Intent data) {
        return WebViewFactory.getProvider().getStatics().parseFileChooserResult(resultCode, data);
    }

    /**
     * Returns file chooser mode.
     */
    public abstract int getMode();

    /**
     * Returns an array of acceptable MIME types. The returned MIME type
     * could be partial such as audio/*. The array will be empty if no
     * acceptable types are specified.
     */
    public abstract String[] getAcceptTypes();

    /**
     * Returns preference for a live media captured value (e.g. Camera, Microphone).
     * True indicates capture is enabled, false disabled.
     *
     * Use <code>getAcceptTypes</code> to determine suitable capture devices.
     */
    public abstract boolean isCaptureEnabled();

    /**
     * Returns the title to use for this file selector, or null. If null a default
     * title should be used.
     */
    public abstract CharSequence getTitle();

    /**
     * The file name of a default selection if specified, or null.
     */
    public abstract String getFilenameHint();

    /**
     * Creates an intent that would start a file picker for file selection.
     * The Intent supports choosing files from simple file sources available
     * on the device. Some advanced sources (for example, live media capture)
     * may not be supported and applications wishing to support these sources
     * or more advanced file operations should build their own Intent.
     *
     * <pre>
     * How to use:
     * 1. Build an intent using {@link #createIntent}
     * 2. Fire the intent using {@link android.app.Activity#startActivityForResult}.
     * 3. Check for ActivityNotFoundException and take a user friendly action if thrown.
     * 4. Listen the result using {@link android.app.Activity#onActivityResult}
     * 5. Parse the result using {@link #parseResult} only if media capture was not requested.
     * 6. Send the result using filePathCallback of {@link WebChromeClient#onShowFileChooser}
     * </pre>
     *
     * @return an Intent that supports basic file chooser sources.
     */
    public abstract Intent createIntent();
}

都有註釋,不解釋。

解決辦法

看完原始碼一切都明瞭了,怎麼做,重寫上邊這些方法就好。但是 @hide 方法不能 Override 怎麼辦——簡單粗暴,直接寫(沒有程式碼提示是不是有點心虛?等執行完了就不心虛了)。為了相容所有版本,最好把3個引數不同的 openFileChooser() 方法都寫上, onShowFileChooser()正常 Override 就好:

webView.setWebChromeClient(new WebChromeClient() {

    /**
     * 8(Android 2.2) <= API <= 10(Android 2.3)回撥此方法
     */
    public void openFileChooser(ValueCallback<Uri> uploadMsg) {
        Log.e("WangJ", "執行方法 openFileChooser-1");
        // (2)該方法回撥時說明版本API < 21,此時將結果賦值給 mUploadCallbackBelow,使之 != null
        mUploadCallbackBelow = uploadMsg;
        takePhoto();
    }

    /**
     * 11(Android 3.0) <= API <= 15(Android 4.0.3)回撥此方法
     */
    public void openFileChooser(ValueCallback<Uri> uploadMsg, String acceptType) {
        Log.e("WangJ", "執行方法 openFileChooser-2 (acceptType: " + acceptType + ")");
        openFileChooser(uploadMsg);
    }

    /**
     * 16(Android 4.1.2) <= API <= 20(Android 4.4W.2)回撥此方法
     */
    public void openFileChooser(ValueCallback<Uri> uploadMsg, String acceptType, String capture) {
        Log.e("WangJ", "執行方法 openFileChooser-3 (acceptType: " + acceptType + "; capture: " + capture + ")");
        openFileChooser(uploadMsg);
    }

    /**
     * API >= 21(Android 5.0.1)回撥此方法
     */
    @Override
    public boolean onShowFileChooser(WebView webView, ValueCallback<Uri[]> filePathCallback, FileChooserParams fileChooserParams) {
        Log.e("WangJ", "執行方法 onShowFileChooser");
        // (1)該方法回撥時說明版本API >= 21,此時將結果賦值給 mUploadCallbackAboveL,使之 != null
        mUploadCallbackAboveL = filePathCallback;
        takePhoto();
        return true;
    }
});

/* 省略其他內容 */
/**
 * 呼叫相機
 */
private void takePhoto() {
    // 指定拍照儲存位置的方式調起相機
    String filePath = Environment.getExternalStorageDirectory() + File.separator
            + Environment.DIRECTORY_PICTURES + File.separator;
    String fileName = "IMG_" + DateFormat.format("yyyyMMdd_hhmmss", Calendar.getInstance(Locale.CHINA)) + ".jpg";
    imageUri = Uri.fromFile(new File(filePath + fileName));

    Intent intent = new Intent(MediaStore.ACTION_IMAGE_CAPTURE);
    intent.putExtra(MediaStore.EXTRA_OUTPUT, imageUri);
    startActivityForResult(intent, REQUEST_CODE);


    // 選擇圖片(不包括相機拍照),則不用成功後發重新整理相簿的廣播
//        Intent i = new Intent(Intent.ACTION_GET_CONTENT);
//        i.addCategory(Intent.CATEGORY_OPENABLE);
//        i.setType("image/*");
//        startActivityForResult(Intent.createChooser(i, "Image Chooser"), REQUEST_CODE);
}

@Override
protected void onActivityResult(int requestCode, int resultCode, Intent data) {
    super.onActivityResult(requestCode, resultCode, data);
    if (requestCode == REQUEST_CODE) {
        // 經過上邊(1)、(2)兩個賦值操作,此處即可根據其值是否為空來決定採用哪種處理方法
        if (mUploadCallbackBelow != null) {
            chooseBelow(resultCode, data);
        } else if (mUploadCallbackAboveL != null) {
            chooseAbove(resultCode, data);
        } else {
            Toast.makeText(this, "發生錯誤", Toast.LENGTH_SHORT).show();
        }
    }
}

/**
 * Android API < 21(Android 5.0)版本的回撥處理
 * @param resultCode 選取檔案或拍照的返回碼
 * @param data 選取檔案或拍照的返回結果
 */
private void chooseBelow(int resultCode, Intent data) {
    Log.e("WangJ", "返回呼叫方法--chooseBelow");

    if (RESULT_OK == resultCode) {
        updatePhotos();

        if (data != null) {
            // 這裡是針對檔案路徑處理
            Uri uri = data.getData();
            if (uri != null) {
                Log.e("WangJ", "系統返回URI:" + uri.toString());
                mUploadCallbackBelow.onReceiveValue(uri);
            } else {
                mUploadCallbackBelow.onReceiveValue(null);
            }
        } else {
            // 以指定影象儲存路徑的方式調起相機,成功後返回data為空
            Log.e("WangJ", "自定義結果:" + imageUri.toString());
            mUploadCallbackBelow.onReceiveValue(imageUri);
        }
    } else {
        mUploadCallbackBelow.onReceiveValue(null);
    }
    mUploadCallbackBelow = null;
}

/**
 * Android API >= 21(Android 5.0) 版本的回撥處理
 * @param resultCode 選取檔案或拍照的返回碼
 * @param data 選取檔案或拍照的返回結果
 */
private void chooseAbove(int resultCode, Intent data) {
    Log.e("WangJ", "返回呼叫方法--chooseAbove");

    if (RESULT_OK == resultCode) {
        updatePhotos();

        if (data != null) {
            // 這裡是針對從檔案中選圖片的處理
            Uri[] results;
            Uri uriData = data.getData();
            if (uriData != null) {
                results = new Uri[]{uriData};
                for (Uri uri : results) {
                    Log.e("WangJ", "系統返回URI:" + uri.toString());
                }
                mUploadCallbackAboveL.onReceiveValue(results);
            } else {
                mUploadCallbackAboveL.onReceiveValue(null);
            }
        } else {
            Log.e("WangJ", "自定義結果:" + imageUri.toString());
            mUploadCallbackAboveL.onReceiveValue(new Uri[]{imageUri});
        }
    } else {
        mUploadCallbackAboveL.onReceiveValue(null);
    }
    mUploadCallbackAboveL = null;
}

private void updatePhotos() {
    // 該廣播即使多發(即選取照片成功時也傳送)也沒有關係,只是喚醒系統重新整理媒體檔案
    Intent intent = new Intent(Intent.ACTION_MEDIA_SCANNER_SCAN_FILE);
    intent.setData(imageUri);
    sendBroadcast(intent);
}

為什麼要分開chooseBelow()、chooseAbove()處理?
因為 openFileChooser()、onShowFileChooser()方法引數中那個回撥引數的泛型不同(一個Uri、一個Uri[]),分開處理明瞭一些。
看結果:
啟動拍照執行動畫 啟動選擇圖片執行結果

怎麼樣?看完這個結果,粗暴寫那幾個 @hide 的方法不心虛了吧?

為什麼同樣的HTML檔案在瀏覽器中開啟和我們做的不一樣,瀏覽器節能拍照又能選檔案呢?
那是因為我們寫死了要麼是使用拍照,要麼是用檔案選取,如果你願意,可以根據 openFileChooser()、onShowFileChooser()方法中的引數指定更個性化的響應,也可以做到像瀏覽器一樣。

可能的問題

許可權問題

再次提示,別忘了許可權問題,別再這裡被坑。

打包完成後不能工作

本來在demo中跑的好好的,但當我們打好release包測試的時候卻又發現沒法拍照、沒法選擇圖片了!!!真是坑了個爹啊!!!想想不奇怪,因為 openFileChooser() 方法被系統隱藏,又不能 Override,而我們的release包是開啟了混淆的,所以在打包的時候混淆了openFileChooser(),這就導致無法回撥openFileChooser()了。
-keepclassmembers class * extends android.webkit.WebChromeClient{
public void openFileChooser(…);
}

當然作為良好的面向物件開發者,你可以用一個藉口把這個過程寫的更優美一點,我只求能把問題說明白,這裡就不實現這一步了。