要跟踪追加到 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 偏移量和长度 | 之前的文本保持不变 |
| 处理显示字符 | 在当前字符串中获取新的字符索引 | 必须重新审视片段边界是否为字素边界 |
| 在任意编辑后保留位置 | 能跟踪编辑的位置模型 | 这个只追加的账本无法满足 |
下面的账本有四条明确规则:
- 它从空字符串开始。
- 它唯一的文本修改操作是追加完整的 Swift
String。 - 每次追加都会记录之前的 UTF-16 总数和片段的 UTF-16 长度。
- 片段编号只有与生成它的账本实例或快照配合使用时才有意义。
这些规则使偏移量具有实用价值。追加后缀会保留之前的编码内容,因此早先的范围仍位于原处。插入、删除、替换或转换之前的文本则需要不同的契约。这是基于文档所述字符串表示方式推导出的设计,并非 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 会保护账本内部的一致性,但应用仍需决定哪个片段排在前面。
了解这些偏移量何时不再适用
重用范围前,请按以下顺序检查:
- 检查所有权。 片段编号是否属于此账本或其兼容的只追加后代?
- 检查修改历史。 记录的范围之前是否有内容发生变化?如果有,请重建账本或采用能跟踪编辑的模型。
- 检查单位。 使用方的坐标是否为 UTF-16 代码单元?不要用字符数或 UTF-8 字节数替代。
- 检查目标结果。 你要还原准确追加的内容,还是选择一个完整的显示字符?两者可能需要不同的边界。
按字符选择的方式并不总能只表示某个追加内容。重音符与前一个字母属于同一个显示字符。如果桌面任务是高亮显示,请明确选择一种呈现规则:将选区扩展到包含它的字符,或在片段列表中单独显示该内容。扩展会包含来自较早片段的文本,因此请保留原始偏移量以便准确还原。
要精确跟踪追加内容,关键不变量很简单:保持前缀不变,明确坐标单位,并将每个范围与其文本一起保存。在此契约下,账本可以在后续追加后还原较早的内容,而无需在修改期间保留一个 String.Index。
来源
Collection — Apple。
Strings and Characters — Swift.org。
String — Apple。
NSString — Apple。
append(contentsOf:) — Apple。
substring(with:) — Apple。
length — Apple。
Concurrency — Swift.org。
Sendable — Swift.org。