19、FFmpeg集成:NDK开发基础、FFmpeg编译与集成、JNI调用封装

说实话,做Android音视频开发,绕不开FFmpeg。这玩意儿就像厨房里的菜刀——你不会用,菜就切不好;你不会集成,播放器就做不溜。今天这一章,我带你把这把刀磨锋利。

先别急着敲代码。咱们得把地基打牢。NDK开发、FFmpeg编译、JNI调用,这三块缺一不可。我当年第一次搞FFmpeg集成时,光编译就折腾了两天……嗯,踩过的坑今天全给你抖出来。

19.1 NDK开发基础:你其实每天都在用

NDK是什么?说白了就是让你在Android上写C/C++代码的工具链。为什么需要它?因为Java/Kotlin做音视频解码太慢了,而且很多底层库(比如FFmpeg)就是C写的,你绕不开。

我个人习惯把NDK开发分成三步:

  1. 配置NDK环境——在local.properties里指定ndk.dir,或者在gradle里配置ndkVersion
  2. 编写C/C++代码——实现具体的音视频处理逻辑
  3. 通过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"
        }
    }
}
我的小经验:NDK版本别追新。我建议用NDK 21或23,这两个版本对FFmpeg的兼容性最好。NDK 25以后有些ABI接口变了,容易踩坑。

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调用流程:

  1. Java层声明native方法
  2. 用javah生成头文件(或者手动写)
  3. C++层实现JNI函数
  4. 编译生成.so
  5. 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 知识体系总览

这一章的内容比较多,我画了一张图帮你理清思路:

FFmpeg集成知识体系 NDK开发基础 FFmpeg编译 JNI调用封装 CMake构建脚本 ABI架构选择 C/C++源码组织 交叉编译环境 configure配置 make编译与输出 Java native声明 JNI函数签名 业务层封装 最终产出:libnativeplayer.so + FFmpeg动态库 三大模块缺一不可,顺序执行:NDK环境 → FFmpeg编译 → JNI封装

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。还有NewByteArrayGetByteArrayElements这些,用完了都要释放。JNI层的内存泄漏很难排查,我建议你写个工具类统一管理。

好了,这一章的内容就到这里。FFmpeg集成是整个播放器开发中最基础也最关键的一步。你把这一步走稳了,后面的解码、渲染、音视频同步,都会顺很多。

公众号:蓝海资料掘金营,微信deep3321