要跟踪追加到 Swift 字符串的文本,请存储采用指定编码的偏移量,而不是保留旧的字符串索引。本指南为一个小型本地 Mac 文本预览计算构建一个只追加的 UTF-16 片段账本。

任务很明确:拼接合成文本片段,记住每个片段的位置,并在后续追加后准确还原其内容。示例完全在内存中运行并打印结果。片段边界表示文本的组成部分,可能与显示字符之间的边界不同。

为什么保存旧的末尾索引并不可靠

一种看似方便的做法是保存 text.endIndex,追加一个片段,再将保存的索引与新的 text.endIndex 配对。这混用了修改前取得的索引和修改后的字符串。Swift 的集合文档明确警告,修改操作可能使已保存的索引失效。短示例运行成功,并不能证明后续修改时也有通用保证。请参阅 Apple 的集合文档。

即使重新构造索引,也还有第二个问题:追加文本可能改变拼接处的字符边界。Swift 语言指南以 "cafe" 后接 "\u{0301}"(一个组合尖音符)为例说明了这一点。结果文本仍包含四个 Swift Character 值,因为重音符会与前一个字母结合。请参阅字符串和字符。

因此,记录旧的字符数并在最终字符串中按该数量向前移动字符,并不能可靠地找回追加片段的起始位置。在重音符示例中,追加的内容从最终字素簇内部开始。没有单独的显示字符对应这部分内容。

应将追加边界和字符边界视为两个不同的概念。前者记录文本的拼接方式;后者描述 Swift 在字符级操作中如何对拼接后的文本分组。

账本应存储哪种坐标?

对于这个 Mac 计算,使用整数 UTF-16 偏移量和长度。Apple 文档说明,NSString 使用 UTF-16 代码单元表示字符串位置和范围。Swift 的 String.utf16 视图提供匹配的代码单元,因此账本可以使用同一坐标系进行测量和 Foundation 子串提取。请参阅NSString和String 的 UTF-16 视图。

读者任务 合适的表示形式 条件
分别还原每个原始片段 自有 String 值数组 无需组合文本的坐标
定位组合文本中的确切内容 UTF-16 偏移量和长度 之前的文本保持不变
处理显示字符 在当前字符串中获取新的字符索引 必须重新审视片段边界是否为字素边界
在任意编辑后保留位置 能跟踪编辑的位置模型 这个只追加的账本无法满足

下面的账本有四条明确规则:

  1. 它从空字符串开始。
  2. 它唯一的文本修改操作是追加完整的 Swift String。
  3. 每次追加都会记录之前的 UTF-16 总数和片段的 UTF-16 长度。
  4. 片段编号只有与生成它的账本实例或快照配合使用时才有意义。

这些规则使偏移量具有实用价值。追加后缀会保留之前的编码内容,因此早先的范围仍位于原处。插入、删除、替换或转换之前的文本则需要不同的契约。这是基于文档所述字符串表示方式推导出的设计,并非 Swift 提供的自动位置跟踪功能。

构建完整的本地片段账本

使用可访问 Foundation 的 macOS Swift 命令行目标。将其示例顶层程序替换为下面的完整程序。实现使用 append(contentsOf:),其文档所述的 Swift 签名接受另一个 String。请参阅 Apple 的append(contentsOf:) 参考。

import Foundation

struct Segment: Sendable {
    let utf16Location: Int
    let utf16Length: Int

    var foundationRange: NSRange {
        NSRange(location: utf16Location, length: utf16Length)
    }
}

enum LedgerError: Error {
    case unknownSegment(Int)
}

struct SegmentLedger: Sendable {
    private(set) var text = ""
    private(set) var segments: [Segment] = []
    private var utf16Total = 0

    @discardableResult
    mutating func append(_ fragment: String) -> Int {
        let length = fragment.utf16.count
        let (newTotal, overflow) =
            utf16Total.addingReportingOverflow(length)
        precondition(!overflow, "UTF-16 total exceeds Int capacity")

        let segment = Segment(
            utf16Location: utf16Total,
            utf16Length: length
        )

        text.append(contentsOf: fragment)
        segments.append(segment)
        utf16Total = newTotal
        return segments.count - 1
    }

    func fragment(at segmentID: Int) throws -> String {
        guard segments.indices.contains(segmentID) else {
            throw LedgerError.unknownSegment(segmentID)
        }

        let segment = segments[segmentID]
        if segment.utf16Length == 0 {
            return ""
        }

        return (text as NSString).substring(
            with: segment.foundationRange
        )
    }
}

let inputs = ["cafe", "\u{0301}", "", "\u{1F7E6}", " done"]
var ledger = SegmentLedger()

for input in inputs {
    ledger.append(input)
}

let expectedLocations = [0, 4, 5, 5, 7]
let expectedLengths = [4, 1, 0, 2, 5]

for id in inputs.indices {
    let recovered = try ledger.fragment(at: id)
    let segment = ledger.segments[id]

    // Compare code units, so the check is encoding-exact.
    precondition(Array(recovered.utf16) == Array(inputs[id].utf16))
    precondition(segment.utf16Location == expectedLocations[id])
    precondition(segment.utf16Length == expectedLengths[id])

    print("\(id): location=\(segment.utf16Location), " +
          "length=\(segment.utf16Length)")
}

precondition(ledger.text.utf16.count == 12)
precondition(ledger.text.count == 10)

// Later appends must preserve earlier segment contents.
ledger.append("!")
for id in inputs.indices {
    let recovered = try ledger.fragment(at: id)
    precondition(Array(recovered.utf16) == Array(inputs[id].utf16))
}

// Invalid segment numbers produce an explicit error.
do {
    _ = try ledger.fragment(at: -1)
    preconditionFailure("Expected an unknown-segment error")
} catch LedgerError.unknownSegment(let id) {
    precondition(id == -1)
}

print("All segment checks passed.")

我根据检索到的 API 文档检查了这段程序;此处未运行,因为可用的 shell 没有 Swift 可执行文件。程序中的前置条件明确说明了你在 Mac 上运行时预期的行为。

示例旨在检查的内容

预期片段坐标根据字面输入计算。组合重音符占一个 UTF-16 代码单元,空片段占零个,蓝色方块占两个。最后的后缀占五个。这些是输入示例的计算结果,不是性能测量。

片段 输入 UTF-16 位置 UTF-16 长度
0 "cafe" 0 4
1 "\u{0301}" 4 1
2 "" 5 0
3 "\u{1F7E6}" 5 2
4 " done" 7 5

空片段有自己的片段编号,尽管它与后续片段位于同一位置。这样既保留了追加历史,也不会凭空添加文本。明确返回 "" 也避免了在此情况下调用 Foundation 子串方法。

恢复检查刻意比较 UTF-16 数组。Swift 字符串相等性会识别规范等价,这意味着某些底层标量序列不同的字符串仍会被判定为相等。对于此账本的契约,编码级精确断言更合适。请参阅字符串和字符中关于字符串比较的说明。

为什么提取操作使用 Foundation

存储的整数会转换为 NSRange,并直接传给 NSString.substring(with:)。Apple 文档说明,此方法会提取指定范围,且要求该范围位于接收对象的边界内。无效范围可能导致异常。账本同时控制文本和范围,并在提取前拒绝未知的片段编号。请参阅substring(with:)。

每个记录的边界都来自完整的 Swift 字符串追加。示例绝不会人为构造一个位于蓝色方块 UTF-16 代理对中间的偏移量。边界仍可能落在组合字符序列内部,正如重音符示例所示;这种表示方式的目的正是准确还原各部分内容。

不要使用 String.count 构造这些 Foundation 范围。Apple 将 NSString.length 定义为 UTF-16 代码单元数量;它与字符数的度量方式不同,也不表示渲染宽度。请参阅NSString.length。

将修改、片段创建和快照放在一起管理

对于小型同步计算,这个值类型就足够了:一个所有者负责追加片段并读取结果。它的私有 setter 可防止调用方在不更新片段表的情况下独立替换组合文本。缓存的 UTF-16 总数也避免了每次追加时都重新计算不断增长的组合字符串。

如果多个异步任务都要向一个账本写入内容,请将该账本放在单个 actor 后面;如果它属于 Mac 界面,也可以让它由 @MainActor 管理。在一个同步隔离方法中完成追加和元数据更新。Swift 文档说明,在该方法内,同步的 actor 隔离工作不会在某个挂起点被中断。请参阅并发。

将文本和片段元数据作为一个值快照一起返回。避免分别等待文本读取和片段表读取:两次调用之间可能发生另一次追加。示例中的 Sendable 声明表示这些值适合跨越隔离边界;它们不会串行化对共享变量的并发写入。Swift 的Sendable 文档说明了可传递值与受保护可变状态之间的区别。

如果内容顺序很重要,请在追加之前确定顺序。actor 会保护账本内部的一致性,但应用仍需决定哪个片段排在前面。

了解这些偏移量何时不再适用

重用范围前,请按以下顺序检查:

  1. 检查所有权。 片段编号是否属于此账本或其兼容的只追加后代?
  2. 检查修改历史。 记录的范围之前是否有内容发生变化?如果有,请重建账本或采用能跟踪编辑的模型。
  3. 检查单位。 使用方的坐标是否为 UTF-16 代码单元?不要用字符数或 UTF-8 字节数替代。
  4. 检查目标结果。 你要还原准确追加的内容,还是选择一个完整的显示字符?两者可能需要不同的边界。

按字符选择的方式并不总能只表示某个追加内容。重音符与前一个字母属于同一个显示字符。如果桌面任务是高亮显示,请明确选择一种呈现规则:将选区扩展到包含它的字符,或在片段列表中单独显示该内容。扩展会包含来自较早片段的文本,因此请保留原始偏移量以便准确还原。

要精确跟踪追加内容,关键不变量很简单:保持前缀不变,明确坐标单位,并将每个范围与其文本一起保存。在此契约下,账本可以在后续追加后还原较早的内容,而无需在修改期间保留一个 String.Index。

来源