From e7fec43945ae2bdc9da05fda667e320bfbf78d18 Mon Sep 17 00:00:00 2001 From: Fiiiga Date: Wed, 26 Aug 2026 19:47:38 +0800 Subject: [PATCH 1/3] =?UTF-8?q?=E5=88=A0=E9=99=A4=E6=B6=88=E6=81=AF?= =?UTF-8?q?=E6=97=B6=EF=BC=8C=E4=BC=9A=E4=B8=80=E5=B9=B6=E5=88=A0=E9=99=A4?= =?UTF-8?q?=E4=B8=8D=E5=86=8D=E8=A2=AB=E5=BC=95=E7=94=A8=E7=9A=84=E5=9B=BE?= =?UTF-8?q?=E7=89=87=E7=BC=93=E5=AD=98=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Bark.xcodeproj/project.pbxproj | 4 ++ Bark/AppDelegate+Realm.swift | 2 + Bark/AppDelegate.swift | 3 ++ Common/MessageImageCleaner.swift | 72 +++++++++++++++++++++++++++ Controller/MessageListViewModel.swift | 8 +++ 5 files changed, 89 insertions(+) create mode 100644 Common/MessageImageCleaner.swift 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 From f6f8eb327a494f4161faaa84c94825afd3c046f5 Mon Sep 17 00:00:00 2001 From: Fiiiga Date: Sat, 26 Sep 2026 12:06:18 +0800 Subject: [PATCH 2/3] =?UTF-8?q?=E4=B8=BA=E6=B6=88=E6=81=AF=E5=9B=BE?= =?UTF-8?q?=E7=89=87=E5=BC=95=E7=94=A8=E6=9F=A5=E8=AF=A2=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=20Realm=20=E7=B4=A2=E5=BC=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Common/MessageImageCleaner.swift | 20 ++++++++++---------- Common/RealmConfiguration.swift | 2 +- Model/Message.swift | 2 +- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/Common/MessageImageCleaner.swift b/Common/MessageImageCleaner.swift index b8989628..f1b78434 100644 --- a/Common/MessageImageCleaner.swift +++ b/Common/MessageImageCleaner.swift @@ -36,7 +36,6 @@ class MessageImageCleaner { /// 收集这些消息引用的图片地址,需要在消息删除`之前`调用 func imageUrls(in messages: Results) -> [String] { - // 不用 distinct(by:),image 字段没有加索引,交给 Set 去重更稳妥 return Array(Set(messages.filter("image != nil").compactMap { $0.image })) } @@ -51,19 +50,20 @@ class MessageImageCleaner { guard let cache = self.imageCache, let realm = try? Realm() else { return } + // GCD 线程上的 Realm 不自动刷新,refresh 以避免读到旧快照 + realm.refresh() - // 同一个图片地址可能被多条消息引用,所以先取出所有仍被引用的地址 - // image 字段没有加索引,逐个地址去查会退化成多次全表扫描,所以只扫一遍 - let referenced = Set( - realm.objects(Message.self) - .filter("image != nil") - .compactMap { $0.image } - ) - - for imageUrl in candidates.subtracting(referenced) { + // 按索引查询是否还被其它消息引用 + let messages = realm.objects(Message.self) + for imageUrl in candidates { guard let url = URL(string: imageUrl) else { continue } + // 原始地址和百分号编码地址对应同一个缓存文件,二者皆查 + let aliases = Set([imageUrl, url.absoluteString, imageUrl.removingPercentEncoding].compactMap { $0 }) + guard messages.filter("image IN %@", aliases).isEmpty else { + continue + } // 缓存 key 与 ImageDownloader 存入时保持一致 cache.removeImage(forKey: url.cacheKey, fromMemory: true, fromDisk: true) } diff --git a/Common/RealmConfiguration.swift b/Common/RealmConfiguration.swift index 52e045d9..8fa17a20 100644 --- a/Common/RealmConfiguration.swift +++ b/Common/RealmConfiguration.swift @@ -13,7 +13,7 @@ let kRealmDefaultConfiguration = { let fileUrl = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first?.appendingPathComponent("bark.realm") let config = Realm.Configuration( fileURL: fileUrl, - schemaVersion: 18, + schemaVersion: 19, migrationBlock: { migration, oldSchemaVersion in switch oldSchemaVersion { case 0...13: diff --git a/Model/Message.swift b/Model/Message.swift index 58987446..1fcae250 100644 --- a/Model/Message.swift +++ b/Model/Message.swift @@ -21,7 +21,7 @@ class Message: Object { @Persisted var body: String? @Persisted var bodyType: String? @Persisted var url: String? - @Persisted var image: String? + @Persisted(indexed: true) var image: String? @Persisted(indexed: true) var group: String? @Persisted(indexed: true) var createDate: Date? @Persisted(indexed: true) var expireDate: Date? From 8ec89131a7beab6eebdac97b1a43b3250542498a Mon Sep 17 00:00:00 2001 From: Fiiiga Date: Tue, 29 Sep 2026 10:50:32 +0800 Subject: [PATCH 3/3] =?UTF-8?q?=E6=B8=85=E7=90=86=20API=20=E6=96=B0?= =?UTF-8?q?=E5=A2=9E=20completion=20=E5=9B=9E=E8=B0=83=EF=BC=8C=E7=AD=89?= =?UTF-8?q?=E5=9B=BE=E7=89=87=E7=BC=93=E5=AD=98=E6=B8=85=E7=90=86=E5=AE=8C?= =?UTF-8?q?=E6=88=90=E5=90=8E=E5=86=8D=E7=BB=93=E6=9D=9F=E5=90=8E=E5=8F=B0?= =?UTF-8?q?=E4=BB=BB=E5=8A=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Bark/AppDelegate.swift | 6 +++++- Common/MessageImageCleaner.swift | 16 ++++++++++++++-- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/Bark/AppDelegate.swift b/Bark/AppDelegate.swift index bdef7128..87ebd290 100644 --- a/Bark/AppDelegate.swift +++ b/Bark/AppDelegate.swift @@ -137,8 +137,12 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD realm.delete(message) } WidgetHistorySnapshotStore.shared.refreshFromRealmAsync() - MessageImageCleaner.shared.removeUnreferencedImages([imageUrl]) notifyMessagesDidChange() + // 图片缓存删完再结束后台任务 + MessageImageCleaner.shared.removeUnreferencedImages([imageUrl]) { + completionHandler(.newData) + } + return } completionHandler(.newData) diff --git a/Common/MessageImageCleaner.swift b/Common/MessageImageCleaner.swift index f1b78434..0c4b3b1c 100644 --- a/Common/MessageImageCleaner.swift +++ b/Common/MessageImageCleaner.swift @@ -40,14 +40,18 @@ class MessageImageCleaner { } /// 删除不再被任何消息引用的图片缓存,需要在消息删除`之后`调用 - func removeUnreferencedImages(_ imageUrls: [String?]) { + /// - Parameter completion: 清理流程结束后回调,不代表全部删除成功。 + /// 无候选图片时立即调用,其余情况在后台队列调用。 + func removeUnreferencedImages(_ imageUrls: [String?], completion: (() -> Void)? = nil) { let candidates = Set(imageUrls.compactMap { $0 }) guard !candidates.isEmpty else { + completion?() return } cleanQueue.async { guard let cache = self.imageCache, let realm = try? Realm() else { + completion?() return } // GCD 线程上的 Realm 不自动刷新,refresh 以避免读到旧快照 @@ -55,6 +59,7 @@ class MessageImageCleaner { // 按索引查询是否还被其它消息引用 let messages = realm.objects(Message.self) + let group = DispatchGroup() for imageUrl in candidates { guard let url = URL(string: imageUrl) else { continue @@ -65,7 +70,14 @@ class MessageImageCleaner { continue } // 缓存 key 与 ImageDownloader 存入时保持一致 - cache.removeImage(forKey: url.cacheKey, fromMemory: true, fromDisk: true) + group.enter() + cache.removeImage(forKey: url.cacheKey, fromMemory: true, fromDisk: true) { + group.leave() + } + } + // 等所有异步删除操作结束后再回调,不阻塞清理队列 + group.notify(queue: self.cleanQueue) { + completion?() } } }