网易美学 iOS 端图片资源加载实践

达芬奇密码2018-07-03 09:30

做为一个美妆类的 app,难以避免会有大量精美的图片需要显示,如何高性能地加载与显示这些图片从项目建立之初就是一大挑战。 下图是网易美学 app 的两个典型的、常见的界面,图片元素不仅多,而且大小不一,这就需要有良好的封装,使得开发人员易用的同时,减少可能的犯错机会。好在像 SDWebImage 这类第三方库已经为我们解决了图片异步加载、二级缓存、图片解码与旋转等诸多问题,所以本文仅探讨与我们具体业务相关的部分。

 

加速下载

可以提高下载速度的环节有许多,这里简单地分为客户端能处理的部分与客户端不能处理的部分。客户端不能处理的部分主要有增加 CDN,扩大服务器带宽等,这些事情提相应的工单即可;客户端能处理的部分主要有减少图片流量,加速连接等。

图片裁切

只下载我们所需要的大小的图片是减少图片流量的基本思路,正好网易的 NOS 提供相当好的图片裁切的支持。为此我们做了 NSURL 的扩展,用于生成合适的图片裁切方式及大小的链接:

typedef NS_ENUM(NSUInteger, MZImageClipMode) {
    MZImageClipModeScaleFit      = 'x',
    MZImageClipModeScaleClip     = 'y',
    MZImageClipModeScaleFill     = 'z'
};
/**
 *  根据网易 NOS 图片 CDN 的规则生成特定大小的图片链接
 */
@interface NSURL (ImageCDN)
@property (nonatomic, readonly, getter=isCDNSupported, assign) BOOL CDNSupported;

/**
 *  传入指定尺寸和图片类型及裁切模式,生成等比,且长宽不超过该尺寸的图片。支持的图片的类型有 jpeg, webp, png。
 *  图片裁切模式:
 *  - Fit,即按长边等比缩放,最终的图片面积小于等于 `size`
 *  - Clip,先按短边等比缩放,再切掉长边,最终图片面积等于 `size`
 *  - Fill,按短边等比缩放,最终的图片面积大于等于 `size`
 *
 *  @param size      图片长宽
 *  @param imageType 图片类型,支持 `jpeg`,`webp`,`png`,如果为 nil 或空串,使用原来的格式
 *  @param mode      图片裁切方式
 *  @param reserve   是否保留图片 Meta,默认为 YES
 *
 *  @return 新图片的链接
 */
- (NSURL *)mz_imageURLWithSize:(CGSize)size type:(NSString *)imageType clipMode:(MZImageClipMode)mode reserveMeta:(BOOL)reserve;
- (NSURL *)mz_imageURLWithSize:(CGSize)size type:(NSString *)imageType clipMode:(MZImageClipMode)mode;
- (NSURL *)mz_imageURLWithSize:(CGSize)size clipMode:(MZImageClipMode)mode;

这里的 size 是 point,所以实际生成的大小会根据当前设备屏幕的 scale 乘以相应的系数,比如头像的大小是 36x36,那么在 iPhone 6 Plus 上最终生成的链接其实是:

https://xxx.com/xxx.png?imageView&thumbnail=108x108

WebP

光减小图片的边长还不够,同样长宽的图片不同格式的数据流量也差很远。所以上面的代码中有个 type 参数,不填时默认为 webpwebp 是由 google 研发的图片格式,具有相当高的压缩比,实际使用下来,图片所占磁盘空间是 jpeg 六分之一到十分之一。但是它也有一些坑,见下面的 异常情况。 使用 webp 格式之后,一张 36x36 的头像实际链接是:

https://xxx.com/xxx.png?imageView&thumbnail=108x108&type=webp

HTTP

由于之前一直传 iOS 10 以上以后只支持 HTTPS 了,所以我们提前做了一些准备,所有的图片链接都是 HTTPS 的,但是后来好像又放宽了要求。然后考虑到 SSL 的连接建立会更加耗时,所以就让所有的图片走 HTTP 了,同时在 Info.Plist 中加白名单。

 

在代码中,我们预留了一个配置项让图片链接变成 HTTP 的,同时方便一旦苹果改规则时可以热修复(已经下了这个功能)修改这个行为:

- (BOOL)forceHTTP
{
    return YES;
}

- (NSURL *)mz_imageURLWithSize:(CGSize)size type:(NSString *)imageType clipMode:(MZImageClipMode)mode reserveMeta:(BOOL)reserve
        ...
        if (self.forceHTTP) {
            components.scheme = @"http";
        }
        NSURL *newURL = components.URL;
        return newURL;
}

异常情况

以上代码大部分情况下运行良好,但也出现了一些异常,第一个影响比较大的就是色差。

Meta

不同的图片格式一般都是由元信息(meta)和数据部分(payload)构成的,元信息中包含了图片的宽高、色彩空间等。色差这个问题本人之前的一篇文章有详细的说明,这里不再赘述,主要就是要保留图片的元信息,所以上述代码中增加了一个 reserveMeta 的参数,默认为 YES

注意:保留元信息会导致每张图片大小增加 1KB 左右不等的流量消耗,因为有些图片会内嵌颜色描述文件。另外,之前据 NOS 的同事说,webp 格式无法保留 meta ,所以代码中只是预留了这个参数,也不知道现在支持没有。

保留元信息的图片链接如下:

https://xxx.com/xxx.png?imageView&thumbnail=108x108&type=webp&stripmeta=0

超长图片

美妆博主有时候懒得排版,就使用一整张超级长的图片来承载内容,所以这个异常情况也只有从事这个行业的 app 开发时才能遇到吧。WebP 图片有个技术限制,它的 meta 中表示图片长宽的信息占了 14 bit,也就是它能表示的最大长宽为 16384(webp 官方说明:https://developers.google.com/speed/webp/docs/webp_lossless_bitstream_specification#2_riff_header )。于是当 size 超过 16384 时,type 被隐式地改为 jpeg了:

- (NSURL *)mz_imageURLWithSize:(CGSize)size type:(NSString *)imageType clipMode:(MZImageClipMode)mode reserveMeta:(BOOL)reserve
        ...
        CGFloat scale = [UIScreen mainScreen].scale;
        size.width = ceilf(size.width * scale);
        size.height = ceilf(size.height * scale);

        if (size.width > 16384 || size.height > 16384) {
            imageType = @"jpeg";
        }
        ...
        if (self.forceHTTP) {
            components.scheme = @"http";
        }
        NSURL *newURL = components.URL;
        return newURL;
}

这里有两个链接可以体验一下:

http://beauty.nosdn.127.net/beauty/img/79c90765-a5ca-41d6-bccc-163bb0c5e238.jpg?imageView&type=webp&thumbnail=670y35880&stripmeta=0

http://beauty.nosdn.127.net/beauty/img/79c90765-a5ca-41d6-bccc-163bb0c5e238.jpg?imageView&type=jpeg&thumbnail=670y35880&stripmeta=0

图片状态

图片元素显示在界面上共有三种状态:加载中,加载好,加载出错。于是一个 UIImageView 就会有三种样式。

                    

                                         

                                                           

加载中的状态是有一个动效的,所以不能简单地用一张 placeholder 来实现。因此我们为 UIImageView 做了一些扩展,结合它自身的 frame,调用 NSURL 的合适方法生成与自身大小对应的图片链接,并自动切换三种状态:

@interface UIImageView (MZ)
- (void)mz_setImageWithPlaceholderWithURL:(NSURL *)url;
- (void)mz_setImageWithPlaceholderWithURL:(NSURL *)url expectedSize:(CGSize)size;
- (void)mz_setImageWithPlaceholderWithURL:(NSURL *)url clipMode:(MZImageClipMode)clipMode;
@end

整个项目中绝大多数时候只用调第一个方法就可以满足要求,方便开发者的使用。这里有个小问题就是,部分时候 UIImageViewframe 是无法提前知道的,加上有些人习惯直接用 [UIImageView new] 来创建对象,使得初始 frame{{0, 0}, {0, 0}},导致下载的图片大小是 0x0。我们项目中的做法是延迟一个周期:

- (void)mz_setImageWithPlaceholderWithURL:(NSURL *)url
{
    if (CGSizeEqualToSize(self.frame.size, CGSizeZero)) {
        self.mz_shouldCancelBlock = NO;
        dispatch_async(dispatch_get_main_queue(), ^{
            if (self.mz_shouldCancelBlock)
                return;
            [self mz_setImageWithPlaceholderWithURL:url expectedSize:self.frame.size];
        });
    }
    else {
        self.mz_shouldCancelBlock = YES;
        [self mz_setImageWithPlaceholderWithURL:url expectedSize:self.frame.size];
    }
}

- (void)mz_setImageWithPlaceholderWithURL:(NSURL *)url expectedSize:(CGSize)size
{
        url = [url mz_imageURLWithSize:size];
        // 转发到 SDWebImage 相应的方法上,下载图片
        ...
}

延迟一个周期一般图片该有的布局已经完成,frame 也将是正确的大小,对于再特殊的情况只能特殊处理了。

图片预下载

网易美学主要由“合辑”来表现图文内容,一篇合辑可以很长,同时最多可以支持 99 张图片及 1000 个产品。而类似这种界面一般由 UITableViewUICollectionView 来实现的,通常做法是在 cell 出来时调用 mz_setImageWithPlaceholderWithURL: 方法来下载并显示图片,但这个体验是不完美的。一般人看东西是从上往下,而且阅读速度并不会太快,这个时间空档可以用来预下载图片。

SDWebImage 已经封装了相关支持 SDWebImagePrefetcher,我们只做了一些简单地业务封装,因为我们的图片链接是要根据图片视图大小算出来的,而不是服务端返回的原始 url。

另外,图片预下载的 SDWebImageManager 与图片视图的 SDWebImageManager 实例是两个单例,而且配置也不一样:

图片视图 预下载 说明
最大并发数 6 3 这两个是经验值,太大太小都不合适
优先级 视图显示优先
失败重试
后台运行
执行顺序 后入先出 先入先出 网易美学中大量的列表内容,用户快速滑过一屏时,第一屏的内容可能还没加载出来,用户已经停在第二屏了,所以视图上应该后出现的先下载更合理! 而预下载的图按次序来就好

加入预下载功能后,现在浏览合辑时,一般情况下,除了刚进入时的封面图,用户是看不到图片的加载过程的。

GIF

gif 图是个很特殊的存在,一方面 iOS 原生支持不太友好,非常占内存,我们用的第三方的 FLAnimatedImage;另一方面 NOS 不支持 gif 的完整裁切,裁切后只有前 50 帧,所以只能下载原链接。所以我们为 gif 定制了 SDWebImageDownloader,因为原有的实现它的回调中有 UIImage 参数,非常占内存,我们的定制版只有 NSData 参数,用于传到 FLAnimatedImage 来显示动画。

具体实现这里不过多说明,有一点需要注意,FLAnimatedImageinitWithAnimatedGIFData: 生成的实例播动画是有延迟的,当 gif 比较大时很明显。通过阅读它的源码发现,用它的三个参数的方法生成实例可以改善这个情况:

[[FLAnimatedImage alloc] initWithAnimatedGIFData:data
                           optimalFrameCacheSize:2        // 一个内部的魔法数字
                               predrawingEnabled:YES]

可以改进的点

  1. 我们的 app 中很多时候同一张图出现在不同的列表中,由于布局的不同导致 frame 的不一样,所以最终的链接不一样,要下载多次。事实上图片 size 差个几像素用户看不出来的,可以统一用一个合适的 size ,这样避免多次下载。基本想法是从指定的 size 往上找最小的 2 的次方,如,同一个用户的头像在一个界面是 24x24,另一个界面是 30x30,那么生成的最终图片链接的大小是 32x32 就好,比 24 大的最小 2 的次方是 32,比 30 大的最小 2 的次方也是 32。为什么取 2 的次方?只是单纯地感觉这样比较快。
  2. 视图下载与预下载功能会重复下载第一屏的图片。SDWebImage 已经做了非常好的实现来防止同一个 url 多次下载,但由于我们项目中是两个单例,所以这个去重功能失效了,也许可以再优化一下。

以上。

本文来自网易实践者社区,经作者谭歆授权发布。