android 视频和图片预览

上篇文章(https://www.jianshu.com/p/7c3f1359edbd)介绍了如何快速获取手机中的视频和图片,本篇文章将接着上篇继续阐述,如何实现视频及图片的预览。具体代码,请参考https://github.com/life2smile/PhotoAlbum.git

本篇文章主要分为三个部分,第一部分是阐述要实现的效果;第一部分是图片预览实现;第二部分是视频预览实现。

一、实现的效果

需求

1、预览页面是可滚动的,即支持在预览页面预览当前图片的同时,可以滑动预览下一张或前一张的图片或者视频。
2、预览页面既要支持图片预览也要支持视频预览。
3、用户在点击宫格中任一图片或者视频跳转预览页面的时候,预览页要保证正确展示该图片或视频,而不是都展示第一张图片或者视频。

方案

对于第一个问题,预览页面是可滑动的,大家自然而然的就想到了viewpager,确实是这样,本篇文章也是采用viewpager进行实现(ViewFipper也是个不错的选择)。最关键的是与之对应的adapter怎么选择。因为手机中的图片和视频有可能有上千张甚至更多,因此要重点考虑下内存的使用,避免因预览而占用过多的内存,以引起卡顿什么被系统kill。为此,这里采用了继承FragmentStatePagerAdapter的实现方案来实现。FragmentStatePagerAdapter在页面不可见的时候会及时回收对应的页面,以避免消耗大量内存。注意,采用FragmentStatePagerAdapter,其承载视图的页面类型显然要是Fragment。

对于第二个问题,预览页面既要支持图片预览,又要支持视频预览,显然二者的视图是不一样的,因此这里提供两种不同类型的Fragment,一个是ImgeFragment;一个是VideoFragment,从名字很容易看出:ImgeFragment用于图片预览,VideoFragment用于视频预览。

对于第三个问题,根据上篇文章,用户首先会看到宫格视图,然后随便点击某一个视图即可预览,因此跳转的预览页面的时候需要明确告诉预览页面,要预览的当前页面的position,以正确加载。

二、滑动实现

这里对于滑动的实现主要介绍下Adapter对应的实现,其他实现可参考git上的项目源码。

class PreviewAdapter(fragmentManager: FragmentManager) : FragmentStatePagerAdapter(fragmentManager) {//这里注意我们自定义的适配器继承自FragmentStatePagerAdapter
    private var mPreviewDataPathList: MutableList<PreviewData> = ArrayList()

    constructor(fragmentManager: FragmentManager, list: MutableList<PreviewData>?) : this(fragmentManager) {
        list?.let {
            mPreviewDataPathList.addAll(list)
        }
    }

    override fun getItem(position: Int): Fragment {
        val mediaData: PreviewData = mPreviewDataPathList[position]

        mediaData.takeIf {//根据预览的文件类型来展示相应类型的视图,这里是视频预览页面
            it.isVideo()
        }?.let {
            return VideoFragment.newInstance(it)
        }

        mediaData.takeIf {//图片预览页面
            it.isImage()
        }?.let {
            return ImageFragment.newInstance(it)
        }
        return Fragment()//默认返回一个空页面。这种情况理论不会发生。
    }

    override fun getCount(): Int {
        return mPreviewDataPathList.size
    }
}

从代码可以看出,适配器会根据不同的文件类型来加载不同的页面,进而完成可滑动并同时支持图片和视频预览的功能。

三、实现图片预览

图片预览的思路很简单,拿到图片文件路径之后,加载到ImageView控件中即可。

class ImageFragment : Fragment() {
    private var mFilePath: String? = null

    companion object {//kotlin伴随对象,这里面的方法可以理解为java中的静态方法
        fun newInstance(previewData: PreviewData): ImageFragment {//fragment的标准实现,用于接收外部参数
            val fragment: ImageFragment = ImageFragment()
            val bundle = Bundle()
            bundle.putString("filePath", previewData.filePath)//图片路径
            fragment.arguments = bundle
            return fragment
        }
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        arguments?.let {
            mFilePath = it.getString("filePath")
        }
    }

    override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
        val imageView = ImageView(context)//这里直接通过代码生成了一个ImageView,和在xml中写实现的效果一样。
        val margin: Int = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 10f, resources.displayMetrics).toInt()

        imageView.setPadding(margin, 0, margin, 0)

        mFilePath?.let {
//这里对预览的图片宽高进行处理:以屏幕宽度为准进行图片裁剪
            val options = BitmapFactory.Options()
            options.inJustDecodeBounds = true
            BitmapFactory.decodeFile(mFilePath, options)

            val screenW = resources.displayMetrics.widthPixels
            val screenH = resources.displayMetrics.heightPixels

            val resizeW = if (options.outWidth > screenW) screenW else options.outWidth
            val resizeH = if (options.outHeight > screenH) screenH else options.outHeight

            mFilePath?.let {
                imageView.setImageBitmap(ImageResizeUtil.resize(it, resizeW, resizeH))
            }
        }
        return imageView
    }
}

四、视频预览

要解决的问题

本demo采用的视频预览组件为VideoView,视频预览有几个难点要注意
1、VideoView本身没有提供预览第一帧的功能,因此展示的时候会黑屏
2、VideoView也没有提供可视化的播放和暂停icon,需要自己展示
3、VideoView在调用播放接口(start)到正式展示视频第一帧的过程,有一段时间差,这段时间也是黑屏的状态。
4、VideoView默认无法控制其尺寸,也就是videoview会依据其视频源的尺寸进行默认布局。这个需要处理
5、要考虑到纵向视频以及横向视频的预览播放问题。
6、在用户滑动切换的时候要及时停止

方案

针对问题1,我们只需要在videoview上覆盖一个ImageView作为视频第一帧即可,而ImageView的图片来源及时先前获取到的视频缩略图
针对问题2,只需要提供一个播放icon,监听其点击事件,调用播放接口即可
针对问题3,这个过程实际上有监听的api,通过监听准备状态再将视频第一帧隐藏即可,但是这个方法在某些机型上存在bug,即准备完成一次后就不在回调监听api,这个会在代码进行注释解释。
针对问题4和5,需要我们自定义VideoView,以精确控制控件的大小。
针对问题6,我们只需要监听fragment的显示状态即可

具体实现代码如下:
首先是VideoFragment代码:

class VideoFragment : Fragment() {
    private var mFilePath: String? = null
    private var mThumbnailPath: String? = null

    private var mVideoView: ResizeVideoView? = null//针对问题4和5自定义VideoView,具体代码会在下面给到
    private var mPlayImg: ImageView? = null
    private var mFirstFrameImg: ImageView? = null

    private var mPrepared: Boolean = false//标识是否已经准备过了一次,用于解决在问题3方案中某些机型存在bug

    companion object {
        fun newInstance(previewData: PreviewData): Fragment {
            val fragment = VideoFragment()
            val bundle = Bundle()
            bundle.putString("filePath", previewData.filePath)
            bundle.putString("thumbNailPath", previewData.thumbnailPath)
            fragment.arguments = bundle
            return fragment
        }
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        arguments?.let {
            mFilePath = it.getString("filePath")
            mThumbnailPath = it.getString("thumbNailPath")
        }
    }

    override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
        val view = inflater.inflate(R.layout.fragment_preview_video, container, false)

        initView(view)

        resizeVideo(view)

        setListener()

        return view;
    }

    private fun setListener() {
        mPlayImg?.setOnClickListener {//播放按钮
            mPlayImg?.visibility = View.GONE
            if (mPrepared) {//如果已经准备过一次,直接隐藏预览视频帧即可
                mFirstFrameImg?.visibility = View.GONE
            }
            mVideoView?.start()//播放视频,这个方法调用之后,到真正播放之前会有延时,期间会产生黑屏,因此需要监听setOnPreparedListener接口,见下面代码
        }

        mVideoView?.setOnPreparedListener {//视频播准备完毕后,会回调该接口
            it.setOnInfoListener(MediaPlayer.OnInfoListener { mp, what, extra ->
                if (what == MediaPlayer.MEDIA_INFO_VIDEO_RENDERING_START) {//标识准备完毕
                    mPrepared = true
                    mFirstFrameImg?.visibility = View.GONE//隐藏第一帧,开始展示视频播放
                }
                return@OnInfoListener true
            })
        }

        mVideoView?.setOnCompletionListener {
            pause()//视频播放完毕后及时停止
        }
    }

    private fun initView(view: View) {
        mVideoView = view.findViewById(R.id.fragment_preview_video)
        mFirstFrameImg = view.findViewById(R.id.fragment_preview_first_frame)
        mPlayImg = view.findViewById(R.id.fragment_preview_play_img)

        mThumbnailPath?.let {
            mFirstFrameImg?.setImageURI(Uri.fromFile(File(it)))
        }

        mFilePath?.let {
            mVideoView?.setVideoPath(mFilePath)
        }
    }

//重新布局VieoView
    private fun resizeVideo(view: View) {
        val sizeArr: IntArray = videoReSize()
//这里需要注意,考虑到父viewgroup可能会有padding,所以这里处理掉,以避免无法生效
        mVideoView?.resizeVideoView(sizeArr[0] - (view.paddingLeft + view.paddingRight),
                sizeArr[1] - (view.paddingTop + view.paddingBottom))

        mFirstFrameImg?.let {
            val layoutParams = FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT)
            layoutParams.width = sizeArr[0]
            layoutParams.height = sizeArr[1]
            layoutParams.gravity = Gravity.CENTER
            it.layoutParams = layoutParams
        }
    }

//监听fragment的显示状态,当不可见时需要停止视频播放
    override fun setUserVisibleHint(isVisibleToUser: Boolean) {
        super.setUserVisibleHint(isVisibleToUser)
        if (!isVisibleToUser) {
            pause()
        }
    }

    override fun onPause() {
        super.onPause()
        pause()
    }

//这里主要是获取需要展示出来的VideoView的高度和宽度,默认已屏幕的高宽进行适配。
    private fun videoReSize(): IntArray {
        val res = IntArray(2) { 0 }

        mThumbnailPath?.let {
            val option: BitmapFactory.Options = BitmapFactory.Options()
            option.inJustDecodeBounds = true
            BitmapFactory.decodeFile(mThumbnailPath, option)

            val screenW = resources.displayMetrics.widthPixels
            val screenH = resources.displayMetrics.heightPixels

            val originW = option.outWidth
            val originH = option.outHeight

            (originH > originW).let {
                if (it) {
                    res[1] = maxOf(originH, screenH)
                    res[0] = res[1] * originW / originH
                } else {
                    res[0] = maxOf(originW, screenW)
                    res[1] = res[0] * originH / originW
                }
            }
        }

        return res
    }

    private fun pause() {
        mVideoView?.pause()
        resetStatus()
    }

    private fun resetStatus() {
        mPlayImg?.visibility = View.VISIBLE
        mFirstFrameImg?.visibility = View.VISIBLE
    }
}

ResizeVideoView代码:

class ResizeVideoView : VideoView {
    private var mVideoViewWidth: Int = 0
    private var mVideoViewHeight: Int = 0

    constructor(context: Context) : super(context)
    constructor(context: Context, attributeSet: AttributeSet) : super(context, attributeSet)

//代码的重点在此,主要是根据外部传入的高度和宽度进行resize,这里默认处理的前提是要明确指定mode为EXACTLY,即在使用该空间的父viewgroup中明确指定宽高或者指定为match_parent。当然可以扩展支持wrap_content等特性。
    override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec)

        val widthSize = if (mVideoViewWidth == 0) MeasureSpec.getSize(widthMeasureSpec) else mVideoViewWidth
        val heightSize = if (mVideoViewHeight == 0) MeasureSpec.getSize(heightMeasureSpec) else mVideoViewHeight


        if (MeasureSpec.getMode(widthMeasureSpec) == MeasureSpec.EXACTLY
                && MeasureSpec.getMode(heightMeasureSpec) == MeasureSpec.EXACTLY) {
            setMeasuredDimension(widthSize, heightSize)
        }
    }


    fun resizeVideoView(width: Int, height: Int) {
        mVideoViewWidth = width
        mVideoViewHeight = height
        invalidate()
    }

}

五、The End
每一个看似很小的功能,想要实现的完美无缺都不容易,止于纸上谈兵,try it。
最后代码地址再粘贴下:https://github.com/life2smile/PhotoAlbum.git

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

推荐阅读更多精彩内容