图标素材准备:如何制作和准备托盘图标?

托盘图标这东西,看着小,其实门道不少。我刚开始做系统托盘程序时,随便找了个 16x16 的 PNG 就往上怼,结果在 Windows 上显示模糊,在 macOS 上直接不认。嗯,后来才搞明白——不同平台对图标的格式、尺寸、透明度都有各自的脾气。

今天咱们就聊聊,怎么用 Python 的 PIL 库(Pillow)动态生成托盘图标。说白了,就是让你不用依赖外部图片文件,代码里直接画一个出来。

为什么需要动态生成图标?

你想想看,如果你的程序需要根据状态切换图标——比如网络连接正常显示绿色,断开显示红色——难道要准备几十个图标文件?太蠢了。动态生成,一个函数搞定所有状态。

我在项目中遇到过需要实时显示 CPU 占用率的托盘图标。每次刷新都重新画一个带数字的小图标,用 PIL 几行代码就解决了。

PIL 基础:创建空白图标

先来个最简单的。创建一个 32x32 的图标,背景透明,画个蓝色圆点。

from PIL import Image, ImageDraw

# 创建一个 RGBA 模式的图像,背景透明
icon_size = (32, 32)
img = Image.new('RGBA', icon_size, (0, 0, 0, 0))

# 获取绘图对象
draw = ImageDraw.Draw(img)

# 画一个蓝色圆点
draw.ellipse([4, 4, 28, 28], fill=(0, 120, 255, 255))

# 保存为 PNG
img.save('tray_icon.png')

这里要注意:Image.new 的第一个参数必须是 'RGBA',否则不支持透明背景。第二个参数是尺寸,第三个是背景色——(0,0,0,0) 表示全透明黑色。

我的习惯:图标尺寸我一般用 32x32 或 64x64。Windows 托盘推荐 16x16 或 32x32,macOS 推荐 22x22。但为了清晰,我会生成 64x64 再让系统缩放,效果反而更好。

绘制复杂形状:状态指示器

实际项目中,图标往往需要表达状态。比如绿色代表正常,黄色代表警告,红色代表错误。

def create_status_icon(status):
    """status: 'ok', 'warn', 'error'"""
    colors = {
        'ok':   (0, 200, 83, 255),    # 绿色
        'warn': (255, 193, 7, 255),   # 黄色
        'error': (244, 67, 54, 255)   # 红色
    }
    
    size = 32
    img = Image.new('RGBA', (size, size), (0, 0, 0, 0))
    draw = ImageDraw.Draw(img)
    
    color = colors.get(status, colors['ok'])
    
    # 画一个圆角矩形(模拟 macOS 风格)
    draw.rounded_rectangle(
        [2, 2, size-2, size-2],
        radius=6,
        fill=color
    )
    
    # 在中间画一个白色小图标(比如对勾或感叹号)
    if status == 'ok':
        # 画一个简单的对勾
        draw.line([10, 16, 14, 20, 22, 12], fill=(255,255,255,255), width=3)
    elif status == 'warn':
        # 画感叹号
        draw.rectangle([14, 8, 18, 18], fill=(255,255,255,255))
        draw.ellipse([14, 20, 18, 24], fill=(255,255,255,255))
    elif status == 'error':
        # 画叉号
        draw.line([10, 10, 22, 22], fill=(255,255,255,255), width=3)
        draw.line([22, 10, 10, 22], fill=(255,255,255,255), width=3)
    
    return img

# 使用示例
ok_icon = create_status_icon('ok')
ok_icon.save('status_ok.png')

这段代码里,我用 rounded_rectangle 画圆角矩形,比直接画圆形更现代。对勾和叉号用简单的线条实现,不用加载字体。

我曾经踩过的坑:画线条时 width 参数在 Pillow 8.0 以上才支持。如果你用的旧版本,线条会特别细,看不清。建议升级到最新版:pip install --upgrade Pillow。

添加文字:动态数字图标

有时候需要在图标上显示数字,比如未读消息数。用 PIL 的 ImageFont 可以加载系统字体。

from PIL import ImageFont

def create_number_icon(number, max_number=99):
    """生成带数字的图标,数字超过 max_number 显示 '+'"""
    size = 32
    img = Image.new('RGBA', (size, size), (0, 0, 0, 0))
    draw = ImageDraw.Draw(img)
    
    # 画红色圆形背景
    draw.ellipse([0, 0, size-1, size-1], fill=(255, 59, 48, 255))
    
    # 准备文字
    text = str(number) if number <= max_number else f'{max_number}+'
    
    # 加载字体(这里用默认字体,实际建议指定路径)
    try:
        font = ImageFont.truetype('arial.ttf', 16)
    except:
        font = ImageFont.load_default()
    
    # 计算文字居中位置
    bbox = draw.textbbox((0, 0), text, font=font)
    text_width = bbox[2] - bbox[0]
    text_height = bbox[3] - bbox[1]
    x = (size - text_width) // 2
    y = (size - text_height) // 2 - 1  # 微调垂直居中
    
    # 绘制白色文字
    draw.text((x, y), text, fill=(255, 255, 255, 255), font=font)
    
    return img

# 生成示例
for num in [5, 23, 100]:
    icon = create_number_icon(num)
    icon.save(f'badge_{num}.png')

这里有个细节:textbbox 用来计算文字的实际尺寸,确保文字居中。不同字体、不同字号,bbox 返回的值不一样,所以每次都要算。

我个人的建议:字体文件最好用绝对路径,或者把字体文件放在项目目录里。因为不同系统默认字体不一样——Windows 有 arial.ttf,macOS 有 Helvetica,Linux 可能没有。我一般用 os.path.join 拼接路径,确保跨平台兼容。

多状态组合:复合图标

有时候一个图标需要表达多个信息。比如主图标 + 角标。像手机 App 那样,主图标是应用 logo,右上角有个小红点表示通知。

def create_composite_icon(main_color, badge_number):
    """主图标 + 数字角标"""
    size = 32
    img = Image.new('RGBA', (size, size), (0, 0, 0, 0))
    draw = ImageDraw.Draw(img)
    
    # 主图标:一个圆角矩形
    draw.rounded_rectangle(
        [0, 0, size-1, size-1],
        radius=8,
        fill=main_color
    )
    
    # 角标:在右上角画一个小红点
    badge_size = 10
    badge_x = size - badge_size - 2
    badge_y = 2
    draw.ellipse(
        [badge_x, badge_y, badge_x + badge_size, badge_y + badge_size],
        fill=(255, 59, 48, 255)
    )
    
    # 如果角标数字大于0,显示数字
    if badge_number > 0:
        try:
            font = ImageFont.truetype('arial.ttf', 8)
        except:
            font = ImageFont.load_default()
        
        text = str(badge_number) if badge_number < 10 else '9+'
        bbox = draw.textbbox((0, 0), text, font=font)
        tw = bbox[2] - bbox[0]
        th = bbox[3] - bbox[1]
        tx = badge_x + (badge_size - tw) // 2
        ty = badge_y + (badge_size - th) // 2
        draw.text((tx, ty), text, fill=(255,255,255,255), font=font)
    
    return img

# 生成示例
icon = create_composite_icon((0, 122, 255, 255), 3)
icon.save('composite_icon.png')

这段代码展示了如何在一个图标里叠加多个元素。主图标是蓝色圆角矩形,右上角是红色角标,角标里还有数字。实际项目中,你可以把主图标换成你的应用 logo。

图标格式与平台适配

不同平台对图标格式要求不同。我整理了一个表格,方便你对照:

平台 推荐格式 推荐尺寸 注意事项
Windows .ico 或 .png 16x16, 32x32, 48x48 .ico 可包含多尺寸;.png 需设置透明度
macOS .png 22x22, 44x44 (Retina) 必须带 alpha 通道;建议用 @2x 高清图
Linux .png 24x24, 32x32, 48x48 部分桌面环境支持 .svg
跨平台 (通用) .png 32x32 兼容性最好,推荐首选

如果你需要生成 .ico 文件,PIL 也支持:

# 将 PNG 转换为 ICO
img = Image.open('tray_icon.png')
img.save('tray_icon.ico', format='ICO', sizes=[(16,16), (32,32), (48,48)])
注意:PIL 保存 .ico 时,sizes 参数可以指定多个尺寸。但 Windows 对 16x16 和 32x32 支持最好,48x48 在某些旧系统上可能不显示。我一般只生成 16 和 32 两个尺寸。

完整示例:动态状态切换

最后,给你一个完整的实战例子。假设你的程序需要根据网络状态切换托盘图标:

import time
from PIL import Image, ImageDraw

class TrayIconGenerator:
    def __init__(self):
        self.size = 32
    
    def _create_base(self):
        img = Image.new('RGBA', (self.size, self.size), (0, 0, 0, 0))
        draw = ImageDraw.Draw(img)
        return img, draw
    
    def connected(self):
        """网络已连接:绿色圆点"""
        img, draw = self._create_base()
        draw.ellipse([4, 4, 28, 28], fill=(0, 200, 83, 255))
        # 中间画个白色对勾
        draw.line([11, 16, 14, 20, 21, 12], fill=(255,255,255,255), width=3)
        return img
    
    def disconnected(self):
        """网络断开:红色圆点"""
        img, draw = self._create_base()
        draw.ellipse([4, 4, 28, 28], fill=(244, 67, 54, 255))
        # 中间画白色叉号
        draw.line([11, 11, 21, 21], fill=(255,255,255,255), width=3)
        draw.line([21, 11, 11, 21], fill=(255,255,255,255), width=3)
        return img
    
    def loading(self):
        """加载中:黄色圆点"""
        img, draw = self._create_base()
        draw.ellipse([4, 4, 28, 28], fill=(255, 193, 7, 255))
        # 画三个小点表示加载
        for i, offset in enumerate([10, 15, 20]):
            draw.ellipse([offset, 14, offset+3, 17], fill=(255,255,255,255))
        return img

# 使用示例
gen = TrayIconGenerator()
gen.connected().save('status_connected.png')
gen.disconnected().save('status_disconnected.png')
gen.loading().save('status_loading.png')

这个类封装了三种状态。实际使用时,你只需要根据网络状态调用对应方法,返回的 Image 对象可以直接传给系统托盘库(比如 pystray 或 infi.systray)。

我的经验:动态生成图标时,建议把生成逻辑和显示逻辑分开。生成函数只返回 Image 对象,不负责设置托盘。这样方便单元测试,也方便后续替换图标风格。

好了,关于图标素材准备就聊这么多。说白了,PIL 就是你的画笔,想画什么画什么。下次你的程序需要根据状态换图标时,别再傻傻地准备几十个图片文件了——几行代码,动态生成,干净利落。