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。在我们的项目开发过程中,有两个很常见的场景:
[Logging log:SOME_PAGE_IMPRESSION_EVENT eventData:@{ @"some_value": someObject.someValue, }];
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; }
这样做有几个坏处:
nil
的值不论是传空字符串还是不传,在语义上都不是很正确,甚至还可能会导致一些奇怪的 server bug 所以我们希望 NSDictionary
用起来是这样的:
nil
的时候不会 crash nil
以后它对应的 key 的确存在,且能取到值(NSNull) 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,改造的目的是让他们都能通过。
根据 crash log,dictionary 主要有三个入口传入 nil object:
dictionaryWithObjects:forKeys:count:
setObject:forKey
的时候 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 以后,所有测试用例都可以顺利通过了。
如上修改 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 中拿走吧。