政采云 Flutter 动态 iconfont 实践探索
前言
动态修改 iconfont?有可能吗?
目前越来越多的团队开始使用 Flutter 来开发 App ,在 Flutter 中我们可以像前端一样很方便地使用 iconfont 而不是图片来显示图标 (icon):将 iconfont 字体文件放到工程目录,在 Flutter 代码中使用文件里的 icon。可这种方式难以满足动态修改 icon 的需求,如果产品经理突然想要更换项目里的 icon (比如节日更换 icon) ,我们通常只能通过发版来解决,但是发版的步骤繁琐且对老版本无效。
接下来让我们一起来探索一套基于 Flutter 的 iconfont 动态加载方案。
iconfont 原理
iconfont 即“字体图标”,它是将图标做成字体文件,然后通过指定不同的字符而显示不同的 icon。在字体文件中,每一个字符都对应一个 Unicode 编码,而每一个 Unicode 码对应一个显示字形,不同的字体就是指字形不同,即字符对应的字形是不同的。而在 iconfont 中,只是将 Unicode 码对应的字形做成了图标,所以不同的字符最终就会渲染成不同的图标。
在 Flutter 开发中,iconfont 和图片相比有如下优势:
-
体积小:可以减小安装包大小。 -
矢量的:iconfont 都是矢量图标,放大不会影响其清晰度 -
可以应用文本样式:可以像文本一样改变字体图标的颜色、大小对齐等。
正是有了上述优势,所以我们会在 Flutter 项目中优先考虑使用 iconfont,而不是图片。
iconfont 动态化之前的使用方式
在我们现有的 Flutter 项目中,关于 iconfont 的使用,都是通过 [icontfont 官网](https://www.iconfont.cn)下载 ttf 字体文件至项目中的 assets 文件夹下,然后在 pubsepc.yaml 文件中配置来实现 ttf 字体文件的静态加载。
fonts:
- family: IconFont
fonts:
- asset: assets/fonts/iconfont.ttf
然后定义一个类(ZcyIcons)来管理 iconfont 文件中的所有 IconData:
“可以通过编写脚本自动生成这个类的代码,这样每次更新 iconfont 文件后只需要执行一下脚本即可生成最新的代码。
class _MyIcon {
static const font_name = 'iconfont';
static const package_name = 'flutter_common';
const _MyIcon(int codePoint) : super(codePoint, fontFamily: font_name, fontPackage: package_name,);
}
class ZcyIcons {
static const IconData tongzhi = const _MyIcon(0xe784);
static Map<String, IconData> _map = Map();
ZcyIcons._();
static IconData from(String name) {
if(_map.isEmpty) {
initialization();
}
return _map[name];
}
static void initialization() {
_map["tongzhi"] = tongzhi;
}
}
在使用的时候,有两种调用方式
/// 方法 1:直接加载
Icon(ZcyIcons.arrow)
/// 方法 2:通过 name 的值去取 map 中对应的 IconData
Icon(ZcyIcons.from(name))
虽然第二种方法能通过改变 key 的值来动态的从 map 中加载对应的 IconData ,但是仅局限于所有的 IconData 都已经在 map 中配置好且不再更改。
“既然 iconfont 是字体文件,那么如果系统能动态加载字体文件,那么一定也能用同样的方式去动态加载 iconfont。
iconfont 动态化方案
步骤 1: 加载远程下发的 ttf 文件
Flutter SDK 提供了 FontLoader 类来实现字体的动态加载。而我们解决这个问题的核心就是这个 FontLoader 类。
它有一个 addFont 方法,支持将 ByteData 格式数据转化为字体包并加载到应用字体资源库:
class FontLoader{
...
void addFont(Future<ByteData> bytes) {
if (_loaded)
throw StateError('FontLoader is already loaded');
_fontFutures.add(bytes.then(
(ByteData data) => Uint8List.view(data.buffer, data.offsetInBytes, data.lengthInBytes)
));
}
Future<void> load() async {
if (_loaded)
throw StateError('FontLoader is already loaded');
_loaded = true;
final Iterable<Future<void>> loadFutures = _fontFutures.map(
(Future<Uint8List> f) => f.then<void>(
(Uint8List list) => loadFont(list, family)
)
);
return Future.wait(loadFutures.toList());
}
}
/// 下载远端的字体文件
static Future<ByteData> httpFetchFontAndSaveToDevice(Uri fontUri) {
return () async {
http.Response response;
try {
response = await _httpClient.get(uri);
} catch (e) {
throw Exception('Failed to get font with url: ${fontUrl.path}');
}
if (response.statusCode == 200) {
return ByteData.view(response.bodyBytes.buffer);
} else {
/// 如果执行失败, 抛出异常.
throw Exception('Failed to download font with url: ${fontUrl.path}');
}
};
}
/// 加载字体,先从本地文件加载,如果不存在,则使用[loader]加载
static Future<void> loadFontIfNecessary(ByteData loader, String fontFamilyToLoad) async {
assert(fontFamilyToLoad != null && loader != null);
if (_loadedFonts.contains(fontFamilyToLoad)) {
return;
} else {
_loadedFonts.add(fontFamilyToLoad);
}
try {
Future<ByteData> byteData;
byteData = file_io.loadFontFromDeviceFileSystem(fontFamilyToLoad);
if (await byteData != null) {
return _loadFontByteData(fontFamilyToLoad, byteData);
}
byteData = loader();
if (await byteData != null) {
/// 通过 FontLoader 加载下载好的字体文件
final fontLoader = FontLoader(familyWithVariantString);
fontLoader.addFont(byteData);
await fontLoader.load();
successLoadedFonts.add(familyWithVariantString);
}
} catch (e) {
_loadedFonts.remove(fontFamilyToLoad);
print('Error: unable to load font $fontFamilyToLoad because the following exception occured:\n$e');
}
}
步骤 2: 通过 icon 的名称获取需要加载的 unicode 值
在实际使用时我们发现需要指定 icon 对应字体文件的 codePoint ,也就是 unicode 值:
代码中通过 iconfont 的 unicde 值获取 icon 的用法如下:
/// StringToInt 方法是定义的将 "" 从 String 类型的 16 进制值转为 int 类型方法
MyIcons.from(StringToInt(''));
这样的用法对于我们开发来说不是很友好,每次都需要去查找这个 unicde 值对应的是哪个图标,因此我们可以在之前下载 ttf 文件的接口创建一个映射关系表,然后在 iconfont 初始化的时候通过代码将动态下发的 icon 名称和 Unicode 进行关联。
接口返回数据格式:
更改接口格式后代码中 icon 的用法:
/// _aliasMap 是将接口下发的 nameList 保存起来的 Map
MyIcons.from(StringToInt(_aliasMap['tongzhi']);
“假设我们有这么一个场景:APP 进入首页,下载最新的 iconfont.ttf 文件并加载,但是 Icon 已经加载完成,此时怎么做到动态刷新当前 Icon 里面的内容呢?
步骤 3:动态加载异步优化
之前的步骤已经可以完成 APP 启动后本地字体文件的更新,但是无法解决 icon 已经加载完成后的数据更新,因此我们的动态化方案需要依赖于 FutureBuilder。
FutureBuilder 会依赖一个 Future,它会根据所依赖的 Future 的状态来动态构建自身。
我们可以扩展一个 Icon 的 dynamic 方法去返回一个依赖于 FutureBuilder 的 Icon,当我们的 iconfont 字体文件更新成功后让 FutureBuilder 强制去刷新这个 Icon。
主要代码如下:
/// Icon 的扩展方法,主要实现 Icon 组件的动态刷新
/// [dynamic]方法主要通过[FutureBuilder]实现动态加载的核心原理
extension DynamicIconExtension on Icon {
/// 用来监听新 icon 字体加载成功后的回调及时刷新 icon,
Widget get dynamic {
/// 没有使用动态 iconfont 的情况下直接返回
if (this.icon is! DynamicIconDataMixin) return this;
final mix = this.icon as DynamicIconDataMixin;
final loadedKey = UniqueKey();
return FutureBuilder(
future: mix.dynamicIconFont.loadedAsync,
builder: (context, snapshot) {
/// 由于 icon 的配置未发生变化但实际上其使用的字体已经发生了变化,所以这里通过使用不同的 key 让其强制刷新
return KeyedSubtree(
key: snapshot.hasData ? loadedKey : null,
child: this,
);
},
);
}
}
/// 调用代码如下:
Icon(MyIcons.from('')).dynamic
至此,我们的动态化方案支持的能力如下:
-
可动态修改项目中已有的 icon -
通过 name/code 的形式动态设置 icon -
可在项目中使用新增的 icon整个方案的流程图如下:
总结
总体来说,整个方案的核心原理就是通过 FontLoader 来实现字体文件的动态加载。但是其中涉及到一些动态化的处理和 iconfont 的原理探究,涉及到多点多面的知识,需要融会贯通并组合在一起使用。
参考资料
Flutter 中文网(https://book.flutterchina.club/chapter3/img_and_icon.html)
招贤纳士
政采云技术团队(Zero),一个富有激情、创造力和执行力的团队,Base 在风景如画的杭州。团队现有300多名研发小伙伴,既有来自阿里、华为、网易的“老”兵,也有来自浙大、中科大、杭电等校的新人。团队在日常业务开发之外,还分别在云原生、区块链、人工智能、低代码平台、中间件、大数据、物料体系、工程平台、性能体验、可视化等领域进行技术探索和实践,推动并落地了一系列的内部技术产品,持续探索技术的新边界。此外,团队还纷纷投身社区建设,目前已经是 google flutter、scikit-learn、Apache Dubbo、Apache Rocketmq、Apache Pulsar、CNCF Dapr、Apache DolphinScheduler、alibaba Seata 等众多优秀开源社区的贡献者。
如果你想改变一直被事折腾,希望开始折腾事;如果你想改变一直被告诫需要多些想法,却无从破局;如果你想改变你有能力去做成那个结果,却不需要你;如果你想改变你想做成的事需要一个团队去支撑,但没你带人的位置;如果你想改变本来悟性不错,但总是有那一层窗户纸的模糊……如果你相信相信的力量,相信平凡人能成就非凡事,相信能遇到更好的自己。如果你希望参与到随着业务腾飞的过程,亲手推动一个有着深入的业务理解、完善的技术体系、技术创造价值、影响力外溢的技术团队的成长过程,我觉得我们该聊聊。任何时间,等着你写点什么,发给 [email protected]