欧美bbbwbbbw肥妇,免费乱码人妻系列日韩,一级黄片

Jetpack?Compose?實(shí)現(xiàn)一個(gè)圖片選擇框架功能

 更新時(shí)間:2022年06月13日 09:18:00   作者:業(yè)志陳  
這篇文章主要介紹了Jetpack?Compose?實(shí)現(xiàn)一個(gè)圖片選擇框架,本文通過實(shí)例代碼圖文相結(jié)合給大家介紹的非常詳細(xì),對(duì)大家的學(xué)習(xí)或工作具有一定的參考借鑒價(jià)值,需要的朋友可以參考下

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

可惜近幾年知乎官方已經(jīng)不再對(duì) Matisse 進(jìn)行維護(hù)更新了,上一次提交記錄還停留在 2019 年,累積了 400 個(gè) issues 一直沒人解答,很多高版本系統(tǒng)的兼容性問題和內(nèi)部 bug 也一直得不到解決。我反編譯了知乎的 App,發(fā)現(xiàn)其內(nèi)部還保留著 Matisse 的相關(guān)代碼,所以知乎應(yīng)該不是完全廢棄了 Matisse,而只是不再開源了

我公司的項(xiàng)目也使用到了 Matisse,隨著 Android 系統(tǒng)的更新,時(shí)不時(shí)地就會(huì)有用戶來反饋問題,無奈我也只能 fork 了源碼自己來維護(hù)。一直這么小修小補(bǔ)終究不太合適,而且如果不進(jìn)行完全重寫的話,Matisse 的一些交互體驗(yàn)問題也沒法得到徹底解決,而這些問題在知乎目前的官方 App 上也一樣存在,以修改個(gè)人頭像時(shí)打開的圖片選擇頁面為例:

我發(fā)現(xiàn)的問題有三個(gè):

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

由于以上問題,也讓我有了徹底放棄 Matisse,自己來實(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)兼容性問題,下文會(huì)提到

此框架也有一些劣勢(shì):

  • 預(yù)覽圖片時(shí)不支持手勢(shì)縮放。一開始我有嘗試用 Jetpack Compose 來實(shí)現(xiàn)圖片手勢(shì)縮放,但效果不太理想,我又不想引入 View 體系中的三方庫,所以此版本暫不支持圖片手勢(shì)縮放
  • 框架內(nèi)部采用的圖片加載庫是 Coil,且不支持替換。由于目前支持 Jetpack Compose 的圖片加載庫基本只能選擇 Coil 了,因此沒有提供替換圖片加載庫的入口
  • 圖片列表的滑動(dòng)性能要低于原生的 RecyclerView,debug 版本尤為明顯。此問題目前無解,只能等 Google 官方后續(xù)的優(yōu)化了

代碼我也開源到了 Github,懶得想名字,再加上一開始的設(shè)計(jì)思路也來自于 Matisse,因此就取了一樣的名字,也叫 Matisse。下文如果沒有特別說明,Matisse 指的就是此 Jetpack Compose 版本的圖片選擇框架了

用 Jetpack Compose 來實(shí)現(xiàn) UI 相比原生的 View 體系實(shí)在要簡單很多,在這一塊除了滑動(dòng)性能之外我也沒遇到其它問題。因此,本文的內(nèi)容和 Jetpack Compose 無關(guān),主要是講 Matisse 的一些實(shí)現(xiàn)細(xì)節(jié)和遇到的系統(tǒng)兼容性問題

獲取圖片

實(shí)現(xiàn)一個(gè)圖片選擇框架的第一步自然就是要獲取到相冊(cè)內(nèi)的所有圖片了,因此需要申請(qǐng) READ_EXTERNAL_STORAGE 權(quán)限,此外還需要依賴系統(tǒng)的 MediaStore API 來讀取所有圖片

MediaStore 相當(dāng)于一個(gè)文件系統(tǒng)數(shù)據(jù)庫,記錄了當(dāng)前設(shè)備中所有文件的索引,我們可以通過它來快速查找設(shè)備中特定類型的文件。Matisse 使用的是 MediaStore.Image,在操作上就類似于查詢數(shù)據(jù)庫,通過聲明需要的數(shù)據(jù)庫字段 projection 和排序規(guī)則 sortOrder,得到相應(yīng)的數(shù)據(jù)庫游標(biāo) cursor,通過 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),因此可以通過 bucketId 來對(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ì)有太多的自定義需求,因此大多是通過直接調(diào)起系統(tǒng)相機(jī)來實(shí)現(xiàn)拍照,優(yōu)點(diǎn)是實(shí)現(xià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é)問:不同的生成規(guī)則對(duì)應(yīng)著不同的權(quán)限,甚至同種方式在不同系統(tǒng)版本上對(duì)權(quán)限的要求也不一樣,對(duì)用戶的感知也不一樣。此外,如果用戶在相機(jī)頁面取消拍照的話,此時(shí) imageUri 指向的圖片文件就沒有用了,我們還需要主動(dòng)刪除該文件

Matisse 通過 CaptureStrategy 接口來抽象以上邏輯

/**
 * 拍照策略
 */
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)了三種拍照策略供開發(fā)者選擇:

  • NothingCaptureStrategy
  • FileProviderCaptureStrategy
  • MediaStoreCaptureStrategy

NothingCaptureStrategy

NothingCaptureStrategy 代表的是不開啟拍照功能,也是 Matisse 默認(rèn)的拍照策略

/**
 *  什么也不做,即不開啟拍照功能
 */
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 臨時(shí)訪問權(quán)限來實(shí)現(xiàn),否則將直接拋出異常。而將 File 轉(zhuǎn)換為 content://URI 的操作就需要依靠 FileProvider 來實(shí)現(xiàn)了。Matisse 傳遞給系統(tǒng)相機(jī)的 imageUri 也需要滿足此規(guī)則

FileProviderCaptureStrategy 采用的策略就是:

  • 在 ExternalFilesDir 的 Pictures 目錄中創(chuàng)建一個(gè)圖片臨時(shí)文件用于存儲(chǔ)拍照結(jié)果,通過 FileProvider 得到該文件對(duì)應(yīng)的 content://URI ,從而得到待寫入的 imageUri
  • 假如用戶最終取消拍照,則直接刪除創(chuàng)建的臨時(shí)文件
  • 假如用戶最終完成拍照,則通過 BitmapFactory 獲取圖片的詳細(xì)信息
  • 由于圖片是保存在應(yīng)用自身的私有目錄中,因此不需要申請(qǐng)任何權(quán)限,也正因?yàn)槭撬接心夸?,所以圖片不會(huì)出現(xiàn)在系統(tǒng)相冊(cè)中
/**
 *  通過 FileProvider 來生成拍照所需要的 ImageUri
 *  無需申請(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 視自身情況而定,通過 authorities 來實(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

顧名思義,此策略通過 MediaStore 來生成所需要的 imageUri

在 Android 10 系統(tǒng)之前,應(yīng)用需要獲取到 WRITE_EXTERNAL_STORAGE 權(quán)限后才可以向共享存儲(chǔ)空間中寫入文件。從 Android 10 開始,應(yīng)用通過 MediaStore 向共享存儲(chǔ)空間中寫入文件無需任何權(quán)限,且對(duì)于應(yīng)用自身創(chuàng)建的文件,無需 READ_EXTERNAL_STORAGE 權(quán)限就可以直接訪問和刪除

MediaStoreCaptureStrategy 采用的策略就是:

  • 在大于等于 10 的系統(tǒng)版本中,不申請(qǐng) WRITE_EXTERNAL_STORAGE 權(quán)限,其它系統(tǒng)版本則進(jìn)行申請(qǐng)
  • 通過 MediaStore 向系統(tǒng)預(yù)創(chuàng)建一張圖片,從而得到待寫入的 imageUri
  • 假如用戶最終取消拍照,則通過 MediaStore 刪除 imageUri 指向的臟數(shù)據(jù)
  • 假如用戶最終完成拍照,則通過 MediaStore 去查詢 imageUri 對(duì)應(yīng)圖片的詳細(xì)信息
  • 由于圖片一開始就保存在 MediaStore 中,因此圖片會(huì)顯示在系統(tǒng)相冊(cè)中
/**
 *  通過 MediaStore 來生成拍照所需要的 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é)

所以說,除了 NothingCaptureStrategy 代表不開啟拍照功能外,其他兩種策略所需要的權(quán)限和圖片存儲(chǔ)的位置都不一樣,對(duì)用戶的感知也不一樣

拍照策略所需權(quán)限配置項(xiàng)對(duì)用戶是否可見
NothingCaptureStrategy   
FileProviderCaptureStrategy外部需要配置 FileProvider否,圖片存儲(chǔ)在應(yīng)用私有目錄內(nèi),對(duì)用戶不可見
MediaStoreCaptureStrategyAndroid 10 之前需要 WRITE_EXTERNAL_STORAGE 權(quán)限,Android 10 開始不需要權(quán)限是,圖片存儲(chǔ)在系統(tǒng)相冊(cè)內(nèi),對(duì)用戶可見

開發(fā)者根據(jù)自己的實(shí)際情況來決定選擇哪一種策略:

  • 如果應(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è)問題而多申請(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)用如果沒有聲明 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 本身是通過調(diào)起系統(tǒng)相機(jī)來實(shí)現(xiàn)拍照的,但如果引用方聲明了 CAMERA 權(quán)限的話,將連鎖導(dǎo)致 Matisse 也必須申請(qǐng) CAMERA 權(quán)限

為了解決這個(gè)問題,Matisse 通過檢查應(yīng)用的 Manifest 文件中是否包含 CAMERA 權(quán)限來決定是否需要進(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ù)

在文章開頭給出來的知乎官方 App 示例中可以看到,Pictures 目錄明明顯示有三張圖片,但點(diǎn)擊進(jìn)去又發(fā)現(xiàn)目錄是空的。這是由于 MediaStore 中存在臟數(shù)據(jù)導(dǎo)致的

當(dāng)應(yīng)用通過 MediaStoreCaptureStrategy 來啟動(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)該是一開始在歸類圖片目錄的時(shí)候沒有檢查圖片是否真的存在,等到要加載圖片的時(shí)候才發(fā)現(xiàn)圖片不可用

雖然 MediaStoreCaptureStrategy 會(huì)主動(dòng)刪除自己生成的臟數(shù)據(jù),但我們沒法確保其它應(yīng)用就不會(huì)向 MediaStore 插入臟數(shù)據(jù)。因此,Matisse 會(huì)在遍歷查詢所有圖片的過程中,同時(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ì)先通過 resolveActivity 方法查詢系統(tǒng)中是否有應(yīng)用可以處理拍照請(qǐng)求,有的話才去啟動(dòng)相機(jī),避免由于設(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)上也有著一個(gè)兼容性問題:軟件包可見性過濾

如果應(yīng)用的目標(biāo)平臺(tái)是 Android 11 或更高版本,那么當(dāng)應(yīng)用通過 queryIntentActivities()、getPackageInfo()、getInstalledApplications() 等方法查詢?cè)O(shè)備上已安裝的其它應(yīng)用相關(guān)信息時(shí),系統(tǒng)會(huì)默認(rèn)對(duì)返回結(jié)果進(jìn)行過濾。也就是說,通過這些方法查詢到的應(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í)際情況來決定哪些實(shí)現(xiàn) Android 核心功能的系統(tǒng)服務(wù)對(duì)所有應(yīng)用均可見

Matisse 的解決方案是:在 Manifest 文件中通過 queries 主動(dòng)聲明 IMAGE_CAPTURE,從而提高對(duì)此 action 的可見性

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

File API 的兼容性

嚴(yán)格來說,F(xiàn)ile API 的兼容性并不屬于 Matisse 遇到的問題,而是外部使用者會(huì)遇到的問題

從 Android 10 開始,系統(tǒng)推出了分區(qū)存儲(chǔ)的特性,限制了應(yīng)用讀寫共享文件的方式。當(dāng)應(yīng)用開啟分區(qū)存儲(chǔ)特性后,對(duì)共享文件的讀寫需要通過 MediaStore 來實(shí)現(xiàn),而不能使用以前常用的 File API,否則將直接拋出異常:FileNotFoundException open failed: EACCES (Permission denied)

例如,像 Glide、Coil 等圖片框架均支持通過 ByteArray 來加載圖片,對(duì)于開啟了分區(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 可能覺得這種限制對(duì)于應(yīng)用來說過于嚴(yán)格,因此又取消了限制,允許應(yīng)用繼續(xù)通過 File API 來讀寫共享文件,系統(tǒng)會(huì)自動(dòng)將 File API 重定向?yàn)?MediaStore API =_=

因此,雖然 Matisse 的返回值中包含了圖片的絕對(duì)路徑 path,但如果外部開啟了分區(qū)存儲(chǔ)特性的話,在 Android 10 設(shè)備上是不能直接通過 File API 來讀寫共享文件的,在其它系統(tǒng)版本上則可以繼續(xù)使用

Github

以上就是 Matisse 的一些實(shí)現(xiàn)細(xì)節(jié)和遇到的系統(tǒng)兼容性問題,更多實(shí)現(xiàn)細(xì)節(jié)請(qǐng)看 Github:Matisse

Matisse 同時(shí)也發(fā)布到了 Jitpack,方便開發(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)文章

最新評(píng)論