首先,这是一个基于具体业务的组件优化方案,我尽量把业务逻辑从代码中抽离出来,部分地方代码可能有删减。
现在这个方案是用于一个多图片的新闻类应用,粗略估计过,用户在浏览完第一页所有新闻(共48篇),会消耗流量达100m,其中98m为图片,这里值得优化的空间非常大。
针对这种情况,我们先后使用过的优化包含:wifi条件下预载所有文章、图片和js、css数据;重用所有已经下载的js、css和图片的缓存;后台图片的压缩。
后台压缩和WebP化依赖第三方多媒体处理服务器,已知比较好的国内服务有腾讯优图和七牛。这里我们采用的七牛的服务。
我们的后台通过七牛的图片压缩(包含质量和分辨率),我们将首页流量由100m减少到了80m,依然有极大的提升空间。因此客户端采用基于WebP的流量压缩方案,将流量由80m压缩到了20m,减少了75%!相对于最初的处理,流量减少了80%!(android大多数机型支持WebP animated,压缩能达到80%,但iOS的解码对于WebP animated图片支持并不好,经常会出现失败的情况,所以iOS最终压缩率取决于首页中gif图的个数和大小,实际测试结果,优化幅度大概60%-80%之间)
在准备做这项优化之前,查阅过很多资料,发现WebP适配的相关文章博客,都只是介绍简单的功能性适配,所以,并没有得到什么好的思路。
于是,在三周的时间里,我一直边测试边优化,在没有初步方案的情况下,一点点完成功能,最终整理代码,解耦组件,整理出一套效果非常理想,并且使用方便的解决方案。
WebP,是一种同时提供了有损压缩与无损压缩的图片文件格式,是Google新推出的影像技术,它可让网页图档有效进行压缩,同时又不影响图片格式兼容与实际清晰度,进而让整体网页下载速度加快。
WebP 无损压缩的图片可以比同样大小的 PNG 小 26%;
WebP 有损压缩的图片可以比同样大小的 JPEG 小 25-34%;
WebP 支持无损的透明图层通道,代价只需增加 22% 的字节存储空间;
WebP 有损透明图像可以比同样大小的 PNG 图像小3倍。
WebP在Native支持方面上,早已比较成熟,据说淘宝客户端在两年前就使用了WebP(主要是Native使用),后来H5全面使用,WebView的WebP采用插件的方式支持。
在安卓上,WebP的支持是非常简单的,毕竟都是谷歌的东西,自己当然要支持,但是在iOS的WebKit内核(UIWebView和WKWebView)上,是不能直接支持的。不过最近传言macOS 10.12上的Safari有测试WebP的迹象,暂时还不太明朗。
由于OS X不支持原生WebP解码,所以,可以先安装一个工具。
推荐使用Homebrew,具体使用参考 http://brew.sh/index_zh-cn.html
安装完成后,使用命令
$brew install webp
就可以安装libwebp了。
客户端方面,Native图片加载使用的SDWebImage,该组件直接支持WebP的解码。需要在将预编译宏’WebP’置为1,并在pod中引入’iOS-WebP’即可。
服务端方面,我们采用七牛图片服务器,默认传给客户端的参数是一张jpg或者png的图片链接,通过修改url的请求参数实现对WebP图片的获取。相关规则可以参考七牛开发文档。
1.首先考虑,请求的webp图片是通过url参数拼接完成的,所以,需要对客户端内请求的所有图片URL做处理,必须全部命中。而且,将来的缓存也应基于此URL进行处理,所以,添加一个NSURL分类,URL的处理由这个分类统一处理,所有的URL替换最终都会指向这个分类中的方法,耦合度基本可以将至最低。
@interface NSURL (ReplaceWebP) - (NSURL *)qd_replaceToWebPURLWithScreenWidth; - (NSString *)qd_defultWebPURLCacheKey; - (BOOL)qd_isShouldReplaceImageFormat; @end
下面是替换URL和缓存key的核心处理代码
static NSString * const qdHost = @"img.host.com"; @implementation NSURL (ReplaceWebP) - (NSString *)qd_defultWebPURLCacheKey { if (![self qd_isShouldReplaceImageFormat]) { return self.absoluteString; } NSString *key; if ([self isWebPURL]) { key = self.absoluteString; } else { key = [self qd_replaceToWebPURLWithScreenWidth].absoluteString; } return key; } - (NSURL *)qd_replaceToWebPURLWithImageWidth:(int)width { if ([self qd_isShouldReplaceImageFormat]) { NSString *urlStr; if ([self URLStringcontainFomartString:@"?"]) { if ([self URLStringcontainFomartString:@"format/jpg"]) { urlStr = [self.absoluteString stringByReplacingOccurrencesOfString:@"format/jpg" withString:@"format/webp"]; } else { NSString *suffixStr = @"imageView2/0/format/webp/ignore-error/1"; urlStr = [NSString stringWithFormat:@"%@/%@", self.absoluteString, suffixStr]; } } else { NSString *pathExtension = [[self.absoluteString.pathExtension componentsSeparatedByString:@"-"] firstObject]; urlStr = [NSString stringWithFormat:@"%@.%@-WebPiOSW%d",self.absoluteString.stringByDeletingPathExtension, pathExtension, width]; } return [NSURL URLWithString:urlStr]; } return self; } - (NSURL *)qd_replaceToWebPURLWithScreenWidth { int width = (int)([UIScreen mainScreen].bounds.size.width * [UIScreen mainScreen].scale); return [self qd_replaceToWebPURLWithImageWidth:(int)width]; }
所有的URL替换,最终都会到 - (NSURL *)qd_replaceToWebPURLWithImageWidth:(int)width 这个方法中来
下面是条件过滤,确保100%命中所有需要替换的图片格式
- (BOOL)isQDHost { NSString *nsModel = [UIDevice currentDevice].model; BOOL s_isiPad = [nsModel hasPrefix:@"iPad"]; if (s_isiPad) return NO; return [self URLStringcontainFomartString:qdHost]; } - (BOOL)qd_isShouldReplaceImageFormat { if (![self isQDHost]) { return NO; } if ([self isWebPURL]) { return NO; } NSArray *extensions = @[@".jpg", @".jpeg", @".png"]; for (NSString *extension in extensions) { if ([self.absoluteString.lowercaseString rangeOfString:extension options:NSCaseInsensitiveSearch].location != NSNotFound){ return YES; } } return NO; } - (BOOL)URLStringcontainFomartString:(NSString *)string { return ([self.absoluteString.lowercaseString rangeOfString:string options:NSCaseInsensitiveSearch].location != NSNotFound); } - (BOOL)isWebPURL { return [self URLStringcontainFomartString:@"-webp"] || [self URLStringcontainFomartString:@"/webp"]; } @end
所以,替换URL这个功能,被完全抽离出来,之后的代码,只需要考虑具体逻辑的问题了。
Native图片加载使用的SDWebImage,首先需要理解SD的代码,确定是最终的图片下载是调用的哪个方法
- (id <SDWebImageOperation>)downloadImageWithURL:(NSURL *)url options:(SDWebImageOptions)options progress:(SDWebImageDownloaderProgressBlock)progressBlock completed:(SDWebImageCompletionWithFinishedBlock)completedBlock
所有的图片下载,最终都走到了这个方法中,所以,替换URL应该在这个方法的最前面实现。
{ if ([url isKindOfClass:NSString.class]) { url = [NSURL URLWithString:(NSString *)url]; } if (![url isKindOfClass:NSURL.class]) { url = nil; } url = [url qd_replaceToWebPURLWithScreenWidth]; ... ... }
由于在评估了难度之后,我们果断地把SDWebImage从Pods中移除,手动添加一个子工程,这样可以比较方便地修改内部实现,而不至于用swizzling这种黑魔法来修改传入参数。这个技能虽然炫酷,然而很多情况下,杀敌一万,自损两万,不建议经常使用。
因修改了url值,若在上层通过SDImageCache判断是否有本地缓存时,也需要对url先做qd_defultWebPURLCacheKey来获取其真实缓存的key。这一部分比较简单。
这一部分是这个方案的难度所在。
webkit内核现在都不支持解析WebP格式的图片,这里主要采用的iOS系统的NSURLProtocol来替换其网络请求,再将网络回包数据进行转码成jpg或者png(为了透明度),再返回给webview进行渲染的。
同样的,iOS在此处依然不对gif进行任何处理。
另外,NSURLProtocol会拦截全局的网络流量,为避免误伤,这里需要单独识别是否是WebView发起的请求,可以通过识别request中的UA是否包含”AppleWebKit”来实现。
@implementation QDWebURLProtocol + (BOOL)canInitWithRequest:(NSURLRequest *)request { NSString *ua = [request valueForHTTPHeaderField:@"User-Agent"]; if ([request.URL qd_isShouldReplaceImageFormat] && [ua lf_containsSubString:@"AppleWebKit"]) { return YES; } }
这里可以接管所有WebView中需要替换的图片URL
下面,会自动调用startLoading方法,这里采用了一个非常特别的方式处理
- (void)startLoading { if ([self.request.URL qd_isShouldReplaceImageFormat]) { [[SDWebImageManager sharedManager] downloadImageWithURL:self.request.URL options:0 progress:nil completed:^(UIImage *image, NSError *error, SDImageCacheType cacheType, BOOL finished, NSURL *imageURL) { NSData *data; if ([imageURL.absoluteString.lowercaseString lf_containsSubString:@".png"]) { data = UIImagePNGRepresentation(image); } else { data = UIImageJPEGRepresentation(image, 1); } [self.client URLProtocol:self didLoadData:data]; [self.client URLProtocolDidFinishLoading:self]; }]; return; } self.connection = [NSURLConnection connectionWithRequest:self.request delegate:self]; }
是不是很奇特,由SDWebImageManager直接接管图片请求,手动finishLoading。
在最开始,这里并不是这么写的,当时是在系统的
- (void)connection:(NSURLConnection *)connection didReceiveData:(NSData *)data
方法中转码处理。按这个思路写,代码越写越散,BUG也越来越多。所以,换了个思路,既然SD可以支持WebP,为什么不用他来全面托管呢?
这样的话,原生请求和WebView的图片缓存也可以经由SD统一起来,所以,这应该是一个好的方案。
这样的话,WebP的所有请求都已经可以处理(wifi预加载暂时不管,因为是自己写的downloader,替换URL后直接改把缓存指向修改就可以),之后要处理缓存的问题
以前的代码已经实现了内部文章的缓存,包含js、css以及image等。这里通过NSURLCache来实现。相应的,基于WebP的图片缓存的读取也应该在NSURLCache中处理,在先处理完URL后,用新的Key来进行映射。
这里建议所有基于WebView的流量优化都最好用UA的判断包住,避免带来问题。因为无论NSURLProtocol还是NSURLCache都是全局网络控制。
先下一个楔子在这里
篇幅略长,缓存处理放在下一篇介绍。