|
1 | | -# TaskManager - Swift Concurrency |
| 1 | +# 🎯 TaskManager for Swift Concurrency |
2 | 2 |
|
3 | | -## Overview |
| 3 | +> Elegant task orchestration for Swift apps - Control concurrent operations with precision |
4 | 4 |
|
5 | | -swift-concurrency supports structured concurrency but it also supports unstructured concurrency using Task API. |
6 | | -Task API runs immediately its work item. Although using closure can make deferred tasks. |
| 5 | +[](https://swift.org) |
| 6 | +[](https://swift.org) |
| 7 | +[](LICENSE) |
7 | 8 |
|
8 | | -TaskManager accepts work items to run in a serial queue isolated by the key. |
9 | | -Passing key and mode (drop current items / wait in current items) |
| 9 | +## Introduction |
10 | 10 |
|
11 | | -## Usage |
| 11 | +TaskManager is a powerful Swift library that brings order to chaos in asynchronous programming. While Swift's structured concurrency is excellent, unstructured tasks created with `Task { }` run immediately and can lead to race conditions, redundant operations, and unpredictable behavior. |
| 12 | + |
| 13 | +TaskManager solves this by providing: |
| 14 | +- **Task isolation by key** - Group related operations together |
| 15 | +- **Execution control** - Choose whether to cancel existing tasks or queue new ones |
| 16 | +- **SwiftUI integration** - First-class support for UI-driven async operations |
| 17 | +- **Actor-based safety** - Thread-safe by design using Swift actors |
| 18 | + |
| 19 | +## 🚀 Installation |
| 20 | + |
| 21 | +### Swift Package Manager |
| 22 | + |
| 23 | +Add TaskManager to your `Package.swift`: |
| 24 | + |
| 25 | +```swift |
| 26 | +dependencies: [ |
| 27 | + .package(url: "https://github.com/muukii/swift-concurrency-task-manager.git", from: "1.0.0") |
| 28 | +] |
| 29 | +``` |
| 30 | + |
| 31 | +Or add it through Xcode: |
| 32 | +1. File → Add Package Dependencies |
| 33 | +2. Enter the repository URL |
| 34 | +3. Click "Add Package" |
| 35 | + |
| 36 | +## 📋 Requirements |
| 37 | + |
| 38 | +- Swift 6.0+ |
| 39 | +- iOS 14.0+ / macOS 11.0+ / tvOS 16.0+ / watchOS 10.0+ |
| 40 | +- Xcode 15.0+ |
| 41 | + |
| 42 | +## 🎓 Core Concepts |
| 43 | + |
| 44 | +### TaskKey |
| 45 | + |
| 46 | +A `TaskKey` is a unique identifier that groups related operations. Tasks with the same key are managed together, allowing you to control their execution behavior. |
| 47 | + |
| 48 | +```swift |
| 49 | +// Type-based keys for strong typing |
| 50 | +enum UserOperations: TaskKeyType {} |
| 51 | +let key = TaskKey(UserOperations.self) |
| 52 | + |
| 53 | +// String-based keys for simplicity |
| 54 | +let key: TaskKey = "user-fetch" |
| 55 | + |
| 56 | +// Dynamic keys with combined values |
| 57 | +let key = TaskKey(UserOperations.self).combined(userID) |
| 58 | + |
| 59 | +// Unique keys for one-off operations |
| 60 | +let key = TaskKey.distinct() |
| 61 | + |
| 62 | +// Code location-based keys |
| 63 | +let key = TaskKey.code() // Uses file:line:column |
| 64 | +``` |
| 65 | + |
| 66 | +### Execution Modes |
| 67 | + |
| 68 | +TaskManager offers two execution modes: |
| 69 | + |
| 70 | +- **`.dropCurrent`** - Cancels any running task with the same key before starting the new one |
| 71 | +- **`.waitInCurrent`** - Queues the new task to run after existing tasks complete |
| 72 | + |
| 73 | +### Task Isolation |
| 74 | + |
| 75 | +Tasks are isolated by their keys, meaning operations with different keys run concurrently, while operations with the same key are managed according to their mode. |
| 76 | + |
| 77 | +## 💡 Basic Usage |
| 78 | + |
| 79 | +### Simple Task Management |
| 80 | + |
| 81 | +```swift |
| 82 | +let manager = TaskManagerActor() |
| 83 | + |
| 84 | +// Drop any existing user fetch and start a new one |
| 85 | +let task = await manager.task( |
| 86 | + key: TaskKey("user-fetch"), |
| 87 | + mode: .dropCurrent |
| 88 | +) { |
| 89 | + let user = try await api.fetchUser() |
| 90 | + return user |
| 91 | +} |
| 92 | + |
| 93 | +// Wait for the result |
| 94 | +let user = try await task.value |
| 95 | +``` |
| 96 | + |
| 97 | +### Real-World Example: Search-as-you-type |
| 98 | + |
| 99 | +```swift |
| 100 | +class SearchViewModel { |
| 101 | + let taskManager = TaskManagerActor() |
| 102 | + |
| 103 | + func search(query: String) async { |
| 104 | + // Cancel previous search when user types |
| 105 | + await taskManager.task( |
| 106 | + key: TaskKey("search"), |
| 107 | + mode: .dropCurrent |
| 108 | + ) { |
| 109 | + // Debounce |
| 110 | + try await Task.sleep(for: .milliseconds(300)) |
| 111 | + |
| 112 | + let results = try await api.search(query) |
| 113 | + await MainActor.run { |
| 114 | + self.searchResults = results |
| 115 | + } |
| 116 | + } |
| 117 | + } |
| 118 | +} |
| 119 | +``` |
| 120 | + |
| 121 | +## 🎨 SwiftUI Integration |
| 122 | + |
| 123 | +TaskManager provides a property wrapper for seamless SwiftUI integration: |
| 124 | + |
| 125 | +```swift |
| 126 | +struct UserProfileView: View { |
| 127 | + @TaskManager var taskManager |
| 128 | + @State private var isLoading = false |
| 129 | + @State private var user: User? |
| 130 | + |
| 131 | + var body: some View { |
| 132 | + VStack { |
| 133 | + if isLoading { |
| 134 | + ProgressView() |
| 135 | + } else if let user { |
| 136 | + Text(user.name) |
| 137 | + } |
| 138 | + |
| 139 | + Button("Refresh") { |
| 140 | + taskManager.task( |
| 141 | + isRunning: $isLoading, |
| 142 | + key: TaskKey("fetch-user"), |
| 143 | + mode: .dropCurrent |
| 144 | + ) { |
| 145 | + user = try await api.fetchCurrentUser() |
| 146 | + } |
| 147 | + } |
| 148 | + } |
| 149 | + } |
| 150 | +} |
| 151 | +``` |
| 152 | + |
| 153 | +## 🔥 Advanced Usage |
| 154 | + |
| 155 | +### Dynamic Task Keys |
| 156 | + |
| 157 | +Create sophisticated task isolation strategies: |
12 | 158 |
|
13 | 159 | ```swift |
| 160 | +// Isolate tasks per user |
| 161 | +func updateUserStatus(userID: String, isFavorite: Bool) async { |
| 162 | + let key = TaskKey(UserOperations.self).combined(userID) |
| 163 | + |
| 164 | + await taskManager.task(key: key, mode: .dropCurrent) { |
| 165 | + try await api.updateUserStatus(userID, favorite: isFavorite) |
| 166 | + } |
| 167 | +} |
| 168 | + |
| 169 | +// Isolate tasks per resource and operation |
| 170 | +func downloadImage(url: URL, size: ImageSize) async { |
| 171 | + let key = TaskKey("image-download") |
| 172 | + .combined(url.absoluteString) |
| 173 | + .combined(size.rawValue) |
| 174 | + |
| 175 | + await taskManager.task(key: key, mode: .waitInCurrent) { |
| 176 | + try await imageLoader.download(url, size: size) |
| 177 | + } |
| 178 | +} |
| 179 | +``` |
14 | 180 |
|
15 | | -enum MyTask: TaskKeyType {} |
| 181 | +### Batch Operations |
16 | 182 |
|
17 | | -let manager = TaskManager() |
| 183 | +Execute multiple operations efficiently: |
18 | 184 |
|
19 | | -// this `await` is just for appending task item since TaskManager is Actor. |
20 | | -let ref = await manager.task(key: .init(MyTask.self), mode: .dropCurrent) { |
21 | | - // work |
| 185 | +```swift |
| 186 | +await taskManager.batch { manager in |
| 187 | + // These run concurrently (different keys) |
| 188 | + manager.task(key: TaskKey("fetch-user"), mode: .dropCurrent) { |
| 189 | + userData = try await api.fetchUser() |
| 190 | + } |
| 191 | + |
| 192 | + manager.task(key: TaskKey("fetch-posts"), mode: .dropCurrent) { |
| 193 | + posts = try await api.fetchPosts() |
| 194 | + } |
| 195 | + |
| 196 | + manager.task(key: TaskKey("fetch-settings"), mode: .dropCurrent) { |
| 197 | + settings = try await api.fetchSettings() |
| 198 | + } |
22 | 199 | } |
| 200 | +``` |
| 201 | + |
| 202 | +### Task State Management |
| 203 | + |
| 204 | +Control task execution flow: |
| 205 | + |
| 206 | +```swift |
| 207 | +let manager = TaskManagerActor() |
| 208 | + |
| 209 | +// Pause all task execution |
| 210 | +await manager.setIsRunning(false) |
23 | 211 |
|
24 | | -// to wait the completion of the task, use `ref.value`. |
25 | | -await ref.value |
| 212 | +// Tasks will queue but not execute |
| 213 | +await manager.task(key: TaskKey("operation"), mode: .waitInCurrent) { |
| 214 | + // This won't run until isRunning is true |
| 215 | +} |
| 216 | + |
| 217 | +// Resume execution |
| 218 | +await manager.setIsRunning(true) |
| 219 | + |
| 220 | +// Check if a specific task is running |
| 221 | +let isRunning = await manager.isRunning(for: TaskKey("operation")) |
26 | 222 | ``` |
27 | 223 |
|
28 | | -## Use cases |
| 224 | +### Error Handling |
29 | 225 |
|
30 | | -**Toggle user status** |
31 | | -Picture some social service - toggling the user status like favorite or not. |
32 | | -For the client, it needs to dispatch asynchronous requests for them. |
33 | | -In some case final user state would be different from what the client expected if the client dispatched multiple requests for the toggle - like the user tapped the update button continuously. |
| 226 | +TaskManager preserves Swift's native error handling: |
34 | 227 |
|
35 | 228 | ```swift |
36 | | -enum SomeRequestKey: TaskKeyType {} |
| 229 | +do { |
| 230 | + let result = try await taskManager.task( |
| 231 | + key: TaskKey("risky-operation"), |
| 232 | + mode: .dropCurrent |
| 233 | + ) { |
| 234 | + try await riskyOperation() |
| 235 | + }.value |
| 236 | +} catch is CancellationError { |
| 237 | + print("Task was cancelled") |
| 238 | +} catch { |
| 239 | + print("Task failed: \(error)") |
| 240 | +} |
| 241 | +``` |
| 242 | + |
| 243 | +## 🏗️ Architecture Patterns |
| 244 | + |
| 245 | +### Repository Pattern |
| 246 | + |
| 247 | +```swift |
| 248 | +class UserRepository { |
| 249 | + private let taskManager = TaskManagerActor() |
| 250 | + |
| 251 | + func fetchUser(id: String, forceRefresh: Bool = false) async throws -> User { |
| 252 | + let key = TaskKey(UserOperations.self).combined(id) |
| 253 | + let mode: TaskManagerActor.Mode = forceRefresh ? .dropCurrent : .waitInCurrent |
| 254 | + |
| 255 | + return try await taskManager.task(key: key, mode: mode) { |
| 256 | + // Check cache first |
| 257 | + if !forceRefresh, let cached = await cache.get(id) { |
| 258 | + return cached |
| 259 | + } |
| 260 | + |
| 261 | + // Fetch from network |
| 262 | + let user = try await api.fetchUser(id) |
| 263 | + await cache.set(user, for: id) |
| 264 | + return user |
| 265 | + }.value |
| 266 | + } |
| 267 | +} |
| 268 | +``` |
37 | 269 |
|
38 | | -let key = TaskKey(SomeRequestKey.self).combined(targetUserID) |
| 270 | +### ViewModel Pattern |
39 | 271 |
|
40 | | -await taskManager.task(key: key, mode: .dropCurrent) { ... } |
| 272 | +```swift |
| 273 | +@Observable |
| 274 | +class ProductListViewModel { |
| 275 | + private let taskManager = TaskManagerActor() |
| 276 | + var products: [Product] = [] |
| 277 | + var isLoading = false |
| 278 | + |
| 279 | + func loadProducts(category: String? = nil) { |
| 280 | + Task { |
| 281 | + await taskManager.task( |
| 282 | + key: TaskKey("load-products").combined(category ?? "all"), |
| 283 | + mode: .dropCurrent |
| 284 | + ) { |
| 285 | + await MainActor.run { self.isLoading = true } |
| 286 | + defer { Task { @MainActor in self.isLoading = false } } |
| 287 | + |
| 288 | + let products = try await api.fetchProducts(category: category) |
| 289 | + await MainActor.run { |
| 290 | + self.products = products |
| 291 | + } |
| 292 | + } |
| 293 | + } |
| 294 | + } |
| 295 | +} |
41 | 296 | ``` |
42 | 297 |
|
43 | | -To avoid that case, the client stops the current request before starting a new request in the queue. |
44 | | -The above example binds the requests with a typed request key and target user identifier, that makes a queue for that. |
| 298 | +## 📚 API Reference |
| 299 | + |
| 300 | +### TaskManagerActor |
| 301 | + |
| 302 | +The main actor that manages task execution. |
| 303 | + |
| 304 | +#### Methods |
| 305 | + |
| 306 | +- `task(label:key:mode:priority:operation:)` - Submit a task for execution |
| 307 | +- `taskDetached(label:key:mode:priority:operation:)` - Submit a detached task |
| 308 | +- `batch(_:)` - Execute multiple operations in a batch |
| 309 | +- `setIsRunning(_:)` - Control task execution state |
| 310 | +- `isRunning(for:)` - Check if a task is running for a given key |
| 311 | +- `cancelAll()` - Cancel all managed tasks |
| 312 | + |
| 313 | +### TaskKey |
| 314 | + |
| 315 | +Identifies and groups related tasks. |
| 316 | + |
| 317 | +#### Initialization |
| 318 | + |
| 319 | +- `init(_:TaskKeyType)` - Create from a type |
| 320 | +- `init(_:String)` - Create from a string |
| 321 | +- `init(_:Int)` - Create from an integer |
| 322 | +- `init(_:Hashable & Sendable)` - Create from any hashable value |
| 323 | + |
| 324 | +#### Methods |
| 325 | + |
| 326 | +- `combined(_:)` - Combine with another key |
| 327 | +- `static func distinct()` - Create a unique key |
| 328 | +- `static func code()` - Create a key from source location |
| 329 | + |
| 330 | +### SwiftUI Components |
| 331 | + |
| 332 | +#### @TaskManager Property Wrapper |
| 333 | + |
| 334 | +Provides TaskManager functionality in SwiftUI views with automatic lifecycle management. |
| 335 | + |
| 336 | +#### TaskManagerActorWrapper |
| 337 | + |
| 338 | +SwiftUI-friendly wrapper with `isRunning` binding support. |
| 339 | + |
| 340 | +## 🤝 Contributing |
| 341 | + |
| 342 | +Contributions are welcome! Please feel free to submit a Pull Request. |
| 343 | + |
| 344 | +## 📄 License |
| 345 | + |
| 346 | +TaskManager is available under the Apache 2.0 license. See the [LICENSE](LICENSE) file for more info. |
| 347 | + |
| 348 | +## 🙏 Acknowledgments |
| 349 | + |
| 350 | +Built with ❤️ using Swift's modern concurrency features and inspired by the need for better async task control in real-world applications. |
0 commit comments