macOS で SwiftUI の Table に数千件のデータと sortOrder を放り込むとハングするのを回避する。
環境
- macOS 15.2 (24C101)
- Xcode 16.2 (16C5032a)
詳細
macOS における SwiftUI の Table はあまり出来が良くないようで、 Apple Developer Forums を眺めても「パフォーマンス出ないんだけど」みたいな Topic があったり、まぁ割とみんな困ってそうである。
今回引いた問題は
nonisolated
public init<Data, Sort>(
_ data: Data,
selection: Binding<Set<Value.ID>>,
sortOrder: Binding<[Sort]>,
@TableColumnBuilder<Value, Sort> columns: () -> Columns
) where Rows == TableForEachContent<Data>, Data : RandomAccessCollection, Sort : SortComparator, Columns.TableRowValue == Data.Element, Data.Element == Sort.Compared
これを使って、カラムをクリックしてソート可能な Table を作ったとき、データ量に比例して重くなっていき、 16 GB RAM の M1 Mac mini では 4000 件を越えたあたりでハングする、という問題だ。
ちなみに 36 GB RAM の M3 MacBook Pro だともうちょっと耐える。 10000 件のデータならレインボーカーソルの後に処理が返ってくるが、 20000 件ほど放り込んだらハングした。
再現するコードの リポジトリ を作って、 Instruments でプロファイルしたファイルと共に Apple に Feedback 済。
内容
大した量じゃないのでコードを全部貼ると
import SwiftUI
private struct TableItem: Codable, Equatable, Comparable, Hashable, Identifiable, Sendable {
let name: String
let starCount: Int
let rank: Int
let uuid: UUID
var id: UUID { uuid }
static func < (lhs: Self, rhs: Self) -> Bool {
lhs.rank < rhs.rank
}
}
private func createRandomName() -> String {
let names = ["Alice", "Bob", "Charlie", "David", "Eve", "Frank", "Grace", "Heidi", "Ivan", "Judy"]
while let choice = names.randomElement() {
while let choice2 = names.randomElement(), choice != choice2 {
while let choice3 = names.randomElement(), choice != choice3, choice2 != choice3 {
return "\(choice)-\(choice2)-\(choice3)"
}
}
}
fatalError("This should never happen")
}
private func createRandomTableItems(count: Int) -> [TableItem] {
(0..<count).map { _ in
TableItem(name: createRandomName(), starCount: Int.random(in: 0..<5), rank: Int.random(in: 0..<10000), uuid: UUID())
}
}
@main
struct BrokenSwiftUITableApp: App {
// NOTE: 後述する .id(sortOrder) を追加しない場合、 16 GB RAM の M1 Mac mini では 4000 件を越えたあたりでレインボーカーソルで死ぬ。
private let items: [TableItem] = createRandomTableItems(count: 10000)
private var sortedItems: [TableItem] {
items.sorted(using: sortOrder)
}
@State private var selection: Set<TableItem.ID> = []
@State private var sortOrder = [
KeyPathComparator(\TableItem.rank, order: .reverse),
KeyPathComparator(\TableItem.name, order: .forward),
]
@State private var sortId = UUID()
var body: some Scene {
WindowGroup {
Table(sortedItems, selection: $selection, sortOrder: $sortOrder) {
TableColumn("Rank", value: \.rank) { item in
Text("\(item.rank)")
.monospacedDigit()
}
TableColumn("Star", value: \.starCount) { item in
Text(String(repeating: "⭐️", count: item.starCount))
.monospacedDigit()
}
TableColumn("Name", value: \.name) { item in
Text(item.name)
.monospaced()
}
TableColumn("UUID", value: \.uuid) { item in
Text(item.uuid.uuidString)
.monospaced()
.foregroundStyle(.secondary)
}
}
// .id(sortOrder) // NOTE: これを付ければ sortOrder 毎に Table が作り直されるので問題が起きなくなる。
}
}
}
回避策
SwiftUI 内部でなんとか Table
を使い回そうとしているが実装が悪くて死んでいるような挙動なので、 sortOrder
が変わったら View を作り直すよう明示的に .id(sortOrder)
を付けると改善する。
@available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *)
extension View {
/// Binds a view's identity to the given proxy value.
///
/// When the proxy value specified by the `id` parameter changes, the
/// identity of the view — for example, its state — is reset.
@inlinable nonisolated public func id<ID>(_ id: ID) -> some View where ID : Hashable
}
とのことで Hashable
でさえあれば良いのだが、幸い KeyPathComparator
は SortComparator
を経由して Hashable
なので、そのまま放り込むことができる。
締め
社内アプリや、書き捨てのアプリなど、手元でサクっと作るのに SwiftUI は手軽で程良いのでよく使うのだけど、稀に良くこういう問題を引きがち。
SwiftUI は簡単そうな顔をしていて全く簡単じゃないという問題があり、Apple のやる気ガチャ (優秀なエンジニアがアサインされるかどうか) があるような気がしなくもない。信心が試される。