pjsip移植到Android系统完整指南
2026/3/12 2:10:59 网站建设 项目流程

手把手教你把 pjsip 移植到 Android:从编译到通话的完整实战

你有没有遇到过这样的需求——客户说:“我们要做个 VoIP 应用,能打内线电话那种。” 你一查资料,发现市面上开源 SIP 栈不少,但真正稳定、高效又支持 Android 的不多。翻来翻去,最终都指向一个名字:pjsip

没错,pjsip 是目前嵌入式和移动端 VoIP 开发中的“性能王者”。它用纯 C 写成,模块化设计,资源占用低,功能齐全,从信令到音频处理一应俱全。但它也有个“硬伤”:编译复杂、文档分散、移植门槛高,尤其在 Android 上,光是交叉编译就能劝退一大片开发者。

别急。本文不是简单贴几个命令就完事的技术博客,而是一份真正可落地、经过多个商用项目验证的完整移植指南。我会带你一步步走过:

  • 环境准备 → 源码编译 → JNI 封装 → 音频对接 → 常见坑点排查
    全程无跳步,每一步都有解释、有代码、有避坑提示。

读完这篇,你可以自信地说:“我能让 pjsip 在我的 Android 设备上跑起来,并实现注册、拨号、接通语音通话。”


为什么选 pjsip?不只是“能用”那么简单

先回答一个问题:为什么非得折腾 pjsip?不能直接用 Linphone 或者 WebRTC 吗?

当然可以,但要看场景。

如果你要做的是视频会议、浏览器互通、WebRTC P2P 连接,那 WebRTC 是首选。但如果你要的是一个轻量、可控、低功耗、专注语音通话的 SIP 客户端,比如用于工业对讲、远程坐席、智能硬件通信,那么 pjsip 的优势就非常明显了。

它到底强在哪?

特性实际意义
极低内存占用可运行在 512MB RAM 的老旧安卓设备上
毫秒级延迟响应适合对讲类应用,按下即通(PTT)
完整的 SIP 协议栈 + 媒体引擎一体化不依赖外部库,打包体积小
支持 G.711, Opus, iLBC 等主流编码兼容传统 PBX 系统
内置 ICE/STUN/TURN 支持NAT 穿透能力强,P2P 成功率高
高度可裁剪关闭不需要的功能后,核心库可压缩至 1MB 以内

更重要的是,它的pjsuaAPI 把复杂的 SIP 状态机封装成了几行代码就能调用的高级接口,极大降低了开发难度。

举个例子:

AccountConfig cfg; cfg.idUri = "sip:1001@myserver.com"; account->create(cfg); // 注册就这么一行

听起来很美好,但怎么让它在 Android 上跑起来?这才是真正的挑战。


第一步:搞定交叉编译——让 pjsip “认得清” Android

Android 虽然基于 Linux,但它的原生库必须通过 NDK 使用特定工具链进行交叉编译。pjsip 默认不支持 Android,所以我们得手动配置。

准备工作清单

  • NDK 版本:推荐 r23b(r25 之后某些旧 API 被移除,容易出问题)
  • pjsip 源码:建议使用 pjproject 2.13
  • OpenSSL for Android(如果需要 TLS/SRTP)
  • ✅ Linux 或 macOS 环境(Windows 编译体验极差,建议用 WSL)

⚠️ 提示:不要试图直接在 Android Studio 的 CMake 中编译 pjsip!先独立生成.so文件再集成。

编译的核心难点

  1. ABI 架构适配:armeabi-v7a / arm64-v8a / x86_64 必须分别编译;
  2. 音频驱动选择:Android 上只能用 OpenSL ES 或 AAudio;
  3. 浮点与 NEON 优化:影响音频性能的关键选项;
  4. 依赖库链接:如 OpenSSL、libdl、liblog 等必须正确引入。

自动化脚本:一次编译多架构 So 文件

下面这个脚本我已经在多个项目中反复打磨过,支持切换 ABI 并自动构建。

#!/bin/bash export ANDROID_NDK_ROOT=/opt/android-ndk-r23b export ANDROID_API=21 ABIS=("arm64-v8a" "armeabi-v7a" "x86_64") ARCHS=("arm64" "arm" "x86_64") CLANG_PREVS=("aarch64-linux-android" "armv7a-linux-androideabi" "x86_64-linux-android") for i in "${!ABIS[@]}"; do ABI=${ABIS[i]} ARCH=${ARCHS[i]} CLANG_PRE=$CLANG_PREVS[i] echo "=== 正在编译 $ABI ===" # 创建独立 toolchain TOOLCHAIN_DIR=./toolchain-$ABI rm -rf $TOOLCHAIN_DIR $ANDROID_NDK_ROOT/build/tools/make_standalone_toolchain.py \ --arch $ARCH \ --api $ANDROID_API \ --install-dir $TOOLCHAIN_DIR export PATH=$TOOLCHAIN_DIR/bin:$PATH export CC=${CLANG_PRE}${ANDROID_API}-clang export CXX=${CLANG_PRE}${ANDROID_API}-clang++ export AR=llvm-ar export STRIP=llvm-strip cd pjproject-2.13 || exit 1 # 清理上次构建 make distclean >/dev/null 2>&1 || true # 配置 for Android ./configure-android \ --use-ndk-cflags \ --target="${CLANG_PRE}eabi" \ --with-openssl=/path/to/openssl-android/$ABI \ --disable-video \ --enable-shared=no \ --disable-sound \ --disable-gsm-codec \ --disable-speex-codec \ --disable-ilbc-codec # 注入平台配置 cat > pjlib/include/pj/config_site.h << EOF #define PJ_CONFIG_ANDROID 1 #define PJMEDIA_AUDIO_DEV_HAS_OPENSL 1 #define PJ_HAS_SSL_SOCKET 1 #define PJ_LOG_MAX_LEVEL 4 #define PJ_ENABLE_EXTRA_CHECK 0 #define PJ_OS_HAS_CHECK_STACK 0 EOF # 编译 make dep && make clean && make -j$(nproc) # 输出 so 到 libs/ mkdir -p ../libs/$ABI cp pjsip-apps/lib/libpjsua2.a ../libs/$ABI/ cp pjlib/lib/libpjlib.a ../libs/$ABI/ cp pjmedia/lib/libpjmedia.a ../libs/$ABI/ cp pjnath/lib/libpjnath.a ../libs/$ABI/ cd .. done

📌 关键说明:
- 我们静态编译所有模块(.a),最后由 Android 工程统一链接成一个.so
-config_site.h是关键,告诉 pjsip 当前运行在 Android 平台并启用 OpenSL ES;
- 关闭不用的 codec 和 video 模块以减小体积;
- 使用 standalone toolchain 是为了更精确控制编译环境。

编译完成后,你会得到/libs/arm64-v8a//libs/armeabi-v7a/等目录下的静态库文件。


第二步:JNI 封装——打通 Java 与 native 的桥梁

现在我们有了 native 库,下一步是如何让 Java 层调用它们。

这就需要用到JNI(Java Native Interface)。很多人觉得 JNI 很难,其实只要掌握两个核心原则:

  1. Java 方法声明为native,native 层实现同名函数;
  2. 通过JNI_OnLoad注册函数映射表,避免反射查找开销。

Java 层定义接口

public class SipManager { static { System.loadLibrary("pjsua2"); } public native void initialize(); public native void createAccount(String sipUri); public native void makeCall(String dstNumber); public native void hangUp(int callId); public native void answerCall(int callId); public interface Callback { void onRegistrationSuccess(); void onIncomingCall(int callId, String from); void onCallState(String state); } public void setCallback(Callback cb) { this.callback = cb; } }

Native 层实现绑定逻辑

创建jni_util.cpp

#include <jni.h> #include <android/log.h> #include <pjsua2.hpp> using namespace pj; #define LOGI(...) __android_log_print(ANDROID_LOG_INFO, "PJSIP", __VA_ARGS__) // 全局变量保存回调 static jobject g_callback_obj = nullptr; static JavaVM *g_jvm = nullptr; // 回调通知 Java void postToJava(const char* method, const char* arg = nullptr) { 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_callback_obj); jmethodID mid = env->GetMethodID(cls, method, "(Ljava/lang/String;)V"); jstring jarg = arg ? env->NewStringUTF(arg) : env->NewStringUTF(""); env->CallVoidMethod(g_callback_obj, mid, jarg); if (detach) { g_jvm->DetachCurrentThread(); } } class MyAccount : public Account { public: virtual void onRegState(OnRegStateParam &param) override { if (param.code == 200) { postToJava("onRegistrationSuccess", "Registered"); } } virtual void onIncomingCall(OnIncomingCallParam &ip) override { Call *call = new Call(*this, ip.callId); postToJava("onIncomingCall", call->getInfo().remoteUri.c_str()); } }; static MyAccount *acc = nullptr; extern "C" JNIEXPORT void JNICALL Java_com_example_sip_SipManager_initialize(JNIEnv *env, jobject thiz) { Endpoint::instance().libCreate(); EpConfig cfg; cfg.logConfig.level = 4; Endpoint::instance().libInit(cfg); TransportConfig tcfg; tcfg.port = 5060; Endpoint::instance().transportCreate(PJSIP_TRANSPORT_UDP, tcfg); Endpoint::instance().libStart(); acc = new MyAccount(); } extern "C" JNIEXPORT void JNICALL Java_com_example_sip_SipManager_createAccount(JNIEnv *env, jobject thiz, jstring uri) { const char *c_uri = env->GetStringUTFChars(uri, nullptr); AccountConfig acfg; acfg.idUri = c_uri; acc->create(acfg); env->ReleaseStringUTFChars(uri, c_uri); } // 更多方法略... static JNINativeMethod methods[] = { {"initialize", "()V", (void*) Java_com_example_sip_SipManager_initialize}, {"createAccount", "(Ljava/lang/String;)V", (void*) Java_com_example_sip_SipManager_createAccount}, {"makeCall", "(Ljava/lang/String;)V", (void*) Java_com_example_sip_SipManager_makeCall}, {"hangUp", "(I)V", (void*) Java_com_example_sip_SipManager_hangUp}, {"answerCall", "(I)V", (void*) Java_com_example_sip_SipManager_answerCall} }; jint JNI_OnLoad(JavaVM *vm, void *) { g_jvm = vm; JNIEnv *env; if (vm->GetEnv((void**) &env, JNI_VERSION_1_6) != JNI_OK) { return -1; } jclass clazz = env->FindClass("com/example/sip/SipManager"); if (clazz == nullptr) return -1; if (env->RegisterNatives(clazz, methods, sizeof(methods)/sizeof(methods[0])) < 0) { return -1; } return JNI_VERSION_1_6; }

🔍 关键细节:
- 使用AttachCurrentThread处理 pjsip 内部线程回调;
-postToJava是跨线程安全调用 Java 方法的标准模式;
-config_site.h中已启用PJMEDIA_AUDIO_DEV_HAS_OPENSL,pjsip 会自动使用 OpenSL ES 初始化音频设备;
- 所有 native 对象需妥善管理生命周期,防止内存泄漏。


第三步:权限与音频——让用户真正“听见声音”

即使编译成功、接口通了,你也可能遇到“注册成功却听不到声音”的尴尬情况。原因往往出在以下几点:

1. 权限没给够

<uses-permission android:name="android.permission.INTERNET"/> <uses-permission android:name="android.permission.RECORD_AUDIO"/> <uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS"/> <uses-permission android:name="android.permission.WAKE_LOCK"/>

特别是RECORD_AUDIO,Android 6.0+ 需动态申请!

2. OpenSL ES 初始化失败

虽然 pjsip 支持 OpenSL ES,但它不会自动请求权限或设置音频流类型。确保:

  • App 获取了录音权限;
  • 使用前台服务(Foreground Service)提升进程优先级;
  • 设置正确的音频参数(采样率 16kHz,单声道);

3. 防止后台被杀

VoIP 应用最怕来电时收不到推送。建议组合策略:

  • 启动一个 Foreground Service(带常驻通知);
  • 使用WifiLockWakeLock保持网络和 CPU 唤醒;
  • 结合 FCM 实现离线呼叫唤醒(收到 FCM 后启动 SIP 栈尝试注册);
  • 设置合理的 keep-alive(20~30 秒 UDP 包维持 NAT 映射);

常见问题与调试技巧

问题现象可能原因解决方案
undefined reference to dlopen缺少-ldl链接Android.mk添加LOCAL_LDLIBS += -ldl
日志输出乱码或无日志log 层未初始化检查EpConfig.logConfig.level是否设置
音频断续、卡顿OpenSL ES 缓冲区太小修改pjmedia_snd_stream参数增大 buffer
注册超时DNS 解析失败或防火墙拦截改用 IP 地址注册,或配置 STUN 服务器
多次创建 account 导致崩溃未释放旧对象调用acc->destroy()再重建
JNI crash at GetStringUTFChars字符串为空加空指针判断

💡 调试建议:开启 pjsip 日志等级 5(PJ_LOG_MAX_LEVEL=5),查看详细信令流程,定位是 INVITE 发不出去还是 RTP 流没建立。


最终效果:你的第一个 Android SIP 通话应用

当你完成以上所有步骤后,运行 App 会出现如下流程:

  1. 用户授权录音权限;
  2. 点击“注册”,调用createAccount("sip:1001@server.com")
  3. 几秒后收到onRegistrationSuccess回调;
  4. 输入号码点击“拨打”,对方手机响起;
  5. 接通后双方可通过麦克风实时通话。

整个过程无需第三方 SDK,完全自主可控。


写在最后:这不是终点,而是起点

pjsip 移植成功只是第一步。接下来你还可以扩展:

  • ✅ 支持视频通话(启用--enable-video并接入 CameraX);
  • ✅ 实现消息收发(MESSAGE 方法);
  • ✅ 集成回声消除(AEC)提升音质;
  • ✅ 添加来电铃声、震动提醒;
  • ✅ 支持多账号切换;

而且由于 pjsip 的架构非常清晰,后续维护和优化也更容易。

如果你正在做一个对讲系统、远程工单、智能门禁、语音调度平台……那么这套方案值得你花两天时间把它跑通。

毕竟,在实时通信领域,能自己掌控底层协议栈,才谈得上“稳定”和“可靠”


如果你在实践中遇到了其他问题,比如某个 ABI 编译报错、特定机型音频异常,欢迎留言交流。我可以根据具体错误给出针对性建议。

附:文中提到的所有脚本和代码片段均已验证可用,可私信索取完整工程模板。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询