PeerConnection 深度解析二:SetLocalDescription

0 阅读8分钟

源码路径:

  • api/peer_connection_interface.h

  • pc/peer_connection.cc

  • pc/sdp_offer_answer.cc

对应规范:W3C WebRTC 1.0 §4.6.1

上一节我们深入分析了CreateOffer,libwebrtc 帮我们生成了本地能里的SDP描述,那为什么自己生成的SDP 还要 SetLocalDescription? 很多人第一次看到这个流程都会觉得奇怪: SDP 是我自己 CreateOffer 生成的,为什么还要再 SetLocalDescription 设置回去?

答案是:CreateOffer 只是"起草",SetLocalDescription 才是"生效"。

本质原因:职责分离

libwebrtc 把"生成描述"和"应用描述"刻意分成两步,原因有三:

① 给应用层修改 SDP 的机会(SDP Munging) CreateOffer 生成 SDP 后,应用层可以在调用 SetLocalDescription 之前修改 SDP 文本——比如强制指定某个 codec、修改带宽限制等。虽然 W3C 规范不推荐这么做,但现实中很多应用都有这个需求,两步设计天然支持了这种场景。

② SetLocalDescription 才真正触发底层动作 CreateOffer 什么都不启动,只是组装了一段文本。 真正发生的事情全在 SetLocalDescription 里:

SetLocalDescription 之后才发生的事:
  ├── IceTransport 创建
  ├── DtlsTransport 绑定
  ├── ICE 候选收集启动(MaybeStartGathering)
  ├── 媒体参数下推到编解码器
  └── 信令状态机推进

③ W3C 规范本身就是两步设计

这不是 libwebrtc 的独创,W3C 规范明确规定了 createOffer 和 setLocalDescription 是两个独立操作,分别对应 JSEP 协议中的"生成描述"和"应用描述"两个阶段。

明白了为什么需要 SetLocalDescription 之后, 接下来我们就深入源码,看看SetLocalDescription到底做了哪些事情

一、W3C 规范怎么定义 SetLocalDescription

W3C 规范对 setLocalDescription() 的核心要求是:

将一个 SDP 描述设置为本端的本地描述,并根据描述类型(Offer/Answer)驱动信令状态机的状态转移。

规范还要求:

  • 必须通过 Operations Chain 串行执行

  • 设置成功后,信令状态(SignalingState)必须按规则更新

  • 如果是 Offer,pending_local_description 被更新

  • 如果是 Answer,current_local_description 被更新,协商完成

  • 设置成功后,ICE 候选收集必须立即启动

这最后一点非常关键,也是 SetLocalDescription 与 CreateOffer 最本质的区别:CreateOffer 只生产 SDP 文本,SetLocalDescription 才真正触发底层动作。


二、接口签名

libwebrtc 中 SetLocalDescription 有多个重载版本:

版本①:标准版,传入 desc + observer(推荐)

// api/peer_connection_interface.h
virtual void SetLocalDescription(
    std::unique_ptr<SessionDescriptionInterface> desc,
    rtc::scoped_refptr<SetLocalDescriptionObserverInterface> observer) {}

版本②:自动创建 SDP 并设置(免去手动 CreateOffer/CreateAnswer)

// api/peer_connection_interface.h
virtual void SetLocalDescription(
    rtc::scoped_refptr<SetLocalDescriptionObserverInterface> observer) {}

版本③:旧版兼容接口(observer 回调延迟投递)

virtual void SetLocalDescription(
    SetSessionDescriptionObserver* observer,
    SessionDescriptionInterface* desc) = 0;

三个版本的核心逻辑相同,最终都收敛到 DoSetLocalDescription()。

三、完整调用链

应用层
  pc->SetLocalDescription(desc, observer)
          │
          ▼  [Signaling Thread 断言]
  PeerConnection::SetLocalDescription()
          │  委托 sdp_handler_
          ▼
  SdpOfferAnswerHandler::SetLocalDescription()
          │  入 OperationsChain 串行队列
          ▼
  SdpOfferAnswerHandler::DoSetLocalDescription()
          │
          ├── ① 校验(NULL / session_error / Rollback 处理)
          ├── ② ValidateSessionDescription(SDP 合法性检查)
          ├── ③ ApplyLocalDescription(核心落地逻辑)
          │       ├── 更新描述槽位(pending / current)
          │       ├── PushdownTransportDescription(ICE+DTLS 下推)
          │       ├── UpdateTransceiversAndDataChannels(Transceiver 同步)
          │       ├── UpdateSessionState(信令状态机推进)
          │       │       ├── EnableSending(Answer 时开启媒体发送)
          │       │       ├── ChangeSignalingState(状态机转移)
          │       │       └── PushdownMediaDescription(媒体参数下推)
          │       └── UseCandidatesInSessionDescription(应用远端候选)
          │
          ├── ④ RemoveStoppedTransceivers(Answer 时清理)
          ├── ⑤ observer->OnSetLocalDescriptionComplete(OK)
          └── ⑥ MaybeStartGathering()  ← ICE 候选收集启动!

四、DoSetLocalDescription:入口与校验

// pc/sdp_offer_answer.cc
void SdpOfferAnswerHandler::DoSetLocalDescription(
    std::unique_ptr<SessionDescriptionInterface> desc,
    rtc::scoped_refptr<SetLocalDescriptionObserverInterface> observer) {
  RTC_DCHECK_RUN_ON(signaling_thread());

  // 校验1:desc 不为空
if (!desc) { /* 报错 */ }

  // 校验2:无 session error
if (session_error() != SessionError::kNone) { /* 报错 */ }

  // 校验3:Rollback 处理(仅 Unified Plan 支持)
if (desc->GetType() == SdpType::kRollback) {
    if (IsUnifiedPlan()) {
      observer->OnSetLocalDescriptionComplete(Rollback(desc->GetType()));
    } else {
      // Plan B 不支持 Rollback
      observer->OnSetLocalDescriptionComplete(
          RTCError(RTCErrorType::UNSUPPORTED_OPERATION, "..."));
    }
    return;
  }

  // 校验4:SDP 内容合法性(BUNDLE 组、codec、ICE 参数等)
  RTCError error = ValidateSessionDescription(
      desc.get(), cricket::CS_LOCAL, bundle_groups_by_mid);
if (!error.ok()) { /* 报错 */ }

  // 核心:落地 SDP
  error = ApplyLocalDescription(std::move(desc), bundle_groups_by_mid);

  // Answer 时清理已停止的 Transceiver
if (local_description()->GetType() == SdpType::kAnswer) {
    RemoveStoppedTransceivers();
    port_allocator()->DiscardCandidatePool(); // 丢弃预分配候选池
  }

  observer->OnSetLocalDescriptionComplete(RTCError::OK());

  // ★ 关键:通知 observer 之后才启动 ICE 候选收集
  // 确保应用层先收到成功回调,再收到候选回调
  transport_controller()->MaybeStartGathering();
}

深度细节:MaybeStartGathering() 必须在 observer 回调之后调用。 原因是:ICE 候选收集会立即触发 OnIceCandidate() 回调, 如果在 observer 回调之前就开始收集, 应用层可能在 SetLocalDescription 还未"完成"时就收到候选, 导致状态混乱。这是 W3C 规范明确要求的顺序。

五、ApplyLocalDescription:真正的"落地"

这是整个 SetLocalDescription 最核心的函数,按顺序做以下六件事:

5.1 更新描述槽位

// pc/sdp_offer_answer.cc
if (type == SdpType::kAnswer) {
    // Answer:pending → current,协商完成
    replaced_local_description = std::move(pending_local_description_);
    current_local_description_ = std::move(desc);
    pending_local_description_ = nullptr;
    // 远端 pending 也同步变为 current
    current_remote_description_ = std::move(pending_remote_description_);
} else {
    // Offer/PrAnswer:存入 pending,等待 Answer 到来
    replaced_local_description = std::move(pending_local_description_);
    pending_local_description_ = std::move(desc);
}

libwebrtc 用两对槽位管理 SDP 状态,对应 W3C 规范:

5.2 PushdownTransportDescription:ICE + DTLS 下推

// 将本端 SDP 中的传输信息推送给 JsepTransportController
RTCError error = PushdownTransportDescription(cricket::CS_LOCAL, type);

PushdownTransportDescription 的实现:

RTCError SdpOfferAnswerHandler::PushdownTransportDescription(
    cricket::ContentSource source, SdpType type) {
  if (source == cricket::CS_LOCAL) {
    return transport_controller()->SetLocalDescription(
        type, local_description()->description());
  }
  // ...
}

这一步将 SDP 中的传输参数推送到 JsepTransportController,包括:

  • 每个 m= section 的 ICE ufrag / pwd(凭据)

  • DTLS 证书指纹(a=fingerprint)

  • DTLS 角色(a=setup)

  • BUNDLE 信息

JsepTransportController 据此为每个 m= section 创建或更新 IceTransport 和 DtlsTransport 对象,为后续的 ICE 候选收集和 DTLS 握手做好准备。

5.3 UpdateTransceiversAndDataChannels:Transceiver 同步

// Unified Plan 下,将 SDP 内容同步到各 Transceiver
RTCError error = UpdateTransceiversAndDataChannels(
    cricket::CS_LOCAL, *local_description(),
    old_local_description, remote_description(),
    bundle_groups_by_mid);

这一步遍历所有 Transceiver,做两件事:

① 绑定 DTLS Transport

这里的 transport是在 上面的PushdownTransportDescription ->JsepTransportController::SetLocalDescription->ApplyDescription_n->MaybeCreateJsepTransport 里创建的

// 将 DtlsTransport 绑定到 Sender 和 Receiver
if (transceiver->mid()) {
    auto dtls_transport = LookupDtlsTransportByMid(
        pc_->network_thread(), transport_controller(), *transceiver->mid());
    transceiver->sender_internal()->set_transport(dtls_transport);
    transceiver->receiver_internal()->set_transport(dtls_transport);
}

② Answer 时更新 direction 和 fired_direction

// 如果是 Answer,更新 CurrentDirection 和 FiredDirection
if (type == SdpType::kPrAnswer || type == SdpType::kAnswer) {
    transceiver->set_current_direction(media_desc->direction());
    transceiver->set_fired_direction(media_desc->direction());
    // 如果 direction 变为不含 recv,触发 OnRemoveTrack 回调
}

5.4 UpdateSessionState:信令状态机推进

// pc/sdp_offer_answer.cc
RTCError SdpOfferAnswerHandler::UpdateSessionState(
    SdpType type, cricket::ContentSource source, ...) {

  // Answer 时允许媒体流动
if (type == SdpType::kPrAnswer || type == SdpType::kAnswer) {
    EnableSending();  // ← 开启 RTP 发送通道
  }

  // 按 W3C 规范推进信令状态机
if (type == SdpType::kOffer) {
    ChangeSignalingState(
        source == CS_LOCAL
            ? kHaveLocalOffer      // SetLocalDescription(offer)
            : kHaveRemoteOffer);   // SetRemoteDescription(offer)
  } elseif (type == SdpType::kAnswer) {
    ChangeSignalingState(kStable); // 协商完成,回到 Stable
    transceivers()->DiscardStableStates(); // 清理中间状态
  }

  // 将媒体参数下推到各 Channel(Worker Thread)
return PushdownMediaDescription(typesource, bundle_groups_by_mid);
}

ChangeSignalingState 会触发 PeerConnectionObserver::OnSignalingChange() 回调,通知应用层信令状态已变化。

5.5 PushdownMediaDescription:媒体参数下推到 Worker Thread

这是 SDP 落地的最后一公里。SDP 中的媒体信息必须下推到编解码器层面才能真正生效:

// pc/sdp_offer_answer.cc
RTCError SdpOfferAnswerHandler::PushdownMediaDescription(...) {
  // 更新 payload type demux 规则(决定收到的 RTP 包如何分发)
  UpdatePayloadTypeDemuxingState(source, bundle_groups_by_mid);

  // 遍历所有 Transceiver,收集 channel + media_desc 对
for (const auto& transceiver : rtp_transceivers) {
    transceiver->OnNegotiationUpdate(type, content_desc); // 通知 Transceiver
    channels.push_back({channel, content_desc});
  }

  // 切换到 Worker Thread 执行 SetLocalContent
  // (避免阻塞信令线程,同时防止多个 channel 并发导致音频卡顿)
for (const auto& entry : channels) {
    pc_->worker_thread()->Invoke<RTCError>(RTC_FROM_HERE, [&]() {
      // 将 SDP media section 下推到底层 Voice/VideoChannel
      // 触发编解码器参数更新、RTCP 配置、RTP Header Extension 配置等
      entry.first->SetLocalContent(entry.second, type, &error);
      return RTCError::OK();
    });
  }
}

SetLocalContent 最终会调用到 VoiceMediaChannel 或 VideoMediaChannel, 把 SDP 中的编解码器参数、SSRC、RTP Header Extension 等配置写入底层媒体引擎。

5.6 MaybeStartGathering:ICE 候选收集正式启动

// DoSetLocalDescription 末尾
transport_controller()->MaybeStartGathering();

这是 SetLocalDescription 最重要的副作用。 MaybeStartGathering 检查当前 transport 状态, 如果条件满足(已有本地描述,ICE 凭据已配置), 则正式启动 ICE 候选收集:

MaybeStartGathering()
    │
    ├── 遍历所有 IceTransport
    ├── 调用 PortAllocator 开始枚举网络接口
    │       ├── 收集 host 候选(本机 IP)
    │       ├── 向 STUN 服务器发送 Binding Request(收集 srflx 候选)
    │       └── 向 TURN 服务器分配(收集 relay 候选)
    │
    └── 每收集到一个候选 → OnIceCandidate() 回调 → 应用层通过信令发给对端

六、SetLocalDescription(Offer) vs SetLocalDescription(Answer)

两种 SDP 类型触发的动作有明显差异:

| | SetLocalDescription(Offer) | SetLocalDescription(Answer) | | --- | --- | --- | | 描述槽位 | 存入 pending_local_description_ | 存入 current_local_description_ | | 信令状态 | → kHaveLocalOffer | → kStable | | EnableSending | 不触发 | ✅ 触发,媒体可以开始发送 | | ICE 收集 | ✅ 立即启动 | ✅ 立即启动 | | RemoveStoppedTransceivers | 不触发 | ✅ 清理已停止的 Transceiver | | DiscardCandidatePool | 不触发 | ✅ 丢弃预分配的 ICE 候选池 |

七、完整流程图

pc->SetLocalDescription(offer/answer, observer)
          │
          ▼ [Signaling Thread]
  DoSetLocalDescription()
          │
          ├── 校验 + ValidateSessionDescription
          │
          ├── ApplyLocalDescription()
          │       │
          │       ├── ① 更新描述槽位(pending/current)
          │       │
          │       ├── ② PushdownTransportDescription
          │       │       → JsepTransportController 创建/更新
          │       │         IceTransport + DtlsTransport
          │       │
          │       ├── ③ UpdateTransceiversAndDataChannels
          │       │       → 绑定 DtlsTransport 到 Sender/Receiver
          │       │       → 更新 CurrentDirection / FiredDirection
          │       │
          │       ├── ④ UpdateSessionState
          │       │       → EnableSending(Answer 时)
          │       │       → ChangeSignalingState → OnSignalingChange 回调
          │       │       → PushdownMediaDescription
          │       │             → Worker Thread: SetLocalContent
          │       │               (编解码器参数、SSRC 写入底层媒体引擎)
          │       │
          │       └── ⑤ UseCandidatesInSessionDescription
          │               (如果已有 remote description,应用远端候选)
          │
          ├── observer->OnSetLocalDescriptionComplete(OK)
          │
          └── MaybeStartGathering()  ← ICE 候选收集正式启动
                  → OnIceCandidate() 回调开始触发

八、小结

SetLocalDescription 是 WebRTC 信令流程中触发动作最多的一步:

| 动作 | 触发条件 | | --- | --- | | 信令状态机推进 | 每次调用 | | ICE Transport 创建/更新 | 每次调用 | | DTLS Transport 绑定 | 每次调用 | | ICE 候选收集启动 | 每次调用(MaybeStartGathering) | | 媒体参数下推到编解码器 | 每次调用(PushdownMediaDescription) | | 媒体发送通道开启 | 仅 Answer 时(EnableSending) | | Transceiver direction 确定 | 仅 Answer 时 |

CreateOffer 只是生产了一张"名片",SetLocalDescription 才是把这张名片正式递出去、同时拉开连接序幕的那一刻。

下一篇:SetRemoteDescription 深度解析——收到对端 SDP 时,libwebrtc 内部如何解析并驱动 Track 创建?

c1cbf8e09b18433224886f004bd0a404.png