11 KiB
需求综述
录像由数万条录像步组成,每条录像步包含可变数量的参数,参数类型涵盖数字、字符串、布尔值和 bigint。直接操作 ArrayBuffer 的字节布局会导致系统耦合存储格式细节、修改录像困难。需要一个独立工具封装底层存储的读写,并提供插入、删除、修改等编辑能力。
需求理解
明确需求
- 变长指令编码。指令码使用
uint8或uint16,宽度取决于已注册命令的最大 ID。 - 自描述参数格式。参数使用类型标识 + 变长内容的编码,每条录像步的参数数量可变。类型标识同时承担短字符串的长度信息,节省额外字节。
- 录像可编辑。支持在任意有效位置插入、删除、修改录像步——不是只追加的日志。
- 顺序流式读取。播放是按顺序逐帧进行的,需要流式读取器避免每次随机访问的 O(n) 偏移扫描。
隐含需求
- 容量管理。
ArrayBuffer不可动态扩容,写入前需检查容量,不足时分配新缓冲并复制。 - 偏移累积。指令数组中的每条录像步长度不固定,随机访问某一条时需从头扫描累积偏移。流式读取器可以利用顺序访问特性只扫描一次。
设计前提
- 双缓冲存储。指令数组和参数数组各自使用独立的
ArrayBuffer,通过DataView读写。 - 不可变元数据。
IReplayMetadata中的格式参数在初始化时确定,读取时不修改。 - 边解码边播放。不一次性解码全部数据——沙箱通过流式读取器按需解码。
核心概念定义
指令数组
录像中所有指令码与参数数量的序列化存储。每条录像步在指令数组中占用 1+W 字节(W 为指令码宽度):第一个 uint8 为参数数量,后续 uint8 或 uint16 为指令码。
参数数组
录像中所有参数内容的序列化存储。每个参数由一个 uint8 类型标识开头,后接对应类型的二进制内容。类型标识同时编码短字符串的长度(8-256 → 长度 n-7)。
流式读取器
持有一个内部偏移指针,每次调用 read 解码并返回当前指针指向的录像步,然后推进指针。由于指令数组中每条录像步的总字节数不固定,指针以步索引而非字节偏移为单位——读取器内部维护已扫描过的累积字节偏移,每次 read 仅解码一步。
指令宽度
指令码的存储字节数。初始为 uint8(1 字节)。命令 ID 上限在 registerCommand 注册时即可获知——若注册了 > 255 的 ID,直接初始化为 uint16,不涉及运行时重编码。若需要变更已存在录像的指令宽度(例如切换为更大的指令码空间以兼容新增命令,或压缩为更小的空间以节省体积),则通过 setCommandWidth 方法将全部指令条目按新宽度重编码。
接口设计分析
IReplayArray
设计思路
由于录像需要支持任意有效位置的插入和删除——比如玩家在已录制的操作序列中间补充了额外操作,或者修复了某段错误的录像——只提供追加写入是不够的。因此需要 insert 和 delete 方法,以及在原位置修改的 set 方法。追加仍然是最频繁的操作——因此需要 add 方法。
由于沙箱播放是顺序推进的,每次调用 get(index) 都需要从头扫描积累偏移——因此设计 createReadStream 创建流式读取器。流式读取器内部维护步索引指针和已累积字节偏移,每次 read 仅解码一步,将偏移计算从 O(n²) 降为 O(n)。同时保留 get 用于跳转播放等非顺序场景。
由于命令 ID 上限在注册阶段已知,指令宽度在 IReplayArray 构造时即确定——若最大 ID > 255 则初始化为 uint16。但已存在的录像可能需要变更宽度——例如加载旧 uint8 录像后注册了 > 255 的新命令需要升级,或者想将 uint16 录像压缩回 uint8 以节省存储。因此需要 setCommandWidth 方法将全部指令条目按新宽度重编码。
接口分析
- 成员
length: number:预期频率中频。安全系统检测步数变化、沙箱判断播放是否结束。 - 成员
metadata: IReplayMetadata:预期频率低频。存档时读取以持久化格式参数。 - 方法
add(id: number, params: ReplayParamValue[]):预期频率高频。每次record调用一次。 - 方法
insert(index: number, id: number, params: ReplayParamValue[]):预期频率低频。index等于length时等同于add;index超出length时触发警告并忽略。 - 方法
delete(index: number):预期频率低频。 - 方法
set(index: number, id: number, params: ReplayParamValue[]):预期频率低频。 - 方法
get(index: number): IReplayStepHandler:预期频率低频。跳转播放时使用。 - 方法
createReadStream(startIndex?: number): IReplayReadStream:预期频率中频。沙箱播放时调用。 - 方法
setCommandWidth(width: ReplayCommandWidth):预期频率低频。变更已存在录像的指令码宽度,将全部指令重编码。
预期体量
预期代码体量 300-360 行。
- 参数编码预期 100 行。七种参数类型各自需要独立的写入逻辑:布尔值为类型 0 接 1 字节;int8/int16/int32/int64/float64 各为一个单字节类型标识(1-5)接对应宽度的定长写入;bigint 需类型 6 接
uint16长度再接 n 个int32;短字符串将长度编码进类型字节(8-256,长度 = n-7),TextEncoder编码后直接写入内容;长字符串用类型 7 接uint16长度再写内容。每种类型还需容量检查与扩容,七种类型的分支代码量相当可观。 - 参数解码预期 80 行。与编码对称的七种读取分支,根据类型字节分发。数值类型按宽度读,bigint 读长度后拼接 int32,短字符串从类型字节反推长度后
TextDecoder解码,长字符串先读长度。每条分支均含边界检查。 - 指令读写预期 60 行。写入时根据当前
commandWidth决定写入 1 还是 2 字节,读取时同理。每条录像步的指令部分 =uint8参数数量 + W 字节指令码。get和createReadStream均需维护字节偏移——每次读取或跳过一条录像步时,根据上一条的参数数量和参数总字节长度计算下一条的起始偏移。 - 插入与删除预期 60 行。
insert需将指定位置及之后的所有指令和参数数据向后移动以腾出空间,再在空位写入新步。delete则反向——将之后的数据向前移动覆盖被删除步。参数数组的移动量取决于被操作步的参数总字节数,需先解码获取。set本质是先delete后insert。 - 容量管理与复制预期 30 行。两个
ArrayBuffer各自独立管理容量,写入前检查,不足时分配更大新缓冲(如 2 倍增长),逐字节复制旧数据后再继续写入。 - 流式读取器预期 25 行。内部维护步索引、指令数组字节偏移、参数数组字节偏移。
read解码当前步并推进三个变量,返回IReplayStepHandler,超出length时返回 null。构造时可选startIndex,需扫描至指定位置。 - 指令宽度变更预期 25 行。
setCommandWidth遍历全部已写指令条目——解析每条录像步的参数数量和原宽度的指令码——按新宽度重新写入指令数组。commandWidth可能变大也可能变小,编码前需根据新宽度判断每条指令码是否在范围内(例如uint8模式下指令 ID > 255 应报错)。
IReplayReadStream
设计思路
由于沙箱播放是顺序推进的,流式读取器持有当前步索引指针及已累积的字节偏移,每次调用 read 仅解码一步且指针自动推进——避免了 get(n) 每次 O(n) 扫描的累积开销。读取完毕后返回 null,调用方可判断播放结束。
接口分析
- 成员
index: number:预期频率低频。显示当前播放进度。 - 成员
length: number:预期频率低频。用于判断是否已读取完毕。 - 方法
read(): IReplayStepHandler | null:预期频率高频。沙箱每步调用一次。
预期体量
预期代码体量 25 行。
IReplayMetadata
设计思路
指令码宽度在读取时必须知晓才能正确解析每条录像步。uint8 和 uint16 是不同的编码模式,不是一个可变参数——因此用枚举 ReplayCommandWidth 标记,存档时持久化此枚举值。
接口分析
- 成员
commandWidth: ReplayCommandWidth:预期频率低频。初始化时根据最大命令 ID 设定,指令读写时使用。
预期体量
预期代码体量 5 行(含枚举定义)。
实现思路
参数编码格式
| 类型标识 | 参数类型 | 内容编码 |
|---|---|---|
| 0 | 布尔值 | 1 字节:0 或 1 |
| 1 | int8 | 1 字节 |
| 2 | int16 | 2 字节 |
| 3 | int32 | 4 字节 |
| 4 | int64 | 8 字节 |
| 5 | float64 | 8 字节 |
| 6 | bigint | uint16 长度 N,后接 N 个 int32 |
| 7 | 长字符串(≥250 字节) | uint16 长度 N,后接 TextEncoder 编码的 N 字节 |
| 8-256 | 短字符串(n-7 字节) | TextEncoder 编码的 n-7 字节,无额外长度字段 |
短字符串方案:类型标识本身编码了长度(8 表示 1 字节,256 表示 249 字节),TextEncoder 编码后直接写内容。大多数录像参数(道具 ID 名、操作描述等)落在此范围内,基本无需额外长度字段。
指令宽度确定
IReplayArray构造时传入最大命令 ID(由IReplaySystem.registerCommand的累积结果提供)。若 maxId > 255,commandWidth初始化为Uint16;否则为Uint8。- 注册阶段即确定宽度,后续
add/insert不涉及宽度切换。 setCommandWidth用于变更已存在录像的指令宽度:遍历全部指令条目,将原宽度的指令码按新宽度重写,更新metadata。若缩小宽度时存在超过新宽度表示范围的指令 ID,应报错。
流式读取器内部状态
read() 每步仅解码当前指针指向的录像步:
- 从指令数组当前字节偏移读
uint8参数数量,再按commandWidth读指令码。 - 根据参数数量,从参数数组当前字节偏移逐参数解码。
- 将指令数组字节偏移增加
1 + commandWidth,参数数组字节偏移增加已解码参数的总字节数。 - 返回
IReplayStepHandler,index为当前步索引。指针推进到下一步。
涉及文件
@user/data-common/src/replay/array.ts
- 新增
ReplayCommandWidth枚举 - 编写
IReplayArray、IReplayReadStream、IReplayMetadata接口 - 编写
ReplayArray类:实现IReplayArray,封装双ArrayBuffer操作、指令宽度管理与变更、CRUD 编辑、流式读取 - 编写
ReplayReadStream类:实现IReplayReadStream