Post

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.

iOS 18 NSAttributedString Attributes Range Merging|Equatable Behavior Change Explained

点击这里查看本文章简体中文版本。

點擊這裡查看本文章正體中文版本。

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](https://unsplash.com/@ubahnverleih?utm_content=creditCopyText&utm_medium=referral&utm_source=unsplash){:target="_blank"}

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 with or without Equatable/Hashable implemented

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 type BreaklinePlaceholder 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.


Buy me a beer

This post was originally published on Medium (View original post), and automatically converted and synced by ZMediumToMarkdown.

Improve this page on Github.

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