Android WebView 實現檔案選擇、拍照、錄製視訊、錄音

one發表於2021-11-02

原文地址:Android WebView 實現檔案選擇、拍照、錄製視訊、錄音 | Stars-One的雜貨小窩

Android中的WebView如果不進行相應的設定,H5頁面的上傳按鈕是無法觸發Android彈出檔案選擇框的,所以,需要進行以下的設定

原理說明

Webview通過setWebChromeClient()方法來設定一個WebChromeClient物件,裡面有相關的方法處理,我們需要將其相關的方法處理即可實現對應的效果(如彈出對話方塊,許可權申請或彈出檔案選擇)

我們想要實現檔案選擇,只需要繼承WebChromeClient類,重寫其的onShowFileChooser()方法即可,方法如下:

boolean onShowFileChooser(WebView webView, ValueCallback<Uri[]> filePathCallback, FileChooserParams fileChooserParams)

可以看到,onShowFileChooser()方法中存在有3個引數,分別為webview,filePathCallbackfileChooserParams

  • fileChooserParams是檔案選擇的引數,我們可以利用此物件的方法fileChooserParams.getAcceptTypes()來知道H5中的上傳元件的accept屬性(即H5規定接收的檔案格式)

通常情況,我們通過拿到對應的檔案格式,從而彈出對應的檔案選擇,比如說接收的格式是圖片型別,可以給出拍照或者是從相簿中選擇照片的兩個選項

  • filePathCallback是檔案選擇後的回撥,呼叫filePathCallback.onReceiveValue()方法,把我們把檔案的Uri傳回給H5

PS:需要考慮到使用者沒有選擇檔案的情況.filePathCallback則需要傳空陣列回去(null也行)

程式碼如下:

//注意onReceiveValue方法接收的是個Uri陣列
filePathCallback.onReceiveValue(new Uri[]{});

filePathCallback.onReceiveValue(null);

之後的上傳操作由前端H5實現,這裡就不過多展開了,前端使用相應的上傳元件即可

步驟實現

1.前提許可權和配置

  • 選擇圖片需要儲存許可權
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
  • 拍照和錄製視訊需要相機許可權
<uses-permission android:name="android.permission.CAMERA" />

記得宣告和動態獲取許可權,這裡不再贅述

同時,還要設定webview,下面給出比較全面的設定,點選展開即可檢視

Webview相關的配置
WebSettings webSettings = webview.getSettings();
webSettings.setAllowFileAccess(true);
webSettings.setDomStorageEnabled(true);
webSettings.setDatabaseEnabled(true);
webSettings.setJavaScriptEnabled(true);  //支援js
webSettings.setUseWideViewPort(true);//設定此屬性,可任意比例縮放
webSettings.setLoadWithOverviewMode(true);
webSettings.setBuiltInZoomControls(false);
webSettings.setDisplayZoomControls(false);
webSettings.setAllowFileAccessFromFileURLs(true);
// 視訊播放需要使用
int SDK_INT = android.os.Build.VERSION.SDK_INT;
if (SDK_INT > 16) {
    webSettings.setMediaPlaybackRequiresUserGesture(false);
}
webSettings.setSupportZoom(false);//支援縮放
requestFocusFromTouch();

//跨域取消
try {
    Class<?> clazz = getSettings().getClass();
    Method method = clazz.getMethod(
            "setAllowUniversalAccessFromFileURLs", boolean.class);
    if (method != null) {
        method.invoke(getSettings(), true);
    }
} catch (IllegalArgumentException | NoSuchMethodException | IllegalAccessException | InvocationTargetException e) {
    e.printStackTrace();
}

2.重寫onShowFileChooser方法

建立一個類CustomWebViewChrome,繼承WebChromeClient,重寫其的onShowFileChooser()方法,為了方便說明,下面只給出部分程式碼:

@Override
public boolean onShowFileChooser(WebView webView, ValueCallback<Uri[]> filePathCallback, FileChooserParams fileChooserParams) {
    this.filePathCallback = filePathCallback;
    //姜前端H5接收的格式型別轉為字串,且後面不帶分號
    String[] acceptTypes = fileChooserParams.getAcceptTypes();
    String acceptType = "*/*";
    StringBuilder sb = new StringBuilder();
    if (acceptTypes.length > 0) {
        for (String type : acceptTypes) {
            sb.append(type).append(';');
        }
    }
    if (sb.length() > 0) {
        String typeStr = sb.toString();
        acceptType = typeStr.substring(0, typeStr.length() - 1);
    }

    //根據判斷,觸發相關的操作,如檔案選擇,拍照等...詳見3步講解
    //這裡,也可以實現彈出個對話方塊供使用者選擇,記得在彈出對話方塊之後呼叫下回撥onReceiveValue方法,否則會出現下次無法彈出對話方塊的Bug
    
    return true;
}

這裡,需要說明的是,這個回撥這是在點選前端H5的上傳元件(即input標籤設定type屬性為file)即可觸發,但是,我們需要呼叫filePathCallback.onReceiveValue()方法才能把檔案給回前端

但檔案的引數我們應該怎麼獲取,且呼叫上述所說方法?

目前的思路是:

CustomWebViewChrome類中建立個變數,存放filePathCallback這個引數

觸發檔案選擇等操作後面都會回撥對應Activity中的onActivityResult()方法,在onActivityResult()方法中處理檔案,得到檔案對應的Uri

之後就是可以利用我們CustomWebViewChrome物件中的filePathCallback,進行檔案的回撥操作,將檔案Uri傳給前端H5

3.Activity獲得Uri並回撥

上述程式碼中,我特地空了一段程式碼,這裡以圖片選擇為例,使用Intent跳轉到選擇圖片的頁面

Intent intent = new Intent(Intent.ACTION_PICK, null);
intent.setDataAndType(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, "image/*");
currentActivity.startActivityForResult(intent,15);

跳轉頁面都是需要Context引數,但CustomWebViewChrome裡面沒有,所以得加個變數,在建立物件的時候將當前的Activity傳進來(當然,我自己這邊是傳了個Webview物件,也可以獲得對應的activity物件)

跳轉頁面後傳有個15(即requestCode),之後得在onActivityResult判斷requestCode是否為15,從而對返回的資料進行處理,得到檔案的Uri,再回撥

public class WebViewActivity extends AppCompatActivity {

    CustomWebViewChrome customWebViewChrome;
    
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        //省略對應的webview設定
      
        WebView webview = findViewById(R.id.webview);
        CustomWebViewChrome customWebViewChrome = new CustomWebViewChrome(webview);
        webview.setWebChromeClient(customWebViewChrome);
        
        String url = "https://stars-one.site";
        customWebView.loadUrl(url);
    }

    @Override
    protected void onActivityResult(int requestCode, int resultCode, @Nullable Intent data) {
        if (requestCode == 15 ) {
            if(resultCode==Activity.RESULT_OK)[
                Uri imgUri = data.getData();
                filePathCallback.onReceiveValue(imgUri);
            ]else{
               filePathCallback.onReceiveValue(new Uri[]{});
            }
        }
    }
}

當然這裡你也可以選擇使用第三庫來實現檔案選擇,個人推薦的這個庫LuckSiege/PictureSelector: 圖片選擇器,可以拍照和錄製視訊,且可以多選圖片或視訊檔案,錄音檔案也支援選擇(但是無法錄音),而且也封裝有許可權的動態申請,比較方便,且程式碼也比較優雅

原理也是一樣的,只要按照開源庫的文件說明,先拿到檔案Uri,之後回撥filePathCallback.onReceiveValue()即可

如果是自己要實現,則是有些麻煩,不過下面我也是研究了下拍照和錄製視訊如何使用Intent方式跳轉,簡單的補充說明下,僅供參考

補充-Intent跳轉頁面

下面的例子中,省略了回撥filePathCallback.onReceiveValue()程式碼!!!和上面的保持一致即可!!

錄製視訊

Intent takeVideoIntent = new Intent(MediaStore.ACTION_VIDEO_CAPTURE);
//takeVideoIntent.putExtra(MediaStore.EXTRA_SIZE_LIMIT,10*1024*1024);//限制10M
takeVideoIntent.putExtra(MediaStore.EXTRA_DURATION_LIMIT, 10);//限制錄製時長
if (takeVideoIntent.resolveActivity(activity.getPackageManager()) != null) {
    activity.startActivityForResult(takeVideoIntent, 16);
}

onActivityResult回撥檔案處理

Uri videoUri = data.getData();
//省略回撥

拍照

拍照的話,得先定義好檔案的輸出路徑,但需要注意的是,之後在onActivityResult()方法回撥中的data不會攜帶任何資料

所以在跳轉頁面前得把檔案輸出路徑先儲存一份,之後再onActivityResult,再拿之前的儲存的資料回撥即可

String acceptType = fileChooserParams.getAcceptTypes()[0];
File file = new File(getExternalFilesDir(Environment.DIRECTORY_PICTURES), "imageCapture+" + System.currentTimeMillis() + ".jpg");
//這個變數是存放在當前的Activity中
captureUri = AppUtils.getPathUri(MainActivity.this, file.getPath());
Intent intent = new Intent(MediaStore.ACTION_IMAGE_CAPTURE);
intent.putExtra(MediaStore.EXTRA_OUTPUT, captureUri);
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
startActivityForResult(intent, CATURE_REQUEST);
public static Uri getPathUri(Context context, String filePath) {
    Uri uri;
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
        String packageName = context.getPackageName();
        uri = FileProvider.getUriForFile(context, packageName + ".fileprovider", new File(filePath));
    } else {
        uri = Uri.fromFile(new File(filePath));
    }
    return uri;
}

檔案選擇

Intent intent = new Intent(Intent.ACTION_GET_CONTENT);
intent.setType("file/*");
startActivityForResult(intent, requestCode);

onActivityResult()回撥中也是通過data.getData()來獲得選中檔案的Uri

錄音

本來錄音也可以通過intent跳轉的,但實際上手機提示打不開的問題,提示如下:

No Activity found to handle Intent { act=android.provider.MediaStore.RECORD_SOUND }

所以目前解決方案考慮的是直接使用H5進行錄音,Android這邊則是需要動態申請錄音許可權即可

使用的開源庫框架:xiangyuecn/Recorder

研究使用其的提供的apk安裝測試(也是webview套h5),可以正常申請許可權並錄音

但實際專案中,彈出的許可權申請後,允許許可權,但是h5還是拿不到許可權導致無法錄音,需要第二次重新進APP才可以正常錄音,原因不明...

研究無果,只好在進入APP前進行錄音許可權的申請,而不是每次點選開始錄音才申請許可權

錄音Vue程式碼

首先,安裝依賴 "recorder-core": "^1.1.21021500",寫在package.json檔案中

引入官方提供的MP3播放的js,JS下載地址

之後在Vue檔案中需要引入

import Recorder from 'recorder-core'

//需要使用到的音訊格式編碼引擎的js檔案統統載入進來
import 'recorder-core/src/engine/mp3'
import 'recorder-core/src/engine/mp3-engine'

頁面按順序點選按鈕即可測試錄音功能,可以根據情況改造邏輯,只要記住,每次錄音前必須要申請一次錄音許可權

下面的程式碼是Uni-App的實現方式,註釋上也補充有Vue原生的使用方法,兩者不同是,Vue原生得使用audio標籤來播放錄音,而Uni-App可以通過程式碼的方式進行建立

<template>
	<!--參考例子 https://github.com/xiangyuecn/Recorder/blob/master/assets/demo-vue/component/recorder.vue -->
	<view>
		<button type="default" @click="openRecord()">1.申請許可權</button>
		<button type="default" @click="startRecord()">2.開始錄音</button>
		<button type="default" @click="stopRecord()">3.停止錄音</button>
		<button type="default" @click="playRecord()">4.播放錄音</button>
		<text>{{tipText}}</text>


        <!-- 如果是Vue原生,需要使用audio標籤來播放聲音 -->
		<!-- <audio ref="LogAudioPlayer" :src="audioSrc" style="width:100%"></audio> -->
	</view>
</template>

<script>
	import Recorder from 'recorder-core'

	//需要使用到的音訊格式編碼引擎的js檔案統統載入進來
	import 'recorder-core/src/engine/mp3'
	import 'recorder-core/src/engine/mp3-engine'

	export default {
		data() {
			return {

				rec: null,
				tipText: "",
				audio: {
					blob: null,
					duration: null
				},
				audioBase64: ""
			}
		},
		created() {
			this.rec = Recorder();
		},
		methods: {
			openRecord() {

				this.rec.open(function() {
					//開啟麥克風授權獲得相關資源
					console.log("授權成功")
					// success && success();
				}, function(msg, isUserNotAllow) {
					//使用者拒絕未授權或不支援
					console.log((isUserNotAllow ? "UserNotAllow," : "") + "無法錄音:" + msg);
				});
			},
			startRecord() {
				this.rec.start();
				this.tipText = "錄製中"
				console.log("錄製中");
			},
			stopRecord() {
				let that = this
				this.rec.stop(function(blob, duration) {
					that.audio.blob = blob
					that.audio.duration = duration
					that.tipText = "停止錄音"

					//音訊檔案轉成base64編碼
					var reader = new FileReader();
					reader.onloadend = function() {
						that.audioBase64 = reader.result;
						console.log(that.audioBase64)
					};
					reader.readAsDataURL(blob)

				}, function(s) {
					console.log("結果出錯!")
				}, true); //自動close

			},
			playRecord() {
				this.tipText = "播放中"

				let innerAudioContext = uni.createInnerAudioContext();
				innerAudioContext.autoplay = true;
                
				//base64轉blob
				//base64資料是","後面的資料,看看是由後端處理還是前端處理
				// let blob = this.dataURLtoBlob(this.audioBase64)
				// innerAudioContext.src = (window.URL || webkitURL).createObjectURL(blob);
				innerAudioContext.src = (window.URL || webkitURL).createObjectURL(this.audio.blob);

				innerAudioContext.onPlay(() => {
					console.log('開始播放');
				});
				innerAudioContext.onError((res) => {
					console.log(res.errMsg);
					console.log(res.errCode);
				});
                
                <!-- Vue原生的播放-->
                // var audio=this.$refs.LogAudioPlayer;
                // audio.controls=true;
                // if(!(audio.ended || audio.paused)){
                //   this.tipText = "暫停"
                //   console.log("暫停")
                //   audio.pause();
                // };
                // audio.onerror=function(e){
                //   this.tipText = "播放失敗"
                //   console.log("播放失敗")
                // };
                // audio.src=(window.URL || webkitURL).createObjectURL(this.audio.blob)
                // audio.play()
			},
			//音訊的base64轉blob
			dataURLtoBlob(dataurl) {
				var arr = dataurl.split(',');
				//注意base64的最後面中括號和引號是不轉譯的   
				var _arr = arr[1].substr(0, arr[1].length - 2);
				var mime = arr[0].match(/:(.*?);/)[1],
					bstr = atob(_arr),
					n = bstr.length,
					u8arr = new Uint8Array(n);
				while (n--) {
					u8arr[n] = bstr.charCodeAt(n);
				}
				return new Blob([u8arr], {
					type: mime
				});
			},
		},
	}
</script>

相關文章