How to prevent the browser cache interfering with NSURLCache?

958 views Asked by At

I am trying to achieve the following on iOS:

  • Always load local files to a UIWebView for static assets (.html, .js, etc.)
  • Allow an update protocol such that after some period of time we can return a different set of static assets for the same URLs

Download link for minimal example.

I have this working but it seems the NSURLCache is sometimes completely missed (reproducible) and the only reliable way to fix this has been to use nasty cachebusting tricks on the page being loaded. Another equally nasty hack is to destroy the UIWebView and create another.

As an example of this we have three versions of our webapp (red, blue and green for v1, v2 and v3 respectively):

v1v2v3

Each screen is made up of a single HTML and JS file.

index.html
----------

<!DOCTYPE html>
<html>
<head>
</head>

<body style="background-color:#FF0000">
    <h1 id="label" style="color:#FFFFFF"></h1>
    <script src="app.js"></script>
</body>
</html>

app.js
------
var label = document.getElementById('label');
label.innerHTML = 'red';

Every 2 seconds the following happens:

  1. We change what files the NSURLCache will return to make it return the different versions (note we implement this by overriding cachedResponseForRequest: rather than storeCachedResponse:forRequest:)
  2. The UIWebView loads a dummy page "http://www.cacheddemo.co.uk/index.html"

The NSURLCache logic is implemented simply as a rotating NSMutableArray:

@implementation ExampleCache

- (id)init
{
    self = [super initWithMemoryCapacity:8 * 1024 * 1024 diskCapacity:8 * 1024 * 1024 diskPath:@"webcache.db"];
    if(self) {
        [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(swapCache:) name:@"swapCache" object:nil];
        self.cache = [@[@{@"index.html":@"index1.html", @"app.js":@"app1.js"},
                       @{@"index.html":@"index2.html", @"app.js":@"app2.js"},
                       @{@"index.html":@"index3.html", @"app.js":@"app3.js"}] mutableCopy];
    }

    return self;
}

- (void)swapCache:(NSNotification *)notification
{
    [self.cache addObject:self.cache[0]];
    [self.cache removeObjectAtIndex:0];
}

- (NSCachedURLResponse *)cachedResponseForRequest:(NSURLRequest *)request
{
    NSString *file = [[[request URL] pathComponents] lastObject];
    NSString *mimeType;

    if([file hasSuffix:@".html"]) {
        mimeType = @"text/html";
    } else if([file hasSuffix:@".js"]) {
        mimeType = @"application/javascript";
    }

    if(mimeType) {
        NSString *cachedFile = self.cache[0][file];
        NSUInteger indexOfDot = [cachedFile rangeOfString:@"."].location;

        NSString *path = [[NSBundle mainBundle] pathForResource:[cachedFile substringToIndex:indexOfDot] ofType:[cachedFile substringFromIndex:indexOfDot + 1] inDirectory:@"www"];
        NSData *data = [NSData dataWithContentsOfFile:path];

        if(data.length) {
            NSLog(@"Response returned for %@", file);

            NSURLResponse *urlResponse = [[NSURLResponse alloc] initWithURL:[request URL] MIMEType:mimeType  expectedContentLength:data.length textEncodingName:nil];
            NSCachedURLResponse *response = [[NSCachedURLResponse alloc] initWithResponse:urlResponse data:data];
            return response;
        }
    }

    NSLog(@"No response for %@ - %@", file, request);
    return nil;
}

The view controller logic uses GCD to reload the UIWebView after the delay:

- (void)viewDidLoad
{
    [super viewDidLoad];

    self.webView = [[UIWebView alloc] initWithFrame:self.view.bounds];
    [self.webView setAutoresizingMask:UIViewAutoresizingFlexibleHeight | UIViewAutoresizingFlexibleWidth];
    [self.webView setDelegate:self];
    [self.view addSubview:self.webView];

    [self loadContent];
}

- (void)loadContent
{
    [self.webView loadRequest:[NSURLRequest requestWithURL:[NSURL URLWithString:[NSString stringWithFormat:@"%@?%d", @"http://www.cacheddemo.co.uk/index.html", arc4random()]]]];
//    [self.webView loadRequest:[NSURLRequest requestWithURL:[NSURL URLWithString:@"http://www.cacheddemo.co.uk/index.html"]]];

    double delayInSeconds = 2.0;
    dispatch_time_t popTime = dispatch_time(DISPATCH_TIME_NOW, (int64_t)(delayInSeconds * NSEC_PER_SEC));
    dispatch_after(popTime, dispatch_get_main_queue(), ^(void){
        [[NSNotificationCenter defaultCenter] postNotificationName:@"swapCache" object:nil];
        [self loadContent];
    });
}

The part I can not understand here is that adding the query string will make the page reloading work flawlessly (loads R - G - B - R - G - ...) - this is the uncommented line:

[self.webView loadRequest:[NSURLRequest requestWithURL:[NSURL URLWithString:[NSString stringWithFormat:@"%@?%d", @"http://www.cacheddemo.co.uk/index.html", arc4random()]]]];

Once we get rid of the query string the NSURLCache stops being hit other than the first request so it just stays on the R page:

[self.webView loadRequest:[NSURLRequest requestWithURL:[NSURL URLWithString:@"http://www.cacheddemo.co.uk/index.html"]]];

The fact that the query string causes the NSURLCache to act as it is supposed to indicates to me that the browser cache is interfering in some way. In my head I figure the caching levels work as so:

  1. Check browser cache
  2. If nothing returned so far - check NSURLCache
  3. If nothing returned so far - check proxy server cache
  4. Finally attempt to load the remote resource

How can we disable the browser cache entirely so we can completely control caching behaviour for UIWebView. Unfortunately I do not see an option to set the Cache-Control header in NSURLCache - I already tried returning a NSHTTPURLResponse with the headers set but this seems to be ignored.

2

There are 2 answers

2
Leonardo On

I am not sure I understand correctly, but the cache-control has to be set on server side, with something like no-cache, expires and so on, and not on iOS side.

Second, by modifying the query string i.e. www.mysite.com/page?id=whatever..., iOS and any browser think the request is not the same, if you have opened the cache itself with some db editor, you should have seen one request, which is one database entry, for each changed query.

This trick of adding a random query string is quite useful for avoiding the browser to cache javascript file.

I hope I understand correctly your question.

1
Ishan On

The browser does appear to have its own "cache" above the networking layer's cache (i.e. NSURLCache).

Not sure if setting the cache control headers will solve it but if you want to try that you can do so in your code for cachedResponseForRequest. Where you create an NSURLResponse object you can use initWithURL:statusCode:HTTPVersion:headerFields: to set the header fields (including the cache control headers). In your cache control header you can try using both no-store and no-cache (e.g. http://blog.55minutes.com/2011/10/how-to-defeat-the-browser-back-button-cache/ ). If you try this please let us know if it does or does not work.

However, in practice I think the most pragmatic and maintainable solution will be to the use a number on the URL (i.e. cache busting). BTW instead of a random number you can use a simple increment that gets reset whenever the webview is allocated.