iOS ≥ 18 NSAttributedString attributes Range 合併的一個行為改變
iOS ≥ 18 開始 NSAttributedString attributes Range 合併會參考 Equatable
問題起因
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 ≥ 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.
有任何問題及指教歡迎與我聯絡。