博客分类: iphoneipod touchipad
ioswebviewcache 
智能手机的流行让移动运营商们大赚了一笔,然而消费者们却不得不面对可怕的数据流量账单。因为在线看部电影可能要上千块通讯费,比起电影院什么的简直太坑爹了。 
所以为了减少流量开销,离线浏览也就成了很关键的功能,而UIWebView这个让人又爱又恨的玩意弱爆了,居然只在Mac OS X上提供webView:resource:willSendRequest:redirectResponse:fromDataSource:这个方法,于是只好自己动手实现了。 

原理就是SDK里绝大部分的网络请求都会访问[NSURLCache sharedURLCache]这个对象,它的cachedResponseForRequest:方法会返回一个NSCachedURLResponse对象。如果这个NSCachedURLResponse对象不为nil,且没有过期,那么就使用这个缓存的响应,否则就发起一个不访问缓存的请求。 
要注意的是NSCachedURLResponse对象不能被提前释放,除非UIWebView去调用NSURLCache的removeCachedResponseForRequest:方法,原因貌似是UIWebView并不retain这个响应。而这个问题又很头疼,因为UIWebView有内存泄露的嫌疑,即使它被释放了,也很可能不去调用上述方法,于是内存就一直占用着了。 

顺便说下NSURLRequest对象,它有个cachePolicy属性,只要其值为NSURLRequestReloadIgnoringLocalCacheData的话,就不会访问缓存。可喜的是这种情况貌似只有在缓存里没取到,或是强制刷新时才可能出现。 
实际上NSURLCache本身就有磁盘缓存功能,然而在iOS上,NSCachedURLResponse却被限制为不能缓存到磁盘(NSURLCacheStorageAllowed被视为NSURLCacheStorageAllowedInMemoryOnly)。 
不过既然知道了原理,那么只要自己实现一个NSURLCache的子类,然后改写cachedResponseForRequest:方法,让它从硬盘读取缓存即可。 

于是就开工吧。这次的demo逻辑比较复杂,因此我就按步骤来说明了。 

先定义视图和控制器。 
它的逻辑是打开应用时就尝试访问缓存文件,如果发现存在,则显示缓存完毕;否则就尝试下载整个网页的资源;在下载完成后,也显示缓存完毕。 
不过下载所有资源需要解析HTML,甚至是JavaScript和CSS。为了简化我就直接用一个不显示的UIWebView载入这个页面,让它自动去发起所有请求。 
当然,缓存完了还需要触发事件来显示网页。于是再提供一个按钮,点击时显示缓存的网页,再次点击就关闭。 
顺带一提,我本来想用Google为例的,可惜它自己实现了HTML 5离线浏览,也就体现不出这种方法的意义了,于是只好拿百度来垫背。 
Objective-c代码 收藏代码

#import <UIKit/UIKit.h>  


@interface WebViewController : UIViewController <UIWebViewDelegate> {  

UIWebView *web;  

UILabel *label;  

}  


@property (nonatomic, retain) UIWebView *web;  

@property (nonatomic, retain) UILabel *label;  


- (IBAction)click;  


@end  



#import "WebViewController.h"  

#import "URLCache.h"  


@implementation WebViewController  


@synthesize web, label;  


- (IBAction)click {  

if (web) {  

[web removeFromSuperview];  

self.web = nil;  

} else {  

CGRect frame = {{0, 0}, {320, 380}};  

UIWebView *webview = [[UIWebView alloc] initWithFrame:frame];  

webview.scalesPageToFit = YES;  

self.web = webview;  


NSURLRequest *request = [NSURLRequest requestWithURL:[NSURL URLWithString:@"http://www.baidu.com/"]];  

[webview loadRequest:request];  

[self.view addSubview:webview];  

[webview release];  

}  

}  


- (void)addButton {  

CGRect frame = {{130, 400}, {60, 30}};  

UIButton *button = [UIButton buttonWithType:UIButtonTypeRoundedRect];  

button.frame = frame;  

[button addTarget:self action:@selector(click) forControlEvents:UIControlEventTouchUpInside];  

[button setTitle:@"我点" forState:UIControlStateNormal];  

[self.view addSubview:button];  

}  


- (void)viewDidLoad {  

[super viewDidLoad];  


URLCache *sharedCache = [[URLCache alloc] initWithMemoryCapacity:1024 * 1024 diskCapacity:0 diskPath:nil];  

[NSURLCache setSharedURLCache:sharedCache];  


CGRect frame = {{60, 200}, {200, 30}};  

UILabel *textLabel = [[UILabel alloc] initWithFrame:frame];  

textLabel.textAlignment = UITextAlignmentCenter;  

[self.view addSubview:textLabel];  

self.label = textLabel;  


if (![sharedCache.responsesInfo count]) { // not cached  

textLabel.text = @"缓存中…";  


CGRect frame = {{0, 0}, {320, 380}};  

UIWebView *webview = [[UIWebView alloc] initWithFrame:frame];  

webview.delegate = self;  

self.web = webview;  


NSURLRequest *request = [NSURLRequest requestWithURL:[NSURL URLWithString:@"http://www.baidu.com/"]];  

[webview loadRequest:request];  

[webview release];  

} else {  

textLabel.text = @"已从硬盘读取缓存";  

[self addButton];  

}  


[sharedCache release];  

}  


- (void)webView:(UIWebView *)webView didFailLoadWithError:(NSError *)error {  

self.web = nil;  

label.text = @"请接通网络再运行本应用";  

}  


- (void)webViewDidFinishLoad:(UIWebView *)webView {  

self.web = nil;  

label.text = @"缓存完毕";  

[self addButton];  


URLCache *sharedCache = (URLCache *)[NSURLCache sharedURLCache];  

[sharedCache saveInfo];  

}  


- (void)didReceiveMemoryWarning {  

[super didReceiveMemoryWarning];  


if (!web) {  

URLCache *sharedCache = (URLCache *)[NSURLCache sharedURLCache];  

[sharedCache removeAllCachedResponses];  

}  

}  


- (void)viewDidUnload {  

self.web = nil;  

self.label = nil;  

}  



- (void)dealloc {  

[super dealloc];  

[web release];  

[label release];  

}  


@end  



大部分的代码没什么要说的,随便挑2点。  

实现了UIWebViewDelegate,因为需要知道缓存完毕或下载失败这个事件。  

另外,正如前面所说的,UIWebView可能不会通知释放缓存。所以在收到内存警告时,如果UIWebView对象已被释放,那么就可以安全地清空缓存了(或许还要考虑多线程的影响)。  


接下来就是重点了:实现URLCache类。  

它需要2个属性:一个是用于保存NSCachedURLResponse的cachedResponses,另一个是用于保存响应信息的responsesInfo(包括MIME类型和文件名)。  

另外还需要实现一个saveInfo方法,用于将responsesInfo保存到磁盘。不过大多数应用应该使用数据库来保存,这里我只是为了简化而已。  

Objective-c代码 收藏代码 

#import <Foundation/Foundation.h>  


@interface URLCache : NSURLCache {  

NSMutableDictionary *cachedResponses;  

NSMutableDictionary *responsesInfo;  

}  


@property (nonatomic, retain) NSMutableDictionary *cachedResponses;  

@property (nonatomic, retain) NSMutableDictionary *responsesInfo;  


- (void)saveInfo;  


@end  



#import "URLCache.h"  

@implementation URLCache  

@synthesize cachedResponses, responsesInfo;  


- (void)removeCachedResponseForRequest:(NSURLRequest *)request {  

NSLog(@"removeCachedResponseForRequest:%@", request.URL.absoluteString);  

[cachedResponses removeObjectForKey:request.URL.absoluteString];  

[super removeCachedResponseForRequest:request];  

}  


- (void)removeAllCachedResponses {  

NSLog(@"removeAllObjects");  

[cachedResponses removeAllObjects];  

[super removeAllCachedResponses];  

}  


- (void)dealloc {  

[cachedResponses release];  

[responsesInfo release];  

}  


@end  



写完这些没技术含量的代码后,就来实现saveInfo方法吧。  

这里有一个要点需要说下,iTunes会备份所有的应用资料,除非放在Library/Caches或tmp文件夹下。由于缓存并不是什么很重要的用户资料,没必要增加用户的备份时间和空间,所以我们应该把缓存放到这2个文件夹里。而后者会在退出应用或重启系统时清空,这显然不是我们想要的效果,于是最佳选择是前者。  

Objective-c代码 收藏代码 

static NSString *cacheDirectory;  


+ (void)initialize {  

NSArray *paths = NSSearchPathForDirectoriesInDomains(NSCachesDirectory, NSUserDomainMask, YES);  

cacheDirectory = [[paths objectAtIndex:0] retain];  

}  


- (void)saveInfo {  

if ([responsesInfo count]) {  

NSString *path = [cacheDirectory stringByAppendingString:@"responsesInfo.plist"];  

[responsesInfo writeToFile:path atomically: YES];  

}  

}  


这里我用了stringByAppendingString:方法,更保险的是使用stringByAppendingPathComponent:。不过我估计后者会做更多的检查工作,所以采用了前者。  


在实现saveInfo后,初始化方法就也可以实现了。它主要就是载入保存的plist文件,如果不存在则新建一个空的NSMutableDictionary对象。  

Objective-c代码 收藏代码 

- (id)initWithMemoryCapacity:(NSUInteger)memoryCapacity diskCapacity:(NSUInteger)diskCapacity diskPath:(NSString *)path {  

if (self = [super initWithMemoryCapacity:memoryCapacity diskCapacity:diskCapacity diskPath:path]) {  

cachedResponses = [[NSMutableDictionary alloc] init];  

NSString *path = [cacheDirectory stringByAppendingString:@"responsesInfo.plist"];  

NSFileManager *fileManager = [[NSFileManager alloc] init];  

if ([fileManager fileExistsAtPath:path]) {  

responsesInfo = [[NSMutableDictionary alloc] initWithContentsOfFile:path];  

} else {  

responsesInfo = [[NSMutableDictionary alloc] init];  

}  

[fileManager release];  

}  

return self;  

}  


接下来就可以实现cachedResponseForRequest:方法了。  

我们得先判断是不是GET方法,因为其他方法不应该被缓存。还得判断是不是网络请求,例如http、https和ftp,因为连data协议等本地请求都会跑到这个方法里来…  

Objective-c代码 收藏代码 

static NSSet *supportSchemes;  


+ (void)initialize {  

NSArray *paths = NSSearchPathForDirectoriesInDomains(NSCachesDirectory, NSUserDomainMask, YES);  

cacheDirectory = [[paths objectAtIndex:0] retain];  

supportSchemes = [[NSSet setWithObjects:@"http", @"https", @"ftp", nil] retain];  

}  


- (NSCachedURLResponse *)cachedResponseForRequest:(NSURLRequest *)request {  

if ([request.HTTPMethod compare:@"GET"] != NSOrderedSame) {  

return [super cachedResponseForRequest:request];  

}  


NSURL *url = request.URL;  

if (![supportSchemes containsObject:url.scheme]) {  

return [super cachedResponseForRequest:request];  

}  

//...  

}



因为没必要处理它们,所以直接交给父类的处理方法了,它会自行决定是否返回nil的。 

接着判断是不是已经在cachedResponses里了,这样的话直接拿出来即可: 

Objective-c代码 收藏代码 

NSString *absoluteString = url.absoluteString;  

NSLog(@"%@", absoluteString);  

NSCachedURLResponse *cachedResponse = [cachedResponses objectForKey:absoluteString];  

if (cachedResponse) {  

NSLog(@"cached: %@", absoluteString);  

return cachedResponse;  

}


再查查responsesInfo里有没有,如果有的话,说明可以从磁盘获取: 
Objective-c代码 收藏代码

NSDictionary *responseInfo = [responsesInfo objectForKey:absoluteString];  

if (responseInfo) {  

NSString *path = [cacheDirectory stringByAppendingString:[responseInfo objectForKey:@"filename"]];  

NSFileManager *fileManager = [[NSFileManager alloc] init];  

if ([fileManager fileExistsAtPath:path]) {  

[fileManager release];  


NSData *data = [NSData dataWithContentsOfFile:path];  

NSURLResponse *response = [[NSURLResponse alloc] initWithURL:request.URL MIMEType:[responseInfo objectForKey:@"MIMEType"] expectedContentLength:data.length textEncodingName:nil];  

cachedResponse = [[NSCachedURLResponse alloc] initWithResponse:response data:data];  

[response release];  


[cachedResponses setObject:cachedResponse forKey:absoluteString];  

[cachedResponse release];  

NSLog(@"cached: %@", absoluteString);  

return cachedResponse;  

}  

[fileManager release];  

}



这里的难点在于构造NSURLResponse和NSCachedURLResponse,不过对照下文档看看也就清楚了。如前文所说,我们还得把cachedResponse保存到cachedResponses里,避免它被提前释放。 

接下来就说明缓存不存在了,需要我们自己发起一个请求。可恨的是NSURLResponse不能更改属性,所以还需要手动新建一个NSMutableURLRequest对象: 
Objective-c代码 收藏代码
NSMutableURLRequest *newRequest = [NSMutableURLRequest requestWithURL:url cachePolicy:NSURLRequestReloadIgnoringLocalCacheData timeoutInterval:request.timeoutInterval]; 
newRequest.allHTTPHeaderFields = request.allHTTPHeaderFields; 
newRequest.HTTPShouldHandleCookies = request.HTTPShouldHandleCookies; 

实际上NSMutableURLRequest还有一些其他的属性,不过并不太重要,所以我就只复制了这2个。 

然后就可以用它来发起请求了。由于UIWebView就是在子线程调用cachedResponseForRequest:的,不用担心阻塞的问题,所以无需使用异步请求: 
Objective-c代码 收藏代码
NSError *error = nil; 
NSURLResponse *response = nil; 
NSData *data = [NSURLConnection sendSynchronousRequest:newRequest returningResponse:&response error:&error]; 
if (error) { 
NSLog(@"%@", error); 
NSLog(@"not cached: %@", absoluteString); 
return nil; 


如果下载没出错的话,我们就能拿到data和response了,于是就能将其保存到磁盘了。保存的文件名必须是合法且独一无二的,所以我就用到了sha1算法。 
Objective-c代码 收藏代码
uint8_t digest[CC_SHA1_DIGEST_LENGTH]; 
CC_SHA1(data.bytes, data.length, digest); 
NSMutableString* output = [NSMutableString stringWithCapacity:CC_SHA1_DIGEST_LENGTH * 2]; 
for(int i = 0; i < CC_SHA1_DIGEST_LENGTH; i++) 
[output appendFormat:@"%02x", digest ]; 

NSString *filename = output;//sha1([absoluteString UTF8String]); 
NSString *path = [cacheDirectory stringByAppendingString:filename]; 
NSFileManager *fileManager = [[NSFileManager alloc] init]; 
[fileManager createFileAtPath:path contents:data attributes:nil]; 
[fileManager release]; 

接下来还得将文件信息保存到responsesInfo,并构造一个NSCachedURLResponse。 
然而这里还有个陷阱,因为直接使用response对象会无效。我稍微研究了一下,发现它其实是个NSHTTPURLResponse对象,可能是它的allHeaderFields属性影响了缓存策略,导致不能重用。 
不过这难不倒我们,直接像前面那样构造一个NSURLResponse对象就行了,这样就没有allHeaderFields属性了:
Objective-c代码 收藏代码
NSURLResponse *newResponse = [[NSURLResponse alloc] initWithURL:response.URL MIMEType:response.MIMEType expectedContentLength:data.length textEncodingName:nil]; 
responseInfo = [NSDictionary dictionaryWithObjectsAndKeys:filename, @"filename", newResponse.MIMEType, @"MIMEType", nil]; 
[responsesInfo setObject:responseInfo forKey:absoluteString]; 
NSLog(@"saved: %@", absoluteString); 

cachedResponse = [[NSCachedURLResponse alloc] initWithResponse:newResponse data:data]; 
[newResponse release]; 
[cachedResponses setObject:cachedResponse forKey:absoluteString]; 
[cachedResponse release]; 
return cachedResponse;