为UIWebView实现离线浏览

[转] 为UIWebView实现离线浏览

博客分类: 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; 
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值