Improve projection performance
This commit is contained in:
parent
d2617a25ff
commit
b5046a16c2
@ -26,7 +26,11 @@ final class SourceLibrary: ObservableObject, SourceScanSessionHosting, SourcePer
|
||||
category: "ConnectedDevicePerformance"
|
||||
)
|
||||
|
||||
@Published var sources: [MinecraftSource] = []
|
||||
@Published var sources: [MinecraftSource] = [] {
|
||||
didSet {
|
||||
rebuildLookupIndexes()
|
||||
}
|
||||
}
|
||||
@Published var connectedDevices: [ConnectedDeviceSidebarEntry] = []
|
||||
@Published var isRestoringPersistedSources = true
|
||||
|
||||
@ -40,6 +44,8 @@ final class SourceLibrary: ObservableObject, SourceScanSessionHosting, SourcePer
|
||||
private let notificationService: ScanNotificationServicing
|
||||
private let itemActionService: ContentItemActionService
|
||||
private let connectedDeviceSourceFactory = ConnectedDeviceSourceFactory()
|
||||
private var sourceIndexByID: [URL: Int] = [:]
|
||||
private var sourceIDByItemID: [URL: URL] = [:]
|
||||
var lastMatchedConnectedSourceIDs: Set<URL> = []
|
||||
var cachedDeviceDiscoveryByUDID: [String: CachedConnectedDeviceDiscovery] = [:]
|
||||
var isShuttingDown = false
|
||||
@ -90,6 +96,14 @@ final class SourceLibrary: ObservableObject, SourceScanSessionHosting, SourcePer
|
||||
visibleSources
|
||||
}
|
||||
|
||||
func sourceID(forItemID itemID: URL) -> URL? {
|
||||
sourceIDByItemID[itemID]
|
||||
}
|
||||
|
||||
func containsItem(withID itemID: URL) -> Bool {
|
||||
sourceIDByItemID[itemID] != nil
|
||||
}
|
||||
|
||||
func shutdown() {
|
||||
guard !isShuttingDown else {
|
||||
return
|
||||
@ -189,7 +203,11 @@ final class SourceLibrary: ObservableObject, SourceScanSessionHosting, SourcePer
|
||||
}
|
||||
|
||||
func source(withID sourceID: URL) -> MinecraftSource? {
|
||||
sources.first(where: { $0.id == sourceID })
|
||||
guard let index = sourceIndexByID[sourceID], sources.indices.contains(index) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return sources[index]
|
||||
}
|
||||
|
||||
func rescanSource(withID sourceID: URL) {
|
||||
@ -499,6 +517,18 @@ final class SourceLibrary: ObservableObject, SourceScanSessionHosting, SourcePer
|
||||
)
|
||||
}
|
||||
|
||||
private func rebuildLookupIndexes() {
|
||||
sourceIndexByID = Dictionary(uniqueKeysWithValues: sources.enumerated().map { ($0.element.id, $0.offset) })
|
||||
|
||||
var itemIndex: [URL: URL] = [:]
|
||||
for source in sources {
|
||||
for item in source.items {
|
||||
itemIndex[item.id] = source.id
|
||||
}
|
||||
}
|
||||
sourceIDByItemID = itemIndex
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func updateAvailability(for sourceID: URL, to newAvailability: SourceAvailability) -> (previous: SourceAvailability, becameAvailable: Bool) {
|
||||
SourceSyncRuntime.updateAvailability(for: sourceID, to: newAvailability, on: self)
|
||||
|
||||
@ -33,7 +33,7 @@ struct ItemListColumnView<MenuContent: View>: View {
|
||||
let subtitle: String
|
||||
let showsSubtitle: Bool
|
||||
let isRefreshing: Bool
|
||||
let isUpdatingProjection: Bool
|
||||
let showsProjectionLoadingState: Bool
|
||||
let items: [MinecraftContentItem]
|
||||
let searchPrompt: String
|
||||
let chooseFolderAction: () -> Void
|
||||
@ -58,7 +58,7 @@ struct ItemListColumnView<MenuContent: View>: View {
|
||||
}
|
||||
.listStyle(.inset)
|
||||
.overlay {
|
||||
if isUpdatingProjection && items.isEmpty {
|
||||
if showsProjectionLoadingState && items.isEmpty {
|
||||
ItemListLoadingOverlay()
|
||||
}
|
||||
}
|
||||
@ -73,7 +73,7 @@ struct ItemListColumnView<MenuContent: View>: View {
|
||||
subtitle: subtitle,
|
||||
showsSubtitle: showsSubtitle,
|
||||
isRefreshing: isRefreshing,
|
||||
isUpdatingProjection: isUpdatingProjection
|
||||
showsProjectionLoadingState: showsProjectionLoadingState
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -106,7 +106,7 @@ private struct ItemListHeaderView: View {
|
||||
let subtitle: String
|
||||
let showsSubtitle: Bool
|
||||
let isRefreshing: Bool
|
||||
let isUpdatingProjection: Bool
|
||||
let showsProjectionLoadingState: Bool
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
@ -121,13 +121,13 @@ private struct ItemListHeaderView: View {
|
||||
.font(.title2.weight(.semibold))
|
||||
.lineLimit(2)
|
||||
|
||||
if isRefreshing || isUpdatingProjection {
|
||||
if isRefreshing || showsProjectionLoadingState {
|
||||
ProgressView()
|
||||
.appActivityIndicatorStyle(.small)
|
||||
}
|
||||
}
|
||||
|
||||
if showsSubtitle || isUpdatingProjection {
|
||||
if showsSubtitle || showsProjectionLoadingState {
|
||||
Text(displaySubtitle)
|
||||
.appTextStyle(.supporting)
|
||||
}
|
||||
@ -140,7 +140,7 @@ private struct ItemListHeaderView: View {
|
||||
}
|
||||
|
||||
private var displaySubtitle: String {
|
||||
if isUpdatingProjection {
|
||||
if showsProjectionLoadingState {
|
||||
return "Loading items..."
|
||||
}
|
||||
|
||||
|
||||
@ -323,7 +323,7 @@ struct ItemListColumnPreviewContainer: View {
|
||||
subtitle: "5 items in Kid iPad Imports",
|
||||
showsSubtitle: false,
|
||||
isRefreshing: false,
|
||||
isUpdatingProjection: false,
|
||||
showsProjectionLoadingState: false,
|
||||
items: PreviewFixtures.primarySource.displayItems,
|
||||
searchPrompt: "Search Worlds",
|
||||
chooseFolderAction: {},
|
||||
|
||||
@ -20,6 +20,7 @@ struct ContentView: View {
|
||||
@State private var isShowingDeviceSourceSheet = false
|
||||
@State private var sortMode: ItemSortMode = .name
|
||||
@State private var directoryPreviewContents: [DirectoryEntry] = []
|
||||
@State private var showsProjectionLoadingState = false
|
||||
@State private var itemListProjection = ItemCollectionProjection.placeholder(
|
||||
for: ItemCollectionProjectionRequest(
|
||||
selection: nil,
|
||||
@ -33,6 +34,7 @@ struct ContentView: View {
|
||||
private let deviceSourceFactory: ConnectedDeviceSourceFactory
|
||||
private let itemActionService: ContentItemActionService
|
||||
private let directoryPreviewLimit = 12
|
||||
private let projectionLoadingDelay: Duration = .milliseconds(150)
|
||||
|
||||
init() {
|
||||
let dependencies = ContentViewDependencies.makeDefault()
|
||||
@ -53,15 +55,25 @@ struct ContentView: View {
|
||||
sortMode: sortMode,
|
||||
source: resolvedCurrentSource
|
||||
)
|
||||
let fastPathProjection = fastProjection(for: currentProjectionRequest, previousRequest: itemListProjection.request)
|
||||
let reusesCurrentProjectedItems =
|
||||
itemListProjection.request.selection == currentProjectionRequest.selection &&
|
||||
itemListProjection.request.source?.id == currentProjectionRequest.source?.id
|
||||
let isUpdatingItemListProjection = itemListProjection.request != currentProjectionRequest
|
||||
let resolvedItemListProjection = itemListProjection.request == currentProjectionRequest
|
||||
? itemListProjection
|
||||
: ItemCollectionProjection.placeholder(for: currentProjectionRequest)
|
||||
let resolvedItemListProjection = if let fastPathProjection {
|
||||
fastPathProjection
|
||||
} else if itemListProjection.request == currentProjectionRequest {
|
||||
itemListProjection
|
||||
} else {
|
||||
ItemCollectionProjection.placeholder(for: currentProjectionRequest)
|
||||
}
|
||||
let resolvedCurrentSelectedItem = currentSelectedItem(in: resolvedCurrentSource)
|
||||
let resolvedDisplayedItems = reusesCurrentProjectedItems ? itemListProjection.items : []
|
||||
let resolvedDisplayedItems: [MinecraftContentItem] = if let fastPathProjection {
|
||||
fastPathProjection.items
|
||||
} else if reusesCurrentProjectedItems {
|
||||
itemListProjection.items
|
||||
} else {
|
||||
[]
|
||||
}
|
||||
|
||||
NavigationSplitView(columnVisibility: $columnVisibility) {
|
||||
SourcesSidebarView(
|
||||
@ -94,9 +106,9 @@ struct ContentView: View {
|
||||
showsSourceName: !isSidebarVisible,
|
||||
title: resolvedItemListProjection.title,
|
||||
subtitle: resolvedItemListProjection.subtitle,
|
||||
showsSubtitle: isSearching || isUpdatingItemListProjection,
|
||||
showsSubtitle: isSearching || showsProjectionLoadingState,
|
||||
isRefreshing: resolvedCurrentSource?.isScanning == true,
|
||||
isUpdatingProjection: isUpdatingItemListProjection,
|
||||
showsProjectionLoadingState: showsProjectionLoadingState,
|
||||
items: resolvedDisplayedItems,
|
||||
searchPrompt: resolvedItemListProjection.searchPrompt,
|
||||
chooseFolderAction: pickFolder,
|
||||
@ -180,14 +192,41 @@ struct ContentView: View {
|
||||
}
|
||||
.task(id: currentProjectionRequest) {
|
||||
let request = currentProjectionRequest
|
||||
if let fastPathProjection = fastProjection(for: request, previousRequest: itemListProjection.request) {
|
||||
showsProjectionLoadingState = false
|
||||
itemListProjection = fastPathProjection
|
||||
return
|
||||
}
|
||||
|
||||
let projection = await Task.detached(priority: .userInitiated) {
|
||||
ItemCollectionProjector.makeProjection(for: request)
|
||||
}.value
|
||||
guard !Task.isCancelled else {
|
||||
return
|
||||
}
|
||||
showsProjectionLoadingState = false
|
||||
itemListProjection = projection
|
||||
}
|
||||
.task(id: currentProjectionRequest) {
|
||||
showsProjectionLoadingState = false
|
||||
|
||||
guard itemListProjection.request != currentProjectionRequest else {
|
||||
return
|
||||
}
|
||||
|
||||
guard fastProjection(for: currentProjectionRequest, previousRequest: itemListProjection.request) == nil else {
|
||||
return
|
||||
}
|
||||
|
||||
try? await Task.sleep(for: projectionLoadingDelay)
|
||||
guard !Task.isCancelled else {
|
||||
return
|
||||
}
|
||||
|
||||
if itemListProjection.request != currentProjectionRequest {
|
||||
showsProjectionLoadingState = true
|
||||
}
|
||||
}
|
||||
.task(id: resolvedCurrentSelectedItem?.id) {
|
||||
await refreshDirectoryPreviewContents()
|
||||
}
|
||||
@ -224,10 +263,9 @@ struct ContentView: View {
|
||||
return item
|
||||
}
|
||||
|
||||
for source in library.visibleSources {
|
||||
if let item = source.items.first(where: { $0.id == selectedItemID }) {
|
||||
return item
|
||||
}
|
||||
if let sourceID = library.sourceID(forItemID: selectedItemID),
|
||||
let source = library.source(withID: sourceID) {
|
||||
return source.items.first(where: { $0.id == selectedItemID })
|
||||
}
|
||||
|
||||
return nil
|
||||
@ -558,18 +596,17 @@ struct ContentView: View {
|
||||
}
|
||||
|
||||
if let selectedItemID {
|
||||
let itemStillExists = library.visibleSources
|
||||
.flatMap(\.items)
|
||||
.contains(where: { $0.id == selectedItemID })
|
||||
|
||||
if !itemStillExists {
|
||||
if !library.containsItem(withID: selectedItemID) {
|
||||
self.selectedItemID = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func areFileActionsEnabled(for item: MinecraftContentItem) -> Bool {
|
||||
guard let source = library.visibleSources.first(where: { $0.items.contains(where: { $0.id == item.id }) }) else {
|
||||
guard
|
||||
let sourceID = library.sourceID(forItemID: item.id),
|
||||
let source = library.source(withID: sourceID)
|
||||
else {
|
||||
return false
|
||||
}
|
||||
|
||||
@ -577,9 +614,28 @@ struct ContentView: View {
|
||||
}
|
||||
|
||||
private func sourceForItem(_ item: MinecraftContentItem) -> MinecraftSource? {
|
||||
library.visibleSources.first(where: { source in
|
||||
source.items.contains(where: { $0.id == item.id })
|
||||
})
|
||||
guard let sourceID = library.sourceID(forItemID: item.id) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return library.source(withID: sourceID)
|
||||
}
|
||||
|
||||
private func fastProjection(
|
||||
for request: ItemCollectionProjectionRequest,
|
||||
previousRequest: ItemCollectionProjectionRequest
|
||||
) -> ItemCollectionProjection? {
|
||||
guard
|
||||
let sourceID = request.source?.id,
|
||||
sourceID == previousRequest.source?.id,
|
||||
request.searchText == previousRequest.searchText,
|
||||
request.sortMode == previousRequest.sortMode,
|
||||
request.selection != previousRequest.selection
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return ItemCollectionProjector.makeProjection(for: request)
|
||||
}
|
||||
|
||||
private func saveItem(_ item: MinecraftContentItem) {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user