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层的实现,我把它拆成三个模块:连接管理、数据打包、发送控制。下面这张图可以帮你快速建立整体认知:
说白了,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推流的核心实现,就讲到这里。这些内容是我多年实战经验的总结,希望能帮你少走弯路。