19、FFmpeg集成:NDK开发基础、FFmpeg编译与集成、JNI调用封装
说实话,做Android音视频开发,绕不开FFmpeg。这玩意儿就像厨房里的菜刀——你不会用,菜就切不好;你不会集成,播放器就做不溜。今天这一章,我带你把这把刀磨锋利。
先别急着敲代码。咱们得把地基打牢。NDK开发、FFmpeg编译、JNI调用,这三块缺一不可。我当年第一次搞FFmpeg集成时,光编译就折腾了两天……嗯,踩过的坑今天全给你抖出来。
19.1 NDK开发基础:你其实每天都在用
NDK是什么?说白了就是让你在Android上写C/C++代码的工具链。为什么需要它?因为Java/Kotlin做音视频解码太慢了,而且很多底层库(比如FFmpeg)就是C写的,你绕不开。
我个人习惯把NDK开发分成三步:
- 配置NDK环境——在local.properties里指定ndk.dir,或者在gradle里配置ndkVersion
- 编写C/C++代码——实现具体的音视频处理逻辑
- 通过JNI桥接——让Java层能调用C函数
先看一个最简单的NDK项目结构:
app/
├── src/
│ └── main/
│ ├── java/com/example/player/
│ │ └── NativeBridge.java // JNI声明类
│ ├── cpp/
│ │ ├── native_bridge.cpp // JNI实现
│ │ └── CMakeLists.txt // CMake构建脚本
│ └── AndroidManifest.xml
└── build.gradle
CMakeLists.txt里怎么写?我直接给你一个模板:
cmake_minimum_required(VERSION 3.18.1)
project("nativeplayer")
add_library(
nativeplayer # 库名
SHARED # 动态库
native_bridge.cpp # 源文件
)
find_library(
log-lib
log
)
target_link_libraries(
nativeplayer
${log-lib}
)
然后在build.gradle里关联CMake:
android {
...
externalNativeBuild {
cmake {
path "src/main/cpp/CMakeLists.txt"
version "3.18.1"
}
}
}
19.2 FFmpeg编译:最磨人的一步
FFmpeg编译,说白了就是交叉编译。你要在电脑上编译出能在Android手机上跑的.so文件。为什么难?因为FFmpeg的配置项太多了,而且不同架构(arm64-v8a、armeabi-v7a、x86_64)要分别编译。
我曾经为了编译一个带x264编码器的FFmpeg,折腾了整整一个周末。最后发现是NDK的toolchain路径写错了……所以今天我把完整流程给你,你照着做就行。
19.2.1 准备编译环境
你需要:
- Linux或macOS(Windows用WSL也行)
- NDK r21e(我推荐这个版本)
- FFmpeg 4.4.4(稳定,bug少)
- 足够耐心(认真脸)
19.2.2 编写编译脚本
这是核心。我写了一个通用的编译脚本,你改改路径就能用:
#!/bin/bash
# 配置NDK路径
NDK=/home/user/Android/Sdk/ndk/21.4.7075529
TOOLCHAIN=$NDK/toolchains/llvm/prebuilt/linux-x86_64
API=21
# 目标架构:arm64-v8a
ARCH=arm64
CPU=armv8-a
CROSS_PREFIX=$TOOLCHAIN/bin/aarch64-linux-android-
CC=$TOOLCHAIN/bin/aarch64-linux-android21-clang
CXX=$TOOLCHAIN/bin/aarch64-linux-android21-clang++
# 输出目录
PREFIX=$(pwd)/android/$ARCH
./configure \
--prefix=$PREFIX \
--target-os=android \
--arch=$ARCH \
--cpu=$CPU \
--cc=$CC \
--cxx=$CXX \
--cross-prefix=$CROSS_PREFIX \
--enable-shared \
--disable-static \
--disable-doc \
--disable-ffmpeg \
--disable-ffplay \
--disable-ffprobe \
--disable-avdevice \
--disable-postproc \
--enable-small \
--enable-gpl \
--enable-version3
make -j8
make install
编译完成后,你会得到这些.so文件:
| 库名 | 作用 | 是否必须 |
|---|---|---|
| libavcodec.so | 音视频编解码核心 | 是 |
| libavformat.so | 封装格式处理(MP4、FLV等) | 是 |
| libavutil.so | 工具函数库(内存、数学等) | 是 |
| libswscale.so | 图像缩放、色彩空间转换 | 推荐 |
| libswresample.so | 音频重采样 | 推荐 |
| libavfilter.so | 滤镜功能 | 按需 |
--enable-shared,生成动态库。静态库虽然也能用,但会导致APK体积暴增。我见过有人编译出200MB的APK……就是因为用了静态库。
19.3 JNI调用封装:让Java和C++谈恋爱
FFmpeg编译好了,.so文件也有了。怎么在Java层调用它?这就得靠JNI了。JNI就像一座桥,Java在桥这头,C++在桥那头,中间通过函数签名来通信。
先看一个完整的JNI调用流程:
- Java层声明native方法
- 用javah生成头文件(或者手动写)
- C++层实现JNI函数
- 编译生成.so
- Java层加载.so并调用
19.3.1 Java层声明
public class NativeBridge {
static {
System.loadLibrary("nativeplayer");
}
// 初始化FFmpeg
public static native int initFFmpeg(String url);
// 获取视频时长(毫秒)
public static native long getDuration();
// 解码一帧视频数据
public static native int decodeVideoFrame(byte[] outData);
// 释放资源
public static native void release();
}
19.3.2 C++层实现
这里我展示一个简化版的实现,重点看JNI函数签名怎么写:
#include <jni.h>
#include <string>
#include "ffmpeg/include/libavformat/avformat.h"
extern "C" JNIEXPORT jint JNICALL
Java_com_example_player_NativeBridge_initFFmpeg(
JNIEnv* env, jclass clazz, jstring url) {
const char* urlStr = env->GetStringUTFChars(url, nullptr);
AVFormatContext* fmtCtx = avformat_alloc_context();
int ret = avformat_open_input(&fmtCtx, urlStr, nullptr, nullptr);
env->ReleaseStringUTFChars(url, urlStr);
if (ret != 0) {
return -1; // 打开失败
}
// 查找流信息
ret = avformat_find_stream_info(fmtCtx, nullptr);
if (ret < 0) {
return -2;
}
// 这里可以保存fmtCtx到全局变量,供后续使用
// 实际项目中建议用单例或对象管理
return 0; // 成功
}
你可能会问:为什么函数名这么长?这是JNI的命名规则——Java_包名_类名_方法名。包名里的点要换成下划线。我第一次写的时候忘了这个规则,结果一直报UnsatisfiedLinkError……
19.3.3 封装一个工具类
直接暴露native方法给业务层用,不太优雅。我习惯再封装一层:
public class FFmpegPlayer {
private static final String TAG = "FFmpegPlayer";
public interface OnPrepareListener {
void onPrepared(long duration);
void onError(int code, String msg);
}
private OnPrepareListener listener;
public void setOnPrepareListener(OnPrepareListener listener) {
this.listener = listener;
}
public void prepare(String url) {
new Thread(() -> {
int result = NativeBridge.initFFmpeg(url);
if (result == 0) {
long duration = NativeBridge.getDuration();
if (listener != null) {
listener.onPrepared(duration);
}
} else {
if (listener != null) {
listener.onError(result, "初始化失败");
}
}
}).start();
}
public void release() {
NativeBridge.release();
}
}
这样业务层调用起来就清爽多了:
FFmpegPlayer player = new FFmpegPlayer();
player.setOnPrepareListener(new FFmpegPlayer.OnPrepareListener() {
@Override
public void onPrepared(long duration) {
Log.d(TAG, "视频时长: " + duration + "ms");
}
@Override
public void onError(int code, String msg) {
Log.e(TAG, "错误: " + code + ", " + msg);
}
});
player.prepare("/sdcard/test.mp4");
19.4 知识体系总览
这一章的内容比较多,我画了一张图帮你理清思路:
19.5 避坑指南
最后,我把这些年踩过的坑总结一下,你遇到了直接对照排查:
坑1:UnsatisfiedLinkError
原因通常是.so文件没放到正确的目录。检查一下你的jniLibs目录结构:
app/src/main/jniLibs/
├── arm64-v8a/
│ ├── libnativeplayer.so
│ ├── libavcodec.so
│ ├── libavformat.so
│ └── ...
├── armeabi-v7a/
│ └── ...
└── x86_64/
└── ...
少一个架构目录,或者少一个.so文件,都会崩。
坑2:编译出来的.so文件太大
我曾经编译出单个libavcodec.so就30MB。后来加了--enable-small,降到8MB。再配合--disable-encoders(如果你只需要解码),能压到5MB以内。
坑3:JNI调用时内存泄漏
每次调用GetStringUTFChars后,一定要记得ReleaseStringUTFChars。还有NewByteArray、GetByteArrayElements这些,用完了都要释放。JNI层的内存泄漏很难排查,我建议你写个工具类统一管理。
好了,这一章的内容就到这里。FFmpeg集成是整个播放器开发中最基础也最关键的一步。你把这一步走稳了,后面的解码、渲染、音视频同步,都会顺很多。