简单讨论 SwiftUI 中 ForEach 引起的越界问题

06/22/2021

问题的发生

某日检查公司产品在 Firebase 上的崩溃日志时,检查到一条这样的错误:

[__NSSingleObjectArrayI objectAtIndex:]: index 1 beyond bounds [0 .. 0]

简而言之,在一个 [0 … 0] 的 range 中,数组访问到了 index = 1。这简直匪夷所思,一个被限定好的合法的 range,是如何产生越界的 index 呢?我们定义到相关代码:

let events: [EKEvents] = getEvents()
let count = min(2, events.count)
ForEach(0 ..< count) { index in
    EventView(event: events[index])
}

代码功能是,获取用户日程表展示在列表中,其中如果日程个数多于两个时,只显示前两个。联系到报错,我初步认为 events 的变化导致了这次崩溃。

Events 列表发生了什么

我定位到获取用户日历的相关接口,找到了一处文档中的疑点。在方法 events(matching:) 的文档中有这样一处表述:

@result     An array of EKEvent objects, or nil. There is no guaranteed order to the events.

虽然文档中提到返回值可能包含 nil,但是返回值类型却是 [EKEvent],无论是数组还是数组中的元素都不是 Optional 类型,又怎么会返回 nil 呢?

分析到这,我怀疑这个接口可能会返回类似 [nil, …] 的结构(实际上是说不通的),于是我暂时性地丰富了健壮性:

let events = self.eventStore.events(matching: predicate)
if let _ = events.first {
    return events
} else {
    return []
}

成功复现

之后的某天因为需求改动,获取 events 的方法添加了一组默认值。在调试这个改动时,我们终于稳定复现了上面出现的崩溃。当 events 数组从两个或以上个元素变动为只有一个元素后刷新对应 UI,就会稳定触发崩溃。与此同时,log 中出现如下警告:

ForEach<range<int>, Int, Optional<eventview>> count (1) != its initial count (2). `ForEach(_:content:)` should only be used for *constant* data.

由此终于确定,这是 SwiftUI 中的 ForEach 在缓存时依然保存了上次的 range [0 … 1] 并应用到新的遍历中,所以在 [0 … 0] 中也得到了 index = 1 的结果。

奇妙的 id

我们阅读 ForEach 的文档发现,它总是用初始化的 range 进行遍历:

/// The instance only reads the initial value of the provided `data` and
/// doesn't need to identify views across updates. To compute views on
/// demand over a dynamic range, use ``ForEach/init(_:id:content:)``.

文档提到,如果 range 是动态的,则需要使用含有参数 id 的初始化方法。其中,id 是针对被遍历对象进行哈希的标志。这样一来,ForEach 的每次遍历都会经过一次哈希,而不是使用初始值。因此,我们得到了修复这个问题的代码:

/// F**k foreach, id is needed!
ForEach(0 ..< count, id: \.self) { index in
    EventView(event: events[index])
}

其中的 self 表示使用 range 中的每个 Int 本身作为哈希的标志。这里如果遍历的是 events, 也可以指定 events 中某个 hashable 的属性作为哈希的标志。

#EOF