SVG矢量图标管理:鸿蒙矢量图引入、统一图标库封装
图标是UI里最高频的资源。一个中型项目,各种图标加起来几十上百个:导航栏、列表项、操作按钮、状态提示。很多项目一开始直接往media目录里丢PNG,用的时候$r(‘app.media.icon_xxx’)引用。图标一多,问题就来了:同一个"返回"箭头在三个目录里有三个版本,颜色还不一样;想统一改成主题色,得重新导出几十张图;换肤功能根本做不了。
SVG矢量图标能解决这些问题:体积小、任意缩放不失真、颜色可以通过代码改。本文讲清楚鸿蒙里SVG怎么引入、怎么统一封装成图标库,代码可以直接复制到项目里用。
01 为什么用SVG而不是PNG
先说清楚为什么值得花时间做SVG图标库。
**体积小。**一个简单图标的SVG文件通常只有几百字节,PNG为了适配多倍屏,要准备1x、2x、3x三套,加起来几十KB。图标多了,包体积差距很明显。
**任意缩放不失真。**PNG放大就糊,需要准备多套尺寸。SVG是矢量,定义的是路径,80的图标和8的图标一样清晰。折叠屏、平板大屏适配的时候,不用再补图。
**颜色可以动态改。**单色SVG可以通过Image的fillColor属性改填充色,同一个图标能适配不同主题、不同状态(正常、选中、禁用),不用准备多份。
**好维护。**设计改了个描边粗细,重新导出一份SVG替换就行,不用批量重新切图。
代价是SVG渲染比PNG稍微多一点性能开销,但图标这种小尺寸图形完全感知不到。列表里几百个图标同时滚动这种极端场景,才需要考虑优化。
02 SVG引入方式
鸿蒙里引入SVG很简单,把文件放到resources/base/media目录下,用$r引用就行。系统会自动识别SVG格式。
entry/src/main/resources/
└── base/
└── media/
├── ic_back.svg
├── ic_search.svg
├── ic_setting.svg
└── ic_user.svg
页面里直接用Image组件展示:
@Entry
@Component
struct IconDemo {
build() {
Column({ space: 20 }) {
// 直接引用资源
Image($r('app.media.ic_back'))
.width(24)
.height(24)
// 指定颜色(仅单色SVG有效)
Image($r('app.media.ic_search'))
.width(24)
.height(24)
.fillColor('#0066FF')
// 用objectFit保持比例
Image($r('app.media.ic_setting'))
.width(32)
.height(32)
.objectFit(ImageFit.Contain)
}
.padding(20)
}
}
fillColor是SVG图标最重要的能力。它会把SVG里所有填充替换成指定颜色,实现"一个图标文件,多种状态色"。但有前提:SVG必须是单色的。如果图标有多个颜色、有渐变、有阴影,fillColor不会生效,或者只替换部分。
所以做图标库的时候,规范要定死:所有图标统一用单色,靠fillColor改色。多色图标(比如品牌Logo)单独放,不走这套体系。
03 SVG文件规范
从设计那边拿到的SVG,不能直接用,要先检查几个东西,不然会出现各种奇怪问题。
**第一,viewBox要规整。**理想的SVG,viewBox是"0 0 24 24"或"0 0 32 32"这种正方形。如果是"0 0 24 18",图标本身就不是正方形,放到正方形容器里会留白或者被拉伸。导出的时候让设计统一按24x24的画板切。
**第二,去掉多余属性。**设计工具导出的SVG经常带一堆无用信息:命名空间、编辑器注释、隐藏图层。这些不影响渲染,但会让文件变大,还可能带上设计稿的绝对坐标导致偏移。
<svg width="24" height="24" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M12 4l-8 8h5v8h6v-8h5z" fill="#000000"/>
</svg>
保留width、height、viewBox、path就够了。
**第三,区分fill和stroke。**有的图标是填充图形(fill),有的是线条描边(stroke)。fillColor只改fill,不改stroke。如果图标是描边的,fillColor可能不起作用,需要改成fill形式,或者在SVG里把stroke色也统一。
**第四,不要动画和滤镜。**鸿蒙的SVG渲染对动画、滤镜、渐变支持有限。SMIL动画、复杂的filter效果在这些场景下不会生效。图标就做静态的、单色的,效果最稳。
04 统一图标库封装
直接在每个页面写$r(‘app.media.ic_xxx’)有几个问题:资源名容易写错、尺寸和颜色每个页面自己定、图标改了名要全项目搜替换。封装一个统一的Icon组件,这些问题都能解决。
思路是:把所有图标资源集中在一个枚举里,页面只传图标名,尺寸、颜色由组件统一处理。
// common/components/icon/IconName.ets
export enum IconName {
BACK = 'ic_back',
SEARCH = 'ic_search',
SETTING = 'ic_setting',
USER = 'ic_user',
HOME = 'ic_home',
CART = 'ic_cart',
CLOSE = 'ic_close',
ARROW_RIGHT = 'ic_arrow_right',
SUCCESS = 'ic_success',
ERROR = 'ic_error'
}
枚举值就是资源文件名。加新图标的时候,往media里放文件,枚举里加一行,其他地方就能用了。
然后是封装Icon组件:
// common/components/icon/AppIcon.ets
import { IconName } from './IconName';
@Component
export struct AppIcon {
// 图标名,必传
name: IconName = IconName.BACK;
// 尺寸,默认24
size: number = 24;
// 颜色,默认主题色
color: string = '#333333';
// 是否可点击
onClick?: () => void;
build() {
Image($r(`app.media.${this.name}`))
.width(this.size)
.height(this.size)
.fillColor(this.color)
.objectFit(ImageFit.Contain)
.onClick(() => {
if (this.onClick) {
this.onClick();
}
})
}
}
注意这里$r用的是模板字符串拼接,资源名从枚举里来。这样页面写的时候不可能拼错,枚举有代码提示,改名字也是全局改。
页面里用起来就是一行:
import { AppIcon, IconName } from '../common/components/icon';
@Entry
@Component
struct HomePage {
build() {
Column({ space: 16 }) {
// 默认24大小,默认颜色
AppIcon({ name: IconName.SEARCH })
// 自定义尺寸和颜色
AppIcon({ name: IconName.SETTING, size: 28, color: '#0066FF' })
// 带点击事件
AppIcon({
name: IconName.BACK,
size: 24,
color: '#333333',
onClick: () => {
console.info('返回');
}
})
}
.padding(20)
}
}
这样一个组件覆盖了90%的图标使用场景。尺寸统一用数字传,不用每个页面写width、height两行。
05 主题色与状态管理
图标库真正有价值的地方,是能跟着主题和状态自动变。比如深色模式切换的时候,图标颜色要跟着从深变浅;Tab栏选中和未选中,图标颜色不一样。
可以把颜色做成统一的配置,不散落在各个页面。
// common/components/icon/IconColor.ets
export class IconColor {
// 主色
static readonly PRIMARY = '#0066FF';
// 正文色
static readonly TEXT = '#333333';
// 次要文字
static readonly SECONDARY = '#666666';
// 禁用态
static readonly DISABLED = '#CCCCCC';
// 反白
static readonly WHITE = '#FFFFFF';
}
再进一步,做深色模式适配。用一个状态管理当前主题,Icon组件自动读当前色值。
@Component
export struct AppIcon {
@Prop name: IconName = IconName.BACK;
@Prop size: number = 24;
// 不传颜色时,按主题自动取
@Prop color: string = '';
@StorageProp('isDarkMode') isDarkMode: boolean = false;
private getIconColor(): string {
if (this.color !== '') return this.color;
return this.isDarkMode ? '#FFFFFF' : '#333333';
}
build() {
Image($r(`app.media.${this.name}`))
.width(this.size)
.height(this.size)
.fillColor(this.getIconColor())
.objectFit(ImageFit.Contain)
}
}
页面里不传color的时候,图标自动跟随当前主题。这种设计让换肤、深色模式变成零成本——切一下全局状态,所有图标自动变色。
Tab栏这种选中态场景,也可以直接传不同颜色:
AppIcon({
name: IconName.HOME,
size: 24,
color: this.currentTab === 0 ? IconColor.PRIMARY : IconColor.SECONDARY
})
06 图标库目录组织
图标多了之后,文件命名和目录结构要定规则,不然很快就乱。
resources/base/media/
├── ic_back.svg
├── ic_search.svg
├── ic_setting.svg
├── ic_tab_home.svg
├── ic_tab_home_active.svg
└── ic_logo_brand.svg # 多色图标单独命名
common/components/icon/
├── IconName.ets # 图标枚举
├── IconColor.ets # 颜色常量
├── AppIcon.ets # 通用图标组件
└── Index.ets # 统一导出
命名规则统一用ic_模块_功能的格式:
-
ic_:前缀固定,一眼区分图标和其他图片资源
-
tab_home:模块名,Tab栏首页
-
active:状态后缀,表示选中态
不要用icon1、icon2这种名字,也不要中英文混着起。半年后自己都不知道ic_new_2是干嘛的。
Index.ets统一导出,外部只从一个入口进:
// common/components/icon/Index.ets
export { AppIcon } from './AppIcon';
export { IconName } from './IconName';
export { IconColor } from './IconColor';
// 外部使用:
// import { AppIcon, IconName, IconColor } from '../common/components/icon';
这样以后图标组件内部怎么拆分、怎么重构,外部调用代码都不用改。模块化项目里,这一层收口尤其重要。
落地注意事项
**图标尺寸用偶数。**20、24、28这种整数值,不要用23.5这种小数。小数会导致渲染时半像素偏移,边缘看起来发虚。
**一个图标一个文件。**不要把多个图标塞进一个SVG里。虽然技术上能做,但fillColor会一起改所有图形的颜色,没法单独控制。
**fillColor不生效先检查文件。**如果改了颜色没反应,先用文本编辑器打开SVG,看是fill还是stroke,看有没有多个颜色。这是90%的原因。
**多色图标单独处理。**品牌Logo这类必须保留原色的,不走AppIcon组件,直接Image引用,也不要设fillColor。
**不要给图标加阴影和圆角。**SVG本身不支持CSS式的圆角,圆角要在图形里画。复杂效果建议让设计重新导出,不要在前端用滤镜凑。
小结
SVG图标库的价值不在于"用了矢量图",而在于统一。统一资源命名、统一尺寸、统一颜色来源、统一调用方式。页面里不再出现零散的$r引用,而是AppIcon加IconName这种结构化的写法。
实际落地分三步:先把现有PNG图标转成单色SVG,统一定好24x24的画板;再做Icon枚举和AppIcon组件,把页面里的图标引用逐步替换;最后接上主题色配置,让图标跟着深色模式和选中态自动变。走完这三步,图标这块基本就不用再操心了。
更多推荐

所有评论(0)