`

为UIWebView实现离线浏览(转)

 
阅读更多

智能手机的流行让移动运营商们大赚了一笔,然而消费者们却不得不面对可怕的数据流量账单。因为在线看部电影可能要上千块通讯费,比起电影院什么的简直太坑爹了。
所以为了减少流量开销,离线浏览也就成了很关键的功能,而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离线浏览,也就体现不出这种方法的意义了,于是只好拿百度来垫背。

#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保存到磁盘。不过大多数应用应该使用数据库来保存,这里我只是为了简化而已。

#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个文件夹里。而后者会在退出应用或重启系统时清空,这显然不是我们想要的效果,于是最佳选择是前者。

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对象。

- (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协议等本地请求都会跑到这个方法里来…

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里了,这样的话直接拿出来即可:

NSString *absoluteString = url.absoluteString;
NSLog(@"%@", absoluteString);
NSCachedURLResponse *cachedResponse = [cachedResponses objectForKey:absoluteString];
if (cachedResponse) {
    NSLog(@"cached: %@", absoluteString);
    return cachedResponse;
}


再查查responsesInfo里有没有,如果有的话,说明可以从磁盘获取:

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对象:

NSMutableURLRequest *newRequest = [NSMutableURLRequest requestWithURL:url cachePolicy:NSURLRequestReloadIgnoringLocalCacheData timeoutInterval:request.timeoutInterval];
newRequest.allHTTPHeaderFields = request.allHTTPHeaderFields;
newRequest.HTTPShouldHandleCookies = request.HTTPShouldHandleCookies;

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

然后就可以用它来发起请求了。由于UIWebView就是在子线程调用cachedResponseForRequest:的,不用担心阻塞的问题,所以无需使用异步请求:

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;
}

2013 年 6 月 17 日更新:
在 iOS 6 上,sendSynchronousRequest 方法会超时,需要用 sendAsynchronousRequest 来替代。代码懒得写了,可以参考 LocalCache for iOS 6.0


如果下载没出错的话,我们就能拿到data和response了,于是就能将其保存到磁盘了。保存的文件名必须是合法且独一无二的,所以我就用到了sha1算法

NSString *filename = 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属性了:

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;


OK,现在终于大功告成了,打开WIFI然后启动这个程序,过一会就会提示缓存完毕了。然后关掉WIFI,尝试打开网页,你会发现网页能正常载入了。
而查看log,也能发现这确实是从我们的缓存中取出来的。
还不放心的话可以退出程序,这样内存缓存肯定就释放了。然后再次进入并打开网页,你会发现一切仍然正常~

2013 年 6 月 17 日更新:
除了 NSURLCache,还可以使用 NSURLProtocol
后者比前者更通用,但文档没有介绍如何实现,可以参考 RNCachingURLProtocol 和 AFCache

分享到:
评论

相关推荐

    [转] 为UIWebView实现离线浏览

    NULL 博文链接:https://re-reference.iteye.com/blog/1391408

    ios-基于NSURLSession NSURLProtol的UIWebView离线缓存.zip

    基于NSURLSession NSURLProtol的UIWebView离线缓存 主要是使用苹果的黑魔法类:NSURLProtocol来对网络请求进行拦截,拦截后使用自定义的网络去加载数据后进行离线缓存。这样保证在没有网络的情况下,也能保证离线能...

    UIWebView 离线浏览(iPhone源代码)

     实现 UIWebView 的离线浏览(缓存)功能。 作者说: 原理:将get请求的数据缓存在本地cache目录中,以后访问的时候直接从cache中获取数据。 备注:在初始化缓存的时候可以指定缓存的有效期时间长度,具体参看...

    IOS 离线浏览网页

    通过网页浏览时,讲缓存保存起来,若下次离线浏览该网页,则显示之前保存的数据;若下次在线浏览网页,则清空缓存,重新加载保存。 本代码在http://code4app.com/ios/UIWebView离线缓存/53c39db6933bf07c388b49c8 ...

    UIWebView实现图文混排

    UIWebView实现图文混排

    UIWebView离线缓存

    代码测试了一台ipad2(IOS 5.1),一台5s(IOS 7.0.4)都可以正常缓存.

    UIWebView实现https双向认证请求

    NULL 博文链接:https://bewithme.iteye.com/blog/2105282

    Cocos2dx中UIWebView替换为WKWebView

    IOS开发中因为引入cocos2dx,导致代码审核不通过无法提交( Apple will stop accepting...不想更新的话,可以修改UIWebView为WKWebView。具体的修改方式使用以下的文件替换cocoas引擎中的UIWebViewImpl-ios.mm文件即可。

    UIWebView的使用

    这是我在学习ios的过程中写的一个demo,使用UIWebView实现一个简单的浏览器

    UIWebView的使用代码

    UIWebView包含一个scrollView组件,用来将关联web内容实现滚动效果,页面滚动后的UIWebView的面板周围会出现阴影效果,该效果是在四周添加UIImageView实现的,因此移除这种阴影效果的代码如下: ? 1 2 3 4 5 ...

    swift-两步实现iOSUIWebView通过cookie完成自动登录。

    两步实现iOS UIWebView 通过 cookie 完成自动登录。

    iOS UIWebView实现禁止用户复制剪切功能

    UIWebView 组件来作为混合模式开发的桥梁,用过UIWebView组件的开发者都知道,当UIWebView加载显示HTML页面时,组件本身提供了一些系统默认的交互行为,这篇文章给大家分享的是iOS UIWebView实现禁止用户复制剪切...

    UIWebView+html+css

    UIWebView+html+css,实现UIWebView中可点击链接,并且可显示gif动画效果

    UIWebView缓存网页

    利用UIWebView下载缓存网页,离线情况下也可以看之前下载过的网页

    iOS开发中UIWebView的加载本地数据的三种方式

    UIWebView是IOS内置的浏览器,可以浏览网页,打开文档 html/htm pdf docx txt等格式的文件。 safari浏览器就是通过UIWebView做的。 服务器将MIME的标识符等放入传送的数据中告诉浏览器使用那种插件读取相关文件。 ...

    UIWebView和js交互

    UIWebView和js交互 里边自己写了个H5方便大家理解

    UIWebView和js交互demo1

    使用UIWebView和JS进行交互,可以OC操作js,也可以JS对OC进行交互

    UIWebView Demo代码

    UIWebView Web 网页 Demo 代码

Global site tag (gtag.js) - Google Analytics