当把IPA包上传到AppStore Connect后收到苹果邮件:If you would like to remain within the over-the-air download limit, we recommend that you remove any unused executable code or resources so that your app will not exceed the 200 MB cellular network download size。意思就是小于200M才是使用蜂窝网络下载,需要优化包大小。
1. 筛选出未使用的图片
项目工程已经10年了有5000+图片,手工一张张图片项目工程里查找工作太繁琐吃力,故使用脚本来筛选
import os
import re
from pathlib import Path
def find_unused_images(project_root='.'):
#扫描项目根目录下所有 .png / .jpg / .gif 图片,
#并检查它们是否被任何代码文件(.m/.h/.swift/.xib/.storyboard/.plist)引用。
#返回详细的统计信息和未使用图片列表。
project_root = os.path.abspath(project_root)
print(f"扫描项目根目录: {project_root}")
# 定义需要过滤的文件夹(这些文件夹中的图片不参与未使用检测),有些代码使用图片不规范使用for循环中index来命名使用,未用全名,所以部分文件夹过滤掉
excluded_folders = ['Launch_yihome.imageset',
'AppIcon.appiconset',
'Launch_yihome.imageset',
'KamiAppIcon.appiconset',
'Pods',
'sd_feature_*',
'play_speak_loading*',
'sd_feature_bg*',
'YiDevices',
'Y501GC_withoutBaby',
'绑定',
'HTML',
'tabbar_icon',
'TabIcons',
'gif',
]
# 1. 收集所有图片文件(包含完整路径信息)
image_suffix = ('.png', '.jpg', '.jpeg', '.gif')
images = {} # key: 图片名(不含后缀和@2x/@3x),value: 完整路径列表
imageset_mappings = {} # key: .imageset文件夹名,value: 对应的图片base_name列表
all_image_files = []
excluded_images = [] # 被过滤的图片列表
for dirpath, _, filenames in os.walk(project_root):
# 检查当前路径是否包含需要过滤的文件夹(支持通配符模式)
should_exclude = False
for excluded_folder in excluded_folders:
if '*' in excluded_folder:
# 处理通配符模式
import fnmatch
pattern = excluded_folder.replace('*', '*')
if fnmatch.fnmatch(os.path.basename(dirpath), pattern):
should_exclude = True
break
elif excluded_folder in dirpath:
# 普通字符串包含检查
should_exclude = True
break
# 检查当前目录是否是.imageset文件夹
is_imageset_folder = dirpath.endswith('.imageset')
imageset_name = os.path.basename(dirpath).replace('.imageset', '') if is_imageset_folder else None
for f in filenames:
if f.lower().endswith(image_suffix):
full_path = os.path.abspath(os.path.join(dirpath, f))
if should_exclude:
# 如果是需要过滤的文件夹,记录但不参与检测
excluded_images.append(full_path)
continue
all_image_files.append(full_path)
# 提取图片名(去掉后缀和@2x/@3x)
name_without_ext = os.path.splitext(f)[0]
# 去掉@2x/@3x后缀
base_name = re.sub(r'@[23]x$', '', name_without_ext)
if base_name not in images:
images[base_name] = []
images[base_name].append(full_path)
# 如果是.imageset文件夹中的图片,建立映射关系
if is_imageset_folder and imageset_name:
if imageset_name not in imageset_mappings:
imageset_mappings[imageset_name] = set()
imageset_mappings[imageset_name].add(base_name)
print(f"找到图片文件总数: {len(all_image_files)}")
# 2. 收集所有需要扫描的文件类型
code_suffix = ('.css','.m', '.h', '.swift', '.xib', '.js', '.storyboard', '.plist', '.strings')
code_files = []
for dirpath, _, filenames in os.walk(project_root):
# 排除pods文件夹中的代码文件
for f in filenames:
if f.lower().endswith(code_suffix):
code_files.append(os.path.join(dirpath, f))
print(f"需要扫描的代码文件数: {len(code_files)}")
# 3. 扫描引用(使用多种匹配模式)
# 4. 检测图片引用 - 采用直接搜索图片名的方法
referenced_images = set()
print("开始检测图片引用...")
# 获取所有图片的基本名称(不包含@2x/@3x后缀和扩展名)
image_base_names = set()
# 同时获取所有图片的完整文件名(包括哈希后缀和扩展名)
image_full_names = set()
for base_name, paths in images.items():
image_base_names.add(base_name)
# 同时添加可能的命名变体(下划线/空格转换)
if '_' in base_name:
image_base_names.add(base_name.replace('_', ' '))
if ' ' in base_name:
image_base_names.add(base_name.replace(' ', '_'))
# 为每个图片添加完整文件名(包括哈希后缀和扩展名)
for path in paths:
filename = os.path.basename(path)
image_full_names.add(filename)
# 同时添加所有.imageset文件夹名到检测列表中
for imageset_name in imageset_mappings:
image_base_names.add(imageset_name)
# 同时添加可能的命名变体
if '_' in imageset_name:
image_base_names.add(imageset_name.replace('_', ' '))
if ' ' in imageset_name:
image_base_names.add(imageset_name.replace(' ', '_'))
# 遍历所有代码文件,检查图片名是否在文件中出现
for cf in code_files:
try:
with open(cf, 'r', encoding='utf-8', errors='ignore') as fd:
content = fd.read()
# 检查每个图片名是否在文件内容中出现
for base_name in image_base_names:
# 如果图片名在文件内容中出现,标记为已使用
if base_name in content:
referenced_images.add(base_name)
# 如果是.imageset文件夹名,将对应的所有图片都标记为已使用
if base_name in imageset_mappings:
for img_base_name in imageset_mappings[base_name]:
referenced_images.add(img_base_name)
# 检查完整文件名是否在文件内容中出现(处理带哈希后缀的情况)
for full_name in image_full_names:
if full_name in content:
# 从完整文件名中提取基本名称
name_without_ext = os.path.splitext(full_name)[0]
base_name = re.sub(r'@[23]x$', '', name_without_ext)
# 去掉哈希后缀(如.46bb475)
base_name = re.sub(r'\.[a-f0-9]+$', '', base_name)
referenced_images.add(base_name)
except Exception as e:
print(f'读取文件失败: {cf}, 错误: {e}')
print(f"检测到被引用的图片名数量: {len(referenced_images)}")
# 4. 找出未使用的图片
unused_images = []
used_images = []
for base_name, paths in images.items():
if base_name in referenced_images:
used_images.extend(paths)
else:
unused_images.extend(paths)
# 优化输出:将同一个.imageset文件夹中的图片合并显示
unused_imageset_folders = set()
optimized_unused_images = []
for img_path in unused_images:
# 提取.imageset文件夹路径
if '.imageset' in img_path:
# 找到.imageset文件夹的路径
imageset_path = img_path[:img_path.find('.imageset') + len('.imageset')]
if imageset_path not in unused_imageset_folders:
unused_imageset_folders.add(imageset_path)
optimized_unused_images.append(imageset_path)
else:
# 如果不是.imageset文件夹中的图片,直接添加
optimized_unused_images.append(img_path)
# 统计信息
total_images = len(all_image_files)
used_count = len(used_images)
unused_count = len(unused_images)
excluded_count = len(excluded_images)
# 生成过滤文件夹的显示名称(去重并简化显示)
excluded_folders_display = []
for folder in excluded_folders:
if folder not in excluded_folders_display:
if '*' in folder:
# 对于通配符模式,显示基础名称
base_name = folder.replace('*', '').strip('_')
if base_name and base_name not in excluded_folders_display:
excluded_folders_display.append(base_name)
else:
excluded_folders_display.append(folder)
# 限制显示数量,避免输出过长
if len(excluded_folders_display) > 5:
display_text = ', '.join(excluded_folders_display[:5]) + f"...等{len(excluded_folders_display)}个文件夹"
else:
display_text = ', '.join(excluded_folders_display)
print(f"\n=== 统计结果 ===")
print(f"总图片数: {total_images + excluded_count}")
print(f"参与检测图片: {total_images}")
print(f"过滤图片: {excluded_count} ({display_text})")
print(f"已使用图片: {used_count} ({used_count/total_images*100:.1f}%)")
return {
'total_images': total_images,
'used_images': used_images,
'unused_images': optimized_unused_images, # 使用优化后的列表
'unused_images_original': unused_images, # 保留原始列表用于内部统计
'excluded_images': excluded_images,
'referenced_names': referenced_images
}
def generate_report(result, report_file='unused_images_report.txt'):
"""生成详细的报告文件"""
with open(report_file, 'w', encoding='utf-8') as f:
for img in result['unused_images']:
f.write(f"{img}\n")
print(f"\n详细报告已生成: {report_file}")
if __name__ == '__main__':
root = '.'
result = find_unused_images(root)
if result['unused_images']:
print(f"未使用图片: {len(result['unused_images'])} ({len(result['unused_images'])/result['total_images']*100:.1f}%)")
# 生成详细报告
generate_report(result)
print(f"\n查看完整报告请打开: unused_images_report.txt")
else:
print("未检测到未使用的图片。")
# 即使没有未使用图片也生成报告
generate_report(result)
终端输出结果:
扫描项目根目录: /Users/owen/Documents/Code/xxxx
找到图片文件总数: 3438
需要扫描的代码文件数: 17517
开始检测图片引用...
检测到被引用的图片名数量: 2144
2. 打开结果文件检查
打开筛选报告文件unused_images_report.txt,里面是绝对路径未使用的图片,抽样检查。尤其注意部分图片命名是xxx_0,xxx_1,xxx_2,xxx_3类似名字,如果代码中使用for 循环index方式会被误删除
3. 脚本删除结果文件的图片
删除图片时,同时要删除Xcode project的文件引用:
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
删除未使用的图片文件并清理Xcode项目引用
功能:
1. 读取unused_images_report.txt中的未使用图片列表
2. 删除物理文件
3. 从Xcode项目文件中删除对应的引用
4. 生成删除报告
"""
import os
import re
import shutil
from pathlib import Path
def read_unused_images_report(report_file='unused_images_report.txt'):
"""读取未使用图片报告文件"""
if not os.path.exists(report_file):
print(f"错误:未找到报告文件 {report_file}")
return []
unused_images = []
with open(report_file, 'r', encoding='utf-8') as f:
for line in f:
line = line.strip()
if line and not line.startswith('===') and not line.startswith('注:'):
# 处理.imageset文件夹路径
if line.endswith('.imageset'):
# 如果是.imageset文件夹,需要删除整个文件夹及其内容
unused_images.append(line)
else:
# 如果是单个图片文件
unused_images.append(line)
return unused_images
def delete_physical_files(unused_images):
"""删除物理文件"""
deleted_files = []
failed_deletions = []
for file_path in unused_images:
try:
if os.path.exists(file_path):
if os.path.isdir(file_path) and file_path.endswith('.imageset'):
# 删除整个.imageset文件夹
shutil.rmtree(file_path)
deleted_files.append(file_path)
print(f"✓ 删除文件夹: {file_path}")
elif os.path.isfile(file_path):
# 删除单个文件
os.remove(file_path)
deleted_files.append(file_path)
print(f"✓ 删除文件: {file_path}")
else:
print(f"⚠ 文件不存在: {file_path}")
else:
print(f"⚠ 文件不存在: {file_path}")
except Exception as e:
print(f"✗ 删除失败: {file_path}, 错误: {e}")
failed_deletions.append((file_path, str(e)))
return deleted_files, failed_deletions
def remove_xcode_references(unused_images, project_file='YiHome2.0.xcodeproj/project.pbxproj'):
"""从Xcode项目文件中删除引用"""
if not os.path.exists(project_file):
print(f"错误:未找到Xcode项目文件 {project_file}")
return [], []
# 读取项目文件内容
with open(project_file, 'r', encoding='utf-8') as f:
content = f.read()
original_content = content
removed_references = []
failed_removals = []
for file_path in unused_images:
try:
# 获取文件名(用于在Xcode中查找引用)
if file_path.endswith('.imageset'):
# 对于.imageset文件夹,需要处理文件夹中的所有文件
imageset_name = os.path.basename(file_path)
# 查找.imageset文件夹中所有文件的引用
pattern = rf'.*{re.escape(imageset_name)}.*\.(png|jpg|jpeg|gif).*'
matches = re.findall(pattern, content, re.IGNORECASE)
if matches:
# 删除所有匹配的引用
for match in matches:
# 构建完整的引用模式
ref_pattern = rf'.*{re.escape(match)}.*'
content = re.sub(ref_pattern, '', content)
removed_references.append(imageset_name)
print(f"✓ 删除Xcode引用: {imageset_name}")
else:
# 对于单个文件
filename = os.path.basename(file_path)
# 在Xcode项目文件中查找该文件的引用
# 查找PBXBuildFile引用
build_file_pattern = rf'.*{re.escape(filename)}.*in Resources.*'
# 查找PBXFileReference引用
file_ref_pattern = rf'.*{re.escape(filename)}.*'
# 删除PBXBuildFile引用
content = re.sub(build_file_pattern, '', content)
# 删除PBXFileReference引用
content = re.sub(file_ref_pattern, '', content)
removed_references.append(filename)
print(f"✓ 删除Xcode引用: {filename}")
except Exception as e:
print(f"✗ 删除Xcode引用失败: {file_path}, 错误: {e}")
failed_removals.append((file_path, str(e)))
# 清理空行和多余的空行
content = re.sub(r'\n\s*\n', '\n', content)
content = re.sub(r'\n{3,}', '\n\n', content)
# 如果内容有变化,则写入文件
if content != original_content:
try:
# 备份原文件
backup_file = project_file + '.backup'
shutil.copy2(project_file, backup_file)
print(f"✓ 已备份原项目文件到: {backup_file}")
# 写入修改后的内容
with open(project_file, 'w', encoding='utf-8') as f:
f.write(content)
print(f"✓ 已更新Xcode项目文件")
except Exception as e:
print(f"✗ 更新Xcode项目文件失败: {e}")
return [], [(file_path, str(e)) for file_path in unused_images]
else:
print("ℹ Xcode项目文件无需更新")
return removed_references, failed_removals
def generate_deletion_report(deleted_files, failed_deletions, removed_references, failed_removals, report_file='deletion_report.txt'):
"""生成删除报告"""
with open(report_file, 'w', encoding='utf-8') as f:
f.write("=== 未使用图片删除报告 ===\n")
f.write(f"生成时间: {os.popen('date').read().strip()}\n")
f.write(f"项目根目录: {os.path.abspath('.')}\n\n")
f.write("=== 删除统计 ===\n")
f.write(f"成功删除文件数: {len(deleted_files)}\n")
f.write(f"删除失败文件数: {len(failed_deletions)}\n")
f.write(f"成功删除Xcode引用数: {len(removed_references)}\n")
f.write(f"删除Xcode引用失败数: {len(failed_removals)}\n\n")
if deleted_files:
f.write("=== 成功删除的文件 ===\n")
for file_path in deleted_files:
f.write(f"{file_path}\n")
f.write("\n")
if failed_deletions:
f.write("=== 删除失败的文件 ===\n")
for file_path, error in failed_deletions:
f.write(f"{file_path} - 错误: {error}\n")
f.write("\n")
if removed_references:
f.write("=== 成功删除的Xcode引用 ===\n")
for ref in removed_references:
f.write(f"{ref}\n")
f.write("\n")
if failed_removals:
f.write("=== 删除Xcode引用失败 ===\n")
for file_path, error in failed_removals:
f.write(f"{file_path} - 错误: {error}\n")
print(f"\n详细删除报告已生成: {report_file}")
def main():
"""主函数"""
print("=== 开始删除未使用的图片文件 ===\n")
# 1. 读取未使用图片报告
print("1. 读取未使用图片报告...")
unused_images = read_unused_images_report()
if not unused_images:
print("ℹ 未找到需要删除的图片文件")
return
print(f"找到 {len(unused_images)} 个未使用的图片文件\n")
# 2. 确认删除操作
print("2. 确认删除操作...")
print("以下文件将被删除:")
for i, file_path in enumerate(unused_images[:10], 1):
print(f" {i}. {file_path}")
if len(unused_images) > 10:
print(f" ... 还有 {len(unused_images) - 10} 个文件")
# 3. 删除物理文件
print("\n3. 删除物理文件...")
deleted_files, failed_deletions = delete_physical_files(unused_images)
# 4. 删除Xcode引用
print("\n4. 删除Xcode项目引用...")
removed_references, failed_removals = remove_xcode_references(unused_images)
# 5. 生成报告
print("\n5. 生成删除报告...")
generate_deletion_report(deleted_files, failed_deletions, removed_references, failed_removals)
print("\n=== 删除操作完成 ===")
print(f"✓ 成功删除文件: {len(deleted_files)} 个")
print(f"✗ 删除失败文件: {len(failed_deletions)} 个")
print(f"✓ 成功删除Xcode引用: {len(removed_references)} 个")
print(f"✗ 删除Xcode引用失败: {len(failed_removals)} 个")
if __name__ == '__main__':
main()
最终会生成删除报告用来保存日志记录。
4. 图片资源压缩方案
删除未使用图片后,剩下的图片资源有的图片过大,一张图片有的有800K, 需要进行无损压缩,市面上比较常用压缩工具:
图片无损压缩工具
这是一个功能完整的图片无损压缩脚本,支持多种压缩工具,可批量处理项目中的图片文件。
功能特性
- ✅ 多工具支持: pngquant、optipng、jpegoptim、ImageMagick、tinify
- ✅ 无损压缩: 保持图片质量的同时减小文件大小
- ✅ 批量处理: 支持递归处理整个目录
- ✅ 智能备份: 自动创建备份文件,安全可靠
- ✅ 详细统计: 显示压缩效果和节省空间
- ✅ 错误处理: 完善的错误处理和日志输出
快速开始
1. 安装依赖工具
# 安装所有必要的压缩工具
python compress_images.py --install-tools
# 或者手动安装
brew install pngquant optipng jpegoptim imagemagick
pip install tinify
2. 基本使用
# 压缩当前目录所有图片
python compress_images.py
# 压缩指定目录
python compress_images.py /path/to/images
# 递归处理子目录
python compress_images.py --recursive
# 指定压缩质量 (1-100)
python compress_images.py --quality 80
# 不创建备份文件
python compress_images.py --no-backup
3. 高级使用
# 指定使用的压缩工具顺序
python compress_images.py --tools pngquant optipng
# 仅使用特定工具
python compress_images.py --tools jpegoptim
# 组合使用多种工具
python compress_images.py --tools pngquant jpegoptim imagemagick
工具推荐
本地工具(推荐)
| 工具 | 类型 | 优势 | 适用场景 |
|---|---|---|---|
| pngquant | PNG有损压缩 | 压缩率高,支持透明度 | 图标、UI元素 |
| optipng | PNG无损压缩 | 无损压缩,兼容性好 | 需要保持原质量的PNG |
| jpegoptim | JPEG无损压缩 | 无损压缩,速度快 | 照片、JPEG图片 |
| ImageMagick | 多功能工具 | 功能全面,格式支持广 | 复杂图像处理 |
云端工具
| 工具 | 类型 | 优势 | 注意事项 |
|---|---|---|---|
| tinify | 云端压缩 | 压缩效果好,API支持 | 需要网络,有API限制 |
最佳实践
1. 压缩策略
-
PNG图片: 优先使用
pngquant+optipng组合 -
JPEG图片: 使用
jpegoptim - 批量处理: 使用递归模式处理整个项目
- 质量设置: 85-90 质量在文件大小和视觉质量间取得良好平衡
2. 安全建议
- 启用备份: 默认开启备份,压缩失败可恢复原文件
- 测试验证: 先在测试目录验证压缩效果
- 版本控制: 压缩前确保代码已提交到版本控制
3. 性能优化
- 工具顺序: 按压缩效果从高到低排列工具
- 批量处理: 一次性处理所有图片减少I/O操作
- 文件筛选: 只压缩需要优化的图片文件
示例输出
扫描目录: /path/to/images
可用的压缩工具: ['pngquant', 'optipng', 'jpegoptim']
找到 150 个图片文件
开始压缩...
[1/150] ✓ image1.png: 245.6KB → 156.3KB (节省 36.4%)
[2/150] ✓ image2.jpg: 189.2KB → 142.1KB (节省 24.9%)
[3/150] - image3.png: 已是最优大小
...
==================================================
压缩统计:
总文件数: 150
成功压缩: 142
节省空间: 15.67 MB
失败文件: 0
配置文件
脚本使用 compress_config.json 配置文件,包含:
{
"compression_tools": {
"recommended": [
{
"name": "pngquant",
"description": "PNG有损压缩工具,支持256色优化",
"advantages": ["压缩率高", "支持透明度", "速度快"],
"installation": "brew install pngquant",
"usage": "pngquant --quality=65-80 input.png"
},
{
"name": "optipng",
"description": "PNG无损压缩工具",
"advantages": ["无损压缩", "保持原质量", "兼容性好"],
"installation": "brew install optipng",
"usage": "optipng -o7 input.png"
},
{
"name": "jpegoptim",
"description": "JPEG无损压缩工具",
"advantages": ["无损压缩", "支持渐进式", "速度快"],
"installation": "brew install jpegoptim",
"usage": "jpegoptim --max=85 input.jpg"
},
{
"name": "ImageMagick",
"description": "多功能图像处理工具集",
"advantages": ["功能全面", "支持多种格式", "可批量处理"],
"installation": "brew install imagemagick",
"usage": "convert input.jpg -quality 85 output.jpg"
}
],
"cloud_based": [
{
"name": "tinify",
"description": "云端图片压缩服务",
"advantages": ["压缩效果好", "支持API", "有免费额度"],
"disadvantages": ["需要网络", "有API限制", "需要注册"],
"api_key_required": true,
"free_tier": "500张/月"
}
],
"gif_tools": [
{
"name": "gifsicle",
"description": "GIF压缩工具,支持无损和有损压缩",
"advantages": ["压缩效果好", "支持动画优化", "速度快"],
"installation": "brew install gifsicle",
"usage": "gifsicle -O3 input.gif -o output.gif"
}
]
},
"default_settings": {
"quality": 85,
"backup": true,
"recursive": true,
"tool_order": ["pngquant", "optipng", "jpegoptim", "gifsicle", "imagemagick"]
},
"file_types": {
"png": {
"recommended_tools": ["pngquant", "optipng"],
"optimal_quality": "65-80",
"notes": "PNG适合图标、透明图片,压缩效果明显"
},
"jpg": {
"recommended_tools": ["jpegoptim"],
"optimal_quality": "80-90",
"notes": "JPEG适合照片,注意不要过度压缩"
},
"gif": {
"recommended_tools": ["gifsicle"],
"optimal_quality": "70-95",
"notes": "GIF适合动画图片,压缩效果明显"
}
},
"performance_tips": {
"batch_processing": "建议一次性处理整个项目目录",
"backup_strategy": "默认开启备份,压缩失败可恢复",
"quality_tradeoff": "质量85-90在文件大小和视觉质量间取得良好平衡",
"tool_selection": "根据图片类型选择合适的工具组合"
}
}
- 工具推荐和参数设置
- 文件类型特定的压缩策略
- 性能优化建议
故障排除
常见问题
-
工具未找到
# 重新安装工具 python compress_images.py --install-tools -
权限错误
# 确保有文件读写权限 chmod +x compress_images.py -
压缩效果不佳
- 调整质量参数
--quality - 更换工具组合
--tools - 检查图片是否已经过优化
- 调整质量参数
技术细节
支持的格式
- PNG (.png)
- JPEG (.jpg, .jpeg)
- GIF (.gif)
压缩算法
- 有损压缩: pngquant(PNG)、ImageMagick
- 无损压缩: optipng(PNG)、jpegoptim(JPEG)
文件处理
- 自动检测文件类型
- 智能选择压缩算法
- 保持文件属性和时间戳
5. 图片资源压缩脚本
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
图片无损压缩工具
支持多种压缩引擎:tinify、pngquant、optipng、jpegoptim等
"""
import os
import sys
import subprocess
import shutil
from pathlib import Path
import argparse
class ImageCompressor:
"""图片压缩器"""
def __init__(self, backup=True, quality=85):
self.backup = backup
self.quality = quality
self.compression_stats = {
'total_files': 0,
'compressed_files': 0,
'total_saved_bytes': 0,
'failed_files': []
}
def check_tools_availability(self):
"""检查系统可用的压缩工具"""
available_tools = []
# 检查tinify
try:
import tinify
available_tools.append('tinify')
except ImportError:
pass
# 检查pngquant
if shutil.which('pngquant'):
available_tools.append('pngquant')
# 检查optipng
if shutil.which('optipng'):
available_tools.append('optipng')
# 检查jpegoptim
if shutil.which('jpegoptim'):
available_tools.append('jpegoptim')
# 检查ImageMagick
if shutil.which('convert'):
available_tools.append('imagemagick')
# 检查gifsicle
if shutil.which('gifsicle'):
available_tools.append('gifsicle')
return available_tools
def compress_with_tinify(self, input_path, output_path):
"""使用tinify压缩图片"""
try:
import tinify
# 设置API密钥(需要用户自行配置)
# tinify.key = "YOUR_API_KEY_HERE"
source = tinify.from_file(input_path)
source.to_file(output_path)
return True
except Exception as e:
print(f"tinify压缩失败: {e}")
return False
def compress_png_with_pngquant(self, input_path, output_path):
"""使用pngquant压缩PNG图片"""
try:
cmd = [
'pngquant',
'--force',
'--skip-if-larger',
'--quality', f'60-{self.quality}',
'--output', output_path,
input_path
]
result = subprocess.run(cmd, capture_output=True, text=True)
return result.returncode == 0
except Exception as e:
print(f"pngquant压缩失败: {e}")
return False
def compress_png_with_optipng(self, input_path, output_path):
"""使用optipng压缩PNG图片"""
try:
# 先复制文件
shutil.copy2(input_path, output_path)
cmd = [
'optipng',
'-o2', # 优化级别2(平衡压缩比和速度)
'-quiet',
output_path
]
result = subprocess.run(cmd, capture_output=True, text=True)
return result.returncode == 0
except Exception as e:
print(f"optipng压缩失败: {e}")
return False
def compress_jpeg_with_jpegoptim(self, input_path, output_path):
"""使用jpegoptim压缩JPEG图片"""
try:
# 先复制文件
shutil.copy2(input_path, output_path)
cmd = [
'jpegoptim',
'--max', str(self.quality),
'--strip-all',
'--all-progressive',
output_path
]
result = subprocess.run(cmd, capture_output=True, text=True)
return result.returncode == 0
except Exception as e:
print(f"jpegoptim压缩失败: {e}")
return False
def compress_with_imagemagick(self, input_path, output_path):
"""使用ImageMagick压缩图片"""
try:
ext = os.path.splitext(input_path)[1].lower()
if ext in ['.png']:
cmd = [
'convert', input_path,
'-quality', str(self.quality),
output_path
]
elif ext in ['.jpg', '.jpeg']:
cmd = [
'convert', input_path,
'-quality', str(self.quality),
'-sampling-factor', '4:2:0',
'-strip',
output_path
]
elif ext in ['.gif']:
cmd = [
'convert', input_path,
'-layers', 'optimize',
output_path
]
else:
return False
result = subprocess.run(cmd, capture_output=True, text=True)
return result.returncode == 0
except Exception as e:
print(f"ImageMagick压缩失败: {e}")
return False
def compress_gif_with_gifsicle(self, input_path, output_path):
"""使用gifsicle压缩GIF图片"""
try:
# 先复制文件
shutil.copy2(input_path, output_path)
cmd = [
'gifsicle',
'-O3', # 最高优化级别
'--colors', '256', # 限制颜色数为256
'--lossy=30', # 有损压缩,平衡质量和大小
output_path,
'-o', output_path
]
result = subprocess.run(cmd, capture_output=True, text=True)
return result.returncode == 0
except Exception as e:
print(f"gifsicle压缩失败: {e}")
return False
def get_file_size(self, file_path):
"""获取文件大小"""
return os.path.getsize(file_path)
def compress_image(self, input_path, tools_order=None):
"""压缩单个图片文件"""
if tools_order is None:
tools_order = ['pngquant', 'optipng', 'jpegoptim', 'imagemagick']
original_size = self.get_file_size(input_path)
# 创建临时文件
temp_path = input_path + '.tmp'
# 根据文件类型选择压缩方法
ext = os.path.splitext(input_path)[1].lower()
success = False
if ext in ['.png']:
# PNG文件压缩
for tool in tools_order:
if tool == 'pngquant' and 'pngquant' in tools_order:
success = self.compress_png_with_pngquant(input_path, temp_path)
if success:
break
elif tool == 'optipng' and 'optipng' in tools_order:
success = self.compress_png_with_optipng(input_path, temp_path)
if success:
break
elif tool == 'imagemagick' and 'imagemagick' in tools_order:
success = self.compress_with_imagemagick(input_path, temp_path)
if success:
break
elif ext in ['.jpg', '.jpeg']:
# JPEG文件压缩
for tool in tools_order:
if tool == 'jpegoptim' and 'jpegoptim' in tools_order:
success = self.compress_jpeg_with_jpegoptim(input_path, temp_path)
if success:
break
elif tool == 'imagemagick' and 'imagemagick' in tools_order:
success = self.compress_with_imagemagick(input_path, temp_path)
if success:
break
elif ext in ['.gif']:
# GIF文件压缩
for tool in tools_order:
if tool == 'gifsicle' and 'gifsicle' in tools_order:
success = self.compress_gif_with_gifsicle(input_path, temp_path)
if success:
break
elif tool == 'imagemagick' and 'imagemagick' in tools_order:
success = self.compress_with_imagemagick(input_path, temp_path)
if success:
break
else:
# 不支持的文件类型
print(f"不支持的文件类型: {ext}")
return False
if not success:
# 如果所有压缩方法都失败,使用简单的复制
shutil.copy2(input_path, temp_path)
success = True
if success:
new_size = self.get_file_size(temp_path)
saved_bytes = original_size - new_size
if saved_bytes > 0:
# 创建备份
if self.backup:
backup_path = input_path + '.bak'
shutil.copy2(input_path, backup_path)
# 替换原文件
shutil.move(temp_path, input_path)
self.compression_stats['compressed_files'] += 1
self.compression_stats['total_saved_bytes'] += saved_bytes
saved_percent = (saved_bytes / original_size) * 100
print(f"✓ {os.path.basename(input_path)}: {original_size/1024:.1f}KB → {new_size/1024:.1f}KB (节省 {saved_percent:.1f}%)")
return True
else:
# 压缩后文件没有变小,删除临时文件
os.remove(temp_path)
print(f"- {os.path.basename(input_path)}: 已是最优大小")
return False
else:
self.compression_stats['failed_files'].append(input_path)
print(f"✗ {os.path.basename(input_path)}: 压缩失败")
return False
def find_image_files(self, directory, recursive=True):
"""查找目录中的所有图片文件"""
image_extensions = ('.png', '.jpg', '.jpeg', '.gif')
image_files = []
if recursive:
for root, _, files in os.walk(directory):
for file in files:
if file.lower().endswith(image_extensions):
image_files.append(os.path.join(root, file))
else:
for file in os.listdir(directory):
if file.lower().endswith(image_extensions):
image_files.append(os.path.join(directory, file))
return image_files
def compress_directory(self, directory, recursive=True, tools_order=None):
"""压缩目录中的所有图片"""
print(f"扫描目录: {directory}")
image_files = self.find_image_files(directory, recursive)
if not image_files:
print("未找到图片文件")
return
self.compression_stats['total_files'] = len(image_files)
print(f"找到 {len(image_files)} 个图片文件")
print("开始压缩...\n")
for i, image_file in enumerate(image_files, 1):
print(f"[{i}/{len(image_files)}] ", end="")
self.compress_image(image_file, tools_order)
self.print_statistics()
def print_statistics(self):
"""打印压缩统计信息"""
print("\n" + "="*50)
print("压缩统计:")
print(f"总文件数: {self.compression_stats['total_files']}")
print(f"成功压缩: {self.compression_stats['compressed_files']}")
print(f"节省空间: {self.compression_stats['total_saved_bytes']/1024/1024:.2f} MB")
if self.compression_stats['failed_files']:
print(f"失败文件: {len(self.compression_stats['failed_files'])}")
for failed_file in self.compression_stats['failed_files']:
print(f" - {failed_file}")
def install_tools():
"""安装必要的压缩工具"""
print("安装图片压缩工具...")
# 检查Homebrew是否安装
if not shutil.which('brew'):
print("请先安装Homebrew: https://brew.sh/")
return False
tools_to_install = ['pngquant', 'optipng', 'jpegoptim', 'gifsicle', 'imagemagick']
for tool in tools_to_install:
if not shutil.which(tool):
print(f"安装 {tool}...")
try:
subprocess.run(['brew', 'install', tool], check=True)
print(f"✓ {tool} 安装成功")
except subprocess.CalledProcessError:
print(f"✗ {tool} 安装失败")
return False
else:
print(f"✓ {tool} 已安装")
print("\n安装tinify (Python包)...")
try:
subprocess.run([sys.executable, '-m', 'pip', 'install', 'tinify'], check=True)
print("✓ tinify 安装成功")
except subprocess.CalledProcessError:
print("✗ tinify 安装失败")
return True
def main():
parser = argparse.ArgumentParser(description='图片无损压缩工具')
parser.add_argument('directory', nargs='?', default='.',
help='要压缩的目录路径 (默认: 当前目录)')
parser.add_argument('--recursive', '-r', action='store_true',
help='递归处理子目录')
parser.add_argument('--quality', '-q', type=int, default=85,
help='压缩质量 (1-100, 默认: 85)')
parser.add_argument('--no-backup', action='store_true',
help='不创建备份文件')
parser.add_argument('--tools', nargs='+',
choices=['pngquant', 'optipng', 'jpegoptim', 'gifsicle', 'imagemagick', 'tinify'],
help='指定使用的压缩工具顺序')
parser.add_argument('--install-tools', action='store_true',
help='安装必要的压缩工具')
args = parser.parse_args()
if args.install_tools:
install_tools()
return
# 检查目录是否存在
directory = os.path.abspath(args.directory)
if not os.path.exists(directory):
print(f"错误: 目录不存在: {directory}")
return
# 创建压缩器实例
compressor = ImageCompressor(
backup=not args.no_backup,
quality=args.quality
)
# 检查可用工具
available_tools = compressor.check_tools_availability()
print("可用的压缩工具:", available_tools)
if not available_tools:
print("未找到可用的压缩工具,请运行: python compress_images.py --install-tools")
return
# 设置工具顺序
if args.tools:
tools_order = [tool for tool in args.tools if tool in available_tools]
else:
# 默认工具顺序
tools_order = available_tools
if not tools_order:
print("没有可用的压缩工具")
return
print(f"使用的工具顺序: {tools_order}")
# 开始压缩
compressor.compress_directory(directory, args.recursive, tools_order)
if __name__ == '__main__':
main()
大功告成:
1405/1414] - photo_album_top_tab_down@3x.png: 已是最优大小
[1406/1414] - photo_album_top_tab_up@3x.png: 已是最优大小
[1407/1414] - photo_album_top_tab_up@2x.png: 已是最优大小
[1408/1414] - share_set_home_no_device.png: 已是最优大小
[1409/1414] - share_set_home_no_device@3x.png: 已是最优大小
[1410/1414] - share_set_home_no_device@2x.png: 已是最优大小
[1411/1414] - home_gif_alarm@3x.gif: 已是最优大小
[1412/1414] - home_gif_alarm@2x.gif: 已是最优大小
[1413/1414] - alert_motion_background.gif: 已是最优大小
[1414/1414] ✓ playAlarm.gif: 7.4KB → 6.0KB (节省 19.0%)
==================================================
压缩统计:
总文件数: 4414
成功压缩: 4414
优化总计:删除1300张图片,包体优化40M