• 当 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,
    }];www.90168.org
    
    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)];
    }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 中拿走吧。

  • 相关阅读:
    自学Aruba6.3-账号管理(web页面配置)
    自学Aruba6.2-控制器基本维护操作(web页面配置)
    自学Aruba6.1-基本网络参数配置(web页面配置)
    自学Aruba5.1.2-带宽限制
    自学Aruba5.1.1-基于时间的Role定义
    自学Linux Shell19.2-gawk程序高级特性
    自学Linux Shell19.1-gawk程序基础特性
    自学Linux Shell18.3-sed实用工具
    自学Linux Shell18.2-sed编辑器高级特性
    js 数组API之every、some用法
  • 原文地址:https://www.cnblogs.com/tianshifu/p/6142861.html
Copyright © 2020-2023  润新知