Fix log performance

This commit is contained in:
世界
2025-12-29 15:35:17 +08:00
parent 73b75faa02
commit 6de410c3f7
2 changed files with 126 additions and 122 deletions
+121 -119
View File
@@ -23,11 +23,17 @@ struct LogTextView: View {
} }
} }
@MainActor
class LogCoordinator { class LogCoordinator {
var lastLogsCount: Int = 0 var lastLogsCount: Int = 0
var lastLog: LogEntry? var lastLog: LogEntry?
var lastSearchText: String = "" var lastSearchText: String = ""
var cachedAttributedString: NSAttributedString? var buildVersion: Int = 0
var currentBuildTask: Task<Void, Never>?
deinit {
currentBuildTask?.cancel()
}
func shouldUpdate(logs: [LogEntry], searchText: String) -> UpdateStrategy { func shouldUpdate(logs: [LogEntry], searchText: String) -> UpdateStrategy {
let currentCount = logs.count let currentCount = logs.count
@@ -47,14 +53,12 @@ class LogCoordinator {
if currentCount == 0 || searchChanged || lastLogsCount > currentCount { if currentCount == 0 || searchChanged || lastLogsCount > currentCount {
// Full rebuild needed // Full rebuild needed
strategy = .fullRebuild strategy = .fullRebuild
cachedAttributedString = nil
} else if currentCount > lastLogsCount { } else if currentCount > lastLogsCount {
// Incremental update possible // Incremental update possible
strategy = .incremental(from: lastLogsCount) strategy = .incremental(from: lastLogsCount)
} else { } else {
// Same count but different last log (shouldn't happen normally) // Same count but different last log (shouldn't happen normally)
strategy = .fullRebuild strategy = .fullRebuild
cachedAttributedString = nil
} }
lastLogsCount = currentCount lastLogsCount = currentCount
@@ -63,6 +67,38 @@ class LogCoordinator {
return strategy return strategy
} }
#if os(iOS) || os(macOS)
fileprivate func scheduleBuildTask(
logs: [LogEntry],
searchText: String,
monoFont: PlatformFont,
defaultColor: PlatformColor,
startIndex: Int?,
isViewValid: @escaping @MainActor () -> Bool,
applyUpdate: @escaping @MainActor (NSAttributedString, Bool) -> Void
) {
currentBuildTask?.cancel()
buildVersion += 1
let version = buildVersion
currentBuildTask = Task.detached(priority: .userInitiated) { [weak self] in
guard let attributedString = try? await buildAttributedString(
logs: logs,
monoFont: monoFont,
defaultColor: defaultColor,
searchText: searchText,
startIndex: startIndex ?? 0
) else { return }
await MainActor.run {
guard let self, isViewValid() else { return }
guard self.buildVersion == version else { return }
applyUpdate(attributedString, startIndex != nil)
self.currentBuildTask = nil
}
}
}
#endif
enum UpdateStrategy { enum UpdateStrategy {
case noUpdate case noUpdate
case fullRebuild case fullRebuild
@@ -71,26 +107,18 @@ class LogCoordinator {
} }
#if os(iOS) || os(macOS) #if os(iOS) || os(macOS)
private func buildAttributedString(logs: [LogEntry], monoFont: PlatformFont, defaultColor: PlatformColor, searchText: String, baseAttributedString: NSAttributedString? = nil, startIndex: Int = 0) -> NSAttributedString { private func buildAttributedString(logs: [LogEntry], monoFont: PlatformFont, defaultColor: PlatformColor, searchText: String, startIndex: Int = 0) async throws -> NSAttributedString {
let result: NSMutableAttributedString let result = NSMutableAttributedString()
let highlightColor: PlatformColor = .systemYellow let highlightColor: PlatformColor = .systemYellow
let cancellationCheckInterval = 50
if let base = baseAttributedString {
result = NSMutableAttributedString(attributedString: base)
// Add newline separator if appending to existing content
if result.length > 0 {
result.append(NSAttributedString(string: "\n", attributes: [
.foregroundColor: defaultColor,
.font: monoFont,
]))
}
} else {
result = NSMutableAttributedString()
}
let logsToProcess = logs[startIndex...] let logsToProcess = logs[startIndex...]
for (offset, log) in logsToProcess.enumerated() { for (offset, log) in logsToProcess.enumerated() {
if offset % cancellationCheckInterval == 0 {
try Task.checkCancellation()
}
let attributedString = ANSIColors.parseAnsiString(log.message) let attributedString = ANSIColors.parseAnsiString(log.message)
let nsAttributedString = NSMutableAttributedString(string: String(attributedString.characters)) let nsAttributedString = NSMutableAttributedString(string: String(attributedString.characters))
@@ -136,46 +164,12 @@ class LogCoordinator {
#endif #endif
#if os(iOS) #if os(iOS)
struct LogTextViewIOS: View { struct LogTextViewIOS: UIViewRepresentable {
let logs: [LogEntry] let logs: [LogEntry]
let font: Font let font: Font
let shouldAutoScroll: Bool let shouldAutoScroll: Bool
let searchText: String let searchText: String
var body: some View {
ScrollViewReader { proxy in
ScrollView {
LogUITextView(logs: logs, searchText: searchText)
.font(font)
.fixedSize(horizontal: false, vertical: true)
.frame(maxWidth: .infinity, alignment: .topLeading)
.padding()
.id("logContent")
}
.onAppear {
if shouldAutoScroll {
scrollToBottom(proxy: proxy)
}
}
.onChangeCompat(of: logs.count) { _ in
if shouldAutoScroll {
scrollToBottom(proxy: proxy)
}
}
}
}
private func scrollToBottom(proxy: ScrollViewProxy) {
DispatchQueue.main.async {
proxy.scrollTo("logContent", anchor: .bottom)
}
}
}
struct LogUITextView: UIViewRepresentable {
let logs: [LogEntry]
let searchText: String
private static let monoFont = UIFont.monospacedSystemFont(ofSize: 11, weight: .regular) private static let monoFont = UIFont.monospacedSystemFont(ofSize: 11, weight: .regular)
private static let defaultColor = UIColor.label private static let defaultColor = UIColor.label
@@ -183,58 +177,64 @@ class LogCoordinator {
let textView = UITextView() let textView = UITextView()
textView.isEditable = false textView.isEditable = false
textView.isSelectable = true textView.isSelectable = true
textView.isScrollEnabled = false textView.isScrollEnabled = true
textView.backgroundColor = .clear textView.backgroundColor = .clear
textView.textContainerInset = .zero textView.textContainerInset = UIEdgeInsets(top: 16, left: 16, bottom: 16, right: 16)
textView.textContainer.lineFragmentPadding = 0 textView.textContainer.lineFragmentPadding = 0
textView.font = Self.monoFont textView.font = Self.monoFont
textView.textColor = Self.defaultColor textView.textColor = Self.defaultColor
textView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
// Low hugging priority allows expansion to fill available space for proper ScrollView integration
textView.setContentHuggingPriority(.defaultLow, for: .vertical)
return textView return textView
} }
func updateUIView(_ textView: UITextView, context: Context) { func updateUIView(_ textView: UITextView, context: Context) {
let updateStrategy = context.coordinator.shouldUpdate(logs: logs, searchText: searchText) let updateStrategy = context.coordinator.shouldUpdate(logs: logs, searchText: searchText)
let startIndex: Int?
switch updateStrategy { switch updateStrategy {
case .noUpdate: case .noUpdate:
return return
case .fullRebuild: case .fullRebuild:
let attributedString = buildAttributedString(logs: logs, monoFont: Self.monoFont, defaultColor: Self.defaultColor, searchText: searchText) startIndex = nil
context.coordinator.cachedAttributedString = attributedString case let .incremental(from: index):
textView.attributedText = attributedString startIndex = index
case let .incremental(from: startIndex):
// 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)
} }
textView.invalidateIntrinsicContentSize() let shouldAutoScroll = shouldAutoScroll
context.coordinator.scheduleBuildTask(
logs: logs,
searchText: searchText,
monoFont: Self.monoFont,
defaultColor: Self.defaultColor,
startIndex: startIndex,
isViewValid: { [weak textView] in textView?.window != nil },
applyUpdate: { [weak textView] attributedString, isIncremental in
guard let textView else { return }
if isIncremental {
let textStorage = textView.textStorage
if textStorage.length > 0 {
textStorage.append(NSAttributedString(string: "\n", attributes: [
.foregroundColor: Self.defaultColor,
.font: Self.monoFont,
]))
}
textStorage.append(attributedString)
} else {
textView.attributedText = attributedString
}
if shouldAutoScroll {
Self.scrollToBottom(textView)
}
}
)
} }
@available(iOS 16.0, *) private static func scrollToBottom(_ textView: UITextView) {
func sizeThatFits(_ proposal: ProposedViewSize, uiView: UITextView, context _: Context) -> CGSize? { textView.layoutManager.ensureLayout(for: textView.textContainer)
guard let width = proposal.width, width > 0 else { return nil } textView.layoutIfNeeded()
let size = uiView.sizeThatFits(CGSize(width: width, height: .greatestFiniteMagnitude)) let bottom = textView.contentSize.height - textView.bounds.height + textView.adjustedContentInset.bottom
return CGSize(width: width, height: size.height) if bottom > 0 {
textView.setContentOffset(CGPoint(x: 0, y: bottom), animated: false)
}
} }
func makeCoordinator() -> LogCoordinator { func makeCoordinator() -> LogCoordinator {
@@ -271,6 +271,7 @@ class LogCoordinator {
if let textContainer = textView.textContainer { if let textContainer = textView.textContainer {
textContainer.widthTracksTextView = true textContainer.widthTracksTextView = true
textContainer.containerSize = NSSize(width: scrollView.contentSize.width, height: .greatestFiniteMagnitude) textContainer.containerSize = NSSize(width: scrollView.contentSize.width, height: .greatestFiniteMagnitude)
textContainer.lineFragmentPadding = 0
} }
scrollView.documentView = textView scrollView.documentView = textView
@@ -281,46 +282,47 @@ class LogCoordinator {
guard let textView = scrollView.documentView as? NSTextView else { return } guard let textView = scrollView.documentView as? NSTextView else { return }
guard let textStorage = textView.textStorage else { return } guard let textStorage = textView.textStorage else { return }
let lastCount = context.coordinator.lastLogsCount
let updateStrategy = context.coordinator.shouldUpdate(logs: logs, searchText: searchText) let updateStrategy = context.coordinator.shouldUpdate(logs: logs, searchText: searchText)
let startIndex: Int?
switch updateStrategy { switch updateStrategy {
case .noUpdate: case .noUpdate:
return return
case .fullRebuild: case .fullRebuild:
let attributedText = buildAttributedString(logs: logs, monoFont: Self.monoFont, defaultColor: Self.defaultColor, searchText: searchText) startIndex = nil
context.coordinator.cachedAttributedString = attributedText case let .incremental(from: index):
textStorage.setAttributedString(attributedText) startIndex = index
case let .incremental(from: startIndex): }
// 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 let shouldAutoScroll = shouldAutoScroll
if textStorage.length > 0 { context.coordinator.scheduleBuildTask(
let newline = NSAttributedString(string: "\n", attributes: [ logs: logs,
.foregroundColor: Self.defaultColor, searchText: searchText,
.font: Self.monoFont, monoFont: Self.monoFont,
]) defaultColor: Self.defaultColor,
textStorage.append(newline) startIndex: startIndex,
isViewValid: { [weak textView] in textView?.window != nil },
applyUpdate: { [weak textView, weak textStorage] attributedString, isIncremental in
guard let textView, let textStorage else { return }
if isIncremental {
if textStorage.length > 0 {
textStorage.append(NSAttributedString(string: "\n", attributes: [
.foregroundColor: Self.defaultColor,
.font: Self.monoFont,
]))
}
textStorage.append(attributedString)
} else {
textStorage.setAttributedString(attributedString)
}
if let textContainer = textView.textContainer {
textView.layoutManager?.ensureLayout(for: textContainer)
}
if shouldAutoScroll {
textView.scrollToEndOfDocument(nil)
}
} }
)
// Append to existing text storage instead of replacing
textStorage.append(newLogsAttributedString)
// Update cache to full content
context.coordinator.cachedAttributedString = NSAttributedString(attributedString: textStorage)
}
if let textContainer = textView.textContainer {
textView.layoutManager?.ensureLayout(for: textContainer)
}
let shouldScroll = shouldAutoScroll && logs.count != lastCount
if shouldScroll {
DispatchQueue.main.async {
textView.scrollToEndOfDocument(nil)
}
}
} }
func makeCoordinator() -> LogCoordinator { func makeCoordinator() -> LogCoordinator {
@@ -12,6 +12,7 @@ import SwiftUI
@MainActor @MainActor
public class LogDataModel: ObservableObject { public class LogDataModel: ObservableObject {
@Published public var filteredLogs: [LogEntry] = [] @Published public var filteredLogs: [LogEntry] = []
@Published public private(set) var visibleLogs: [LogEntry] = []
@Published public var showFileExporter = false @Published public var showFileExporter = false
@Published public var logFileURL: URL? @Published public var logFileURL: URL?
@@ -27,11 +28,11 @@ public class LogDataModel: ObservableObject {
public var isEmpty: Bool { commandClient.logList.isEmpty } public var isEmpty: Bool { commandClient.logList.isEmpty }
public var isConnected: Bool { commandClient.isConnected } public var isConnected: Bool { commandClient.isConnected }
public var visibleLogs: [LogEntry] { private func updateVisibleLogs() {
if filteredLogs.count <= Self.maxVisibleLogs { if filteredLogs.count <= Self.maxVisibleLogs {
return filteredLogs visibleLogs = filteredLogs
} else { } else {
return Array(filteredLogs.suffix(Self.maxVisibleLogs)) visibleLogs = Array(filteredLogs.suffix(Self.maxVisibleLogs))
} }
} }
@@ -72,6 +73,7 @@ public class LogDataModel: ObservableObject {
} }
} }
self.updateVisibleLogs()
self.lastProcessedLogCount = logList.count self.lastProcessedLogCount = logList.count
self.lastEffectiveLevel = effectiveLevel self.lastEffectiveLevel = effectiveLevel
self.lastSearchText = searchText self.lastSearchText = searchText