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