前言:这将是一个很长的非答案。我实际上并不知道您的代码出了什么问题,但我可以分享我所做知道的可以帮助您解决问题的事情,并在此过程中学到一些有趣的东西。
了解错误
Exception NSException * "-[_NSCoreDataTaggedObjectID objectForKey:]: 无法识别的选择器发送到实例 0x8000000000000000"
- 抛出了一个 Objective C 异常(但未被捕获)。
- 尝试调用
-[_NSCoreDataTaggedObjectID objectForKey:] 时发生异常。这是以书面形式引用 Objective C 方法的传统方式。在这种情况下,它是:
- 一个实例方法(因此是
-,而不是用于类方法的+)
- 在课堂上
_NSCoreDataTaggedObjectID(稍后会详细介绍)
- 在名为
objectForKey:的方法上
- 接收此方法调用的对象是地址为
0x8000000000000000的对象。
这是一个非常奇怪的地址。出事了。
另一个提示是_NSCoreDataTaggedObjectID 这个奇怪的类名。我们可以对此进行一些观察:
- 前缀
_NS 表明它是CoreData 的内部实现细节。
- 我们通过 Google 搜索名称来查找 CoreData 框架的类转储,这表明:
- 它的名称中有“tagged”一词,在 Objective C 世界中具有特殊含义。
一些背景故事
Objective C 使用消息传递作为方法分派的唯一机制(与 Swift 不同,Swift 通常更喜欢静态和 v-table 分派,具体取决于上下文)。您编写的每个方法调用本质上都是objc_msgSend(及其变体)之上的语法糖,将接收器对象、选择器(被调用方法的“名称”)和参数传递给它。这是一个特殊的函数,它可以检查接收者对象的类,并查看类的层次结构,直到找到所需选择器的方法实现。
这很棒,因为它允许你做很多很酷的运行时动态行为。例如,macOS 应用程序上的菜单栏项只会定义它们调用的方法名称。单击它们会将“该消息”“发送”到响应者链,该链将在第一个具有实现的对象上调用该方法(术语是“第一个响应该消息的对象”)。
这非常有效,但有几个取舍。其中之一是一切都必须是一个对象。所谓对象,我们指的是一个堆分配的内存区域,它的前几个内存字存储了对象的元数据。这个元数据将包含一个指向对象类的指针,正如我刚刚描述的那样,这是在objc_msgSend 中执行方法循环过程所必需的。
问题是,对于小对象(尤其是 NSNumber 值、小字符串、空数组等),这几个对象元数据字的开销可能比您实际的对象数据大几倍有兴趣。例如即使NSNumber(value: true /* or false */) 存储一位“有用”数据,在 64 位系统上也会有 128 位的对象开销。再加上与处理大量微小对象相关的所有 malloc/free 和保留/释放开销,您就会遇到真正的性能问题。
“标记指针”是解决这个问题的方法。这个想法是,对于特定特权类的足够小的值,我们不会为它们的对象分配堆内存。相反,我们将直接将它们的对象数据存储在它们的指针表示中。当然,我们需要一种方法来知道给定的指针是真正的指针(指向真正的堆分配对象),还是内联编码数据的“假指针”。
malloc 只返回与 16 字节边界对齐的内存的关键实现。这意味着每个内存地址的 4 位总是0(如果不是,那么它就不会是 16 字节对齐的)。这些“未使用”的 4 位可用于区分真实指针和标记指针。确切使用了哪些位以及进程架构和运行时版本之间的差异,但总体思路是相同的。
如果一个指针值对于这 4 位有 0000,那么系统就会知道它是一个指向真正堆分配对象的真正对象指针。这些 4 位值的所有其他可能值可用于指示在剩余位中存储了哪种数据。 Objective C 运行时实际上是开源的,所以你实际上可以看到the tagged pointer classes and their tags:
{
// 60-bit payloads
OBJC_TAG_NSAtom = 0,
OBJC_TAG_1 = 1,
OBJC_TAG_NSString = 2,
OBJC_TAG_NSNumber = 3,
OBJC_TAG_NSIndexPath = 4,
OBJC_TAG_NSManagedObjectID = 5,
OBJC_TAG_NSDate = 6,
// 60-bit reserved
OBJC_TAG_RESERVED_7 = 7,
// 52-bit payloads
OBJC_TAG_Photos_1 = 8,
OBJC_TAG_Photos_2 = 9,
OBJC_TAG_Photos_3 = 10,
OBJC_TAG_Photos_4 = 11,
OBJC_TAG_XPC_1 = 12,
OBJC_TAG_XPC_2 = 13,
OBJC_TAG_XPC_3 = 14,
OBJC_TAG_XPC_4 = 15,
OBJC_TAG_NSColor = 16,
OBJC_TAG_UIColor = 17,
OBJC_TAG_CGColor = 18,
OBJC_TAG_NSIndexSet = 19,
OBJC_TAG_NSMethodSignature = 20,
OBJC_TAG_UTTypeRecord = 21,
// When using the split tagged pointer representation
// (OBJC_SPLIT_TAGGED_POINTERS), this is the first tag where
// the tag and payload are unobfuscated. All tags from here to
// OBJC_TAG_Last52BitPayload are unobfuscated. The shared cache
// builder is able to construct these as long as the low bit is
// not set (i.e. even-numbered tags).
OBJC_TAG_FirstUnobfuscatedSplitTag = 136, // 128 + 8, first ext tag with high bit set
OBJC_TAG_Constant_CFString = 136,
OBJC_TAG_First60BitPayload = 0,
OBJC_TAG_Last60BitPayload = 6,
OBJC_TAG_First52BitPayload = 8,
OBJC_TAG_Last52BitPayload = 263,
OBJC_TAG_RESERVED_264 = 264
可以看到,字符串、索引路径、日期等类似“小而多”的类都有保留的指针标记值。对于这些“普通类”中的每一个(NSString、NSDate、NSNumber 等),都有一个特殊的内部子类,它实现了所有相同的公共 API,但使用标记指针而不是常规对象。
如您所见,OBJC_TAG_NSManagedObjectID 有一个值。事实证明,NSManagedObjectID 对象数量众多且足够小,以至于它们将从这种标记指针表示中受益匪浅。毕竟,NSManagedObjectID 的值可能是一个整数,很像 NSNumber,这会浪费堆分配。
如果您想了解更多关于标记指针的信息,我推荐 Mike Ash 的著作,例如 https://www.mikeash.com/pyblog/friday-qa-2012-07-27-lets-build-tagged-pointers.html
最近还有一个关于这个主题的 WWDC 演讲:WWDC 2020 - Advancements in the Objective-C runtime
奇怪的地址
所以在上一节中我们发现_NSCoreDataTaggedObjectID 是NSManagedObjectID 的标记指针子类。现在我们可以注意到一些奇怪的东西,我们看到的指针值有很多零:0x8000000000000000。所以我们正在处理的可能是对象的某种未初始化状态。
结论
调用堆栈可以进一步阐明这种情况发生的确切位置,但我们知道,在您的程序中的某处,NSManagedObjectID 的未初始化值正在调用 objectForKey: 方法。
您可能在正确初始化之前太早地访问了一个值。
要解决此问题,您可以采取以下几种方法之一:
- 未来的理想世界,只需使用 Swift 5.5 的结构化并发(一旦在足够多的设备上可用)和 async/await 将工作推送到后台并等待结果。
- 仅在值准备就绪后,才使用完成处理程序调用您的值消耗代码。这是最直接简单的,但会因完成处理程序样板和错误而炸毁您的代码库。
- 使用并发抽象库,例如 Combine、RxSwift 或 PromiseKit。这将需要更多的工作来设置,但通常会比在任何地方抛出完成处理程序产生更清晰/更安全的代码。