在iOS上进行WebP编码是一种怎样的体验?

2,302 阅读8分钟

前言

事情是这样的,出于节省流量的目的,最近在研究如何在iOS上将相机输出的JPEG编码成WebP,用于后续的图片上传。WebP由于其优势,可以达到既节省流量又能拥有不错的图像质量,所带来的好处也又很多,譬如加快了加载时间、节约服务器带宽。具体的WebP介绍可以看看这篇文章:WebP 极限压缩及ios实现。由于在研究过程中发现,WebP在移动端大多数场景为解码,也就是利用其体积小的特性节省下载资源部分的流量,但对于在移动端编码的资料就不多。故想通过本文记录一下此次研究。

移动端上的WebP编码

Android

总所周知,WebP是Google提出的,所以在Android4.0以后,其Bitmap的api就已经提供了WebP的编解码能力。

// Bitmap.java
public boolean compress(CompressFormat format, int quality, OutputStream stream)

// pic为一个Bitmap对象
pic.compress(Bitmap.CompressFormat.WEBP, 90, outputStream)

以上代码就是在Android上对于WebP编码的调用。

iOS

在iOS上,由于原生并未对WebP进行相应支持,所以需要借助第三方库。libwebp就是Google提供出来能够跨平台提供WebP编解码能力的库。通过对iOS两个比较受欢迎的图片加载库:SDWebImage、YYImage的调研得知,这两个库都提供了支持WebP编解码能力的插件,而通过声明的podspec可以得知,他们都是依赖libwebp。

  • SDWebImage提供的WebP插件:SDWebImageWebPCoder

  • YYImage提供的插件

ps: 值得注意的是,在YYImage的podspec上,声明了一个采用WebP.framework的subspec,这个问题将会是在后面会提到。

这里先贴一下两个库对于WebP编码的简单调用

// SDWebImage
SDImageWebPCoder.shared.encodedData(with: image, format: .webP, options: [.encodeCompressionQuality: 0.9])

// YYImage
let webpEncoder = YYImageEncoder.init(type: .webP)
webpEncoder?.loopCount = 0
webpEncoder?.quality = 0.9
webpEncoder?.addImage(with: jpegData, duration: 0)
let yyWebpData = webpEncoder?.encode()

基于libwebp实现的WebP编码

libwebp是Google提供的一个用于进行WebP格式编解码的支持库。libwebp仓库镜像地址libwebp官方api文档。 从文档中可以得知,编码的api可分为简单api、高级api。通过查阅libwebp的源码发现,简单api的实现其实就是库中对于高级api的封装,可以理解为高级api的可自定义性会比较强。

简单api

先贴一下我采用高级api的部分代码实现

WebPConfig config;
if (!WebPConfigPreset(&config, WEBP_PRESET_DEFAULT, 90.f)) {
   CFRelease(webPImageDatRef);
   return nil;
}

config.thread_level = 1;

WebPPicture pic;
if (!WebPPictureInit(&pic)) {
   CFRelease(webPImageDatRef);
   return nil;
}

pic.use_argb = 0;
pic.width = (int)webPImageWidth;
pic.height = (int)webPImageHeight;

WebPMemoryWriter writer;
WebPMemoryWriterInit(&writer);
pic.writer = WebPMemoryWrite;
pic.custom_ptr = &writer;

int result = WebPPictureImportRGB(&pic, rgb, (int)webPBytesPerRow);
if (!result) {
   WebPMemoryWriterClear(&writer);
   CFRelease(webPImageDatRef);
   return nil;
}

WebPEncode(&config, &pic);

NSData *webPFinalData = [NSData dataWithBytes:writer.mem length:writer.size];

WebPPictureFree(&pic);
CFRelease(webPImageDatRef);
WebPMemoryWriterClear(&writer);
  • 可以看到最后的WebPEncode函数其实是需要一个WebPConfig对象及一个WebPPicture对象。

  • WebPConfig对象

    该对象主要是配置一些在编码过程中压缩算法的参数,具体的解释可参考上方Google给出的官方文档,WebPConfigPreset函数最终会调用源码config_enc.c中的WebPConfigInitInternal函数,这里是该对象被初始化并赋予默认值的地方。上述设置的thread_level是打开libwebp的多线程编码能力,主要是想尝试能否在其多线程的情况下提高编码效率。

  • WebPPicture对象

    该对象可以理解为是webp的一个交换结构,即输入、输出及一些有关图像的信息。

    • 输入指的就是rgb这个对象,它是一个uint8_t*,是通过传入的UIImage实体转换过来的,这个在后面会提到。它是通过WebPPictureImportRGB函数转换成WebPPicture的信息的。 ps: 需要注意的是,在Import之前,需要判断好UIImage的通道类型,譬如如果是RGBA则需要调用WebPPictureImportRGBA,否则可能会出现编码后的图像失真。 这里由于是从相机获取的,所以调用WebPPictureImportRGB
    • 输出是一个libwebp提供的WebPMemoryWriter对象,其中mem属性代表转换后的数据,size属性代表转换后数据的大小。我们可以通过这两个属性调用原生方法初始化一个NSData。
  • WebPEncode

    最终会调用到WebPEncode函数进行WebP的编码。经过测试发现该方法是最为耗时也最占用CPU资源的。编码结束后还需要调用相应的函数释放内存,防止内存泄漏。

关于编码时的通道数与相机出图的通道类型不对称问题

以下是相机的参数设置

测试的设备为iPhone7 系统:iOS 13.1,我们尝试输出一张在该设备上的最大分辨率图片,以下是输出结果

这里需要关注的点是,尽管我们认为相机输出的会是RGB,但其还是有32位,且具有kCGImageAlphaNoneSkipLast的标志位。所以实际上它的通道类型为RGBX。这个明显和上述所说的在编码之前调用WebPPictureImportRGB函数不符的(当然,这里应该是可以调用WebPPictureImportRGBX函数。这个没有深入研究,因为考虑到到了8位可能对编码速度有影响,可以作为思考扩展。)。所以在本次研究中,参考了SDWebImage的处理方案,下面是通过参考SDWebImage方案所进行的编码前预处理(相关源码可以在SDImageWebPCoder.m中sd_encodedWebpDataWithImage方法中找到)。

CGImageRef webPImageRef = image.CGImage;
CGBitmapInfo bitmapInfo = CGImageGetBitmapInfo(webPImageRef);
size_t webPBytesPerRow = CGImageGetBytesPerRow(webPImageRef);

size_t webPImageWidth = CGImageGetWidth(webPImageRef);
size_t webPImageHeight = CGImageGetHeight(webPImageRef);

CGDataProviderRef webPDataProviderRef = CGImageGetDataProvider(webPImageRef);
CFDataRef webPImageDatRef = CGDataProviderCopyData(webPDataProviderRef);

uint8_t *rgb = NULL;
// Convert all other cases to target color mode using vImage
vImageConverterRef convertor = NULL;
vImage_Error error = kvImageNoError;

vImage_CGImageFormat srcFormat = {
    .bitsPerComponent = (uint32_t)CGImageGetBitsPerComponent(webPImageRef),
    .bitsPerPixel = (uint32_t)CGImageGetBitsPerPixel(webPImageRef),
    .colorSpace = CGImageGetColorSpace(webPImageRef),
    .bitmapInfo = bitmapInfo
};
vImage_CGImageFormat destFormat = {
    .bitsPerComponent = 8,
    .bitsPerPixel = 24,
    .colorSpace = CGColorSpaceCreateDeviceRGB(),
    .bitmapInfo = kCGImageAlphaNone | kCGBitmapByteOrderDefault // RGB888/RGBA8888 (Non-premultiplied to works for libwebp)
    };

convertor = vImageConverter_CreateWithCGImageFormat(&srcFormat, &destFormat, NULL, kvImageNoFlags, &error);
if (error != kvImageNoError) {
    CFRelease(webPImageDatRef);
    return nil;
}

vImage_Buffer src = {
    .data = (uint8_t *)CFDataGetBytePtr(webPImageDatRef),
    .width = webPImageWidth,
    .height = webPImageHeight,
    .rowBytes = webPBytesPerRow
};
vImage_Buffer dest;

error = vImageBuffer_Init(&dest, webPImageHeight, webPImageWidth, destFormat.bitsPerPixel, kvImageNoFlags);
if (error != kvImageNoError) {
    vImageConverter_Release(convertor);
    CFRelease(webPImageDatRef);
    return nil;
}

// Convert input color mode to RGB888/RGBA8888
error = vImageConvert_AnyToAny(convertor, &src, &dest, NULL, kvImageNoFlags);
vImageConverter_Release(convertor);
if (error != kvImageNoError) {
    CFRelease(webPImageDatRef);
    return nil;
}

rgb = dest.data; // Converted buffer
webPBytesPerRow = dest.rowBytes; // Converted bytePerRow
CFRelease(webPImageDatRef); // Use CFData to manage bytes for free, the same code path for error handling
webPImageDatRef = CFDataCreateWithBytesNoCopy(kCFAllocatorDefault, rgb, webPBytesPerRow * webPImageHeight, kCFAllocatorDefault);

这里用到的是iOS的Accelerate框架,最终实现的效果是将原来在相机输出的图片进行转换,生成一个24位的、bitmapInfo标志位为kCGImageAlphaNone的图像数据。再利用此数据流进行WebP的编码,这样就能与编码时的WebPPictureImportRGB函数对齐。

编码耗时

从官方提供的数据来看,WebP与JPG相比较,编码速度慢10倍。这里利用上述的方案,选用一张预先在相机输出的结果图(图片大小为2448*3264),进行循环10次的编码,最终计算出10次耗时的平均值。 ps:这里设置的编码质量为90%

机型/系统循环10次平均耗时/s平均大小/bytes
iPhone5s/iOS122.529741811752319488398
iPhone5s/iOS11.2.62.5465808153152465488398
iPhone8P/iOS 13.40.965844702720642488398
iPad mini4/iOS13.1.31.944874358177185488398
iPhone7/iOS13.11.0465802907943726488398
  • 该图片在iPhone7设备上JPEG的编码时间为0.11402392387390137s, 同样是这是编码质量为90%。这里可以基本验证编码时间为10倍左右。
  • 上述设备中有两台5s,平均耗时都在2.5s多,这个也能初步证明编码的耗时和iOS系统关系不大,与设备本身硬件性能关系较大。
  • 关于编码产物的大小,这里测试发现基本平均都在488398bytes,产物大小可能只与编码算法配置相关(这个可能需要做大量的兼容性测试)。

题外话

  • 这个插播一个在研究过程中碰到的问题:直接使用libwebp源码在debug模式下耗时很严重,远远超过上述所说的10倍。

    libwebp的源码可以通过pod拉取(pod 'libwebp'),这个在上述SDWebImage的podspec声明中也有体现。虽然在上述提供的镜像仓库内未找到相关的podspec文件,但我在拉取之后的json文件中找到了对应的git仓库地址,对应的是Google的git仓库。libwebp

    如果我们直接使用源码的方式,进行导入。在debug模式下,其编码的耗时会比上述统计的耗时高出几十倍之多。这个也是在研究耗时过程中碰到的最主要问题,后续是通过将libwebp提供的iosbuild.sh编译成framework,才最终发现这个问题。目前怀疑是debug模式下,libwebp会调用一些debug的逻辑,导致总体速度变慢,目前未在源码中找到。这个可能也是开头提到的YYImage会提供两个subspec的原因。

  • 还有一个是比较严重的问题是cpu占用率。

    在测试发现,在编码的过程中设备的cpu会飙升到90%以上,采用工具查看主要的占用来源于WebPEncode函数,可能对于手机性能的损耗会比较大。

扩展

在文章开头提到的Android自带的WebP编码方法,这就引出了一个问题:Android本身也是使用libwebp进行编解码的,是否里面也是通过高级api实现?iOS是否可以参考其WebPConfig的设置来实现效果的对齐?

通过查看Android源码得知,文章开头提到的compress方法其实里面真实调用的是一个native方法 通过查阅Android源码得知,此方法最终调用的是在/external/skia/src/images/SkWebpEncoder.cpp的

bool SkWebpEncoder::Encode(SkWStream* stream, const SkPixmap& pixmap, const Options& opts)

源代码地址:SkWebpEncoder,这里参考的是Android api 28。

可以看出,Android原生对于WebPConfig的定制化也是不高的。

最后

本文主要介绍了在iOS利用libwebp进行WebP编码的研究方案,以及利用此方案的一些性能研究。