iOS 18 NSAttributedString Attributes Range Merging|Equatable Behavior Change Explained
Discover how iOS 18 updates NSAttributedString attributes range merging by referencing Equatable, resolving inconsistencies and improving attribute handling for developers.
点击这里查看本文章简体中文版本。
點擊這裡查看本文章正體中文版本。
This post was translated with AI assistance — let me know if anything sounds off!
iOS ≥ 18 NSAttributedString attributes Range merging behavior change
Starting from iOS ≥ 18, NSAttributedString attributes Range merging will refer to Equatable.
Photo by C M
Cause of the Problem
After the release of iOS 18 on September 17, 2024, a developer reported that the open-source project ZMarkupParser crashes when parsing certain HTML on iOS 18.
Seeing this issue is a bit confusing because the program worked fine before. It only started crashing with iOS 18, which is unusual. It should be caused by some changes in the underlying Foundation of iOS 18.
Crash Trace
Trace Code pinpointed the crash issue to iterating over .breaklinePlaceholder
attributes and performing deletion operations on the 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 created to mark HTML tag information, optimizing the use of line break characters:
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, inputting a
mutableAttributedString
did not cause any problems when performing the above operations; this means the input data content has changed in iOS 18.
NSAttributedString attributes: [NSAttributedString.Key: Any?]
Before diving deeper into the issue, let’s first introduce the merging mechanism of NSAttributedString attributes.
NSAttributedString attributes will automatically compare adjacent Range Attributes objects with the same .key and merge them if they are identical. 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 Attributes Merge Result:
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 using enumerateAttribute(.breaklinePlaceholder...)
, the following result is 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 inferred that the underlying implementation uses Set<Hashable>
as the container for Attributes, which automatically excludes duplicate Attribute objects.
However, for ease of use, the NSAttributedString attributes: [NSAttributedString.Key: Any?]
value object is declared as type Any?
, without restricting it to Hashable.
It is therefore speculated that the system conforms to as? Hashable
at the underlying level and then uses a Set to manage object merging.
The difference in the iOS ≥ 18 adjustment this time is likely due to the underlying implementation 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))
Before iOS 17, the following Attributes merge 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{
}
iOS ≥ 18 will get the following merged Attributes results:
1
2
3
4
<div><div><p>{
breaklinePlaceholder = "NSAttributedStringCrash.BreaklinePlaceholder(rawValue: 1)";
}Test{
}
You can see that the same code produces different results on different iOS versions, which ultimately causes the subsequent
enumerateAttribute(.breaklinePlaceholder..)
processing logic to behave unexpectedly and leads to a crash.
⭐️ iOS ≥ 18 NSAttributedString attributes: [NSAttributedString.Key: Any?] will also consider Equatable ==⭐️
Comparison of iOS 17/18 Results with or without Implementing Equatable/Hashable
⭐️⭐️ iOS ≥ 18 will refer more to
Equatable
, while iOS ≤ 17 will not.⭐️⭐️
Combining the above, the value object of NSAttributedString attributes: [NSAttributedString.Key: Any?]
is declared as type Any?
. Based on observations, iOS ≥ 18 first checks equality using Equatable
, then uses a Hashable
Set to manage object merging.
Conclusion
NSAttributedString attributes: [NSAttributedString.Key: Any?] When merging Range Attributes, iOS ≥ 18 additionally considers Equatable, which is different from before.
Additionally, starting from iOS 18, if you only declare Equatable
, XCode Console will also output a Warning:
Obj-C
-hash
invoked on a Swift value of typeBreaklinePlaceholder
that is Equatable but not Hashable; this can lead to severe performance problems.
If you have any questions or feedback, feel free to contact me.
This post was originally published on Medium (View original post), and automatically converted and synced by ZMediumToMarkdown.