2026/4/5 21:31:55
网站建设
项目流程
广州番禺做网站,wordpress文章内容编辑器,西安代做网站,百度关键词排名价格从零开始#xff1a;在Android上跑通PJSIP的实战之路 你有没有遇到过这样的场景#xff1f;项目需要做一个VoIP软电话#xff0c;要求支持SIP注册、来电弹窗、双向通话#xff0c;还得稳定穿透各种复杂的NAT网络。市面上的SDK要么太贵#xff0c;要么功能残缺#xff0c…从零开始在Android上跑通PJSIP的实战之路你有没有遇到过这样的场景项目需要做一个VoIP软电话要求支持SIP注册、来电弹窗、双向通话还得稳定穿透各种复杂的NAT网络。市面上的SDK要么太贵要么功能残缺还可能夹带私货。于是你把目光投向了开源世界——PJSIP。它名声在外协议完整、性能强悍、MIT授权免费商用。但真要把它塞进你的Android App里你会发现官方文档像一本没写完的操作手册社区问答散落在十年之前的论坛帖中。编译报错一堆运行起来不是崩溃就是静音。别急。这篇文章不讲空话只说实战。我会带你一步步走过我踩过的所有坑从环境搭建到最终实现一个能打电话的APK。这不是理论推演而是我在真实项目中验证过的完整路径。为什么是 PJSIP先回答一个问题明明有那么多现成的通信库为什么要选 PJSIP因为它是少数能把 SIP 协议栈 音频引擎 NAT穿透 编解码全包圆的C语言库。不像某些方案信令用一个库媒体处理又要引入FFmpeg和WebRTC子模块最后包体积飙到20MB起步。PJSIP 的设计哲学很“嵌入式”- 最小可运行配置仅需64KB内存- 支持G.711、Opus、iLBC等主流语音编码- 内建AEC回声消除、VAD语音检测、Jitter Buffer- 完整实现STUN/TURN/ICE连DTLS-SRTP都给你备好了。更重要的是它允许你完全掌控底层逻辑。比如你想做一款可视门禁系统只需要语音对讲远程开门控制不需要视频渲染那一套重包袱——PJSIP可以轻松裁剪掉视频相关模块静态链接后整个libpjsip.a加起来不到1.5MB。这正是我们在某智能楼宇项目中的选择依据。准备工作NDK 环境到底怎么配很多人第一步就卡住了PJSIP 是用 Autotools 构建的而 Android NDK 推荐用 CMake。两者怎么协同答案是让 configure 脚本以为自己在交叉编译Linux程序实际上指向NDK的工具链。先搞清楚几个关键点NDK r21 已全面转向 LLVM/Clang不再推荐使用GCC每个ABI如arm64-v8a对应不同的clang前端命令--host参数必须与目标架构匹配必须显式指定 sysroot 和 API Level。举个例子你要为 arm64-v8a 编译那编译器路径应该是$NDK/toolchains/llvm/prebuilt/linux-x86_64/bin/aarch64-linux-android21-clang这里的21就是你设定的最低API等级。低于这个版本会链接失败。自动化脚本才是王道手动敲几十个参数太容易出错。我封装了一个通用构建脚本放在项目的scripts/build_pjsip.sh下#!/bin/bash export ANDROID_NDK/home/user/android-ndk-r25b export TARGET_ABIarm64-v8a export API_LEVEL21 case $TARGET_ABI in armeabi-v7a) HOSTarm-linux-androideabi TOOLCHAIN_SUFFIXarmv7a-linux-androideabi ;; arm64-v8a) HOSTaarch64-linux-android TOOLCHAIN_SUFFIXaarch64-linux-android ;; *) echo Unsupported ABI exit 1 ;; esac export TOOLCHAIN$ANDROID_NDK/toolchains/llvm/prebuilt/linux-x86_64 export CC$TOOLCHAIN/bin/$TOOLCHAIN_SUFFIX$API_LEVEL-clang export CXX$TOOLCHAIN/bin/$TOOLCHAIN_SUFFIX$API_LEVEL-clang export AR$TOOLCHAIN/bin/$HOST-ar export LD$TOOLCHAIN/bin/$HOST-ld export STRIP$TOOLCHAIN/bin/$HOST-strip # 开始配置 ./configure \ --host$HOST \ --prefix$(pwd)/output/$TARGET_ABI \ --enable-sharedno \ --enable-staticyes \ --disable-video \ --disable-libwebrtc \ --disable-sound \ --disable-opencore-amr \ --with-android-ndk$ANDROID_NDK \ --with-sdk-level$API_LEVEL \ ac_cv_func_strnlen_workingyes \ CFLAGS-DANDROID -D__ANDROID_API__$API_LEVEL -fdata-sections -ffunction-sections -Os \ LDFLAGS-Wl,-gc-sections make clean make -j$(nproc) make install✅ 提示如果你打算集成OpenH264或x264用于未来扩展视频能力可以把--disable-video去掉并添加--with-openh264yes。运行这个脚本后你会在output/arm64-v8a/lib目录下看到十几个.a文件包括libpjsua2.a,libpjmedia.a,libpjnath.a等。头文件也会自动复制到include目录。编译时报错了这几个坑你肯定遇到过❌ 错误一undefined reference to dlopen这是最常见的链接错误。虽然你在代码里没调用dlopen但PJSIP内部用了动态加载机制比如插件式日志模块需要链接系统的动态库。解决方法在LDFLAGS中加入以下库-llog -landroid -ldl -latomic其中--llog打Log用--landroid访问Android原生音频设备要用--ldl解决dlopen问题--latomic某些原子操作依赖。可以在 configure 命令末尾加上LIBS-llog -landroid -ldl -latomic❌ 错误二error: use of undeclared identifier AF_PACKET这是因为 PJSIP 默认尝试启用数据链路层抓包功能用于调试但在Android上不允许。解决方法强制关闭网络嗅探相关特性在 CFLAGS 中添加-DPJ_HAS_TCP0 -DPJ_SOCK_HAS_PKTINFO0或者干脆在config_site.h中定义#define PJ_HAS_TCP 0 #define PJ_SOCK_HAS_PKTINFO 0❌ 错误三找不到android/log.h说明SYSROOT没设置对。确保你的 NDK 路径正确并且在 configure 时传入--with-android-ndk参数。JNI封装如何安全地打通 Java 与 C 的边界现在我们有了.a静态库下一步是要让Java层能调用这些C函数。核心思路是写一层JNI胶水代码把 PJSUA-LIB 的事件模型桥接到 Android 的消息循环中。第一步初始化 PJSIP 引擎我们在 Java 层定义一个SipManager类public class SipManager { static { System.loadLibrary(pjsip); } public native int initialize(String sipServer); public native int makeCall(String number); public native void hangUp(int callId); // 回调接口 public interface Listener { void onIncomingCall(int callId); void onCallStateChange(int callId, String state); } }对应的 JNI 实现如下简化版#include jni.h #include pjlib.h #include pjsua-lib/pjsua.h static JavaVM *g_jvm NULL; static jobject g_listener_obj NULL; static jmethodID mid_incoming_call NULL; JNIEXPORT jint JNICALL Java_com_example_sip_SipManager_initialize(JNIEnv *env, jobject thiz, jstring sip_server) { // 保存 JVM 指针供回调线程使用 (*env)-GetJavaVM(env, g_jvm); // 创建全局引用防止对象被GC回收 g_listener_obj (*env)-NewGlobalRef(env, thiz); // 获取回调方法ID jclass cls (*env)-GetObjectClass(env, thiz); mid_incoming_call (*env)-GetMethodID(env, cls, onIncomingCall, (I)V); // 初始化PJSIP pj_status_t status pjsua_create(); if (status ! PJ_SUCCESS) return -1; pjsua_config cfg; pjsua_logging_config log_cfg; pjsua_config_default(cfg); pjsua_logging_config_default(log_cfg); cfg.cb.on_incoming_call on_incoming_call; log_cfg.console_level 3; status pjsua_init(cfg, log_cfg, NULL); if (status ! PJ_SUCCESS) return -2; status pjsua_start(); if (status ! PJ_SUCCESS) return -3; return 0; }注意这里的关键细节-NewGlobalRef是必须的否则Java对象可能在后台被回收-GetMethodID要提前缓存避免每次回调都查找- 所有非主线程的PJSIP回调必须先 AttachCurrentThread 到 JVM。第二步处理来电回调当SIP服务器发来 INVITE 请求时PJSIP会触发on_incoming_call回调void on_incoming_call(pjsua_acc_id acc_id, pjsua_call_id call_id, pjsip_rx_data *rdata) { JNIEnv *env; int need_detach 0; // 判断当前是否已附加到JVM int get_env_result (*g_jvm)-GetEnv(g_jvm, (void**)env, JNI_VERSION_1_6); if (get_env_result JNI_EDETACHED) { if ((*g_jvm)-AttachCurrentThread(g_jvm, env, NULL) 0) { return; } need_detach 1; } if (mid_incoming_call) { (*env)-CallVoidMethod(env, g_listener_obj, mid_incoming_call, call_id); } if (need_detach) { (*g_jvm)-DetachCurrentThread(g_jvm); } }这段代码处理了多线程环境下 JNI 调用的安全性问题。很多崩溃就是因为回调线程未Attach导致的。实际运行中遇到的问题及应对策略 音频卡顿或单向通话这是Android平台上最典型的“表面正常、实则残废”的问题。常见原因后台进程被系统冻结音频采集线程调度延迟AudioRecord缓冲区太小导致丢帧采样率不一致App设为48kHzPJSIP默认16kHz解决方案使用高优先级线程运行音频流struct sched_param param; param.sched_priority 10; pthread_setschedparam(pthread_self(), SCHED_FIFO, param);在AndroidManifest.xml添加权限和前台服务声明uses-permission android:nameandroid.permission.RECORD_AUDIO/ uses-permission android:nameandroid.permission.FOREGROUND_SERVICE/ service android:name.SipService android:foregroundServiceTypemicrophone/启动时创建通知保持服务活跃Notification notification new NotificationCompat.Builder(this, CHANNEL_ID) .setContentTitle(VoIP Service Running) .setSmallIcon(R.drawable.ic_call) .build(); startForeground(1, notification);统一音频参数在 PJSIP 初始化前设置pjsua_media_config media_cfg; pjsua_media_config_default(media_cfg); media_cfg.clock_rate 16000; // 统一使用16kHz media_cfg.snd_clock_rate 16000; media_cfg.audio_frame_ptime 20; // 20ms帧时长 media_cfg.channel_count 1; // 单声道这样既能降低功耗又能提升兼容性。 如何穿透企业级NAT很多用户反映在家能打在公司防火墙后就注册不上。根本原因是UDP端口被封锁。方案一启用 STUNpj_stun_config stun_cfg; pj_stun_config_default(stun_cfg); pj_stun_config_stun_server(stun_cfg, stun.l.google.com, 19302); pjsua_transport_config tp_cfg; pjsua_transport_config_default(tp_cfg); pjsua_transport_config_set_stun_server(tp_cfg, stun.l.google.com);方案二部署自有 TURN 服务器推荐使用 Coturn 搭建中继服务turnserver -a -f -r your-domain.com \ --lt-cred-mech \ --useradmin:password \ --realmyour-domain.com \ --listening-port3478然后在PJSIP中配置pjsua_var.turn_cfg.enable PJ_TRUE; pjsua_var.turn_cfg.server pj_str(turn.your-domain.com); pjsua_var.turn_cfg.port 3478; pjsua_var.turn_cfg.username pj_str(admin); pjsua_var.turn_cfg.password pj_str(password);这样一来即使处于 symmetric NAT 之后也能通过 relay mode 建立连接。性能与体验优化建议✅ 库体积控制如果你只做语音通话务必关闭不需要的模块--disable-video \ --disable-libwebrtc \ --disable-speex-aec \ --disable-gsm-codec \ --disable-ilbc-codec \ --without-libffi最终静态库总大小可压缩至1.2~1.5MB远小于任何基于WebRTC的方案。✅ 功耗优化启用 VADVoice Activity Detectionmedia_cfg.no_vad 0; // 启用VAD静默期间停止发送RTP包节省流量和电量。空闲时暂停录音线程收到呼叫再唤醒。✅ 安全性增强启用 TLS 传输信令pjsua_transport_config_default(tcp_cfg); tcp_cfg.tls_setting.method PJSIP_TLSV1_METHOD; tcp_cfg.port 5061; pjsua_transport_create(PJSIP_TRANSPORT_TLS, tcp_cfg, NULL);启用 SRTP 加密媒体流cfg.use_srtp PJMEDIA_SRTP_REQUIRED; cfg.srtp_secure_signaling 1;杜绝中间人攻击和窃听风险。结语这条路还能走多远完成上述步骤后我们的App已经可以完成完整的SIP流程注册 → 主叫 → 被叫 → 双向通话 → 挂断。但这只是起点。PJSIP的强大之处在于它的可扩展性。接下来你可以轻松添加DTMF按键透传多账号切换IM消息MSRP协议视频通话启用Video模块 OpenGL ES渲染会议桥接使用 pjsua_conf 连接多个通话而且这一切都不需要更换底层框架。所以当你下次面对“定制化VoIP需求”时不妨试试亲手把PJSIP搬上Android。虽然前期门槛略高但一旦跑通你就拥有了一个真正属于自己的通信引擎。如果你在集成过程中遇到了其他问题欢迎在评论区留言交流。我可以分享更详细的 Makefile 配置、Coturn 部署脚本、以及音频调试工具链。