├── .github └── FUNDING.yml ├── .gitignore ├── .swiftpm └── xcode │ ├── package.xcworkspace │ └── xcshareddata │ │ └── IDEWorkspaceChecks.plist │ └── xcshareddata │ └── xcschemes │ └── PersistentHistoryTrackingKit.xcscheme ├── LICENSE ├── Package.resolved ├── Package.swift ├── README.md ├── READMECN.md ├── Sources └── PersistentHistoryTrackingKit │ ├── CleanStrategy.swift │ ├── Cleaner.swift │ ├── DefaultLogger.swift │ ├── Extensions.swift │ ├── Fetcher.swift │ ├── Merger.swift │ ├── PersistentHistoryTrackingKit.swift │ ├── Protocol │ ├── CleanerProtocol.swift │ ├── DeduplicatorProtocol.swift │ ├── FetcherProtocol.swift │ ├── LoggerProtocol.swift │ ├── MergerProtocol.swift │ └── TransactionTimestampManagerProtocol.swift │ └── TransactionTimestampManager.swift └── Tests └── PersistentHistoryTrackingKitTests ├── CleanStrategyTests.swift ├── CleanerTests.swift ├── FetcherTests.swift ├── LoggerTests.swift ├── MergerTests.swift ├── PersistentHistoryTrackingKitTests.swift ├── TestsHelper ├── CoreDataStackHelper.swift └── Extension.swift └── TimestampManagerTests.swift /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] 4 | patreon: fatbobman 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry 12 | polar: # Replace with a single Polar username 13 | buy_me_a_coffee: fatbobman 14 | thanks_dev: # Replace with a single thanks.dev username 15 | custom: ['https://afdian.com','https://www.paypal.com/paypalme/fatbobman'] 16 | 17 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | /*.xcodeproj 5 | xcuserdata/ 6 | DerivedData/ 7 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata 8 | -------------------------------------------------------------------------------- /.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /.swiftpm/xcode/xcshareddata/xcschemes/PersistentHistoryTrackingKit.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 29 | 35 | 36 | 37 | 38 | 39 | 44 | 45 | 47 | 53 | 54 | 55 | 56 | 57 | 67 | 68 | 74 | 75 | 81 | 82 | 83 | 84 | 86 | 87 | 90 | 91 | 92 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Fatbobman (东坡肘子) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "object": { 3 | "pins": [ 4 | { 5 | "package": "AsyncAlgorithms", 6 | "repositoryURL": "https://github.com/apple/swift-async-algorithms.git", 7 | "state": { 8 | "branch": null, 9 | "revision": "cca423ff03ab657062f3781847e65a867b51bb01", 10 | "version": "0.0.3" 11 | } 12 | } 13 | ] 14 | }, 15 | "version": 1 16 | } 17 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.5 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "PersistentHistoryTrackingKit", 8 | platforms: [ 9 | .iOS(.v15), 10 | .macOS(.v12), 11 | .macCatalyst(.v15), 12 | .tvOS(.v15), 13 | .watchOS(.v8) 14 | ], 15 | products: [ 16 | // Products define the executables and libraries a package produces, and make them visible to other packages. 17 | .library( 18 | name: "PersistentHistoryTrackingKit", 19 | targets: ["PersistentHistoryTrackingKit"] 20 | ) 21 | ], 22 | dependencies: [ 23 | // Dependencies declare other packages that this package depends on. 24 | // .package(url: /* package url */, from: "1.0.0"), 25 | .package(url: "https://github.com/apple/swift-async-algorithms", from: "0.0.1"), 26 | ], 27 | targets: [ 28 | // Targets are the basic building blocks of a package. A target can define a module or a test suite. 29 | // Targets can depend on other targets in this package, and on products in packages this package depends on. 30 | .target( 31 | name: "PersistentHistoryTrackingKit", 32 | dependencies: [ 33 | .product(name: "AsyncAlgorithms", package: "swift-async-algorithms"), 34 | ] 35 | ), 36 | .testTarget( 37 | name: "PersistentHistoryTrackingKitTests", 38 | dependencies: ["PersistentHistoryTrackingKit"] 39 | ) 40 | ] 41 | ) 42 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Persistent History Tracking Kit 2 | 3 | Helps you easily handle Core Data's Persistent History Tracking 4 | 5 | ![](https://img.shields.io/badge/Platform%20Compatibility-iOS%20|%20macOS%20|%20tvOS%20|%20watchOs-red) ![](https://img.shields.io/badge/Swift%20Compatibility-5.5-red) 6 | 7 | [中文版说明](https://github.com/fatbobman/PersistentHistoryTrackingKit/blob/main/READMECN.md) 8 | 9 | ## What's This? 10 | 11 | > Use persistent history tracking to determine what changes have occurred in the store since the enabling of persistent history tracking. —— Apple Documentation 12 | 13 | When Persistent History Tracking is enabled, your application will begin creating transactions for any changes that occur in Core Data Storage. Whether they come from application extensions, background contexts, or the main application. 14 | 15 | Each target of your application can fetch the transactions that have occurred since a given date and merge them into the local storage. This way, you can keep up to date with changes made by other persistent storage coordinators and keep your storage up to date. After merging all transactions, you can update the merge date so that the next time you merge, you will only get the new transactions that have not yet been processed. 16 | 17 | The **Persistent History Tracking Kit** will automate the above process for you. 18 | 19 | ## How does persistent history tracking work? 20 | 21 | Upon receiving a remote notification of Persistent History Tracking from Core Data, Persistent History Tracking Kit will do the following: 22 | 23 | * Query the current author's (current author) last merge transaction time 24 | * Get new transactions created by other applications, application extensions, background contexts, etc. (all authors) in addition to this application since the date of the last merged transaction 25 | * Merge the new transaction into the specified context (usually the current application's view context) 26 | * Update the current application's merge transaction time 27 | * Clean up transactions that have been merged by all applications 28 | 29 | For more specific details on how this works, read [在 CoreData 中使用持久化历史跟踪](https://fatbobman.com/zh/posts/persistenthistorytracking/) or [Using Persistent History Tracking in CoreData ](https://fatbobman.com/en/posts/persistenthistorytracking/). 30 | 31 | ## Usage 32 | 33 | ```swift 34 | // in Core Data Stack 35 | import PersistentHistoryTrackingKit 36 | 37 | init() { 38 | container = NSPersistentContainer(name: "PersistentTrackBlog") 39 | // Prepare your Container 40 | let desc = container.persistentStoreDescriptions.first! 41 | // Turn on persistent history tracking in persistentStoreDescriptions 42 | desc.setOption(true as NSNumber, 43 | forKey: NSPersistentHistoryTrackingKey) 44 | desc.setOption(true as NSNumber, 45 | forKey: NSPersistentStoreRemoteChangeNotificationPostOptionKey) 46 | container.loadPersistentStores(completionHandler: { _, _ in }) 47 | 48 | container.viewContext.transactionAuthor = "app1" 49 | // after loadPersistentStores 50 | let kit = PersistentHistoryTrackingKit( 51 | container: container, 52 | currentAuthor: "app1", 53 | allAuthors: ["app1", "app2", "app3"], 54 | userDefaults: userDefaults, 55 | logLevel: 3, 56 | ) 57 | } 58 | ``` 59 | 60 | ## Parameters 61 | 62 | ### currentAuthor 63 | 64 | The name of the author of the current application. The name is usually the same as the transaction name of the view context 65 | 66 | ```swift 67 | container.viewContext.transactionAuthor = "app1" 68 | ``` 69 | 70 | ### allAuthors 71 | 72 | The author name of all members managed by the Persistent History Tracking Kit. 73 | 74 | Persistent History Tracking Kit should only be used to manage transactions generated by developer-created applications, application extensions, and backend contexts; other system-generated transactions (e.g. Core Data with CloudKit) are handled by the system itself. 75 | 76 | For example, if your application author name is: "appAuthor" and your application extension author name is: "extensionAuthor", then. 77 | 78 | ```swift 79 | allAuthors: ["appAuthor", "extensionAuthor"], 80 | ``` 81 | 82 | For transactions generated in the backend context, the backend context should also have a separate author name if it is not set to auto-merge. 83 | 84 | ```swift 85 | allAuthors: ["appAuthor", "extensionAuthor", "appBatchAuthor"], 86 | ``` 87 | 88 | ### includingCloudKitMirroring 89 | 90 | Whether or not to merge network data imported by Core Data with CloudKit, is only used in scenarios where the Core Data cloud sync state needs to be switched in real time. See [Switching Core Data Cloud Sync Status in Real-Time](https://fatbobman.com/en/posts/real-time-switching-of-cloud-syncs-status/) for details on usage 91 | 92 | ### batchAuthors 93 | 94 | Some authors (such as background contexts for batch changes) only create transactions and do not merge and clean up transactions generated by other authors. You can speed up the cleanup of such transactions by setting them in batchAuthors. 95 | 96 | ```swift 97 | batchAuthors: ["appBatchAuthor"], 98 | ``` 99 | 100 | 101 | Even if not set, these transactions will be automatically cleared after reaching maximumDuration. 102 | 103 | ### maximumDuration 104 | 105 | Normally, transactions are only cleaned up after they have been merged by all authors. However, in some cases, individual authors may not run for a long time or may not be implemented yet, causing transactions to remain in SQLite. In the long run, this can cause a performance degradation of the database. 106 | 107 | By setting maximumDuration, Persistent History Tracking Kit will force the removal of transactions that have reached the set duration. The default setting is 7 days. 108 | 109 | ```swift 110 | maximumDuration: 60 * 60 * 24 * 7, 111 | ``` 112 | 113 | Performing cleanup on transactions does not harm the application's data. 114 | 115 | ### contexts 116 | 117 | The context used for merging transactions, usually the application's view context. By default, it is automatically set to the container's view context. 118 | 119 | ```swift 120 | contexts: [viewContext], 121 | ``` 122 | 123 | ### userDefaults 124 | 125 | If an App Group is used, use the UserDefaults available for the group. 126 | 127 | ```swift 128 | let appGroupUserDefaults = UserDefaults(suiteName: "group.com.yourGroup")! 129 | 130 | userDefaults: appGroupUserDefaults, 131 | ``` 132 | 133 | ### cleanStrategy 134 | 135 | Persistent History Tracking Kit currently supports three transaction cleanup strategies: 136 | 137 | * none 138 | 139 | Merge only, no cleanup 140 | 141 | * byDuration 142 | 143 | Set a minimum time interval between cleanups 144 | 145 | * byNotification 146 | 147 | Set the minimum number of notifications between cleanups 148 | 149 | ```swift 150 | // Each notification is cleaned up 151 | cleanStrategy: .byNotification(times: 1), 152 | // At least 60 seconds between cleanings 153 | cleanStrategy: .byDuration(seconds: 60), 154 | ``` 155 | 156 | When the cleanup policy is set to none, cleanup can be performed at the right time by generating separate cleanup instances. 157 | 158 | ```swift 159 | let kit = PersistentHistoryTrackingKit( 160 | container: container, 161 | currentAuthor: "app1", 162 | allAuthors: "app1,app2,app3", 163 | userDefaults: userDefaults, 164 | cleanStrategy: .byNotification(times: 1), 165 | logLevel: 3, 166 | autoStart: false 167 | ) 168 | let cleaner = kit.cleanerBuilder() 169 | 170 | // Execute cleaner at the right time, for example when the application enters the background 171 | clear() 172 | ``` 173 | 174 | ### uniqueString 175 | 176 | The string prefix for the timestamp in UserDefaults. 177 | 178 | ### logger 179 | 180 | The Persistent History Tracking Kit provides default logging output. To export Persistent History Tracking Kit information through the logging system you are using, simply make your logging code conform to the PersistentHistoryTrackingKitLoggerProtocol. 181 | 182 | ```swift 183 | public protocol PersistentHistoryTrackingKitLoggerProtocol { 184 | func log(type: PersistentHistoryTrackingKitLogType, message: String) 185 | } 186 | 187 | struct MyLogger: PersistentHistoryTrackingKitLoggerProtocol { 188 | func log(type: PersistentHistoryTrackingKitLogType, message: String) { 189 | print("[\(type.rawValue.uppercased())] : message") 190 | } 191 | } 192 | 193 | logger:MyLogger(), 194 | ``` 195 | 196 | ### logLevel 197 | 198 | The output of log messages can be controlled by setting logLevel: 199 | 200 | * 0 Turn off log output 201 | * 1 Important status only 202 | * 2 Detail information 203 | 204 | ### autoStart 205 | 206 | Whether to start the Persistent History Tracking Kit instance as soon as it is created. 207 | 208 | During the execution of the application, the running state can be changed by start() or stop(). 209 | 210 | ```swift 211 | kit.start() 212 | kit.stop() 213 | ``` 214 | 215 | ## Requirements 216 | 217 | .iOS(.v13), 218 | 219 | .macOS(.v10_15), 220 | 221 | .macCatalyst(.v13), 222 | 223 | .tvOS(.v13), 224 | 225 | .watchOS(.v6) 226 | 227 | ## Install 228 | 229 | ```swift 230 | dependencies: [ 231 | .package(url: "https://github.com/fatbobman/PersistentHistoryTrackingKit.git", from: "1.0.0") 232 | ] 233 | ``` 234 | 235 | ## License 236 | 237 | This library is released under the MIT license. See [LICENSE](https://github.com/fatbobman/persistentHistoryTrackingKit/blob/main/LICENSE) for details. 238 | -------------------------------------------------------------------------------- /READMECN.md: -------------------------------------------------------------------------------- 1 | # Persistent History Tracking Kit 2 | 3 | 帮助您轻松处理 Core Data 的持久性历史跟踪。 4 | 5 | ![](https://img.shields.io/badge/Platform%20Compatibility-iOS%20|%20macOS%20|%20tvOS%20|%20watchOs-red) ![](https://img.shields.io/badge/Swift%20Compatibility-5.5-red) 6 | 7 | ## What's This? 8 | 9 | > Use persistent history tracking to determine what changes have occurred in the store since the enabling of persistent history tracking. —— Apple Documentation 10 | 11 | 启用持久历史记录跟踪(Persistent History Tracking)后,您的应用程序将开始为 Core Data 存储中发生的任何更改创建事务。无论它们来自应用程序扩展、后台上下文还是主应用程序。 12 | 13 | 您的应用程序的每个目标都可以获取自给定日期以来发生的事务,并将其合并到本地存储。这样,您可以随时了解其他持久化存储协调器的更改,让您的存储保持最新状态。合并所有事务后,您可以更新合并日期,这样您在下次合并时将只会获取到尚未处理的新事务。 14 | 15 | **Persistent History Tracking Kit** 将为您自动完成上述的过程。 16 | 17 | ## 持久性历史跟踪是如何进行的? 18 | 19 | 在接收到 Core Data 发送的持久历史记录跟踪远程通知后,Persistent History Tracking Kit 将进行如下工作: 20 | 21 | * 查询当前应用的(current author)上次合并事务的时间 22 | * 获取从上次合并事务日期后,除了本应用程序外,由其他应用程序、应用程序扩展、后台上下文等(all authors)新创建的事务 23 | * 将新的事务合并到指定的上下文中(通常是当前应用程序的视图上下文) 24 | * 更新当前应用程序的合并事务时间 25 | * 清理已被所有应用合并后的事务 26 | 27 | 更具体的工作原理和细节,可以阅读 [在 CoreData 中使用持久化历史跟踪](https://fatbobman.com/zh/posts/persistenthistorytracking/) 或者 [Using Persistent History Tracking in CoreData ](https://fatbobman.com/en/posts/persistenthistorytracking/)。 28 | 29 | ## 使用方法 30 | 31 | ```swift 32 | // in Core Data Stack 33 | import PersistentHistoryTrackingKit 34 | 35 | init() { 36 | container = NSPersistentContainer(name: "PersistentTrackBlog") 37 | // Prepare your Container 38 | let desc = container.persistentStoreDescriptions.first! 39 | // Turn on persistent history tracking in persistentStoreDescriptions 40 | desc.setOption(true as NSNumber, 41 | forKey: NSPersistentHistoryTrackingKey) 42 | desc.setOption(true as NSNumber, 43 | forKey: NSPersistentStoreRemoteChangeNotificationPostOptionKey) 44 | container.loadPersistentStores(completionHandler: { _, _ in }) 45 | 46 | container.viewContext.transactionAuthor = "app1" 47 | // after loadPersistentStores 48 | let kit = PersistentHistoryTrackingKit( 49 | container: container, 50 | currentAuthor: "app1", 51 | allAuthors: ["app1", "app2", "app3"], 52 | userDefaults: userDefaults, 53 | logLevel: 3, 54 | ) 55 | } 56 | ``` 57 | 58 | ## 配置参数 59 | 60 | ### currentAuthor 61 | 62 | 当前应用的 author 名称。名称通常与视图上下文的事务名称一致 63 | 64 | ```swift 65 | container.viewContext.transactionAuthor = "app1" 66 | ``` 67 | 68 | ### allAuthors 69 | 70 | 由 Persistent History Tracking Kit 管理的所有成员的 author 名称。 71 | 72 | Persistent History Tracking Kit 应只用来管理由开发者创建的应用程序、应用程序扩展、后台上下文产生的事务,其他由系统生成的事务(例如 Core Data with CloudKit),系统会自行处理。 73 | 74 | 例如,您的应用程序 author 名称为:“appAuthor”,应用程序扩展 author 名称为:“extensionAuthor”,则: 75 | 76 | ```swift 77 | allAuthors: ["appAuthor", "extensionAuthor"], 78 | ``` 79 | 80 | 对于后台上下文中生成的事务,如果没有设置成自动合并的话,后台上下文也应该设置单独的 author 名称: 81 | 82 | ```swift 83 | allAuthors: ["appAuthor", "extensionAuthor", "appBatchAuthor"], 84 | ``` 85 | 86 | ### includingCloudKitMirroring 87 | 88 | 是否合并由 Core Data with CloudKit 导入的网络数据,仅用于需要实时切换 Core Data 云同步状态的场景。具体用法请参阅 [实时切换 Core Data 的云同步状态](https://fatbobman.com/zh/posts/real-time-switching-of-cloud-syncs-status/) 89 | 90 | ### batchAuthors 91 | 92 | 某些 author(例如用于批量更改的后台上下文)只会创建事务,并不会对其他 author 的产生事务进行合并和清理。通过将其设置在 batchAuthors 中,可以加速该类事务的清理。 93 | 94 | ```swift 95 | batchAuthors: ["appBatchAuthor"], 96 | ``` 97 | 98 | 即使不设定,这些事务也将在达到 maximumDuration 后被自动清除。 99 | 100 | ### maximumDuration 101 | 102 | 正常情况下,事务只有被所有的 author 都合并后才会被清理。但在某些情况下,个别 author 可能长期未运行或尚未实现,导致事务始终保持在 SQLite 中。长此以往,会造成数据库性能下降。 103 | 104 | 通过设置 maximumDuration ,Persistent History Tracking Kit 会强制清除已达到设定时长的事务。默认设置为 7 天。 105 | 106 | ```swift 107 | maximumDuration: 60 * 60 * 24 * 7, 108 | ``` 109 | 110 | 清除事务并不会对应用程序的数据造成损害。 111 | 112 | ### contexts 113 | 114 | 用于合并事务的上下文,通常情况下是应用程序的视图上下文。默认会自动设置为 container 的视图上下文。 115 | 116 | ```swift 117 | contexts: [viewContext], 118 | ``` 119 | 120 | ### userDefaults 121 | 122 | 用于保存时间戳的 UserDefaults。如果使用了 App Group,请使用可用于 group 的 UserDefaults。 123 | 124 | ```swift 125 | let appGroupUserDefaults = UserDefaults(suiteName: "group.com.yourGroup")! 126 | 127 | userDefaults: appGroupUserDefaults, 128 | ``` 129 | 130 | ### cleanStrategy 131 | 132 | Persistent History Tracking Kit 目前支持三种事务清理策略: 133 | 134 | * none 135 | 136 | 只合并,不清理 137 | 138 | * byDuration 139 | 140 | 设定两次清理之间的最小时间间隔 141 | 142 | * byNotification 143 | 144 | 设定两次清理之间的最小通知次数间隔 145 | 146 | ```swift 147 | // 每个通知都清理 148 | cleanStrategy: .byNotification(times: 1), 149 | // 两次清理之间,至少间隔 60 秒 150 | cleanStrategy: .byDuration(seconds: 60), 151 | ``` 152 | 153 | 当清理策略设置为 none 时,可以通过生成单独的清理实例,在合适的时机进行清理。 154 | 155 | ```swift 156 | let kit = PersistentHistoryTrackingKit( 157 | container: container, 158 | currentAuthor: "app1", 159 | allAuthors: "app1,app2,app3", 160 | userDefaults: userDefaults, 161 | cleanStrategy: .byNotification(times: 1), 162 | logLevel: 3, 163 | autoStart: false 164 | ) 165 | let cleaner = kit.cleanerBuilder() 166 | 167 | // Execute cleaner at the right time, for example when the application enters the background 168 | clear() 169 | ``` 170 | 171 | ### uniqueString 172 | 173 | 时间戳在 UserDefaults 中的字符串前缀。 174 | 175 | ### logger 176 | 177 | Persistent History Tracking Kit 提供了默认的日志输出功能。如果想通过您正在使用的日志系统来输出 Persistent History Tracking Kit 的信息,只需让您的日志代码符合 PersistentHistoryTrackingKitLoggerProtocol 即可。 178 | 179 | ```swift 180 | public protocol PersistentHistoryTrackingKitLoggerProtocol { 181 | func log(type: PersistentHistoryTrackingKitLogType, message: String) 182 | } 183 | 184 | struct MyLogger: PersistentHistoryTrackingKitLoggerProtocol { 185 | func log(type: PersistentHistoryTrackingKitLogType, message: String) { 186 | print("[\(type.rawValue.uppercased())] : message") 187 | } 188 | } 189 | 190 | logger:MyLogger(), 191 | ``` 192 | 193 | ### logLevel 194 | 195 | 通过设定 logLevel 可以控制日志信息的输出: 196 | 197 | * 0 关闭日志输出 198 | * 1 仅重要状态 199 | * 2 详细信息 200 | 201 | ### autoStart 202 | 203 | 是否在创建 Persistent History Tracking Kit 实例后,马上启动。 204 | 205 | 在应用程序的执行过程中,可以通过 start() 或 stop() 来改变运行状态。 206 | 207 | ```swift 208 | kit.start() 209 | kit.stop() 210 | ``` 211 | 212 | ## 系统需求 213 | 214 | ​ .iOS(.v13), 215 | 216 | ​ .macOS(.v10_15), 217 | 218 | ​ .macCatalyst(.v13), 219 | 220 | ​ .tvOS(.v13), 221 | 222 | ​ .watchOS(.v6) 223 | 224 | ## 安装 225 | 226 | ```swift 227 | dependencies: [ 228 | .package(url: "https://github.com/fatbobman/PersistentHistoryTrackingKit.git", from: "1.0.0") 229 | ] 230 | ``` 231 | 232 | ## License 233 | 234 | This library is released under the MIT license. See [LICENSE](https://github.com/fatbobman/persistentHistoryTrackingKit/blob/main/LICENSE) for details. 235 | 236 | -------------------------------------------------------------------------------- /Sources/PersistentHistoryTrackingKit/CleanStrategy.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CleanStrategy.swift 3 | // 4 | // 5 | // Created by Yang Xu on 2022/2/14 6 | // Copyright © 2022 Yang Xu. All rights reserved. 7 | // 8 | // Follow me on Twitter: @fatbobman 9 | // My Blog: https://www.fatbobman.com 10 | // 微信公共号: 肘子的Swift记事本 11 | // 12 | 13 | import Foundation 14 | 15 | /// Transaction 清理策略。 16 | /// 17 | /// 如果仅需合并,无需自动清理,可以选择none。 18 | /// byNotification可以指定每隔通知清理一次。默认设置为 byNotification(0) 19 | /// bySeconds设置成美两次清理中间至少间隔多少秒。 20 | public enum TransactionCleanStrategy { 21 | case none 22 | case byDuration(seconds: TimeInterval) 23 | case byNotification(times: Int) 24 | } 25 | 26 | /// 清理规则协议 27 | protocol TransactionPurgePolicy { 28 | /// 在每次接收到 notification 时判断,是否可以进行清理 29 | mutating func allowedToClean() -> Bool 30 | init(strategy: TransactionCleanStrategy) 31 | } 32 | 33 | /// 关闭策略。设置成该策略后,Kit中将不会执行清理任务 34 | /// 用于想手动控制清理任务执行的情况。 35 | /// 可以使用Kit的 生成可手动执行任务的清理实例 36 | struct TransactionCleanStrategyNone: TransactionPurgePolicy { 37 | func allowedToClean() -> Bool { 38 | false 39 | } 40 | 41 | init(strategy: TransactionCleanStrategy = .none) {} 42 | } 43 | 44 | /// 按时间间隔实行清理策略。 45 | /// 设定间隔的秒数。每次执行清理任务时,应与上次清理时间之间至少保持设定的时间距离 46 | struct TransactionCleanStrategyByDuration: TransactionPurgePolicy { 47 | private var lastCleanTimestamp: Date? 48 | private let duration: TimeInterval 49 | 50 | mutating func allowedToClean() -> Bool { 51 | if (lastCleanTimestamp ?? .distantPast).advanced(by: duration) < Date() { 52 | lastCleanTimestamp = Date() 53 | return true 54 | } else { 55 | return false 56 | } 57 | } 58 | 59 | init(strategy: TransactionCleanStrategy) { 60 | if case .byDuration(let seconds) = strategy { 61 | self.duration = seconds 62 | } else { 63 | fatalError("Transaction clean strategy should be byDuration") 64 | } 65 | } 66 | } 67 | 68 | /// 按通知次数间隔实行清理策略 69 | /// 70 | /// 每接收到几次 notification 执行一次清理。 times = 1时,每次都会执行。 times = 3时,每三次执行一次清理 71 | struct TransactionCleanStrategyByNotification: TransactionPurgePolicy { 72 | private var count: Int 73 | private var times: Int 74 | init(strategy: TransactionCleanStrategy) { 75 | if case .byNotification(times: let times) = strategy { 76 | self.times = times 77 | self.count = times 78 | } else { 79 | fatalError("Transaction clean strategy should be byNotification") 80 | } 81 | } 82 | 83 | mutating func allowedToClean() -> Bool { 84 | if count >= times { 85 | count = 1 86 | return true 87 | } else { 88 | count += 1 89 | return false 90 | } 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /Sources/PersistentHistoryTrackingKit/Cleaner.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Cleaner.swift 3 | // 4 | // 5 | // Created by Yang Xu on 2022/2/11 6 | // Copyright © 2022 Yang Xu. All rights reserved. 7 | // 8 | // Follow me on Twitter: @fatbobman 9 | // My Blog: https://www.fatbobman.com 10 | // 微信公共号: 肘子的Swift记事本 11 | // 12 | 13 | import CoreData 14 | import Foundation 15 | 16 | /// Persistent history transaction Cleaner 17 | struct Cleaner: TransactionCleanerProtocol { 18 | init( 19 | backgroundContext: NSManagedObjectContext, 20 | authors: [String] 21 | ) { 22 | self.backgroundContext = backgroundContext 23 | self.authors = authors 24 | } 25 | 26 | let backgroundContext: NSManagedObjectContext 27 | let authors: [String] 28 | 29 | func cleanTransaction(before timestamp: Date?) throws { 30 | guard let timestamp = timestamp else { return } 31 | try backgroundContext.performAndWait { 32 | if let request = getPersistentStoreRequest(before: timestamp, for: authors) { 33 | try backgroundContext.execute(request) 34 | } 35 | } 36 | } 37 | 38 | // make a request for delete transactions before timestamp 39 | func getPersistentStoreRequest(before timestamp: Date, for allAuthors: [String]) -> NSPersistentStoreRequest? { 40 | let historyStoreRequest = NSPersistentHistoryChangeRequest.deleteHistory(before: timestamp) 41 | if let fetchRequest = NSPersistentHistoryTransaction.fetchRequest { 42 | fetchRequest.predicate = createPredicateForAllAuthors(allAuthors: authors) 43 | historyStoreRequest.fetchRequest = fetchRequest 44 | return historyStoreRequest 45 | } 46 | else { 47 | return nil 48 | } 49 | } 50 | 51 | /// create predicate for all authors 52 | func createPredicateForAllAuthors(allAuthors: [String]) -> NSPredicate { 53 | var predicates = [NSPredicate]() 54 | for author in allAuthors { 55 | let predicate = NSPredicate(format: "%K = %@", 56 | #keyPath(NSPersistentHistoryTransaction.author), 57 | author) 58 | predicates.append(predicate) 59 | } 60 | let compoundPredicate = NSCompoundPredicate(orPredicateWithSubpredicates: predicates) 61 | return compoundPredicate 62 | } 63 | } 64 | 65 | /// 可用于外部的Transaction清理器 66 | /// 67 | /// 在 PersistentHistoryTrackKit 中使用 cleanerBuilder() 来生成该实例。该清理器的配置继承于 Kit 实例 68 | /// 69 | /// let kit = PersistentHistoryTrackKit(.....) 70 | /// let cleaner = kit().cleanerBuilder 71 | /// 72 | /// cleaner() //在需要执行清理的地方运行 73 | /// 74 | /// 比如每次app进入后台时,执行清理任务。 75 | public struct PersistentHistoryTrackingKitManualCleaner { 76 | let cleaner: Cleaner 77 | let timestampManager: TransactionTimestampManager 78 | let authors: [String] 79 | let logger: PersistentHistoryTrackingKitLoggerProtocol 80 | public var logLevel: Int 81 | 82 | init(clear: Cleaner, 83 | timestampManager: TransactionTimestampManager, 84 | logger: PersistentHistoryTrackingKitLoggerProtocol, 85 | logLevel: Int, 86 | authors: [String]) { 87 | self.cleaner = clear 88 | self.timestampManager = timestampManager 89 | self.logger = logger 90 | self.authors = authors 91 | self.logLevel = logLevel 92 | } 93 | 94 | public func callAsFunction() { 95 | let cleanTimestamp = timestampManager.getLastCommonTransactionTimestamp(in: authors) 96 | do { 97 | try cleaner.cleanTransaction(before: cleanTimestamp) 98 | sendMessage(type: .info, level: 2, message: "Delete transaction success") 99 | } catch { 100 | sendMessage(type: .error, level: 1, message: "Delete transaction error: \(error.localizedDescription)") 101 | } 102 | } 103 | 104 | /// 发送日志 105 | func sendMessage(type: PersistentHistoryTrackingKitLogType, level: Int, message: String) { 106 | guard level <= logLevel else { return } 107 | logger.log(type: type, message: message) 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /Sources/PersistentHistoryTrackingKit/DefaultLogger.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Logger.swift 3 | // 4 | // 5 | // Created by Yang Xu on 2022/2/10 6 | // Copyright © 2022 Yang Xu. All rights reserved. 7 | // 8 | // Follow me on Twitter: @fatbobman 9 | // My Blog: https://www.fatbobman.com 10 | // 微信公共号: 肘子的Swift记事本 11 | // 12 | 13 | import Foundation 14 | 15 | /// PersistentHistoryTrackKit 日志的默认实现。 16 | /// 如果开发者没有使用自定义的日志实现,则 PersistentHistoryTrackKit 会默认使用本实现 17 | struct DefaultLogger: PersistentHistoryTrackingKitLoggerProtocol { 18 | /// 输出日志 19 | /// - Parameters: 20 | /// - type: 日志类型:info, debug, notice, error, fault 21 | /// - message: 信息内容 22 | func log(type: PersistentHistoryTrackingKitLogType, message: String) { 23 | print("[\(type.rawValue.uppercased())] : \(message)") 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /Sources/PersistentHistoryTrackingKit/Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Extensions.swift 3 | // 4 | // 5 | // Created by Yang Xu on 2022/2/12 6 | // Copyright © 2022 Yang Xu. All rights reserved. 7 | // 8 | // Follow me on Twitter: @fatbobman 9 | // My Blog: https://www.fatbobman.com 10 | // 微信公共号: 肘子的Swift记事本 11 | // 12 | 13 | import CoreData 14 | import Foundation 15 | 16 | extension NSManagedObjectContext { 17 | @discardableResult 18 | func performAndWait(_ block: () throws -> T) throws -> T { 19 | var result: Result? 20 | performAndWait { 21 | result = Result { try block() } 22 | } 23 | return try result!.get() 24 | } 25 | 26 | @discardableResult 27 | func performAndWait(_ block: () -> T) -> T { 28 | var result: T? 29 | performAndWait { 30 | result = block() 31 | } 32 | return result! 33 | } 34 | } 35 | 36 | public extension Task where Success == Never, Failure == Never { 37 | static func sleep(seconds duration: Double) async throws { 38 | try await sleep(nanoseconds: UInt64(duration * 1000000000)) 39 | } 40 | } 41 | 42 | import Combine 43 | /// 将Publisher转换成异步序列。 44 | /// 45 | /// 同系统内置的 publisher.values 不同,本实现将首先对数据进行缓存。尤其适用于NotificationCenter之类的应用。 46 | struct CombineAsyncPublisher

: AsyncSequence, AsyncIteratorProtocol where P: Publisher, P.Failure == Never { 47 | typealias Element = P.Output 48 | typealias AsyncIterator = CombineAsyncPublisher

49 | 50 | func makeAsyncIterator() -> Self { 51 | return self 52 | } 53 | 54 | private let stream: AsyncStream 55 | private var iterator: AsyncStream.Iterator 56 | private var cancellable: AnyCancellable? 57 | 58 | init(_ upstream: P, bufferingPolicy limit: AsyncStream.Continuation.BufferingPolicy = .unbounded) { 59 | var subscription: AnyCancellable? 60 | stream = AsyncStream(P.Output.self, bufferingPolicy: limit) { continuation in 61 | subscription = upstream 62 | .sink(receiveValue: { value in 63 | continuation.yield(value) 64 | }) 65 | } 66 | cancellable = subscription 67 | iterator = stream.makeAsyncIterator() 68 | } 69 | 70 | mutating func next() async -> P.Output? { 71 | await iterator.next() 72 | } 73 | } 74 | 75 | extension Publisher where Self.Failure == Never { 76 | var sequence: CombineAsyncPublisher { 77 | CombineAsyncPublisher(self) 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /Sources/PersistentHistoryTrackingKit/Fetcher.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Fetcher.swift 3 | // 4 | // 5 | // Created by Yang Xu on 2022/2/11 6 | // Copyright © 2022 Yang Xu. All rights reserved. 7 | // 8 | // Follow me on Twitter: @fatbobman 9 | // My Blog: https://www.fatbobman.com 10 | // 微信公共号: 肘子的Swift记事本 11 | // 12 | 13 | import CoreData 14 | import Foundation 15 | 16 | /// 获取从指定时期之后的,非当前author生成的 transaction 17 | struct Fetcher: TransactionFetcherProtocol { 18 | init(backgroundContext: NSManagedObjectContext, 19 | currentAuthor: String, 20 | allAuthors: [String], 21 | includingCloudKitMirroring: Bool = false) { 22 | self.backgroundContext = backgroundContext 23 | self.currentAuthor = currentAuthor 24 | if includingCloudKitMirroring { 25 | self.allAuthors = Array(Set(allAuthors + Self.cloudMirrorAuthors)) 26 | } else { 27 | self.allAuthors = Array(Set(allAuthors)) 28 | } 29 | } 30 | 31 | var backgroundContext: NSManagedObjectContext 32 | var currentAuthor: String 33 | var allAuthors: [String] 34 | 35 | /// 获取所有不是当前 author 产生的 transaction 36 | /// - Parameter date: 从该日期之后产生 37 | /// - Returns:[NSPersistentHistoryTransaction] 38 | func fetchTransactions(from date: Date) throws -> [NSPersistentHistoryTransaction] { 39 | try backgroundContext.performAndWait { 40 | let historyChangeRequest = createHistoryChangeRequest(from: date) 41 | let historyResult = try backgroundContext.execute(historyChangeRequest) as? NSPersistentHistoryResult 42 | return historyResult?.result as? [NSPersistentHistoryTransaction] ?? [] 43 | } 44 | } 45 | 46 | /// 生成 NSPersistentHistoryChangeRequest。 47 | /// 所有不是当前 author 产生的 transaction。 48 | /// - Parameter date: 获取从该日期之后产生的 transaction 49 | /// - Returns: NSPersistentHistoryChangeRequest 50 | func createHistoryChangeRequest(from date: Date) -> NSPersistentHistoryChangeRequest { 51 | let historyChangeRequest = NSPersistentHistoryChangeRequest.fetchHistory(after: date) 52 | if let fetchRequest = NSPersistentHistoryTransaction.fetchRequest { 53 | fetchRequest.predicate = createPredicateForOtherAuthors(currentAuthor: currentAuthor, allAuthors: allAuthors) 54 | historyChangeRequest.fetchRequest = fetchRequest 55 | } 56 | return historyChangeRequest 57 | } 58 | 59 | /// 创建排除当前author的查询谓词 60 | func createPredicateForOtherAuthors(currentAuthor: String, allAuthors: [String]) -> NSPredicate { 61 | var predicates = [NSPredicate]() 62 | for author in allAuthors where author != currentAuthor { 63 | let predicate = NSPredicate(format: "%K = %@", 64 | #keyPath(NSPersistentHistoryTransaction.author), 65 | author) 66 | predicates.append(predicate) 67 | } 68 | let compoundPredicate = NSCompoundPredicate(type: .or, subpredicates: predicates) 69 | return compoundPredicate 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /Sources/PersistentHistoryTrackingKit/Merger.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Merger.swift 3 | // 4 | // 5 | // Created by Yang Xu on 2022/2/12 6 | // Copyright © 2022 Yang Xu. All rights reserved. 7 | // 8 | // Follow me on Twitter: @fatbobman 9 | // My Blog: https://www.fatbobman.com 10 | // 微信公共号: 肘子的Swift记事本 11 | // 12 | 13 | import CoreData 14 | import Foundation 15 | 16 | struct Merger: TransactionMergerProtocol { 17 | func callAsFunction(merge transactions: [NSPersistentHistoryTransaction], 18 | into contexts: [NSManagedObjectContext]) { 19 | for transaction in transactions { 20 | let userInfo = transaction.objectIDNotification().userInfo ?? [:] 21 | NSManagedObjectContext.mergeChanges(fromRemoteContextSave: userInfo, into: contexts) 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /Sources/PersistentHistoryTrackingKit/PersistentHistoryTrackingKit.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PersistentHistoryTrackingKit.swift 3 | // 4 | // 5 | // Created by Yang Xu on 2022/2/11 6 | // Copyright © 2022 Yang Xu. All rights reserved. 7 | // 8 | // Follow me on Twitter: @fatbobman 9 | // My Blog: https://www.fatbobman.com 10 | // 微信公共号: 肘子的Swift记事本 11 | // 12 | 13 | import CoreData 14 | import Foundation 15 | import AsyncAlgorithms 16 | 17 | // swiftlint:disable line_length 18 | 19 | public final class PersistentHistoryTrackingKit { 20 | /// 日志显示等级,从0-2级。0 关闭 2 最详尽 21 | public var logLevel: Int 22 | 23 | /// 清除策略 24 | var strategy: TransactionPurgePolicy 25 | 26 | /// 当前 transaction 的 author 27 | let currentAuthor: String 28 | 29 | /// 全部的 authors (包括app group当中所有使用同一数据库的成员以及用于批量操作的author) 30 | let allAuthors: [String] 31 | 32 | /// 是否合并由 NSPersistentCloudContainer 导入的网络数据 33 | /// 如果你直接在 NSPersistentCloudContainer 上使用 Persistent History Tracking ,可以直接使用默认值 false,此时,NSPersistentCloudContainer 将自动处理合并事宜 34 | /// 此选项通常用于 NSPersistentContainer 之上,将另一个 CloudContainer 导入的数据合并到当前的 container 的 viewContext 中。 35 | let includingCloudKitMirroring: Bool 36 | 37 | /// 用于批量操作的 authors 38 | /// 39 | /// 由于批量操作的 author 只会生成 transaction,并不会对其他 author 产生的 transaction 进行合并和清除。 40 | /// 仅此此类 auhtors 最好可以单独标注出来,这样其他的 authors 在清除时将不会为其保留不必要的 transaction。 41 | /// 即使不单独设置,当遗留的 transaction 满足 maximumDuration 后,仍会被自动清除。 42 | let batchAuthors: [String] 43 | 44 | /// 需要被合并的上下文,通常是视图上下文。可以有多个 45 | let contexts: [NSManagedObjectContext] 46 | 47 | /// transaction 最长可以保存的时间(秒)。如果在该时间内仍无法获取到全部的 author 更新时间戳, 48 | /// 将返回从当前时间减去该秒数的日期 Date().addingTimeInterval(-1 * abs(maximumDuration)) 49 | let maximumDuration: TimeInterval 50 | 51 | /// 在 UserDefaults 中保存时间戳 Key 的前缀。 52 | let uniqueString: String 53 | 54 | /// 日志管理器 55 | let logger: PersistentHistoryTrackingKitLoggerProtocol 56 | 57 | /// 获取需要处理的 transaction 58 | let fetcher: Fetcher 59 | 60 | /// 合并transaction到指定的托管对象上下文中(contexts) 61 | let merger: Merger 62 | 63 | /// 删除transaction中重复数据 64 | let deduplicator: TransactionDeduplicatorProtocol? 65 | 66 | /// transaction清除器,清除可确认的已被所有authors合并的transaction 67 | let cleaner: Cleaner 68 | 69 | /// 时间戳管理器,过去并更新合并事件戳 70 | let timestampManager: TransactionTimestampManager 71 | 72 | /// 处理持久化历史跟踪事件的任务。可以通过start开启,stop停止。 73 | var transactionProcessingTasks = [Task]() 74 | 75 | /// 持久化存储协调器,用于缩小通知返回 76 | private let coordinator: NSPersistentStoreCoordinator 77 | /// 专职处理transaction的托管对象上下文 78 | private let backgroundContext: NSManagedObjectContext 79 | 80 | /// 创建处理 Transaction 的任务。 81 | /// 82 | /// 通过将持久化历史跟踪记录的通知转换成异步序列,实现了逐个处理的机制。 83 | func createTransactionProcessingTask() -> Task { 84 | Task { 85 | sendMessage(type: .info, level: 1, message: "Persistent History Track Kit Start") 86 | // 响应 notification 87 | let publisher = NotificationCenter.default.publisher( 88 | for: .NSPersistentStoreRemoteChange, 89 | object: coordinator 90 | ) 91 | for await _ in publisher.values.buffer(policy: .unbounded) where !Task.isCancelled { 92 | sendMessage(type: .info, 93 | level: 2, 94 | message: "Get a `NSPersistentStoreRemoteChange` notification") 95 | 96 | // fetch 97 | let transactions = fetchTransactions( 98 | for: currentAuthor, 99 | since: timestampManager, 100 | by: fetcher, 101 | logger: sendMessage 102 | ) 103 | 104 | if transactions.isEmpty { continue } 105 | 106 | // merge 107 | mergeTransactionsInContexts( 108 | transactions: transactions, 109 | by: merger, 110 | timestampManager: timestampManager, 111 | logger: sendMessage 112 | ) 113 | 114 | deduplicator?(deduplicate: transactions, in: contexts) 115 | 116 | // clean 117 | cleanTransactions( 118 | beforeDate: timestampManager, 119 | allAuthors: allAuthors, 120 | batchAuthors: batchAuthors, 121 | by: cleaner, 122 | logger: sendMessage 123 | ) 124 | } 125 | sendMessage(type: .info, level: 1, message: "Persistent History Track Kit Stop") 126 | } 127 | } 128 | 129 | /// get all new transactions since the last merge date 130 | func fetchTransactions( 131 | for currentAuthor: String, 132 | since lastTimestampManager: TransactionTimestampManagerProtocol, 133 | by fetcher: TransactionFetcherProtocol, 134 | logger: Logger? 135 | ) -> [NSPersistentHistoryTransaction] { 136 | let lastTimestamp = lastTimestampManager 137 | .getLastHistoryTransactionTimestamp(for: currentAuthor) ?? Date.distantPast 138 | logger?(.info, 2, 139 | "The last history transaction timestamp for \(allAuthors) is \(Self.dateFormatter.string(from: lastTimestamp))") 140 | var transactions = [NSPersistentHistoryTransaction]() 141 | do { 142 | transactions = try fetcher.fetchTransactions(from: lastTimestamp) 143 | let changesCount = transactions 144 | .map { $0.changes?.count ?? 0 } 145 | .reduce(0, +) 146 | let message = "There are \(transactions.count) transactions with \(changesCount) changes related to `\(currentAuthor)` in the query" 147 | logger?(.info, 2, message) 148 | } catch { 149 | logger?(.error, 1, "Fetch transaction error: \(error.localizedDescription)") 150 | } 151 | return transactions 152 | } 153 | 154 | /// merge transactions in contexts 155 | func mergeTransactionsInContexts( 156 | transactions: [NSPersistentHistoryTransaction], 157 | by merger: TransactionMergerProtocol, 158 | timestampManager: TransactionTimestampManagerProtocol, 159 | logger: Logger? 160 | ) { 161 | guard let lastTimestamp = transactions.last?.timestamp else { return } 162 | merger(merge: transactions, into: contexts) 163 | timestampManager.updateLastHistoryTransactionTimestamp(for: currentAuthor, to: lastTimestamp) 164 | let message = "merge \(transactions.count) transactions, update `\(currentAuthor)`'s timestamp to \(Self.dateFormatter.string(from: lastTimestamp))" 165 | logger?(.info, 2, message) 166 | } 167 | 168 | /// clean up all transactions that has been merged by all contexts 169 | func cleanTransactions( 170 | beforeDate timestampManager: TransactionTimestampManagerProtocol, 171 | allAuthors: [String], 172 | batchAuthors: [String], 173 | by cleaner: TransactionCleanerProtocol, 174 | logger: Logger? 175 | ) { 176 | guard strategy.allowedToClean() else { return } 177 | let cleanTimestamp = timestampManager.getLastCommonTransactionTimestamp(in: allAuthors, exclude: batchAuthors) 178 | do { 179 | try cleaner.cleanTransaction(before: cleanTimestamp) 180 | logger?(.info, 2, "Delete transaction success") 181 | } catch { 182 | logger?(.error, 1, "Delete transaction error: \(error.localizedDescription)") 183 | } 184 | } 185 | 186 | typealias Logger = (PersistentHistoryTrackingKitLogType, Int, String) -> Void 187 | 188 | /// 发送日志 189 | func sendMessage(type: PersistentHistoryTrackingKitLogType, level: Int, message: String) { 190 | guard level <= logLevel else { return } 191 | logger.log(type: type, message: message) 192 | } 193 | 194 | init(logLevel: Int, 195 | strategy: TransactionCleanStrategy, 196 | deduplicator: TransactionDeduplicatorProtocol?, 197 | currentAuthor: String, 198 | allAuthors: [String], 199 | includingCloudKitMirroring: Bool, 200 | batchAuthors: [String], 201 | viewContext: NSManagedObjectContext, 202 | contexts: [NSManagedObjectContext], 203 | userDefaults: UserDefaults, 204 | maximumDuration: TimeInterval, 205 | uniqueString: String, 206 | logger: PersistentHistoryTrackingKitLoggerProtocol, 207 | autoStart: Bool) { 208 | self.logLevel = logLevel 209 | self.currentAuthor = currentAuthor 210 | self.allAuthors = allAuthors 211 | self.includingCloudKitMirroring = includingCloudKitMirroring 212 | self.batchAuthors = batchAuthors 213 | self.contexts = contexts 214 | self.maximumDuration = maximumDuration 215 | self.uniqueString = uniqueString 216 | self.logger = logger 217 | 218 | // 检查 viewContext 是否为视图上下文 219 | guard viewContext.concurrencyType == .mainQueueConcurrencyType else { 220 | fatalError("`viewContext` must be a view context ( concurrencyType == .mainQueueConcurrencyType)") 221 | } 222 | 223 | guard let coordinator = viewContext.persistentStoreCoordinator else { 224 | fatalError("`viewContext` must have a persistentStoreCoordinator available") 225 | } 226 | 227 | let backgroundContext = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType) 228 | backgroundContext.persistentStoreCoordinator = coordinator 229 | 230 | switch strategy { 231 | case .none: 232 | self.strategy = TransactionCleanStrategyNone() 233 | case .byDuration: 234 | self.strategy = TransactionCleanStrategyByDuration(strategy: strategy) 235 | case .byNotification: 236 | self.strategy = TransactionCleanStrategyByNotification(strategy: strategy) 237 | } 238 | 239 | self.fetcher = Fetcher( 240 | backgroundContext: backgroundContext, 241 | currentAuthor: currentAuthor, 242 | allAuthors: allAuthors, 243 | includingCloudKitMirroring: includingCloudKitMirroring 244 | ) 245 | 246 | self.merger = Merger() 247 | self.deduplicator = deduplicator 248 | self.cleaner = Cleaner(backgroundContext: backgroundContext, authors: allAuthors) 249 | self.timestampManager = TransactionTimestampManager(userDefaults: userDefaults, maximumDuration: maximumDuration, uniqueString: uniqueString) 250 | self.coordinator = coordinator 251 | self.backgroundContext = backgroundContext 252 | 253 | if autoStart { 254 | start() 255 | } 256 | } 257 | 258 | deinit { 259 | stop() 260 | } 261 | } 262 | 263 | public extension PersistentHistoryTrackingKit { 264 | /// 启动处理任务 265 | func start() { 266 | guard transactionProcessingTasks.isEmpty else { 267 | return 268 | } 269 | transactionProcessingTasks.append(createTransactionProcessingTask()) 270 | } 271 | 272 | /// 停止处理任务 273 | func stop() { 274 | transactionProcessingTasks.forEach { 275 | $0.cancel() 276 | } 277 | transactionProcessingTasks.removeAll() 278 | } 279 | } 280 | 281 | extension PersistentHistoryTrackingKit { 282 | static let dateFormatter: DateFormatter = { 283 | let formatter = DateFormatter() 284 | formatter.dateStyle = .short 285 | formatter.timeStyle = .medium 286 | return formatter 287 | }() 288 | } 289 | 290 | public extension PersistentHistoryTrackingKit { 291 | /// 创建一个可独立运行的 transaction 清除器 292 | /// 293 | /// 通常使用该清除器时,cleanStrategy 应设置为 .none 294 | /// 在 PersistentHistoryTrackKit 中使用 cleanerBuilder() 来生成该实例。该清理器的配置继承于 Kit 实例 295 | /// 296 | /// let kit = PersistentHistoryTrackKit(.....) 297 | /// let cleaner = kit().cleanerBuilder 298 | /// 299 | /// cleaner() //在需要执行清理的地方运行 300 | /// 301 | /// 比如每次app进入后台时,执行清理任务。 302 | func cleanerBuilder() -> PersistentHistoryTrackingKitManualCleaner { 303 | PersistentHistoryTrackingKitManualCleaner( 304 | clear: cleaner, 305 | timestampManager: timestampManager, 306 | logger: logger, 307 | logLevel: logLevel, 308 | authors: allAuthors 309 | ) 310 | } 311 | } 312 | 313 | public extension PersistentHistoryTrackingKit { 314 | /// 使用viewContext的初始化器 315 | convenience init(viewContext: NSManagedObjectContext, 316 | contexts: [NSManagedObjectContext]? = nil, 317 | currentAuthor: String, 318 | allAuthors: [String], 319 | includingCloudKitMirroring: Bool = false, 320 | batchAuthors: [String] = [], 321 | userDefaults: UserDefaults, 322 | cleanStrategy: TransactionCleanStrategy = .byNotification(times: 1), 323 | deduplicator: TransactionDeduplicatorProtocol? = nil, 324 | maximumDuration: TimeInterval = 60 * 60 * 24 * 7, 325 | uniqueString: String = "PersistentHistoryTrackingKit.lastToken.", 326 | logger: PersistentHistoryTrackingKitLoggerProtocol? = nil, 327 | logLevel: Int = 1, 328 | autoStart: Bool = true) { 329 | let contexts = contexts ?? [viewContext] 330 | let logger = logger ?? DefaultLogger() 331 | self.init(logLevel: logLevel, 332 | strategy: cleanStrategy, 333 | deduplicator: deduplicator, 334 | currentAuthor: currentAuthor, 335 | allAuthors: allAuthors, 336 | includingCloudKitMirroring: includingCloudKitMirroring, 337 | batchAuthors: batchAuthors, 338 | viewContext: viewContext, 339 | contexts: contexts, 340 | userDefaults: userDefaults, 341 | maximumDuration: maximumDuration, 342 | uniqueString: uniqueString, 343 | logger: logger, 344 | autoStart: autoStart) 345 | } 346 | 347 | /// 使用NSPersistentContainer的初始化器 348 | convenience init(container: NSPersistentContainer, 349 | contexts: [NSManagedObjectContext]? = nil, 350 | currentAuthor: String, 351 | allAuthors: [String], 352 | includingCloudKitMirroring: Bool = false, 353 | batchAuthors: [String] = [], 354 | userDefaults: UserDefaults, 355 | cleanStrategy: TransactionCleanStrategy = .byNotification(times: 1), 356 | deduplicator: TransactionDeduplicatorProtocol? = nil, 357 | maximumDuration: TimeInterval = 60 * 60 * 24 * 7, 358 | uniqueString: String = "PersistentHistoryTrackingKit.lastToken.", 359 | logger: PersistentHistoryTrackingKitLoggerProtocol? = nil, 360 | logLevel: Int = 1, 361 | autoStart: Bool = true) { 362 | let viewContext = container.viewContext 363 | let contexts = contexts ?? [viewContext] 364 | let logger = logger ?? DefaultLogger() 365 | self.init(logLevel: logLevel, 366 | strategy: cleanStrategy, 367 | deduplicator: deduplicator, 368 | currentAuthor: currentAuthor, 369 | allAuthors: allAuthors, 370 | includingCloudKitMirroring: includingCloudKitMirroring, 371 | batchAuthors: batchAuthors, 372 | viewContext: viewContext, 373 | contexts: contexts, 374 | userDefaults: userDefaults, 375 | maximumDuration: maximumDuration, 376 | uniqueString: uniqueString, 377 | logger: logger, 378 | autoStart: autoStart) 379 | } 380 | } 381 | 382 | extension Notification:@unchecked Sendable {} 383 | -------------------------------------------------------------------------------- /Sources/PersistentHistoryTrackingKit/Protocol/CleanerProtocol.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CleanerProtocol.swift 3 | // 4 | // 5 | // Created by Yang Xu on 2022/2/11 6 | // Copyright © 2022 Yang Xu. All rights reserved. 7 | // 8 | // Follow me on Twitter: @fatbobman 9 | // My Blog: https://www.fatbobman.com 10 | // 微信公共号: 肘子的Swift记事本 11 | // 12 | 13 | import CoreData 14 | import Foundation 15 | 16 | public protocol TransactionCleanerProtocol { 17 | /// 用来提取Request和删除 transaction 的上下文。通常是私有上下文 18 | var backgroundContext: NSManagedObjectContext { get } 19 | /// 清理 transactions 时只处理 transactionAuthor 在该数组中的 transaction 20 | var authors: [String] { get } 21 | /// 清除指定时间之前由 authors 产生的 transaction 22 | func cleanTransaction(before timestamp: Date?) throws 23 | } 24 | -------------------------------------------------------------------------------- /Sources/PersistentHistoryTrackingKit/Protocol/DeduplicatorProtocol.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DeduplicatorProtocol.swift 3 | // 4 | // 5 | // Created by Yang Yubo on 2024/6/18. 6 | // 7 | 8 | import CoreData 9 | import Foundation 10 | 11 | public protocol TransactionDeduplicatorProtocol { 12 | /// 将 transaction 中的重复数据从托管对象上下文删除 13 | func callAsFunction(deduplicate transactions: [NSPersistentHistoryTransaction], in contexts: [NSManagedObjectContext]) 14 | } 15 | -------------------------------------------------------------------------------- /Sources/PersistentHistoryTrackingKit/Protocol/FetcherProtocol.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FetcherProtocol.swift 3 | // 4 | // 5 | // Created by Yang Xu on 2022/2/11 6 | // Copyright © 2022 Yang Xu. All rights reserved. 7 | // 8 | // Follow me on Twitter: @fatbobman 9 | // My Blog: https://www.fatbobman.com 10 | // 微信公共号: 肘子的Swift记事本 11 | // 12 | 13 | import CoreData 14 | import Foundation 15 | 16 | /// 获取从指定时期之后的,非当前author生成的 transaction 17 | protocol TransactionFetcherProtocol { 18 | /// 托管对象上下文。最好使用背景上下文 19 | var backgroundContext: NSManagedObjectContext { get } 20 | /// 当前的 author 名称。应用程序上下文的 transactionAuthor 需要与其一致 21 | var currentAuthor: String { get } 22 | /// 全部的 author 名称。在 app group 的情况下,每个app或app extension都使用各自的 author 名称 23 | /// 在同一个app下,对于批量添加的数据(batch insert),应该使用单独的 author,以区别。 24 | var allAuthors: [String] { get } 25 | 26 | /// 获取指定日期后的所有 Transaction。 27 | func fetchTransactions(from date: Date) throws -> [NSPersistentHistoryTransaction] 28 | } 29 | 30 | extension TransactionFetcherProtocol { 31 | static var cloudMirrorAuthors: [String] { ["NSCloudKitMirroringDelegate.import"] } 32 | } 33 | -------------------------------------------------------------------------------- /Sources/PersistentHistoryTrackingKit/Protocol/LoggerProtocol.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LoggerProtocol.swift 3 | // 4 | // 5 | // Created by Yang Xu on 2022/2/10 6 | // Copyright © 2022 Yang Xu. All rights reserved. 7 | // 8 | // Follow me on Twitter: @fatbobman 9 | // My Blog: https://www.fatbobman.com 10 | // 微信公共号: 肘子的Swift记事本 11 | // 12 | 13 | import Foundation 14 | 15 | /// 用于 PersistentHistoryTrackKit 的日志协议。 16 | /// 开发者可以创建符合该协议的类型,以便让 PersistentHistoryTrackKi t与你已有的日志模块协同工作。 17 | /// 日志输出的开关和细节控制均在 PersistentHistoryTrackKit 上 18 | public protocol PersistentHistoryTrackingKitLoggerProtocol { 19 | /// 输出日志。开发者可以将 LogType 转换成自己使用的日志模块对应的 Type 20 | func log(type: PersistentHistoryTrackingKitLogType, message: String) 21 | } 22 | 23 | /// 日志类型。尽管定义了5中类型,不过当前只会使用其中的 debug 和 error。 24 | public enum PersistentHistoryTrackingKitLogType: String { 25 | case debug, info, notice, error, fault 26 | } 27 | -------------------------------------------------------------------------------- /Sources/PersistentHistoryTrackingKit/Protocol/MergerProtocol.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MergerProtocol.swift 3 | // 4 | // 5 | // Created by Yang Xu on 2022/2/12 6 | // Copyright © 2022 Yang Xu. All rights reserved. 7 | // 8 | // Follow me on Twitter: @fatbobman 9 | // My Blog: https://www.fatbobman.com 10 | // 微信公共号: 肘子的Swift记事本 11 | // 12 | 13 | import CoreData 14 | import Foundation 15 | 16 | protocol TransactionMergerProtocol { 17 | /// 将 transaction 合并到指定的托管对象上下文。可以多个上下文,之间用 ,分隔 18 | func callAsFunction(merge transactions: [NSPersistentHistoryTransaction], into contexts: [NSManagedObjectContext]) 19 | } 20 | -------------------------------------------------------------------------------- /Sources/PersistentHistoryTrackingKit/Protocol/TransactionTimestampManagerProtocol.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TransactionTimestampManagerProtocol.swift 3 | // 4 | // 5 | // Created by Yang Xu on 2022/2/10 6 | // Copyright © 2022 Yang Xu. All rights reserved. 7 | // 8 | // Follow me on Twitter: @fatbobman 9 | // My Blog: https://www.fatbobman.com 10 | // 微信公共号: 肘子的Swift记事本 11 | // 12 | 13 | import Foundation 14 | 15 | /// 保存和获取时间戳的管理协议。 16 | public protocol TransactionTimestampManagerProtocol { 17 | /// 从给定的 author 列表中,获取可以安全删除的时间戳。 18 | /// 19 | /// 如果给定了 exclude ,将仅对 authors - batchAuthors 的 author 进行时间判断 20 | /// Cleaner 将依据该时间戳 ,指示 Core Data 删除该时间戳之前的 Transaction。 21 | /// - Returns: 可以安全删除的日期。 22 | /// 当返回值为 nil 时,意味需要更新时间戳的 author 还没有全部更新 23 | func getLastCommonTransactionTimestamp(in authors: [String], exclude batchAuthors: [String]) -> Date? 24 | /// 更新指定 author 的最后更新日期 25 | /// 最后更新是指,该 author 对应的程序(app,app extension)已经在改时间戳完成了 Transaction 的合并工作 26 | func updateLastHistoryTransactionTimestamp(for author: String, to newDate: Date?) 27 | /// 获取指定的 author 的最后更新日期 28 | /// - Parameter author: author 是每个 app 或 app extension 的字符串名称。该名称应与NSManagedObjectContext的transactionAuthor一致 29 | /// - Returns: 该 author 的最后更新日期。如果该 author 尚未更新日期,则返回 nil 30 | func getLastHistoryTransactionTimestamp(for author: String) -> Date? 31 | } 32 | -------------------------------------------------------------------------------- /Sources/PersistentHistoryTrackingKit/TransactionTimestampManager.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TransactionTimestampManager.swift 3 | // 4 | // 5 | // Created by Yang Xu on 2022/2/10 6 | // Copyright © 2022 Yang Xu. All rights reserved. 7 | // 8 | // Follow me on Twitter: @fatbobman 9 | // My Blog: https://www.fatbobman.com 10 | // 微信公共号: 肘子的Swift记事本 11 | // 12 | 13 | import Foundation 14 | 15 | /// author 的 Transaction 合并更新的时间戳管理器。 16 | /// 本实现采用 UserDefaults 对每个 author 的最后更新日期进行保存,并从中返回可被安全删除的日期。 17 | /// 为了防止在 AppGroup 的情况下,部分 app 始终没有被启用或实现,从而导致数据不全的情况。 18 | /// 本实现设定了阈值日期机制,在满足了设定的情况下,将阈值日期作为可安全删除的日期返回 19 | struct TransactionTimestampManager: TransactionTimestampManagerProtocol { 20 | /// 用于保存的 UserDefaults 实例。对于 AppGroup,应该使用可用于全体成员的实例。如:UserDefaults(suiteName: Settings.AppGroup.groupID) 21 | private let userDefaults: UserDefaults 22 | /// transaction 最长可以保存的时间(秒)。如果在改时间内仍无法获取到全部的 author 更新时间戳, 23 | /// 将返回从当前时间剪去该秒数的日期 Date().addingTimeInterval(-1 * abs(maximumDuration)) 24 | private let maximumDuration: TimeInterval 25 | /// 在 UserDefaults 中保存时间戳 Key 的前缀。 26 | private let uniqueString: String 27 | 28 | func getLastCommonTransactionTimestamp(in authors: [String], exclude batchAuthors: [String] = []) -> Date? { 29 | let shouldCheckAuthors = Set(authors).subtracting(batchAuthors) 30 | let lastTimestamps = shouldCheckAuthors 31 | .compactMap { author in 32 | getLastHistoryTransactionTimestamp(for: author) 33 | } 34 | // 没有任何author记录时间的情况下,直接返回nil 35 | let lastTimestamp = lastTimestamps.min() ?? Date().addingTimeInterval(-1 * abs(maximumDuration)) 36 | return lastTimestamp 37 | } 38 | 39 | func updateLastHistoryTransactionTimestamp(for author: String, to newDate: Date?) { 40 | let key = uniqueString + author 41 | userDefaults.set(newDate, forKey: key) 42 | } 43 | 44 | /// 获取指定的 author 的最后更新日期 45 | /// - Parameter author: author 是每个 app 或 app extension 的字符串名称。该名称应与NSManagedObjectContext的transactionAuthor一致 46 | /// - Returns: 该 author 的最后更新日期。如果该 author 尚未更新日期,则返回 nil 47 | func getLastHistoryTransactionTimestamp(for author: String) -> Date? { 48 | let key = uniqueString + author 49 | return userDefaults.value(forKey: key) as? Date 50 | } 51 | 52 | /// 创建 author 的 Transaction 合并更新的时间戳管理器。 53 | /// - Parameters: 54 | /// - userDefaults: 用于保存的 UserDefaults 实例。 55 | /// 对于 AppGroup,应该使用可用于全体成员的实例。如:UserDefaults(suiteName: Settings.AppGroup.groupID) 56 | /// - maximumDuration: transaction 最长可以保存的时间(秒)。如果在改时间内仍无法获取到全部的 author 更新时间戳, 57 | /// 将返回从当前时间剪去该秒数的日期 Date().addingTimeInterval(-1 * abs(maximumDuration))。默认值为 604,800 秒(7日)。 58 | /// - uniqueString: 在 UserDefaults 中保存时间戳 Key 的前缀。默认值为:"PersistentHistoryTrackingKit.lastToken." 59 | init(userDefaults: UserDefaults, 60 | maximumDuration: TimeInterval = 60 * 60 * 24 * 7, // 7 days 61 | uniqueString: String = "PersistentHistoryTrackingKit.lastToken.") { 62 | self.userDefaults = userDefaults 63 | self.maximumDuration = maximumDuration 64 | self.uniqueString = uniqueString 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /Tests/PersistentHistoryTrackingKitTests/CleanStrategyTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CleanStrategyTests.swift 3 | // 4 | // 5 | // Created by Yang Xu on 2022/2/14 6 | // Copyright © 2022 Yang Xu. All rights reserved. 7 | // 8 | // Follow me on Twitter: @fatbobman 9 | // My Blog: https://www.fatbobman.com 10 | // 微信公共号: 肘子的Swift记事本 11 | // 12 | 13 | @testable import PersistentHistoryTrackingKit 14 | import XCTest 15 | 16 | class CleanStrategyTests: XCTestCase { 17 | func testNoneStrategy() { 18 | let strategy = TransactionCleanStrategyNone() 19 | XCTAssertFalse(strategy.allowedToClean()) 20 | } 21 | 22 | func testByDuration() async throws { 23 | var strategy = TransactionCleanStrategyByDuration(strategy: .byDuration(seconds: 3)) 24 | XCTAssertTrue(strategy.allowedToClean()) 25 | await sleep(seconds: 2) 26 | XCTAssertFalse(strategy.allowedToClean()) 27 | await sleep(seconds: 1.1) 28 | XCTAssertTrue(strategy.allowedToClean()) 29 | XCTAssertFalse(strategy.allowedToClean()) 30 | XCTAssertFalse(strategy.allowedToClean()) 31 | await sleep(seconds: 3) 32 | XCTAssertTrue(strategy.allowedToClean()) 33 | } 34 | 35 | func testByNotification() async throws { 36 | var strategyBy3 = TransactionCleanStrategyByNotification(strategy: .byNotification(times: 3)) 37 | XCTAssertTrue(strategyBy3.allowedToClean()) 38 | 39 | // 每三次执行一次 40 | XCTAssertFalse(strategyBy3.allowedToClean()) 41 | XCTAssertFalse(strategyBy3.allowedToClean()) 42 | XCTAssertTrue(strategyBy3.allowedToClean()) 43 | 44 | XCTAssertFalse(strategyBy3.allowedToClean()) 45 | XCTAssertFalse(strategyBy3.allowedToClean()) 46 | XCTAssertTrue(strategyBy3.allowedToClean()) 47 | 48 | // 每次都执行 49 | var strategyBy1 = TransactionCleanStrategyByNotification(strategy: .byNotification(times: 1)) 50 | XCTAssertTrue(strategyBy1.allowedToClean()) 51 | XCTAssertTrue(strategyBy1.allowedToClean()) 52 | XCTAssertTrue(strategyBy1.allowedToClean()) 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /Tests/PersistentHistoryTrackingKitTests/CleanerTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CleanerTests.swift 3 | // 4 | // 5 | // Created by Yang Xu on 2022/2/12 6 | // Copyright © 2022 Yang Xu. All rights reserved. 7 | // 8 | // Follow me on Twitter: @fatbobman 9 | // My Blog: https://www.fatbobman.com 10 | // 微信公共号: 肘子的Swift记事本 11 | // 12 | 13 | import CoreData 14 | @testable import PersistentHistoryTrackingKit 15 | import XCTest 16 | 17 | class CleanerTests: XCTestCase { 18 | let storeURL = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask) 19 | .first? 20 | .appendingPathComponent("PersistentHistoryTrackKitCleanTest.sqlite") ?? URL(fileURLWithPath: "") 21 | 22 | override func tearDown() async throws { 23 | await sleep(seconds: 2) 24 | try? FileManager.default.removeItem(at: storeURL) 25 | try? FileManager.default.removeItem(at: storeURL.deletingPathExtension().appendingPathExtension("sqlite-wal")) 26 | try? FileManager.default.removeItem(at: storeURL.deletingPathExtension().appendingPathExtension("sqlite-shm")) 27 | } 28 | 29 | func testCleanerInAppGroup() throws { 30 | // given 31 | let container1 = CoreDataHelper.createNSPersistentContainer(storeURL: storeURL) 32 | let container2 = CoreDataHelper.createNSPersistentContainer(storeURL: storeURL) 33 | let app1backgroundContext = container1.newBackgroundContext() 34 | let fetcher = Fetcher(backgroundContext: app1backgroundContext, 35 | currentAuthor: AppActor.app1.rawValue, 36 | allAuthors: [AppActor.app1.rawValue, AppActor.app2.rawValue]) 37 | let cleaner = Cleaner( 38 | backgroundContext: app1backgroundContext, 39 | authors: [AppActor.app1.rawValue, AppActor.app2.rawValue] 40 | ) 41 | 42 | let app1viewContext = container1.viewContext 43 | app1viewContext.transactionAuthor = AppActor.app1.rawValue 44 | 45 | let app2viewContext = container2.viewContext 46 | app2viewContext.transactionAuthor = AppActor.app2.rawValue 47 | 48 | // when 49 | app1viewContext.performAndWait { 50 | let event = Event(context: app1viewContext) 51 | event.timestamp = Date() 52 | app1viewContext.saveIfChanged() 53 | } 54 | 55 | app2viewContext.performAndWait { 56 | let event = Event(context: app2viewContext) 57 | event.timestamp = Date() 58 | app2viewContext.saveIfChanged() 59 | } 60 | 61 | // then 62 | let transactionsBeforeClean = try fetcher.fetchTransactions(from: Date().addingTimeInterval(-2)) 63 | XCTAssertEqual(transactionsBeforeClean.count, 1) 64 | 65 | try cleaner.cleanTransaction(before: Date()) 66 | 67 | let transactionsAfterClean = try fetcher.fetchTransactions(from: Date().addingTimeInterval(-2)) 68 | XCTAssertEqual(transactionsAfterClean.count, 0) 69 | } 70 | 71 | func testCleanerInBatchOperation() throws { 72 | guard #available(macOS 11.0, iOS 14.0, watchOS 7.0, tvOS 14.0, *) else { 73 | return 74 | } 75 | // given 76 | let container = CoreDataHelper.createNSPersistentContainer(storeURL: storeURL) 77 | let viewContext = container.viewContext 78 | let batchContext = container.newBackgroundContext() 79 | let backgroundContext = container.newBackgroundContext() 80 | 81 | viewContext.transactionAuthor = AppActor.app1.rawValue 82 | batchContext.transactionAuthor = AppActor.app2.rawValue // 批量添加使用单独的author 83 | 84 | let fetcher = Fetcher(backgroundContext: backgroundContext, 85 | currentAuthor: AppActor.app1.rawValue, 86 | allAuthors: [AppActor.app1.rawValue, AppActor.app2.rawValue]) 87 | 88 | let cleaner = Cleaner(backgroundContext: backgroundContext, 89 | authors: [AppActor.app1.rawValue, AppActor.app2.rawValue]) 90 | 91 | // when insert by batch 92 | viewContext.performAndWait { 93 | let event = Event(context: viewContext) 94 | event.timestamp = Date() 95 | viewContext.saveIfChanged() 96 | } 97 | 98 | try batchContext.performAndWait { 99 | var count = 0 100 | 101 | let batchInsert = NSBatchInsertRequest(entity: Event.entity()) { (dictionary: NSMutableDictionary) in 102 | dictionary["timestamp"] = Date() 103 | count += 1 104 | return count == 10 105 | } 106 | try batchContext.execute(batchInsert) 107 | } 108 | 109 | // then 110 | let transactionsBeforeClean = try fetcher.fetchTransactions(from: Date().addingTimeInterval(-2)) 111 | XCTAssertEqual(transactionsBeforeClean.count, 1) 112 | XCTAssertEqual(transactionsBeforeClean.first?.changes?.count, 9) 113 | 114 | try cleaner.cleanTransaction(before: Date()) 115 | 116 | let transactionsAfterClean = try fetcher.fetchTransactions(from: Date().addingTimeInterval(-2)) 117 | XCTAssertEqual(transactionsAfterClean.count, 0) 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /Tests/PersistentHistoryTrackingKitTests/FetcherTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FetcherTests.swift 3 | // 4 | // 5 | // Created by Yang Xu on 2022/2/11 6 | // Copyright © 2022 Yang Xu. All rights reserved. 7 | // 8 | // Follow me on Twitter: @fatbobman 9 | // My Blog: https://www.fatbobman.com 10 | // 微信公共号: 肘子的Swift记事本 11 | // 12 | 13 | import CoreData 14 | @testable import PersistentHistoryTrackingKit 15 | import XCTest 16 | 17 | class FetcherTest: XCTestCase { 18 | let storeURL = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask) 19 | .first? 20 | .appendingPathComponent("PersistentHistoryKitFetcherTest.sqlite") ?? URL(fileURLWithPath: "") 21 | 22 | override func tearDown() async throws { 23 | try? FileManager.default.removeItem(at: storeURL) 24 | try? FileManager.default.removeItem(at: storeURL.deletingPathExtension().appendingPathExtension("sqlite-wal")) 25 | try? FileManager.default.removeItem(at: storeURL.deletingPathExtension().appendingPathExtension("sqlite-shm")) 26 | } 27 | 28 | func testFetcherAuthorsIncludingCloudKit() async throws { 29 | // given 30 | let container1 = CoreDataHelper.createNSPersistentContainer(storeURL: storeURL) 31 | let app1backgroundContext = container1.newBackgroundContext() 32 | 33 | // when 34 | let fetcher1 = Fetcher( 35 | backgroundContext: app1backgroundContext, 36 | currentAuthor: AppActor.app1.rawValue, 37 | allAuthors: [AppActor.app1.rawValue, AppActor.app2.rawValue] 38 | ) 39 | let fetcher2 = Fetcher( 40 | backgroundContext: 41 | app1backgroundContext, currentAuthor: AppActor.app1.rawValue, 42 | allAuthors: [AppActor.app1.rawValue, AppActor.app2.rawValue], 43 | includingCloudKitMirroring: true 44 | ) 45 | 46 | // then 47 | XCTAssertEqual(fetcher1.allAuthors.count, 2) 48 | XCTAssertEqual(fetcher2.allAuthors.count, 3) 49 | XCTAssertTrue(fetcher2.allAuthors.contains("NSCloudKitMirroringDelegate.import")) 50 | } 51 | 52 | /// 使用两个协调器,模拟在app group的情况下,从不同的app或app extension中操作数据库。 53 | func testFetcherInAppGroup() async throws { 54 | // given 55 | let container1 = CoreDataHelper.createNSPersistentContainer(storeURL: storeURL) 56 | let container2 = CoreDataHelper.createNSPersistentContainer(storeURL: storeURL) 57 | let app1backgroundContext = container1.newBackgroundContext() 58 | let fetcher = Fetcher(backgroundContext: app1backgroundContext, 59 | currentAuthor: AppActor.app1.rawValue, 60 | allAuthors: [AppActor.app1.rawValue, AppActor.app2.rawValue]) 61 | 62 | let app1viewContext = container1.viewContext 63 | app1viewContext.transactionAuthor = AppActor.app1.rawValue 64 | 65 | let app2viewContext = container2.viewContext 66 | app2viewContext.transactionAuthor = AppActor.app2.rawValue 67 | 68 | // when 69 | app1viewContext.performAndWait { 70 | let event = Event(context: app1viewContext) 71 | event.timestamp = Date() 72 | app1viewContext.saveIfChanged() 73 | } 74 | 75 | app2viewContext.performAndWait { 76 | let event = Event(context: app2viewContext) 77 | event.timestamp = Date() 78 | app2viewContext.saveIfChanged() 79 | } 80 | 81 | // then 82 | let transactions = try fetcher.fetchTransactions(from: Date().addingTimeInterval(-2)) 83 | XCTAssertEqual(transactions.count, 1) 84 | 85 | let request = NSFetchRequest(entityName: "Event") 86 | let eventCounts = try app1viewContext.count(for: request) 87 | XCTAssertEqual(eventCounts, 2) 88 | } 89 | 90 | func testFetcherInBatchOperation() async throws { 91 | guard #available(macOS 11.0, iOS 14.0, watchOS 7.0, tvOS 14.0, *) else { 92 | return 93 | } 94 | // given 95 | let container = CoreDataHelper.createNSPersistentContainer(storeURL: storeURL) 96 | let viewContext = container.viewContext 97 | let batchContext = container.newBackgroundContext() 98 | let backgroundContext = container.newBackgroundContext() 99 | 100 | viewContext.transactionAuthor = AppActor.app1.rawValue 101 | batchContext.transactionAuthor = AppActor.app2.rawValue // 批量添加使用单独的author 102 | 103 | let fetcher = Fetcher(backgroundContext: backgroundContext, 104 | currentAuthor: AppActor.app1.rawValue, 105 | allAuthors: [AppActor.app1.rawValue, AppActor.app2.rawValue]) 106 | 107 | // when insert by batch 108 | viewContext.performAndWait { 109 | let event = Event(context: viewContext) 110 | event.timestamp = Date() 111 | viewContext.saveIfChanged() 112 | } 113 | 114 | try batchContext.performAndWait { 115 | var count = 0 116 | 117 | let batchInsert = NSBatchInsertRequest(entity: Event.entity()) { (dictionary: NSMutableDictionary) in 118 | dictionary["timestamp"] = Date() 119 | count += 1 120 | return count == 10 121 | } 122 | try batchContext.execute(batchInsert) 123 | } 124 | 125 | // then 126 | let transactions = try fetcher.fetchTransactions(from: Date().addingTimeInterval(-2)) 127 | XCTAssertEqual(transactions.count, 1) 128 | XCTAssertEqual(transactions.first?.changes?.count, 9) 129 | 130 | let request = NSFetchRequest(entityName: "Event") 131 | let eventCounts = try viewContext.count(for: request) 132 | XCTAssertEqual(eventCounts, 10) 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /Tests/PersistentHistoryTrackingKitTests/LoggerTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LoggerTests.swift 3 | // 4 | // 5 | // Created by Yang Xu on 2022/2/10 6 | // Copyright © 2022 Yang Xu. All rights reserved. 7 | // 8 | // Follow me on Twitter: @fatbobman 9 | // My Blog: https://www.fatbobman.com 10 | // 微信公共号: 肘子的Swift记事本 11 | // 12 | 13 | @testable import PersistentHistoryTrackingKit 14 | import XCTest 15 | 16 | class LoggerTests: XCTestCase { 17 | func testLogger() throws { 18 | // given 19 | let logger = LoggerSpy() 20 | // when 21 | logger.log(type: .info, message: "hello") 22 | // then 23 | XCTAssertEqual(LoggerSpy.message, "hello") 24 | XCTAssertEqual(LoggerSpy.type, .info) 25 | } 26 | } 27 | 28 | struct LoggerSpy: PersistentHistoryTrackingKitLoggerProtocol { 29 | static var type:PersistentHistoryTrackingKitLogType? 30 | static var message:String? 31 | func log(type: PersistentHistoryTrackingKitLogType, message: String) { 32 | Self.type = type 33 | Self.message = message 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /Tests/PersistentHistoryTrackingKitTests/MergerTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MergerTests.swift 3 | // 4 | // 5 | // Created by Yang Xu on 2022/2/12 6 | // Copyright © 2022 Yang Xu. All rights reserved. 7 | // 8 | // Follow me on Twitter: @fatbobman 9 | // My Blog: https://www.fatbobman.com 10 | // 微信公共号: 肘子的Swift记事本 11 | // 12 | 13 | import CoreData 14 | @testable import PersistentHistoryTrackingKit 15 | import XCTest 16 | 17 | class MergerTests: XCTestCase { 18 | let storeURL = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask) 19 | .first? 20 | .appendingPathComponent("PersistentHistoryTrackKitMergeTest.sqlite") ?? URL(fileURLWithPath: "") 21 | 22 | override func tearDown() async throws { 23 | await sleep(seconds: 2) 24 | try? FileManager.default.removeItem(at: storeURL) 25 | try? FileManager.default.removeItem(at: storeURL.deletingPathExtension().appendingPathExtension("sqlite-wal")) 26 | try? FileManager.default.removeItem(at: storeURL.deletingPathExtension().appendingPathExtension("sqlite-shm")) 27 | } 28 | 29 | func testMergerInAppGroup() throws { 30 | // given 31 | let container1 = CoreDataHelper.createNSPersistentContainer(storeURL: storeURL) 32 | let container2 = CoreDataHelper.createNSPersistentContainer(storeURL: storeURL) 33 | let app1backgroundContext = container1.newBackgroundContext() 34 | let fetcher = Fetcher(backgroundContext: app1backgroundContext, 35 | currentAuthor: AppActor.app1.rawValue, 36 | allAuthors: [AppActor.app1.rawValue, AppActor.app2.rawValue]) 37 | let merger = Merger() 38 | 39 | let app1viewContext = container1.viewContext 40 | app1viewContext.transactionAuthor = AppActor.app1.rawValue 41 | 42 | let app2viewContext = container2.viewContext 43 | app2viewContext.transactionAuthor = AppActor.app2.rawValue 44 | 45 | app2viewContext.performAndWait { 46 | let event = Event(context: app2viewContext) 47 | event.timestamp = Date() 48 | app2viewContext.saveIfChanged() 49 | } 50 | let transactions = try fetcher.fetchTransactions(from: Date().addingTimeInterval(-2)) 51 | 52 | let userInfo = transactions.first?.objectIDNotification().userInfo ?? [:] 53 | guard let objectIDs = userInfo["inserted_objectIDs"] as? NSSet, 54 | let objectID = objectIDs.allObjects.first as? NSManagedObjectID 55 | else { 56 | fatalError() 57 | } 58 | app1viewContext.retainsRegisteredObjects = true // 为检查保持托管对象不清除 59 | app1backgroundContext.retainsRegisteredObjects = true 60 | 61 | // when 62 | merger(merge: transactions, into: [app1viewContext, app1backgroundContext]) 63 | 64 | // then 65 | app1viewContext.performAndWait { 66 | XCTAssertNotNil(app1viewContext.registeredObject(for: objectID)) 67 | } 68 | app1backgroundContext.performAndWait { 69 | XCTAssertNotNil(app1backgroundContext.registeredObject(for: objectID)) 70 | } 71 | } 72 | 73 | func testMergerInBatchOperation() async throws { 74 | guard #available(macOS 11.0, iOS 14.0, watchOS 7.0, tvOS 14.0, *) else { 75 | return 76 | } 77 | // given 78 | let container = CoreDataHelper.createNSPersistentContainer(storeURL: storeURL) 79 | let viewContext = container.viewContext 80 | let batchContext = container.newBackgroundContext() 81 | let backgroundContext = container.newBackgroundContext() 82 | 83 | viewContext.transactionAuthor = AppActor.app1.rawValue 84 | batchContext.transactionAuthor = AppActor.app2.rawValue // 批量添加使用单独的author 85 | 86 | let fetcher = Fetcher(backgroundContext: backgroundContext, 87 | currentAuthor: AppActor.app1.rawValue, 88 | allAuthors: [AppActor.app1.rawValue, AppActor.app2.rawValue]) 89 | 90 | let merger = Merger() 91 | // insert by batch 92 | try batchContext.performAndWait { 93 | var count = 0 94 | 95 | let batchInsert = NSBatchInsertRequest(entity: Event.entity()) { (dictionary: NSMutableDictionary) in 96 | dictionary["timestamp"] = Date() 97 | count += 1 98 | return count == 10 99 | } 100 | try batchContext.execute(batchInsert) 101 | } 102 | 103 | let transactions = try fetcher.fetchTransactions(from: Date().addingTimeInterval(-2)) 104 | 105 | let userInfo = transactions.first?.objectIDNotification().userInfo ?? [:] 106 | guard let objectIDs = userInfo["inserted_objectIDs"] as? NSSet, 107 | let objectID = objectIDs.allObjects.first as? NSManagedObjectID 108 | else { 109 | fatalError() 110 | } 111 | viewContext.retainsRegisteredObjects = true 112 | viewContext.performAndWait { 113 | XCTAssertNil(viewContext.registeredObject(for: objectID)) 114 | } 115 | // when 116 | merger(merge: transactions, into: [viewContext]) 117 | 118 | // then 119 | viewContext.performAndWait { 120 | XCTAssertNotNil(viewContext.registeredObject(for: objectID)) 121 | } 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /Tests/PersistentHistoryTrackingKitTests/PersistentHistoryTrackingKitTests.swift: -------------------------------------------------------------------------------- 1 | import CoreData 2 | import PersistentHistoryTrackingKit 3 | import XCTest 4 | 5 | @MainActor 6 | final class PersistentHistoryTrackingKitTests: XCTestCase { 7 | let storeURL = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask) 8 | .first? 9 | .appendingPathComponent("PersistentHistoryKitTestDB.sqlite") ?? URL(fileURLWithPath: "") 10 | let uniqueString = "PersistentHistoryTrackingKit.lastToken.tests." 11 | let userDefaults = UserDefaults.standard 12 | 13 | override func setUpWithError() throws { 14 | // 清除 UserDefaults 环境 15 | for author in AppActor.allCases { 16 | userDefaults.removeObject(forKey: uniqueString + author.rawValue) 17 | } 18 | } 19 | 20 | override func tearDown() async throws { 21 | await sleep(seconds: 3) 22 | try? FileManager.default.removeItem(at: storeURL) 23 | try? FileManager.default.removeItem(at: storeURL.deletingPathExtension().appendingPathExtension("sqlite-wal")) 24 | try? FileManager.default.removeItem(at: storeURL.deletingPathExtension().appendingPathExtension("sqlite-shm")) 25 | } 26 | 27 | func testPersistentHistoryKitInAppGroup() async throws { 28 | // given 29 | let container1 = CoreDataHelper.createNSPersistentContainer(storeURL: storeURL) 30 | let container2 = CoreDataHelper.createNSPersistentContainer(storeURL: storeURL) 31 | container1.viewContext.transactionAuthor = AppActor.app1.rawValue 32 | container2.viewContext.transactionAuthor = AppActor.app2.rawValue 33 | let authors = [AppActor.app1.rawValue, AppActor.app2.rawValue] 34 | let kit = PersistentHistoryTrackingKit( 35 | container: container1, 36 | currentAuthor: AppActor.app1.rawValue, 37 | allAuthors: authors, 38 | userDefaults: userDefaults, 39 | cleanStrategy: .byNotification(times: 1), 40 | uniqueString: uniqueString, 41 | logLevel: 3, 42 | autoStart: false 43 | ) 44 | 45 | kit.start() 46 | 47 | let viewContext1 = container1.viewContext 48 | let viewContext2 = container2.viewContext 49 | viewContext1.retainsRegisteredObjects = true 50 | // when 51 | 52 | let objectID: NSManagedObjectID = viewContext2.performAndWait { 53 | let event = Event(context: viewContext2) 54 | event.timestamp = Date() 55 | viewContext2.saveIfChanged() 56 | return event.objectID 57 | } 58 | 59 | // then 60 | await sleep(seconds: 2) 61 | 62 | await viewContext1.perform { 63 | XCTAssertNotNil(viewContext1.registeredObject(for: objectID)) 64 | } 65 | let lastTimestamp = userDefaults.value(forKey: uniqueString + AppActor.app1.rawValue) as? Date 66 | XCTAssertNotNil(lastTimestamp) 67 | 68 | kit.stop() 69 | await sleep(seconds: 2) 70 | } 71 | 72 | // swiftlint:disable:next function_body_length 73 | func testKitInBatchInsert() async throws { 74 | guard #available(macOS 11.0, iOS 14.0, watchOS 7.0, tvOS 14.0, *) else { 75 | return 76 | } 77 | // given 78 | let container1 = CoreDataHelper.createNSPersistentContainer(storeURL: storeURL) 79 | let viewContext = container1.viewContext 80 | viewContext.transactionAuthor = AppActor.app1.rawValue 81 | viewContext.retainsRegisteredObjects = true 82 | let batchContext = container1.newBackgroundContext() 83 | batchContext.transactionAuthor = AppActor.app2.rawValue 84 | let authors = [AppActor.app1.rawValue, AppActor.app2.rawValue] 85 | let anotherContext = container1.newBackgroundContext() 86 | anotherContext.retainsRegisteredObjects = true 87 | let kit = PersistentHistoryTrackingKit( 88 | viewContext: container1.viewContext, 89 | contexts: [viewContext, anotherContext], // test merge to multi context 90 | currentAuthor: AppActor.app1.rawValue, 91 | allAuthors: authors, 92 | batchAuthors: [AppActor.app2.rawValue], 93 | userDefaults: userDefaults, 94 | cleanStrategy: .byNotification(times: 1), 95 | uniqueString: uniqueString, 96 | logLevel: 3 97 | ) 98 | try batchContext.performAndWait { 99 | var count = 0 100 | 101 | let batchInsert = NSBatchInsertRequest(entity: Event.entity()) { (dictionary: NSMutableDictionary) in 102 | dictionary["timestamp"] = Date() 103 | count += 1 104 | return count == 10 105 | } 106 | try batchContext.execute(batchInsert) 107 | } 108 | 109 | // when 110 | let objectID: NSManagedObjectID = batchContext.performAndWait { 111 | let request = NSFetchRequest(entityName: "Event") 112 | request.sortDescriptors = [NSSortDescriptor(keyPath: \Event.timestamp, ascending: false)] 113 | guard let results = try? batchContext.fetch(request), 114 | let object = results.first else { fatalError() } 115 | return object.objectID 116 | } 117 | await sleep(seconds: 2) 118 | // then 119 | viewContext.performAndWait { 120 | XCTAssertNotNil(viewContext.registeredObject(for: objectID)) 121 | } 122 | anotherContext.performAndWait { 123 | XCTAssertNotNil(anotherContext.registeredObject(for: objectID)) 124 | } 125 | kit.stop() 126 | await sleep(seconds: 2) 127 | } 128 | 129 | func testManualCleaner() async throws { 130 | // given 131 | let container1 = CoreDataHelper.createNSPersistentContainer(storeURL: storeURL) 132 | let container2 = CoreDataHelper.createNSPersistentContainer(storeURL: storeURL) 133 | container1.viewContext.transactionAuthor = AppActor.app1.rawValue 134 | container2.viewContext.transactionAuthor = AppActor.app2.rawValue 135 | let authors = [AppActor.app1.rawValue, AppActor.app2.rawValue] 136 | let kit = PersistentHistoryTrackingKit( 137 | container: container1, 138 | currentAuthor: AppActor.app1.rawValue, 139 | allAuthors: authors, 140 | userDefaults: userDefaults, 141 | cleanStrategy: .none, 142 | uniqueString: uniqueString, 143 | logLevel: 2, 144 | autoStart: false 145 | ) 146 | 147 | let cleaner = kit.cleanerBuilder() 148 | 149 | kit.start() 150 | 151 | let viewContext1 = container1.viewContext 152 | let viewContext2 = container2.viewContext 153 | viewContext1.retainsRegisteredObjects = true 154 | 155 | // when 156 | 157 | let objectID: NSManagedObjectID = viewContext2.performAndWait { 158 | let event = Event(context: viewContext2) 159 | event.timestamp = Date() 160 | viewContext2.saveIfChanged() 161 | return event.objectID 162 | } 163 | 164 | // then 165 | await sleep(seconds: 2) 166 | 167 | cleaner() // 手动清除 168 | 169 | viewContext1.performAndWait { 170 | XCTAssertNotNil(viewContext1.registeredObject(for: objectID)) 171 | } 172 | let lastTimestamp = userDefaults.value(forKey: uniqueString + AppActor.app1.rawValue) as? Date 173 | XCTAssertNotNil(lastTimestamp) 174 | 175 | kit.stop() 176 | await sleep(seconds: 2) 177 | } 178 | 179 | /// 测试两个app都执行了Kit后,transaction 是否有被清除 180 | func testTwoAppWithKit() async throws { 181 | // given 182 | let container1 = CoreDataHelper.createNSPersistentContainer(storeURL: storeURL) 183 | let container2 = CoreDataHelper.createNSPersistentContainer(storeURL: storeURL) 184 | let viewContext1 = container1.viewContext 185 | viewContext1.transactionAuthor = AppActor.app1.rawValue 186 | let viewContext2 = container2.viewContext 187 | viewContext2.transactionAuthor = AppActor.app2.rawValue 188 | viewContext1.retainsRegisteredObjects = true 189 | viewContext2.retainsRegisteredObjects = true 190 | let authors = [AppActor.app1.rawValue, AppActor.app2.rawValue, AppActor.app3.rawValue] 191 | 192 | let app1kit = PersistentHistoryTrackingKit( 193 | container: container1, 194 | contexts: [viewContext1], 195 | currentAuthor: AppActor.app1.rawValue, 196 | allAuthors: authors, 197 | userDefaults: userDefaults, 198 | uniqueString: uniqueString, 199 | logLevel: 2 200 | ) 201 | 202 | let app2kit = PersistentHistoryTrackingKit( 203 | container: container1, 204 | contexts: [viewContext2], 205 | currentAuthor: AppActor.app2.rawValue, 206 | allAuthors: authors, 207 | userDefaults: userDefaults, 208 | uniqueString: uniqueString, 209 | logLevel: 2 210 | ) 211 | 212 | let backgroundContext = container1.newBackgroundContext() 213 | backgroundContext.transactionAuthor = AppActor.app3.rawValue 214 | 215 | // when 216 | let objectID: NSManagedObjectID = backgroundContext.performAndWait { 217 | let event = Event(context: backgroundContext) 218 | event.timestamp = Date() 219 | backgroundContext.saveIfChanged() 220 | return event.objectID 221 | } 222 | 223 | await sleep(seconds: 2) 224 | 225 | // then 226 | viewContext1.performAndWait { 227 | XCTAssertNotNil(viewContext1.registeredObject(for: objectID)) 228 | } 229 | 230 | viewContext2.performAndWait { 231 | XCTAssertNotNil(viewContext2.registeredObject(for: objectID)) 232 | } 233 | 234 | app1kit.stop() 235 | app2kit.stop() 236 | } 237 | } 238 | -------------------------------------------------------------------------------- /Tests/PersistentHistoryTrackingKitTests/TestsHelper/CoreDataStackHelper.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Yang Xu on 2022/2/11 6 | // Copyright © 2022 Yang Xu. All rights reserved. 7 | // 8 | // Follow me on Twitter: @fatbobman 9 | // My Blog: https://www.fatbobman.com 10 | // 微信公共号: 肘子的Swift记事本 11 | // 12 | 13 | import CoreData 14 | import Foundation 15 | 16 | class CoreDataHelper { 17 | static func createNSPersistentContainer( 18 | storeURL: URL? = URL(fileURLWithPath: "/dev/null"), 19 | enablePersistentHistoryTrack: Bool = true 20 | ) -> NSPersistentContainer { 21 | let container = NSPersistentContainer(name: "Test Model", managedObjectModel: Self.model) 22 | guard let desc = container.persistentStoreDescriptions.first else { 23 | fatalError() 24 | } 25 | desc.url = storeURL 26 | if enablePersistentHistoryTrack { 27 | desc.setOption(true as NSNumber, forKey: NSPersistentHistoryTrackingKey) 28 | desc.setOption(true as NSNumber, forKey: NSPersistentStoreRemoteChangeNotificationPostOptionKey) 29 | } 30 | container.persistentStoreDescriptions = [desc] 31 | container.loadPersistentStores(completionHandler: { _, error in 32 | if let error = error { 33 | fatalError("create container error : \(error.localizedDescription)") 34 | } 35 | }) 36 | return container 37 | } 38 | 39 | /// 创建一个NSManagedObjectModel Entity: Event property: timestamp 40 | static func createTestNSManagedObjectModelModel() -> NSManagedObjectModel { 41 | let eventEntity = NSEntityDescription() 42 | eventEntity.name = "Event" 43 | eventEntity.managedObjectClassName = "Event" 44 | 45 | let timestampAttribute = NSAttributeDescription() 46 | 47 | if #available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, *) { 48 | timestampAttribute.type = .date 49 | } else { 50 | timestampAttribute.attributeType = .dateAttributeType 51 | } 52 | timestampAttribute.name = "timestamp" 53 | eventEntity.properties.append(timestampAttribute) 54 | 55 | let model = NSManagedObjectModel() 56 | model.entities = [eventEntity] 57 | return model 58 | } 59 | 60 | static var model = createTestNSManagedObjectModelModel() 61 | } 62 | 63 | @objc(Event) 64 | class Event: NSManagedObject { 65 | @NSManaged var timestamp: Date? 66 | } 67 | -------------------------------------------------------------------------------- /Tests/PersistentHistoryTrackingKitTests/TestsHelper/Extension.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Extension.swift 3 | // 4 | // 5 | // Created by Yang Xu on 2022/2/11 6 | // Copyright © 2022 Yang Xu. All rights reserved. 7 | // 8 | // Follow me on Twitter: @fatbobman 9 | // My Blog: https://www.fatbobman.com 10 | // 微信公共号: 肘子的Swift记事本 11 | // 12 | 13 | import CoreData 14 | import Foundation 15 | 16 | extension NSManagedObjectContext { 17 | func saveIfChanged() { 18 | guard self.hasChanges else { return } 19 | do { 20 | try self.save() 21 | } catch { 22 | fatalError("Context save error: \(error.localizedDescription)") 23 | } 24 | } 25 | } 26 | 27 | extension NSManagedObjectContext { 28 | @discardableResult 29 | func performAndWait(_ block: () throws -> T) throws -> T { 30 | var result: Result? 31 | performAndWait { 32 | result = Result { try block() } 33 | } 34 | return try result!.get() 35 | } 36 | 37 | @discardableResult 38 | func performAndWait(_ block: () -> T) -> T { 39 | var result: T? 40 | performAndWait { 41 | result = block() 42 | } 43 | return result! 44 | } 45 | } 46 | 47 | func sleep(seconds: Double) async { 48 | try? await Task.sleep(seconds: seconds) 49 | } 50 | -------------------------------------------------------------------------------- /Tests/PersistentHistoryTrackingKitTests/TimestampManagerTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TimestampManagerTests.swift 3 | // 4 | // 5 | // Created by Yang Xu on 2022/2/10 6 | // Copyright © 2022 Yang Xu. All rights reserved. 7 | // 8 | // Follow me on Twitter: @fatbobman 9 | // My Blog: https://www.fatbobman.com 10 | // 微信公共号: 肘子的Swift记事本 11 | // 12 | 13 | @testable import PersistentHistoryTrackingKit 14 | import XCTest 15 | 16 | class TimestampManagerTests: XCTestCase { 17 | let uniqueString = "PersistentHistoryTrackingKit.lastToken.Tests." 18 | let userDefaults = UserDefaults.standard 19 | 20 | override func setUpWithError() throws { 21 | // 清除 UserDefaults 环境 22 | for author in AppActor.allCases { 23 | userDefaults.removeObject(forKey: uniqueString + author.rawValue) 24 | } 25 | } 26 | 27 | func testSetSingleAuthorTimestamp() { 28 | // given 29 | let author = AppActor.app1.rawValue 30 | let manager = TransactionTimestampManager(userDefaults: userDefaults, uniqueString: uniqueString) 31 | let key = uniqueString + author 32 | 33 | // when 34 | let date = Date() 35 | manager.updateLastHistoryTransactionTimestamp(for: author, to: date) 36 | 37 | // then 38 | XCTAssertEqual(date, userDefaults.value(forKey: key) as? Date) 39 | } 40 | 41 | func testNoAuthorUpdateTimestamp() { 42 | // given 43 | let max:TimeInterval = 100 44 | let manager = TransactionTimestampManager(userDefaults: userDefaults, maximumDuration: max, uniqueString: uniqueString) 45 | let authors = AppActor.allCases.map { $0.rawValue } 46 | 47 | // when 48 | let lastTimestamp = manager.getLastCommonTransactionTimestamp(in: authors) 49 | 50 | // then 51 | XCTAssertNotNil(lastTimestamp) 52 | } 53 | 54 | func testAllAuthorsHaveUpdatedTimestamp() { 55 | // given 56 | let manager = TransactionTimestampManager(userDefaults: userDefaults, uniqueString: uniqueString) 57 | 58 | let date1 = Date().addingTimeInterval(-1000) 59 | let date2 = Date().addingTimeInterval(-2000) 60 | let date3 = Date().addingTimeInterval(-3000) 61 | 62 | manager.updateLastHistoryTransactionTimestamp(for: AppActor.app1.rawValue, to: date1) 63 | manager.updateLastHistoryTransactionTimestamp(for: AppActor.app2.rawValue, to: date2) 64 | manager.updateLastHistoryTransactionTimestamp(for: AppActor.app3.rawValue, to: date3) 65 | 66 | let authors = AppActor.allCases.map { $0.rawValue } 67 | 68 | // when 69 | let lastTimestamp = manager.getLastCommonTransactionTimestamp(in: authors) 70 | 71 | // then 72 | XCTAssertEqual(lastTimestamp, date3) 73 | } 74 | 75 | // 仅部分author设置了时间戳,尚未触及阈值日期 76 | func testPartOfAuthorsHaveUpdatedTimestampAndThresholdNotYetTouched() { 77 | // given 78 | let manager = TransactionTimestampManager(userDefaults: userDefaults, uniqueString: uniqueString) 79 | 80 | let date1 = Date().addingTimeInterval(-1000) 81 | let date2 = Date().addingTimeInterval(-2000) 82 | 83 | manager.updateLastHistoryTransactionTimestamp(for: AppActor.app1.rawValue, to: date1) 84 | manager.updateLastHistoryTransactionTimestamp(for: AppActor.app2.rawValue, to: date2) 85 | 86 | let authors = AppActor.allCases.map { $0.rawValue } 87 | 88 | // when 89 | let lastTimestampe = manager.getLastCommonTransactionTimestamp(in: authors) 90 | 91 | // then 92 | XCTAssertNotNil(lastTimestampe) 93 | } 94 | 95 | // 部分author设置了时间戳,已触及阈值日期 96 | func testPartOfAuthorsHaveUpdatedTimestampAndTouchedThreshold() { 97 | // given 98 | let maxDuration = 3000.0 99 | let manager = TransactionTimestampManager( 100 | userDefaults: userDefaults, 101 | maximumDuration: maxDuration, 102 | uniqueString: uniqueString 103 | ) 104 | 105 | let date1 = Date().addingTimeInterval(-(maxDuration + 1000)) 106 | let date2 = Date().addingTimeInterval(-(maxDuration + 2000)) 107 | 108 | manager.updateLastHistoryTransactionTimestamp(for: AppActor.app1.rawValue, to: date1) 109 | manager.updateLastHistoryTransactionTimestamp(for: AppActor.app2.rawValue, to: date2) 110 | 111 | let authors = AppActor.allCases.map { $0.rawValue } 112 | 113 | // when 114 | let lastTimestamp = manager.getLastCommonTransactionTimestamp(in: authors) 115 | 116 | // then 117 | XCTAssertNotNil(lastTimestamp) 118 | if let lastTimestamp = lastTimestamp { 119 | XCTAssertLessThan(lastTimestamp, date1) 120 | } 121 | } 122 | 123 | // 测试当batchAuthors有内容时,是否可以获取正确的时间 124 | func testGetLastCommonTimestampWhenBatchAuthorsIsNotEmpty() { 125 | // given 126 | let manager = TransactionTimestampManager(userDefaults: userDefaults, uniqueString: uniqueString) 127 | 128 | let authors = ["app1", "app1Batch"] 129 | let batchAuthors = ["app1Batch"] 130 | let currentAuthor = "app1" 131 | 132 | let updateDate = Date() 133 | 134 | // when 135 | manager.updateLastHistoryTransactionTimestamp(for: currentAuthor, to: updateDate) 136 | let lastDate1 = manager.getLastCommonTransactionTimestamp(in: authors) 137 | 138 | // then 139 | XCTAssertNotNil(lastDate1) 140 | 141 | // when 142 | let lastDate2 = manager.getLastCommonTransactionTimestamp(in: authors, exclude: batchAuthors) 143 | 144 | XCTAssertEqual(lastDate2, updateDate) 145 | } 146 | } 147 | 148 | enum AppActor: String, CaseIterable { 149 | case app1, app2, app3 150 | } 151 | --------------------------------------------------------------------------------