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 type="blue" size=""]相关 PR flutter/pull/161545[/scode]PopScopeiOS 与 Android 从交互逻辑上的差异导致行为不一致。[scode type="blue" size=""]相关 PR flutter/issues/138624[/scode]
组件已经封装好的组件，可以直接替代原有 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 元素，常用于在有限的空间中展示更多的信息。自适应我们总是希望只有在文本宽度超过可以显示的大小时才显示 Marquee，使用 LayoutBuilder 可以很容易的做到这一点。只需要通过 TextPainter 对需要显示的文本进行渲染，同时限制最大宽度为可用的宽度，如果超出了需要显示的布局范围，就显示为跑马灯组件，否则返回一个普通的 Text 组件。
组件封装好的组件，可以直接替代 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, ); } int leftIndex = 0; int rightIndex = text.characters.length; double leftWidth = 0; double rightWidth = 0; int lastValidLeftIndex = 0; int lastValidRightIndex = text.characters.length; while (leftIndex < rightIndex) { final nextLeftWidth = _calculateTextWidth( text.characters.elementAt(leftIndex), textScaler) + leftWidth; final nextRightWidth = _calculateTextWidth( text.characters.elementAt(rightIndex - 1), textScaler) + rightWidth; final currentText = '${text.charSubstring(0, leftIndex)}$ellipsis${text.charSubstring(rightIndex)}'; if (_createTextPainter( currentText, constraints.maxWidth, textScaler, ).didExceedMaxLines) { break; } else { lastValidLeftIndex = leftIndex; lastValidRightIndex = rightIndex; if (leftWidth <= rightWidth) { leftWidth = nextLeftWidth; leftIndex++; } else { rightWidth = nextRightWidth; rightIndex--; } } } final truncatedText = '${text.charSubstring(0, lastValidLeftIndex)}$ellipsis${text.charSubstring(lastValidRightIndex)}'; _cache.put(cacheKey, truncatedText.fixAutoLines()); return Text( truncatedText.fixAutoLines(), maxLines: maxLines, style: style, textScaler: textScaler, ); }); } } extension StringExt on String { String fixAutoLines() => characters.join('\u{200B}'); String charSubstring(int start, [int? end]) => characters.getRange(start, end).join(); String removeLineBreaks() => replaceAll(RegExp(r'[\r\n]+'), ''); }效果图实现思路由于我们平时所用的字体基本都不是等宽字体，所以想要文本在中间省略就不能单纯的通过计算字符的数量来判断，因为我们需要在实际渲染宽度的中间省略。TextPainter好在 Flutter 为我们提供了一个可以高效计算文本渲染相关的 Object，通过使用 TextPainter，我们可以在不实际渲染文本的情况下获取文本的信息。基本原理对于多行文本来说，我们首先需要判断能不能放得下，如果没有超出所需要的行数，直接返回即可，这样可以减少计算的开销。TextPainter 提供了一个 didExceedMaxLines 方法来知道有没有超出最大行数。双指针知道基本原理之后实现起来就比较容易了，我们可以选择使用一个比较简单的双指针，分别从文字开头和文字末尾往中间收缩。为了保证省略两侧宽度相等，当左侧宽度较小时，右移左指针，当右侧宽度较小时，左移右指针。实现细节Text 组件按字符换行可能你会注意到上面的方法中使用了一个扩展，这个扩展的意义是，Flutter 在 Text 组件中，默认会按照词换行，也就是说 Flutter 总是希望一个单词能够完整的显示，如果是这样的话，可能会出现空行的情况，因为并不是所有的单词都能够刚好占满一整行。如果我们需要实现字符换行，一种比较简易的方法是在文本中插入零宽字符，这种字符不会显示出来，但是会破坏字符串原本的完整性，很多隐形水印也是通过零宽字符来实现的。字符串中的 Characters为了考虑更多的情况，比如带有 emoji 的字符串，我们就不能通过 String 中的方法对字符串进行操作了，因为编码格式的原因，对带有 emoji 的字符串进行 substring 、length 时，可能会得到预期之外的值，甚至导致异常。所以对于比较通用的组件，在可预见的未来中无法知道会不会出现 emoji 的情况，我们就需要通过 Characters 的方式对字符串进行操作，虽然这样可能会带来额外的开销。有关字符串编码的更多内容可以查看 [post cid="203" cover="" size="small"/]
监听键盘弹出是很常见的需求，但是键盘的状态并不由 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) 。
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_perferences 可以直接存储 List<String> ，所以我们直接将要存储的值放入一个列表中即可。import 'dart:async'; import 'package:mood_diary/utils/utils.dart'; class CacheUtil { Future<List<String>?> getCacheList(String key, Future<List<String>?> Function() fetchData, {int maxAgeMillis = 900000}) async { var cachedData = Utils().prefUtil.getValue<List<String>>(key); // 检查缓存是否有效，如果无效则更新缓存 if (cachedData == null || _isCacheExpired(cachedData, maxAgeMillis)) { await _updateCacheList(key, fetchData); cachedData = Utils().prefUtil.getValue<List<String>>(key); // 获取更新后的缓存数据 } return cachedData; } bool _isCacheExpired(List<String> cachedData, int maxAgeMillis) { if (cachedData.length < 2) { return true; // 缓存数据格式不正确，视为过期 } int timestamp = int.parse(cachedData.last); return DateTime.now().millisecondsSinceEpoch - timestamp >= maxAgeMillis; } Future<void> _updateCacheList(String key, Future<List<String>?> Function() fetchData) async { var newData = await fetchData(); if (newData != null) { await Utils() .prefUtil .setValue<List<String>>(key, newData..add(DateTime.now().millisecondsSinceEpoch.toString())); } } }使用也非常简单，要取值时只需要调用 getCacheList 方法即可，fetchData 是一个函数，用于更新数据，这样就可以实现数据的自动更新。
通过函数扩展的方式，调用时只要在原有函数后面加上对应方法即可。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(); }); } }
写在前面成品展示相关依赖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, assignId: true, builder: (logic) { return PopScope( canPop: false, child: Scaffold( appBar: AppBar( automaticallyImplyLeading: false, ), body: Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ const Icon(Icons.lock), const SizedBox( height: 16.0, ), Text( '请输入密码', style: textStyle.titleMedium, ), const SizedBox( height: 16.0, ), AnimatedBuilder( animation: logic.animation, builder: (context, child) { return Transform.translate( offset: Offset(logic.interpolate(logic.animation.value), 0), child: Wrap( spacing: 16.0, children: buildPasswordIndicator(), ), ); }, ), const SizedBox( height: 32.0, ), SizedBox( width: buttonSize * 3 + 20, height: buttonSize * 4 + 30, child: GridView.count( crossAxisCount: 3, childAspectRatio: 1.0, crossAxisSpacing: 10, mainAxisSpacing: 10, children: [ buildNumButton('1'), buildNumButton('2'), buildNumButton('3'), buildNumButton('4'), buildNumButton('5'), buildNumButton('6'), buildNumButton('7'), buildNumButton('8'), buildNumButton('9'), buildBiometricsButton(), buildNumButton('0'), buildDeleteButton() ], ), ), ], ), ), ), ); }, ); } } [/tab][tab name="State"]import 'package:get/get.dart'; import 'package:mood_diary/utils/utils.dart'; class LockState { late RxString password; late RxString realPassword; //锁定类型，是立即锁定导致，还是启动锁定导致 late String? lockType; LockState() { password = ''.obs; realPassword = Utils().prefUtil.getValue<String>('password')!.obs; lockType = Get.arguments; ///Initialize variables } } [/tab][tab name="Logic"]import 'package:flutter/animation.dart'; import 'package:flutter/services.dart'; import 'package:get/get.dart'; import 'package:mood_diary/router/app_routes.dart'; import 'lock_state.dart'; class LockLogic extends GetxController with GetSingleTickerProviderStateMixin { final LockState state = LockState(); late AnimationController animationController = AnimationController(vsync: this, duration: const Duration(milliseconds: 200)); late Animation<double> animation = Tween(begin: 0.0, end: 1.0).animate(CurvedAnimation(parent: animationController, curve: Curves.easeInOut)); @override void onReady() { // TODO: implement onReady super.onReady(); } @override void onClose() { // TODO: implement onClose animationController.dispose(); super.onClose(); } double interpolate(double x) { var step = 10.0; if (x <= 0.25) { return 4 * step * x; } else if (x <= 0.75) { return step - 4 * step * (x - 0.25); } else { return -step + 4 * step * (x - 0.75); } } void deletePassword() { if (state.password.value.isNotEmpty) { state.password.value = state.password.value.substring(0, state.password.value.length - 1); HapticFeedback.selectionClick(); } } Future<void> updatePassword(String value) async { if (state.password.value.length < 4) { state.password.value += value; HapticFeedback.selectionClick(); } Future.delayed(const Duration(milliseconds: 100), () async { if (state.password.value.length == 4) { //密码正确 if (state.password.value == state.realPassword.value) { checked(); } else { animationController.forward(); await HapticFeedback.mediumImpact(); Future.delayed(const Duration(milliseconds: 200), () { animationController.reverse(); state.password.value = ''; }); } } }); } void checked() { //如果是启动时的锁定，就跳转到homepage if (state.lockType == null) { Get.offAllNamed(AppRoutes.homePage); } //如果是开启立即锁定后生命周期变化导致的锁定 if (state.lockType == 'pause') { Get.backLegacy(); } } } [/tab][/tabs]其他如果要实现软件离开后自动锁定的功能，可以在一个不会被销毁的页面比如主页，引入 AppLifecycleListener 来监听生命周期变化，当生命周期变化时自动跳转到锁屏页面，这样就可以实现自动锁屏的功能，需要注意的是，由于使用过程中导致的锁屏需要保存路由状态，所以要防止用户通过返回的防止返回上一个路由，可以使用 PopScope 包裹锁屏页面，拦截返回操作。如果引入 local_auth 包使用生物识别，可能需要修改弹窗内容，因为调用的是系统底层的弹窗而不是来自于 Flutter，如果不修改弹窗内容，可能会是英文的界面。以安卓平台为例，修改弹窗内容。//生物识别 Future<bool> check() async { return await _authentication.authenticate( authMessages: [ const AndroidAuthMessages( biometricHint: "", biometricNotRecognized: "验证失败", biometricSuccess: "验证成功", cancelButton: "取消", goToSettingsButton: "设置", goToSettingsDescription: "请先在系统中开启指纹", signInTitle: "扫描您的指纹以继续", ) ], localizedReason: '安全验证', options: const AuthenticationOptions( useErrorDialogs: true, stickyAuth: true, sensitiveTransaction: true, biometricOnly: true, ), ); }
写在前面成品展示相关依赖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 ValueKey('playButton'), children: [ Row( mainAxisAlignment: MainAxisAlignment.center, children: [ Obx(() { return Text(state.durationTime.value.toString().split('.')[0]); }), ], ), Row( mainAxisAlignment: MainAxisAlignment.center, children: [ TextButton( onPressed: () { logic.cancelRecorder(); }, child: const Text('取消')), FilledButton( onPressed: () { state.isRecording.value ? logic.pauseRecorder() : logic.resumeRecorder(); }, child: AnimatedIcon( icon: AnimatedIcons.play_pause, progress: logic.animationController, )), TextButton( onPressed: () { logic.stopRecorder(); }, child: const Text('保存')), ], ) ], ) : null, ), ); }), ], ), ); }, ); } } [/tab][tab name="State"]import 'package:get/get.dart'; class RecordSheetState { late RxList<double> amplitudes; late Rx<Duration> durationTime; late RxBool isRecording; late RxBool isStarted; late String fileName; late RxDouble height; late bool isStop; RecordSheetState() { amplitudes = <double>[].obs; isRecording = false.obs; isStarted = false.obs; fileName = ''; height = .0.obs; isStop = false; durationTime = const Duration().obs; ///Initialize variables } } [/tab][tab name="Logic"]import 'dart:async'; import 'dart:math'; import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:mood_diary/pages/edit/edit_logic.dart'; import 'package:mood_diary/utils/utils.dart'; import 'package:permission_handler/permission_handler.dart'; import 'package:record/record.dart'; import 'package:uuid/uuid.dart'; import 'record_sheet_state.dart'; class RecordSheetLogic extends GetxController with GetSingleTickerProviderStateMixin { final RecordSheetState state = RecordSheetState(); final AudioRecorder audioRecorder = AudioRecorder(); late AnimationController animationController = AnimationController(vsync: this, duration: const Duration(milliseconds: 100), lowerBound: 0, upperBound: 1.0); final size = MediaQuery.sizeOf(Get.context!); final editLogic = Bind.find<EditLogic>(); late double baseline = .0; @override void onReady() { // TODO: implement onReady super.onReady(); listenAmplitude(); } @override void onClose() async { // TODO: implement onClose if (state.isStop == false) { await audioRecorder.cancel(); } audioRecorder.dispose(); animationController.dispose(); super.onClose(); } Future<void> startRecorder() async { if (await Utils().permissionUtil.checkPermission(Permission.microphone)) { await animationController.forward(); state.isRecording.value = true; state.isStarted.value = true; state.height.value = 140.0; state.fileName = 'audio-${const Uuid().v7()}.m4a'; //暂时保存在缓存目录中 await audioRecorder.start(const RecordConfig(encoder: AudioEncoder.aacLc), path: Utils().fileUtil.getCachePath(state.fileName)); } } void listenAmplitude() { final amplitudeStream = audioRecorder.onAmplitudeChanged(const Duration(milliseconds: 100)); amplitudeStream.listen((amplitude) { if (amplitude.current.isFinite && amplitude.current != amplitude.max) { maxLengthAdd(amplitude.current); } timeIncrease(); }); } double normalizeAmplitude(double amplitude) { baseline = min(baseline, amplitude); return (amplitude + baseline.abs()) / baseline.abs(); } void maxLengthAdd(value) { if (state.amplitudes.length > size.width ~/ 6.0) { state.amplitudes.removeAt(0); } state.amplitudes.add(normalizeAmplitude(value)); } void timeIncrease() { state.durationTime.value += const Duration(milliseconds: 100); } Future<void> stopRecorder() async { state.isStop = true; await audioRecorder.stop(); animationController.reset(); editLogic.setAudioName(state.fileName); Get.backLegacy(); } Future<void> pauseRecorder() async { state.isRecording.value = false; await audioRecorder.pause(); await animationController.reverse(); } Future<void> resumeRecorder() async { await animationController.forward(); state.isRecording.value = true; await audioRecorder.resume(); } Future<void> cancelRecorder() async { state.amplitudes.value = <double>[]; state.durationTime.value = const Duration(); animationController.reset(); state.isStarted.value = false; state.isRecording.value = false; state.height.value = 0; await audioRecorder.cancel(); } } [/tab][/tabs]频谱组件[tabs][tab name="View" active="true"]import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:mood_diary/components/record_sheet/record_sheet_logic.dart'; class WaveFormPainter extends CustomPainter { final double barWidth; final double spaceWidth; final Color color; final List<double> amplitudes; final recordLogic = Bind.find<RecordSheetLogic>(); WaveFormPainter( this.amplitudes, { required this.barWidth, required this.spaceWidth, this.color = Colors.white, }); @override void paint(Canvas canvas, Size size) { final paint = Paint() ..color = color ..strokeCap = StrokeCap.round ..strokeWidth = barWidth ..style = PaintingStyle.fill; final barHeightFactor = size.height - barWidth; for (int i = 0; i < amplitudes.length; i++) { final x = i * (barWidth + spaceWidth) + barWidth / 2; final y = barHeightFactor * (1 - amplitudes[i]); canvas.drawLine(Offset(x, barHeightFactor), Offset(x, y), paint); } } @override bool shouldRepaint(covariant CustomPainter oldDelegate) { return true; } } class WaveFormComponent extends StatelessWidget { const WaveFormComponent({ super.key, required this.amplitudes, this.barWidth = 4.0, this.spaceWidth = 2.0, required this.height, }); final List<double> amplitudes; final double barWidth; final double spaceWidth; final double height; @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; return CustomPaint( painter: WaveFormPainter(amplitudes, color: colorScheme.primary, barWidth: barWidth, spaceWidth: spaceWidth), size: Size(amplitudes.length * (barWidth + spaceWidth), height), ); } }[/tab][/tabs]播放组件[tabs][tab name="View" active="true"]import 'package:audioplayers/audioplayers.dart'; import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'audio_player_logic.dart'; class AudioPlayerComponent extends StatelessWidget { const AudioPlayerComponent({super.key, required this.path, required this.index, this.isEdit = false}); final String path; final String index; final bool isEdit; @override Widget build(BuildContext context) { final logic = Get.put(AudioPlayerLogic(), tag: index); final state = Bind.find<AudioPlayerLogic>(tag: index).state; final colorScheme = Theme.of(context).colorScheme; return GetBuilder<AudioPlayerLogic>( init: logic, tag: index, assignId: true, initState: (_) async { await logic.initAudioPlayer(path); }, builder: (logic) { return Container( decoration: BoxDecoration(color: colorScheme.secondaryContainer, borderRadius: BorderRadius.circular(8.0)), constraints: const BoxConstraints(maxWidth: 400), padding: const EdgeInsets.all(8.0), child: Row( children: [ IconButton.filled( onPressed: () { logic.audioPlayer.state == PlayerState.playing ? logic.pause() : logic.play(path); }, icon: AnimatedIcon( icon: AnimatedIcons.play_pause, progress: logic.animationController, )), Expanded( child: Column( children: [ Obx(() { return Slider( value: state.totalDuration.value != Duration.zero ? ((state.currentDuration.value.inMilliseconds / state.totalDuration.value.inMilliseconds) .clamp(0, 1)) : 0, onChangeEnd: (value) { logic.to(value); }, onChanged: (double value) { logic.changeValue(value); }, inactiveColor: colorScheme.outline, ); }), Padding( padding: const EdgeInsets.symmetric(horizontal: 24.0), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Obx(() { return Text( state.totalDuration.value.toString().split('.')[0].padLeft(8, '0'), style: TextStyle(color: colorScheme.onSecondaryContainer), ); }), Obx(() { return Text( state.currentDuration.value.toString().split('.')[0].padLeft(8, '0'), style: TextStyle(color: colorScheme.onSecondaryContainer), ); }), ], ), ), ], ), ), if (isEdit) ...[ IconButton( onPressed: () { logic.editLogic.deleteAudio(int.parse(index)); }, icon: const Icon(Icons.cancel), style: const ButtonStyle(tapTargetSize: MaterialTapTargetSize.shrinkWrap), ) ] ], ), ); }, ); } } [/tab][tab name="State"]import 'package:get/get.dart'; class AudioPlayerState { late String audioPath; late Rx<Duration> totalDuration; late Rx<Duration> currentDuration; late RxBool handleChange; AudioPlayerState() { audioPath = ''; handleChange = false.obs; totalDuration = Duration.zero.obs; currentDuration = Duration.zero.obs; ///Initialize variables } } [/tab][tab name="Logic"]import 'package:audioplayers/audioplayers.dart'; import 'package:flutter/animation.dart'; import 'package:get/get.dart'; import 'package:mood_diary/pages/edit/edit_logic.dart'; import 'audio_player_state.dart'; class AudioPlayerLogic extends GetxController with GetSingleTickerProviderStateMixin { final AudioPlayerState state = AudioPlayerState(); final AudioPlayer audioPlayer = AudioPlayer(); late final AnimationController animationController = AnimationController(vsync: this, duration: const Duration(milliseconds: 100)); final EditLogic editLogic = Bind.find<EditLogic>(); @override void onInit() { // TODO: implement onInit audioPlayer.eventStream.listen((event) { //读取时间完成 if (event.eventType == AudioEventType.duration) { state.totalDuration.value = event.duration!; } //播放完成 if (event.eventType == AudioEventType.complete) { animationController.reset(); state.currentDuration.value = Duration.zero; audioPlayer.stop(); } }); audioPlayer.onPositionChanged.listen((duration) { if (state.handleChange.value == false) { state.currentDuration.value = duration; } }); super.onInit(); } @override void onReady() { // TODO: implement onReady super.onReady(); } @override void onClose() { // TODO: implement onClose animationController.dispose(); audioPlayer.dispose(); super.onClose(); } //初始化获取时长信息 Future<void> initAudioPlayer(String value) async { await audioPlayer.setSourceDeviceFile(value); } Future<void> play(String path) async { await animationController.forward(); await audioPlayer.play(DeviceFileSource(path)); } Future<void> pause() async { await animationController.reverse(); await audioPlayer.pause(); } Future<void> to(value) async { await audioPlayer.seek(state.currentDuration.value); state.handleChange.value = false; } Future<void> changeValue(value) async { state.handleChange.value = true; state.currentDuration.value = Duration(milliseconds: (state.totalDuration.value.inMilliseconds * value).toInt()); } } [/tab][/tabs]
写在前面成品展示看起来卡顿是因为抽帧了。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-TC-Version': version, 'Authorization': authorization, }; } PublicHeader.fromMap(Map<String, dynamic> map) { action = map['X-TC-Action']; timestamp = map['X-TC-Timestamp']; version = map['X-TC-Version']; authorization = map['Authorization']; } PublicHeader(this.action, this.timestamp, this.version, this.authorization); } class Message { late String role; late String content; Message(this.role, this.content); Map<String, dynamic> toMap() { return {'Role': role, 'Content': content}; } Message.fromMap(Map<String, dynamic> map) { role = map['Role']; content = map['Content']; } } class HunyuanResponse { late String note; late List<Choices> choices; late int created; late String id; late Usage usage; HunyuanResponse( {required this.note, required this.choices, required this.created, required this.id, required this.usage}); HunyuanResponse.fromJson(Map<String, dynamic> json) { note = json['Note']; if (json['Choices'] != null) { choices = <Choices>[]; json['Choices'].forEach((v) { choices.add(Choices.fromJson(v)); }); } created = json['Created']; id = json['Id']; usage = (json['Usage'] != null ? Usage.fromJson(json['Usage']) : null)!; } Map<String, dynamic> toJson() { final Map<String, dynamic> data = <String, dynamic>{}; data['Note'] = note; data['Choices'] = choices.map((v) => v.toJson()).toList(); data['Created'] = created; data['Id'] = id; data['Usage'] = usage.toJson(); return data; } } class Choices { late Delta delta; late String finishReason; Choices({required this.delta, required this.finishReason}); Choices.fromJson(Map<String, dynamic> json) { delta = (json['Delta'] != null ? Delta.fromJson(json['Delta']) : null)!; finishReason = json['FinishReason']; } Map<String, dynamic> toJson() { final Map<String, dynamic> data = <String, dynamic>{}; data['Delta'] = delta.toJson(); data['FinishReason'] = finishReason; return data; } } class Delta { late String role; late String content; Delta({required this.role, required this.content}); Delta.fromJson(Map<String, dynamic> json) { role = json['Role']; content = json['Content']; } Map<String, dynamic> toJson() { final Map<String, dynamic> data = <String, dynamic>{}; data['Role'] = role; data['Content'] = content; return data; } } class Usage { late int promptTokens; late int completionTokens; late int totalTokens; Usage({required this.promptTokens, required this.completionTokens, required this.totalTokens}); Usage.fromJson(Map<String, dynamic> json) { promptTokens = json['PromptTokens']; completionTokens = json['CompletionTokens']; totalTokens = json['TotalTokens']; } Map<String, dynamic> toJson() { final Map<String, dynamic> data = <String, dynamic>{}; data['PromptTokens'] = promptTokens; data['CompletionTokens'] = completionTokens; data['TotalTokens'] = totalTokens; return data; } } 编写签名方法[scode type="red" size=""]在正常项目中，签名过程需要放到后端进行，这里为了演示放在了客户端，谨慎在生产环境使用[/scode]第三方的 API 调用都需要对方法进行签名，这里用的是腾讯混元大模型 签名方法 v3。String sha256HexToLowercase(String input) { return sha256.convert(utf8.encode(input)).toString().toLowerCase(); } List<int> hmacSha256(List<int> key, List<int> data) { return Hmac(sha256, key).convert(data).bytes; } //生成腾讯云签名 String generateSignature(int timestamp, body) { String dateTime = DateTime.fromMillisecondsSinceEpoch(timestamp, isUtc: true).toString().split(' ')[0]; //拼接规范请求串 var canonicalRequest = 'POST\n/\n\ncontent-type:application/json\nhost:hunyuan.tencentcloudapi.com\nx-tc-action:chatcompletions\n\ncontent-type;host;x-tc-action\n${sha256HexToLowercase(jsonEncode(body))}'; //待签名字符串 var stringToSign = 'TC3-HMAC-SHA256\n${timestamp ~/ 1000}\n$dateTime/hunyuan/tc3_request\n${sha256HexToLowercase(canonicalRequest)}'; var id = '你的id'; var key = '你的key'; var date = hmacSha256(utf8.encode('TC3$key'), utf8.encode(dateTime)); var service = hmacSha256(date, utf8.encode('hunyuan')); var signing = hmacSha256(service, utf8.encode('tc3_request')); var signature = hmacSha256(signing, utf8.encode(stringToSign)); var authorization = 'TC3-HMAC-SHA256 Credential=$id/$dateTime/hunyuan/tc3_request, SignedHeaders=content-type;host;x-tc-action, Signature=${hex(signature).toLowerCase()}'; return authorization; } double calculateValue(double x, double cardNumber, double maxValue, double minValue, double steepness) { return maxValue * exp(-steepness * pow((x - cardNumber), 2)) + minValue; }在 utils 目录下创建一个 utils.dart 文件，也可以使用单例模式创建一个实例。将上面代码中的 id 和 key 换成之前申请的即可。调用接口有了签名我们就可以调用接口了，在 api 目录下新建一个 api.dart 文件，这里存放我们真正的请求。class Api { Api._(); static final Api _instance = Api._(); factory Api() => _instance; Future<Stream<String>?> getHunYuan(List<Message> messages, int model) async { //获取时间戳 var timestamp = DateTime.now().millisecondsSinceEpoch; var hunyuanModel = switch (model) { 0 => 'hunyuan-lite', 1 => 'hunyuan-standard', 2 => 'hunyuan-pro', _ => 'hunyuan-lite', }; //请求正文 var body = { 'Model': hunyuanModel, 'Messages': messages.map((value) => value.toMap()).toList(), 'Stream': true, }; //获取签名 var authorization = Utils().generateSignature(timestamp, body); //构造请求头 var header = PublicHeader('ChatCompletions', timestamp ~/ 1000, '2023-09-01', authorization); //发起请求 return await Http().postStream('https://hunyuan.tencentcloudapi.com', header: header.toMap(), data: body); } } 调用的方式非常简单，第一个参数是对话的上下文，第二个参数为调用的模型类型。对话上下文的类型是一个 List<Message> ，Message 的定义在上面的 hunyuan.dart 文件中，有两个属性 role 和 content ，分别代表角色和内容，role 的可选值有 system、user、assistant。其中，system 角色可选，如存在则必须位于列表的最开始。user 和 assistant 需交替出现（一问一答），以 user 提问开始和结束，且 content 不能为空。role 的顺序示例：[system（可选） user assistant user assistant user ...]。content 就是对话的文本内容。编写界面新建文件夹编写页面之前肯定要新建文件夹，在 page 目录下新建一个 assistant 文件夹，使用 Getx 的架构，分别创建三个文件 assistant_logic.dart 、assistant_state.dart 、assistant_view.dart 代表逻辑，状态，和视图。定义 State打开 assistant_state.dart ，编写待会需要用到的状态。class AssistantState { //聚焦对象 late FocusNode focusNode; //输入框控制器 late TextEditingController textEditingController; //ListView控制器 late ScrollController scrollController; //对话上下文 late List<Message> messages; //模型版本 late int modelVersion; AssistantState() { focusNode = FocusNode(); textEditingController = TextEditingController(); messages = []; scrollController = ScrollController(); modelVersion = 0; ///Initialize variables } }编写 View之后我们就可以开始编写界面了，首先分析一下布局，在 Scaffold 的基础上，我们需要一个位于底部的输入框，以及中间可以无限滚动的列表。[column][block][/block][block][/block][/column]如何实现这样的效果呢，如果直接使用 ListView 会发现输入框无法固定在屏幕底部，如果使用 Scaffold 中的 bottomNavigationBar ，会发现输入框无法被键盘顶上去，虽然 Scaffold 会自动规避键盘，但是只对 body 部分有效，解决方法就是用 Flexible 组件包裹 ListView ，就可以实现这样的效果。class AssistantPage extends StatelessWidget { const AssistantPage({super.key}); @override Widget build(BuildContext context) { final logic = Get.find<AssistantLogic>(); final state = Get.find<AssistantLogic>().state; final colorScheme = Theme.of(context).colorScheme; final modelName = ['hunyuan-lite', 'hunyuan-standard', 'hunyuan-pro']; return GetBuilder<AssistantLogic>( init: logic, assignId: true, builder: (logic) { return GestureDetector( onTap: () { logic.unFocus(); }, child: Scaffold( appBar: AppBar( title: const Text('AI 助手'), leading: IconButton( onPressed: () { logic.handleBack(); }, icon: const Icon(Icons.arrow_back_outlined), ), actions: [ IconButton( onPressed: () { logic.newChat(); }, icon: const Icon(Icons.refresh)), ], ), body: Column( children: [ Flexible( child: state.messages.isEmpty ? const Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Text('暂无对话'), ], ) : ListView.builder( controller: state.scrollController, itemBuilder: (context, index) { if (state.messages[index].role == 'user') { return Padding( padding: const EdgeInsets.symmetric(horizontal: 10.0), child: Text( state.messages[index].content, style: TextStyle(color: colorScheme.primary), ), ); } else { return Padding( padding: const EdgeInsets.all(10.0), child: Container( padding: const EdgeInsets.all(10.0), decoration: BoxDecoration( color: colorScheme.surfaceContainer, borderRadius: const BorderRadius.all(Radius.circular(10.0))), child: MarkdownBody( selectable: true, data: state.messages[index].content, ), ), ); } }, itemCount: state.messages.length, )), Padding( padding: const EdgeInsets.all(10.0), child: Column( children: [ Row( children: [ Text('当前模型：${modelName[state.modelVersion]}'), IconButton( onPressed: () { logic.changeModel(); }, icon: const Icon(Icons.change_circle_outlined)) ], ), Row( crossAxisAlignment: CrossAxisAlignment.end, children: [ Expanded( child: TextField( focusNode: state.focusNode, controller: state.textEditingController, maxLines: null, decoration: InputDecoration( fillColor: colorScheme.surfaceContainerHighest, filled: true, suffixIcon: IconButton( onPressed: () { logic.clearText(); }, icon: const Icon(Icons.cancel), ), hintText: '消息', enabledBorder: const OutlineInputBorder( borderSide: BorderSide.none, borderRadius: BorderRadius.all(Radius.circular(20.0)), ), focusedBorder: const OutlineInputBorder( borderSide: BorderSide.none, borderRadius: BorderRadius.all(Radius.circular(20.0)), ), ), )), IconButton.filled( onPressed: () { logic.checkGetAi(); }, icon: const Icon(Icons.arrow_upward)) ], ), ], ), ) ], ), ), ); }, ); } }这里用 GestureDetector 包裹 Scaffold 是为了实现点击空白部分失去焦点。编写 Logic最重要的就是逻辑了，对话的过程就是，用户在文本框输入后，在对话上下文中添加一个用户提问上下文，之后监听响应流，将响应流拼接为响应结果，之后将结果作为回答上下文添加。class AssistantLogic extends GetxController { final AssistantState state = AssistantState(); @override void onReady() { // TODO: implement onReady super.onReady(); } @override void onClose() { // TODO: implement onClose super.onClose(); } void handleBack() { if (state.focusNode.hasFocus) { unFocus(); Future.delayed(const Duration(seconds: 1), () { Get.back(); }); } else { Get.back(); } } void unFocus() { state.focusNode.unfocus(); } void newChat() { state.messages = []; update(); } void clearText() { state.textEditingController.clear(); } //对话 Future<void> getAi(String ask) async { //清空输入框 clearText(); //失去焦点 unFocus(); //拿到用户提问后，对话上下文中增加一项用户提问 state.messages.add(Message('user', ask)); update(); //带着上下文请求 var stream = await Api().getHunYuan(state.messages, state.modelVersion); //如果收到了请求，添加一个回答上下文 state.messages.add(Message('assistant', '')); update(); //接收stream stream?.listen((content) { Utils().printLog(content); if (content != '' && content.contains('data')) { HunyuanResponse result = HunyuanResponse.fromJson(jsonDecode(content.split('data: ')[1])); state.messages.last.content += result.choices.first.delta.content; update(); } }).onDone(() { toBottom(); }); } void toBottom() { state.scrollController.jumpTo(state.scrollController.position.maxScrollExtent); } String getText() { return state.textEditingController.text; } Future<void> checkGetAi() async { var text = getText(); if (text != '') { if (state.modelVersion != 0 && state.messages.length > 4) { Utils().showSnackBar('请使用 hunyuan-lite 模型'); } else { await getAi(text); } } else { Utils().showSnackBar('还没有输入问题'); } } Future<void> changeModel() async { await showDialog( context: Get.context!, builder: (context) { return SimpleDialog( title: const Text('选择模型'), children: [ const SimpleDialogOption( child: Text('尽量使用 hunyuan-lite 模型，其他两个模型需要收费'), ), SimpleDialogOption( child: Row( children: [ const Text('hunyuan-lite'), if (state.modelVersion == 0) ...[const Icon(Icons.check)] ], ), onPressed: () { state.modelVersion = 0; state.messages = []; update(); Get.back(); }, ), SimpleDialogOption( child: Row( children: [ const Text('hunyuan-standard'), if (state.modelVersion == 1) ...[const Icon(Icons.check)] ], ), onPressed: () { state.modelVersion = 1; state.messages = []; update(); Get.back(); }, ), SimpleDialogOption( child: Row( children: [ const Text('hunyuan-pro'), if (state.modelVersion == 2) ...[const Icon(Icons.check)] ], ), onPressed: () { state.modelVersion = 2; state.messages = []; update(); Get.back(); }, ) ], ); }); } }这里还为 ListView 添加了控制器，在收到完整的请求后，会自动滚动到底部，如果你不喜欢这样，可以去掉。
什么是可变字体（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 属性来说，查看源码会发现，Flutter 对于 FontWeight 的定义是 100-900 之间的整数值，就像下面这样。/// Thin, the least thick. static const FontWeight w100 = FontWeight._(0, 100); /// Extra-light. static const FontWeight w200 = FontWeight._(1, 200); /// Light. static const FontWeight w300 = FontWeight._(2, 300); /// Normal / regular / plain. static const FontWeight w400 = FontWeight._(3, 400); /// Medium. static const FontWeight w500 = FontWeight._(4, 500); /// Semi-bold. static const FontWeight w600 = FontWeight._(5, 600); /// Bold. static const FontWeight w700 = FontWeight._(6, 700); /// Extra-bold. static const FontWeight w800 = FontWeight._(7, 800); /// Black, the most thick. static const FontWeight w900 = FontWeight._(8, 900);也就是说，你不能通过 FontWeight 取一个非整数的值，比如 325。对于普通的字体，基本都遵循这样的规范，比如 300 表示 Light 字重，而 700 表示 Bold 字重，但是对于可变字体来说，不同字重的映射关系可能并不是这样的整数，就拿 MiSans VF 来说，不同字重对应的 FontWeight 是这样的。thin = FontVariation('wght', 150.0); extraLight = FontVariation('wght', 200.0); light = FontVariation('wght', 250.0); normal = FontVariation('wght', 305.0); regular = FontVariation('wght', 330.0); medium = FontVariation('wght', 380.0); demiBold = FontVariation('wght', 450.0); semiBold = FontVariation('wght', 520.0); bold = FontVariation('wght', 630.0); heavy = FontVariation('wght', 700.0);你可能注意到了这里的 FontVariation ，没错，这正是为可变字体准备的特征。FontVariation[scode type="yellow" size=""]如果想了解更多可变字体的特征，移步 Variable fonts guide[/scode]简单的说，可变字体引入了可变轴的概念，这也是为什么仅需要一个字体文件就可以代替多个字体的原因，目前常用的可变轴有五个，分别是字重，宽度，倾斜度，斜体和光学尺寸（MiSans 只支持字重一个可变轴）。对于字重来说，对应的可变轴标签为 wght ，需要注意的是，wght 的值是可以自定义的，不同于传统 FontWeight 100-900 之间整数的表示形式，只要在有效范围内的 wght 值，都能直接反应到字体上，对于 MiSans ，wght 的有效范围是 150-700。在定义好上面的 FontVariation 后，就可以在 TextStyle 中使用了，TextStyle 中的 fontVariation 是一个 List<FontVariation?> ，这样做的原因是，你可以同时定义多个可变轴。Text( '可变字体', style: TextStyle(fontVariations: [VFFontWeight.thin], fontSize: 52), ), Text( '可变字体', style: TextStyle(fontVariations: [VFFontWeight.extraLight], fontSize: 52), ), Text( '可变字体', style: TextStyle(fontVariations: [VFFontWeight.light], fontSize: 52), ), Text( '可变字体', style: TextStyle(fontVariations: [VFFontWeight.normal], fontSize: 52), ), Text( '可变字体', style: TextStyle(fontVariations: [VFFontWeight.regular], fontSize: 52), ), Text( '可变字体', style: TextStyle(fontVariations: [VFFontWeight.medium], fontSize: 52), ), Text( '可变字体', style: TextStyle(fontVariations: [VFFontWeight.demiBold], fontSize: 52), ), Text( '可变字体', style: TextStyle(fontVariations: [VFFontWeight.semiBold], fontSize: 52), ), Text( '可变字体', style: TextStyle(fontVariations: [VFFontWeight.bold], fontSize: 52), ), Text( '可变字体', style: TextStyle(fontVariations: [VFFontWeight.heavy], fontSize: 52), ),上面的代码定义了 10 个标准字重的字体，如果成功运行，在设备上是这样的。FontFeaturefont-feature 并不是可变字体的专利，其他的字体如果遵循 Opentype 规范也可以使用。MiSans 包含了多种特征，具体的特征可以在官网查看，FontFeature 的使用和上面的 FontVariation 类似，参数类型为 List<FontFeature?> ，假如想启用等宽数字，可以使用 FontFeature.enable('thum')。有什么用前面所提到的一些特征，好像传统字体也能做到，代价只是更大的体积而已，但其实可变字体有更多玩法可以使用。就拿字重来说，前面提到，可变字体的 wght 在有效范围内都是可以体现在字体上的，也就是说，你甚至可以将 wght 的值定义在一个动画曲线上，使得字体的字重可以动态改变，就想一个动画一样。这里我使用 Flutter 提供的 IntTween 将字重的范围 150-700 进行插值，同时使用了一个非线性的动画函数。animationController = AnimationController(vsync: this, duration: const Duration(seconds: 5), lowerBound: 0, upperBound: 1.0); var value = IntTween(begin: 150, end: 700).animate(CurvedAnimation(parent: animationController, curve: Curves.bounceInOut));调用动画之后，会得到一个在 5 秒钟之内，从 150-700 变化的一个非线性的值，Curves.bounceInOut 曲线的变化看起来像这样。将他应用到字体上会发生什么呢？有趣。