转载

当 NSDictionary 遇见 nil

Demo project: NSDictionary-NilSafe

问题

相信用 Objective-C 开发 iOS 应用的人对下面的 crash 不会陌生:

  • *** -[__NSPlaceholderDictionary initWithObjects:forKeys:count:]: attempt to insert nil object from objects[1]
  • *** setObjectForKey: key cannot be nil
  • *** setObjectForKey: object cannot be nil

Objective-C 里的 NSDictionary 是不支持 nil 作为 key 或者 value 的。但是总会有一些地方会偶然往 NSDictionary 里插入 nil value。在我们的项目开发过程中,有两个很常见的场景:

  1. 记 event log(button click 或者 page impression 之类)的时候,比如:
[Logging log:SOME_PAGE_IMPRESSION_EVENT eventData:@{     @"some_value": someObject.someValue, }]; 
  1. 发 API request 的时候,比如:
NSDictionary *params = @{       @"some_key": someValue, }; [[APIClient sharedClient] post:someURL params:params callback:callback]; 

最初,我们的代码里存在很多如下片段:

[Logging log:SOME_PAGE_IMPRESSION_EVENT eventData:@{     @"some_value": someObject.someValue ?: @"", }]; 
NSDictionary *params = @{       @"some_key": someValue ?: @"", }; 

或者:

NSMutableDictionary *params = [NSMutableDictionary dictionary];   if (someValue) {       params[@"some_key"] = someValue; } 

这样做有几个坏处:

  1. 冗余代码太多
  2. 一不小心就会忘记检查 nil,有些 corner case 只有上线出现 live crash 了才会被发现
  3. 我们的 API 大部分是以 JSON 格式传参的,所以一个 nil 的值不论是传空字符串还是不传,在语义上都不是很正确,甚至还可能会导致一些奇怪的 server bug

所以我们希望 NSDictionary 用起来是这样的:

  1. 插入 nil 的时候不会 crash
  2. 插入 nil 以后它对应的 key 的确存在,且能取到值(NSNull)
  3. 被 serialize 成 JSON 的时候,被转成 null
  4. NSNull 更接近 nil ,可以吃任何方法不 crash

测试用例

这个任务很适合测试驱动开发,所以可以把上一节的需求简单转化成以下测试用例:

- (void)testLiteral {     id nilVal = nil;     id nilKey = nil;     id nonNilKey = @"non-nil-key";     id nonNilVal = @"non-nil-val";     NSDictionary *dict = @{         nonNilKey: nilVal,         nilKey: nonNilVal,     };     XCTAssertEqualObjects([dict allKeys], @[nonNilKey]);     XCTAssertNoThrow([dict objectForKey:nonNilKey]);     id val = dict[nonNilKey];     XCTAssertEqualObjects(val, [NSNull null]);     XCTAssertNoThrow([val length]);     XCTAssertNoThrow([val count]);     XCTAssertNoThrow([val anyObject]);     XCTAssertNoThrow([val intValue]);     XCTAssertNoThrow([val integerValue]); }  - (void)testKeyedSubscript {     NSMutableDictionary *dict = [NSMutableDictionary dictionary];     id nilVal = nil;     id nilKey = nil;     id nonNilKey = @"non-nil-key";     id nonNilVal = @"non-nil-val";     dict[nonNilKey] = nilVal;     dict[nilKey] = nonNilVal;     XCTAssertEqualObjects([dict allKeys], @[nonNilKey]);     XCTAssertNoThrow([dict objectForKey:nonNilKey]); }  - (void)testSetObject {     NSMutableDictionary *dict = [NSMutableDictionary dictionary];     id nilVal = nil;     id nilKey = nil;     id nonNilKey = @"non-nil-key";     id nonNilVal = @"non-nil-val";     [dict setObject:nilVal forKey:nonNilKey];     [dict setObject:nonNilVal forKey:nilKey];     XCTAssertEqualObjects([dict allKeys], @[nonNilKey]);     XCTAssertNoThrow([dict objectForKey:nonNilKey]); }  - (void)testArchive {     id nilVal = nil;     id nilKey = nil;     id nonNilKey = @"non-nil-key";     id nonNilVal = @"non-nil-val";     NSDictionary *dict = @{         nonNilKey: nilVal,         nilKey: nonNilVal,     };     NSData *data = [NSKeyedArchiver archivedDataWithRootObject:dict];     NSDictionary *dict2 = [NSKeyedUnarchiver unarchiveObjectWithData:data];     XCTAssertEqualObjects([dict2 allKeys], @[nonNilKey]);     XCTAssertNoThrow([dict2 objectForKey:nonNilKey]); }  - (void)testJSON {     id nilVal = nil;     id nilKey = nil;     id nonNilKey = @"non-nil-key";     id nonNilVal = @"non-nil-val";     NSDictionary *dict = @{         nonNilKey: nilVal,         nilKey: nonNilVal,     };     NSData *data = [NSJSONSerialization dataWithJSONObject:dict options:0 error:NULL];     NSString *jsonString = [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding];     NSString *expectedString = @"{/"non-nil-key/":null}";     XCTAssertEqualObjects(jsonString, expectedString); } 

以上代码在 demo project 里可以找到,改造以前,所有 case 应该都会 fail,改造的目的是让他们都能通过。

Method Swizzling

根据 crash log,dictionary 主要有三个入口传入 nil object:

  1. 字面量初始化一个 dictionary 的时候,会调用 dictionaryWithObjects:forKeys:count:
  2. 直接调用 setObject:forKey 的时候
  3. 通过下标方式赋值的时候,会调用 setObject:forKeyedSubscript:

所以可以通过 method swizzling,把这四个方法(还有 initWithObjects:forKeys:count: ,虽然没有发现哪里有调用到它)替换成自己的方法,在 key 为 nil 的时候忽略,在 value 为 nil 的时候,替换为 NSNull 再插入。

其中 setObject:forKey 方法因为是通过 class cluster 实现的,所以实际替换的是 __NSDictionaryM 的方法。

dictionaryWithObjects:forKeys:count: 为例:

+ (instancetype)gl_dictionaryWithObjects:(const id [])objects forKeys:(const id<NSCopying> [])keys count:(NSUInteger)cnt {     id safeObjects[cnt];     id safeKeys[cnt];     NSUInteger j = 0;     for (NSUInteger i = 0; i < cnt; i++) {         id key = keys[i];         id obj = objects[i];         if (!key) {             continue;         }         if (!obj) {             obj = [NSNull null];         }         safeKeys[j] = key;         safeObjects[j] = obj;         j++;     }     return [self gl_dictionaryWithObjects:safeObjects forKeys:safeKeys count:j]; } 

完整代码参见 GitHub 源文件 。

引入这个 category 以后,所有测试用例都可以顺利通过了。

NSNull 的安全性

如上修改 NSDictionary 以后,从 dictionary 里拿到 NSNull 的几率就变高了,所以我们希望 NSNull 可以像 nil 一样,接受所有方法调用并且返回 nil/0。

起初,我们用 libextobjc 里的 EXTNil 作为 placeholder 让 null 更安全。后来发觉其实可以参照 EXTNil 的实现直接 swizzle NSNull 本身的方法,让它可以接受所有方法调用:

- (NSMethodSignature *)gl_methodSignatureForSelector:(SEL)aSelector {     NSMethodSignature *sig = [self gl_methodSignatureForSelector:aSelector];     if (sig) {         return sig;     }     return [NSMethodSignature signatureWithObjCTypes:@encode(void)]; }  - (void)gl_forwardInvocation:(NSInvocation *)anInvocation {     NSUInteger returnLength = [[anInvocation methodSignature] methodReturnLength];     if (!returnLength) {         // nothing to do         return;     }      // set return value to all zero bits     char buffer[returnLength];     memset(buffer, 0, returnLength);      [anInvocation setReturnValue:buffer]; } 

总结

至此,我们解决了第一节中提到的所有问题,有了一个 nil safe 的 NSDictionary。这个方案在实际项目中使用了一年多,效果良好,唯一遇到过的一个坑是往 NSUserDefaults 里写入带 NSNull 的 dictionary 的时候会 crash: Attempt to insert non-property list object 。当然这不是这个方案本身带来的问题,解决方法是把 dictionary archive 或者 serialize 成 JSON 后再写入 User Defaults,但是话说回来,复杂的结构体还是考虑从 User Defaults 中拿走吧。

原文  http://tech.glowing.com/cn/how-we-made-nsdictionary-nil-safe/
正文到此结束
Loading...