Why Does the Initial Call to Nsattributedstring with an HTML String Take Over 100 Times Longer Than Subsequent Calls

Why does the initial call to NSAttributedString with an HTML string take over 100 times longer than subsequent calls?

That's a really good question. It turns out that (at least for me) it is always slower the first time I call the method, no matter if the debugger is attached or not. Here is why: The first time you parse an HTML-attributed string, iOS loads a whole JavaScriptCore engine and WebKit into memory. Watch:

The first time we run the method (before parsing the string) only 3 threads exist:

screenshot 1

After the string is parsed, we have 11 threads:

screenshot 2

Now the next time we run the method, most of those web-related threads are still in existence:

screenshot 3

That explains why it's slow the first time and fast thereafter.

Convert HTML to NSAttributedString in iOS

In iOS 7, UIKit added an initWithData:options:documentAttributes:error: method which can initialize an NSAttributedString using HTML, eg:

[[NSAttributedString alloc] initWithData:[htmlString dataUsingEncoding:NSUTF8StringEncoding] 
options:@{NSDocumentTypeDocumentAttribute: NSHTMLTextDocumentType,
NSCharacterEncodingDocumentAttribute: @(NSUTF8StringEncoding)}
documentAttributes:nil error:nil];

In Swift:

let htmlData = NSString(string: details).data(using: String.Encoding.unicode.rawValue)
let options = [NSAttributedString.DocumentReadingOptionKey.documentType:
NSAttributedString.DocumentType.html]
let attributedString = try? NSMutableAttributedString(data: htmlData ?? Data(),
options: options,
documentAttributes: nil)

NSAttributedString not rendering on time - Swift

The problem here is that UIView has an undocumented height limit of 8192 points. Once the label is 8192 points tall (or taller), it no longer updates the frame buffer. (Presumably the window server simply ignores the label's CALayer once the layer is too large.) Whatever was already in the frame buffer remains there. Presumably the behavior is undefined and may vary across devices and versions of iOS.

(Edit: Having thought about this more, I suspect the limit is 16,384 pixels, not 8,192 points. I don't think the window server deals in points.)

To demonstrate, I took your code and storyboard and added three things:

  1. I constrained the height of your label to be less than or equal to 8185 points.
  2. I added a slider that updates the constant of that height constraint. The slider allows the range 8185 to 8200.
  3. I added another label that displays the constant of the height constraint.

Here's what happens in the iPhone SE simulator running iOS 10.2:

demo of 8192 point height problems

In the demo, you can see that as soon as the height limit crosses 8192 points, the thumb of the slider starts to look smeared, and the height label's text visibly overwrites itself. This is because the green label is no longer updating the frame buffer, so whatever was previously there (as drawn by the slider and the height label) remains, only overwritten where the slider and the height label redraw themselves. As soon as the height goes back down across the 8192 point threshold, the green label draws itself again, cleaning up the mess.

I think you're not seeing this in your iPhone 7 test because the iPhone 7 has a wider screen than the iPhone SE. All iPhones with 3.5 inch and 4 inch screens are 320 points wide. iPhones with 4.7 inch screens are 375 points wide, and iPhones with 5.5 inch screens are 414 points wide.

A wider screen means a wider UILabel. That means more text fits on each line, so the label doesn't have to be as tall to fit all the text. I suspect that on the larger screens, your labels are shorter than 8192 points so they don't hit undefined behavior.

Workarounds:

  • If you don't intend to let the user scroll to see the entire text, just put a height constraint on the label.

  • If you want to let the user scroll, try using a UIWebView or WKWebView instead of a UILabel to show the text. I believe these views can handle content more than 8192 points tall.

Incidentally, you're not the first person to run into this problem: UILabel view disappear when the height greater than 8192. However, your question currently has an open bounty, which prevents it from being closed as a duplicate.

Parsing HTML into NSAttributedText - how to set font?

Figured it out. Bit of a bear, and maybe not the best answer.

This code will go through all the font changes. I know that it is using "Times New Roman" and "Times New Roman BoldMT" for the fonts.
But regardless, this will find the bold fonts and let me reset them. I can also reset the size while I'm at it.

I honestly hope/think there is a way to set this up at parse time, but I can't find it if there is.

- (void)changeFont:(NSMutableAttributedString*)string
{
NSRange range = (NSRange){0,[string length]};
[string enumerateAttribute:NSFontAttributeName inRange:range options:NSAttributedStringEnumerationLongestEffectiveRangeNotRequired usingBlock:^(id value, NSRange range, BOOL *stop) {
UIFont* currentFont = value;
UIFont *replacementFont = nil;

if ([currentFont.fontName rangeOfString:@"bold" options:NSCaseInsensitiveSearch].location != NSNotFound) {
replacementFont = [UIFont fontWithName:@"HelveticaNeue-CondensedBold" size:25.0f];
} else {
replacementFont = [UIFont fontWithName:@"HelveticaNeue-Thin" size:25.0f];
}

[string addAttribute:NSFontAttributeName value:replacementFont range:range];
}];

}

SwiftUI Decode HTML Entities Error: Simultaneous accesses to 0x7ff43ff29b50, but modification requires exclusive access

NSAttributedString processes the run loop when it parses HTML. You can see it at the call to CFRUnLoopRunSpecific inside of the call to initWithData:. This is a very old problem with NSAttributedString. (I think I first encountered it around OS X 10.5, but I'm sure it's older than that.)

Because it processes the run loop, all kinds of things can happen in the middle of evaluating an HTML string. Timers can fire. Delayed selectors can be called. And in SwiftUI, that means that the UI might try to update. It's a mess. And it generates race conditions inside of apparently safe code. You're actually a bit lucky that Swift catches this kind of problem and crashes. The other common symptoms are even harder to debug ("impossible" deadlocks due to code reentrance is the most common I've encountered).

The short answer is that it's not safe to use NSAttributedString to evaluate HTML synchronously. The docs don't warn you about this, and the name of the method gives no hint about it. But you can't. Some folks will tell you to just make sure you evaluate this on the main thread, but even that doesn't guarantee you won't have really bizarre reentrance bugs if you have anything else pending on the run loop.

You'll need to evaluate this string another way. For example, see Martin R's answer to How do I decode HTML entities in Swift?

For a quick example of how even running this on the main thread can get you in trouble, consider the following code:

func delayed() {
print("Should be last")
}

func dothing() {
// Run this on the next runloop
DispatchQueue.main.async { self.delayed() }

// These should print in order
print("Prints first")
print("It's a party!".decoded)
}

// somewhere on the main queue. There's no background threads needed
dothing()

This outputs:

Prints first
2020-12-20 22:16:07.976101-0500 test[84698:5693517] [plugin] AddInstanceForFactory: No factory registered for id <CFUUID 0x600000bb8520> F8BB1C28-BAE8-11D6-9C31-00039315CD46
Should be last
It's a party!

If delayed mutates any state, then things may change out from under you, even though everything is on the main thread.

(The weird error is there because I tested this in didFinishLaunching, and that means that the runloop gets processed before the app finishes launching. That's exactly the kind of bugs you get with this.)

boundingRectWithSize for NSAttributedString returning wrong size

Looks like you weren't providing the correct options. For wrapping labels, provide at least:

CGRect paragraphRect =
[attributedText boundingRectWithSize:CGSizeMake(300.f, CGFLOAT_MAX)
options:(NSStringDrawingUsesLineFragmentOrigin|NSStringDrawingUsesFontLeading)
context:nil];

Note: if the original text width is under 300.f there won't be line wrapping, so make sure the bound size is correct, otherwise you will still get wrong results.



Related Topics



Leave a reply



Submit