Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 65 additions & 0 deletions Docs/architecture.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
# Architecture

Technical reference for the SmartAsyncImage library internals.

## Component Overview

| Type | File | Isolation | Responsibility |
|------|------|-----------|----------------|
| `SmartAsyncImage<Content>` | `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<NSURL, UIImage>` is checked first. On hit, the `UIImage` is returned immediately.
6. **Disk lookup** -- `SmartAsyncImageDiskCache.load(key:)` hashes the URL via `SmartAsyncImageEncoder`, reads `<hash>.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<UIImage, any Error>` 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<NSURL, UIImage>` | App lifecycle (evicted under memory pressure) |
| 2. Disk | PNG files (`<percent-encoded-url>.img`) | App restarts (cleared by OS under storage pressure) |
| 3. Network | `URLSession.shared` (or injected session) | N/A |

**Task coalescing** -- `inflightRequests: [URL: Task<UIImage, any Error>]` 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`.
66 changes: 66 additions & 0 deletions Docs/development.md
Original file line number Diff line number Diff line change
@@ -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_<UUID>`). 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.
63 changes: 41 additions & 22 deletions README.md
Original file line number Diff line number Diff line change
@@ -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/)

<sub><strong>Codecov Snapshot</strong></sub><br/>
<a href="https://codecov.io/gh/gentle-giraffe-apps/SmartAsyncImage">
<img
src="https://codecov.io/gh/gentle-giraffe-apps/SmartAsyncImage/graphs/icicle.svg"
height="80"
alt="Code coverage snapshot by file and module (Codecov tree graph)"
/>
</a>

💬 **[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)`
Expand All @@ -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)
Expand All @@ -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
Expand All @@ -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).
Expand Down Expand Up @@ -92,21 +91,41 @@ 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)
}
}
```

## 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

<sub><strong>Codecov Snapshot</strong></sub><br/>
<a href="https://codecov.io/gh/gentle-giraffe-apps/SmartAsyncImage">
<img
src="https://codecov.io/gh/gentle-giraffe-apps/SmartAsyncImage/graphs/icicle.svg"
height="80"
alt="Code coverage snapshot by file and module (Codecov tree graph)"
/>
</a>

These checks are intended to keep the design system safe to evolve over time.

---

## Architecture

```mermaid
Expand Down
40 changes: 0 additions & 40 deletions Sources/SmartAsyncImage/SmartAsyncImageMemoryCache.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
40 changes: 40 additions & 0 deletions Tests/SmartAsyncImageTests/SmartAsyncImageTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Loading