@mmerterden/multi-agent-pipeline 20.7.0 → 20.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +9 -0
- package/LICENSE +0 -10
- package/docs/facts.json +1 -1
- package/manifest.json +266 -267
- package/package.json +2 -2
- package/pipeline/scripts/_notices.mjs +1 -1
- package/pipeline/skills/.skill-manifest.json +68 -68
- package/pipeline/skills/shared/README.md +70 -70
- package/pipeline/skills/shared/external/alarmkit/SKILL.md +373 -381
- package/pipeline/skills/shared/external/alarmkit/evals/evals.json +23 -18
- package/pipeline/skills/shared/external/alarmkit/references/alarmkit-patterns.md +328 -378
- package/pipeline/skills/shared/external/app-clips/SKILL.md +260 -160
- package/pipeline/skills/shared/external/app-clips/evals/evals.json +27 -27
- package/pipeline/skills/shared/external/app-clips/references/data-handoff-notifications-location.md +150 -83
- package/pipeline/skills/shared/external/app-clips/references/routing-and-experiences.md +135 -83
- package/pipeline/skills/shared/external/app-clips/references/size-capabilities-and-promotion.md +143 -85
- package/pipeline/skills/shared/external/app-intents/SKILL.md +302 -304
- package/pipeline/skills/shared/external/app-intents/evals/evals.json +21 -21
- package/pipeline/skills/shared/external/app-intents/references/appintents-advanced.md +594 -894
- package/pipeline/skills/shared/external/app-store-optimization/SKILL.md +339 -277
- package/pipeline/skills/shared/external/app-store-optimization/evals/evals.json +27 -23
- package/pipeline/skills/shared/external/app-store-optimization/references/keyword-research-methodology.md +105 -122
- package/pipeline/skills/shared/external/app-store-optimization/references/product-page-variants.md +143 -166
- package/pipeline/skills/shared/external/app-store-review/SKILL.md +307 -326
- package/pipeline/skills/shared/external/app-store-review/evals/evals.json +21 -21
- package/pipeline/skills/shared/external/app-store-review/references/privacy-manifest.md +105 -67
- package/pipeline/skills/shared/external/app-store-review/references/review-checklists.md +114 -101
- package/pipeline/skills/shared/external/apple-on-device-ai/SKILL.md +333 -360
- package/pipeline/skills/shared/external/apple-on-device-ai/evals/evals.json +24 -27
- package/pipeline/skills/shared/external/apple-on-device-ai/references/coreml-conversion.md +215 -322
- package/pipeline/skills/shared/external/apple-on-device-ai/references/coreml-optimization.md +161 -256
- package/pipeline/skills/shared/external/apple-on-device-ai/references/foundation-models.md +277 -387
- package/pipeline/skills/shared/external/apple-on-device-ai/references/mlx-swift.md +196 -210
- package/pipeline/skills/shared/external/authentication/SKILL.md +265 -381
- package/pipeline/skills/shared/external/authentication/evals/evals.json +25 -25
- package/pipeline/skills/shared/external/authentication/references/keychain-biometric.md +133 -178
- package/pipeline/skills/shared/external/authentication/references/passkeys.md +111 -147
- package/pipeline/skills/shared/external/avkit/SKILL.md +267 -364
- package/pipeline/skills/shared/external/avkit/evals/evals.json +26 -26
- package/pipeline/skills/shared/external/avkit/references/avkit-patterns.md +375 -493
- package/pipeline/skills/shared/external/background-processing/SKILL.md +270 -382
- package/pipeline/skills/shared/external/background-processing/evals/evals.json +22 -22
- package/pipeline/skills/shared/external/background-processing/references/background-task-patterns.md +169 -317
- package/pipeline/skills/shared/external/callkit-voip/SKILL.md +290 -371
- package/pipeline/skills/shared/external/callkit-voip/evals/evals.json +24 -24
- package/pipeline/skills/shared/external/callkit-voip/references/callkit-patterns.md +175 -343
- package/pipeline/skills/shared/external/cloudkit-sync/SKILL.md +292 -381
- package/pipeline/skills/shared/external/cloudkit-sync/evals/evals.json +33 -30
- package/pipeline/skills/shared/external/cloudkit-sync/references/cloudkit-patterns.md +227 -355
- package/pipeline/skills/shared/external/contacts-framework/SKILL.md +197 -346
- package/pipeline/skills/shared/external/contacts-framework/evals/evals.json +19 -21
- package/pipeline/skills/shared/external/contacts-framework/references/contacts-patterns.md +169 -308
- package/pipeline/skills/shared/external/core-bluetooth/SKILL.md +226 -376
- package/pipeline/skills/shared/external/core-bluetooth/evals/evals.json +25 -22
- package/pipeline/skills/shared/external/core-bluetooth/references/ble-patterns.md +257 -337
- package/pipeline/skills/shared/external/core-data/SKILL.md +292 -368
- package/pipeline/skills/shared/external/core-data/evals/evals.json +30 -27
- package/pipeline/skills/shared/external/core-motion/SKILL.md +235 -324
- package/pipeline/skills/shared/external/core-motion/evals/evals.json +31 -27
- package/pipeline/skills/shared/external/core-motion/references/motion-patterns.md +210 -310
- package/pipeline/skills/shared/external/core-nfc/SKILL.md +292 -366
- package/pipeline/skills/shared/external/core-nfc/evals/evals.json +22 -24
- package/pipeline/skills/shared/external/core-nfc/references/nfc-patterns.md +233 -329
- package/pipeline/skills/shared/external/coreml/SKILL.md +323 -367
- package/pipeline/skills/shared/external/coreml/evals/evals.json +24 -21
- package/pipeline/skills/shared/external/coreml/references/coreml-swift-integration.md +562 -565
- package/pipeline/skills/shared/external/cryptokit/SKILL.md +253 -394
- package/pipeline/skills/shared/external/cryptokit/evals/evals.json +20 -18
- package/pipeline/skills/shared/external/cryptokit/references/cryptokit-patterns.md +299 -488
- package/pipeline/skills/shared/external/debugging-instruments/SKILL.md +270 -323
- package/pipeline/skills/shared/external/debugging-instruments/evals/evals.json +27 -30
- package/pipeline/skills/shared/external/debugging-instruments/references/instruments-guide.md +167 -315
- package/pipeline/skills/shared/external/debugging-instruments/references/lldb-patterns.md +140 -193
- package/pipeline/skills/shared/external/device-integrity/SKILL.md +230 -353
- package/pipeline/skills/shared/external/device-integrity/evals/evals.json +25 -21
- package/pipeline/skills/shared/external/device-integrity/references/device-integrity-patterns.md +159 -197
- package/pipeline/skills/shared/external/energykit/SKILL.md +225 -392
- package/pipeline/skills/shared/external/energykit/evals/evals.json +29 -28
- package/pipeline/skills/shared/external/energykit/references/energykit-patterns.md +174 -470
- package/pipeline/skills/shared/external/eventkit-calendar/SKILL.md +261 -383
- package/pipeline/skills/shared/external/eventkit-calendar/evals/evals.json +25 -22
- package/pipeline/skills/shared/external/eventkit-calendar/references/eventkit-patterns.md +165 -268
- package/pipeline/skills/shared/external/healthkit/SKILL.md +252 -303
- package/pipeline/skills/shared/external/healthkit/evals/evals.json +24 -23
- package/pipeline/skills/shared/external/healthkit/references/healthkit-patterns.md +369 -523
- package/pipeline/skills/shared/external/homekit-matter/SKILL.md +233 -348
- package/pipeline/skills/shared/external/homekit-matter/evals/evals.json +27 -22
- package/pipeline/skills/shared/external/homekit-matter/references/matter-commissioning.md +199 -305
- package/pipeline/skills/shared/external/ios-accessibility/SKILL.md +368 -340
- package/pipeline/skills/shared/external/ios-accessibility/evals/evals.json +28 -27
- package/pipeline/skills/shared/external/ios-accessibility/references/a11y-patterns.md +314 -260
- package/pipeline/skills/shared/external/ios-accessibility/references/media-accessibility.md +97 -67
- package/pipeline/skills/shared/external/ios-accessibility/references/nutrition-labels.md +165 -101
- package/pipeline/skills/shared/external/ios-localization/SKILL.md +258 -371
- package/pipeline/skills/shared/external/ios-localization/evals/evals.json +23 -23
- package/pipeline/skills/shared/external/ios-localization/references/formatstyle-locale.md +283 -491
- package/pipeline/skills/shared/external/ios-localization/references/string-catalogs.md +313 -440
- package/pipeline/skills/shared/external/ios-networking/SKILL.md +265 -341
- package/pipeline/skills/shared/external/ios-networking/evals/evals.json +24 -24
- package/pipeline/skills/shared/external/ios-networking/references/background-websocket.md +425 -652
- package/pipeline/skills/shared/external/ios-networking/references/file-storage-patterns.md +143 -285
- package/pipeline/skills/shared/external/ios-networking/references/lightweight-clients.md +93 -53
- package/pipeline/skills/shared/external/ios-networking/references/network-framework.md +231 -456
- package/pipeline/skills/shared/external/ios-networking/references/urlsession-patterns.md +517 -784
- package/pipeline/skills/shared/external/ios-simulator/SKILL.md +265 -393
- package/pipeline/skills/shared/external/ios-simulator/evals/evals.json +21 -21
- package/pipeline/skills/shared/external/ios-simulator/references/simctl-commands.md +177 -270
- package/pipeline/skills/shared/external/live-activities/SKILL.md +318 -360
- package/pipeline/skills/shared/external/live-activities/evals/evals.json +21 -21
- package/pipeline/skills/shared/external/live-activities/references/activitykit-patterns.md +478 -710
- package/pipeline/skills/shared/external/mapkit-location/SKILL.md +295 -267
- package/pipeline/skills/shared/external/mapkit-location/evals/evals.json +28 -24
- package/pipeline/skills/shared/external/mapkit-location/references/mapkit-corelocation-patterns.md +378 -532
- package/pipeline/skills/shared/external/mapkit-location/references/mapkit-patterns.md +397 -499
- package/pipeline/skills/shared/external/metrickit-diagnostics/SKILL.md +165 -348
- package/pipeline/skills/shared/external/metrickit-diagnostics/evals/evals.json +26 -23
- package/pipeline/skills/shared/external/metrickit-diagnostics/references/metrickit-patterns.md +123 -130
- package/pipeline/skills/shared/external/musickit-audio/SKILL.md +189 -315
- package/pipeline/skills/shared/external/musickit-audio/evals/evals.json +22 -21
- package/pipeline/skills/shared/external/musickit-audio/references/musickit-patterns.md +181 -270
- package/pipeline/skills/shared/external/natural-language/SKILL.md +188 -340
- package/pipeline/skills/shared/external/natural-language/evals/evals.json +21 -21
- package/pipeline/skills/shared/external/natural-language/references/translation-patterns.md +171 -225
- package/pipeline/skills/shared/external/passkit-wallet/SKILL.md +258 -392
- package/pipeline/skills/shared/external/passkit-wallet/evals/evals.json +30 -29
- package/pipeline/skills/shared/external/passkit-wallet/references/wallet-passes.md +164 -231
- package/pipeline/skills/shared/external/pdfkit/SKILL.md +312 -344
- package/pipeline/skills/shared/external/pdfkit/evals/evals.json +19 -19
- package/pipeline/skills/shared/external/pdfkit/references/pdfkit-patterns.md +413 -624
- package/pipeline/skills/shared/external/pencilkit-drawing/SKILL.md +242 -358
- package/pipeline/skills/shared/external/pencilkit-drawing/evals/evals.json +25 -21
- package/pipeline/skills/shared/external/pencilkit-drawing/references/pencilkit-patterns.md +161 -226
- package/pipeline/skills/shared/external/permissionkit/SKILL.md +282 -400
- package/pipeline/skills/shared/external/permissionkit/evals/evals.json +27 -30
- package/pipeline/skills/shared/external/permissionkit/references/permissionkit-patterns.md +237 -350
- package/pipeline/skills/shared/external/photos-camera-media/SKILL.md +276 -325
- package/pipeline/skills/shared/external/photos-camera-media/references/av-playback.md +299 -545
- package/pipeline/skills/shared/external/photos-camera-media/references/camera-capture.md +344 -588
- package/pipeline/skills/shared/external/photos-camera-media/references/image-loading-caching.md +316 -660
- package/pipeline/skills/shared/external/photos-camera-media/references/photokit-patterns.md +270 -416
- package/pipeline/skills/shared/external/push-notifications/SKILL.md +312 -340
- package/pipeline/skills/shared/external/push-notifications/evals/evals.json +27 -26
- package/pipeline/skills/shared/external/push-notifications/references/notification-patterns.md +328 -485
- package/pipeline/skills/shared/external/push-notifications/references/rich-notifications.md +327 -560
- package/pipeline/skills/shared/external/realitykit-ar/SKILL.md +218 -410
- package/pipeline/skills/shared/external/realitykit-ar/evals/evals.json +24 -27
- package/pipeline/skills/shared/external/realitykit-ar/references/realitykit-patterns.md +221 -348
- package/pipeline/skills/shared/external/shareplay-activities/SKILL.md +222 -393
- package/pipeline/skills/shared/external/shareplay-activities/evals/evals.json +23 -24
- package/pipeline/skills/shared/external/shareplay-activities/references/shareplay-patterns.md +280 -420
- package/pipeline/skills/shared/external/speech-recognition/SKILL.md +217 -421
- package/pipeline/skills/shared/external/speech-recognition/evals/evals.json +23 -26
- package/pipeline/skills/shared/external/speech-recognition/references/speechanalyzer-patterns.md +133 -125
- package/pipeline/skills/shared/external/storekit/SKILL.md +228 -204
- package/pipeline/skills/shared/external/storekit/evals/evals.json +27 -24
- package/pipeline/skills/shared/external/storekit/references/app-review-guidelines.md +98 -109
- package/pipeline/skills/shared/external/storekit/references/core-patterns.md +298 -242
- package/pipeline/skills/shared/external/storekit/references/storekit-advanced.md +356 -649
- package/pipeline/skills/shared/external/swift-api-design-guidelines/SKILL.md +274 -399
- package/pipeline/skills/shared/external/swift-api-design-guidelines/evals/evals.json +22 -24
- package/pipeline/skills/shared/external/swift-api-design-guidelines/references/argument-labels-and-parameters.md +107 -108
- package/pipeline/skills/shared/external/swift-api-design-guidelines/references/conventions-and-special-rules.md +93 -165
- package/pipeline/skills/shared/external/swift-api-design-guidelines/references/naming-and-clarity.md +99 -137
- package/pipeline/skills/shared/external/swift-api-design-guidelines/references/side-effects-and-mutating-pairs.md +77 -120
- package/pipeline/skills/shared/external/swift-architecture/SKILL.md +334 -350
- package/pipeline/skills/shared/external/swift-architecture/evals/evals.json +22 -22
- package/pipeline/skills/shared/external/swift-charts/SKILL.md +208 -394
- package/pipeline/skills/shared/external/swift-charts/evals/evals.json +27 -30
- package/pipeline/skills/shared/external/swift-charts/references/charts-patterns.md +351 -762
- package/pipeline/skills/shared/external/swift-codable/SKILL.md +339 -343
- package/pipeline/skills/shared/external/swift-codable/evals/evals.json +20 -20
- package/pipeline/skills/shared/external/swift-concurrency/SKILL.md +303 -351
- package/pipeline/skills/shared/external/swift-concurrency/evals/evals.json +27 -24
- package/pipeline/skills/shared/external/swift-concurrency/references/approachable-concurrency.md +65 -80
- package/pipeline/skills/shared/external/swift-concurrency/references/async-algorithms.md +48 -84
- package/pipeline/skills/shared/external/swift-concurrency/references/bridging-interop.md +134 -79
- package/pipeline/skills/shared/external/swift-concurrency/references/concurrency-patterns.md +145 -167
- package/pipeline/skills/shared/external/swift-concurrency/references/diagnostics.md +62 -50
- package/pipeline/skills/shared/external/swift-concurrency/references/swiftui-concurrency.md +92 -121
- package/pipeline/skills/shared/external/swift-concurrency/references/synchronization-primitives.md +177 -241
- package/pipeline/skills/shared/external/swift-formatstyle/SKILL.md +258 -234
- package/pipeline/skills/shared/external/swift-language/SKILL.md +342 -382
- package/pipeline/skills/shared/external/swift-language/evals/evals.json +24 -27
- package/pipeline/skills/shared/external/swift-language/references/swift-attributes-interop.md +79 -56
- package/pipeline/skills/shared/external/swift-language/references/swift-patterns-extended.md +297 -340
- package/pipeline/skills/shared/external/swift-security/SKILL.md +180 -161
- package/pipeline/skills/shared/external/swift-security/evals/evals.json +25 -25
- package/pipeline/skills/shared/external/swift-security/references/biometric-authentication.md +314 -469
- package/pipeline/skills/shared/external/swift-security/references/certificate-trust.md +408 -476
- package/pipeline/skills/shared/external/swift-security/references/common-anti-patterns.md +260 -530
- package/pipeline/skills/shared/external/swift-security/references/compliance-owasp-mapping.md +270 -477
- package/pipeline/skills/shared/external/swift-security/references/credential-storage-patterns.md +573 -571
- package/pipeline/skills/shared/external/swift-security/references/cryptokit-public-key.md +370 -441
- package/pipeline/skills/shared/external/swift-security/references/cryptokit-symmetric.md +332 -433
- package/pipeline/skills/shared/external/swift-security/references/keychain-access-control.md +346 -468
- package/pipeline/skills/shared/external/swift-security/references/keychain-fundamentals.md +352 -472
- package/pipeline/skills/shared/external/swift-security/references/keychain-item-classes.md +431 -432
- package/pipeline/skills/shared/external/swift-security/references/keychain-sharing.md +328 -425
- package/pipeline/skills/shared/external/swift-security/references/migration-legacy-stores.md +341 -579
- package/pipeline/skills/shared/external/swift-security/references/secure-enclave.md +396 -457
- package/pipeline/skills/shared/external/swift-security/references/testing-security-code.md +354 -614
- package/pipeline/skills/shared/external/swift-testing/SKILL.md +188 -175
- package/pipeline/skills/shared/external/swift-testing/evals/evals.json +26 -24
- package/pipeline/skills/shared/external/swift-testing/references/testing-advanced.md +80 -84
- package/pipeline/skills/shared/external/swift-testing/references/testing-patterns.md +317 -433
- package/pipeline/skills/shared/external/swiftdata/SKILL.md +392 -256
- package/pipeline/skills/shared/external/swiftdata/evals/evals.json +24 -24
- package/pipeline/skills/shared/external/swiftdata/references/core-data-coexistence.md +206 -402
- package/pipeline/skills/shared/external/swiftdata/references/indexing.md +59 -52
- package/pipeline/skills/shared/external/swiftdata/references/predicate-pitfalls.md +57 -33
- package/pipeline/skills/shared/external/swiftdata/references/swiftdata-advanced.md +354 -747
- package/pipeline/skills/shared/external/swiftdata/references/swiftdata-queries.md +300 -508
- package/pipeline/skills/shared/external/swiftlint/SKILL.md +175 -226
- package/pipeline/skills/shared/external/swiftlint/references/adoption-and-configuration.md +141 -208
- package/pipeline/skills/shared/external/swiftlint/references/custom-rules-and-analyze.md +100 -109
- package/pipeline/skills/shared/external/swiftlint/references/plugins-run-scripts-and-integrations.md +159 -179
- package/pipeline/skills/shared/external/swiftlint/references/rule-reference.md +383 -18
- package/pipeline/skills/shared/external/swiftlint/references/rules-suppressions-and-baselines.md +143 -229
- package/pipeline/skills/shared/external/swiftui-animation/SKILL.md +283 -366
- package/pipeline/skills/shared/external/swiftui-animation/references/animation-advanced.md +396 -608
- package/pipeline/skills/shared/external/swiftui-animation/references/core-animation-bridge.md +336 -385
- package/pipeline/skills/shared/external/swiftui-gestures/SKILL.md +239 -349
- package/pipeline/skills/shared/external/swiftui-gestures/references/gesture-patterns.md +228 -310
- package/pipeline/skills/shared/external/swiftui-layout-components/SKILL.md +260 -249
- package/pipeline/skills/shared/external/swiftui-layout-components/references/form.md +92 -74
- package/pipeline/skills/shared/external/swiftui-layout-components/references/grids.md +112 -177
- package/pipeline/skills/shared/external/swiftui-layout-components/references/list.md +61 -64
- package/pipeline/skills/shared/external/swiftui-layout-components/references/scrollview.md +94 -134
- package/pipeline/skills/shared/external/swiftui-liquid-glass/SKILL.md +193 -225
- package/pipeline/skills/shared/external/swiftui-liquid-glass/references/liquid-glass.md +173 -327
- package/pipeline/skills/shared/external/swiftui-navigation/SKILL.md +193 -168
- package/pipeline/skills/shared/external/swiftui-navigation/references/deeplinks.md +127 -150
- package/pipeline/skills/shared/external/swiftui-navigation/references/navigationstack.md +132 -133
- package/pipeline/skills/shared/external/swiftui-navigation/references/sheets.md +152 -117
- package/pipeline/skills/shared/external/swiftui-navigation/references/tabview.md +106 -140
- package/pipeline/skills/shared/external/swiftui-patterns/SKILL.md +316 -252
- package/pipeline/skills/shared/external/swiftui-patterns/references/architecture-patterns.md +341 -332
- package/pipeline/skills/shared/external/swiftui-patterns/references/deprecated-migration.md +547 -854
- package/pipeline/skills/shared/external/swiftui-patterns/references/design-polish.md +485 -537
- package/pipeline/skills/shared/external/swiftui-patterns/references/platform-and-sharing.md +417 -499
- package/pipeline/skills/shared/external/swiftui-performance/SKILL.md +213 -376
- package/pipeline/skills/shared/external/swiftui-performance/references/demystify-swiftui-performance-wwdc23.md +86 -175
- package/pipeline/skills/shared/external/swiftui-performance/references/optimizing-swiftui-performance-instruments.md +89 -195
- package/pipeline/skills/shared/external/swiftui-performance/references/understanding-hangs-in-your-app.md +95 -182
- package/pipeline/skills/shared/external/swiftui-performance/references/understanding-improving-swiftui-performance.md +71 -149
- package/pipeline/skills/shared/external/swiftui-performance/references/wwdc-session-sources.md +21 -27
- package/pipeline/skills/shared/external/swiftui-uikit-interop/SKILL.md +303 -295
- package/pipeline/skills/shared/external/swiftui-uikit-interop/references/hosting-migration.md +204 -387
- package/pipeline/skills/shared/external/swiftui-uikit-interop/references/representable-recipes.md +469 -683
- package/pipeline/skills/shared/external/swiftui-webkit/SKILL.md +140 -186
- package/pipeline/skills/shared/external/swiftui-webkit/references/loading-and-observation.md +75 -86
- package/pipeline/skills/shared/external/swiftui-webkit/references/local-content-and-custom-schemes.md +63 -60
- package/pipeline/skills/shared/external/swiftui-webkit/references/migration-and-fallbacks.md +69 -137
- package/pipeline/skills/shared/external/swiftui-webkit/references/navigation-and-javascript.md +95 -67
- package/pipeline/skills/shared/external/tipkit/SKILL.md +220 -335
- package/pipeline/skills/shared/external/tipkit/references/tipkit-patterns.md +356 -494
- package/pipeline/skills/shared/external/vision-framework/SKILL.md +260 -375
- package/pipeline/skills/shared/external/vision-framework/references/vision-requests.md +393 -515
- package/pipeline/skills/shared/external/vision-framework/references/visionkit-scanner.md +363 -539
- package/pipeline/skills/shared/external/weatherkit/SKILL.md +152 -310
- package/pipeline/skills/shared/external/weatherkit/references/weatherkit-patterns.md +288 -407
- package/pipeline/skills/shared/external/widgetkit/SKILL.md +216 -288
- package/pipeline/skills/shared/external/widgetkit/references/widgetkit-advanced.md +414 -719
- package/pipeline/skills/shared/external/NOTICE-swift-ios-skills.md +0 -39
package/pipeline/skills/shared/external/photos-camera-media/references/image-loading-caching.md
CHANGED
|
@@ -1,869 +1,525 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Loading, caching and downsampling images
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Everything here uses async/await and targets iOS 26 and Swift 6.3.
|
|
4
|
+
The samples need iOS 17 or later because the models use `@Observable`.
|
|
4
5
|
|
|
5
6
|
## Contents
|
|
6
|
-
- AsyncImage Patterns
|
|
7
|
-
- NSCache-Based In-Memory Cache
|
|
8
|
-
- URLCache-Based Disk Caching
|
|
9
|
-
- Image Downsampling with CGImageSource
|
|
10
|
-
- Image Prefetching for Lists and Grids
|
|
11
|
-
- HEIF/HEIC Handling
|
|
12
|
-
- Compression Before Upload
|
|
13
|
-
- Memory Budget Management
|
|
14
|
-
- Complete Image Loading Pipeline
|
|
15
7
|
|
|
16
|
-
|
|
8
|
+
- [AsyncImage](#asyncimage)
|
|
9
|
+
- [In-memory cache on NSCache](#in-memory-cache-on-nscache)
|
|
10
|
+
- [Disk cache with URLCache](#disk-cache-with-urlcache)
|
|
11
|
+
- [Downsampling at decode time](#downsampling-at-decode-time)
|
|
12
|
+
- [Prefetching](#prefetching)
|
|
13
|
+
- [HEIF and HEIC](#heif-and-heic)
|
|
14
|
+
- [Shrinking before upload](#shrinking-before-upload)
|
|
15
|
+
- [Staying inside a memory budget](#staying-inside-a-memory-budget)
|
|
16
|
+
- [One loader for everything](#one-loader-for-everything)
|
|
17
|
+
- [Which layer for which job](#which-layer-for-which-job)
|
|
17
18
|
|
|
18
|
-
AsyncImage
|
|
19
|
+
## AsyncImage
|
|
19
20
|
|
|
20
|
-
|
|
21
|
+
`AsyncImage` (iOS 15+) is fine for a handful of images and weak for anything
|
|
22
|
+
heavier.
|
|
21
23
|
|
|
22
24
|
```swift
|
|
23
|
-
AsyncImage(url:
|
|
24
|
-
```
|
|
25
|
-
|
|
26
|
-
### Phase Handling
|
|
25
|
+
AsyncImage(url: logoURL)
|
|
27
26
|
|
|
28
|
-
|
|
29
|
-
AsyncImage(url: imageURL) { phase in
|
|
27
|
+
AsyncImage(url: bannerURL) { phase in
|
|
30
28
|
switch phase {
|
|
31
29
|
case .empty:
|
|
32
30
|
ProgressView()
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
image
|
|
36
|
-
.resizable()
|
|
37
|
-
.aspectRatio(contentMode: .fill)
|
|
38
|
-
.frame(width: 200, height: 200)
|
|
39
|
-
.clipped()
|
|
31
|
+
case .success(let banner):
|
|
32
|
+
banner.resizable().aspectRatio(contentMode: .fill)
|
|
40
33
|
case .failure:
|
|
41
|
-
Image(systemName: "photo")
|
|
42
|
-
.font(.largeTitle)
|
|
43
|
-
.foregroundStyle(.secondary)
|
|
44
|
-
.frame(width: 200, height: 200)
|
|
34
|
+
Image(systemName: "photo").foregroundStyle(.secondary)
|
|
45
35
|
@unknown default:
|
|
46
36
|
EmptyView()
|
|
47
37
|
}
|
|
48
38
|
}
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
### Custom Transition
|
|
39
|
+
.frame(width: 200, height: 200)
|
|
40
|
+
.clipped()
|
|
52
41
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
.resizable()
|
|
59
|
-
.aspectRatio(contentMode: .fill)
|
|
60
|
-
.transition(.opacity)
|
|
61
|
-
default:
|
|
62
|
-
Color.secondary.opacity(0.2)
|
|
42
|
+
AsyncImage(url: coverURL, transaction: .init(animation: .easeIn(duration: 0.3))) { phase in
|
|
43
|
+
if let cover = phase.image {
|
|
44
|
+
cover.resizable().scaledToFit().transition(.opacity)
|
|
45
|
+
} else {
|
|
46
|
+
Color.gray.opacity(0.2)
|
|
63
47
|
}
|
|
64
48
|
}
|
|
65
|
-
.frame(width: 200, height: 200)
|
|
66
|
-
.clipShape(.rect(cornerRadius: 12))
|
|
67
49
|
```
|
|
68
50
|
|
|
69
|
-
|
|
51
|
+
What it cannot do:
|
|
70
52
|
|
|
71
|
-
|
|
53
|
+
- Cache across redraws. When the view is rebuilt, the image downloads again.
|
|
54
|
+
- Prefetch rows that are about to scroll into view.
|
|
55
|
+
- Use your own `URLSession`. It rides the shared session with no say over cache
|
|
56
|
+
policy.
|
|
57
|
+
- Expose the downloaded bytes, so no processing, downsampling or persistence.
|
|
58
|
+
- Cancel on your terms. Its work lives and dies with the view.
|
|
72
59
|
|
|
73
|
-
-
|
|
74
|
-
- **No prefetching**: cannot load images ahead of scroll position.
|
|
75
|
-
- **No custom URLSession**: uses the shared session with no cache policy control.
|
|
76
|
-
- **No access to raw data**: cannot process, downsample, or persist the image data.
|
|
77
|
-
- **No cancellation control**: tied entirely to the view lifecycle.
|
|
60
|
+
Past simple, low-volume display, build a loading pipeline like the ones below.
|
|
78
61
|
|
|
79
|
-
|
|
62
|
+
## In-memory cache on NSCache
|
|
80
63
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
An actor-isolated in-memory image cache backed by NSCache. Thread-safe and automatically evicts entries under memory pressure.
|
|
64
|
+
An actor around `NSCache` is safe to call from anywhere, and `NSCache` drops
|
|
65
|
+
entries on its own when memory gets tight. The actor also coalesces requests:
|
|
66
|
+
two callers asking for the same URL share one download.
|
|
86
67
|
|
|
87
68
|
```swift
|
|
88
69
|
import UIKit
|
|
89
70
|
|
|
90
|
-
actor
|
|
91
|
-
static let shared =
|
|
71
|
+
actor PosterCache {
|
|
72
|
+
static let shared = PosterCache()
|
|
92
73
|
|
|
93
|
-
private let
|
|
94
|
-
|
|
74
|
+
private let store: NSCache<NSString, UIImage> = {
|
|
75
|
+
let cache = NSCache<NSString, UIImage>()
|
|
76
|
+
cache.countLimit = 100
|
|
77
|
+
cache.totalCostLimit = 50 * 1024 * 1024
|
|
78
|
+
return cache
|
|
79
|
+
}()
|
|
80
|
+
private var inFlight: [URL: Task<UIImage?, Never>] = [:]
|
|
95
81
|
|
|
96
|
-
|
|
97
|
-
cache.countLimit = countLimit
|
|
98
|
-
cache.totalCostLimit = totalCostLimit // 50 MB default
|
|
99
|
-
}
|
|
82
|
+
private func key(_ address: URL) -> NSString { address.absoluteString as NSString }
|
|
100
83
|
|
|
101
|
-
func image(for
|
|
102
|
-
|
|
84
|
+
func image(for address: URL) -> UIImage? {
|
|
85
|
+
store.object(forKey: key(address))
|
|
103
86
|
}
|
|
104
87
|
|
|
105
|
-
func store(_
|
|
106
|
-
|
|
107
|
-
cache.setObject(image, forKey: url.absoluteString as NSString, cost: cost)
|
|
88
|
+
func store(_ picture: UIImage, for address: URL) {
|
|
89
|
+
store.setObject(picture, forKey: key(address), cost: Self.bitmapBytes(picture))
|
|
108
90
|
}
|
|
109
91
|
|
|
110
|
-
func removeImage(for
|
|
111
|
-
|
|
92
|
+
func removeImage(for address: URL) {
|
|
93
|
+
store.removeObject(forKey: key(address))
|
|
112
94
|
}
|
|
113
95
|
|
|
114
96
|
func removeAll() {
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
// Coalesce duplicate in-flight requests
|
|
128
|
-
if let existingTask = inFlightTasks[url] {
|
|
129
|
-
return await existingTask.value
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
let task = Task<UIImage?, Never> {
|
|
133
|
-
defer { inFlightTasks[url] = nil }
|
|
134
|
-
|
|
135
|
-
guard let (data, response) = try? await session.data(from: url),
|
|
136
|
-
let httpResponse = response as? HTTPURLResponse,
|
|
137
|
-
httpResponse.statusCode == 200,
|
|
138
|
-
let image = UIImage(data: data) else {
|
|
139
|
-
return nil
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
store(image, for: url)
|
|
143
|
-
return image
|
|
97
|
+
store.removeAllObjects()
|
|
98
|
+
inFlight.values.forEach { $0.cancel() }
|
|
99
|
+
inFlight.removeAll()
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
func fetch(from address: URL, session: URLSession = .shared) async -> UIImage? {
|
|
103
|
+
if let hit = image(for: address) { return hit }
|
|
104
|
+
if let running = inFlight[address] { return await running.value }
|
|
105
|
+
let download = Task { () -> UIImage? in
|
|
106
|
+
guard let (body, reply) = try? await session.data(from: address),
|
|
107
|
+
(reply as? HTTPURLResponse)?.statusCode == 200 else { return nil }
|
|
108
|
+
return UIImage(data: body)
|
|
144
109
|
}
|
|
110
|
+
inFlight[address] = download
|
|
111
|
+
defer { inFlight[address] = nil }
|
|
112
|
+
let result = await download.value
|
|
113
|
+
if let result { store(result, for: address) }
|
|
114
|
+
return result
|
|
115
|
+
}
|
|
145
116
|
|
|
146
|
-
|
|
147
|
-
return
|
|
117
|
+
static func bitmapBytes(_ image: UIImage) -> Int {
|
|
118
|
+
guard let bitmap = image.cgImage else { return 0 }
|
|
119
|
+
return bitmap.bytesPerRow * bitmap.height
|
|
148
120
|
}
|
|
149
121
|
}
|
|
150
122
|
```
|
|
151
123
|
|
|
152
|
-
|
|
124
|
+
Price each entry by its decoded bitmap size. Encoding a JPEG just to measure an
|
|
125
|
+
entry is slow and reports compressed bytes, which is not what the cache holds
|
|
126
|
+
in memory.
|
|
153
127
|
|
|
154
128
|
```swift
|
|
155
|
-
struct
|
|
129
|
+
struct PosterView: View {
|
|
156
130
|
let url: URL
|
|
157
|
-
@State private var
|
|
131
|
+
@State private var poster: UIImage?
|
|
158
132
|
|
|
159
133
|
var body: some View {
|
|
160
134
|
Group {
|
|
161
|
-
if let
|
|
162
|
-
Image(uiImage: image)
|
|
163
|
-
.resizable()
|
|
164
|
-
.aspectRatio(contentMode: .fill)
|
|
165
|
-
} else {
|
|
166
|
-
Color.secondary.opacity(0.2)
|
|
167
|
-
.overlay(ProgressView())
|
|
168
|
-
}
|
|
169
|
-
}
|
|
170
|
-
.task(id: url) {
|
|
171
|
-
image = await ImageCache.shared.fetch(from: url)
|
|
135
|
+
if let poster { Image(uiImage: poster).resizable().scaledToFill() } else { ProgressView() }
|
|
172
136
|
}
|
|
137
|
+
.task(id: url) { poster = await PosterCache.shared.fetch(from: url) }
|
|
173
138
|
}
|
|
174
139
|
}
|
|
175
140
|
```
|
|
176
141
|
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
## URLCache-Based Disk Caching
|
|
142
|
+
## Disk cache with URLCache
|
|
180
143
|
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
### Configuring a Dedicated URLSession
|
|
144
|
+
`URLCache` survives relaunches and honours the server's HTTP cache headers with
|
|
145
|
+
no extra code. Give images their own session and cache:
|
|
184
146
|
|
|
185
147
|
```swift
|
|
186
|
-
enum
|
|
187
|
-
static
|
|
188
|
-
let
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
config.httpMaximumConnectionsPerHost = 6
|
|
199
|
-
|
|
200
|
-
return URLSession(configuration: config)
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
private static var cacheDirectory: URL {
|
|
204
|
-
FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0]
|
|
205
|
-
.appendingPathComponent("ImageCache", isDirectory: true)
|
|
206
|
-
}
|
|
148
|
+
enum ImageNetwork {
|
|
149
|
+
static let session: URLSession = {
|
|
150
|
+
let folder = URL.cachesDirectory.appending(path: "ImageCache")
|
|
151
|
+
let megabyte = 1024 * 1024
|
|
152
|
+
let setup = URLSessionConfiguration.default
|
|
153
|
+
setup.urlCache = URLCache(memoryCapacity: 50 * megabyte,
|
|
154
|
+
diskCapacity: 200 * megabyte,
|
|
155
|
+
directory: folder)
|
|
156
|
+
setup.requestCachePolicy = .returnCacheDataElseLoad
|
|
157
|
+
setup.httpMaximumConnectionsPerHost = 6
|
|
158
|
+
return URLSession(configuration: setup)
|
|
159
|
+
}()
|
|
207
160
|
}
|
|
208
|
-
```
|
|
209
161
|
|
|
210
|
-
|
|
162
|
+
let cached = URLRequest(url: artURL, cachePolicy: .returnCacheDataElseLoad)
|
|
163
|
+
let fresh = URLRequest(url: artURL, cachePolicy: .reloadIgnoringLocalCacheData)
|
|
211
164
|
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
let request = URLRequest(url: url, cachePolicy: .returnCacheDataElseLoad)
|
|
215
|
-
let (data, _) = try await session.data(for: request)
|
|
216
|
-
return data
|
|
217
|
-
}
|
|
165
|
+
ImageNetwork.session.configuration.urlCache?.removeAllCachedResponses()
|
|
166
|
+
ImageNetwork.session.configuration.urlCache?.removeCachedResponse(for: cached)
|
|
218
167
|
```
|
|
219
168
|
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
```swift
|
|
223
|
-
func refreshImageData(from url: URL, session: URLSession) async throws -> Data {
|
|
224
|
-
let request = URLRequest(url: url, cachePolicy: .reloadIgnoringLocalCacheData)
|
|
225
|
-
let (data, _) = try await session.data(for: request)
|
|
226
|
-
return data
|
|
227
|
-
}
|
|
228
|
-
```
|
|
169
|
+
## Downsampling at decode time
|
|
229
170
|
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
```swift
|
|
233
|
-
func cleanImageCache(session: URLSession) {
|
|
234
|
-
session.configuration.urlCache?.removeAllCachedResponses()
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
func removeCachedImage(for url: URL, session: URLSession) {
|
|
238
|
-
let request = URLRequest(url: url)
|
|
239
|
-
session.configuration.urlCache?.removeCachedResponse(for: request)
|
|
240
|
-
}
|
|
241
|
-
```
|
|
242
|
-
|
|
243
|
-
---
|
|
244
|
-
|
|
245
|
-
## Image Downsampling with CGImageSource
|
|
246
|
-
|
|
247
|
-
Loading a full-resolution image into memory then scaling it in the view wastes significant memory. Downsampling at decode time creates a smaller bitmap directly.
|
|
248
|
-
|
|
249
|
-
### Downsample Function
|
|
171
|
+
Decoding at full size and letting the view scale it down wastes memory.
|
|
172
|
+
`CGImageSource` can decode straight into a small bitmap instead.
|
|
250
173
|
|
|
251
174
|
```swift
|
|
252
175
|
import ImageIO
|
|
253
|
-
import UIKit
|
|
254
176
|
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
kCGImageSourceCreateThumbnailFromImageAlways: true,
|
|
260
|
-
kCGImageSourceShouldCacheImmediately: true,
|
|
261
|
-
kCGImageSourceCreateThumbnailWithTransform: true,
|
|
262
|
-
kCGImageSourceThumbnailMaxPixelSize: maxDimensionInPixels
|
|
263
|
-
]
|
|
264
|
-
|
|
265
|
-
guard let source = CGImageSourceCreateWithData(data as CFData, nil),
|
|
266
|
-
let cgImage = CGImageSourceCreateThumbnailAtIndex(source, 0, options as CFDictionary) else {
|
|
267
|
-
return nil
|
|
177
|
+
enum Shrinker {
|
|
178
|
+
static func fromData(_ bytes: Data, maxPixels: CGFloat) -> UIImage? {
|
|
179
|
+
guard let reader = CGImageSourceCreateWithData(bytes as CFData, nil) else { return nil }
|
|
180
|
+
return render(reader, maxPixels: maxPixels)
|
|
268
181
|
}
|
|
269
182
|
|
|
270
|
-
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
### Downsample from URL
|
|
275
|
-
|
|
276
|
-
```swift
|
|
277
|
-
func downsample(url: URL, to pointSize: CGSize, scale: CGFloat = UITraitCollection.current.displayScale) -> UIImage? {
|
|
278
|
-
let maxDimensionInPixels = max(pointSize.width, pointSize.height) * scale
|
|
279
|
-
|
|
280
|
-
let options: [CFString: Any] = [
|
|
281
|
-
kCGImageSourceCreateThumbnailFromImageAlways: true,
|
|
282
|
-
kCGImageSourceShouldCacheImmediately: true,
|
|
283
|
-
kCGImageSourceCreateThumbnailWithTransform: true,
|
|
284
|
-
kCGImageSourceThumbnailMaxPixelSize: maxDimensionInPixels
|
|
285
|
-
]
|
|
286
|
-
|
|
287
|
-
guard let source = CGImageSourceCreateWithURL(url as CFURL, nil),
|
|
288
|
-
let cgImage = CGImageSourceCreateThumbnailAtIndex(source, 0, options as CFDictionary) else {
|
|
289
|
-
return nil
|
|
183
|
+
static func fromFile(_ file: URL, maxPixels: CGFloat) -> UIImage? {
|
|
184
|
+
guard let reader = CGImageSourceCreateWithURL(file as CFURL, nil) else { return nil }
|
|
185
|
+
return render(reader, maxPixels: maxPixels)
|
|
290
186
|
}
|
|
291
187
|
|
|
292
|
-
|
|
188
|
+
private static func render(_ reader: CGImageSource, maxPixels: CGFloat) -> UIImage? {
|
|
189
|
+
let plan: [CFString: Any] = [
|
|
190
|
+
kCGImageSourceThumbnailMaxPixelSize: maxPixels,
|
|
191
|
+
kCGImageSourceShouldCacheImmediately: true,
|
|
192
|
+
kCGImageSourceCreateThumbnailFromImageAlways: true,
|
|
193
|
+
kCGImageSourceCreateThumbnailWithTransform: true,
|
|
194
|
+
]
|
|
195
|
+
return CGImageSourceCreateThumbnailAtIndex(reader, 0, plan as CFDictionary).map(UIImage.init(cgImage:))
|
|
196
|
+
}
|
|
293
197
|
}
|
|
294
198
|
```
|
|
295
199
|
|
|
296
|
-
|
|
200
|
+
Downsample when:
|
|
297
201
|
|
|
298
|
-
-
|
|
299
|
-
|
|
300
|
-
-
|
|
301
|
-
-
|
|
202
|
+
- showing thumbnails in lists or grids (a 4032 x 3024 photo in a 100 x 100 cell
|
|
203
|
+
wastes roughly 46 MB per cell otherwise);
|
|
204
|
+
- showing photos the person picked from the library;
|
|
205
|
+
- the server sends images larger than they will be drawn;
|
|
206
|
+
- as a rule of thumb, the source is more than twice the display size in points.
|
|
302
207
|
|
|
303
|
-
|
|
208
|
+
Decoded memory at 3x scale, downsampled against a full decode:
|
|
304
209
|
|
|
305
|
-
| Source
|
|
210
|
+
| Source | Drawn at | Downsampled | Full decode |
|
|
306
211
|
|---|---|---|---|
|
|
307
|
-
|
|
|
308
|
-
|
|
|
309
|
-
|
|
|
310
|
-
|
|
311
|
-
---
|
|
212
|
+
| 4032 x 3024 | 100 x 100 pt | ~0.35 MB | ~46 MB |
|
|
213
|
+
| 4032 x 3024 | 300 x 300 pt | ~3.1 MB | ~46 MB |
|
|
214
|
+
| 1920 x 1080 | 100 x 100 pt | ~0.35 MB | ~7.9 MB |
|
|
312
215
|
|
|
313
|
-
##
|
|
216
|
+
## Prefetching
|
|
314
217
|
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
### Prefetch Coordinator
|
|
218
|
+
Start downloads a little before cells appear. This pairs with
|
|
219
|
+
`UICollectionView` prefetching and with SwiftUI grids alike.
|
|
318
220
|
|
|
319
221
|
```swift
|
|
320
|
-
@Observable
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
private let cache: ImageCache
|
|
324
|
-
private let session: URLSession
|
|
325
|
-
private var prefetchTasks: [URL: Task<Void, Never>] = [:]
|
|
326
|
-
|
|
327
|
-
init(cache: ImageCache = .shared,
|
|
328
|
-
session: URLSession = ImageSessionConfiguration.makeSession()) {
|
|
329
|
-
self.cache = cache
|
|
330
|
-
self.session = session
|
|
331
|
-
}
|
|
222
|
+
@Observable @MainActor
|
|
223
|
+
final class ArtPrefetcher {
|
|
224
|
+
private var jobs: [URL: Task<Void, Never>] = [:]
|
|
332
225
|
|
|
333
226
|
func prefetch(urls: [URL]) {
|
|
334
|
-
for url in urls {
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
_ = await cache.fetch(from: url, session: session)
|
|
339
|
-
prefetchTasks[url] = nil
|
|
227
|
+
for url in urls where jobs[url] == nil {
|
|
228
|
+
jobs[url] = Task {
|
|
229
|
+
_ = await PosterCache.shared.fetch(from: url)
|
|
230
|
+
jobs[url] = nil
|
|
340
231
|
}
|
|
341
232
|
}
|
|
342
233
|
}
|
|
343
234
|
|
|
344
235
|
func cancelPrefetch(urls: [URL]) {
|
|
345
236
|
for url in urls {
|
|
346
|
-
|
|
347
|
-
|
|
237
|
+
jobs[url]?.cancel()
|
|
238
|
+
jobs[url] = nil
|
|
348
239
|
}
|
|
349
240
|
}
|
|
350
241
|
|
|
351
242
|
func cancelAll() {
|
|
352
|
-
|
|
353
|
-
|
|
243
|
+
jobs.values.forEach { $0.cancel() }
|
|
244
|
+
jobs.removeAll()
|
|
354
245
|
}
|
|
355
246
|
}
|
|
356
|
-
```
|
|
357
|
-
|
|
358
|
-
### SwiftUI Integration with ScrollView and LazyVGrid
|
|
359
|
-
|
|
360
|
-
```swift
|
|
361
|
-
struct PhotoGrid: View {
|
|
362
|
-
let photos: [Photo]
|
|
363
|
-
@State private var prefetcher = ImagePrefetcher()
|
|
364
247
|
|
|
248
|
+
struct ArtGrid: View {
|
|
249
|
+
let urls: [URL]
|
|
250
|
+
@State private var prefetcher = ArtPrefetcher()
|
|
365
251
|
private let columns = [GridItem(.adaptive(minimum: 100), spacing: 2)]
|
|
366
252
|
|
|
367
253
|
var body: some View {
|
|
368
254
|
ScrollView {
|
|
369
|
-
LazyVGrid(columns: columns
|
|
370
|
-
ForEach(
|
|
371
|
-
|
|
372
|
-
.
|
|
373
|
-
.clipped()
|
|
255
|
+
LazyVGrid(columns: columns) {
|
|
256
|
+
ForEach(Array(urls.enumerated()), id: \.offset) { index, url in
|
|
257
|
+
PosterView(url: url)
|
|
258
|
+
.frame(height: 100)
|
|
374
259
|
.onAppear {
|
|
375
|
-
|
|
260
|
+
let ahead = urls[(index + 1)..<min(index + 11, urls.count)]
|
|
261
|
+
prefetcher.prefetch(urls: Array(ahead))
|
|
376
262
|
}
|
|
377
263
|
}
|
|
378
264
|
}
|
|
379
265
|
}
|
|
380
|
-
.onDisappear {
|
|
381
|
-
prefetcher.cancelAll()
|
|
382
|
-
}
|
|
383
|
-
}
|
|
384
|
-
|
|
385
|
-
private func prefetchNearby(_ photo: Photo) {
|
|
386
|
-
guard let index = photos.firstIndex(where: { $0.id == photo.id }) else { return }
|
|
387
|
-
let prefetchRange = (index + 1)..<min(index + 10, photos.count)
|
|
388
|
-
let urls = prefetchRange.map { photos[$0].thumbnailURL }
|
|
389
|
-
prefetcher.prefetch(urls: urls)
|
|
266
|
+
.onDisappear { prefetcher.cancelAll() }
|
|
390
267
|
}
|
|
391
268
|
}
|
|
392
269
|
```
|
|
393
270
|
|
|
394
|
-
|
|
271
|
+
In UIKit, a `UICollectionViewDataSourcePrefetching` object maps index paths to
|
|
272
|
+
URLs in `collectionView(_:prefetchItemsAt:)` and calls `cancelPrefetch` from
|
|
273
|
+
`collectionView(_:cancelPrefetchingForItemsAt:)`.
|
|
395
274
|
|
|
396
|
-
|
|
397
|
-
final class PhotoCollectionPrefetcher: NSObject, UICollectionViewDataSourcePrefetching {
|
|
398
|
-
private let prefetcher = ImagePrefetcher()
|
|
399
|
-
private let photos: [Photo]
|
|
275
|
+
## HEIF and HEIC
|
|
400
276
|
|
|
401
|
-
|
|
402
|
-
self.photos = photos
|
|
403
|
-
}
|
|
404
|
-
|
|
405
|
-
func collectionView(_ collectionView: UICollectionView,
|
|
406
|
-
prefetchItemsAt indexPaths: [IndexPath]) {
|
|
407
|
-
let urls = indexPaths.map { photos[$0.item].thumbnailURL }
|
|
408
|
-
prefetcher.prefetch(urls: urls)
|
|
409
|
-
}
|
|
410
|
-
|
|
411
|
-
func collectionView(_ collectionView: UICollectionView,
|
|
412
|
-
cancelPrefetchingForItemsAt indexPaths: [IndexPath]) {
|
|
413
|
-
let urls = indexPaths.map { photos[$0.item].thumbnailURL }
|
|
414
|
-
prefetcher.cancelPrefetch(urls: urls)
|
|
415
|
-
}
|
|
416
|
-
}
|
|
417
|
-
```
|
|
418
|
-
|
|
419
|
-
---
|
|
420
|
-
|
|
421
|
-
## HEIF/HEIC Handling
|
|
422
|
-
|
|
423
|
-
HEIF (High Efficiency Image Format) is the default camera format on modern iPhones. Handle detection, display, and conversion.
|
|
424
|
-
|
|
425
|
-
### Detection
|
|
277
|
+
HEIF is what current iPhones shoot by default.
|
|
426
278
|
|
|
427
279
|
```swift
|
|
428
280
|
import UniformTypeIdentifiers
|
|
429
281
|
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
return ftypRange.elementsEqual("ftyp".utf8)
|
|
435
|
-
}
|
|
436
|
-
|
|
437
|
-
func isHEIF(url: URL) -> Bool {
|
|
438
|
-
guard let type = UTType(filenameExtension: url.pathExtension) else { return false }
|
|
439
|
-
return type.conforms(to: .heif) || type.conforms(to: .heic)
|
|
440
|
-
}
|
|
441
|
-
```
|
|
442
|
-
|
|
443
|
-
### Conversion to JPEG
|
|
444
|
-
|
|
445
|
-
```swift
|
|
446
|
-
func convertHEICToJPEG(data: Data, compressionQuality: CGFloat = 0.9) -> Data? {
|
|
447
|
-
guard let image = UIImage(data: data) else { return nil }
|
|
448
|
-
return image.jpegData(compressionQuality: compressionQuality)
|
|
449
|
-
}
|
|
450
|
-
```
|
|
451
|
-
|
|
452
|
-
### Conversion to PNG
|
|
453
|
-
|
|
454
|
-
```swift
|
|
455
|
-
func convertHEICToPNG(data: Data) -> Data? {
|
|
456
|
-
guard let image = UIImage(data: data) else { return nil }
|
|
457
|
-
return image.pngData()
|
|
458
|
-
}
|
|
459
|
-
```
|
|
460
|
-
|
|
461
|
-
### Conversion with CGImageDestination (More Control)
|
|
462
|
-
|
|
463
|
-
```swift
|
|
464
|
-
import ImageIO
|
|
465
|
-
|
|
466
|
-
func convertHEICToJPEG(sourceData: Data,
|
|
467
|
-
quality: CGFloat = 0.9,
|
|
468
|
-
preserveMetadata: Bool = true) -> Data? {
|
|
469
|
-
guard let source = CGImageSourceCreateWithData(sourceData as CFData, nil),
|
|
470
|
-
let cgImage = CGImageSourceCreateImageAtIndex(source, 0, nil) else {
|
|
471
|
-
return nil
|
|
282
|
+
enum HEIFTools {
|
|
283
|
+
static func looksLikeHEIF(_ bytes: Data) -> Bool {
|
|
284
|
+
guard bytes.count >= 12 else { return false }
|
|
285
|
+
return bytes[bytes.startIndex + 4 ..< bytes.startIndex + 8] == Data("ftyp".utf8)
|
|
472
286
|
}
|
|
473
287
|
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
) else {
|
|
478
|
-
return nil
|
|
288
|
+
static func isHEIF(fileExtension: String) -> Bool {
|
|
289
|
+
guard let kind = UTType(filenameExtension: fileExtension) else { return false }
|
|
290
|
+
return kind.conforms(to: .heif) || kind.conforms(to: .heic)
|
|
479
291
|
}
|
|
480
292
|
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
293
|
+
static func jpeg(from photo: UIImage, quality: CGFloat = 0.9) -> Data? {
|
|
294
|
+
photo.jpegData(compressionQuality: quality)
|
|
295
|
+
}
|
|
484
296
|
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
let metadata = CGImageSourceCopyPropertiesAtIndex(source, 0, nil) {
|
|
488
|
-
options[kCGImageDestinationMergeMetadata] = true
|
|
489
|
-
CGImageDestinationAddImage(destination, cgImage, metadata)
|
|
490
|
-
} else {
|
|
491
|
-
CGImageDestinationAddImage(destination, cgImage, options as CFDictionary)
|
|
297
|
+
static func png(from photo: UIImage) -> Data? {
|
|
298
|
+
photo.pngData()
|
|
492
299
|
}
|
|
493
300
|
|
|
494
|
-
|
|
495
|
-
|
|
301
|
+
static func jpegKeepingMetadata(_ heif: Data, quality: CGFloat = 0.9) -> Data? {
|
|
302
|
+
guard let reader = CGImageSourceCreateWithData(heif as CFData, nil),
|
|
303
|
+
let frame = CGImageSourceCreateImageAtIndex(reader, 0, nil) else { return nil }
|
|
304
|
+
let sink = NSMutableData()
|
|
305
|
+
let jpegID = UTType.jpeg.identifier as CFString
|
|
306
|
+
guard let writer = CGImageDestinationCreateWithData(sink, jpegID, 1, nil)
|
|
307
|
+
else { return nil }
|
|
308
|
+
var properties = (CGImageSourceCopyPropertiesAtIndex(reader, 0, nil) as? [CFString: Any]) ?? [:]
|
|
309
|
+
properties[kCGImageDestinationLossyCompressionQuality] = quality
|
|
310
|
+
properties[kCGImageDestinationMergeMetadata] = true
|
|
311
|
+
CGImageDestinationAddImage(writer, frame, properties as CFDictionary)
|
|
312
|
+
return CGImageDestinationFinalize(writer) ? sink as Data : nil
|
|
313
|
+
}
|
|
496
314
|
}
|
|
497
315
|
```
|
|
498
316
|
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
317
|
+
The `ftyp` check is a cheap first look at the container box; confirm with
|
|
318
|
+
`CGImageSourceGetType` when it matters. The metadata-preserving path copies the
|
|
319
|
+
source properties (EXIF, GPS) into the destination; the `UIImage` paths drop
|
|
320
|
+
them.
|
|
502
321
|
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
### JPEG Compression with Target Size
|
|
322
|
+
## Shrinking before upload
|
|
506
323
|
|
|
507
324
|
```swift
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
return data
|
|
325
|
+
enum UploadPrep {
|
|
326
|
+
static func jpeg(_ photo: UIImage, under maxBytes: Int = 1_000_000) -> Data? {
|
|
327
|
+
var quality: CGFloat = 0.9
|
|
328
|
+
while quality > 0.1 {
|
|
329
|
+
if let encoded = photo.jpegData(compressionQuality: quality), encoded.count <= maxBytes {
|
|
330
|
+
return encoded
|
|
331
|
+
}
|
|
332
|
+
quality -= 0.1
|
|
517
333
|
}
|
|
518
|
-
|
|
519
|
-
}
|
|
520
|
-
|
|
521
|
-
// Final attempt at minimum quality
|
|
522
|
-
return image.jpegData(compressionQuality: 0.1)
|
|
523
|
-
}
|
|
524
|
-
```
|
|
525
|
-
|
|
526
|
-
### Resize and Compress
|
|
527
|
-
|
|
528
|
-
```swift
|
|
529
|
-
func resizeAndCompress(image: UIImage,
|
|
530
|
-
maxDimension: CGFloat = 1920,
|
|
531
|
-
compressionQuality: CGFloat = 0.8) -> Data? {
|
|
532
|
-
let size = image.size
|
|
533
|
-
let scale: CGFloat
|
|
534
|
-
|
|
535
|
-
if max(size.width, size.height) > maxDimension {
|
|
536
|
-
scale = maxDimension / max(size.width, size.height)
|
|
537
|
-
} else {
|
|
538
|
-
scale = 1.0
|
|
334
|
+
return photo.jpegData(compressionQuality: 0.1)
|
|
539
335
|
}
|
|
540
336
|
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
337
|
+
static func resized(_ photo: UIImage, longestSide: CGFloat = 1920) -> Data? {
|
|
338
|
+
let ratio = min(1, longestSide / max(photo.size.width, photo.size.height))
|
|
339
|
+
let target = CGSize(width: photo.size.width * ratio, height: photo.size.height * ratio)
|
|
340
|
+
let redrawn = UIGraphicsImageRenderer(size: target).image { _ in
|
|
341
|
+
photo.draw(in: CGRect(x: 0, y: 0, width: target.width, height: target.height))
|
|
342
|
+
}
|
|
343
|
+
return redrawn.jpegData(compressionQuality: 0.8)
|
|
546
344
|
}
|
|
547
345
|
|
|
548
|
-
|
|
549
|
-
}
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
guard let cgImage = image.cgImage else { return nil }
|
|
557
|
-
|
|
558
|
-
let mutableData = NSMutableData()
|
|
559
|
-
guard let destination = CGImageDestinationCreateWithData(
|
|
560
|
-
mutableData, UTType.heic.identifier as CFString, 1, nil
|
|
561
|
-
) else {
|
|
562
|
-
return nil
|
|
346
|
+
static func heic(_ photo: UIImage, level: CGFloat = 0.8) -> Data? {
|
|
347
|
+
guard let bitmap = photo.cgImage else { return nil }
|
|
348
|
+
let sink = NSMutableData()
|
|
349
|
+
let heicID = UTType.heic.identifier as CFString
|
|
350
|
+
guard let writer = CGImageDestinationCreateWithData(sink, heicID, 1, nil)
|
|
351
|
+
else { return nil }
|
|
352
|
+
CGImageDestinationAddImage(writer, bitmap, [kCGImageDestinationLossyCompressionQuality: level] as CFDictionary)
|
|
353
|
+
return CGImageDestinationFinalize(writer) ? sink as Data : nil
|
|
563
354
|
}
|
|
564
|
-
|
|
565
|
-
let options: [CFString: Any] = [
|
|
566
|
-
kCGImageDestinationLossyCompressionQuality: quality
|
|
567
|
-
]
|
|
568
|
-
|
|
569
|
-
CGImageDestinationAddImage(destination, cgImage, options as CFDictionary)
|
|
570
|
-
guard CGImageDestinationFinalize(destination) else { return nil }
|
|
571
|
-
|
|
572
|
-
return mutableData as Data
|
|
573
355
|
}
|
|
574
356
|
```
|
|
575
357
|
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
358
|
+
Pick the trade-off per use: the quality ladder steps down from 0.9 in 0.1
|
|
359
|
+
steps until the payload fits, with 0.1 as the floor; resizing caps the long
|
|
360
|
+
edge first; HEIC gives smaller files at the same visual quality.
|
|
579
361
|
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
### Memory Warning Observer
|
|
362
|
+
## Staying inside a memory budget
|
|
583
363
|
|
|
584
364
|
```swift
|
|
585
|
-
@Observable
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
var
|
|
365
|
+
@Observable @MainActor
|
|
366
|
+
final class PressureWatch {
|
|
367
|
+
private(set) var underPressure = false
|
|
368
|
+
@ObservationIgnored private var listener: Task<Void, Never>?
|
|
369
|
+
private let cooldown: Duration = .seconds(10)
|
|
589
370
|
|
|
590
371
|
init() {
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
queue: .main
|
|
595
|
-
) { [weak self] _ in
|
|
596
|
-
self?.handleMemoryWarning()
|
|
372
|
+
listener = Task { [weak self] in
|
|
373
|
+
let warnings = NotificationCenter.default.notifications(named: UIApplication.didReceiveMemoryWarningNotification)
|
|
374
|
+
for await _ in warnings { self?.react() }
|
|
597
375
|
}
|
|
598
376
|
}
|
|
599
377
|
|
|
600
|
-
private func
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
Task {
|
|
604
|
-
await ImageCache.shared.removeAll()
|
|
605
|
-
}
|
|
606
|
-
|
|
607
|
-
// Reset after a delay
|
|
378
|
+
private func react() {
|
|
379
|
+
underPressure = true
|
|
608
380
|
Task {
|
|
609
|
-
|
|
610
|
-
|
|
381
|
+
await PosterCache.shared.removeAll()
|
|
382
|
+
try? await Task.sleep(for: cooldown)
|
|
383
|
+
underPressure = false
|
|
611
384
|
}
|
|
612
385
|
}
|
|
613
386
|
}
|
|
614
|
-
```
|
|
615
387
|
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
$0.withMemoryRebound(to: integer_t.self, capacity: Int(count)) {
|
|
624
|
-
task_info(mach_task_self_, task_flavor_t(MACH_TASK_BASIC_INFO), $0, &count)
|
|
388
|
+
func residentMemory() -> UInt64 {
|
|
389
|
+
var usage = mach_task_basic_info()
|
|
390
|
+
var words = mach_msg_type_number_t(MemoryLayout<mach_task_basic_info>.size / MemoryLayout<natural_t>.size)
|
|
391
|
+
let slotCount = Int(words)
|
|
392
|
+
let status = withUnsafeMutablePointer(to: &usage) { raw in
|
|
393
|
+
raw.withMemoryRebound(to: integer_t.self, capacity: slotCount) { slots in
|
|
394
|
+
task_info(mach_task_self_, task_flavor_t(MACH_TASK_BASIC_INFO), slots, &words)
|
|
625
395
|
}
|
|
626
396
|
}
|
|
627
|
-
|
|
628
|
-
return Double(info.resident_size) / (1024 * 1024)
|
|
397
|
+
return status == KERN_SUCCESS ? usage.resident_size : 0
|
|
629
398
|
}
|
|
630
399
|
```
|
|
631
400
|
|
|
632
|
-
|
|
401
|
+
Size the cache from the device instead of a constant:
|
|
633
402
|
|
|
634
403
|
```swift
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
init() {
|
|
639
|
-
let totalMemory = ProcessInfo.processInfo.physicalMemory
|
|
640
|
-
// Use at most 10% of physical memory for image cache
|
|
641
|
-
let budgetBytes = Int(totalMemory / 10)
|
|
642
|
-
cache.totalCostLimit = budgetBytes
|
|
643
|
-
cache.countLimit = 200
|
|
644
|
-
}
|
|
645
|
-
|
|
646
|
-
func image(for url: URL) -> UIImage? {
|
|
647
|
-
cache.object(forKey: url.absoluteString as NSString)
|
|
648
|
-
}
|
|
649
|
-
|
|
650
|
-
func store(_ image: UIImage, for url: URL) {
|
|
651
|
-
let cost = cgImageMemorySize(image)
|
|
652
|
-
cache.setObject(image, forKey: url.absoluteString as NSString, cost: cost)
|
|
653
|
-
}
|
|
654
|
-
|
|
655
|
-
/// Estimate the decoded bitmap size in bytes.
|
|
656
|
-
private func cgImageMemorySize(_ image: UIImage) -> Int {
|
|
657
|
-
guard let cgImage = image.cgImage else { return 0 }
|
|
658
|
-
return cgImage.bytesPerRow * cgImage.height
|
|
659
|
-
}
|
|
660
|
-
|
|
661
|
-
func purge() {
|
|
662
|
-
cache.removeAllObjects()
|
|
663
|
-
}
|
|
664
|
-
}
|
|
404
|
+
let adaptive = NSCache<NSString, UIImage>()
|
|
405
|
+
adaptive.totalCostLimit = Int(ProcessInfo.processInfo.physicalMemory / 10)
|
|
406
|
+
adaptive.countLimit = 200
|
|
665
407
|
```
|
|
666
408
|
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
## Complete Image Loading Pipeline
|
|
409
|
+
Charge each entry `cgImage.bytesPerRow * cgImage.height`, its real decoded
|
|
410
|
+
footprint.
|
|
670
411
|
|
|
671
|
-
|
|
412
|
+
## One loader for everything
|
|
672
413
|
|
|
673
|
-
|
|
414
|
+
`GalleryLoader` stacks the layers: memory cache, `URLCache` on disk,
|
|
415
|
+
downsampling and request coalescing.
|
|
674
416
|
|
|
675
417
|
```swift
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
actor ImageLoader {
|
|
680
|
-
static let shared = ImageLoader()
|
|
418
|
+
actor GalleryLoader {
|
|
419
|
+
static let shared = GalleryLoader()
|
|
681
420
|
|
|
682
|
-
private let
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
let
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
if let cached = memoryCache.object(forKey: cacheKey as NSString) {
|
|
707
|
-
return cached
|
|
708
|
-
}
|
|
709
|
-
|
|
710
|
-
// 2. Coalesce duplicate in-flight requests
|
|
711
|
-
if let existing = inFlightTasks[url] {
|
|
712
|
-
return await existing.value
|
|
713
|
-
}
|
|
714
|
-
|
|
715
|
-
let task = Task<UIImage?, Never> {
|
|
716
|
-
defer { inFlightTasks[url] = nil }
|
|
717
|
-
|
|
718
|
-
guard let (data, response) = try? await session.data(from: url),
|
|
719
|
-
let httpResponse = response as? HTTPURLResponse,
|
|
720
|
-
httpResponse.statusCode == 200 else {
|
|
721
|
-
return nil
|
|
722
|
-
}
|
|
723
|
-
|
|
724
|
-
// 3. Downsample if a display size is provided
|
|
725
|
-
let image: UIImage?
|
|
421
|
+
private let memory: NSCache<NSString, UIImage> = {
|
|
422
|
+
let cache = NSCache<NSString, UIImage>()
|
|
423
|
+
cache.totalCostLimit = Int(ProcessInfo.processInfo.physicalMemory / 10)
|
|
424
|
+
cache.countLimit = 200
|
|
425
|
+
return cache
|
|
426
|
+
}()
|
|
427
|
+
private let network = ImageNetwork.session
|
|
428
|
+
private var running: [String: Task<UIImage?, Never>] = [:]
|
|
429
|
+
|
|
430
|
+
private func key(_ url: URL, _ size: CGSize?) -> String {
|
|
431
|
+
guard let size else { return url.absoluteString }
|
|
432
|
+
let wide = Int(size.width), tall = Int(size.height)
|
|
433
|
+
return "\(url.absoluteString)_\(wide)x\(tall)"
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
func load(_ url: URL, displaySize: CGSize? = nil, scale: CGFloat = 3) async -> UIImage? {
|
|
437
|
+
let slot = key(url, displaySize)
|
|
438
|
+
if let hit = memory.object(forKey: slot as NSString) { return hit }
|
|
439
|
+
if let shared = running[slot] { return await shared.value }
|
|
440
|
+
|
|
441
|
+
let session = network
|
|
442
|
+
let source = url
|
|
443
|
+
let job = Task { () -> UIImage? in
|
|
444
|
+
guard let (body, _) = try? await session.data(from: source) else { return nil }
|
|
726
445
|
if let displaySize {
|
|
727
|
-
|
|
728
|
-
} else {
|
|
729
|
-
image = UIImage(data: data)
|
|
730
|
-
}
|
|
731
|
-
|
|
732
|
-
// 4. Store in memory cache
|
|
733
|
-
if let image {
|
|
734
|
-
let cost = Self.bitmapSize(of: image)
|
|
735
|
-
memoryCache.setObject(image, forKey: cacheKey as NSString, cost: cost)
|
|
446
|
+
return Shrinker.fromData(body, maxPixels: max(displaySize.width, displaySize.height) * scale)
|
|
736
447
|
}
|
|
737
|
-
|
|
738
|
-
return image
|
|
448
|
+
return UIImage(data: body)
|
|
739
449
|
}
|
|
450
|
+
running[slot] = job
|
|
451
|
+
defer { running[slot] = nil }
|
|
740
452
|
|
|
741
|
-
|
|
742
|
-
|
|
453
|
+
guard let image = await job.value else { return nil }
|
|
454
|
+
let cost = image.cgImage.map { $0.bytesPerRow * $0.height } ?? 0
|
|
455
|
+
memory.setObject(image, forKey: slot as NSString, cost: cost)
|
|
456
|
+
return image
|
|
743
457
|
}
|
|
744
458
|
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
let key = cacheKey(url: url, size: displaySize)
|
|
748
|
-
memoryCache.removeObject(forKey: key as NSString)
|
|
459
|
+
func evict(url target: URL, displaySize box: CGSize? = nil) {
|
|
460
|
+
memory.removeObject(forKey: key(target, box) as NSString)
|
|
749
461
|
}
|
|
750
462
|
|
|
751
|
-
/// Purge all in-memory cached images.
|
|
752
463
|
func purgeMemoryCache() {
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
}
|
|
757
|
-
|
|
758
|
-
// MARK: - Private
|
|
759
|
-
|
|
760
|
-
private func cacheKey(url: URL, size: CGSize?) -> String {
|
|
761
|
-
if let size {
|
|
762
|
-
return "\(url.absoluteString)_\(Int(size.width))x\(Int(size.height))"
|
|
763
|
-
}
|
|
764
|
-
return url.absoluteString
|
|
765
|
-
}
|
|
766
|
-
|
|
767
|
-
private static func downsample(data: Data, to pointSize: CGSize) -> UIImage? {
|
|
768
|
-
let scale = UITraitCollection.current.displayScale
|
|
769
|
-
let maxDimension = max(pointSize.width, pointSize.height) * scale
|
|
770
|
-
|
|
771
|
-
let options: [CFString: Any] = [
|
|
772
|
-
kCGImageSourceCreateThumbnailFromImageAlways: true,
|
|
773
|
-
kCGImageSourceShouldCacheImmediately: true,
|
|
774
|
-
kCGImageSourceCreateThumbnailWithTransform: true,
|
|
775
|
-
kCGImageSourceThumbnailMaxPixelSize: maxDimension
|
|
776
|
-
]
|
|
777
|
-
|
|
778
|
-
guard let source = CGImageSourceCreateWithData(data as CFData, nil),
|
|
779
|
-
let cgImage = CGImageSourceCreateThumbnailAtIndex(source, 0, options as CFDictionary) else {
|
|
780
|
-
return nil
|
|
781
|
-
}
|
|
782
|
-
|
|
783
|
-
return UIImage(cgImage: cgImage)
|
|
784
|
-
}
|
|
785
|
-
|
|
786
|
-
private static func bitmapSize(of image: UIImage) -> Int {
|
|
787
|
-
guard let cgImage = image.cgImage else { return 0 }
|
|
788
|
-
return cgImage.bytesPerRow * cgImage.height
|
|
464
|
+
memory.removeAllObjects()
|
|
465
|
+
running.values.forEach { $0.cancel() }
|
|
466
|
+
running.removeAll()
|
|
789
467
|
}
|
|
790
468
|
}
|
|
791
469
|
```
|
|
792
470
|
|
|
793
|
-
|
|
471
|
+
Order of lookup: memory, then an in-flight task for the same key, then a fetch
|
|
472
|
+
(downsampled when a display size is given, plain decode otherwise), then store
|
|
473
|
+
with the bitmap cost. The key carries the display size, so one URL can be
|
|
474
|
+
cached at several sizes.
|
|
794
475
|
|
|
795
476
|
```swift
|
|
796
|
-
struct
|
|
477
|
+
struct GalleryTile: View {
|
|
797
478
|
let url: URL
|
|
798
|
-
var displaySize: CGSize = CGSize(width: 300, height: 300)
|
|
799
|
-
|
|
800
479
|
@State private var image: UIImage?
|
|
801
|
-
@State private var
|
|
480
|
+
@State private var failed = false
|
|
802
481
|
|
|
803
482
|
var body: some View {
|
|
804
483
|
ZStack {
|
|
805
484
|
if let image {
|
|
806
|
-
Image(uiImage: image)
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
} else if isLoading {
|
|
810
|
-
Color.secondary.opacity(0.15)
|
|
811
|
-
.overlay(ProgressView())
|
|
485
|
+
Image(uiImage: image).resizable().scaledToFill()
|
|
486
|
+
} else if failed {
|
|
487
|
+
Image(systemName: "photo").foregroundStyle(.secondary)
|
|
812
488
|
} else {
|
|
813
|
-
|
|
814
|
-
.overlay {
|
|
815
|
-
Image(systemName: "photo")
|
|
816
|
-
.font(.title2)
|
|
817
|
-
.foregroundStyle(.secondary)
|
|
818
|
-
}
|
|
489
|
+
ProgressView()
|
|
819
490
|
}
|
|
820
491
|
}
|
|
821
|
-
.frame(width: displaySize.width, height: displaySize.height)
|
|
822
|
-
.clipped()
|
|
823
492
|
.task(id: url) {
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
isLoading = false
|
|
493
|
+
image = await GalleryLoader.shared.load(url, displaySize: CGSize(width: 120, height: 120))
|
|
494
|
+
failed = image == nil
|
|
827
495
|
}
|
|
828
496
|
}
|
|
829
497
|
}
|
|
830
|
-
```
|
|
831
|
-
|
|
832
|
-
### Memory Warning Integration
|
|
833
498
|
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
let
|
|
499
|
+
struct GalleryScreen: View {
|
|
500
|
+
let urls: [URL]
|
|
501
|
+
private let memoryWarning = NotificationCenter.default.publisher(for: UIApplication.didReceiveMemoryWarningNotification)
|
|
837
502
|
|
|
838
503
|
var body: some View {
|
|
839
504
|
ScrollView {
|
|
840
|
-
LazyVGrid(columns: [GridItem(.adaptive(minimum:
|
|
841
|
-
ForEach(
|
|
842
|
-
PipelineImageView(
|
|
843
|
-
url: photo.thumbnailURL,
|
|
844
|
-
displaySize: CGSize(width: 100, height: 100)
|
|
845
|
-
)
|
|
846
|
-
}
|
|
505
|
+
LazyVGrid(columns: [GridItem(.adaptive(minimum: 120))]) {
|
|
506
|
+
ForEach(urls, id: \.self) { GalleryTile(url: $0).frame(height: 120) }
|
|
847
507
|
}
|
|
848
508
|
}
|
|
849
|
-
.onReceive(
|
|
850
|
-
|
|
851
|
-
)) { _ in
|
|
852
|
-
Task {
|
|
853
|
-
await ImageLoader.shared.purgeMemoryCache()
|
|
854
|
-
}
|
|
509
|
+
.onReceive(memoryWarning) { _ in
|
|
510
|
+
Task { await GalleryLoader.shared.purgeMemoryCache() }
|
|
855
511
|
}
|
|
856
512
|
}
|
|
857
513
|
}
|
|
858
514
|
```
|
|
859
515
|
|
|
860
|
-
|
|
516
|
+
## Which layer for which job
|
|
861
517
|
|
|
862
|
-
|
|
|
518
|
+
| Use | Layers |
|
|
863
519
|
|---|---|
|
|
864
|
-
|
|
|
865
|
-
|
|
|
866
|
-
|
|
|
867
|
-
|
|
|
868
|
-
|
|
|
869
|
-
|
|
|
520
|
+
| A single profile avatar | `AsyncImage` |
|
|
521
|
+
| A scrolling photo grid | loader + downsampling + prefetching |
|
|
522
|
+
| A gallery that works offline | loader + `URLCache` on disk |
|
|
523
|
+
| Images inside chat messages | loader + memory cache |
|
|
524
|
+
| A full-resolution photo viewer | loader, no downsampling |
|
|
525
|
+
| A widget thumbnail | downsample when fetching, store in the app group |
|