1. 项目缘起:为什么要在Ijkplayer上做录像和截图?
做音视频开发的朋友,对Ijkplayer这个名字应该不陌生。作为一款基于FFmpeg的轻量级Android/iOS播放器内核,它凭借优秀的兼容性和可定制性,在众多需要深度定制播放器的项目中扮演着核心角色。然而,Ijkplayer官方库更像是一个“纯净”的播放引擎,它专注于解码、渲染和播放控制,对于很多业务场景中常见的“附加功能”,比如录像(录制正在播放的视频流)和截图(抓取当前播放画面),并没有提供开箱即用的API。
这就引出了一个非常实际的需求:当你的App基于Ijkplayer构建了一个功能完善的播放器,产品经理突然提出“用户需要能保存当前播放的精彩瞬间”或者“希望能把这段直播录下来回看”,你该怎么办?你不可能去魔改FFmpeg的核心解码流程,也不可能让用户去系统相册里翻找。这个需求,必须在我们自己的应用层,或者说在Ijkplayer的“外围”来解决。
我最近就在一个在线教育项目中遇到了这个需求。我们需要允许用户对课程视频进行任意时刻的截图保存,并且对直播课的内容进行本地录制,方便课后复习。经过一番折腾和踩坑,最终形成了一套相对稳定、高效的实现方案。今天,我就把这套方案的核心思路、关键代码以及那些官方文档里不会写的“坑”和技巧,毫无保留地分享出来。无论你是刚接触Ijkplayer,还是正在为类似功能头疼,相信这篇内容都能给你提供一条清晰的路径。
2. 核心思路拆解:录像与截图的本质差异
在动手写代码之前,我们必须从原理上厘清“录像”和“截图”这两个功能在Ijkplayer上下文下的本质区别。这决定了我们后续技术选型和实现路径的完全不同。
2.1 截图:单帧画面的捕获与编码
截图,本质上是在某个精确的时间点,获取视频渲染器(SurfaceView/TextureView)上当前呈现的那一帧图像数据,并将其编码成一张图片(通常是JPEG或PNG)保存到本地。
这里的关键在于“获取数据源”。对于Ijkplayer,我们通常有几种思路:
从渲染层抓取:这是最直观的想法。既然画面最终渲染到了
SurfaceView或TextureView上,那直接对这个View进行截图不就行了?对于TextureView,确实可以通过getBitmap()方法轻松拿到Bitmap对象。这个方案简单粗暴,但存在一个致命问题:它截取的是经过缩放、旋转、添加了UI覆盖层(如播放按钮、字幕)之后的“最终屏幕画面”。如果你需要的是纯净的、原始比例的视频帧,这个方案就不合适。从解码层拦截:这是更专业的做法。Ijkplayer在解码视频流后,会得到原始的YUV或RGB帧数据,然后才交给渲染器。我们可以在数据送往渲染器之前,拦截下某一帧。这种方式能获得最原始的、未经过任何UI污染的图像数据,画质有保证,并且可以获取到精确到帧的时间戳信息。Ijkplayer的
IjkMediaPlayer类提供了一些扩展接口,允许我们注册一个回调来获取解码后的视频帧(AVFrame),这为我们实现方案二提供了可能。
结论:对于追求原始画质和精确控制的场景(如专业工具、内容审核),从解码层拦截帧数据是更优选择。而对于快速实现、且不介意包含播放器UI的普通场景,从TextureView截图也是一种可行的备选方案。
2.2 录像:流媒体的重封装与录制
录像则复杂得多。它不再是获取单张图片,而是要将正在播放的音视频流,在播放的同时,另存为一个新的媒体文件(如MP4)。
这个过程可以类比为“搭桥”:播放器从网络或本地读取原始流(A点),解码后送到渲染器和扬声器(B点)。我们需要在A点到B点之间的某个位置,分出一条支流(C点),将原始的、或者解码后再编码的音视频数据,按照时间顺序重新封装成一个文件。
这里的技术路线选择直接决定了实现的复杂度、性能开销和最终文件的质量:
方案A:录屏(Screen Recording):这是操作系统级别的功能。在Android上,你可以使用
MediaProjectionAPI来录制整个屏幕或指定窗口的内容。这个方案完全独立于Ijkplayer,你录下的是包括播放器界面、系统状态栏在内的所有东西。它的优点是实现相对标准,不依赖播放器内部逻辑;缺点是无法分离纯音视频流,文件体积大,可能涉及隐私权限(需要用户授权),并且在不同系统版本上兼容性不一。方案B:传输流录制(Stream Recording):这是更贴近“录像”本质的方案。我们录制的是Ijkplayer接收到的原始数据流。如果播放的是MP4文件或HTTP-FLV/TS流,我们可以在网络层或解复用(demux)之后,直接将接收到的音视频包(如H.264 NALU, AAC帧)写入一个新的文件容器中。这个过程不需要重新编码,因此性能损耗极低,画质无损,文件体积也相对较小。但它的局限性也很明显:只能录制播放器当前支持的封装格式,并且如果流本身是加密的或特殊编码,就无法直接保存。
方案C:解码后重新编码录制(Transcoding Recording):这是最通用但也是最重的方案。我们像截图方案二那样,从解码后拿到原始的音频帧(PCM)和视频帧(YUV/RGB),然后利用如
MediaCodec、FFmpeg或MediaRecorder等编码器,将它们重新编码(如H.264+AAC),再封装成MP4。这个方案灵活性最高,可以统一输出格式、调整分辨率、码率,但会带来巨大的CPU/GPU开销,对设备性能要求高,不适合长时间录制。
结论:对于大多数播放器内录像需求,方案B(传输流录制)是平衡性能、画质和实现复杂度的最佳选择,前提是片源格式支持。方案C可以作为格式转换或处理的备选,但需谨慎评估性能。方案A(录屏)更适用于录制整个App操作流程,而非单纯的播放内容。
基于以上分析,本文将重点分享:
- 截图:采用从解码层拦截AVFrame的方案,实现高画质、精确的截图。
- 录像:采用传输流录制的方案,实现高性能、无损的直播/视频录制。
3. 环境准备与Ijkplayer深度集成
在开始编码前,我们需要搭建好开发环境,并对Ijkplayer有更深入的了解,特别是如何访问其内部数据。
3.1 Ijkplayer的引入与配置
首先,通过Gradle引入Ijkplayer。推荐使用维护较新的分支或自己编译,以获得更多可控的接口。
// 在项目的build.gradle中添加jitpack仓库(如果使用jitpack版本) allprojects { repositories { ... maven { url 'https://jitpack.io' } } } // 在app模块的build.gradle中添加依赖 dependencies { implementation 'com.github.CarGuo.GSYVideoPlayer:gsyVideoPlayer-java:v8.3.5' // 这是一个包含ijkplayer的流行播放器库,也可直接用纯ijkplayer // 或者使用纯ijkplayer // implementation 'tv.danmaku.ijk.media:ijkplayer-java:0.8.8' // implementation 'tv.danmaku.ijk.media:ijkplayer-armv7a:0.8.8' // 根据需要添加其他架构 }如果你需要自定义编译,开启特定的编解码器或协议支持(比如对于录像功能,确保mpegts、hls等demuxer已开启),则需要下载Ijkplayer源码,参考其官方文档进行编译。这个过程较为复杂,但能获得最大的灵活性。
3.2 理解Ijkplayer的数据管道与扩展点
Ijkplayer的核心是IjkMediaPlayer,它封装了FFmpeg的AVFormatContext、AVCodecContext等核心结构。要实现我们的高级功能,必须与其内部状态进行交互。
- 监听器与回调:标准的
MediaPlayer监听器(OnInfoListener,OnErrorListener)用于处理播放状态、分辨率变化等通用事件。 - IjkMediaPlayer的扩展选项:
IjkMediaPlayer提供了setOption方法,可以配置大量FFmpeg级别的参数,这对调试和功能启用至关重要。 - Native层回调:这是我们实现截图功能的关键。Ijkplayer在Native层(C代码)提供了钩子函数(hook),允许我们在视频帧解码后、渲染前拿到
AVFrame数据。Java层需要通过JNI接口来设置这个回调。
通常,一个深度集成的Ijkplayer播放器类会这样初始化:
public class CustomIjkPlayer { private IjkMediaPlayer mMediaPlayer; public void initPlayer() { mMediaPlayer = new IjkMediaPlayer(); // 设置一些常用选项,提升兼容性 mMediaPlayer.setOption(IjkMediaPlayer.OPT_CATEGORY_PLAYER, "mediacodec", 1L); // 开启硬解 mMediaPlayer.setOption(IjkMediaPlayer.OPT_CATEGORY_PLAYER, "mediacodec-auto-rotate", 1L); mMediaPlayer.setOption(IjkMediaPlayer.OPT_CATEGORY_PLAYER, "opensles", 0L); mMediaPlayer.setOption(IjkMediaPlayer.OPT_CATEGORY_FORMAT, "http-detect-range-support", 0L); mMediaPlayer.setOption(IjkMediaPlayer.OPT_CATEGORY_CODEC, "skip_loop_filter", 48L); // 跳帧策略 // 设置监听器 mMediaPlayer.setOnPreparedListener(...); mMediaPlayer.setOnInfoListener(...); mMediaPlayer.setOnErrorListener(...); // ... 其他监听器 } }准备工作就绪后,我们就可以进入具体的功能实现了。
4. 实战:实现高精度视频截图功能
我们选择从解码层拦截AVFrame的方案。这需要修改或扩展Ijkplayer的Native代码,并建立JNI桥接。
4.1 建立Native层帧捕获回调
这是整个截图功能最核心也最复杂的一步。你需要修改Ijkplayer的C源码(通常是ijkplayer/ijkmedia/ijkplayer/目录下的文件)。
步骤一:定义回调接口在某个头文件(如frame_capture.h)中,定义帧数据回调的函数指针类型。
// frame_capture.h #ifndef IJKFRAME_CAPTURE_H #define IJKFRAME_CAPTURE_H #include <libavutil/frame.h> typedef void (*on_frame_captured)(AVFrame *frame, void *opaque); // 设置全局回调函数 void set_frame_capture_callback(on_frame_captured callback, void *opaque); #endif步骤二:在视频渲染线程注入回调找到视频帧解码后、准备渲染前的函数(例如在ffplay.c的video_refresh函数内部,或在ijksdl_vout.c的渲染函数中)。在合适的时机,调用我们设置的回调函数。
// 在某个视频处理函数中,比如处理完一个AVFrame后 static void video_image_display2(...) { AVFrame *frame = ...; // 获取到当前要渲染的帧 // ... 其他处理逻辑 // 调用截图回调 extern on_frame_captured g_frame_callback; extern void *g_callback_opaque; if (g_frame_callback && frame) { // 注意:这里传递的是AVFrame的指针,确保在回调使用期间frame有效。 // 一种稳妥的做法是av_frame_ref一个副本传给回调,由回调方负责释放。 AVFrame *frame_copy = av_frame_alloc(); av_frame_ref(frame_copy, frame); g_frame_callback(frame_copy, g_callback_opaque); } // ... 后续渲染逻辑 }步骤三:实现JNI桥接创建JNI文件(如ijkplayer_android.c),暴露一个Java可调用的Native方法,用于设置回调。同时,在JNI层实现一个函数,当Native回调触发时,通过JNI将帧数据(如转换为RGB格式的字节数组或直接传递YUV数据)回调到Java层。
// ijkplayer_android.c Java_com_yourpackage_CustomIjkPlayer_setFrameCaptureCallback(JNIEnv *env, jobject thiz) { set_frame_capture_callback(my_native_frame_callback, (void*)env->NewGlobalRef(thiz)); } // Native层的回调函数 void my_native_frame_callback(AVFrame *frame, void *opaque) { JNIEnv *env = ...; // 获取JNIEnv,注意线程绑定 jobject java_obj = (jobject)opaque; // 将AVFrame数据转换为Java可用的格式,例如将YUV转换为RGB Bitmap所需的字节数组 // 这是一个复杂的过程,涉及色彩空间转换和内存拷贝 // 1. 获取frame的宽高、格式 // 2. 使用sws_scale将frame数据转换到目标格式(如AV_PIX_FMT_RGBA) // 3. 将转换后的数据通过JNI传递给Java // 伪代码示例: // jbyteArray data = env->NewByteArray(rgb_size); // env->SetByteArrayRegion(data, 0, rgb_size, (jbyte*)rgb_buffer); // env->CallVoidMethod(java_obj, jmethod_onFrameCaptured, data, width, height); // env->DeleteLocalRef(data); av_frame_free(&frame); // 释放副本 }4.2 Java层接收与处理帧数据
在Java层,我们需要定义一个接口来接收Native层回调过来的帧数据。
public interface FrameCaptureListener { /** * 当捕获到一帧视频数据时回调 * @param data RGB或RGBA格式的字节数组 * @param width 帧宽度 * @param height 帧高度 * @param format 像素格式,例如 Bitmap.Config.ARGB_8888 */ void onFrameCaptured(byte[] data, int width, int height, Bitmap.Config format); }在自定义的播放器类中:
public class CustomIjkPlayer { private FrameCaptureListener mCaptureListener; private IjkMediaPlayer mMediaPlayer; // 设置监听器 public void setFrameCaptureListener(FrameCaptureListener listener) { this.mCaptureListener = listener; if (listener != null) { // 调用Native方法,启用帧捕获 nativeSetFrameCaptureEnabled(true); } else { nativeSetFrameCaptureEnabled(false); } } // 供JNI调用的方法 @CalledByNative private void onFrameData(byte[] rgbData, int width, int height) { if (mCaptureListener != null) { runOnUiThread(() -> { // 注意:在UI线程创建Bitmap Bitmap bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888); ByteBuffer buffer = ByteBuffer.wrap(rgbData); bitmap.copyPixelsFromBuffer(buffer); mCaptureListener.onFrameCaptured(bitmap); // 可以传递Bitmap更方便 }); } } // 外部调用的截图方法 public void captureCurrentFrame() { // 这个方法并不是立刻就能拿到图,而是触发一个信号, // 让Native层在下一帧渲染时回调onFrameData。 // 可以通过设置一个标志位,在my_native_frame_callback中检查该标志位,为真时才回调Java层。 nativeRequestFrameCapture(); } // Native方法声明 private native void nativeSetFrameCaptureEnabled(boolean enabled); private native void nativeRequestFrameCapture(); // ... 加载native库 static { System.loadLibrary("your-ijkplayer-lib"); } }4.3 将Bitmap保存至相册
拿到Bitmap对象后,保存到相册就是标准的Android操作了。注意Android Q(API 29)及以上版本作用域存储(Scoped Storage)的权限变化。
public class BitmapSaver { public static void saveBitmapToGallery(Context context, Bitmap bitmap, String fileName, OnSaveListener listener) { if (bitmap == null || bitmap.isRecycled()) { listener.onFailed("Bitmap is invalid"); return; } // 1. 创建图片文件(MediaStore方式,兼容Android Q+) ContentValues values = new ContentValues(); values.put(MediaStore.Images.Media.DISPLAY_NAME, fileName); values.put(MediaStore.Images.Media.MIME_TYPE, "image/jpeg"); values.put(MediaStore.Images.Media.RELATIVE_PATH, Environment.DIRECTORY_PICTURES + "/YourAppName"); ContentResolver resolver = context.getContentResolver(); Uri uri = null; try { uri = resolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values); if (uri == null) { listener.onFailed("Failed to create new MediaStore record."); return; } // 2. 将Bitmap写入OutputStream OutputStream os = resolver.openOutputStream(uri); if (os == null) { listener.onFailed("Failed to open output stream."); return; } boolean success = bitmap.compress(Bitmap.CompressFormat.JPEG, 90, os); // JPEG质量90% os.close(); if (success) { // 3. 通知系统相册更新(可选,但建议) Intent mediaScanIntent = new Intent(Intent.ACTION_MEDIA_SCANNER_SCAN_FILE); mediaScanIntent.setData(uri); context.sendBroadcast(mediaScanIntent); listener.onSuccess(uri); } else { resolver.delete(uri, null, null); // 删除失败的记录 listener.onFailed("Failed to compress bitmap."); } } catch (IOException e) { e.printStackTrace(); if (uri != null) { resolver.delete(uri, null, null); } listener.onFailed("IO Error: " + e.getMessage()); } } public interface OnSaveListener { void onSuccess(Uri savedUri); void onFailed(String errorMsg); } }关键注意事项与踩坑点:
- 性能与内存:
AVFrame到Bitmap的转换(特别是sws_scale)是CPU密集型操作,频繁截图会导致卡顿。务必在非UI线程执行,并考虑降低截图分辨率(在Native层转换时指定小一点的宽高)。 - 线程安全:Native回调可能发生在非UI线程,操作UI或创建
Bitmap必须切回主线程。 - 帧时效性:
captureCurrentFrame()是异步的,它请求的是“下一帧”或“最近一帧”,而不是“当前屏幕显示的那一帧”。对于精确到秒的截图需求,这个延迟通常可以接受。如果要求绝对精确,可能需要结合播放器的当前时间戳和帧回调时间戳做更复杂的同步。 - 格式兼容:确保Native层转换的像素格式(如
AV_PIX_FMT_RGBA)与Java层Bitmap.Config(如ARGB_8888)匹配,否则颜色会错乱。
5. 实战:实现高效流媒体录制功能
我们采用传输流录制(方案B)。核心思想是:复制Ijkplayer读取到的原始数据包,并将其写入一个新的文件。
5.1 拦截与复制数据包
同样,我们需要深入到Ijkplayer的数据读取层。Ijkplayer通过AVFormatContext的read_frame函数读取音视频包(AVPacket)。我们需要在这个读取动作发生后,将AVPacket复制一份。
步骤一:修改Read Thread找到Ijkplayer中负责读包的线程(通常在ffplay.c的read_thread函数里)。在av_read_frame调用成功后,将读取到的AVPacket分发出去。
// 在read_thread中 for (;;) { AVPacket pkt; ret = av_read_frame(ic, &pkt); if (ret < 0) { break; // 读取结束或出错 } // ... 原有的队列推送逻辑 // >>> 新增:录制逻辑 <<< if (is_recording_enabled) { // 复制AVPacket。注意:av_packet_ref会增加buf的引用计数,管理好内存。 AVPacket pkt_copy; av_init_packet(&pkt_copy); if (av_packet_ref(&pkt_copy, &pkt) >= 0) { // 将pkt_copy放入一个录制专用的队列,由另一个录制线程消费 packet_recorder_push(pkt_copy); } } // >>> 结束新增 <<< av_packet_unref(&pkt); // 释放原始pkt }步骤二:创建录制线程与队列我们需要一个单独的线程和线程安全的队列来处理录制任务,避免阻塞主播放线程。
// 定义一个线程安全的AVPacket队列 typedef struct PacketRecorder { AVFifoBuffer *pkt_fifo; pthread_mutex_t mutex; pthread_cond_t cond; int abort_request; // ... 其他状态,如输出格式上下文等 } PacketRecorder; // 初始化、销毁、推送、弹出队列的函数 PacketRecorder* recorder_init(); void recorder_destroy(PacketRecorder* r); int recorder_push_packet(PacketRecorder* r, AVPacket *pkt); int recorder_pop_packet(PacketRecorder* r, AVPacket *pkt, int block);录制线程的主循环大致如下:
static void *record_thread(void *arg) { PacketRecorder *recorder = (PacketRecorder *)arg; AVPacket pkt; // 1. 创建输出格式上下文(AVFormatContext) AVFormatContext *oc = NULL; avformat_alloc_output_context2(&oc, NULL, "mp4", NULL); // 输出为mp4 // ... 配置输出流(stream),需要从输入流复制codecpar信息 // 2. 写文件头 avformat_write_header(oc, NULL); while (!recorder->abort_request) { if (recorder_pop_packet(recorder, &pkt, 1) <= 0) { continue; } // 3. 重新计算时间戳(关键!) // 输入流的时间戳基准(time_base)和输出流可能不同,需要转换。 av_packet_rescale_ts(&pkt, input_stream->time_base, output_stream->time_base); // 4. 写入文件 av_interleaved_write_frame(oc, &pkt); av_packet_unref(&pkt); } // 5. 写文件尾 av_write_trailer(oc); // 6. 清理资源 avformat_free_context(oc); return NULL; }5.2 Java层控制录制生命周期
在Java层,我们需要提供开始、停止录制的接口,并管理Native层录制线程的状态。
public class CustomIjkPlayer { // ... 其他成员 public boolean startRecording(String outputPath) { if (mMediaPlayer == null || isRecording) { return false; } // 调用Native方法,传入输出文件路径,启动录制线程 boolean success = nativeStartRecording(outputPath); if (success) { isRecording = true; } return success; } public void stopRecording() { if (isRecording) { nativeStopRecording(); isRecording = false; // 可以在这里通知录制完成,处理文件等 } } private native boolean nativeStartRecording(String outputPath); private native void nativeStopRecording(); }5.3 处理时间戳与文件封装
这是录像功能中最容易出错的部分。
- 时间戳转换:原始流中的
AVPacket可能有dts(解码时间戳)和pts(显示时间戳)。在录制时,必须使用av_packet_rescale_ts函数将它们从输入流的time_base转换到输出流的time_base。如果转换错误,会导致录制的视频播放速度异常、音画不同步。 - 流复制:创建输出流时,需要使用
avcodec_parameters_copy将输入流的AVCodecParameters复制到输出流。这确保了编码信息(如编码格式、分辨率、采样率)的一致性。切记不要创建新的编码器,我们是在做“流复制”,不是“转码”。 - 文件格式:
avformat_alloc_output_context2的第三个参数指定封装格式(如"mp4","flv","mpegts")。你需要根据输入流的格式和你的需求来选择。MP4是通用性最好的选择之一。 - 写入模式:使用
av_interleaved_write_frame而不是av_write_frame,前者会帮你处理音视频包的交错(interleaving),确保生成的文件更规范。
关键注意事项与踩坑点:
- 内存管理:
AVPacket和AVFrame一样,需要严格配对使用av_packet_ref/av_packet_unref或av_packet_alloc/av_packet_free,否则会造成内存泄漏。录制队列要及时消费,避免堆积过多数据包导致内存暴涨。 - 线程同步:播放线程和录制线程通过队列通信,必须做好加锁(
pthread_mutex_t)和条件变量(pthread_cond_t)同步,防止竞态条件。 - 录制时机:最好在播放器
prepared之后、开始播放之前启动录制,以确保能录到完整的文件头信息。对于直播流,则随时可以开始。 - 文件写入:确保应用有外部存储的写入权限(
WRITE_EXTERNAL_STORAGE),Android Q以上需要使用MediaStore API或应用专属目录。 - 性能影响:虽然流复制开销远小于转码,但频繁的IO操作和内存拷贝仍会对性能有影响,在低端设备上长时间录制需关注发热和耗电。
6. 进阶优化与问题排查
基础功能实现后,我们还需要考虑稳定性、兼容性和用户体验。
6.1 截图功能的优化策略
- 异步与队列:在Java层设立一个截图任务队列。当用户快速连续点击截图时,将请求入队,由单独的线程按顺序处理Native回调、格式转换和保存,避免阻塞UI或造成帧丢失。
- 分辨率可调:在Native层回调中,可以传递目标宽高给
sws_scale,直接生成缩略图尺寸的Bitmap,节省内存和存储空间。 - 格式选择:提供JPEG(有损、体积小)和PNG(无损、体积大)的选项。在
Bitmap.compress方法中切换CompressFormat即可。 - 精确时间戳:在Native回调时,除了帧数据,一并传递该帧的
pts(显示时间戳)。在Java层,可以将这个时间戳作为文件名的一部分,实现按时间点精准命名。
6.2 录像功能的健壮性处理
- 异常处理:录制过程中可能发生磁盘写满、网络中断(对于网络流)等情况。Native层需要将错误码通过JNI回传给Java层,Java层再通知用户。
- 录制状态同步:确保
startRecording和stopRecording的调用是幂等的。避免重复开始或重复停止。 - 文件完整性:即使录制被意外中断(如App崩溃),也应尽量调用
av_write_trailer来写入文件尾,部分播放器可能能播放不完整的MP4。更好的做法是定期将数据flush到磁盘。 - 格式兼容性检查:不是所有输入流都适合直接复刻为MP4。在开始录制前,可以检查输入流的编码格式(
codec_id)和封装格式,如果不支持,则回退到方案C(转码录制)或提示用户不支持。
6.3 常见问题排查指南
截图黑屏或花屏:
- 检查Native回调是否被触发:在
my_native_frame_callback中添加日志,确认有数据过来。 - 检查像素格式转换:确认
sws_scale转换的源格式和目标格式正确。最常见的错误是YUV的平面格式(如YUV420P)转换错误。 - 检查Bitmap创建:确认Java层收到的
width和height大于0,并且Bitmap.createBitmap成功。 - 检查OpenGL渲染:如果使用了OpenGL渲染(
android_opengl渲染器),帧数据可能不在通常的CPU内存路径上,需要从GPU内存读取,这需要不同的处理方式(如使用glReadPixels)。
- 检查Native回调是否被触发:在
录制的视频无法播放或异常:
- 检查时间戳:这是首要怀疑对象。用
ffprobe工具查看原视频和录制视频的包时间戳,对比是否规律。av_packet_rescale_ts的参数是否正确? - 检查流参数复制:确认
avcodec_parameters_copy成功执行,输出流的codecpar信息完整。 - 检查文件头尾:确保
avformat_write_header和av_write_trailer都被成功调用。可以用ffmpeg -i your_record.mp4检查文件信息是否完整。 - 检查队列阻塞:如果录制线程消费太慢,可能导致包队列堆积,内存溢出。添加队列长度监控和丢弃策略。
- 检查时间戳:这是首要怀疑对象。用
性能问题:
- CPU占用过高:排查是截图转换(
sws_scale)还是录像写文件导致的。使用性能分析工具(如Android Profiler)定位热点。 - 内存泄漏:严格检查所有
AVPacket和AVFrame的引用计数管理。确保每个av_packet_ref都有对应的av_packet_unref,每个av_frame_alloc都有对应的av_frame_free。
- CPU占用过高:排查是截图转换(
7. 替代方案与第三方库考量
如果你觉得修改Ijkplayer源码过于复杂,或者项目时间紧迫,也有一些替代路径可以考虑:
使用TextureView截图:如前所述,如果对画质要求不高,
TextureView.getBitmap()是最快的实现方式。你只需要在播放器类中暴露这个View,然后在合适的时机(如点击截图按钮时)调用即可。但要注意线程安全和Bitmap内存回收。使用更高层次的播放器库:一些基于Ijkplayer封装的、功能更全面的播放器库,如GSYVideoPlayer、JiaoZiVideoPlayer等,它们有时会内置截图或录制功能,或者提供了更易扩展的接口。你可以研究其源码,看是否能满足需求,或在其基础上修改。
独立的录屏方案:对于录像,如果内容允许,使用Android系统的
MediaProjectionAPI是一个完全解耦的方案。你需要处理权限、管理MediaRecorder或ImageReader,并处理音轨的混合(系统录屏可能不包含播放器内部音频)。这个方案更通用,但定制性差,且界面无法隐藏。FFmpeg命令行封装:一个“野路子”但有时很有效的录像方案:将播放的网络流URL直接传递给一个在后台运行的FFmpeg命令行进程,让它去拉流并保存到文件。这完全 bypass 了播放器,但需要处理FFmpeg可执行文件的打包、进程管理以及资源消耗。
每种方案都有其权衡。修改Ijkplayer源码提供了最大的灵活性和最佳的性能/效果,但代价是复杂度高、维护成本高。你需要根据项目的具体需求、团队的技术能力和时间预算来做出选择。
在我经历的项目中,对于需要深度集成、且对画质和性能有要求的场景,直接修改Ijkplayer源码是绕不开的路。这个过程虽然繁琐,但一旦走通,你对多媒体播放的理解会深刻很多,后续应对其他定制化需求也会更加得心应手。上面的代码和思路已经勾勒出了主要的框架,你可以将其作为起点,填充细节,调试适配,最终构建出稳定可靠的播放器增强功能。