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
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
⭐️⭐️ 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