前言
图片上传操作基本上是每个应用都会有的功能,但是由于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,文件里添加如下代码:
第二步
在AndroidManifest.xml的application中添加provider标签,如下图所示:
其中${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? {
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