Android簡單實現本地圖片和視訊選擇器功能
哈嘍,大家好,好久不見了,很久沒有更新 Android 方面的技術文章了,最近在忙公司的 AR 類的新產品,其中涉及到本地圖片和視訊的選擇和上傳功能。至於為什麼不用系統提供的圖片和視訊選擇器,原因你懂的,系統提供的選擇器只能通過 Intent 方式去獲取,這意味著需要離開當前頁面前往系統的媒體庫,選擇完畢後在onActivityResult 方法中拿到結果。這顯然存在很多弊端:
- UI的定製化很差
- 需要離開當前頁面,體驗不好
- 不同機型可能會出現各種問題
- 系統選擇器並不支援多選功能
其實,我們最希望的是拿到手機中的圖片和視訊資料,至於UI的繪製和互動細節都由我們自己來定製。你說你想用 ListView 或者 RecyclerView 來展示所有圖片和視訊,ok,當然可以,那是你的自由!讓我們先來看一下最終實現的效果圖吧:

視訊選擇器效果圖

圖片選擇器效果圖
不要直接一看效果圖以為還是前往的另一個頁面,那和其他圖片選擇器有什麼分別?客官先別急,這裡的效果圖只是為了美觀而已,反正資料給你了,想怎麼安排UI就看你們設計喵了:smile:~,比如可以這樣:

定製化UI效果圖
看到這你可能會以為很複雜,其實不然,程式碼量很少,而且涉及到的核心知識點如:獲取系統圖片和視訊資料、單選和多選功能,相信大家一看就明瞭。好了,喝口茶,且聽我慢慢道來。
獲取手機所有圖片和視訊資料
一般地,獲取手機內部圖片和視訊資料有兩種方式:通過遍歷資料夾獲取圖片和視訊資源,或者通過ContentResolver來獲取。雖然第一種方式拿到的圖片比較齊全,但檔案遍歷操作過於耗時,這裡我推薦採用第二種方式。ContentResolver即內容解析器,可以對ContentProvider中的資料庫進行增刪改查操作,其中主要包含聯絡人、簡訊、相簿、視訊、音訊等一系列資料。我們來看看具體獲取系統圖片資料實現程式碼吧:
/** * <pre> *@author moosphon(about me: <a>https://github.com/Moosphan<a/>) *@date2018/09/16 *@descget 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/>) *@date2018/09/16 *@descget 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
選中狀態。程式碼沒什麼複雜的,主要是一種思路,具體邏輯理清楚就好了,這裡大家可以自己琢磨一下。
Github傳送門: ofollow,noindex">https://github.com/Moosphan/LocalVideoImage-selector
歡迎大家提出改進意見或者幫助我一起完善下去~