批量图片压缩实现

背景

由于当前公司正在开发的Android客户端需要用到大量的图片资源,而为了保证市场分发的apk尽可能的小,所以在开发的过程中,都尽可能的让引入的图片尽可能的小,除了普遍使用的用style代替图片,用.9处理图片方式之外,还有大家公认的做法是,使用Tiny服务对图片进行有损压缩。

当前github上面也有对应的gradle插件,可以对项目中所使用的图片自动上传到tiny进行压缩优化的项目(TinyPngPlugin),也有对应的Intellij插件(TinyPic)。但是都有点不满足于当前自己的需求,gradle插件的项目,使用Groovy实现,需要讲tiny token等配置配置到项目中,且通过匹配文件的文件路径和md5值来判断是否需要上传优化图片,如果不小心保存md5的文件被删除了,则会导致重新上传优化的情况,且当前只支持设置一个tiny token。而Intellij插件同样只支持一个tiny token,且需要用户手动选择需要优化的图片。

目标

鉴于当前我们项目和开发的一些情况,我们期望这样的工具能有以下功能:

  • 可以设置多个Tiny token。由于Tiny一个token一个月只能优化500张图片,而当前项目已经累积了不止500张图片资源了,所以期望可以设置token并动态切换。
  • 对开发者友好。使用之后开发者完全可以不用感知该工具的存在,只需要将自己的图片添加入项目即可。
  • 对优化过的图片不进行二次优化。无论在删除配置文件、移动文件或修改文件名,都不进行二次优化。

实现原理

针对上面几个目标,都是可以实现的:

  • 使用配置文件来保存多个token,当识别到前个token无效时,使用下一个token继续执行上一步动作;
  • 由于我们当前是使用jenkins来构建测试和渠道包,所以可以写脚本工具,然后在jenkins中嵌入到测试包的构建脚本中;
  • 针对图片是否优化过的判断,其实最好的方式是将优化过的图片打上标记,这样只要不替换这个图片,无论怎么修改文件名、移动文件或删除配置,都不会进行二次优化。

以上针对三种情况的解决方式,难的地方在于怎么为图片打上标记。事实上,图片本身会携带出图片显示内容之外的信息,如图片作者、拍摄位置的经纬度、时间等等,我们完全可以把优化标记,放在这些字段上面。

JPEG图片标记实现
JPEG文件格式


由上图可知,一个JPEG图片文件,由多个段落组成,且每个段落的起始标示为两个字节的0xFF加两个字节的段落类型标识。

JPEG标记实现

由于当时使用Python来进行实现该脚本,且Python有对应的类库来进行读写JPEG的APPn段落的exif信息,所以,此处直接将图片的优化标记放置在了图片元数据的copyright字段。实现代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
class JPGMarker(Marker):

def __init__(self, filename, marker):
self._fileName = filename
self._marker = marker

@property
def file_name(self):
return self._fileName

@property
def marker(self):
return self._marker

def mark(self):
"""
open image and add mark to it
:return: True if success, False if fail
"""
exif_dict = piexif.load(self._fileName)
if exif_dict is not 0 and '0th' in exif_dict:
exif_dict['0th'][piexif.ImageIFD.Copyright] = self._marker
exif_bytes = piexif.dump(exif_dict)
piexif.insert(exif_bytes, self._fileName)
return True
return False

处理后的图片,可以直接看到图片元数据中的copyright变为了图片优化后的标志:

PNG图片标记实现
PNG文件格式


一个PNG图片文件,由文件头89 50 4E 0D 0A 1A 0A + 多个数据块组成,每个数据块由如下4部分组成:

由于PNG中某些数据块可以存在多个,且其位置没有限制,因此,完全可以构造一个数据块然后插入到图片中,此处我选择的是tExt数据块。代码实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
class PNGMarker(Marker):

def __init__(self, filename, marker):
self._fileName = filename
self._marker = marker

@property
def file_name(self):
return self._fileName

@property
def marker(self):
return self._marker

def mark(self):
"""
open image and add mark to it
:return: True if success, False if fail
"""
with open(self._fileName, 'r+b') as f:
f.seek(-len(_png_end), os.SEEK_END)
end_sign = f.read()
f.seek(-len(_png_end), os.SEEK_END)
f.write(self._marker)
f.write(end_sign)
return True

其中对应数据块数据的生成:

1
2
3
4
5
6
7
8
9
10
11
class MarkCheckFactory(object):

@staticmethod
def _generate_png_mark(mark_sign):
content = bytearray(b'tEXt')
content.extend(mark_sign)
content_bytes = bytearray(struct.pack('>L', len(mark_sign)))
content_bytes.extend(content)
content_bytes.extend((binascii.crc32(content)).to_bytes(
4, byteorder='big'))
return content_bytes

结尾

最后脚本还实现了以线程池来针对目标目录下的所有子目录下文件进行遍历优化,具体代码可参考:ImageOptimize

参考文献

1. JPEG
2. PNG