25、Android NDK开发与音视频:NDK环境搭建、JNI调用C/C++代码、NDK中实现RTMP推流

说实话,做音视频开发这么多年,我见过太多人在NDK这关就卡住了。很多人觉得JNI麻烦,C++编译报错看不懂,干脆绕道走纯Java方案。结果呢?性能上不去,延迟降不下来,最后还得回头啃NDK这块硬骨头。

我个人习惯是:只要涉及音视频编解码、推流拉流这种对性能敏感的操作,直接上NDK。Java层做业务逻辑,C/C++层做数据处理,各司其职。今天我们就来把NDK环境搭建、JNI调用、以及RTMP推流在NDK中的实现,一次性讲清楚。

核心要点:NDK不是银弹,但在音视频领域,它是绕不开的必经之路。RTMP推流的核心——数据打包、时间戳控制、socket发送——在C层实现,效率远高于Java层。

NDK环境搭建:别让工具成为绊脚石

我记得第一次配置NDK环境时,光是CMakeLists.txt就折腾了半天。现在Android Studio已经集成得很好了,但有些细节还是得注意。

第一步:下载NDK和CMake

打开SDK Manager,在SDK Tools选项卡中勾选:

  • NDK (Side by side) —— 建议选最新稳定版
  • CMake —— 用于构建原生代码
  • LLDB —— 调试原生代码用

第二步:配置build.gradle

在app模块的build.gradle中,添加以下配置:

android {
    ...
    defaultConfig {
        ...
        ndk {
            // 指定要支持的ABI架构
            abiFilters "armeabi-v7a", "arm64-v8a", "x86_64"
        }
    }
    externalNativeBuild {
        cmake {
            path "src/main/cpp/CMakeLists.txt"
            version "3.22.1"
        }
    }
}

我的经验:abiFilters这里别贪多。armeabi-v7a和arm64-v8a覆盖了99%的Android设备。x86_64主要是模拟器用,如果你不需要在模拟器上调试,可以去掉,能减小APK体积。

第三步:编写CMakeLists.txt

在src/main/cpp目录下创建CMakeLists.txt:

cmake_minimum_required(VERSION 3.18.1)
project("rtmp_push")

# 设置C++标准
set(CMAKE_CXX_STANDARD 11)

# 添加自己的库
add_library(
    rtmp_push
    SHARED
    native-lib.cpp
    rtmp_push.cpp
)

# 查找系统库
find_library(
    log-lib
    log
)

# 链接库
target_link_libraries(
    rtmp_push
    ${log-lib}
)

嗯,这里要注意:如果你要引入第三方C库(比如librtmp),需要用add_library配合IMPORTED,或者直接用PREBUILT_SHARED。我一般习惯把第三方库源码直接放在cpp目录下一起编译,省去预编译的麻烦。

JNI调用C/C++代码:桥接的艺术

JNI说白了就是Java和C/C++之间的翻译官。Java这边喊一声,C那边得听得懂,还得干得漂亮。

Java层声明native方法:

public class RtmpPushNative {
    static {
        System.loadLibrary("rtmp_push");
    }

    // 初始化RTMP推流
    public native int init(String url);

    // 推送视频数据
    public native int pushVideo(byte[] data, int len, long timestamp);

    // 推送音频数据
    public native int pushAudio(byte[] data, int len, long timestamp);

    // 停止推流
    public native int stop();
}

C层实现JNI函数:

在native-lib.cpp中,按照JNI命名规范实现:

#include <jni.h>
#include <android/log.h>
#include "rtmp_push.h"

#define LOG_TAG "RtmpPushNative"
#define LOGI(...) __android_log_print(ANDROID_LOG_INFO, LOG_TAG, __VA_ARGS__)

extern "C" JNIEXPORT jint JNICALL
Java_com_example_rtmppush_RtmpPushNative_init(
        JNIEnv* env,
        jobject thiz,
        jstring url) {
    const char* rtmp_url = env->GetStringUTFChars(url, nullptr);
    LOGI("init rtmp url: %s", rtmp_url);

    // 调用RTMP推流核心逻辑
    int ret = rtmp_init(rtmp_url);

    env->ReleaseStringUTFChars(url, rtmp_url);
    return ret;
}

extern "C" JNIEXPORT jint JNICALL
Java_com_example_rtmppush_RtmpPushNative_pushVideo(
        JNIEnv* env,
        jobject thiz,
        jbyteArray data,
        jint len,
        jlong timestamp) {
    jbyte* buf = env->GetByteArrayElements(data, nullptr);
    int ret = rtmp_push_video((uint8_t*)buf, len, timestamp);
    env->ReleaseByteArrayElements(data, buf, 0);
    return ret;
}

我曾经踩过的坑:JNI中GetStringUTFChars和GetByteArrayElements获取到的指针,用完后一定要Release。否则会导致内存泄漏,而且GC不会帮你回收。有一次线上崩溃排查了两天,最后发现是JNI全局引用没释放。

NDK中实现RTMP推流:核心逻辑拆解

RTMP推流在C层的实现,我把它拆成三个模块:连接管理、数据打包、发送控制。下面这张图可以帮你快速建立整体认知:

NDK RTMP推流核心架构 Java层 RtmpPushNative.java — 声明native方法,调用JNI接口 JNI桥接层 native-lib.cpp — 类型转换、数据传递、调用C核心逻辑 C核心逻辑层 rtmp_push.c — 连接管理、数据打包、发送控制 连接管理 握手/建立连接 断线重连 数据打包 FLV Tag封装 H264/AAC打包 发送控制 时间戳管理 socket发送

说白了,RTMP推流就是三步走:连上服务器、把音视频数据按RTMP协议打包、然后发出去。在C层实现,每一步都有讲究。

连接管理:

RTMP握手协议有简单握手和复杂握手两种。现在主流服务器都支持简单握手,实现起来也简单:

int rtmp_init(const char* url) {
    RTMP* rtmp = RTMP_Alloc();
    RTMP_Init(rtmp);

    // 设置推流地址
    RTMP_SetupURL(rtmp, url);
    RTMP_EnableWrite(rtmp);

    // 建立连接
    if (!RTMP_Connect(rtmp, nullptr)) {
        LOGE("RTMP连接失败");
        return -1;
    }

    // 连接流
    if (!RTMP_ConnectStream(rtmp, 0)) {
        LOGE("RTMP连接流失败");
        return -1;
    }

    LOGI("RTMP推流连接成功");
    return 0;
}

我建议:实际项目中,连接失败后一定要做重试机制。我曾经遇到网络波动导致推流中断,加了指数退避重试后,稳定性提升了很多。重试间隔从1秒开始,每次翻倍,最大间隔30秒。

数据打包:

视频数据通常是H264编码后的NAL单元。RTMP协议要求把H264数据封装成FLV VideoTag格式:

int rtmp_push_video(uint8_t* data, int len, uint32_t timestamp) {
    // 构造FLV VideoTag
    // 第一个字节:帧类型(4bit) + 编码ID(4bit)
    // 关键帧: 0x17, 非关键帧: 0x27
    uint8_t frame_type = (data[4] & 0x1F) == 0x05 ? 0x17 : 0x27;

    // 构造RTMP包
    RTMPPacket packet;
    RTMPPacket_Reset(&packet);
    RTMPPacket_Alloc(&packet, len + 9);

    packet.m_body[0] = frame_type;
    packet.m_body[1] = 0x01; // AVC sequence header
    packet.m_body[2] = 0x00; // composition time offset
    packet.m_body[3] = 0x00;
    packet.m_body[4] = 0x00;

    // 拷贝H264数据
    memcpy(packet.m_body + 5, data + 4, len - 4);

    packet.m_packetType = RTMP_PACKET_TYPE_VIDEO;
    packet.m_nChannel = 0x04;
    packet.m_headerType = RTMP_PACKET_SIZE_LARGE;
    packet.m_nTimeStamp = timestamp;
    packet.m_nInfoField2 = rtmp->m_stream_id;
    packet.m_nBodySize = len + 5;

    // 发送
    return RTMP_SendPacket(rtmp, &packet, 0);
}

发送控制:

时间戳是RTMP推流中最容易出问题的地方。我见过不少新手直接把系统当前时间传进去,结果服务器端时间戳乱跳,播放器卡成幻灯片。

正确的做法是:以第一帧为基准,计算相对时间戳。

// 全局变量
static uint32_t g_start_time = 0;

// 在推流开始时记录基准时间
void rtmp_start_push() {
    g_start_time = GetCurrentTimeMs();
}

// 计算相对时间戳
uint32_t get_relative_timestamp() {
    return GetCurrentTimeMs() - g_start_time;
}

注意:RTMP协议要求时间戳是单调递增的。如果你用绝对时间戳,一旦系统时间被用户修改,时间戳就会乱掉。用相对时间戳可以避免这个问题。

实战中的避坑指南

做NDK开发,尤其是音视频推流,坑是真的多。我把自己踩过的坑整理了一下:

常见问题 原因 解决方案
JNI崩溃:signal 11 (SIGSEGV) 空指针或野指针访问 每次使用指针前判空,Release后置NULL
推流画面花屏 H264 sps/pps数据丢失 关键帧前必须发送sps/pps,用AVCDecoderConfigurationRecord
音视频不同步 时间戳计算错误 统一使用相对时间戳,音频视频用同一个基准
内存泄漏 JNI全局引用未释放 使用NewGlobalRef后必须DeleteGlobalRef
编译报错:undefined reference 库链接顺序不对 CMakeLists.txt中target_link_libraries顺序:被依赖的库放后面

说实话,NDK开发最让人头疼的不是C++语法,而是调试。Java层崩溃有堆栈,C层崩溃经常就一个signal 11,啥信息都没有。我的习惯是:

  • 关键函数入口出口加日志,用__android_log_print输出
  • 指针操作前后加assert断言
  • 用addr2line工具把地址转成行号

你想想看,如果能在C层就把RTMP推流搞定,Java层只需要调用几个native方法,整个架构会清爽很多。而且性能上,C层直接操作socket和内存,比Java层经过JNI来回拷贝数据,效率高出一个量级。

好了,NDK环境搭建、JNI调用、以及RTMP推流的核心实现,就讲到这里。这些内容是我多年实战经验的总结,希望能帮你少走弯路。


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