1. 程式人生 > >Android簡單實現本地圖片和視訊選擇器功能

Android簡單實現本地圖片和視訊選擇器功能

哈嘍,大家好,好久不見了,很久沒有更新 Android 方面的技術文章了,最近在忙公司的 AR 類的新產品,其中涉及到本地圖片和視訊的選擇和上傳功能。至於為什麼不用系統提供的圖片和視訊選擇器,原因你懂的,系統提供的選擇器只能通過 Intent 方式去獲取,這意味著需要離開當前頁面前往系統的媒體庫,選擇完畢後在onActivityResult 方法中拿到結果。這顯然存在很多弊端:

  • UI的定製化很差
  • 需要離開當前頁面,體驗不好
  • 不同機型可能會出現各種問題
  • 系統選擇器並不支援多選功能

​其實,我們最希望的是拿到手機中的圖片和視訊資料,至於UI的繪製和互動細節都由我們自己來定製。你說你想用 ListView 或者 RecyclerView 來展示所有圖片和視訊,ok,當然可以,那是你的自由!讓我們先來看一下最終實現的效果圖吧:

視訊選擇器效果圖 圖片選擇器效果圖

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

定製化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了。至於 ImageMediaEntityVideoMediaEntity 具體程式碼就先省略不放了,影響篇幅長度,最後面會有完整的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資料,這樣,就解決了 滑動RecyclerViewCheckBox 狀態混亂問題。同時,我們用 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 選中狀態。程式碼沒什麼複雜的,主要是一種思路,具體邏輯理清楚就好了,這裡大家可以自己琢磨一下。

歡迎大家提出改進意見或者幫助我一起完善下去~