恒美微站
首页
关于我们
建站服务
主题模板
案例展示
资讯中心
联系我们
iOS开发必备:NSDictionary与NSMutableDictionary底层原理与实战陷阱
首页
资讯中心
/
iOS开发必备:NSDictionary与NSMutableDictionary底层原理与实战陷阱
iOS开发必备:NSDictionary与NSMutableDictionary底层原理与实战陷阱
发布时间:2026/10/15 7:25:44
做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数组一次性删除多个键。还有一个容易混淆的APIsetValue:forKey:。它来自KVCKey-Value Coding体系本质也是修改键值但它和setObject:forKey:有两个明显区别第一setValue:forKey:要求key必须是字符串第二当value为nil时setValue:forKey:会调用removeObjectForKey:删除该键而不是报错。而setObject:forKey:在value为nil时会抛出NSInvalidArgumentException。用KVC方法删除不存在的key不会产生异常比较适合将接口里可能为空的字段值写入字典时使用。3.3 关键坑可变对象千万不要直接做keyNSDictionary要求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:逻辑很多诡异问题都会在两分钟内水落石出。