Jetpack?Compose?實現(xiàn)一個圖片選擇框架功能
知乎的 Matisse 應(yīng)該蠻多 Android 開發(fā)者有了解過或者是曾經(jīng)使用過,這是知乎在 2017 年開源的一個 Android 端圖片選擇框架,其顏值在現(xiàn)在看來也還是挺不錯的

可惜近幾年知乎官方已經(jīng)不再對 Matisse 進行維護更新了,上一次提交記錄還停留在 2019 年,累積了 400 個 issues 一直沒人解答,很多高版本系統(tǒng)的兼容性問題和內(nèi)部 bug 也一直得不到解決。我反編譯了知乎的 App,發(fā)現(xiàn)其內(nèi)部還保留著 Matisse 的相關(guān)代碼,所以知乎應(yīng)該不是完全廢棄了 Matisse,而只是不再開源了
我公司的項目也使用到了 Matisse,隨著 Android 系統(tǒng)的更新,時不時地就會有用戶來反饋問題,無奈我也只能 fork 了源碼自己來維護。一直這么小修小補終究不太合適,而且如果不進行完全重寫的話,Matisse 的一些交互體驗問題也沒法得到徹底解決,而這些問題在知乎目前的官方 App 上也一樣存在,以修改個人頭像時打開的圖片選擇頁面為例:

我發(fā)現(xiàn)的問題有三個:
- 知乎的用戶頭像不支持 Gif 格式,當(dāng)用戶點擊 Gif 圖片時會提示 “不支持的文件類型”。按我的想法,既然不支持 Gif 格式,那么一開始展示的時候就應(yīng)該過濾掉才對,而知乎目前的篩選邏輯應(yīng)該就是來源自 Matisse ,因為 Matisse 也不支持 只展示靜態(tài)圖,但又可以 只展示 Gif,這篩選邏輯我覺得十分奇怪
- 當(dāng)取消勾選靜態(tài)圖時,可以看到 Gif 圖片會很明顯地閃爍了一下,此問題在 Matisse 中也存在。而如果從知乎的編輯器進入圖片選擇頁面的話,就不單單是 Gif 圖片會閃爍了,而是整個頁面都會閃爍一下…
- 當(dāng)點擊下拉菜單時,可以看到 Pictures 目錄中有三張圖片,但打開目錄又發(fā)現(xiàn)是空的。這是由于知乎沒有過濾掉一些臟數(shù)據(jù)導(dǎo)致的,后面會講到具體原因
由于以上問題,也讓我有了徹底放棄 Matisse,自己來實現(xiàn)一個新的圖片選擇框架的打算,也實現(xiàn)得差不多了,最終的效果如下所示

除了支持 Matisse 有的基本功能外,此框架的 特點 / 優(yōu)勢 還有:
- 完全用 Kotlin 實現(xiàn),拒絕 Java
- UI 層完全用 Jetpack Compose 實現(xiàn),拒絕原生 View 體系
- 支持更加精細地自定義主題,默認提供了 日間 和 夜間 兩種主題
- 支持精準篩選圖片類型,只會顯示想要的圖片類型
- 同時支持 FileProvider 和 MediaStore 兩種拍照策略
- 獲取到的圖片信息更加豐富,一共包含 uri、displayName、mimeType、width、height、orientation、size、path、bucketId、bucketDisplayName 等十個屬性值
- 已適配到 Android 12 系統(tǒng),解決了幾個系統(tǒng)兼容性問題,下文會提到
此框架也有一些劣勢:
- 預(yù)覽圖片時不支持手勢縮放。一開始我有嘗試用 Jetpack Compose 來實現(xiàn)圖片手勢縮放,但效果不太理想,我又不想引入 View 體系中的三方庫,所以此版本暫不支持圖片手勢縮放
- 框架內(nèi)部采用的圖片加載庫是 Coil,且不支持替換。由于目前支持 Jetpack Compose 的圖片加載庫基本只能選擇 Coil 了,因此沒有提供替換圖片加載庫的入口
- 圖片列表的滑動性能要低于原生的 RecyclerView,debug 版本尤為明顯。此問題目前無解,只能等 Google 官方后續(xù)的優(yōu)化了
代碼我也開源到了 Github,懶得想名字,再加上一開始的設(shè)計思路也來自于 Matisse,因此就取了一樣的名字,也叫 Matisse。下文如果沒有特別說明,Matisse 指的就是此 Jetpack Compose 版本的圖片選擇框架了
用 Jetpack Compose 來實現(xiàn) UI 相比原生的 View 體系實在要簡單很多,在這一塊除了滑動性能之外我也沒遇到其它問題。因此,本文的內(nèi)容和 Jetpack Compose 無關(guān),主要是講 Matisse 的一些實現(xiàn)細節(jié)和遇到的系統(tǒng)兼容性問題
獲取圖片
實現(xiàn)一個圖片選擇框架的第一步自然就是要獲取到相冊內(nèi)的所有圖片了,因此需要申請 READ_EXTERNAL_STORAGE 權(quán)限,此外還需要依賴系統(tǒng)的 MediaStore API 來讀取所有圖片
MediaStore 相當(dāng)于一個文件系統(tǒng)數(shù)據(jù)庫,記錄了當(dāng)前設(shè)備中所有文件的索引,我們可以通過它來快速查找設(shè)備中特定類型的文件。Matisse 使用的是 MediaStore.Image,在操作上就類似于查詢數(shù)據(jù)庫,通過聲明需要的數(shù)據(jù)庫字段 projection 和排序規(guī)則 sortOrder,得到相應(yīng)的數(shù)據(jù)庫游標 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
}每一張圖片都存放于特定的相冊文件夾內(nèi),因此可以通過 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
}
}拍照策略
一般的應(yīng)用對于拍照功能不會有太多的自定義需求,因此大多是通過直接調(diào)起系統(tǒng)相機來實現(xiàn)拍照,優(yōu)點是實現(xiàn)簡單,且不用申請 CAMERA 權(quán)限
實現(xiàn)代碼大致如下所示,最終圖片就會保存在 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
}
}
}
}以上代碼屬于通用流程,當(dāng)判斷到完成拍照后,將以上的 imageUri 返回即可
但生成 imageUri 卻有著很多學(xué)問:不同的生成規(guī)則對應(yīng)著不同的權(quán)限,甚至同種方式在不同系統(tǒng)版本上對權(quán)限的要求也不一樣,對用戶的感知也不一樣。此外,如果用戶在相機頁面取消拍照的話,此時 imageUri 指向的圖片文件就沒有用了,我們還需要主動刪除該文件
Matisse 通過 CaptureStrategy 接口來抽象以上邏輯
/**
* 拍照策略
*/
interface CaptureStrategy {
/**
* 是否啟用拍照功能
*/
fun isEnabled(): Boolean
/**
* 是否需要申請讀取存儲卡的權(quán)限
*/
fun shouldRequestWriteExternalStoragePermission(context: Context): Boolean
/**
* 獲取用于存儲拍照結(jié)果的 Uri
*/
suspend fun createImageUri(context: Context): Uri?
/**
* 獲取拍照結(jié)果
*/
suspend fun loadResources(context: Context, imageUri: Uri): MediaResources?
/**
* 當(dāng)用戶取消拍照時調(diào)用
*/
suspend fun onTakePictureCanceled(context: Context, imageUri: Uri)
/**
* 生成圖片文件名
*/
fun createImageName(): String {
return UUID.randomUUID().toString() + ".jpg"
}
}Matisse 實現(xiàn)了三種拍照策略供開發(fā)者選擇:
- 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 開始,系統(tǒng)禁止應(yīng)用通過 file://URI 來訪問其他應(yīng)用的私有目錄文件,要在應(yīng)用間共享私有文件,必須通過 content://URI 并授予 URI 臨時訪問權(quán)限來實現(xiàn),否則將直接拋出異常。而將 File 轉(zhuǎn)換為 content://URI 的操作就需要依靠 FileProvider 來實現(xiàn)了。Matisse 傳遞給系統(tǒng)相機的 imageUri 也需要滿足此規(guī)則
FileProviderCaptureStrategy 采用的策略就是:
- 在 ExternalFilesDir 的 Pictures 目錄中創(chuàng)建一個圖片臨時文件用于存儲拍照結(jié)果,通過 FileProvider 得到該文件對應(yīng)的
content://URI,從而得到待寫入的 imageUri - 假如用戶最終取消拍照,則直接刪除創(chuàng)建的臨時文件
- 假如用戶最終完成拍照,則通過 BitmapFactory 獲取圖片的詳細信息
- 由于圖片是保存在應(yīng)用自身的私有目錄中,因此不需要申請任何權(quán)限,也正因為是私有目錄,所以圖片不會出現(xiàn)在系統(tǒng)相冊中
/**
* 通過 FileProvider 來生成拍照所需要的 ImageUri
* 無需申請權(quán)限
* 所拍的照片不會保存在系統(tǒng)相冊里
* 外部必須配置 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 系統(tǒng)之前,應(yīng)用需要獲取到 WRITE_EXTERNAL_STORAGE 權(quán)限后才可以向共享存儲空間中寫入文件。從 Android 10 開始,應(yīng)用通過 MediaStore 向共享存儲空間中寫入文件無需任何權(quán)限,且對于應(yīng)用自身創(chuàng)建的文件,無需 READ_EXTERNAL_STORAGE 權(quán)限就可以直接訪問和刪除
MediaStoreCaptureStrategy 采用的策略就是:
- 在大于等于 10 的系統(tǒng)版本中,不申請 WRITE_EXTERNAL_STORAGE 權(quán)限,其它系統(tǒng)版本則進行申請
- 通過 MediaStore 向系統(tǒng)預(yù)創(chuàng)建一張圖片,從而得到待寫入的 imageUri
- 假如用戶最終取消拍照,則通過 MediaStore 刪除 imageUri 指向的臟數(shù)據(jù)
- 假如用戶最終完成拍照,則通過 MediaStore 去查詢 imageUri 對應(yīng)圖片的詳細信息
- 由于圖片一開始就保存在 MediaStore 中,因此圖片會顯示在系統(tǒng)相冊中
/**
* 通過 MediaStore 來生成拍照所需要的 ImageUri
* 根據(jù)系統(tǒng)版本決定是否需要申請 WRITE_EXTERNAL_STORAGE 權(quán)限
* 所拍的照片會保存在系統(tǒng)相冊里
*/
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)
}
}總結(jié)
所以說,除了 NothingCaptureStrategy 代表不開啟拍照功能外,其他兩種策略所需要的權(quán)限和圖片存儲的位置都不一樣,對用戶的感知也不一樣
| 拍照策略 | 所需權(quán)限 | 配置項 | 對用戶是否可見 |
|---|---|---|---|
| NothingCaptureStrategy | |||
| FileProviderCaptureStrategy | 無 | 外部需要配置 FileProvider | 否,圖片存儲在應(yīng)用私有目錄內(nèi),對用戶不可見 |
| MediaStoreCaptureStrategy | Android 10 之前需要 WRITE_EXTERNAL_STORAGE 權(quán)限,Android 10 開始不需要權(quán)限 | 無 | 是,圖片存儲在系統(tǒng)相冊內(nèi),對用戶可見 |
開發(fā)者根據(jù)自己的實際情況來決定選擇哪一種策略:
- 如果應(yīng)用本身就需要申請 WRITE_EXTERNAL_STORAGE 權(quán)限的話,選 MediaStoreCaptureStrategy,拍照后的圖片保存在系統(tǒng)相冊中也比較符合用戶的認知
- 如果應(yīng)用本身就不需要申請 WRITE_EXTERNAL_STORAGE 權(quán)限的話,選 FileProviderCaptureStrategy,為了相冊問題而多申請一個敏感權(quán)限得不償失
拍照權(quán)限
Android 系統(tǒng)的 CAMERA 權(quán)限用于自定義實現(xiàn)相機功能的業(yè)務(wù)場景,也即如果使用到了 Camera API 的話,應(yīng)用就必須聲明和申請 CAMERA 權(quán)限
而調(diào)起系統(tǒng)相機進行拍照不屬于自定義實現(xiàn),因此該操作本身是不要求 CAMERA 權(quán)限的,但是否真的不需要申請權(quán)限要根據(jù)實際情況而定
Android 系統(tǒng)對于 CAMERA 權(quán)限有著比較奇怪的要求:
- 應(yīng)用如果沒有聲明 CAMERA 權(quán)限,此時調(diào)起系統(tǒng)相機不需要申請任何權(quán)限
- 應(yīng)用如果有聲明 CAMERA 權(quán)限,就必須等到用戶同意了 CAMERA 權(quán)限后才能調(diào)起系統(tǒng)相機,否則將直接拋出 SecurityException
因此,雖然 Matisse 本身是通過調(diào)起系統(tǒng)相機來實現(xiàn)拍照的,但如果引用方聲明了 CAMERA 權(quán)限的話,將連鎖導(dǎo)致 Matisse 也必須申請 CAMERA 權(quán)限
為了解決這個問題,Matisse 通過檢查應(yīng)用的 Manifest 文件中是否包含 CAMERA 權(quán)限來決定是否需要進行申請,避免由于意外而奔潰
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 {
/**
* 檢查是否已授權(quán)指定權(quán)限
*/
fun checkSelfPermission(context: Context, permission: String): Boolean {
return ActivityCompat.checkSelfPermission(
context,
permission
) == PackageManager.PERMISSION_GRANTED
}
/**
* 檢查應(yīng)用的 Manifest 文件是否聲明了指定權(quán)限
*/
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
}
}取消拍照導(dǎo)致的臟數(shù)據(jù)
在文章開頭給出來的知乎官方 App 示例中可以看到,Pictures 目錄明明顯示有三張圖片,但點擊進去又發(fā)現(xiàn)目錄是空的。這是由于 MediaStore 中存在臟數(shù)據(jù)導(dǎo)致的
當(dāng)應(yīng)用通過 MediaStoreCaptureStrategy 來啟動相機時,已經(jīng)先向 MediaStore 插入一條圖片數(shù)據(jù)了,但如果用戶此時又取消了拍照,就會導(dǎo)致 MediaStore 中存在一條臟數(shù)據(jù):該數(shù)據(jù)有 id、uri、path、displayName 等信息,但對應(yīng)的圖片文件實際上并不存在。知乎 App 應(yīng)該是一開始在歸類圖片目錄的時候沒有檢查圖片是否真的存在,等到要加載圖片的時候才發(fā)現(xiàn)圖片不可用
雖然 MediaStoreCaptureStrategy 會主動刪除自己生成的臟數(shù)據(jù),但我們沒法確保其它應(yīng)用就不會向 MediaStore 插入臟數(shù)據(jù)。因此,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 的兼容性
當(dāng)我們要隱式啟動一個 Activity 的時候,為了避免由于目標 Activity 不存在而導(dǎo)致應(yīng)用崩潰,我們就需要在 startActivity 前先判斷該隱式啟動是否有接收者,有的話才去調(diào)用 startActivity
Matisse 在啟動系統(tǒng)相機的時候也是如此,會先通過 resolveActivity 方法查詢系統(tǒng)中是否有應(yīng)用可以處理拍照請求,有的話才去啟動相機,避免由于設(shè)備沒有攝像頭而導(dǎo)致應(yīng)用崩潰
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 和更高的系統(tǒng)上也有著一個兼容性問題:軟件包可見性過濾
如果應(yīng)用的目標平臺是 Android 11 或更高版本,那么當(dāng)應(yīng)用通過 queryIntentActivities()、getPackageInfo()、getInstalledApplications() 等方法查詢設(shè)備上已安裝的其它應(yīng)用相關(guān)信息時,系統(tǒng)會默認對返回結(jié)果進行過濾。也就是說,通過這些方法查詢到的應(yīng)用信息會少于設(shè)備上真實安裝的應(yīng)用數(shù)。resolveActivity 方法也受到此影響,經(jīng)測試,在 Android 11 和 Android 12 的模擬器上,resolveActivity 方法均會返回 null,但在一臺 Android 12 的真機上返回值則不為 null,因為不同設(shè)備會根據(jù)自己的實際情況來決定哪些實現(xiàn) Android 核心功能的系統(tǒng)服務(wù)對所有應(yīng)用均可見
Matisse 的解決方案是:在 Manifest 文件中通過 queries 主動聲明 IMAGE_CAPTURE,從而提高對此 action 的可見性
<queries>
<intent>
<action android:name="android.media.action.IMAGE_CAPTURE" />
</intent>
</queries>File API 的兼容性
嚴格來說,F(xiàn)ile API 的兼容性并不屬于 Matisse 遇到的問題,而是外部使用者會遇到的問題
從 Android 10 開始,系統(tǒng)推出了分區(qū)存儲的特性,限制了應(yīng)用讀寫共享文件的方式。當(dāng)應(yīng)用開啟分區(qū)存儲特性后,對共享文件的讀寫需要通過 MediaStore 來實現(xiàn),而不能使用以前常用的 File API,否則將直接拋出異常:FileNotFoundException open failed: EACCES (Permission denied)
例如,像 Glide、Coil 等圖片框架均支持通過 ByteArray 來加載圖片,對于開啟了分區(qū)存儲特性的應(yīng)用,在 Android 10 系統(tǒng)之前,以下方式是完全可用的,但在 Android 10 系統(tǒng)上就會直接崩潰
val filePath: String = xxx imageView.load(File(filePath).readBytes())
而到了 Android 11 后,Google 可能覺得這種限制對于應(yīng)用來說過于嚴格,因此又取消了限制,允許應(yīng)用繼續(xù)通過 File API 來讀寫共享文件,系統(tǒng)會自動將 File API 重定向為 MediaStore API =_=
因此,雖然 Matisse 的返回值中包含了圖片的絕對路徑 path,但如果外部開啟了分區(qū)存儲特性的話,在 Android 10 設(shè)備上是不能直接通過 File API 來讀寫共享文件的,在其它系統(tǒng)版本上則可以繼續(xù)使用
Github
以上就是 Matisse 的一些實現(xiàn)細節(jié)和遇到的系統(tǒng)兼容性問題,更多實現(xiàn)細節(jié)請看 Github:Matisse
Matisse 同時也發(fā)布到了 Jitpack,方便開發(fā)者直接遠程依賴使用:
allprojects {
repositories {
maven { url "https://jitpack.io" }
}
}
dependencies {
implementation 'com.github.leavesCZY:Matisse:0.0.1'
}到此這篇關(guān)于Jetpack Compose 實現(xiàn)一個圖片選擇框架的文章就介紹到這了,更多相關(guān)Jetpack Compose圖片選擇框架內(nèi)容請搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
相關(guān)文章
Android listview定位到上次顯示的位置的實現(xiàn)方法
這篇文章主要介紹了Android listview定位到上次顯示的位置的實現(xiàn)方法的相關(guān)資料,希望通過本文能幫助到大家,需要的朋友可以參考下2017-08-08
Android中findViewById獲取控件返回為空問題怎么解決
這篇文章主要介紹了Android中findViewById獲取控件返回為空問題怎么解決的相關(guān)資料,非常不錯,具有參考借鑒價值,需要的朋友可以參考下2016-06-06
Android自定義ViewGroup實現(xiàn)絢麗的仿支付寶咻一咻雷達脈沖效果
這篇文章主要介紹了Android自定義ViewGroup實現(xiàn)絢麗的仿支付寶咻一咻雷達脈沖效果的相關(guān)資料,需要的朋友可以參考下2016-10-10
Android中URLEncoder空格被轉(zhuǎn)碼為"+"號的處理辦法
當(dāng)上傳文件的文件名中間有空格,用URLEncoder.encode方法會把空格變成加號(+)在前臺頁面顯示的時候會多出加號,下面這篇文章主要給大家介紹了關(guān)于Android中URLEncoder空格被轉(zhuǎn)碼為"+"號的處理辦法,需要的朋友可以參考下2023-01-01
詳解Android ContentProvider的基本原理和使用
ContentProvider(內(nèi)容提供者)是 Android 的四大組件之一,管理 Android 以結(jié)構(gòu)化方式存放的數(shù)據(jù),以相對安全的方式封裝數(shù)據(jù)(表)并且提供簡易的處理機制和統(tǒng)一的訪問接口供其他程序調(diào)用2021-06-06
Android實現(xiàn)系統(tǒng)重新啟動的功能
有些Android版本沒有系統(tǒng)重啟的功能,非常不方便。需要我們自己開發(fā)一個能夠重新啟動的應(yīng)用2013-11-11
Android亮度調(diào)節(jié)的幾種實現(xiàn)方法
本篇文章詳細介紹了Android亮度調(diào)節(jié)的幾種實現(xiàn)方法,具有一定的參考價值,感興趣的小伙伴們可以參考一下。2016-11-11

