icon小图标在日常开发中很常见,也已经有icones.js.orgiconify.design等很成熟的网站。最近在体验vibe coding时,做的网页充斥着各种svg图标,忽然突发奇想能不能自己搞一个跨平台的呢,这样常见的icon图标不用在创建项目的时候拖来拖去,在线动态更新更灵活。

要点

  • 矢量图
  • 跨平台统一资源管理
  • 可在线动态更新

一、文件格式选择

矢量图格式

矢量图由数学公式描述,无限放大不失真;位图由像素点组成,放大后会产生马赛克(像素化)现象。如果icon用位图的话,不同的分辨率就需要对应不同的图片,而如果做一张大图缩放的话,会导致文件过大,下载加载的速度慢。所以矢量图格式是最适合这套图标库的。

SVG格式

SVG是首先看的格式,矢量无损、支持 CSS 修改颜色。web支持友好,通过雪碧图可以减少网络请求次数提升加载性能。安卓支持也很友好,但是iOS的支持太落后。系统原生不支持,开发需要依赖专门的解析库去显示,性能可能有限,所以作为备选方案。

SF Symbols

苹果自己推出的矢量图标库,是官方首推的矢量图标。支持多色渲染,在iOS系统完美契合系统风格。缺点不支持web和安卓,如果导出svg资源然后再用于web和安卓,会有侵权问题。因为苹果明确表示该资源是用于自家平台。

而如果单独为苹果维护一套Symbols,没有脚本工具的话,后续图标就需要维护两套。

PDF格式

在大规模使用 SF Symbols 之前,PDF 是苹果官方最推荐的矢量图标准。它和 SVG 的本质区别在于渲染管线的不同。iOS 系统内置了极度优化的 PDF 解析引擎。相比于 SVGKit 这种第三方库去递归解析 XML 字符串,系统读取 PDF 位流并转化为 CGPath 的速度要快得多。

优势就是iOS 系统对 PDF 有底层硬加速,且不需要第三方库。

Icon Font 自定义字体

跨平台能力极强,几乎可以覆盖所有现代数字终端。

  • Web (PC & Mobile):主流浏览器全支持。
  • iOS (Swift/Objective-C):通过 UIFont 加载,并在 UILabel 或 UIButton 中使用。
  • Android (Kotlin/Java):通过 Typeface 加载,并在 TextView 中使用。
  • 小程序 (WeChat/Alipay):直接支持 Base64 编码的字体引入。
  • 跨平台框架:Flutter 和 React Native 都内置了对 Icon Font 的深度支持,是这些框架默认的首选方案。

优点突出,但是以下短板导致不能采用该格式

  • 可能受浏览器“字体平滑”影响,偶尔发虚,字体加载失败显示“口口”
  • web支持woff,原生只支持ttf格式
  • 可读性差,使用的时候引用\u{e601}\e601单纯字义猜不出来内容
  • 不支持在线更新,iOS要求字体文件必须预先注册到系统中才能被UIFont调用

Lottie

Lottie 是一种由 Airbnb 开源的动画文件格式,它彻底改变了设计师和开发者在应用或网页中实现动画的方式。开发者只需使用 Lottie 库,就能在 iOS、Android、Web 以及桌面应用中直接渲染这些 JSON 文件。

矢量、跨平台、支持动画。

最终格式选择

调研了这么多文件之后,最初感觉最合适的是Lottie,支持打包并且跨平台。但是问题出现了,资源太少,制作麻烦。而svg批量转换的功能并不成熟。脚本不稳定,而官方支持太少。

所以为了维护一套简单的图标库,所以可全部采用svg格式进行维护,通过rsvg-convert脚本将一键批量的将svg批量转为pdf格式供iOS使用。

这样本质只修改维护svg图片资源即可,而iOS则可以通过pdf文件来动态加载。从而实现多端的矢量图引用。

二、格式转换

上一篇讲了文件格式的选型从零搭建一套跨平台的icon图标库(一)- 文件格式。文件选型的方案定为只维护一套svg格式文件,通过rsvg-convert脚本将一键批量的将svg批量转为pdf格式供iOS使用。

这个转换是一个很成熟的方案,比转换Lottie稳定的多。这也就是从管理侧直接管理。本篇文章记录一下如何批量转换。

安装转换环境

转换需要使用rsvg-convert命令,如果电脑没有安装,可以通过homebrew安装。

brew install librsvg

转换命令

单文件可以执行以下命令即可,更多自定义参数可以参考文档:rsvg-convert.rst

rsvg-convert --format=pdf --output pdf输出路径 svg文件路径

批量处理

可以写一个脚本进行批量处理,建立资源文件夹。在该文件夹创建svg2pdf.sh脚本文件、svg和pdf文件目录。这样方便脚本直接读取和输出结果。

svg2pdf.sh脚本内容

#!/bin/bash

# --- 配置区 ---
# 输入和输出根目录
INPUT_ROOT="svg"
OUTPUT_ROOT="pdf"

# --- 检查依赖 ---
if ! command -v rsvg-convert &> /dev/null; then
    echo "❌ 错误: 未安装 rsvg-convert。请运行: brew install librsvg"
    exit 1
fi

# --- 逻辑区 ---
echo "📂 开始递归处理所有子文件夹..."
echo "------------------------------------"

count=0

# 使用 find 寻找所有 .svg 文件,并处理包含空格的路径
find "$INPUT_ROOT" -name "*.svg" | while read -r svg_file; do
    
    # 1. 计算相对路径 (例如: svg/arrow/down.svg -> arrow/down.svg)
    # ${svg_file#$INPUT_ROOT/} 的作用是移除开头的输入目录路径
    relative_path=${svg_file#$INPUT_ROOT/}
    
    # 2. 获取纯文件名和子目录路径
    sub_dir=$(dirname "$relative_path")
    filename=$(basename "$relative_path" .svg)
    
    # 3. 准备输出目录 (例如: pdf/arrow)
    target_dir="$OUTPUT_ROOT/$sub_dir"
    mkdir -p "$target_dir"
    
    # 4. 准备输出文件路径
    output_path="$target_dir/$filename.pdf"
    
    # 5. 执行转换(保持透明矢量)
    rsvg-convert --format=pdf --output "$output_path" "$svg_file"
    
    if [ $? -eq 0 ]; then
        echo "✅ [Converted] $relative_path -> $output_path"
        ((count++))
    else
        echo "⚠️  [Failed] $svg_file"
    fi
done

echo "------------------------------------"
echo "🎉 深度转换完成!共处理 $count 个文件。"
echo "📁 目录结构已同步至: $OUTPUT_ROOT"

然后执行该svg2pdf.sh脚本命令即可批量转换。

./svg2pdf.sh

这样就可以只管理svg文件资源,pdf全部用脚本生成即可。

三、前后端适配

把图标文件搞定了,下一步就是前后端动态配置并使用啦,开始。

服务器配置

SVG 雪碧图(SVG Sprite)是前端开发中处理图标的常用方案。将几十个小图标合并为一个文件,大幅提升加载性能。但是一次性加载一个巨大的 SVG 文件会占用较多带宽,并增加浏览器的解析压力,而现在HTTP/2支持多路复用,可以在同一个 TCP 连接上并行传输多个小 SVG 文件,消除了 HTTP/1.1 的队头阻塞问题。所以直接使用文件列表即可,不需要再转为雪碧图。

这样的话,服务器索引就可采用list列举文件和分类,例如

"list": [
      {
        "svg": "/v2/svg/action/",
        "hash": "c75cd8447ff0076812f5b220eb251092",
        "name": "action",
        "pdf": "/v2/pdf/action/",
        "version": "2.1.0"
      },
      {
        "svg": "/v2/svg/arrow/",
        "hash": "a1387f653e6451b60c6246c3b091237f",
        "name": "arrow",
        "pdf": "/v2/pdf/arrow/",
        "version": "2.0.0"
      }
]

版本号version可用于指定版本,在图标引用时带上版本也更方便更新本地缓存,hash用于文件验证,防止数据伪造,pdfsvg则用于不同文件类型索引。

前端配置

前端核心就是提前下载文件,定位文件路径,缓存、适配渲染显示。

web网页

web网页已经自带缓存,所以可以封装两个js功能去请求索引和在使用的时候方便的读取文件。

1、提前加载资源索引

Assets.fetchIconManifest()

2、定位读取svg文件赋值

let svg = Assets.svg("editor", "hash");
document.getElementById("icon").src = svg;

如果用的是前端框架开发,例如vue框架,可以更方便的封装成自定义image组件,例如

<template>
    <div v-if="imgUrl" class="cf-image-container" :style="{
    width: size,
    height: size,
    backgroundColor: color,
    WebkitMask: `url(${imgUrl}) no-repeat center / contain`,
    mask: `url(${imgUrl}) no-repeat center / contain`
  }"></div>
</template>

<script setup>
import { onMounted, ref } from 'vue';

const props = defineProps({
    category: { type: String, required: true },
    icon: { type: String, required: true },
    size: { type: String, default: '32px' },
    color: { type: String, default: '#666666' }
});

const imgUrl = ref('');

const updateUrl = () => {
    if (window.Assets) {
        window.Assets.getSVG(props.category, props.icon).then(url => {
            if (url) {
                imgUrl.value = url;
            }
        })
    }
};

onMounted(() => {
    updateUrl();
});

const handleError = (e) => {
    imgUrl.value = null;
};
</script>

<style scoped>
.cf-image-container {
    display: inline-block;
    vertical-align: middle;
}
</style>

这样在使用的时候直接指定目录和文件名即可。这里使用div标签设置mask,而不是用image标签,是因为可以通过css的mask直接修改图标颜色,而通过image标签引用svg链接的话,修改图标颜色会比较困难。

iOS平台

iOS的网络图片加载使用Kingfisher,可以避免很多额外的麻烦。如果使用图片格式,直接使用链接即可,第一篇文件格式中对比过格式,为了采用矢量图更方便,所以采用pdf文件格式。

但是kingfisher并不是原生支持pdf的渲染和缓存。所以需要自定义ImageProcessor解码和CacheSerializer缓存逻辑。

解码

解码是解码成需要尺寸的位图,然后画出来。这样可通过手动设置size达到更清晰的显示效果

//PDF序列化
struct DDUtilsPDFProcessor: ImageProcessor {
    public var identifier: String {
        if let size = targetSize {
            return "ddutils.identifier.PDFProcessor(size:\(size.width)x\(size.height))"
        }
        return "ddutils.identifier.PDFProcessor.original"
    }
    private let targetSize: CGSize?
    public init(targetSize: CGSize? = nil) {
        self.targetSize = targetSize
    }
    
    func process(item: ImageProcessItem, options: KingfisherParsedOptionsInfo) -> KFCrossPlatformImage? {
        switch item {
        case .image(let image):
            return image.withRenderingMode(.alwaysTemplate)
        case .data(let data):
            guard
                let provider: CGDataProvider = CGDataProvider(data: data as CFData),
                let pdfDoc: CGPDFDocument = CGPDFDocument(provider),
                let pdfPage: CGPDFPage = pdfDoc.page(at: 1)
            else { return nil }
            
            //原始尺寸
            var pageRect = pdfPage.getBoxRect(.artBox)
            if pageRect.isEmpty {
                pageRect = pdfPage.getBoxRect(.cropBox)
            }
            if pageRect.isEmpty {
                pageRect = pdfPage.getBoxRect(.mediaBox)
            }
            //目标尺寸
            let drawSize = targetSize ?? pageRect.size
            //矢量渲染
            let format = UIGraphicsImageRendererFormat()
            format.opaque = false
            format.scale = UIScreen.main.scale // 自动匹配屏幕像素倍数 (2x/3x)
            
            let renderer = UIGraphicsImageRenderer(size: drawSize, format: format)
            let img = renderer.image { ctx in
                let cgContext = ctx.cgContext
                cgContext.saveGState()
                // 3. 开启高质量渲染选项
                cgContext.interpolationQuality = .high
                cgContext.setAllowsAntialiasing(true)
                cgContext.setShouldAntialias(true)
                //内容翻转
                cgContext.translateBy(x: 0.0, y: drawSize.height)
                cgContext.scaleBy(x: 1, y: -1)
                // 3. 计算缩放比例 (比如 100 / 9 = 11.11倍)
                let scaleX = drawSize.width / pageRect.width
                let scaleY = drawSize.height / pageRect.height
                cgContext.scaleBy(x: scaleX, y: scaleY)
                // 如果 PDF 的原点不是 (0,0),需要把原点移回来
                cgContext.translateBy(x: -pageRect.origin.x, y: -pageRect.origin.y)
                // 5. 绘制
                cgContext.drawPDFPage(pdfPage)
                ctx.cgContext.restoreGState()
            }
            return img.withRenderingMode(.alwaysTemplate)
        }
    }
}
缓存

缓存可以缓存pdf数据,也可以缓存image数据。直接缓存原始数据的好处是一个pdf链接的缓存数据只有一个,坏处就是由于不支持直接加载pdf数据,每次显示都需要将pdf转换一次,性能会下降。直接缓存转换后的image数据,会根据尺寸的不同缓存不同的副本,会增大占用空间,但是读取会直接读取图片数据,性能会更高。

因为矢量图本身文件大小就很小,所以推荐缓存image数据的方式,避免tableview之类的滚动时性能下降造成卡顿的问题。

struct PDFCacheSerializer: CacheSerializer {
    static let shared = PDFCacheSerializer()
    private init() {}
    
    // 从磁盘读取数据转成 Image 时触发
    func image(with data: Data, options: KingfisherParsedOptionsInfo) -> KFCrossPlatformImage? {
        let image = KFCrossPlatformImage(data: data, scale: options.scaleFactor)
        return image?.withRenderingMode(.alwaysTemplate)
    }
    
    // 将 Image 转成 Data 存入磁盘时触发
    func data(with image: KFCrossPlatformImage, original: Data?) -> Data? {
        return image.pngData()
    }
}

这样封装一个UIImageView的拓展函数

func setAssetsPDF(category: String, icon: String, size: CGSize? = nil) {
        if let url = self.assetsImageUrl(category: category, icon: icon) {
                DispatchQueue.main.async {
                    self.object.kf.setImage(with: URL(string: url), options: [.processor(DDUtilsPDFProcessor(targetSize: size)), .cacheSerializer(PDFCacheSerializer.shared), .keepCurrentImageWhileLoading])
                }
            }
}

这样使用时,可以直接用

let imageView = UIImageView(frame: CGRect(x: 100, y: 400, width: 50, height: 50))
imageView.tintColor = .red
imageView.setAssetsPDF(category: "editor", icon: "calendar", size: CGSize(width: 500, height: 500))

动态指定icon,并且可以通过tintColor手动指定颜色,只需要一张矢量图片即可适配高清的不同显示和颜色。

四、资源预览

上一篇说了图片资源的封装,可以直接在项目中集成使用啦。但是怎么找到想要的图片呢?所以搭建一个图片资源预览的网站是最快捷的选择。

思路就是

  1. 服务器封装分类目录接口
  2. 显示每个分类下的图片预览(SVG)
  3. 点击复制图片名或者路径

封装目录接口之后,可以修改该html页面的请求回调即可。该代码里不包含接口设计,所以直接打开是空白哦

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <link rel="icon" type="image/x-icon" href="/favicon.png">
    <title>SVG Preview</title>
    <style>
        * {
            margin: 0;
            padding: 0;
            box-sizing: border-box;
        }
        
        body {
            font-family: Arial, sans-serif;
            background-color: #f5f5f5;
            color: #333;
        }
        
        header {
            background-color: #333;
            color: white;
            padding: 1rem;
            text-align: center;
        }
        
        h1 {
            font-size: 1.5rem;
        }
        
        nav {
            background-color: #444;
            padding: 0.5rem;
            position: sticky;
            top: 0;
            z-index: 100;
        }
        
        .menu {
            display: flex;
            gap: 1rem;
            list-style: none;
            justify-content: center;
            flex-wrap: wrap;
        }
        
        .menu li {
            margin: 0;
        }
        
        .menu button {
            background-color: #555;
            color: white;
            border: none;
            padding: 0.5rem 1rem;
            border-radius: 4px;
            cursor: pointer;
            transition: background-color 0.3s;
        }
        
        .menu button:hover {
            background-color: #666;
        }
        
        .menu button.active {
            background-color: #007bff;
        }
        
        main {
            max-width: 1200px;
            margin: 2rem auto;
            padding: 0 1rem;
        }
        
        .svg-grid {
            display: grid;
            grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
            gap: 1.5rem;
            margin-top: 2rem;
        }
        
        .svg-item {
            background-color: white;
            border-radius: 8px;
            padding: 0.9rem 1rem 0.6rem;
            box-shadow: 0 2px 4px rgba(0,0,0,0.1);
            text-align: center;
            transition: transform 0.3s, box-shadow 0.3s;
        }
        
        .svg-item:hover {
            transform: translateY(-5px);
            box-shadow: 0 5px 15px rgba(0,0,0,0.1);
        }
        
        .svg-preview {
            width: 100%;
            height: 120px;
            display: flex;
            align-items: center;
            justify-content: center;
            margin-bottom: 0.5rem;
            border: 1px solid #ddd;
            border-radius: 4px;
            background-color: #f9f9f9;
        }
        
        .svg-preview img {
            width: 70px;
            height: 70px;
        }
        
        .svg-info {
            margin-bottom: 4px;
        }
        
        .svg-filename {
            font-size: 14px;
            font-weight: bold;
            margin-bottom: 14px;
            word-break: break-all;
        }
        
        .button-group {
            display: flex;
            gap: 0.5rem;
            justify-content: center;
        }
        
        .copy-btn {
            background-color: #f8f9fa;
            border: 1px solid #dee2e6;
            padding: 0.4rem 0;
            border-radius: 7px;
            cursor: pointer;
            font-size: 13px;
            transition: all 0.3s;
        }
        
        .copy-btn:hover {
            background-color: #e9ecef;
            border-color: #adb5bd;
        }
        
        .copy-btn:active {
            transform: translateY(1px);
        }
        
        .notification {
            position: fixed;
            top: 20px;
            right: 20px;
            background-color: #28a745;
            color: white;
            padding: 1rem;
            border-radius: 4px;
            box-shadow: 0 2px 10px rgba(0,0,0,0.2);
            opacity: 0;
            transform: translateY(-20px);
            transition: all 0.3s;
            z-index: 1000;
        }
        
        .notification.show {
            opacity: 1;
            transform: translateY(0);
        }
        
        @media (max-width: 768px) {
            .svg-grid {
                grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
            }
            
            .menu button {
                width: 100%;
                max-width: 200px;
            }
        }
    </style>
</head>
<body>
    <header>
        <h1>SVG Preview</h1>
    </header>
    
    <nav>
        <ul class="menu" id="categoryMenu"></ul>
    </nav>
    
    <main>
        <div class="svg-grid" id="svgGrid"></div>
    </main>
    
    <div class="notification" id="notification"></div>
    
    <script>
        // Store category data
        const categories = {};
        
        // Initialize page
        async function init() {
            try {
                // Get subdirectories under SVG directory
                const response = await fetch('/v2/svgFileList');
                const categoryNames = await response.json();
                
                // Store category data
                categoryNames.forEach(category => {
                    categories[category] = [];
                });
                
                // Create menu
                createMenu(categoryNames);
                
                // Load first category's SVGs
                if (categoryNames.length > 0) {
                    await loadCategory(categoryNames[0]);
                }
            } catch (error) {
                console.error('Initialization failed:', error);
            }
        }
        
        // Create category menu
        function createMenu(categoryNames) {
            const menu = document.getElementById('categoryMenu');
            menu.innerHTML = '';
            
            categoryNames.forEach((category, index) => {
                const li = document.createElement('li');
                const button = document.createElement('button');
                button.textContent = category;
                button.addEventListener('click', async () => {
                    // Update button status
                    document.querySelectorAll('.menu button').forEach(btn => btn.classList.remove('active'));
                    button.classList.add('active');
                    
                    // Load selected category's SVGs
                    await loadCategory(category);
                });
                li.appendChild(button);
                menu.appendChild(li);
                if (index == 0) {
                    button.classList.add('active');
                }
            });
        }
        
        // Load specified category's SVGs
        async function loadCategory(category) {
            try {
                const response = await fetch(`/v2/svgFileList?category=${category}`);
                const filenames = await response.json();
                                
                // Ensure filenames is an array
                if (!Array.isArray(filenames)) {
                    console.error('API returned non-array:', filenames);
                    return;
                }
                
                // Convert to object array with filename and path properties
                const svgFiles = filenames.map(filename => ({
                    filename,
                    path: `/v2/svg/${category}/${filename}.svg`
                }));
                
                // Store category data
                categories[category] = svgFiles;
                
                // Display SVG previews
                displaySVGs(svgFiles);
            } catch (error) {
                console.error('Category load error:', error);
            }
        }
        
        // Display SVG previews
        function displaySVGs(svgFiles) {
            const grid = document.getElementById('svgGrid');
            grid.innerHTML = '';
            
            svgFiles.forEach(svgFile => {
                // Ensure svgFile is an object with necessary properties
                if (typeof svgFile === 'object' && svgFile !== null) {
                    const svgItem = document.createElement('div');
                    svgItem.className = 'svg-item';
                    
                    // Create SVG preview
                    const svgPreview = document.createElement('div');
                    svgPreview.className = 'svg-preview';
                    
                    const svg = document.createElement('img');
                    // Ensure path property exists
                    svg.src = svgFile.path || '';
                    svg.loading = 'lazy';
                    svgPreview.appendChild(svg);
                    
                    // Create SVG info
                    const svgInfo = document.createElement('div');
                    svgInfo.className = 'svg-info';
                    
                    const filename = document.createElement('div');
                    filename.className = 'svg-filename';
                    // Ensure filename property exists
                    filename.textContent = svgFile.filename || 'Unknown filename';
                    
                    // Create button group
                    const buttonGroup = document.createElement('div');
                    buttonGroup.className = 'button-group';
                    
                    // Copy filename button
                    const copyNameBtn = document.createElement('button');
                    copyNameBtn.className = 'copy-btn';
                    copyNameBtn.textContent = 'Copy Name';
                    copyNameBtn.style.flex = '1';
                    // copyNameBtn.style.background = "#28a745"
                    // copyNameBtn.style.color = '#fff';
                    copyNameBtn.addEventListener('click', () => {
                        copyToClipboard(svgFile.filename || '');
                    });
                    
                    // Copy path button
                    const copyPathBtn = document.createElement('button');
                    copyPathBtn.className = 'copy-btn';
                    copyPathBtn.textContent = '🔗';
                    copyPathBtn.style.width = '40px';
                    copyPathBtn.addEventListener('click', () => {
                        copyToClipboard(svgFile.path || '');
                    });
                    
                    buttonGroup.appendChild(copyNameBtn);
                    buttonGroup.appendChild(copyPathBtn);
                    
                    svgInfo.appendChild(filename);
                    svgInfo.appendChild(buttonGroup);
                    
                    svgItem.appendChild(svgPreview);
                    svgItem.appendChild(svgInfo);
                    
                    grid.appendChild(svgItem);
                }
            });
        }
        
        // Copy to clipboard
        function copyToClipboard(text) {
            navigator.clipboard.writeText(text)
                .then(() => {
                    showNotification('Copied to clipboard');
                })
                .catch(err => {
                    console.error('Copy failed:', err);
                    showNotification('Copy failed');
                });
        }
        
        // Show notification
        function showNotification(message) {
            const notification = document.getElementById('notification');
            notification.textContent = message;
            notification.classList.add('show');
            
            setTimeout(() => {
                notification.classList.remove('show');
            }, 2000);
        }
        
        // Initialize page when DOM is loaded
        window.addEventListener('DOMContentLoaded', init);
    </script>
</body>
</html>
最后修改:2026 年 06 月 03 日
请我喝杯可乐,请随意打赏: ☞已打赏列表