Skip to content
leavesCZY edited this page Jun 9, 2024 · 29 revisions

一、Matisse

一个用 Jetpack Compose 实现的 Android 图片视频选择框架

  • 适配到 Android 14
  • 解决了多个系统兼容性问题
  • 按需索取权限,遵循最佳用户实践
  • 完全用 Kotlin & Jetpack Compose 实现
  • 支持多种拍照策略,可以自由定义拍照逻辑
  • 支持自定义图片加载框架,可以自由定义加载逻辑
  • 支持同时选择图片和视频,或者单独选择两者之一
  • 支持精细自定义主题,提供了日夜间两套默认主题

关联的文章:

二、导入依赖

Maven Central

dependencyResolutionManagement {
    repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
    repositories {
        google()
        mavenCentral()
    }
}

dependencies {
    implementation("io.github.leavesczy:matisse:latestVersion")
}

三、基本使用

Matisse 包含两种使用场景,可以组合使用或者是单独使用,这两种场景分别对应两个 ActivityResultContract

  • MatisseContract。展示系统相册内的图片和视频,支持同时启用拍照功能
  • MatisseCaptureContract。直接启动系统相机,拍摄照片

1、MatisseContract

MatisseContract 的启动参数是 Matisse 类,在回调函数里获取用户选择的图片或视频,返回值类型为 List<MediaResource>?

Jetpack Compose:

val mediaPickerLauncher =
    rememberLauncherForActivityResult(contract = MatisseContract()) { result: List<MediaResource>? ->
        if (!result.isNullOrEmpty()) {
            val mediaResource = result[0]
            val uri = mediaResource.uri
            val path = mediaResource.path
            val name = mediaResource.name
            val mimeType = mediaResource.mimeType
        }
    }

val matisse = Matisse(
    maxSelectable = 1,
    imageEngine = GlideImageEngine(),
    mediaType = MediaType.ImageOnly
)
mediaPickerLauncher.launch(matisse)

View:

private val mediaPickerLauncher =
    registerForActivityResult(MatisseContract()) { result: List<MediaResource>? ->
        if (!result.isNullOrEmpty()) {
            val mediaResource = result[0]
            val uri = mediaResource.uri
            val path = mediaResource.path
            val name = mediaResource.name
            val mimeType = mediaResource.mimeType
        }
    }

val matisse = Matisse(
    maxSelectable = 1,
    imageEngine = GlideImageEngine(),
    mediaType = MediaType.ImageOnly
)
mediaPickerLauncher.launch(matisse)

2、MatisseCaptureContract

MatisseCaptureContract 的启动参数是 MatisseCapture 类,在回调函数里获取用户拍摄的照片,返回值类型为 MediaResource?。Matisse 通过启动一个透明 Activity 来完成 “申请权限” 和 “拍照” 等操作

Jetpack Compose:

val takePictureLauncher =
    rememberLauncherForActivityResult(contract = MatisseCaptureContract()) { result ->
        if (result != null) {
            val uri = result.uri
            val path = result.path
            val name = result.name
            val mimeType = result.mimeType
        }
    }

takePictureLauncher.launch(MatisseCapture(captureStrategy = MediaStoreCaptureStrategy()))

View:

private val takePictureLauncher =
    registerForActivityResult(MatisseCaptureContract()) { result: MediaResource? ->
        if (result != null) {
            val uri = result.uri
            val path = result.path
            val name = result.name
            val mimeType = result.mimeType
        }
    }

takePictureLauncher.launch(MatisseCapture(captureStrategy = MediaStoreCaptureStrategy()))

四、请求参数

/**
 * @param maxSelectable 用于设置最多能选择几个媒体资源
 * @param imageEngine 用于自定义图片加载框架
 * @param fastSelect 用于设置是否免去预览图片和确认选择的流程。值为 true 时 maxSelectable 必须为 1
 * @param mediaType 用于设置要加载的媒体资源类型。默认仅图片
 * @param singleMediaType 用于设置是否允许同时选择图片和视频。默认允许
 * @param mediaFilter 用于设置媒体资源的筛选规则。默认不进行筛选
 * @param captureStrategy 拍照策略。默认不开启拍照功能
 */
data class Matisse(
    val maxSelectable: Int,
    val imageEngine: ImageEngine,
    val fastSelect: Boolean = false,
    val mediaType: MediaType = MediaType.ImageOnly,
    val singleMediaType: Boolean = false,
    val mediaFilter: MediaFilter? = null,
    val captureStrategy: CaptureStrategy? = null
)

/**
 * @param captureStrategy 拍照策略
 */
data class MatisseCapture(val captureStrategy: CaptureStrategy)

1、maxSelectable

maxSelectable 用于设置最多能选择几个媒体资源

2、fastSelect

fastSelect 用于设置是否免去预览图片和确认选择的流程,仅在 maxSelectable 为 1 时才能设置为 true。当值为 true 时,用户点击图片后就会立马返回所选图片,无需再次确认

3、mediaType

mediaType 用于设置要加载的媒体资源类型,包含以下四种类型供开发者选择

//仅图片
val imageOnly = MediaType.ImageOnly

//仅视频
val videoOnly = MediaType.VideoOnly

//图片 + 视频
val imageAndVideo = MediaType.ImageAndVideo

//jpg + mp4
val mimeTypes = MediaType.MultipleMimeType(mimeTypes = setOf("image/jpeg", "video/mp4"))

4、imageEngine

imageEngine 用于自定义图片加载框架。考虑到引用方大概率已经集成了某个图片加载框架,因此 Matisse 通过 ImageEngine 接口来让开发者可以自由选择要使用的图片加载框架

interface ImageEngine : Parcelable {

    /**
     * 加载缩略图时调用
     */
    @Composable
    fun Thumbnail(mediaResource: MediaResource)

    /**
     * 加载大图时调用
     */
    @Composable
    fun Image(mediaResource: MediaResource)

}

目前,比较主流且支持 Jetpack Compose 的图片加载框架有两个:Glide 和 Coil。Matisse 默认集成了相应的 ImageEngine 实现:GlideImageEngine 和 CoilImageEngine,引用方可以按照自己的实际情况进行选择

GlideImageEngine

引用方需要在自己的项目中手动引入 Glide 的 Jetpack Compose 扩展依赖库

dependencies {
    val glideComposeVersion = "1.0.0-beta01"
    implementation("com.github.bumptech.glide:compose:$glideComposeVersion")
}

然后就可以直接使用 GlideImageEngine 了

val matisse = Matisse(
    maxSelectable = 1,
    imageEngine = GlideImageEngine(),
    mediaType = MediaType.ImageOnly
)

CoilImageEngine

引用方需要在自己的项目中手动引入 Coil 的 Jetpack Compose 扩展依赖库,根据实际情况来决定是否要引入 coil-gifcoil-video

dependencies {
    val coilVersion = "2.6.0"
    //必选
    implementation("io.coil-kt:coil-compose:$coilVersion")
    //可选,需要展示 Gif 则引入
    implementation("io.coil-kt:coil-gif:$coilVersion")
    //可选,需要展示 Video 则引入
    implementation("io.coil-kt:coil-video:$coilVersion")
}

初始化 Coil

private fun initCoil(context: Context) {
    val imageLoader = ImageLoader.Builder(context = context)
        .components {
            //可选,需要展示 Gif 则引入
            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
                add(ImageDecoderDecoder.Factory())
            } else {
                add(GifDecoder.Factory())
            }
            //可选,需要展示 Video 则引入
            add(VideoFrameDecoder.Factory())
        }
        .build()
    Coil.setImageLoader(imageLoader)
}

然后就可以直接使用 CoilImageEngine 了

val matisse = Matisse(
    maxSelectable = 1,
    imageEngine = CoilImageEngine(),
    mediaType = MediaType.ImageOnly
)

自定义

假如默认的 GlideImageEngine 和 CoilImageEngine 不符合需求,或者是想要使用其它的图片加载框架的话,开发者可以自己来手动实现 ImageEngine

此时引用方的项目就需要开启 Jetpack Compose 功能并引用 kotlinCompiler 了

引用 kotlinCompiler 的方式有两种,取决于引用方使用的 Kotlin 版本:Compose to Kotlin Compatibility Map

  • 在 kotlin 2.0.0 版本之前,通过 kotlinCompilerExtensionVersion 进行引用

    android {
      buildFeatures {
          compose = true
      }
      composeOptions {
          kotlinCompilerExtensionVersion = "x.x.x"
      }
    }
  • 从 kotlin 2.0.0 版本开始,通过 Compose Compiler Gradle Plugin 进行引用

android {
  buildFeatures {
      compose = true
  }
}

plugins {
    id("org.jetbrains.kotlin.plugin.compose") version "2.0.0"
}

另外,由于 ImageEngine 接口继承了 Parcelable 接口,因此 ImageEngine 的任意子类也均需要实现 Parcelable。建议开发者通过引入 Kotlin 的 Parcelize 插件来实现自动序列化

假如引用方希望同时集成图片缩放功能的话,可以去引用任意适用于 Jetpack Compose 的图片缩放库,Matisse 提供了相关的示例代码,开发者可以去参照实现方式

5、singleMediaType

singleMediaType 用于当 mediaType 同时包含图片和视频两种类型时,设置是否允许用户同时选择图片和视频,值为 true 时只能选择一种媒体类型

6、mediaFilter

mediaFilter 用于设置媒体资源的筛选规则,其作用有两个:

  • 忽略特定的媒体资源
  • 默认选中特定的媒体资源

MediaFilter 本身是一个接口,有一个默认实现 DefaultMediaFilter,其包含的三个构造参数就分别对应上述两个功能

/**
 * @param ignoredMimeType 包含在内的 mimeType 将会被忽略,不会展示给用户
 * @param ignoredResourceUri 包含在内的 Uri 将会被忽略,不会展示给用户
 * @param selectedResourceUri 包含在内的 Uri 将会被默认选中
 */
@Parcelize
class DefaultMediaFilter(
    private val ignoredMimeType: Set<String> = emptySet(),
    private val ignoredResourceUri: Set<Uri> = emptySet(),
    private val selectedResourceUri: Set<Uri> = emptySet()
) : MediaFilter

7、captureStrategy

captureStrategy 即拍照策略,用于支持两种拍照场景:

  • 直接启动相机让用户进行拍照
  • 在资源列表页面展示一个拍照入口,在用户点击后进行拍照

CaptureStrategy 决定了相机的启动参数、照片的命名和存储规则、权限的申请规则

Matisse 提供了三种默认实现,用户的感知各不相同

  • FileProviderCaptureStrategy
  • MediaStoreCaptureStrategy
  • SmartCaptureStrategy

1、FileProviderCaptureStrategy

开启拍照功能,通过 FileProvider 来生成拍照所需要的 imageUri,并将图片保存到应用的私有目录内

此策略不需要权限,但需要配置 FileProvider,authorities 视自身情况而定,通过 authorities 来实例化 FileProviderCaptureStrategy

<provider
    android:name="androidx.core.content.FileProvider"
    android:authorities="xxx.xxx.xxx"
    android:exported="false"
    android:grantUriPermissions="true">
    <meta-data
        android:name="android.support.FILE_PROVIDER_PATHS"
        android:resource="@xml/file_provider_paths" />
</provider>

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

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

2、MediaStoreCaptureStrategy

开启拍照功能,通过系统的 MediaStore 来生成拍照所需要的 imageUri,并将图片保存到系统相册中

此策略在不同系统版本上有着不同的权限要求

  • 在 Android 10 之前,需要获取到 WRITE_EXTERNAL_STORAGE 权限后才可以向系统相册插入图片
  • 从 Android 10 开始,通过 MediaStore 向系统相册插入图片无需任何权限

所以,采用此策略后,如果你的项目的 minSdkVersion 小于 29 则需要声明 WRITE_EXTERNAL_STORAGE 权限,否则不需要声明

3、SmartCaptureStrategy

开启拍照功能,此策略同时包含了 FileProviderCaptureStrategy 和 MediaStoreCaptureStrategy 两种策略,因此外部也需要像 FileProviderCaptureStrategy 一样配置 FileProvider,通过 authorities 来实例化 SmartCaptureStrategy

SmartCaptureStrategy 的执行策略是:

  • 当系统版本小于 Android 10 时,执行 FileProviderCaptureStrategy 策略
  • 当系统版本大于等于 Android 10 时,执行 MediaStoreCaptureStrategy 策略

采用此策略后,既无需申请权限,又可以在 Android 10 开始之后的系统版本将照片存入到系统相册中,同时平衡了 “隐私安全” 和 “用户体验” 两者

4、总结

拍照策略 需要的权限 配置项 图片对用户是否可见
FileProviderCaptureStrategy 需要配置 FileProvider 否,图片保存在应用的私有目录内,对用户不可见
MediaStoreCaptureStrategy Android 10 之前需要 WRITE_EXTERNAL_STORAGE 权限,Android 10 开始不需要权限 是,图片保存在系统相册内,对用户可见
SmartCaptureStrategy 需要配置 FileProvider 在 Android 10 之前的系统版本不可见,和 FileProviderCaptureStrategy 一样

开发者根据自己的实际情况来选择拍照策略:

  • 如果应用本身就拥有 WRITE_EXTERNAL_STORAGE 权限的话,建议选 MediaStoreCaptureStrategy,拍摄的图片保存到系统相册中也比较符合用户的认知
  • 如果应用本身就没有 WRITE_EXTERNAL_STORAGE 权限的话,建议选 FileProviderCaptureStrategy 或 SmartCaptureStrategy,为了相册问题而多申请一个敏感权限得不偿失

CaptureStrategy 本身是一个接口,假如以上三种拍照策略均无法满足你的需求的话,开发者可以自己来实现 CaptureStrategy 接口

此外,CaptureStrategy 接口内包含一个返回值类型为 Bundle 的默认方法 getCaptureExtra ,该返回值会被添加到用于启动系统相机的 Intent 中,可用于为相机设置启动参数(例如,开启前置摄像头)。相对应的,FileProviderCaptureStrategy、MediaStoreCaptureStrategy、SmartCaptureStrategy 也都包含一个 Bundle 类型的构造参数作为 getCaptureExtra 方法的返回值,引用方可以通过该参数来控制相机属性

五、主题和文本

Matisse 提供了日间和夜间两套默认主题,也支持引用方进一步自定义主题

通过在项目的 valuesvalues-night 文件夹中按需声明以下属性值,以此来自定义 图片列数、系统状态栏和导航栏的背景色和图标颜色、页面背景色、文本颜色、图标颜色 等多个属性

<?xml version="1.0" encoding="utf-8"?>
<resources>
    <integer name="matisse_image_span_count">3</integer>
    <bool name="matisse_status_bar_dark_icons">true</bool>
    <bool name="matisse_navigation_bar_dark_icons">true</bool>
    <color name="matisse_status_bar_color">#FFFFFFFF</color>
    <color name="matisse_navigation_bar_color">#FFFFFFFF</color>
    <color name="matisse_main_page_background_color">#FFFFFFFF</color>
    <color name="matisse_media_item_background_color">#66CCCCCC</color>
    <color name="matisse_media_item_scrim_color_when_selected">#80000000</color>
    <color name="matisse_capture_item_background_color">#66CCCCCC</color>
    <color name="matisse_capture_item_icon_color">#FFFFFFFF</color>
    <color name="matisse_top_bar_background_color">#FFFFFFFF</color>
    <color name="matisse_top_bar_icon_color">#FF000000</color>
    <color name="matisse_top_bar_text_color">#FF000000</color>
    <color name="matisse_dropdown_menu_background_color">#FFFFFFFF</color>
    <color name="matisse_dropdown_menu_text_color">#FF000000</color>
    <color name="matisse_bottom_navigation_bar_background_color">#FFFFFFFF</color>
    <color name="matisse_preview_text_color">#FF000000</color>
    <color name="matisse_preview_text_color_if_disable">#FFC6CCD2</color>
    <color name="matisse_sure_text_color">#FF000000</color>
    <color name="matisse_sure_text_color_if_disable">#FFC6CCD2</color>
    <color name="matisse_preview_page_background_color">#FF22202A</color>
    <color name="matisse_preview_page_bottom_navigation_bar_background_color">#FF2B2A34</color>
    <color name="matisse_preview_page_back_text_color">#FFFFFFFF</color>
    <color name="matisse_preview_page_sure_text_color">#FFFFFFFF</color>
    <color name="matisse_preview_page_sure_text_color_if_disable">#80FFFFFF</color>
    <color name="matisse_check_box_circle_color">#FFFFFFFF</color>
    <color name="matisse_check_box_circle_color_if_disable">#80FFFFFF</color>
    <color name="matisse_check_box_circle_fill_color">#FF03A9F4</color>
    <color name="matisse_check_box_text_color">#FFFFFFFF</color>
    <color name="matisse_circular_loading_color">#FF03A9F4</color>
    <color name="matisse_video_icon_color">#FFFFFFFF</color>
    <color name="matisse_video_view_page_background_color">#FF22202A</color>
</resources>

此外,Matisse 中也包含了一些默认文本用于对用户进行提示,引用方可以在自己项目中的 strings.xml 文件中按需声明以下属性值,以此来覆盖 Matisse 的默认文本

<?xml version="1.0" encoding="utf-8"?>
<resources>
    <string name="matisse_default_bucket_name">全部</string>
    <string name="matisse_read_media_permission_denied">请授予相册访问权限后重试</string>
    <string name="matisse_write_external_storage_permission_denied">请授予存储写入权限后重试</string>
    <string name="matisse_camera_permission_denied">请授予拍照权限后重试</string>
    <string name="matisse_limit_the_number_of_media">最多只能选择 %d 个图片或视频</string>
    <string name="matisse_cannot_select_both_picture_and_video_at_the_same_time">不能同时选择图片和视频</string>
    <string name="matisse_no_apps_support_take_picture">没有可用于拍照的应用</string>
    <string name="matisse_preview">预览</string>
    <string name="matisse_sure">确定(%d/%d)</string>
    <string name="matisse_back">返回</string>
</resources>

六、声明权限

Matisse 没有主动声明任何权限,开发者需要根据实际情况在自己的项目中进行按需声明

1、必需权限

必需权限用于读取系统相册内的图片和视频

targetSdkVersion 小于 33

如果应用的 targetSdkVersion 小于 33,声明 READ_EXTERNAL_STORAGE 权限即可

<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />

targetSdkVersion 大于等于 33

如果应用的 targetSdkVersion 大于等于 33

  • 需要展示的仅是图片,声明 READ_EXTERNAL_STORAGEREAD_MEDIA_IMAGES 两个权限
  • 需要展示的仅是视频,声明 READ_EXTERNAL_STORAGEREAD_MEDIA_VIDEO 两个权限
  • 需要同时展示图片和视频,声明 READ_EXTERNAL_STORAGEREAD_MEDIA_IMAGESREAD_MEDIA_VIDEO 三个权限

此外,在这种情况下,READ_EXTERNAL_STORAGE 权限已无法用于 Android 13 开始之后的系统版本了,所以可以将权限的 maxSdkVersion 设为 32

<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />

<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />

<uses-permission
    android:name="android.permission.READ_EXTERNAL_STORAGE"
    android:maxSdkVersion="32" />

2、可选权限

可选权限是否需要声明取决于开发者采用的拍照策略和应用的 minSdkVersion

  • SmartCaptureStrategy。无需申请此权限
  • FileProviderCaptureStrategy。无需申请此权限
  • MediaStoreCaptureStrategy
    • 如果应用的 minSdkVersion 大于等于 29。无需申请此权限
    • 如果应用的 minSdkVersion 小于 29。由于在 Android 10 之前向系统相册写入图片需要存储写入权限,所以需要声明 WRITE_EXTERNAL_STORAGE 权限。此外,在这种情况下,Android 10 开始之后的系统版本不再需要此权限,因此可以将权限的 maxSdkVersion 设为 28
<uses-permission
    android:name="android.permission.WRITE_EXTERNAL_STORAGE"
    android:maxSdkVersion="28" />