江有汜 - Flutter
type: channel
link: https://yooss.cn/tag/Flutter/
items: Flutter 混合路由栈管理
items: Flutter 实现自适应跑马灯组件
items: Flutter 实现多行文字中间省略
items: Flutter 监听键盘弹出
items: Flutter shared_preferences 自动过期策略
items: Flutter 防抖节流实现
items: Flutter 应用锁组件
items: Flutter 录音可视化组件
items: Flutter 构建 AI 聊天机器人
items: Flutter 可变字体（Variable Font）

Flutter 混合路由栈管理
type: item
date: 2025-07-13T11:39:00+00:00
description: Flutter 如何引入到原生工程AndroidFlutterActivityFlutterFragementFlutterViewiOS1.FlutterViewControllerFlutterView 的方式集成需要做的更多，因为它是一个 View 所以不能访问 Activity 级别的事件，需要手动维护与 Flutter Engine 的桥接。混合路由栈在混合项目中，一定会出现 Flutter 与原生页面互相跳转的情况，而原生与 Flutter 各自实现了一套路由方案，原生平台使用的是单容器单页面，一个 VC 或 Activity 对应一个原生页面，而 Flutter 是采用的单容器多页面，一个 VC 或 Activity 对应多个原生页面。所以在混合开发中会存在两套路由栈，分别是原生路由栈以及 Flutter 内部路由栈。graph LR NativeA[原生页面 NativeA] --> FlutterContainer[启动 Flutter 容器] subgraph Flutter 容器 FlutterContainer --> FlutterPageA[Flutter 页面 A] FlutterPageA --> FlutterPageB[Flutter 页面 B] end FlutterPageB --> NativeB[原生页面 NativeB]原生页面跳转 Flutter原生跳转 Flutter 页面相对比较简单，因为对于原生来说 Flutter 页面本质也是一个 VC/Activity 所以只需要按照正常的跳转方式跳转即可。但是对于安卓来说可能需要多一些步骤，因为 Flutter 的入口页面是一个 View 而不是 Activity。Flutter 跳转原生相比于原生跳转 Flutter，从 Flutter 页面跳转到原生显然需要做的更多，主要需要考虑到两种场景从 Flutter 页面打开原生页面，从 Flutter 页面回到原生页面。而 Flutter 并没有直接提供操作原生页面的方法，所以我们需要使用 PlatformChannel 来实现。需要注意的点是，Flutter 容器本身属于原生路由栈的一部分，所以当 Flutter 的根页面需要返回时，需要额外处理 Flutter 容器关闭的的问题。侧滑返回手势iOS侧滑返回手势在 iOS 7 中引入，用户可以通过向右滑动页面来进行返回，同时可以看到上一页的内容，这种侧滑返回的行为本质上是附加在页面上的手势行为，所以并没有那么的“系统”，而在 Flutter 与 iOS 的混合开发中，当 Flutter 的 vc 中存在内部路由，则会出现预期行为不一致的情况，因为 vc 自带的 GestureRecognizer 侧滑手势比 Flutter 内部的侧滑手势 touchXX 优先级更高，所以会导致 Flutter 无法收到手势，一种比较简单的解决方案是，创建一个 MethodChannel 来关闭/开启 iOS 的原生侧滑手势监听，在 Flutter 页面内监听路由栈，当路由栈中路由数量大于 1，说明存在 Flutter 的内部路由，这时需要关闭 iOS 的原生手势，当路由栈中路由数量等于 1，说明位于 Flutter 的根页面，这是需要开启 iOS 的原生手势，来关闭 Flutter 容器。另一种方式可以自定义 GestureRecognizer 通过手势竞技场机制来实现。Android从安卓一开始就配有实体的返回按键，之后发展到虚拟导航按键（三大金刚键），再到现在的全面屏手势，在安卓上，不管是侧滑返回还是实体按键返回，都更像是一种“系统”行为，因为你可以在任何位置触发返回操作，即使页面上没有任何可以返回的内容。在 Flutter 与 Android 的混合开发中，Flutter 页面作为一个 Activity 存在，而 Activity 的默认返回行为是 finish() ，即关闭容器，所以当 Flutter 页面通过 Fragment 引入原生且存在内部路由时，触发返回手势就会直接关闭整个 Activity，而不是优先关闭 Flutter 的内部路由。解决方案也很简单，Flutter 作为成熟的框架，肯定考虑到了这些情况，FlutterFragment 有一个onBackPressed() 方法，只需要将这个方法使用 requireActivity().onBackPressedDispatcher.addCallback 附加到当前 Fragment 关联的 Activity 即可。预测式返回手势为了更好的返回体验（肯定不是抄 iOS），Android 13 中引入了一种全新的返回手势，Predictive Back（预测式返回）。[scode t…

Flutter 实现自适应跑马灯组件
type: item
date: 2025-01-25T03:00:00+00:00
description: 组件已经封装好的组件，可以直接替代原有 Text 组件使用，这里也添加了一些便捷的样式用于 ListTile 中的使用。[scode type="blue" size=""]其中的 Marquee 组件来自于 pub 中的插件，当然也可以去看看是怎么实现的，这里就不展开讲了，实现原理也比较简单，通过 ListView 等可滚动组件来模拟 Marquee 效果[/scode]class AdaptiveText extends StatelessWidget { final String text; final TextStyle? style; final double? maxWidth; final bool? isTileTitle; final bool? isTileSubtitle; final bool? isPrimaryTitle; final bool? isTitle; const AdaptiveText( this.text, { super.key, this.style, this.maxWidth, this.isTileTitle, this.isTileSubtitle, this.isPrimaryTitle, this.isTitle, }); @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; final textTheme = Theme.of(context).textTheme; final textScaler = MediaQuery.textScalerOf(context); var textStyle = style; if (isTileTitle == true) { textStyle = textTheme.bodyLarge?.copyWith(color: colorScheme.onSurface); } if (isTileSubtitle == true) { textStyle = textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant); } if (isTitle == true) { textStyle = textTheme.titleLarge; } if (isPrimaryTitle == true) { textStyle = textTheme.titleLarge?.copyWith( color: colorScheme.primary, ); } return LayoutBuilder( builder: (context, constraints) { final textPainter = TextPainter( text: TextSpan(text: text, style: textStyle), textDirection: TextDirection.ltr, maxLines: 1, textScaler: textScaler, )..layout(maxWidth: maxWidth ?? constraints.maxWidth); return textPainter.didExceedMaxLines ? SizedBox( height: textPainter.height, width: maxWidth ?? constraints.maxWidth, child: Marquee( text: text, velocity: 20, blankSpace: 20, textScaler: textScaler, pauseAfterRound: const Duration(seconds: 1), accelerationDuration: const Duration(seconds: 1), accelerationCurve: Curves.linear, decelerationDuration: const Duration(milliseconds: 300), decelerationCurve: Curves.easeOut, style: textStyle, ), ) : Text( text, style: textStyle, maxLines: 1, overflow: TextOverflow.ellipsis, ); }, ); } }跑马灯跑马灯也叫 Marquee 元素，常用于在有限的空间中展示更多的信息。自适应我们总是希望只有在文本…

Flutter 实现多行文字中间省略
type: item
date: 2025-01-17T18:06:00+00:00
description: 组件封装好的组件，可以直接替代 Text 使用，LRUCache 是一种更高效的缓存结构，具体实现可以参考 [post cid="202" cover="" size="small"/]class EllipsisText extends StatelessWidget { final String text; final TextStyle? style; final String ellipsis; final int? maxLines; static final _cache = LRUCache<int, String>(maxSize: 1000); /// 当字体相关属性发生变化时，需要清空缓存 static void clearCache() { _cache.clear(); } const EllipsisText( this.text, { super.key, this.style, this.ellipsis = '...', this.maxLines, }); int _getCacheKey( String text, String ellipsis, double maxWidth, int? maxLines, TextStyle? style, TextScaler textScaler, ) { return Object.hash( text, ellipsis, maxWidth.toStringAsFixed(2), maxLines, style, textScaler, ); } double _calculateTextWidth(String text, TextScaler textScaler) { final span = TextSpan(text: text.fixAutoLines(), style: style); final tp = TextPainter( text: span, maxLines: 1, textDirection: TextDirection.ltr, textScaler: textScaler, )..layout(); return tp.width; } TextPainter _createTextPainter( String displayText, double maxWidth, TextScaler textScaler, ) { return TextPainter( text: TextSpan(text: displayText.fixAutoLines(), style: style), maxLines: maxLines, textDirection: TextDirection.ltr, textScaler: textScaler, )..layout(maxWidth: maxWidth); } @override Widget build(BuildContext context) { final textScaler = MediaQuery.textScalerOf(context); return LayoutBuilder(builder: (context, constraints) { final cacheKey = _getCacheKey( text, ellipsis, constraints.maxWidth, maxLines, style, textScaler, ); if (text.isEmpty) { return const SizedBox.shrink(); } final cachedResult = _cache.get(cacheKey); if (cachedResult != null) { return Text( cachedResult, maxLines: maxLines, overflow: TextOverflow.ellipsis, style: style, textScaler: textScaler, ); } if (!_createTextPainter( text, constraints.maxWidth, textScaler, ).didExceedMaxLines) { _cache.put(cacheKey, text.fixAutoLines()); return Text( text.fixAutoLines(), maxLines: maxLines, overflow: TextOverflow.ellipsis, style: style, textScaler: textScaler, ); } …

Flutter 监听键盘弹出
type: item
date: 2024-08-23T06:59:46+00:00
description: 监听键盘弹出是很常见的需求，但是键盘的状态并不由 Flutter 来控制，所以 Flutter 没有提供直接的方法来获取键盘的状态，不过我们可以通过其他方式来实现。WidgetsBindingObserverWidgetsBindingObserver 是一个比较常用的 mixin 类，通过名字就可以看出，这个类主要是用于监听 Widget 的各种状态，而键盘弹出会导致应用页面发生变化，所以我们可以使用 didChangeMetrics 这个方法来监听底部高度的变化。使用实例首先定义一个枚举类，表示键盘打开的状态。enum KeyboardState { unknown, opening, closing, closed }之后定义监听的逻辑，一般情况下都会选择监听 MediaQuery.viewInsetsOf(context).bottom 来计算底部的距离，因为底部的距离正好是键盘的高度，但是键盘的高度是一个变化的过程，并且就算是同一个键盘，最大高度也有可能发生变化，所以说无法知道键盘的最大高度。最好的方法是，监听高度的变化过程，如果是递增变化，那么就是正在打开的状态，如果是降序变化，那么就是正在关闭的状态，当底部距离变为 0 并且同时不是已经关闭的状态，那么就是关闭了。之所以不能单纯判断底部距离为 0 就是关闭状态是因为前面提到，键盘的高度是一个变化的过程，在键盘打开的前几帧，获取到的高度也可能是 0 。有了逻辑就很容易了，在需要监听的类中混入 WidgetsBindingObserver 后重写 didChangeMetrics 方法。List<double> heightList = []; @override void didChangeMetrics() { WidgetsBinding.instance.addPostFrameCallback((_) { var height = MediaQuery.viewInsetsOf(Get.context!).bottom; if (heightList.isNotEmpty && height != heightList.last) { if (height > heightList.last && state.keyboardState != KeyboardState.opening) { state.keyboardState = KeyboardState.opening; //正在打开 } else if (height < heightList.last && state.keyboardState != KeyboardState.closing) { state.keyboardState = KeyboardState.closing; //正在关闭 } } // 只在高度变化时记录高度 if (heightList.isEmpty || height != heightList.last) { heightList.add(height); } // 当高度为0且键盘经历了开启关闭过程时，认为键盘已完全关闭 if (height == 0 && state.keyboardState != KeyboardState.closed) { state.keyboardState = KeyboardState.closed; heightList.clear(); //已经关闭 } }); super.didChangeMetrics(); }我这里使用了 getx 获取 context。注意不要忘记了 WidgetsBinding.instance.addObserver(this) 以及及时销毁 WidgetsBinding.instance.removeObserver(this) 。

Flutter shared_preferences 自动过期策略
type: item
date: 2024-08-02T22:23:27+00:00
description: key-value 数据库在很多时候都是用来作为缓存使用的。而大部分 key-value 数据库都能配置缓存过期的时，但是在 Flutter 中常用的 shared_preferences 却没有相应的缓存过期策略，不过我们可以自己手动实现。封装 shared_preferencesshared_preferences 本身使用起来已经非常方便了，所以只要进行简单的封装就可以，这里使用泛型的方式。import 'package:shared_preferences/shared_preferences.dart'; class PrefUtil { late final SharedPreferencesWithCache _prefs; Future<void> initPref() async { _prefs = await SharedPreferencesWithCache.create( cacheOptions: const SharedPreferencesWithCacheOptions(allowList: <String>{ })); } Future<void> reset() async { await _prefs.clear(); } Future<void> setValue<T>(String key, T value) async { if (T == int) { await _prefs.setInt(key, value as int); } else if (T == bool) { await _prefs.setBool(key, value as bool); } else if (T == double) { await _prefs.setDouble(key, value as double); } else if (T == String) { await _prefs.setString(key, value as String); } else if (T == List<String>) { await _prefs.setStringList(key, value as List<String>); } else { throw ArgumentError('Unsupported type: $T'); } } T? getValue<T>(String key) { if (T == int) { return _prefs.getInt(key) as T?; } else if (T == bool) { return _prefs.getBool(key) as T?; } else if (T == double) { return _prefs.getDouble(key) as T?; } else if (T == String) { return _prefs.getString(key) as T?; } else if (T == List<String>) { return _prefs.getStringList(key) as T?; } else { throw ArgumentError('Unsupported type: $T'); } } Future<void> removeValue(String key) async { await _prefs.remove(key); } }需要注意的时，在 shared_preferences 的 2.3.0 版本之后，由于底层实现方式的改变，原有的 SharedPreferences 类型被替换为了 SharedPreferencesAsync 和 SharedPreferencesWithCache ，简单的说就是为了安全考虑，分为了异步调用，和同步调用，当使用 SharedPreferencesAsync 时，用于取值的 get 方法返回值是一个 Future ，也就是必须异步调用。而 SharedPreferencesWithCache 则仍可以使用同步调用的方式，和名字一样，原理是通过缓存的方式来实现的，出于安全考虑，现在要使用同步调用的话，必须要预设好 allowList ，也就是将来要存储数据的 key ，对不在列表中的 key 将会抛出异常。也就是说，如果你不知道将来要存储内容的 key 值是多少，就要使用 SharedPreferencesAsync 进行异步调用。缓存控制类其实实现的原理也非常简单，只要在存储原有数据的同时，记录下存储时的时间戳，下次取数据的时候，对比一下时间戳的差值，如果过期了，就可以进行进一步的操作。因为 shared_perferenc…

Flutter 防抖节流实现
type: item
date: 2024-08-01T22:50:07+00:00
description: 通过函数扩展的方式，调用时只要在原有函数后面加上对应方法即可。import 'dart:async'; import 'dart:ui'; // Function extensions to add throttling and debouncing capabilities extension FunctionExtensions on Function { VoidCallback throttle() { return FunctionProxy(this).throttle; } VoidCallback throttleWithTimeout({int? timeout}) { return FunctionProxy(this, timeout: timeout).throttleWithTimeout; } VoidCallback debounce({int? timeout}) { return FunctionProxy(this, timeout: timeout).debounce; } } // FunctionProxy class to implement throttling and debouncing class FunctionProxy { static final Map<int, bool> _throttleMap = {}; static final Map<int, Timer> _debounceMap = {}; final Function target; final int timeout; FunctionProxy(this.target, {int? timeout}) : timeout = timeout ?? 500; // Throttle function to limit the execution rate void throttle() async { int key = target.hashCode; bool canExecute = _throttleMap[key] ?? true; if (canExecute) { _throttleMap[key] = false; try { await target(); } catch (e) { rethrow; } finally { _throttleMap.remove(key); } } } // Throttle function with a specified timeout void throttleWithTimeout() { int key = target.hashCode; bool canExecute = _throttleMap[key] ?? true; if (canExecute) { _throttleMap[key] = false; Timer(Duration(milliseconds: timeout), () { _throttleMap.remove(key); }); target(); } } // Debounce function to delay execution until after a specified period void debounce() { int key = target.hashCode; _debounceMap[key]?.cancel(); _debounceMap[key] = Timer(Duration(milliseconds: timeout), () { _debounceMap.remove(key); target(); }); } }

Flutter 应用锁组件
type: item
date: 2024-07-31T03:22:00+00:00
description: 写在前面成品展示相关依赖getlocal_authshared_preferences组件代码这里使用 getx 作为状态管理，shared_preferences 持久化数据，local_auth 库用于调用系统的生物识别功能，比如 Android 平台的指纹识别，IOS 平台的 Face ID 等。如果没有需求可以不依赖。[tabs][tab name="View" active="true"]import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:mood_diary/utils/utils.dart'; import 'lock_logic.dart'; class LockPage extends StatelessWidget { const LockPage({super.key}); @override Widget build(BuildContext context) { final logic = Bind.find<LockLogic>(); final state = Bind.find<LockLogic>().state; final colorScheme = Theme.of(context).colorScheme; final textStyle = Theme.of(context).textTheme; final buttonSize = (textStyle.displayLarge!.fontSize! * textStyle.displayLarge!.height!); Widget buildNumButton(String num) { return Ink( decoration: BoxDecoration(color: colorScheme.surfaceContainerHighest, shape: BoxShape.circle), child: InkWell( borderRadius: BorderRadius.all(Radius.circular(buttonSize / 2)), onTap: () async { await logic.updatePassword(num); }, child: Center( child: Text( num, style: textStyle.displaySmall, )), ), ); } Widget buildDeleteButton() { return Ink( decoration: const BoxDecoration(shape: BoxShape.circle), child: InkWell( borderRadius: BorderRadius.all(Radius.circular(buttonSize / 2)), onTap: () { logic.deletePassword(); }, child: const Icon( Icons.backspace, ), ), ); } Widget buildBiometricsButton() { return Ink( decoration: const BoxDecoration(shape: BoxShape.circle), child: InkWell( borderRadius: BorderRadius.all(Radius.circular(buttonSize / 2)), onTap: () async { if (await Utils().authUtil.check()) { logic.checked(); } }, child: const Icon( Icons.fingerprint, ), ), ); } List<Widget> buildPasswordIndicator() { return List.generate(4, (index) { return Obx(() { return Icon( Icons.circle, size: 16, color: Color.lerp( state.password.value.length > index ? colorScheme.onSurface : colorScheme.surfaceContainerHighest, Colors.red, logic.animation.value), ); }); }); } return GetBuilder<LockLogic>( init: logic, ass…

Flutter 录音可视化组件
type: item
date: 2024-07-17T10:17:00+00:00
description: 写在前面成品展示相关依赖getpath_provideruuidrecordaudioplayerspermission_handler录音组件[tabs][tab name="View" active="true"]import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:mood_diary/components/wave_form/wave_form_view.dart'; import 'record_sheet_logic.dart'; class RecordSheetComponent extends StatelessWidget { const RecordSheetComponent({super.key}); @override Widget build(BuildContext context) { final logic = Get.put(RecordSheetLogic()); final state = Bind.find<RecordSheetLogic>().state; final colorScheme = Theme.of(context).colorScheme; return GetBuilder<RecordSheetLogic>( init: logic, assignId: true, builder: (logic) { return SafeArea( child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ Padding( padding: const EdgeInsets.all(16.0), child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ SizedBox( width: 48.0, child: ClipRRect( borderRadius: BorderRadius.circular(8.0), child: Divider( thickness: 8.0, height: 8.0, color: colorScheme.outline, ), )), ], ), ), Obx(() { return AnimatedSwitcher( duration: const Duration(milliseconds: 100), child: state.isStarted.value ? SizedBox( height: 100, key: const ValueKey('wave'), child: Obx(() { return WaveFormComponent( amplitudes: state.amplitudes.value, height: 100, ); }), ) : SizedBox( height: 100, key: const ValueKey('start'), child: Center( child: Container( decoration: BoxDecoration( border: Border.all(color: colorScheme.outline, width: 4.0), shape: BoxShape.circle), child: IconButton( onPressed: () { logic.startRecorder(); }, padding: EdgeInsets.zero, icon: const Icon( Icons.circle, size: 48, color: Colors.redAccent, )), )), ), ); }), Obx(() { return AnimatedContainer( duration: const Duration(milliseconds: 100), height: state.height.value, child: OverflowBox( minHeight: 0, maxHeight: state.height.value, alignment: Alignment.center, child: state.isStarted.value ? Column( mainAxisAlignment: MainAxisAlignment.spaceEvenly, key: const …

Flutter 构建 AI 聊天机器人
type: item
date: 2024-06-10T08:54:00+00:00
description: 写在前面成品展示看起来卡顿是因为抽帧了。Flutter 相关依赖提前安装好下列 Flutter 插件，直接使用最新版即可，其中使用了 GetX 作为状态管理，如果你喜欢其他的方案比如 Bolc，Provider 等，请自行修改相关部分的代码。网络请求库 dio 同理。diocryptoflutter_markdownget申请腾讯混元大模型[scode type="blue" size=""]官网链接：腾讯混元大模型[/scode]进入官网后点击立即接入页面的创建密钥即可，验证身份后会得到一个 SecretId 和 SecretKey ，注意妥善保存，不要泄漏给别人。腾讯混元大模型的 lite 版本是免费的，其他版本的价格也不高。如果你喜欢其他厂商的大模型，比如文心一言，通义千问，等等。需要自行编写签名方法。准备工作创建项目在创建好 Flutter 项目后，管理好项目目录结构是很重要的，这里推荐下面的目录结构。封装网络请求在 utils 目录下新建一个 http.dart 文件，用于封装网络请求，使用 dart 中的单例模式，利用工厂函数创建一个全局实例，这样可以更加方便维护，但是会破坏类的单一职责原则，如果你不喜欢，那就不喜欢吧。由于只用到了 post 方法，所以就没有对其他的方法进行更细致的封装。class Http { static final Http _instance = Http._(); factory Http() => _instance; static final Dio dio = Dio(BaseOptions(connectTimeout: const Duration(seconds: 3))); Http._() { dio.interceptors.add(InterceptorsWrapper( onRequest: (RequestOptions options, RequestInterceptorHandler handler) { return handler.next(options); }, onResponse: (Response response, ResponseInterceptorHandler handler) { return handler.next(response); }, onError: (DioException error, ErrorInterceptorHandler handler) { return handler.next(error); }, )); } Future<Stream<String>?> postStream(String path, {Map<String, dynamic>? header, Object? data}) async { Response<ResponseBody> response = await dio.post(path, options: Options(responseType: ResponseType.stream, headers: header), data: data); StreamTransformer<Uint8List, List<int>> transformer = StreamTransformer.fromHandlers(handleData: (data, sink) { sink.add(List<int>.from(data)); }); return response.data?.stream.transform(transformer).transform(const Utf8Decoder()).transform(const LineSplitter()); } }稍微解释一下这里的方法，由于是一个聊天接口，所以返回的是一个流式的响应，需要注意的就是最后处理流时的 LineSplitter() ，因为有可能接口会返回多条数据，如果不使用行分割的话，会导致丢失一些文字。为请求及响应创建 Model参考腾讯云官网文档，在 common/model 目录下创建一个 hunyuan.dart ，这里手动编写了序列化方法，如果你使用了其他的自动序列化框架，自行修改。class PublicHeader { late String action; late int timestamp; late String version; late String authorization; Map<String, dynamic> toMap() { return { 'X-TC-Action': action, 'X-TC-Timestamp': timestamp, 'X…

Flutter 可变字体（Variable Font）
type: item
date: 2024-06-08T17:23:00+00:00
description: 什么是可变字体（Variable Font）普通的字体可能有多个字重（比如 light、regular、bold）等，他们分别是一个个独立的静态的文件，在某些情况下，我们可能需要在应用中使用自定义的字体。可是，相对于西文字体来说，中文字体的体积相对偏高，单个字体文件的大小可能就有 15 MB 以上，并且在某些情况下，我们可能需要引入多个字重的字体，比如粗体和细体，无形中又增大了静态文件的大小。而可变字体，简单一点理解就是将多个字重的字体合并为一个字体。可变字体的选择虽然可变字体的好处众多，但可惜的是，对于中文字体来说，可选择的字体太少，到目前为止，较为优秀的中文可变字体只有 MiSans 的 VF 版本，以及 思源黑体 的 VF 版本，值得高兴的是，这两个字体均提供了免费的商用。接下来我以 MiSans 为例。引入可变字体首先在 MiSans 官网下载字体，下载出来后解压，其中有一个名为 MiSans VF.ttf 的文件，就是我们需要的可变字体。可以看到体积仅有 19 MB，如果还是觉得打包体积过大，可以采取应用下载后分发的方式加载。这样也可以减少打包的大小。创建文件夹在 Flutter 项目的根目录下创建一个 font 文件夹，注意不是 lib 目录下，而是和 lib 同级，之后将文件拷贝到目录下，完成后的项目结构看起来像这样。引入字体文件打开 pubspec.yaml 在 flutter 块中加入如下代码。fonts: - family: MiSans VF fonts: - asset: fonts/MiSans VF.ttf修改后的文件看起来像这样。这里通过静态方法进行引入，会将字体作为静态文件打包到安装包里，会带来一定的体积增加，如果想从网络分发字体，可以使用 FontLoader 加载，这里不展开讲。引入后不要忘记运行一次 pub get 。全局使用字体找到应用入口的 MaterialApp() 块，通常在 main.dart 中，分别有两个属性 theme 和 darkTheme ，用于控制浅色和深色模式的样式，需要对其修改，重新运行应用后，字体就被全局修改为了 MiSans。theme: ThemeData( fontFamily: 'MiSans VF', fontFamilyFallback: const ['MiSans VF'], ), darkTheme: ThemeData( fontFamily: 'MiSans VF', fontFamilyFallback: const ['MiSans VF'], ),使用可变字体可变字体最大的特征就是可变，在 Flutter 中，文字样式属性 TextStyle 的定义如下。const TextStyle({ this.inherit = true, this.color, this.backgroundColor, this.fontSize, this.fontWeight, this.fontStyle, this.letterSpacing, this.wordSpacing, this.textBaseline, this.height, this.leadingDistribution, this.locale, this.foreground, this.background, this.shadows, this.fontFeatures, this.fontVariations, this.decoration, this.decorationColor, this.decorationStyle, this.decorationThickness, this.debugLabel, String? fontFamily, List<String>? fontFamilyFallback, String? package, this.overflow, }) : fontFamily = package == null ? fontFamily : 'packages/$package/$fontFamily', _fontFamilyFallback = fontFamilyFallback, _package = package, assert(color == null || foreground == null, _kColorForegroundWarning), assert(backgroundColor == null || background == null, _kColorBackgroundWarning);可以看到其中有几个关键的属性，就拿一般定义字重用的 fontWeight 属性来说，查看源码会发现，Flu…