哈嘍,大家好,好久不見(jiàn)了,很久沒(méi)有更新 Android 方面的技術(shù)文章了,最近在忙公司的 AR 類的新產(chǎn)品,其中涉及到本地圖片和視頻的選擇和上傳功能。至于為什么不用系統(tǒng)提供的圖片和視頻選擇器,原因你懂的,系統(tǒng)提供的選擇器只能通過(guò) Intent 方式去獲取,這意味著需要離開(kāi)當(dāng)前頁(yè)面前往系統(tǒng)的媒體庫(kù),選擇完畢后在onActivityResult 方法中拿到結(jié)果。這顯然存在很多弊端:
- UI的定制化很差
- 需要離開(kāi)當(dāng)前頁(yè)面,體驗(yàn)不好
- 不同機(jī)型可能會(huì)出現(xiàn)各種問(wèn)題
- 系統(tǒng)選擇器并不支持多選功能
?其實(shí),我們最希望的是拿到手機(jī)中的圖片和視頻數(shù)據(jù),至于UI的繪制和交互細(xì)節(jié)都由我們自己來(lái)定制。你說(shuō)你想用 ListView 或者 RecyclerView 來(lái)展示所有圖片和視頻,ok,當(dāng)然可以,那是你的自由!讓我們先來(lái)看一下最終實(shí)現(xiàn)的效果圖吧:


不要直接一看效果圖以為還是前往的另一個(gè)頁(yè)面,那和其他圖片選擇器有什么分別?客官先別急,這里的效果圖只是為了美觀而已,反正數(shù)據(jù)給你了,想怎么安排UI就看你們?cè)O(shè)計(jì)喵了??~,比如可以這樣:

看到這你可能會(huì)以為很復(fù)雜,其實(shí)不然,代碼量很少,而且涉及到的核心知識(shí)點(diǎn)如:獲取系統(tǒng)圖片和視頻數(shù)據(jù)、單選和多選功能,相信大家一看就明了。好了,喝口茶,且聽(tīng)我慢慢道來(lái)。
獲取手機(jī)所有圖片和視頻數(shù)據(jù)
一般地,獲取手機(jī)內(nèi)部圖片和視頻數(shù)據(jù)有兩種方式:通過(guò)遍歷文件夾獲取圖片和視頻資源,或者通過(guò)ContentResolver來(lái)獲取。雖然第一種方式拿到的圖片比較齊全,但文件遍歷操作過(guò)于耗時(shí),這里我推薦采用第二種方式。ContentResolver即內(nèi)容解析器,可以對(duì)ContentProvider中的數(shù)據(jù)庫(kù)進(jìn)行增刪改查操作,其中主要包含聯(lián)系人、短信、相冊(cè)、視頻、音頻等一系列數(shù)據(jù)。我們來(lái)看看具體獲取系統(tǒng)圖片數(shù)據(jù)實(shí)現(xiàn)代碼吧:
/**
* <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()
}
}
可以通過(guò)代碼看到,我們借助于 ContentResolver.query 方法來(lái)查詢匹配的圖片數(shù)據(jù),我們可以設(shè)置需要獲取的圖片的數(shù)據(jù)字段,如 MediaStore.Images.ImageColumns.DATA 就表示圖片存儲(chǔ)的路徑信息,其他的可以獲取的信息還有圖片ID、圖片大小、圖片類型等,大家可以參照代碼去網(wǎng)上查看具體含義,這里不再贅述。此外,系統(tǒng)還為我們存儲(chǔ)了圖片以及視頻的縮略圖數(shù)據(jù),我們?yōu)榱颂岣邎D片加載速度,可以通過(guò)獲取和展示縮略圖的形式來(lái)增強(qiáng)體驗(yàn)效果。獲取圖片縮略圖的方式采用系統(tǒng)自帶的,也比較簡(jiǎn)單,大家可以自行查看一下文檔。
另外,大家可能會(huì)發(fā)現(xiàn) ImageMediaEntity 這個(gè)類,明白人應(yīng)該很快就會(huì)知道這個(gè)數(shù)據(jù)類主要存儲(chǔ)一些圖片相關(guān)的數(shù)據(jù)。的確,這個(gè)是我個(gè)人封裝的一層針對(duì)圖片的數(shù)據(jù)類,而它還有個(gè)父類,名叫 BaseMediaEntity ,我們來(lái)看看里面都有些啥:
/**
* 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);
}
}
可以看到,這是我們抽離出的公共基類,因?yàn)閳D片和視頻等多媒體數(shù)據(jù)都有公共的數(shù)據(jù)字段id、path和size,差異性由它的子類來(lái)實(shí)現(xiàn)就OK了。至于 ImageMediaEntity 和 VideoMediaEntity 具體代碼就先省略不放了,影響篇幅長(zhǎng)度,最后面會(huì)有完整的sample代碼。
看完了本地圖片數(shù)據(jù)的獲取,自然而然就能知道視頻數(shù)據(jù)也是采用相同的方式獲取,沒(méi)錯(cuò),這里就直接上代碼了,其實(shí)實(shí)現(xiàn)方式是一樣的:
/**
* <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()
}
}
}
通過(guò)上面代碼我們可以發(fā)現(xiàn),這幾乎和獲取圖片數(shù)據(jù)的代碼一樣啊,沒(méi)錯(cuò),是幾乎一樣,但留意的人會(huì)發(fā)現(xiàn),這里我調(diào)用 ContentResolver.query 時(shí)多傳了一個(gè)selection參數(shù),它是query方法的第三個(gè)參數(shù),主要用來(lái)設(shè)置一些查詢的條件,已達(dá)到過(guò)濾功能,大家可以根據(jù)自己需要自行設(shè)置,這里我只是想拿到mp4格式的視頻數(shù)據(jù)。還有人可能會(huì)問(wèn):為什么我這里沒(méi)有獲取視頻的縮略圖數(shù)據(jù)呢?系統(tǒng)雖為我們也提供了獲取視頻縮略圖的方式,但是,并不是所有的視頻都存在視頻縮略圖,這就造成你想加載視頻的縮略圖的時(shí)候會(huì)出現(xiàn)大片空白數(shù)據(jù)問(wèn)題。同時(shí),可能會(huì)有人想借助于其他方式獲取,但主流的幾種方式都比較耗時(shí),不建議在正式項(xiàng)目中采用。其實(shí),通過(guò)查看很多優(yōu)秀的開(kāi)源視頻選擇器框架發(fā)現(xiàn),很多都采用了分批加載功能,比如手機(jī)中一共有一千個(gè)視頻數(shù)據(jù),如果一次性獲取顯然很耗時(shí),而且體驗(yàn)不好,我們可以分批獲取數(shù)據(jù),每頁(yè)100條限制,這就極大的節(jié)省了獲取數(shù)據(jù)的時(shí)間,然后再在列表滑動(dòng)到底部時(shí)加載下一批數(shù)據(jù)。這里我暫時(shí)使用的是 Glide 來(lái)加載我們的視頻數(shù)據(jù),后續(xù)會(huì)尋找更佳方案代替。
下面,我們來(lái)看看圖片視頻的多選、單選效果實(shí)現(xiàn)。用過(guò) RecyclerView 和 CheckBox 組合的開(kāi)發(fā)者都知道,RecyclerView復(fù)用性會(huì)導(dǎo)致 CheckBox 選擇狀態(tài)混亂,即onCheckChanged方法的“神秘回調(diào)”,解決方案也有很多種,網(wǎng)上有些方案沒(méi)有解決問(wèn)題的也有很多。常見(jiàn)的方案有:自定義 checkbox、通過(guò) checkbox 的 onclick 事件來(lái)處理選中狀態(tài),adapter數(shù)據(jù)刷新或者 checkbox 每次選中前移除上次的選中事件等等,我只選兩種進(jìn)行簡(jiǎn)單說(shuō)明。為了節(jié)省時(shí)間,我這里將實(shí)現(xiàn)圖片多選和視頻的單選功能,它們 checkbox 問(wèn)題的處理各自采用不同的方式。
我們先來(lái)看看圖片多選功能實(shí)現(xiàn),前方高能,代碼來(lái)襲:
/**
* <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>
/** 存儲(chǔ)選中的圖片 */
private var chosenImages : HashMap<Int, String> = HashMap()
/** 存儲(chǔ)選中的狀態(tài) */
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)
/** 通過(guò)map存儲(chǔ)checkbox選中狀態(tài),放置rv復(fù)用機(jī)制導(dǎo)致的狀態(tài)混亂狀態(tài) */
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
// 將當(dāng)前選中的圖片存入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)
/** 自定義的本地視頻選擇監(jiān)聽(tīng)器 */
interface OnLocalImageSelectListener{
fun onImageSelect(view: View, position:Int, images: List<String>)
}
}
可以看到,我們這里通過(guò) HashMap 存儲(chǔ)已選中 CheckBox 的狀態(tài),并在 checkBox.setOnCheckedChangeListener 前移除上一次 CheckBox 的監(jiān)聽(tīng)器,然后再在 onCheckChanged 方法中判斷當(dāng)前選中狀態(tài),如果選中,那么map存入 CheckCox 選中狀態(tài),否則移除當(dāng)前位置的value數(shù)據(jù),這樣,就解決了 滑動(dòng)RecyclerView 后 CheckBox 狀態(tài)混亂問(wèn)題。同時(shí),我們用 Map 存儲(chǔ)每個(gè)選中后的圖片路徑信息,然后在自己的回調(diào)中返回這些選中的圖片,最后在 Activity 或者 Fragment 中展示就可以了。
實(shí)現(xiàn)了圖片的多選效果,我們就來(lái)看看視頻單選的實(shí)現(xiàn)吧:
/**
* <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){
//先取消上個(gè)item的勾選狀態(tài)
checkState.remove(mSelectedPosition)
notifyItemChanged(mSelectedPosition)
//設(shè)置新Item的勾選狀態(tài)
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)
/** 自定義的本地視頻選擇監(jiān)聽(tīng)器 */
interface OnLocalVideoSelectListener{
fun onVideoSelect(view:View, position:Int)
}
}
此處主要利用 checkBox.setOnClickListener 以及 HashSet 來(lái)處理單選事件,先通過(guò)一個(gè)mSelectedPosition字段來(lái)保存當(dāng)前選中的 Checkbox 的位置,然后在點(diǎn)擊事件中進(jìn)行分情況處理,由于這里是單選,所以在設(shè)置新的選中狀態(tài)前移除上一次的CheckBox 選中狀態(tài)。代碼沒(méi)什么復(fù)雜的,主要是一種思路,具體邏輯理清楚就好了,這里大家可以自己琢磨一下。
Github傳送門:https://github.com/Moosphan/LocalVideoImage-selector
歡迎大家提出改進(jìn)意見(jiàn)或者幫助我一起完善下去~