Home Behavior Change in Merging NSAttributedString Attributes Range in iOS ≥ 18
Post
Cancel

Behavior Change in Merging NSAttributedString Attributes Range in iOS ≥ 18

Behavior Change in Merging NSAttributedString Attributes Range in iOS ≥ 18

Starting from iOS ≥ 18, merging NSAttributedString attributes Range will reference Equatable

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

Photo by C M

Issue Origin

After the launch of iOS 18 on September 17, 2024, a developer reported a crash when parsing HTML in the open-source project ZMarkupParser.

Seeing this issue was a bit confusing because the program had no issues before, and the crash only occurred with iOS 18, which is illogical. It should be due to some adjustments in the underlying Foundation of iOS 18.

Crash Trace

After tracing the code, the crash issue was pinpointed to occur when iterating over .breaklinePlaceholder Attributes and deleting 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 is a custom NSAttributedString.Key I extended to mark HTML tag information for optimizing the use of line break symbols:

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")
}

But the core issue is not here, because before iOS 17, there was no problem with the input mutableAttributedString when performing the above operations; indicating that the input data content has changed in iOS 18.

NSAttributedString attributes: [NSAttributedString.Key: Any?]

Before delving into the problem, let’s first introduce the merging mechanism of NSAttributedString attributes.

NSAttributedString attributes will automatically compare adjacent Range Attributes objects with the same .key to see if they are the same, and if so, merge them into the same Attribute. For example:

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)]))

Final Merged 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";
}

When enumerating enumerateAttribute(.breaklinePlaceholder...), the following results will be obtained:

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 merging — Underlying implementation speculation

It is speculated that the underlying implementation uses Set<Hashable> as the Attributes container, automatically excluding the same Attribute objects.

However, for convenience of use, the NSAttributedString attributes: [NSAttributedString.Key: Any?] Value objects are declared as Any? Type, without restricting Hashable.

Therefore, it is speculated that the system will conform to as? Hashable at the underlying level and then use Set to merge and manage objects.

The difference in adjustment for iOS ≥ 18 is speculated to be the underlying implementation issue here.

The following is an example using our custom .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))

For iOS ≤ 17, the following Attributes merging result will be obtained:

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{
}

For iOS ≥ 18, the following Attributes merging result will be obtained:

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

The same program can have different results on different versions of iOS, which ultimately leads to unexpected crashes in the subsequent enumerateAttribute(.breaklinePlaceholder..) due to the handling logic.

⭐️ iOS ≥ 18 NSAttributedString attributes: [NSAttributedString.Key: Any?] will reference Equatable == more ⭐️

Comparison of results with and without implementing Equatable/Hashable in iOS 17/18

Comparison of results with and without implementing Equatable/Hashable in iOS 17/18

⭐️⭐️ iOS ≥ 18 will reference Equatable more, while iOS ≤ 17 will not. ⭐️⭐️

Combining the above, the NSAttributedString attributes: [NSAttributedString.Key: Any?] Value object is declared as Any? Type, based on observations, iOS ≥ 18 will first reference Equatable to determine equality, and then use Hashable Set to merge and manage objects.

Conclusion

When merging Range Attributes with NSAttributedString attributes: [NSAttributedString.Key: Any?], iOS ≥ 18 will reference Equatable more, which is different from before.

Additionally, starting from iOS 18, if only Equatable is declared, XCode Console will also output a 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.

For any questions or suggestions, feel free to contact me.

===

本文中文版本

===

This article was first published in Traditional Chinese on Medium ➡️ View Here



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

Practical Application Record of Design Patterns—In WKWebView with Builder, Strategy & Chain of Responsibility Pattern

Custom Domain Tutorial for Github Pages