云端 Mac 上的 iOS UTF-8 流式解码验收

云端 Mac 上的 iOS UTF-8 流式解码验收

同一份响应,本地读取正常,放到云端 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 配置。按天、周、月、季计费,可用性以控制台实时返回为准。

租用构建节点

按构建需求选配置

先确认内存与存储,再选择计费周期和节点。