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