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

ZhgChgLi
ZRealm Dev.
Published in
11 min readSep 20, 2024

--

iOS ≥ 18 開始 NSAttributedString attributes Range 合併會參考 Equatable

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 進行刪除操作時會發生閃退:

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)
}
*** Terminating app due to uncaught exception 'NSRangeException', reason: 'NSMutableRLEArray replaceObjectsInRange:withObject:length:: Out of bounds'

.breaklinePlaceholder 是我自行擴充的一個 NSAttributedString.Key,用來標記 HTML 標籤資訊,優化換行符號使用:

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 例如:

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 合併結果:

<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...) 時會得到以下結果:

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 為例:

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 合併結果:

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

iOS ≥ 18 會得到以下 Attributes 合併結果:

<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 ≥ 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.

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

--

--

ZhgChgLi
ZRealm Dev.

探索世界、求知若渴、教學相長;更愛電影、美劇、西音、運動、生活. www.zhgchg.li