Skip to content
leavesCZY edited this page Oct 19, 2023 · 30 revisions

一、Matisse

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

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

关联的文章:

二、导入依赖

dependencyResolutionManagement {
    repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
    repositories {
        google()
        mavenCentral()
        maven {
            setUrl("https://jitpack.io")
        }
    }
}

dependencies {
    implementation("com.github.leavesCZY:Matisse:latestVersion")
}

三、基本使用

Matisse 一共包含两个 ActivityResultContract,分别有不同的作用

  • MatisseContract。展示系统相册内的图片和视频
  • MatisseCaptureContract。直接启动系统相机拍摄照片

1、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,
    mediaFilter = DefaultMediaFilter(supportedMimeTypes = MimeType.ofImage(hasGif = true)),
    imageEngine = CoilImageEngine(),
    captureStrategy = NothingCaptureStrategy
)
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,
    mediaFilter = DefaultMediaFilter(supportedMimeTypes = MimeType.ofImage(hasGif = true)),
    imageEngine = CoilImageEngine(),
    captureStrategy = NothingCaptureStrategy
)
mediaPickerLauncher.launch(matisse)

2、MatisseCaptureContract

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

Jetpack Compose:

val takePictureLauncher =
    rememberLauncherForActivityResult(contract = 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()))

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()))

四、请求参数

Matisse 和 MatisseCapture 一共包含四类请求参数

/**
 * @param maxSelectable 最多允许选择几个媒体资源
 * @param mediaFilter 用于定义媒体资源的加载和过滤规则
 * @param imageEngine 用于实现加载图片的逻辑
 * @param captureStrategy 拍照策略。默认不开启拍照功能
 */
data class Matisse(
    val maxSelectable: Int,
    val mediaFilter: MediaFilter,
    val imageEngine: ImageEngine,
    val captureStrategy: CaptureStrategy = NothingCaptureStrategy
)

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

1、maxSelectable

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

2、mediaFilter

MediaFilter 是一个接口,用于定义媒体资源的加载和过滤规则,其作用有三个:

  • 声明想要展示的媒体资源类型
  • 忽略特定的媒体资源
  • 默认选中特定的媒体资源

Matisse 获取到每一个特定类型的媒体资源后,都会通过 MediaFilter 接口来判断是否需要忽略或者默认选中该媒体资源

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

/**
 * @param supportedMimeTypes 要展示的媒体资源类型
 * @param ignoredResourceUri 包含在内的 Uri 将会被忽略,不会展示给用户
 * @param selectedResourceUri 包含在内的 Uri 将会被默认选中
 */
class DefaultMediaFilter(
    private val supportedMimeTypes: Set<MimeType>,
    private val ignoredResourceUri: Set<Uri> = emptySet(),
    private val selectedResourceUri: Set<Uri> = emptySet()
) : MediaFilter

当中,MimeType 就用于定义媒体类型,可以通过其内部的 ofAllofImageofVideo 三个方法进行快速筛选

//图片加视频
val mimeTypes = MimeType.ofAll(hasGif = true)
//仅图片
val mimeTypes = MimeType.ofImage(hasGif = true)
//仅视频
val mimeTypes = MimeType.ofVideo()
//仅 Gif
val mimeTypes = setOf(element = MimeType.GIF)
//仅 MP4
val mimeTypes = setOf(element = MimeType.MP4)

需要注意,通过 MediaFilter 默认选中的媒体资源数量不能超出 maxSelectable 的限制,否则将抛出异常

3、imageEngine

考虑到引用方大概率已经集成了某个图片加载框架,因此 Matisse 通过 ImageEngine 接口将图片的加载逻辑交由引用方来实现,避免重复集成多个图片加载框架

interface ImageEngine : Parcelable {

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

    /**
     * 在预览页面加载大图时调用
     */
    @Composable
    fun Image(mediaResource: MediaResource)

}

也正因为如此,引用方的项目需要开启对 Jetpack Compose 的支持。kotlinCompilerExtensionVersion 的具体版本取决于引用方的 Kotlin 版本:Compose to Kotlin Compatibility Map

android {
  buildFeatures {
      compose = true
  }
  composeOptions {
      kotlinCompilerExtensionVersion = "x.x.x"
  }
}

目前,支持 Jetpack Compose 且比较主流的图片加载框架有两个:Coil 和 Glide。这里分别提供相应的示例代码,引用方按照实际情况进行选择,在自己的项目中进行实现

CoilImageEngine

引入 Coil,根据实际需要来决定是否要引入 coil-gifcoil-video

dependencies {
    def coilVersion = "2.4.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

class CoilImageEngine : ImageEngine {

    @Composable
    override fun Thumbnail(mediaResource: MediaResource) {
        AsyncImage(
            modifier = Modifier.fillMaxSize(),
            model = mediaResource.uri,
            contentScale = ContentScale.Crop,
            contentDescription = mediaResource.name
        )
    }

    @Composable
    override fun Image(mediaResource: MediaResource) {
        if (mediaResource.isVideo) {
            AsyncImage(
                modifier = Modifier.fillMaxWidth(),
                model = mediaResource.uri,
                contentScale = ContentScale.FillWidth,
                contentDescription = mediaResource.name
            )
        } else {
            AsyncImage(
                modifier = Modifier
                    .fillMaxWidth()
                    .verticalScroll(state = rememberScrollState()),
                model = mediaResource.uri,
                contentScale = ContentScale.FillWidth,
                contentDescription = mediaResource.name
            )
        }
    }

}

GlideImageEngine

引入 Glide

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

如下即可实现 GlideImageEngine

class GlideImageEngine : ImageEngine {

    @OptIn(ExperimentalGlideComposeApi::class)
    @Composable
    override fun Thumbnail(mediaResource: MediaResource) {
        GlideImage(
            modifier = Modifier.fillMaxSize(),
            model = mediaResource.uri,
            contentScale = ContentScale.Crop,
            contentDescription = mediaResource.name
        )
    }

    @OptIn(ExperimentalGlideComposeApi::class)
    @Composable
    override fun Image(mediaResource: MediaResource) {
        if (mediaResource.isVideo) {
            GlideImage(
                modifier = Modifier.fillMaxWidth(),
                model = mediaResource.uri,
                contentScale = ContentScale.Fit,
                contentDescription = mediaResource.name
            )
        } else {
            GlideImage(
                modifier = Modifier
                    .fillMaxWidth()
                    .verticalScroll(state = rememberScrollState()),
                model = mediaResource.uri,
                contentScale = ContentScale.Fit,
                contentDescription = mediaResource.name
            )
        }
    }

}

4、captureStrategy

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

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

CaptureStrategy 接口就决定了是否要启用拍照功能、相机的启动参数、拍摄的照片应该如何命名和存储、是否要申请权限

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

  • NothingCaptureStrategy
  • FileProviderCaptureStrategy
  • MediaStoreCaptureStrategy
  • SmartCaptureStrategy

NothingCaptureStrategy

默认值,也即不开启拍照功能,选择此策略后在图片列表页面不会显示拍照入口

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_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 向相册写入文件无需任何权限

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

SmartCaptureStrategy

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

SmartCaptureStrategy 的执行策略是:

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

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

总结

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

在 Android 10 开始之后的系统版本可见,和 MediaStoreCaptureStrategy 一样

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

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

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

五、自定义主题和文本

Matisse 提供了日间和夜间两套默认主题,会自动根据应用的 DefaultNightMode 进行匹配

引用方可以精细自定义主题,通过在自己项目的 valuesvalues-night 文件夹中按需声明以下属性值,以此来自定义 Matisse 的 图片列数、系统状态栏和导航栏的背景色和图标颜色、背景色、文本颜色、图标颜色 等属性

<?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_preview_page_background_color">#FF22202A</color>
    <color name="matisse_preview_page_controller_background_color">#FF2B2A34</color>
    <color name="matisse_image_item_background_color">#66CCCCCC</color>
    <color name="matisse_image_item_border_color_when_selected">#FF03A9F4</color>
    <color name="matisse_capture_icon_color">#66000000</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">#99000000</color>
    <color name="matisse_sure_text_color">#FF03A9F4</color>
    <color name="matisse_sure_text_color_if_disable">#9903A9F4</color>
    <color name="matisse_back_text_color">#FFFFFFFF</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_fill_color">#FF03A9F4</color>
    <color name="matisse_check_box_text_color">#FFFFFFFF</color>
    <color name="matisse_video_icon_color">#99FFFFFF</color>
    <color name="matisse_circular_loading_color">#FF03A9F4</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_pictures">最多只能选择%d张图片</string>
    <string name="matisse_no_apps_support_video_preview">没有可用于预览视频的应用</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 没有主动声明任何权限,开发者需要根据实际情况在自己的项目中进行按需声明

必需权限

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

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" />

可选权限

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

  • NothingCaptureStrategy。无需申请此权限
  • 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" />
Clone this wiki locally