黄山市网站建设_网站建设公司_HTTPS_seo优化
2025/12/24 5:50:41 网站建设 项目流程

从零打造 Android 上的 pjsip 软电话:一次深度移植实战

你有没有遇到过这样的场景?客户急着要一个能打 SIP 电话的 App,不依赖微信、不限制运营商,还要低延迟、高保真、后台不断线。市面上的 SDK 不是太贵就是功能残缺,最后只能硬着头皮上——自己搞一套基于pjsip的软电话系统。

这正是我最近半年的真实写照。从第一次编译失败到最终在十几款机型上稳定通话,踩过的坑足够写一本《pjsip 移植避坑指南》。今天,我就把这套“Android + pjsip” 的完整移植路径拆开来讲清楚,不绕弯子,不堆术语,只讲你真正需要知道的东西。


为什么选 pjsip?不只是因为它是开源的

先说结论:如果你要做的是标准 SIP 协议栈支持的 VoIP 应用,pjsip 几乎是 C/C++ 领域里唯一靠谱的选择。

它不是简单的 SIP 解析器,而是一个完整的多媒体通信框架。注册、呼叫、媒体协商(SDP)、RTP 流传输、回声消除(AEC)、NAT 穿透(ICE)……这些你在 RFC 文档里看到的专业名词,它都给你实现了。

更关键的是,它的设计非常“嵌入式友好”——代码轻量、内存占用低、可裁剪性强。我在一台老旧的红米 Note 4X 上测试时,空载内存仅占 8MB 左右,CPU 使用率不到 3%,完全不影响其他应用运行。

当然,也有代价:它是纯 C 写的,和 Android 的 Java/Kotlin 生态天然隔了一层。你要跨过去,靠的就是 NDK 和 JNI。


编译第一关:让 pjsip 在 Android 上“活过来”

别指望直接make就能出.so文件。pjsip 原生并不认识 Android,必须通过NDK 进行交叉编译,生成适用于不同 CPU 架构的动态库。

我们到底在做什么?

简单说,就是在 x86_64 的开发机上,用 Android 提供的 Clang 编译器链,把 C 代码“翻译”成能在 ARM 设备上跑的二进制文件。这个过程叫交叉编译。

你需要准备:
- 安装好 Android NDK(推荐 r25b 或以上)
- 下载 pjsip 源码(建议 v2.15+)
- 准备一个干净的构建环境

实战脚本:我是怎么编出来的

这是我最终稳定的编译脚本,专为 arm64-v8a 优化:

#!/bin/bash export ANDROID_NDK_ROOT=/opt/android-ndk-r25b export TARGET_ABI=arm64-v8a export ANDROID_API=21 ./configure-android \ --use-ndk-cflags \ --target=android-aarch64 \ --with-android-ndk=$ANDROID_NDK_ROOT \ --with-android-api=$ANDROID_API \ --enable-g711-codec \ --enable-opus-codec \ --disable-video \ --without-openSSL \ --prefix=/tmp/pjsip-install-$TARGET_ABI make clean && make dep && make -j$(nproc) make install

几点关键说明:
---target=android-aarch64是目前主流架构,armeabi-v7a 可改用android-arm
---with-android-api=21表示最低支持 Android 5.0,太低会缺少系统调用
---disable-video强烈建议初学者关闭视频模块,否则依赖太多(OpenH264、libyuv 等),极易编译失败
---without-openSSL并非不用 TLS,而是后期单独集成 BoringSSL 或 conscrypt 更可控

执行完后你会得到一堆.a静态库文件。接下来要用Android.mk或 CMake 把它们打包成libpjsip.so


JNI 封装:打通 Java 与 native 的“任督二脉”

编译成功只是第一步。真正的难点在于:如何让 Kotlin 写的 UI 层,调得动 C 层的 pjsip 功能?

答案是JNI—— Java Native Interface。

别被官方文档骗了

网上很多教程教你用javah生成头文件,但那是老古董做法。现在 Android Studio 完全支持javac -h自动生成.h文件,根本不用手动敲。

举个例子:

public class SipService { static { System.loadLibrary("pjsip"); } public static native int init(); public static native int registerAccount(String uri, String username, String passwd); public static native int makeCall(String number); public static native void hangUp(); }

只要你在 Java 中声明native方法,AS 会在编译时自动生成对应的函数签名,比如:

JNIEXPORT jint JNICALL Java_com_example_sip_SipService_init(JNIEnv *env, jclass clazz)

然后你在这个函数里调 pjsip 的 API 就行了。

最容易翻车的地方:回调通知 UI

pjsip 是事件驱动的。比如有人来电,它会触发on_incoming_call()回调函数。但这个函数是 C 写的,你怎么通知 Java 层弹出接听界面?

这就涉及跨线程 JNI 调用

常见错误写法:

// ❌ 错!当前线程可能未绑定 JVM (*env)->CallVoidMethod(env, listener_obj, method_id, from);

正确姿势:

void on_incoming_call(pjsua_acc_id acc_id, pjsua_call_id call_id, ...) { JNIEnv *env; bool need_detach = false; JavaVM *jvm = get_jni_env()->jvm; // 全局保存的 JVM 引用 int status = (*jvm)->GetEnv(jvm, (void**)&env, JNI_VERSION_1_6); if (status == JNI_EDETACHED) { (*jvm)->AttachCurrentThread(jvm, &env, NULL); need_detach = true; } jstring jfrom = (*env)->NewStringUTF(env, "caller@domain.com"); jclass cls = (*env)->GetObjectClass(env, g_listener_obj); jmethodID mid = (*env)->GetMethodID(env, cls, "onIncomingCall", "(Ljava/lang/String;)V"); (*env)->CallVoidMethod(env, g_listener_obj, mid, jfrom); (*env)->DeleteLocalRef(env, jfrom); if (need_detach) { (*jvm)->DetachCurrentThread(jvm); } }

重点来了:
- 所有非主线程调用 JNI 前必须AttachCurrentThread
- 用完记得Detach,否则会导致线程无法退出
-g_listener_obj必须是NewGlobalRef创建的全局引用,不然会被 GC 回收


音频子系统适配:为什么你的 App 有时没声音?

这是我被 QA 相互甩锅最多的环节。用户反馈:“点了拨号,对方听不到我说话。” 查日志一切正常,抓包也有 RTP 包,唯独没声音。

问题往往出在音频路由和设备初始化顺序上。

OpenSL ES 是唯一选择吗?

理论上你可以用 AudioRecord + AudioTrack,但在 VoIP 场景下,延迟太高(通常 >100ms),而且容易被系统中断。

OpenSL ES才是正解。它是 Android 上最接近硬件层的音频接口,支持低至 10ms 的缓冲区设置,适合实时语音。

pjsip 自带opensles_dev.c驱动,但默认不启用。你需要在编译时加上:

--with-open-sl=yes

并在初始化时显式设置工厂:

pjmedia_aud_dev_param param; pjmedia_aud_dev_param_default(&param); param.dir = PJMEDIA_DIR_CAPTURE_PLAYBACK; param.rec_id = PJMEDIA_AUD_DEFAULT_REC_ID; param.play_id = PJMEDIA_AUD_DEFAULT_PLAY_ID; param.clock_rate = 16000; // 推荐 16kHz 匹配 G.711 param.channel_count = 1; param.samples_per_frame = 320; // 20ms frame @ 16k param.bits_per_sample = 16; aud_drv_id = pjmedia_open_sl_stream_create(&param, &capture_cb, &play_cb);

两个关键回调:
-capture_cb:麦克风采集到数据后,交给 pjsip 编码发送
-play_cb:pjsip 解码后的音频数据,送入扬声器播放

常见音频问题及对策

问题可能原因解决方案
某些机型无声音厂商魔改 ROM 导致 OpenSL 初始化失败添加 fallback 机制,尝试使用 dummy 设备或降级到 AudioTrack
听筒模式无声音频路由未切换调用AudioManager.setMode()setSpeakerphoneOn(false)
回声严重AEC 未生效或叠加启用 pjsip 内置 AEC,并禁用 Android 系统 AEC:setPreferredDevice(null)
首次通话延迟大音频设备首次创建耗时长预加载音频设备,在 App 启动时就初始化 OpenSL

还有一个隐藏陷阱:权限。除了常规的RECORD_AUDIO,你还得申请:

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

否则某些厂商 ROM 会静默拒绝音频操作。


系统架构全景图:软电话是怎么跑起来的?

我们来串一遍整个流程,看看各个模块是如何协作的。

[Kotlin UI] ↓ (JNI Call) [Native Layer: libpjsip.so] ↓ (pjsua API) [pjsip Stack] ↓ (Media Channel) [OpenSL ES Driver] ↓ [Android HAL → 麦克风/扬声器]

典型工作流:
1. App 启动 → 加载 so 库 → JNI.init() → pjsua_create()
2. 设置 SIP 账户信息 → 发起注册 → 收到 200 OK 表示上线
3. 用户点击拨号 → JNI.makeCall(number) → pjsip 发 INVITE → SDP 协商
4. 媒体通道建立 → OpenSL 启动录音/播放线程 → RTP 流开始传输
5. 对方接听 → 进入通话状态 → 定时发送 RTCP 报告监控质量
6. 挂断 → 发送 BYE → 释放资源,保持注册状态以便接收新来电

是不是看起来很顺?但现实远比这复杂。


实战中的四大难题,我是这样解决的

1. 后台被杀,收不到来电?

Android 的省电策略越来越激进。一旦 App 被清理,SIP 注册就失效了。

我的方案三连击:
- 使用前台服务(Foreground Service)+ 持久通知,防止被杀
- 结合WorkManager定时心跳重连,确保网络波动后能恢复
- 开启Firebase Cloud Messaging(FCM)穿透唤醒,收到 FCM 消息后立即尝试重建连接

注意:不要滥用 WakeLock,否则耗电严重,会被用户卸载。

2. 多种 ABI 怎么打包?

全量打包会让 APK 膨胀到 20MB+,用户体验极差。

推荐做法:按 ABI 分包

build.gradle中配置:

android { splits { abi { reset() include 'arm64-v8a', 'armeabi-v7a' universalApk false } } }

发布时上传多个 APK,Google Play 会自动分发对应版本。

3. 如何调试 native 崩溃?

pjsip 崩溃不会抛 Java Exception,而是直接SIGSEGV,App 闪退。

应对策略:
- 使用adb logcat | grep libc查看原生堆栈
- 在 Application 中捕获信号量(需 NDK 支持),记录崩溃上下文
- 关键函数入口加日志,例如:

PJ_LOG(3, (__FILE__, "Entering %s", __func__));

再配合adb logcat | grep pjsip,基本能定位 90% 的问题。

4. 编解码选哪个?

窄带语音用G.711(PCMA/PCMU),兼容性最好;
追求音质和抗丢包,选OPUS,支持动态码率、丢包隐藏;
若考虑带宽成本,可用iLBCG.729(注意专利问题)。

建议默认开启 OPUS + G.711 双编码,由 SDP 协商决定最终使用哪一个。


写在最后:这条路还能走多远?

完成这次移植后,我重新评估了这套方案的价值:

优势明显
- 完全自主可控,不受第三方 SDK 限制
- 支持标准 SIP 协议,可对接 Asterisk、FreeSWITCH、3CX 等任意 PBX
- 易于扩展视频、IM、会议等高级功能
- 性能优秀,资源占用低,适合嵌入式设备

⚠️挑战仍在
- 维护成本高,每次升级 pjsip 都要重新适配
- 不同 Android 版本和厂商 ROM 存在兼容性差异
- 需要熟悉 C、NDK、JNI、音频原理的复合型人才

但如果你正在做企业通信、智能门禁、远程医疗这类对稳定性要求高的项目,这套技术栈依然值得投入。

未来我计划进一步优化:
- 接入 AAudio 替代 OpenSL ES(Android 10+)
- 实现 ICE-TCP 和 TURN over TLS 提升穿透能力
- 集成 WebRTC 风格的 stats 监控面板

如果你也在折腾 pjsip,欢迎留言交流。毕竟,一个人走得快,一群人才能走得远。

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

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

立即咨询