From c09db1cb17d9ff4a590f622d6b12497ee85a03dc Mon Sep 17 00:00:00 2001 From: Chris Danford Date: Wed, 3 Aug 2016 13:10:50 -0700 Subject: [PATCH] ASImageNode backing store sharing for memory and CPU reduction (#1974) --- AsyncDisplayKit.xcodeproj/project.pbxproj | 14 ++ AsyncDisplayKit/ASImageNode.mm | 178 ++++++++++++++++++++-- AsyncDisplayKit/Private/ASWeakMap.h | 59 +++++++ AsyncDisplayKit/Private/ASWeakMap.m | 85 +++++++++++ AsyncDisplayKitTests/ASWeakMapTests.m | 57 +++++++ 5 files changed, 377 insertions(+), 16 deletions(-) create mode 100644 AsyncDisplayKit/Private/ASWeakMap.h create mode 100644 AsyncDisplayKit/Private/ASWeakMap.m create mode 100644 AsyncDisplayKitTests/ASWeakMapTests.m diff --git a/AsyncDisplayKit.xcodeproj/project.pbxproj b/AsyncDisplayKit.xcodeproj/project.pbxproj index e573a8c423..9cf13a4e4b 100644 --- a/AsyncDisplayKit.xcodeproj/project.pbxproj +++ b/AsyncDisplayKit.xcodeproj/project.pbxproj @@ -226,6 +226,10 @@ 8021EC1E1D2B00B100799119 /* UIImage+ASConvenience.m in Sources */ = {isa = PBXBuildFile; fileRef = 8021EC1B1D2B00B100799119 /* UIImage+ASConvenience.m */; }; 8021EC1F1D2B00B100799119 /* UIImage+ASConvenience.m in Sources */ = {isa = PBXBuildFile; fileRef = 8021EC1B1D2B00B100799119 /* UIImage+ASConvenience.m */; }; 81EE38501C8E94F000456208 /* ASRunLoopQueue.mm in Sources */ = {isa = PBXBuildFile; fileRef = 81EE384E1C8E94F000456208 /* ASRunLoopQueue.mm */; }; + 83A7D95A1D44542100BF333E /* ASWeakMap.m in Sources */ = {isa = PBXBuildFile; fileRef = 83A7D9591D44542100BF333E /* ASWeakMap.m */; }; + 83A7D95B1D44547700BF333E /* ASWeakMap.m in Sources */ = {isa = PBXBuildFile; fileRef = 83A7D9591D44542100BF333E /* ASWeakMap.m */; }; + 83A7D95C1D44548100BF333E /* ASWeakMap.h in Headers */ = {isa = PBXBuildFile; fileRef = 83A7D9581D44542100BF333E /* ASWeakMap.h */; }; + 83A7D95E1D446A6E00BF333E /* ASWeakMapTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 83A7D95D1D446A6E00BF333E /* ASWeakMapTests.m */; }; 8B0768B41CE752EC002E1453 /* ASDefaultPlaybackButton.m in Sources */ = {isa = PBXBuildFile; fileRef = 8B0768B21CE752EC002E1453 /* ASDefaultPlaybackButton.m */; }; 8BBBAB8C1CEBAF1700107FC6 /* ASDefaultPlaybackButton.h in Headers */ = {isa = PBXBuildFile; fileRef = 8B0768B11CE752EC002E1453 /* ASDefaultPlaybackButton.h */; }; 8BBBAB8D1CEBAF1E00107FC6 /* ASDefaultPlaybackButton.m in Sources */ = {isa = PBXBuildFile; fileRef = 8B0768B21CE752EC002E1453 /* ASDefaultPlaybackButton.m */; }; @@ -975,6 +979,9 @@ 8021EC1B1D2B00B100799119 /* UIImage+ASConvenience.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = "UIImage+ASConvenience.m"; sourceTree = ""; }; 81EE384D1C8E94F000456208 /* ASRunLoopQueue.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = ASRunLoopQueue.h; path = ../ASRunLoopQueue.h; sourceTree = ""; }; 81EE384E1C8E94F000456208 /* ASRunLoopQueue.mm */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.cpp.objcpp; name = ASRunLoopQueue.mm; path = ../ASRunLoopQueue.mm; sourceTree = ""; }; + 83A7D9581D44542100BF333E /* ASWeakMap.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = ASWeakMap.h; sourceTree = ""; }; + 83A7D9591D44542100BF333E /* ASWeakMap.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = ASWeakMap.m; sourceTree = ""; }; + 83A7D95D1D446A6E00BF333E /* ASWeakMapTests.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = ASWeakMapTests.m; sourceTree = ""; }; 8B0768B11CE752EC002E1453 /* ASDefaultPlaybackButton.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = ASDefaultPlaybackButton.h; sourceTree = ""; }; 8B0768B21CE752EC002E1453 /* ASDefaultPlaybackButton.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = ASDefaultPlaybackButton.m; sourceTree = ""; }; 8BDA5FC31CDBDDE1007D13B2 /* ASVideoPlayerNode.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = ASVideoPlayerNode.h; sourceTree = ""; }; @@ -1313,6 +1320,7 @@ 058D09C5195D04C000B7D73C /* AsyncDisplayKitTests */ = { isa = PBXGroup; children = ( + 83A7D95D1D446A6E00BF333E /* ASWeakMapTests.m */, DBC453211C5FD97200B16017 /* ASDisplayNodeImplicitHierarchyTests.m */, DBC452DD1C5C6A6A00B16017 /* ArrayDiffingTests.m */, CC3B208F1C3F892D00798563 /* ASBridgedPropertiesTests.mm */, @@ -1501,6 +1509,8 @@ ACF6ED481B17847A00DA7C62 /* ASStackPositionedLayout.mm */, ACF6ED491B17847A00DA7C62 /* ASStackUnpositionedLayout.h */, ACF6ED4A1B17847A00DA7C62 /* ASStackUnpositionedLayout.mm */, + 83A7D9581D44542100BF333E /* ASWeakMap.h */, + 83A7D9591D44542100BF333E /* ASWeakMap.m */, CC3B20871C3F7A5400798563 /* ASWeakSet.h */, CC3B20881C3F7A5400798563 /* ASWeakSet.m */, DBC452D91C5BF64600B16017 /* NSArray+Diffing.h */, @@ -1782,6 +1792,7 @@ DB55C2671C641AE4004EDCF5 /* ASContextTransitioning.h in Headers */, 68B0277B1C1A79D60041016B /* ASDisplayNode+Beta.h in Headers */, CCF18FF41D2575E300DF5895 /* NSIndexSet+ASHelpers.h in Headers */, + 83A7D95C1D44548100BF333E /* ASWeakMap.h in Headers */, B350622D1B010EFD0018CF92 /* ASScrollDirection.h in Headers */, 254C6B751BF94DF4003EC431 /* ASTextKitComponents.h in Headers */, B35062081B010EFD0018CF92 /* ASScrollNode.h in Headers */, @@ -2128,6 +2139,7 @@ 697C0DE51CF38F28001DE0D4 /* ASLayoutValidation.mm in Sources */, ACF6ED501B17847A00DA7C62 /* ASStackPositionedLayout.mm in Sources */, ACF6ED521B17847A00DA7C62 /* ASStackUnpositionedLayout.mm in Sources */, + 83A7D95A1D44542100BF333E /* ASWeakMap.m in Sources */, 257754A61BEE44CD00737CA5 /* ASTextKitAttributes.mm in Sources */, 81EE38501C8E94F000456208 /* ASRunLoopQueue.mm in Sources */, 9C70F2041CDA4EFA007D6C76 /* ASTraitCollection.m in Sources */, @@ -2168,6 +2180,7 @@ 058D0A3A195D057000B7D73C /* ASDisplayNodeTests.m in Sources */, CC4981B31D1A02BE004E13CC /* ASTableViewThrashTests.m in Sources */, 058D0A3B195D057000B7D73C /* ASDisplayNodeTestsHelper.m in Sources */, + 83A7D95E1D446A6E00BF333E /* ASWeakMapTests.m in Sources */, 056D21551ABCEF50001107EF /* ASImageNodeSnapshotTests.m in Sources */, AC026B581BD3F61800BBC17E /* ASStaticLayoutSpecSnapshotTests.m in Sources */, ACF6ED5E1B178DC700DA7C62 /* ASInsetLayoutSpecSnapshotTests.mm in Sources */, @@ -2296,6 +2309,7 @@ 7AB338661C55B3420055FDE8 /* ASRelativeLayoutSpec.mm in Sources */, 697C0DE61CF38F28001DE0D4 /* ASLayoutValidation.mm in Sources */, 9C70F2051CDA4F06007D6C76 /* ASTraitCollection.m in Sources */, + 83A7D95B1D44547700BF333E /* ASWeakMap.m in Sources */, 34EFC7781B701D3100AD841F /* ASStackUnpositionedLayout.mm in Sources */, DE84918E1C8FFF9F003D89E9 /* ASRunLoopQueue.mm in Sources */, 68FC85E51CE29B7E00EDD713 /* ASTabBarController.m in Sources */, diff --git a/AsyncDisplayKit/ASImageNode.mm b/AsyncDisplayKit/ASImageNode.mm index 73691bcde9..d85a347ec5 100644 --- a/AsyncDisplayKit/ASImageNode.mm +++ b/AsyncDisplayKit/ASImageNode.mm @@ -24,6 +24,10 @@ #import "ASInternalHelpers.h" #import "ASEqualityHelpers.h" +#import "ASEqualityHashHelpers.h" +#import "ASWeakMap.h" + +#include struct ASImageNodeDrawParameters { BOOL opaque; @@ -38,10 +42,81 @@ struct ASImageNodeDrawParameters { asimagenode_modification_block_t imageModificationBlock; }; +/** + * Contains all data that is needed to generate the content bitmap. + */ +@interface ASImageNodeContentsKey : NSObject {} + +@property (nonatomic, strong) UIImage *image; +@property CGSize backingSize; +@property CGRect imageDrawRect; +@property BOOL isOpaque; +@property (nonatomic, strong) UIColor *backgroundColor; +@property ASDisplayNodeContextModifier preContextBlock; +@property ASDisplayNodeContextModifier postContextBlock; +@property asimagenode_modification_block_t imageModificationBlock; + +@end + +@implementation ASImageNodeContentsKey + +- (BOOL)isEqual:(id)object +{ + if (self == object) { + return YES; + } + + // Optimization opportunity: The `isKindOfClass` call here could be avoided by not using the NSObject `isEqual:` + // convention and instead using a custom comparison function that assumes all items are heterogeneous. + // However, profiling shows that our entire `isKindOfClass` expression is only ~1/40th of the total + // overheard of our caching, so it's likely not high-impact. + if ([object isKindOfClass:[ASImageNodeContentsKey class]]) { + ASImageNodeContentsKey *other = (ASImageNodeContentsKey *)object; + return [_image isEqual:other.image] + && CGSizeEqualToSize(_backingSize, other.backingSize) + && CGRectEqualToRect(_imageDrawRect, other.imageDrawRect) + && _isOpaque == other.isOpaque + && [_backgroundColor isEqual:other.backgroundColor] + && _preContextBlock == other.preContextBlock + && _postContextBlock == other.postContextBlock + && _imageModificationBlock == other.imageModificationBlock; + } else { + return NO; + } +} + +- (NSUInteger)hash +{ + NSUInteger subhashes[] = { + // Profiling shows that the work done in UIImage's `hash` is on the order of 0.005ms on an A5 processor + // and isn't proportional to the size of the image. + [_image hash], + + // TODO: Hashing the floats in a CGRect or CGSize is tricky. Equality of floats is + // fuzzy, but it's a 100% requirement that two equal values must produce an identical hash value. + // Until there's a robust solution for hashing floats, leave all float values out of the hash. + // This may lead to a greater number of isEqual comparisons but does not comprimise correctness. + //AS::hash()(_backingSize), + //AS::hash()(_imageDrawRect), + + AS::hash()(_isOpaque), + [_backgroundColor hash], + AS::hash()((void*)_preContextBlock), + AS::hash()((void*)_postContextBlock), + AS::hash()((void*)_imageModificationBlock), + }; + return ASIntegerArrayHash(subhashes, sizeof(subhashes) / sizeof(subhashes[0])); +} + +@end + + @implementation ASImageNode { @private UIImage *_image; + ASWeakMapEntry *_weakCacheEntry; // Holds a reference that keeps our contents in cache. + void (^_displayCompletionBlock)(BOOL canceled); @@ -295,25 +370,85 @@ struct ASImageNodeDrawParameters { imageDrawRect.size.width <= 0.0f || imageDrawRect.size.height <= 0.0f) { return nil; } - + + ASImageNodeContentsKey *contentsKey = [[ASImageNodeContentsKey alloc] init]; + contentsKey.image = image; + contentsKey.backingSize = backingSize; + contentsKey.imageDrawRect = imageDrawRect; + contentsKey.isOpaque = isOpaque; + contentsKey.backgroundColor = backgroundColor; + contentsKey.preContextBlock = preContextBlock; + contentsKey.postContextBlock = postContextBlock; + contentsKey.imageModificationBlock = imageModificationBlock; + + if (isCancelled()) { + return nil; + } + + ASWeakMapEntry *entry = [self.class contentsForkey:contentsKey isCancelled:(asdisplaynode_iscancelled_block_t)isCancelled]; + if (entry == nil) { // If nil, we were cancelled. + return nil; + } + _weakCacheEntry = entry; // Retain so that the entry remains in the weak cache + return entry.value; +} + +static ASWeakMap *cache = nil; +static ASDN::Mutex cacheLock; + ++ (ASWeakMapEntry *)contentsForkey:(ASImageNodeContentsKey *)key isCancelled:(asdisplaynode_iscancelled_block_t)isCancelled +{ + { + ASDN::MutexLocker l(cacheLock); + if (!cache) { + cache = [[ASWeakMap alloc] init]; + } + ASWeakMapEntry *entry = [cache entryForKey:key]; + if (entry != nil) { + // cache hit + return entry; + } + } + + // cache miss + UIImage *contents = [self createContentsForkey:key isCancelled:isCancelled]; + if (contents == nil) { // If nil, we were cancelled + return nil; + } + + { + ASDN::MutexLocker l(cacheLock); + return [cache setObject:contents forKey:key]; + } +} + ++ (UIImage *)createContentsForkey:(ASImageNodeContentsKey *)key isCancelled:(asdisplaynode_iscancelled_block_t)isCancelled +{ + // The following `UIGraphicsBeginImageContextWithOptions` call will sometimes take take longer than 5ms on an + // A5 processor for a 400x800 backingSize. + // Check for cancellation before we call it. + if (isCancelled()) { + return nil; + } + // Use contentsScale of 1.0 and do the contentsScale handling in boundsSizeInPixels so ASCroppedImageBackingSizeAndDrawRectInBounds // will do its rounding on pixel instead of point boundaries - UIGraphicsBeginImageContextWithOptions(backingSize, isOpaque, 1.0); + UIGraphicsBeginImageContextWithOptions(key.backingSize, key.isOpaque, 1.0); CGContextRef context = UIGraphicsGetCurrentContext(); - if (context && preContextBlock) { - preContextBlock(context); + if (context && key.preContextBlock) { + key.preContextBlock(context); } // if view is opaque, fill the context with background color - if (isOpaque && backgroundColor) { - [backgroundColor setFill]; - UIRectFill({ .size = backingSize }); + if (key.isOpaque && key.backgroundColor) { + [key.backgroundColor setFill]; + UIRectFill({ .size = key.backingSize }); } // iOS 9 appears to contain a thread safety regression when drawing the same CGImageRef on // multiple threads concurrently. In fact, instead of crashing, it appears to deadlock. - // The issue is present in Mac OS X El Capitan and has been seen hanging Pro apps like Adobe Premier, + // The issue is present in Mac OS X El Capitan and has been seen hanging Pro apps like Adobe Premiere, // as well as iOS games, and a small number of ASDK apps that provide the same image reference // to many separate ASImageNodes. A workaround is to set .displaysAsynchronously = NO for the nodes // that may get the same pointer for a given UI asset image, etc. @@ -323,25 +458,27 @@ struct ASImageNodeDrawParameters { // Another option is to have ASDisplayNode+AsyncDisplay coordinate these cases, and share the decoded buffer. // Details tracked in https://github.com/facebook/AsyncDisplayKit/issues/1068 - @synchronized(image) { - [image drawInRect:imageDrawRect]; + @synchronized(key.image) { + [key.image drawInRect:key.imageDrawRect]; } - if (context && postContextBlock) { - postContextBlock(context); + if (context && key.postContextBlock) { + key.postContextBlock(context); } - + + // The following `UIGraphicsGetImageFromCurrentImageContext` call will commonly take more than 20ms on an + // A5 processor. Check for cancellation before we call it. if (isCancelled()) { UIGraphicsEndImageContext(); return nil; } - + UIImage *result = UIGraphicsGetImageFromCurrentImageContext(); UIGraphicsEndImageContext(); - if (imageModificationBlock != NULL) { - result = imageModificationBlock(result); + if (key.imageModificationBlock != NULL) { + result = key.imageModificationBlock(result); } return result; @@ -384,6 +521,15 @@ struct ASImageNodeDrawParameters { [self setNeedsDisplay]; } +#pragma mark Interface State + +- (void)clearContents +{ + [super clearContents]; + + _weakCacheEntry = nil; // release contents from the cache. +} + #pragma mark - Cropping - (BOOL)isCropEnabled diff --git a/AsyncDisplayKit/Private/ASWeakMap.h b/AsyncDisplayKit/Private/ASWeakMap.h new file mode 100644 index 0000000000..ead428040b --- /dev/null +++ b/AsyncDisplayKit/Private/ASWeakMap.h @@ -0,0 +1,59 @@ +// +// ASWeakMap.h +// AsyncDisplayKit +// +// Created by Chris Danford on 7/11/16. +// +// Copyright (c) 2014-present, Facebook, Inc. All rights reserved. +// This source code is licensed under the BSD-style license found in the +// LICENSE file in the root directory of this source tree. An additional grant +// of patent rights can be found in the PATENTS file in the same directory. +// + +#import + +NS_ASSUME_NONNULL_BEGIN + + +/** + * This class is used in conjunction with ASWeakMap. Instances of this type are returned by an ASWeakMap, + * must retain this value for as long as they want the entry to exist in the map. + */ +@interface ASWeakMapEntry : NSObject + +@property (nonatomic, retain, readonly) Value value; + +@end + + +/** + * This is not a full-featured map. It does not support features like `count` and FastEnumeration because there + * is not currently a need. + * + * This is a map that does not retain keys or values added to it. When both getting and setting, the caller is + * returned a ASWeakMapEntry and must retain it for as long as it wishes the key/value to remain in the map. + * We return a single Entry value to the caller to avoid two potential problems: + * + * 1) It's easier for callers to retain one value (the Entry) and not two (a key and a value). + * 2) Key values are tested for `isEqual` equality. If if a caller asks for a key "A" that is equal to a key "B" + * already in the map, then we need the caller to retain key "B" and not key "A". Returning an Entry simplifies + * the semantics. + * + * The underlying storage is a hash table and the Key type should implement `hash` and `isEqual:`. + */ +@interface ASWeakMap<__covariant Key : NSObject *, Value> : NSObject + +/** + * Read from the cache. The Value object is accessible from the returned ASWeakMapEntry. + */ +- (nullable ASWeakMapEntry *)entryForKey:(Key)key; + +/** + * Put a value into the cache. If an entry with an equal key already exists, then the value is updated on the existing entry. + */ +- (ASWeakMapEntry *)setObject:(Value)value forKey:(Key)key; + +@end + + +NS_ASSUME_NONNULL_END diff --git a/AsyncDisplayKit/Private/ASWeakMap.m b/AsyncDisplayKit/Private/ASWeakMap.m new file mode 100644 index 0000000000..1c9f6896c9 --- /dev/null +++ b/AsyncDisplayKit/Private/ASWeakMap.m @@ -0,0 +1,85 @@ +// +// ASWeakMap.m +// AsyncDisplayKit +// +// Created by Chris Danford on 7/11/16. +// +// Copyright (c) 2014-present, Facebook, Inc. All rights reserved. +// This source code is licensed under the BSD-style license found in the +// LICENSE file in the root directory of this source tree. An additional grant +// of patent rights can be found in the PATENTS file in the same directory. +// + +#import "ASWeakMap.h" + +@interface ASWeakMapEntry () +@property (nonatomic, strong) NSObject *key; +@end + +@implementation ASWeakMapEntry + +- (instancetype)initWithKey:(NSObject *)key value:(NSObject *)value +{ + self = [super init]; + if (self) { + _key = key; + _value = value; + } + return self; +} + +- (void)setValue:(NSObject *)value +{ + _value = value; +} + +@end + + +@interface ASWeakMap () +@property (nonatomic, strong) NSMapTable *hashTable; +@end + +/** + * Implementation details: + * + * The retained size of our keys is potentially very large (for example, a UIImage is commonly part of a key). + * Unfortunately, NSMapTable does not make guarantees about how quickly it will dispose of entries where + * either the key or the value is weak and has been disposed. So, a NSMapTable with "strong key to weak value" is + * unsuitable for our purpose because the strong keys are retained longer than the value and for an indefininte period of time. + * More details here: http://cocoamine.net/blog/2013/12/13/nsmaptable-and-zeroing-weak-references/ + * + * Our NSMapTable is "weak key to weak value" where each key maps to an Entry. The Entry object is responsible + * for retaining both the key and value. Our convention is that the caller must retain the Entry object + * in order to keep the key and the value in the cache. + */ +@implementation ASWeakMap + +- (instancetype)init +{ + self = [super init]; + if (self) { + _hashTable = [NSMapTable weakToWeakObjectsMapTable]; + } + return self; +} + +- (ASWeakMapEntry *)entryForKey:(NSObject *)key +{ + return [self.hashTable objectForKey:key]; +} + +- (ASWeakMapEntry *)setObject:(NSObject *)value forKey:(NSObject *)key +{ + ASWeakMapEntry *entry = [self.hashTable objectForKey:key]; + if (entry != nil) { + // Update the value in the existing entry. + entry.value = value; + } else { + entry = [[ASWeakMapEntry alloc] initWithKey:key value:value]; + [self.hashTable setObject:entry forKey:key]; + } + return entry; +} + +@end diff --git a/AsyncDisplayKitTests/ASWeakMapTests.m b/AsyncDisplayKitTests/ASWeakMapTests.m new file mode 100644 index 0000000000..9f49457797 --- /dev/null +++ b/AsyncDisplayKitTests/ASWeakMapTests.m @@ -0,0 +1,57 @@ +// +// ASWeakMapTests.m +// AsyncDisplayKit +// +// Created by Chris Danford on 7/23/16. +// +// Copyright (c) 2014-present, Facebook, Inc. All rights reserved. +// This source code is licensed under the BSD-style license found in the +// LICENSE file in the root directory of this source tree. An additional grant +// of patent rights can be found in the PATENTS file in the same directory. +// + +#import +#import "ASWeakMap.h" + +NS_ASSUME_NONNULL_BEGIN + +@interface ASWeakMapTests : XCTestCase + +@end + +@implementation ASWeakMapTests + +- (void)testKeyAndValueAreReleasedWhenEntryIsReleased +{ + ASWeakMap *weakMap = [[ASWeakMap alloc] init]; + + __weak NSObject *weakKey; + __weak NSObject *weakValue; + @autoreleasepool { + NSObject *key = [[NSObject alloc] init]; + NSObject *value = [[NSObject alloc] init]; + ASWeakMapEntry *entry = [weakMap setObject:value forKey:key]; + XCTAssertEqual([weakMap entryForKey:key], entry); + + weakKey = key; + weakValue = value; +} + XCTAssertEqual(weakKey, nil); + XCTAssertEqual(weakValue, nil); +} + +- (void)testKeyEquality +{ + ASWeakMap *weakMap = [[ASWeakMap alloc] init]; + NSString *keyA = @"key"; + NSString *keyB = [keyA copy]; // `isEqual` but not pointer equal + NSObject *value = [[NSObject alloc] init]; + + ASWeakMapEntry *entryA = [weakMap setObject:value forKey:keyA]; + ASWeakMapEntry *entryB = [weakMap entryForKey:keyB]; + XCTAssertEqual(entryA, entryB); +} + +@end + +NS_ASSUME_NONNULL_END