做iOS开发这些年,NSDictionary和NSMutableDictionary几乎每天都在写。无论是网络请求的JSON解析、本地配置的读取,还是内存缓存的设计,字典都是绕不开的基础容器。很多人觉得这两个类简单,无非是存、取、遍历,但真到面试深入问、或者线上出现诡异的崩溃时,才发现不少细节自己根本没搞明白。比如:为什么用MutableString做key会查不到值?为什么字典明明是copy出来的,修改里面的数组却影响了原字典?为什么在enumerateKeysAndObjectsUsingBlock里删除一个键会崩溃?这篇文章就是围绕这两个类,从概念、方法、原理到实战坑,一次性梳理完整。适合刚开始学Objective-C的读者,也适合写过一阵子但想系统回顾字典特性的开发者。
1. NSDictionary与NSMutableDictionary的核心设计思路
1.1 字典容器的本质:为什么我们需要“键值对”
用一个生活化的例子:数组像一列排队的人,你想找“名字叫张三的人”,只能从头到尾一个个看,这就是线性查找。而字典像一本电话簿,你直接通过姓名翻到对应的页码,不需要把整本电话簿读一遍。在程序里,字典就是通过key快速定位value的容器。它解决的痛点是“按唯一标识查找数据”,比如用户ID对应User对象、订单号对应订单详情、配置项名对应配置值。
如果所有数据都用数组存,每次查找都要遍历,时间复杂度是O(n);数据量大了以后非常慢。字典的底层设计目标是让查找接近O(1),也就是无论里面有多少数据,平均只需几次运算就能找到目标。正因为这种特性,字典在缓存、路由表、白名单、消息映射等场景中都是首选。
1.2 不可变与可变的分工:NSDictionary和NSMutableDictionary的定位
OC里很多容器都分不可变和可变两个版本,NSDictionary和NSMutableDictionary就是最典型的组合。NSDictionary在初始化之后,不能添加、删除、修改任何键值对。这听起来像是限制,但其实是刻意设计。只读数据用不可变字典,可以保证数据在传递过程中不会被意外篡改,比如配置表、用户信息快照、接口返回的数据。即便多个对象持有同一个NSDictionary实例,也不用担心被别人悄悄改掉。
NSMutableDictionary则允许动态修改,适合需要频繁增删改的场景,比如运行时的内存缓存、临时表单数据、会话上下文。不可变字典的实例方法里没有setObject:forKey:,一旦你用一个NSDictionary类型的指针去调用可变方法,编译时会收到警告,运行时直接崩溃。反过来,用可变字典赋值给不可变字典指针,只是“视角”变只读了,实际对象仍然可变,如果其他地方持有的是同一个可变实例,依然可以改。所以不可变只代表接口不可变,不代表底层绝对不可变,这个区别要刻在脑子里。
1.3 底层存储逻辑:哈希表与“顺序不稳定”的真相
NSDictionary的内部实现是哈希表结构。存入一个键值对时,会对key对象调用hash方法得到一个哈希值,然后通过哈希值映射到存储区域中的某个位置。当多个key的哈希值映射到同一个位置时,就发生哈希冲突,系统会用额外的处理机制来维护,比如链表法或开放寻址。
这种设计带来的直接结果就是:字典不保证元素的插入顺序。很多新人在日志里看到打印顺序跟插入顺序不一样,就怀疑是系统bug,其实这是哈希映射的正常现象。如果你需要稳定顺序,应该额外维护一个存放key的数组,或者使用系统提供的NSOrderedSet配合逻辑实现有序字典。另外,key对象的hash方法和isEqual:方法必须匹配,两个对象只要isEqual:返回相等,hash也必须相等,否则会出现“明明字典里有这个key,却取不到值”的诡异问题。这一点在自定义对象作为key时尤其致命。
2. NSDictionary常用功能与操作细节
2.1 创建字典的多种方式与推荐写法
NSDictionary提供了很多构造方法,最常用的是下面这些:
// 方式一:字典字面量(推荐) NSDictionary *dict1 = @{@"name": @"张三", @"age": @18}; // 方式二:键值列表方式 NSDictionary *dict2 = [NSDictionary dictionaryWithObjectsAndKeys: @"张三", @"name", @18, @"age", nil]; // 方式三:对象数组与key数组对应 NSArray *values = @[@"张三", @18]; NSArray *keys = @[@"name", @"age"]; NSDictionary *dict3 = [NSDictionary dictionaryWithObjects:values forKeys:keys]; // 方式四:从已有字典创建 NSDictionary *dict4 = [[NSDictionary alloc] initWithDictionary:dict1]; // 方式五:从文件或plist读取 NSDictionary *dict5 = [NSDictionary dictionaryWithContentsOfFile:path];方式二有个必须注意的规则:参数顺序是“对象、key、对象、key”,最后必须以nil结尾。如果中间出现某个对象或key为nil,会导致后面的数据全部无法添加,这属于C语言的隐式限制。方式一最推荐,语言层面规定了key和value都不能为nil,一旦为nil会直接抛出异常,避免静默失败。方式三中objects和keys数组数量必须一致,否则行为未定义。从文件读取时,文件格式必须是plist或XML格式的字典,否则会返回nil。实际开发中,字面量创建简单、可读性强,但是要注意key必须遵循NSCopying协议,这一点在后面会详细讲。
2.2 字典读取与遍历的四种主流方式
读取单个键值最简单的是objectForKey:,等价写法是下标语法。看一下:
NSString *name = [dict1 objectForKey:@"name"]; NSString *age = dict1[@"age"]; // 效果相同如果key不存在,返回nil,这一点很安全。但要注意返回对象的类型是否是期望的类型,因为字典是异构容器,value可以是任意对象。如果要遍历整个字典,有四种常见的姿势:
// 方式一:for-in快速枚举,默认枚举key for (id key in dict) { id value = dict[key]; NSLog(@"%@ -> %@", key, value); } // 方式二:通过allKeys遍历 for (id key in [dict allKeys]) { id value = dict[key]; } // 方式三:block遍历,更推荐 [dict enumerateKeysAndObjectsUsingBlock:^(id key, id obj, BOOL *stop) { NSLog(@"%@ -> %@", key, obj); if ([key isEqualToString:@"stopKey"]) { *stop = YES; } }]; // 方式四:使用NSEnumerator NSEnumerator *enumerator = [dict keyEnumerator]; id key; while ((key = [enumerator nextObject]) != nil) { id value = dict[key]; }性能方面,for-in和allKeys遍历在数据量很小的时候差别可忽略。但allKeys会额外生成一个数组,如果字典很大,内存开销会增加。block遍历是官方推荐方式,因为它能在遍历中通过*stop提前停止,并且直接同时拿到key和value。值得注意:在block遍历中你不能修改字典结构,否则会抛出“collection was mutated while being enumerated”异常。遍历顺序同样没有保证。
2.3 常用辅助方法:数量、比较、排序与描述
除了读取,日常还会用到几个常用方法:
- count:返回键值对数量。
- allKeys / allValues:获取所有key或value数组。
- keysSortedByValueUsingSelector::按value排序并返回key数组。
- keysSortedByValueWithOptions:usingComparator::传入block进行自定义排序,返回key数组。
- isEqualToDictionary::判断两个字典内容是否相等。
- isEqual::本质上也是比较字典内容。
- hash:根据字典内容生成哈希值。
- description:返回字典内容的字符串描述,便于调试日志。
下面这段代码演示排序的应用:
NSDictionary *scores = @{@"A": @88, @"B": @95, @"C": @70}; NSArray *sortedKeys = [scores keysSortedByValueWithOptions:0 usingComparator:^NSComparisonResult(id obj1, id obj2) { return [obj1 compare:obj2]; }]; NSLog(@"%@", sortedKeys); // 输出按value升序排列的key数组注意keysSortedByValue...返回的是key数组,不是排序后的字典,字典本身没有顺序概念。如果你需要维护一个“按分数从高到低读出人名”的逻辑,这种方式非常方便。isEqualToDictionary:比较的是内容而非指针,两个不同内存地址但键值完全相同的字典会产生相等判断。
3. NSMutableDictionary的可变功能与实操要点
3.1 初始化与容量规划:为什么建议预先指定容量
NSMutableDictionary除了继承NSDictionary的基本创建方法,还额外提供带容量的初始化方法:
NSMutableDictionary *dict = [NSMutableDictionary dictionaryWithCapacity:100];这个方法唯一的作用是告诉内部哈希表“我大概会存100个键值对”,系统会预分配足够的存储空间,减少后续添加数据时频繁扩容引起的性能损耗。哈希表扩容和数组扩容类似,需要重新计算所有已有key的位置,数据量越大耗时越明显。因此,如果你提前能估计出数据量,比如从服务器下载的列表最多1000条,就给1000甚至略大的容量。给少了没关系,内部会自动扩容;但频繁扩容会影响插入性能。这个优化在数据量很小时感觉不到,在高频写入的内存缓存里会非常明显。
3.2 增删改查的完整操作集合
可变字典的增删改查是日常工作的高频操作:
NSMutableDictionary *dict = [NSMutableDictionary dictionary]; // 添加或更新键值对 [dict setObject:@"值" forKey:@"key"]; // 添加多个键值对 [dict addEntriesFromDictionary:@{@"a": @1, @"b": @2}]; // 根据key删除 [dict removeObjectForKey:@"a"]; // 按数组中的keys批量删除 [dict removeObjectsForKeys:@[@"a", @"b"]]; // 清空 [dict removeAllObjects]; // 修改值:setObject同一个key会覆盖旧值 // 获取值 id value = [dict objectForKey:@"key"];这里的setObject:forKey:是覆盖语义:如果key已经存在,新值会替换旧值。addEntriesFromDictionary:会把传入字典中的所有键值对添加进来,遇到相同key也会覆盖。removeObjectsForKeys:传入一个key数组,一次性删除多个键。
还有一个容易混淆的API:setValue:forKey:。它来自KVC(Key-Value Coding)体系,本质也是修改键值,但它和setObject:forKey:有两个明显区别:第一,setValue:forKey:要求key必须是字符串;第二,当value为nil时,setValue:forKey:会调用removeObjectForKey:删除该键,而不是报错。而setObject:forKey:在value为nil时会抛出NSInvalidArgumentException。用KVC方法删除不存在的key不会产生异常,比较适合将接口里可能为空的字段值写入字典时使用。
3.3 关键坑:可变对象千万不要直接做key
NSDictionary要求key遵循NSCopying协议,存储时内部会对key做一次copy操作,目的是防止后续key对象被修改导致哈希表错乱。但是这里有一个经典陷阱:如果你用NSMutableString作为key,因为copy会生成一个不可变副本,原来的可变字符串后续即使修改了,字典内部存的还是那个副本,所以查找时用修改后的字符串查不到,这不是崩溃,但逻辑已经错了。更危险的是自定义可变对象作为key,如果这个对象没有正确实现NSCopying,或者copy返回的是可变对象,那么存储后对象内部状态一旦变化,hash值也变了,字典的内部结构相当于被“破坏”了,此后不仅查不到这个key,甚至可能导致整个字典出现异常。
因此,最佳实践是永远不要用可变对象做key。优先使用NSString、NSNumber、NSData这些不可变对象,必须自定义对象做key时,实现NSCopying并保证isEqual:和hash逻辑稳定。还要注意NSCopying的副本不一定是深拷贝,NSString copy通常返回不可变副本,但如果底层不可变,copy可能直接返回原对象,这都没问题,关键是保证它不可变。
4. 深入原理:哈希结构、Copy语义与跨语言桥接
4.1 哈希表的存储原理与性能边界
前面已经提到NSDictionary底层是哈希表,这里再展开一些。当我们执行[dict setObject:value forKey:key]时,系统会对key调用hash方法得到一个整数,再用这个整数对内部数组的长度取模,找到候选位置。如果位置为空,直接放进去;如果位置不为空,说明发生冲突,系统会再根据某种探测策略往下找下一个空位。取值的流程类似:先算hash找到位置,如果该位置的key和传入的key通过isEqual:判断相等,则返回值;否则继续探测。
正常情况下,查找一个键值对只需几次计算,时间复杂度是O(1),这是字典性能优秀的关键。但有两个前提:key的hash分布足够均匀,并且hash和isEqual:保持一致。如果所有key的hash值都一样,哈希表退化成链表触底,查找变成O(n),性能严重劣化。系统类如NSString的hash实现已经优化得很好,所以日常开发基本不会遇到这类问题。真正容易出问题的是自定义key,比如你写了一个类,只重写了isEqual:但没重写hash,或者hash每次都返回固定值,就会导致字典性能下降,甚至出现逻辑错误。原则就是:重写isEqual:时必须同步重写hash。
4.2 让人迷惑的copy/mutableCopy:浅拷贝的真相
很多开发者在字典中遇到“改了副本,原数据也跟着变”的问题,根本原因是没有搞懂拷贝的深度。来看一段测试代码:
NSMutableDictionary *original = [@{@"list": [NSMutableArray arrayWithObject:@1]} mutableCopy]; NSDictionary *copyDict = [original copy]; NSMutableDictionary *mutableCopyDict = [original mutableCopy]; NSMutableArray *array = copyDict[@"list"]; [array addObject:@2]; NSLog(@"original list: %@", original[@"list"]); // 会打印出1、2这里无论copy还是mutableCopy,对字典而言都是浅拷贝:新字典本身是一个新对象,可以独立增删键值对,但字典内部存储的value仍然是原来的对象指针,不会拷贝value指向的内容。所以通过copyDict取出同一个可变数组并修改,original中的数组也会变。如果你希望字典内的对象也互不影响,必须进行深拷贝。最简单的方式是使用归档:
NSData *data = [NSKeyedArchiver archivedDataWithRootObject:original requiringSecureCoding:NO error:nil]; NSDictionary *deepCopy = [NSKeyedUnarchiver unarchiveObjectWithData:data];这种方法要求字典里的所有value都遵循NSCoding协议,系统类基本都支持。也可以用遍历加条件判断手写深拷贝,但代码量会大很多。日常开发中,除非明确需要完全隔离,否则浅拷贝足够;如果字典里嵌套的是不可变对象,浅拷贝和深拷贝没什么区别。
4.3 与Swift Dictionary桥接:语义差异要当心
在混合工程里,NSDictionary和Swift的Dictionary会有自动桥接。当OC方法返回NSDictionary时,Swift里通常呈现为[AnyHashable: Any]?,你可以用as?做类型转换。反过来,Swift的Dictionary传回OC时也能桥接成NSDictionary。看起来无缝,但有一些隐藏差异:
- Swift Dictionary是值类型,执行赋值时会自动拷贝(底层使用写时复制优化),而NSDictionary是引用类型,赋值效果只是指针赋值。
- Swift的字典键类型必须遵循Hashable协议,OC的key遵循NSCopying协议,桥接时会自动处理大部分系统类型。
- Swift中字典的value可以是Optional,但在OC字典里不能直接放nil。如果你把一个Optional的nil桥接过去,编译或运行阶段会得到NSNull,而不是真正缺失。从OC字典读取值时,在Swift里常常得到Any?,你还需要处理实际的NSNull。
举个例子:
let ocDict: NSDictionary = ["name": "张三", "age": NSNull()] if let age = ocDict["age"] as? Int { // 这里不会执行,因为实际是NSNull }所以在JSON解析中,判断空值不能只看是否为nil,要额外辨别NSNull。这个坑在OC和Swift混编时非常常见。
5. 实战中的典型问题与排查笔记
5.1 不可变字典调用可变方法:崩溃定位与预防
最常见的崩溃之一是在NSDictionary实例上调用setObject:forKey:,运行时报unrecognized selector sent to instance。为什么编译器没有拦住?因为很多开发者把NSDictionary指针声明成了NSMutableDictionary,或者反过来。比如某个接口声明返回NSDictionary,实际实现返回的是NSMutableDictionary实例,外部拿到后用可变方法操作,满足崩溃条件;更常见的情况是直接创建NSMutableDictionary *dict = @{...},字面量创建的是不可变字典,把它赋给可变指针,编译时会有警告但能通过,一执行就崩。
预防办法是:创建时可变更,就一定要用[NSMutableDictionary dictionary]或dictionaryWithCapacity:;拿到外部数据时,把类型转换看清楚,不确定就打印[object isKindOfClass:[NSMutableDictionary class]]。在开发和测试阶段,建议打开异常断点和僵尸对象,让崩溃发生的位置立刻暴露出来,节省排查时间。
5.2 键值为nil引发的崩溃与NSNull占位
Objective-C的容器不允许存nil,这在字典中体现得淋漓尽致。dict[@“key”]如果key为nil,会返回nil,看起来还好,但向字典写入nil key或nil value,会立刻抛出NSInvalidArgumentException。在做网络层的时候,服务器返回的JSON字段可能缺失,解析代码要特别注意判断:
id value = jsonDict[@"field"]; if (value == nil || [value isKindOfClass:[NSNull class]]) { // 字段缺失或为null,做兜底处理 } else { // 真正有值 }JSON中的null,系统解析后不是nil,而是NSNull对象。字典可以正常保存NSNull,这意味着你把整个字典写入另一个字典时不会崩溃,但读取时如果直接把NSNull当作字符串或数字使用,就可能产生类型转换问题甚至崩溃。针对这种情况,建议在数据层对可空字段做规范化,比如把所有NSNull替换成默认值,或者在模型初始化时统一判断。
5.3 遍历过程中修改字典结构
快速枚举和block遍历期间,字典的内容不能增删。如果在遍历中执行removeObjectForKey:,会抛出NSGenericException: collection was mutated while being enumerated。常见场景是业务中需要删除满足条件的键值对,比如清理缓存中过期的key。
正确做法有两种:第一,先取allKeys,遍历keys数组,再根据条件删除字典里的键;第二,把需要保留的键值对收集到新字典,遍历结束后替换原字典。示例:
NSMutableDictionary *cache = ...; NSArray *allKeys = [cache allKeys]; for (NSString *key in allKeys) { if (需要删除的条件) { [cache removeObjectForKey:key]; } }这里要特别注意:allKeys生成的是新数组,所以for-in遍历的是数组而不是字典,可以安全删除。如果你用enumerateKeysAndObjectsUsingBlock,即使只读取不修改也会因为代码写错而偶发崩溃,因为block内部无法阻止你写修改代码,所以依然建议取key数组的方式。
5.4 多线程并发访问:没有天生安全的字典
NSDictionary和NSMutableDictionary本身都不保证线程安全。多个线程同时读一个不可变字典是安全的,因为内容不会变;但只要有一个线程在写,就可能出现内存异常或数据错乱。我见过一个线上问题:多个任务并发写入同一个内存缓存,crash概率不高,但偶尔会造成读到的value是半初始化状态,难排查。解决方案有很多,最简单的是加串行队列保护:
@interface SafeCache : NSObject @property (nonatomic, strong) NSMutableDictionary *storage; @property (nonatomic, strong) dispatch_queue_t queue; @end @implementation SafeCache - (instancetype)init { self = [super init]; if (self) { _storage = [NSMutableDictionary dictionary]; _queue = dispatch_queue_create("com.example.safecache", DISPATCH_QUEUE_SERIAL); } return self; } - (void)setObject:(id)obj forKey:(NSString *)key { dispatch_sync(self.queue, ^{ [self.storage setObject:obj forKey:key]; }); } - (id)objectForKey:(NSString *)key { __block id result; dispatch_sync(self.queue, ^{ result = self.storage[key]; }); return result; } @end串行队列能保证读写都是串行执行,数据必然一致。如果读多写少,可以直接用dispatch_barrier_async处理写操作,读操作并发执行,以提升效率。更进一步,可以把读写都封装成方法,不让外部直接拿到内部字典的引用,避免绕过锁。
6. 一个完整的字典应用示例:用户信息缓存模块
6.1 业务场景与需求分析
假设我们要做一个用户信息缓存模块。App启动时登录成功,服务端返回用户资料,包括userId、昵称、头像、年龄等。客户端需要按userId快速查到用户资料;用户资料更新后能覆盖旧内容;退出登录时清空整个缓存。如果缓存没有命中,还需要触发网络拉取,但这里我们先只实现内存缓存部分。
这个模块最适合用NSMutableDictionary来做:userId是NSString,用户资料是一个NSDictionary或自定义模型对象。考虑到线程安全,最好用上面SafeCache的思路封装。对外暴露的接口要尽量少,内部细节不泄出去。
6.2 线程安全缓存的完整实现
先定义缓存类:
// UserCache.h #import <Foundation/Foundation.h> @interface UserCache : NSObject + (instancetype)sharedCache; - (void)cacheUser:(NSDictionary *)user forKey:(NSString *)userId; - (NSDictionary *)userForKey:(NSString *)userId; - (void)removeUserForKey:(NSString *)userId; - (void)clearAll; @end实现里用串行队列包住一个NSMutableDictionary:
// UserCache.m #import "UserCache.h" @interface UserCache () @property (nonatomic, strong) NSMutableDictionary *storage; @property (nonatomic, strong) dispatch_queue_t workQueue; @end @implementation UserCache + (instancetype)sharedCache { static UserCache *instance; static dispatch_once_t onceToken; dispatch_once(&onceToken, ^{ instance = [[UserCache alloc] init]; }); return instance; } - (instancetype)init { self = [super init]; if (self) { _storage = [NSMutableDictionary dictionary]; _workQueue = dispatch_queue_create("com.demo.usercache", DISPATCH_QUEUE_SERIAL); } return self; } - (void)cacheUser:(NSDictionary *)user forKey:(NSString *)userId { if (!user || !userId) { return; } dispatch_async(self.workQueue, ^{ [self.storage setObject:user forKey:userId]; }); } - (NSDictionary *)userForKey:(NSString *)userId { if (!userId) { return nil; } __block NSDictionary *result; dispatch_sync(self.workQueue, ^{ result = [self.storage objectForKey:userId]; }); return result; } - (void)removeUserForKey:(NSString *)userId { if (!userId) { return; } dispatch_async(self.workQueue, ^{ [self.storage removeObjectForKey:userId]; }); } - (void)clearAll { dispatch_async(self.workQueue, ^{ [self.storage removeAllObjects]; }); } @end这个实现有几个要点:写操作使用dispatch_async,读操作使用dispatch_sync,保证同一时刻只有串行队列里的一个块在访问storage。缓存user时做了nil判断,避免掉入崩溃坑。注意这里传入的user是NSDictionary,调用方一般从JSON转来,本身应该是不可变字典,但为了安全,也可以在存储时对user做一次copy,避免外部修改。
6.3 字典与JSON互转的实战细节
网络请求拿到的二进制数据,通过NSJSONSerialization转成对象:
NSData *data = [responseObject dataUsingEncoding:NSUTF8StringEncoding]; NSError *error = nil; id json = [NSJSONSerialization JSONObjectWithData:data options:kNilOptions error:&error]; if (!error && [json isKindOfClass:[NSDictionary class]]) { NSDictionary *userInfo = (NSDictionary *)json; [[UserCache sharedCache] cacheUser:userInfo forKey:userInfo[@"userId"]]; }特别注意两点:JSONObjectWithData返回的类型可能是字典、数组、字符串、数字,必须先用isKindOfClass判断,不能盲目当成字典使用;如果服务器字段缺失,userInfo[@"userId"]可能为nil,用它作为key会崩溃,所以缓存方法里做了保护。如果需要把本地字典转成JSON用来上传或落盘:
NSError *writeError = nil; NSData *jsonData = [NSJSONSerialization dataWithJSONObject:userInfo options:NSJSONWritingPrettyPrinted error:&writeError]; if (jsonData) { [jsonData writeToFile:tempPath atomically:YES]; }注意NSJSONSerialization要求字典中所有value必须是NSDictionary、NSArray、NSString、NSNumber或NSNull,如果字典里有自定义模型对象或NSDate,转换会失败。遇到这种情况需要先做一层转换,把模型对象替换成字典,把NSDate转成时间戳字符串。这个细节在做持久化和跨端传输时非常关键。
到这里,NSDictionary和NSMutableDictionary的核心内容基本讲完了。我最后再说一点个人经验:初学阶段不要只盯着API名称,有空可以写几个小例子验证copy、遍历修改、线程安全这些边界行为。字典的坑通常不是入门教程里会强调的,但实际项目里踩一次就够喝一壶。建议养成在写集合代码时多思考“对象类型是什么、是否会被修改、是否多线程访问”的习惯。还有一个小技巧:当你怀疑字典内容不对时,先打印description,再单独验证key的hash和isEqual:逻辑,很多诡异问题都会在两分钟内水落石出。