實作 Android 客製化相簿選擇器

最近專案上要做一個 App 內部使用的媒體選擇器,要可以列出手機目前的照片或影片,也要可以列出手機的相簿,使用者選擇相簿後,列表顯示該相簿的媒體。

其實用 Android 內建 Intent 就可以開啟然後選擇了,為何需要客製化呢? 原因就在於我們的需求不只是這樣,假設我們是一個照片分享的App,希望使用者能分享多張照片、且照片屬於高解析度的,那麼我們如果用原生的照片選擇器,使用者只能一張一張慢慢選,且如果選到解析度低的就會提示使用者不符合上傳規則,這樣的做法是一個很糟的使用者體驗。能客製化一個媒體選擇器就是我們該來做的事情。

需求分析

好,製作之前先來釐清我們客製化的需求是什麼

  • 一開啟時預設要顯示所有相簿的媒體,使用者可以點擊相簿名稱開啟相簿列表來選擇開啟其他相簿,然後列出該相簿的媒體。
  • 可以列出照片和影片,或者只有其中一種,只顯示照片或影片。choose(MimeType.ALL / MimeType.IMAGE / MimeType.VIDEO)
  • 支援單選和多選,多選可以有數量上限。 mutiple(true) / maxSelect(Int)
  • 可以依照照片尺寸做過濾,只顯示符合尺寸的照片。 imageMaxSize(Long)
  • 可以依照影片長度做過濾,只顯示符合範圍的影片。 videoMaxSecond(Int) / videoMinSecond(Int)

架構設計

我們採用 MVVM 的架構來實作,搭配 RxJava 做資料存取,這邊來做職責劃分:

  1. Model 儲存媒體和相簿的資料類別
  2. Repository 用來存取相簿、媒體,做不同的資料查詢、過濾
  3. ViewModel 處理介面邏輯和資料串接
  4. View 單純顯示介面和傳遞使用者操作給 ViewModel。

實作

首先,別忘了我們開啟相簿需要先要求權限,我們在 AndroidManifest.xml 和 Runtime 都需要檢查:

<manifest package="com.enginebai.gallery"
xmlns:android="http://schemas.android.com/apk/res/android">
<!-- Remember to add this line -->
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
<application
...
view raw AndroidManifest.xml hosted with ❤ by GitHub

Model

我們先定義三個資料類別,分別用來儲存相簿、媒體和開啟相簿設定值:

const val ALL_MEDIA_ALBUM_NAME = "ALL_MEDIA_ALBUM_NAME"
const val KEY_MEDIA_LIST = "mediaList"
data class AlbumItem(
val name: String,
val folder: String,
val coverImagePath: String
) {
val mediaList = mutableListOf<Media>()
}
class AlbumSetting : Serializable {
var mimeType = MimeType.ALL
var multipleSelection: Boolean = false
var maxSelection = 10
var imageMaxSize: Long? = null
var videoMaxSecond: Int? = null
var videoMinSecond: Int? = null
}
data class Media(
val path: String,
var name: String?,
var album: String?,
var size: Long?,
var datetime: Long?,
var duration: Long?,
var width: Int?,
var height: Int?
)
enum class MimeType(private val typeName: String) {
ALL("all"),
IMAGE("image"),
VIDEO("video");
override fun toString() = typeName
}
view raw Model.kt hosted with ❤ by GitHub

Repository

interface AlbumRepo {
fun fetchAlbums(setting: AlbumSetting? = null): Completable
fun getAlbums(setting: AlbumSetting? = null): BehaviorSubject<List<AlbumItem>>
fun getAlbumItem(name: String, setting: AlbumSetting? = null): Observable<AlbumItem>
fun getAlbumItemSync(name: String, settings: AlbumSetting? = null): AlbumItem?
}
view raw AlbumRepo.kt hosted with ❤ by GitHub

AlbumRepo 我們定義資料存取的介面,主要方法有 

  1. fetchAlbums(): 用在拉相簿資料然後將資料更新相簿資料的訂閱者,這邊使用 Completable 當資料拉完後就完成了。這個方法每次呼叫後都會重新拉相簿資料,可以用在第一次拉資料或者強制更新資料用途。
  2. getAlbums(): 訂閱相簿資料的更新。
class AlbumRepoImpl(private val context: Context) : AlbumRepo {
private val albumSubject = BehaviorSubject.create<List<AlbumItem>>()
private val albumItemMapping = mutableMapOf<String, AlbumItem>()
override fun fetchAlbums(setting: AlbumSetting?): Completable {
return Completable.fromAction {
fetchAlbumSync(setting)
}
}
private fun fetchAlbumSync(setting: AlbumSetting?) {
albumItemMapping.clear()
val contentUri = MediaStore.Files.getContentUri("external")
val selection =
"(${MediaStore.Files.FileColumns.MEDIA_TYPE}=? OR " +
"${MediaStore.Files.FileColumns.MEDIA_TYPE}=?) AND " +
"${MediaStore.MediaColumns.SIZE} > 0"
val selectionArgs =
arrayOf(
MediaStore.Files.FileColumns.MEDIA_TYPE_IMAGE.toString(),
MediaStore.Files.FileColumns.MEDIA_TYPE_VIDEO.toString()
)
val projections =
arrayOf(
MediaStore.Files.FileColumns._ID,
MediaStore.Images.Media.BUCKET_DISPLAY_NAME,
MediaStore.MediaColumns.DATA,
MediaStore.MediaColumns.DISPLAY_NAME,
MediaStore.MediaColumns.DATE_MODIFIED,
MediaStore.MediaColumns.MIME_TYPE,
MediaStore.MediaColumns.WIDTH,
MediaStore.MediaColumns.HEIGHT,
MediaStore.MediaColumns.SIZE,
MediaStore.Video.Media.DURATION
)
val sortBy = "${MediaStore.Files.FileColumns.DATE_MODIFIED} DESC"
val cursor =
context.contentResolver.query(contentUri, projections, selection, selectionArgs, sortBy)
if (true == cursor?.moveToFirst()) {
val pathCol = cursor.getColumnIndex(MediaStore.MediaColumns.DATA)
val bucketNameCol =
cursor.getColumnIndex(MediaStore.Images.Media.BUCKET_DISPLAY_NAME)
val nameCol = cursor.getColumnIndex(MediaStore.MediaColumns.DISPLAY_NAME)
val dateCol = cursor.getColumnIndex(MediaStore.MediaColumns.DATE_MODIFIED)
val mimeType = cursor.getColumnIndex(MediaStore.MediaColumns.MIME_TYPE)
val sizeCol = cursor.getColumnIndex(MediaStore.MediaColumns.SIZE)
val durationCol = cursor.getColumnIndex(MediaStore.Video.Media.DURATION)
val widthCol = cursor.getColumnIndex(MediaStore.MediaColumns.WIDTH)
val heightCol = cursor.getColumnIndex(MediaStore.MediaColumns.HEIGHT)
do {
val path = cursor.getString(pathCol)
val bucketName = cursor.getString(bucketNameCol)
val name = cursor.getString(nameCol)
val dateTime = cursor.getLong(dateCol)
val type = cursor.getString(mimeType)
val size = cursor.getLong(sizeCol)
val duration = cursor.getLong(durationCol)
val width = cursor.getInt(widthCol)
val height = cursor.getInt(heightCol)
if (path.isNullOrEmpty() || type.isNullOrEmpty())
continue
val file = File(path)
if (!file.exists() || !file.isFile)
continue
if (MimeType.IMAGE == setting?.mimeType &&
!type.startsWith(MimeType.IMAGE.toString())
)
continue
if (MimeType.VIDEO == setting?.mimeType &&
!type.startsWith(MimeType.VIDEO.toString())
)
continue
if (type.startsWith(MimeType.IMAGE.toString())) {
if (null != setting?.imageMaxSize) {
if (size > setting.imageMaxSize!!) {
continue
}
}
}
if (type.startsWith(MimeType.VIDEO.toString())) {
if (null != setting?.videoMinSecond && duration < setting.videoMinSecond!!.times(1000)) {
continue
}
if (null != setting?.videoMaxSecond && duration > setting.videoMaxSecond!!.times(1000)) {
continue
}
}
val media = Media(path, name, bucketName, size, dateTime, duration, width, height)
// 初始化所有媒體的相簿
if (isEmpty()) {
addAlbumItem(ALL_MEDIA_ALBUM_NAME, "", path)
}
// 把目前媒體放到所有媒體的相簿
addMediaToAlbum(ALL_MEDIA_ALBUM_NAME, media)
// 把目前媒體放到對應的相簿
val folder = file.parentFile?.absolutePath ?: ""
addAlbumItem(bucketName, folder, path)
addMediaToAlbum(bucketName, media)
} while (cursor.moveToNext())
}
cursor?.close()
albumSubject.onNext(albumItemMapping.values.toList())
}
override fun getAlbums(setting: AlbumSetting?): BehaviorSubject<List<AlbumItem>> {
if (isEmpty()) {
fetchAlbums(setting)
.subscribeOn(Schedulers.io())
.subscribe()
}
return albumSubject
}
override fun getAlbumItem(name: String, setting: AlbumSetting?): Observable<AlbumItem> {
if (isEmpty()) {
fetchAlbums(setting)
.subscribeOn(Schedulers.io())
.subscribe()
}
return albumSubject.map {
albumItemMapping[name]
}
}
override fun getAlbumItemSync(name: String, settings: AlbumSetting?): AlbumItem? {
if (isEmpty())
fetchAlbumSync(settings)
return albumItemMapping[name]
}
private fun addAlbumItem(name: String, folder: String, coverImagePath: String) {
albumItemMapping[name] ?: run {
albumItemMapping[name] = AlbumItem(name, folder, coverImagePath)
}
}
private fun addMediaToAlbum(albumName: String, media: Media) {
albumItemMapping[albumName]?.mediaList?.add(media)
}
private fun isEmpty() = albumItemMapping.keys.isEmpty()
}
view raw AlbumRepoImpl.kt hosted with ❤ by GitHub

AlbumRepo 實作的部分我們會講兩個小重點:

首先是,fetchAlbums() 實作大方向是我們使用 ContentProvider 來查詢我們所有相簿和每個相簿內的所有媒體,查詢之後我們用一個列表儲存起來,同時從設定中過濾掉不符合條件限制的媒體,一併在加入列表之前過濾掉不符條件的媒體。

ContentProvider 的查詢方法類似 SQL (SELECT 欄位 FROM 位置 WHERE 條件) 的語句,你要指定查詢的欄位,我們這邊就是 selections 的變數內容,selectionArgs 就是我們 selections 裡面有一些數值要帶入 (selections 裡面的問號),projections 則是查詢出來後對應到的欄位名稱,用來取出該欄位的數值用的,其它用法可以參考官網文件

再來就是 RxJava 的資料流走向,我們多宣告了一個 BehaviorSubject,當 fetchAlbums() 查詢完資料後,會透過這個 Subject 去發布資料變更通知給 getAlbums(),所以訂閱者可以呼叫完 fetchAlbums() 去強制更新後,再訂閱 getAlbums() 來取得更新資料。

ViewModel

class GalleryViewModel : ViewModel(), KoinComponent {
var setting: AlbumSetting? = null
val multipleSelectMedia = BehaviorSubject.createDefault<MutableList<Media>>(mutableListOf())
val singleSelectMedia = PublishSubject.create<Media>()
val currentAlbumItem = BehaviorSubject.create<AlbumItem>()
private val albumRepo: AlbumRepo by inject()
fun loadAlbums(): Completable {
return albumRepo.fetchAlbums(setting)
.doOnComplete { selectAlbum(albumRepo.getAlbumItemSync(ALL_MEDIA_ALBUM_NAME, setting)!!) }
}
fun getAlbums(): Observable<List<AlbumItem>> = albumRepo.getAlbums(setting)
fun selectAlbum(album: AlbumItem) {
currentAlbumItem.onNext(album)
}
fun selectMedia(media: Media) {
if (true == setting?.multipleSelection) {
multipleSelectMedia.value?.run {
if (this.contains(media))
this.remove(media)
else {
setting?.maxSelection?.let {
if (this.size < it)
this.add(media)
} ?: kotlin.run {
this.add(media)
}
}
multipleSelectMedia.onNext(this)
}
} else {
singleSelectMedia.onNext(media)
}
}
fun isSelect(media: Media): Boolean = multipleSelectMedia.value!!.contains(media)
}
view raw GalleryViewModel.kt hosted with ❤ by GitHub

ViewModel 宣告了儲存目前選擇媒體和相簿的 Subject,讓 View 可以訂閱變更,當有任何資料變更時,介面可以有對應的變動。 當目前選擇的媒體有變更時,單選相簿就會回傳該媒體,而多選相簿的時候則會更新按鈕顯示目前已選的數目。

View

這邊就實作單純的 UI 介面、Adapter 和事件,View 都從 ViewModel 訂閱資料流來做對應的變更,可以參考下列連結程式碼,這邊就不特別說明。

結語

完整程式碼就參考下面連結(歡迎給星 Star鼓勵、fork 或者開 issue 給予回饋或提問):https://github.com/enginebai/GalleryEngine

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Google photo

You are commenting using your Google account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s

Blog at WordPress.com.

Up ↑