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 选中状态。代码没什么复杂的,主要是一种思路,具体逻辑理清楚就好了,这里大家可以自己琢磨一下。

Github传送门:https://github.com/Moosphan/LocalVideoImage-selector

欢迎大家提出改进意见或者帮助我一起完善下去~

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 159,117评论 4 362
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,328评论 1 293
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,839评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 44,007评论 0 206
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,384评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,629评论 1 219
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,880评论 2 313
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,593评论 0 198
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,313评论 1 243
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,575评论 2 246
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,066评论 1 260
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,392评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,052评论 3 236
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,082评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,844评论 0 195
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,662评论 2 274
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,575评论 2 270

推荐阅读更多精彩内容