diff options
| author | Fuwn <[email protected]> | 2026-02-18 12:45:11 -0800 |
|---|---|---|
| committer | Fuwn <[email protected]> | 2026-02-18 12:45:19 -0800 |
| commit | 2aaf7665047f61e72495b99b6caaef54d19332fe (patch) | |
| tree | c82cbc7a8b99bdc2eb59e75748c2c18bb9d39713 | |
| parent | perf: memoize post grid derived collections and remove columns cache (diff) | |
| download | sora-testing-2aaf7665047f61e72495b99b6caaef54d19332fe.tar.xz sora-testing-2aaf7665047f61e72495b99b6caaef54d19332fe.zip | |
chore: finalise performance optimisation batch and metrics
| -rw-r--r-- | Sora/Data/Booru/BooruManager.swift | 24 | ||||
| -rw-r--r-- | Sora/Views/FavoritesView.swift | 54 | ||||
| -rw-r--r-- | Sora/Views/Generic/GenericListView.swift | 49 | ||||
| -rw-r--r-- | SoraTests/ViewDerivedDataTests.swift | 76 | ||||
| -rw-r--r-- | docs/perf/2026-02-18-after.md | 92 | ||||
| -rw-r--r-- | docs/perf/2026-02-18-baseline.md | 1 |
6 files changed, 265 insertions, 31 deletions
diff --git a/Sora/Data/Booru/BooruManager.swift b/Sora/Data/Booru/BooruManager.swift index 9b674da..562bb1e 100644 --- a/Sora/Data/Booru/BooruManager.swift +++ b/Sora/Data/Booru/BooruManager.swift @@ -29,8 +29,6 @@ class BooruManager: ObservableObject { // swiftlint:disable:this type_body_leng private let userAgent: String private var urlCache: [String: URL] = [:] private var lastPostCount = 0 - private var xmlParserPool: [BooruPostXMLParser] = [] - private let parserPoolLock = NSLock() // MARK: - Computed Properties var tags: [String] { @@ -452,28 +450,6 @@ class BooruManager: ObservableObject { // swiftlint:disable:this type_body_leng return Array(uniquePosts.values) } - private func xmlParser(for provider: BooruProvider) -> BooruPostXMLParser { - parserPoolLock.lock() - - defer { parserPoolLock.unlock() } - - if let parser = xmlParserPool.popLast() { - return parser - } - - return BooruPostXMLParser(data: Data(), provider: provider) - } - - private func returnXMLParser(_ parser: BooruPostXMLParser) { - parserPoolLock.lock() - - defer { parserPoolLock.unlock() } - - if xmlParserPool.count < 3 { - xmlParserPool.append(parser) - } - } - private func updatePosts(_ newPosts: [BooruPost], replace: Bool) { if replace { posts = [] diff --git a/Sora/Views/FavoritesView.swift b/Sora/Views/FavoritesView.swift index 12f1bc0..04ecd24 100644 --- a/Sora/Views/FavoritesView.swift +++ b/Sora/Views/FavoritesView.swift @@ -25,6 +25,9 @@ struct FavoritesView: View { // swiftlint:disable:this type_body_length @State private var displayedFavorites: [SettingsFavoritePost] = [] @State private var displayedPosts: [BooruPost] = [] @State private var displayedColumnsData: [[SettingsFavoritePost]] = [] + @State private var shuffleOrderByIdentifier: [UUID: Int] = [:] + @State private var shuffleSourceIdentifiers: [UUID] = [] + @State private var shouldRefreshShuffleOrder = true private func refreshFolderHierarchy() { folderHierarchy = FolderHierarchy(folders: settings.folders) @@ -61,7 +64,7 @@ struct FavoritesView: View { // swiftlint:disable:this type_body_length } } - private func sortedFavorites(from unsortedFavorites: [SettingsFavoritePost]) + private func orderedFavorites(from unsortedFavorites: [SettingsFavoritePost]) -> [SettingsFavoritePost] { unsortedFavorites.sorted { leftFavorite, rightFavorite in @@ -76,8 +79,40 @@ struct FavoritesView: View { // swiftlint:disable:this type_body_length return leftFavorite.visitedCount > rightFavorite.visitedCount case .shuffle: - return Bool.random() + return leftFavorite.date > rightFavorite.date + } + } + } + + private func refreshShuffleOrder(for favoritesToShuffle: [SettingsFavoritePost]) { + let currentIdentifiers = favoritesToShuffle.map(\.id) + + if shouldRefreshShuffleOrder || shuffleSourceIdentifiers != currentIdentifiers { + var refreshedShuffleOrderByIdentifier: [UUID: Int] = [:] + let shuffledIdentifiers = currentIdentifiers.shuffled() + + for (identifierIndex, identifier) in shuffledIdentifiers.enumerated() { + refreshedShuffleOrderByIdentifier[identifier] = identifierIndex + } + + shuffleOrderByIdentifier = refreshedShuffleOrderByIdentifier + shuffleSourceIdentifiers = currentIdentifiers + shouldRefreshShuffleOrder = false + } + } + + private func shuffledFavorites(from favoritesToShuffle: [SettingsFavoritePost]) + -> [SettingsFavoritePost] + { + favoritesToShuffle.sorted { leftFavorite, rightFavorite in + let leftIndex = shuffleOrderByIdentifier[leftFavorite.id] ?? Int.max + let rightIndex = shuffleOrderByIdentifier[rightFavorite.id] ?? Int.max + + if leftIndex != rightIndex { + return leftIndex < rightIndex } + + return leftFavorite.id.uuidString < rightFavorite.id.uuidString } } @@ -93,7 +128,7 @@ struct FavoritesView: View { // swiftlint:disable:this type_body_length private func refreshDisplayedFavorites() { let normalizedSearchText = searchText.lowercased() - let filteredFavorites = settings.favorites.filter { favorite in + let matchingFavorites = settings.favorites.filter { favorite in let matchesSearch = normalizedSearchText.isEmpty || favorite.tags @@ -106,7 +141,14 @@ struct FavoritesView: View { // swiftlint:disable:this type_body_length return matchesSelectedCollection(for: favorite) && matchesSearch && matchesProvider } - let sortedFavorites = sortedFavorites(from: filteredFavorites) + let sortedFavorites: [SettingsFavoritePost] + + if sort == .shuffle { + refreshShuffleOrder(for: matchingFavorites) + sortedFavorites = shuffledFavorites(from: matchingFavorites) + } else { + sortedFavorites = orderedFavorites(from: matchingFavorites) + } displayedFavorites = sortedFavorites displayedPosts = sortedFavorites.map { $0.toBooruPost() } @@ -365,6 +407,10 @@ struct FavoritesView: View { // swiftlint:disable:this type_body_length refreshDisplayedFavorites() } .onChange(of: sort) { + if sort == .shuffle { + shouldRefreshShuffleOrder = true + } + refreshDisplayedFavorites() } .onChange(of: settings.folders) { diff --git a/Sora/Views/Generic/GenericListView.swift b/Sora/Views/Generic/GenericListView.swift index a829856..096ac85 100644 --- a/Sora/Views/Generic/GenericListView.swift +++ b/Sora/Views/Generic/GenericListView.swift @@ -32,6 +32,9 @@ struct GenericListView<T: Identifiable & Hashable & GenericItem>: View { let removeAllAction: () -> Void @State private var folderHierarchy = FolderHierarchy(folders: []) @State private var displayedItems: [T] = [] + @State private var shuffleOrderByIdentifier: [UUID: Int] = [:] + @State private var shuffleSourceIdentifiers: [UUID] = [] + @State private var shouldRefreshShuffleOrder = true private func refreshFolderHierarchy() { folderHierarchy = FolderHierarchy(folders: settings.folders) @@ -85,14 +88,44 @@ struct GenericListView<T: Identifiable & Hashable & GenericItem>: View { return leftItem.visitedCount > rightItem.visitedCount case .shuffle: - return Bool.random() + return leftItem.date > rightItem.date + } + } + } + + private func refreshShuffleOrder(for itemsToShuffle: [T]) { + let currentIdentifiers = itemsToShuffle.map(\.id) + + if shouldRefreshShuffleOrder || shuffleSourceIdentifiers != currentIdentifiers { + var refreshedShuffleOrderByIdentifier: [UUID: Int] = [:] + let shuffledIdentifiers = currentIdentifiers.shuffled() + + for (identifierIndex, identifier) in shuffledIdentifiers.enumerated() { + refreshedShuffleOrderByIdentifier[identifier] = identifierIndex + } + + shuffleOrderByIdentifier = refreshedShuffleOrderByIdentifier + shuffleSourceIdentifiers = currentIdentifiers + shouldRefreshShuffleOrder = false + } + } + + private func shuffledItems(from itemsToShuffle: [T]) -> [T] { + itemsToShuffle.sorted { leftItem, rightItem in + let leftIndex = shuffleOrderByIdentifier[leftItem.id] ?? Int.max + let rightIndex = shuffleOrderByIdentifier[rightItem.id] ?? Int.max + + if leftIndex != rightIndex { + return leftIndex < rightIndex } + + return leftItem.id.uuidString < rightItem.id.uuidString } } private func refreshDisplayedItems() { let normalizedSearchText = searchText.lowercased() - let filteredItems = items.filter { item in + let matchingItems = items.filter { item in let matchesSearch = normalizedSearchText.isEmpty || item.tags @@ -106,7 +139,13 @@ struct GenericListView<T: Identifiable & Hashable & GenericItem>: View { return matchesSelectedCollection(for: item) && matchesSearch && matchesProvider } - displayedItems = sortedItems(from: filteredItems) + if !allowBookmarking, sort == .shuffle { + refreshShuffleOrder(for: matchingItems) + displayedItems = shuffledItems(from: matchingItems) + return + } + + displayedItems = sortedItems(from: matchingItems) } @ViewBuilder private var listContent: some View { @@ -379,6 +418,10 @@ struct GenericListView<T: Identifiable & Hashable & GenericItem>: View { refreshDisplayedItems() } .onChange(of: sort) { + if sort == .shuffle { + shouldRefreshShuffleOrder = true + } + refreshDisplayedItems() } .onChange(of: settings.folders) { diff --git a/SoraTests/ViewDerivedDataTests.swift b/SoraTests/ViewDerivedDataTests.swift index a97465d..d8760e7 100644 --- a/SoraTests/ViewDerivedDataTests.swift +++ b/SoraTests/ViewDerivedDataTests.swift @@ -191,6 +191,82 @@ final class ViewDerivedDataTests: XCTestCase { ) } + func testListViewsAvoidComparatorRandomShuffleSorting() throws { + let listViewSource = try loadSource(at: "Sora/Views/Generic/GenericListView.swift") + let favoritesViewSource = try loadSource(at: "Sora/Views/FavoritesView.swift") + let normalizedListViewSource = strippingCommentsAndStrings(from: listViewSource) + let normalizedFavoritesViewSource = strippingCommentsAndStrings(from: favoritesViewSource) + + let listViewComparatorRandomCount = tokenCount( + matching: #"\bBool\s*\.\s*random\s*\("#, + in: normalizedListViewSource + ) + let favoritesViewComparatorRandomCount = tokenCount( + matching: #"\bBool\s*\.\s*random\s*\("#, + in: normalizedFavoritesViewSource + ) + + // swiftlint:disable:next prefer_nimble + XCTAssertEqual( + listViewComparatorRandomCount, + 0, + "Generic list sorting should not use comparator-based random ordering." + ) + // swiftlint:disable:next prefer_nimble + XCTAssertEqual( + favoritesViewComparatorRandomCount, + 0, + "Favorites sorting should not use comparator-based random ordering." + ) + } + + func testBooruManagerRemovesUnusedXMLParserPoolPaths() throws { + let source = try loadSource(at: "Sora/Data/Booru/BooruManager.swift") + let normalizedSource = strippingCommentsAndStrings(from: source) + + let poolStorageCount = tokenCount( + matching: #"\bxmlParserPool\b"#, + in: normalizedSource + ) + let lockStorageCount = tokenCount( + matching: #"\bparserPoolLock\b"#, + in: normalizedSource + ) + let parserFactoryCount = invocationCount( + forFunction: "xmlParser", + in: normalizedSource + ) + let parserReturnCount = invocationCount( + forFunction: "returnXMLParser", + in: normalizedSource + ) + + // swiftlint:disable:next prefer_nimble + XCTAssertEqual( + poolStorageCount, + 0, + "BooruManager should not keep an unused XML parser pool." + ) + // swiftlint:disable:next prefer_nimble + XCTAssertEqual( + lockStorageCount, + 0, + "BooruManager should not keep an unused parser pool lock." + ) + // swiftlint:disable:next prefer_nimble + XCTAssertEqual( + parserFactoryCount, + 0, + "BooruManager should not expose unused xml parser factory paths." + ) + // swiftlint:disable:next prefer_nimble + XCTAssertEqual( + parserReturnCount, + 0, + "BooruManager should not expose unused xml parser return paths." + ) + } + private func referenceCount(for symbol: String, in source: String) -> Int { let totalMatches = tokenCount( matching: #"\b\#(symbol)\b"#, diff --git a/docs/perf/2026-02-18-after.md b/docs/perf/2026-02-18-after.md new file mode 100644 index 0000000..00cb48a --- /dev/null +++ b/docs/perf/2026-02-18-after.md @@ -0,0 +1,92 @@ +# 2026-02-18 After Metrics + +## Scope +- Task 8 final verification after performance and redundancy optimisation tasks. +- Includes dead-code cleanup, derived-data hot-path cleanup, and shuffle-order stability changes. + +## Commands Run + +### Full test suite via app scheme +```bash +xcodebuild -project Sora.xcodeproj -scheme Sora -destination 'platform=iOS Simulator,name=iPhone 17 Pro' test +``` +Result: success. + +Summary: +- `SoraTests` executed: `14` +- failures: `0` + +### Format and lint +```bash +just format && just lint +``` +Result: success. + +### Debug simulator build +```bash +just build_ios_simulator Debug +``` +Result: success. + +## Timing Snapshots + +### Full test suite +Command: +```bash +/usr/bin/time -p sh -c "xcodebuild -project Sora.xcodeproj -scheme Sora -destination 'platform=iOS Simulator,name=iPhone 17 Pro' test > /tmp/sora_task8_full_test.log 2>&1" +``` +Timing: +- real: `8.51s` +- user: `1.60s` +- sys: `1.23s` + +### Format and lint +Command: +```bash +/usr/bin/time -p sh -c "just format && just lint" +``` +Timing: +- real: `0.66s` +- user: `1.33s` +- sys: `0.14s` + +### Debug simulator build +Command: +```bash +/usr/bin/time -p just build_ios_simulator Debug +``` +Timing: +- real: `2.21s` +- user: `0.88s` +- sys: `0.45s` + +## Static Code-Path Checks + +Post-grid derived-data hot path: +- `PostGridView.activePosts` references: `0` +- `PostGridView.getColumnsData` invocations: `0` + +Generic list derived-data symbols: +- `filteredItems` references: `0` +- `sortedFilteredItems` references: `0` +- comparator `Bool.random()` calls: `0` + +Favorites derived-data symbols: +- `filteredFavorites` references: `0` +- `sortedFilteredFavorites` references: `0` +- comparator `Bool.random()` calls: `0` + +Booru manager dead parser-pool paths: +- `xmlParserPool` references: `0` +- `parserPoolLock` references: `0` +- `xmlParser(` invocations: `0` +- `returnXMLParser(` invocations: `0` + +## Render And Behaviour Notes +- Post-grid derived post list and column layout are refreshed only on explicit dependency changes (`posts`, rating filters, column count). +- Generic and favorites shuffle now uses stable shuffled order maps; reordering occurs on explicit shuffle trigger or changed source item sets. +- Existing source-analysis regression tests for derived-data paths pass. + +## Remaining Warnings +- Existing Xcode project warning remains: + - `Sora.xcodeproj`: Copy Bundle Resources includes `Sora/Resources/Info.generated.plist`. diff --git a/docs/perf/2026-02-18-baseline.md b/docs/perf/2026-02-18-baseline.md index 9240ad6..bdea2d2 100644 --- a/docs/perf/2026-02-18-baseline.md +++ b/docs/perf/2026-02-18-baseline.md @@ -90,3 +90,4 @@ Timing: ## Notes - Existing warning observed during build/test: - `Sora.xcodeproj`: Copy Bundle Resources includes `Sora/Resources/Info.generated.plist`. +- Follow-up metrics and final verification are recorded in `docs/perf/2026-02-18-after.md`. |