Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

18 Commits
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Stream-iOS

CI Swift Platform Dependencies

A single screen SwiftUI app that renders an infinite, image heavy list at a steady frame rate. Built to demonstrate production engineering practices, not feature count.

The premise: anyone can build a list. This project shows what it takes to make one scroll smoothly at scale: custom image caching, downsampling, task cancellation, strict MVVM, TDD, and CI, with zero third party dependencies.

Data source: the public Rick and Morty API, 826 paginated, image heavy records.


Why zero dependencies

No SDWebImage. No Alamofire. No DI framework.

Every piece of infrastructure is hand built to show the underlying engineering rather than library configuration:

  • Image pipeline (in progress): an actor based loader with NSCache memory caching, ImageIO downsampling to render size, and cooperative task cancellation when rows leave the screen.
  • Networking: protocol based CharacterRepository over URLSession, fully mockable, with no live network calls in tests.
  • Dependency injection: constructor injection wired in a single composition root. A DI framework earns its place when wiring cost exceeds framework cost. On this surface area it does not.

Swift 6, by choice

The project runs in Swift 6 language mode with Default Actor Isolation set to nonisolated.

Xcode defaults new projects to implicit @MainActor isolation. That default suits form driven apps where nearly all work is UI work. This app is the opposite: image downloading, decoding, downsampling, and JSON parsing across 826 records all belong off the main thread. Starting from nonisolated means every isolation annotation in the codebase is a deliberate decision rather than an exception to a default that does not fit.

The result:

Type Isolation Reason
CharacterListViewModel @MainActor drives UI state, must be main thread
ImageLoader actor owns a shared cache and in flight requests
Models and DTOs none immutable Sendable value types, safe anywhere

Architecture

MVVM with a strict dependency rule: views know the ViewModel, the ViewModel knows abstractions, nothing knows the view.

Stream-iOS/
  App/                    // @main entry, composition root (all wiring lives here)
  Features/
    Characters/           // CharacterListView, CharacterListViewModel, CharacterRowView
  Core/
    Networking/           // CharacterRepository protocol, URLSession implementation, Endpoint, APIError
    ImageLoading/         // ImageLoader actor, cache, downsampling (Phase 5)
    Models/               // CharacterDTO, PagedResponse, CharacterStatus
Stream-iOSTests/
  Fixtures/               // saved API response, loaded from the test bundle
  Mocks/                  // MockCharacterRepository

Key implementation choices, and why:

Choice Reason
List over LazyVStack List recycles rows the way UICollectionView does. LazyVStack retains every view it creates, which bloats memory on 800+ items.
Pagination by page: Int, not the API's next URL URL construction stays inside the repository. The ViewModel asks for a page number and never handles a URL from the network. The trade off is that end of list detection becomes the ViewModel's job, checked against info.pages.
Endpoint as a struct, not an enum Each endpoint is one self contained factory method. An enum needs a parallel switch for path, query, and method, which does not scale past a handful of cases.
Defensive enum decoding CharacterStatus falls back to .unknown for unrecognised values, so a new status from the API cannot break decoding.
DTO drops unused fields episode, created, origin, and location are not decoded. Less parsing per record across 826 items.
Re-entrancy guard on pagination await releases the actor, so a second call can start while the first is suspended. The guard prevents duplicate page fetches during fast scrolling.

Testing

Swift Testing (@Test, #expect) rather than XCTest. TDD on everything below the view layer. The view is thin by design and carries no unit tests, which is a deliberate choice over brittle UI tests.

Current coverage:

  • Decoding: fixture based PagedResponse and CharacterDTO decoding, plus the unknown status fallback.
  • Endpoint: URL construction through URLComponents, including path normalisation.
  • ViewModel: first page load, second page append, error state, and concurrent load deduplication.

No live network in tests. All I/O sits behind protocols and is mocked at the seam.

The test worth reading

concurrentLoadsRequestPageOnlyOnce launches three overlapping calls through withTaskGroup, with a deliberate delay in the mock so the calls genuinely overlap rather than run one after another. It asserts the repository received the request exactly once.

The test was verified by removing the guard: it failed with requestedPages == [1, 1, 1] and 60 duplicated records, then passed again once the guard was restored. A test that has never been seen to fail proves nothing.

Run locally:

xcodebuild test -scheme Stream-iOS -destination 'platform=iOS Simulator,name=iPhone 17'

CI/CD and code review

  • GitHub Actions builds and tests on every PR to main. main is branch protected, so nothing merges red.
  • Every phase is a real PR with a description, self review, and conventional commits (feat:, test:, ci:). The PR history documents the engineering process, not only the result.

Performance (measured, not claimed)

Metric Baseline (AsyncImage) Custom pipeline
Scroll frame rate, flick scrolling TBD TBD
Memory at 800+ loaded rows TBD TBD
Cache hit rate after first full scroll n/a, no cache TBD

Phase 4 shipped AsyncImage on purpose to establish a baseline. It has no cache and refetches every image on row reuse. Phase 5 replaces it and the same Instruments trace is repeated for comparison. Screenshots live in /docs/perf/.

Roadmap

  • Phase 0: repo, CI pipeline, branch protection
  • Phase 1: models and decoding (TDD)
  • Phase 2: networking, endpoint, repository protocol (TDD)
  • Phase 3: ViewModel, pagination, dedupe guard (TDD)
  • Phase 4: List UI, infinite scroll, baseline profiling
  • Phase 5: custom image pipeline, cache, downsampling, cancellation
  • Phase 6: skeleton states, error retry, Instruments comparison
  • Phase 7: extract StreamImageKit as a standalone Swift Package

Requirements

  • Xcode 16 or later
  • iOS 17+
  • No package resolution needed. There are no dependencies.

Licence

MIT

About

A single-screen SwiftUI app that renders an infinite, image-heavy list at a steady frame rate — built to demonstrate production-grade engineering practices, not feature count.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages