summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorFuwn <[email protected]>2026-02-18 12:45:11 -0800
committerFuwn <[email protected]>2026-02-18 12:45:19 -0800
commit2aaf7665047f61e72495b99b6caaef54d19332fe (patch)
treec82cbc7a8b99bdc2eb59e75748c2c18bb9d39713
parentperf: memoize post grid derived collections and remove columns cache (diff)
downloadsora-testing-2aaf7665047f61e72495b99b6caaef54d19332fe.tar.xz
sora-testing-2aaf7665047f61e72495b99b6caaef54d19332fe.zip
chore: finalise performance optimisation batch and metrics
-rw-r--r--Sora/Data/Booru/BooruManager.swift24
-rw-r--r--Sora/Views/FavoritesView.swift54
-rw-r--r--Sora/Views/Generic/GenericListView.swift49
-rw-r--r--SoraTests/ViewDerivedDataTests.swift76
-rw-r--r--docs/perf/2026-02-18-after.md92
-rw-r--r--docs/perf/2026-02-18-baseline.md1
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`.