-
Notifications
You must be signed in to change notification settings - Fork 62
Home
一个用 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。直接启动系统相机拍摄照片
通过 Matisse 类来传递启动参数,在回调函数里获取用户选择的图片或视频,返回值类型为 List<MediaResource>?
。如果设置了允许拍摄照片的话,返回的也可能是用户刚拍摄的照片
Jetpack Compose:
val mediaPickerLauncher =
rememberLauncherForActivityResult(contract = MatisseContract()) { result: List<MediaResource>? ->
if (result.isNotEmpty()) {
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 = SmartCaptureStrategy("yourFileProvider.authority")
)
mediaPickerLauncher.launch(matisse)
View:
private val mediaPickerLauncher =
registerForActivityResult(MatisseContract()) { result: List<MediaResource>? ->
if (result.isNotEmpty()) {
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 = SmartCaptureStrategy("yourFileProvider.authority")
)
mediaPickerLauncher.launch(matisse)
通过 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 = SmartCaptureStrategy("yourFileProvider.authority")))
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 = SmartCaptureStrategy("yourFileProvider.authority")))
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)
maxSelectable 用于设置最多允许选择几个媒体资源
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 就用于定义媒体类型,可以通过其内部的 ofAll
、ofImage
、ofVideo
三个方法进行快速筛选
//图片加视频
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 的限制,否则将抛出异常
考虑到引用方大概率已经集成了某个图片加载框架,因此 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。这里分别提供相应的示例代码,引用方按照实际情况进行选择,在自己的项目中进行实现
引入 Coil,根据实际需要来决定是否要引入 coil-gif
和 coil-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
)
}
}
}
引入 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
)
}
}
}
captureStrategy 即拍照策略,用于支持两种拍照场景:
- 直接启动相机让用户进行拍照
- 在图片列表页面展示一个拍照入口,在用户点击后进行拍照
CaptureStrategy 接口就决定了是否要启用拍照功能、相机的启动参数、拍摄的照片应该如何命名和存储、是否要申请权限
Matisse 提供了 CaptureStrategy 的四种默认实现,用户的感知各不相同
- NothingCaptureStrategy
- FileProviderCaptureStrategy
- MediaStoreCaptureStrategy
- SmartCaptureStrategy
默认值,也即不开启拍照功能,选择此策略后在图片列表页面不会显示拍照入口
开启拍照功能,通过 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>
开启拍照功能,通过系统的 MediaStore 来生成拍照所需要的 imageUri,并将图片存储在系统相册中
此策略在不同系统版本上有着不同的权限要求
- 在 Android 10 之前,需要获取到
WRITE_EXTERNAL_STORAGE
权限后才可以向相册写入文件 - 从 Android 10 开始,通过 MediaStore 向相册写入文件无需任何权限
所以,采用此策略后,如果你的项目的 minSdkVersion 小于 29,则需要声明 WRITE_EXTERNAL_STORAGE
权限,否则不需要声明
开启拍照功能,此策略同时包含了 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 进行匹配
引用方可以精细自定义主题,通过在自己项目的 values
和 values-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,声明 READ_EXTERNAL_STORAGE
权限即可
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
如果应用的 targetSdkVersion 大于等于 33
- 需要展示的仅是图片,声明
READ_EXTERNAL_STORAGE
和READ_MEDIA_IMAGES
两个权限 - 需要展示的仅是视频,声明
READ_EXTERNAL_STORAGE
和READ_MEDIA_VIDEO
两个权限 - 需要同时展示图片和视频,声明
READ_EXTERNAL_STORAGE
、READ_MEDIA_IMAGES
、READ_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" />