From 4b5dca0be8c6da3db98b7902324725850629b78c Mon Sep 17 00:00:00 2001 From: Allen Humphreys Date: Fri, 30 Jun 2023 16:47:11 -0400 Subject: [PATCH] Clean up AppCoordinator and have a look at performance --- Packages/ConfCore/ConfCore/Logging.swift | 35 +++ Packages/ConfCore/ConfCore/Session.swift | 29 +- Packages/ConfCore/ConfCore/Storage.swift | 11 +- PlayerUI/Views/PUIPlayerView.swift | 1 + WWDC/AppCoordinator+SessionActions.swift | 6 +- WWDC/AppCoordinator.swift | 298 ++++++++++----------- WWDC/ScheduleContainerViewController.swift | 12 +- WWDC/SessionRowProvider.swift | 57 +++- WWDC/SessionViewModel.swift | 9 +- WWDC/SessionsTableViewController.swift | 10 +- 10 files changed, 271 insertions(+), 197 deletions(-) diff --git a/Packages/ConfCore/ConfCore/Logging.swift b/Packages/ConfCore/ConfCore/Logging.swift index be675324..047ec75e 100644 --- a/Packages/ConfCore/ConfCore/Logging.swift +++ b/Packages/ConfCore/ConfCore/Logging.swift @@ -48,3 +48,38 @@ public extension Logging { public func makeLogger(subsystem: String, category: String) -> Logger { Logger(subsystem: subsystem, category: category) } + +public protocol Signposting: Logging { + static var signposter: OSSignposter { get } + var signposter: OSSignposter { get } +} + +public extension Signposting { + static func makeSignposter() -> OSSignposter { OSSignposter(logger: log) } + var signposter: OSSignposter { Self.signposter } +} + +public extension OSSignposter { + /// Convenient but several caveats because OSLogMessage is stupid + func withEscapingOneShotIntervalSignpost( + _ name: StaticString, + _ message: String? = nil, + around task: (@escaping () -> Void) throws -> T + ) rethrows -> T { + var state: OSSignpostIntervalState? + if let message { + state = beginInterval(name, id: makeSignpostID(), "\(message)") + } else { + state = beginInterval(name, id: makeSignpostID()) + } + + let end = { + if let innerState = state { + state = nil + endInterval(name, innerState) + } + } + + return try task(end) + } +} diff --git a/Packages/ConfCore/ConfCore/Session.swift b/Packages/ConfCore/ConfCore/Session.swift index db491f2b..3054a46a 100644 --- a/Packages/ConfCore/ConfCore/Session.swift +++ b/Packages/ConfCore/ConfCore/Session.swift @@ -117,11 +117,14 @@ public class Session: Object, Decodable { mediaDuration = other.mediaDuration // merge assets - let assets = other.assets.filter { otherAsset in - return !self.assets.contains(where: { $0.identifier == otherAsset.identifier }) + // Pulling the identifiers into Swift Arrays is an optimization for realm + let currentAssetIds = Array(self.assets.map(\.identifier)) + other.assets.forEach { otherAsset in + guard !currentAssetIds.contains(otherAsset.identifier) else { return } + self.assets.append(otherAsset) } - self.assets.append(objectsIn: assets) + let currentRelatedIds = Array(related.map(\.identifier)) other.related.forEach { newRelated in let effectiveRelated: RelatedResource @@ -131,10 +134,11 @@ public class Session: Object, Decodable { effectiveRelated = newRelated } - guard !related.contains(where: { $0.identifier == effectiveRelated.identifier }) else { return } + guard !currentRelatedIds.contains(effectiveRelated.identifier) else { return } related.append(effectiveRelated) } + let currentFocusIds = Array(focuses.map(\.name)) other.focuses.forEach { newFocus in let effectiveFocus: Focus @@ -144,7 +148,7 @@ public class Session: Object, Decodable { effectiveFocus = newFocus } - guard !focuses.contains(where: { $0.name == effectiveFocus.name }) else { return } + guard !currentFocusIds.contains(effectiveFocus.name) else { return } focuses.append(effectiveFocus) } @@ -311,6 +315,21 @@ extension Session { return asset } + public func thumbImageAsset() -> SessionAsset? { + guard let baseURL = event.first.flatMap({ URL(string: $0.imagesPath) }) else { return nil } + + let filename = "\(staticContentId)_wide_162x91_2x.jpg" + + let url = baseURL.appendingPathComponent("\(staticContentId)/\(filename)") + + let asset = SessionAsset() + + asset.assetType = .image + asset.remoteURL = url.absoluteString + + return asset + } + public func assets(matching types: [SessionAssetType]) -> Results { assert(!types.contains(.image), "This method does not support finding image assets") diff --git a/Packages/ConfCore/ConfCore/Storage.swift b/Packages/ConfCore/ConfCore/Storage.swift index d48249ef..3f085fad 100644 --- a/Packages/ConfCore/ConfCore/Storage.swift +++ b/Packages/ConfCore/ConfCore/Storage.swift @@ -11,13 +11,14 @@ import Foundation import RealmSwift import OSLog -public final class Storage: Logging { +public final class Storage: Logging, Signposting { public let realmConfig: Realm.Configuration public let realm: Realm private var disposeBag: Set = [] public static let log = makeLogger() + public static var signposter = makeSignposter() public init(_ realm: Realm) { self.realmConfig = realm.configuration @@ -77,6 +78,7 @@ public final class Storage: Logging { } func store(contentResult: Result, completion: @escaping (Error?) -> Void) { + let state = signposter.beginInterval("store content result", id: signposter.makeSignpostID(), "begin") let contentsResponse: ContentsResponse do { contentsResponse = try contentResult.get() @@ -86,7 +88,12 @@ public final class Storage: Logging { return } - performSerializedBackgroundWrite(disableAutorefresh: true, completionBlock: completion) { backgroundRealm in + performSerializedBackgroundWrite( + disableAutorefresh: true + ) { [weak self] in + self?.signposter.endInterval("store content result", state, "end") + completion($0) + } writeBlock: { backgroundRealm in // Save everything backgroundRealm.add(contentsResponse.rooms, update: .all) backgroundRealm.add(contentsResponse.tracks, update: .all) diff --git a/PlayerUI/Views/PUIPlayerView.swift b/PlayerUI/Views/PUIPlayerView.swift index 1ac0e47d..acbbddd1 100644 --- a/PlayerUI/Views/PUIPlayerView.swift +++ b/PlayerUI/Views/PUIPlayerView.swift @@ -1637,6 +1637,7 @@ extension PUIPlayerView: AVPictureInPictureControllerDelegate { } // Called 2nd, not called when the exit button is pressed + // TODO: The restore button doesn't attempt to do a restoration if the source view is no longer in a window public func pictureInPictureController( _ pictureInPictureController: AVPictureInPictureController, restoreUserInterfaceForPictureInPictureStopWithCompletionHandler completionHandler: @escaping (Bool) -> Void diff --git a/WWDC/AppCoordinator+SessionActions.swift b/WWDC/AppCoordinator+SessionActions.swift index 2bab6b5f..1687a1d0 100644 --- a/WWDC/AppCoordinator+SessionActions.swift +++ b/WWDC/AppCoordinator+SessionActions.swift @@ -191,18 +191,18 @@ extension AppCoordinator { alert.window.center() enum Choice: NSApplication.ModalResponse.RawValue { - case removeCalender = 1000 + case removeCalendar = 1000 case cancel = 1001 } guard let choice = Choice(rawValue: alert.runModal().rawValue) else { return } switch choice { - case .removeCalender: + case .removeCalendar: do { try eventStore.remove(storedEvent, span: .thisEvent, commit: true) } catch let error as NSError { - log.error("Failed to remove event from calender: \(String(describing: error), privacy: .public)") + log.error("Failed to remove event from calendar: \(String(describing: error), privacy: .public)") } default: break diff --git a/WWDC/AppCoordinator.swift b/WWDC/AppCoordinator.swift index 13ba1c75..e9073b79 100644 --- a/WWDC/AppCoordinator.swift +++ b/WWDC/AppCoordinator.swift @@ -14,9 +14,11 @@ import PlayerUI import OSLog import AVFoundation -final class AppCoordinator: Logging { +final class AppCoordinator: Logging, Signposting { static let log = makeLogger() + static let signposter: OSSignposter = makeSignposter() + private lazy var cancellables = Set() var liveObserver: LiveObserver @@ -24,9 +26,12 @@ final class AppCoordinator: Logging { var storage: Storage var syncEngine: SyncEngine + // - Top level controllers var windowController: MainWindowController var tabController: WWDCTabViewController + var searchCoordinator: SearchCoordinator + // - The 3 tabs var exploreController: ExploreViewController var scheduleController: ScheduleContainerViewController var videosController: SessionsSplitViewController @@ -50,7 +55,53 @@ final class AppCoordinator: Logging { /// Whether we were playing the video when a clip sharing session begin, to restore state later. var wasPlayingWhenClipSharingBegan = false + /// The list controller for the active tab + var currentListController: SessionsTableViewController? { + switch activeTab { + case .schedule: + return scheduleController.splitViewController.listViewController + case .videos: + return videosController.listViewController + default: + return nil + } + } + + var exploreTabLiveSession: some Publisher { + let liveInstances = storage.realm.objects(SessionInstance.self) + .filter("rawSessionType == 'Special Event' AND isCurrentlyLive == true") + .sorted(byKeyPath: "startTime", ascending: false) + + return liveInstances.collectionPublisher + .map({ $0.toArray().first?.session }) + .map({ SessionViewModel(session: $0, instance: $0?.instances.first, track: nil, style: .schedule) }) + .replaceErrorWithEmpty() + } + + /// The session that is currently selected on the videos tab (observable) + var selectedSession: some Publisher { videosController.listViewController.$selectedSession } + + /// The session that is currently selected on the schedule tab (observable) + var selectedScheduleItem: some Publisher { scheduleController.splitViewController.listViewController.$selectedSession } + + /// The session that is currently selected on the videos tab + var selectedSessionValue: SessionViewModel? { videosController.listViewController.selectedSession } + + /// The session that is currently selected on the schedule tab + var selectedScheduleItemValue: SessionViewModel? { scheduleController.splitViewController.listViewController.selectedSession } + + /// The selected session's view model, regardless of which tab it is selected in + var selectedViewModelRegardlessOfTab: SessionViewModel? + + /// The viewModel for the current playback session + var currentPlaybackViewModel: PlaybackViewModel? { + didSet { + observeNowPlayingInfo() + } + } + init(windowController: MainWindowController, storage: Storage, syncEngine: SyncEngine) { + let signpostState = Self.signposter.beginInterval("initialization", id: Self.signposter.makeSignpostID(), "begin init") self.storage = storage self.syncEngine = syncEngine @@ -121,13 +172,6 @@ final class AppCoordinator: Logging { restoreApplicationState() - setupBindings() - setupDelegation() - - _ = NotificationCenter.default.addObserver(forName: NSApplication.willTerminateNotification, object: nil, queue: nil) { _ in self.saveApplicationState() } - _ = NotificationCenter.default.addObserver(forName: .RefreshPeriodicallyPreferenceDidChange, object: nil, queue: nil, using: { _ in self.resetAutorefreshTimer() }) - _ = NotificationCenter.default.addObserver(forName: .PreferredTranscriptLanguageDidChange, object: nil, queue: .main, using: { self.preferredTranscriptLanguageDidChange($0) }) - NSApp.isAutomaticCustomizeTouchBarMenuItemEnabled = true let buttonsController = TitleBarButtonsViewController( @@ -141,58 +185,50 @@ final class AppCoordinator: Logging { } startup() + Self.signposter.endInterval("initialization", signpostState, "end init") } - /// The list controller for the active tab - var currentListController: SessionsTableViewController? { - switch activeTab { - case .schedule: - return scheduleController.splitViewController.listViewController - case .videos: - return videosController.listViewController - default: - return nil - } - } - - var exploreTabLiveSession: some Publisher { - let liveInstances = storage.realm.objects(SessionInstance.self) - .filter("rawSessionType == 'Special Event' AND isCurrentlyLive == true") - .sorted(byKeyPath: "startTime", ascending: false) + // MARK: - Start up - return liveInstances.collectionPublisher - .map({ $0.toArray().first?.session }) - .map({ SessionViewModel(session: $0, instance: $0?.instances.first, track: nil, style: .schedule) }) - .replaceErrorWithEmpty() - } + func startup() { + setupBindings() + setupDelegation() + setupObservations() - /// The session that is currently selected on the videos tab (observable) - var selectedSession: some Publisher { - return videosController.listViewController.$selectedSession - } + NotificationCenter.default.publisher(for: NSApplication.willTerminateNotification).sink { _ in + self.saveApplicationState() + }.store(in: &cancellables) + NotificationCenter.default.publisher(for: .RefreshPeriodicallyPreferenceDidChange).sink { _ in + self.resetAutorefreshTimer() + }.store(in: &cancellables) + NotificationCenter.default.publisher(for: .PreferredTranscriptLanguageDidChange).receive(on: DispatchQueue.main).sink { + self.preferredTranscriptLanguageDidChange($0) + }.store(in: &cancellables) + NotificationCenter.default.publisher(for: .SyncEngineDidSyncSessionsAndSchedule).receive(on: DispatchQueue.main).sink { [weak self] note in + guard self?.checkSyncEngineOperationSucceededAndShowError(note: note) == true else { return } + DownloadManager.shared.syncWithFileSystem() + }.store(in: &cancellables) + NotificationCenter.default.publisher(for: .WWDCEnvironmentDidChange).receive(on: DispatchQueue.main).sink { _ in + self.refresh(nil) + }.store(in: &cancellables) - /// The session that is currently selected on the schedule tab (observable) - var selectedScheduleItem: some Publisher { - return scheduleController.splitViewController.listViewController.$selectedSession - } + liveObserver.start() - /// The session that is currently selected on the videos tab - var selectedSessionValue: SessionViewModel? { - return videosController.listViewController.selectedSession - } + DispatchQueue.main.async { self.configureSharePlayIfSupported() } - /// The session that is currently selected on the schedule tab - var selectedScheduleItemValue: SessionViewModel? { - return scheduleController.splitViewController.listViewController.selectedSession - } + refresh(nil) + windowController.contentViewController = tabController + windowController.showWindow(self) + tabController.showLoading() - /// The selected session's view model, regardless of which tab it is selected in - var selectedViewModelRegardlessOfTab: SessionViewModel? + // Allow the window time to display before pulling the data from realm + DispatchQueue.main.async { + self.videosController.listViewController.sessionRowProvider.startup() + self.scheduleController.splitViewController.listViewController.sessionRowProvider.startup() + } - /// The viewModel for the current playback session - var currentPlaybackViewModel: PlaybackViewModel? { - didSet { - observeNowPlayingInfo() + if Arguments.showPreferences { + showPreferences(nil) } } @@ -207,12 +243,15 @@ final class AppCoordinator: Logging { } .store(in: &cancellables) + // Bind the session tables' selections to their respective detail view controller + // This seems like it should move down a level as those are both children func bind(session: P, to detailsController: SessionDetailsViewController) where P.Output == SessionViewModel?, P.Failure == Never { session.receive(on: DispatchQueue.main).sink { [weak self] viewModel in NSAnimationContext.runAnimationGroup { context in context.duration = 0.35 + // Here we change the controller detailsController.viewModel = viewModel self?.updateSelectedViewModelRegardlessOfTab() } @@ -239,23 +278,6 @@ final class AppCoordinator: Logging { updateCurrentActivity(with: selectedViewModelRegardlessOfTab) } - func selectSessionOnAppropriateTab(with viewModel: SessionViewModel) { - - if currentListController?.canDisplay(session: viewModel) == true { - currentListController?.select(session: viewModel) - return - } - - if videosController.listViewController.canDisplay(session: viewModel) { - videosController.listViewController.select(session: viewModel) - tabController.activeTab = .videos - - } else if scheduleController.splitViewController.listViewController.canDisplay(session: viewModel) { - scheduleController.splitViewController.listViewController.select(session: viewModel) - tabController.activeTab = .schedule - } - } - private func setupDelegation() { let videoDetail = videosController.detailViewController @@ -273,112 +295,76 @@ final class AppCoordinator: Logging { scheduleController.splitViewController.listViewController.delegate = self } - var hasPerformedInitialListUpdate = false - private func observeStartUpTasks() { - // Initial app launch waits for all of these things to be loaded before dismissing the primary loading spinner - // It may, however, delay the presentation of content on tabs that already have everything they need - let startupDependencies = Publishers.CombineLatest4( - storage.tracksObservable, - storage.eventsObservable, - storage.focusesShallowObservable, - storage.scheduleObservable - ) - - // Instead of watching for storage output, we can watch signals on the things that start up - startupDependencies - .replaceErrorWithEmpty() - .filter { - !$0.0.isEmpty && !$0.1.isEmpty && !$0.2.isEmpty + func checkSyncEngineOperationSucceededAndShowError(note: Notification) -> Bool { + if let error = note.object as? APIError { + switch error { + case .adapter, .unknown: + WWDCAlert.show(with: error) + case .http: + break } - .prefix(1) - .receive(on: DispatchQueue.main) - .sink { [weak self] tracks, _, _, sections in - guard let self else { return } - - self.tabController.hideLoading() - if !hasPerformedInitialListUpdate { - // Filters only need configured once, the other stuff in - // here might only need to happen once as well -// self.searchCoordinator.configureFilters() - - // These aren't live updating, which is part of the problem. Filter results update live - // but get mixed in with these static lists of live-updating objects. We'll change the architecture - // of the sessions list to get 2 streams and then combine them which will simplify startup - // self.videosController.listViewController.sessionRowProvider = VideosSessionRowProvider( - // tracks: tracks, - // filterPredicate: searchCoordinator.$videosFilterPredicate - // ) - // self.scheduleController.splitViewController.listViewController.sessionRowProvider = ScheduleSessionRowProvider( - // scheduleSections: sections, - // filterPredicate: searchCoordinator.$scheduleFilterPredicate - // ) - } + } else if let error = note.object as? Error { + WWDCAlert.show(with: error) + } else { + return true + } - if !hasPerformedInitialListUpdate && liveObserver.isWWDCWeek { - hasPerformedInitialListUpdate = true + return false + } - scheduleController.splitViewController.listViewController.scrollToToday() - } + /// This should only be called once during startup, all other data updates should flow through observations on that that data + private func setupObservations() { + + // Wait for the data to be loaded to hide the loading spinner + // this avoids some jittery UI. Technically this could be changed to only watch + // the tab that will be visible during startup. + Publishers.CombineLatest( + self.videosController.listViewController.$hasPerformedInitialRowDisplay, + self.scheduleController.splitViewController.listViewController.$hasPerformedInitialRowDisplay + ) + .replaceErrorWithEmpty() + .drop { + $0.0 == false || $0.1 == false + } + .prefix(1) // Happens once then automatically completes + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + guard let self else { return } + + signposter.emitEvent("Hide loading", "Hide loading") + tabController.hideLoading() + + // TODO: Think about changing this + if liveObserver.isWWDCWeek { + scheduleController.splitViewController.listViewController.scrollToToday() } - .store(in: &cancellables) + } + .store(in: &cancellables) storage.eventHeroObservable.map({ $0 != nil }) .replaceError(with: false) .receive(on: DispatchQueue.main) - .assign(to: &scheduleController.$showHeroView) + .assign(to: &scheduleController.$isShowingHeroView) storage.eventHeroObservable .replaceError(with: nil) .driveUI(\.heroController.hero, on: scheduleController) .store(in: &cancellables) - -// liveObserver.start() - - DispatchQueue.main.async { self.configureSharePlayIfSupported() } - - DownloadManager.shared.syncWithFileSystem() } - var searchCoordinator: SearchCoordinator - - func startup() { - windowController.contentViewController = tabController - windowController.showWindow(self) - -// if storage.isEmpty { - tabController.showLoading() -// } - - func checkSyncEngineOperationSucceededAndShowError(note: Notification) -> Bool { - if let error = note.object as? APIError { - switch error { - case .adapter, .unknown: - WWDCAlert.show(with: error) - case .http: - break - } - } else if let error = note.object as? Error { - WWDCAlert.show(with: error) - } else { - return true - } - - return false - } - - _ = NotificationCenter.default.addObserver(forName: .SyncEngineDidSyncSessionsAndSchedule, object: nil, queue: .main) { note in - _ = checkSyncEngineOperationSucceededAndShowError(note: note) - } - - _ = NotificationCenter.default.addObserver(forName: .WWDCEnvironmentDidChange, object: nil, queue: .main) { _ in - self.refresh(nil) + func selectSessionOnAppropriateTab(with viewModel: SessionViewModel) { + if currentListController?.canDisplay(session: viewModel) == true { + currentListController?.select(session: viewModel) + return } - refresh(nil) - observeStartUpTasks() + if videosController.listViewController.canDisplay(session: viewModel) { + videosController.listViewController.select(session: viewModel) + tabController.activeTab = .videos - if Arguments.showPreferences { - showPreferences(nil) + } else if scheduleController.splitViewController.listViewController.canDisplay(session: viewModel) { + scheduleController.splitViewController.listViewController.select(session: viewModel) + tabController.activeTab = .schedule } } diff --git a/WWDC/ScheduleContainerViewController.swift b/WWDC/ScheduleContainerViewController.swift index 6b026a9e..94f2aa73 100644 --- a/WWDC/ScheduleContainerViewController.swift +++ b/WWDC/ScheduleContainerViewController.swift @@ -29,7 +29,7 @@ final class ScheduleContainerViewController: WWDCWindowContentViewController { /// This should be bound to a state that returns `true` when the schedule is not available. @Published - var showHeroView = false + var isShowingHeroView = false private(set) lazy var heroController: EventHeroViewController = { EventHeroViewController() @@ -73,21 +73,21 @@ final class ScheduleContainerViewController: WWDCWindowContentViewController { } private func bindViews() { - $showHeroView.replaceError(with: false).driveUI(\.view.isHidden, on: splitViewController) + $isShowingHeroView.replaceError(with: false).driveUI(\.view.isHidden, on: splitViewController) .store(in: &cancellables) - $showHeroView.toggled().replaceError(with: true) + $isShowingHeroView.toggled().replaceError(with: true) .driveUI(\.view.isHidden, on: heroController) .store(in: &cancellables) - $showHeroView.driveUI { [weak self] _ in + $isShowingHeroView.driveUI { [weak self] _ in self?.view.needsUpdateConstraints = true } .store(in: &cancellables) } override var childForWindowTopSafeAreaConstraint: NSViewController? { - showHeroView ? heroController : nil + isShowingHeroView ? heroController : nil } - + } diff --git a/WWDC/SessionRowProvider.swift b/WWDC/SessionRowProvider.swift index e29af213..8262266f 100644 --- a/WWDC/SessionRowProvider.swift +++ b/WWDC/SessionRowProvider.swift @@ -15,6 +15,7 @@ // import OrderedCollections +import OSLog #if canImport(Combine) import Combine @@ -122,17 +123,21 @@ struct SessionRows: Equatable { protocol SessionRowProvider { func sessionRowIdentifierForToday() -> SessionIdentifiable? + func startup() var rows: SessionRows? { get } var rowsPublisher: AnyPublisher { get } } -final class VideosSessionRowProvider: SessionRowProvider, Logging { +final class VideosSessionRowProvider: SessionRowProvider, Logging, Signposting { static let log = makeLogger() + static let signposter = makeSignposter() @Published var rows: SessionRows? var rowsPublisher: AnyPublisher { $rows.dropFirst().compacted().eraseToAnyPublisher() } + private let publisher: AnyPublisher + init( tracks: Results, filterPredicate: P, @@ -153,11 +158,15 @@ final class VideosSessionRowProvider: SessionRowProvider, Logging { .replaceErrorWithEmpty() .map { (track, $0) } }.combineLatest() + .do { + Self.log.debug("Source tracks changed") + } } .switchToLatest() - .map { - Self.log.debug("Source tracks changed") - return Self.allViewModels($0) + .map { sortedTracks in + Self.signposter.withIntervalSignpost("Row generation", id: Self.signposter.makeSignpostID(), "Calculate view models") { + Self.allViewModels(sortedTracks) + } } let filterPredicate = filterPredicate @@ -174,7 +183,7 @@ final class VideosSessionRowProvider: SessionRowProvider, Logging { .do { Self.log.debug("Filter predicate updated") } - Publishers.CombineLatest3( + publisher = Publishers.CombineLatest3( tracksAndSessions.replaceErrorWithEmpty(), filterPredicate, playingSessionIdentifier @@ -208,8 +217,17 @@ final class VideosSessionRowProvider: SessionRowProvider, Logging { } .switchToLatest() .map(Self.sessionRows) - .do { Self.log.debug("Received new update") } - .assign(to: &$rows) + .removeDuplicates() + .do { Self.log.debug("Updated session rows") } + .eraseToAnyPublisher() + } + + func startup() { + Self.signposter.withEscapingOneShotIntervalSignpost("Row generation", "Time to first value") { endInterval in + publisher + .do(endInterval) + .assign(to: &$rows) + } } private static func allViewModels(_ tracks: [(Track, Results)]) -> OrderedDictionary, OrderedDictionary)> { @@ -257,12 +275,15 @@ final class VideosSessionRowProvider: SessionRowProvider, Logging { } // TODO: Consider using covariant/subclassing to make this simpler compared to protocol -final class ScheduleSessionRowProvider: SessionRowProvider, Logging { +final class ScheduleSessionRowProvider: SessionRowProvider, Logging, Signposting { static let log = makeLogger() + static let signposter = makeSignposter() @Published var rows: SessionRows? var rowsPublisher: AnyPublisher { $rows.dropFirst().compacted().eraseToAnyPublisher() } + private let publisher: AnyPublisher + init( scheduleSections: S, filterPredicate: P, @@ -284,7 +305,11 @@ final class ScheduleSessionRowProvider: SessionRowProvider, Logging { .do { Self.log.debug("Section instances changed") } } .switchToLatest() - .map(Self.allViewModels) + .map { sortedSections in + Self.signposter.withIntervalSignpost("Calculate view models", id: Self.signposter.makeSignpostID()) { + Self.allViewModels(sortedSections) + } + } let filterPredicate = filterPredicate .drop(while: { @@ -300,7 +325,7 @@ final class ScheduleSessionRowProvider: SessionRowProvider, Logging { }) .do { Self.log.debug("Filter predicate updated") } - Publishers.CombineLatest3( + publisher = Publishers.CombineLatest3( sectionsAndSessions.replaceErrorWithEmpty(), filterPredicate, playingSessionIdentifier @@ -338,10 +363,16 @@ final class ScheduleSessionRowProvider: SessionRowProvider, Logging { .switchToLatest() .map(Self.sessionRows) .removeDuplicates() - .do { - Self.log.debug("Updated session rows") + .do { Self.log.debug("Updated session rows") } + .eraseToAnyPublisher() + } + + func startup() { + Self.signposter.withEscapingOneShotIntervalSignpost("Time to first value") { endInterval in + publisher + .do(endInterval) + .assign(to: &$rows) } - .assign(to: &$rows) } private static func allViewModels(_ sections: [(ScheduleSection, Results)]) -> OrderedDictionary, OrderedDictionary)> { diff --git a/WWDC/SessionViewModel.swift b/WWDC/SessionViewModel.swift index 9fac0f8b..896a73b1 100644 --- a/WWDC/SessionViewModel.swift +++ b/WWDC/SessionViewModel.swift @@ -20,7 +20,9 @@ final class SessionViewModel { let sessionInstance: SessionInstance let track: Track let identifier: String - var webUrl: URL? + lazy var webUrl: URL? = { + SessionViewModel.webUrl(for: session) + }() var imageUrl: URL? let trackName: String @@ -184,11 +186,6 @@ final class SessionViewModel { sessionInstance = instance ?? session.instances.first ?? SessionInstance() title = session.title identifier = session.identifier - imageUrl = SessionViewModel.imageUrl(for: session) - - if let webUrlStr = session.asset(ofType: .webpage)?.remoteURL { - webUrl = URL(string: webUrlStr) - } } static func subtitle(from session: Session, at event: ConfCore.Event?) -> String { diff --git a/WWDC/SessionsTableViewController.swift b/WWDC/SessionsTableViewController.swift index 9d7b681a..d063c639 100644 --- a/WWDC/SessionsTableViewController.swift +++ b/WWDC/SessionsTableViewController.swift @@ -149,7 +149,7 @@ class SessionsTableViewController: NSViewController, NSMenuItemValidation, Loggi rowsToDisplay = rows.filtered guard performInitialRowDisplayIfNeeded(displaying: rowsToDisplay, allRows: rows.all) else { - log.debug("Performed initial row display") + log.debug("Performed initial row display with [\(rowsToDisplay.count)] rows") return } @@ -164,12 +164,12 @@ class SessionsTableViewController: NSViewController, NSMenuItemValidation, Loggi private lazy var displayedRowsLock = DispatchQueue(label: "io.wwdc.sessiontable.displayedrows.lock\(self.hashValue)", qos: .userInteractive) - private var hasPerformedInitialRowDisplay = false + @Published + private(set) var hasPerformedInitialRowDisplay = false private func performInitialRowDisplayIfNeeded(displaying rows: [SessionRow], allRows: [SessionRow]) -> Bool { guard !hasPerformedInitialRowDisplay else { return true } - hasPerformedInitialRowDisplay = true displayedRowsLock.suspend() @@ -204,6 +204,7 @@ class SessionsTableViewController: NSViewController, NSMenuItemValidation, Loggi self.tableView.allowsEmptySelection = false }, completionHandler: { self.displayedRowsLock.resume() + self.hasPerformedInitialRowDisplay = true }) return false @@ -321,15 +322,12 @@ class SessionsTableViewController: NSViewController, NSMenuItemValidation, Loggi } func isSessionVisible(for session: SessionIdentifiable) -> Bool { - assert(hasPerformedInitialRowDisplay, "Rows must be displayed before checking this value") - return displayedRows.contains { row -> Bool in row.represents(session: session) } } func canDisplay(session: SessionIdentifiable) -> Bool { - assert(sessionRowProvider.rows != nil, "We should avoid asking this question until things are initialized") return sessionRowProvider.rows?.all.contains { row -> Bool in row.represents(session: session) } ?? false