简单讨论 SwiftUI 中 ForEach 引起的越界问题
问题的发生
某日检查公司产品在 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 的属性作为哈希的标志。
发表评论