mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 12:25:19 +00:00
Eliminate disk I/O from SwiftUI view rendering path
Critical performance fix for UI freezes when receiving media: Problem: mediaAttachment(for:) was called during every SwiftUI render, performing synchronous disk I/O on main thread: - FileManager.fileExists() called 2-6x per message (checking subdirs) - applicationFilesDirectory() creating directories on every call - With multiple media messages, this meant 20-100+ disk ops per render Solution: 1. Remove fileExists checks - construct URLs directly - Files are validated during playback/display (fail gracefully if missing) - Sender determines subdirectory (outgoing vs incoming) 2. Cache applicationFilesDirectory() result - Static cache prevents repeated FileManager.url() calls - Directory created only once 3. Remove redundant playback.replaceURL() in VoiceNoteView.onAppear - Controller already initialized with correct URL This eliminates ALL disk I/O from the view rendering hot path.
This commit is contained in:
@@ -1620,28 +1620,39 @@ private extension ContentView {
|
|||||||
func mediaAttachment(for message: BitchatMessage) -> MessageMedia? {
|
func mediaAttachment(for message: BitchatMessage) -> MessageMedia? {
|
||||||
guard let baseDirectory = applicationFilesDirectory() else { return nil }
|
guard let baseDirectory = applicationFilesDirectory() else { return nil }
|
||||||
|
|
||||||
func url(from prefix: String, in subdirectories: [String]) -> URL? {
|
// Extract filename from message content
|
||||||
|
func url(from prefix: String, subdirectory: String) -> URL? {
|
||||||
guard message.content.hasPrefix(prefix) else { return nil }
|
guard message.content.hasPrefix(prefix) else { return nil }
|
||||||
let filename = String(message.content.dropFirst(prefix.count)).trimmingCharacters(in: .whitespacesAndNewlines)
|
let filename = String(message.content.dropFirst(prefix.count)).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
guard !filename.isEmpty else { return nil }
|
guard !filename.isEmpty else { return nil }
|
||||||
let fm = FileManager.default
|
|
||||||
for sub in subdirectories {
|
// Construct URL directly without fileExists check (avoids blocking disk I/O in view body)
|
||||||
let directory = baseDirectory.appendingPathComponent(sub, isDirectory: true)
|
// Files are checked during playback/display, so missing files fail gracefully
|
||||||
let candidate = directory.appendingPathComponent(filename)
|
let directory = baseDirectory.appendingPathComponent(subdirectory, isDirectory: true)
|
||||||
if fm.fileExists(atPath: candidate.path) {
|
return directory.appendingPathComponent(filename)
|
||||||
return candidate
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if let url = url(from: "[voice] ", in: ["voicenotes/outgoing", "voicenotes/incoming"]) {
|
// Try outgoing first (most common for sent media), fall back to incoming
|
||||||
|
if message.content.hasPrefix("[voice] ") {
|
||||||
|
let filename = String(message.content.dropFirst("[voice] ".count)).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !filename.isEmpty else { return nil }
|
||||||
|
// Check outgoing first for sent messages, incoming for received
|
||||||
|
let subdir = message.sender == viewModel.nickname ? "voicenotes/outgoing" : "voicenotes/incoming"
|
||||||
|
let url = baseDirectory.appendingPathComponent(subdir, isDirectory: true).appendingPathComponent(filename)
|
||||||
return .voice(url)
|
return .voice(url)
|
||||||
}
|
}
|
||||||
if let url = url(from: "[image] ", in: ["images/outgoing", "images/incoming"]) {
|
if message.content.hasPrefix("[image] ") {
|
||||||
|
let filename = String(message.content.dropFirst("[image] ".count)).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !filename.isEmpty else { return nil }
|
||||||
|
let subdir = message.sender == viewModel.nickname ? "images/outgoing" : "images/incoming"
|
||||||
|
let url = baseDirectory.appendingPathComponent(subdir, isDirectory: true).appendingPathComponent(filename)
|
||||||
return .image(url)
|
return .image(url)
|
||||||
}
|
}
|
||||||
if let url = url(from: "[file] ", in: ["files/outgoing", "files/incoming"]) {
|
if message.content.hasPrefix("[file] ") {
|
||||||
|
let filename = String(message.content.dropFirst("[file] ".count)).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !filename.isEmpty else { return nil }
|
||||||
|
let subdir = message.sender == viewModel.nickname ? "files/outgoing" : "files/incoming"
|
||||||
|
let url = baseDirectory.appendingPathComponent(subdir, isDirectory: true).appendingPathComponent(filename)
|
||||||
return .file(url)
|
return .file(url)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
@@ -2128,13 +2139,26 @@ private extension ContentView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func applicationFilesDirectory() -> URL? {
|
func applicationFilesDirectory() -> URL? {
|
||||||
|
// Cache the directory lookup to avoid repeated FileManager calls during view rendering
|
||||||
|
struct Cache {
|
||||||
|
static var cachedURL: URL?
|
||||||
|
static var didAttempt = false
|
||||||
|
}
|
||||||
|
|
||||||
|
if Cache.didAttempt {
|
||||||
|
return Cache.cachedURL
|
||||||
|
}
|
||||||
|
|
||||||
do {
|
do {
|
||||||
let base = try FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
|
let base = try FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
|
||||||
let filesDir = base.appendingPathComponent("files", isDirectory: true)
|
let filesDir = base.appendingPathComponent("files", isDirectory: true)
|
||||||
try FileManager.default.createDirectory(at: filesDir, withIntermediateDirectories: true, attributes: nil)
|
try FileManager.default.createDirectory(at: filesDir, withIntermediateDirectories: true, attributes: nil)
|
||||||
|
Cache.cachedURL = filesDir
|
||||||
|
Cache.didAttempt = true
|
||||||
return filesDir
|
return filesDir
|
||||||
} catch {
|
} catch {
|
||||||
SecureLogger.error("Failed to resolve application files directory: \(error)", category: .session)
|
SecureLogger.error("Failed to resolve application files directory: \(error)", category: .session)
|
||||||
|
Cache.didAttempt = true
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ struct VoiceNoteView: View {
|
|||||||
WaveformCache.shared.waveform(for: url, completion: { bins in
|
WaveformCache.shared.waveform(for: url, completion: { bins in
|
||||||
self.waveform = bins
|
self.waveform = bins
|
||||||
})
|
})
|
||||||
playback.replaceURL(url)
|
// No need to call playback.replaceURL - already initialized with correct URL
|
||||||
}
|
}
|
||||||
.onChange(of: url) { newValue in
|
.onChange(of: url) { newValue in
|
||||||
WaveformCache.shared.waveform(for: newValue, completion: { bins in
|
WaveformCache.shared.waveform(for: newValue, completion: { bins in
|
||||||
|
|||||||
Reference in New Issue
Block a user