Material Icons 的使用及优化
Material Icons 的使用及优化
Google 在发布 Android 12 时推出了 Material 3
,同时提供了 Material Icons
和 Material Fonts
,供设计师和开发者来使用,而且提供了全平台支持(直接支持Android、iOS、Flutter、web,理论上桌面平台也是可以使用的)。只是国内设计师不太喜欢 Material 风格,导致大部分情况都是开发者在私人项目中使用。
按照 Google 的想法,适量图才是屏幕适配的最终方案,可以进行无限缩放又不失真,而且占用空间又小。这么一看它的优势很大,秒杀png,jpg等位图适配方案,下面简单介绍一下怎么使用。
只是简单介绍,重头戏是下面讲到的优化 字体分割。
使用
访问这个地址 Material Icons 就可以打开 Material 图标库,可以浏览、搜索需要的素材。
Google 对不同的平台提供了不同的使用方案,当然我们也可以到 资源仓库 下载单个svg源文件来使用。这里简单介绍一下每个文件夹对应的内容,下面会提到update
内的python脚本。
Android
Android Studio支持Vector Asset工具,
可以直接选择素材,然后将目标drawable.xml添加到项目中。
而且这个工具还支持导入本地矢量图svg,然后转换为xml。
web 和 Flutter
Google 提供了各种版本的 npm依赖,很方便就可以接入到项目中,Flutter原生支持。
原理就是 加载含有所有图标元数据的字体文件,然后根据图标字符在控件上显示
例如:search
图标的 unicode 字符是 \ue8b6
,那我们在实际上显示的就是\ue8b6
这个字符的形状。
除了这种字体文件以外,web 也可以使用svg
iOS
iOS平台由于不是亲儿子,只提供了1x 2x 3x
的png图片,当然也可以用svg,这里不过多介绍了。
方案总结
可以看出除了Android平台以外,如果不做优化可能会对应用大小有一定影响,特别是使用 Material Symbols可变字体时,字体文件会成倍增长,从上面提到的 npm包 的大小就可以看出来
- MaterialIconsOutlined-Regular.otf -> 331 KB
- MaterialSymbolsOutlined[FILL,GRAD,opsz,wght].ttf -> 5.69 MB
不同的平台支持的方案不同,也总结一下优劣:
- Android的方案是每个图片作为矢量图资源单独导入到项目中;
- 优点:可以单独引入每一个图标,对APK大小影响较小
- 缺点:图标较多时不太容易管理,只能作为drawable来使用
- iOS 使用的是png位图;
- 优点:可以单独引入每一个图标,对APP大小影响较小
- 缺点:不支持矢量图,放大后容易失真,内存占用较大
- web、Flutter 都是将整个图标字体导入到项目中(暂时叫它
icon font
方案);- 优点:使用图标字体,有矢量图的优点,放大不失真
- 缺点:只能整体引入到项目,不能删减图标
我们的目标首先是要保证图标不会失真,所以 iOS 使用位图的方案肯定是行不通的,只剩 svg(xml)
方案和 icon font
方案了,又要使图片更容易集中管理,只剩下 icon font
方案了。
优化方案
既然 Web 可以通过加载图标字体来显示图标,那是不是可以对字体进行压缩,去掉不需要的字符信息,然后应用到其他平台上呢。理论存在那就试一试,下面在Android平台来讲一讲怎么优化的
字体分割
要想分割字体,先看看 Material Icons Font 是怎么生成或解析的,在仓库的的文档上有关键的一句:
说这个仓库的内容是根据上游源文件来生成的,并不是在这里直接维护的,所以我们要寻根溯源的话就要到上游仓库来查看,就是这个google/fonts,这个仓库维护了 Google Font 和 Material Icons 会比较繁杂,而且有字体相关的知识,这里可以不了解。
我们在
github.com/google/mate… 这个python文件中就可以查出端倪来:
注释写到 为Google-style的iconfonts来生成codepoint文件的工具,
codepoint
就是图标名和字符的映射表,而且里有相关的 fontTools
导包,说明这个就是解析 icon font
的。而且这个库在上游仓库也频频出现,说明它的功能不仅仅是解析字体文件的。
fontTools介绍
百度大法找到了他们的英文官网 fontTools
它的介绍:
fontTools is a family of libraries and utilities for manipulating fonts in Python.
我们要找的功能是字体分割功能,通过阅读文档找到了subset子工具,就支持字体分割功能,而且还支持和其他工具一起使用来深度压缩。
fontTools.subset使用
pyftsubset
是基于fontTools的OpenType字体分割和优化工具,支持OpenType (.otf 和 .ttf) 和 WOFF (.woff) 字体文件。是对 fontTools.subset API 的命令行封装。
OpenType字体,是由Microsoft和Adobe公司开发的字体格式,是一种轮廓字体(可以理解为类似矢量图,可以保证放大不失真)。
pyftsubset
就是我们想要的功能,介绍一下简单使用:
安装 fontTools
pip install fonttools
分割字体
pyftsubset MaterialIconsOutlined-Regular.otf \
--unicodes-file=unicodes.txt \
--output-file=icons.otf \
--verbose
MaterialIconsOutlined-Regular.otf
源字体文件unicodes.txt
需要保留的字符的unicode码icons.otf
输出文件
unicodes.txt
格式,# 代表注释
# brightness_auto
e1ab
# brightness_4
e3a9
# tips_and_updates
e79a
运行命令会输出 icons.otf
字体文件,而且分割后的字体只有1572字节(因为只有三个字符的数据🐶)。
Input font: 339168 bytes: /Users/***/AndroidEasterEggs/script/icons/MaterialIconsOutlined-Regular.otf
Subset font: 1572 bytes: /Users/***/AndroidEasterEggs/script/icons/icons.otf
测试分割后的字体
将 icons.otf
复制到 assets
下用于加载,这里使用深色模式切换 MaterialSwitch 开关来显示图标
- e1ab 是
brightness_auto
太阳图标 ☀ - e3a9 是
brightness_4
月亮图标 🌙
<com.google.android.material.materialswitch.MaterialSwitch
android:id="@+id/switch_night_mode"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textOff="\ue1ab"
android:textOn="\ue3a9"
app:showText="true"
app:switchTextAppearance="@style/TextAppearance.NightModeSwitch" />
由于显示的是文本,所以要调整一下字体颜色和大小,而且不能用sp,避免跟随系统字体大小缩放
<style name="TextAppearance.NightModeSwitch" parent="">
<item name="android:textColor">?attr/colorButtonNormal</item>
<item name="android:textSize" tools:ignore="SpUsage">16dp</item>
</style>
代码中将 Typeface
设置到 MaterialSwitch
上
val typecace = Typeface.createFromAsset(context.assets, "icons.otf")
switchNightMode.setSwitchTypeface(typecace)
无图无真相
跟随系统 | 深色模式 |
---|---|
![]() | ![]() |
可以看出fontTools
分割出来的字体文件是正确的。而且Android是可以使用这种方案来显示图标的,只需要调整字体大小就可以控制图标大小,调整字体颜色可以修改图标颜色。
下面来一点点想象力 封装一个 FontIconDrawable 来配合 ImageView
来使用
package com.dede.android_eggs.ui
import android.content.Context
import android.content.res.ColorStateList
import android.graphics.*
import android.graphics.Paint.FontMetrics
import android.graphics.drawable.Drawable
import android.os.Build
import android.text.TextPaint
import androidx.annotation.Dimension
import androidx.annotation.FloatRange
import androidx.core.graphics.component1
import androidx.core.graphics.component2
import androidx.core.graphics.component3
import androidx.core.graphics.component4
import com.dede.basic.dp // dp 转 px 扩展属性
import com.dede.basic.globalContext // 全局上下文
import com.google.android.material.color.MaterialColors
import kotlin.math.min
/**
* Material Icons.
*/
class FontIconsDrawable(
context: Context,
private val unicode: String,
@Dimension(unit = Dimension.DP) size: Float = -1f,
) : Drawable() {
companion object {
val ICONS_TYPEFACE: Typeface by lazy {
Typeface.createFromAsset(globalContext.assets, "icons.otf")
}
}
private val paint = TextPaint(Paint.ANTI_ALIAS_FLAG)
private val metrics = FontMetrics()
private val padding = Rect()
private val tempBounds = Rect()
private var dimension: Int = -1
private var colorStateList: ColorStateList? = null
private var degree: Float = 0f
init {
paint.typeface = ICONS_TYPEFACE
paint.textAlign = Paint.Align.CENTER
val color = MaterialColors.getColor(
context, com.google.android.material.R.attr.colorAccent, Color.WHITE
)
paint.color = color
if (size > 0) {
dimension = size.dp
setBounds(0, 0, dimension, dimension)
computeIconSize()
}
}
fun setRotate(@FloatRange(from = 0.0, to = 360.0) degree: Float) {
this.degree = degree % 360
invalidateSelf()
}
fun setColor(color: Int) {
if (color != paint.color) {
paint.color = color
invalidateSelf()
}
}
fun setColorStateList(colorStateList: ColorStateList?) {
if (colorStateList != this.colorStateList) {
this.colorStateList = colorStateList
invalidateSelf()
}
}
override fun onStateChange(state: IntArray): Boolean {
if (colorStateList != null) {
return true
}
return super.onStateChange(state)
}
override fun getIntrinsicHeight(): Int {
return if (dimension > 0) dimension else -1
}
override fun getIntrinsicWidth(): Int {
return if (dimension > 0) dimension else -1
}
override fun getPadding(padding: Rect): Boolean {
padding.set(this.padding)
return true
}
fun setPadding(@Dimension padding: Int) {
this.setPadding(padding, padding, padding, padding)
}
fun setPadding(
@Dimension left: Int,
@Dimension top: Int,
@Dimension right: Int,
@Dimension bottom: Int,
) {
if (this.padding.left == left && this.padding.top == top &&
this.padding.right == right && this.padding.bottom == bottom
) return
this.padding.set(left, top, right, bottom)
computeIconSize()
invalidateSelf()
}
fun setPadding(padding: Rect) {
setPadding(padding.left, padding.top, padding.right, padding.bottom)
}
// 计算图标大小所对应的字体大小
private fun computeIconSize() {
if (dimension > 0) {
tempBounds.set(0, 0, dimension, dimension)
} else {
tempBounds.set(bounds)
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
tempBounds.inset(padding.left, padding.top, padding.right, padding.bottom)
} else {
val (left, top, right, bottom) = tempBounds
tempBounds.set(
left + padding.left,
top + padding.top,
right - padding.right,
bottom - padding.bottom
)
}
val size = min(tempBounds.width(), tempBounds.height())
if (size <= 0) return
paint.textSize = size.toFloat()
paint.getFontMetrics(metrics)
}
override fun onBoundsChange(bounds: Rect) {
if (dimension > 0) {
return
}
computeIconSize()
}
override fun draw(canvas: Canvas) {
if (unicode.isEmpty()) return
val count = canvas.save()
val x = tempBounds.exactCenterX()
canvas.rotate(degree, x, tempBounds.exactCenterY())
val colorStateList = this.colorStateList
if (colorStateList != null) {
paint.color = colorStateList.getColorForState(state, colorStateList.defaultColor)
}
val y = (metrics.descent - metrics.ascent) / 2 - metrics.ascent / 2 + padding.top
canvas.drawText(unicode, x, y, paint)
canvas.restoreToCount(count)
}
override fun setAlpha(alpha: Int) {
paint.alpha = alpha
invalidateSelf()
}
override fun setColorFilter(colorFilter: ColorFilter?) {
paint.colorFilter = colorFilter
invalidateSelf()
}
override fun getOpacity(): Int {
return PixelFormat.TRANSLUCENT
}
}
使用 FontIconsDrawable
val icon = FontIconsDrawable(wrapperContext, Icons.TIPS_AND_UPDATES, 48f)
MaterialAlertDialogBuilder(wrapperContext)
.setIcon(icon)
.setTitle(android.R.string.dialog_alert_title)
.setMessage(R.string.message_trypophobia_warning)
.setPositiveButton(android.R.string.ok, null)
.setNegativeButton(android.R.string.cancel, null)
.show()
看图说话
封装成shell脚本
这样如果图标有更新,我们只需要更新 unicodes.txt
文件内容,然后运行python 命令就行了。简单封装一下,并将分割后的字体复制到 assets
目录下
subset_icons_font.sh
#!/usr/bin/env sh
ROOT=$(cd $(dirname "$0") || exit; pwd)
GIT_ROOT="$(git rev-parse --show-toplevel)"
TARGET_DIR="$GIT_ROOT/app/src/main/assets"
OUTPUT_FILE="$ROOT/icons.otf"
# Install requirements
pip3 install -t "$ROOT/Library" -r "$ROOT/requirements.txt"
# https://fonttools.readthedocs.io/en/latest/subset/index.html
# 添加了一些优化命令行参数并使用了zopfli进一步压缩字体文件
pyftsubset "$ROOT/MaterialIconsOutlined-Regular.otf" \
--unicodes-file="$ROOT/unicodes.txt" \
--output-file="$OUTPUT_FILE" \
--drop-tables=meta \
--ignore-missing-unicodes \
--desubroutinize \
--recalc-timestamp \
--with-zopfli \
--no-hinting \
--verbose
cp "$OUTPUT_FILE" "$TARGET_DIR"
exit 0
生成图标常量引用代码
这里先说一下我对android drawable管理的痛点,项目开发时经常会对功能进行修改或删减,每次这么删除图片资源时都比较麻烦,经常要在layout和代码之间来回检查,而且drawable又分了不同的dpi和类型,容易删不干净或误删。
我在想可不可以使用一个常量 class
来引用这些图标呢,有点像Flutter中的Icons
Icon(
Icons.search_outlined,
),
我们可以根据 unicodes.txt
文件来生成这个Icons代码,按行解析就可以生成
# brightness_auto
e1ab
# brightness_4
e3a9
# tips_and_updates
e79a
这里使用python来实现解析逻辑:generate_icons_kt.py
#!/usr/bin/python
# -*- coding: UTF-8 -*-
import sys
class_format = """package com.dede.android_eggs.ui
/** Generated automatically via **generate_icons_kt.py**, do not modify this file. */
object Icons {
%s
}
"""
property_format = """
/** %s */
const val %s = "\\u%s"
"""
with open("unicodes.txt", "r", encoding='utf-8') as f:
lines = f.readlines()
""" sample unicodes.txt
# android
e859
"""
icons = []
name = None
value = None
for line in lines:
line = line.strip()
if len(line) == 0:
continue
if (line.startswith("#")):
name = line.replace("#", "").strip()
elif (name != None):
value = line
icons.append({"name": name, "value": value})
print("parser: %s=%s" % (name, value))
else:
print("unknown line: %s" % line)
icons = sorted(icons, key=lambda icon: icon["name"])
icons = map(lambda icon: property_format %
(icon["name"], icon["name"].upper(), icon["value"].upper()), icons)
_class = class_format % (''.join(icons))
with open("Icons.kt", "w", encoding='utf-8') as f:
f.write(_class)
运行一下看下结果
python3 ./generate_icons_kt.py
生成的 Icons.kt
package com.dede.android_eggs.ui
/** Generated automatically via **generate_icons_kt.py**, do not modify this file. */
object Icons {
/** brightness_4 */
const val BRIGHTNESS_4 = "\uE3A9"
/** brightness_auto */
const val BRIGHTNESS_AUTO = "\uE1AB"
/** tips_and_updates */
const val TIPS_AND_UPDATES = "\uE79A"
}
将 generate_icons_kt.py
添加到上面的 subset_icons_font.sh
脚本后面
# ... ...
cp "$OUTPUT_FILE" "$TARGET_DIR"
python3 "$ROOT/generate_icons_kt.py"
cp "$ROOT/Icons.kt" "$GIT_ROOT/app/src/main/java/com/dede/android_eggs/ui/"
exit 0
这样运行 subset_icons_font.sh
就可以生成分割后的 icons.otf
和对应的 Icons.kt
文件,并且复制到指定目录,代码中就可以快速引用
val icon = FontIconsDrawable(context, Icons.TIPS_AND_UPDATES, 48f).apply {
setColor(Color.WHITE)
setRotate(180f)
}
是不是很简单🐶
其他
可变字体
这里是以静态字体作为Demo来讲的,如果想使用可变字体,原理相同,只需要把字体文件换一下即可,不过Android 10以后才支持可变字体,可以参数化调整字体的权重、倾斜等额外信息。
例如:MaterialSymbolsOutlined[FILL,GRAD,opsz,wght].ttf,支持[FILL, GRAD, opsz, wght]
四种参数(注意这只是Material可变字体特有的参数,并不是所有可变字体都支持)。各个参数的含义介绍 Material Symbols
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val typeface = Typeface.Builder(context.assets, "icons.ttf")
.setFontVariationSettings("'FILL' 0, 'GRAD' 0, 'opsz' 48, 'wght' 400")// 拼接字体参数
.build()
}
如果想应用到其他平台原理也一样,比如iOS,只需要编写对应的自定义字体显示代码即可。
另外这个方案有一定的弊端,例如:
- 一些情况需要硬编码字符在布局中
- 无法预览,只能通过
unicodes.txt
映射关系来查询图标
但是字体分割方案是可以用于分割普通字体文件的,例如:
- 只保留多语言字体中部分字符的情况,如只保留英文字符
- COLRv1 Emoji 字体分割
- and so on.
相关链接
- AndroidEasterEggs 演示代码仓库,命令行脚本位于
./script/icons
下 - Material Icons / Material Symbols 图标浏览和下载
- Material Design Icons git仓库
- Google Fonts Material Design Icons的上游仓库
- fontTools 文档
- fontTools.subset 文档
- OpenType 字体文件结构
- OpenType Overview
转载自:https://juejin.cn/post/7200962751400378423