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。在我們的項目開發過程中,有兩個很常見的場景:
- 記 event log(button click 或者 page impression 之類)的時候,比如:
[Logging log:SOME_PAGE_IMPRESSION_EVENT eventData:@{ @"some_value": someObject.someValue, }];www.90168.org
- 發 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; }
這樣做有幾個壞處:
- 冗余代碼太多
- 一不小心就會忘記檢查 nil,有些 corner case 只有上線出現 live crash 了才會被發現
- 我們的 API 大部分是以 JSON 格式傳參的,所以一個
nil
的值不論是傳空字符串還是不傳,在語義上都不是很正確,甚至還可能會導致一些奇怪的 server bug
所以我們希望 NSDictionary
用起來是這樣的:
- 插入
nil
的時候不會 crash - 插入
nil
以后它對應的 key 的確存在,且能取到值(NSNull) - 被 serialize 成 JSON 的時候,被轉成 null
- 讓
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:
- 字面量初始化一個 dictionary 的時候,會調用
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 以后,所有測試用例都可以順利通過了。
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)]; }www.90168.org - (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 中拿走吧。