diff --git a/Bark.xcodeproj/project.pbxproj b/Bark.xcodeproj/project.pbxproj index cc63279e..064da99f 100644 --- a/Bark.xcodeproj/project.pbxproj +++ b/Bark.xcodeproj/project.pbxproj @@ -142,6 +142,7 @@ 06B1158F247BB1FB006D91FB /* Message.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06B1158E247BB1FB006D91FB /* Message.swift */; }; 06B11591247BC132006D91FB /* Message.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06B1158E247BB1FB006D91FB /* Message.swift */; }; 06BBB88725650C6C0076F63E /* ArchiveSettingManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06C5953224811505006B98F3 /* ArchiveSettingManager.swift */; }; + 062218542F741C91003E2DA0 /* MessageImageCleaner.swift in Sources */ = {isa = PBXBuildFile; fileRef = 062218512F741C91003E2DA0 /* MessageImageCleaner.swift */; }; 06BBB89125650CCF0076F63E /* ArchiveSettingManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06C5953224811505006B98F3 /* ArchiveSettingManager.swift */; }; 06BBB896256518760076F63E /* NewServerViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06BBB895256518760076F63E /* NewServerViewModel.swift */; }; 06BBB8B72567AC140076F63E /* MessageSettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06BBB8B62567AC140076F63E /* MessageSettingsViewModel.swift */; }; @@ -419,6 +420,7 @@ 06C5952C2480E3F8006B98F3 /* LabelCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LabelCell.swift; sourceTree = ""; }; 06C5953024811392006B98F3 /* ArchiveSettingCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArchiveSettingCell.swift; sourceTree = ""; }; 06C5953224811505006B98F3 /* ArchiveSettingManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArchiveSettingManager.swift; sourceTree = ""; }; + 062218512F741C91003E2DA0 /* MessageImageCleaner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageImageCleaner.swift; sourceTree = ""; }; 06C595352481160F006B98F3 /* BKLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BKLabel.swift; sourceTree = ""; }; 06C777CF2ECEFF210032A044 /* MarkdownParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkdownParser.swift; sourceTree = ""; }; 06CF784021C7A50300A052D7 /* NotificationServiceExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = NotificationServiceExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -686,6 +688,7 @@ 0653677729B727A60038BDB8 /* CryptoSettingRelay.swift */, 06F08EA629B1DDFE006AB9CA /* Error+Extension.swift */, 06E944742C07012E00AC86AB /* RealmConfiguration.swift */, + 062218512F741C91003E2DA0 /* MessageImageCleaner.swift */, 0687F2A72CCB791A00B2A52F /* UIFont+Extension.swift */, 06BCAE552CDB19260092867A /* GroupMuteSettingManager.swift */, 06C777CF2ECEFF210032A044 /* MarkdownParser.swift */, @@ -1349,6 +1352,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 062218542F741C91003E2DA0 /* MessageImageCleaner.swift in Sources */, 0635A8072CE4883A0027E00F /* DonateCell.swift in Sources */, 06840DBB272298FB001B3193 /* BKColor.swift in Sources */, 06C2CF232685B88D0034B127 /* TextCell.swift in Sources */, diff --git a/Bark/AppDelegate+Realm.swift b/Bark/AppDelegate+Realm.swift index 873c7742..e75fd066 100644 --- a/Bark/AppDelegate+Realm.swift +++ b/Bark/AppDelegate+Realm.swift @@ -134,6 +134,7 @@ extension AppDelegate { let expiredMessages = realm.objects(Message.self) .filter("expireDate != nil AND expireDate <= %@", now) let expiredMessageIds = Array(expiredMessages.map(\.id)) + let expiredImageUrls = MessageImageCleaner.shared.imageUrls(in: expiredMessages) if !messagesToAdd.isEmpty || !expiredMessages.isEmpty { do { @@ -161,6 +162,7 @@ extension AppDelegate { if !expiredMessageIds.isEmpty { UNUserNotificationCenter.current().removeDeliveredNotifications(withIdentifiers: expiredMessageIds) + MessageImageCleaner.shared.removeUnreferencedImages(expiredImageUrls) } for plistUrl in plistFiles { diff --git a/Bark/AppDelegate.swift b/Bark/AppDelegate.swift index c3e435d2..bdef7128 100644 --- a/Bark/AppDelegate.swift +++ b/Bark/AppDelegate.swift @@ -131,10 +131,13 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD if let realm = try? Realm(), let message = realm.objects(Message.self).filter("id == %@", id).first { + // 删除前收集图片地址 + let imageUrl = message.image try? realm.write { realm.delete(message) } WidgetHistorySnapshotStore.shared.refreshFromRealmAsync() + MessageImageCleaner.shared.removeUnreferencedImages([imageUrl]) notifyMessagesDidChange() } diff --git a/Common/MessageImageCleaner.swift b/Common/MessageImageCleaner.swift new file mode 100644 index 00000000..b8989628 --- /dev/null +++ b/Common/MessageImageCleaner.swift @@ -0,0 +1,72 @@ +// +// MessageImageCleaner.swift +// Bark +// +// Created by Cnitor on 2026/8/26. +// Copyright © 2026 Cnitor. All rights reserved. +// + +import Foundation +import Kingfisher +import RealmSwift + +/* + 消息记录里的 image 字段只保存图片地址,图片本体由 NotificationServiceExtension 下载后, + 存放在 App Groups 共享的 Kingfisher 图片缓存中(详见 ImageDownloader)。 + 存入时用的是 StorageExpiration.never,不会自动过期,所以消息删除后必须主动清理,否则图片文件会一直残留。 + + 通知中心里推送显示的图不受影响,那是 NSE 复制出来的附件副本。 + 但下拉查看大图读的是同一份缓存,缓存删掉后会重新联网下载,离线时那条推送的大图就显示不出来了, + 这是删图片必然要付出的代价。 + */ +class MessageImageCleaner { + static let shared = MessageImageCleaner() + + private let cleanQueue = DispatchQueue(label: "me.fin.bark.message-image-clean", qos: .utility) + + /// 与 ImageDownloader 使用同一个图片缓存 + private lazy var imageCache: ImageCache? = { + guard let groupUrl = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "group.bark") else { + return nil + } + return try? ImageCache(name: "shared", cacheDirectoryURL: groupUrl) + }() + + private init() {} + + /// 收集这些消息引用的图片地址,需要在消息删除`之前`调用 + func imageUrls(in messages: Results) -> [String] { + // 不用 distinct(by:),image 字段没有加索引,交给 Set 去重更稳妥 + return Array(Set(messages.filter("image != nil").compactMap { $0.image })) + } + + /// 删除不再被任何消息引用的图片缓存,需要在消息删除`之后`调用 + func removeUnreferencedImages(_ imageUrls: [String?]) { + let candidates = Set(imageUrls.compactMap { $0 }) + guard !candidates.isEmpty else { + return + } + + cleanQueue.async { + guard let cache = self.imageCache, let realm = try? Realm() else { + return + } + + // 同一个图片地址可能被多条消息引用,所以先取出所有仍被引用的地址 + // image 字段没有加索引,逐个地址去查会退化成多次全表扫描,所以只扫一遍 + let referenced = Set( + realm.objects(Message.self) + .filter("image != nil") + .compactMap { $0.image } + ) + + for imageUrl in candidates.subtracting(referenced) { + guard let url = URL(string: imageUrl) else { + continue + } + // 缓存 key 与 ImageDownloader 存入时保持一致 + cache.removeImage(forKey: url.cacheKey, fromMemory: true, fromDisk: true) + } + } + } +} diff --git a/Controller/MessageListViewModel.swift b/Controller/MessageListViewModel.swift index 07bb8d07..26d33f78 100644 --- a/Controller/MessageListViewModel.swift +++ b/Controller/MessageListViewModel.swift @@ -336,6 +336,7 @@ class MessageListViewModel: ViewModel, ViewModelType { realm.delete(message) } WidgetHistorySnapshotStore.shared.refreshFromRealmAsync() + MessageImageCleaner.shared.removeUnreferencedImages([model.image]) } // 删除 cell item section.messages.removeAll { cellItem in @@ -354,10 +355,13 @@ class MessageListViewModel: ViewModel, ViewModelType { messageResult = self.results?.filter("group == nil") } if let messageResult { + // 删除前收集图片地址 + let imageUrls = MessageImageCleaner.shared.imageUrls(in: messageResult) try? realm.write { realm.delete(messageResult) } WidgetHistorySnapshotStore.shared.refreshFromRealmAsync() + MessageImageCleaner.shared.removeUnreferencedImages(imageUrls) } } // 删除 cell item @@ -390,6 +394,7 @@ class MessageListViewModel: ViewModel, ViewModelType { realm.delete(message) } WidgetHistorySnapshotStore.shared.refreshFromRealmAsync() + MessageImageCleaner.shared.removeUnreferencedImages([model.image]) } if let index = section.messages.firstIndex(where: { item in @@ -425,10 +430,13 @@ class MessageListViewModel: ViewModel, ViewModelType { return } + // 删除前收集图片地址 + let imageUrls = MessageImageCleaner.shared.imageUrls(in: messages) try? realm.write { realm.delete(messages) } WidgetHistorySnapshotStore.shared.refreshFromRealmAsync() + MessageImageCleaner.shared.removeUnreferencedImages(imageUrls) } self.page = 0