Android簡單實現本地圖片和視訊選擇器功能
哈嘍,大家好,好久不見了,很久沒有更新 Android 方面的技術文章了,最近在忙公司的 AR 類的新產品,其中涉及到本地圖片和視訊的選擇和上傳功能。至於為什麼不用系統提供的圖片和視訊選擇器,原因你懂的,系統提供的選擇器只能通過 Intent 方式去獲取,這意味著需要離開當前頁面前往系統的媒體庫,選擇完畢後在onActivityResult 方法中拿到結果。這顯然存在很多弊端:
- UI的定製化很差
- 需要離開當前頁面,體驗不好
- 不同機型可能會出現各種問題
- 系統選擇器並不支援多選功能
其實,我們最希望的是拿到手機中的圖片和視訊資料,至於UI的繪製和互動細節都由我們自己來定製。你說你想用 ListView 或者 RecyclerView 來展示所有圖片和視訊,ok,當然可以,那是你的自由!讓我們先來看一下最終實現的效果圖吧:
不要直接一看效果圖以為還是前往的另一個頁面,那和其他圖片選擇器有什麼分別?客官先別急,這裡的效果圖只是為了美觀而已,反正資料給你了,想怎麼安排UI就看你們設計喵了?~,比如可以這樣:
看到這你可能會以為很複雜,其實不然,程式碼量很少,而且涉及到的核心知識點如:獲取系統圖片和視訊資料、單選和多選功能,相信大家一看就明瞭。好了,喝口茶,且聽我慢慢道來。
獲取手機所有圖片和視訊資料
一般地,獲取手機內部圖片和視訊資料有兩種方式:通過遍歷資料夾獲取圖片和視訊資源,或者通過ContentResolver來獲取。雖然第一種方式拿到的圖片比較齊全,但檔案遍歷操作過於耗時,這裡我推薦採用第二種方式。ContentResolver即內容解析器,可以對ContentProvider中的資料庫進行增刪改查操作,其中主要包含聯絡人、簡訊、相簿、視訊、音訊等一系列資料。我們來看看具體獲取系統圖片資料實現程式碼吧:
/** * <pre> * @author moosphon (about me: <a>https://github.com/Moosphan<a/>) * @date 2018/09/16 * @desc get all pictures of the phone. * <pre/> */ fun getLocalPictures(mContext: Context?): List<ImageMediaEntity>? { val images = ArrayList<ImageMediaEntity>() val resolver = mContext?.contentResolver var cursor: Cursor? = null queryImageThumbnails(resolver!!, arrayOf(MediaStore.Images.Thumbnails.IMAGE_ID, MediaStore.Images.Thumbnails.DATA)) try { cursor = resolver.query(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, arrayOf(MediaStore.Images.ImageColumns.DATA, MediaStore.Images.ImageColumns._ID, MediaStore.Images.ImageColumns.SIZE, MediaStore.Images.ImageColumns.MIME_TYPE), null, null, null) return if (cursor == null || !cursor.moveToFirst()) { null } else { do { val picPath = cursor.getString(cursor.getColumnIndex(MediaStore.Images.Media.DATA)) val id = cursor.getString(cursor.getColumnIndex(MediaStore.Images.Media._ID)) val size = cursor.getString(cursor.getColumnIndex(MediaStore.Images.Media.SIZE)) val mimeType = cursor.getString(cursor.getColumnIndex(MediaStore.Images.Media.MIME_TYPE)) val image = ImageMediaEntity.Builder(id, picPath) .setMimeType(mimeType) .setSize(size) .setThumbnailPath(mThumbnailMap?.get(id)) .build() images.add(image) mThumbnailMap = null }while (cursor.moveToNext()) return images } } finally { if (cursor != null) { cursor.close() } } } /** * search for thumbnails for local images * * @author moosphon */ private fun queryImageThumbnails(cr: ContentResolver, projection: Array<String>) { var cur: Cursor? = null try { cur = MediaStore.Images.Thumbnails.queryMiniThumbnails(cr, MediaStore.Images.Thumbnails.EXTERNAL_CONTENT_URI, MediaStore.Images.Thumbnails.MINI_KIND, projection) if (cur != null && cur.moveToFirst()) { do { val imageId = cur.getString(cur.getColumnIndex(MediaStore.Images.Thumbnails.IMAGE_ID)) val imagePath = cur.getString(cur.getColumnIndex(MediaStore.Images.Thumbnails.DATA)) mThumbnailMap = mapOf(imageId to imagePath) } while (cur.moveToNext() && !cur.isLast) } } finally { cur?.close() } }
可以通過程式碼看到,我們藉助於 ContentResolver.query
方法來查詢匹配的圖片資料,我們可以設定需要獲取的圖片的資料欄位,如 MediaStore.Images.ImageColumns.DATA
就表示圖片儲存的路徑資訊,其他的可以獲取的資訊還有圖片ID、圖片大小、圖片型別等,大家可以參照程式碼去網上檢視具體含義,這裡不再贅述。此外,系統還為我們儲存了圖片以及視訊的縮圖資料,我們為了提高圖片載入速度,可以通過獲取和展示縮圖的形式來增強體驗效果。獲取圖片縮圖的方式採用系統自帶的,也比較簡單,大家可以自行檢視一下文件。
另外,大家可能會發現 ImageMediaEntity
這個類,明白人應該很快就會知道這個資料類主要儲存一些圖片相關的資料。的確,這個是我個人封裝的一層針對圖片的資料類,而它還有個父類,名叫 BaseMediaEntity
,我們來看看裡面都有些啥:
/**
* base entity data for local media
*
* @author Moosphon
*/
public abstract class BaseMediaEntity implements Parcelable{
protected enum TYPE{
IMAGE,
VIDEO
}
protected String path;
protected String id;
protected String size;
public Boolean isSelected = false;
public BaseMediaEntity() {
}
public BaseMediaEntity(String path, String id) {
this.path = path;
this.id = id;
}
public BaseMediaEntity(Parcel in) {
this.path = in.readString();
this.id = in.readString();
this.size = in.readString();
}
public abstract TYPE getMediaType();
public String getPath() {
return path;
}
public void setPath(String path) {
this.path = path;
}
public String getId() {
return id;
}
public void setId(String id) {
this.id = id;
}
public String getSize() {
return size;
}
public void setSize(String size) {
this.size = size;
}
public Boolean getSelected() {
return isSelected;
}
public void setSelected(Boolean selected) {
isSelected = selected;
}
@Override
public int describeContents() {
return 0;
}
@Override
public void writeToParcel(Parcel dest, int flags) {
dest.writeString(this.path);
dest.writeString(this.id);
dest.writeString(this.size);
}
}
可以看到,這是我們抽離出的公共基類,因為圖片和視訊等多媒體資料都有公共的資料欄位id、path和size,差異性由它的子類來實現就OK了。至於 ImageMediaEntity
和 VideoMediaEntity
具體程式碼就先省略不放了,影響篇幅長度,最後面會有完整的sample程式碼。
看完了本地圖片資料的獲取,自然而然就能知道視訊資料也是採用相同的方式獲取,沒錯,這裡就直接上程式碼了,其實實現方式是一樣的:
/**
* <pre>
* @author moosphon (about me: <a>https://github.com/Moosphan<a/>)
* @date 2018/09/16
* @desc get all videos of the phone.
* <pre/>
*/
fun getLocalVideos(mContext: Context?) : List<VideoMediaEntity>?{
val videos = ArrayList<VideoMediaEntity>()
val resolver = mContext?.contentResolver
var cursor: Cursor? = null
try {
cursor = resolver?.query(MediaStore.Video.Media.EXTERNAL_CONTENT_URI,
arrayOf(MediaStore.Images.ImageColumns.DATA,
MediaStore.Video.Media._ID,
MediaStore.Video.Media.DISPLAY_NAME,
MediaStore.Video.Media.RESOLUTION,
MediaStore.Video.Media.SIZE,
MediaStore.Video.Media.DURATION,
MediaStore.Video.Media.DATE_MODIFIED),
MediaStore.Video.Media.MIME_TYPE + "=?", arrayOf("video/mp4"), null)
return if (cursor == null || !cursor.moveToFirst()) {
null
} else {
while (cursor.moveToNext()){
// video path
val path = cursor.getString(cursor.getColumnIndexOrThrow(MediaStore.Video.Media.DATA))
// video id
val id = cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.Video.Media._ID))
// video display name
val name = cursor.getString(cursor.getColumnIndexOrThrow(MediaStore.Video.Media.DISPLAY_NAME))
// video resolution
val resolution = cursor.getString(cursor.getColumnIndexOrThrow(MediaStore.Video.Media.RESOLUTION))
// video size
val size = cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.Video.Media.SIZE))
// video duration
val duration = cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.Video.Media.DURATION))
val date = cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.Video.Media.DATE_MODIFIED))
val video = VideoMediaEntity.Builder(id.toString(), path)
.setTitle(name)
.setDateTaken(date.toString())
.setDuration(duration.toString())
.setSize(size.toString())
.build()
videos.add(video)
}
return videos
}
} finally {
if (cursor != null) {
cursor.close()
}
}
}
通過上面程式碼我們可以發現,這幾乎和獲取圖片資料的程式碼一樣啊,沒錯,是幾乎一樣,但留意的人會發現,這裡我呼叫 ContentResolver.query
時多傳了一個selection引數,它是query方法的第三個引數,主要用來設定一些查詢的條件,已達到過濾功能,大家可以根據自己需要自行設定,這裡我只是想拿到mp4格式的視訊資料。還有人可能會問:為什麼我這裡沒有獲取視訊的縮圖資料呢?系統雖為我們也提供了獲取視訊縮圖的方式,但是,並不是所有的視訊都存在視訊縮圖,這就造成你想載入視訊的縮圖的時候會出現大片空白資料問題。同時,可能會有人想借助於其他方式獲取,但主流的幾種方式都比較耗時,不建議在正式專案中採用。其實,通過檢視很多優秀的開源視訊選擇器框架發現,很多都採用了分批載入功能,比如手機中一共有一千個視訊資料,如果一次性獲取顯然很耗時,而且體驗不好,我們可以分批獲取資料,每頁100條限制,這就極大的節省了獲取資料的時間,然後再在列表滑動到底部時載入下一批資料。這裡我暫時使用的是 Glide 來載入我們的視訊資料,後續會尋找更佳方案代替。
下面,我們來看看圖片視訊的多選、單選效果實現。用過 RecyclerView 和 CheckBox 組合的開發者都知道,RecyclerView複用性會導致 CheckBox 選擇狀態混亂,即onCheckChanged方法的“神祕回撥”,解決方案也有很多種,網上有些方案沒有解決問題的也有很多。常見的方案有:自定義 checkbox、通過 checkbox 的 onclick 事件來處理選中狀態,adapter資料重新整理或者 checkbox 每次選中前移除上次的選中事件等等,我只選兩種進行簡單說明。為了節省時間,我這裡將實現圖片多選和視訊的單選功能,它們 checkbox 問題的處理各自採用不同的方式。
我們先來看看圖片多選功能實現,前方高能,程式碼來襲:
/**
* <pre>
* author: moosphon
* date: 2018/09/16
* desc: 本地視訊的介面卡
* <pre/>
*/
class LocalImageAdapter: RecyclerView.Adapter<LocalImageAdapter.LocalImageViewHolder>() {
lateinit var context: Context
private var mSelectedPosition: Int = 0
var listener: OnLocalImageSelectListener? = null
private lateinit var data: List<ImageMediaEntity>
/** 儲存選中的圖片 */
private var chosenImages : HashMap<Int, String> = HashMap()
/** 儲存選中的狀態 */
private var checkStates : HashMap<Int, Boolean> = HashMap()
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): LocalImageViewHolder {
context = parent.context
val view = LayoutInflater.from(parent.context).inflate(R.layout.rv_item_local_video_layout, parent, false)
return LocalImageViewHolder(view)
}
override fun getItemCount(): Int {
return data.size
}
override fun onBindViewHolder(holder: LocalImageViewHolder, position: Int) {
val thumbnailImage: ImageView = holder.view.find(R.id.local_video_item_thumbnail)
val checkBox: CheckBox = holder.view.find(R.id.local_video_item_cb)
/** 通過map儲存checkbox選中狀態,放置rv複用機制導致的狀態混亂狀態 */
checkBox.setOnCheckedChangeListener(null)
checkBox.isChecked = checkStates.containsKey(position)
val options = RequestOptions()
.diskCacheStrategy(DiskCacheStrategy.NONE)
.error(R.mipmap.ic_launcher)
.placeholder(R.mipmap.ic_launcher)
Glide.with(context)
.asBitmap()
.load(data[position].thumbnailPath)
.apply(options)
.thumbnail(0.2f)
.into(thumbnailImage)
checkBox.setOnCheckedChangeListener{
_, isChecked ->
if (isChecked){
checkStates[position] = true
// 將當前選中的圖片存入map
chosenImages[position] = data[position].path
}else{
// 從選中列表中移除
checkStates.remove(position)
chosenImages.remove(position)
}
if (listener != null){
val selectedImages = ArrayList<String>()
for (v in chosenImages.values){
selectedImages.add(v)
}
listener!!.onImageSelect(holder.view, position, selectedImages)
}
}
}
fun setData(data: List<ImageMediaEntity>){
this.data = data
for (i in 0 until data.size) {
if (data[i].isSelected) {
mSelectedPosition = i
}
}
}
class LocalImageViewHolder(val view: View) : RecyclerView.ViewHolder(view)
/** 自定義的本地視訊選擇監聽器 */
interface OnLocalImageSelectListener{
fun onImageSelect(view: View, position:Int, images: List<String>)
}
}
可以看到,我們這裡通過 HashMap 儲存已選中 CheckBox
的狀態,並在 checkBox.setOnCheckedChangeListener
前移除上一次 CheckBox
的監聽器,然後再在 onCheckChanged
方法中判斷當前選中狀態,如果選中,那麼map存入 CheckCox
選中狀態,否則移除當前位置的value資料,這樣,就解決了 滑動RecyclerView
後 CheckBox
狀態混亂問題。同時,我們用 Map
儲存每個選中後的圖片路徑資訊,然後在自己的回撥中返回這些選中的圖片,最後在 Activity
或者 Fragment
中展示就可以了。
實現了圖片的多選效果,我們就來看看視訊單選的實現吧:
/**
* <pre>
* author: moosphon
* date: 2018/09/16
* desc: 本地視訊的介面卡
* <pre/>
*/
class LocalVideoAdapter: RecyclerView.Adapter<LocalVideoAdapter.LocalVideoViewHolder>() {
lateinit var context: Context
private var mSelectedPosition: Int = -1
var listener: OnLocalVideoSelectListener? = null
private lateinit var data: List<VideoMediaEntity>
private var checkState: HashSet<Int> = HashSet()
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): LocalVideoViewHolder {
context = parent.context
val view = LayoutInflater.from(parent.context).inflate(R.layout.rv_item_local_video_layout, parent, false)
return LocalVideoViewHolder(view)
}
override fun getItemCount(): Int {
return data.size
}
override fun onBindViewHolder(holder: LocalVideoViewHolder, position: Int) {
val thumbnailImage: ImageView = holder.view.find(R.id.local_video_item_thumbnail)
val checkBox: CheckBox = holder.view.find(R.id.local_video_item_cb)
checkBox.isChecked = checkState.contains(position)
val options = RequestOptions()
.diskCacheStrategy(DiskCacheStrategy.NONE)
.error(R.mipmap.ic_launcher)
.placeholder(R.mipmap.ic_launcher)
Glide.with(context)
.asBitmap()
.load(data[position].path)
.apply(options)
.thumbnail(0.2f)
.into(thumbnailImage)
checkBox.setOnClickListener {
if (mSelectedPosition!=position){
//先取消上個item的勾選狀態
checkState.remove(mSelectedPosition)
notifyItemChanged(mSelectedPosition)
//設定新Item的勾選狀態
mSelectedPosition = position
checkState.add(mSelectedPosition)
notifyItemChanged(mSelectedPosition)
}else if(checkBox.isChecked){
checkState.add(position)
}else if(!checkBox.isChecked){
checkState.remove(position)
}
if (listener != null){
listener!!.onVideoSelect(holder.view, position)
}
}
}
fun setData(data: List<VideoMediaEntity>){
this.data = data
for (i in 0 until data.size) {
if (data[i].isSelected) {
mSelectedPosition = i
}
}
}
class LocalVideoViewHolder(val view: View) : RecyclerView.ViewHolder(view)
/** 自定義的本地視訊選擇監聽器 */
interface OnLocalVideoSelectListener{
fun onVideoSelect(view:View, position:Int)
}
}
此處主要利用 checkBox.setOnClickListener
以及 HashSet
來處理單選事件,先通過一個mSelectedPosition欄位來儲存當前選中的 Checkbox
的位置,然後在點選事件中進行分情況處理,由於這裡是單選,所以在設定新的選中狀態前移除上一次的CheckBox
選中狀態。程式碼沒什麼複雜的,主要是一種思路,具體邏輯理清楚就好了,這裡大家可以自己琢磨一下。
歡迎大家提出改進意見或者幫助我一起完善下去~