引言

在WebRTC的实际应用中,SDP (Session Description Protocol) 是实现端对端媒体协商的核心协议。本文将深入分析WebRTC Android SDK中四个最重要的SDP相关API的完整实现链路:

  • createOffer() - 创建SDP offer
  • createAnswer() - 创建SDP answer
  • setLocalDescription() - 设置本地描述
  • setRemoteDescription() - 设置远端描述

我们将从Java层API调用开始,逐层深入到JNI桥接层、C++ WebRTC核心实现,最终到达媒体协商和会话描述生成的底层逻辑。

第一层:Java API层 - 入口接口

1.1 PeerConnection.java 中的API定义

sdk/android/api/org/autelrtc/PeerConnection.java 中,我们可以看到这四个API的定义:

// 创建SDP Offer
public void createOffer(SdpObserver observer, MediaConstraints constraints) {
    nativeCreateOffer(observer, constraints);
}

// 创建SDP Answer
public void createAnswer(SdpObserver observer, MediaConstraints constraints) {
    nativeCreateAnswer(observer, constraints);
}

// 设置本地描述
public void setLocalDescription(SdpObserver observer, SessionDescription sdp) {
    nativeSetLocalDescription(observer, sdp);
}

// 设置远端描述
public void setRemoteDescription(SdpObserver observer, SessionDescription sdp) {
    nativeSetRemoteDescription(observer, sdp);
}

1.2 参数解析

SdpObserver: 异步回调观察者,用于接收SDP操作的成功或失败结果

  • onCreateSuccess() - SDP创建成功
  • onCreateFailure() - SDP创建失败
  • onSetSuccess() - SDP设置成功
  • onSetFailure() - SDP设置失败

MediaConstraints: 媒体约束条件,影响SDP生成的参数

SessionDescription: 包含SDP类型(offer/answer)和SDP字符串内容

第二层:JNI桥接层 - Java与C++的桥梁

2.1 JNI方法实现

sdk/android/src/jni/pc/peer_connection.cc 中,我们可以看到JNI层的实现:

// CreateOffer的JNI实现
static void JNI_PeerConnection_CreateOffer(
    JNIEnv* jni,
    const jni_zero::JavaParamRef<jobject>& j_pc,
    const jni_zero::JavaParamRef<jobject>& j_observer,
    const jni_zero::JavaParamRef<jobject>& j_constraints) {
  
  // 1. 转换Java MediaConstraints为C++ MediaConstraints
  std::unique_ptr<MediaConstraints> constraints =
      JavaToNativeMediaConstraints(jni, j_constraints);
      
  // 2. 创建C++ SDP观察者包装器
  auto observer = rtc::make_ref_counted<CreateSdpObserverJni>(
      jni, j_observer, std::move(constraints));
      
  // 3. 转换约束为RTCOfferAnswerOptions
  PeerConnectionInterface::RTCOfferAnswerOptions options;
  CopyConstraintsIntoOfferAnswerOptions(observer->constraints(), &options);
  
  // 4. 调用C++ PeerConnection的CreateOffer方法
  ExtractNativePC(jni, j_pc)->CreateOffer(observer.get(), options);
}

2.2 SDP观察者的桥接

CreateSdpObserverJni 类负责将C++回调转换为Java回调:

void CreateSdpObserverJni::OnSuccess(SessionDescriptionInterface* desc) {
  JNIEnv* env = AttachCurrentThreadIfNeeded();
  std::string sdp;
  RTC_CHECK(desc->ToString(&sdp)) << "got so far: " << sdp;
  
  // 将C++ SessionDescription转换为Java SessionDescription
  Java_SdpObserver_onCreateSuccess(
      env, j_observer_global_,
      NativeToJavaSessionDescription(env, sdp, desc->type()));
  delete desc; // 释放C++对象
}

void CreateSdpObserverJni::OnFailure(webrtc::RTCError error) {
  JNIEnv* env = AttachCurrentThreadIfNeeded();
  Java_SdpObserver_onCreateFailure(env, j_observer_global_,
                                   NativeToJavaString(env, error.message()));
}

第三层:WebRTC C++核心层 - 协议逻辑核心

3.1 PeerConnection分发层

pc/peer_connection.cc 中,所有SDP相关操作都被委托给 SdpOfferAnswerHandler

void PeerConnection::CreateOffer(CreateSessionDescriptionObserver* observer,
                                 const RTCOfferAnswerOptions& options) {
  RTC_DCHECK_RUN_ON(signaling_thread());
  sdp_handler_->CreateOffer(observer, options);
}

void PeerConnection::SetLocalDescription(
    SetSessionDescriptionObserver* observer,
    SessionDescriptionInterface* desc_ptr) {
  RTC_DCHECK_RUN_ON(signaling_thread());
  sdp_handler_->SetLocalDescription(observer, desc_ptr);
}

3.2 SdpOfferAnswerHandler - SDP处理核心

pc/sdp_offer_answer.cc 中,实现了完整的SDP处理逻辑:

3.2.1 CreateOffer实现详解
void SdpOfferAnswerHandler::DoCreateOffer(
    const PeerConnectionInterface::RTCOfferAnswerOptions& options,
    rtc::scoped_refptr<CreateSessionDescriptionObserver> observer) {
  
  RTC_DCHECK_RUN_ON(signaling_thread());
  TRACE_EVENT0("webrtc", "SdpOfferAnswerHandler::DoCreateOffer");

  // 1. 验证观察者和PeerConnection状态
  if (!observer) {
    RTC_LOG(LS_ERROR) << "CreateOffer - observer is NULL.";
    return;
  }
  
  if (pc_->IsClosed()) {
    // PeerConnection已关闭,返回错误
    pc_->message_handler()->PostCreateSessionDescriptionFailure(
        observer.get(),
        RTCError(RTCErrorType::INVALID_STATE, "PeerConnection is closed"));
    return;
  }

  // 2. 检查会话错误状态
  if (session_error() != SessionError::kNone) {
    std::string error_message = GetSessionErrorMsg();
    pc_->message_handler()->PostCreateSessionDescriptionFailure(
        observer.get(),
        RTCError(RTCErrorType::INTERNAL_ERROR, std::move(error_message)));
    return;
  }

  // 3. 验证Offer选项
  if (!ValidateOfferAnswerOptions(options)) {
    pc_->message_handler()->PostCreateSessionDescriptionFailure(
        observer.get(),
        RTCError(RTCErrorType::INVALID_PARAMETER, "Invalid options"));
    return;
  }

  // 4. 处理Unified Plan的legacy选项
  if (IsUnifiedPlan()) {
    RTCError error = HandleLegacyOfferOptions(options);
    if (!error.ok()) {
      pc_->message_handler()->PostCreateSessionDescriptionFailure(
          observer.get(), std::move(error));
      return;
    }
  }

  // 5. 构建媒体会话选项并创建Offer
  cricket::MediaSessionOptions session_options;
  GetOptionsForOffer(options, &session_options);
  webrtc_session_desc_factory_->CreateOffer(observer.get(), options,
                                            session_options);
}
3.2.2 SetLocalDescription实现详解
void SdpOfferAnswerHandler::DoSetLocalDescription(
    std::unique_ptr<SessionDescriptionInterface> desc,
    rtc::scoped_refptr<SetLocalDescriptionObserverInterface> observer) {
    
  RTC_DCHECK_RUN_ON(signaling_thread());
  TRACE_EVENT0("webrtc", "SdpOfferAnswerHandler::DoSetLocalDescription");

  // 1. 基础验证
  if (!observer) {
    RTC_LOG(LS_ERROR) << "SetLocalDescription - observer is NULL.";
    return;
  }

  if (!desc) {
    observer->OnSetLocalDescriptionComplete(
        RTCError(RTCErrorType::INTERNAL_ERROR, "SessionDescription is NULL."));
    return;
  }

  // 2. 检查会话状态
  if (session_error() != SessionError::kNone) {
    std::string error_message = GetSessionErrorMsg();
    observer->OnSetLocalDescriptionComplete(
        RTCError(RTCErrorType::INTERNAL_ERROR, std::move(error_message)));
    return;
  }

  // 3. 处理Rollback操作
  if (desc->GetType() == SdpType::kRollback) {
    if (IsUnifiedPlan()) {
      observer->OnSetLocalDescriptionComplete(Rollback(desc->GetType()));
    } else {
      observer->OnSetLocalDescriptionComplete(
          RTCError(RTCErrorType::UNSUPPORTED_OPERATION,
                   "Rollback not supported in Plan B"));
    }
    return;
  }

  // 4. 验证会话描述
  std::map<std::string, const cricket::ContentGroup*> bundle_groups_by_mid =
      GetBundleGroupsByMid(desc->description());
  RTCError error = ValidateSessionDescription(desc.get(), cricket::CS_LOCAL,
                                              bundle_groups_by_mid);
  if (!error.ok()) {
    observer->OnSetLocalDescriptionComplete(std::move(error));
    return;
  }

  // 5. 应用本地描述
  error = ApplyLocalDescription(std::move(desc), bundle_groups_by_mid);
  observer->OnSetLocalDescriptionComplete(std::move(error));
}

第四层:会话描述工厂 - SDP内容生成

4.1 WebRtcSessionDescriptionFactory

pc/webrtc_session_description_factory.cc 中,负责实际的SDP生成:

void WebRtcSessionDescriptionFactory::InternalCreateOffer(
    CreateSessionDescriptionRequest request) {
    
  // 1. 处理ICE重启
  if (sdp_info_->local_description()) {
    for (cricket::MediaDescriptionOptions& options :
         request.options.media_description_options) {
      if (sdp_info_->NeedsIceRestart(options.mid)) {
        options.transport_options.ice_restart = true;
      }
    }
  }

  // 2. 调用媒体会话工厂创建Offer
  auto result = session_desc_factory_.CreateOfferOrError(
      request.options, 
      sdp_info_->local_description() 
          ? sdp_info_->local_description()->description() 
          : nullptr);
          
  if (!result.ok()) {
    PostCreateSessionDescriptionFailed(request.observer.get(), result.error());
    return;
  }

  std::unique_ptr<cricket::SessionDescription> desc = std::move(result.value());

  // 3. 更新会话版本号(RFC 3264要求)
  RTC_DCHECK(session_version_ + 1 > session_version_);
  auto offer = std::make_unique<JsepSessionDescription>(
      SdpType::kOffer, std::move(desc), session_id_,
      rtc::ToString(session_version_++));

  // 4. 复制现有ICE候选
  if (sdp_info_->local_description()) {
    for (const cricket::MediaDescriptionOptions& options :
         request.options.media_description_options) {
      if (!options.transport_options.ice_restart) {
        CopyCandidatesFromSessionDescription(sdp_info_->local_description(),
                                             options.mid, offer.get());
      }
    }
  }

  // 5. 返回创建成功的结果
  PostCreateSessionDescriptionSucceeded(request.observer.get(),
                                        std::move(offer));
}

4.2 MediaSessionDescriptionFactory - 媒体协商核心

pc/media_session.cc 中实现具体的媒体协商逻辑:

webrtc::RTCErrorOr<std::unique_ptr<SessionDescription>>
MediaSessionDescriptionFactory::CreateOfferOrError(
    const MediaSessionOptions& session_options,
    const SessionDescription* current_description) const {
    
  // 1. 初始化ICE凭证迭代器
  IceCredentialsIterator ice_credentials(
      session_options.pooled_ice_credentials);

  // 2. 获取当前活跃内容
  std::vector<const ContentInfo*> current_active_contents;
  if (current_description) {
    current_active_contents =
        GetActiveContents(*current_description, session_options);
  }

  // 3. 获取当前流参数
  StreamParamsVec current_streams =
      GetCurrentStreamParams(current_active_contents);

  // 4. 获取所有可能的编解码器
  Codecs audio_codecs;
  Codecs video_codecs;
  if (!GetCodecsForOffer(current_active_contents, &audio_codecs,
                        &video_codecs)) {
    LOG_AND_RETURN_ERROR(RTCErrorType::INTERNAL_ERROR,
                         "Failed to get codecs for offer");
  }

  // 5. 创建会话描述
  auto offer = std::make_unique<SessionDescription>();

  // 6. 为每个媒体选项创建内容描述
  for (const MediaDescriptionOptions& media_description_options :
       session_options.media_description_options) {
       
    // 创建音频或视频内容
    auto error_or_content = CreateContentOffer(
        media_description_options, session_options, audio_codecs, 
        video_codecs, current_streams, current_description, 
        &ice_credentials);
        
    if (!error_or_content.ok()) {
      return error_or_content.MoveError();
    }
    
    offer->AddContent(std::move(error_or_content.value()));
  }

  return offer;
}

第五层:协议状态管理 - 信令状态机

5.1 本地描述应用过程

RTCError SdpOfferAnswerHandler::ApplyLocalDescription(
    std::unique_ptr<SessionDescriptionInterface> desc,
    const std::map<std::string, const cricket::ContentGroup*>&
        bundle_groups_by_mid) {
        
  RTC_DCHECK_RUN_ON(signaling_thread());
  TRACE_EVENT0("webrtc", "SdpOfferAnswerHandler::ApplyLocalDescription");

  // 1. 清除统计缓存
  pc_->ClearStatsCache();

  // 2. 保存旧的本地描述引用
  const SessionDescriptionInterface* old_local_description =
      local_description();
  std::unique_ptr<SessionDescriptionInterface> replaced_local_description;
  
  SdpType type = desc->GetType();
  
  // 3. 根据SDP类型更新状态
  if (type == SdpType::kAnswer) {
    // Answer会使pending变为current
    replaced_local_description = pending_local_description_
                                     ? std::move(pending_local_description_)
                                     : std::move(current_local_description_);
    current_local_description_ = std::move(desc);
    pending_local_description_ = nullptr;
    current_remote_description_ = std::move(pending_remote_description_);
  } else {
    // Offer设置为pending
    replaced_local_description = std::move(pending_local_description_);
    pending_local_description_ = std::move(desc);
  }

  // 4. 设置初始offer者标志
  if (!initial_offerer_) {
    initial_offerer_.emplace(type == SdpType::kOffer);
  }

  // 5. 确定caller/callee角色
  if (!is_caller_) {
    if (remote_description()) {
      is_caller_ = false; // 远端描述先设置,本端是callee
    } else {
      is_caller_ = true;  // 本端描述先设置,本端是caller
    }
  }

  // 6. 推送传输描述到传输层
  RTCError error = PushdownTransportDescription(cricket::CS_LOCAL, type);
  if (!error.ok()) {
    return error;
  }

  // 7. 更新收发器和数据通道(Unified Plan)
  if (IsUnifiedPlan()) {
    error = UpdateTransceiversAndDataChannels(
        cricket::CS_LOCAL, *local_description()->description(),
        old_local_description, bundle_groups_by_mid);
    if (!error.ok()) {
      return error;
    }
  }

  // 8. 如果两端描述都已设置,创建媒体通道
  if (remote_description()) {
    error = CreateChannels(*local_description()->description());
    if (!error.ok()) {
      return error;
    }
  }

  return RTCError::OK();
}

5.2 媒体通道创建

RTCError SdpOfferAnswerHandler::CreateChannels(const SessionDescription& desc) {
  TRACE_EVENT0("webrtc", "SdpOfferAnswerHandler::CreateChannels");
  
  RTC_DCHECK_RUN_ON(signaling_thread());
  
  // 1. 创建音频通道
  const cricket::ContentInfo* voice = cricket::GetFirstAudioContent(&desc);
  if (voice && !voice->rejected &&
      !rtp_manager()->GetAudioTransceiver()->internal()->channel()) {
    auto error =
        rtp_manager()->GetAudioTransceiver()->internal()->CreateChannel(
            voice->name, pc_->call_ptr(), pc_->configuration()->media_config,
            pc_->SrtpRequired(), pc_->GetCryptoOptions(), audio_options(),
            video_options(), video_bitrate_allocator_factory_.get(),
            [&](absl::string_view mid) {
              RTC_DCHECK_RUN_ON(network_thread());
              return transport_controller_n()->GetRtpTransport(mid);
            });
    if (!error.ok()) {
      return error;
    }
  }

  // 2. 创建视频通道
  const cricket::ContentInfo* video = cricket::GetFirstVideoContent(&desc);
  if (video && !video->rejected &&
      !rtp_manager()->GetVideoTransceiver()->internal()->channel()) {
    auto error =
        rtp_manager()->GetVideoTransceiver()->internal()->CreateChannel(
            video->name, pc_->call_ptr(), pc_->configuration()->media_config,
            pc_->SrtpRequired(), pc_->GetCryptoOptions(),
            audio_options(), video_options(),
            video_bitrate_allocator_factory_.get(), 
            [&](absl::string_view mid) {
              RTC_DCHECK_RUN_ON(network_thread());
              return transport_controller_n()->GetRtpTransport(mid);
            });
    if (!error.ok()) {
      return error;
    }
  }

  // 3. 创建数据通道传输
  const cricket::ContentInfo* data = cricket::GetFirstDataContent(&desc);
  if (data && !data->rejected && 
      !pc_->CreateDataChannelTransport(data->name)) {
    LOG_AND_RETURN_ERROR(RTCErrorType::INTERNAL_ERROR,
                         "Failed to create data channel transport.");
  }

  return RTCError::OK();
}

第六层:编解码器协商 - 媒体能力匹配

6.1 编解码器协商过程

在Answer创建过程中,需要根据Offer中的编解码器进行协商:

webrtc::RTCErrorOr<std::unique_ptr<SessionDescription>>
MediaSessionDescriptionFactory::CreateAnswerOrError(
    const SessionDescription* offer,
    const MediaSessionOptions& session_options,
    const SessionDescription* current_description) const {
    
  // 1. 验证输入参数
  if (!offer) {
    LOG_AND_RETURN_ERROR(RTCErrorType::INTERNAL_ERROR, "Called without offer.");
  }

  // 2. 选择拥塞控制反馈格式
  bool has_ack_ccfb = false;
  if (transport_desc_factory_->trials().IsEnabled(
          "WebRTC-RFC8888CongestionControlFeedback")) {
    for (const auto& content : offer->contents()) {
      if (content.media_description()->rtcp_fb_ack_ccfb()) {
        has_ack_ccfb = true;
      }
    }
  }

  // 3. 获取Answer的编解码器列表
  Codecs answer_audio_codecs;
  Codecs answer_video_codecs;
  GetCodecsForAnswer(current_active_contents, *offer, 
                     &answer_audio_codecs, &answer_video_codecs);

  auto answer = std::make_unique<SessionDescription>();

  // 4. 为每个媒体段创建对应的Answer内容
  size_t content_index = 0;
  for (const ContentInfo& content : offer->contents()) {
    
    // 根据Offer内容创建对应的Answer内容
    auto error_or_content = CreateAnswerContent(
        content, session_options.media_description_options[content_index],
        session_options, answer_audio_codecs, answer_video_codecs,
        current_streams, current_description, &ice_credentials);
        
    if (!error_or_content.ok()) {
      return error_or_content.MoveError();
    }
    
    answer->AddContent(std::move(error_or_content.value()));
    ++content_index;
  }

  return answer;
}

6.2 编解码器匹配和过滤

void MediaSessionDescriptionFactory::GetCodecsForAnswer(
    const std::vector<const ContentInfo*>& current_active_contents,
    const SessionDescription& remote_offer,
    Codecs* answer_audio_codecs,
    Codecs* answer_video_codecs) const {
    
  // 获取所有支持的编解码器
  *answer_audio_codecs = GetAudioCodecsForAnswer();
  *answer_video_codecs = GetVideoCodecsForAnswer();

  // 建立统一的payload type映射
  UsedPayloadTypes used_payload_types;
  for (const ContentInfo& content : remote_offer.contents()) {
    if (IsMediaContent(&content)) {
      const MediaContentDescription* media_desc =
          content.media_description();
      for (const Codec& codec : media_desc->codecs()) {
        used_payload_types.FindAndSetIdUsed(codec.id);
      }
    }
  }

  // 为Answer编解码器分配payload type
  for (Codec& codec : *answer_audio_codecs) {
    if (!used_payload_types.FindAndSetIdUsed(codec.id)) {
      // 如果当前ID已被占用,需要重新分配
      codec.id = used_payload_types.FindAndSetIdUsed();
    }
  }
  
  for (Codec& codec : *answer_video_codecs) {
    if (!used_payload_types.FindAndSetIdUsed(codec.id)) {
      codec.id = used_payload_types.FindAndSetIdUsed();
    }
  }
}

第七层:信令状态机 - 协商流程控制

7.1 信令状态转换

WebRTC的信令状态机控制整个SDP协商流程:

// 状态定义
enum SignalingState {
  kStable,              // 稳定状态
  kHaveLocalOffer,      // 已发送本地Offer
  kHaveLocalPrAnswer,   // 已发送本地临时Answer
  kHaveRemoteOffer,     // 已接收远端Offer
  kHaveRemotePrAnswer,  // 已接收远端临时Answer
  kClosed               // 已关闭
};

// 状态转换逻辑
void SdpOfferAnswerHandler::ChangeSignalingState(
    PeerConnectionInterface::SignalingState signaling_state) {
  RTC_DCHECK_RUN_ON(signaling_thread());
  if (signaling_state_ == signaling_state) {
    return;
  }
  RTC_LOG(LS_INFO) << "Session: " << pc_->session_id() 
                   << " Old state: " << GetSignalingStateString(signaling_state_)
                   << " New state: " << GetSignalingStateString(signaling_state);
  signaling_state_ = signaling_state;
  pc_->Observer()->OnSignalingChange(signaling_state_);
}

7.2 协商需求检测

void SdpOfferAnswerHandler::UpdateNegotiationNeeded() {
  RTC_DCHECK_RUN_ON(signaling_thread());
  
  if (!IsUnifiedPlan()) {
    return; // Plan B不支持自动协商检测
  }

  bool is_negotiation_needed = false;

  // 1. 检查是否有未关联的收发器
  for (const auto& transceiver : rtp_manager()->transceivers()->List()) {
    if (!transceiver->internal()->mid().has_value() && 
        !transceiver->stopped()) {
      is_negotiation_needed = true;
      break;
    }
  }

  // 2. 检查是否有方向改变的收发器
  if (!is_negotiation_needed && local_description() && remote_description()) {
    for (const auto& transceiver : rtp_manager()->transceivers()->List()) {
      if (transceiver->internal()->HasDirectionChanged()) {
        is_negotiation_needed = true;
        break;
      }
    }
  }

  // 3. 检查数据通道是否需要协商
  if (!is_negotiation_needed) {
    is_negotiation_needed = pc_->sctp_data_channels_n().size() > 0 &&
        !cricket::GetFirstDataContent(local_description()->description());
  }

  if (is_negotiation_needed != is_negotiation_needed_) {
    RTC_LOG(LS_INFO) << "Negotiation needed: " << is_negotiation_needed;
    is_negotiation_needed_ = is_negotiation_needed;
  }
}

性能优化和错误处理

异步处理机制

WebRTC使用消息处理器来确保操作在正确的线程上执行:

void SdpOfferAnswerHandler::PostCreateSessionDescriptionSucceeded(
    CreateSessionDescriptionObserver* observer,
    std::unique_ptr<SessionDescriptionInterface> description) {
  pc_->message_handler()->PostTask(
      [observer_refptr =
           rtc::scoped_refptr<CreateSessionDescriptionObserver>(observer),
       description = std::move(description)]() mutable {
        observer_refptr->OnSuccess(description.release());
      });
}

错误处理策略

void SdpOfferAnswerHandler::PostCreateSessionDescriptionFailed(
    CreateSessionDescriptionObserver* observer,
    RTCError error) {
  RTC_DCHECK(observer);
  std::string error_message = error.message();
  RTC_LOG(LS_ERROR) << "Create SDP failed: " << error_message;
  
  pc_->message_handler()->PostTask(
      [observer_refptr =
           rtc::scoped_refptr<CreateSessionDescriptionObserver>(observer),
       error = std::move(error)]() mutable {
        observer_refptr->OnFailure(std::move(error));
      });
}

总结

通过深入分析这四个核心SDP API的实现,我们可以看到WebRTC的架构设计具有以下特点:

  1. 分层架构清晰:从Java API到JNI桥接,再到C++核心实现,每一层都有明确的职责分工。

  2. 异步处理模式:所有SDP操作都是异步的,通过回调机制返回结果,避免阻塞UI线程。

  3. 状态机管理:通过严格的信令状态机控制SDP协商流程,确保操作的正确性。

  4. 编解码器协商:复杂的媒体能力协商逻辑,支持多种音视频编解码器的匹配和选择。

  5. 错误处理完善:在每一层都有完善的错误检查和处理机制。

  6. 线程安全:使用线程检查宏确保操作在正确的线程上执行。

这种设计使得WebRTC能够在复杂的网络环境中稳定地进行实时音视频通信,是一个值得学习的工程实践典范。

理解这些底层实现细节,对于开发高质量的WebRTC应用和解决实际问题都有重要价值。

Logo

网易智企-云信开发者社区是面向全网开发者的技术交流与服务平台,依托近 29 年 IM、音视频技术积累,提供 IM、RTC、实时对话智能体、云原生、短信等全场景开发资源。

更多推荐