Apps that communicate with a server via HTTP usually have two particular requirements: don’t make the user wait for data whenever possible, and be useful when there is no internet connection. Both are the source of much reinventing the wheel.
These are very common problems, so it shouldn’t surprise us that iOS has all the APIs we need to implement response caching and offline mode. Very little code is required, even less if your server plays nice with cache headers and you’re targeting iOS 7 and above.
The shared NSURLCache
gives us much out of the box. If our server uses Cache-Control
HTTP headers a sets the maximum age for its responses, both the shared NSURLSession
(iOS 7 only) and NSURLConnection
will respect this and return cached responses before they expire. We don’t need to write any code to get this functionality, and in a perfect world we would stop here.
Sadly, the world of HTTP caching in iOS is far from perfect.
What happens if a response has expired and the app is offline?
By default, NSURLSession
and NSURLConnection
will not use an expired response if there isn’t internet connectivity. This is the behavior of NSURLRequestUseProtocolCachePolicy
, the default value of - [NSURLRequest cachePolicy]
, which simply follows the HTTP protocol cache headers to its best of its knowledge.
In most cases, showing old data is better that showing no data (exceptions being weather and stock, for example). If we want our offline mode to always return the cached data, then our requests must have a different cache policy, one that uses cache data regardless of its expiration date. Both NSURLRequestReturnCacheDataDontLoad
and NSURLRequestReturnCacheDataElseLoad
fit this criteria. In particular, NSURLRequestReturnCacheDataElseLoad
has the advantage of trying the network if no cached response is found.
Of course, we should only use these cache policies when there is no internet connectivity. If we have some reachability observer at hand, our code could look like this:
1
2
3
4
5
6
7
8
9
10
11
|
NSURLRequest
*request
;
if
(
reachability
.
isReachable
)
{
request
=
[
NSURLRequest
requestWithURL
:url
]
;
}
else
{
request
=
[
NSURLRequest
requestWithURL
:url
cachePolicy
:NSURLRequestReturnCacheDataElseLoad
timeoutInterval
:
60
]
;
}
|
Then we give this request to NSURLSession
or NSURLConnection
and hope for the best.
But not if you´re targeting iOS 6. There is a bug in iOS 6 that prevents NSURLRequestReturnCacheDataDontLoad
and NSURLRequestReturnCacheDataElseLoad
from working properly. If you’re targetting this version and want to implement a true offline mode you’ll have to read NSURLCache
directly:
1
|
NSCachedURLResponse
*cachedResponse
=
[
[
NSURLCache
sharedURLCache
]
cachedResponseForRequest
:request
]
;
|
The most excellent AFNetworking 2.0 library offers HTTP requests managers in AFHTTPSessionManager
(using NSURLSession
) and AFHTTPRequestOperationManager
(using NSURLConnection
). Both can be customised by subclassing them and overriding the method that creates the data task or operation respectively.
Subclassing AFHTTPSessionManager
to modify the cache policy of URL requests can be done by overriding dataTaskWithRequest:completionHandler:
:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
|
@implementation
MYHTTPSessionManager
-
(
NSURLSessionDataTask
*
)
dataTaskWithRequest
:
(
NSURLRequest
*
)
request
completionHandler
:
(
void
(
^
)
(
NSURLResponse
*response
,
id
responseObject
,
NSError
*error
)
)
completionHandler
{
NSMutableURLRequest
*modifiedRequest
=
request
.
mutableCopy
;
AFNetworkReachabilityManager
*reachability
=
self
.
reachabilityManager
;
if
(
!
reachability
.
isReachable
)
{
modifiedRequest
.
cachePolicy
=
NSURLRequestReturnCacheDataElseLoad
;
}
return
[
super
dataTaskWithRequest
:modifiedRequest
completionHandler
:completionHandler
]
;
}
@end
|
Likewise, with AFHTTPRequestOperationManager
:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
|
@implementation
MYHTTPRequestOperationManager
-
(
AFHTTPRequestOperation
*
)
HTTPRequestOperationWithRequest
:
(
NSURLRequest
*
)
request
success
:
(
void
(
^
)
(
AFHTTPRequestOperation
*operation
,
id
responseObject
)
)
success
failure
:
(
void
(
^
)
(
AFHTTPRequestOperation
*operation
,
NSError
*error
)
)
failure
{
NSMutableURLRequest
*modifiedRequest
=
request
.
mutableCopy
;
AFNetworkReachabilityManager
*reachability
=
self
.
reachabilityManager
;
if
(
!
reachability
.
isReachable
)
{
modifiedRequest
.
cachePolicy
=
NSURLRequestReturnCacheDataElseLoad
;
}
return
[
super
HTTPRequestOperationWithRequest
:modifiedRequest
success
:success
failure
:failure
]
;
}
@end
|
Note that in both cases we’re using AFNetworkReachabilityManager
as our reachability observer instead of implementing our own. Another advantage of using AFNetworking.
AFNetworking does not provide a workaround for the iOS 6 bug mentioned above and most likely never will. We still have to read the cache directly if our app targets iOS 6 and want to use the mentioned cache policies.
What happens if the server doesn’t set cache headers?
Good server APIs are few and scarce, and a consistent use of cache headers is not always a given. Even if we don’t have any say on the server cache policy, we can still set the expiration date of responses by adding or replacing cache headers at client level. This should only be used as a last resort, when it’s impossible to reach or convince backend developers to add cache headers.
The NSURLSession
delegate receives a URLSession:dataTask:willCacheResponse:completionHandler:
, as part of the NSURLSessionDataDelegate
protocol. This provides a great opportunity to modify the response before caching it. If our server doesn’t provide cache headers and we’re confident it should, we can add them like this:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
|
-
(
void
)
URLSession
:
(
NSURLSession
*
)
session
dataTask
:
(
NSURLSessionDataTask
*
)
dataTask
willCacheResponse
:
(
NSCachedURLResponse
*
)
proposedResponse
completionHandler
:
(
void
(
^
)
(
NSCachedURLResponse
*cachedResponse
)
)
completionHandler
{
NSURLResponse
*response
=
proposedResponse
.
response
;
NSHTTPURLResponse
*HTTPResponse
=
(
NSHTTPURLResponse
*
)
response
;
NSDictionary
*headers
=
HTTPResponse
.
allHeaderFields
;
NSCachedURLResponse
*cachedResponse
;
if
(
headers
[
@"Cache-Control"
]
)
{
NSMutableDictionary
*modifiedHeaders
=
headers
.
mutableCopy
;
[
modifiedHeaders
setObject
:
@"max-age=60"
forKey
:
@"Cache-Control"
]
;
NSHTTPURLResponse
*modifiedResponse
=
[
[
NSHTTPURLResponse
alloc
]
initWithURL
:HTTPResponse
.
URL
statusCode
:HTTPResponse
.
statusCode
HTTPVersion
:
@"HTTP/1.1"
headerFields
:modifiedHeaders
]
;
cachedResponse
=
[
[
NSCachedURLResponse
alloc
]
initWithResponse
:modifiedResponse
data
:proposedResponse
.
data
userInfo
:proposedResponse
.
userInfo
storagePolicy
:proposedResponse
.
storagePolicy
]
;
}
else
{
cachedResponse
=
proposedResponse
;
}
completionHandler
(
cachedResponse
)
;
}
|
Likewise, the NSURLConnection
delegate receives a connection:willCacheResponse:
, as part of the NSURLConnectionDataDelegate
protocol. The code to modify the response headers right before caching the response would be something like this:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
|
-
(
NSCachedURLResponse
*
)
connection
:
(
NSURLConnection
*
)
connection
willCacheResponse
:
(
NSCachedURLResponse
*
)
cachedResponse
{
NSURLResponse
*response
=
cachedResponse
.
response
;
if
(
[
response
isKindOfClass
:NSHTTPURLResponse
.
class
]
)
return
cachedResponse
;
NSHTTPURLResponse
*HTTPResponse
=
(
NSHTTPURLResponse
*
)
response
;
NSDictionary
*headers
=
HTTPResponse
.
allHeaderFields
;
if
(
headers
[
@"Cache-Control"
]
)
return
cachedResponse
;
NSMutableDictionary
*modifiedHeaders
=
headers
.
mutableCopy
;
modifiedHeaders
[
@"Cache-Control"
]
=
@"max-age=60"
;
NSHTTPURLResponse
*modifiedResponse
=
[
[
NSHTTPURLResponse
alloc
]
initWithURL
:HTTPResponse
.
URL
statusCode
:HTTPResponse
.
statusCode
HTTPVersion
:
@"HTTP/1.1"
headerFields
:modifiedHeaders
]
;
cachedResponse
=
[
[
NSCachedURLResponse
alloc
]
initWithResponse
:modifiedResponse
data
:cachedResponse
.
data
userInfo
:cachedResponse
.
userInfo
storagePolicy
:cachedResponse
.
storagePolicy
]
;
return
cachedResponse
;
}
|
When using AFNetworking the code is slightly different depending on which HTTP manager we’re using. AFHTTPSessionManager
acts as the NSURLSession
delegate, so we simply implement URLSession:dataTask:willCacheResponse:completionHandler:
in our subclass and call super as required by the AFNetworking documentation.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
|
-
(
void
)
URLSession
:
(
NSURLSession
*
)
session
dataTask
:
(
NSURLSessionDataTask
*
)
dataTask
willCacheResponse
:
(
NSCachedURLResponse
*
)
proposedResponse
completionHandler
:
(
void
(
^
)
(
NSCachedURLResponse
*cachedResponse
)
)
completionHandler
{
NSURLResponse
*response
=
proposedResponse
.
response
;
NSHTTPURLResponse
*HTTPResponse
=
(
NSHTTPURLResponse
*
)
response
;
NSDictionary
*headers
=
HTTPResponse
.
allHeaderFields
;
if
(
headers
[
@"Cache-Control"
]
)
{
NSMutableDictionary
*modifiedHeaders
=
headers
.
mutableCopy
;
modifiedHeaders
[
@"Cache-Control"
]
=
@"max-age=60"
;
NSHTTPURLResponse
*modifiedHTTPResponse
=
[
[
NSHTTPURLResponse
alloc
]
initWithURL
:HTTPResponse
.
URL
statusCode
:HTTPResponse
.
statusCode
HTTPVersion
:
@"HTTP/1.1"
headerFields
:modifiedHeaders
]
;
proposedResponse
=
[
[
NSCachedURLResponse
alloc
]
initWithResponse
:modifiedHTTPResponse
data
:proposedResponse
.
data
userInfo
:proposedResponse
.
userInfo
storagePolicy
:proposedResponse
.
storagePolicy
]
;
}
[
super
URLSession
:session
dataTask
:dataTask
willCacheResponse
:proposedResponse
completionHandler
:completionHandler
]
;
}
|
In the case of AFHTTPRequestOperationManager
, AFHTTPRequestOperation
has a handy block setter for connection:willCacheResponse:
. This allows us to put all our cache code in the HTTPRequestOperationWithRequest:success:failure
override:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
|
-
(
AFHTTPRequestOperation
*
)
HTTPRequestOperationWithRequest
:
(
NSURLRequest
*
)
request
success
:
(
void
(
^
)
(
AFHTTPRequestOperation
*operation
,
id
responseObject
)
)
success
failure
:
(
void
(
^
)
(
AFHTTPRequestOperation
*operation
,
NSError
*error
)
)
failure
{
NSMutableURLRequest
*modifiedRequest
=
request
.
mutableCopy
;
AFNetworkReachabilityManager
*reachability
=
self
.
reachabilityManager
;
if
(
!
reachability
.
isReachable
)
{
modifiedRequest
.
cachePolicy
=
NSURLRequestReturnCacheDataElseLoad
;
}
AFHTTPRequestOperation
*operation
=
[
super
HTTPRequestOperationWithRequest
:modifiedRequest
success
:success
failure
:failure
]
;
[
operation
setCacheResponseBlock
:
^
NSCachedURLResponse
*
(
NSURLConnection
*connection
,
NSCachedURLResponse
*cachedResponse
)
{
// Modify cache header as shown above
}
]
;
return
operation
;
}
|
NSURLCache
caches responses, not the result of parsing them. Complex responses might require considerable parsing time, and in those cases it might be best to cache the parsed objects in a custom cache instead of relying on NSURLCache
.
Additionally, you might want to validate the response before caching it. Servers are prone to sporadically return errors (e.g., “Too many connections”), and caching such a response would make sure the user gets the same error for as long as the response doesn’t expire.