Home iOS ≥ 18 NSAttributedString attributes Range 合併的一個行為改變
Post
Cancel

iOS ≥ 18 NSAttributedString attributes Range 合併的一個行為改變

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.

有任何問題及指教歡迎 與我聯絡

===

View the English version of this article here.

本文首次發表於 Medium ➡️ 前往查看



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

Design Patterns 的實戰應用紀錄—In WKWebView with Builder, Strategy & Chain of Responsibility Pattern

Github Pages 自訂網域教學