网页开发常常会遇到选择本地文件的需求,在浏览器环境下,页面可以很轻松地拉起资源管理器,而在 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

效果如下:

WebView 选择单个文件

某些场景下,我们可能需要上传多张图片,我们修改 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 会为空,此时的处理方案与上文选择单个文件一致。

效果如下:

WebView 选择多个文件

这样我们就实现了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 无惧权限烦恼』一起食用。