手把手教你用 CMake 把 pjsip 接入 Android 项目
你有没有遇到过这样的场景:想在自己的 App 里加个语音通话功能,查了一圈发现pjsip是最靠谱的选择——开源、稳定、功能全。但一上手就卡住了:编译报错、链接失败、ABI 不兼容、日志出不来……更头疼的是,网上教程大多还停留在Android.mk时代,而你现在用的是 Google 官方主推的CMake。
别急。这篇文章就是为了解决这个“最后一公里”问题而写的。
我们不堆术语,不讲空话,只聚焦一件事:如何干净利落地把 pjsip 通过 CMake 集成进你的 Android 项目。从交叉编译到库引用,从头文件配置到运行调试,一步步带你走通全流程。
为什么选 pjsip?它真的适合移动端吗?
先说结论:是的,而且非常合适。
虽然 pjsip 是用纯 C 写的,看起来像是“上古时代”的技术栈,但它有几个关键优势让它至今仍是 VoIP 领域的香饽饽:
- 轻量高效:内存占用低,启动快,特别适合资源受限的移动设备。
- 协议完整:SIP + SDP + RTP/RTCP + STUN/TURN/ICE 全都有,一套搞定信令和媒体。
- 音频处理强:内置回声消除(AEC)、抖动缓冲、多种编解码器支持(G.711, Opus 等)。
- 跨平台能力拉满:Linux、Windows、iOS、Android 全能跑,代码复用率高。
更重要的是,它提供了PJSUA2这个高级封装 API,让你不用直接操作底层 SIP 消息就能实现注册、拨号、接听等核心功能,开发效率大大提升。
不过也得承认:它的构建系统对新手不太友好。尤其是要在 Android 上跑起来,得先做交叉编译,生成.a静态库,再导入项目。这一步就把很多人挡在门外了。
好消息是,一旦你把库编好,后续集成反而很简单——只要你会写CMakeLists.txt。
编译 pjsip:先给它“瘦身”,再交叉编译
第一步:准备源码与环境
git clone https://github.com/pjsip/pjproject.git cd pjproject推荐使用v2.13 或更新版本,对 Android NDK r21+ 支持更好。
接着设置几个关键环境变量(以 Linux/macOS 为例):
export ANDROID_NDK_ROOT=/path/to/your/android-ndk export TARGET_ABI=armeabi-v7a # 可改为 arm64-v8a, x86_64 等 export ANDROID_API=21⚠️ 注意:NDK 路径不能有空格!否则 configure 脚本会挂掉。
第二步:创建 config_site.h —— 让 pjsip 适应 Android
在pjlib/include/pj/目录下新建一个config_site.h文件,内容如下:
#define PJ_CONFIG_ANDROID 1 #include <pj/config_site_sample.h> // 关闭视频相关模块(省空间) #define PJMEDIA_HAS_VIDEO 0 // 启用 Opus 编解码器(可选) #define PJMEDIA_HAS_OPUS_CODEC 1 // 使用 OpenSL ES 作为音频后端 #define PJMEDIA_AUDIO_DEV_HAS_OPENSL 1 // 禁用浮点异常检测(Android 上不需要) #define PJ_HAS_FLOATING_POINT 1这个文件的作用是告诉 pjsip:“我现在跑在 Android 上,请按这里的配置来裁剪功能。”比如关掉视频可以减少约 3MB 的体积。
第三步:运行 configure-android 脚本
pjsip 官方提供了一个专门用于 Android 的配置脚本:
./configure-android \ --use-ndk-cflags \ --target=armv7a-linux-androideabi \ --with-android-ndk=$ANDROID_NDK_ROOT \ --with-android-api=$ANDROID_API \ --disable-video \ --enable-g711-codec \ --enable-opus-codec如果你要编译arm64-v8a,则换成:
--target=aarch64-linux-android执行成功后会生成 Makefile。然后开始编译:
make dep && make clean && make -j8等待几分钟,你会在pjlib/lib,pjsip/lib等目录看到一堆.a文件,例如:
libpjsua2.alibpjmedia.alibpjsip-core.alibpjlib-util.a
这些就是你需要的静态库。
第四步:批量处理多 ABI
为了支持不同手机架构,你需要为每个 ABI 分别编译一次。建议写个脚本自动化完成:
#!/bin/bash ABIS=("armeabi-v7a" "arm64-v8a" "x86" "x86_64") for abi in "${ABIS[@]}"; do echo "Building for $abi" ./configure-android --target=... # 根据 abi 设置参数 make clean && make -j8 cp -r lib ../prebuilt/$abi/ done最后整理出这样一个目录结构:
app/src/main/jniLibs/ ├── armeabi-v7a/ │ ├── libpjsua2.a │ ├── libpjmedia.a │ └── ... ├── arm64-v8a/ │ ├── libpjsua2.a │ └── ... └── ...同时把所有头文件复制到cpp/include/下:
app/src/main/cpp/include/ ├── pjsip/ ├── pjmedia/ ├── pjnath/ └── pj/现在,准备工作完成了。
CMake 怎么写?这才是重点!
接下来就是在项目中配置CMakeLists.txt,让 Gradle 能顺利找到并链接这些库。
先看顶层 CMakeLists.txt
cmake_minimum_required(VERSION 3.18) project("sip-native") # 使用 C99 和 C++17 set(CMAKE_C_STANDARD 99) set(CMAKE_CXX_STANDARD 17) # 启用异常和 RTTI(PJSUA2 需要用到) set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -fexceptions -frtti") # 导入预编译的 pjsip 静态库 macro(import_pjsip_lib NAME) add_library(${NAME} STATIC IMPORTED) set_target_properties(${NAME} PROPERTIES IMPORTED_LOCATION ${CMAKE_SOURCE_DIR}/../jniLibs/${ANDROID_ABI}/lib${NAME}.a ) endmacro() # 批量导入所有模块 import_pjsip_lib(pjlib) import_pjsip_lib(pjlib_util) import_pjsip_lib(pjsip_core) import_pjsip_lib(pjsip_simple) import_pjsip_lib(pjsip_ua) import_pjsip_lib(pjsua2) import_pjsip_lib(pjnath) import_pjsip_lib(pjmedia) # 创建本地 JNI 封装库 add_library(native-sip SHARED native_sip.cpp SipManager.cpp ) # 包含头文件路径 target_include_directories(native-sip PRIVATE ${CMAKE_SOURCE_DIR}/include ${CMAKE_SOURCE_DIR}/include/pj ${CMAKE_SOURCE_DIR}/include/pjmedia ${CMAKE_SOURCE_DIR}/include/pjsip ) # 链接所有依赖库 target_link_libraries(native-sip # pjsip 模块 pjlib pjlib_util pjsip_core pjsip_simple pjsip_ua pjsua2 pjnath pjmedia # 系统库 log android m atomic )几点关键说明:
IMPORTED_LOCATION中的${ANDROID_ABI}是 CMake 自动传入的,对应当前构建的目标架构。- 必须启用
-fexceptions和-frtti,因为 PJSUA2 内部用了 C++ 异常和动态类型识别。 atomic库必须显式链接,否则某些 ARM 设备上会出现undefined reference to __atomic_load_8错误。log和android是 Android 原生系统库,分别用于日志输出和访问 Java 层对象。
在 build.gradle 中启用 CMake
确保app/build.gradle里有这段:
android { compileSdk 34 defaultConfig { applicationId "com.example.voipapp" minSdk 21 targetSdk 34 versionCode 1 versionName "1.0" externalNativeBuild { cmake { cppFlags "-std=c++17", "-fexceptions", "-frtti" abiFilters 'armeabi-v7a', 'arm64-v8a' } } } externalNativeBuild { cmake { path file('src/main/cpp/CMakeLists.txt') version '3.18.1' } } }这样 Gradle 就知道去哪找 CMake 脚本,并自动为每个 ABI 构建对应的.so。
JNI 层怎么封?别让回调崩了 App
Java 层不可能直接调用 C 函数,必须通过 JNI 桥接。你可以创建一个SipManager类来做封装。
示例:native_sip.cpp 中的关键函数
#include <jni.h> #include <pjsua2.hpp> #include <android/log.h> #define LOGI(...) __android_log_print(ANDROID_LOG_INFO, "SIP-JNI", __VA_ARGS__) using namespace pj; static JavaVM *g_jvm = nullptr; static jobject g_listener = nullptr; class MyCall : public Call { public: MyCall(Account &acc, int call_id = PJSUA_INVALID_ID) : Call(acc, call_id) {} void onCallState(OnCallStateParam ¶m) override { if (g_listener && g_jvm) { JNIEnv *env; bool detach = false; if (g_jvm->GetEnv((void**)&env, JNI_VERSION_1_6) != JNI_OK) { g_jvm->AttachCurrentThread(&env, nullptr); detach = true; } jclass cls = env->GetObjectClass(g_listener); jmethodID mid = env->GetMethodID(cls, "onCallStateChanged", "(I)V"); env->CallVoidMethod(g_listener, mid, getState()); if (detach) g_jvm->DetachCurrentThread(); } } };注意线程安全问题:pjsip 内部有自己的 I/O 线程,回调发生时可能不在 JVM 主线程,所以要用GetEnv判断是否需要AttachCurrentThread。
注册 JVM 和事件监听器
extern "C" JNIEXPORT void JNICALL Java_com_example_SipService_nativeInit(JNIEnv *env, jobject thiz) { env->GetJavaVM(&g_jvm); // 保存全局引用,防止被回收 g_listener = env->NewGlobalRef(thiz); // 初始化 pjsip Endpoint::instance().libCreate(); EpConfig cfg; Endpoint::instance().libInit(cfg); } extern "C" JNIEXPORT void JNICALL Java_com_example_SipService_nativeStartAccount(JNIEnv *env, jobject thiz, jstring sipUri) { const char *uri = env->GetStringUTFChars(sipUri, nullptr); AccountConfig acfg; acfg.setIdUri(std::string(uri)); // ... 配置服务器地址、认证信息等 Account *acc = new Account(); acc->create(acfg); env->ReleaseStringUTFChars(sipUri, uri); }Java 层就可以这样调用:
public class SipService extends Service { static { System.loadLibrary("native-sip"); } public native void nativeInit(); public native void nativeStartAccount(String sipUri); @Override public void onCreate() { super.onCreate(); nativeInit(); nativeStartAccount("sip:user@server.com"); } // 回调方法 public void onCallStateChanged(int state) { Log.d("SIP", "Call state: " + state); // 更新 UI 或发送广播 } }常见坑点与解决方案
| 问题 | 表现 | 解法 |
|---|---|---|
编译时报__atomic未定义 | undefined reference to __atomic_load_8 | 在target_link_libraries中加上atomic |
| 日志不出 | PJ_LOG_LEVEL=5也没输出 | 调用LogWriter::setLevel(5);并确保 stdout 重定向 |
| 音频断续/延迟高 | 对话听起来一卡一卡 | 使用 OpenSL ES,采样率设为 16kHz,帧大小 20ms |
| JNI 崩溃 | JNI DETECTED ERROR IN APPLICATION | 所有非主线程回调前先AttachCurrentThread |
| 库太大 | APK 增大十几 MB | 在config_site.h中关闭视频、H.264、G.729 等非必要模块 |
还有一个实用技巧:如果只想保留核心语音功能,可以在编译时禁用大量模块:
--disable-video --disable-speex-codec --disable-gsm-codec --without-libffi这样最终打包下来的.a文件总和可以控制在6~8MB左右。
最后一点思考:这条路还能走多远?
也许你会问:现在 WebRTC 都这么火了,为啥还要折腾 pjsip?
答案是:场景不同,需求不同。
- 如果你要做浏览器互通、高清视频会议、数据通道传输,那当然是 WebRTC 更合适。
- 但如果你对接的是传统 PBX、IMS 网络、SIP 运营商线路,或者只是做一个简单的软电话客户端,pjsip 依然是最成熟、最稳定的方案之一。
而且它足够轻量,学习成本相对可控,社区资料丰富,文档齐全。配合 CMake + JNI 的现代 Android 开发模式,完全可以打造出专业级的 VoIP 应用。
更重要的是,这套集成思路不仅适用于 pjsip,也适用于其他任何需要引入第三方 C/C++ 静态库的项目。掌握了这一套流程,你就等于打通了 native 开发的任督二脉。
如果你正在尝试将 SIP 协议集成到 App 中,不妨试试这条路。我已经把它跑通了,也希望你能少踩点坑。
有任何疑问或更好的优化建议,欢迎留言交流。