网页开发常常会遇到选择本地文件的需求,在浏览器环境下,页面可以很轻松地拉起资源管理器,而在 Hybrid 应用开发中,往往还需要做一些适配。
你也许觉得诧异,之前的『Android WebView 和 JavaScript 交互』所介绍的方法难道不足以完成交互吗?
确实,之前介绍的文章已经足够我们完成上述需求,H5 页面响应点击事件后调用 Android 端的方法,Android 端完成文件选择操作,再调用 JavaScript 方法将数据返回。在选择文件这个需求上,似乎不够优雅。
我们看看浏览器上 H5 是如何选择文件的:
<html>
<input type="file" />
</html>
可以看到只需要 <input/>
标签指定文件属性即可实现。如果为了兼容移动平台而修改大量代码的话,大可不必。
那么应该如何做呢?
我们先简单写一个 HTML 文件用于测试:
<!DOCTYPE html>
<html>
<body>
<input id="select_file" type="file" accept="image/*" />
<img id="image" src="" style="width: 120px; height: auto; margin: 24px" />
<script type="text/javascript">
var input = document.getElementById("select_file");
var image = document.getElementById("image");
input.onchange = function () {
if (this.files.length > 0) {
var url = URL.createObjectURL(this.files[0]);
image.src = url;
}
}
</script>
</body>
</html>
这里只使用了 <input/>
标签和 <img/>
,交互就是选择图片并将其显示出来。我目前对于前端的技术并不太熟悉,做得有些丑陋请勿见怪。浏览器的效果如下:
其实 Android 端 WebView
也提供了对文件类型 <input/>
的处理方案:
class MainActivity : AppCompatActivity() {
private var fileUploadCallback: ValueCallback<Array<Uri>>? = null
private val launcher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
if (it.resultCode == RESULT_OK) {
it.data?.dataString?.apply {
fileUploadCallback?.onReceiveValue(arrayOf(Uri.parse(this)))
fileUploadCallback = null
}
}
}
override fun onCreate(savedInstanceState: Bundle?) {
...
webView.webChromeClient = object : WebChromeClient() {
override fun onShowFileChooser(webView: WebView?, filePathCallback: ValueCallback<Array<Uri>>?, fileChooserParams: FileChooserParams?): Boolean {
val intent = fileChooserParams?.createIntent() ?: return super.onShowFileChooser(webView, filePathCallback, fileChooserParams)
fileUploadCallback = filePathCallback
launcher.launch(intent)
return true
}
}
}
}
WebChromeClient
提供了一个 onShowFileChooser()
方法通知客户端显示文件选择器,我们可以在这里处理选择文件的操作,其中 ValueCallback<Uri[]>
类型的参数是一个回调,以提供要上传的文件的路径列表,我们在文件选择完成后使用它;FileChooserParams
用于描述要打开的文件选择器的模式以及与其一起使用的选项,这么听起来和 Intent
有点像,它也确实可以通过 createIntent()
来创建;返回值是布尔类型,如果我们需要执行回调,则返回 true
,默认处理则为 false
。
效果如下:
某些场景下,我们可能需要上传多张图片,我们修改 HTML 文件:
<!DOCTYPE html>
<html>
<body>
<input id="select_file" type="file" accept="image/*" multiple />
<img id="image_0" src="" style="width: 120px; height: auto; margin: 24px" />
<img id="image_1" src="" style="width: 120px; height: auto; margin: 24px" />
<script type="text/javascript">
var input = document.getElementById("select_file");
var image0 = document.getElementById("image_0");
var image1 = document.getElementById("image_1");
input.onchange = function () {
if (this.files.length > 0) {
var url0 = URL.createObjectURL(this.files[0]);
image0.src = url0;
}
if (this.files.length > 1) {
var url1 = URL.createObjectURL(this.files[1]);
image1.src = url1;
}
}
</script>
</body>
</html>
主要是 <input/>
标签增加了 multiple
属性,支持同时选择多个文件,然后 JavaScript 中增加了对第二张图片的处理。浏览器效果如下:
Android 端也需要做相应的处理:
class MainActivity : AppCompatActivity() {
private var fileUploadCallback: ValueCallback<Array<Uri>>? = null
private val launcher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
if (it.resultCode == RESULT_OK) {
var results = emptyArray<Uri>()
it.data?.clipData?.apply {
val list = mutableListOf<Uri>()
for (i in 0 until itemCount) {
list.add(getItemAt(i).uri)
}
results = list.toTypedArray()
}
it.data?.dataString?.apply {
results = arrayOf(Uri.parse(this))
}
fileUploadCallback?.onReceiveValue(results)
fileUploadCallback = null
}
}
override fun onCreate(savedInstanceState: Bundle?) {
...
webView.webChromeClient = object : WebChromeClient() {
override fun onShowFileChooser(webView: WebView?, filePathCallback: ValueCallback<Array<Uri>>?, fileChooserParams: FileChooserParams?): Boolean {
val intent = fileChooserParams?.createIntent() ?: return super.onShowFileChooser(webView, filePathCallback, fileChooserParams)
if (fileChooserParams.mode == FileChooserParams.MODE_OPEN_MULTIPLE) {
intent.putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true)
}
fileUploadCallback = filePathCallback
launcher.launch(intent)
return true
}
}
}
}
需要注意,尽管 HTML 已经配置了 multiple
属性,但是 createIntent()
似乎并不会给 Intent
添加 Intent.EXTRA_ALLOW_MULTIPLE
标记。因此我们需要手动从 FileChooserParams
中调用 getModel()
方法判断并添加此标记。它有 4 个值:
public class WebChromeClient {
...
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;
...
}
}
不过 MODE_OPEN_FOLDER
被标记为 @hide
,所以我们只能用其他三个。
文件选择完成后,我们的处理也与上文选择单个文件有所不同,当选择多个文件时,Uri
会通过 ClipData
返回,这里需要注意的是,当选择单个文件时,ClipData
会为空,此时的处理方案与上文选择单个文件一致。
效果如下:
这样我们就实现了Android 端 WebView
与 Web 端 <input/>
选择文件的优雅交互。
其实 Activity Result API 还给我们提供了选择文件的 ActivityResultContracts
,能够更加方便我们解析结果,不过就要写两个 ActivityResultLauncher
分别处理:
class MainActivity : AppCompatActivity() {
private var fileUploadCallback: ValueCallback<Array<Uri>>? = null
private val documentLauncher = registerForActivityResult(ActivityResultContracts.OpenDocument()) {
it?.run {
fileUploadCallback?.onReceiveValue(arrayOf(this))
fileUploadCallback = null
}
}
private val documentsLauncher = registerForActivityResult(ActivityResultContracts.OpenMultipleDocuments()) {
fileUploadCallback?.onReceiveValue(it.toTypedArray())
fileUploadCallback = null
}
override fun onCreate(savedInstanceState: Bundle?) {
...
webView.webChromeClient = object : WebChromeClient() {
override fun onShowFileChooser(webView: WebView?, filePathCallback: ValueCallback<Array<Uri>>?, fileChooserParams: FileChooserParams?): Boolean {
fileUploadCallback = filePathCallback
return when (fileChooserParams?.mode) {
FileChooserParams.MODE_OPEN -> {
documentLauncher.launch(fileChooserParams.acceptTypes)
true
}
FileChooserParams.MODE_OPEN_MULTIPLE -> {
documentsLauncher.launch(fileChooserParams.acceptTypes)
true
}
else -> super.onShowFileChooser(webView, filePathCallback, fileChooserParams)
}
}
}
}
}
这种方法不需要将 FileChooserParams
生成 Intent
(但是要记得通过 getAcceptTypes()
取出并设置 MIME 类型),ActivityResultContracts
已经替我们做了相关的处理,文件选择的结果也不需要手动解析,直接就可以拿到 Uri
进行下一步操作,我个人是认为比上面的统一处理是要清晰一些。
大多数使用场景其实都是上传图片,可以配合之前的文章『浅尝 Android 13 Photo Picker 无惧权限烦恼』一起食用。