Skip to content

Commit

Permalink
Introduce caching strategy
Browse files Browse the repository at this point in the history
  • Loading branch information
kaishin committed Jan 2, 2024
1 parent 3f9270e commit b2b4ee7
Show file tree
Hide file tree
Showing 3 changed files with 93 additions and 53 deletions.
1 change: 1 addition & 0 deletions Demo/Demo-iOS/ViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ class ViewController: UIViewController {
}

func animate() {
imageView.setFrameBufferSize(1)
imageView.animate(withGIFNamed: currentGIFName, preparationBlock: {
DispatchQueue.main.async {
self.imageDataLabel.text = self.currentGIFName.capitalized + " (\(self.imageView.frameCount) frames / \(String(format: "%.2f", self.imageView.gifLoopDuration))s)"
Expand Down
3 changes: 2 additions & 1 deletion Sources/Gifu/Classes/Animator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ public class Animator {
/// Checks if there is a new frame to display.
fileprivate func updateFrameIfNeeded() {
guard let store = frameStore else { return }

if store.isFinished {
stopAnimating()
if let animationBlock = animationBlock {
Expand Down Expand Up @@ -109,7 +110,7 @@ public class Animator {
frameStore = FrameStore(data: imageData,
size: size,
contentMode: contentMode,
framePreloadCount: frameBufferSize,
frameBufferSize: frameBufferSize,
loopCount: loopCount)
frameStore!.shouldResizeFrames = shouldResizeFrames
frameStore!.prepareFrames(completionHandler)
Expand Down
142 changes: 90 additions & 52 deletions Sources/Gifu/Classes/FrameStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,13 @@ import UIKit

/// Responsible for storing and updating the frames of a single GIF.
class FrameStore {
enum FrameCachingStrategy: Equatable {
case cacheNext(Int)
case cacheAll
}

/// The caching strategy to use for frames
var cachingStrategy: FrameCachingStrategy

/// Total duration of one animation loop
var loopDuration: TimeInterval = 0
Expand All @@ -13,13 +20,13 @@ class FrameStore {

/// Flag indicating if number of loops has been reached (never true for infinite loop)
var isFinished: Bool = false

/// Desired number of loops, <= 0 for infinite loop
let loopCount: Int

/// Index of current loop
var currentLoop = 0

/// Maximum duration to increment the frame timer with.
let maxTimeStep = 1.0

Expand All @@ -33,7 +40,12 @@ class FrameStore {
let contentMode: UIView.ContentMode

/// Maximum number of frames to load at once
let frameBufferSize: Int
var frameBufferSize: Int {
switch cachingStrategy {
case .cacheNext(let size): size
case .cacheAll: 10
}
}

/// The total number of frames in the GIF.
var frameCount = 0
Expand All @@ -52,7 +64,7 @@ class FrameStore {
var previousFrameIndex = 0 {
didSet {
preloadFrameQueue.async {
self.updatePreloadedFrames()
self.updateFrameCache()
}
}
}
Expand All @@ -62,7 +74,7 @@ class FrameStore {

/// Specifies whether GIF frames should be resized.
var shouldResizeFrames = true

/// Dispatch queue used for preloading images.
private lazy var preloadFrameQueue: DispatchQueue = {
return DispatchQueue(label: "co.kaishin.Gifu.preloadQueue")
Expand All @@ -89,12 +101,18 @@ class FrameStore {
///
/// - parameter data: The raw GIF image data.
/// - parameter delegate: An `Animatable` delegate.
init(data: Data, size: CGSize, contentMode: UIView.ContentMode, frameBufferSize: Int, loopCount: Int) {
init(
data: Data,
size: CGSize,
contentMode: UIView.ContentMode,
frameBufferSize: Int,
loopCount: Int
) {
let options = [String(kCGImageSourceShouldCache): kCFBooleanFalse] as CFDictionary
self.imageSource = CGImageSourceCreateWithData(data as CFData, options) ?? CGImageSourceCreateIncremental(options)
self.size = size
self.contentMode = contentMode
self.frameBufferSize = frameBufferSize
self.cachingStrategy = frameBufferSize > 0 ? .cacheNext(frameBufferSize) : .cacheAll
self.loopCount = loopCount
}

Expand Down Expand Up @@ -148,18 +166,26 @@ class FrameStore {
}
}

private extension FrameStore {
/// Whether preloading is needed or not.
var isPreloadingNeeded: Bool {
return frameBufferSize < frameCount - 1
extension UIImage {
var memorySize: Int {
guard let cgImage = self.cgImage else { return 0 }
let instanceSize = MemoryLayout<UIImage>.size(ofValue: self)
let pixmapSize = cgImage.height * cgImage.bytesPerRow
let totalSize = instanceSize + pixmapSize
return totalSize
}
}


private extension FrameStore {
/// Optionally loads a single frame from an image source, resizes it if required, then returns an `UIImage`.
///
/// - parameter index: The index of the frame to load.
/// - returns: An optional `UIImage` instance.
func loadFrame(at index: Int) -> UIImage? {
guard let imageRef = CGImageSourceCreateImageAtIndex(imageSource, index, nil) else { return nil }
guard let imageRef = CGImageSourceCreateImageAtIndex(imageSource, index, nil)
else { return nil }

let image = UIImage(cgImage: imageRef)
let scaledImage: UIImage?

Expand All @@ -177,26 +203,53 @@ private extension FrameStore {
}

/// Updates the frames by preloading new ones and replacing the previous frame with a placeholder.
func updatePreloadedFrames() {
guard isPreloadingNeeded
else { return }
func updateFrameCache() {
if case let .cacheNext(size) = cachingStrategy,
size < frameCount - 1 {
deleteCachedFrame(at: previousFrameIndex)
}

cacheUpcomingFramesIfNeeded()
}

func deleteCachedFrame(at index: Int) {
lock.lock()
animatedFrames[previousFrameIndex] = animatedFrames[previousFrameIndex].placeholderFrame
animatedFrames[index] = animatedFrames[index].placeholderFrame
lock.unlock()
}

func cacheUpcomingFramesIfNeeded() {
guard animatedFrames.filter(\.isPlaceholder).count > 0
else { return }

func indexesToCache(startingAt index: Int) -> [Int] {
let nextIndex = increment(frameIndex: index)
let lastIndex = increment(frameIndex: index, by: frameBufferSize)

for index in preloadIndexes(withStartingIndex: currentFrameIndex) {
if lastIndex >= nextIndex {
return [Int](nextIndex...lastIndex)
} else {
return [Int](nextIndex..<frameCount) + [Int](0...lastIndex)
}
}

for index in indexesToCache(startingAt: currentFrameIndex) {
loadFrameAtIndexIfNeeded(index)
}
}

func loadFrameAtIndexIfNeeded(_ index: Int) {
let frame: AnimatedFrame

lock.lock()
frame = animatedFrames[index]
lock.unlock()
if !frame.isPlaceholder { return }

guard frame.isPlaceholder
else { return }

let loadedFrame = frame.makeAnimatedFrame(with: loadFrame(at: index))

lock.lock()
animatedFrames[index] = loadedFrame
lock.unlock()
Expand All @@ -209,7 +262,7 @@ private extension FrameStore {
timeSinceLastFrameChange += min(maxTimeStep, duration)
}

/// Ensures that `timeSinceLastFrameChange` remains accurate after each frame change by substracting the `currentFrameDuration`.
/// Ensures that `timeSinceLastFrameChange` remains accurate after each frame change by subtracting the `currentFrameDuration`.
func resetTimeSinceLastFrameChange() {
timeSinceLastFrameChange -= currentFrameDuration
}
Expand Down Expand Up @@ -252,39 +305,24 @@ private extension FrameStore {
func isLastLoop(loopIndex: Int) -> Bool {
return loopIndex == loopCount - 1
}

/// Returns the indexes of the frames to preload based on a starting frame index.
///
/// - parameter index: Starting index.
/// - returns: An array of indexes to preload.
func preloadIndexes(withStartingIndex index: Int) -> [Int] {
let nextIndex = increment(frameIndex: index)
let lastIndex = increment(frameIndex: index, by: frameBufferSize)

if lastIndex >= nextIndex {
return [Int](nextIndex...lastIndex)
} else {
return [Int](nextIndex..<frameCount) + [Int](0...lastIndex)
}
}


func setupAnimatedFrames() {
resetAnimatedFrames()
var duration: TimeInterval = 0
(0..<frameCount).forEach { index in
lock.lock()
let frameDuration = CGImageFrameDuration(with: imageSource, atIndex: index)
duration += min(frameDuration, maxTimeStep)
animatedFrames += [AnimatedFrame(image: nil, duration: frameDuration)]
lock.unlock()

if index > frameBufferSize { return }
loadFrameAtIndexIfNeeded(index)
}
self.loopDuration = duration
resetAnimatedFrames()

var duration: TimeInterval = 0

(0..<frameCount).forEach { index in
lock.lock()
let frameDuration = CGImageFrameDuration(with: imageSource, atIndex: index)
duration += min(frameDuration, maxTimeStep)
animatedFrames += [AnimatedFrame(image: nil, duration: frameDuration)]
lock.unlock()

if index > frameBufferSize { return }
loadFrameAtIndexIfNeeded(index)
}

self.loopDuration = duration
}

/// Reset animated frames.
Expand Down

0 comments on commit b2b4ee7

Please sign in to comment.