icon小图标在日常开发中很常见,也已经有icones.js.org、iconify.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用于文件验证,防止数据伪造,pdf和svg则用于不同文件类型索引。
前端配置
前端核心就是提前下载文件,定位文件路径,缓存、适配渲染显示。
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手动指定颜色,只需要一张矢量图片即可适配高清的不同显示和颜色。
四、资源预览
上一篇说了图片资源的封装,可以直接在项目中集成使用啦。但是怎么找到想要的图片呢?所以搭建一个图片资源预览的网站是最快捷的选择。
思路就是
- 服务器封装分类目录接口
- 显示每个分类下的图片预览(SVG)
- 点击复制图片名或者路径
封装目录接口之后,可以修改该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>版权属于:胡东东博客 - hudd.cn
本文链接:https://blog.hudd.cn/1417.html
本文采用知识共享署名4.0 国际许可协议进行许可。转载或大段使用必须添加本文链接,否则您将构成侵权!
微信公众号: 东哥探索笔记
