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:
jack
2025-10-14 22:26:03 +02:00
parent 0a525be57a
commit da0474680c
2 changed files with 38 additions and 14 deletions
+37 -13
View File
@@ -1563,28 +1563,39 @@ private extension ContentView {
func mediaAttachment(for message: BitchatMessage) -> MessageMedia? {
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 }
let filename = String(message.content.dropFirst(prefix.count)).trimmingCharacters(in: .whitespacesAndNewlines)
guard !filename.isEmpty else { return nil }
let fm = FileManager.default
for sub in subdirectories {
let directory = baseDirectory.appendingPathComponent(sub, isDirectory: true)
let candidate = directory.appendingPathComponent(filename)
if fm.fileExists(atPath: candidate.path) {
return candidate
}
}
return nil
// Construct URL directly without fileExists check (avoids blocking disk I/O in view body)
// Files are checked during playback/display, so missing files fail gracefully
let directory = baseDirectory.appendingPathComponent(subdirectory, isDirectory: true)
return directory.appendingPathComponent(filename)
}
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)
}
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)
}
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 nil
@@ -2071,13 +2082,26 @@ private extension ContentView {
}
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 {
let base = try FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
let filesDir = base.appendingPathComponent("files", isDirectory: true)
try FileManager.default.createDirectory(at: filesDir, withIntermediateDirectories: true, attributes: nil)
Cache.cachedURL = filesDir
Cache.didAttempt = true
return filesDir
} catch {
SecureLogger.error("Failed to resolve application files directory: \(error)", category: .session)
Cache.didAttempt = true
return nil
}
}
+1 -1
View File
@@ -103,7 +103,7 @@ struct VoiceNoteView: View {
WaveformCache.shared.waveform(for: url, completion: { bins in
self.waveform = bins
})
playback.replaceURL(url)
// No need to call playback.replaceURL - already initialized with correct URL
}
.onChange(of: url) { newValue in
WaveformCache.shared.waveform(for: newValue, completion: { bins in