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
2 changes: 1 addition & 1 deletion .github/workflows/build-and-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ concurrency:

jobs:
test:
runs-on: macos-latest
runs-on: macos-26
timeout-minutes: 30
env:
CI: true
Expand Down
181 changes: 146 additions & 35 deletions AsyncImageView/Renderers/MulticastedRenderer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,45 @@ import UIKit

import ReactiveSwift

// The initial value is `nil`.
private typealias ImageProperty = Property<ImageResult?>
private final class InFlightImage {
let result: Property<ImageResult?>
let observer: Signal<ImageResult, Never>.Observer
let disposable = SerialDisposable()
private let hasStarted = Atomic(false)

init() {
let (signal, observer) = Signal<ImageResult, Never>.pipe()
self.result = Property(initial: nil, then: signal)
self.observer = observer
}

deinit {
self.disposable.dispose()
}

func startIfNeeded(_ action: () -> Void) {
let shouldStart = self.hasStarted.modify { hasStarted in
guard !hasStarted else { return false }
hasStarted = true

return true
}

if shouldStart {
action()
}
}
}

private enum CachedImage {
case rendering(InFlightImage, latest: ImageResult?)
case completed(ImageResult)
}

private enum CachedImageLookup {
case rendering(InFlightImage)
case completed(ImageResult)
}

/// `RendererType` decorator which guarantees that images for a given `RenderDataType`
/// are only rendered once, and multicasted to every observer.
Expand All @@ -23,7 +60,7 @@ public final class MulticastedRenderer<
Renderer.Data == Data,
Renderer.Error == Never {
private let renderer: Renderer
private let cache: Atomic<[Data: ImageProperty]>
private let cache: Atomic<[Data: CachedImage]>

#if !os(watchOS)
private let memoryWarningDisposable: Disposable
Expand All @@ -45,36 +82,122 @@ public final class MulticastedRenderer<
}

public func renderImageWithData(_ data: Data) -> SignalProducer<ImageResult, Never> {
let property = getPropertyForData(data)
switch self.lookup(for: data) {
case let .completed(result):
return SignalProducer(value: result)

return property.producer
.filter { $0 != nil } // Skip initial `nil` value.
.map { $0! }
.take(first: 1)
}
case let .rendering(entry):
return self.producer(for: data, entry: entry)
}
}

private func getPropertyForData(_ data: Data) -> ImageProperty {
var result: ImageProperty!
private func lookup(for data: Data) -> CachedImageLookup {
return self.cache.modify { cache in
switch cache[data] {
case let .completed(result), let .rendering(_, latest: result?):
return .completed(result)

self.cache.modify { cache in
if let property = cache[data] {
result = property
} else {
result = ImageProperty(
initial: nil,
then: renderer.createProducerForRenderingData(data)
.map(Optional.init)
)
case let .rendering(entry, latest: nil):
return .rendering(entry)

case nil:
let entry = InFlightImage()
cache[data] = .rendering(entry, latest: nil)

cache[data] = result
return .rendering(entry)
}
}
}

private func startRendering(_ data: Data, into entry: InFlightImage) {
entry.disposable.inner = self.renderer
.renderImageWithData(data)
.start { [weak self, weak entry] event in
guard let self, let entry else { return }

if let result = event.value {
self.receive(result, for: data, entry: entry)
} else if event.isTerminating {
self.finishRendering(data, entry: entry)
}
}
}

private func receive(_ result: Renderer.RenderResult, for data: Data, entry: InFlightImage) {
let cacheMiss = ImageResult(
image: result.image,
cacheHit: false,
shouldCache: result.shouldCache
)

guard result.shouldCache else {
self.remove(entry, for: data)
entry.observer.send(value: cacheMiss)

return
}

let cacheHit = ImageResult(image: result.image, cacheHit: true, shouldCache: true)
self.cache.modify { cache in
guard case let .rendering(current, _) = cache[data], current === entry else { return }
cache[data] = .rendering(entry, latest: cacheHit)
}

entry.observer.send(value: cacheMiss)
entry.observer.send(value: cacheHit)
}

private func finishRendering(_ data: Data, entry: InFlightImage) {
self.cache.modify { cache in
guard case let .rendering(current, latest) = cache[data], current === entry else { return }

cache[data] = latest.map(CachedImage.completed)
}
entry.observer.sendCompleted()
}

return result
private func remove(_ entry: InFlightImage, for data: Data) {
self.cache.modify { cache in
guard case let .rendering(current, _) = cache[data], current === entry else { return }
cache[data] = nil
}
}

private func producer(for data: Data, entry: InFlightImage) -> SignalProducer<ImageResult, Never> {
return SignalProducer { [self, entry] observer, lifetime in
// Keep async callbacks deliverable after cache eviction or a temporary wrapper.
// `Lifetime` releases this action on completion or cancellation.
lifetime.observeEnded {
_ = self
_ = entry
}

let cachedResult = self.cache.withValue { cache -> ImageResult? in
switch cache[data] {
case let .completed(result), let .rendering(_, latest: result?):
return result

case .rendering(_, latest: nil), nil:
return nil
}
}

if let cachedResult {
lifetime += SignalProducer(value: cachedResult).start(observer)
} else {
lifetime += entry.result.producer
.compactMap { $0 }
.take(first: 1)
.start(observer)
entry.startIfNeeded {
self.startRendering(data, into: entry)
}
}
}
}

#if !os(watchOS)
private static func clearCacheOnMemoryWarning(_ cache: Atomic<[Data: ImageProperty]>) -> Disposable {
private static func clearCacheOnMemoryWarning(_ cache: Atomic<[Data: CachedImage]>) -> Disposable {
return NotificationCenter.default
.reactive.notifications(forName: UIApplication.didReceiveMemoryWarningNotification, object: nil)
.observe(on: QueueScheduler())
Expand All @@ -91,15 +214,3 @@ extension RendererType where Error == Never {
return MulticastedRenderer(renderer: self)
}
}

extension RendererType {
fileprivate func createProducerForRenderingData(_ data: Data) -> SignalProducer<ImageResult, Error> {
return self.renderImageWithData(data)
.flatMap(.concat) { result in
return SignalProducer([
ImageResult(image: result.image, cacheHit: false, shouldCache: result.shouldCache),
ImageResult(image: result.image, cacheHit: true, shouldCache: result.shouldCache)
])
}
}
}
13 changes: 6 additions & 7 deletions AsyncImageViewTests/MulticastedRendererSpec.swift
Original file line number Diff line number Diff line change
Expand Up @@ -127,15 +127,14 @@ class MulticastedRendererSpec: QuickSpec {
expect(getCacheHitValue()) == false
}

it("is a cache hit the second time the producer is fetched") {
let producer = getProducerForData(data, size)
it("is a cache hit after the initial producer completes") {
var initialResult: ImageResult?
getProducerForData(data, size).startWithValues { initialResult = $0 }
scheduler.advance(by: interval)
expect(initialResult).toEventuallyNot(beNil())

var result: ImageResult?
producer.startWithValues { result = $0 }

expect(result).toEventuallyNot(beNil())
expect(result?.cacheHit) == true
expect(initialResult?.cacheHit) == false
expect(getImageForData(data, size)?.cacheHit) == true
}
}
}
Expand Down
Loading
Loading