Android原生拍照及相册选取图片

前言

图片上传操作基本上是每个应用都会有的功能,但是由于Android碎片话严重,导致适配很繁琐,比如6.0以下版本、6.0的动态权限、7.0的FileProvider、8.0的特殊情况,再加上马上要出的Android Q权限变化。我个人项目中三方库能少用就少用,能自己解决,绝不用三方库,因为很多时候三方库不一定能满足自身要求,尤其是UI方面。下面记录下我项目中用到的图片操作,用Android原生方式实现,并且适配了Andrioid 7.0的FileProvider。

动态权限

6.0以上危险权限需要动态获取,那可能不知道哪些是危险权限,那么看下图:

危险权限列表

选取图片用的文件读写和相机操作都需要动态获取,我们这里依然用原生实现,代码如下:

    override fun onResume() {
        super.onResume()
        checkPermission()
    }

     /**
     * 获取权限
     * 因为是一次请求完几个权限,不是在用到的时候再请求,
     * 所以不需要重写onRequestPermissionsResult方法
     */
    private fun checkPermission() {
        val storage = arrayOf(
            Manifest.permission.WRITE_EXTERNAL_STORAGE,
            Manifest.permission.READ_EXTERNAL_STORAGE
        )
        val camera = arrayOf(Manifest.permission.CAMERA)
        //检查相机权限
        if (ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA) != PackageManager.PERMISSION_GRANTED) {
            // 之前拒绝了权限,但没有点击 不再询问 这个时候让它继续请求权限
            if (ActivityCompat.shouldShowRequestPermissionRationale(this, Manifest.permission.CAMERA)) {
                Log.d(TAG, "用户曾拒绝打开相机权限")
                ActivityCompat.requestPermissions(this, camera, 100)
            } else {
                //注册相机权限
                ActivityCompat.requestPermissions(this, camera, 100)
            }
        }
        //检查文件读写权限
        if (ContextCompat.checkSelfPermission(
                this,
                Manifest.permission.READ_EXTERNAL_STORAGE
            ) != PackageManager.PERMISSION_GRANTED
            || ContextCompat.checkSelfPermission(
                this,
                Manifest.permission.WRITE_EXTERNAL_STORAGE
            ) != PackageManager.PERMISSION_GRANTED
        ) {
            if (ActivityCompat.shouldShowRequestPermissionRationale(this, Manifest.permission.CAMERA)) {
                Log.d(TAG, "用户曾拒绝打开文件读写权限")
                ActivityCompat.requestPermissions(this, storage, 101)
            } else {
                //注册相机权限
                ActivityCompat.requestPermissions(this, storage, 101)
            }
        }
    }

7.0FileProvider适配

第一步

在res目录下创建xml文件夹,在文件夹下创建一个文件,比如:file_paths.xml,文件里添加如下代码:

<?xml version="1.0" encoding="utf-8"?>
<paths>
    <files-path
        name="files"
        path="/"/>
    <cache-path
        name="cache"
        path="/"/>
    <external-path
        name="external"
        path="/"/>
    <external-files-path
        name="external_file_path"
        path="/"/>
    <external-cache-path
        name="external_cache_path"
        path="/"/>
</paths>

第二步

在AndroidManifest.xml的application中添加provider标签,如下图所示:

provider.png

其中${applicationId}你可以直接这么写,也可以替换成你的包名。这两步配置好了后7.0适配算是完成了三分之二,后面如何在代码中读取文件,请耐心往后看。

目录创建

相机拍照后保存在手机DCIM/Image目录下,如下代码中getFileUri方法就是对7.0的兼容

/**
 * @author limh
 * @function
 * @date 2019/4/19 9:40
 */
object FileUtils {
    val BASE_PATH = "${Environment.getExternalStorageDirectory()}/DCIM/Image"
    /**
     * 根据文件名称 生成目录
     * @param fileName 文件名称
     */
    fun getFilePath(fileName: String): String {
        val dir = File(BASE_PATH)
        //如果目录不存在 先创建目录
        if (!dir.exists()) {
            dir.mkdir()
        }
        return "$BASE_PATH/$fileName"
    }

    /**
     * 获取文件Uri
     * @param fileName 文件名称
     */
    fun getFileUri(context: Context, fileName: String): Uri {
        val filePah = getFilePath(fileName)
        return if (Build.VERSION.SDK_INT >= 24) {
            FileProvider.getUriForFile(context, "${BuildConfig.APPLICATION_ID}.fileprovider", File(filePah))
        } else {
            Uri.fromFile(File(filePah))
        }
    }
}

打开相机

    /**
     * 打开相机
     */
    private fun openCamera() {
        Log.d(TAG, "打开相机")
        imgUri = FileUtils.getFileUri(context, "{${System.currentTimeMillis()}}.jpg")
        // 创建Intent,用于启动手机的照相机拍照
        val intent = Intent(MediaStore.ACTION_IMAGE_CAPTURE)
        // 指定输出到文件uri中
        intent.putExtra(MediaStore.EXTRA_OUTPUT, imgUri)
        // 启动intent开始拍照
        startActivityForResult(intent, REQUEST_CAMERA)
    }
  • 注意:上面打开相机方式拍照后文件会保存在imgUri中,onActivityResult回调不会返回数据,当回调成功后直接拿imgUri就是你拍的照片内容。拍照完成打开DCIM/Image/Album_xxxxxx.jpg,就是你刚拍摄的图片。

打开相册

    /**
     * 打开相册
     */
    private fun openAlbum() {
        Log.d(TAG, "打开相册")
        val albumIntent = Intent(Intent.ACTION_PICK)
        albumIntent.setDataAndType(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, "image/*")
        startActivityForResult(albumIntent, REQUEST_ALBUM)
    }
  • 注意:这种方式打开相册 打开相册后选择的图片uri会在intent的data中,需要我们从里面取.

原生裁剪

    /**
     * 裁剪图片
     * @param uri:要裁剪的文件
     */
    private fun corpPic(uri: Uri?) {
        //裁剪后的文件名称
        val fileName = "Crop_${System.currentTimeMillis()}.jpg"
        //裁剪后文件Uri
        imgUri = FileUtils.getFileUri(context, fileName)
        val intent = Intent("com.android.camera.action.CROP")
        intent.setDataAndType(uri, "image/*")
        //以下两行添加,解决无法加载此图片的提示
        intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
        intent.addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION)
        intent.putExtra(MediaStore.EXTRA_OUTPUT, Uri.fromFile(File(FileUtils.getFilePath(fileName))))
        intent.putExtra("crop", "true")
        intent.putExtra("aspectX", 1) // 裁剪框比例
        intent.putExtra("aspectY", 1)
        intent.putExtra("outputX", 100) // 输出图片大小
        intent.putExtra("outputY", 100)
        intent.putExtra("scale", false)
        intent.putExtra("outputFormat", Bitmap.CompressFormat.JPEG.toString())
        startActivityForResult(intent, REQUEST_CORP)
    }

-注意:裁剪输出目录一定要用Uri.fromFile生成,直接用imgUri的话会提示:无法保存经过裁剪的图片,图片也无法正常加载。

个人感觉原生裁剪很好用,可以设置裁剪比例和输出大小。设置完后图片很小,也不需要做压缩操作。

相机相册以及裁剪回调

    override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
        super.onActivityResult(requestCode, resultCode, data)
        Log.d(TAG, "返回结果:${data ?: data.toString()}")
        if (requestCode == REQUEST_CAMERA) {
            //相机结果返回
            if (resultCode == Activity.RESULT_OK) {
                imgUri?.let { corpPic(it) }
            }
        } else if (requestCode == REQUEST_ALBUM) {
            //相册返回结果
            if (resultCode == Activity.RESULT_OK) {
                //选择的图片转存 在DCIM/Image目录
                data?.let { corpPic(it.data) }
            }
        } else if (requestCode == REQUEST_CORP) {
            //裁剪回调
            Picasso.get().load(imgUri).memoryPolicy(MemoryPolicy.NO_CACHE).into(image)
        }
    }

至此,整个相机相册以及裁剪操作完成,最终的结果都保存到Uri中,但是有时候还需要解析文件路径,这个解析就涉及到版本兼容,又是另一个大坑,5.0上下获取方式都不同,好歹踩过坑的人,因此再上一个文件路径解析工具,直接调用ImagePath.getRealPathFromUri将Uri转换为String路径:

/**
 * @author limh
 * @function 解析图片路径
 * @date 2019/2/25 11:29
 */
object ImagePath {
    /**
     * 根据Uri获取图片的绝对路径
     *
     * @param context 上下文对象
     * @param uri     图片的Uri
     * @return 如果Uri对应的图片存在, 那么返回该图片的绝对路径, 否则返回null
     */
    fun getRealPathFromUri(context: Context, uri: Uri?): String? {
        if (null == uri) return ""
        val sdkVersion = Build.VERSION.SDK_INT
        return if (sdkVersion >= 19) {
            getRealPathFromUriAboveApi19(context, uri)
        } else { // api < 19
            getRealPathFromUriBelowAPI19(context, uri)
        }
    }

    /**
     * 适配api19以下(不包括api19),根据uri获取图片的绝对路径
     *
     * @param context 上下文对象
     * @param uri     图片的Uri
     * @return 如果Uri对应的图片存在, 那么返回该图片的绝对路径, 否则返回null
     */
    private fun getRealPathFromUriBelowAPI19(context: Context, uri: Uri): String? {
        return getDataColumn(context, uri, null, null)
    }

    /**
     * 适配api19及以上,根据uri获取图片的绝对路径
     *
     * @param context 上下文对象
     * @param uri     图片的Uri
     * @return 如果Uri对应的图片存在, 那么返回该图片的绝对路径, 否则返回null
     */
    @SuppressLint("NewApi")
    private fun getRealPathFromUriAboveApi19(context: Context, uri: Uri): String? {
        var filePath: String? = null
        if (DocumentsContract.isDocumentUri(context, uri)) {
            // 如果是document类型的 uri, 则通过document id来进行处理
            val documentId = DocumentsContract.getDocumentId(uri)
            if (isMediaDocument(uri)) { // MediaProvider
                // 使用':'分割
                val id = documentId.split(":".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray()[1]

                val selection = MediaStore.Images.Media._ID + "=?"
                val selectionArgs = arrayOf(id)
                filePath =
                    getDataColumn(context, MediaStore.Images.Media.EXTERNAL_CONTENT_URI, selection, selectionArgs)
            } else if (isDownloadsDocument(uri)) { // DownloadsProvider
                val contentUri = ContentUris.withAppendedId(
                    Uri.parse("content://downloads/public_downloads"),
                    java.lang.Long.valueOf(documentId)
                )
                filePath = getDataColumn(context, contentUri, null, null)
            }
        } else if ("content".equals(uri.scheme!!, ignoreCase = true)) {
            // 如果是 content 类型的 Uri
            filePath = getDataColumn(context, uri, null, null)
        } else if ("file" == uri.scheme) {
            // 如果是 file 类型的 Uri,直接获取图片对应的路径
            filePath = uri.path
        }
        return filePath
    }

    /**
     * 获取数据库表中的 _data 列,即返回Uri对应的文件路径
     *
     * @return
     */
    private fun getDataColumn(context: Context, uri: Uri, selection: String?, selectionArgs: Array<String>?): String? {
        var path: String? = null

        val projection = arrayOf(MediaStore.Images.Media.DATA)
        var cursor: Cursor? = null
        try {
            cursor = context.contentResolver.query(uri, projection, selection, selectionArgs, null)
            if (cursor != null && cursor.moveToFirst()) {
                val columnIndex = cursor.getColumnIndexOrThrow(projection[0])
                path = cursor.getString(columnIndex)
            }
        } catch (e: Exception) {
            cursor?.close()
        }

        return path
    }

    /**
     * @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
    }

    /**
     * @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
    }
}

总结

生命不息,踩坑不止,很多Android三方库追根揭底也是对原生SDK的封装,多了解下原生的写法,你就是下一个轮子大神。

最后附上github地址:
https://github.com/limhGeek/Album

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 156,265评论 4 359
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 66,274评论 1 288
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 106,087评论 0 237
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,479评论 0 203
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 51,782评论 3 285
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,218评论 1 207
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,594评论 2 309
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,316评论 0 194
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 33,955评论 1 237
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,274评论 2 240
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 31,803评论 1 255
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,177评论 2 250
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 32,732评论 3 229
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 25,953评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,687评论 0 192
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,263评论 2 267
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,189评论 2 258

推荐阅读更多精彩内容