一周内从0到1开发一款 AR眼镜 相机应用?

目录

[1. 📂 前言](#1. 📂 前言)

[2. 💠 任务拆分](#2. 💠 任务拆分)

[2.1 产品需求拆分](#2.1 产品需求拆分)

[2.2 开发工作拆分](#2.2 开发工作拆分)

[3. 🔱 开发实现](#3. 🔱 开发实现)

[3.1 代码目录截图](#3.1 代码目录截图)

[3.2 app 模块](#3.2 app 模块)

[3.3 middleware 模块](#3.3 middleware 模块)

[3.4 portal 模块](#3.4 portal 模块)

[4. ⚛️ 拍照与录像](#4. ⚛️ 拍照与录像)

[4.1 前滑后滑统一处理](#4.1 前滑后滑统一处理)

[4.2 初始化 View 以及 Camera](#4.2 初始化 View 以及 Camera)

[4.3 页面前后滑处理](#4.3 页面前后滑处理)

[4.4 字符串资源](#4.4 字符串资源)

[4.5 照片视频存放](#4.5 照片视频存放)

[4.6 拍照](#4.6 拍照)

[4.7 录像](#4.7 录像)

[5. ⚛️ 图片视频查看](#5. ⚛️ 图片视频查看)

[5.1 数据结构定义](#5.1 数据结构定义)

[5.2 图片视频获取工具类](#5.2 图片视频获取工具类)

[5.3 解耦 viewmodel 和 model 层](#5.3 解耦 viewmodel 和 model 层)

[5.4 初始化 View](#5.4 初始化 View)

[5.5 BannerViewPager 组件加载和使用](#5.5 BannerViewPager 组件加载和使用)

[5.6 获取数据并加载到 View 显示](#5.6 获取数据并加载到 View 显示)

[5.7 页面前后滑处理](#5.7 页面前后滑处理)

[5.8 gradle 依赖](#5.8 gradle 依赖)

[6. ✅ 小结](#6. ✅ 小结)


1. 📂 前言

背景:为了满足用户对 AR 眼镜相机功能的体验,研发内部决定开发一款带有 AR 眼镜特性相机应用,无产品、设计、测试以及项目同学的参与。

参与开发人员:OS/应用开发同学(本人)。

客户与用户:用户是最终使用产品的人,更多关注功能实用性,当前阶段用户是OS/应用开发同学,未来用户是产品经理、设计同学,以及未来会使用此OS的用户;客户是直属领导,更多关注功能完成度。

2. 💠 任务拆分

2.1 产品需求拆分

由于是研发内部需求,没有产品经理参与,所以需要通过调研已有产品,并结合过往相机应用开发经验,大致拆分为三块需求:拍照、录像以及图片视频查看

2.2 开发工作拆分

根据拆分需求以及 AR 眼镜的特性,拆分出如下开发工作:

  1. 搭建项目;------0.5人/天(基于 Android应用开发框架轮子 构造相机应用初版代码)

  2. 实现打开应用后默认拍照模式的开发与自测;------0.5人/天(使用CameraX API,参考开源库 KotlinCameraXDemo

  3. 实现TP点击拍照并保存在图库的开发与自测;------0.5人/天

  4. 实现TP前滑切换为录像模式、TP点击录像、再次TP点击结束录像、并保存在图库、TP再次前滑切换为拍照模式的开发与自测;------1人/天

  5. 实现TP后滑打开图库查看功能的开发与自测;------0.5人/天

  6. 实现图库查看时可前滑后滑浏览图片、视频,对于视频可点击播放与暂停功能的开发与自测;------1人/天(此部分任务,由于本人曾经做过有现成代码可搬运过来,正常情况下可能至少需要3天左右时间的开发与调优)

  7. 请产品同学以及开发同学进行功能体验并优化功能;------0.5人/天

  8. 代码整理、新建仓库上传代码以及内置 APK 在系统 OS;------0.5人/天

3. 🔱 开发实现

3.1 代码目录截图

3.2 app 模块

主要定制 Application 与 manifest。

3.3 middleware 模块

中间件层,主要放置一些模块间共用的元素,比如中英文文字翻译、应用 logo、一些 base 类等,由于开发框架文章有对应阐述,此处就不再赘述。

3.4 portal 模块

按照 MVVM 应用架构,在 MainActivity 类中实现了拍照和录像功能,在 MediaDetailActivity 类中实现了图片视频的查看功能,功能具体代码实现,将在接来下的章节作为本文重点展开。

4. ⚛️ 拍照与录像

4.1 前滑后滑统一处理

在 BaseActivity 中重写 dispatchGenericMotionEvent方法,提供 scrollForward 和 scrollBackward 方法,方便子类统一处理前滑后滑事件。

Kotlin 复制代码
abstract class BaseActivity<VB : ViewBinding, VM : BaseViewModel> : AppCompatActivity(), IView {

    protected lateinit var binding: VB

    protected val viewModel: VM by lazy {
        ViewModelProvider(this)[(this.javaClass.genericSuperclass as ParameterizedType).actualTypeArguments[1] as Class<VM>]
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = initBinding(layoutInflater)
        setContentView(binding.root)

        initData()
        initViewModel()
        initView()
    }

    override fun dispatchGenericMotionEvent(event: MotionEvent): Boolean {
        if (event.action == MotionEvent.ACTION_SCROLL && FastScrollUtils.isNotFastScroll(binding.root)) {
            if (event.getAxisValue(MotionEvent.AXIS_VSCROLL) < 0) scrollForward() else scrollBackward()
        }
        return super.dispatchGenericMotionEvent(event)
    }

    abstract fun initBinding(inflater: LayoutInflater): VB
    abstract fun scrollForward()
    abstract fun scrollBackward()


}

并且加上了防快速操作的逻辑,避免不必要 bug。

Kotlin 复制代码
/**
 * Description: 防止快速滑动,多次触发事件
 * CreateDate:  2022/11/29 17:59
 * Author:      agg
 */
object FastScrollUtils {

    private const val MIN_CLICK_DELAY_TIME = 500

    fun isNotFastScroll(view: View, time: Int = MIN_CLICK_DELAY_TIME): Boolean {
        var flag = true
        val curClickTime = System.currentTimeMillis()
        view.getTag(view.id)?.let {
            if (curClickTime - (it as Long) < time) {
                flag = false
            }
        }
        if (flag) view.setTag(view.id, curClickTime)
        return flag
    }

}

4.2 初始化 View 以及 Camera

首先,在 Activity 的初始化过程中,初始化窗口时将 WIndow 设置为 0dof,让可视窗口跟随用户视野移动。

Kotlin 复制代码
    private fun initWindow() {
        Log.i(TAG, "initWindow: ")
        val lp = window.attributes
        lp.dofIndex = 0
        lp.subType = WindowManager.LayoutParams.MB_WINDOW_IMMERSIVE_0DOF
        window.attributes = lp
    }

然后,初始化 CameraX 相关配置,包括了 ImageCapture 拍照和 VideoCapture 录像 API 的初始化,bindToLifecycle 绑定生命周期。

Kotlin 复制代码
    private val CAMERA_MAX_RESOLUTION = Size(3264, 2448)
    private lateinit var mCameraExecutor: ExecutorService
    private var mImageCapture: ImageCapture? = null
    private var mVideoCapture: VideoCapture? = null
    private var mIsVideoModel = false
    private var mVideoRecordTime = 0L

    private fun initCameraView() {
        Log.i(TAG, "initCameraView: ")
        mCameraExecutor = Executors.newSingleThreadExecutor()
        val cameraProviderFuture = ProcessCameraProvider.getInstance(this)
        cameraProviderFuture.addListener({
            try {
                mImageCapture = ImageCapture.Builder()
                    .setCaptureMode(ImageCapture.CAPTURE_MODE_MAXIMIZE_QUALITY)
                    .setTargetResolution(CAMERA_MAX_RESOLUTION).build()
                mVideoCapture = VideoCapture.Builder().build()
                val cameraSelector = CameraSelector.DEFAULT_BACK_CAMERA
                val cameraProvider = cameraProviderFuture.get()
                cameraProvider.unbindAll()
                cameraProvider.bindToLifecycle(this, cameraSelector, mImageCapture, mVideoCapture)
            } catch (e: java.lang.Exception) {
                Log.e(TAG, "bindCamera Failed!: $e")
            }
        }, ContextCompat.getMainExecutor(this))
    }

其次,如果拍照、录像需要预览界面,一是需要增加 PreviewView 预览组件,二是将 bindToLifecycle 方法参数中增加 Preview 即可,如下代码所示:

XML 复制代码
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:id="@+id/parent"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:keepScreenOn="true">

    <androidx.camera.view.PreviewView
        android:id="@+id/previewView"
        android:layout_width="match_parent"
        android:layout_height="match_parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
Kotlin 复制代码
cameraProviderFuture.addListener({
    try {
        // ...
        // 可预览
        val preview = Preview.Builder().build()
        binding.previewView.apply {
            implementationMode = PreviewView.ImplementationMode.COMPATIBLE
            preview.setSurfaceProvider(surfaceProvider)
        }
        cameraProvider.bindToLifecycle(
            this, cameraSelector, preview, mImageCapture, mVideoCapture
        )
//      cameraProvider.bindToLifecycle(this, cameraSelector, mImageCapture, mVideoCapture)
    } catch (e: java.lang.Exception) {
        Log.e(TAG, "bindCamera Failed!: $e")
    }
}, ContextCompat.getMainExecutor(this))

最后,监听窗口点击并处理对应事件即可。

Kotlin 复制代码
    private val mVideoIsRecording = AtomicBoolean(false)
    private fun isRecording(): Boolean = mVideoIsRecording.get()

    override fun initView() {
        initWindow()
        initCameraView()
        binding.parent.setOnClickListener {
            Log.i(TAG, "click: model=$mIsVideoModel,isRecordingVideo=${isRecording()}")
            try {
                if (mIsVideoModel) if (isRecording()) stopRecord() else startRecord() else takePicture()
            } catch (e: Exception) {
                e.printStackTrace()
            }
        }
    }

4.3 页面前后滑处理

Kotlin 复制代码
    /**
     * 往前滑动:切换为录像模式/拍照模式
     */
    override fun scrollForward() {
        Log.i(TAG, "scrollForward: model=$mIsVideoModel,isRecordingVideo=${isRecording()}")
        if (mIsVideoModel) {
            if (isRecording()) {
                MBToast(
                    this, Toast.LENGTH_SHORT, getString(R.string.record_video_in_progress)
                ).show()
            } else {
                mIsVideoModel = false
                MBToast(this, Toast.LENGTH_SHORT, getString(R.string.photo_model)).show()
            }
        } else {
            mIsVideoModel = true
            MBToast(this, Toast.LENGTH_SHORT, getString(R.string.video_model)).show()
        }
    }

    /**
     * 往后滑动:打开图库查看功能
     */
    override fun scrollBackward() {
        if (isRecording()) {
            MBToast(this, Toast.LENGTH_SHORT, getString(R.string.record_video_in_progress)).show()
        } else {
            Log.i(TAG, "scrollBackward: 打开图库查看功能")
            MediaDetailActivity.launch(this)
        }
    }

4.4 字符串资源

XML 复制代码
// 中文:
<resources>
    <string name="app_name">Mo相机</string>
    <string name="loading">加载中...</string>
    <string name="tap_to_snap">单击拍照</string>
    <string name="tap_to_record_video">单击录像</string>
    <string name="tap_to_stop_record_video">单击暂停录像</string>
    <string name="scroll_backward_to_gallery">后滑打开图库</string>
    <string name="scroll_forward_to_snap_model">前滑切换拍照模式</string>
    <string name="scroll_forward_to_video_model">前滑切换录像模式</string>
    <string name="record_video_start">开始录像</string>
    <string name="record_video_in_progress">正在录像</string>
    <string name="record_video_complete">录像完成</string>
    <string name="take_picture_complete">拍照完成</string>
    <string name="photo_model">拍照模式</string>
    <string name="video_model">录像模式</string>
</resources>

// 英文:
<resources>
    <string name="app_name">MoCamera</string>
    <string name="loading">Loading...</string>
    <string name="tap_to_snap">Tap To Snap</string>
    <string name="tap_to_record_video">Tap To Record Video</string>
    <string name="tap_to_stop_record_video">Tap To Stop Record Video</string>
    <string name="scroll_backward_to_gallery">Scroll Backward To Gallery</string>
    <string name="scroll_forward_to_snap_model">Scroll Forward To Snap Model</string>
    <string name="scroll_forward_to_video_model">Scroll Forward To Video Model</string>
    <string name="record_video_start">Record Video Start</string>
    <string name="record_video_in_progress">Video Recording In Progress</string>
    <string name="record_video_complete">Record Video Complete</string>
    <string name="take_picture_complete">Take Picture Complete</string>
    <string name="photo_model">Photo Model</string>
    <string name="video_model">Video Model</string>
</resources>

4.5 照片视频存放

initOutputDirectory 初始化存放目录,以及 createPhotoFile 创建照片存放路径和 createVideoFile 创建视频存放路径、updateMediaFile 更新系统相册。

Kotlin 复制代码
class MainViewModel : BaseViewModel() {

    private lateinit var outputDirectory: File

    fun init(context: ContextWrapper) {
        viewModelScope.launch { SoundPoolTools.init(context) }
        initOutputDirectory(context)
    }

    /**
     * 更新系统相册
     */
    fun updateMediaFile(context: Context, file: File) {
        val intent = Intent(Intent.ACTION_MEDIA_SCANNER_SCAN_FILE)
        intent.data = Uri.fromFile(file)
        context.sendBroadcast(intent)
    }

    fun createPhotoFile(): File {
        return File(
            outputDirectory, System.currentTimeMillis().toString() + Constants.PHOTO_EXTENSION
        )
    }

    fun createVideoFile(): File {
        return File(
            outputDirectory, System.currentTimeMillis().toString() + Constants.VIDEO_EXTENSION
        )
    }

    private fun initOutputDirectory(context: ContextWrapper) {
        outputDirectory = File(context.externalMediaDirs[0], context.getString(R.string.app_name))
        if (!outputDirectory.exists()) outputDirectory.mkdir()
    }

}

4.6 拍照

Kotlin 复制代码
    /**
     * 拍照
     */
    private fun takePicture() {
        Log.i(TAG, "takePicture: ")
        SoundPoolTools.playCameraPhoto(this)

        val photoFile = viewModel.createPhotoFile()
        mImageCapture?.takePicture(ImageCapture.OutputFileOptions.Builder(photoFile).build(),
            mCameraExecutor,
            object : ImageCapture.OnImageSavedCallback {
                override fun onError(exc: ImageCaptureException) {
                    Log.e(TAG, "Photo capture failed: ${exc.message}", exc)
                }

                override fun onImageSaved(output: ImageCapture.OutputFileResults) {
                    val savedUri = output.savedUri ?: Uri.fromFile(photoFile)
                    Log.i(TAG, "Photo capture succeeded: $savedUri")
                    runOnUiThread {
                        MBToast(
                            this@MainActivity,
                            Toast.LENGTH_SHORT,
                            getString(R.string.take_picture_complete)
                        ).show()
                    }
                    viewModel.updateMediaFile(this@MainActivity, photoFile)
                }
            })
    }

4.7 录像

Kotlin 复制代码
    private fun startRecord() {
        if (isRecording()) {
            MBToast(this, Toast.LENGTH_SHORT, getString(R.string.record_video_in_progress)).show()
            return
        }
        Log.i(TAG, "startRecord: ")

        val videoFile = viewModel.createVideoFile()
        mVideoCapture?.startRecording(VideoCapture.OutputFileOptions.Builder(videoFile).build(),
            mCameraExecutor,
            object : VideoCapture.OnVideoSavedCallback {
                override fun onVideoSaved(output: VideoCapture.OutputFileResults) {
                    mVideoIsRecording.set(false)
                    val savedUri = output.savedUri ?: Uri.fromFile(videoFile)
                    Log.i(TAG, "onVideoSaved:${savedUri.path}")
                    runOnUiThread {
                        MBToast(
                            this@MainActivity,
                            Toast.LENGTH_SHORT,
                            getString(R.string.record_video_complete)
                        ).show()

                    }
                    viewModel.updateMediaFile(this@MainActivity, videoFile)
                }

                override fun onError(videoCaptureError: Int, message: String, cause: Throwable?) {
                    mVideoIsRecording.set(false)
                    Log.e(TAG, "onError:${message}")
                }
            })
        mVideoIsRecording.set(true)

        if (isRecording()) {
            MBToast(this, Toast.LENGTH_SHORT, getString(R.string.record_video_start)).show()
        }
    }

    private fun stopRecord() {
        Log.i(TAG, "stopRecord: ")
        if (mVideoIsRecording.get()) mVideoCapture?.stopRecording()
    }

5. ⚛️ 图片视频查看

5.1 数据结构定义

首先,定义图片、视频及其父类的数据结构。

Kotlin 复制代码
open class MediaInfo(
    @Expose
    var size: Long = 0L, // 大小 单位B
    @Expose
    var width: Float = 0f, // 宽
    @Expose
    var height: Float = 0f, // 高
    @Expose
    var localPath: String = "", // 系统绝对路径
    @Expose
    var localPathUri: String = "", // 媒体文件Uri
    @Expose
    var fileName: String = "", // 文件名
    @Expose
    var mimeType: String = "", // 媒体类型
    @Expose
    var mediaId: String = "", // 媒体ID
    @Expose
    var lastModified: Long = 0L, // 最后更改时间
) {

    companion object {
        const val PHOTO_MIMETYPE_DEFAULT = "image/jpeg"
        const val VIDEO_MIMETYPE_DEFAULT = "video/mp4"

        const val PHOTO_MIMETYPE_DEFAULT_CONTAIN = "image"
        const val VIDEO_MIMETYPE_DEFAULT_CONTAIN = "video"
    }

    enum class MediaType(val type: Int) {
        NOT_DEFINE(0),
        PHOTO(1),
        VIDEO(2),
    }

}
Kotlin 复制代码
data class PhotoInfo(
    var photoId: String = "",
    @SerializedName("photoCoverFull") var photoCoverFull: String = "", // 在线图片url 或者 系统图片media路径
    @SerializedName("photoCover") // 在线图片缩略图 或者 系统图片media路径
    var photoCover: String = "",
) : MediaInfo() {
    override fun equals(other: Any?): Boolean {
        return if (other is PhotoInfo) {
            other.photoId == this.photoId
        } else {
            false
        }
    }

    override fun hashCode(): Int {
        return photoId.hashCode()
    }
}
Kotlin 复制代码
/**
 * Description:
 * CreateDate:      2022/11/16 18:09
 * Author:          agg
 *
 * 码率(比特率),单位为 bps,比特率越高,传送的数据速度越快。在压缩视频时指定码率,则可确定压缩后的视频大小。
 * 视频大小(byte) = (duration(ms) / 1000) * (biteRate(bit/s) / 8)
 */
data class VideoInfo(
    var firstFrame: Bitmap? = null, // 视频第一帧图,业务层使用,可能为空。
    @Expose
    var duration: Long = 0L, // 视频长度 ms
    @Expose
    var biteRate: Long = 0L, // 视频码率 bps

    /* --------not necessary, maybe not value---- */
    @Expose
    var addTime: Long = 0L, // 视频添加时间
    @Expose
    var videoRotation: Int = 0, // 视频方向
    /* --------not necessary, maybe not value---- */
) : MediaInfo()

其次,需要提供混合图片视频的数据结构,混合展示图片视频时需使用到。

Kotlin 复制代码
data class MixMediaInfoList(
    var isLoadVideoListFinish: Boolean = false,
    var isLoadPhotoListFinish: Boolean = false,
    var videoList: MutableList<MediaInfo> = mutableListOf(),
    var photoList: MutableList<MediaInfo> = mutableListOf(),
    var maxMediaList: MutableList<MediaInfo> = mutableListOf(),
) {

    suspend fun getMaxMediaList(): MutableList<MediaInfo> = withContext(Dispatchers.IO) {
        if (maxMediaList.isEmpty()) {
            maxMediaList.addAll(videoList)
            maxMediaList.addAll(photoList)
            maxMediaList.sortByDescending { it.lastModified }
        }
        maxMediaList
    }

}

5.2 图片视频获取工具类

首先,提供图片获取工具类,并将其转为自定义的数据结构 PhotoInfo。

Kotlin 复制代码
object PhotoInfoUtils {

    /**
     * 获取系统所有图片文件
     */
    suspend fun getSysPhotos(contentResolver: ContentResolver): MutableList<MediaInfo> =
        withContext(Dispatchers.IO) {
            val photoList: MutableList<MediaInfo> = mutableListOf()
            var cursor: Cursor? = null
            try {
                cursor = contentResolver.query(
                    Media.EXTERNAL_CONTENT_URI, arrayOf(
                        Media._ID,
                        Media.DATA,
                        Media.DISPLAY_NAME,
                        Media.MIME_TYPE, // 媒体类型
                        Media.SIZE,
                        Media.WIDTH, // 图片宽
                        Media.HEIGHT, // 图片高
                        Media.DATE_MODIFIED,
                    ), null, null, ContactsContract.Contacts._ID + " DESC", null
                )
                cursor?.moveToFirst()
                if (cursor == null || cursor.isAfterLast) return@withContext photoList

                cursor.let {
                    while (!it.isAfterLast) {
                        val photoLibraryInfo = getPhoto(it)
                        // cursor并不能保证每张图都能获取到宽高,仅在这里取size
                        photoList.add(photoLibraryInfo)
                        it.moveToNext()
                    }
                }
            } catch (e: Exception) {
                LogUtils.e(e)
            } finally {
                cursor?.close()
            }
            photoList
        }

    @SuppressLint("Range")
    private fun getPhoto(cursor: Cursor): PhotoInfo {
        val phoneLibraryInfo = PhotoInfo()
        phoneLibraryInfo.photoId = cursor.getString(cursor.getColumnIndex(Media._ID))
        phoneLibraryInfo.localPath = cursor.getString(cursor.getColumnIndex(Media.DATA))
        phoneLibraryInfo.mimeType = cursor.getString(cursor.getColumnIndex(Media.MIME_TYPE))
        phoneLibraryInfo.size = cursor.getLong(cursor.getColumnIndex(Media.SIZE))
        phoneLibraryInfo.width = cursor.getFloat(cursor.getColumnIndex(Media.WIDTH))
        phoneLibraryInfo.height = cursor.getFloat(cursor.getColumnIndex(Media.HEIGHT))
        phoneLibraryInfo.lastModified = cursor.getLong(cursor.getColumnIndex(Media.DATE_MODIFIED))

        val thumbnailUri: Uri = ContentUris.withAppendedId(
            Media.EXTERNAL_CONTENT_URI, phoneLibraryInfo.photoId.toLong()
        )
        phoneLibraryInfo.photoCoverFull = thumbnailUri.toString()
        phoneLibraryInfo.photoCover = thumbnailUri.toString()
        phoneLibraryInfo.localPathUri = thumbnailUri.toString()
        return phoneLibraryInfo
    }

}

然后,提供视频获取工具类,并将其转为自定义的数据结构 VideoInfo。

Kotlin 复制代码
/**
 * Description: 视频信息工具类
 * CreateDate:  2022/11/16 18:46
 * Author:      agg
 */
@SuppressLint("Range", "Recycle")
object VideoInfoUtils {

    private const val VIDEO_FIRST_FRAME_TIME_US = 1000L
    private const val URI_VIDEO_PRE = "content://media/external/video/media"

    /**
     * 获取系统所有视频文件
     */
    suspend fun getSysVideos(contentResolver: ContentResolver): MutableList<MediaInfo> =
        withContext(Dispatchers.IO) {
            val videoList: MutableList<MediaInfo> = mutableListOf()
            var cursor: Cursor? = null
            try {
                val queryArray = arrayOf(
                    Media._ID,
                    Media.SIZE, // 视频大小
                    Media.WIDTH, // 视频宽
                    Media.HEIGHT, // 视频高
                    Media.DATA, // 视频绝对路径
                    Media.DISPLAY_NAME, // 视频文件名
                    Media.MIME_TYPE, // 媒体类型
                    Media.DURATION, // 视频长度
                    Media.DATE_ADDED, // 视频添加时间
                    Media.DATE_MODIFIED, // 视频最后更改时间
                ).toMutableList()
//                if (SDK_INT >= Build.VERSION_CODES.R) queryArray.add(Media.BITRATE)// 视频码率
                cursor = contentResolver.query(
                    Media.EXTERNAL_CONTENT_URI,
                    queryArray.toTypedArray(),
                    null,
                    null,
                    Media.DATE_ADDED + " DESC",
                    null
                )
                cursor?.moveToFirst()
                if (cursor == null || cursor.isAfterLast) return@withContext videoList
                while (!cursor.isAfterLast) {
                    getVideoInfo(cursor).run { if (duration > 0 && size > 0) videoList.add(this) }
                    cursor.moveToNext()
                }
            } catch (e: Exception) {
                LogUtils.e(e)
            } finally {
                cursor?.close()
            }
            videoList
        }

    /**
     * 获取视频文件信息
     * 注:(1)暂未包括videoRotation;(2)biteRate通过文件大小和视频时长计算
     */
    private suspend fun getVideoInfo(cursor: Cursor): VideoInfo = withContext(Dispatchers.IO) {
        val videoInfo = VideoInfo()
        videoInfo.mediaId = cursor.getString(cursor.getColumnIndex(Media._ID))
        videoInfo.size = cursor.getLong(cursor.getColumnIndex(Media.SIZE))
        videoInfo.width = cursor.getFloat(cursor.getColumnIndex(Media.WIDTH))
        videoInfo.height = cursor.getFloat(cursor.getColumnIndex(Media.HEIGHT))
        videoInfo.localPath = cursor.getString(cursor.getColumnIndex(Media.DATA))
        videoInfo.localPathUri = getVideoPathUri(videoInfo.mediaId).toString()
        videoInfo.fileName = cursor.getString(cursor.getColumnIndex(Media.DISPLAY_NAME))
        videoInfo.mimeType = cursor.getString(cursor.getColumnIndex(Media.MIME_TYPE))
//        不能在这获取第一帧,太耗时,改为使用的地方去获取第一帧。
//        videoInfo.firstFrame = getVideoThumbnail(cursor.getString(cursor.getColumnIndex(Media.DATA)))
        videoInfo.duration = cursor.getLong(cursor.getColumnIndex(Media.DURATION))
        videoInfo.biteRate = ((8 * videoInfo.size * 1024) / (videoInfo.duration / 1000f)).toLong()
//            if (SDK_INT >= Build.VERSION_CODES.R) cursor.getLong(cursor.getColumnIndex(Media.BITRATE))
//            else ((8 * videoInfo.size * 1024) / (videoInfo.duration / 1000f)).toLong()
        videoInfo.addTime = cursor.getLong(cursor.getColumnIndex(Media.DATE_ADDED))
        videoInfo.lastModified = cursor.getLong(cursor.getColumnIndex(Media.DATE_MODIFIED))
        videoInfo
    }

    /**
     * 获取视频文件信息
     * 注:(1)暂未包括lastModified、addTime;(2)mediaId以filePath代替
     *
     * @param path 视频文件的路径
     * @return VideoInfo 视频文件信息
     */
    suspend fun getVideoInfo(path: String?): VideoInfo = withContext(Dispatchers.IO) {
        val videoInfo = VideoInfo()
        if (!path.isNullOrEmpty()) {
            val media = MediaMetadataRetriever()
            try {
                media.setDataSource(path)
                videoInfo.size =
                    File(path).let { if (FileUtils.isFileExists(it)) it.length() else 0 }
                videoInfo.width = media.extractMetadata(METADATA_KEY_VIDEO_WIDTH)?.toFloat() ?: 0f
                videoInfo.height = media.extractMetadata(METADATA_KEY_VIDEO_HEIGHT)?.toFloat() ?: 0f
                videoInfo.localPath = path
                videoInfo.localPathUri = getVideoPathUri(Utils.getApp(), path).toString()
                videoInfo.fileName = path.split(File.separator).let {
                    if (it.isNotEmpty()) it[it.size - 1] else ""
                }
                videoInfo.mimeType = media.extractMetadata(METADATA_KEY_MIMETYPE) ?: ""
//                videoInfo.firstFrame = media.getFrameAtTime(VIDEO_FIRST_FRAME_TIME_US)?.let { compressVideoThumbnail(it) }
                videoInfo.duration = media.extractMetadata(METADATA_KEY_DURATION)?.toLong() ?: 0
                videoInfo.biteRate = media.extractMetadata(METADATA_KEY_BITRATE)?.toLong() ?: 0
                videoInfo.videoRotation =
                    media.extractMetadata(METADATA_KEY_VIDEO_ROTATION)?.toInt() ?: 0
                videoInfo.mediaId = path
            } catch (e: Exception) {
            } finally {
                media.release()
            }
        }
        videoInfo
    }

    /**
     * 获取视频缩略图:通过Uri抓取第一帧
     * @param videoUriString 视频在媒体库中Uri。如:content://media/external/video/media/11378
     */
    suspend fun getVideoThumbnail(context: Context, videoUriString: String): Bitmap? =
        withContext(Dispatchers.IO) {
            var bitmap: Bitmap? = null
            val retriever = MediaMetadataRetriever()
            try {
                retriever.setDataSource(context, Uri.parse(videoUriString))
                // OPTION_CLOSEST_SYNC:在给定的时间,检索最近一个同步与数据源相关联的的帧(关键帧)
                // OPTION_CLOSEST:表示获取离该时间戳最近帧(I帧或P帧)
                bitmap = if (SDK_INT >= Build.VERSION_CODES.O_MR1) {
                    retriever.getScaledFrameAtTime(
                        VIDEO_FIRST_FRAME_TIME_US,
                        OPTION_CLOSEST_SYNC,
                        THUMBNAIL_DEFAULT_COMPRESS_VALUE.toInt(),
                        THUMBNAIL_DEFAULT_COMPRESS_VALUE.toInt()
                    )
                } else {
                    retriever.getFrameAtTime(VIDEO_FIRST_FRAME_TIME_US)
                        ?.let { compressVideoThumbnail(it) }
                }
            } catch (e: Exception) {
            } finally {
                try {
                    retriever.release()
                } catch (e: Exception) {
                }
            }
            bitmap
        }

    /**
     * 通过视频资源ID,直接获取视频Uri
     * @param mediaId 视频资源ID
     */
    fun getVideoPathUri(mediaId: String): Uri =
        Uri.withAppendedPath(Uri.parse(URI_VIDEO_PRE), mediaId)

    /**
     * 通过视频资源本地路径,获取视频Uri
     * @param path 视频资源本地路径
     */
    fun getVideoPathUri(context: Context, path: String): Uri? {
        var uri: Uri? = null
        var cursor: Cursor? = null
        try {
            cursor = context.contentResolver.query(
                Media.EXTERNAL_CONTENT_URI,
                arrayOf(Media._ID),
                Media.DATA + "=? ",
                arrayOf(path),
                null
            )
            uri = if (cursor != null && cursor.moveToFirst()) {
                Uri.withAppendedPath(
                    Uri.parse(URI_VIDEO_PRE),
                    "" + cursor.getInt(cursor.getColumnIndex(MediaStore.MediaColumns._ID))
                )
            } else {
                if (File(path).exists()) {
                    val values = ContentValues()
                    values.put(Media.DATA, path)
                    context.contentResolver.insert(Media.EXTERNAL_CONTENT_URI, values)
                } else {
                    null
                }
            }
        } catch (e: Exception) {
        } finally {
            cursor?.close()
        }
        return uri
    }

}

同时,提供视频缩略图工具类,方便业务代码使用。

Kotlin 复制代码
/**
 * Description: 视频缩略图工具类
 * 宽高压缩、缩放法压缩可针对Bitmap操作,而采样率压缩和质量压缩针对于File、Resource操作
 * CreateDate:  2022/11/24 18:48
 * Author:      agg
 */
object VideoThumbnailUtils {

    /**
     * 视频缩略图默认压缩尺寸
     */
    const val THUMBNAIL_DEFAULT_COMPRESS_VALUE = 1024f

    /**
     * 视频缩略图默认压缩比例
     */
    private const val THUMBNAIL_DEFAULT_SCALE_VALUE = 0.5f

    private const val MAX_IMAGE_SIZE = 500 * 1024  //图片压缩阀值

    /**
     * 压缩视频缩略图
     * @param bitmap 视频缩略图
     */
    fun compressVideoThumbnail(bitmap: Bitmap): Bitmap? {
        val width: Int = bitmap.width
        val height: Int = bitmap.height
        val max: Int = Math.max(width, height)
        if (max > THUMBNAIL_DEFAULT_COMPRESS_VALUE) {
            val scale: Float = THUMBNAIL_DEFAULT_COMPRESS_VALUE / max
            val w = (scale * width).roundToInt()
            val h = (scale * height).roundToInt()
            return compressVideoThumbnail(bitmap, w, h)
        }
        return bitmap
    }

    /**
     * 压缩视频缩略图:宽高压缩
     * 注:如果用户期望的长度和宽度和原图长度宽度相差太多的话,图片会很不清晰。
     * @param bitmap 视频缩略图
     */
    fun compressVideoThumbnail(bitmap: Bitmap, width: Int, height: Int): Bitmap? {
        return Bitmap.createScaledBitmap(bitmap, width, height, true)
    }

    /**
     * 压缩视频缩略图:缩放法压缩
     * 注:长度和宽度没有变,内存缩小4倍(内存像素宽高各缩小一半)
     */
    fun compressVideoThumbnailMatrix(bitmap: Bitmap): Bitmap? {
        val matrix = Matrix()
        matrix.setScale(THUMBNAIL_DEFAULT_SCALE_VALUE, THUMBNAIL_DEFAULT_SCALE_VALUE)
        return Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true)
    }

    /**
     * 压缩视频缩略图:采样率压缩
     * @param filePath 视频缩略图路径
     */
    fun compressVideoThumbnailSample(filePath: String, width: Int, height: Int): Bitmap? {
        return BitmapFactory.Options().run {
            // inJustDecodeBounds 设置为 true后,BitmapFactory.decodeFile 不生成Bitmap对象,而仅仅是读取该图片的尺寸和类型信息。
            inJustDecodeBounds = true
            BitmapFactory.decodeFile(filePath, this)
            inSampleSize = calculateInSampleSize(this, width, height)
            inJustDecodeBounds = false
            BitmapFactory.decodeFile(filePath, this)
        }
    }

    /**
     * 压缩图片到指定宽高
     * @param localPath 图片本地路径
     * @param size 原图所占空间大小
     */
    fun compressLocalImage(localPath: String, size: Int): Bitmap? {
        return if (size > MAX_IMAGE_SIZE) {
            val scale = size.toDouble() / MAX_IMAGE_SIZE
            val simpleSize = ceil(sqrt(scale)).toInt() //取最接近的平方根
            var bitmap = BitmapFactory.Options().run {
                inPreferredConfig = Bitmap.Config.RGB_565
                inSampleSize = simpleSize
                inJustDecodeBounds = false

                val ins = Utils.getApp().contentResolver.openInputStream(Uri.parse(localPath))
                BitmapFactory.decodeStream(ins, null, this)
            }

            val angle = readImageRotation(localPath)
            if (angle != 0 && bitmap != null) {
                bitmap = rotatingImageView(angle, bitmap)
            }
            if (bitmap == null) {
                return null
            }
            val result = tryCompressAgain(bitmap)

            result
        } else { //不压缩
            null
        }
    }

    /**
     * 试探性进一步压缩体积
     */
    private fun tryCompressAgain(original: Bitmap): Bitmap {
        val out = ByteArrayOutputStream()
        original.compress(Bitmap.CompressFormat.JPEG, 100, out)
        val matrix = Matrix()
        var resultBp = original

        try {
            var scale = 1.0f
            while (out.toByteArray().size > MAX_IMAGE_SIZE) {
                matrix.setScale(scale, scale)//每次缩小 1/10
                resultBp =
                    Bitmap.createBitmap(
                        original,
                        0,
                        0,
                        original.width,
                        original.height,
                        matrix,
                        true
                    )
                out.reset()
                resultBp.compress(Bitmap.CompressFormat.JPEG, 100, out)
                scale *= 0.9f
            }
        } catch (e: Exception) {
            e.printStackTrace()
        } finally {
            out.close()
        }
        return resultBp
    }

    //获取图片旋转角度
    private fun readImageRotation(path: String): Int {
        return kotlin.runCatching {
            val ins = Utils.getApp().contentResolver.openInputStream(Uri.parse(path)) ?: return 0
            val exifInterface = ExifInterface(ins)
            val orientation: Int = exifInterface.getAttributeInt(
                ExifInterface.TAG_ORIENTATION,
                ExifInterface.ORIENTATION_NORMAL
            )
            ins.close()
            return when (orientation) {
                ExifInterface.ORIENTATION_ROTATE_90 -> 90
                ExifInterface.ORIENTATION_ROTATE_180 -> 180
                ExifInterface.ORIENTATION_ROTATE_270 -> 270
                else -> 0
            }
        }.getOrNull() ?: 0
    }

    /**
     * 旋转图片
     */
    private fun rotatingImageView(angle: Int, bitmap: Bitmap): Bitmap? {
        // 旋转图片 动作
        val matrix = Matrix()
        matrix.postRotate(angle.toFloat())
        // 创建新的图片
        return Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true)
    }

    /**
     * 计算采样率:值为2的幂。例如, 一个分辨率为2048x1536的图片,如果设置 inSampleSize 为4,那么会产出一个大约512x384大小的Bitmap。
     * @param options
     * @param reqWidth 想要压缩到的宽度
     * @param reqHeight  想要压缩到的高度
     * @return
     */
    fun calculateInSampleSize(options: BitmapFactory.Options, reqWidth: Int, reqHeight: Int): Int {
        var inSampleSize = 1
        if (options.outHeight > reqHeight || options.outWidth > reqWidth) {
            val halfHeight: Int = options.outHeight / 2
            val halfWidth: Int = options.outWidth / 2
            while (halfHeight / inSampleSize >= reqHeight && halfWidth / inSampleSize >= reqWidth) {
                inSampleSize *= 2
            }
        }
        return inSampleSize
    }

    /**
     * 计算采样率:值为整数的采样率。
     */
    fun calculateInSampleSizeFixed(options: BitmapFactory.Options, width: Int, height: Int): Int {
        val hRatio = ceil(options.outHeight.div(height.toDouble())) // 大于1:图片高度>手机屏幕高度
        val wRatio = ceil(options.outWidth.div(width.toDouble())) // 大于1:图片宽度>手机屏幕宽度
        return if (hRatio > wRatio) hRatio.toInt() else wRatio.toInt()
    }

}

5.3 解耦 viewmodel 和 model 层

Kotlin 复制代码
class MediaDetailViewModel : BaseViewModel() {

    private val model by lazy { MediaDetailModel() }
    val getSysPhotosLiveData: MutableLiveData<List<MediaInfo>> by lazy { MutableLiveData<List<MediaInfo>>() }
    val getSysVideosLiveData: MutableLiveData<List<MediaInfo>> by lazy { MutableLiveData<List<MediaInfo>>() }

    fun getSysVideos(contentResolver: ContentResolver) {
        viewModelScope.launch(Dispatchers.IO) {
            getSysVideosLiveData.postValue(model.getSysVideos(contentResolver))
        }
    }

    fun getSysPhotos(contentResolver: ContentResolver) {
        viewModelScope.launch(Dispatchers.IO) {
            getSysPhotosLiveData.postValue(model.getSysPhotos(contentResolver))
        }
    }

}

open class BaseViewModel : ViewModel()


class MediaDetailModel {

    suspend fun getSysVideos(contentResolver: ContentResolver) =
        VideoInfoUtils.getSysVideos(contentResolver)

    suspend fun getSysPhotos(contentResolver: ContentResolver) =
        PhotoInfoUtils.getSysPhotos(contentResolver)

}

5.4 初始化 View

首先,在 Activity 的初始化过程中,初始化窗口时将 WIndow 设置为 0dof,让可视窗口跟随用户视野移动。

Kotlin 复制代码
    private fun initWindow() {
        Log.i(TAG, "initWindow: ")
        val lp = window.attributes
        lp.dofIndex = 0
        lp.subType = WindowManager.LayoutParams.MB_WINDOW_IMMERSIVE_0DOF
        window.attributes = lp
    }

其次,引入图片视频 BannerViewPager 组件。

Kotlin 复制代码
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:id="@+id/parent"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <com.zhpan.bannerview.BannerViewPager
        android:id="@+id/bvFeedPhotoContent"
        android:layout_width="match_parent"
        android:layout_height="match_parent" />

</androidx.constraintlayout.widget.ConstraintLayout>

最后,初始化 BannerViewPager 组件。

Kotlin 复制代码
    private fun initBannerViewPager() {
        (binding.bvFeedPhotoContent as BannerViewPager<MediaInfo>).apply {
            bigDetailAdapter = MediaDetailItemAdapter()
            adapter = bigDetailAdapter
            setLifecycleRegistry(lifecycle)
            setCanLoop(false)
            setAutoPlay(false)
            setIndicatorVisibility(GONE)
            disallowParentInterceptDownEvent(true) // 不允许内部拦截,使得activity可以获得下滑能力
            registerOnPageChangeCallback(object : ViewPager2.OnPageChangeCallback() {
                /**
                 * 某个页面被选中(从0计数) 翻页成功才会调用
                 * @param position 翻页后的视图在集合中位置
                 */
                @SuppressLint("SetTextI18n")
                override fun onPageSelected(position: Int) {
                    super.onPageSelected(position)
                    // 刷新上个页面的信息(图片缩放置为初始状态、视频停止播放)
                    Log.i(TAG, "onPageSelected: position = $position")
                }
            })
        }.create()
    }

    private fun updateViewPager(allMediaInfoList: MutableList<MediaInfo>) {
        Log.i(TAG, "updateViewPager: size = ${allMediaInfoList.size}")
        binding.bvFeedPhotoContent.refreshData(allMediaInfoList)
        binding.bvFeedPhotoContent.setCurrentItem(0, false)
    }

5.5 BannerViewPager 组件加载和使用

首先,自定义适配器 MediaDetailItemAdapter。

Kotlin 复制代码
class MediaDetailItemAdapter : BaseBannerAdapter<MediaInfo>() {

    private var context: Context? = null

    override fun getLayoutId(viewType: Int): Int = R.layout.media_detail_item_adapter

    override fun createViewHolder(
        parent: ViewGroup, itemView: View?, viewType: Int
    ): BaseViewHolder<MediaInfo> {
        context = parent.context
        return super.createViewHolder(parent, itemView, viewType)
    }

    override fun bindData(
        holder: BaseViewHolder<MediaInfo>, mediaInfo: MediaInfo, position: Int, pageSize: Int
    ) {
        val vpViewGroup = holder.findViewById<VideoPlayerViewGroup>(R.id.vpViewGroup)
        val ivAvatarDetail = holder.findViewById<PhotoView>(R.id.ivAvatarDetail)
        val ivAvatarDetailLarge =
            holder.findViewById<SubsamplingScaleImageView>(R.id.ivAvatarDetailLarge)
        if (mediaInfo is PhotoInfo) {
            vpViewGroup.visibility = GONE
            initPhotoInfo(mediaInfo, ivAvatarDetail, ivAvatarDetailLarge)
        } else if (mediaInfo is VideoInfo) {
            vpViewGroup.visibility = VISIBLE
            ivAvatarDetail.visibility = GONE
            ivAvatarDetailLarge.visibility = GONE
            vpViewGroup.initVideoInfo(mediaInfo)
        }
    }

    fun releaseAllVideos() {
        GSYVideoManager.releaseAllVideos()
    }

    private fun initPhotoInfo(
        photoInfo: PhotoInfo,
        ivAvatarDetail: PhotoView,
        ivAvatarDetailLarge: SubsamplingScaleImageView
    ) {
        ivAvatarDetail.visibility = VISIBLE
        ivAvatarDetailLarge.visibility = VISIBLE
        ivAvatarDetail.maximumScale = MAX_SCALE
        ivAvatarDetail.minimumScale = MIN_SCALE
        ivAvatarDetail.scale = MIN_SCALE

        val imageUrl = photoInfo.photoCoverFull
        val thumbUrl = photoInfo.photoCover
        if (imageUrl.isNotEmpty()) {
            ivAvatarDetail.visibility = VISIBLE
            context?.let {
                val isLarge = photoInfo.size > 5000 * 1024 // 大于5m认为是大图
                if (!isLarge) {
                    ivAvatarDetail.visibility = VISIBLE
                    Glide.with(it).load(imageUrl).thumbnail(Glide.with(it).load(thumbUrl))
                        .override(SIZE_ORIGINAL, SIZE_ORIGINAL).into(ivAvatarDetail)
                    ivAvatarDetailLarge.visibility = INVISIBLE
                } else {
                    ivAvatarDetailLarge.visibility = VISIBLE
                    Glide.with(it).load(imageUrl).downloadOnly(object : SimpleTarget<File>() {
                        override fun onResourceReady(
                            resource: File, transition: Transition<in File>?
                        ) {
                            // 在宽高均大于手机屏幕的图片被下载到media后会被强制旋转,这里需要禁止
                            ivAvatarDetailLarge.orientation =
                                SubsamplingScaleImageView.ORIENTATION_USE_EXIF
                            ivAvatarDetailLarge.setImage(
                                ImageSource.uri(Uri.fromFile(resource))
                            )
                            ivAvatarDetail.visibility = INVISIBLE
                        }
                    })
                }
            }
        } else {
            ivAvatarDetail.visibility = GONE
            ivAvatarDetailLarge.visibility = GONE
        }
    }

    companion object {
        const val MAX_SCALE = 5F
        const val MIN_SCALE = 1F
    }

}

对于视频组件,需要自定义 View 及其 ViewGroup。

Kotlin 复制代码
class VideoPlayerView : StandardGSYVideoPlayer {

    constructor(context: Context?) : super(context)

    constructor(context: Context?, attrs: AttributeSet?) : super(context, attrs)

    constructor(context: Context?, fullFlag: Boolean?) : super(context, fullFlag)

    override fun getLayoutId(): Int = R.layout.video_player_view

    override fun touchSurfaceMoveFullLogic(absDeltaX: Float, absDeltaY: Float) {
        super.touchSurfaceMoveFullLogic(absDeltaX, absDeltaY)
        //不给触摸快进,如果需要,屏蔽下方代码即可
        mChangePosition = false

        //不给触摸音量,如果需要,屏蔽下方代码即可
        mChangeVolume = false

        //不给触摸亮度,如果需要,屏蔽下方代码即可
        mBrightness = false
    }

    override fun touchDoubleUp(e: MotionEvent?) {
        //super.touchDoubleUp();
        //不需要双击暂停
    }

}

对应的,视频播放的核心操作逻辑放在 VideoPlayerViewGroup。

Kotlin 复制代码
class VideoPlayerViewGroup(context: Context, attrs: AttributeSet?) :
    ConstraintLayout(context, attrs), GSYVideoProgressListener, SeekBar.OnSeekBarChangeListener {

    companion object {
        private const val DELAY_DISMISS_3_SECOND = 3000L
        private const val VIDEO_RESOLUTION_BEYOND_2K = 3500f
    }

    private val binding by lazy {
        VideoPlayerViewGroupBinding.inflate(LayoutInflater.from(context), this, true)
    }

    private val runnable: Runnable = Runnable {
        binding.playBtn.isVisible = false
        binding.currentPosition.isVisible = false
        binding.seekbar.isVisible = false
        binding.duration.isVisible = false
    }

    private var videoInfo: VideoInfo? = null

    /**
     * seekbar拖动后的进度
     */
    private var seekbarTouchFinishProgress = -1L

    /**
     * 视频未初始化时,记录seekbar拖动后的进度,初始化后,需定位到记录位置。
     */
    private var seekbarTouchFinishProgressNotInitial = -1L

    fun initVideoInfo(videoInfo: VideoInfo) {
        this.videoInfo = videoInfo
        updateVideoSeekbar(0, 0, videoInfo.duration)

        if (videoInfo.firstFrame != null) {
            binding.firstFrame.setImageBitmap(videoInfo.firstFrame)
        } else {
            CoroutineScope(Dispatchers.Main).launch {
                videoInfo.firstFrame = getVideoThumbnail(context, videoInfo.localPathUri)
                binding.firstFrame.setImageBitmap(videoInfo.firstFrame)
            }
        }
        binding.playBtn.setOnClickListener { handleVideoPlayer() }
        binding.clickScreen.setOnClickListener { handleClickScreen() }
        binding.seekbar.setOnSeekBarChangeListener(this)

        if (!binding.firstFrame.isVisible) restoreVideoPlayer()
        if (!binding.seekbar.isVisible) {
            binding.currentPosition.isVisible = true
            binding.seekbar.isVisible = true
            binding.duration.isVisible = true
            binding.playBtn.isVisible = true
        }
    }

    /**
     * 更新seekbar
     */
    private fun updateVideoSeekbar(
        progress: Int, currentPosition: Long, duration: Long
    ) {
        binding.currentPosition.text = convertToVideoTimeFromSecond(currentPosition / 1000)
        binding.seekbar.progress = progress
        binding.duration.text = convertToVideoTimeFromSecond(duration / 1000)
    }

    private fun handleVideoPlayer() {
        when {
            binding.firstFrame.isVisible -> {
                seekbarTouchFinishProgress = -1L
                binding.firstFrame.isVisible = false
                binding.playBtn.setImageResource(R.drawable.ic_pause)

                // 初始化播放
                binding.videoPlayer.backButton?.isVisible = false
                if (videoInfo?.width ?: 0f > VIDEO_RESOLUTION_BEYOND_2K || videoInfo?.height ?: 0f > VIDEO_RESOLUTION_BEYOND_2K) {
                    PlayerFactory.setPlayManager(SystemPlayerManager::class.java)
                } else {
                    PlayerFactory.setPlayManager(IjkPlayerManager::class.java)
                }
                binding.videoPlayer.setUp(videoInfo?.localPathUri, true, "")
                binding.videoPlayer.startPlayLogic()
                binding.videoPlayer.setGSYVideoProgressListener(this)
                binding.videoPlayer.setVideoAllCallBack(object : GSYSampleCallBack() {
                    override fun onAutoComplete(url: String?, vararg objects: Any?) {
                        super.onAutoComplete(url, *objects)
                        restoreVideoPlayer()
                        updateVideoSeekbar(0, 0, videoInfo?.duration ?: 0)
                    }
                })

                if (binding.seekbar.isVisible) {
                    seekbarDelayDismiss()
                } else {
                    binding.playBtn.isVisible = false
                }
            }

            binding.videoPlayer.currentState == GSYVideoView.CURRENT_STATE_PAUSE -> {
                binding.playBtn.setImageResource(R.drawable.ic_pause)
                binding.videoPlayer.onVideoResume(false)

                // playBtn不可见,则底部seekbar不可见
                if (binding.seekbar.isVisible) {
                    seekbarDelayDismiss()
                } else {
                    binding.playBtn.isVisible = false
                }
            }

            binding.videoPlayer.isInPlayingState -> {
                binding.playBtn.setImageResource(R.drawable.ic_play)
                binding.videoPlayer.onVideoPause()

                handler.removeCallbacks(runnable)
            }
        }
    }

    private fun handleClickScreen() {
        when {
            binding.firstFrame.isVisible -> {
                if (binding.videoPlayer.currentState == -1 || binding.videoPlayer.currentState == CURRENT_STATE_NORMAL) {
                    binding.currentPosition.isVisible = !binding.currentPosition.isVisible
                    binding.seekbar.isVisible = !binding.seekbar.isVisible
                    binding.duration.isVisible = !binding.duration.isVisible
                    binding.playBtn.isVisible = true
                }
            }

            binding.videoPlayer.currentState == GSYVideoView.CURRENT_STATE_PAUSE -> {
                binding.currentPosition.isVisible = !binding.currentPosition.isVisible
                binding.seekbar.isVisible = !binding.seekbar.isVisible
                binding.duration.isVisible = !binding.duration.isVisible
                binding.playBtn.isVisible = true
            }

            binding.videoPlayer.isInPlayingState -> {
                if (binding.playBtn.isVisible) {
                    if (binding.seekbar.isVisible) {
                        handler.post(runnable)
                    } else {
                        binding.currentPosition.isVisible = true
                        binding.seekbar.isVisible = true
                        binding.duration.isVisible = true

                        seekbarDelayDismiss()
                    }
                } else {
                    if (!binding.seekbar.isVisible) {
                        binding.currentPosition.isVisible = true
                        binding.seekbar.isVisible = true
                        binding.duration.isVisible = true
                    }
                    binding.playBtn.isVisible = true
                    seekbarDelayDismiss()
                }
            }
        }
    }

    /**
     * 进度条延迟3秒消失
     */
    private fun seekbarDelayDismiss() {
        handler.removeCallbacks(runnable)
        handler.postDelayed(runnable, DELAY_DISMISS_3_SECOND)
    }

    /**
     * 还原播放
     */
    private fun restoreVideoPlayer() {
        binding.firstFrame.isVisible = true
        binding.playBtn.setImageResource(R.drawable.ic_play)
        binding.playBtn.isVisible = true
        binding.currentPosition.isVisible = true
        binding.seekbar.isVisible = true
        binding.duration.isVisible = true
        if (handler != null) handler.removeCallbacks(runnable)

        binding.videoPlayer.onVideoPause()
        binding.videoPlayer.release()
        binding.videoPlayer.onVideoReset()
        binding.videoPlayer.setVideoAllCallBack(null)
    }

    override fun onProgress(
        progress: Long, secProgress: Long, currentPosition: Long, duration: Long
    ) {
        if (seekbarTouchFinishProgress != -1L && progress <= binding.seekbar.progress) {
            return
        }
        seekbarTouchFinishProgress = -1
        if (seekbarTouchFinishProgressNotInitial != -1L) {
            seekbarTouchFinishProgress = seekbarTouchFinishProgressNotInitial
            binding.videoPlayer.seekTo(seekbarTouchFinishProgressNotInitial)
            seekbarTouchFinishProgressNotInitial = -1
            return
        }
        updateVideoSeekbar(progress.toInt(), currentPosition, videoInfo?.duration ?: 0)
    }

    override fun onStartTrackingTouch(seekBar: SeekBar) {
        handler.removeCallbacks(runnable)
    }

    override fun onProgressChanged(seekBar: SeekBar, progress: Int, fromUser: Boolean) {
    }

    override fun onStopTrackingTouch(seekBar: SeekBar) {
        val position = (seekBar.progress.toLong() + 1) * (videoInfo?.duration ?: 0) / 100
        if (binding.videoPlayer.isInPlayingState) seekbarTouchFinishProgress = position
        binding.currentPosition.text = convertToVideoTimeFromSecond(position / 1000)
        binding.videoPlayer.seekTo(position)
        if (binding.videoPlayer.isInPlayingState && binding.videoPlayer.currentState != GSYVideoView.CURRENT_STATE_PAUSE) seekbarDelayDismiss()
        if (binding.videoPlayer.currentState == -1 || binding.videoPlayer.currentState == CURRENT_STATE_NORMAL) {
            seekbarTouchFinishProgressNotInitial = position
        }
    }

}

最后,附上 xml 文件。

1)media_detail_item_adapter.xml

XML 复制代码
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <com.agg.mocamera.portal.feature.home.view.VideoPlayerViewGroup
        android:id="@+id/vpViewGroup"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:visibility="gone" />

    <com.github.chrisbanes.photoview.PhotoView
        android:id="@+id/ivAvatarDetail"
        android:layout_width="match_parent"
        android:layout_height="match_parent" />

    <com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView
        android:id="@+id/ivAvatarDetailLarge"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:visibility="invisible" />

</androidx.constraintlayout.widget.ConstraintLayout>

2)video_player_view.xml

Kotlin 复制代码
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:background="#121212">

    <FrameLayout
        android:id="@+id/surface_container"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:gravity="center" />

</RelativeLayout>

3)video_player_view_group.xml

Kotlin 复制代码
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:id="@+id/videoViewParent"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:ignore="SpUsage">

    <com.agg.mocamera.portal.feature.home.view.VideoPlayerView
        android:id="@+id/videoPlayer"
        android:layout_width="match_parent"
        android:layout_height="match_parent" />

    <View
        android:id="@+id/clickScreen"
        android:layout_width="match_parent"
        android:layout_height="match_parent" />

    <ImageView
        android:id="@+id/firstFrame"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:contentDescription="@null"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

    <ImageView
        android:id="@+id/playBtn"
        android:layout_width="48dp"
        android:layout_height="48dp"
        android:contentDescription="@null"
        android:src="@drawable/ic_play"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

    <com.agg.ui.MBTextView
        android:id="@+id/currentPosition"
        android:layout_width="wrap_content"
        android:layout_height="20dp"
        android:layout_marginStart="15dp"
        android:layout_marginBottom="58dp"
        android:gravity="center"
        android:textColor="#FFFFFF"
        android:textSize="12dp"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        tools:text="00:00" />

    <SeekBar
        android:id="@+id/seekbar"
        style="@style/Widget.AppCompat.ProgressBar.Horizontal"
        android:layout_width="0dp"
        android:layout_height="24dp"
        android:duplicateParentState="false"
        android:max="100"
        android:maxHeight="6dp"
        android:minHeight="6dp"
        android:progress="0"
        android:progressDrawable="@drawable/video_seek_bar_progress"
        android:splitTrack="false"
        android:thumb="@drawable/ic_video_seek_bar_thumb"
        app:layout_constraintBottom_toBottomOf="@id/currentPosition"
        app:layout_constraintEnd_toStartOf="@id/duration"
        app:layout_constraintStart_toEndOf="@id/currentPosition"
        app:layout_constraintTop_toTopOf="@id/currentPosition" />

    <com.agg.ui.MBTextView
        android:id="@+id/duration"
        android:layout_width="wrap_content"
        android:layout_height="20dp"
        android:layout_marginEnd="15dp"
        android:gravity="center"
        android:textColor="#FFFFFF"
        android:textSize="12dp"
        app:layout_constraintBottom_toBottomOf="@id/currentPosition"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintTop_toTopOf="@id/currentPosition"
        tools:text="04:59" />

</androidx.constraintlayout.widget.ConstraintLayout>

5.6 获取数据并加载到 View 显示

Kotlin 复制代码
    private var allPhotoInfoList: MutableList<MediaInfo> = mutableListOf()
    private var mixMediaInfoList = MixMediaInfoList()
    private var bigDetailAdapter: MediaDetailItemAdapter? = null


    private fun initAllMediaInfoList() {
        viewModel.getSysPhotos(contentResolver)
        viewModel.getSysVideos(contentResolver)
    }

    override fun initViewModel() {
        viewModel.getSysPhotosLiveData.observe(this) { photoList ->
            Log.i(TAG, "initViewModel: photoList = ${photoList.size}")
            if (!mixMediaInfoList.isLoadPhotoListFinish) {
                mixMediaInfoList.isLoadPhotoListFinish = true
                mixMediaInfoList.photoList.addAll(photoList)
                if (mixMediaInfoList.isLoadVideoListFinish) {
                    lifecycleScope.launch { updateViewPager(mixMediaInfoList.getMaxMediaList()) }
                }
            }
        }
        viewModel.getSysVideosLiveData.observe(this) { videoList ->
            Log.i(TAG, "initViewModel: videoList = ${videoList.size}")
            if (!mixMediaInfoList.isLoadVideoListFinish) {
                mixMediaInfoList.isLoadVideoListFinish = true
                mixMediaInfoList.videoList.addAll(videoList)
                if (mixMediaInfoList.isLoadPhotoListFinish) {
                    lifecycleScope.launch { updateViewPager(mixMediaInfoList.getMaxMediaList()) }
                }
            }
        }
    }

    override fun onDestroy() {
        super.onDestroy()
        bigDetailAdapter?.releaseAllVideos()
    }

5.7 页面前后滑处理

Kotlin 复制代码
    override fun scrollForward() {
        val position = binding.bvFeedPhotoContent.currentItem
        Log.i(TAG, "scrollForward: position=$position,size=${allPhotoInfoList.size}")
        binding.bvFeedPhotoContent.currentItem = position + 1
    }

    override fun scrollBackward() {
        val position = binding.bvFeedPhotoContent.currentItem
        Log.i(TAG, "scrollBackward: position=$position,size=${allPhotoInfoList.size}")
        binding.bvFeedPhotoContent.currentItem = position - 1
    }

5.8 gradle 依赖

Kotlin 复制代码
    // CameraX core library using the camera2 implementation
    // The following line is optional, as the core library is included indirectly by camera-camera2
    // implementation "androidx.camera:camera-core:${camerax_version}"
    implementation "androidx.camera:camera-camera2:${CAMERAX}"
    // If you want to additionally use the CameraX Lifecycle library
    implementation "androidx.camera:camera-lifecycle:${CAMERAX}"
    // If you want to additionally use the CameraX View class
    implementation "androidx.camera:camera-view:${CAMERA_VIEW}"
    // If you want to additionally use the CameraX Extensions library
    // implementation "androidx.camera:camera-extensions:1.0.0-alpha31"

    implementation "com.github.zhpanvip:BannerViewPager:${BANNER_VIEW_PAGER}"
    implementation "com.github.chrisbanes:PhotoView:${PHOTO_VIEW}"
    implementation "com.davemorrissey.labs:subsampling-scale-image-view-androidx:${SUBSAMPLING_SCALE_IMAGE_VIEW_ANDROIDX}"
    // ijkplayer
    implementation "com.github.CarGuo.GSYVideoPlayer:gsyVideoPlayer-java:${GSY_VIDEO_PLAYER}"
    implementation "com.github.CarGuo.GSYVideoPlayer:gsyVideoPlayer-armv7a:${GSY_VIDEO_PLAYER}"
    implementation 'com.tencent.tav:libpag:4.2.41'


    CAMERAX = "1.1.0-alpha11"
    CAMERA_VIEW = "1.0.0-alpha31"
    BANNER_VIEW_PAGER = "3.5.0"
    PHOTO_VIEW = "2.0.0"
    SUBSAMPLING_SCALE_IMAGE_VIEW_ANDROIDX = "3.10.0"
    GSY_VIDEO_PLAYER = "v8.3.4-release-jitpack"

6. ✅ 小结

对于 AR 眼镜上的 Camera 功能,可以去实现很多有意思的产品功能,本文只是一个基础的拍照、录像和查看实现方案,更多业务细节请参考产品逻辑去创造。

另外,由于本人能力有限,如有错误,敬请批评指正,谢谢。

相关推荐
Swuagg5 天前
数字经济下的 AR 眼镜
ar·ar眼镜·数字经济
虹科数字化与AR10 天前
安宝特分享 | AR技术助力医院总院与分院间的远程面诊
ar·ar眼镜·远程医疗·ar医疗
市象25 天前
AR商业化的“AI转身”
ar·ar眼镜·rokid
深圳智物通讯1 个月前
AR智能眼镜|AR眼镜定制开发|工业AR眼镜方案
ar·ar眼镜·ai智能眼镜
虹科数字化与AR1 个月前
安宝特方案 | AR助力紧急救援,科技守卫生命每一刻!
ar·ar眼镜·远程协助
虹科数字化与AR1 个月前
安宝特分享 | 如何利用AR技术革新医疗实践:从远程急救到多学科协作
ar·ar眼镜·ar医疗·工业ar
虹科数字化与AR2 个月前
安宝特案例 | AR技术在院外心脏骤停急救中的革命性应用
ar·ar眼镜·ar医疗·急诊
虹科数字化与AR2 个月前
安宝特分享 | AR技术引领:跨国工业远程协作创新模式
ar·智能制造·ar眼镜·远程协作
虹科数字化与AR2 个月前
安宝特案例 | Fundació Puigvert 医院应用AR技术开创尿石症治疗新纪元
ar·手术·ar眼镜·远程医疗·ar医疗