连接只管「通不通」,流才管「搬什么」。 createStream 发一条带事务号的请求,服务器回 _result 并在末尾带 streamId,通常是 1。
4.2.2 建好了连接,这一节在连接上开出一条流。 4.2.4 就是在这条流上发 play 或 publish。
一、连接与流是两个层次
NetConnection 与 NetStream 是两个对象,职责分得很清: ① 连接:负责握手、鉴权、app 路由。 它的状态只有「连上」与「断开」。 ② 流:负责一路具体的音视频。 一条连接可以开多条流,每条流有自己的 streamId。
为什么要分两层? 因为一个主播可能同时推「摄像头 + 桌面 + 麦克风」三路,它们共用一条连接(省掉重复握手与鉴权),但各自是独立的流。 这也让「一条连接里同时拉两路流」成为可能。
实测一条完整的推流会话,命令顺序是: connect → releaseStream → FCPublish → createStream → publish。 五条里真正必须的只有 connect 与 createStream → publish。
二、createStream 的请求与响应
请求极简:createStream + 事务号 + 一个空值。
实测 25 字节。
响应是 _result + 同一个事务号 + 空值 + streamId。
实测 29 字节。
那个 streamId 就是「以后所有消息里填的流号」。
编号规则: ① streamId 0 留给「连接本身」:所有控制消息都填 0。 ② 第一条流从 1 开始,之后每条加一。 ③ 流关掉之后编号不复用,避免旧包被误认成新流。
所以抓包时看到「流 ID 0」的消息,一定是控制类(设置块大小、确认、带宽);看到「流 ID 1」才是真正的音视频。 这个判据比记消息类型号快得多。
三、推流前那两条「遗留命令」
releaseStream 与 FCPublish 不在标准里,是 Flash 时代的产物。 但几乎所有服务器都实现了,不发就可能出问题。
它们各自的作用: ① releaseStream:通知服务器「把这个流名上的旧会话踢掉」。 同名流重推时,不发它就报「流已被占用」。 ② FCPublish:推流预检,服务器据此决定「允不允许你推」。 有的平台把鉴权放在这一步。
这两条最容易造成的误解是:报错说「占用」,人就以为是别人在推,其实是自己上一次没释放干净。 长时间推流断开重连必踩,所以它们虽然是遗留,还是得发。
四、streamId 用错会怎样
streamId 是所有音视频消息必须带的字段,填错不会报错,只会「没反应」。
三种典型表现: ① 填 0:消息被当成控制类,服务器忽略,观众看不到画面。 ② 填一个没创建过的号:服务器找不到对应流,直接丢弃。 抓包能看到包发出去了,但服务端统计里没有。 ③ 两条流混用同一个号:数据会串到一起,表现是画面里突然插进另一路的内容。
这三个错误都不报错,所以排查只能靠抓包。 判断方法:看服务端有没有「收到流」的日志,再看消息里的流号是不是 1。
五、动手 Demo:createStream 与 _result
编一条 createStream 请求与它的响应,把 streamId 解出来。 顺手看清「流 0 是连接、流 1 是第一路」的分界。
import struct
def amf_str(s):
b = s.encode("utf-8")
return b"\x02" + struct.pack("!H", len(b)) + b
def amf_num(v):
return b"\x00" + struct.pack("!d", float(v))
# ① 请求:createStream + 事务号 4 + 空值
req = amf_str("createStream") + amf_num(4) + b"\x05"
print("请求 %d 字节" % len(req))
# ② 响应:_result + 事务号 4 + 空值 + streamId 1
res = amf_str("_result") + amf_num(4) + b"\x05" + amf_num(1)
print("响应 %d 字节" % len(res))
# ③ 解出 streamId:跳过 _result 与两个 9 字节的数字
off = 1 + 2 + len("_result") # 类型标记 + 长度 + 内容
off += 9 # 事务号
off += 1 # 空值
sid = struct.unpack("!d", res[off + 1:off + 9])[0]
print("streamId = %.0f" % sid)
# ④ 流号与用途的约定
print("流 ID 0 → 协议控制/用户控制消息(不承载音视频)")
print("流 ID 1 → 第一路音视频(推流或拉流)")
实跑输出:
--------------------------------------------------------------------
建立流——从连接里分出一条流
--------------------------------------------------------------------
**① 一条连接为什么要有多条流**
**RTMP 的连接(NetConnection)只负责「通不通」;**
**真正搬数据的是流(NetStream)。**
**一条连接可以开多条流:一路摄像头、一路桌面、一路麦克风。**
**② 真编 createStream 与它的响应**
请求 25 字节,事务号 4
响应 29 字节
解析:命令 _result,事务号 4,参数 None,streamId = 1
**streamId 从 1 开始编号,0 留给「连接本身」。**
**所以后面所有消息里的流 ID 字段,0 表示控制、1 以上表示具体流。**
**③ 推流前还要多做两步**
步骤 命令 作用
-------------------------------------------------------------
释放旧流 releaseStream 同名流正在推时先踢掉
发布预检 FCPublish 通知服务器「我要推这个流」
正式发布 publish 带流名与推流类型
**releaseStream 与 FCPublish 是 Flash 时代的遗留,标准里没有。**
**但几乎所有服务器都实现了它们。**
**不发的后果是「同一个流名重推时会失败」,报的是「流已被占用」。**
**实测一条完整推流会话的命令顺序是:**
**connect → releaseStream → FCPublish → createStream → publish。**
**五条里有三条是为了兼容老客户端,真正必须的只有 connect 与 publish。**
请求 25 字节、响应 29 字节——差的那 4 字节就是 streamId 那个双精度的一部分。 解析时关键是跳过两个固定长度的段:事务号 9 字节、空值 1 字节,然后才是 streamId。 流号 0 与 1 的分界是后面所有分析的基础:看到 0 就当控制消息处理,看到 1 才去解音视频。
30 秒实验
- 把事务号改成 9,看响应里的事务号该不该跟着变——必须变,否则客户端配不上对,会把响应丢掉。
- 把响应里的 streamId 改成 2,再看「流 ID 0/1」的判据——判据要改成「0 是控制,非 0 是流」,不能写死 1。
- 连发两次 createStream,看两个 streamId 是不是 1 和 2——这就是「一条连接多条流」的编号方式。
常见误区
- 「connect 成功就能推流」——还要 createStream 拿到流号。
- 「streamId 可以从 0 开始」——0 是连接本身,流从 1 开始。
- 「releaseStream 是废弃命令,可以不发」——同名流重推时会报「占用」,实际还得发。
自测三问(点开对答案)
1. 连接与流的分工是什么? —— **连接负责通不通与鉴权,流负责搬一路音视频。**一条连接可开多条流。 2. streamId 0 代表什么? —— **连接本身。**控制类消息的流 ID 字段都填 0。 3. 为什么重推同名流会报占用? —— **上一次的会话没释放。**发一条 releaseStream 就能踢掉。- ☐ (本节完,下一节 4.2.4 RTMP播放)