diff --git a/Docs/architecture.md b/Docs/architecture.md new file mode 100644 index 0000000..d6dec92 --- /dev/null +++ b/Docs/architecture.md @@ -0,0 +1,65 @@ +# Architecture + +Technical reference for the SmartAsyncImage library internals. + +## Component Overview + +| Type | File | Isolation | Responsibility | +|------|------|-----------|----------------| +| `SmartAsyncImage` | `SmartAsyncImage.swift` | `@MainActor` (SwiftUI View) | SwiftUI drop-in view; wires `onAppear`/`onDisappear` to ViewModel | +| `SmartAsyncImagePhase` | `SmartAsyncImageViewModel.swift` | `Sendable` (enum) | Load state: `empty`, `loading`, `success(Image)`, `failure(Error)` | +| `SmartAsyncImageViewModel` | `SmartAsyncImageViewModel.swift` | `@MainActor @Observable` | Owns the `Task`, drives phase transitions, delegates fetching to cache | +| `SmartAsyncImageMemoryCacheProtocol` | `SmartAsyncImageMemoryCache.swift` | Any (protocol) | Single-method abstraction (`image(for:)`) for injectable/mockable caching | +| `SmartAsyncImageMemoryCache` | `SmartAsyncImageMemoryCache.swift` | `actor` | Coordinates memory cache, disk cache, network fetch, and task coalescing | +| `SmartAsyncImageDiskCache` | `SmartAsyncImageDiskCache.swift` | `Sendable` struct | PNG persistence in `Library/Caches/SmartAsyncImageCache/`; I/O in detached tasks | +| `SmartAsyncImageEncoder` | `SmartAsyncImageEncoder.swift` | `Sendable` struct | Percent-encodes a URL into a filename-safe string (internal `encode`/`decode`) | + +**Concurrency notes:** `Package.swift` enables `StrictConcurrency` and `ExistentialAny` upcoming features on both library and test targets (swift-tools-version 6.1). The view and ViewModel share `@MainActor` isolation. The memory cache actor serializes `NSCache` and `inflightRequests` without manual locking. Disk cache and encoder are stateless `Sendable` structs. + +## Image Load Lifecycle + +A complete load/cancel cycle from view appearance to rendered image: + +1. **`SmartAsyncImage.body`** renders and SwiftUI calls `onAppear`. +2. **`ViewModel.load()`** is invoked on the main actor. A `guard` check ensures `phase == .empty`; duplicate calls are no-ops. Phase transitions to `.loading`. +3. **A `Task` is created** and stored in `loadTask`. The closure captures `url` and `cache` by value to avoid retaining `self`. +4. **`cache.image(for: url)`** is called, entering the actor-isolated `SmartAsyncImageMemoryCache`. +5. **Memory lookup** -- `NSCache` is checked first. On hit, the `UIImage` is returned immediately. +6. **Disk lookup** -- `SmartAsyncImageDiskCache.load(key:)` hashes the URL via `SmartAsyncImageEncoder`, reads `.img` from the Caches directory, and decodes it into a `UIImage`. On hit, the image is returned. +7. **Task coalescing check** -- if `inflightRequests[url]` already holds a `Task`, the caller awaits that existing task instead of creating a new network request. +8. **Network fetch** -- a new `Task` is created and stored in `inflightRequests[url]`. Inside the task: + - `Task.checkCancellation()` (checkpoint 1) + - `URLSession.data(from: url)` performs the download + - `Task.checkCancellation()` (checkpoint 2) + - HTTP response is validated (status 200..<300) + - `UIImage(data:)` decodes the response body + - The image is stored in `NSCache` + - `Task.checkCancellation()` (checkpoint 3) + - A `Task.detached(priority: .utility)` writes the PNG to disk without blocking the return +9. **`inflightRequests[url]`** is set to `nil` via `defer` after the task's value is awaited, regardless of success or failure. +10. **Back in the ViewModel**, the returned `UIImage` is wrapped in `Image(uiImage:)` and phase transitions to `.success` on `MainActor.run`. +11. **Error paths** -- `CancellationError` resets phase to `.empty`; all other errors set `.failure(error)`. +12. **Disappearance** -- SwiftUI calls `onDisappear`, which invokes `ViewModel.cancel()`. This cancels `loadTask`, nils it out, and resets phase to `.empty`. + +## Caching Strategy + +**Three-level lookup order** (checked sequentially within `SmartAsyncImageMemoryCache.image(for:)`): + +| Level | Storage | Survives | +|-------|---------|----------| +| 1. Memory | `NSCache` | App lifecycle (evicted under memory pressure) | +| 2. Disk | PNG files (`.img`) | App restarts (cleared by OS under storage pressure) | +| 3. Network | `URLSession.shared` (or injected session) | N/A | + +**Task coalescing** -- `inflightRequests: [URL: Task]` ensures concurrent requests for the same URL share a single network task. Entry is inserted before `await task.value` and removed in a `defer` block. + +## Design Decisions + +- **UIKit dependency** -- `UIImage` for decoding, PNG serialization (`pngData()`), and `NSCache` storage. SwiftUI `Image` is only constructed at the view layer via `Image(uiImage:)`. +- **iOS 17+ minimum** -- Required for `@Observable` (Observation framework), avoiding `@Published`/`ObservableObject` boilerplate. +- **Protocol for memory cache** -- `SmartAsyncImageMemoryCacheProtocol` with a single `image(for:)` requirement allows tests to inject a `MockMemoryCache` actor without network or disk. +- **Actor over class** -- `SmartAsyncImageMemoryCache` is an `actor` to serialize `inflightRequests` and `NSCache` access. Although `NSCache` is itself thread-safe, the coalescing dictionary is not. +- **Sendable struct for disk cache** -- Holds only a `URL` and encoder (both `Sendable`). I/O dispatched to detached tasks; no mutable state. +- **Detached disk writes** -- `Task.detached(priority: .utility)` so writes don't inherit the actor's executor or block the image return. +- **`@State` for ViewModel** -- `@Observable` classes work with `@State` in iOS 17+, avoiding `ObservableObject` entirely. +- **Convenience init behavior** -- Uses `placeholder()` for `.empty`/`.loading`, `exclamationmark.triangle` for `.failure`, `image.resizable()` for `.success`. diff --git a/Docs/development.md b/Docs/development.md new file mode 100644 index 0000000..cbe087a --- /dev/null +++ b/Docs/development.md @@ -0,0 +1,66 @@ +# Development Guide + +How to build, test, and contribute to SmartAsyncImage. + +## Repository Layout + +| Path | Description | +|------|-------------| +| `Package.swift` | SPM manifest (swift-tools-version 6.1, iOS 17+) | +| `MODULE.bazel` | Bazel module manifest (Bzlmod); depends on `rules_swift` and `rules_apple` | +| `Sources/SmartAsyncImage/SmartAsyncImage.swift` | SwiftUI view with two public initializers | +| `Sources/SmartAsyncImage/SmartAsyncImageViewModel.swift` | `@MainActor @Observable` ViewModel and `SmartAsyncImagePhase` enum | +| `Sources/SmartAsyncImage/SmartAsyncImageMemoryCache.swift` | Actor-based memory cache, cache protocol, task coalescing, network fetch | +| `Sources/SmartAsyncImage/SmartAsyncImageDiskCache.swift` | PNG disk persistence in system Caches directory | +| `Sources/SmartAsyncImage/SmartAsyncImageEncoder.swift` | URL-to-filename percent encoding | +| `Sources/SmartAsyncImage/BUILD.bazel` | Bazel `swift_library` target for the library | +| `Tests/SmartAsyncImageTests/SmartAsyncImageTests.swift` | All tests (Swift Testing framework) | +| `Demo/SmartAsyncImageDemo/` | Xcode demo app with local package reference | +| `Demo/SmartAsyncImageDemo/BUILD.bazel` | Bazel `ios_application` target for the demo | +| `Demo/SmartAsyncImageDemo/fastlane/Fastfile` | Fastlane lanes for build, test, and coverage | +| `.github/workflows/ci.yml` | GitHub Actions CI workflow | +| `.deepsource.toml` | DeepSource static analysis configuration | +| `Docs/` | This documentation folder | + +## Build and Test + +```bash +swift build # SPM build +swift test # SPM tests (no coverage) +bazel build //Sources/SmartAsyncImage:SmartAsyncImage # Bazel library +bazel build //Demo/SmartAsyncImageDemo:SmartAsyncImageDemo # Bazel demo +``` + +For Xcode, open `Demo/SmartAsyncImageDemo/SmartAsyncImageDemo.xcodeproj` (local package reference is preconfigured). + +Fastlane lanes (from `Demo/SmartAsyncImageDemo/`): `bundle exec fastlane ios build`, `bundle exec fastlane ios package_tests`, `bundle exec fastlane coverage_xml`. These use xcodebuild under the hood with coverage enabled and shared derived data at `.build/DerivedData`. + +### Test Framework + +Tests use **Swift Testing** (`import Testing`), not XCTest. Assertions use `#expect(...)` and `#require(...)`. Suites are declared with `@Suite`, tests with `@Test`. + +### Network Requirement + +Integration tests in `SmartAsyncImageMemoryCacheIntegrationTests` fetch real images from the network (Google favicon, Apple favicon, Google logo). These require an active internet connection. + +## Testing Patterns + +**`MockMemoryCache` actor** -- In-test `actor` conforming to `SmartAsyncImageMemoryCacheProtocol`. Supports configurable delay (`setDelay`) and forced failure (`setShouldFail`) for testing loading states, cancellation, and error paths without network access. + +**UUID-isolated disk cache per test** -- Each disk cache test creates a `SmartAsyncImageDiskCache` with a `UUID`-suffixed folder name (e.g., `TestSmartAsyncImageCache_`). A cleanup step removes the folder. Prevents cross-test pollution. + +**`@MainActor` ViewModel tests** -- `SmartAsyncImageViewModelTests` is annotated `@MainActor` so ViewModel methods can be called synchronously. Tests use `Task.sleep` to allow async operations to settle before asserting phase transitions. + +**Isolated cache instances** -- Integration tests use `createIsolatedCache()`, returning a fresh `SmartAsyncImageMemoryCache` backed by its own UUID-named disk cache folder. Avoids shared state from `.shared`. + +## CI Pipeline + +Defined in `.github/workflows/ci.yml`. Triggers on pushes to `main` and all pull requests. Runs on `macos-26`. + +Key insight: the `build` lane compiles once with `build-for-testing` into shared derived data (`.build/DerivedData`), then the demo app build and `package_tests` lane (`test-without-building`) both reuse those artifacts, avoiding recompilation. Coverage is converted from `.xcresult` to Cobertura XML via `xcresultparser` and uploaded to Codecov. + +## Quality Tools + +**DeepSource** -- Configured in `.deepsource.toml`. Runs `swift` and `secrets` analyzers on commits to `main`. Excludes `.build/`, `.swiftpm/`, `DerivedData/`. + +**Codecov** -- Line coverage uploaded from CI on pushes to `main` and pull requests. diff --git a/README.md b/README.md index 21d3037..d10797f 100644 --- a/README.md +++ b/README.md @@ -1,30 +1,17 @@ # SmartAsyncImage -A smarter, faster `AsyncImage` for SwiftUI with built-in in-memory and disk caching, cancellation, and Swift 6 concurrency. +A smarter, faster `AsyncImage` for SwiftUI (iOS) with built-in in-memory and disk caching, cancellation, and Swift 6 concurrency. [![CI](https://github.com/gentle-giraffe-apps/SmartAsyncImage/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/gentle-giraffe-apps/SmartAsyncImage/actions/workflows/ci.yml) -[![Swift](https://img.shields.io/badge/Swift-6.1-orange.svg)](https://swift.org) +[![Coverage](https://codecov.io/gh/gentle-giraffe-apps/SmartAsyncImage/branch/main/graph/badge.svg)](https://codecov.io/gh/gentle-giraffe-apps/SmartAsyncImage) +[![Swift](https://img.shields.io/badge/Swift-6.1+-orange.svg)](https://swift.org) ![Bazel](https://img.shields.io/badge/Bazel-enabled-555?logo=bazel) [![SPM Compatible](https://img.shields.io/badge/SPM-Compatible-brightgreen.svg)](https://swift.org/package-manager/) [![Platforms](https://img.shields.io/badge/platforms-iOS%2017%2B-blue)](https://developer.apple.com/ios/) ![Commit activity](https://img.shields.io/github/commit-activity/y/gentle-giraffe-apps/SmartAsyncImage) ![Last commit](https://img.shields.io/github/last-commit/gentle-giraffe-apps/SmartAsyncImage) - -[![Coverage](https://codecov.io/gh/gentle-giraffe-apps/SmartAsyncImage/branch/main/graph/badge.svg)](https://codecov.io/gh/gentle-giraffe-apps/SmartAsyncImage) -[![DeepSource Static Analysis](https://img.shields.io/badge/DeepSource-Static%20Analysis-0A2540?logo=deepsource&logoColor=white)](https://deepsource.io/) [![DeepSource](https://app.deepsource.com/gh/gentle-giraffe-apps/SmartAsyncImage.svg/?label=active+issues&show_trend=true)](https://app.deepsource.com/gh/gentle-giraffe-apps/SmartAsyncImage/) -Codecov Snapshot
- - Code coverage snapshot by file and module (Codecov tree graph) - - -💬 **[Join the discussion. Feedback and questions welcome](https://github.com/gentle-giraffe-apps/SmartAsyncImage/discussions)** - ## Features - SwiftUI-friendly API with an observable view model - Smart phase handling: `empty`, `loading`, `success(Image)`, `failure(Error)` @@ -33,9 +20,11 @@ A smarter, faster `AsyncImage` for SwiftUI with built-in in-memory and disk cach - Swift Concurrency (`async/await`) with cooperative cancellation - MainActor-safe state updates +💬 **[Join the discussion. Feedback and questions welcome](https://github.com/gentle-giraffe-apps/SmartAsyncImage/discussions)** + ## Requirements - iOS 17+ -- Swift 6.2+ +- Swift 6.1+ - Swift Package Manager ## 📦 Installation (Swift Package Manager) @@ -48,13 +37,23 @@ A smarter, faster `AsyncImage` for SwiftUI with built-in in-memory and disk cach 4. Choose a version rule (or `main` while developing) 5. Add the **SmartAsyncImage** product to your app target +### Via `Package.swift` + +```swift +dependencies: [ + .package(url: "https://github.com/gentle-giraffe-apps/SmartAsyncImage.git", from: "1.0.0") +] +``` + +Then add `"SmartAsyncImage"` to the `dependencies` of your target. + ## Demo App A runnable SwiftUI demo app is included in this repository using a local package reference. **Path:** ``` -Demo/SmartAsyncImageDemo/SmartAsyncDemo.xcodeproj +Demo/SmartAsyncImageDemo/SmartAsyncImageDemo.xcodeproj ``` ### How to Run @@ -64,7 +63,7 @@ Demo/SmartAsyncImageDemo/SmartAsyncDemo.xcodeproj ``` 2. Open the demo project: ``` - Demo/SmartAsyncImageDemo/SmartAsyncDemo.xcodeproj + Demo/SmartAsyncImageDemo/SmartAsyncImageDemo.xcodeproj ``` 3. Select an iOS 17+ simulator. 4. Build & Run (⌘R). @@ -92,14 +91,12 @@ struct MinimalRemoteImageView: View { // ---------------------------------------------- switch phase { - case .empty: + case .empty, .loading: ProgressView() case .success(let image): image.resizable().scaledToFit() case .failure: Image(systemName: "photo") - @unknown default: - EmptyView() } } .frame(width: 150, height: 150) @@ -107,6 +104,28 @@ struct MinimalRemoteImageView: View { } ``` +## Quality & Tooling + +This project enforces quality gates via CI and static analysis: + +- **CI:** All commits to `main` must pass GitHub Actions checks +- **Static analysis:** DeepSource runs on every commit to `main`. + The badge indicates the current number of outstanding static analysis issues. +- **Test coverage:** Codecov reports line coverage for the `main` branch + +Codecov Snapshot
+ + Code coverage snapshot by file and module (Codecov tree graph) + + +These checks are intended to keep the design system safe to evolve over time. + +--- + ## Architecture ```mermaid diff --git a/Sources/SmartAsyncImage/SmartAsyncImageMemoryCache.swift b/Sources/SmartAsyncImage/SmartAsyncImageMemoryCache.swift index c321d2b..6310e78 100644 --- a/Sources/SmartAsyncImage/SmartAsyncImageMemoryCache.swift +++ b/Sources/SmartAsyncImage/SmartAsyncImageMemoryCache.swift @@ -82,43 +82,3 @@ public actor SmartAsyncImageMemoryCache: SmartAsyncImageMemoryCacheProtocol { return try await task.value } } - -// MARK: - Test Helpers - -actor MockMemoryCache: SmartAsyncImageMemoryCacheProtocol { - private var cache: [URL: UIImage] = [:] - private var shouldFail = false - private var delay: TimeInterval = 0 - - func setImage(_ image: UIImage, for url: URL) { - cache[url] = image - } - - func getImage(for url: URL) -> UIImage? { - return cache[url] - } - - func setShouldFail(_ value: Bool) { - shouldFail = value - } - - func setDelay(_ value: TimeInterval) { - delay = value - } - - func image(for url: URL) async throws -> UIImage { - if delay > 0 { - try await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000)) - } - - if shouldFail { - throw URLError(.badServerResponse) - } - - guard let image = cache[url] else { - throw URLError(.resourceUnavailable) - } - - return image - } -} diff --git a/Tests/SmartAsyncImageTests/SmartAsyncImageTests.swift b/Tests/SmartAsyncImageTests/SmartAsyncImageTests.swift index 442a82a..eccddba 100644 --- a/Tests/SmartAsyncImageTests/SmartAsyncImageTests.swift +++ b/Tests/SmartAsyncImageTests/SmartAsyncImageTests.swift @@ -6,6 +6,46 @@ import UIKit import SwiftUI @testable import SmartAsyncImage +// MARK: - Test Helpers (Mock) + +actor MockMemoryCache: SmartAsyncImageMemoryCacheProtocol { + private var cache: [URL: UIImage] = [:] + private var shouldFail = false + private var delay: TimeInterval = 0 + + func setImage(_ image: UIImage, for url: URL) { + cache[url] = image + } + + func getImage(for url: URL) -> UIImage? { + return cache[url] + } + + func setShouldFail(_ value: Bool) { + shouldFail = value + } + + func setDelay(_ value: TimeInterval) { + delay = value + } + + func image(for url: URL) async throws -> UIImage { + if delay > 0 { + try await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000)) + } + + if shouldFail { + throw URLError(.badServerResponse) + } + + guard let image = cache[url] else { + throw URLError(.resourceUnavailable) + } + + return image + } +} + // MARK: - SmartAsyncImageEncoder Tests @Suite("SmartAsyncImageEncoder Tests")