[ASTextNode2] Simplify Caching#832
Conversation
3b87fd8 to
de902b2
Compare
e348f49 to
98a3546
Compare
98a3546 to
d332a8c
Compare
Generated by 🚫 Danger |
| @@ -110,6 +110,7 @@ - (BOOL)isEqual:(ASTextNodeRendererKey *)object | |||
| dispatch_once(&onceToken, ^{ | |||
| __rendererCache = [[NSCache alloc] init]; | |||
| __rendererCache.countLimit = 500; // 500 renders cache | |||
There was a problem hiding this comment.
I know this is for ASTextNode1...however, this might actually be quite a bit of memory. Do we dump this cache on memory warning? Is there anything simple we can do to make it more responsive to some combination of memory consumption (by objects in the cache), device memory size, or frequency of memory pressure?
I think there's a need for something like ASMemoryManager (or MemoryOverlord lol) that provides some conveniences around memory. Total memory capacity could be bucketed into enums with perhaps a low/med/high — especially iPads would mostly be in low or med, for example. Most valuably would be registering some blocks or observers to call upon memory pressure, and providing an indication of how severe or sustained the memory pressure is.
For example, repeated memory pressure should certainly reduce the countLimit, display range size, or other such parameters rather than just one-time cache dumps. Even if there's no mechanism to increase these again, this would allow us to choose more aggressive defaults without as much concern for apps that may have e.g. many large text blocks.
There was a problem hiding this comment.
A memory management object is really interesting. Take a look at this, which provides 3 levels of severity. This is the hook that NSCache uses. Side note, I'm in favor of removing the explicit count limit here and letting NSCache decide.
| // If nil, regenerate. | ||
| NSAttributedString *_preparedAttributedText; | ||
|
|
||
| // Just a fast cache. May not be valid but good place to check first. |
There was a problem hiding this comment.
Could you clarify this comment a bit, specifically what about it may not be valid (like if constrainedSize doesn't match?)
Same for "If nil, regenerate" -- could remove this comment, or make it a bit more clear how the variable is used and expectations for how it is maintained.
| @end | ||
|
|
||
| /** | ||
| * If set, we will record all values set to attributedText into an array |
There was a problem hiding this comment.
It would be great to use this feature to collect a bunch of data and add it to our unit tests! This might collect some noise when iOS changes, but it would also be an excellent way to identify breakage / race conditions from core text classes changing (e.g. if we had an auto-scrolling collection showing ASTextCellNodes, measuring them in batches of 20 insertions to the data source).
There was a problem hiding this comment.
Yes we've actually got some – AttributedStringsFixture0.plist that I captured from Pinterest a while back. Just need to build a test that uses them.
| ASTextLayout *layout = [self compatibleLayoutWithContainer:container text:[self l_preparedAttributedText]]; | ||
|
|
||
| // Is this necessary? | ||
| [self setNeedsDisplay]; |
There was a problem hiding this comment.
Hmm, I think you should remove this (as long as the tests pass, or fix them if the tests are not too hard to fix).
As long as we have the layer's .needsDisplayOnBoundsChange set to YES, then this should not be a factor.
One could argue that we should use default-synchronous display whenever the layout changes while interfaceState is Visible. That would fix some of the stretching texture issues that often happen when changing a string and it resizes the frame. However, this would require something like setNeedsSynchronousDisplay, and it would be called in -layout instead of here.
There was a problem hiding this comment.
Agree that we shouldn't need to call setNeedsDisplay here.
| - (void)prepareAttributedString:(NSMutableAttributedString *)attributedString | ||
| - (NSAttributedString *)l_preparedAttributedText | ||
| { | ||
| ASLockScopeSelf(); |
There was a problem hiding this comment.
Pretty sure the lock should stay here!
There was a problem hiding this comment.
Oops, meant to replace that with a lock assertion. I used the l_ prefix to indicate the method is called while the lock is held. That may be too easy to miss, so I could change it to locked_ although locked assertions will probably prevent people from missing it in reality (unit tests).
| @@ -323,10 +320,17 @@ - (NSArray *)exclusionPaths | |||
| return _textContainer.exclusionPaths; | |||
There was a problem hiding this comment.
In the method above this (won't let me comment on it), setExclusionPaths:...is it possible for _textContainer to not be allocated yet? Should we call self.textContainer or something?
There was a problem hiding this comment.
No, the textContainer is created in -init and kept forever.
A good way to think of it is the text container actually IS the text node in raw form. It stores many of the nodes properties and it's nicely copyable with atomic accessors. The text node itself is a bridge between the container and the rest of ASDK.
| return _preparedAttributedText; | ||
| } | ||
|
|
||
| if (!_attributedText) { |
There was a problem hiding this comment.
Move this condition above the other one, and instead of early return, just do _preparedAttributedText = [[NSAttributedString alloc] init];
Then it will still early return, and _preparedAttributedText will be set.
| shadow.shadowBlurRadius = _shadowRadius; | ||
| [attributedString addAttribute:NSShadowAttributeName value:shadow range:NSMakeRange(0, attributedString.length)]; | ||
| } | ||
| _preparedAttributedText = [attributedString copy]; |
There was a problem hiding this comment.
This second copy is not necessary, right? Since this code is called so much, I'd suggest we just let the mutable object live on, as anyone up-casting it can crash as much as they want :).
There was a problem hiding this comment.
So my thing in cases like this, is if I think there's even one downstream copy that is likely, just go ahead and copy now to make that free, to avoid multiple copies of the same object, and to save my mental energy. This method will get called once per node per configuration (change of properties with a layout/render pass in between). So in one data controller batch, this should be called realistically <100 times (say, 25 items with 4 text nodes per).
In this case for example, the attributed string will get copied when we make an ASTextNodeCacheKey from it anyway. And in fact, it will get copied each layout/render pass, so we will do more copying if we don't change properties in between those passes. We could remove THAT copy too, but then we're putting a little crack in our abstractions. These copies will be fast, they will mostly happen off-main, and there will only be ~1 per text node in common cases so I think we should leave it.
| return [[NSAttributedString alloc] init]; | ||
| } | ||
|
|
||
| NSMutableAttributedString *attributedString = [_attributedText mutableCopy]; |
There was a problem hiding this comment.
Why was there not a copy here before? Is it definitely needed here, or should we be doing it at a higher level like setAttributedString?
| @"bgColor": self.backgroundColor ?: [NSNull null], | ||
| // Pass along self as a hack so that we can update _lastUsedLayout. | ||
| // We'll be careful | ||
| @"instance" : self |
There was a problem hiding this comment.
Hmm, lol. Should we use ASWeakProxy, or is that guaranteed to not make a difference in when we're deallocated?
Since it is possible to have two displays going on at the same time (the sentinel in ASDisplayNode tracks which one should win), I'm not sure it is safe to set _lastUsedLayout unless we're checking which display is intended to / applied as the winner?
There was a problem hiding this comment.
Yeah it's definitely possible for the loser to be set – but lastUsedLayout is not considered authoritative. As the comment says, it's just a fast-path for the common case – a first place to check before going to the global layout cache. If we miss, it's not a tragedy.
| * | ||
| * NOTE: Be careful to copy `text` if needed. | ||
| * NOTE: The `container` you pass into this should be a copy, as it may be | ||
| * retained by the cache in the event of a cache miss. |
There was a problem hiding this comment.
Can't we just copy the container in the case of a cache miss? That would be a huge win in some workflows, if that's the only reason / case for the copy.
There was a problem hiding this comment.
Agree. It's great if we can reduce the API complexity of this method, and get a perf win in some cases.
There was a problem hiding this comment.
The problem is that currently the callers of this method already have to create a copy, in order to change the container size. So the copy is free. If I copy inside this method, we create two copies. Does that make sense?
| // On a cache miss, this method is going to run long and in some | ||
| // scenarios it may make sense to run concurrently with different | ||
| // sizes, so don't hold the lock the whole time. | ||
| auto lastLayout = ASLockedSelf(_lastUsedLayout); |
There was a problem hiding this comment.
Doesn't the caller need to have self locked this whole time? Otherwise, it will need to lock after it receives the layout, and re-check that it is still valid.
There was a problem hiding this comment.
No, because we're inside a method with immutable parameters. The validity of the layout can't change out from under us – whatever text and container they passed in, that's what we need to respond with. It's up to the consumer to worry about whether those parameters remain valid throughout the method.
I really hated to turn this from a class method to an instance method because it's so unclear that this method simply does not rely on any instance state. It asks the instance for a quick candidate layout to consult first, and it puts the result into that bucket for the next call.
For instance, the layout system calls this with the lock held for the reason you mentioned. The display system is operating on fixed draw params, so it doesn't need to lock the node to call this.
| ASTextLayout *layout = [self compatibleLayoutWithContainer:container text:[self l_preparedAttributedText]]; | ||
|
|
||
| // Is this necessary? | ||
| [self setNeedsDisplay]; |
There was a problem hiding this comment.
Agree that we shouldn't need to call setNeedsDisplay here.
| - (void)prepareAttributedString:(NSMutableAttributedString *)attributedString | ||
| - (NSAttributedString *)l_preparedAttributedText | ||
| { | ||
| ASLockScopeSelf(); |
| * | ||
| * NOTE: Be careful to copy `text` if needed. | ||
| * NOTE: The `container` you pass into this should be a copy, as it may be | ||
| * retained by the cache in the event of a cache miss. |
There was a problem hiding this comment.
Agree. It's great if we can reduce the API complexity of this method, and get a perf win in some cases.
|
Adlai Holler seems not to be a GitHub user. You need a GitHub account to be able to sign the CLA. If you have already a GitHub account, please add the email address used for this commit to your account. You have signed the CLA already but the status is still pending? Let us recheck it. |
|
@Adlai-Holler Can you follow up on this diff? |
This moves the bucketing behavior into the NSCache itself.
The cache which is currently
NSAttributedString -> ASTextCacheValue (a.k.a. [ASTextLayout])becomesASTextCacheKey -> ASTextLayoutso that, instead of us doing the bucketing and scan, NSCache does it.ASTextCacheKeyis a pair of(NSAttributedString, ASTextContainer), similar toASTextKitAttributesin the ASTextNode1 world.The fact is, a given pair of
(container, attributed string)generates one layout, which can be used against multiple(container, attributed string)pairs, and this diff makes the-hashand-isEqual:implementations reflect that.When a text layout is generated, it is assigned back to the cache key that it was built for. Then in the future,
-[ASTextCacheKey isEqual:]will really answer the question "is the layout valid for both of these keys?"