Jetpack Compose 实现一个图片选择框架功能

目录
  • 获取图片
  • 拍照策略
  • NothingCaptureStrategy
  • FileProviderCaptureStrategy
  • MediaStoreCaptureStrategy
  • 总结
  • 拍照权限
  • 取消拍照导致的脏数据
  • resolveActivity API 的兼容性
  • File API 的兼容性
  • Github

知乎的 Matisse应该蛮多 Android 开发者有了解过或者是曾经使用过,这是知乎在 2017 年开源的一个 Android 端图片选择框架,其颜值在现在看来也还是挺不错的

可惜近几年知乎官方已经不再对 Matisse 进行维护更新了,上一次提交记录还停留在 2019 年,累积了 400 个 issues 一直没人解答,很多高版本系统的兼容性问题和内部 bug 也一直得不到解决。我反编译了知乎的 App,发现其内部还保留着 Matisse 的相关代码,所以知乎应该不是完全废弃了 Matisse,而只是不再开源了

我公司的项目也使用到了 Matisse,随着 Android 系统的更新,时不时地就会有用户来反馈问题,无奈我也只能 fork 了源码自己来维护。一直这么小修小补终究不太合适,而且如果不进行完全重写的话,Matisse 的一些交互体验问题也没法得到彻底解决,而这些问题在知乎目前的官方 App 上也一样存在,以修改个人头像时打开的图片选择页面为例:

我发现的问题有三个:

  • 知乎的用户头像不支持 Gif 格式,当用户点击 Gif 图片时会提示 “不支持的文件类型”。按我的想法,既然不支持 Gif 格式,那么一开始展示的时候就应该过滤掉才对,而知乎目前的筛选逻辑应该就是来源自 Matisse ,因为 Matisse 也不支持 只展示静态图,但又可以 只展示 Gif,这筛选逻辑我觉得十分奇怪
  • 当取消勾选静态图时,可以看到 Gif 图片会很明显地闪烁了一下,此问题在 Matisse 中也存在。而如果从知乎的编辑器进入图片选择页面的话,就不单单是 Gif 图片会闪烁了,而是整个页面都会闪烁一下…
  • 当点击下拉菜单时,可以看到 Pictures 目录中有三张图片,但打开目录又发现是空的。这是由于知乎没有过滤掉一些脏数据导致的,后面会讲到具体原因

由于以上问题,也让我有了彻底放弃 Matisse,自己来实现一个新的图片选择框架的打算,也实现得差不多了,最终的效果如下所示

除了支持 Matisse 有的基本功能外,此框架的 特点 / 优势 还有:

  • 完全用 Kotlin 实现,拒绝 Java
  • UI 层完全用 Jetpack Compose 实现,拒绝原生 View 体系
  • 支持更加精细地自定义主题,默认提供了 日间 和 夜间 两种主题
  • 支持精准筛选图片类型,只会显示想要的图片类型
  • 同时支持 FileProvider 和 MediaStore 两种拍照策略
  • 获取到的图片信息更加丰富,一共包含 uri、displayName、mimeType、width、height、orientation、size、path、bucketId、bucketDisplayName 等十个属性值
  • 已适配到 Android 12 系统,解决了几个系统兼容性问题,下文会提到

此框架也有一些劣势:

  • 预览图片时不支持手势缩放。一开始我有尝试用 Jetpack Compose 来实现图片手势缩放,但效果不太理想,我又不想引入 View 体系中的三方库,所以此版本暂不支持图片手势缩放
  • 框架内部采用的图片加载库是 Coil,且不支持替换。由于目前支持 Jetpack Compose 的图片加载库基本只能选择 Coil 了,因此没有提供替换图片加载库的入口
  • 图片列表的滑动性能要低于原生的 RecyclerView,debug 版本尤为明显。此问题目前无解,只能等 Google 官方后续的优化了

代码我也开源到了 Github,懒得想名字,再加上一开始的设计思路也来自于 Matisse,因此就取了一样的名字,也叫 Matisse。下文如果没有特别说明,Matisse 指的就是此 Jetpack Compose 版本的图片选择框架了

用 Jetpack Compose 来实现 UI 相比原生的 View 体系实在要简单很多,在这一块除了滑动性能之外我也没遇到其它问题。因此,本文的内容和 Jetpack Compose 无关,主要是讲 Matisse 的一些实现细节和遇到的系统兼容性问题

获取图片

实现一个图片选择框架的第一步自然就是要获取到相册内的所有图片了,因此需要申请 READ_EXTERNAL_STORAGE 权限,此外还需要依赖系统的 MediaStore API 来读取所有图片

MediaStore 相当于一个文件系统数据库,记录了当前设备中所有文件的索引,我们可以通过它来快速查找设备中特定类型的文件。Matisse 使用的是 MediaStore.Image,在操作上就类似于查询数据库,通过声明需要的数据库字段 projection 和排序规则 sortOrder,得到相应的数据库游标 cursor,通过 cursor 遍历查询出每一个字段值

val projection = arrayOf(
    MediaStore.Images.Media._ID,
    MediaStore.Images.Media.DISPLAY_NAME,
    MediaStore.Images.Media.MIME_TYPE,
    MediaStore.Images.Media.WIDTH,
    MediaStore.Images.Media.HEIGHT,
    MediaStore.Images.Media.SIZE,
    MediaStore.Images.Media.ORIENTATION,
    MediaStore.Images.Media.DATA,
    MediaStore.Images.Media.BUCKET_ID,
    MediaStore.Images.Media.BUCKET_DISPLAY_NAME,
)
val sortOrder = "${MediaStore.Images.Media.DATE_MODIFIED} DESC"
val mediaResourcesList = mutableListOf<MediaResources>()
val mediaCursor = context.contentResolver.query(
    MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
    projection,
    selection,
    selectionArgs,
    sortOrder,
) ?: return@withContext null
mediaCursor.use { cursor ->
    while (cursor.moveToNext()) {
        val id = cursor.getLong(MediaStore.Images.Media._ID)
        val displayName =
            cursor.getString(MediaStore.Images.Media.DISPLAY_NAME)
        val mimeType = cursor.getString(MediaStore.Images.Media.MIME_TYPE)
        val width = cursor.getInt(MediaStore.Images.Media.WIDTH)
        val height = cursor.getInt(MediaStore.Images.Media.HEIGHT)
        val size = cursor.getLong(MediaStore.Images.Media.SIZE)
        val orientation = cursor.getInt(MediaStore.Images.Media.ORIENTATION)
        val data = cursor.getString(MediaStore.Images.Media.DATA)
        val bucketId = cursor.getString(MediaStore.Images.Media.BUCKET_ID)
        val bucketDisplayName =
            cursor.getString(MediaStore.Images.Media.BUCKET_DISPLAY_NAME)
        val contentUri =
            ContentUris.withAppendedId(
                MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
                id
            )
        val mediaResources = MediaResources(
            uri = contentUri,
            displayName = displayName,
            mimeType = mimeType,
            width = width,
            height = height,
            orientation = orientation,
            path = data,
            size = size,
            bucketId = bucketId,
            bucketDisplayName = bucketDisplayName,
        )
        mediaResourcesList.add(mediaResources)
    }
    return@withContext mediaResourcesList
}

每一张图片都存放于特定的相册文件夹内,因此可以通过 bucketId 来对每一张图片进行归类,从而得到 Matisse 中的下拉菜单

suspend fun groupByBucket(resources: List<MediaResources>): List<MediaBucket> {
    return withContext(context = Dispatchers.IO) {
        val resourcesMap = linkedMapOf<String, MutableList<MediaResources>>()
        resources.forEach { res ->
            val bucketId = res.bucketId
            val list = resourcesMap[bucketId]
            if (list == null) {
                resourcesMap[bucketId] = mutableListOf(res)
            } else {
                list.add(res)
            }
        }
        val allMediaBucketResource = mutableListOf<MediaBucket>()
        resourcesMap.forEach {
            val resourcesList = it.value
            if (resourcesList.isNotEmpty()) {
                val bucketId = it.key
                val bucketDisplayName = resourcesList[0].bucketDisplayName
                allMediaBucketResource.add(
                    MediaBucket(
                        bucketId = bucketId,
                        bucketDisplayName = bucketDisplayName,
                        bucketDisplayIcon = resourcesList[0].uri,
                        resources = resourcesList,
                        displayResources = resourcesList
                    )
                )
            }
        }
        return@withContext allMediaBucketResource
    }
}

拍照策略

一般的应用对于拍照功能不会有太多的自定义需求,因此大多是通过直接调起系统相机来实现拍照,优点是实现简单,且不用申请 CAMERA 权限

实现代码大致如下所示,最终图片就会保存在 imageUri 指向的文件中

class MatisseActivity : ComponentActivity() {
    private var tempImageUri: Uri? = null
    private fun takePicture(imageUri: Uri) {
        tempImageUri = imageUri
        val intent = Intent(MediaStore.ACTION_IMAGE_CAPTURE)
        intent.putExtra(MediaStore.EXTRA_OUTPUT, imageUri)
        startActivityForResult(intent, 1)
    }
    override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
        super.onActivityResult(requestCode, resultCode, data)
        if (requestCode == 1 && resultCode == Activity.RESULT_OK) {
            val mTempImageUri = tempImageUri
            if (mTempImageUri != null) {
                //TODO
            }
        }
    }
}

以上代码属于通用流程,当判断到完成拍照后,将以上的 imageUri 返回即可

但生成 imageUri 却有着很多学问:不同的生成规则对应着不同的权限,甚至同种方式在不同系统版本上对权限的要求也不一样,对用户的感知也不一样。此外,如果用户在相机页面取消拍照的话,此时 imageUri 指向的图片文件就没有用了,我们还需要主动删除该文件

Matisse 通过 CaptureStrategy 接口来抽象以上逻辑

/**
 * 拍照策略
 */
interface CaptureStrategy {
    /**
     * 是否启用拍照功能
     */
    fun isEnabled(): Boolean
    /**
     * 是否需要申请读取存储卡的权限
     */
    fun shouldRequestWriteExternalStoragePermission(context: Context): Boolean
    /**
     * 获取用于存储拍照结果的 Uri
     */
    suspend fun createImageUri(context: Context): Uri?
    /**
     * 获取拍照结果
     */
    suspend fun loadResources(context: Context, imageUri: Uri): MediaResources?
    /**
     * 当用户取消拍照时调用
     */
    suspend fun onTakePictureCanceled(context: Context, imageUri: Uri)
    /**
     * 生成图片文件名
     */
    fun createImageName(): String {
        return UUID.randomUUID().toString() + ".jpg"
    }
}

Matisse 实现了三种拍照策略供开发者选择:

  • NothingCaptureStrategy
  • FileProviderCaptureStrategy
  • MediaStoreCaptureStrategy

NothingCaptureStrategy

NothingCaptureStrategy 代表的是不开启拍照功能,也是 Matisse 默认的拍照策略

/**
 *  什么也不做,即不开启拍照功能
 */
object NothingCaptureStrategy : CaptureStrategy {
    override fun isEnabled(): Boolean {
        return false
    }
    override fun shouldRequestWriteExternalStoragePermission(context: Context): Boolean {
        return false
    }
    override suspend fun createImageUri(context: Context): Uri? {
        return null
    }
    override suspend fun loadResources(context: Context, imageUri: Uri): MediaResources? {
        return null
    }
    override suspend fun onTakePictureCanceled(context: Context, imageUri: Uri) {
    }
}

FileProviderCaptureStrategy

顾名思义,此策略通过 FileProvider 来生成所需要的 imageUri

从 Android 7.0 开始,系统禁止应用通过 file://URI 来访问其他应用的私有目录文件,要在应用间共享私有文件,必须通过 content://URI 并授予 URI 临时访问权限来实现,否则将直接抛出异常。而将 File 转换为 content://URI 的操作就需要依靠 FileProvider 来实现了。Matisse 传递给系统相机的 imageUri 也需要满足此规则

FileProviderCaptureStrategy 采用的策略就是:

  • 在 ExternalFilesDir 的 Pictures 目录中创建一个图片临时文件用于存储拍照结果,通过 FileProvider 得到该文件对应的 content://URI ,从而得到待写入的 imageUri
  • 假如用户最终取消拍照,则直接删除创建的临时文件
  • 假如用户最终完成拍照,则通过 BitmapFactory 获取图片的详细信息
  • 由于图片是保存在应用自身的私有目录中,因此不需要申请任何权限,也正因为是私有目录,所以图片不会出现在系统相册中
/**
 *  通过 FileProvider 来生成拍照所需要的 ImageUri
 *  无需申请权限
 *  所拍的照片不会保存在系统相册里
 *  外部必须配置 FileProvider,并在此处传入 authority
 */
class FileProviderCaptureStrategy(private val authority: String) : CaptureStrategy {
    private val uriFileMap = mutableMapOf<Uri, File>()
    override fun isEnabled(): Boolean {
        return true
    }
    override fun shouldRequestWriteExternalStoragePermission(context: Context): Boolean {
        return false
    }
    override suspend fun createImageUri(context: Context): Uri? {
        return withContext(context = Dispatchers.IO) {
            return@withContext try {
                val storageDir = context.getExternalFilesDir(Environment.DIRECTORY_PICTURES)
                val tempFile = File.createTempFile(
                    createImageName(),
                    "",
                    storageDir
                )
                val uri = FileProvider.getUriForFile(
                    context,
                    authority,
                    tempFile
                )
                uriFileMap[uri] = tempFile
                return@withContext uri
            } catch (e: Throwable) {
                e.printStackTrace()
                null
            }
        }
    }
    override suspend fun loadResources(context: Context, imageUri: Uri): MediaResources {
        return withContext(context = Dispatchers.IO) {
            val imageFile = uriFileMap[imageUri]!!
            uriFileMap.remove(imageUri)
            val imageFilePath = imageFile.absolutePath
            val option = BitmapFactory.Options()
            option.inJustDecodeBounds = true
            BitmapFactory.decodeFile(imageFilePath, option)
            return@withContext MediaResources(
                uri = imageUri,
                displayName = imageFile.name,
                mimeType = option.outMimeType ?: "",
                width = max(option.outWidth, 0),
                height = max(option.outHeight, 0),
                orientation = 0,
                size = imageFile.length(),
                path = imageFile.absolutePath,
                bucketId = "",
                bucketDisplayName = ""
            )
        }
    }
    override suspend fun onTakePictureCanceled(context: Context, imageUri: Uri) {
        withContext(context = Dispatchers.IO) {
            val imageFile = uriFileMap[imageUri]!!
            uriFileMap.remove(imageUri)
            if (imageFile.exists()) {
                imageFile.delete()
            }
        }
    }
}

外部需要在自身项目中声明 FileProvider,authorities 视自身情况而定,通过 authorities 来实例化 FileProviderCaptureStrategy

<provider
    android:name="androidx.core.content.FileProvider"
    android:authorities="github.leavesczy.matisse.samples.FileProvider"
    android:exported="false"
    android:grantUriPermissions="true">
    <meta-data
        android:name="android.support.FILE_PROVIDER_PATHS"
        android:resource="@xml/file_paths" />
</provider>

file_paths.xml 中需要配置 external-files-path 路径的 Pictures 文件夹,name 可以随意命名

<?xml version="1.0" encoding="utf-8"?>
<paths>
    <external-files-path
        name="Capture"
        path="Pictures" />
</paths>

MediaStoreCaptureStrategy

顾名思义,此策略通过 MediaStore 来生成所需要的 imageUri

在 Android 10 系统之前,应用需要获取到 WRITE_EXTERNAL_STORAGE 权限后才可以向共享存储空间中写入文件。从 Android 10 开始,应用通过 MediaStore 向共享存储空间中写入文件无需任何权限,且对于应用自身创建的文件,无需 READ_EXTERNAL_STORAGE 权限就可以直接访问和删除

MediaStoreCaptureStrategy 采用的策略就是:

  • 在大于等于 10 的系统版本中,不申请 WRITE_EXTERNAL_STORAGE 权限,其它系统版本则进行申请
  • 通过 MediaStore 向系统预创建一张图片,从而得到待写入的 imageUri
  • 假如用户最终取消拍照,则通过 MediaStore 删除 imageUri 指向的脏数据
  • 假如用户最终完成拍照,则通过 MediaStore 去查询 imageUri 对应图片的详细信息
  • 由于图片一开始就保存在 MediaStore 中,因此图片会显示在系统相册中
/**
 *  通过 MediaStore 来生成拍照所需要的 ImageUri
 *  根据系统版本决定是否需要申请 WRITE_EXTERNAL_STORAGE 权限
 *  所拍的照片会保存在系统相册里
 */
class MediaStoreCaptureStrategy : CaptureStrategy {
    override fun isEnabled(): Boolean {
        return true
    }
    override fun shouldRequestWriteExternalStoragePermission(context: Context): Boolean {
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
            return false
        }
        return ActivityCompat.checkSelfPermission(
            context,
            Manifest.permission.WRITE_EXTERNAL_STORAGE
        ) == PackageManager.PERMISSION_DENIED
    }
    override suspend fun createImageUri(context: Context): Uri? {
        return MediaProvider.createImage(context = context, fileName = createImageName())
    }
    override suspend fun loadResources(context: Context, imageUri: Uri): MediaResources? {
        return MediaProvider.loadResources(
            context = context,
            uri = imageUri
        )
    }
    override suspend fun onTakePictureCanceled(context: Context, imageUri: Uri) {
        MediaProvider.deleteImage(context = context, imageUri = imageUri)
    }
}

总结

所以说,除了 NothingCaptureStrategy 代表不开启拍照功能外,其他两种策略所需要的权限和图片存储的位置都不一样,对用户的感知也不一样

拍照策略 所需权限 配置项 对用户是否可见
NothingCaptureStrategy      
FileProviderCaptureStrategy 外部需要配置 FileProvider 否,图片存储在应用私有目录内,对用户不可见
MediaStoreCaptureStrategy Android 10 之前需要 WRITE_EXTERNAL_STORAGE 权限,Android 10 开始不需要权限 是,图片存储在系统相册内,对用户可见

开发者根据自己的实际情况来决定选择哪一种策略:

  • 如果应用本身就需要申请 WRITE_EXTERNAL_STORAGE 权限的话,选 MediaStoreCaptureStrategy,拍照后的图片保存在系统相册中也比较符合用户的认知
  • 如果应用本身就不需要申请 WRITE_EXTERNAL_STORAGE 权限的话,选 FileProviderCaptureStrategy,为了相册问题而多申请一个敏感权限得不偿失

拍照权限

Android 系统的 CAMERA 权限用于自定义实现相机功能的业务场景,也即如果使用到了 Camera API 的话,应用就必须声明和申请 CAMERA 权限

而调起系统相机进行拍照不属于自定义实现,因此该操作本身是不要求 CAMERA 权限的,但是否真的不需要申请权限要根据实际情况而定

Android 系统对于 CAMERA 权限有着比较奇怪的要求:

  • 应用如果没有声明 CAMERA 权限,此时调起系统相机不需要申请任何权限
  • 应用如果有声明 CAMERA 权限,就必须等到用户同意了 CAMERA 权限后才能调起系统相机,否则将直接抛出 SecurityException

因此,虽然 Matisse 本身是通过调起系统相机来实现拍照的,但如果引用方声明了 CAMERA 权限的话,将连锁导致 Matisse 也必须申请 CAMERA 权限

为了解决这个问题,Matisse 通过检查应用的 Manifest 文件中是否包含 CAMERA 权限来决定是否需要进行申请,避免由于意外而奔溃

private fun requestCameraPermissionIfNeed() {
    if (PermissionUtils.containsPermission(
            context = this,
            permission = Manifest.permission.CAMERA
        )
        &&
        !PermissionUtils.checkSelfPermission(
            context = this,
            permission = Manifest.permission.CAMERA
        )
    ) {
        requestCameraPermission.launch(Manifest.permission.CAMERA)
    } else {
        takePicture()
    }
}
internal object PermissionUtils {
    /**
     * 检查是否已授权指定权限
     */
    fun checkSelfPermission(context: Context, permission: String): Boolean {
        return ActivityCompat.checkSelfPermission(
            context,
            permission
        ) == PackageManager.PERMISSION_GRANTED
    }
    /**
     * 检查应用的 Manifest 文件是否声明了指定权限
     */
    fun containsPermission(context: Context, permission: String): Boolean {
        val packageManager: PackageManager = context.packageManager
        try {
            val packageInfo = packageManager.getPackageInfo(
                context.packageName,
                PackageManager.GET_PERMISSIONS
            )
            val permissions = packageInfo.requestedPermissions
            if (!permissions.isNullOrEmpty()) {
                return permissions.contains(permission)
            }
        } catch (e: Throwable) {
            e.printStackTrace()
        }
        return false
    }
}

取消拍照导致的脏数据

在文章开头给出来的知乎官方 App 示例中可以看到,Pictures 目录明明显示有三张图片,但点击进去又发现目录是空的。这是由于 MediaStore 中存在脏数据导致的

当应用通过 MediaStoreCaptureStrategy 来启动相机时,已经先向 MediaStore 插入一条图片数据了,但如果用户此时又取消了拍照,就会导致 MediaStore 中存在一条脏数据:该数据有 id、uri、path、displayName 等信息,但对应的图片文件实际上并不存在。知乎 App 应该是一开始在归类图片目录的时候没有检查图片是否真的存在,等到要加载图片的时候才发现图片不可用

虽然 MediaStoreCaptureStrategy 会主动删除自己生成的脏数据,但我们没法确保其它应用就不会向 MediaStore 插入脏数据。因此,Matisse 会在遍历查询所有图片的过程中,同时判断该图片指向的文件是否真的存在,有的话才进行展示

mediaCursor.use { cursor ->
    while (cursor.moveToNext()) {
        val data = cursor.getString(MediaStore.Images.Media.DATA)
        if (data.isBlank() || !File(data).exists()) {
            continue
        }
        //TODO
    }
}

resolveActivity API 的兼容性

当我们要隐式启动一个 Activity 的时候,为了避免由于目标 Activity 不存在而导致应用崩溃,我们就需要在 startActivity 前先判断该隐式启动是否有接收者,有的话才去调用 startActivity

Matisse 在启动系统相机的时候也是如此,会先通过 resolveActivity 方法查询系统中是否有应用可以处理拍照请求,有的话才去启动相机,避免由于设备没有摄像头而导致应用崩溃

private fun takePicture() {
    lifecycleScope.launch {
        val imageUri = captureStrategy.createImageUri(context = this@MatisseActivity)
        tempImageUri = imageUri
        if (imageUri != null) {
            val captureIntent = Intent(MediaStore.ACTION_IMAGE_CAPTURE)
            if (captureIntent.resolveActivity(packageManager) != null) {
                takePictureLauncher.launch(imageUri)
            }
        }
    }
}

resolveActivity 方法在 Android 11 和更高的系统上也有着一个兼容性问题:软件包可见性过滤

如果应用的目标平台是 Android 11 或更高版本,那么当应用通过 queryIntentActivities()、getPackageInfo()、getInstalledApplications() 等方法查询设备上已安装的其它应用相关信息时,系统会默认对返回结果进行过滤。也就是说,通过这些方法查询到的应用信息会少于设备上真实安装的应用数。resolveActivity 方法也受到此影响,经测试,在 Android 11 和 Android 12 的模拟器上,resolveActivity 方法均会返回 null,但在一台 Android 12 的真机上返回值则不为 null,因为不同设备会根据自己的实际情况来决定哪些实现 Android 核心功能的系统服务对所有应用均可见

Matisse 的解决方案是:在 Manifest 文件中通过 queries 主动声明 IMAGE_CAPTURE,从而提高对此 action 的可见性

<queries>
    <intent>
        <action android:name="android.media.action.IMAGE_CAPTURE" />
    </intent>
</queries>

File API 的兼容性

严格来说,File API 的兼容性并不属于 Matisse 遇到的问题,而是外部使用者会遇到的问题

从 Android 10 开始,系统推出了分区存储的特性,限制了应用读写共享文件的方式。当应用开启分区存储特性后,对共享文件的读写需要通过 MediaStore 来实现,而不能使用以前常用的 File API,否则将直接抛出异常:FileNotFoundException open failed: EACCES (Permission denied)

例如,像 Glide、Coil 等图片框架均支持通过 ByteArray 来加载图片,对于开启了分区存储特性的应用,在 Android 10 系统之前,以下方式是完全可用的,但在 Android 10 系统上就会直接崩溃

val filePath: String = xxx
imageView.load(File(filePath).readBytes())

而到了 Android 11 后,Google 可能觉得这种限制对于应用来说过于严格,因此又取消了限制,允许应用继续通过 File API 来读写共享文件,系统会自动将 File API 重定向为 MediaStore API =_=

因此,虽然 Matisse 的返回值中包含了图片的绝对路径 path,但如果外部开启了分区存储特性的话,在 Android 10 设备上是不能直接通过 File API 来读写共享文件的,在其它系统版本上则可以继续使用

Github

以上就是 Matisse 的一些实现细节和遇到的系统兼容性问题,更多实现细节请看 Github:Matisse

Matisse 同时也发布到了 Jitpack,方便开发者直接远程依赖使用:

allprojects {
    repositories {
        maven { url "https://jitpack.io" }
    }
}
dependencies {
    implementation 'com.github.leavesCZY:Matisse:0.0.1'
}

到此这篇关于Jetpack Compose 实现一个图片选择框架的文章就介绍到这了,更多相关Jetpack Compose图片选择框架内容请搜索我们以前的文章或继续浏览下面的相关文章希望大家以后多多支持我们!

(0)

相关推荐

  • Android Jetpack Compose实现列表吸顶效果

    目录 stickyHeader 实体类 加载假数据 吸顶标题 二级条目 完整代码 效果图 安卓传统的 Recyclerview 打造悬浮头部StickyHeader的吸顶效果,十分麻烦,而在Compose中就简单多了 stickyHeader Compose设计的时候考虑得很周到,他们提供了stickyHeader 作用就是添加一个粘性标题项,即使在它后面滚动时也会保持固定.标头将保持固定,直到下一个标头取而代之. 参数key - 表示唯一的密钥键. 它不允许对列表出现使用相同的键.密钥的类型应

  • Android Jetpack Compose无限加载列表

    目录 前言 方法一: paging-compose 方法二:自定义实现 添加 LoadingIndicator 总结 前言 Android 中使用 ListView 或者 RecycleView 经常有滚动到底部自动 LoadMore 的需求,那么在 Compose 中该如何实现呢? 两种方法可供选择: 基于 paging-compose 自定义实现 方法一: paging-compose Jetpack 的 Paging 组件提供了对 Compose 的支持 dependencies { ..

  • Jetpack Compose实现列表和动画效果详解

    目录 创建一个列表消息卡片 可交互的动画效果 创建一个列表消息卡片 到目前为止,我们只有一个消息的卡片,看上去有点单调,所以让我们来改善它,让它拥有多条信息.我们需要创建一个能够显示多条消息的函数.对于这种情况,我们可以使用 Compose 的 LazyColumn 和 LazyRow.这些 Composable 只渲染屏幕上可见的元素,所以它们的设计对于长列表来说很有效果.同时,它们避免了 RecyclerView 与 XML 布局的复杂性. import androidx.compose.f

  • 利用Jetpack Compose实现主题切换功能

    目录 前言 color.kt Theme.kt 关于compositionLocalOf 完整代码 前言 新建的Compose项目默认的 Material 主题为我们提供了一些颜色,但对我这种花里胡哨的人来说根本不够呀. 所以系统提供的主题不能满足需求时候可以自己配置主题 compose 实现换肤很简单 之前xml方法可复杂了 通过LayoutInflater调用inflate方法加载XML布局,在inflate方法中有一个createViewFromTag,再根据LayoutInflater当

  • 通过Jetpack Compose实现双击点赞动画效果

    目录 实现步骤 先红色画个爱心 点击事件加动画 完整代码 效果图 实现步骤 先红色画个爱心 Icon( Icons.Filled.Favorite, "爱心", Modifier .align(Alignment.Center) tint = Color.Red ) 点击事件加动画 双击监听 .pointerInput(Unit) { detectTapGestures( onDoubleTap = { ... } ) } #### **API 介绍** | API名称 | 作用 |

  • 利用Jetpack Compose实现绘制五角星效果

    目录 说明 自定义星行Modifier 原理 实现 代码 最终实现效果 说明 compose中我们的所有ui操作,包括一些行为,例如:点击.手势等都需要使用Modifier来进行操作.因此对Modifier的理解可以帮助我们解决很多问题的 自定义星行Modifier 本文我们打算自定义一个Modifier,通过这个modifier我们可以实现用一个操作符就画出五角星的效果 原理 我们实现绘制五角星的原理如下图,首先我们会虚构两个圆,将内圆和外圆角度平分五份,然后依次连接内圆和外圆的切点的坐标,然

  • Jetpack Compose 实现一个图片选择框架功能

    目录 获取图片 拍照策略 NothingCaptureStrategy FileProviderCaptureStrategy MediaStoreCaptureStrategy 总结 拍照权限 取消拍照导致的脏数据 resolveActivity API 的兼容性 File API 的兼容性 Github 知乎的 Matisse应该蛮多 Android 开发者有了解过或者是曾经使用过,这是知乎在 2017 年开源的一个 Android 端图片选择框架,其颜值在现在看来也还是挺不错的 可惜近几年

  • android实现一个图片验证码倒计时功能

    1.如图所示,要实现一个验证码的倒计时的效果          2.实现 图中获取验证码那块是一个button按钮 关键部分,声明一个TimeCount,继承自CountDownTimer /*验证码倒计时*/ private class TimeCount extends CountDownTimer{ /** * @param millisInFuture 总时间长度(毫秒) * @param countDownInterval 时间间隔(毫秒),每经过一次时间间隔都会调用onTick方法

  • flutter 动手撸一个城市选择citypicker功能

    城市选择器在项目开发中一般都会用到,基于flutter版本的也有一个city_pickers但是已经很久没有人维护了,项目中之前也用的是这个,最近升级到flutter1.17.x后,发现有一定的概率闪退,无奈之下,只能自动动手撸一个了 demo下载地址:https://github.com/qqcc1388/city_picker CityPickerView能够实现以下功能 显示省市区地址,市或者区可以为空白数据 省市区数据支持自定义,但是格式要按照city.json中个格式来,如果需要外部传

  • 利用Jetpack Compose实现经典俄罗斯方块游戏

    目录 可组合函数 游戏机身 - TetrisBody 游戏按钮 - TetrisButton 游戏屏幕 - TetrisScreen 调度器 - TetrisViewModel 项目地址 你的童年是否有俄罗斯方块呢,本文就来介绍如何通过 Jetpack Compose 实现一个俄罗斯方块 ~~ 先看下效果图,功能还是挺完善的 就我自己的体验来说,使用 Compose 开发的应用我感受不到和 Android 原生开发之间有什么性能差异,但 Compose 在开发难度上会低很多 Google 官网上

  • Jetpack Compose实现对角线滚动效果

    目录 缘起 初试 探索 学习 FreeScrollState freeScroll 总结 缘起 不久前刷到 newki 前辈的文章,用自定义 viewGroup的方式实现了如图效果: Android自定义ViewGroup嵌套与交互实战,幕布全屏滚动效果 我当时的反应: new bee ! new bee ! 这效果不错 初试 大佬用 Android View 出来了,那能否用 Google 新一代 UI Compose 来整一个呢? 正好手上有本 fun 神写得书 <Jetpack Compo

  • Java实现的可选择及拖拽图片的面板功能【基于swing组件】

    本文实例讲述了Java实现的可选择及拖拽图片的面板功能.分享给大家供大家参考,具体如下: 今天在论坛上看到帖子希望能在 Swing 中实现像拖地图一样拖拽图片.这里是一个最简单的实现,提供了一个基本思路. import javax.swing.*; import javax.swing.filechooser.FileNameExtensionFilter; import java.awt.*; import java.awt.event.MouseEvent; import java.awt.

  • 微信小程序实现图片选择并预览功能

    本文实例为大家分享了微信小程序实现图片选择并预览的具体代码,供大家参考,具体内容如下 (一).功能说明 做的是一个意见反馈,用户发表意见和上传图片,限制了最多只能上传三张图片. 其他要点:textarea使用,底部保存按钮固定 (二).小程序接口说明 wx.chooseLocation(Object object) 从本地相册选择图片或使用相机拍照. (三).效果图 效果如下: (四).代码展示 WXML页面: <view class="wrap"> <view cl

  • Android中调用另一个Activity并返回结果(选择头像功能为例)

    场景 Android中点击按钮启动另一个Activity以及Activity之间传值: https://www.jb51.net/article/178218.htm 在上面启动Activity和传值之后,怎样获取Acitvity的返回值.下面示例实现点击选择头像按钮,跳转到头像显示Activity,并将选择的图片的索引返回,在MainActivity中获取后设置头像. 效果 注: 实现 首先是主页面MainActivity的布局,添加一个选择头像按钮和一个ImageView用来显示头像. <?

  • Android实现拍照、选择相册图片并裁剪功能

    通过拍照或相册中获取图片,并进行裁剪操作,然后把图片显示到ImageView上.  当然也可以上传到服务器(项目中绝大部分情况是上传到服务器),参考网上资料及结合项目实际情况,  测试了多款手机暂时没有发现严重问题.代码有注释,直接贴代码: public class UploadPicActivity extends Activity implements View.OnClickListener { private Button take_photo_btn; private Button s

随机推荐