浅谈移动端图片压缩(iOS & Android)

[复制链接]
seenoth 发表于 2019-3-15 14:06:09 | 显示全部楼层 |阅读模式
来源:https://www.jianshu.com/p/1a0a58cf6fab
若内容不全,可点击上述链接查看来源网页,在网页中点击红色双层向下的箭头阅读全文


在App中,如果分享、发布、上传功能涉及到图片,必不可少会对图片进行一定程度的压缩。笔者最近在公司项目中恰好重构了双端(iOS&Android)的图片压缩模块。本文会非常基础的讲解一些图片压缩的方式和思路。

图片格式基础点阵图&矢量图
  • 点阵图:也叫位图。用像素为单位,像素保存颜色信息,排列像素实现显示。
  • 矢量图:记录元素形状和颜色的算法,显示时展示算法运算的结果。
颜色

表示颜色时,有两种形式,一种为索引色(IndexColor),一种为直接色(DirectColor)

  • 索引色:用一个数字索引代表一种颜色,在图像信息中存储数字到颜色的映射关系表(调色盘Palette)。每个像素保存该像素颜色对应的数字索引。一般调色盘只能存储有限种类的颜色,通常为256种。所以每个像素的数字占用1字节(8bit)大小。
  • 直接色:用四个数字来代表一种颜色,数字分别对应颜色中红色,绿色,蓝色,透明度(RGBA)。每个像素保存这四个纬度的信息来代表该像素的颜色。根据色彩深度(每个像素存储颜色信息的bit数不同),最多可以支持的颜色种类也不同,常见的有8位(R3+G3+B2)、16位(R5+G6+B5)、24位(R8+G8+B8)、32位(A8+R8+G8+B8)。所以每个像素占用1~4字节大小。
移动端常用图片格式

图片格式中一般分为静态图和动态图

静态图
  • JPG:是支持JPEG(一种有损压缩方法)标准中最常用的图片格式。采用点阵图。常见的是使用24位的颜色深度的直接色(不支持透明)。

  • PNG:是支持无损压缩的图片格式。采用点阵图。PNG有5种颜色选项:索引色、灰度、灰度透明、真彩色(24位直接色)、真彩色透明(32位直接色)。

  • WebP:是同时支持有损压缩和无所压缩的的图片格式。采用点阵图。支持32位直接色。移动端支持情况如下:

系统原生WebView浏览器iOS第三方库支持不支持不支持Android4.3后支持完整功能支持支持动态图
  • GIF:是支持无损压缩的图片格式。采用点阵图。使用索引色,并有1位透明度通道(透明与否)。

  • APNG:基于PNG格式扩展的格式,加入动态图支持。采用点阵图。使用32位直接色。但没有被官方PNG接纳。移动端支持情况如下:

系统原生WebView浏览器iOS支持支持支持Android第三方库支持不支持不支持
  • AnimatedWebp:Webp的动图形式,实际上是文件中打包了多个单帧Webp,在libwebp0.4后开始支持。移动端支持情况如下:
系统原生WebView系统浏览器iOS第三方库支持不支持不支持Android第三方库支持不支持不支持

而由于一般项目需要兼容三端(iOS、Android、Web的关系),最简单就是支持JPG、PNG、GIF这三种通用的格式。所以本文暂不讨论其余图片格式的压缩。

移动端系统图片处理架构

根据我的了解,画了一下iOS&Android图片处理架构。iOS这边,也是可以直接调用底层一点的框架的。

屏幕快照2019-01-13下午9.37.00iOS的ImageIO

本文iOS端处理图片主要用ImageIO框架,使用的原因主要是静态图动态图API调用保持一致,且不会因为UIImage转换时会丢失一部分数据的信息。

ImageIO主要提供了图片编解码功能,封装了一套C语言接口。在Swift中不需要对C对象进行内存管理,会比Objective-C中使用方便不少,但api结果返回都是Optional(实际上非空),需要用guard/if,或者!进行转换。

解码1.创建CGImageSource

CGImageSource相当于ImageIO数据来源的抽象类。通用的使用方式CGImageSourceCreateWithDataProvider:需要提供一个DataProvider,可以指定文件、URL、Data等输入。也有通过传入CFData来进行创建的便捷方法CGImageSourceCreateWithData:。方法的第二个参数options传入一个字典进行配置。根据Apple在WWDC2018上的ImageandGraphicsBestPractices上的例子,当不需要解码仅需要创建CGImageSource的时候,应该将kCGImageSourceShouldCache设为false。

11994763-6f25c32bd4d3b4272.解码得到CGImage

用CGImageSourceCreateImageAtIndex:或者CGImageSourceCreateThumbnailAtIndex:来获取生成的CGImage,这里参数的Index就是第几帧图片,静态图传入0即可。

编码1.创建CGImageDestination

CGImageDestination相当于ImageIO数据输出的抽象类。通用的使用方式CGImageDestinationCreateWithDataConsumer:需要提供一个DataConsumer,可以置顶URL、Data等输入。也有通过传入CFData来进行创建的便捷方法CGImageDestinationCreateWithData:,输出会写入到传入的Data中。方法还需要提供图片类型,图片帧数。

2.添加CGImage

添加CGImage使用CGImageDestinationAddImage:方法,动图的话,按顺序多次调用就行了。

而且还有一个特别的CGImageDestinationAddImageFromSource:方法,添加的其实是一个CGImageSource,有什么用呢,通过options参数,达到改变图像设置的作用。比如改变JPG的压缩参数,用上这个功能后,就不需要转换成更顶层的对象(比如UIImage),减少了转换时的编解码的损耗,达到性能更优的目的。

3.进行编码

调用CGImageDestinationFinalize:,表示开始编码,完成后会返回一个Bool值,并将数据写入CGImageDestination提供的DataConsumer中。

压缩思路分析

位图占用的空间大小,其实就是像素数量x单像素占用空间x帧数。所以减小图片空间大小,其实就从这三个方向下手。其中单像素占用空间,在直接色的情况下,主要和色彩深度相关。在实际项目中,改变色彩深度会导致图片颜色和原图没有保持完全一致,笔者并不建议对色彩深度进行更改。而像素数量就是平时非常常用的图片分辨率缩放。除此之外,JPG格式还有特有的通过指定压缩系数来进行有损压缩。

  • JPG:压缩系数+分辨率缩放+色彩深度降低
  • PNG:分辨率缩放+降低色彩深度
  • GIF:减少帧数+每帧分辨率缩放+减小调色盘
判断图片格式

后缀扩展名来判断其实并不保险,真实的判断方式应该是通过文件头里的信息进行判断。

JPGPNGGIF开头:FFD8+结尾:FFD989504E470D0A1A0A4749463839/3761

简单判断用前三个字节来判断

iOSextensionData{enumImageFormat{casejpg,png,gif,unknown}varimageFormat:ImageFormat{varheaderData=[UInt8](repeating:0,count:3)self.copyBytes(to:&headerData,from:(0..<3))lethexString=headerData.reduce(""){$0+String(($1&0xFF),radix:16)}.uppercased()varimageFormat=ImageFormat.unknownswitchhexString{case"FFD8FF":imageFormat=.jpgcase"89504E":imageFormat=.pngcase"474946":imageFormat=.gifdefault:break}returnimageFormat}}

iOS中除了可以用文件头信息以外,还可以将Data转成CGImageSource,然后用CGImageSourceGetType这个API,这样会获取到ImageIO框架支持的图片格式的的UTI标识的字符串。对应的标识符常量定义在MobileCoreServices框架下的UTCoreTypes中。

字符串常量UTI格式(字符串原始值)kUTTypePNGpublic.pngkUTTypeJPEGpublic.jpegkUTTypeGIFcom.compuserve.gifAndoridenumclassImageFormat{JPG,PNG,GIF,UNKNOWN}funByteArray.imageFormat():ImageFormat{valheaderData=this.slice(0..2)valhexString=headerData.fold(StringBuilder("")){result,byte->result.append((byte.toInt()and0xFF).toString(16))}.toString().toUpperCase()varimageFormat=ImageFormat.UNKNOWNwhen(hexString){"FFD8FF"->{imageFormat=ImageFormat.JPG}"89504E"->{imageFormat=ImageFormat.PNG}"474946"->{imageFormat=ImageFormat.GIF}}returnimageFormat}色彩深度改变

实际上,减少深度一般也就是从32位减少至16位,但颜色的改变并一定能让产品、用户、设计接受,所以笔者在压缩过程并没有实际使用改变色彩深度的方法,仅仅研究了做法。

iOS

在iOS中,改变色彩深度,原生的CGImage库中,没有简单的方法。需要自己设置参数,重新生成CGImage。

publicinit?(width:Int,height:Int,bitsPerComponent:Int,bitsPerPixel:Int,bytesPerRow:Int,space:CGColorSpace,bitmapInfo:CGBitmapInfo,provider:CGDataProvider,decode:UnsafePointer<CGFloat>?,shouldInterpolate:Bool,intent:CGColorRenderingIntent)
  • bitsPerComponent每个通道占用位数
  • bitsPerPixel每个像素占用位数,相当于所有通道加起来的位数,也就是色彩深度
  • bytesPerRow传入0即可,系统会自动计算
  • space色彩空间
  • bitmapInfo这个是一个很重要的东西,其中常用的信息有CGImageAlphaInfo,代表是否有透明通道,透明通道在前还是后面(ARGB还是RGBA),是否有浮点数(floatComponents),CGImageByteOrderInfo,代表字节顺序,采用大端还是小端,以及数据单位宽度,iOS一般采用32位小端模式,一般用orderDefault就好。

那么对于常用的色彩深度,就可以用这些参数的组合来完成。同时笔者在查看更底层的vImage框架的vImage_CGImageFormat结构体时(CGImage底层也是使用vImage,具体可查看Accelerate框架vImage库的vImage_Utilities文件),发现了Apple的注释,里面也包含了常用的色彩深度用的参数。

屏幕快照2019-01-15下午9.16.40

这一块为了和Android保持一致,笔者封装了Android常用的色彩深度参数对应的枚举值。

publicenumColorConfig{casealpha8casergb565caseargb8888casergbaF16caseunknown//其余色彩配置}

CGBitmapInfo由于是OptionalSet,可以封装用到的属性的便捷方法。

extensionCGBitmapInfo{init(_alphaInfo:CGImageAlphaInfo,_isFloatComponents:Bool=false){vararray=[CGBitmapInfo(rawValue:alphaInfo.rawValue),CGBitmapInfo(rawValue:CGImageByteOrderInfo.orderDefault.rawValue)]ifisFloatComponents{array.append(.floatComponents)}self.init(array)}}

那么ColorConfig对应的CGImage参数也可以对应起来了。

extensionColorConfig{structCGImageConfig{letbitsPerComponent:IntletbitsPerPixel:IntletbitmapInfo:CGBitmapInfo}varimageConfig:CGImageConfig?{switchself{case.alpha8:returnCGImageConfig(bitsPerComponent:8,bitsPerPixel:8,bitmapInfo:CGBitmapInfo(.alphaOnly))case.rgb565:returnCGImageConfig(bitsPerComponent:5,bitsPerPixel:16,bitmapInfo:CGBitmapInfo(.noneSkipFirst))case.argb8888:returnCGImageConfig(bitsPerComponent:8,bitsPerPixel:32,bitmapInfo:CGBitmapInfo(.premultipliedFirst))case.rgbaF16:returnCGImageConfig(bitsPerComponent:16,bitsPerPixel:64,bitmapInfo:CGBitmapInfo(.premultipliedLast,true))case.unknown:returnnil}}}

反过来,判断CGImage的ColorConfig的方法。

extensionCGImage{varcolorConfig:ColorConfig{ifisColorConfig(.alpha8){return.alpha8}elseifisColorConfig(.rgb565){return.rgb565}elseifisColorConfig(.argb8888){return.argb8888}elseifisColorConfig(.rgbaF16){return.rgbaF16}else{return.unknown}}funcisColorConfig(_colorConfig:ColorConfig)->Bool{guardletimageConfig=colorConfig.imageConfigelse{returnfalse}ifbitsPerComponent==imageConfig.bitsPerComponent&&bitsPerPixel==imageConfig.bitsPerPixel&&imageConfig.bitmapInfo.contains(CGBitmapInfo(alphaInfo))&&imageConfig.bitmapInfo.contains(.floatComponents){returntrue}else{returnfalse}}}

对外封装的Api,也就是直接介绍的ImageIO的使用步骤,只是参数不一样。

///改变图片到指定的色彩配置//////-Parameters:///-rawData:原始图片数据///-config:色彩配置///-Returns:处理后数据publicstaticfuncchangeColorWithImageData(_rawData:Data,config:ColorConfig)->Data?{guardletimageConfig=config.imageConfigelse{returnrawData}guardletimageSource=CGImageSourceCreateWithData(rawDataasCFData,[kCGImageSourceShouldCache:false]asCFDictionary),letwriteData=CFDataCreateMutable(nil,0),letimageType=CGImageSourceGetType(imageSource),letimageDestination=CGImageDestinationCreateWithData(writeData,imageType,1,nil),letrawDataProvider=CGDataProvider(data:rawDataasCFData),letimageFrame=CGImage(width:Int(rawData.imageSize.width),height:Int(rawData.imageSize.height),bitsPerComponent:imageConfig.bitsPerComponent,bitsPerPixel:imageConfig.bitsPerPixel,bytesPerRow:0,space:CGColorSpaceCreateDeviceRGB(),bitmapInfo:imageConfig.bitmapInfo,provider:rawDataProvider,decode:nil,shouldInterpolate:true,intent:.defaultIntent)else{returnnil}CGImageDestinationAddImage(imageDestination,imageFrame,nil)guardCGImageDestinationFinalize(imageDestination)else{returnnil}returnwriteDataasData}///获取图片的色彩配置//////-ParameterrawData:原始图片数据///-Returns:色彩配置publicstaticfuncgetColorConfigWithImageData(_rawData:Data)->ColorConfig{guardletimageSource=CGImageSourceCreateWithData(rawDataasCFData,[kCGImageSourceShouldCache:false]asCFDictionary),letimageFrame=CGImageSourceCreateImageAtIndex(imageSource,0,nil)else{return.unknown}returnimageFrame.colorConfig}Android

对于Android来说,其原生的Bitmap库有相当方便的转换色彩深度的方法,只需要传入Config就好。

publicBitmapcopy(Configconfig,booleanisMutable){checkRecycled("Can'tcopyarecycledbitmap");if(config==Config.HARDWARE&&isMutable){thrownewIllegalArgumentException("Hardwarebitmapsarealwaysimmutable");}noteHardwareBitmapSlowCall();Bitmapb=nativeCopy(mNativePtr,config.nativeInt,isMutable);if(b!=null){b.setPremultiplied(mRequestPremultiplied);b.mDensity=mDensity;}returnb;}

iOS的CGImage参数和Android的Bitmap.Config以及色彩深度对应关系如下表:

色彩深度iOSAndroid8位灰度(只有透明度)bitsPerComponent:8bitsPerPixel:8bitmapInfo:CGImageAlphaInfo.alphaOnlyBitmap.Config.ALPHA_816位色(R5+G6+R5)bitsPerComponent:5bitsPerPixel:16bitmapInfo:CGImageAlphaInfo.noneSkipFirstBitmap.Config.RGB_56532位色(A8+R8+G8+B8)bitsPerComponent:8bitsPerPixel:32bitmapInfo:CGImageAlphaInfo.premultipliedFirstBitmap.Config.ARGB_888864位色(R16+G16+B16+A16但使用半精度减少一半储存空间)用于宽色域或HDRbitsPerComponent:16bitsPerPixel:64bitmapInfo:CGImageAlphaInfo.premultipliedLast+.floatComponentsBitmap.Config.RGBA_F16JPG的压缩系数改变

JPG的压缩算法相当复杂,以至于主流使用均是用libjpeg这个广泛的库进行编解码(在Android7.0上开始使用性能更好的libjpeg-turbo,iOS则是用Apple自己开发未开源的AppleJPEG)。而在iOS和Android上,都有Api输入压缩系数,来压缩JPG。但具体压缩系数如何影响压缩大小,笔者并未深究。这里只能简单给出使用方法。

iOS

iOS里面压缩系数为0-1之间的数值,据说iOS相册中采用的压缩系数是0.9。同时,png不支持有损压缩,所以kCGImageDestinationLossyCompressionQuality这个参数是无效。

staticfunccompressImageData(_rawData:Data,compression:Double)->Data?{guardletimageSource=CGImageSourceCreateWithData(rawDataasCFData,[kCGImageSourceShouldCache:false]asCFDictionary),letwriteData=CFDataCreateMutable(nil,0),letimageType=CGImageSourceGetType(imageSource),letimageDestination=CGImageDestinationCreateWithData(writeData,imageType,1,nil)else{returnnil}letframeProperties=[kCGImageDestinationLossyCompressionQuality:compression]asCFDictionaryCGImageDestinationAddImageFromSource(imageDestination,imageSource,0,frameProperties)guardCGImageDestinationFinalize(imageDestination)else{returnnil}returnwriteDataasData}Andoid

Andoird用Bitmap自带的接口,并输出到流中。压缩系数是0-100之间的数值。这里的参数虽然可以填Bitmap.CompressFormat.PNG,但当然也是无效的。

valoutputStream=ByteArrayOutputStream()valimage=BitmapFactory.decodeByteArray(rawData,0,rawData.count())image.compress(Bitmap.CompressFormat.JPEG,compression,outputStream)resultData=outputStream.toByteArray()GIF的压缩

GIF压缩上有很多种思路。参考开源项目gifsicleImageMagick中的做法,大概有以下几种。

  • 由于GIF支持全局调色盘和局部调色盘,在没有局部调色盘的时候会用放在文件头中的全局调色盘。所以对于颜色变化不大的GIF,可以将颜色放入全局调色盘中,去除局部调色盘。

  • 对于颜色较少的GIF,将调色盘大小减少,比如从256种减少到128种等。

    1490353055438_2367_14903530557811490353098026_7360_1490353098210
  • 对于背景一致,画面中有一部分元素在变化的GIF,可以将多个元素和背景分开存储,然后加上如何还原的信息

    b522ac7896b320b4a9ee1eed1034e4fe_articlex9e9fe93459fe7117909eb27771bdc182_articlex433b41c29c6a70e64631a3d4c363e468_articlex
  • 对于背景一致,画面中有一部分元素在动的GIF,可以和前面一帧比较,将不动的部分透明化

    d3c7444d59eed11d98abbb7c4e1da7ec_articlexe50b7f75feebb9bd056bb8dca9964873_articlex704d70c65d22fb240cb5f6f7be5bbf86_articlex
  • 对于帧数很多的GIF,可以抽取中间部分的帧,减少帧数

  • 对于每帧分辨率很高的GIF,将每帧的分辨率减小

对于动画的GIF,3、4是很实用的,因为背景一般是不变的,但对于拍摄的视频转成的GIF,就没那么实用了,因为存在轻微抖动,很难做到背景不变。但在移动端,除非将ImageMagick或者gifsicle移植到iOS&Android上,要实现前面4个方法是比较困难的。笔者这里只实现了抽帧,和每帧分辨率压缩。

至于抽帧的间隔,参考了文章中的数值。

帧数每x帧使用1帧<9x=29-20x=321-30x=431-40x=5>40x=6

这里还有一个问题,抽帧的时候,原来的帧可能使用了3、4的方法进行压缩过,但还原的时候需要还原成完整的图像帧,再重新编码时,就没有办法再用3、4进行优化了。虽然帧减少了,但实际上会将帧还原成未做3、4优化的状态,一增一减,压缩的效果就没那么好了(所以这种压缩还是尽量在服务器做)。抽帧后记得将中间被抽取的帧的时间累加在剩下的帧的时间上,不然帧速度就变快了,而且不要用抽取数x帧时间偷懒来计算,因为不一定所有帧的时间是一样的。

iOS

iOS上的实现比较简单,用ImageIO的函数即可实现,性能也比较好。

先定义从ImageSource获取每帧的时间的便捷扩展方法,帧时长会存在kCGImagePropertyGIFUnclampedDelayTime或者kCGImagePropertyGIFDelayTime中,两个key不同之处在于后者有最小值的限制,正确的获取方法参考苹果在WebKit中的使用方法

extensionCGImageSource{funcframeDurationAtIndex(_index:Int)->Double{varframeDuration=Double(0.1)guardletframeProperties=CGImageSourceCopyPropertiesAtIndex(self,index,nil)as?[AnyHashable:Any],letgifProperties=frameProperties[kCGImagePropertyGIFDictionary]as?[AnyHashable:Any]else{returnframeDuration}ifletunclampedDuration=gifProperties[kCGImagePropertyGIFUnclampedDelayTime]as?NSNumber{frameDuration=unclampedDuration.doubleValue}else{ifletclampedDuration=gifProperties[kCGImagePropertyGIFDelayTime]as?NSNumber{frameDuration=clampedDuration.doubleValue}}ifframeDuration<0.011{frameDuration=0.1}returnframeDuration}varframeDurations:[Double]{letframeCount=CGImageSourceGetCount(self)return(0..<frameCount).map{self.frameDurationAtIndex($0)}}}

先去掉不要的帧,合并帧的时间,再重新生成帧就完成了。注意帧不要被拖得太长,不然体验不好,我这里给的最大值是200ms。

///同步压缩图片抽取帧数,仅支持GIF//////-Parameters:///-rawData:原始图片数据///-sampleCount:采样频率,比如3则每三张用第一张,然后延长时间///-Returns:处理后数据staticfunccompressImageData(_rawData:Data,sampleCount:Int)->Data?{guardletimageSource=CGImageSourceCreateWithData(rawDataasCFData,[kCGImageSourceShouldCache:false]asCFDictionary),letwriteData=CFDataCreateMutable(nil,0),letimageType=CGImageSourceGetType(imageSource)else{returnnil}//计算帧的间隔letframeDurations=imageSource.frameDurations//合并帧的时间,最长不可高于200msletmergeFrameDurations=(0..<frameDurations.count).filter{$0%sampleCount==0}.map{min(frameDurations[$0..<min($0+sampleCount,frameDurations.count)].reduce(0.0){$0+$1},0.2)}//抽取帧每n帧使用1帧letsampleImageFrames=(0..<frameDurations.count).filter{$0%sampleCount==0}.compactMap{CGImageSourceCreateImageAtIndex(imageSource,$0,nil)}guardletimageDestination=CGImageDestinationCreateWithData(writeData,imageType,sampleImageFrames.count,nil)else{returnnil}//每一帧图片都进行重新编码zip(sampleImageFrames,mergeFrameDurations).forEach{//设置帧间隔letframeProperties=[kCGImagePropertyGIFDictionary:[kCGImagePropertyGIFDelayTime:$1,kCGImagePropertyGIFUnclampedDelayTime:$1]]CGImageDestinationAddImage(imageDestination,$0,framePropertiesasCFDictionary)}guardCGImageDestinationFinalize(imageDestination)else{returnnil}returnwriteDataasData}

压缩分辨率也是类似的,每帧按分辨率压缩再重新编码就好。

Android

Android原生对于GIF的支持就不怎么友好了,由于笔者Android研究不深,暂时先用Glide中的GIF编解码组件来完成。编码的性能比较一般,比不上iOS,但除非换用更底层C++库实现的编码库,Java写的性能都很普通。先用Gradle导入Glide,注意解码器是默认的,但编码器需要另外导入。

api'com.github.bumptech.glide:glide:4.8.0'api'com.github.bumptech.glide:gifencoder-integration:4.8.0'

抽帧思路和iOS一样,只是Glide的这个GIF解码器没办法按指定的index取读取某一帧,只能一帧帧读取,调用advance方法往后读取。先从GIF读出头部信息,然后在读真正的帧信息。

/***返回同步压缩gif图片Byte数据[rawData]的按[sampleCount]采样后的Byte数据*/privatefuncompressGifDataWithSampleCount(context:Context,rawData:ByteArray,sampleCount:Int):ByteArray?{if(sampleCount<=1){returnrawData}valgifDecoder=StandardGifDecoder(GifBitmapProvider(Glide.get(context).bitmapPool))valheaderParser=GifHeaderParser()headerParser.setData(rawData)valheader=headerParser.parseHeader()gifDecoder.setData(header,rawData)valframeCount=gifDecoder.frameCount//计算帧的间隔valframeDurations=(0untilframeCount).map{gifDecoder.getDelay(it)}//合并帧的时间,最长不可高于200msvalmergeFrameDurations=(0untilframeCount).filter{it%sampleCount==0}.map{min(frameDurations.subList(it,min(it+sampleCount,frameCount)).fold(0){acc,duration->acc+duration},200)}//抽取帧valsampleImageFrames=(0untilframeCount).mapNotNull{gifDecoder.advance()varimageFrame:Bitmap?=nullif(it%sampleCount==0){imageFrame=gifDecoder.nextFrame}imageFrame}valgifEncoder=AnimatedGifEncoder()varresultData:ByteArray?=nulltry{valoutputStream=ByteArrayOutputStream()gifEncoder.start(outputStream)gifEncoder.setRepeat(0)//每一帧图片都进行重新编码sampleImageFrames.zip(mergeFrameDurations).forEach{//设置帧间隔gifEncoder.setDelay(it.second)gifEncoder.addFrame(it.first)it.first.recycle()}gifEncoder.finish()resultData=outputStream.toByteArray()outputStream.close()}catch(e:IOException){e.printStackTrace()}returnresultData}

压缩分辨率的时候要注意,分辨率太大编码容易出现Crash(应该是OOM),这里设置为512。

/***返回同步压缩gif图片Byte数据[rawData]每一帧长边到[limitLongWidth]后的Byte数据*/privatefuncompressGifDataWithLongWidth(context:Context,rawData:ByteArray,limitLongWidth:Int):ByteArray?{valgifDecoder=StandardGifDecoder(GifBitmapProvider(Glide.get(context).bitmapPool))valheaderParser=GifHeaderParser()headerParser.setData(rawData)valheader=headerParser.parseHeader()gifDecoder.setData(header,rawData)valframeCount=gifDecoder.frameCount//计算帧的间隔valframeDurations=(0..(frameCount-1)).map{gifDecoder.getDelay(it)}//计算调整后大小vallongSideWidth=max(header.width,header.height)valratio=limitLongWidth.toFloat()/longSideWidth.toFloat()valresizeWidth=(header.width.toFloat()*ratio).toInt()valresizeHeight=(header.height.toFloat()*ratio).toInt()//每一帧进行缩放valresizeImageFrames=(0untilframeCount).mapNotNull{gifDecoder.advance()varimageFrame=gifDecoder.nextFrameif(imageFrame!=null){imageFrame=Bitmap.createScaledBitmap(imageFrame,resizeWidth,resizeHeight,true)}imageFrame}valgifEncoder=AnimatedGifEncoder()varresultData:ByteArray?=nulltry{valoutputStream=ByteArrayOutputStream()gifEncoder.start(outputStream)gifEncoder.setRepeat(0)//每一帧都进行重新编码resizeImageFrames.zip(frameDurations).forEach{//设置帧间隔gifEncoder.setDelay(it.second)gifEncoder.addFrame(it.first)it.first.recycle()}gifEncoder.finish()resultData=outputStream.toByteArray()outputStream.close()returnresultData}catch(e:IOException){e.printStackTrace()}returnresultData}分辨率压缩

这个是最常用的,而且也比较简单。

iOS

iOS的ImageIO提供了CGImageSourceCreateThumbnailAtIndex的API来创建缩放的缩略图。在options中添加需要缩放的长边参数即可。

///同步压缩图片数据长边到指定数值//////-Parameters:///-rawData:原始图片数据///-limitLongWidth:长边限制///-Returns:处理后数据publicstaticfunccompressImageData(_rawData:Data,limitLongWidth:CGFloat)->Data?{guardmax(rawData.imageSize.height,rawData.imageSize.width)>limitLongWidthelse{returnrawData}guardletimageSource=CGImageSourceCreateWithData(rawDataasCFData,[kCGImageSourceShouldCache:false]asCFDictionary),letwriteData=CFDataCreateMutable(nil,0),letimageType=CGImageSourceGetType(imageSource)else{returnnil}letframeCount=CGImageSourceGetCount(imageSource)guardletimageDestination=CGImageDestinationCreateWithData(writeData,imageType,frameCount,nil)else{returnnil}//设置缩略图参数,kCGImageSourceThumbnailMaxPixelSize为生成缩略图的大小。当设置为800,如果图片本身大于800*600,则生成后图片大小为800*600,如果源图片为700*500,则生成图片为800*500letoptions=[kCGImageSourceThumbnailMaxPixelSize:limitLongWidth,kCGImageSourceCreateThumbnailWithTransform:true,kCGImageSourceCreateThumbnailFromImageIfAbsent:true]asCFDictionaryifframeCount>1{//计算帧的间隔letframeDurations=imageSource.frameDurations//每一帧都进行缩放letresizedImageFrames=(0..<frameCount).compactMap{CGImageSourceCreateThumbnailAtIndex(imageSource,$0,options)}//每一帧都进行重新编码zip(resizedImageFrames,frameDurations).forEach{//设置帧间隔letframeProperties=[kCGImagePropertyGIFDictionary:[kCGImagePropertyGIFDelayTime:$1,kCGImagePropertyGIFUnclampedDelayTime:$1]]CGImageDestinationAddImage(imageDestination,$0,framePropertiesasCFDictionary)}}else{guardletresizedImageFrame=CGImageSourceCreateThumbnailAtIndex(imageSource,0,options)else{returnnil}CGImageDestinationAddImage(imageDestination,resizedImageFrame,nil)}guardCGImageDestinationFinalize(imageDestination)else{returnnil}returnwriteDataasData}Android

Android静态图用Bitmap里面的createScaleBitmapAPI就好了,GIF上文已经讲了。

/***返回同步压缩图片Byte数据[rawData]的长边到[limitLongWidth]后的Byte数据,Gif目标长边最大压缩到512,超过用512*/funcompressImageDataWithLongWidth(context:Context,rawData:ByteArray,limitLongWidth:Int):ByteArray?{valformat=rawData.imageFormat()if(format==ImageFormat.UNKNOWN){returnnull}val(imageWidth,imageHeight)=rawData.imageSize()vallongSideWidth=max(imageWidth,imageHeight)if(longSideWidth<=limitLongWidth){returnrawData}if(format==ImageFormat.GIF){//压缩Gif分辨率太大编码时容易崩溃returncompressGifDataWithLongWidth(context,rawData,max(512,longSideWidth))}else{valimage=BitmapFactory.decodeByteArray(rawData,0,rawData.size)valratio=limitLongWidth.toDouble()/longSideWidth.toDouble()valresizeImageFrame=Bitmap.createScaledBitmap(image,(image.width.toDouble()*ratio).toInt(),(image.height.toDouble()*ratio).toInt(),true)image.recycle()varresultData:ByteArray?=nullwhen(format){ImageFormat.PNG->{resultData=resizeImageFrame.toByteArray(Bitmap.CompressFormat.PNG)}ImageFormat.JPG->{resultData=resizeImageFrame.toByteArray(Bitmap.CompressFormat.JPEG)}else->{}}resizeImageFrame.recycle()returnresultData}}限制大小的压缩方式

也就是将前面讲的方法综合起来,笔者这边给出一种方案,没有对色彩进行改变,JPG先用二分法减少最多6次的压缩系数,GIF先抽帧,抽帧间隔参考前文,最后采用逼近目标大小缩小分辨率。

iOS///同步压缩图片到指定文件大小//////-Parameters:///-rawData:原始图片数据///-limitDataSize:限制文件大小,单位字节///-Returns:处理后数据publicstaticfunccompressImageData(_rawData:Data,limitDataSize:Int)->Data?{guardrawData.count>limitDataSizeelse{returnrawData}varresultData=rawData//若是JPG,先用压缩系数压缩6次,二分法ifresultData.imageFormat==.jpg{varcompression:Double=1varmaxCompression:Double=1varminCompression:Double=0for_in0..<6{compression=(maxCompression+minCompression)/2ifletdata=compressImageData(resultData,compression:compression){resultData=data}else{returnnil}ifresultData.count<Int(CGFloat(limitDataSize)*0.9){minCompression=compression}elseifresultData.count>limitDataSize{maxCompression=compression}else{break}}ifresultData.count<=limitDataSize{returnresultData}}//若是GIF,先用抽帧减少大小ifresultData.imageFormat==.gif{letsampleCount=resultData.fitSampleCountifletdata=compressImageData(resultData,sampleCount:sampleCount){resultData=data}else{returnnil}ifresultData.count<=limitDataSize{returnresultData}}varlongSideWidth=max(resultData.imageSize.height,resultData.imageSize.width)//图片尺寸按比率缩小,比率按字节比例逼近whileresultData.count>limitDataSize{letratio=sqrt(CGFloat(limitDataSize)/CGFloat(resultData.count))longSideWidth*=ratioifletdata=compressImageData(resultData,limitLongWidth:longSideWidth){resultData=data}else{returnnil}}returnresultData}Android/***返回同步压缩图片Byte数据[rawData]的数据大小到[limitDataSize]后的Byte数据*/funcompressImageDataWithSize(context:Context,rawData:ByteArray,limitDataSize:Int):ByteArray?{if(rawData.size<=limitDataSize){returnrawData}valformat=rawData.imageFormat()if(format==ImageFormat.UNKNOWN){returnnull}varresultData=rawData//若是JPG,先用压缩系数压缩6次,二分法if(format==ImageFormat.JPG){varcompression=100varmaxCompression=100varminCompression=0try{valoutputStream=ByteArrayOutputStream()for(indexin0..6){compression=(maxCompression+minCompression)/2outputStream.reset()valimage=BitmapFactory.decodeByteArray(rawData,0,rawData.size)image.compress(Bitmap.CompressFormat.JPEG,compression,outputStream)image.recycle()resultData=outputStream.toByteArray()if(resultData.size<(limitDataSize.toDouble()*0.9).toInt()){minCompression=compression}elseif(resultData.size>limitDataSize){maxCompression=compression}else{break}}outputStream.close()}catch(e:IOException){e.printStackTrace()}if(resultData.size<=limitDataSize){returnresultData}}//若是GIF,先用抽帧减少大小if(format==ImageFormat.GIF){valsampleCount=resultData.fitSampleCount()valdata=compressGifDataWithSampleCount(context,resultData,sampleCount)if(data!=null){resultData=data}else{returnnull}if(resultData.size<=limitDataSize){returnresultData}}val(imageWidth,imageHeight)=resultData.imageSize()varlongSideWidth=max(imageWidth,imageHeight)//图片尺寸按比率缩小,比率按字节比例逼近while(resultData.size>limitDataSize){valratio=Math.sqrt(limitDataSize.toDouble()/resultData.size.toDouble())longSideWidth=(longSideWidth.toDouble()*ratio).toInt()valdata=compressImageDataWithLongWidth(context,resultData,longSideWidth)if(data!=null){resultData=data}else{returnnull}}returnresultData}

注意在异步线程中使用,毕竟是耗时操作。

最后

所有代码均封装成文件在iOSAndroid中了,如有错误和建议,欢迎指出。

Reference