基于 FFmpeg 的跨平台视频播放器简明教程(十二):Android SurfaceView 显示图片和播放视频

系列文章目录

  1. 基于 FFmpeg 的跨平台视频播放器简明教程(一):FFMPEG + Conan 环境集成
  2. 基于 FFmpeg 的跨平台视频播放器简明教程(二):基础知识和解封装(demux)
  3. 基于 FFmpeg 的跨平台视频播放器简明教程(三):视频解码
  4. 基于 FFmpeg 的跨平台视频播放器简明教程(四):像素格式与格式转换
  5. 基于 FFmpeg 的跨平台视频播放器简明教程(五):使用 SDL 播放视频
  6. 基于 FFmpeg 的跨平台视频播放器简明教程(六):使用 SDL 播放音频和视频
  7. 基于 FFmpeg 的跨平台视频播放器简明教程(七):使用多线程解码视频和音频
  8. 基于 FFmpeg 的跨平台视频播放器简明教程(八):音画同步
  9. 基于 FFmpeg 的跨平台视频播放器简明教程(九):Seek 策略
  10. 基于 FFmpeg 的跨平台视频播放器简明教程(十):在 Android 运行 FFmpeg
  11. 基于 FFmpeg 的跨平台视频播放器简明教程(十一):一种简易播放器的架构介绍

前言

上一章中我们介绍了一个简易的播放器架构,对之前零碎的代码片段进行了组织和重构,形成了较为灵活的一种架构设计,它非常简单,但足够满足我们的需求。

现在,接着我们在 Android 上的旅程。今天我们来讨论如何在 Android 上显示画面。

Android 原生的 Java/Kotlin 接口播放视频还是很容易的,有 MediaController、MediaPlayer 等类可以直接使用,相关教程参考Android实现视频播放的3种实现方式

由于我们的代码几乎都是 C/C++ ,因此需要找到一种从 Native 层进行视频播放的方法。这里要介绍的是 SurfaceView + Native 的显示方式。

Android 图像绘制介绍

关于 Android 图像绘制系统网上有很多文章说的较为清楚了,例如

这些内容中,我们要重点关注 BufferQueue 中生产者与消费者之间的关系

例如:一个Activity是一个Surface、一个Dialog也是一个Surface,承载了上层的图形数据,与SurfaceFlinger侧的Layer相对应。

Native层Surface实现了ANativeWindow结构体,在构造函数中持有一个IGraphicBufferProducer,用于和 BufferQueue 进行交互。


版权声明:本文为CSDN博主「Jason_Lee155」的原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接及本声明。

原文链接:https://blog.csdn.net/jason_lee155/article/details/121663662

有了上述的基本认识后,接下来我们介绍 SurfaceView

Android 中 Surface 与 SurfaceView

关于 Surface 的介绍文章有:

总结下上述文章的对我们来说重要的理解内容:

  1. 无论开发者使用什么渲染API,一切内容都会渲染到Surface上;Surface中会关联一个BufferQueue用于提供图像数据缓存
  2. Suface 继承自 ANativeWindow,可以通过 dequeueBuffer,queueBuffer,lockBuffer 等接口拿到 BufferQueue 中的 Buffer 对象

关于 SurfaceView 的介绍文章有:

总结下上述文章对我们来说的重点内容:

  1. SurfaceView和宿主窗口是分离的。正常情况下窗口的View共享同一个Window,而Window也对应一个Surface,所有View也就共享同一个Surface。而 SurfaceView 具备独立的Surface,相当于和宿主窗口绘制是分离互不干扰。
  2. SurfaceView 的核心在于提供了两个线程:UI线程和渲染线程,两个线程通过"双缓冲"机制来达到高效的界面适时更新

OK,我们将上面的知识串起来:

  1. SurfaceView 持有一个 Surface。
  2. 通过 Surface 我们能够获取到 BufferQueue 中的一个 Buffer。
  3. 将图像绘制到这个 Buffer 后,就能正确的显示图像。
  4. SurfaceView 中的 Surface 与其他窗口的 View 是独立的,我们可以在另一个线程中去渲染它

JNI SurfaceView 显示图片

在了解了 Surface 和 SurfaceView 后,我们现在已经能够做到使用 JNI(NDK)和 SurfaceView 来显示一张图片了,具体代码在 T02DisplayImageActivity 中,现在做一些代码上的解释。

kotlin 复制代码
class T02DisplayImageActivity : AppCompatActivity() {
    private lateinit var mSurfaceView: MySurfaceView

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_display_image)

        // get image bitmap
        val options = BitmapFactory.Options()
        options.inScaled = false
        val bitmap = BitmapFactory.decodeResource(resources, R.drawable.test, options)

        mSurfaceView = findViewById(R.id.surfaceView_display_image)
        mSurfaceView.setAspectRation(bitmap.width, bitmap.height)

        mSurfaceView.holder.addCallback(object: SurfaceHolder.Callback{
            override fun surfaceCreated(holder: SurfaceHolder) {
                val surface = holder.surface
                renderImage(surface, bitmap)
            }

            override fun surfaceChanged(p0: SurfaceHolder, format: Int, width: Int, height: Int) {
            }

            override fun surfaceDestroyed(p0: SurfaceHolder) {
            }

        })
    }

    external fun renderImage(surface: Surface, bitmap: Bitmap);
}
  1. 我们在 R.layout.activity_display_image 中布局中放置一个 MySurfaceView 用于显示图片。MySurfaceView 继承自 SurfaceView,并且做了一些方法的重写,这部分后面再细说。
  2. 通过 BitmapFactory 来读取一张图片,用于显示
  3. 接下来,添加了一个SurfaceHolder的回调函数,用于监听SurfaceView的生命周期。具体来说:surfaceCreated()方法在SurfaceView创建时被调用,其中的holder参数表示SurfaceView的SurfaceHolder对象,可以通过它获取到Surface对象。在这个方法中,调用了renderImage()函数,将bitmap渲染到Surface上。
  4. 现在看看 renderImage 方法做了什么,代码如下:
cpp 复制代码
extern "C"
JNIEXPORT void JNICALL
Java_com_example_videoplayertutorials_T02DisplayImageActivity_renderImage(JNIEnv *env,
                                                                          jobject thiz,
                                                                          jobject surface,
                                                                          jobject bitmap) {
  AndroidBitmapInfo info;
  AndroidBitmap_getInfo(env, bitmap, &info);

  char *data = NULL;
  AndroidBitmap_lockPixels(env, bitmap, (void **) &data);

  ANativeWindow *nativeWindow = ANativeWindow_fromSurface(env, surface);
  ANativeWindow_setBuffersGeometry(nativeWindow, info.width, info.height,
                                   AHARDWAREBUFFER_FORMAT_R8G8B8A8_UNORM);

  ANativeWindow_Buffer buffer;
  ANativeWindow_lock(nativeWindow, &buffer, NULL);

  auto *data_src_line = (int32_t *) data;
  const auto src_line_stride = info.stride / sizeof(int32_t);

  auto *data_dst_line = (uint32_t *) buffer.bits;
  for (int y = 0; y < buffer.height; y++) {
    std::copy_n(data_src_line, buffer.width, data_dst_line);

    data_src_line += src_line_stride;

    data_dst_line += buffer.stride;
  }

  ANativeWindow_unlockAndPost(nativeWindow);
  AndroidBitmap_unlockPixels(env, bitmap);

  ANativeWindow_release(nativeWindow);
}
  1. 通过AndroidBitmap_getInfo函数获取Bitmap的信息,包括宽度、高度、格式等。

  2. 通过AndroidBitmap_lockPixels函数锁定Bitmap的像素,防止在操作过程中被其他线程修改。

  3. 通过ANativeWindow_fromSurface函数获取Surface对应的ANativeWindow。

  4. 通过ANativeWindow_setBuffersGeometry函数设置ANativeWindow的缓冲区大小和格式。注意,这里我们设置的格式是 AHARDWAREBUFFER_FORMAT_R8G8B8A8_UNORM,也就是 RGBA 格式,因为我们的数据源 bitmap 它就是 RGBA 格式的。你可以在 AHardwareBuffer_Format 找到所有支持的格式,其中 YUV420 也是支持的。

  5. 通过ANativeWindow_lock函数锁定ANativeWindow的缓冲区,准备写入数据。

  6. 将Bitmap的像素数据复制到ANativeWindow的缓冲区。

  7. 通过ANativeWindow_unlockAndPost函数解锁ANativeWindow的缓冲区,并将缓冲区的内容显示到屏幕上。

  8. 通过AndroidBitmap_unlockPixels函数解锁Bitmap的像素。

  9. 通过ANativeWindow_release函数释放ANativeWindow。

如果你的代码正确,那么可以看到图片正常显示,如下图:

正确的 View 大小

为了说明 View 大小的问题,让我们先将代码中的 MySurfaceView 全部改为 SurfaceView,运行代码后,你会发现图片显示时被拉伸填充了,如下图:

额,所以这是为啥嘞?让我们来分析分析:

  1. 首先,在 ANativeWindow_setBuffersGeometry 中,我们对 Buffer 大小和格式进行了设置,它的大小与图片大小是一致的。只有与 Buffer 大小一致,才能正确地将所有图片数据拷贝到 Buffer 上。所以这里是没问题的。
  2. 通过 Android Studio 的 Layout Inspector 工具,可以看到 Surface View 填满了整个屏幕
  3. 也就是说 Buffer 中图片,被 Android 系统做了 resize 使其填充到整个屏幕了。因此导致了图像的拉伸情况。

了解了原因,那么如何解决这个问题呢?大致思路是去修改 view 的尺寸,让 view 适配视频的尺寸。具体的:

  1. 新建 MySurfaceView,继承自 SurfaceView
  2. 在 MySurfaceView 新增 setAspectRation 方法,设置 view 的宽高比,例如 16:9 是一个横屏的宽高比
  3. 接着,重写 onMeasure 方法,在 onMeasure 方法中根据宽高比, 具体代码如下:
kotlin 复制代码
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec)
        val width = MeasureSpec.getSize(widthMeasureSpec)
        val height = MeasureSpec.getSize(heightMeasureSpec)
        if(mWidth == 0){
            setMeasuredDimension(width, height)
            return
        }

        // calculate expected width by ratio
        val expectedWidth = height * mWidth / mHeight

        // if expected width is too big, set max width to expected width
        if(expectedWidth >= width){
            // to maintain aspect ratio, calculate expected height
            val expectedHeight = width * mHeight / mWidth
            setMeasuredDimension(width, expectedHeight)
        }else{
            // or the expected width can fit in the parent, set the expected width
            setMeasuredDimension(expectedWidth, height)
        }
    }
  • 首先,调用父类方法 super.onMeasure 和 MeasureSpec.getSize 获取预期的 width 和 height。例如 width=1080, height=2070,即屏幕的大小
  • 接着,根据预期的宽高比去计算 expectedWidth,例如宽高比是 16:9 ,那么 e x p W h = 16 9 \frac{expW}{h}=\frac{16}{9} hexpW=916 ,得到 expectedWidth = height * 16/9 = 2070 * 16/9 = 3680
  • 如果 expectedWidth >= width 说明如果按照宽高比对现有 view 进行等比例放大,那么超过目前可接受的最大宽度,无法满足。因此,我们转而去缩放 height,以便最终 view 的宽高比符合我们的预期。因此 w e x p H = 16 9 \frac{w}{expH}=\frac{16}{9} expHw=916 得到 val expectedHeight = width * mHeight / mWidth
  • 如果 expectedWidth < width 说明当前的 view 可以放下,则直接设置 setMeasuredDimension(expectedWidth, height) 即可

完成了上述的修改,我们使用 MySurfaceView 进行视频的显示,此时 View 的尺寸符合图片的宽高比,图片也不会被拉伸和缩放了

SurfaceView 播放视频

我们了解了如何使用 SurfaceView 显示图片,并解决了图片被拉伸的问题。显示视频那也就水到渠成了,视频只是很多张图片罢了。

首先,我们将上屏显示图像的模块进行封装,在 基于 FFmpeg 的跨平台视频播放器简明教程(十一):一种简易播放器的架构介绍 文中,VideoOutput 模块负责显示视频帧,我们新建一个叫 SurfaceViewVideoOutput 的类,用它来在 SurfaceView 上显示图片。具体代码在 j_andr_surfaceview_video_output 中,其中 drawFrame 完全与显示图片的代码是一致的:

cpp 复制代码
int drawFrame(std::shared_ptr<Frame> frame) override {
    if(nativeWindow_ == nullptr) {
      LOGE("nativeWindow_ is null, can't drawFrame");
      return -1;
    }

    ANativeWindow_Buffer buffer;
    ANativeWindow_lock(nativeWindow_, &buffer, NULL);

    // copy frame to buffer
    auto *data_src_line = (int32_t *) frame->f->data[0];
    const auto src_line_stride = frame->f->linesize[0] / sizeof(int32_t);

    auto *data_dst_line = (uint32_t *) buffer.bits;
    auto height = std::min(buffer.height, frame->f->height);
    for (int y = 0; y < height; y++) {
      std::copy_n(data_src_line, buffer.width, data_dst_line);

      data_src_line += src_line_stride;

      data_dst_line += buffer.stride;
    }

    ANativeWindow_unlockAndPost(nativeWindow_);

    return 0;
  }

接着,为了在 Android 上更容易使用 C/C++ 播放器代码,我们创建一个 SimplePlayer 的 Kotlin 类,它是对底层 j_video_player::SimplePlayer 的封装,所有与播放相关接口都通过 SimplePlayer 最终调用到 C/C++ 层。具体代码查看 SimplePlayer 以及它的 JNI 实现 jni_simple_player

完成了上面的动作,我们在 Android 上就能愉快地进行视频播放了,具体代码在 DisplayVideoActivity 中。

总结

本文首先简略的介绍了 Android 图像的显示系统,引出 BufferQueue 的概念;接着介绍了 Surface 和 SurfaceView,Surface 关联着一个 BufferQueue,而 SurfaceView 持有一个 Surface;接下来,我们展示了如何在 SurfaceView 上显示图片,并解决图片宽高比与手机屏幕不一致导致的图像拉伸问题;最后,我们使用 SimplePlayer 在 SurfaceView 做视频播放。

参考

相关推荐
每次的天空4 小时前
Android学习总结之算法篇五(字符串)
android·学习·算法
Gracker5 小时前
Android Weekly #202513
android
张拭心7 小时前
工作九年程序员的三月小结
android·前端
每次的天空7 小时前
Flutter学习总结之Android渲染对比
android·学习·flutter
写代码的小王吧8 小时前
【安全】Java幂等性校验解决重复点击(6种实现方式)
java·linux·开发语言·安全·web安全·网络安全·音视频
鸿蒙布道师10 小时前
鸿蒙NEXT开发土司工具类(ArkTs)
android·ios·华为·harmonyos·arkts·鸿蒙系统·huawei
智想天开10 小时前
11.使用依赖注入容器实现松耦合
android
yunteng52111 小时前
音视频(四)android编译
android·ffmpeg·音视频·x264·x265
tangweiguo0305198711 小时前
(kotlin) Android 13 高版本 图片选择、显示与裁剪功能实现
android·开发语言·kotlin
匹马夕阳11 小时前
(一)前端程序员转安卓开发分析和规划建议
android·前端