Swift 标识集合

CI Slack

一个数据结构库,用于以符合人体工程学、高性能的方式处理可标识元素集合。

动机

在你的应用程序状态中建模元素集合时,很容易想到使用标准的 Array。然而,随着你的应用程序变得更加复杂,这种方法可能会在许多方面崩溃,包括意外地对错误的元素进行突变,甚至崩溃。😬

例如,如果你正在使用 SwiftUI 构建一个“Todos”应用程序,你可能会在一个可标识的值类型中建模单个待办事项

struct Todo: Identifiable {
  var description = ""
  let id: UUID
  var isComplete = false
}

并且你会在你的应用程序视图模型中将这些待办事项的数组作为发布字段持有

class TodosViewModel: ObservableObject {
  @Published var todos: [Todo] = []
}

视图可以非常简单地渲染这些待办事项的列表,并且因为它们是可标识的,我们甚至可以省略 Listid 参数

struct TodosView: View {
  @ObservedObject var viewModel: TodosViewModel
  
  var body: some View {
    List(self.viewModel.todos) { todo in
      ...
    }
  }
}

如果你的部署目标设置为最新版本的 SwiftUI,你可能会想传递一个绑定到列表,以便每一行都被赋予对其待办事项的可变访问权限。这在简单的情况下会起作用,但是一旦你引入副作用,例如 API 客户端或分析,或者想要编写单元测试,你必须将此逻辑推送到视图模型中。这意味着每一行都必须能够将其操作传达回视图模型。

你可以通过在视图模型上引入一些端点来做到这一点,例如当行的完成切换被更改时

class TodosViewModel: ObservableObject {
  ...
  func todoCheckboxToggled(at id: Todo.ID) {
    guard let index = self.todos.firstIndex(where: { $0.id == id })
    else { return }
    
    self.todos[index].isComplete.toggle()
    // TODO: Update todo on backend using an API client
  }
}

这段代码足够简单,但它可能需要完全遍历数组才能完成其工作。

也许对于一行来说,将其索引传达回视图模型会更高效,然后它可以通过其索引下标直接突变待办事项。但这使得视图更加复杂

List(self.viewModel.todos.enumerated(), id: \.element.id) { index, todo in
  ...
}

这还算不错,但目前它甚至无法编译。一个 进化提案 可能会很快改变这一点,但在那之前,ListForEach 必须传递一个 RandomAccessCollection,这也许最简单的方法是通过构造另一个数组来实现

List(Array(self.viewModel.todos.enumerated()), id: \.element.id) { index, todo in
  ...
}

这可以编译,但是我们只是将性能问题转移到了视图:每次评估此主体时,都有可能分配一个全新的数组。

但即使可以将枚举集合直接传递给这些视图,通过索引标识可变状态的元素也会引入许多其他问题。

虽然确实我们可以极大地简化和提高任何通过索引下标突变元素的视图模型方法的性能

class TodosViewModel: ObservableObject {
  ...
  func todoCheckboxToggled(at index: Int) {
    self.todos[index].isComplete.toggle()
    // TODO: Update todo on backend using an API client
  }
}

我们添加到此端点的任何异步工作都必须非常小心不要在以后使用此索引。索引不是一个稳定的标识符:待办事项可以随时移动和删除,并且在一个时刻标识“购买生菜”的索引可能在下一刻标识“给妈妈打电话”,或者更糟糕的是,可能是一个完全无效的索引并导致你的应用程序崩溃!

class TodosViewModel: ObservableObject {
  ...
  func todoCheckboxToggled(at index: Int) async {
    self.todos[index].isComplete.toggle()
    
    do {
      // ❌ Could update the wrong todo, or crash!
      self.todos[index] = try await self.apiClient.updateTodo(self.todos[index]) 
    } catch {
      // Handle error
    }
  }
}

每当你需要在执行一些异步工作后访问特定的待办事项时,你必须执行遍历数组的工作

class TodosViewModel: ObservableObject {
  ...
  func todoCheckboxToggled(at index: Int) async {
    self.todos[index].isComplete.toggle()
    
    // 1️⃣ Get a reference to the todo's id before kicking off the async work
    let id = self.todos[index].id
  
    do {
      // 2️⃣ Update the todo on the backend
      let updatedTodo = try await self.apiClient.updateTodo(self.todos[index])
              
      // 3️⃣ Find the updated index of the todo after the async work is done
      let updatedIndex = self.todos.firstIndex(where: { $0.id == id })!
      
      // 4️⃣ Update the correct todo
      self.todos[updatedIndex] = updatedTodo
    } catch {
      // Handle error
    }
  }
}

介绍:标识集合

标识集合旨在通过提供数据结构来解决所有这些问题,以便以符合人体工程学、高性能的方式处理可标识元素集合。

大多数情况下,你可以简单地将 Array 替换为 IdentifiedArray

import IdentifiedCollections

class TodosViewModel: ObservableObject {
  @Published var todos: IdentifiedArrayOf<Todo> = []
  ...
}

然后你可以通过其基于 id 的下标直接突变元素,无需遍历,即使在执行异步工作之后也是如此

class TodosViewModel: ObservableObject {
  ...
  func todoCheckboxToggled(at id: Todo.ID) async {
    self.todos[id: id]?.isComplete.toggle()
    
    do {
      // 1️⃣ Update todo on backend and mutate it in the todos identified array.
      self.todos[id: id] = try await self.apiClient.updateTodo(self.todos[id: id]!)
    } catch {
      // Handle error
    }

    // No step 2️⃣ 😆
  }
}

你还可以简单地将标识集合传递给像 ListForEach 这样的视图,而不会有任何复杂性

List(self.viewModel.todos) { todo in
  ...
}

标识集合旨在与 SwiftUI 应用程序以及用 Composable Architecture 编写的应用程序集成。

设计

IdentifiedArray 是 Apple Swift CollectionsOrderedDictionary 类型的轻量级包装器。它具有许多相同的性能特征和设计考虑因素,但更适合解决在你的应用程序状态中持有可标识元素集合的问题。

IdentifiedArray 不会暴露 OrderedDictionary 的任何可能导致破坏不变性的细节。例如,OrderedDictionary<ID, Identifiable> 可以自由地持有一个标识符与其键不匹配的值,或者多个值可能具有相同的 id,而 IdentifiedArray 不允许出现这些情况。

并且与 OrderedSet 不同,IdentifiedArray 不要求其 Element 类型符合 Hashable 协议,这可能很难或不可能做到,并且会引入关于哈希质量等方面的问题。

IdentifiedArray 甚至不要求其 Element 符合 Identifiable。正如 SwiftUI 的 ListForEach 视图采用 id 键路径来获取元素的标识符一样,IdentifiedArray 可以使用键路径来构造

var numbers = IdentifiedArray(id: \Int.self)

性能

IdentifiedArray 旨在匹配 OrderedDictionary 的性能特征。它已使用 Swift Collections Benchmark 进行基准测试

社区

如果你想讨论这个库,或者对如何使用它来解决特定问题有疑问,你可以与 Point-Free 爱好者在许多地方进行讨论

文档

Identified Collections API 的最新文档可在此处 获取

翻译

有兴趣了解更多吗?

这些概念(以及更多)在 Point-Free 中进行了深入探讨,这是一个由 Brandon WilliamsStephen Celis 主持的探索函数式编程和 Swift 的视频系列。

在以下 Point-Free 剧集中探讨了 IdentifiedArrayComposable Architecture 中的用法

video poster image

许可

所有模块均在 MIT 许可下发布。有关详细信息,请参阅 LICENSE