首先,这是一个基于具体业务的组件优化方案,我尽量把业务逻辑从代码中抽离出来,部分地方代码可能有删减。

现在这个方案是用于一个多图片的新闻类应用,粗略估计过,用户在浏览完第一页所有新闻(共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

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图片的获取。相关规则可以参考七牛开发文档。

三、具体方案实现

首先考虑,请求的webp图片是通过url参数拼接完成的,所以,需要对客户端内请求的所有图片URL做处理,必须全部命中。而且,将来的缓存也应基于此URL进行处理,所以,添加一个NSURL分类,URL的处理由这个分类统一处理,所有的URL替换最终都会指向这个分类中的方法,耦合度基本可以将至最低。

1.替换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这个功能,被完全抽离出来,之后的代码,只需要考虑具体逻辑的问题了。

2. Native 图片请求替换

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。这一部分比较简单。

3. WebView 图片请求替换

这一部分是这个方案的难度所在。

webkit内核现在都不支持解析WebP格式的图片,这里主要采用的iOS系统的NSURLProtocol来替换其网络请求(不了解NSURLProtocol,可以动动自己勤劳的小手Google一下),再将网络回包数据进行转码成jpg或者png(为了透明度),再返回给webview进行渲染的。

友情链接,NSURLProtocol用法,大神文章

同样的,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。

首先需要明确,WebP节约流量,究竟是怎么样的原理:

所谓图片格式,是采用何种解码编码方式决定的,所有数据最终一定是变成二进制数据,NSData;
既然UIWebView不支持解码WebP,我们可以让图片在网络中以WebP格式的NSData传递,本地收到data后,解码成UIWebView可以识别的UIImage;事实上,Native方面就是这么做就可以达到目标了,然而在WebView的请求中,无论我们本地做了何种处理,最终交给WebView的也一定是NSData,所以,需要再把UIImage编码成jpg或者png(之所以我们没有把gif也转WebP,就是因为从WebP的动图UIImage,转码成NSData这条路走不通,于是我们放弃了gif转WebP)。

所以,大致的数据路径如下:

本地发送WebP请求 —> Server —> 返回WebP格式Data —> Data经谷歌的WebP decode得到UIImage —> 将UIImage对象编码成JPG或PNG格式NSData —> 替换本应交给WebView的WebP格式Data —> WebView接收JPG或PNG格式Data —> 渲染图片

在最开始,这里并不是这么写的,当时是在系统的

- (void)connection:(NSURLConnection *)connection didReceiveData:(NSData *)data

方法中转码处理。按这个思路写,代码越写越散,BUG也越来越多。所以,换了个思路,既然SD可以支持WebP,为什么不用他来全面托管呢?

这样的话,原生请求和WebView的图片缓存也可以经由SD统一起来,所以,这应该是一个好的方案。

这样的话,WebP的所有请求都已经可以处理(wifi预加载暂时不管,因为是自己写的downloader,替换URL后直接改把缓存指向修改就可以),之后要处理缓存的问题

4. 图片缓存处理

以前的代码已经实现了内部文章的缓存,包含js、css以及image等。这里通过NSURLCache来实现。相应的,基于WebP的图片缓存的读取也应该在NSURLCache中处理,在先处理完URL后,用新的Key来进行映射。

这里建议所有基于WebView的流量优化都最好用UA的判断包住,避免带来问题。因为无论NSURLProtocol还是NSURLCache都是全局网络控制。

篇幅略长,具体缓存处理放在下一篇介绍。