【Android】混合开发- H5 能直接调起原生的相册和相机吗?

混合开发中,由 H5 界面调用原生的相册和相机,也是一种常见的需求场景。

刚入坑混合开发的我,当在需求评审会上,被提到这样的功能时,我还并不知道如何去实现。不过 iOS 的同事告诉我:“H5 那边可以直接唤起的,小问题”。我很开心,因为这样才符合混合开发的意义嘛。但是万万万万万万没想到,Android,可以又不完全可以???让我们一起来看看这条探索之路吧。

01. 尝试

尝试1

抱着希望在 Google 上一顿搜索,有文章说要在 H5 标签中,添加capture属性。

<input type="file" accept="image/*" capture="camera"> 
<input type="file" accept="video/*" capture="camcorder"> 
<input type="file" accept="audio/*" capture="microphone">
复制代码

于是,让前端的同学加了相应的属性,结果发现并没有什么作用。

尝试2

通过研究(google)发现,当 <input> 标签修饰的控件被点击,我们这边是可以收到这个事件的。会通过 WebChromClient 中的 onShowFileChooser() 回调用来。

private val webViewChromeClient = object : WebChromeClient() {

    override fun onShowFileChooser(webView: WebView, 
                                   filePathCallback:ValueCallback<Array<Uri>>,
                                   fileChooserParams: FileChooserParams): Boolean {
        //这个拿到,将结果返回给 H5 的
        mFilePathCallback = filePathCallback
        val acceptTypes = fileChooserParams.acceptTypes
        if (acceptTypes.contains("image/*")) {
            //todo 调起选择框
        }
        return true
    }
}
复制代码

那??拿到照片后,怎么返回给 H5 呢?

看到上面的 ValueCallback 了吗?点击源码瞅一眼,哦?就一个方法,那就调它返回文件的 path 给到 H5。

public interface ValueCallback<T> {
    void onReceiveValue(T var1);
}
复制代码

整体思路如上。

03.整体流程

那具体上怎么操作呢?

 //1.调起选择框
 //2.权限申请和管理
 //3.操作完的回调
 //其实就是基本调起原生相册,相机的操作。不过,需要特别注意的就是,在回调返回值给H5的时候,
 //无论是否有值都要回调给 H5,否则下次就调不起来了。believe it or not, you can try it.
 //mFilePathCallback?.onReceiveValue(null)
复制代码

/**
 * 显示相册/拍照选择对话框
 */
private fun showSelectDialog() {
    if (mSelectPhotoDialog == null) {
        //简单写个Dialog
        mSelectPhotoDialog = SelectDialog(this, View.OnClickListener { view ->
            when (view.id) {
                //不同选择的,不同权限申请
                R.id.tv_camera -> requestPermissions(SELECT_CAMERA)
                R.id.tv_photo -> requestPermissions(SELECT_ALBUM)
                //♨♨♨不管选择还是不选择,必须有返回结果,否则就只会调用一次。(不理解的话,可以试试不写下面的代码)
                R.id.tv_cancel -> {
                    mFilePathCallback?.onReceiveValue(null)
                    mFilePathCallback = null
                }
            }
        })
    }
    mSelectPhotoDialog?.show()
}
复制代码

//权限申请和管理,这里用了 PermissionX 库,假设你知道(ResonDialog,是我自定义的,这里不贴代码了)

private fun requestPermissions(type: Int) {
    when (type) {
        SELECT_CAMERA -> {
            PermissionX.init(this)
                .permissions(
                    Manifest.permission.CAMERA,
                    Manifest.permission.WRITE_EXTERNAL_STORAGE
                )
                .onExplainRequestReason { scope, deniedList ->
                    scope.showRequestReasonDialog(
                        ReasonDialog(
                            this,
                            "该功能需要拍照和存储权限",
                            deniedList
                        )
                    )
                }
                .request { allGranted, _, deniedList ->
                    //若被拒绝的权限不为0,需要返回空数据给 H5
                    if (deniedList.size != 0) {
                        mFilePathCallback?.onReceiveValue(null)
                    }
                    //所有权限都被授权后的操作
                    if (allGranted) {
                        startCamera()
                    }
                }
        }
        SELECT_ALBUM -> {
            PermissionX.init(this)
                .permissions(
                    Manifest.permission.WRITE_EXTERNAL_STORAGE
                )
                .onExplainRequestReason { scope, deniedList ->
                    scope.showRequestReasonDialog(
                        ReasonDialog(
                            this,
                            "该功能需要访问您的相册,需要存储权限",
                            deniedList
                        )
                    )
                }
                .request { allGranted, _, deniedList ->
                    //若被拒绝的权限不为0,需要返回空数据给 H5
                    if (deniedList.size != 0) {
                        mFilePathCallback?.onReceiveValue(null)
                    }
                    //所有权限都被授权后的操作
                    if (allGranted) {
                        startAlbum()
                    }
                }
        }
    }

}
复制代码

最后,是操作后的回调。要将最后结果返回给 H5

override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
    super.onActivityResult(requestCode, resultCode, data)
    if (requestCode == PhotoUtils.RESULT_CODE_CAMERA && resultCode == Activity.RESULT_OK) {
        //拍照并确定
        //可以考虑--压缩图片(这里因为我司H5那边做了压缩,所以客户端就可以不做了)
        mFilePathCallback?.onReceiveValue(arrayOf(Uri.parse(PhotoUtils.PATH_PHOTO)))
    } else if (requestCode == PhotoUtils.RESULT_CODE_PHOTO && resultCode == Activity.RESULT_OK) {
        //相册选择并确定
        val result = data?.data
        val path = result?.let { PhotoUtils.getPath(this, it) }
        if (path == null) {
            mFilePathCallback?.onReceiveValue(null)
        } else {
            mFilePathCallback?.onReceiveValue(arrayOf(Uri.parse(path)))

        }
    } else {
        mFilePathCallback?.onReceiveValue(null)
    }
}
复制代码

最后贴出用到的 PhotoUtils,感谢郭富东大佬共享的工具类。 里面用了,如果要用到,可以加入依赖。否则,注释掉就好了。

//压缩算法
implementation 'top.zibin:Luban:1.1.8'
复制代码

/**
 * @Author :郭富东
 * @Date:2019/2/1:10:37
 * @descriptio:
 */
object PhotoUtils {

    const val RESULT_CODE_CAMERA = 0x02
    const val RESULT_CODE_PHOTO = 0x04
    const val RESULT_CODE_CROP = 0x05

    lateinit var PATH_PHOTO: String


    fun photoClip(context: Activity, uri: Uri) {
        // 调用系统中自带的图片剪裁
        val intent = Intent("com.android.camera.action.CROP")
        intent.setDataAndType(uri, "image/*")
        // 下面这个crop=true是设置在开启的Intent中设置显示的VIEW可裁剪
        intent.putExtra("crop", "true")
        // aspectX aspectY 是宽高的比例
        intent.putExtra("aspectX", 1)
        intent.putExtra("aspectY", 1)
        // outputX outputY 是裁剪图片宽高
        intent.putExtra("outputX", 150)
        intent.putExtra("outputY", 150)
        intent.putExtra("return-data", true)
        context.startActivityForResult(intent, RESULT_CODE_CROP)
    }

    /**
     * 拍照
     * @param context Activity
     */
    fun startCamera(context: Activity) {
        val intent = Intent(MediaStore.ACTION_IMAGE_CAPTURE)
        PATH_PHOTO = getSdCardDirectory(context) + "/temp.png"
        val temp = File(PATH_PHOTO)
        if (!temp.parentFile.exists()) {
            temp.parentFile.mkdirs()
        }
        if (temp.exists()) {
            temp.delete()
        }
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
            //添加这一句表示对目标应用临时授权该Uri所代表的文件
            intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
            intent.addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION)
            // 通过FileProvider创建一个content类型的Uri
            val uri: Uri =
                FileProvider.getUriForFile(context, context.packageName + ".fileprovider", temp)
            intent.putExtra(MediaStore.EXTRA_OUTPUT, uri)
        } else {
            intent.putExtra(MediaStore.EXTRA_OUTPUT, Uri.fromFile(temp))
        }
        context.startActivityForResult(intent, RESULT_CODE_CAMERA)
    }

    /**
     * 打开相册
     * @param context Activity
     */
    fun startAlbum(context: Activity) {
        val albumIntent = Intent(Intent.ACTION_PICK, MediaStore.Images.Media.EXTERNAL_CONTENT_URI)
        albumIntent.addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION);
        albumIntent.setDataAndType(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, "image/*")
        context.startActivityForResult(albumIntent, RESULT_CODE_PHOTO)
    }

    abstract class OnPictureCompressListener {
        fun onStart() {}
        abstract fun onSuccess(file: File)
        abstract fun onError(e: Throwable)
    }

    /**
     * 压缩图片
     * @param context Context
     * @param path String
     * @param listener OnPictureCompressListener?
     */
    fun compressPicture(context: Context, path: String, listener: OnPictureCompressListener?) {
        Luban.with(context)
                .load(path)
                .ignoreBy(1000)
                .setTargetDir(getSdCardDirectory(context))
                .filter { path -> !(TextUtils.isEmpty(path) || path.toLowerCase().endsWith(".gif")) }
                .setCompressListener(object : OnCompressListener {
                    override fun onStart() {
                        //压缩开始前调用,可以在方法内启动 loading UI
                        listener?.onStart()
                    }

                    override fun onSuccess(file: File) {
                        //压缩成功后调用,返回压缩后的图片文件
                        listener?.onSuccess(file)
                    }

                    override fun onError(e: Throwable) {
                        //当压缩过程出现问题时调用
                        listener?.onError(e)
                    }
                }).launch()
    }

    fun getSdCardDirectory(context: Context): String {
        var sdDir: File? = null
        if (Environment.getExternalStorageState() == Environment.MEDIA_MOUNTED) {
            sdDir = Environment.getExternalStorageDirectory()
//            sdDir = context.getExternalFilesDir(Environment.DIRECTORY_PICTURES)
        } else {
            sdDir = context.cacheDir
        }
        val cacheDir = File(sdDir, "h5pic")
        if (!cacheDir.exists()) {
            cacheDir.mkdirs()
        }
        return cacheDir.path
    }

    @RequiresApi(Build.VERSION_CODES.KITKAT)
    fun getPath(context: Context, uri: Uri): String? {
        val isKitKat = Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT
        // DocumentProvider
        if (isKitKat && DocumentsContract.isDocumentUri(context, uri)) {
            // ExternalStorageProvider
            if (isExternalStorageDocument(uri)) {
                val docId = DocumentsContract.getDocumentId(uri)
                val split = docId.split(":".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray()
                val type = split[0]
                if ("primary".equals(type, ignoreCase = true)) {
                    return Environment.getExternalStorageDirectory().path + "/" + split[1]
                }
            } else if (isDownloadsDocument(uri)) {
                val id = DocumentsContract.getDocumentId(uri)
                val contentUri = ContentUris.withAppendedId(
                    Uri.parse("content://downloads/public_downloads"),
                    java.lang.Long.valueOf(id)
                )
                return getDataColumn(context, contentUri, null, null)
            } else if (isMediaDocument(uri)) {
                val docId = DocumentsContract.getDocumentId(uri)
                val split = docId.split(":".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray()
                val contentUri = when (split[0]) {
                    "image" -> {
                        MediaStore.Images.Media.EXTERNAL_CONTENT_URI
                    }
                    "video" -> {
                        MediaStore.Video.Media.EXTERNAL_CONTENT_URI
                    }
                    "audio" -> {
                        MediaStore.Audio.Media.EXTERNAL_CONTENT_URI
                    }
                    else -> {
                        MediaStore.Images.Media.EXTERNAL_CONTENT_URI
                    }
                }

                val selection = "_id=?"
                val selectionArgs = arrayOf(split[1])

                return getDataColumn(context, contentUri, selection, selectionArgs)
            }
        } else if ("content".equals(uri.scheme, ignoreCase = true)) {
            return getDataColumn(context, uri, null, null)
        } else if ("file".equals(uri.scheme, ignoreCase = true)) {
            return uri.path
        }
        return null
    }

    private fun getDataColumn(
        context: Context,
        uri: Uri,
        selection: String?,
        selectionArgs: Array<String>?
    ): String? {
        var cursor: Cursor? = null
        val column = "_data"
        val projection = arrayOf(column)

        try {
            cursor = context.contentResolver.query(uri, projection, selection, selectionArgs, null)
            if (cursor != null && cursor.moveToFirst()) {
                val columnIndex = cursor.getColumnIndexOrThrow(column)
                return cursor?.getString(columnIndex)
            }
        } finally {
            if (cursor != null) cursor!!.close()
        }
        return null
    }


    /**
     * @param uri The Uri to check.
     * @return Whether the Uri authority is ExternalStorageProvider.
     */
    private fun isExternalStorageDocument(uri: Uri): Boolean {
        return "com.android.externalstorage.documents" == uri.authority
    }


    /**
     * @param uri The Uri to check.
     * @return Whether the Uri authority is DownloadsProvider.
     */
    private fun isDownloadsDocument(uri: Uri): Boolean {
        return "com.android.providers.downloads.documents" == uri.authority
    }


    /**
     * @param uri The Uri to check.
     * @return Whether the Uri authority is MediaProvider.
     */
    private fun isMediaDocument(uri: Uri): Boolean {
        return "com.android.providers.media.documents" == uri.authority
    }
}
复制代码

04. 结语

核心的内容都在上面了,如果还有一些细节上存在疑问。可以留言或者私信我,我将很乐意为您解答。如果出现图片上传失败,不妨看看我另一篇文章——【Android】混合开发 - 奇案 - 上传照片至 H5 失败,或许能为你解决相关问题。

作者:Quincy_Ye
链接:https://juejin.cn/post/7145290364969025549
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值