同一份响应,本地读取正常,放到云端 Mac 的流水线里却偶尔出现乱码,先检查解码边界。网络交付的是字节块,不是完整字符;“中”的三个字节可能分三次到达。逐块调用字符串转换,即使每次都不崩溃,也可能把合法文本变成替换字符。下面用一个独立 Swift 脚本固定这项契约,不依赖真实网络的分块运气。
先定义字节流契约
输入限定为 UTF-8,输出只能包含已完整解码的 Unicode 标量。空块不改变状态,未完成序列留到下一块,结束时仍有残留则失败。不要把“当前没有文字输出”当作连接关闭。
这里选择严格拒绝非法输入:孤立续字节、过长编码、代理区编码和超过 Unicode 上限的序列都不能通过。替换乱码适合某些展示场景,但不能悄悄用于协议字段、配置或持久化记录。
网络块边界、字符边界和业务消息边界是三件事。UTF-8 解码成功,不代表已经收到完整的一行或一条消息。
若协议声明其他编码,应在入口拒绝或交给明确的转换层,不能靠反复尝试编码来猜。本文也不自动剥离字节顺序标记,由上层决定是否允许。
实现有界的增量状态
将下面代码保存为 utf8-contract.swift。它只保留当前未完成字符,跨调用残留最多三个字节;每次调用的输出量仍与输入块大小相关。错误发生后丢弃实例,不再继续喂数据。
import Foundation
enum DecodeFailure: Error {
case invalidByte
case truncated
}
struct StrictUTF8 {
private var pending: [UInt8] = []
private var expected = 0
mutating func push(_ bytes: [UInt8]) throws -> String {
var output: [UInt8] = []
for byte in bytes {
if pending.isEmpty {
switch byte {
case 0x00...0x7F:
output.append(byte)
continue
case 0xC2...0xDF:
expected = 2
case 0xE0...0xEF:
expected = 3
case 0xF0...0xF4:
expected = 4
default:
throw DecodeFailure.invalidByte
}
pending.append(byte)
continue
}
var allowed: ClosedRange<UInt8> = 0x80...0xBF
if pending.count == 1 {
switch pending[0] {
case 0xE0: allowed = 0xA0...0xBF
case 0xED: allowed = 0x80...0x9F
case 0xF0: allowed = 0x90...0xBF
case 0xF4: allowed = 0x80...0x8F
default: break
}
}
guard allowed.contains(byte) else {
throw DecodeFailure.invalidByte
}
pending.append(byte)
if pending.count == expected {
output.append(contentsOf: pending)
pending.removeAll(keepingCapacity: true)
expected = 0
}
}
guard let text = String(bytes: output, encoding: .utf8) else {
throw DecodeFailure.invalidByte
}
return text
}
func finish() throws {
guard pending.isEmpty else {
throw DecodeFailure.truncated
}
}
}
首字节确定长度,第二字节收紧范围,后续字节再检查续字节区间。这样无需等到字符串转换阶段才发现代理区或越界编码。实现没有自动恢复逻辑,也不会把非法字节后的 ASCII 当作一条可信新消息。
穷举分块而不是重复请求
继续追加以下测试。对这段短样本,枚举相邻字节之间所有“切或不切”的组合,比只检查一次两段切分更完整;另外插入空块,验证它不会清掉残留状态。
let sample = "A中🙂e\u{301}"
let bytes = Array(sample.utf8)
for mask in 0..<(1 << (bytes.count - 1)) {
var decoder = StrictUTF8()
var chunk: [UInt8] = []
var result = ""
for index in bytes.indices {
chunk.append(bytes[index])
let cut = index == bytes.count - 1
|| (mask & (1 << index)) != 0
if cut {
result += try decoder.push(chunk)
result += try decoder.push([])
chunk.removeAll(keepingCapacity: true)
}
}
try decoder.finish()
precondition(Array(result.utf8) == bytes)
}
这里按 UTF-8 字节比较,避免规范等价的字符串比较掩盖内容变化。组合重音也提醒我们:完整标量不一定是完整的用户可见字符。解码器可以先输出字母,再输出组合标记;界面若需要按字形操作,应在更上层处理。
穷举数量随样本长度指数增长,只用于短固定向量。长文本另用固定随机种子产生切分,并把失败时的块长度序列保存下来。
把拒绝路径也变成断言
合法文本通过只证明了一半。追加这些向量,分别覆盖孤立续字节、过长编码、代理区、超出上限、错误续字节以及结尾截断。每条输入都按单字节推进,确保错误不能借助分块绕过。
let invalid: [[UInt8]] = [
[0x80],
[0xC0, 0xAF],
[0xED, 0xA0, 0x80],
[0xF4, 0x90, 0x80, 0x80],
[0xE4, 0x41],
[0xF0, 0x9F, 0x99]
]
for input in invalid {
var decoder = StrictUTF8()
var rejected = false
do {
for byte in input {
_ = try decoder.push([byte])
}
try decoder.finish()
} catch {
rejected = true
}
precondition(rejected)
}
还应补充合法范围边界,如最高合法标量对应的四字节序列。不要把字符串里是否出现替换字符作为唯一判据,因为输入本来就可能合法地包含该字符。严格解码必须根据原始字节作判断。
接入流水线并保留错误边界
在选定的 Xcode 命令行工具环境中运行:
xcrun swift utf8-contract.swift
正常结束即通过,抛错或断言失败必须阻断后续任务。正式集成时,把解码器放进共享源码,将向量迁入测试 target;独立脚本适合先验证算法,不能代替实际 iOS target 的编译与执行。
每条连接拥有自己的实例,并按接收顺序串行调用。重连创建新实例,不能把旧连接的半个字符拼到新响应前面。取消操作也应丢弃状态,不调用“成功结束”路径。
最后检查消费侧:逐块输出只能进入临时缓冲;一条业务消息完成且校验通过后,才提交持久化或触发动作。若前半段已经落库,后半段才发现非法编码,严格解码也无法自动撤销副作用。错误记录保留字节偏移、块长度和错误类别即可,避免默认上传完整响应内容。这样验收的就不只是“没有乱码”,而是分块变化不会改变结果,错误也不会被当成成功交付。
常见问题
每次收到 Data 后直接转成字符串是否可靠?
不可靠。网络块不保证落在字符边界,严格转换可能失败,容错转换可能插入替换字符。应保留尚未完整的字节序列,到后续块到达后再解码。
流结束时剩余一两个字节能否忽略?
不能。对完整 UTF-8 文本而言,这表示字符被截断,应返回失败。若协议允许恢复,应单独定义恢复策略,而不是把截断当作成功。
让整台 Mac 只跑团队的构建
选择 Apple Silicon M4 独享物理 Mac mini,保留团队自己的工具链与 runner 配置。按天、周、月、季计费,可用性以控制台实时返回为准。
租用构建节点