MFC框架下SIP语音通话客户端开发:从PJSIP集成到音频处理实战

📅 2026/7/15 21:30:23 👁️ 阅读次数 📝 编程学习
MFC框架下SIP语音通话客户端开发:从PJSIP集成到音频处理实战

1. 项目概述:在MFC框架下构建一个SIP语音通话客户端

如果你是一个长期在Windows桌面端摸爬滚打的C++开发者,尤其是对MFC(Microsoft Foundation Classes)又爱又恨的那批人,那么“在MFC里搞一个SIP客户端实现语音通话”这个想法,大概率在你脑子里闪过不止一次。这听起来像是一个老技术栈(MFC)去挑战一个经典但复杂的通信协议(SIP),最终实现一个现代应用(VOIP通话)。没错,这正是我们今天要深入探讨的核心。这不仅仅是一个简单的功能实现,它更像是一次对MFC框架潜力的深度挖掘,以及对实时音视频(RTC)底层原理的实践探索。想象一下,你手头有一个遗留的、基于MFC的大型客户端软件,现在需要为其增加内部语音对讲或客服通话功能,从头换框架成本太高,那么基于现有MFC架构集成SIP和音频处理,就成了一个非常务实且具有挑战性的选择。这个项目适合那些已经熟悉MFC基础、对Windows消息机制和C++面向对象编程有较好理解,并渴望向网络通信和多媒体处理领域迈进的开发者。通过它,你不仅能巩固MFC的UI与业务逻辑整合能力,更能深入理解SIP信令交互、RTP/RTCP媒体流传输以及音频采集/播放的完整链条。

2. 技术选型与整体架构设计

2.1 为什么坚持MFC?框架的利与弊

在开始动手之前,我们必须直面灵魂拷问:202X年了,为什么还要用MFC?直接上Qt、WPF甚至Electron不香吗?对于全新的绿色项目,这些现代框架无疑是更优解。但现实往往更复杂:你需要维护或扩展一个历史悠久的MFC遗产系统;你的团队对MFC技术栈有深厚的积累;项目对安装包体积、运行时依赖有极端苛刻的要求(MFC程序静态链接后可以非常精简);或者你需要深度调用一些仅通过COM或原生Win32 API暴露的硬件或系统功能。在这些场景下,MFC仍然是一个稳定、高效且可控的选择。然而,我们必须清醒认识到其弊端:UI现代化困难、跨平台能力为零、社区活跃度低。因此,本项目的核心思路是将MFC主要作为UI容器和业务逻辑调度器,而将SIP信令、媒体处理等核心通信功能剥离为独立的、可替换的C++模块或第三方库,从而在利用MFC优势的同时,规避其在网络多媒体领域的短板。

2.2 SIP协议栈与媒体引擎的选择

这是项目的技术核心。我们不可能从Socket开始一字一句地实现完整的SIP/RTP协议,那将是一个浩大的工程。明智的做法是集成成熟的开源库。

  1. SIP协议栈选型

    • PJSIP:这是最主流、最强大的选择。它是一个用C语言编写的开源多媒体通信库,不仅完整实现了SIP、SDP、RTP/RTCP等协议,还内置了音频编解码(如G.711, G.722, OPUS)、回声消除、网络穿透(STUN)等媒体处理功能。其设计清晰,文档相对完善,且被众多商业项目验证。虽然它是C库,但完全可以用C++封装调用。这是我们首推的方案
    • eXosip/oSIP:另一个流行的C语言SIP协议栈。eXosip在oSIP基础上提供了更易用的高层API。它比PJSIP更轻量,但媒体处理能力较弱,通常需要搭配其他媒体库(如PortAudio、WebRTC audio)使用。如果你只需要最基础的SIP信令控制,这是一个备选。
    • 纯C++实现(如RFC阅读者):仅适用于教育或极度定制化场景,不推荐用于生产环境。
  2. 媒体处理与音频I/O

    • 如果选用PJSIP,其内置的pjmedia子库已经提供了强大的音频设备抽象、编解码和传输能力,基本可以一站式解决。
    • 如果选用eXosip或需要更灵活的音频管道,可以考虑:
      • PortAudio:跨平台的音频I/O库,抽象了不同操作系统的音频API(如Windows的WASAPI/MME, Linux的ALSA)。
      • WebRTC Audio Processing Module (APM):如果你需要顶级的回声消除(AEC)、噪声抑制(NS)、自动增益控制(AGC),可以集成WebRTC的音频处理模块。但这会显著增加复杂度。
    • Windows原生API:对于MFC程序,直接使用waveIn/waveOut或更现代的WASAPI也是可行的,但这将把你牢牢绑在Windows平台上。

整体架构设计图(文字描述): 我们的客户端将采用分层设计:

  • UI表现层(MFC):负责显示通话状态、联系人列表、拨号盘,并响应用户的点击、输入事件。例如,CButton触发呼叫,CEdit显示状态,CListCtrl展示通话记录。
  • 业务逻辑层(C++ 类):这是连接UI和通信核心的桥梁。它接收UI层的事件,转化为具体的SIP操作(如发起呼叫、挂断),并管理通话状态机。同时,它也将从通信核心层收到的状态更新(如来电振铃、通话建立)反馈给UI层更新显示。这里会大量用到MFC的消息映射(ON_MESSAGE)和自定义事件通知。
  • 通信核心层(第三方库封装):这是最独立的一层。我们创建一个或多个C++类(如SipAgentMediaEngine),内部封装对PJSIP等库的调用。这一层专注于协议解析、消息收发、媒体流建立和处理。它通过回调函数(Callback)或观察者模式(Observer)将事件通知给业务逻辑层。关键点:这一层应尽量少用或不用MFC特有的数据类型和机制,保持其纯净性,以便未来可能的移植。

2.3 开发环境搭建要点

  • IDE:Visual Studio 2019或2022。虽然怀念VC6.0,但新版本对C++11/14/17标准支持更好,调试器也更强大。创建项目时选择“MFC应用程序”。
  • 第三方库集成
    • PJSIP:从其官网下载源码包。编译Windows版本通常需要在命令行中配置configure脚本(指定VS工具链),然后nmake。这个过程可能会遇到路径、依赖问题,需要耐心。编译后会得到.lib静态库和.dll动态库以及头文件。
    • 在VS中配置:将库文件的目录添加到项目的“附加包含目录”和“附加库目录”;将.lib文件名添加到“附加依赖项”。对于动态库,需要将.dll文件放在最终可执行文件的同级目录或系统路径。
  • MFC项目设置:在“项目属性”中,注意将“字符集”设置为“使用多字节字符集”或“Unicode”,这会影响字符串处理,需要与你集成的库保持一致。PJSIP通常使用窄字符(多字节)。

注意:编译PJSIP等开源库是第一个“坑”。务必仔细阅读其官方文档中的READMEINSTALL文件。对于Windows,通常推荐使用pjproject-vsxx.sln(如果提供)这样的Visual Studio解决方案文件来编译,这比命令行更友好。确保编译的库的运行时(MT/MD)与你的MFC项目设置匹配,否则会导致链接错误或运行时崩溃。

3. 核心模块实现详解

3.1 SIP信令模块的封装与初始化

我们以集成PJSIP为例。首先,我们需要创建一个管理SIP生命周期的类,比如叫CSipEngine

// SipEngine.h #pragma once #include <pjsua2.hpp> // PJSIP的C++ API头文件 #include <string> #include <functional> class CSipEngine : public pj::AccountCallback { public: CSipEngine(); virtual ~CSipEngine(); bool Init(const std::string& server, int port, const std::string& user, const std::string& pass); void Destroy(); bool MakeCall(const std::string& targetUri); void AnswerCall(); void HangupCall(); // 状态回调,通过自定义消息或事件通知UI std::function<void(const std::string&)> onCallStateChanged; std::function<void(const std::string&)> onIncomingCall; protected: // 重写PJSIP回调 virtual void onIncomingCall(pj::OnIncomingCallParam &prm) override; virtual void onCallState(pj::OnCallStateParam &prm) override; // ... 其他需要重写的回调,如onRegState private: pj::Endpoint* m_ep; // PJSIP端点 pj::Account* m_acc; // SIP账户 pj::AudioMedia* m_audMed; // 音频媒体 pj::Call* m_currentCall; // 当前通话 // ... 其他成员变量 };

初始化流程详解(Init函数内)

  1. 创建Endpointm_ep = new pj::Endpoint;这是PJSIP所有操作的起点。
  2. Endpoint初始化m_ep->libCreate();然后进行一堆配置,通过EpConfig设置日志级别、UDP/TCP端口、编解码器优先级等。这里有个关键点:线程模型。PJSIP默认有自己的工作线程,我们需要确保它和MFC的UI主线程能安全通信。通常,我们会让PJSIP运行在后台线程,通过向UI线程PostMessage来更新界面。
    pj::EpConfig ep_cfg; ep_cfg.logConfig.level = 4; // 设置日志级别,调试时有用 ep_cfg.uaConfig.maxCalls = 4; // 最大通话数 ep_cfg.uaConfig.userAgent = "MyMfcSipClient/1.0"; m_ep->libInit(ep_cfg);
  3. 创建传输层m_ep->transportCreate(PJSIP_TRANSPORT_UDP, ...);指定使用UDP还是TCP监听。
  4. 启动库m_ep->libStart();至此,PJSIP内核开始运行。
  5. 注册SIP账户
    pj::AccountConfig acc_cfg; acc_cfg.idUri = "sip:" + user + "@" + server; // 例如 sip:1001@192.168.1.100 acc_cfg.regConfig.registrarUri = "sip:" + server; acc_cfg.sipConfig.authCreds.push_back( pj::AuthCredInfo("digest", "*", user, 0, pass) ); m_acc = new pj::Account; m_acc->create(acc_cfg, true); // true表示立即注册
  6. 设置回调:因为CSipEngine继承了pj::AccountCallback,并在创建账户时注册了自身,所以当有来电或通话状态变化时,onIncomingCallonCallState就会被PJSIP的后台线程调用。

关键技巧:在onIncomingCallonCallState这些回调函数中,绝对不能直接操作MFC的UI控件,因为它们运行在PJSIP的线程,而非MFC的UI主线程。正确的做法是,在这些回调里缓存状态信息,然后通过::PostMessage或MFC的PostMessage函数,向主窗口发送一个自定义的Windows消息(如WM_USER + 100),在消息处理函数中安全地更新UI。

3.2 音频设备的查找与配置

通话的核心是音频。PJSIP的pjmedia抽象了音频设备。

  1. 枚举音频设备:在初始化后,可以获取系统可用的音频输入(麦克风)和输出(扬声器)设备列表,供用户在UI上选择。
    pj::AudDevManager& audDevMgr = m_ep->audDevManager(); pj::AudioDevInfoVector devs = audDevMgr.enumDev(); // 枚举所有设备 for (auto& dev : devs) { if (dev.inputCount > 0) { // 这是一个输入设备,添加到UI的麦克风下拉框 } if (dev.outputCount > 0) { // 这是一个输出设备,添加到UI的扬声器下拉框 } }
  2. 设置当前设备:根据用户选择或默认配置,设置要使用的设备ID。
    audDevMgr.setCaptureDev(captureDevId); // 设置采集设备 audDevMgr.setPlaybackDev(playbackDevId); // 设置播放设备
  3. 音频编解码器管理:PJSIP支持多种编解码器。你需要根据网络带宽和语音质量要求,启用或禁用某些编解码器,并设置优先级。通常G.711(PCMA/PCMU)是保底必选的,因为它几乎任何SIP设备都支持。如果带宽允许,可以启用G.722(宽带音频)或OPUS(更高效,音质更好)。
    m_ep->codecSetPriority("PCMU/8000", 1); // 设置最高优先级 m_ep->codecSetPriority("PCMA/8000", 2); m_ep->codecSetPriority("G722/16000", 3);

实操心得:音频设备的选择和切换有时会导致无声或杂音。一个稳健的做法是,在程序启动时,保存当前系统的默认音频设备ID。在通话开始前,强制切换到自己选择的设备;通话结束后,再尝试恢复系统默认设备。此外,Windows的音频服务(特别是WASAPI)有时在设备热插拔时行为怪异,需要在代码中监听设备变化事件并做相应处理。

3.3 呼叫建立、维持与拆除的完整流程

这是SIP信令交互的核心体现。我们结合UI操作来描述。

  1. 发起呼叫(UI点击“呼叫”按钮)

    • UI层:用户输入号码(如“1002”),点击呼叫按钮。
    • 业务逻辑层:按钮事件处理函数中,获取目标URI(如“sip:1002@192.168.1.100”),调用CSipEngine::MakeCall(targetUri)
    • 通信核心层(MakeCall实现):
      pj::Call* call = new pj::Call(*m_acc, -1); // -1 表示创建新通话 pj::CallOpParam prm(true); // true 表示立即发起呼叫 call->makeCall(targetUri, prm); m_currentCall = call; // 保存当前通话引用
    • PJSIP库会自动构造并发送SIPINVITE请求。此时,UI应显示“正在呼叫...”或类似状态。
  2. 处理来电(PJSIP回调触发)

    • 当对方呼叫我们时,PJSIP在后台线程调用CSipEngine::onIncomingCall
    • 在该回调中,我们保存来电的CallId,然后向MFC主窗口发送一个自定义消息(如WM_INCOMING_CALL),并附带来电号码等信息。
    • MFC主窗口的消息处理函数收到后,弹出振铃提示框,并更新UI状态为“来电中...”。
  3. 应答呼叫(UI点击“接听”按钮)

    • UI层:用户点击接听。
    • 业务逻辑层:调用CSipEngine::AnswerCall()
    • 通信核心层:
      if (m_currentCall) { pj::CallOpParam prm; prm.statusCode = pjsip_status_code::PJSIP_SC_OK; m_currentCall->answer(prm); // 发送200 OK响应 }
    • 同时,PJSIP会自动建立音频媒体流(RTP)。此时,UI应显示“通话中...”,并开始计时。
  4. 通话中的媒体流连接

    • 通话建立后,我们需要将PJSIP的音频媒体流连接到之前配置的音频设备上。这通常在onCallState回调中,当检测到通话状态变为PJSIP_INV_STATE_CONFIRMED(已确认)时进行。
      if (prm.state == PJSIP_INV_STATE_CONFIRMED) { pj::CallInfo ci = call->getInfo(); if (ci.media.size() > 0) { // 获取音频媒体流 pj::AudioMedia& aud_med = (pj::AudioMedia&)call->getAudioMedia(-1); // 连接到声音设备管理器 m_ep->audDevManager().getCaptureDevMedia().startTransmit(aud_med); aud_med.startTransmit(m_ep->audDevManager().getPlaybackDevMedia()); } }
    • 这两行startTransmit调用,就建立了麦克风->网络发送,和网络接收->扬声器的音频通路。
  5. 挂断呼叫(UI点击“挂断”按钮或对方挂断)

    • 主动挂断:调用call->hangup(prm),发送BYE请求。
    • 对方挂断:在onCallState回调中收到PJSIP_INV_STATE_DISCONNECTED状态,然后清理通话资源。
    • 无论哪种方式,都需要断开音频流连接,并更新UI状态为“空闲”。

4. MFC UI与业务逻辑的深度整合

4.1 使用自定义消息进行线程间通信

这是MFC程序集成异步事件驱动的第三方库(如PJSIP)的关键模式。PJSIP的事件发生在它的工作线程,而MFC的UI控件必须在主线程(或称为UI线程)中操作。

  1. 定义自定义消息

    // 在stdafx.h或某个公共头文件中 #define WM_SIP_EVENT (WM_USER + 100) // 自定义消息基值 #define WM_SIP_CALL_STATE (WM_SIP_EVENT + 1) #define WM_SIP_INCOMING_CALL (WM_SIP_EVENT + 2) #define WM_SIP_REG_STATE (WM_SIP_EVENT + 3)
  2. 在SIP引擎回调中发送消息

    void CSipEngine::onIncomingCall(pj::OnIncomingCallParam &prm) { int callId = prm.callId; std::string from = prm.rdata.pjRxData->msg_info.from->user.ptr; // 获取来电号码(简化示例) // 将数据打包,通过PostMessage发送到主窗口 ::PostMessage(m_hMainWnd, WM_SIP_INCOMING_CALL, (WPARAM)callId, (LPARAM)new std::string(from)); }

    m_hMainWnd需要你在初始化时,将MFC主窗口的句柄传递给CSipEngine

  3. 在主窗口类中处理消息

    • 在头文件的afx_msg部分声明消息处理函数:
      afx_msg LRESULT OnSipIncomingCall(WPARAM wParam, LPARAM lParam);
    • 在CPP文件的消息映射表(BEGIN_MESSAGE_MAP)中添加映射:
      ON_MESSAGE(WM_SIP_INCOMING_CALL, &CMainFrame::OnSipIncomingCall)
    • 实现消息处理函数:
      LRESULT CMainFrame::OnSipIncomingCall(WPARAM wParam, LPARAM lParam) { int callId = (int)wParam; std::string* pFrom = (std::string*)lParam; CString strMsg; strMsg.Format(_T("来电号码: %s"), CString(pFrom->c_str())); // 现在可以安全地更新UI了,比如弹窗、播放铃声、更新状态栏 m_wndStatusBar.SetPaneText(0, strMsg); // 记得删除动态分配的内存! delete pFrom; return 0; }

重要警告:通过LPARAM/WPARAM传递指针是危险的,必须确保指针的生命周期。上面的例子中new std::string在回调线程创建,在UI线程删除,需要非常小心。更安全的方法是使用线程安全的消息队列,或者传递基本数据类型和固定大小的缓冲区。另一种MFC风格的做法是使用PostMessage发送消息,但将数据保存在SIP引擎的某个成员变量中(用锁保护),消息处理函数再去读取。

4.2 设计一个状态清晰的用户界面

一个典型的SIP客户端UI应包括:

  • 主窗口:显示当前状态(注册状态、通话状态、通话时长)。
  • 拨号盘:可以用多个CButton或一个自定义控件实现。
  • 联系人列表/通话记录:使用CListCtrl,支持双击呼叫。
  • 音频设备选择:两个CComboBox,分别列出麦克风和扬声器设备。
  • 控制按钮:呼叫/接听/挂断/保持/静音。按钮的状态应根据通话状态机动态启用或禁用(例如,通话中“呼叫”按钮应禁用,“挂断”按钮应启用)。

状态管理:在业务逻辑层维护一个清晰的状态机(如IDLE,REGISTERING,REGISTERED,DIALING,RINGING,CONNECTED,HELD等)。任何UI控件的状态变化都应基于这个状态机来驱动,而不是散落在各个事件处理函数中。这能极大减少Bug。

4.3 音频控制功能的实现:静音与保持

  1. 静音(Mute): 静音不是在SIP信令层实现的,而是在本地音频流处理层。PJSIP的AudioMedia提供了简单的方法。

    // 在CSipEngine中 void CSipEngine::SetMicrophoneMute(bool mute) { if (!m_ep) return; pj::AudDevManager& mgr = m_ep->audDevManager(); // 静音实际上是停止将采集的音频发送到通话流 // 我们需要一个标志位来记录静音状态,并控制音频流的传输 if (mute) { // 断开麦克风到通话流的连接 mgr.getCaptureDevMedia().stopTransmit(m_callAudioMedia); // m_callAudioMedia是当前通话的音频流引用 } else { // 重新连接 mgr.getCaptureDevMedia().startTransmit(m_callAudioMedia); } }

    UI上,一个“静音”按钮可以在按下和弹起时调用这个函数。

  2. 保持(Hold): 保持是SIP协议定义的动作,涉及信令交互。它通过发送re-INVITE请求,在SDP中修改媒体流属性为sendonlyinactive来实现。

    void CSipEngine::PutOnHold(bool hold) { if (!m_currentCall) return; pj::CallOpParam prm; if (hold) { // 设置本地保持 prm.opt.flag |= PJSUA_CALL_UPDATE_CONTACT; // 修改SDP,指示我们只接收不发送(或都不发送) // 这通常通过设置特定的SDP方向属性实现,PJSIP的Call::setHold()封装了此逻辑 m_currentCall->setHold(prm); } else { // 取消保持,恢复通话 m_currentCall->reinvite(prm); // 发送新的INVITE恢复双向媒体 } }

    在UI上,“保持”按钮通常是一个切换按钮,点击后状态变为“已保持”,再次点击恢复。

5. 项目调试、优化与常见问题排查

5.1 调试技巧与日志分析

  1. 启用PJSIP详细日志:在初始化Endpoint时,将日志级别(ep_cfg.logConfig.level)设置为5或更高。PJSIP会将日志输出到控制台或指定文件。通过分析这些日志,你可以看到每一行SIP信令的发送和接收,这是排查注册失败、呼叫不通等问题的最直接手段。
  2. 使用网络抓包工具Wireshark是必备神器。在测试时,在Wireshark中过滤SIPRTP协议包。你可以清晰地看到INVITE,200 OK,ACK,BYE等SIP消息的完整内容,以及RTP流是否建立。对比SDP(Session Description Protocol)内容,可以确认双方协商的编解码器、IP地址和端口是否正确。
  3. MFC内存泄漏检测:在Visual Studio调试模式下,可以使用_CrtSetDbgFlag等函数在程序退出时输出内存泄漏报告。由于我们混合了C++new/delete和PJSIP可能的内存管理,仔细检查报告至关重要。
  4. 线程死锁与界面冻结:确保所有耗时的操作(如网络请求、复杂的媒体处理)都放在工作线程中。如果UI在主线程等待一个工作线程的结果,必须使用非阻塞的方式(如消息、事件、回调),否则界面会卡死。使用TRACE宏或OutputDebugString输出线程ID,帮助判断函数在哪个线程执行。

5.2 性能优化与稳定性提升

  1. 回声消除(AEC):这是VOIP通话质量的杀手。PJSIP内置了回声消除功能,但需要正确配置。确保在Endpoint初始化时启用它,并根据实际环境调整参数。如果效果不佳,可以考虑集成更强大的算法,如WebRTC的AEC模块,但这会显著增加集成复杂度。
  2. 网络适应性
    • NAT穿透:如果客户端位于路由器后(私网IP),需要注册到支持STUN/ICE/TURN的公网SIP服务器才能被叫通。PJSIP支持STUN配置。在EpConfiguaConfig里设置STUN服务器地址。
    • 码率与抖动缓冲:在网络较差时,可以动态调整使用的音频编解码器(从高码率切换到G.711),并适当增加抖动缓冲区(jitter buffer)的大小来对抗网络抖动,但会增加延迟。PJSIP的MediaConfig可以设置这些参数。
  3. 资源管理与异常处理
    • 妥善管理PJSIP对象生命周期:遵循“谁创建,谁销毁”的原则。确保在程序退出或账户注销时,按顺序销毁CallAccountEndpoint
    • 处理断网重连:监听网络状态变化(Windows下可以监听网络事件),当检测到网络恢复时,尝试重新初始化PJSIP的传输层或重新注册账户。
    • 心跳与保活:对于TCP连接,可能需要配置SIP的保活机制(如定期发送OPTIONS消息),以防止中间防火墙断开空闲连接。

5.3 常见问题速查与解决方案

下表列出了开发过程中最可能遇到的“坑”及其排查思路:

问题现象可能原因排查步骤与解决方案
无法注册到SIP服务器1. 网络不通。
2. 服务器地址、端口错误。
3. 用户名/密码错误。
4. 防火墙/安全软件拦截。
5. 服务器不支持客户端所用传输协议(UDP/TCP)。
1. Ping服务器地址,用telnet测试端口。
2. 检查PJSIP日志,看REGISTER请求是否发出,以及服务器的响应(通常是401/403)。
3. 在Wireshark中抓包,对比正常注册流程。
4. 暂时关闭防火墙测试。
5. 尝试切换transportCreate使用的协议。
可以注册,但无法呼叫1. 对方号码(URI)格式错误。
2. 服务器路由问题。
3. 本地SDP(媒体地址)是私网IP,对方无法连接。
1. 检查构造的SIP URI格式(如sip:1002@server.com)。
2. 查看PJSIP日志中INVITE请求的发送和响应。
3. 检查SDP中的c=行IP地址。如果是在内网,需要在服务器或客户端配置STUN/ICE以获取公网可达地址。
呼叫接通后没有声音1. 音频设备未正确选择或初始化。
2. 音频编解码器未协商一致。
3. 防火墙阻止了RTP端口(通常为16384-32768)。
4. 音频流传输未正确连接(startTransmit未调用)。
1. 检查PJSIP日志中音频设备初始化是否成功。
2. 查看SDP交互内容,确认双方共同的编解码器。强制启用G.711测试。
3. 在Wireshark中过滤RTP,看是否有音频包收发。如果没有,检查网络和防火墙。
4. 确认在通话建立(CONFIRMED状态)后,正确调用了连接音频设备与媒体流的代码。
通话中有回声或啸叫1. 未启用或配置好回声消除(AEC)。
2. 扬声器音量过大,被麦克风再次采集。
1. 确认PJSIP初始化时启用了AEC (ep_cfg.medConfig.ecOptions)。
2. 建议用户使用耳机,或调低扬声器音量。在代码中尝试调整AEC的尾音长度(ecTailLen)参数。
程序运行一段时间后崩溃1. 内存泄漏。
2. 多线程访问冲突(如在回调中直接操作已销毁的MFC对象)。
3. PJSIP对象生命周期管理错误。
1. 使用VS内存泄漏检测工具。
2. 所有从PJSIP回调到UI的操作,必须通过消息队列或检查对象有效性后再进行。使用临界区(CCriticalSection)或互斥量保护共享数据。
3. 确保销毁顺序:先挂断所有通话(Call),再删除账户(Account),最后销毁端点(Endpoint)。
UI界面在通话时卡顿或无响应1. 在UI线程执行了耗时操作(如复杂的音频处理)。
2. 消息队列被塞满。
1. 使用性能分析工具定位耗时函数。将耗时操作移至工作线程。
2. 确保向UI线程PostMessage的频率不要过高,例如,不要将每一帧音频数据都发送消息。

最后一点个人体会:开发这样一个MFC SIP客户端,最难的不是某个API的调用,而是如何将事件驱动的异步网络库消息驱动的MFC框架优雅、稳定地融合。这要求你对Windows消息循环、多线程编程有深刻理解。建议在项目初期就搭建好稳固的“异步事件 -> Windows消息 -> UI更新”的通信框架,这会让后续所有功能的开发事半功倍。另外,VOIP涉及声学、网络、操作系统多个层面,问题可能出现在任何一环,学会使用Wireshark和日志进行分层排查,是解决复杂问题的必备能力。这个项目做下来,你对实时通信系统的认识会远超仅仅调用一个SDK的层次。