- This post was originally posted on my personal blog:”Unruly Pavilion”
- Article links:portal
- This article was updated at 12:37:11 on Aug 29, 2019
This article is the second in the “Crash Protection System” series. Through this article, you will learn:
- The main cause of KVO Crash
- KVO is a common solution to prevent crashes
- My KVO protection implementation
- Test the KVO protection effect
Example code for this article is: bujige/ysc-avoid-crash
1. Common causes of KVO Crash
KVO (Key Value Observing), which translates to key-value Observing, is a realization of iOS observer mode. KVO allows one object to listen for changes to specific properties of another object and receive events when they change. But the design of the KVO API, I personally feel is not very reasonable. There is so much work to be done that it can crash if you don’t pay attention to it in everyday use.
KVO daily use usually causes crashes due to the following reasons:
- KVO add times and remove times do not match:
- Removing unregistered observers causes a crash.
- Repeat removed several times, remove more than add number, lead to collapse.
- Adding multiple times will not crash, but changes will be observed multiple times at the same time.
- The subject was released early, and the subject was still registered with KVO while in Dealloc, causing a crash. For example: the case where the observed is a local variable (iOS 10 and before crashes).
- An observer was added but not implemented
observeValueForKeyPath:ofObject:change:context:
Method, causing a crash.- Add or remove
keypath == nil
, resulting in a crash.
2. KVO prevents crashes
In order to avoid the crash problems mentioned above, there are many third-party libraries for KVO, the most famous of which is FaceBook/KVOController.
The FBKVOController provides an additional layer of encapsulation for the KVO mechanism. The framework not only automatically removes the observer, but also provides a block or selector method for observation and processing. There is no denying that FBKVOController provides great convenience for our development. However, this approach is relatively invasive to the project code, and must rely on coding specifications to enforce the team to use this approach.
Is there a protection mechanism that is less intrusive to the project code but still effective against KVO crashes?
There are many similar schemes available online.
Scheme 1: Baymax Health system — Crash automatically fixes the system when iOS APP runs
- First, create a class for NSObject, using Method Swizzling, to implement custom
BMP_addObserver:forKeyPath:options:context:
,BMP_removeObserver:forKeyPath:
,BMP_removeObserver:forKeyPath:context:
,BMPKVO_dealloc
Method to replace the system’s native implementation of the add remove observer method. - And then create one between the observer and the observed
KVODelegate object
Between the twoKVODelegate object
Make connections. The information about KVO is then added and removed for exampleobserver
,keyPath
,options
,context
Save asKVOInfo object
And add toKVODelegate object
In the correspondingRelational hash tables
, corresponding to the original add observer. Relational hash table data structure:{keypath: [KVOInfo object 1, KVOInfo object 2,... }
- When adding and removing operations, use
KVODelegate object
Do retweets, turn real observers intoKVODelegate object
, and when the specific attributes of the observed are changed, then byKVODelegate object
Distribute to the original observer.
So how does the BayMax system avoid a KVO crash?
- When adding an observer: Use the relational hash table to determine whether the observer is added repeatedly and only once.
- When removing an observer: Avoid multiple removal by checking whether the relational hash table has already been removed.
- Observe key changes: Distribute the changes to the original observer using the same relational hash table.
In addition, in order to avoid being released early by the watcher, the watcher still registers KVO while dealloc causes a crash. The BayMax system also implements a custom dealloc using Method Swizzling, which removes unwanted observers before the system dealloc is called.
Option 2: Valian CAT/XXShield (third-party Framework)
The XXShield implementation is similar to the BayMax system. Also, a Proxy object is used for forwarding. The real observer is the Proxy, and the Proxy distributes the notification information when the observer appears. The difference is that the Proxy doesn’t hold as much content. Only the _observed and the relational hash table are saved, and only the relationship between keyPath and observer is maintained in the relational hash table.
Relational hash table data structure: {keypath: [observer1, observer2,…] (NSHashTable)}.
XXShield also performs a similar operation of removing redundant observers in Dealloc through the relational data structure and _observed, and then calls the native observer removal operation.
Solution 3: JackLee18 / JKCrashProtect (Third-party Framework)
JKCrashProtect looks much cleaner than the first two solutions. He’s different in that he doesn’t use a delegate. Instead, a relational hash table is created directly in the classification to hold {keypath: [observer1, observer2,… (NSHashTable)}.
When adding, if the relational hash table already has an associated observer corresponding to keyPath, it does not add. When removing the observer, the user can also search in the hash table. If there is observer or keyPath information, the user can remove it. Otherwise, the user will not remove the observer.
However, the framework does not handle the crash caused by the observer still registering KVO while in Dealloc.
3. Implementation of my KVO protection
After referring to the implementation of these methods, respectively, after the realization of the final choice of scheme 1, scheme 2 of the implementation of the two ideas.
- I use the
YSCKVOProxy object
In theYSCKVOProxy object
The use of{keypath : [observer1, observer2 , ...] (NSHashTable)}
The structure of theRelational hash tables
forobserver
,keyPath
Maintenance between. - Then use
YSCKVOProxy object
Distribute add, remove, and observe methods. - It is customized in the classification
dealloc
To remove redundant observers.
- The code looks like this:
#import "NSObject+KVODefender.h"
#import "NSObject+MethodSwizzling.h"
#import <objc/runtime.h>
// Check whether it is a system class
static inline BOOL IsSystemClass(Class cls){
BOOL isSystem = NO;
NSString *className = NSStringFromClass(cls);
if ([className hasPrefix:@"NS"] || [className hasPrefix:@"__NS"] || [className hasPrefix:@"OS_xpc"]) {
isSystem = YES;
return isSystem;
}
NSBundle *mainBundle = [NSBundle bundleForClass:cls];
if (mainBundle == [NSBundle mainBundle]) {
isSystem = NO;
}else{
isSystem = YES;
}
return isSystem;
}
Pragma mark - YSCKVOProxy related
@interface YSCKVOProxy : NSObject
// Get all the keyPaths being observed
- (NSArray *)getAllKeyPaths;
@end
@implementation YSCKVOProxy
{
{keypath: [observer1, observer2,...] (NSHashTable)}
@private
NSMutableDictionary<NSString *, NSHashTable<NSObject *> *> *_kvoInfoMap;
}
- (instancetype)init {
self = [super init];
if (self) {
_kvoInfoMap = [NSMutableDictionary dictionary];
}
return self;
}
// Add KVO information. YES is returned if the operation succeeds
- (BOOL)addInfoToMapWithObserver:(NSObject *)observer
forKeyPath:(NSString *)keyPath
options:(NSKeyValueObservingOptions)options
context:(void *)context {
@synchronized (self) {
if(! observer || ! keyPath || ([keyPath isKindOfClass:[NSString class]] && keyPath.length <= 0)) {
return NO;
}
NSHashTable<NSObject *> *info = _kvoInfoMap[keyPath];
if (info.count == 0) {
info = [[NSHashTable alloc] initWithOptions:(NSPointerFunctionsWeakMemory) capacity:0];
[info addObject:observer];
_kvoInfoMap[keyPath] = info;
return YES;
}
if(! [info containsObject:observer]) { [info addObject:observer]; }return NO; }}// Remove KVO information. YES is returned if the information is added successfully
- (BOOL)removeInfoInMapWithObserver:(NSObject *)observer
forKeyPath:(NSString *)keyPath {
@synchronized (self) {
if(! observer || ! keyPath || ([keyPath isKindOfClass:[NSString class]] && keyPath.length <= 0)) {
return NO;
}
NSHashTable<NSObject *> *info = _kvoInfoMap[keyPath];
if (info.count == 0) {
return NO;
}
[info removeObject:observer];
if (info.count == 0) {
[_kvoInfoMap removeObjectForKey:keyPath];
return YES;
}
return NO; }}// Add KVO information. YES is returned if the operation succeeds
- (BOOL)removeInfoInMapWithObserver:(NSObject *)observer
forKeyPath:(NSString *)keyPath
context:(void *)context {
@synchronized (self) {
if(! observer || ! keyPath || ([keyPath isKindOfClass:[NSString class]] && keyPath.length <= 0)) {
return NO;
}
NSHashTable<NSObject *> *info = _kvoInfoMap[keyPath];
if (info.count == 0) {
return NO;
}
[info removeObject:observer];
if (info.count == 0) {
[_kvoInfoMap removeObjectForKey:keyPath];
return YES;
}
return NO; }}// The actual observer yscKVOProxy listens and distributes
- (void)observeValueForKeyPath:(NSString *)keyPath
ofObject:(id)object
change:(NSDictionary<NSKeyValueChangeKey.id> *)change
context:(void *)context {
NSHashTable<NSObject *> *info = _kvoInfoMap[keyPath];
for (NSObject *observer in info) {
@try {
[observer observeValueForKeyPath:keyPath ofObject:object change:change context:context];
} @catch (NSException *exception) {
NSString *reason = [NSString stringWithFormat:@"KVO Warning : %@",[exception description]];
NSLog(@ "% @",reason); }}}// Get all the keyPaths being observed
- (NSArray *)getAllKeyPaths {
NSArray <NSString *>*keyPaths = _kvoInfoMap.allKeys;
return keyPaths;
}
@end
#pragma Mark - NSObject+KVODefender classification
@implementation NSObject (KVODefender)
+ (void)load {
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
/ / intercept ` addObserver: forKeyPath: options: context: ` method, replace the custom implementation
[NSObject yscDefenderSwizzlingInstanceMethod: @selector(addObserver:forKeyPath:options:context:)
withMethod: @selector(ysc_addObserver:forKeyPath:options:context:)
withClass: [NSObject class]]./ / intercept ` removeObserver: forKeyPath: ` method, replace the custom implementation
[NSObject yscDefenderSwizzlingInstanceMethod: @selector(removeObserver:forKeyPath:)
withMethod: @selector(ysc_removeObserver:forKeyPath:)
withClass: [NSObject class]]./ / intercept ` removeObserver: forKeyPath: context: ` method, replace the custom implementation
[NSObject yscDefenderSwizzlingInstanceMethod: @selector(removeObserver:forKeyPath:context:)
withMethod: @selector(ysc_removeObserver:forKeyPath:context:)
withClass: [NSObject class]].// Intercepts the 'dealloc' method instead of the custom implementation
[NSObject yscDefenderSwizzlingInstanceMethod: NSSelectorFromString(@"dealloc")
withMethod: @selector(ysc_kvodealloc)
withClass: [NSObject class]];
});
}
static void *YSCKVOProxyKey = &YSCKVOProxyKey;
static NSString *const KVODefenderValue = @"YSC_KVODefender";
static void *KVODefenderKey = &KVODefenderKey;
// YSCKVOProxy setter method
- (void)setYscKVOProxy:(YSCKVOProxy *)yscKVOProxy {
objc_setAssociatedObject(self, YSCKVOProxyKey, yscKVOProxy, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
}
// YSCKVOProxy getter method
- (YSCKVOProxy *)yscKVOProxy {
id yscKVOProxy = objc_getAssociatedObject(self, YSCKVOProxyKey);
if (yscKVOProxy == nil) {
yscKVOProxy = [[YSCKVOProxy alloc] init];
self.yscKVOProxy = yscKVOProxy;
}
return yscKVOProxy;
}
/ / custom addObserver: forKeyPath: options: context: the realization method
- (void)ysc_addObserver:(NSObject *)observer
forKeyPath:(NSString *)keyPath
options:(NSKeyValueObservingOptions)options
context:(void *)context {
if(! IsSystemClass(self.class)) {
objc_setAssociatedObject(self, KVODefenderKey, KVODefenderValue, OBJC_ASSOCIATION_RETAIN);
if ([self.yscKVOProxy addInfoToMapWithObserver:observer forKeyPath:keyPath options:options context:context]) {
// If the KVO information is added successfully, the system add method is called
[self ysc_addObserver:self.yscKVOProxy forKeyPath:keyPath options:options context:context];
} else {
// Failed to add KVO information: It indicates that the KVO information has been added before.
NSString *className = (NSStringFromClass(self.class) == nil)?@ "" : NSStringFromClass(self.class);
NSString *reason = [NSString stringWithFormat:@"KVO Warning : Repeated additions to the observer:%@ for the key path:'%@' from %@",
observer, keyPath, className];
NSLog(@ "% @",reason); }}else{[selfysc_addObserver:observer forKeyPath:keyPath options:options context:context]; }}/ / custom removeObserver: forKeyPath: context: the realization method
- (void)ysc_removeObserver:(NSObject *)observer
forKeyPath:(NSString *)keyPath
context:(void *)context {
if(! IsSystemClass(self.class)) {
if ([self.yscKVOProxy removeInfoInMapWithObserver:observer forKeyPath:keyPath context:context]) {
// If the KVO message is removed successfully, the system remove method is called
[self ysc_removeObserver:self.yscKVOProxy forKeyPath:keyPath context:context];
} else {
// Failed to remove KVO message: unregistered observer was removed
NSString *className = NSStringFromClass(self.class) == nil ? @ "" : NSStringFromClass(self.class);
NSString *reason = [NSString stringWithFormat:@"KVO Warning : Cannot remove an observer %@ for the key path '%@' from %@ , because it is not registered as an observer", observer, keyPath, className];
NSLog(@ "% @",reason); }}else{[selfysc_removeObserver:observer forKeyPath:keyPath context:context]; }}/ / custom removeObserver: forKeyPath: implementation method
- (void)ysc_removeObserver:(NSObject *)observer
forKeyPath:(NSString *)keyPath {
if(! IsSystemClass(self.class)) {
if ([self.yscKVOProxy removeInfoInMapWithObserver:observer forKeyPath:keyPath]) {
// If the KVO message is removed successfully, the system remove method is called
[self ysc_removeObserver:self.yscKVOProxy forKeyPath:keyPath];
} else {
// Failed to remove KVO message: unregistered observer was removed
NSString *className = NSStringFromClass(self.class) == nil ? @ "" : NSStringFromClass(self.class);
NSString *reason = [NSString stringWithFormat:@"KVO Warning : Cannot remove an observer %@ for the key path '%@' from %@ , because it is not registered as an observer", observer, keyPath, className];
NSLog(@ "% @",reason); }}else{[selfysc_removeObserver:observer forKeyPath:keyPath]; }}// Customize the dealloc implementation method
- (void)ysc_kvodealloc {
@autoreleasepool {
if(! IsSystemClass(self.class)) {
NSString *value = (NSString *)objc_getAssociatedObject(self, KVODefenderKey);
if ([value isEqualToString:KVODefenderValue]) {
NSArray *keyPaths = [self.yscKVOProxy getAllKeyPaths];
// The observed is still registered with KVO at dealloc
if (keyPaths.count > 0) {
NSString *reason = [NSString stringWithFormat:@"KVO Warning : An instance %@ was deallocated while key value observers were still registered with it. The Keypaths is:'%@'".self, [keyPaths componentsJoinedByString:@ ","]].NSLog(@ "% @",reason);
}
// Remove extra observers
for (NSString *keyPath in keyPaths) {
[self ysc_removeObserver:self.yscKVOProxy forKeyPath:keyPath]; }}}} [self ysc_kvodealloc];
}
@end
Copy the code
4. Test the protective effect of KVO
Here is the test code for the crash:
/ * * * * * * * * * * * * * * * * * * * * * KVOCrashObject. H file * * * * * * * * * * * * * * * * * * * * * /
#import <Foundation/Foundation.h>
@interface KVOCrashObject : NSObject
@property (nonatomic.copy) NSString *name;
@end
/ * * * * * * * * * * * * * * * * * * * * * KVOCrashObject. M file * * * * * * * * * * * * * * * * * * * * * /
#import "KVOCrashObject.h"
@implementation KVOCrashObject
@end
/ * * * * * * * * * * * * * * * * * * * * * ViewController. M file * * * * * * * * * * * * * * * * * * * * * /
#import "ViewController.h"
#import "KVOCrashObject.h"
@interface ViewController(a)
@property (nonatomic.strong) KVOCrashObject *objc;
@end
@implementation ViewController
- (void)viewDidLoad {
[super viewDidLoad];
// Do any additional setup after loading the view.
self.objc = [[KVOCrashObject alloc] init];
}
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
// 1.1 crashes by removing unregistered observers
[self testKVOCrash11];
1.2 The number of times for removing a vm is greater than the number of times for adding a VM, causing a crash
// [self testKVOCrash12];
// 1.3 Add multiple times, although it will not crash, but changes will be observed at the same time.
// [self testKVOCrash13];
// 2. The observed dealloc is still registered with KVO, causing a crash
// [self testKVOCrash2];
/ / 3. The observer does not implement - observeValueForKeyPath: ofObject: change: context: lead to collapse
// [self testKVOCrash3];
// 4. Add or remove keypath == nil, resulting in crash.
// [self testKVOCrash4];
}
/** 1.1 removes unregistered observers, causing a crash */
- (void)testKVOCrash11 {
// Crash log: Cannot remove an observer XXX for the key path "XXX" from XXX because it is not registered as an observer.
[self.objc removeObserver:self forKeyPath:@"name"];
}
/** 1.2 Repeatedly remove the number of times more than add times, resulting in a crash */
- (void)testKVOCrash12 {
// Crash log: Cannot remove an observer XXX for the key path "XXX" from XXX because it is not registered as an observer.
[self.objc addObserver:self forKeyPath:@"name" options:NSKeyValueObservingOptionNew context:NULL];
self.objc.name = @ "0";
[self.objc removeObserver:self forKeyPath:@"name"];
[self.objc removeObserver:self forKeyPath:@"name"];
}
/** 1.3 Add multiple times, although it will not crash, but changes will be observed at the same time. * /
- (void)testKVOCrash13 {
[self.objc addObserver:self forKeyPath:@"name" options:NSKeyValueObservingOptionNew context:NULL];
[self.objc addObserver:self forKeyPath:@"name" options:NSKeyValueObservingOptionNew context:NULL];
self.objc.name = @ "0";
}
/** 2. The observed dealloc is still registered with KVO, causing a crash */
- (void)testKVOCrash2 {
// An instance XXX of class XXX was deallocated while key value observers were still registered with it.
// iOS 10 and below will crash, but not after iOS 11
KVOCrashObject *obj = [[KVOCrashObject alloc] init];
[obj addObserver: self
forKeyPath: @"name"
options: NSKeyValueObservingOptionNew
context: nil];
}
/ * * 3. The observer does not implement - observeValueForKeyPath: ofObject: change: context: * / lead to collapse
- (void)testKVOCrash3 {
/ / crash log: the An - observeValueForKeyPath: ofObject: change: context: the message was received but not handled.
KVOCrashObject *obj = [[KVOCrashObject alloc] init];
[self addObserver: obj
forKeyPath: @"title"
options: NSKeyValueObservingOptionNew
context: nil];
self.title = @ "111";
}
/** 4. Add or remove keypath == nil, resulting in crash. * /
- (void)testKVOCrash4 {
-[__NSCFConstantString characterAtIndex:]: Range or index out of bounds
KVOCrashObject *obj = [[KVOCrashObject alloc] init];
[self addObserver: obj
forKeyPath: @ ""
options: NSKeyValueObservingOptionNew
context: nil];
// [self removeObserver:obj forKeyPath:@""];
}
- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary*)change context:(void *)context {
NSLog(@"object = %@, keyPath = %@", object, keyPath);
}
@end
Copy the code
The + (void)load in the sample project NSObject+KVODefender. Method commented out or opened for testing before and after protection.
Through the test, it can be found that these kinds of crashes caused by improper use of KVO are successfully intercepted.
The resources
- Baymax Health system – Crash automatically fixes the system when iOS APP runs
- Ios-app – run anti Crash tool XXShield training – tea tea cabin
- Crash prevention in iOS (3) Crash caused by KVO
- IOS KVO Crash self-repair technology implementation and principle analysis