Caching broken on iOS 8 - 2/3

The roundup for the released version of 8.1 is available in Part 3.

In Part 1 I discussed how NSURLCache is broken on iOS 8, and promised some source code. The source code is available here, and it is worth taking a closer look at some of the results.

The first thing to do is to edit the configuration section in AppDelegate.h and enter the URL of a suitable image to download. I have deliberately not specified an image here, because I do not want to send high traffic to any particular site. The URL specified will not work from outside the Airsource network, so do not try it!

// Configuration section

// Minimum cache size to actually use store on iOS 8. Below this it doesn't cache anything!
// Things are much more resilient on iOS 7
#define CACHE_SIZE (5 * 1024 * 1024)

// We will fetch this URL both in its provided form, and by appending a fake GET parameter.
// Ensure that your webserver returns the same content for a URL of the form
// http://quincykit.airsource.co.uk/test3.png?5
#define DOWNLOAD_URL @"http://quincykit.airsource.co.uk/test3.png?%lu"

// End configuration section

Then, build the code and run it on an iOS 7.1 device. If you do not have a suitable device, then use an iOS 7.1 simulator. The results are the same. I have not bothered creating a suitable Default-568h.png, so you will see top and bottom black bands.

There are four things you can do from this screen.

entry screen of test app

  1. Fetch - this will execute 10 fetches of an image, each with a different request URL, to ensure that they create separate cache entries.
  2. Inspect the cache - updates the stats at the top of the page. The cache operates somewhat asynchronously, so while we update stats after doing fetches and so forth, the cache may add or delete files after that time.
  3. Clear the cache.
  4. Execute a fetch, always with the same URL, to test that reading a cached entry is working.

The success state of fetches (whether cached or otherwise) will be shown at the bottom of the screen, while the top of the screen shows the latest stats on the cache - actual size (determined by inspecting the actual files stored), size reported by NSURLCache:currentDiskUsage, and the maximum size of cache allowable (NSURLCache:diskCapacity). iOS 7 ignores the sizes of files Cache.db-shm, and Cache.db-wal when reporting the cache size, while iOS 8.1 does not, so I display both exclusive and inclusive sizes.

On startup, you will note that the size of the cache on iOS 7 is non-zero. This is because the database file Cache.db has been initialised by the operation system, and although it has no data rows, it still takes space.

Now execute a few fetches (use the Fetch button). You should see the cache size increase, assuming your fetches succeed. The memory cache will be much larger than the disk cache, because NSURLCache doesn't move things into store until it needs to. Hit "Inspect cache" after a few seconds if the size has not updated.

Next, restart the application. The reason for this is that we included the code

NSURLCache *cache = [[MyCache alloc] initWithMemoryCapacity:sz diskCapacity:sz diskPath:@"nsurlcache"];
[NSURLCache setSharedURLCache:cache];
// This apparently has no effect on iOS 7, whether done before or after the setSharedURLCache line
// However, on iOS 8 it works fine.
// BUT on iOS 8 it will not remove cached responses that would never have been stored in the cache.
[cache removeAllCachedResponses];

in application:didFinishLaunchingWithOptions:

On restart, you will see that the cache has NOT been cleared. Moreover, everything that was in memory has been moved into store. Clearly it does not matter how the application exits - the cache can be treated as persistent. Clearly also, clearing the cache at startup does not work on iOS 7.

Now hit "Clear cache". The cache correctly clears out.

Next, hit "Fetch lots". This execute 10 fetches and will fill up the cache quickly. You should soon see that the disk size of the cache shrinks, indicating that the cache is being purged. Moreover, neither the disk size nor memory size of the cache will exceed the maximum specified.

An important test is to check that the cache is read from. Hit "Cacheable Fetch". Then hit it again. You should see, in the logs on XCode, the line "Retrieved ". If you are on a device, you can simply put the device into Airplane mode, and see if the fetch succeeds. If it does, you must be reading from the cache.

Finally, I noticed that a report that removeCachedResponseForRequest: has no effect. My own experimentation confirmed this - on both iOS 8, and 8.1. I found that removeCachedResponsesSinceData: did work - a new API for iOS 8 that has yet to make it into the documentation (it is present in the API diffs for iOS 8), but I fail to see what use this API is - surely I want to remove cached responses from before a particular date, not afterwards! I have not added test code for this yet, but I have noted the results below.

Results from iOS 7.1.2 (device or simulator)

  • Cache reported size matches actual - FLAWED. The reported size excludes the SHM and WAL files.
  • Clear cache on startup - FAIL.
  • Cache correctly purged - PASS.
  • Cache reads - PASS.
  • Cache clears on demand - PASS. The WAL is not deleted - and this can be large.
  • Clearing cache a second time resets the WAL - PASS (determined by inspecting logs in XCode)
  • Can clear individual cache items - PASS.

Repeat the tests on a iOS 8.0.x device (simulator not tested), and you should get the following results:

  • Cache reported size matches actual - FAIL. The reported size is consistently far too small.
  • Clear cache on startup - PASS.
  • Cache correctly purged - FAIL. You should, by repeatedly hitting the "Fetch lots" button, be able to generate cache sizes (both actual and reported) clearly in excesss of the specified maximum. Eventually you will see the reported cache size reduce - but NOT the actual size.
  • Cache reads - PASS.
  • Cache clears on demand - PASS.
  • Clearing cache a second time resets the WAL - PASS (determined by inspecting logs in XCode)
  • Can clear individual cache items - FAIL.

Repeat the tests on an iOS 8.1 beta device or simulator, and you should get the following results:

  • Cache reported size matches actual - PASS. The reported size now includes the SHM and WAL files.
  • Clear cache on startup - PASS.
  • Cache correctly purged - FLAWED. Sort of - the disk usage sometimes exceeds the maximum size of the cache by a megabyte or two. I believe that the operating system is excluding the size of the WAL file when deciding if the cache is too big or not.
  • Cache reads - PASS.
  • Cache clears on demand - PASS (but does not delete the WAL, even though that is included in the reported size).
  • Clearing cache a second time resets the WAL - PASS (determined by inspecting logs in XCode).
  • Can clear individual cache items - FAIL.

There are two more potentially significant issue that I discovered while doing this research. The minimum disk cache capacity on iOS 8 appears to be 5 megabytes, determined by experimentation. When I set the disk capacity below that value, no data was created on the file system, nor was any memory used. No such issues showed up on iOS 7 for small cache sizes.

This has a potentially serious consequence - if a developer set the size of the cache explicitly on iOS 7 to a value smaller than 5 megabytes, they will find that the cache is not used at all (even if present on device) on iOS 8. The cache will instantiate properly - and storeCachedResponse:forRequest: will be called - but nothing will be stored - and any existing data present on the file system from when the app was used with iOS 7 will neither be retrieved nor cleared.

Ben Blaukopf
in iOS Tagged Technical iOS NSURLCache

Airsource design and develop apps for ourselves and for our clients. We help people like you to turn concepts into reality, taking ideas from initial design, development and ongoing maintenance and support.

Contact us today to find out how we can help you build and maintain your app.