Repository navigation
Home
一个基于 Jetpack Compose 实现的 Android 图片和视频选择框架
- 广泛的兼容性:有效解决了不同系统版本间的兼容性问题
- 最佳权限实践:遵循 Android 权限管理最佳实践,按需申请权限
- 纯 Kotlin 与 Compose:完全采用 Kotlin 和 Jetpack Compose 构建,拥抱现代 Android 开发
- 灵活的拍照策略:支持多种拍摄策略,并允许开发者自定义拍照逻辑
- 自定义图片加载:支持接入各类图片加载框架(如 Coil, Glide)
- 灵活的选择模式:支持同时选择图片和视频,也支持仅选择其中一种媒体类型
- 完善的主题系统:提供深度的 UI 定制能力,并内置了浅色和深色两套默认主题
关联的文章:
dependencyResolutionManagement {
repositories {
google()
mavenCentral()
}
}
dependencies {
implementation("io.github.leavesczy:matisse:latestVersion")
}Matisse 不传递 Coil 或 Glide。使用哪个图片引擎,就在宿主中添加对应依赖,见下文 imageEngine。
Matisse 包含两种使用场景,可以单独使用或者组合使用,分别对应两个 ActivityResultContract:
-
MatisseContract:展示系统相册内的图片和视频,支持同时开启拍照功能。结果为按选择顺序排列的媒体列表;用户取消或未选择任何媒体时为null -
MatisseCaptureContract:直接启动系统相机拍摄照片(透明 Activity)。结果为拍摄得到的媒体;取消拍照、权限被拒或未能读取到有效结果时为null
启动参数是 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 mimeType = mediaResource.mimeType
}
}
val matisse = Matisse(
maxSelectable = 1,
imageEngine = CoilImageEngine(),
mediaType = MediaType.ImageOnly
)
mediaPickerLauncher.launch(input = matisse)View:
private val mediaPickerLauncher =
registerForActivityResult(contract = MatisseContract()) { result: List<MediaResource>? ->
if (!result.isNullOrEmpty()) {
val mediaResource = result[0]
val uri = mediaResource.uri
val mimeType = mediaResource.mimeType
}
}
val matisse = Matisse(
maxSelectable = 1,
imageEngine = CoilImageEngine(),
mediaType = MediaType.ImageOnly
)
mediaPickerLauncher.launch(input = matisse)启动参数是 MatisseCapture,结果类型为 MediaResource?。
Jetpack Compose:
val takePictureLauncher =
rememberLauncherForActivityResult(contract = MatisseCaptureContract()) { result ->
if (result != null) {
val uri = result.uri
val mimeType = result.mimeType
}
}
takePictureLauncher.launch(
input = MatisseCapture(captureStrategy = MediaStoreCaptureStrategy())
)View:
private val takePictureLauncher =
registerForActivityResult(contract = MatisseCaptureContract()) { result: MediaResource? ->
if (result != null) {
val uri = result.uri
val mimeType = result.mimeType
}
}
takePictureLauncher.launch(
input = MatisseCapture(captureStrategy = MediaStoreCaptureStrategy())
)/**
* @param maxSelectable 最多可选择的媒体数量,必须大于 0
* @param imageEngine 图片加载引擎。Matisse 不传递 Coil / Glide,宿主需按所选实现自行添加依赖
* @param gridColumns 网格列数,必须大于 0,默认 3
* @param returnOnTap 点击缩略图是否立即返回。为 true 时 maxSelectable 必须为 1;
* 选择器内拍照成功后同样立即返回该照片。默认 false
* @param mediaType 要展示的媒体类型,默认仅图片
* @param allowMixedMedia 是否允许同一结果中同时包含图片和视频,默认 false
* @param captureStrategy 拍照策略。mediaType 必须包含图片,否则只能为 null。
* mediaType 为 MimeTypes 时还须包含 image/jpeg。默认关闭拍照
*/
data class Matisse(
val maxSelectable: Int,
val imageEngine: ImageEngine,
val gridColumns: Int = 3,
val returnOnTap: Boolean = false,
val mediaType: MediaType = MediaType.ImageOnly,
val allowMixedMedia: Boolean = false,
val captureStrategy: CaptureStrategy? = null
)
/**
* 独立拍照功能的启动配置。通过 MatisseCaptureContract 启动后进入拍照流程。
*
* @param captureStrategy 用于创建输出 Uri、读取拍照结果及清理无效结果的拍照策略
*/
data class MatisseCapture(val captureStrategy: CaptureStrategy)不满足上述约束时,构造 Matisse 会抛出 IllegalArgumentException。
最多能选择几个媒体资源,必须大于 0。
用于加载网格缩略图、相册封面和预览图。视频在预览页由 Matisse 内联播放,引擎只负责静态封面。
interface ImageEngine : Parcelable {
/** 网格和相册列表:填满容器,允许裁切 */
@Composable
fun Thumbnail(mediaResource: MediaResource)
/** 预览大图:保持宽高比、不裁切;超出区域时自行提供查看完整内容的方式 */
@Composable
fun Preview(mediaResource: MediaResource)
}内置 GlideImageEngine 和 CoilImageEngine。实现会随 Matisse 经 Intent 传递,必须满足 Parcelable,建议使用 Parcelize。
宿主引入 Glide Compose 即可(会带上 Glide 5.x):
dependencies {
implementation("com.github.bumptech.glide:compose:1.0.0-beta10")
}Matisse 会在首次为 MediaStore 媒体加载缩略图或预览时,向当前 Glide 实例追加自有模型类型的加载组件。宿主其它 Uri / String / File 请求不受影响。通过 Glide.tearDown / Glide.init 重新初始化后,下次使用时会再次注册。
val matisse = Matisse(
maxSelectable = 1,
imageEngine = GlideImageEngine(),
mediaType = MediaType.ImageOnly
)需要同时引入 coil-compose 和 coil-video。若要在预览页播放 GIF 等动图,再引入 coil-gif。
dependencies {
val version = "3.6.3"
implementation("io.coil-kt.coil3:coil-compose:$version")
implementation("io.coil-kt.coil3:coil-video:$version")
// 可选,预览动图时引入
implementation("io.coil-kt.coil3:coil-gif:$version")
}Matisse 使用独立 Activity,读不到 Composable 树上的 LocalImageLoader。动图需要在 Application 中通过 SingletonImageLoader.setSafe 向宿主 ImageLoader 注册 Decoder:
SingletonImageLoader.setSafe(factory = { context ->
ImageLoader.Builder(context = context)
.components {
if (Build.VERSION.SDK_INT >= 28) {
add(factory = AnimatedImageDecoder.Factory())
} else {
add(factory = GifDecoder.Factory())
}
}
.build()
})缩略图读取逻辑通过请求级 Fetcher 接入,宿主其它图片请求不受影响。
val matisse = Matisse(
maxSelectable = 1,
imageEngine = CoilImageEngine(),
mediaType = MediaType.ImageOnly
)内置引擎不满足需求时,自行实现 ImageEngine。
- 宿主开启 Jetpack Compose,Kotlin 版本需与 Compose Compiler 匹配:Compose to Kotlin Compatibility Map。
- 启用 Parcelize。实现会随
Matisse经 Intent 传到选择器 Activity,类及其成员必须满足Parcelable。 - 实现两个 Composable:
-
Thumbnail:网格和相册列表。外层已是有界格子,应fillMaxSize()填满,允许裁切(例如ContentScale.Crop)。 -
Preview:预览页。图片保持宽高比、不裁切;超出区域时自行提供查看完整内容的方式(例如纵向滚动)。视频只画静态封面,播放由 Matisse 处理。
-
- 选择器是独立 Activity,读不到宿主页面 Composable 树上的
CompositionLocal(例如LocalImageLoader)。图片加载请用单例 / Application 级配置,或在引擎内部自行创建。 - 把实现传给
Matisse(imageEngine = ...)。
@Parcelize
class MyImageEngine : ImageEngine {
@Composable
override fun Thumbnail(mediaResource: MediaResource) {
AsyncImage(
modifier = Modifier.fillMaxSize(),
model = mediaResource.uri,
contentScale = ContentScale.Crop,
contentDescription = null
)
}
@Composable
override fun Preview(mediaResource: MediaResource) {
val modifier = if (mediaResource.isVideo) {
Modifier.fillMaxWidth()
} else {
Modifier
.fillMaxWidth()
.verticalScroll(state = rememberScrollState())
}
AsyncImage(
modifier = modifier,
model = mediaResource.uri,
contentScale = ContentScale.FillWidth,
contentDescription = null
)
}
}上面示例依赖 Coil Compose,仅为说明接口约定。使用其它加载库时,同样遵守 Thumbnail 填满裁切、Preview 等比不裁切、视频只出封面即可。
一行显示几个媒体资源,默认 3,必须大于 0。
为 true 时,点击缩略图立即返回该项,不进入预览和底栏确认;选择器内拍照成功后同样立即返回。仅当 maxSelectable 为 1 时才能开启。
MediaType.ImageOnly
MediaType.VideoOnly
MediaType.ImageAndVideo
MediaType.MimeTypes(mimeTypes = setOf("image/gif", "video/mp4"))MimeTypes 按 MediaStore 的 MIME 精确匹配,集合不能为空,且每项必须以 image/ 或 video/ 开头。
启用拍照时,mediaType 必须包含图片;若为 MimeTypes,还必须包含 image/jpeg(内置策略的输出类型)。自定义策略返回的 MIME 与 mediaType 不匹配时,拍照结果不会加入列表。
当 mediaType 同时包含图片和视频时,是否允许在同一结果中混选。默认 false。
用于两种拍照场景:
-
MatisseCaptureContract:直接拍照 -
Matisse.captureStrategy:相册网格中展示拍照入口
Matisse 提供三种实现:
FileProviderCaptureStrategyMediaStoreCaptureStrategySmartCaptureStrategy
内置策略输出 JPEG。若宿主 Manifest 声明了 CAMERA,拍照前会申请该权限;未声明则直接启动系统相机。
通过 FileProvider 生成 Uri,照片保存在应用专属外部目录 getExternalFilesDir(Pictures),不会写入系统相册,也不需要存储权限。authority 必须与 Manifest 配置完全一致。
<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><?xml version="1.0" encoding="utf-8"?>
<paths>
<external-files-path
name="Matisse"
path="Pictures" />
</paths>FileProviderCaptureStrategy(authority = "xxx.xxx.xxx")内置实现固定使用上述 Pictures 目录。若要使用其它目录,请自行实现 CaptureStrategy,并同步修改 FileProvider 路径。
通过 MediaStore 生成 Uri,照片写入系统相册。Android 9 及以下需要 WRITE_EXTERNAL_STORAGE(Matisse 会在拍照前申请);Android 10 及以上不需要。因此当 minSdkVersion 小于 29 时,宿主需要声明该权限。
Android 9 及以下使用 FileProvider,Android 10 及以上使用 MediaStore。即使主要在新系统上使用,也需要按 FileProviderCaptureStrategy 完成 FileProvider 配置,以覆盖 Android 9 及以下设备。
SmartCaptureStrategy(
fileProviderCaptureStrategy = FileProviderCaptureStrategy(authority = "xxx.xxx.xxx")
)| 策略 | 存储权限 | 额外配置 | 照片是否出现在系统相册 |
|---|---|---|---|
| FileProviderCaptureStrategy | 无 | FileProvider | 否,应用私有目录 |
| MediaStoreCaptureStrategy | Android 10 之前需要 WRITE_EXTERNAL_STORAGE
|
无 | 是 |
| SmartCaptureStrategy | 无 | FileProvider | Android 10 起可见 |
应用本身已有存储写入权限时,适合使用 MediaStore。没有该权限时,适合使用 FileProvider 或 Smart。
CaptureStrategy是接口,内置三种策略无法满足需求时可以自行实现。实现会经 Intent 传递,必须满足Parcelable。Matisse 从主线程发起策略调用,文件和 ContentResolver 操作需要自行切换到后台。拍照会话会保存在 Activity 状态中,进程被回收后可能对同一 Uri 再次调用loadCapturedMedia与deleteCaptureUri,实现需要能够重复执行。createImageName默认返回IMG_yyyyMMdd_HHmmssSSS.jpg。流程:先调用
shouldRequestWriteExternalStoragePermission;完成必要的存储写入与相机权限处理后,再调用createCaptureUri启动系统相机(通过MediaStore.EXTRA_OUTPUT传递 Uri)。相机成功返回后调用loadCapturedMedia。取消、失败、读取结果为 null 或抛出异常,以及 Uri 已创建但未能交给系统相机时,会调用deleteCaptureUri(实现需要容忍 Uri 已不存在)。createCaptureUri返回 null 时不会调用删除;创建失败时应返回 null 而不是抛出异常。loadCapturedMedia返回 null 前应给慢写入的相机留出足够等待时间。
Matisse 提供日间和夜间两套默认主题。在宿主的 values、values-night(以及 values-zh 等)中覆盖同名资源即可自定义系统栏、页面颜色、文案和图标。选择器 Activity 使用 Theme.Matisse,独立拍照使用 Theme.Matisse.Capture。
系统栏图标颜色由 bool 控制:
-
matisse_status_bar_icons_dark:为true时状态栏使用深色图标,适合浅色背景 -
matisse_navigation_bar_icons_dark:为true时导航栏使用深色图标,适合浅色背景
日间默认均为 true,夜间(values-night)默认均为 false。自定义主题时请同时覆盖对应 values / values-night。
颜色与系统栏图标(日间默认值):
<?xml version="1.0" encoding="utf-8"?>
<resources>
<bool name="matisse_status_bar_icons_dark">true</bool>
<bool name="matisse_navigation_bar_icons_dark">true</bool>
<color name="matisse_status_bar_background_color">#FFFFFFFF</color>
<color name="matisse_navigation_bar_background_color">#FFFFFFFF</color>
<color name="matisse_main_page_background_color">#FFF7F8FA</color>
<color name="matisse_media_item_background_color">#FFE6E8ED</color>
<color name="matisse_media_item_scrim_unselected_color">#08000000</color>
<color name="matisse_media_item_scrim_selected_color">#66000000</color>
<color name="matisse_media_video_icon_background_color">#F7FFFFFF</color>
<color name="matisse_media_video_icon_color">#FF0B1F3A</color>
<color name="matisse_capture_background_color">#FFE6E8ED</color>
<color name="matisse_capture_icon_color">#665B6B82</color>
<color name="matisse_top_bar_background_color">#FFFFFFFF</color>
<color name="matisse_top_bar_icon_color">#FF0B1F3A</color>
<color name="matisse_top_bar_text_color">#FF0B1F3A</color>
<color name="matisse_dropdown_menu_background_color">#FFFFFFFF</color>
<color name="matisse_dropdown_menu_text_color">#FF0B1F3A</color>
<color name="matisse_bottom_bar_background_color">#FFFFFFFF</color>
<color name="matisse_bottom_bar_preview_text_color">#FF0B1F3A</color>
<color name="matisse_bottom_bar_preview_text_disabled_color">#FFA0AEC0</color>
<color name="matisse_bottom_bar_confirm_text_color">#FF3B9AFF</color>
<color name="matisse_bottom_bar_confirm_text_disabled_color">#FFA0AEC0</color>
<color name="matisse_preview_page_background_color">#FF0B1220</color>
<color name="matisse_preview_page_bottom_bar_background_color">#E6162033</color>
<color name="matisse_preview_page_confirm_text_color">#FF5BA3F7</color>
<color name="matisse_preview_page_confirm_text_disabled_color">#665BA3F7</color>
<color name="matisse_checkbox_circle_stroke_color">#F7FFFFFF</color>
<color name="matisse_checkbox_circle_stroke_disabled_color">#66FFFFFF</color>
<color name="matisse_checkbox_circle_fill_unselected_color">#26000000</color>
<color name="matisse_checkbox_circle_fill_selected_color">#FF3B9AFF</color>
<color name="matisse_checkbox_text_color">#FFFFFFFF</color>
<color name="matisse_loading_indicator_color">#FF3B9AFF</color>
<color name="matisse_empty_glow_color">#243B9AFF</color>
<color name="matisse_empty_landscape_color">#FF9AADC6</color>
<color name="matisse_empty_shadow_color">#12000000</color>
<color name="matisse_empty_title_text_color">#FF0B1F3A</color>
<color name="matisse_empty_subtitle_text_color">#FF5A7394</color>
<color name="matisse_empty_retry_text_color">#FF3B9AFF</color>
</resources>文案默认英文,中文环境使用 values-zh。在宿主中覆盖同名 string / plurals 即可:
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="matisse_action_preview">预览</string>
<string name="matisse_action_confirm">确定</string>
<string name="matisse_action_confirm_with_count">确定(%1$d/%2$d)</string>
<string name="matisse_action_retry">重试</string>
<string name="matisse_bucket_all">全部</string>
<string name="matisse_bucket_name_with_count">%1$s(%2$d)</string>
<string name="matisse_error_permission_image">请在系统设置中允许访问照片</string>
<string name="matisse_error_permission_video">请在系统设置中允许访问视频</string>
<string name="matisse_error_permission_media">请在系统设置中允许访问照片和视频</string>
<string name="matisse_error_permission_storage">请授予存储写入权限后重试</string>
<string name="matisse_error_permission_camera">请授予拍照权限后重试</string>
<plurals name="matisse_error_max_images">
<item quantity="other">最多只能选择 %1$d 张图片</item>
</plurals>
<plurals name="matisse_error_max_videos">
<item quantity="other">最多只能选择 %1$d 个视频</item>
</plurals>
<plurals name="matisse_error_max_media">
<item quantity="other">最多只能选择 %1$d 个图片或视频</item>
</plurals>
<string name="matisse_error_mixed_media">不能同时选择图片和视频</string>
<string name="matisse_error_no_camera_app">没有可用于拍照的应用</string>
<string name="matisse_empty_image_title">暂无图片</string>
<string name="matisse_empty_video_title">暂无视频</string>
<string name="matisse_empty_media_title">暂无照片和视频</string>
<string name="matisse_empty_permission_image_title">无法访问照片</string>
<string name="matisse_empty_permission_video_title">无法访问视频</string>
<string name="matisse_empty_permission_media_title">无法访问相册</string>
<string name="matisse_empty_error_title">加载失败</string>
<string name="matisse_empty_error_subtitle">相册内容加载失败,请重试</string>
</resources>图标为 XML 矢量,覆盖同名 drawable 即可:ic_matisse_arrow_back、ic_matisse_arrow_drop_down、ic_matisse_photo_camera、ic_matisse_play_arrow、ic_matisse_check、ic_matisse_placeholder。
Matisse 不会在库 Manifest 中声明媒体读取权限。宿主需要根据 mediaType 自行声明。Matisse 只申请宿主已声明、且当前场景需要的权限。
targetSdkVersion 小于 33
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />targetSdkVersion 大于等于 33
按要展示的类型声明 READ_MEDIA_IMAGES 和/或 READ_MEDIA_VIDEO,并在 Android 12 及以下继续使用 READ_EXTERNAL_STORAGE:
<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" />仅图片时不必声明 READ_MEDIA_VIDEO,仅视频同理。未获得完整读取权限、且未启用部分访问时,无法进入选择界面。
Android 14 部分访问(可选)
当设备为 Android 14 及以上、且宿主 targetSdkVersion >= 34 时,若再声明:
<uses-permission android:name="android.permission.READ_MEDIA_VISUAL_USER_SELECTED" />Matisse 会一并请求,并接受用户授予的部分媒体访问。再次启动选择器时,若当前不是完整访问(包括仅有部分授权),会重新打开系统授权界面,以便调整可访问范围;已经是完整访问时不会重复弹窗。
是否需要存储写入权限取决于拍照策略和 minSdkVersion:
-
FileProviderCaptureStrategy、SmartCaptureStrategy:不需要 -
MediaStoreCaptureStrategy且minSdkVersion小于 29:需要声明WRITE_EXTERNAL_STORAGE,可以将maxSdkVersion设为 28
<uses-permission
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="28" />CAMERA 由宿主决定是否声明。声明后,Matisse 会在需要时申请;未声明则直接启动系统相机。