crossmate

A collaborative crossword app for iOS
Log | Files | Refs | LICENSE

commit ddb3e953d67455b295bd929595e742c18f25524b
parent e862c908e7a41a4c730260d0855b86baa79893f9
Author: Michael Camilleri <[email protected]>
Date:   Sat,  2 May 2026 17:13:07 +0900

Fix needless game parsing on key strokes

The architecture prior to this commit meant that all games were reparsed
from the XD source on every key stroke. This resulted in unacceptable
performance as the number of games increased.

This commit uses a caching approach to cache summaries of the games so
that the game list can be efficiently updated (because of the way that
SwiftUI works, the game list is updated even when the puzzle view is
what the user can see).

Co-Authored-By: Claude Opus 4.7 <[email protected]>

Diffstat:
MCrossmate/Models/CrossmateModel.xcdatamodeld/CrossmateModel.xcdatamodel/contents | 5+++++
MCrossmate/Persistence/GameStore.swift | 130+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------
MCrossmate/Persistence/PersistenceController.swift | 25+++++++++++++++++++++++++
MCrossmate/Sync/RecordSerializer.swift | 3+++
MCrossmate/Views/GameListView.swift | 21+++++++++------------
5 files changed, 156 insertions(+), 28 deletions(-)

diff --git a/Crossmate/Models/CrossmateModel.xcdatamodeld/CrossmateModel.xcdatamodel/contents b/Crossmate/Models/CrossmateModel.xcdatamodeld/CrossmateModel.xcdatamodel/contents @@ -1,6 +1,9 @@ <?xml version="1.0" encoding="UTF-8" standalone="yes"?> <model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="22522" systemVersion="24F74" minimumToolsVersion="Automatic" sourceLanguage="Swift" usedWithSwiftData="NO" userDefinedModelVersionIdentifier=""> <entity name="GameEntity" representedClassName="GameEntity" syncable="YES" codeGenerationType="class"> + <attribute name="blockMask" optional="YES" attributeType="Binary"/> + <attribute name="cachedPublisher" optional="YES" attributeType="String"/> + <attribute name="cachedPuzzleDate" optional="YES" attributeType="Date" usesScalarValueType="NO"/> <attribute name="ckRecordName" optional="YES" attributeType="String"/> <attribute name="ckShareRecordName" optional="YES" attributeType="String"/> <attribute name="ckSystemFields" optional="YES" attributeType="Binary"/> @@ -9,6 +12,8 @@ <attribute name="completedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/> <attribute name="createdAt" attributeType="Date" usesScalarValueType="NO"/> <attribute name="databaseScope" optional="YES" attributeType="Integer 16" defaultValueString="0" usesScalarValueType="YES"/> + <attribute name="gridHeight" optional="YES" attributeType="Integer 16" defaultValueString="0" usesScalarValueType="YES"/> + <attribute name="gridWidth" optional="YES" attributeType="Integer 16" defaultValueString="0" usesScalarValueType="YES"/> <attribute name="id" attributeType="UUID" usesScalarValueType="NO"/> <attribute name="isAccessRevoked" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/> <attribute name="lamportHighWater" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/> diff --git a/Crossmate/Persistence/GameStore.swift b/Crossmate/Persistence/GameStore.swift @@ -32,27 +32,62 @@ struct GameSummary: Identifiable, Equatable { let hasUnseenOtherMoves: Bool init?(entity: GameEntity) { - guard let id = entity.id, - let source = entity.puzzleSource, - let xd = try? XD.parse(source) else { - return nil + guard let id = entity.id else { return nil } + + let width: Int + let height: Int + let publisher: String? + let puzzleDate: Date? + let blocks: [Bool] + + if entity.gridWidth > 0, + entity.gridHeight > 0, + let mask = entity.blockMask, + mask.count == Int(entity.gridWidth) * Int(entity.gridHeight) { + // Fast path: derived data is cached on the entity, so the list + // can render without parsing XD on every keystroke-driven save. + width = Int(entity.gridWidth) + height = Int(entity.gridHeight) + publisher = entity.cachedPublisher + puzzleDate = entity.cachedPuzzleDate + blocks = mask.map { $0 != 0 } + } else { + // Fallback for legacy rows that haven't been backfilled yet, or + // test fixtures that bypass the creation helpers. The + // PersistenceController backfill should make this rare. + guard let source = entity.puzzleSource, + let xd = try? XD.parse(source) else { + return nil + } + let puzzle = Puzzle(xd: xd) + width = puzzle.width + height = puzzle.height + publisher = puzzle.publisher + puzzleDate = puzzle.date + var bs: [Bool] = [] + bs.reserveCapacity(puzzle.width * puzzle.height) + for r in 0..<puzzle.height { + for c in 0..<puzzle.width { + bs.append(puzzle.cells[r][c].isBlock) + } + } + blocks = bs } - let puzzle = Puzzle(xd: xd) let cellEntities = (entity.cells as? Set<CellEntity>) ?? [] - var filledSet: Set<Int> = [] for ce in cellEntities where !(ce.letter ?? "").isEmpty { - filledSet.insert(Int(ce.row) * puzzle.width + Int(ce.col)) + filledSet.insert(Int(ce.row) * width + Int(ce.col)) } var thumbCells: [GameThumbnailCell] = [] - thumbCells.reserveCapacity(puzzle.width * puzzle.height) - for r in 0..<puzzle.height { - for c in 0..<puzzle.width { - if puzzle.cells[r][c].isBlock { + thumbCells.reserveCapacity(width * height) + for r in 0..<height { + for c in 0..<width { + let idx = r * width + c + if blocks[idx] { thumbCells.append(.block) - } else if filledSet.contains(r * puzzle.width + c) { + } else if filledSet.contains(idx) { thumbCells.append(.filled) } else { thumbCells.append(.empty) @@ -62,12 +97,12 @@ struct GameSummary: Identifiable, Equatable { self.id = id self.title = entity.title ?? "Untitled" - self.publisher = puzzle.publisher - self.puzzleDate = puzzle.date + self.publisher = publisher + self.puzzleDate = puzzleDate self.updatedAt = entity.updatedAt self.completedAt = entity.completedAt - self.gridWidth = puzzle.width - self.gridHeight = puzzle.height + self.gridWidth = width + self.gridHeight = height self.thumbnailCells = thumbCells self.isOwned = entity.databaseScope == 0 self.isShared = entity.ckShareRecordName != nil || entity.databaseScope == 1 @@ -77,6 +112,67 @@ struct GameSummary: Identifiable, Equatable { } } +/// Per-entity memoisation of `GameSummary`. The library list re-runs on +/// every Core Data save (i.e., every keystroke), but only the active +/// entity's fields actually change. The cache key intentionally uses fast +/// scalar/string fields so a hit never has to fault the `cells` +/// relationship; `MoveBuffer` bumps `updatedAt` atomically with cell +/// writes, so it acts as a faithful proxy for "thumbnail might have +/// changed". +@MainActor +final class GameSummaryCache { + private struct Key: Equatable { + let updatedAt: Date? + let completedAt: Date? + let latestOther: Int64 + let lastSeenOther: Int64 + let scope: Int16 + let shareName: String? + let revoked: Bool + } + private var entries: [NSManagedObjectID: (key: Key, summary: GameSummary)] = [:] + + func summary(for entity: GameEntity) -> GameSummary? { + let key = Key( + updatedAt: entity.updatedAt, + completedAt: entity.completedAt, + latestOther: entity.latestOtherMoveLamport, + lastSeenOther: entity.lastSeenOtherMoveLamport, + scope: entity.databaseScope, + shareName: entity.ckShareRecordName, + revoked: entity.isAccessRevoked + ) + if let hit = entries[entity.objectID], hit.key == key { + return hit.summary + } + guard let fresh = GameSummary(entity: entity) else { return nil } + entries[entity.objectID] = (key, fresh) + return fresh + } +} + +extension GameEntity { + /// Writes the derived puzzle data that `GameSummary` (and the library + /// list) needs into the entity, so the list path never has to call + /// `XD.parse` on every Core Data save. Block layout is encoded as one + /// byte per cell in row-major order. + func populateCachedSummaryFields(from puzzle: Puzzle) { + cachedPublisher = puzzle.publisher + cachedPuzzleDate = puzzle.date + gridWidth = Int16(puzzle.width) + gridHeight = Int16(puzzle.height) + + var bytes = [UInt8]() + bytes.reserveCapacity(puzzle.width * puzzle.height) + for r in 0..<puzzle.height { + for c in 0..<puzzle.width { + bytes.append(puzzle.cells[r][c].isBlock ? 1 : 0) + } + } + blockMask = Data(bytes) + } +} + /// Repository over the local Core Data store. Manages the lifecycle of /// games — loading a specific one, creating new ones from bundled puzzles, /// and deleting them. The library list itself is driven by `@FetchRequest` @@ -414,6 +510,7 @@ final class GameStore { entity.ckRecordName = "game-\(gameID.uuidString)" entity.ckZoneName = "game-\(gameID.uuidString)" entity.databaseScope = 0 + entity.populateCachedSummaryFields(from: puzzle) try context.save() onGameCreated?("game-\(gameID.uuidString)") @@ -575,6 +672,7 @@ final class GameStore { entity.ckRecordName = "game-\(gameID.uuidString)" entity.ckZoneName = "game-\(gameID.uuidString)" entity.databaseScope = 0 + entity.populateCachedSummaryFields(from: puzzle) try context.save() onGameCreated?("game-\(gameID.uuidString)") diff --git a/Crossmate/Persistence/PersistenceController.swift b/Crossmate/Persistence/PersistenceController.swift @@ -37,6 +37,31 @@ final class PersistenceController { } container.viewContext.automaticallyMergesChangesFromParent = true + + if !inMemory { + backfillCachedSummaryFields() + } + } + + /// One-shot pass for `GameEntity` rows created before + /// `populateCachedSummaryFields` was wired into the creation paths. Runs + /// off the main thread, no-ops on every subsequent launch. + private func backfillCachedSummaryFields() { + let bg = container.newBackgroundContext() + bg.perform { + let req = NSFetchRequest<GameEntity>(entityName: "GameEntity") + req.predicate = NSPredicate( + format: "gridWidth == 0 AND puzzleSource != nil AND puzzleSource != %@", + "" + ) + guard let rows = try? bg.fetch(req), !rows.isEmpty else { return } + for entity in rows { + guard let source = entity.puzzleSource, + let xd = try? XD.parse(source) else { continue } + entity.populateCachedSummaryFields(from: Puzzle(xd: xd)) + } + if bg.hasChanges { try? bg.save() } + } } // Loaded once and shared across all container instances so that entity diff --git a/Crossmate/Sync/RecordSerializer.swift b/Crossmate/Sync/RecordSerializer.swift @@ -375,6 +375,9 @@ enum RecordSerializer { let fileURL = asset.fileURL, let source = try? String(contentsOf: fileURL, encoding: .utf8) { entity.puzzleSource = source + if let xd = try? XD.parse(source) { + entity.populateCachedSummaryFields(from: Puzzle(xd: xd)) + } } return entity diff --git a/Crossmate/Views/GameListView.swift b/Crossmate/Views/GameListView.swift @@ -20,18 +20,7 @@ struct GameListView: View { @State private var resignTarget: GameSummary? @State private var leaveTarget: GameSummary? @State private var leaveError: Error? - - private var inProgress: [GameSummary] { - games.compactMap(GameSummary.init(entity:)) - .filter { $0.completedAt == nil } - .sorted { ($0.updatedAt ?? .distantPast) > ($1.updatedAt ?? .distantPast) } - } - - private var completed: [GameSummary] { - games.compactMap(GameSummary.init(entity:)) - .filter { $0.completedAt != nil } - .sorted { ($0.completedAt ?? .distantPast) > ($1.completedAt ?? .distantPast) } - } + @State private var summaryCache = GameSummaryCache() var body: some View { GeometryReader { geometry in @@ -110,6 +99,14 @@ struct GameListView: View { @ViewBuilder private func content(usesRoomierType: Bool) -> some View { + let summaries = games.compactMap { summaryCache.summary(for: $0) } + let inProgress = summaries + .filter { $0.completedAt == nil } + .sorted { ($0.updatedAt ?? .distantPast) > ($1.updatedAt ?? .distantPast) } + let completed = summaries + .filter { $0.completedAt != nil } + .sorted { ($0.completedAt ?? .distantPast) > ($1.completedAt ?? .distantPast) } + Group { if games.isEmpty { ContentUnavailableView {