Jelajahi Sumber

Merge pull request #249 from krider2010/master

Provide a simple prefetcher implementation.
Wei Wang 10 tahun lalu
induk
melakukan
1d39c83632

+ 18 - 0
Kingfisher.xcodeproj/project.pbxproj

@@ -119,6 +119,13 @@
 		D1ED2D401AD2D09F00CFC3EB /* Kingfisher.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D1ED2D351AD2D09F00CFC3EB /* Kingfisher.framework */; };
 		D1ED2D4C1AD2D09F00CFC3EB /* Kingfisher.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D1ED2D351AD2D09F00CFC3EB /* Kingfisher.framework */; };
 		D1ED2D4D1AD2D09F00CFC3EB /* Kingfisher.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = D1ED2D351AD2D09F00CFC3EB /* Kingfisher.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; };
+		D9638BA01C7DBA660046523D /* ImagePrefetcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = D9638B9F1C7DBA660046523D /* ImagePrefetcher.swift */; };
+		D9638BA11C7DBA660046523D /* ImagePrefetcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = D9638B9F1C7DBA660046523D /* ImagePrefetcher.swift */; };
+		D9638BA21C7DBA660046523D /* ImagePrefetcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = D9638B9F1C7DBA660046523D /* ImagePrefetcher.swift */; };
+		D9638BA31C7DBA660046523D /* ImagePrefetcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = D9638B9F1C7DBA660046523D /* ImagePrefetcher.swift */; };
+		D9638BA61C7DC71F0046523D /* ImagePrefetcherTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D9638BA41C7DC71F0046523D /* ImagePrefetcherTests.swift */; };
+		D9638BA71C7DCF560046523D /* ImagePrefetcherTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D9638BA41C7DC71F0046523D /* ImagePrefetcherTests.swift */; };
+		D9638BA81C7DCF570046523D /* ImagePrefetcherTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D9638BA41C7DC71F0046523D /* ImagePrefetcherTests.swift */; };
 /* End PBXBuildFile section */
 
 /* Begin PBXContainerItemProxy section */
@@ -314,6 +321,8 @@
 		D1ED2D351AD2D09F00CFC3EB /* Kingfisher.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Kingfisher.framework; sourceTree = BUILT_PRODUCTS_DIR; };
 		D1ED2D3F1AD2D09F00CFC3EB /* KingfisherTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = KingfisherTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
 		D809C0611AAB7CA1AE240862 /* Pods-KingfisherTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-KingfisherTests.debug.xcconfig"; path = "Pods/Target Support Files/Pods-KingfisherTests/Pods-KingfisherTests.debug.xcconfig"; sourceTree = "<group>"; };
+		D9638B9F1C7DBA660046523D /* ImagePrefetcher.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = ImagePrefetcher.swift; path = Sources/ImagePrefetcher.swift; sourceTree = "<group>"; };
+		D9638BA41C7DC71F0046523D /* ImagePrefetcherTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ImagePrefetcherTests.swift; sourceTree = "<group>"; };
 		FE96DF45BEE5F8EBB01C7956 /* Pods-KingfisherTests-OSX.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-KingfisherTests-OSX.release.xcconfig"; path = "Pods/Target Support Files/Pods-KingfisherTests-OSX/Pods-KingfisherTests-OSX.release.xcconfig"; sourceTree = "<group>"; };
 /* End PBXFileReference section */
 
@@ -430,6 +439,7 @@
 				D10945EA1C526B6C001408EB /* Image.swift */,
 				D10945EB1C526B6C001408EB /* ImageCache.swift */,
 				D10945EC1C526B6C001408EB /* ImageDownloader.swift */,
+				D9638B9F1C7DBA660046523D /* ImagePrefetcher.swift */,
 				D10945ED1C526B6C001408EB /* ImageTransition.swift */,
 				D10945EE1C526B6C001408EB /* ImageView+Kingfisher.swift */,
 				D10945EF1C526B6C001408EB /* Info.plist */,
@@ -475,6 +485,7 @@
 				D12E0C451C47F23500AC98AD /* ImageCacheTests.swift */,
 				D12E0C461C47F23500AC98AD /* ImageDownloaderTests.swift */,
 				D12E0C471C47F23500AC98AD /* ImageExtensionTests.swift */,
+				D9638BA41C7DC71F0046523D /* ImagePrefetcherTests.swift */,
 				D12E0C481C47F23500AC98AD /* ImageViewExtensionTests.swift */,
 				D12E0C4A1C47F23500AC98AD /* KingfisherManagerTests.swift */,
 				D12E0C4B1C47F23500AC98AD /* KingfisherOptionsInfoTests.swift */,
@@ -1252,6 +1263,7 @@
 				D109461F1C526C61001408EB /* KingfisherManager.swift in Sources */,
 				D10946201C526C61001408EB /* KingfisherOptionsInfo.swift in Sources */,
 				D10946211C526C61001408EB /* Resource.swift in Sources */,
+				D9638BA21C7DBA660046523D /* ImagePrefetcher.swift in Sources */,
 				D10946221C526C61001408EB /* String+MD5.swift in Sources */,
 				D10946231C526C61001408EB /* ThreadHelper.swift in Sources */,
 			);
@@ -1273,6 +1285,7 @@
 				D12E0C761C47F71700AC98AD /* KingfisherTestHelper.swift in Sources */,
 				D12E0C6E1C47F6FE00AC98AD /* ImageCacheTests.swift in Sources */,
 				D12E0C6F1C47F6FE00AC98AD /* ImageDownloaderTests.swift in Sources */,
+				D9638BA71C7DCF560046523D /* ImagePrefetcherTests.swift in Sources */,
 				D12E0C701C47F6FE00AC98AD /* ImageExtensionTests.swift in Sources */,
 				D12E0C711C47F6FE00AC98AD /* ImageViewExtensionTests.swift in Sources */,
 				D12E0C721C47F6FE00AC98AD /* KingfisherManagerTests.swift in Sources */,
@@ -1288,6 +1301,7 @@
 				D12E0C891C47F7B700AC98AD /* KingfisherTestHelper.swift in Sources */,
 				D12E0C821C47F7AF00AC98AD /* ImageCacheTests.swift in Sources */,
 				D12E0C831C47F7AF00AC98AD /* ImageDownloaderTests.swift in Sources */,
+				D9638BA81C7DCF570046523D /* ImagePrefetcherTests.swift in Sources */,
 				D12E0C841C47F7AF00AC98AD /* ImageExtensionTests.swift in Sources */,
 				D12E0C851C47F7AF00AC98AD /* ImageViewExtensionTests.swift in Sources */,
 				D12E0C861C47F7AF00AC98AD /* KingfisherManagerTests.swift in Sources */,
@@ -1316,6 +1330,7 @@
 				D10946121C526C0D001408EB /* ImageView+Kingfisher.swift in Sources */,
 				D10946131C526C0D001408EB /* KingfisherManager.swift in Sources */,
 				D10946141C526C0D001408EB /* KingfisherOptionsInfo.swift in Sources */,
+				D9638BA11C7DBA660046523D /* ImagePrefetcher.swift in Sources */,
 				D10946151C526C0D001408EB /* Resource.swift in Sources */,
 				D10946161C526C0D001408EB /* String+MD5.swift in Sources */,
 				D10946171C526C0D001408EB /* ThreadHelper.swift in Sources */,
@@ -1330,6 +1345,7 @@
 				D109462D1C526CF5001408EB /* ImageTransition.swift in Sources */,
 				D10946251C526CE8001408EB /* Image.swift in Sources */,
 				D10946261C526CE8001408EB /* ImageCache.swift in Sources */,
+				D9638BA31C7DBA660046523D /* ImagePrefetcher.swift in Sources */,
 				D10946271C526CE8001408EB /* ImageDownloader.swift in Sources */,
 				D10946281C526CE8001408EB /* KingfisherManager.swift in Sources */,
 				D10946291C526CE8001408EB /* KingfisherOptionsInfo.swift in Sources */,
@@ -1369,6 +1385,7 @@
 				D10945FB1C526B86001408EB /* ImageView+Kingfisher.swift in Sources */,
 				D10945FC1C526B86001408EB /* KingfisherManager.swift in Sources */,
 				D10945FD1C526B86001408EB /* KingfisherOptionsInfo.swift in Sources */,
+				D9638BA01C7DBA660046523D /* ImagePrefetcher.swift in Sources */,
 				D10945FE1C526B86001408EB /* Resource.swift in Sources */,
 				D10945FF1C526B86001408EB /* String+MD5.swift in Sources */,
 				D10946001C526B86001408EB /* ThreadHelper.swift in Sources */,
@@ -1383,6 +1400,7 @@
 				D12E0C571C47F23500AC98AD /* KingfisherTestHelper.swift in Sources */,
 				D12E0C581C47F23500AC98AD /* UIButtonExtensionTests.swift in Sources */,
 				D12E0C561C47F23500AC98AD /* KingfisherOptionsInfoTests.swift in Sources */,
+				D9638BA61C7DC71F0046523D /* ImagePrefetcherTests.swift in Sources */,
 				D12E0C551C47F23500AC98AD /* KingfisherManagerTests.swift in Sources */,
 				D12E0C511C47F23500AC98AD /* ImageDownloaderTests.swift in Sources */,
 				D12E0C521C47F23500AC98AD /* ImageExtensionTests.swift in Sources */,

+ 15 - 0
Sources/ImageCache.swift

@@ -542,6 +542,21 @@ extension ImageCache {
         public let cacheType: CacheType?
     }
     
+    /**
+     Determine if a cached image exists for the given image, as keyed by the URL. It will return true if the
+     image is found either in memory or on disk. Essentially as long as there is a cache of the image somewhere
+     true is returned. A convenience method that decodes `isImageCachedForKey`.
+     
+     - parameter url: The image URL.
+     
+     - returns: True if the image is cached, false otherwise.
+     */
+    public func cachedImageExistsforURL(url: NSURL) -> Bool {
+        let resource = Resource(downloadURL: url)
+        let result = isImageCachedForKey(resource.cacheKey)
+        return result.cached
+    }
+
     /**
     Check whether an image is cached for a key.
     

+ 152 - 0
Sources/ImagePrefetcher.swift

@@ -0,0 +1,152 @@
+//
+//  ImagePrefetcher.swift
+//  Kingfisher
+//
+//  Created by Claire Knight <claire.knight@moggytech.co.uk> on 24/02/2016
+//
+//  Copyright (c) 2016 Wei Wang <onevcat@gmail.com>
+//
+//  Permission is hereby granted, free of charge, to any person obtaining a copy
+//  of this software and associated documentation files (the "Software"), to deal
+//  in the Software without restriction, including without limitation the rights
+//  to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+//  copies of the Software, and to permit persons to whom the Software is
+//  furnished to do so, subject to the following conditions:
+//
+//  The above copyright notice and this permission notice shall be included in
+//  all copies or substantial portions of the Software.
+//
+//  THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+//  IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+//  FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+//  AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+//  LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+//  OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+//  THE SOFTWARE.
+
+
+#if os(OSX)
+    import AppKit
+#else
+    import UIKit
+#endif
+
+
+/// Progress update block of prefetcher.
+public typealias PrefetchProgressBlock = ((completedURLs: Int, allURLs: Int) -> ())
+
+/// Completion block of prefetcher.
+public typealias PrefetchCompletionBlock = ((cancelled: Bool, completedURLs: Int, skippedURLs: Int) -> ())
+
+private let defaultPrefetcherInstance = ImagePrefetcher()
+
+/// `ImagePrefetcher` represents a downloading manager for requesting many images via URLs and caching them.
+public class ImagePrefetcher: NSObject {
+    
+    private var prefetchURLs: [NSURL]?
+    private var skippedCount = 0
+    private var requestedCount = 0
+    private var finishedCount = 0
+    
+    private var cancelCompletionHandlerCalled = false
+    
+    /// The default manager to use for downloads.
+    public lazy var manager: KingfisherManager = KingfisherManager.sharedManager
+
+    /// The default prefetcher.
+    public class var defaultPrefetcher: ImagePrefetcher {
+        return defaultPrefetcherInstance
+    }
+
+    /// The maximum concurrent downloads to use when prefetching images. Default is 5.
+    public var maxConcurrentDownloads = 5
+    
+    /**
+     Download the images from `urls` and cache them. This can be useful for background downloading
+     of assets that are required for later use in an app. This code will not try and update any UI
+     with the results of the process, but calls the handlers with the number cached etc. Failed
+     images are just skipped.
+     
+     Warning: This will cancel any existing prefetch operation in progress! Use `isPrefetching() to
+     control this in your own code as you see fit.
+     
+     - parameter urls:              The list of URLs to prefetch
+     - parameter progressBlock:     Block to be called when progress updates. Completed and total
+                                    counts are provided. Completed does not imply success.
+     - parameter completionHandler: Block to be called when prefetching is complete. Completed is all
+                                    those made, and skipped is the number of failed ones.
+     */
+    public func prefetchURLs(urls: [NSURL], progressBlock: PrefetchProgressBlock?, completionHandler: PrefetchCompletionBlock?) {
+        
+        // Clear out any existing prefetch operation first
+        cancelPrefetching()
+        
+        cancelCompletionHandlerCalled = false
+        
+        prefetchURLs = urls
+        
+        guard urls.count > 0 else {
+            CompletionHandler?()
+            return
+        }
+        
+        for i in (0..<urls.count) where i < maxConcurrentDownloads && requestedCount < urls.count {
+            startPrefetching(i, progressBlock: progressBlock, completionHandler: completionHandler)
+        }
+    }
+   
+    /**
+     This cancels any existing prefetching activity that might be occuring. It does not stop any currently
+     running cache operation, but prevents any further ones being started and terminates the looping. For
+     surety, be sure that the completion block on the prefetch is called after calling this if you expect
+     an operation to be running.
+     */
+    func cancelPrefetching() {
+        prefetchURLs = .None
+        skippedCount = 0
+        requestedCount = 0
+        finishedCount = 0
+    }
+
+    /**
+     Checks to see if this prefetcher is already prefetching any images.
+     
+     - returns: True if there are images still to be prefetched, false otherwise.
+     */
+    func isPrefetching() -> Bool {
+        guard let urls = prefetchURLs else { return false }
+        return urls.count > 0
+    }
+    
+    internal func startPrefetching(index: Int, progressBlock: PrefetchProgressBlock?, completionHandler: PrefetchCompletionBlock?) {
+        guard let urls = prefetchURLs where index < (urls.count ?? 0) else { return }
+        
+        requestedCount++
+        
+        let task = RetrieveImageTask()
+        let resource = Resource(downloadURL: urls[index])
+        let total = urls.count
+        
+        manager.downloadAndCacheImageWithURL(resource.downloadURL, forKey: resource.cacheKey, retrieveImageTask: task, progressBlock: nil, completionHandler: { image, error, cacheType, imageURL in
+            self.finishedCount++
+            
+            if image == .None {
+                self.skippedCount++
+            }
+            
+            progressBlock?(completedURLs: self.finishedCount, allURLs: total)
+            
+            // Reference the prefetchURLs rather than urls in case the request has been cancelled
+            if (self.prefetchURLs?.count ?? 0) > self.requestedCount {
+                self.startPrefetching(self.requestedCount, progressBlock: progressBlock, completionHandler: completionHandler)
+            } else if self.finishedCount == self.requestedCount {
+                self.prefetchURLs?.removeAll()
+                completionHandler?(cancelled: false, completedURLs: self.finishedCount, skippedURLs: self.skippedCount)
+            } else if (self.prefetchURLs == nil || self.prefetchURLs!.count == 0) && !self.cancelCompletionHandlerCalled {
+                self.cancelCompletionHandlerCalled = true
+                completionHandler?(cancelled: true, completedURLs: self.finishedCount, skippedURLs: self.skippedCount)
+            }
+            
+        }, options: nil)
+    }
+}

+ 1 - 1
Sources/KingfisherManager.swift

@@ -89,7 +89,7 @@ public class KingfisherManager {
     /**
     Default init method
     
-    - returns: A Kingfisher manager object with default cache and default downloader.
+    - returns: A Kingfisher manager object with default cache, default downloader, and default prefetcher.
     */
     public init() {
         cache = ImageCache.defaultCache

+ 46 - 0
Tests/KingfisherTests/ImageCacheTests.swift

@@ -152,6 +152,52 @@ class ImageCacheTests: XCTestCase {
         
         waitForExpectationsWithTimeout(5, handler: nil)
     }
+    
+    func testCachedFileExists() {
+        let expectation = expectationWithDescription("cache does contain image")
+        
+        let URLString = testKeys[0]
+        let URL = NSURL(string: URLString)!
+        
+        let exists = cache.cachedImageExistsforURL(URL)
+        XCTAssertFalse(exists)
+        
+        cache.retrieveImageForKey(URLString, options: nil, completionHandler: { (image, type) -> () in
+            XCTAssertNil(image, "Should not be cached yet")
+            XCTAssertEqual(type, nil)
+
+            self.cache.storeImage(testImage, forKey: URLString, toDisk: true) { () -> () in
+                self.cache.retrieveImageForKey(URLString, options: nil, completionHandler: { (image, type) -> () in
+                    XCTAssertNotNil(image, "Should be cached (memory or disk)")
+                    XCTAssertEqual(type, CacheType.Memory)
+
+                    let exists = self.cache.cachedImageExistsforURL(URL)
+                    XCTAssertTrue(exists, "Image should exist in the cache (memory or disk)")
+
+                    self.cache.clearMemoryCache()
+                    self.cache.retrieveImageForKey(URLString, options: nil, completionHandler: { (image, type) -> () in
+                        XCTAssertNotNil(image, "Should be cached (disk)")
+                        XCTAssertEqual(type, CacheType.Disk)
+                        
+                        let exists = self.cache.cachedImageExistsforURL(URL)
+                        XCTAssertTrue(exists, "Image should exist in the cache (disk)")
+                        
+                        expectation.fulfill()
+                    })
+                })
+            }
+        })
+        
+        waitForExpectationsWithTimeout(5, handler: nil)
+    }
+    
+    func testCachedFileDoesNotExist() {
+        let URLString = testKeys[0]
+        let URL = NSURL(string: URLString)!
+        
+        let exists = cache.cachedImageExistsforURL(URL)
+        XCTAssertFalse(exists)
+    }
 
     func testCachedImageIsFetchedSyncronouslyFromTheMemoryCache() {
         cache.storeImage(testImage, forKey: testKeys[0], toDisk: false) { () -> () in

+ 123 - 0
Tests/KingfisherTests/ImagePrefetcherTests.swift

@@ -0,0 +1,123 @@
+//
+//  ImagePrefetcherTests.swift
+//  Kingfisher
+//
+//  Created by Claire Knight <claire.knight@moggytech.co.uk> on 24/02/2016
+//
+//  Copyright (c) 2016 Wei Wang <onevcat@gmail.com>
+//
+//  Permission is hereby granted, free of charge, to any person obtaining a copy
+//  of this software and associated documentation files (the "Software"), to deal
+//  in the Software without restriction, including without limitation the rights
+//  to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+//  copies of the Software, and to permit persons to whom the Software is
+//  furnished to do so, subject to the following conditions:
+//
+//  The above copyright notice and this permission notice shall be included in
+//  all copies or substantial portions of the Software.
+//
+//  THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+//  IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+//  FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+//  AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+//  LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+//  OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+//  THE SOFTWARE.
+
+import XCTest
+@testable import Kingfisher
+
+class ImagePrefetcherTests: XCTestCase {
+
+    var prefetcher: ImagePrefetcher!
+    
+    override class func setUp() {
+        super.setUp()
+        LSNocilla.sharedInstance().start()
+    }
+    
+    override class func tearDown() {
+        super.tearDown()
+        LSNocilla.sharedInstance().stop()
+    }
+    
+    override func setUp() {
+        super.setUp()
+        // Put setup code here. This method is called before the invocation of each test method in the class.
+        prefetcher = ImagePrefetcher()
+    }
+    
+    override func tearDown() {
+        // Put teardown code here. This method is called after the invocation of each test method in the class.
+        cleanDefaultCache()
+        prefetcher = nil
+        super.tearDown()
+    }
+
+    func testPrefetchingImages() {
+        let expectation = expectationWithDescription("wait for prefetching images")
+        
+        var urls = [NSURL]()
+        for URLString in testKeys {
+            stubRequest("GET", URLString).andReturn(200).withBody(testImageData)
+            urls.append(NSURL(string: URLString)!)
+        }
+
+        let total = urls.count
+        
+        prefetcher.prefetchURLs(urls, progressBlock: { (completedURLs, allURLs) -> () in
+            XCTAssertEqual(allURLs, total, "total urls should match all those the prefetcher knows about")
+        }) { (cancelled, completedURLs, skippedURLs) -> () in
+            expectation.fulfill()
+            XCTAssertFalse(cancelled, "the prefetch should not have been cancelled")
+            XCTAssertEqual(completedURLs, total, "all requests should have been completed, regardless of success")
+            KingfisherManager.sharedManager.cache.clearMemoryCache()  // Remove from the Memory cache to ensure it is on disk!
+            let cacheStatus = KingfisherManager.sharedManager.cache.isImageCachedForKey(Resource(downloadURL: urls[0]).cacheKey)
+            XCTAssertEqual(CacheType.Disk, cacheStatus.cacheType ?? CacheType.None, "prefetched images should be cached to disk")
+        }
+        
+        waitForExpectationsWithTimeout(5, handler: nil)
+    }
+    
+    func testCancelPrefetching() {
+        let expectation = expectationWithDescription("wait for prefetching images")
+        
+        var urls = [NSURL]()
+        for URLString in testKeys {
+            stubRequest("GET", URLString).andReturn(200).withBody(testImageData)
+            urls.append(NSURL(string: URLString)!)
+        }
+        
+        prefetcher.maxConcurrentDownloads = 2
+        
+        prefetcher.prefetchURLs(urls, progressBlock: { (completedURLs, allURLs) -> () in
+            }) { (cancelled, completedURLs, skippedURLs) -> () in
+                XCTAssertTrue(cancelled, "the prefetch should have been cancelled")
+                // The completed and skipped URLs will depend on how far through the process the prefetch got before the cancel was called
+                expectation.fulfill()
+        }
+        prefetcher.cancelPrefetching()
+
+        waitForExpectationsWithTimeout(5, handler: nil)
+    }
+    
+    func testIsPrefetching() {
+        let expectation = expectationWithDescription("wait for prefetching images")
+        
+        var urls = [NSURL]()
+        for URLString in testKeys {
+            stubRequest("GET", URLString).andReturn(200).withBody(testImageData)
+            urls.append(NSURL(string: URLString)!)
+        }
+        
+        prefetcher.prefetchURLs(urls, progressBlock: { (completedURLs, allURLs) -> () in
+            XCTAssertTrue(self.prefetcher.isPrefetching(), "should be prefetching")
+            }) { (cancelled, completedURLs, skippedURLs) -> () in
+                XCTAssertFalse(cancelled, "the prefetch should not have been cancelled")
+                XCTAssertFalse(self.prefetcher.isPrefetching(), "should not be prefetching")
+                expectation.fulfill()
+        }
+        
+        waitForExpectationsWithTimeout(5, handler: nil)
+    }
+}