本文系统梳理 WebRTC 的五大核心概念:信令、SDP、ICE/STUN/TURN、DTLS+SRTP、GCC 拥塞控制,并深入讲解 NAT 穿透原理和 GCC 算法细节。


缩写全称速查

缩写 全称 中文
SDP Session Description Protocol 会话描述协议
ICE Interactive Connectivity Establishment 交互式连接建立
STUN Session Traversal Utilities for NAT NAT 会话穿越工具
TURN Traversal Using Relays around NAT 通过中继穿越 NAT
DTLS Datagram Transport Layer Security 数据报传输层安全协议
SRTP Secure Real-time Transport Protocol 安全实时传输协议
RTP Real-time Transport Protocol 实时传输协议
RTCP RTP Control Protocol RTP 控制协议
GCC Google Congestion Control 谷歌拥塞控制算法
NAT Network Address Translation 网络地址转换
TLS Transport Layer Security 传输层安全协议
UDP User Datagram Protocol 用户数据报协议
TCP Transmission Control Protocol 传输控制协议
NACK Negative Acknowledgement 否定确认(丢包重传请求)
FEC Forward Error Correction 前向纠错
REMB Receiver Estimated Maximum Bitrate 接收端估计最大码率
BWE Bandwidth Estimation 带宽估计

一、信令(Signaling)

一句话:WebRTC 两端想通话,但双方都不知道对方在哪、能用什么格式——信令就是用来交换这些「元信息」的通道。

比喻:你要和朋友打电话,但没有存他号码。你们先互发微信消息「我的号码是 xxx,你的是多少?」——这个过程就是信令。

关键点

  • WebRTC 不规定信令用什么协议,随便你用 WebSocket、HTTP、甚至短信
  • 信令只传两类内容:SDP(我能做什么)和 ICE Candidate(我在哪)
  • 信令服务器本身不碰媒体流,只是个「中间人」转发消息
1
2
3
4
5
A ──── Offer SDP ────→ 信令服务器 ──→ B
A ←─── Answer SDP ─── 信令服务器 ←── B
A ──── ICE Candidate → 信令服务器 ──→ B
A ←─── ICE Candidate ─ 信令服务器 ←── B
// 信令结束后,A 和 B 直连,信令服务器退出历史舞台

二、SDP(Session Description Protocol)

一句话:我把自己的「能力清单」写给你看——我支持哪些编解码器、用什么分辨率、监听哪个端口。

比喻:相亲前双方交换简历,写清楚「我身高 180、会做饭、有房」,对方看完说「OK 我接受,我的情况是 xxx」。

真实的 SDP 长这样(截取片段):

1
2
3
4
5
6
7
8
9
10
11
12
v=0
o=- 1234567890 2 IN IP4 127.0.0.1
s=-
t=0 0
a=group:BUNDLE 0 1
m=audio 9 UDP/TLS/RTP/SAVPF 111 103 104
a=rtpmap:111 opus/48000/2 ← 支持 Opus 编码,48kHz,双声道
a=rtpmap:103 ISAC/16000
m=video 9 UDP/TLS/RTP/SAVPF 96 97
a=rtpmap:96 VP8/90000 ← 支持 VP8 视频编码
a=rtpmap:97 H264/90000 ← 也支持 H264
a=fmtp:97 profile-level-id=42e01f ← H264 具体 Profile

Offer / Answer 模型

1
2
3
A 发 Offer:「我支持 VP8 和 H264,你选一个」
B 回 Answer:「好,我们用 H264」
// 之后双方都只用 H264 通信

三、ICE / STUN / TURN

核心问题:互联网上大多数设备在 NAT 后面(路由器后),没有公网 IP,两端怎么直连?

3.1 STUN 和 TURN 的本质区别

一句话区别

  • STUN:照镜子,告诉你自己的公网地址是什么,不转发流量
  • TURN:快递柜,P2P 打不通时帮你中转所有流量
STUN TURN
作用 告诉你「你家门牌号是 xxx」 帮你把信收下,再转交给朋友
流量过服务器吗 ❌ 不过,只是查询 ✅ 所有流量都过
费用 极低(只有查询请求) 高(要承载所有音视频流量)
使用时机 每次连接都用 仅 P2P 打不通时兜底

3.2 STUN——「你的公网地址是什么」

比喻:你站在镜子前不知道自己长啥样,STUN 服务器就是那面镜子,照完告诉你「你的公网 IP 是 1.2.3.4:5678」。

1
2
3
你(192.168.1.100:12345)
──→ STUN Server(公网)
← 「你的公网地址是 1.2.3.4:5678」

3.3 ICE——「尝试所有路径,找最优的一条」

ICE 会枚举三类 Candidate(候选地址),按优先级依次尝试:

类型 是什么 优先级
host 本机局域网 IP 最高(同局域网直连)
srflx 通过 STUN 拿到的公网 IP 中(NAT 穿透)
relay TURN 服务器中继地址 最低(兜底)
1
2
3
① 先试 host candidate(局域网直连)→ 最快
② 打不通 → 试 srflx candidate(STUN 拿到的公网地址,P2P 穿透)
③ 还打不通 → 用 relay candidate(TURN 中继)→ 兜底,必通

3.4 TURN——「P2P 打不通时的中继」

走 TURN 就是走公网服务器代理所有媒体流

1
2
3
4
5
6
7
P2P 直连(host/srflx):
手机A ──────────────────────────→ 手机B
直接传,不过任何服务器

走 TURN:
手机A ──→ TURN服务器(公网)──→ 手机B
所有音视频都经过这里

走 TURN 的影响:

  • 延迟增加:多绕一跳,增加 20-100ms(取决于 TURN 服务器离你多远)
  • 带宽成本高:服务器要承载完整双向媒体流,1路 720p 约 1-2Mbps
  • 内容仍然安全:媒体流是 SRTP 加密的,TURN 服务器只转发密文,看不到内容

生产环境策略:

  • 能 P2P 直连的绝不走 TURN(省成本)
  • TURN 服务器尽量部署在离用户近的地区(降延迟)
  • 监控 TURN 使用率,超过 30% 说明 P2P 打洞成功率偏低,需排查

它们不是路由器实现,代码里只需配置地址:

1
2
3
4
5
6
7
8
9
10
11
12
val iceServers = listOf(
// STUN(Google 免费公共服务器)
PeerConnection.IceServer.builder("stun:stun.l.google.com:19302").createIceServer(),
// TURN(需要账号密码,一般自己部署)
PeerConnection.IceServer.builder("turn:your-turn-server.com:3478")
.setUsername("user")
.setPassword("password")
.createIceServer()
)
val config = PeerConnection.RTCConfiguration(iceServers)
// ICE 打洞、STUN 查询、TURN 中继全部自动处理,无需额外代码
val peerConnection = factory.createPeerConnection(config, observer)

四、NAT 深挖

4.1 NAT 是什么,为什么存在

IPv4 地址只有 43 亿个,全球设备远不止这个数。NAT 的本质是「地址复用」——一个公网 IP 后面可以藏几百台设备,路由器负责做地址映射。

1
2
3
你的手机(192.168.1.100:12345)
↓ 路由器做 NAT
公网(1.2.3.4:54321)←── 外网只看到这个地址

4.2 NAT 的四种类型(从容易到难穿透排序)

① 完全锥型 NAT(Full Cone NAT)

1
2
规则:只要内网 A 发过包,任何人都能从 1.2.3.4:54321 发包进来找到 A
穿透难度:★☆☆☆ 最容易

② 地址限制锥型 NAT(Address Restricted Cone)

1
2
规则:只有 A 主动发过包的目标 IP,才能回包进来(端口不限)
穿透难度:★★☆☆

③ 端口限制锥型 NAT(Port Restricted Cone)

1
2
规则:只有 A 主动发过包的目标 IP + Port,才能回包进来
穿透难度:★★★☆

④ 对称型 NAT(Symmetric NAT)

1
2
3
4
规则:每次发往不同目标,路由器分配不同的公网端口
发给 B → 公网端口 54321
发给 C → 公网端口 54322(换了!)
穿透难度:★★★★ 最难

对称型 NAT 是 P2P 打洞最大的敌人——你通过 STUN 拿到的公网端口,只对 STUN 服务器有效,发给对端时路由器又换了个新端口,对端根本找不到你。

4.3 P2P 打洞原理(以端口限制锥型为例)

两端同时向对方「假装」发包,让路由器在 NAT 表里留下记录,之后对方的包就能进来了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
A(192.168.1.100)  NAT-A(1.2.3.4)    NAT-B(5.6.7.8)  B(192.168.1.200)

① A 和 B 都把自己的 srflx Candidate 通过信令发给对方
A 知道:B 的公网地址是 5.6.7.8:9012
B 知道:A 的公网地址是 1.2.3.4:5678

② A 向 5.6.7.8:9012 发 STUN Binding Request
→ NAT-A 记录:内网 192.168.1.100:X ↔ 公网 1.2.3.4:5678(允许 5.6.7.8 回包)
→ 包到达 NAT-B,但 NAT-B 还没有 A 的记录,丢弃

③ B 同时向 1.2.3.4:5678 发 STUN Binding Request
→ NAT-B 记录:内网 192.168.1.200:Y ↔ 公网 5.6.7.8:9012(允许 1.2.3.4 回包)
→ 包到达 NAT-A,此时 NAT-A 已有记录,放行!A 收到了

④ A 回复 B,B 的 NAT 也放行
✅ 洞打通,双方直连

关键在于「同时」——两端必须几乎同时发包,这就是为什么信令服务器要协调双方同步开始。

4.4 对称型 NAT 为什么打不通

1
2
3
A 通过 STUN 拿到公网地址 1.2.3.4:5678(STUN 服务器看到的端口)
A 向 B 发包 → NAT-A 分配新端口 1.2.3.4:5679(换了!)
B 拿着 5678 去敲门,但 NAT-A 只认 5679 → 永远打不通

此时只能走 TURN 中继。现实中企业内网、运营商级 NAT(CGNAT)大量使用对称型 NAT,P2P 打洞成功率约 70-80%,剩下 20-30% 必须靠 TURN 兜底,所以生产环境不能省 TURN。


五、DTLS + SRTP

一句话:WebRTC 的所有媒体流强制加密,不能关,不存在明文传输。

为什么需要两层?

协议 作用
密钥交换 DTLS 基于 UDP 的 TLS,握手阶段交换加密密钥
媒体加密 SRTP 用 DTLS 协商出来的密钥,加密每一帧音视频

比喻

  • DTLS = 见面时互换暗语(密钥)
  • SRTP = 之后说话都用暗语加密

流程

1
2
3
1. ICE 建立 UDP 连接
2. DTLS 握手(交换证书 + 密钥)
3. 之后所有 RTP 包用协商好的密钥加密 → SRTP

为什么用 DTLS 不用 TLS?

因为 RTP 跑在 UDP 上,UDP 没有 TCP 的可靠传输,TLS 依赖 TCP,所以需要能在 UDP 上工作的 DTLS。


六、GCC 拥塞控制深挖

GCC 本质是两个并行的带宽估计器,结果取最小值:

1
2
3
              ┌── 基于延迟的估计器(Trendline Filter)──┐
输入:网络包 → → min() → 目标码率
└── 基于丢包的估计器(Loss-based)─────────┘

6.1 估计器一:基于延迟梯度(Trendline Filter)

核心思想:网络队列开始积压时,包的到达间隔会变长,比丢包更早暴露拥塞。

第一步:计算每个包组的延迟梯度

WebRTC 把包分组(每组若干个包),测量「发送间隔」和「到达间隔」的差值:

1
2
3
4
发送端:包组1在 t=0 发出,包组2在 t=20ms 发出  → 发送间隔 20ms
接收端:包组1在 t=50ms 到达,包组2在 t=75ms 到达 → 到达间隔 25ms

延迟梯度 δ = 到达间隔 - 发送间隔 = 25 - 20 = +5ms

δ > 0 说明队列在积压(拥塞信号);δ < 0 说明队列在消散(网络变好)。

第二步:Trendline 滤波,过滤抖动噪声

单个包的 δ 抖动很大(网络天然不稳定),用线性回归拟合最近 N 个 δ 的趋势线:

1
2
δ序列:+2, -1, +3, +4, +5, +6  → 趋势向上 → 拥塞信号
δ序列:+2, -1, +3, -2, +1, -1 → 趋势平稳 → 正常

第三步:状态机判断

1
2
3
NORMAL   ──(trend持续上升)──→  OVERUSE(拥塞)
OVERUSE ──(trend下降)──────→ UNDERUSE(空闲)
UNDERUSE ──(trend平稳)──────→ NORMAL

只有连续多个包组都显示 OVERUSE,才触发降码率(避免误判)。

第四步:AIMD 调整码率

1
2
3
UNDERUSE → 加性增加(Additive Increase):每秒增加 8% 码率
OVERUSE → 乘性减少(Multiplicative Decrease):乘以 0.85,立刻降 15%
NORMAL → 保持

为什么「加慢减快」:降码率是应急响应,必须快;加码率如果太猛会重新触发拥塞,所以慢慢探测上限在哪。

6.2 估计器二:基于丢包率(Loss-based)

接收端通过 RTCP 报告丢包情况(每秒一次),发送端根据丢包率调整:

1
2
3
4
丢包率 < 2%   → 可以提升码率(乘以 1.08)
丢包率 2~10% → 保持当前码率
丢包率 > 10% → 降码率(乘以 1 - 0.5 × 丢包率)
比如丢包 20% → 乘以 0.9 → 降 10%

6.3 两个估计器为什么都需要

延迟梯度 丢包率
响应速度 快(队列刚积压就感知到) 慢(必须等到包真的丢了)
准确性 受网络抖动影响,有误判 准确,丢了就是丢了
适用场景 提前预防拥塞 确认拥塞已发生

取两者最小值 = 保守策略,哪个说要降就降,不冒险。

6.4 完整 GCC 工作闭环

1
2
3
4
5
6
7
8
9
10
11
发送端                              接收端
│ │
│── 发 RTP 包(带时间戳)──────────→ │
│ │ 计算到达间隔,算 δ
│ │ 统计丢包率
│ ←── RTCP Transport-CC Feedback ── │ (每包都反馈到达时间)
│ ←── RTCP REMB(估计最大码率)───── │ (旧版,逐渐被 Transport-CC 替代)
│ │
│ 发送端本地运行 Trendline Filter
│ 计算目标码率 → 通知编码器调整码率
│ → 编码器输出更小/更大的帧

现代 WebRTC 主要用 Transport-CC(接收端把每个包的到达时间戳都反馈给发送端,发送端自己算延迟梯度),比老的 REMB 更精准。


七、串起来看整个流程

1
2
3
4
5
6
7
8
1. A 和 B 各自通过 STUN 拿到自己的公网地址
2. A 创建 Offer SDP(我支持 VP8/H264/Opus)发给信令服务器 → 转给 B
3. B 创建 Answer SDP(我选 H264)发回给 A
4. 双方交换 ICE Candidate,开始打洞
5. ICE 找到最优路径(优先直连,打不通走 TURN)
6. DTLS 握手,协商加密密钥
7. 开始传 SRTP 加密的音视频流
8. GCC 持续监测网络,动态调整发送码率

最后更新:2026-06

目标:理解 WebRTC 原理 + 跑通 Android 推拉流 Demo + 了解移动端直播架构
节奏:工作日 30-45 分钟(看理论),周末集中写代码
整理时间:2026-06


一、背景知识:WebRTC vs 传统直播

1.1 WebRTC 不只针对 Web

WebRTC 有完整的 Android / iOS 原生库,移动端支持非常成熟:

  • Android:org.webrtc:google-webrtcio.getstream:stream-webrtc-android
  • iOS:内置 WebRTC.framework
  • Web:浏览器原生支持

1.2 三种运行模式

模式 架构 适用场景 延迟
P2P 端到端直连 1v1 通话 最低
SFU 服务器转发,不解码 多人会议、连麦
MCU 服务器解码+混流 大会议、弱网端 较高

腾讯会议、Zoom 均采用 SFU + MCU 混合模式,根据参与人数和网络状况动态切换。

1.3 传统直播 vs WebRTC 详细对比

架构对比

1
2
3
4
5
6
# 传统直播
主播 → RTMP推流 → 流媒体服务器 → CDN分发 → 观众拉流(HLS/FLV)

# WebRTC
端A <── ICE协商 ──> 端B
<── 媒体流(SRTP) ──>

延迟对比

协议 延迟 原因
HLS 5-30秒 切片缓冲,每片2-10秒
HTTP-FLV 1-3秒 流式传输,有缓冲区
RTMP 1-3秒 TCP,有重传队列
WebRTC 50-500ms UDP,丢包直接丢弃不等重传

WebRTC 延迟低的根本原因:宁可丢帧,不等重传

弱网表现

WebRTC 内置三重弱网保障:

  • NACK:发现丢包主动请求重传
  • FEC(前向纠错):提前发冗余包,丢了也能还原
  • GCC(Google Congestion Control):实时估算带宽,自动降码率保流畅

成本对比

传统直播 WebRTC
分发成本 低(CDN按流量) 高(SFU服务器)
开源生态 FFmpeg/ijkplayer/SRS WebRTC原生/Mediasoup/Livekit
延迟 秒级 毫秒级

1.4 移动端直播架构全貌

1
2
3
主播手机
├── RTMP/SRT 推流 ──> CDN ──> HLS/FLV ──> 普通观众(高并发)
└── WebRTC ──────────> SFU ──> WebRTC ──> 连麦观众(低延迟)

二、Week 1-2:理论打底

工作日 30-45 分钟,通勤/睡前均可。不写代码,先把原理搞透。

Week 1:WebRTC 核心原理

必须理解的 5 个核心概念:

① 信令(Signaling)

  • WebRTC 本身不定义信令协议,需要自己实现
  • 本质:交换 SDP 和 ICE Candidate 的通道
  • 常见实现:WebSocket / HTTP 长轮询 / 任意方式均可

② SDP(Session Description Protocol)

  • 描述「我能用什么编解码器、什么格式通信」
  • 使用 Offer/Answer 模型:A 发 Offer,B 回 Answer

③ ICE / STUN / TURN

1
2
3
4
5
6
STUN Server:告诉你「你的公网 IP 是 x.x.x.x:port」
ICE:枚举所有可能的连接路径,选最优的一条
- host:本机局域网地址(最优先)
- srflx:通过 STUN 获取的公网地址
- relay:通过 TURN 中继的地址(兜底)
TURN Server:P2P 穿透失败时,所有流量通过服务器中继

④ DTLS + SRTP

  • 所有 WebRTC 媒体流强制加密,无法关闭
  • DTLS:基于 UDP 的 TLS,握手交换密钥
  • SRTP:加密后的 RTP,传输音视频数据

⑤ 拥塞控制(GCC)

  • 实时估算当前可用带宽
  • 带宽充足 → 提升码率/分辨率;带宽不足 → 降低码率

推荐阅读:WebRTC for the Curious(必读,免费电子书)

Week 2:音视频基础

视频编解码器对比

编解码器 特点 WebRTC 中的角色
H.264 硬件支持最广,兼容性最好 Android 默认首选
H.265/HEVC 压缩率更高,但专利费问题 部分场景使用
VP8 Google 开源,无专利 WebRTC 早期默认
VP9 VP8 升级版,压缩率好 Chrome 常用
AV1 新一代,压缩率最高 逐步普及中

关键参数关系

1
2
码率 = 分辨率 × 帧率 × 压缩率(近似)
弱网时:优先降码率 → 其次降分辨率 → 最后降帧率

帧类型

  • I帧(关键帧):完整图像,可独立解码,体积大
  • P帧(预测帧):相对前一帧的差异,体积小
  • B帧(双向预测帧):WebRTC 通常不用(增加延迟)

推荐阅读:Digital Video Introduction


三、Week 3-4:Android WebRTC 第一个 Demo

Week 3:Loopback Demo

添加依赖:

1
2
// app/build.gradle
implementation 'io.getstream:stream-webrtc-android:1.1.3'

核心 API 学习顺序:

1
2
3
4
5
6
7
8
9
PeerConnectionFactory(全局初始化)

AudioSource / VideoSource(媒体源)

AudioTrack / VideoTrack(媒体轨道)

PeerConnection(连接管理核心)

SurfaceViewRenderer(视频渲染)

Loopback Demo 核心逻辑:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// 本机两个 PeerConnection 互连,不需要服务器
private fun startNegotiation() {
val constraints = MediaConstraints().apply {
mandatory.add(MediaConstraints.KeyValuePair("OfferToReceiveVideo", "true"))
}
pcA.createOffer(object : SdpObserver {
override fun onCreateSuccess(sdp: SessionDescription) {
pcA.setLocalDescription(SimpleSdpObserver(), sdp)
pcB.setRemoteDescription(SimpleSdpObserver(), sdp)
pcB.createAnswer(object : SdpObserver {
override fun onCreateSuccess(answer: SessionDescription) {
pcB.setLocalDescription(SimpleSdpObserver(), answer)
pcA.setRemoteDescription(SimpleSdpObserver(), answer)
}
override fun onSetSuccess() {}
override fun onCreateFailure(error: String) {}
override fun onSetFailure(error: String) {}
}, constraints)
}
override fun onSetSuccess() {}
override fun onCreateFailure(error: String) {}
override fun onSetFailure(error: String) {}
}, constraints)
}

// ICE Candidate 交换
inner class PeerAObserver : PeerConnection.Observer {
override fun onIceCandidate(candidate: IceCandidate) {
pcB.addIceCandidate(candidate) // A 的 candidate 加给 B
}
// ...
}

验收标准: 本机摄像头画面出现在两个 SurfaceViewRenderer 里。

Week 4:信令服务器 + 1v1 通话

最简信令服务器(Node.js):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const WebSocket = require('ws')
const wss = new WebSocket.Server({ port: 8080 })
const rooms = {}

wss.on('connection', (ws) => {
ws.on('message', (data) => {
const msg = JSON.parse(data)
if (!rooms[msg.roomId]) rooms[msg.roomId] = []
if (!rooms[msg.roomId].includes(ws)) rooms[msg.roomId].push(ws)
// 转发给房间内其他人
rooms[msg.roomId].forEach(client => {
if (client !== ws && client.readyState === WebSocket.OPEN) {
client.send(data)
}
})
})
})

两台手机通话时序:

1
2
3
4
5
6
7
8
9
设备A                信令服务器              设备B
├── 加入房间 ───────→ │ │
│ │ ←── 加入房间 ──────────┤
├── createOffer ─────→ │ │
│ │ ──── offer ────────────→ │
│ │ ←─── createAnswer ────── │
├── setRemoteDesc ←── │ │
├── IceCandidate ────→ │ ──── candidate ────── → │
└──────────── P2P 媒体流建立 ─────────────────────┘

验收标准: 两台手机互相看到对方摄像头,听到对方声音。


四、Week 5-6:SFU + 多人推拉流

Week 5:接入 Livekit

搭建服务器:

1
2
3
docker run --rm \
-p 7880:7880 -p 7881:7881 -p 7882:7882/udp \
livekit/livekit-server --dev --bind 0.0.0.0

Android 接入核心代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
implementation 'io.livekit:livekit-android:2.5.0'

// 连接并推流
room = LiveKit.create(applicationContext)
room.connect(url = "ws://your-server:7880", token = token)
room.localParticipant.setCameraEnabled(true)
room.localParticipant.setMicrophoneEnabled(true)

// 拉流(订阅其他参与者)
room.events.collect { event ->
if (event is RoomEvent.TrackSubscribed && event.track is VideoTrack) {
(event.track as VideoTrack).addRenderer(remoteRenderer)
}
}

验收标准: 3台手机进同一房间,互相能看到听到。

Week 6:传统推拉流(RTMP + HLS)

搭建 SRS 服务器:

1
docker run -p 1935:1935 -p 8080:8080 ossrs/srs:5

Android RTMP 推流(腾讯 LiteAV SDK):

1
2
3
4
val pusher = V2TXLivePusher(this, V2TXLiveMode.TXLiveMode_RTMP)
pusher.startCamera(true)
pusher.startMicrophone()
pusher.startPush("rtmp://your-server/live/stream1")

Android HLS 拉流(ExoPlayer):

1
2
3
4
val player = ExoPlayer.Builder(this).build()
player.setMediaItem(MediaItem.fromUri("http://your-server:8080/live/stream1.m3u8"))
player.prepare()
player.play()

验收标准: 一台手机推 RTMP,另一台 ExoPlayer 拉 HLS 播放。


五、Week 7-8:融会贯通

Week 7:自测问题

  1. 为什么 WebRTC 的延迟比 HLS 低 10-100 倍?
  2. ICE 协商失败会怎样?TURN 服务器的作用是什么?
  3. GCC 是怎么估算带宽的?丢包和延迟增大各触发什么策略?
  4. SFU 和 MCU 的 CPU 消耗为什么差这么多?
  5. 1000 人观看的直播,WebRTC 和 HLS 的服务器成本差多少?

Week 8:Wireshark 抓包

1
2
3
4
5
brew install wireshark

# 过滤条件
dtls # DTLS 握手
stun # STUN 协议

重点观察:

  • STUN Binding Request/Response:ICE 发现公网地址
  • DTLS ClientHello/ServerHello:加密握手过程
  • RTCP REMB 包:带宽估算反馈信号

六、配套资源

资源 类型 链接
WebRTC for the Curious 必读电子书 https://webrtcforthecurious.com/
Digital Video Introduction 音视频基础 https://github.com/leandromoreira/digital_video_introduction
stream-webrtc-android Android WebRTC 库 https://github.com/GetStream/webrtc-android
Livekit Android SDK SFU 接入 https://github.com/livekit/client-sdk-android
SRS 流媒体服务器 传统直播服务器 https://github.com/ossrs/srs
AppRTCMobile Google 官方示例 https://chromium.googlesource.com/external/webrtc/

七、每周验收 Checklist

Week 1

  • 能解释 SDP Offer/Answer 交换过程
  • 能画出 ICE 连接建立的流程图
  • 理解 STUN 和 TURN 的区别

Week 2

  • 理解 I/P/B 帧区别,知道 WebRTC 为什么不用 B 帧
  • 能解释码率/帧率/分辨率三者关系

Week 3

  • Loopback Demo 跑通
  • 理解 SDP 协商在代码里如何触发

Week 4

  • 信令服务器搭起来
  • 两台手机 1v1 通话成功

Week 5

  • Livekit 3人房间跑通
  • 理解 SFU 模式下每个客户端的上下行

Week 6

  • RTMP 推流 + HLS 拉流跑通
  • 能描述「直播+连麦」混合架构

Week 7

  • 能回答全部 5 个自测问题

Week 8

  • 抓到 DTLS 握手包并能解释
  • 找到 RTCP REMB 包,理解其作用

附录一:服务端用 Docker,客户端跑真机

为什么推荐 Docker 跑服务端

跑 Demo 时服务端有三个组件:信令服务器、Livekit SFU、SRS 流媒体服务器。这三个用 Docker 有明显优势:

  • 一行命令启动,不污染本机环境
  • 随时销毁重建,环境始终干净
  • 官方镜像开箱即用,无需手动编译配置
1
2
3
4
5
6
7
8
9
10
11
# 信令服务器(自己写的 Node.js)
docker build -t signaling-server .
docker run -p 8080:8080 signaling-server

# Livekit SFU
docker run --rm \
-p 7880:7880 -p 7881:7881 -p 7882:7882/udp \
livekit/livekit-server --dev --bind 0.0.0.0

# SRS 流媒体服务器
docker run -p 1935:1935 -p 8080:8080 ossrs/srs:5

不适合用 Docker 的部分

  • Android App:必须跑在真机或 Android 模拟器上,Docker 里没有摄像头/麦克风
  • Wireshark 抓包:Docker 有网络 NAT 层,抓包会变复杂,建议直接在宿主机抓

推荐组合

1
2
3
4
服务端(Docker)          客户端(真机)
信令服务器 ──────────────→ Android 手机A
Livekit SFU ─────────────→ Android 手机B
SRS 流媒体 ───────────────→ Android 手机C

局域网内手机和 Mac 互通,your-server 填 Mac 的局域网 IP 即可。


附录二:常见问题排查

ICE 连接失败

  1. 检查 STUN 服务器是否可达
  2. 检查防火墙是否放行 UDP
  3. 检查 ICE candidate 是否正确交换
  4. 添加 TURN 服务器作为兜底

画面卡顿/花屏

  1. 查看丢包率(>5% 明显影响质量)
  2. 确认硬件编码已启用
  3. 检查码率是否超过实际带宽

Android 音频回声

  1. 确认 AEC(回声消除)已启用
  2. 检查 AudioManager 模式是否为 MODE_IN_COMMUNICATION

最后更新:2026-06

前言:SSO 是什么

SSO(Single Sign-On,单点登录)本质上是一种票据中转机制:用户在公司统一登录页输入密码,登录页生成一张一次性凭证(ticket),App 拿到这张票去服务器换取真正的身份 Cookie。全程用户的密码只交给公司统一登录页,App 本身永远看不到密码。


一、参与方

在理解流程之前,先明确有哪些系统参与了这件事:

角色 是什么 地址(生产环境)
SSO 服务 公司统一登录页,负责验证密码并颁发 ticket https://sso.example.com
UserService 公司内部用户体系服务,负责用 ticket 换 Cookie https://userservice.example.com
MyApp App 公司员工用的内部 Android App
AuthCallbackServer App 内部在 127.0.0.1 上开的临时 HTTP 服务器 http://127.0.0.1:{随机端口}/callback
系统浏览器(Chrome 等) 用来打开 SSO 登录页,让用户输密码

二、完整时序图(文字版)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
用户              App(UI)          AuthViewModel     AuthRepository      系统浏览器          SSO服务            UserService
| | | | | | |
| 点击"SSO登录" | | | | | |
|---------------->| | | | | |
| | startSsoLogin() | | | | |
| |------------------>| | | | |
| | | prepareSsoLogin()| | | |
| | |----------------->| | | |
| | | | 开ServerSocket | | |
| | | | 构造SSO URL | | |
| | | | | | |
| | | SsoEffect. | | | |
| | | LaunchUrl(url) | | | |
| |<------------------| | | | |
| | startActivity | | | | |
| |------------------------------------------------->| | |
| | | | | | |
| | | awaitSsoCallback()| | | |
| | |----------------->| | | |
| | | | accept()阻塞等待 | | |
| | | | | | |
| | | | | 打开SSO登录页 | |
| 输入账号密码 | | | |----------------->| |
|------------------------------------------------------------------------------------------------>| |
| | | | | | 验证密码 |
| | | | | | 颁发ticket |
| | | | | redirect到 | |
| | | | | 127.0.0.1/callback?ticket=xxx |
| | | |<-----------------| | |
| | | | 收到HTTP请求 | | |
| | | | 提取ticket | | |
| | | | 返回成功HTML | | |
| | | |----------------->| | |
| 看到"登录成功"页| | | | | |
|<--------------------------------------------------------| | | |
| | | | | | |
| | | | exchangeTicket() | | |
| | | |---------------------------------------------------->|
| | | | | | 验证ticket |
| | | | | | 返回Cookie+用户信息
| | | |<----------------------------------------------------|
| | | | 存加密本地存储 | | |
| | 状态→Idle | | | | |
| |<------------------| | | | |
| 进入主界面 | | | | | |
|<-----------------| | | | | |

三、逐步拆解每个环节

第 1 步:用户点击按钮

LoginScreen.kt 中的 SSO 登录按钮绑定了 onSsoLoginClick 回调,点击后调用:

1
authViewModel.startSsoLogin(subsystemAlias = "userservice")

subsystemAlias 固定为 “userservice”,用于告知 SSO 服务和 UserService 这次登录属于哪个子系统。


第 2 步:并发保护检查

startSsoLogin 第一件事就是防止重复触发:

1
2
3
4
5
6
7
fun startSsoLogin(subsystemAlias: String) {
if (ssoLoginJob?.isActive == true || _ssoLoginState.value.isBusy()) {
return // ← 直接忽略,什么都不做
}
_ssoLoginState.value = SsoLoginState.LaunchingLogin
ssoLoginJob = viewModelScope.launch { runSsoLogin(subsystemAlias) }
}

“忙碌”的定义是:当前状态是 LaunchingLogin、HandlingCallback、LaunchingImAuthorization、HandlingImCallback 其中任意一种。也就是说,从点击按钮到登录完成的整个过程中,再次点击都会被静默忽略。


第 3 步:准备阶段——在本机开一个 HTTP 服务器

这是 SSO 流程最特殊的地方,也是和 IM 登录最大的区别。

authRepository.prepareSsoLogin(“userservice”) 做了这几件事:

3a. 关闭旧服务器(防止上次未正常关闭留下的残留)

1
callbackServer?.close()

3b. 创建新的 AuthCallbackServer

1
2
val server = AuthCallbackServer()
callbackServer = server

AuthCallbackServer 的构造函数:

1
2
3
4
5
6
7
8
9
10
11
private val serverSocket = ServerSocket(
0, // ← 端口 0 = 让 OS 随机分配一个空闲端口
1, // ← backlog=1,同时只接受 1 个连接
InetAddress.getByName("127.0.0.1") // ← 只监听本机回环地址,外部无法访问
)
val port: Int = serverSocket.localPort // 拿到 OS 分配的实际端口,比如 54321
val callbackUrl: String = "http://127.0.0.1:$port/callback"

init {
serverSocket.soTimeout = 5 * 60 * 1000 // 5 分钟超时
}

为什么要在本机开服务器?

SSO 的安全机制要求 ticket 必须被”redirect”回一个预先登记的地址(service 参数)。桌面端可以用 localhost,移动端也用同样的思路——在本机开一个临时 HTTP 服务器监听回调。

3c. 构造 SSO 登录 URL

1
2
3
4
fun buildLoginUrl(callbackUrl: String, subsystemAlias: String): String {
val encodedService = URLEncoder.encode(callbackUrl, "UTF-8")
return "${config.ssoHost}?service=$encodedService&subsystem=$subsystemAlias"
}

最终 URL 长这样(生产环境):

1
2
3
https://sso.example.com
?service=http%3A%2F%2F127.0.0.1%3A54321%2Fcallback
&subsystem=userservice

这个 URL 告诉 SSO 服务:用户登录成功后,把 ticket 带到 http://127.0.0.1:54321/callback 这个地址。


第 4 步:打开系统浏览器

prepareSsoLogin 返回 URL 后,ViewModel 通过 Channel 发出副作用:

1
2
_ssoEffect.send(SsoEffect.LaunchUrl(url))
_ssoLoginState.value = SsoLoginState.HandlingCallback // 状态更新

LoginScreen.kt 中的 LaunchedEffect 收到后:

1
2
3
is SsoEffect.LaunchUrl -> {
context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(effect.url)))
}

此刻 App 切到后台,系统浏览器打开了 https://sso.example.com?service=...&subsystem=userservice。

💡 深入:startActivity 打开浏览器的底层机制

Intent.ACTION_VIEW 配合 https:// URL,Android 系统会查询已安装 App 中哪些声明了处理 https scheme 的 Activity(通常是 Chrome 或系统默认浏览器),然后启动它。这和你在手机上点一个网页链接完全一样——App 本身不渲染页面,只是把 URL 丢给系统,自己退到后台。

此时 App 进入后台,但协程仍在 Dispatchers.IO 线程上挂起等待 ServerSocket.accept(),App 进程并没有被销毁。

📦 深入:TCP 只管传输,不管消费——为什么必须主动 read()?

一个常见误解:「连接建立了,数据不就被消费了吗?」——并不是。

TCP 做的事只是:把字节从发送方的内核发送缓冲区,搬到接收方的内核接收缓冲区。搬完之后 TCP 的工作就结束了。数据躺在接收缓冲区里,等应用程序来主动取

类比:TCP 是快递员,接收缓冲区是门口的快递柜。快递员把包裹放进柜子之后,你得自己去开柜取包裹——快递员不会帮你搬进房间,也不知道你有没有取。柜子满了他就没地方放新包裹,只能在外面等(TCP 流量控制)。

所以 accept() 只是「接受了连接」,数据还在缓冲区里:

这也是 headers 必须全部读完的根本原因:如果缓冲区里还有未读的 headers,缓冲区可能满,TCP 流量控制会让浏览器的发送窗口缩为 0,发送方卡住等接收方腾空间,而接收方又在等浏览器读响应——双方互相等待,死锁。读完所有 headers 是消除这个隐患的防御性写法。


第 5 步:用户在浏览器输入账号密码

这一步完全在浏览器内发生,App 对此一无所知,也看不到任何密码。

SSO 服务验证密码成功后,做两件事:

  1. 生成一个一次性 ticket(例如 ST-12345-abcdef-sso)

  2. 把浏览器 redirect 到:

1
http://127.0.0.1:54321/callback?ticket=ST-12345-abcdef-sso

这个 redirect 实际上是浏览器发起的一次 HTTP 请求,目标是本机的 ServerSocket。

💡深入:302 重定向到本机的完整过程

这里没有任何魔法,就是最普通的 HTTP 302 重定向。

用户点击「确认登录」后,浏览器向 SSO 服务提交表单(POST 请求)。SSO 服务验证密码正确后,不返回 200,而是返回 302

浏览器收到 302,会自动向 Location 里的地址发起一个新的 GET 请求:

这个请求的目标是本机 127.0.0.1,走的是回环网卡(loopback),不经过任何网络,直接被同一台设备上正在 accept() 的 ServerSocket 接住。浏览器完全不知道对面是一个 Android App,它只认 HTTP 协议——只要对方在那个端口上,能建立 TCP 连接、返回合法的 HTTP 响应,浏览器就会处理它。


第 6 步:AuthCallbackServer 接收 ticket

就在用户登录的同时,App 在 Dispatchers.IO 上挂起等待:

1
2
3
4
5
6
7
8
9
10
11
12
13
override suspend fun awaitSsoCallback(): Result<Unit> {
acquireWakeLock() // ← 先拿 WakeLock,防止 CPU 休眠
return try {
runCatching {
val server = callbackServer ?: error("No SSO callback server running")
val result = server.awaitCallback() // ← 阻塞在这里等浏览器回来
callbackServer = null
exchangeTicket(result.ticket, result.callbackUrl).getOrThrow()
}
} finally {
releaseWakeLock()
}
}

WakeLock 的作用:Android 系统可能在屏幕关闭时让 CPU 进入休眠,导致 ServerSocket.accept() 永远不会被唤醒。PARTIAL_WAKE_LOCK 让 CPU 保持运行,超时设置 6 分钟(略长于服务器的 5 分钟超时)。

awaitCallback() 内部的 HTTP 解析过程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 1. 读取请求行
val requestLine = reader.readLine()
// 内容类似: "GET /callback?ticket=ST-12345-abcdef-sso HTTP/1.1"

// 2. 消耗所有 header 行(必须读完,否则浏览器会一直等待)
while (true) {
val line = reader.readLine() ?: break
if (line.isEmpty()) break // 空行 = headers 结束
}

// 3. 解析路径和 ticket 参数
val ticket = parseQueryParam(queryString, "ticket")
// ticket = "ST-12345-abcdef-sso"

// 4. 向浏览器返回成功 HTML
sendResponse(writer, SUCCESS_HTML)
// HTML 里有 <meta http-equiv="refresh" content="2;url=myapp://auth/callback">
// 用户会看到"登录成功,正在返回 myapp..."

// 5. 返回结果
CallbackResult(ticket = ticket, callbackUrl = "http://127.0.0.1:54321/callback")

💡深入:ServerSocket 为什么能控制浏览器显示什么?

浏览器向 127.0.0.1:54321 发出 GET 请求后,会等待对方返回 HTTP 响应,再把响应 body 渲染出来。AuthCallbackServer 里的 sendResponse() 手动拼了一个合法的 HTTP 响应:

浏览器收到这个响应,看到 Content-Type: text/html,就按 HTML 把 body 渲染出来——也就是用户看到的「登录成功,正在返回 myapp…」页面。AuthCallbackServer 扮演的角色就是一个只活一次的微型 HTTP 服务器,接完 ticket 就关门,顺手把成功页发给浏览器。

为什么 headers 必须全部读完? TCP 是双向流。浏览器把请求发完后,要等服务端把请求全部读走,才会开始等响应。如果 App 不消耗完 header 行就直接写响应,可能触发 TCP 流量控制,双方互相等待(死锁)。所以代码里有那段 while 循环,必须一行一行读到空行才能写响应。

callbackUrl 为什么要带回去? UserService 的 internal_login 接口需要用它做二次校验——确认 ticket 确实是颁发给这个地址的,防止 ticket 被其他人截获后拿来冒用。


第 7 步:用 ticket 换 Cookie(exchangeTicket)

这是整个流程的核心兑换步骤:

1
2
3
4
5
6
7
8
POST https://userservice.example.com/auth/internal_login
Content-Type: application/json

{
"ticket": "ST-12345-abcdef-sso",
"validateService": "http://127.0.0.1:54321/callback",
"subsystemAlias": "userservice"
}

UserService 收到请求后:

  1. 拿着 ticket 去 SSO 服务验证(这是服务端对服务端的校验,客户端看不到)

  2. 验证 validateService 与当初颁发 ticket 时记录的 service 一致

  3. 查出对应的用户信息

  4. HTTP 响应头里下发 Set-Cookie,在 响应体里返回用户数据

响应示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
HTTP/1.1 200 OK
Set-Cookie: app-access-token-prod=eyJhbGciOi...; Path=/; Domain=.example.com; Secure; HttpOnly
Content-Type: application/json

{
"data": {
"name": "张三",
"email": "zhangsan@example.com",
"userNameAlias": "zhangsan",
"userId": "xxx",
"access-token": "eyJhbGciOi..."
}
}

兼容性处理:部分 UserService 环境只在 JSON 里返回 access-token,不下发 Set-Cookie。CookieCodec.withAccessTokenFallback 会把 JSON 里的值补合成一条标准格式的 Set-Cookie:

1
2
// 如果 Set-Cookie 里没有 access token,就自己造一条
"app-access-token-prod=eyJhbGciOi...; Path=/; Domain=.example.com; Secure; HttpOnly"

用户信息解析:兼容两种响应格式

  • internal_login:data.name、data.email 直接在 data 下

  • user_info(会话恢复时用):data.internalUser.name、data.internalUser.email 在嵌套层


第 8 步:持久化登录态

凭据换取成功后:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 1. 把 Cookie 列表和用户信息存入 EncryptedSharedPreferences
saveSsoSession(cookies, user)
// 用 AES256-GCM 加密,key 用 Android Keystore 保护

// 2. 更新内存状态
_authSnapshot.value = AuthSnapshot(
stage = Stage.SSO_AUTHENTICATED,
ssoSession = SsoSession(cookies, user, updatedAt = System.currentTimeMillis()),
gatewayCredential = null,
)

// 3. 尝试拉取 Gateway 凭据(失败不影响登录成功)
refreshGatewayCredential()
// POST /instance/get → 获取 Gateway URL 和 token
// 成功后 stage 升为 GATEWAY_READY

存储的 key 一览:

1
2
3
4
5
"sso_cookies_json"   → JSON 序列化的 Cookie 字符串列表
"user_snapshot_json" → JSON 序列化的用户信息
"auth_env" → 当前环境(PROD/BETA/SIT)
"gateway_url" → Gateway 地址
"gateway_token" → Gateway token

第 9 步:状态回到 Idle,UI 跳转主界面

1
_ssoLoginState.value = SsoLoginState.Idle

LoginScreen 收到状态变化,跳转主界面。整个 SSO 流程结束。


四、各种失败场景的处理

场景 A:用户 5 分钟内没有完成登录

ServerSocket.soTimeout = 5 * 60 * 1000,超时后 accept() 抛出 SocketTimeoutException,被 runCatching 捕获,状态切为 Error,显示”登录失败”。

场景 B:用户在 SSO 页面点了取消/关闭了 WebView

cancelSsoLoginIfWaiting() 被 UI 调用:

1
2
3
4
5
6
7
8
9
fun cancelSsoLoginIfWaiting() {
if (!_ssoLoginState.value.isBusy()) return
try {
authRepository.cancelSsoLogin() // 关闭 ServerSocket
} finally {
ssoLoginJob?.cancel() // 取消协程
_ssoLoginState.value = SsoLoginState.Idle
}
}

场景 C:ticket 已过期或被用过(一次性)

UserService 返回非 2xx,error(“internal_login failed: ${response.code}”) 被 runCatching 捕获,状态切为 Error。

场景D:协程被取消(App 进后台被系统杀死等)

CancellationException 被单独捕获,状态回 Idle 并重新 throw,确保协程取消语义正确:

1
2
3
4
} catch (e: CancellationException) {
_ssoLoginState.value = SsoLoginState.Idle
throw e // ← 必须重新抛出,否则协程无法正常取消
}

五、下次启动如何恢复登录态(restoreSession)

App 不是每次都要走完整的 SSO 流程。下次启动时:

1
2
3
4
5
1. 从 EncryptedSharedPreferences 读出 cookies + user
2. POST /auth/user_info(带上已存的 Cookie)
→ UserService 验证 Cookie 是否仍有效
3. 有效 → 直接恢复 SSO_AUTHENTICATED,跳过登录页
4. 无效 → clearAuth(),显示登录页,要求重新 SSO

旧版本兼容:如果本地只有旧二维码凭据(gateway_url 有值但 sso_cookies_json 为空),说明是从旧版本升级来的用户,直接清除并强制重新登录。


六、一句话总结每个类的职责

类/文件 职责
LoginScreen.kt 按钮点击 → 调 ViewModel;收 SsoEffect → 打开浏览器
AuthViewModel 状态机控制;协程生命周期管理;并发保护
AuthRepositoryImpl 实现所有登录逻辑;持久化;协调各 API 类
AuthCallbackServer 在 127.0.0.1 开临时 HTTP 服务器,等浏览器回调,提取 ticket
AuthApi 封装两个 UserService HTTP 接口:internal_login(换 Cookie)和 user_info(校验 Cookie)
AuthEnvConfig 管理不同环境(PROD/BETA/SIT)的服务地址和 Cookie 名
CookieCodec Set-Cookie 字符串的编解码工具;兜底合成缺失的 access token Cookie
SsoLoginState 登录流程的状态枚举
SsoEffect UI 副作用通道(LaunchUrl / LaunchExternalUrl)

七、IM 登录(App-to-App)完整流程

与 SSO 登录的本质区别

SSO 登录的回调靠的是 HTTP redirect → 本机 ServerSocket,整个过程在当前 App 进程里闭环。

IM 登录完全不同:App 跳出去,IM App 完成授权,再用 deep link 把 token 直接带回来。没有 ServerSocket,没有 HTTP 服务器,中间 App 可以被系统暂停甚至进程重启,token 依然能安全回来。

第 1 步:用户点击「IM 登录」

1
authViewModel.startImAuthorization()

同样先做并发保护检查,忙碌状态直接忽略。通过后状态切到 LaunchingImAuthorization。

第 2 步:prepareImAuthorization —— 构造授权 URL

① 生成防重放的 state

1
2
val state = UUID.randomUUID().toString().replace("-", "")
// 例如:a1b2c3d4e5f6...(32 位随机字符串)

state 的作用是防止 CSRF 攻击——确保回跳回来的数据确实是这次授权发出去的,不是别人伪造的。

② state 双重持久化

1
2
pendingImAuthorizationState = state                              // 内存(进程存活时用)
prefs?.edit()?.putString(KEY_PENDING_IM_STATE, state)?.apply() // 加密磁盘(进程重启后也能校验)

为什么要存磁盘?因为跳到 IM App 后,Android 系统可能把 MyApp 进程回收(内存不足)。用户在 IM App 里完成授权、回跳时进程重新启动,内存里的 state 已经没了,必须从磁盘读回来校验。

③ 构造 imapp://corp-im/auth?… URL

ImAuthorizationUrlCodec.buildAuthorizationUrl() 把所有参数编码进 URL:

1
2
3
4
5
6
7
8
9
10
11
12
imapp://corp-im/auth
?callback=myapp%3A%2F%2Fauth-callback%3Fstate%3D...%26provider%3Dhi
&state=a1b2c3d4e5f6...
&subsystem=myapp
&source=android
&mode=local_token
&cookie_name=app-access-token-prod
&client_name=MyApp
&client_icon_url=https://...(myapp 的图标)
&client_subtitle=使用当前 IM 账号继续访问 MyApp
&client_body=MyApp 仅会使用本次授权完成企业登录和工作区连接。
&consent_version=myapp_mobile_v1
参数 含义
callback 授权完成后 IM App 把 token 带到这个 URL 回跳,scheme 是 myapp://
state 防重放标识,IM App 必须原样带回
mode=local_token 告诉 Hi:不走服务端接口,直接在本地把 token 放进回调 URL
cookie_name 告诉 Hi 要回传哪个 Cookie 的 token
client_* IM 授权页展示的 App 信息(图标、名称、说明文字)

第 3 步:跳转 IM App

1
_ssoEffect.send(SsoEffect.LaunchExternalUrl(url))

UI 层收到后:

1
2
3
4
5
6
7
is SsoEffect.LaunchExternalUrl -> {
try {
context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(effect.url)))
} catch (e: ActivityNotFoundException) {
authViewModel.handleImAuthorizationLaunchFailed() // IM 未安装兜底
}
}

imapp:// 是 IM App 注册的 URL scheme,Android 系统找到 IM App 并把它拉起来。此时 MyApp 退到后台,不需要等待,没有挂起的协程,没有 ServerSocket

第 4 步:在 IM App 里发生了什么

这部分完全在 IM App 内部,MyApp 代码看不到,但从参数可以推断:

  1. IM App 解析 imapp://corp-im/auth?… 里的参数

  2. 展示授权确认页(用 client_name、client_icon_url、client_subtitle、client_body 渲染)

  3. 用户点击「同意授权」

  4. IM App 从本地取出当前登录用户的 access token(mode=local_token 决定了这一步在本地完成,不走网络)

  5. IM App 把 token 和用户信息拼入 callback URL,用 startActivity 打开它:

1
2
3
4
5
6
7
8
9
10
myapp://auth-callback
?state=a1b2c3d4e5f6... ← 原样带回,用于校验
&provider=im
&email=zhangsan@example.com
&userserviceAccessToken=eyJhbGci... ← access token
&name=张三
&userNameAlias=zhangsan
&avatar=https://...
&userId=xxx
&sessionId=yyy

myapp:// 是 MyApp 注册的 URL scheme。Android 系统收到这个 Intent,把 MyApp 拉到前台,把整个 URL 传给 MainActivity。

App 层拿到 callbackUrl 后调用:

1
authViewModel.handleImAuthorizationCallback(callbackUrl)

状态切到 HandlingImCallback。

第 6 步:parseCallback —— 校验 + 解析

ImAuthorizationUrlCodec.parseCallback() 做了一系列防御性检查,任何一步失败就返回 Failure

1
2
3
4
5
6
7
8
9
10
11
12
13
① scheme 必须是 myapp,host 必须是 auth-callback
↓ 通过
② pendingState 不能为空(内存 or 磁盘读回)
↓ 通过
③ provider 必须是 "im"
↓ 通过
④ 检查 error / error_description 参数(IM 授权失败时会带这两个)
↓ 无错误
⑤ state 必须与 pendingState 完全一致(防 CSRF 核心)
↓ 一致
⑥ email 和 access token 不能为空
↓ 都有值
⑦ 返回 Success,携带所有字段

token 字段的多键名兼容:IM App 不同版本回传的字段名可能不同,代码做了多键名兼容:

1
2
private val tokenKeys = listOf("userserviceAccessToken", "access_token", "accessToken", "ssoAccessToken")
private val emailKeys = listOf("email", "userEmail", "mail")

URL 参数位置的双重解析:token 既可能在 query string(?key=value),也可能在 fragment(#?key=value),代码两个位置都解析,query 优先级高于 fragment:

1
2
3
4
5
private fun parseParams(uri: URI): Map<String, String> {
val queryParams = parseQuery(uri.rawQuery)
val fragmentParams = parseFragment(uri.rawFragment)
return fragmentParams + queryParams // query 覆盖 fragment
}

第 7 步:构造 Cookie(IM 登录专有)

与 SSO 登录最大的区别在这里:SSO 的 Cookie 由 UserService 服务端下发,IM 登录的 Cookie 由客户端本地构造

1
2
3
4
5
6
7
private fun buildImAuthorizationCookies(result: ImAuthorizationCallbackResult.Success): List<String> =
buildList {
add("${result.cookieName}=${result.accessToken}; Path=/; Domain=.example.com; Secure; HttpOnly")
if (result.sessionId.isNotBlank()) {
add("session_id=${result.sessionId}; Path=/; Domain=.example.com; Secure; HttpOnly")
}
}

构造完之后,持久化 + 更新状态 + 拉 Gateway 凭据,和 SSO 登录完全一样。

IM 登录 vs SSO 登录核心差异

对比维度 SSO 登录 IM 登录
用户操作 在浏览器输账号密码 在 IM App 点「同意授权」
回调机制 HTTP redirect → 本机 ServerSocket deep link → MainActivity
token 来源 UserService 服务端 Set-Cookie 下发 IM App 本地填入回调 URL
进程存活要求 必须一直活着(WakeLock 保活) 可被杀死,state 持久化到磁盘
超时机制 5 分钟 ServerSocket 超时 无超时,依赖 Hi 响应
防重放 靠本机端口唯一性 UUID state + 磁盘持久化双重校验

八、URL Scheme 注册与系统路由机制

注册方式:AndroidManifest.xml 中的 Intent Filter

App 安装时,Android 系统扫描 AndroidManifest.xml,把所有 <intent-filter> 登记进系统的包管理器数据库(PackageManager)。MyApp 在 MainActivity 上声明了两条 deep link:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<!-- IM App-to-App 授权回调 -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="myapp" android:host="auth-callback" />
</intent-filter>

<!-- SSO 回调(冗余保留)-->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="myapp" android:host="auth" android:pathPrefix="/callback" />
</intent-filter>

两条都用 myapp://,靠 host 区分用途:

  • auth-callback → IM 登录回调
  • auth + /callback 路径前缀 → SSO 相关回调

系统如何路由这个 URL?

IM App 完成授权后,执行的代码等价于:

1
2
3
// IM App 内部
val intent = Intent(Intent.ACTION_VIEW, Uri.parse("myapp://auth-callback?state=xxx&..."))
startActivity(intent)

Android 系统收到后按以下流程处理:

1
2
3
4
5
1. 从 Intent 里提取:action=VIEW,scheme=myapp,host=auth-callback
2. 查 PackageManager:谁注册了能处理这个 Intent 的 Activity?
3. 找到 MyApp 的 MainActivity
4. 进程还活着 → 拉到前台,调 onNewIntent(intent)
进程被杀了 → 重启进程,调 onCreate(intent)

launchMode="singleTop" 的关键作用

1
2
3
4
<activity
android:name=".MainActivity"
android:launchMode="singleTop"这里
...>

singleTop 的意思:如果返回栈顶已经有一个 MainActivity 实例,不新建,直接复用,通过 onNewIntent() 把新 Intent 传进来。

如果用默认的 standard 模式,每次 deep link 回来都会创建新的 MainActivity 实例,用户返回时会出现异常的页面栈。

<queries> 声明是干什么的?

1
2
3
4
5
6
<queries>
<intent>
<action android:name="android.intent.action.VIEW" />
<data android:scheme="imapp" />
</intent>
</queries>

这是反向的:MyApp 要跳出去打开 IM App(imapp://)。Android 11+ 引入了包可见性限制——App 默认看不到其他 App 的存在。声明 <queries> 之后,MyApp 才能:

  • 通过 packageManager.resolveActivity() 检查 IM App 是否已安装
  • startActivity(Intent(ACTION_VIEW, Uri.parse("imapp://..."))) 成功跳转

不加这个声明,startActivity 会直接抛 ActivityNotFoundException,触发代码里 IM App 未安装的兜底分支。

两个 App 的双向关系

1
2
3
4
5
6
7
8
9
10
11
12
13
MyApp                                     IM App
│ │
│ 注册 intent-filter: │ 注册 intent-filter:
│ myapp://auth-callback │ imapp://corp-im/auth
│ │
│ <queries> 声明能看见 imapp:// │
│ │
│──── startActivity(imapp://...) ───────>│ 被系统拉起,展示授权页
│ │
│<─── startActivity(myapp://...) ────────│ 授权完成,回跳
│ │
│ onNewIntent() 收到 deep link │
│ parseCallback() 校验 state │

系统扮演的角色是路由器:谁注册了能处理这个 URL scheme 的 Activity,Intent 就发给谁。两个 App 通过 scheme 互相「拨号」,系统负责「接线」。

runBlocking 的阻塞实现原理是 Kotlin 协程基础架构中非常精妙的一部分。它并不是通过简单的“忙等待”(busy-wait)来实现阻塞,而是通过在当前线程上启动一个事件循环(Event Loop) 来实现的。

下面我们分层来解析它的实现原理。

核心原理:线程局部事件循环 (Thread-Local Event Loop)

当调用 runBlocking 时,它的核心工作是在当前线程上安装并运行一个事件循环。这个事件循环负责调度和执行其代码块内所有的协程任务,直到所有任务完成。

1. 阻塞的入口:runBlocking 调用

当你调用 runBlockBlocking { ... } 时,会发生以下几步:

a. 创建事件循环和调度器

-

runBlocking 会创建一个 BlockingEventLoop 作为当前线程的局部事件循环。

-

同时,它会创建一个 BlockingCoroutine 实例,它使用这个新创建的事件循环作为其协程上下文中的 ContinuationInterceptor(调度器)。

b. 启动协程

  • 你传入的 lambda 代码块被包装成一个协程(StandaloneCoroutine),并在这个新创建的、使用阻塞事件循环的上下文中启动。

c. 进入事件循环

-

这是最关键的一步。runBlocking 会调用 eventLoop.join() 或类似的机制(在旧版本中是 joinBlocking)。

-

这个 join() 方法会启动一个 while 循环,这个循环会持续运行,直到满足两个条件:

1.

事件循环中没有可立即执行的任务(协程)。

2.

事件循环已经退出isCompleted 为 true),即所有子协程都已完成。

2. 事件循环的内部工作方式

这个 while 循环就是“阻塞”发生的地方,但它内部是高效的:

|

1
// 概念上的伪代码,简化自 kotlinx.coroutines.EventLoopImplBaseprivate fun joinBlocking() {    val eventLoop = ThreadLocalEventLoop.current() as BlockingEventLoop    while (true) {        // 1. 检查是否所有工作都已完成        if (eventLoop.isCompleted) break // 条件2满足,退出循环,解除阻塞        // 2. 处理所有队列中的任务        val task = eventLoop.processNextEvent()        if (task != null) {            // 执行任务(例如,恢复一个挂起的协程)            task.run()        } else {            // 3. 如果没有立即要处理的任务,但事件循环还未结束            // 就让线程真正地“睡眠”一小段时间,而不是忙等待            // 在JVM上,这可能会调用 LockSupport.parkNanos()            eventLoop.parkNanos()         }    }}

|

-

有任务时:循环会不断地从事件队列中取出任务(例如,一个 delay 定时器到期了,需要恢复某个协程)并执行它。此时线程是在“工作”的。

-

无任务且未完成时:线程会通过 parkNanos 等方法被短暂挂起,避免 100% 的 CPU 占用。一旦有新任务被提交到事件循环(例如,一个定时器到期),线程会被唤醒并继续工作。

3. 如何结束阻塞?

当 runBlocking 代码块内部的所有协程(包括所有子协程)都执行完毕后,根协程的状态会变为“已完成”。这会触发事件循环将自己的 isCompleted 标志设置为 true

外层的 while 循环检测到这个条件后,就会 break 退出循环。runBlocking 方法随之返回,调用线程得以继续执行后面的代码。阻塞解除

与挂起 (Suspend) 的关键区别

为了更好理解,我们对比一下 runBlocking 和普通协程构建器(如 launch)的区别:

|
特性
| runBlocking
| launch / async
|

|
线程行为
| 阻塞 (Block)
| 挂起 (Suspend)
|

|
实现机制
| 在当前线程启动一个事件循环并运行 while 循环。
| 将协程体包装成状态机,通过 Continuation.resumeWith() 在调度器线程池中执行。
|

|
资源占用
| 占用调用线程,但通过事件循环高效调度,无任务时线程会短暂休眠。
| 不占用调用线程。当协程挂起时,底层线程立即被释放,可去执行其他任务。
|

|
使用场景
| 主函数、测试、集成阻塞代码与协程世界。
| 常规的异步、并发编程。
|

-

挂起:是协程的行为。一个挂起函数释放了它当前占用的线程,当结果准备好时,它会在另一个线程(由调度器决定)上恢复执行。

-

阻塞:是线程的行为。一个阻塞操作占用了线程,在操作完成之前,这个线程不能做任何其他事情。runBlocking 的“阻塞”是阻塞在它自己的事件循环上。

总结:runBlocking 的阻塞原理

1.

安装事件循环runBlocking 在当前线程上设置了一个专属于它的 **BlockingEventLoop**。

2.

运行循环至完成:它启动一个 while 循环,这个循环会持续运行,处理事件循环中的任务(恢复协程、执行定时操作等)。

3.

高效等待:循环在没有立即任务但协程未全部完成时,会通过 park 等操作让线程短暂休眠,避免CPU空转。

4.

退出条件:一旦它内部的所有协程工作完成,循环退出,runBlocking 返回,线程解除阻塞。

所以,runBlocking 的阻塞是一种有生产力的阻塞——线程虽然被“卡”在了一个循环里,但这个循环正在高效地驱动着整个协程世界的运转。这正是它不能用在已有协程环境中的原因,因为你会把一个本该用于处理大量协程的线程(如 Dispatchers.IO 中的线程)浪费在运行这一个事件循环上。

由Deepseek生成,已理解。

获取trace文件

1、获取ANR日志 adb bugreport,导出trace文件,分析trace, pid, tid

2、解压日志后/data/anr路径下根据发生时间选择对应文件

3、—– pid 17172 at 2025-07-08 15:53:02 —–

根据当前pid分析出17172进程出现问题并且记录发生时间

分析ANR

  1. 通过进程ID17172搜索定位到

|

1
"main" prio=5 tid=1 Native | group="main" sCount=1 dsCount=0 flags=1 obj=0x7261bba8 self=0x7e35a97c00 | sysTid=17472 nice=-10 cgrp=default sched=0/0 handle=0x7e37005ed0 | state=S schedstat=( 13473368664 317028758 13398 ) utm=1066 stm=281 core=5 HZ=100 | stack=0x7fdb9c2000-0x7fdb9c4000 stackSize=8192KB | held mutexes=

|

  1. 解释字段含义
    “main” prio=5 tid=1 Native
    |
1
"main":线程名称(主线程)。 prio=5:线程优先级(5 是默认优先级,范围 1~10,值越大优先级越高)。 tid=1:线程 ID(1 通常是主线程)。 Native:线程当前在执行 本地代码(JNI/NDK),而非 Java/Kotlin 代码。

|

group=”main” sCount=1 dsCount=0 flags=1 obj=0x7261bba8 self=0x7e35a97c00

|

1
group="main":线程所属组(主线程组)。 sCount=1:线程被挂起(suspend)的次数。dsCount=0:调试器挂起次数(0 表示未被调试器暂停)。 flags=1:线程状态标志(1 通常表示线程处于活跃状态)。 obj=0x7261bba8:线程关联的 Java 对象地址。 self=0x7e35a97c00:线程本身的 Native 地址。

|

sysTid=17172 nice=-10 cgrp=default sched=0/0 handle=0x7e37005ed0

|

1
sysTid=17172:系统级线程 ID(与 ps -t 命令看到的 ID 一致)。 nice=-10:线程的调度优先级(-20~19,值越小优先级越高,0 是默认值)。 cgrp=default:线程所属的 CPU 调度组(默认组)。 sched=0/0:调度策略(0 表示 SCHED_NORMAL,即普通优先级)。 handle=0x7e37005ed0:线程句柄的内存地址。

|

state=S schedstat=( 13473368664 317028758 13398 ) utm=1066 stm=281 core=5 HZ=100

|

1
state=S:线程当前状态(S 表示休眠/Sleeping,其他常见状态:R=运行中,D=不可中断休眠)。 schedstat=( 13473368664 317028758 13398 ):调度统计信息,格式为 (运行时间ns, 等待时间ns, 执行次数)。 utm=1066 stm=281:线程在用户态(utm)和内核态(stm)的累计运行时间(单位:jiffies,1 jiffy=10ms)。 core=5:线程最后运行的 CPU 核心编号。 HZ=100:系统时钟频率(100 表示每秒 100 次 tick)。

|

|
字段
| 当前值
| 正常参考值
| 问题指示
|

|
nice
| -10
| 0
| 异常提高优先级,需审查代码
|

|
schedstat
| 13.47秒运行
| <100ms
| 主线程重度占用 CPU
|

|
utm/stm
| 10.66s/2.81s
| <1s
| 存在未异步化的耗时任务
|

人际交往的基本技巧

1. 如果想采蜂蜜,就不要捅蜂窝

动物对那些它们认为是正确的行为的学习会十分迅速,而且记得更加牢固。这远比通过惩罚它们的错误行为来教育它们更为迅速有效。

人们厌恶批评就如同渴望认可一样。

批评带来的怨恨会打击员工的积极性,影响你和家人、朋友的感情。同时,你所批评的境况仍得不到改善。

己所不欲,勿施于人。

2. 每个人都渴望得到赞美

所有和我们共事的人都是普普通通的人,他们都渴望得到肯定。每个灵魂都渴望获得赞美。

普天之下,只有一个方法可以让别人做事,那就是让别人想做这件事。

每个人对赞美的渴望,正如同对美食的渴望一样强烈。

我们日常最容易忽略的一种美德就是赞赏别人。

真心地给予别人肯定,不要吝啬你的赞美。人们会为你的肯定和赞美而欢呼雀跃,并且会用心铭记你的话语,直到永远。

3. 激起别人急切的欲望

如果成功有什么秘诀的话,那就是要先去了解对方的需求,从他们的角度来思考问题。首先激起对方急切的欲望。能做到这点的人,将掌握世界;不能做到这点的人,将四处碰壁。

唯一能影响别人的方法就是跟他们谈论那些他们想要的东西,并让他们知道怎么样才能得到它。

让别人喜欢你的六种方法

1. 用热情和真诚的态度对待他人

一个人可以通过真诚地关心他人来赢得关注、时间,甚至是合作。

如果你希望别人喜欢你,如果你希望赢得真正的友情,如果你希望在帮助别人的同时帮助自己,请牢记这条原则:真诚地关心别人。

2. 微笑的价值

喜欢微笑的人,在管理、教育和推销方面都比较成功,并且教育出来的孩子也更加开朗、乐观。

笑容比起皱眉更能传情达意,鼓励比惩罚更有效。

一个人脸上的神情,要远远比她身上所穿的衣服重要得多。

微笑会让人觉得你非常友善,会让你感受到一种温暖,它表示“我喜欢你,你令我非常开心,很高兴能见到你”。

微笑是你传播善意的信使,你的笑容可以照亮每一个看到它的人。对于那些看惯了皱眉、愁容满面,或是转过脸避开别人目光的人,你的笑容就如同阳光驱散雾霾般照进他们的心里。

3. 记住他人的姓名

姓名对于一个人来说是极为特殊、极为重要的。名字把人跟人区别开,使得一个人成为世上独一无二的个体。记得一个人的姓名,在人际交往上同样重要。

记住一个人的姓名,并轻易地叫出来,对这个人来说就是一种巧妙而有效的恭维。

4. 成为一名出色的听众

千万别忘记,那个与你交谈的人,他对于自己的需求和问题,比起对你以及你的问题的感兴趣程度要超过百倍。

认真地倾听别人谈话,是我们能给对方的一种最大的恭维。很少有人能够拒绝那种带有恭维性的认真聆听。

如果你想要成为一位出色的谈论家,一个善于倾听的人,想要别人对你感兴趣,那么你首先需要对他人感兴趣,提出一些别人愿意作答的问题,鼓励对方多谈论自己,以及他们的成就。

5. 让别人觉得自己重要

生活中,存在着一个举足轻重的原则,如果我们遵从这条原则,就永远也不会遇到麻烦;要是不遵从它,就会陷入无尽的麻烦。这个原则就是:让别人感受到他们自己的重要性。

人类本性中最深层的渴望,就是试图让自己变得重要。

主要让别人觉得自己重要,那么很多人的命运就很有可能因此而改变。

几乎每个你遇到的人都会认为他在某些地方比你强,这是一条永恒不变的真理。赢得这种人心的办法就是,以一种微妙的方式让他们意识到了你了解他们的重要性,并真诚地认同他们。

三人行,必有我师焉。要会向别人学习。

走出孤独忧虑的人生

1. 让自己忙起来

让自己不停地忙着。忧虑的人一定要让自己沉浸在工作中,否则只有在绝望中挣扎。

2. 不要为小事烦恼

不要让自己因为一些应该抛弃和忘记的小事而忧虑。要记住:生命如此短暂,不要再为小事而烦恼。

3. 不要自寻烦恼

要使我们能够停止忧虑,就可以根据事情发生的概率来评估我们的忧虑究竟值不值,这样,我想我们应该可以减少99%的忧虑。

让我们看看以前的记录,让我们根据概率问问自己,现在担心会发生的事情,到底真正发生的有多少?

4. 适应不可避免的事实

不要为月亮哭泣,也不要因事而后悔。

顺应时势,就是你在踏上人生旅途时最重要的一件事。

很显然,环境本身并不能使我们快乐或不快乐,只有我们对周围环境的反应才决定了我们的感受。

在必要的时候,我们都应该经受得住灾难和悲剧,甚至要战胜它们。也许我们会认为自己办不到,但事实上,我们内在的力量却强大得惊人,只要我们愿意利用,他就能帮助我们克服一切困难。

当我们不再反抗那些不可避免的事实之后,我们就可以节省精力,创造更丰富的生活。

5. 不要做无用功

在避免忧虑方面,没有比“船到桥头自然直”和“不要为打翻的牛奶哭泣”更基本,也更有用的话。

让过去的错误产生价值的唯一方法,就是平静地分析过去的错误,并从中吸取教训,然后再忘记错误。

不要试着去锯木屑。

不要为工作和金钱而烦恼

1. 做自己喜欢的工作

一定要记住,在做你生命中最重要而且影响最深远的决定之前,请务必多花点时间了解真实的真相。如果不这么做,那么你的决定将可能让你痛苦不已。

如果人们从事他们自己无限热爱的工作,他们都可以获得成功。

避免选择那些早就很激烈并且拥挤的职业和行业;避免选择只有10%的生存机会的行业。

克服“你只适合一项职业”的错误认知。每个人都可以在多项职业上取得成功。

2. 处理好金钱引发的烦恼

人们的大多数烦恼都和金钱有关。

使大多数人烦恼的并不是他们没有足够的金钱,而是他们不知道如何支配手中已有的金钱。

结构化并发

让我们先将思路转为日常业务开发中,比如在某某业务中,可能存在好几个需要同时处理的逻辑,比如同时请求两个网络接口,同时操作两个子任务等。我们暂且称上述学术化概念为 多个并发操作。

而每个并发操作其实都是在处理一个单独的任务,这个任务中,可能还存在子任务 ; 同样对于这个子任务来说,它又是其父任务的子单元。每个任务都有自己的生命周期,子任务的生命周期会继承父任务的生命周期,比如如果父任务关闭,子任务也会被取消。而如果满足这样特性,我们就称其就是 结构化并发。

异常传递流程

默认情况下,任意一个协程发生异常时都会影响到整个协程树,而异常的传递通常是双向的,也即协程会向子协程与父协程共同传递。

整体流程如下:

  1. 先 cancel 子协程

  2. 取消自己

  3. 将异常传递给父协程

  4. (重复上述过程,直到根协程关闭)

异常传递形式

-

launch : 层层向上传递,在launch外部try-catch无效

-

async : 根协程启动或加SupervisorJob启动,会优先当前暴露,可在await()捕获;否则会将异常传递给父协程,导致异常没有在调用处暴露。

异常处理

是否是根协程启动

async + 是否是根协程启动

  • async 时内部也是新的作用域,如果 async 对应的是根协程(scope.async),那么我们可以在 await() 时直接捕获异常

  • async 时如果不是根协程启动,则会将异常传递给父协程,导致异常没有在调用处暴露。可以在async时声明SupervisorJob,此时即可在 await() 时捕获异常

launch + 是否是根或父协程自带异常处理器

  • 在launch外部try-catch无用

  • launch自带的异常处理器不会处理当前协程发生的异常,除非当前协程显式添加SupervisorJob

SupervisorJob

根协程自带的SupervisorJob,子协程会继承

非根协程的父协程声明的SupervisorJob,子协程不会继承: 子协程在 launch 时会创建新的协程作用域,其会使用默认新的 Job 替代父协程传递 SupervisorJob ,

所以导致我们传递的 SupervisorJob 被覆盖。所以如果我们想让子协程不影响父协程或者其他子协程,此时就必须再显示添加 SupervisorJob。

场景实践

严格意义上来说,所有异常都可以用 tryCatch 去处理

严格意义上来说,所有异常都可以用 tryCatch 去处理,只要我们的处理位置得当。但这并不是所有方式的最优解,特别是如果你想更优雅的处理异常时,此时就可以考虑 CoroutineExceptionHandler 。

什么时候该用 SupervisorJob ,什么时候该用 Job?

引用官方的一句话就是:想要避免取消操作在异常发生时被传播,记得使用 SupervisorJob ;反之则使用 Job。

SupervisorJob + tryCatch

我们有两个接口 A,B 需要同时请求,当接口A异常时,需要不影响B接口的正常展示,当接口B异常时,此时界面展示异常信息

SupervisorJob + CoroutineExceptionHandler

如果你有一个顶级协程,并且需要自动捕获所有的异常,则此时可以选用上述方式

参考

普通callback实现

与协程异步代码同步写法对比

callback实现代码

|

1
//CallbackImpl.kt‭​​‌‌‎‭‬​abstract class CallbackImpl(val callBack: CallBack<Any>): CallBack<Any> {‭​​‌‌‎‭‬​ ‭​​‌‌‎‭‬​    override fun onResult(result: Any?) {‭​​‌‌‎‭‬​        val cb = callBack‭​​‌‌‎‭‬​        while (true) {‭​​‌‌‎‭‬​            val outcome = invoke(result)‭​​‌‌‎‭‬​            if (outcome === CALLBACK_FLAG) {‭​​‌‌‎‭‬​                return‭​​‌‌‎‭‬​            }‭​​‌‌‎‭‬​ ‭​​‌‌‎‭‬​            cb.onResult(outcome)‭​​‌‌‎‭‬​            return‭​​‌‌‎‭‬​        }‭​​‌‌‎‭‬​    }‭​​‌‌‎‭‬​ ‭​​‌‌‎‭‬​    protected abstract fun invoke(result: Any?): Any?‭​​‌‌‎‭‬​}‭​​‌‌‎‭‬​ ‭​​‌‌‎‭‬​val CALLBACK_FLAG = CoroutineSingletons.CALLBACK‭​​‌‌‎‭‬​enum class CoroutineSingletons { CALLBACK }‭​​‌‌‎‭‬​ ‭​​‌‌‎‭‬​interface CallBack<T> {‭​​‌‌‎‭‬​    fun onResult(result: T?)‭​​‌‌‎‭‬​}‭​​‌‌‎‭‬​object UserInfoHelper {‭​​‌‌‎‭‬​    fun launch() {‭​​‌‌‎‭‬​        Log.e("MainActivity", "launch run")‭​​‌‌‎‭‬​        requestUserCallback(object : CallBack<Any> {‭​​‌‌‎‭‬​            override fun onResult(result: Any?) {‭​​‌‌‎‭‬​                Log.e("MainActivity", "launch result: $result")‭​​‌‌‎‭‬​            }‭​​‌‌‎‭‬​        })‭​​‌‌‎‭‬​    }‭​​‌‌‎‭‬​ ‭​​‌‌‎‭‬​    val handler = Handler(Looper.getMainLooper())‭​​‌‌‎‭‬​    private fun delay(timeMillis: Long, callBack: CallBack<Any>): Any {‭​​‌‌‎‭‬​        handler.postDelayed({‭​​‌‌‎‭‬​            callBack.onResult(Unit)‭​​‌‌‎‭‬​        }, timeMillis)‭​​‌‌‎‭‬​        return CALLBACK_FLAG‭​​‌‌‎‭‬​    }‭​​‌‌‎‭‬​ ‭​​‌‌‎‭‬​    //正常情况下应该保持到协程的context中,这样写为了简单处理‭​​‌‌‎‭‬​    private var userCallback:UserCallback? = null‭​​‌‌‎‭‬​    abstract class UserCallback(callBack: CallBack<Any>) : CallbackImpl(callBack) {‭​​‌‌‎‭‬​        var result: Any? = null‭​​‌‌‎‭‬​        var flag: Int = 0‭​​‌‌‎‭‬​    }‭​​‌‌‎‭‬​ ‭​​‌‌‎‭‬​    fun requestUserCallback(callBack: CallBack<Any>): Any {‭​​‌‌‎‭‬​        val cb = if (userCallback == null) {‭​​‌‌‎‭‬​            val c = object : UserCallback(callBack) {‭​​‌‌‎‭‬​                override fun invoke(result: Any?): Any? {‭​​‌‌‎‭‬​                    this.result = result‭​​‌‌‎‭‬​                    ++flag‭​​‌‌‎‭‬​                    return requestUserCallback(this)‭​​‌‌‎‭‬​                }‭​​‌‌‎‭‬​            }‭​​‌‌‎‭‬​            userCallback = c‭​​‌‌‎‭‬​            c‭​​‌‌‎‭‬​        }else {‭​​‌‌‎‭‬​            userCallback!!‭​​‌‌‎‭‬​        }‭​​‌‌‎‭‬​ ‭​​‌‌‎‭‬​        var returnResult = cb.result‭​​‌‌‎‭‬​        when (cb.flag) {‭​​‌‌‎‭‬​            0 -> {‭​​‌‌‎‭‬​                if (requestFriendListCallback(cb) == CALLBACK_FLAG) {‭​​‌‌‎‭‬​                    return CALLBACK_FLAG‭​​‌‌‎‭‬​                }‭​​‌‌‎‭‬​            }‭​​‌‌‎‭‬​ ‭​​‌‌‎‭‬​            1 -> {‭​​‌‌‎‭‬​                returnResult = cb.result‭​​‌‌‎‭‬​            }‭​​‌‌‎‭‬​        }‭​​‌‌‎‭‬​ ‭​​‌‌‎‭‬​        return "user: $returnResult"‭​​‌‌‎‭‬​    }‭​​‌‌‎‭‬​ ‭​​‌‌‎‭‬​    //正常情况下应该保持到协程的context中,这样写为了简单处理‭​​‌‌‎‭‬​    var requestFriendListCallback: RequestFriendListCallback? = null‭​​‌‌‎‭‬​    abstract class RequestFriendListCallback(callBack: CallBack<Any>) : CallbackImpl(callBack) {‭​​‌‌‎‭‬​        var result: Any? = null‭​​‌‌‎‭‬​        var flag: Int = 0‭​​‌‌‎‭‬​    }‭​​‌‌‎‭‬​ ‭​​‌‌‎‭‬​    fun requestFriendListCallback(callBack: CallBack<Any>): Any {‭​​‌‌‎‭‬​ ‭​​‌‌‎‭‬​        val cb = if ( requestFriendListCallback == null) {‭​​‌‌‎‭‬​            val c = object : RequestFriendListCallback(callBack) {‭​​‌‌‎‭‬​                override fun invoke(result: Any?): Any? {‭​​‌‌‎‭‬​                    this.result = result‭​​‌‌‎‭‬​                    ++flag‭​​‌‌‎‭‬​                    return requestFriendListCallback(this)‭​​‌‌‎‭‬​                }‭​​‌‌‎‭‬​            }‭​​‌‌‎‭‬​            requestFriendListCallback = c‭​​‌‌‎‭‬​            c‭​​‌‌‎‭‬​        } else {‭​​‌‌‎‭‬​            requestFriendListCallback!!‭​​‌‌‎‭‬​        }‭​​‌‌‎‭‬​ ‭​​‌‌‎‭‬​        when (cb.flag) {‭​​‌‌‎‭‬​            0 -> {‭​​‌‌‎‭‬​                if (delay(3000, cb) == CALLBACK_FLAG) {‭​​‌‌‎‭‬​                    return CALLBACK_FLAG‭​​‌‌‎‭‬​                }‭​​‌‌‎‭‬​            }‭​​‌‌‎‭‬​ ‭​​‌‌‎‭‬​            1 -> {‭​​‌‌‎‭‬​ ‭​​‌‌‎‭‬​            }‭​​‌‌‎‭‬​        }‭​​‌‌‎‭‬​ ‭​​‌‌‎‭‬​        return "friendList"‭​​‌‌‎‭‬​    }‭​​‌‌‎‭‬​}‭​​‌‌‎‭‬​

|

协程反编译字节码

|

1

|

0%