Optimize UI performance with message caching and List view

- Convert BitchatMessage from struct to class for efficient caching
- Cache formatted AttributedStrings to avoid expensive regex on every render
- Replace ScrollView+LazyVStack with native List for better cell reuse
- Implement message windowing (show last 100 messages for performance)
- Add LazyLinkPreviewView that defers loading for 0.5s to improve scroll performance

These optimizations significantly improve scroll performance, especially with large message lists.
This commit is contained in:
jack
2025-07-24 12:38:31 +02:00
parent a338008511
commit f45c52e9d3
3 changed files with 111 additions and 33 deletions
+35 -1
View File
@@ -929,7 +929,7 @@ enum DeliveryStatus: Codable, Equatable {
} }
} }
struct BitchatMessage: Codable, Equatable { class BitchatMessage: Codable {
let id: String let id: String
let sender: String let sender: String
let content: String let content: String
@@ -942,6 +942,23 @@ struct BitchatMessage: Codable, Equatable {
let mentions: [String]? // Array of mentioned nicknames let mentions: [String]? // Array of mentioned nicknames
var deliveryStatus: DeliveryStatus? // Delivery tracking var deliveryStatus: DeliveryStatus? // Delivery tracking
// Cached formatted text (not included in Codable)
private var _cachedFormattedText: [String: AttributedString] = [:]
func getCachedFormattedText(isDark: Bool) -> AttributedString? {
return _cachedFormattedText["\(isDark)"]
}
func setCachedFormattedText(_ text: AttributedString, isDark: Bool) {
_cachedFormattedText["\(isDark)"] = text
}
// Codable implementation
enum CodingKeys: String, CodingKey {
case id, sender, content, timestamp, isRelay, originalSender
case isPrivate, recipientNickname, senderPeerID, mentions, deliveryStatus
}
init(id: String? = nil, sender: String, content: String, timestamp: Date, isRelay: Bool, originalSender: String? = nil, isPrivate: Bool = false, recipientNickname: String? = nil, senderPeerID: String? = nil, mentions: [String]? = nil, deliveryStatus: DeliveryStatus? = nil) { init(id: String? = nil, sender: String, content: String, timestamp: Date, isRelay: Bool, originalSender: String? = nil, isPrivate: Bool = false, recipientNickname: String? = nil, senderPeerID: String? = nil, mentions: [String]? = nil, deliveryStatus: DeliveryStatus? = nil) {
self.id = id ?? UUID().uuidString self.id = id ?? UUID().uuidString
self.sender = sender self.sender = sender
@@ -957,6 +974,23 @@ struct BitchatMessage: Codable, Equatable {
} }
} }
// Equatable conformance for BitchatMessage
extension BitchatMessage: Equatable {
static func == (lhs: BitchatMessage, rhs: BitchatMessage) -> Bool {
return lhs.id == rhs.id &&
lhs.sender == rhs.sender &&
lhs.content == rhs.content &&
lhs.timestamp == rhs.timestamp &&
lhs.isRelay == rhs.isRelay &&
lhs.originalSender == rhs.originalSender &&
lhs.isPrivate == rhs.isPrivate &&
lhs.recipientNickname == rhs.recipientNickname &&
lhs.senderPeerID == rhs.senderPeerID &&
lhs.mentions == rhs.mentions &&
lhs.deliveryStatus == rhs.deliveryStatus
}
}
protocol BitchatDelegate: AnyObject { protocol BitchatDelegate: AnyObject {
func didReceiveMessage(_ message: BitchatMessage) func didReceiveMessage(_ message: BitchatMessage)
func didConnectToPeer(_ peerID: String) func didConnectToPeer(_ peerID: String)
+13 -8
View File
@@ -938,9 +938,15 @@ class ChatViewModel: ObservableObject {
} }
func formatMessageAsText(_ message: BitchatMessage, colorScheme: ColorScheme) -> AttributedString { func formatMessageAsText(_ message: BitchatMessage, colorScheme: ColorScheme) -> AttributedString {
// Check cache first
let isDark = colorScheme == .dark
if let cachedText = message.getCachedFormattedText(isDark: isDark) {
return cachedText
}
// Not cached, format the message
var result = AttributedString() var result = AttributedString()
let isDark = colorScheme == .dark
let primaryColor = isDark ? Color.green : Color(red: 0, green: 0.5, blue: 0) let primaryColor = isDark ? Color.green : Color(red: 0, green: 0.5, blue: 0)
let secondaryColor = primaryColor.opacity(0.7) let secondaryColor = primaryColor.opacity(0.7)
@@ -1075,6 +1081,9 @@ class ChatViewModel: ObservableObject {
result.append(content.mergingAttributes(contentStyle)) result.append(content.mergingAttributes(contentStyle))
} }
// Cache the formatted text
message.setCachedFormattedText(result, isDark: isDark)
return result return result
} }
@@ -2223,21 +2232,17 @@ extension ChatViewModel: BitchatDelegate {
if let index = messages.firstIndex(where: { $0.id == messageID }) { if let index = messages.firstIndex(where: { $0.id == messageID }) {
let currentStatus = messages[index].deliveryStatus let currentStatus = messages[index].deliveryStatus
if !shouldSkipUpdate(currentStatus: currentStatus, newStatus: status) { if !shouldSkipUpdate(currentStatus: currentStatus, newStatus: status) {
var updatedMessage = messages[index] messages[index].deliveryStatus = status
updatedMessage.deliveryStatus = status
messages[index] = updatedMessage
} }
} }
// Update in private chats // Update in private chats
var updatedPrivateChats = privateChats var updatedPrivateChats = privateChats
for (peerID, var chatMessages) in updatedPrivateChats { for (peerID, chatMessages) in updatedPrivateChats {
if let index = chatMessages.firstIndex(where: { $0.id == messageID }) { if let index = chatMessages.firstIndex(where: { $0.id == messageID }) {
let currentStatus = chatMessages[index].deliveryStatus let currentStatus = chatMessages[index].deliveryStatus
if !shouldSkipUpdate(currentStatus: currentStatus, newStatus: status) { if !shouldSkipUpdate(currentStatus: currentStatus, newStatus: status) {
var updatedMessage = chatMessages[index] chatMessages[index].deliveryStatus = status
updatedMessage.deliveryStatus = status
chatMessages[index] = updatedMessage
updatedPrivateChats[peerID] = chatMessages updatedPrivateChats[peerID] = chatMessages
} }
} }
+63 -24
View File
@@ -163,18 +163,20 @@ struct ContentView: View {
private func messagesView(privatePeer: String?) -> some View { private func messagesView(privatePeer: String?) -> some View {
ScrollViewReader { proxy in ScrollViewReader { proxy in
ScrollView { List {
LazyVStack(alignment: .leading, spacing: 2) { let messages: [BitchatMessage] = {
let messages: [BitchatMessage] = { if let privatePeer = privatePeer {
if let privatePeer = privatePeer { let msgs = viewModel.getPrivateChatMessages(for: privatePeer)
let msgs = viewModel.getPrivateChatMessages(for: privatePeer) return msgs
return msgs } else {
} else { return viewModel.messages
return viewModel.messages }
} }()
}()
// Implement windowing - show last 100 messages for performance
ForEach(messages, id: \.id) { message in let windowedMessages = messages.suffix(100)
ForEach(Array(windowedMessages), id: \.id) { message in
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: 4) {
// Check if current user is mentioned // Check if current user is mentioned
let _ = message.mentions?.contains(viewModel.nickname) ?? false let _ = message.mentions?.contains(viewModel.nickname) ?? false
@@ -208,24 +210,26 @@ struct ContentView: View {
// Don't show link preview if the message is just the emoji // Don't show link preview if the message is just the emoji
let cleanContent = message.content.trimmingCharacters(in: .whitespacesAndNewlines) let cleanContent = message.content.trimmingCharacters(in: .whitespacesAndNewlines)
if cleanContent.hasPrefix("👇") { if cleanContent.hasPrefix("👇") {
LinkPreviewView(url: markdownLink.url, title: markdownLink.title) LazyLinkPreviewView(
.padding(.top, 2) url: markdownLink.url,
.id("\(message.id)-\(markdownLink.url.absoluteString)") title: markdownLink.title,
id: "\(message.id)-\(markdownLink.url.absoluteString)"
)
} }
} else { } else {
// Check for plain URLs // Check for plain URLs
let urls = message.content.extractURLs() let urls = message.content.extractURLs()
ForEach(Array(urls.prefix(3).enumerated()), id: \.offset) { index, urlInfo in ForEach(Array(urls.prefix(3).enumerated()), id: \.offset) { index, urlInfo in
LinkPreviewView(url: urlInfo.url, title: nil) LazyLinkPreviewView(
.padding(.top, 2) url: urlInfo.url,
.id("\(message.id)-\(urlInfo.url.absoluteString)") title: nil,
id: "\(message.id)-\(urlInfo.url.absoluteString)"
)
} }
} }
} }
} }
} }
.padding(.horizontal, 12)
.padding(.vertical, 2)
.id(message.id) .id(message.id)
.contentShape(Rectangle()) .contentShape(Rectangle())
.onTapGesture { .onTapGesture {
@@ -236,10 +240,13 @@ struct ContentView: View {
showMessageActions = true showMessageActions = true
} }
} }
.listRowInsets(EdgeInsets(top: 2, leading: 12, bottom: 2, trailing: 12))
.listRowSeparator(.hidden)
.listRowBackground(backgroundColor)
} }
}
.padding(.vertical, 8)
} }
.listStyle(.plain)
.scrollContentBackground(.hidden)
.background(backgroundColor) .background(backgroundColor)
.onTapGesture(count: 3) { .onTapGesture(count: 3) {
// Triple-tap to clear current chat // Triple-tap to clear current chat
@@ -248,7 +255,9 @@ struct ContentView: View {
.onChange(of: viewModel.messages.count) { _ in .onChange(of: viewModel.messages.count) { _ in
if privatePeer == nil && !viewModel.messages.isEmpty { if privatePeer == nil && !viewModel.messages.isEmpty {
withAnimation { withAnimation {
proxy.scrollTo(viewModel.messages.last?.id, anchor: .bottom) // Scroll to last message in windowed view
let lastId = viewModel.messages.suffix(100).last?.id
proxy.scrollTo(lastId, anchor: .bottom)
} }
} }
} }
@@ -257,7 +266,9 @@ struct ContentView: View {
let messages = viewModel.privateChats[peerID], let messages = viewModel.privateChats[peerID],
!messages.isEmpty { !messages.isEmpty {
withAnimation { withAnimation {
proxy.scrollTo(messages.last?.id, anchor: .bottom) // Scroll to last message in windowed view
let lastId = messages.suffix(100).last?.id
proxy.scrollTo(lastId, anchor: .bottom)
} }
} }
} }
@@ -940,6 +951,34 @@ struct MessageContentView: View {
} }
} }
// Lazy loading wrapper for LinkPreviewView
struct LazyLinkPreviewView: View {
let url: URL
let title: String?
let id: String
@State private var shouldLoad = false
var body: some View {
Group {
if shouldLoad {
LinkPreviewView(url: url, title: title)
.padding(.top, 2)
.id(id)
}
}
.onAppear {
// Delay loading to improve scroll performance
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
shouldLoad = true
}
}
.onDisappear {
shouldLoad = false
}
}
}
// Delivery status indicator view // Delivery status indicator view
struct DeliveryStatusView: View { struct DeliveryStatusView: View {
let status: DeliveryStatus let status: DeliveryStatus