Post

iOS 18 NSAttributedString attributes Range 合并行为改变|Equatable 影响解析与闪退问题排除

iOS 18 起 NSAttributedString attributes 合并机制改为参考 Equatable,导致自订属性 Range 合并行为异动,造成 HTML 解析闪退。深入剖析底层合并逻辑与 Swift Equatable 实作,快速掌握修正关键,避免闪退并优化字串属性管理。

iOS 18 NSAttributedString attributes Range 合并行为改变|Equatable 影响解析与闪退问题排除

Click here to view the English version of this article.

點擊這裡查看本文章正體中文版本。

基于 SEO 考量,本文标题与描述经 AI 调整,原始版本请参考内文。


iOS ≥ 18 NSAttributedString attributes Range 合并的一个行为改变

iOS ≥ 18 开始 NSAttributedString attributes Range 合并会参考 Equatable

Photo by [C M](https://unsplash.com/@ubahnverleih?utm_content=creditCopyText&utm_medium=referral&utm_source=unsplash){:target="_blank"}

Photo by C M

问题起因

iOS 18 2024/9/17 上线后,之前做的开源专案 ZMarkupParser 就有开发者回报 iOS 18 在解析部分 HTML 时会发生闪退。

看到这个 Issue 有点困惑,因为程式在以前都没问题,iOS 18 开始才会闪退,不符合常理,应该是 iOS 18 底层 Foundation 有什么调整导致。

Crash Trace

Trace Code 后定位到闪退问题点是遍历 .breaklinePlaceholder Attributes 并针对 Range 进行删除操作时会发生闪退:

1
2
3
4
5
6
mutableAttributedString.enumerateAttribute(.breaklinePlaceholder, in: NSMakeRange(0, NSMakeRange(0, mutableAttributedString.string.utf16.count))) { value, range, _ in
  // ...if condition...
  // mutableAttributedString.deleteCharacters(in: preRange)
  // ...if condition...
  // mutableAttributedString.deleteCharacters(in: range)
}

.breaklinePlaceholder 是我自行扩充的一个 NSAttributedString.Key,用来标记 HTML 标签资讯,优化换行符号使用:

1
2
3
4
5
6
7
8
9
10
11
struct BreaklinePlaceholder: OptionSet {
    let rawValue: Int

    static let tagBoundaryPrefix = BreaklinePlaceholder(rawValue: 1)
    static let tagBoundarySuffix = BreaklinePlaceholder(rawValue: 2)
    static let breaklineTag = BreaklinePlaceholder(rawValue: 3)
}

extension NSAttributedString.Key {
    static let breaklinePlaceholder: NSAttributedString.Key = .init("breaklinePlaceholder")
}

但核心问题不是这里 ,因为在 iOS 17 以前,输入的 mutableAttributedString 在执行以上操作时不会有问题;代表输入的资料内容在 iOS 18 有所变动。

NSAttributedString attributes: [NSAttributedString.Key: Any?]

在深入挖掘问题之前先介绍一下 NSAttributedString attributes 的 合并机制

NSAttributedString attributes 会 自动比较 .key 相同的相邻 Range Attributes 物件是否相同,相同则合并成同个 Attribute 例如:

1
2
3
4
5
let mutableAttributedString = NSMutableAttributedString(string: "", attributes: nil)
mutableAttributedString.append(NSAttributedString(string: "<div>", attributes: [.font: UIFont.systemFont(ofSize: 14)]))
mutableAttributedString.append(NSAttributedString(string: "<div>", attributes: [.font: UIFont.systemFont(ofSize: 14)]))
mutableAttributedString.append(NSAttributedString(string: "<p>", attributes: [.font: UIFont.systemFont(ofSize: 14)]))
mutableAttributedString.append(NSAttributedString(string: "Test", attributes: [.font: UIFont.systemFont(ofSize: 12)]))

最终 Attributes 合并结果:

1
2
3
4
5
<div><div><p>{
    NSFont = "<UICTFont: 0x101d13400> font-family: \".SFUI-Regular\"; font-weight: normal; font-style: normal; font-size: 14.00pt";
}Test{
    NSFont = "<UICTFont: 0x101d13860> font-family: \".SFUI-Regular\"; font-weight: normal; font-style: normal; font-size: 12.00pt";
}

enumerateAttribute(.breaklinePlaceholder...) 时会得到以下结果:

1
2
NSRange {0, 13}: <UICTFont: 0x101d13400> font-family: ".SFUI-Regular"; font-weight: normal; font-style: normal; font-size: 14.00pt
NSRange {13, 4}: <UICTFont: 0x101d13860> font-family: ".SFUI-Regular"; font-weight: normal; font-style: normal; font-size: 12.00pt

NSAttributedString attributes 合并 — 底层实践方式推测

推测底层是使用 Set<Hashable> 做为 Attributes 容器,会自动排除相同的 Attriubte 物件。

但是为了使用方便, NSAttributedString attributes: [NSAttributedString.Key: Any?] Value 物件是宣告成 Any? Type,没有限制 Hashable。

也因此推测系统在底层会在 Conform as? Hashable 然后使用 Set 合并管理物件。

这次的 iOS ≥ 18 调整差异推测就是这边底层的实现问题。

以下是以我们自订的 .breaklinePlaceholder Attributes 为例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
struct BreaklinePlaceholder: Equatable {
    let rawValue: Int

    static let tagBoundaryPrefix = BreaklinePlaceholder(rawValue: 1)
    static let tagBoundarySuffix = BreaklinePlaceholder(rawValue: 2)
    static let breaklineTag = BreaklinePlaceholder(rawValue: 3)
}

extension NSAttributedString.Key {
    static let breaklinePlaceholder: NSAttributedString.Key = .init("breaklinePlaceholder")
}

//

let mutableAttributedString = NSMutableAttributedString(string: "", attributes: nil)
mutableAttributedString.append(NSAttributedString(string: "<div>", attributes: [.breaklinePlaceholder: NSAttributedString.Key.BreaklinePlaceholder.tagBoundaryPrefix]))
mutableAttributedString.append(NSAttributedString(string: "<div>", attributes: [.breaklinePlaceholder: NSAttributedString.Key.BreaklinePlaceholder.tagBoundaryPrefix]))
mutableAttributedString.append(NSAttributedString(string: "<p>", attributes: [.breaklinePlaceholder: NSAttributedString.Key.BreaklinePlaceholder.tagBoundaryPrefix]))
mutableAttributedString.append(NSAttributedString(string: "Test", attributes: nil))

iOS ≤ 17 前会得到以下 Attributes 合并结果:

1
2
3
4
5
6
7
8
<div>{
    breaklinePlaceholder = "NSAttributedStringCrash.BreaklinePlaceholder(rawValue: 1)";
}<div>{
    breaklinePlaceholder = "NSAttributedStringCrash.BreaklinePlaceholder(rawValue: 1)";
}<p>{
    breaklinePlaceholder = "NSAttributedStringCrash.BreaklinePlaceholder(rawValue: 1)";
}Test{
}

iOS ≥ 18 会得到以下 Attributes 合并结果:

1
2
3
4
<div><div><p>{
    breaklinePlaceholder = "NSAttributedStringCrash.BreaklinePlaceholder(rawValue: 1)";
}Test{
}

可以看到同样的程式在不同版本的 iOS 有不同的结果,这最终导致了后续的 enumerateAttribute(.breaklinePlaceholder..) 中的处理逻辑不合预期造成闪退。

⭐️ iOS ≥ 18 NSAttributedString attributes: [NSAttributedString.Key: Any?] 会多参考 Equatable ==⭐️

比较 iOS 17/18 有无实现 Equatable/Hashable 的结果

比较 iOS 17/18 有无实现 Equatable/Hashable 的结果

⭐️⭐️ iOS ≥ 18 会多参考 Equatable ,iOS ≤ 17 则不会。⭐️⭐️

结合前述, NSAttributedString attributes: [NSAttributedString.Key: Any?] Value 物件是宣告成 Any? Type, 就观测结果, iOS ≥ 18 会先参考 Equatable 判断是否相同,然后再使用 Hashable Set 合并管理物件。

结论

NSAttributedString attributes: [NSAttributedString.Key: Any?] 在合并 Range Attribute 时,iOS ≥ 18 会多参考 Equatable,这点与以往不同。

另外在 iOS 18 开始如果只宣告 Equatable XCode Console 也会输出 Warning:

Obj-C ` -hash invoked on a Swift value of type BreaklinePlaceholder` that is Equatable but not Hashable; this can lead to severe performance problems.

有任何问题及指教欢迎 与我联络


Buy me a beer

本文首次发表于 Medium (点击查看原始版本),由 ZMediumToMarkdown 提供自动转换与同步技术。

Improve this page on Github.

This post is licensed under CC BY 4.0 by the author.