政采云 Flutter 动态 iconfont 实践探索

政采云技术团队.png

以北1.png

前言

动态修改 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());
  }
}

我们可以创建一个接口来下发 iconfont 字体文件远端地址及该文件的 hash 值,每次启动 APP 将本地字体文件的 hash 值与接口中的值对比,当存在差异时将远端的字体文件下载到本地并以 ByteData 的数据格式供 FontLoader 加载即可。附上部分关键代码:

/// 下载远端的字体文件
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 方法是定义的将 "&#xe67b;" 从 String 类型的16进制值转为 int 类型方法
MyIcons.from(StringToInt('&#xe67b;'));

这样的用法对于我们开发来说不是很友好,每次都需要去查找这个 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('&#xe67b;')).dynamic

至此,我们的动态化方案支持的能力如下:

  • 可动态修改项目中已有的 icon
  • 通过 name/code 的形式动态设置 icon
  • 可在项目中使用新增的 icon 整个方案的流程图如下:

总结

总体来说,整个方案的核心原理就是通过 FontLoader 来实现字体文件的动态加载。但是其中涉及到一些动态化的处理和 iconfont 的原理探究,涉及到多点多面的知识,需要融会贯通并组合在一起使用。

参考资料

Flutter中文网

推荐阅读

政采云Flutter低成本屏幕适配方案探索

Redis系列之Bitmaps

MySQL 之 InnoDB 锁系统源码分析

招贤纳士

政采云技术团队(Zero),一个富有激情、创造力和执行力的团队,Base 在风景如画的杭州。团队现有300多名研发小伙伴,既有来自阿里、华为、网易的“老”兵,也有来自浙大、中科大、杭电等校的新人。团队在日常业务开发之外,还分别在云原生、区块链、人工智能、低代码平台、中间件、大数据、物料体系、工程平台、性能体验、可视化等领域进行技术探索和实践,推动并落地了一系列的内部技术产品,持续探索技术的新边界。此外,团队还纷纷投身社区建设,目前已经是 google flutter、scikit-learn、Apache Dubbo、Apache Rocketmq、Apache Pulsar、CNCF Dapr、Apache DolphinScheduler、alibaba Seata 等众多优秀开源社区的贡献者。如果你想改变一直被事折腾,希望开始折腾事;如果你想改变一直被告诫需要多些想法,却无从破局;如果你想改变你有能力去做成那个结果,却不需要你;如果你想改变你想做成的事需要一个团队去支撑,但没你带人的位置;如果你想改变本来悟性不错,但总是有那一层窗户纸的模糊……如果你相信相信的力量,相信平凡人能成就非凡事,相信能遇到更好的自己。如果你希望参与到随着业务腾飞的过程,亲手推动一个有着深入的业务理解、完善的技术体系、技术创造价值、影响力外溢的技术团队的成长过程,我觉得我们该聊聊。任何时间,等着你写点什么,发给 zcy-tc@cai-inc.com

微信公众号

文章同步发布,政采云技术团队公众号,欢迎关注

政采云技术团队.png