【Android&iOS】QDaily 基于 WebP 的流量优化实践

2,037 阅读11分钟

流量优化对于一个app来讲意义非常重大,能节约用户的流量,节约用户的存储空间,而且能有提高网络请求的回包速度,提高app的速度。因此流量优化历来都是app的优化重点,而且是一个持续优化的点。

QDaily是一个多图片的新闻类应用,采编喜欢上传gif图来提高内容的表现力,这也使得流量消耗非常大。粗略估计,用户在浏览完第一页所有新闻(共48篇),会消耗流量达100m,其中98m为图片,这里值得优化的空间非常大。

针对这种情况,我们先后使用的优化包含:wifi条件下预载所有文章、图片和js、css数据;重用所有已经下载的js、css和图片的缓存;后台图片的压缩以及客户端图片的WebP化。

其中,后台压缩和WebP化依赖第三方多媒体处理服务器,已知比较好的国内服务有腾讯优图和七牛。这里我们采用的七牛的服务,以下很多具体的调用都是基于七牛的。

我们的后台通过七牛的图片压缩(包含质量和分辨率),我们将首页流量由100m减少到了80m,依然有极大的提升空间。因此客户端采用基于WebP的流量压缩方案,将流量由80m压缩到了20m,减少了75%!相对于最初的处理,流量减少了80%!(android大多数机型支持WebP animated,压缩能达到80%,但iOS的压缩率取决于首页中gif图的个数和大小,测试大概优化在60%-80%之间)

下面就介绍下这个效果极好的WebP的流量解决方案。

一、WebP的介绍

1、什么是 WebP

WebP (发音 weppy ),是一种同时提供了有损压缩与无损压缩的图片文件格式,是Google新推出的影像技术,它可让网页图档有效进行压缩,同时又不影响图片格式兼容与实际清晰度,进而让整体网页下载速度加快。

  • WebP 无损压缩的图片可以比同样大小的 PNG 小 26%;
  • WebP 有损压缩的图片可以比同样大小的 JPEG 小 25-34%;
  • WebP 支持无损的透明图层通道,代价只需增加 22% 的字节存储空间;
  • WebP 有损透明图像可以比同样大小的 PNG 图像小3倍。

2、手机端支持情况

  • WebP在手机端浏览器的支持情况(WebView & UIWebView) 查看图片

  • Native Android在4.0以上Image直接支持对WebP的解码。iOS可以通过google提供的WebP解析库来实现UIImageView中显示WebP。

QDaily不支持4.0以下手机(现在估计也没啥支持的了吧。。。),所以下面的功能并没有测试,无脑搬运: Android 4.0 以下 WebP 解析库(链接)

iOS WebP 解析库(链接)

3、WebP工具在Mac Os下的安装(本地编解码)

2.安装完成后,用如下命令安装libwebp。

brew install webp

1.到此网站链接下载与系统版本对应的MacPorts,安装MacPorts之前需要安装Xcode。

2.按照此文档对MacPorts进行安装,链接,我选择的是下载.pkg文件进行安装的。整个安装过程中建议准备梯子进行科学上网。

3.安装完成后更新:

sudo port -v selfupdate

4.然后安装libwebp,

sudo port install webp

1.使用 cwebp 将 JPEG 或 PNG 图像转换成 WebP 格式。

cwebp [options] -q quality input.jpg -o output.webp

2.使用 dwebp 实用程序将 WebP 图像转换回 PNG、PAM、PPM 或 PGM 图像。

dwebp input_file.webp [options] [-o output_file]

二、QDaily iOS客户端中图片流量优化方案

1、iOS Native

QDaily的Native图片加载使用的SDWebImage,该组件直接支持WebP的解码。需要在将预编译宏’WebP’置为1,并在pod中引入’iOS-WebP’即可。

我们图片显示后台是七牛的,默认传给客户端的参数是一张jpg或者png的图片链接,通过修改url的请求参数实现对WebP图片的获取。亲测iOS下对WebP Animated的支持很差,经常有转码失败情况,所以iOS中并未支持WebP的动图显示。

所有SDWebImage的图片加载都首先经过SDWebImageManager中下面的方法:

- (id )downloadImageWithURL:(NSURL *)url
                                 options:(SDWebImageOptions)options
                                progress:(SDWebImageDownloaderProgressBlock)progressBlock
                               completed:(SDWebImageCompletionWithFinishedBlock)completedBlock

我们通过在该方法最初修改url的参数来实现对请求url的替换和本地缓存的读取:

{
    if ([url isKindOfClass:NSString.class]) {
        url = [NSURL URLWithString:(NSString *)url];
    }
    if (![url isKindOfClass:NSURL.class]) {
        url = nil;
    }
    url = [url qd_replaceToWebPURLWithScreenWidth];
    ...
    ...
}

为NSURL增加扩展NSURL+ReplaceWebP

NSURL+ReplaceWebP.h

@interface NSURL (ReplaceWebP)
- (NSURL *)qd_replaceToWebPURLWithScreenWidth;
- (NSString *)qd_defultWebPURLCacheKey;
- (BOOL)qd_isShouldReplaceImageFormat;
@end
NSURL+ReplaceWebP.m

static NSString * const qdailyHost = @"img.qdaily.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.lowercaseString;
}

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

- (BOOL)isQdailyHost {
    NSString *nsModel = [UIDevice currentDevice].model;
    BOOL s_isiPad = [nsModel hasPrefix:@"iPad"];
    if (s_isiPad) return NO;
    return [self URLStringcontainFomartString:qdailyHost];
}

- (BOOL)qd_isShouldReplaceImageFormat {
    if (![self isQdailyHost]) {
        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值,若在上层通过SDImageCache判断是否有本地缓存时,也需要对url先做qd_defultWebPURLCacheKey来获取其真实缓存的key。

2、iOS WebView中

苹果系列所有的webkit内核现在都不支持解析WebP格式的图片,QDaily处理这里主要采用的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;
    }
    return NO;
}

+(NSURLRequest *)canonicalRequestForRequest:(NSURLRequest *)request{
    return request;
}

- (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];
                                                      }];
        
    }
 
}
- (void)stopLoading { 
}
@end

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

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

三、QDaily Android客户端的图片流量优化方案

WebP就是google出的编码格式,和Android同宗同源,支持自然会好一些。4.0以上的系统,原生默认支持WebP的展示。不过鉴于国内android手机各种奇葩的系统,有必要在WebView中进行WebP的识别支持。

1、Native部分的WebP

QDaily的Android客户端的图片请求使用的glide,所有请求的缓存通过GlideModule的形式进行全部的本地接管,没有使用他们默认的cache。

为了解耦方便和避免侵入性,我们在glide和业务代码之间封装了ImageManager,所有的图片请求都会走这里进行发送,因此,WebP的优化和处理主要在这里进行。

主要包含以下几个方法:

//主处理方法,根据对url进行处理
public static String getWebpUrl(String url, boolean isWebView);
//判断当前url是否已经是WebP的请求了
public static boolean isWebP(String url);
//url是否支持转成WebP请求
public static boolean supportConvertWebP(String url);
//获取文章的host
public static String getHost(String url);
//域名判断,限定七牛域名才可以进行转换url
public static boolean isQiNiuImageHost(String url);
// url后跟的WebP请求后缀
public static String getWebpExtBaseScreen();
// WebView对WebP静图的支持
public static boolean isWebViewSupportWebp();
// WebView对WebP动图的支持
public static boolean isWebViewSupportWebpAnimation();
 // Native对WebP静图的支持
public static boolean isSupportWebp();
// Native对WebP动图的支持
public static boolean isSupportWebpAnimation();

所有Native部分的ImageView在加载图片时候,调用的方法第一步,会先调用getWebpUrl方法对url进行处理,该方法会根据机器情况进行相应的url参数拼凑。具体实现如下:

public static String getWebpUrl(String url, boolean isWebView) {
       if (TextUtils.isEmpty(url)) {
           QLog.e("", "url 不能为空");
           return "";
       }
       boolean isSupportWebp = isWebView? isWebViewSupportWebp(): isSupportWebp();
       boolean isSupportWebpAnimation = isWebView? isWebViewSupportWebpAnimation(): isSupportWebpAnimation();
       if (supportConvertWebP(url) && isSupportWebp) {
           String ext = MimeTypeMap.getFileExtensionFromUrl(url);
           if (TextUtils.isEmpty(ext) || (ext.contains("gif") && !isSupportWebpAnimation)) 
               return url;
           if (url.contains("?")) {
               if (url.contains("format/jpg")) {
                   return url.replace("format/jpg", "format/webp");
               }
               int index = url.indexOf("gif");
               if (index != -1) {
                   return url.substring(0, index) + "gif" + WebpExtGif;
               }
               return url + WebpExtDefault;
           } else {
               String query = ext.contains("gif") ? WebpExtGif : getWebpExtBaseScreen();
               int index = url.indexOf(ext);
               return url.substring(0, index) + ext.split("-")[0] + query;
           }
       }
       return url;
   }
   public static boolean isWebP(String url) {
       return url.contains("/format/webp") || url.contains("-Webp");
   }
   public static boolean supportConvertWebP(String url) {
       return MManagerCenter.getManager(DevConfigManager.class).isUseWebp() && isQiNiuImageHost(url) && !url.contains("/format/webp") && !url.contains("-Webp");
   }
   public static String getHost(String url) {
       if (url == null || url.trim().equals("")) {
           return "";
       }
       String host = "";
       Pattern p = Pattern.compile("(http://|https://)?([^/]*)", Pattern.CASE_INSENSITIVE);
       Matcher matcher = p.matcher(url);
       if (matcher.find()) {
           host = matcher.group();
       }
       return host;
   }
   public static boolean isQiNiuImageHost(String url) {
       String host = getHost(url);
       return host.equals("http://img.qdaily.com");
   }
   public static String getWebpExtBaseScreen(){
       int width = LocalDisplay.SCREEN_REAL_WIDTH_PIXELS;
       if (width >= 1080) {
           return WebpExtW3;
       }
       if (width < 540) {
           return WebpExtW1;
       }
       return WebpExtW2;
   }
   public static boolean isWebViewSupportWebp(){
       return MManagerCenter.getManager(QDConfigManager.class).isWebViewSupportWebp();
   }
   public static boolean isWebViewSupportWebpAnimation(){
       return MManagerCenter.getManager(QDConfigManager.class).isWebViewSupportWebpAnimation();
   }
   public static boolean isSupportWebp(){
       return true;
   }
   public static boolean isSupportWebpAnimation(){
       return false;
   }
   

2、WebView部分的WebP

基于预加载和缓存的需要,QDaily的文章采用的方式是通过接口下载html文件,在本地加载进webview的方式来实现的,具体方法如下:

loadDataWithBaseURL(curUrl, "html string", "text/html", "UTF-8", curUrl);

因为android的WebView可以直接解码WebP格式,所以这里直接进行了html中的url替换,即正则取出所有的image请求url,用上面的getWebpUrl的方法进行转换,再替换原来的url即可。具体实现方法如下:

public static String converHtmlToWebPHtml(String html){
   Map map = getSupportWebPImgArray(html);
 if (map != null && map.size() > 0) {
        for (Map.Entry entry: map.entrySet()) {
          html = html.replaceAll(entry.getKey(), entry.getValue());
      }
   }
    return html;
    }
private static Map getSupportWebPImgArray(String html) {
  if (!(isWebViewSupportWebp() || isWebViewSupportWebpAnimation()) && TextUtils.isEmpty(html))
        return null;
    Map result = new HashMap<>();
  Pattern p = Pattern.compile("]*data-src=\"([^\"]*)\"[^>]*>");
    Matcher m = p.matcher(html);
    while (m.find()) {
      String url = m.group(1);
        String value = getWebpUrl(url, true);
       if (!url.equals(value)) result.put(url, getWebpUrl(url, true));
 }
   p = Pattern.compile("]*src=\"([^\"]*)\"[^>]*>");
 m = p.matcher(html);
    while (m.find()) {
      String url = m.group(1);
        String value = getWebpUrl(url, true);
       if (!url.equals(value)) result.put(url, getWebpUrl(url, true));
 }
   return result;
   }

3、判断Android端WebView对WebP Animated的支持

官方称4.3以上系统直接支持WebP Animated,但亲测部分国产手机(例如华为!)在4.4的EMUI中WebView不能正常解码WebP Animated,解决方案是在App第一次启动时进行WebView的WebP支持情况检查,并将值保存在sp中。

检查方法为在WebView中分别加载1像素的WebP静图和动图,并用JS检测是否成功显示,成功显示即为支持。

直接上代码:

  
  
  
WEBP TEST
   



其中,第一个js方法用来检测是否支持静态webp,src对应一段webp图片的base64编码,呈现出来的宽度为1px。第二个方法src对应一个动态webp,由于animated webp的base64比较长,所以直接将一个1k的1px宽的webp animation和这段html一起放在了assets中,用于调用检测。

上面说将html文件以及所用到的图片都放在 assets 目录下。然后在页面上通过WebView来显示。下面上webview的检测代码:


private WebView webView;
private void checkWebpSupport(){
    if (isInitWebViewCheckSupportWebp)
        return;
    webView = new WebView(this);
    webView.setWebChromeClient(new WebChromeClient() {
        @Override
        public boolean onConsoleMessage(ConsoleMessage cm) {
            String log = cm.message();
            if (!TextUtils.isEmpty(log) && log.contains("QDaily:")) {
                if (log.equals("QDaily:supportWebp=true")) {
                    setWebViewSupportWebp(true);
                } else if (log.equals("QDaily:supportWebp=false")) {
                    setWebViewSupportWebp(false);
                } else if (log.equals("QDaily:supportWebpAnimation=true")) {
                    setWebViewSupportWebpAnimation(true);
                } else if (log.equals("QDaily:supportWebpAnimation=false")) {
                    setWebViewSupportWebpAnimation(false);
                }
                isInitWebViewCheckSupportWebp = true;
            }
            return true;
        }
    });
    webView.getSettings().setJavaScriptEnabled(true);
    webView.loadUrl("file:///android_asset/webp.html");
    }

三、其它资源的缓存优化

QDaily在网络请求本身的优化主要有对http请求的gzip压缩,和对图片大小和格式的定制化(根据屏幕尺寸请求大小,根据客户端情况选择是否WebP)。 其它部分在流量的优化都在缓存部分,为的是相同资源不再请求第二次。下面将简单介绍这里,一些具体的部分会在以后文章再做展开。

统一图片的本地缓存处理

一个app中,WebView和ImageView都会请求图片,Android端和iOS端都定制了自己统一的内存LRU Cache和Disk LRU Cache进行处理,避免重复请求。

css和js的缓存

QDaily里文章用了统一的css和js文件。客户端这边主要进行缓存和复用,以进行重复使用

http请求的缓存

这里主要是将volley的缓存管理起来,通过etag进行更新判断

上面介绍的比较范,后面会有比较具体的介绍。

参考链接

chiemy.com/android/and…

hahack.com/wiki/sundri…

blog.teamtreehouse.com/getting-sta…

谷歌官方文档支持 developers.google.com/speed/webp/

www.smarting.me/glide-image…