From 32f5bf1f3bc037fdc14fe9ee1db56b0d42df6c6f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Wed, 29 Oct 2025 13:49:59 +0800 Subject: [PATCH] Improve performance for Logs --- .../Views/Log/LogTextView.swift | 45 ++++++++++++++--- ApplicationLibrary/Views/Log/LogView.swift | 10 ++-- .../Views/Log/LogViewModel.swift | 12 +++++ Library/Network/CommandClient.swift | 48 ++++++++++++++++--- 4 files changed, 96 insertions(+), 19 deletions(-) diff --git a/ApplicationLibrary/Views/Log/LogTextView.swift b/ApplicationLibrary/Views/Log/LogTextView.swift index 7524e15..9095c77 100644 --- a/ApplicationLibrary/Views/Log/LogTextView.swift +++ b/ApplicationLibrary/Views/Log/LogTextView.swift @@ -206,9 +206,25 @@ class LogCoordinator { context.coordinator.cachedAttributedString = attributedString textView.attributedText = attributedString case let .incremental(from: startIndex): - let attributedString = buildAttributedString(logs: logs, monoFont: Self.monoFont, defaultColor: Self.defaultColor, searchText: searchText, baseAttributedString: context.coordinator.cachedAttributedString, startIndex: startIndex) - context.coordinator.cachedAttributedString = attributedString - textView.attributedText = attributedString + // Build only the new logs, not the entire attributed string + let newLogsAttributedString = buildAttributedString(logs: logs, monoFont: Self.monoFont, defaultColor: Self.defaultColor, searchText: searchText, baseAttributedString: nil, startIndex: startIndex) + + // Append to existing text storage instead of replacing + let textStorage = textView.textStorage + + // Add newline separator before appending if there's existing content + if textStorage.length > 0 { + let newline = NSAttributedString(string: "\n", attributes: [ + .foregroundColor: Self.defaultColor, + .font: Self.monoFont, + ]) + textStorage.append(newline) + } + + textStorage.append(newLogsAttributedString) + + // Update cache to full content + context.coordinator.cachedAttributedString = NSAttributedString(attributedString: textStorage) } } @@ -254,6 +270,7 @@ class LogCoordinator { func updateNSView(_ scrollView: NSScrollView, context: Context) { guard let textView = scrollView.documentView as? NSTextView else { return } + guard let textStorage = textView.textStorage else { return } let lastCount = context.coordinator.lastLogsCount let updateStrategy = context.coordinator.shouldUpdate(logs: logs, searchText: searchText) @@ -264,11 +281,25 @@ class LogCoordinator { case .fullRebuild: let attributedText = buildAttributedString(logs: logs, monoFont: Self.monoFont, defaultColor: Self.defaultColor, searchText: searchText) context.coordinator.cachedAttributedString = attributedText - textView.textStorage?.setAttributedString(attributedText) + textStorage.setAttributedString(attributedText) case let .incremental(from: startIndex): - let attributedText = buildAttributedString(logs: logs, monoFont: Self.monoFont, defaultColor: Self.defaultColor, searchText: searchText, baseAttributedString: context.coordinator.cachedAttributedString, startIndex: startIndex) - context.coordinator.cachedAttributedString = attributedText - textView.textStorage?.setAttributedString(attributedText) + // Build only the new logs, not the entire attributed string + let newLogsAttributedString = buildAttributedString(logs: logs, monoFont: Self.monoFont, defaultColor: Self.defaultColor, searchText: searchText, baseAttributedString: nil, startIndex: startIndex) + + // Add newline separator before appending if there's existing content + if textStorage.length > 0 { + let newline = NSAttributedString(string: "\n", attributes: [ + .foregroundColor: Self.defaultColor, + .font: Self.monoFont, + ]) + textStorage.append(newline) + } + + // Append to existing text storage instead of replacing + textStorage.append(newLogsAttributedString) + + // Update cache to full content + context.coordinator.cachedAttributedString = NSAttributedString(attributedString: textStorage) } let shouldScroll = shouldAutoScroll && logs.count != lastCount diff --git a/ApplicationLibrary/Views/Log/LogView.swift b/ApplicationLibrary/Views/Log/LogView.swift index 61d681a..e676518 100644 --- a/ApplicationLibrary/Views/Log/LogView.swift +++ b/ApplicationLibrary/Views/Log/LogView.swift @@ -34,7 +34,7 @@ private struct LogViewContent: View { previewContent } else if viewModel.isEmpty { emptyContent - } else if viewModel.filteredLogs.isEmpty { + } else if viewModel.visibleLogs.isEmpty { emptyContent } else { logScrollView @@ -113,7 +113,7 @@ private struct LogViewContent: View { ScrollViewReader { reader in ScrollView { LazyVStack(alignment: .leading, spacing: 8) { - ForEach(viewModel.filteredLogs) { logEntry in + ForEach(viewModel.visibleLogs) { logEntry in Text(highlightedText(for: logEntry.message)) .font(logFont) .focusable() @@ -127,7 +127,7 @@ private struct LogViewContent: View { .onAppear { scrollToLastEntry(reader) } - .onChangeCompat(of: viewModel.filteredLogs.count) { _ in + .onChangeCompat(of: viewModel.visibleLogs.count) { _ in if !viewModel.isPaused { scrollToLastEntry(reader) } @@ -135,7 +135,7 @@ private struct LogViewContent: View { } #else LogTextView( - logs: viewModel.filteredLogs, + logs: viewModel.visibleLogs, font: logFont, shouldAutoScroll: !viewModel.isPaused, searchText: viewModel.searchText @@ -166,7 +166,7 @@ private struct LogViewContent: View { #if os(tvOS) private func scrollToLastEntry(_ reader: ScrollViewProxy) { - guard let lastEntry = viewModel.filteredLogs.last else { return } + guard let lastEntry = viewModel.visibleLogs.last else { return } withAnimation { reader.scrollTo(lastEntry.id, anchor: .bottom) } diff --git a/ApplicationLibrary/Views/Log/LogViewModel.swift b/ApplicationLibrary/Views/Log/LogViewModel.swift index 3fa5dff..ced14b2 100644 --- a/ApplicationLibrary/Views/Log/LogViewModel.swift +++ b/ApplicationLibrary/Views/Log/LogViewModel.swift @@ -25,9 +25,21 @@ public class LogViewModel: ObservableObject { private var lastEffectiveLevel: Int? private var lastSearchText = "" + // Performance optimization: limit visible logs to prevent UI lag + private static let maxVisibleLogs = 1000 + public var isEmpty: Bool { commandClient.logList.isEmpty } public var isConnected: Bool { commandClient.isConnected } + // Only show last N logs for performance, but keep all in filteredLogs for export + public var visibleLogs: [LogEntry] { + if filteredLogs.count <= Self.maxVisibleLogs { + return filteredLogs + } else { + return Array(filteredLogs.suffix(Self.maxVisibleLogs)) + } + } + public init(commandClient: CommandClient) { self.commandClient = commandClient diff --git a/Library/Network/CommandClient.swift b/Library/Network/CommandClient.swift index d3f0867..36c39eb 100644 --- a/Library/Network/CommandClient.swift +++ b/Library/Network/CommandClient.swift @@ -66,6 +66,11 @@ public class CommandClient: ObservableObject { @Published public var connections: [LibboxConnection]? public var rawConnections: LibboxConnections? + // Batch processing for logs + private var pendingLogs: [LogEntry] = [] + private var logBatchTimer: DispatchWorkItem? + private let logBatchInterval: TimeInterval = 0.1 // 100ms batch window + public init(_ connectionTypes: [ConnectionType], logMaxLines: Int = 3000) { self.connectionTypes = connectionTypes self.logMaxLines = logMaxLines @@ -102,6 +107,20 @@ public class CommandClient: ObservableObject { } } + private func flushPendingLogs() { + guard !pendingLogs.isEmpty else { return } + + // Batch append all pending logs + logList.append(contentsOf: pendingLogs) + pendingLogs.removeAll() + + // Trim to max lines if needed + if logList.count > logMaxLines { + let removeCount = logList.count - logMaxLines + logList.removeFirst(removeCount) + } + } + public func filterConnectionsNow() { guard let message = rawConnections else { return @@ -217,15 +236,30 @@ public class CommandClient: ObservableObject { guard let messageList else { return } + + // Collect new logs + var newLogs: [LogEntry] = [] + while messageList.hasNext() { + let logEntry = messageList.next()! + newLogs.append(LogEntry(level: Int(logEntry.level), message: logEntry.message)) + } + + guard !newLogs.isEmpty else { return } + DispatchQueue.main.async { [self] in - while messageList.hasNext() { - let logEntry = messageList.next()! - commandClient.logList.append(LogEntry(level: Int(logEntry.level), message: logEntry.message)) - } - if commandClient.logList.count > commandClient.logMaxLines { - let removeCount = commandClient.logList.count - commandClient.logMaxLines - commandClient.logList.removeFirst(removeCount) + // Add to pending batch + commandClient.pendingLogs.append(contentsOf: newLogs) + + // Cancel existing timer + commandClient.logBatchTimer?.cancel() + + // Schedule batch flush + let workItem = DispatchWorkItem { [weak commandClient] in + guard let commandClient else { return } + commandClient.flushPendingLogs() } + commandClient.logBatchTimer = workItem + DispatchQueue.main.asyncAfter(deadline: .now() + commandClient.logBatchInterval, execute: workItem) } }