likes
comments
collection
share

Material Icons 的使用及优化

作者站长头像
站长
· 阅读数 52

Material Icons 的使用及优化

Google 在发布 Android 12 时推出了 Material 3,同时提供了 Material IconsMaterial Fonts,供设计师和开发者来使用,而且提供了全平台支持(直接支持Android、iOS、Flutter、web,理论上桌面平台也是可以使用的)。只是国内设计师不太喜欢 Material 风格,导致大部分情况都是开发者在私人项目中使用。

按照 Google 的想法,适量图才是屏幕适配的最终方案,可以进行无限缩放又不失真,而且占用空间又小。这么一看它的优势很大,秒杀png,jpg等位图适配方案,下面简单介绍一下怎么使用。

只是简单介绍,重头戏是下面讲到的优化 字体分割

使用

访问这个地址 Material Icons 就可以打开 Material 图标库,可以浏览、搜索需要的素材。

Material Icons 的使用及优化

Google 对不同的平台提供了不同的使用方案,当然我们也可以到 资源仓库 下载单个svg源文件来使用。这里简单介绍一下每个文件夹对应的内容,下面会提到update内的python脚本。

Material Icons 的使用及优化

Android

Android Studio支持Vector Asset工具, Material Icons 的使用及优化

可以直接选择素材,然后将目标drawable.xml添加到项目中。 Material Icons 的使用及优化

而且这个工具还支持导入本地矢量图svg,然后转换为xml。

web 和 Flutter

Google 提供了各种版本的 npm依赖,很方便就可以接入到项目中,Flutter原生支持。 Material Icons 的使用及优化 原理就是 加载含有所有图标元数据的字体文件,然后根据图标字符在控件上显示

例如: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 是怎么生成或解析的,在仓库的的文档上有关键的一句: Material Icons 的使用及优化 说这个仓库的内容是根据上游源文件来生成的,并不是在这里直接维护的,所以我们要寻根溯源的话就要到上游仓库来查看,就是这个google/fonts,这个仓库维护了 Google Font 和 Material Icons 会比较繁杂,而且有字体相关的知识,这里可以不了解。

我们在 github.com/google/mate… 这个python文件中就可以查出端倪来: Material Icons 的使用及优化 注释写到 为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)

无图无真相

跟随系统深色模式
Material Icons 的使用及优化Material Icons 的使用及优化

可以看出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()

看图说话

Material Icons 的使用及优化

封装成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.

相关链接