汇率转换类的App我做得不算少,但这次接到的任务有点特别:要在 HarmonyOS 6.0 上跑 Flutter,并且把“常用货币对”这个看似简单的功能做成真正贴合用户习惯的模块。项目代号 SwiftRate,核心不是那个花哨的实时图表,而是最不起眼也最容易被做砸的“常用货币对构建”。
说实话,很多人做汇率App,第一版都是“列表 + 搜索 + 输入数字”,能用就行。但当你的目标是适配鸿蒙生态、让 Flutter 跑出接近原生的体验时,你会发现“常用货币对”涉及的东西远不止一个数组:数据怎么存、怎么排序、怎么跟汇率源联动、怎么在弱网下降级、怎么处理浮点精度、怎么让用户在 2 秒内完成一次查询,每个环节都有坑。这篇就把我做 SwiftRate 时关于常用货币对的完整思路、代码结构、鸿蒙适配经验和踩坑记录一次性捋清楚。
1. 需求分析:为什么“常用货币对”是汇率App的核心战场
1.1 用户真实需求的三个层次
先拆需求。汇率转换App的用户分三类:海淘购物党、跨境出差人士、以及做外贸单据处理的人。这三类人的诉求差异很大,但对“常用货币对”的需求是一致的——他们要的不是一张包含 170 多种货币的全量表,而是进入 App 第一眼就能看到自己关心的那几组汇率。
我梳理出的用户真实需求分三个层次:
第一层是“快速查看”:用户打开 App,希望在 1 到 2 秒内看到 USD/CNY、EUR/CNY、JPY/CNY 这类固定组合的最新汇率。这一层考验的是启动速度、缓存命中率和 UI 渲染效率。
第二层是“灵活调整”:用户可能今天关心 USD/CNY,明天要处理 GBP/EUR,后天又要看 THB/CNY。他需要能方便地增删、排序、置顶货币对,而不是永远面对一套写死的组合。这一层考验数据结构设计与交互逻辑。
第三层是“场景感知”:比如用户在凌晨三点打开 App,他大概率是想看是否发生了大幅波动;用户如果常驻某个地区,他关注的本位币可能不一定是 RMB。这一层虽然我现在只做了雏形,但它为后续的智能推荐、历史趋势分析留出了扩展空间。
最初需求文档里只有一句“维护一个常用货币对列表”,但这三个层次的分析决定了后面的所有技术决策。如果你拿到了类似需求,先不要急着写 ListView,先把这层需求想透。
1.2 方案选型:本地构建还是服务端下发
明确了需求层次后,下一个问题是:常用货币对数据放哪里?
方案 A:写死在前端。简单粗暴,一个 const 数组完事。缺点很明显:无法动态调整,用户换了地区或者产品策略变了,只能发版更新。
方案 B:服务端下发,前端动态渲染。灵活,但当时项目没有配套的配置中心,为这一个功能搭一套下发通道有点重。
方案 C:本地数据库 + 启动时拉取云端配置覆盖。结合前两者优点,给用户一个开箱即用的默认组合,同时允许后续通过接口动态更新热门货币对。
我在 SwiftRate 里选的是方案 C,但数据库没有用 SQLite,而是用轻量级的键值存储组件,直接存 JSON 序列化后的货币对列表。为什么这么选?因为常用货币对的数据量很小,几十条撑死了,没有必要引入 SQLite 这样的重型依赖;键值存储读写速度足够快,序列化成本也低;而且 HashMap 式的存储天然适合按用户维度维护。
服务端下发的配置结构我设计了这样一个 JSON Schema 雏形:
{ "base": "CNY", "version": 32, "updatedAt": 1735689600, "pairs": [ { "from": "USD", "to": "CNY", "weight": 100 }, { "from": "EUR", "to": "CNY", "weight": 96 }, ... ] }这里的weight字段用于决定默认排序权重,前端收到后与本地数据合并:云端配置里 weight 高的排前面,本地用户手动置顶的永远在顶部。这样既保留了用户的个性化操作,又能让产品侧根据运营数据动态调整热门货币对。
1.3 数据模型设计要预留的坑
这个环节不是在写需求,也不是在绘图,而是在做工程取舍。我说两个当时纠结过的点:
一个是“Base Currency”(基准货币)要不要做成全局可变。有些用户习惯以 USD 为基准看其他货币,有些习惯以本地货币为基准。如果全局基准货币可变,那么所有货币对的含义就会跟着变,存储结构也要调整。最后我的做法是:常用货币对列表固定以用户设置的“本地货币”为基准,汇率换算时通过交叉汇率(cross rate)计算非基准货币之间的比率。这样数据结构简单,用户心智也清晰。
另一个是货币代码规范化。不同国家的用户输入的货币符号五花八门:美元有 USD、$、US$;人民币有 CNY、RMB、¥。在建数据模型时,必须以 ISO 4217 三位字母码作为唯一主键,符号只作为展示层映射。这句话说起来简单,但如果不提前设计好映射表,后面处理用户输入时会很痛苦。
2. 核心实现:数据结构、存储与汇率联动逻辑
2.1 用 Dart 写一个干净的 CurrencyPair 模型
Flutter 的生态用 Dart,Dart 的强类型特性非常适合这种有明确约束的领域模型。我先定义货币对的不可变实体:
enum CurrencyCode { usd('USD', '美元', '\$'), eur('EUR', '欧元', '€'), cny('CNY', '人民币', '¥'), jpy('JPY', '日元', '¥'), gbp('GBP', '英镑', '£'), // ... 仅列出高频币种 const CurrencyCode(this.code, this.zhName, this.symbol); final String code; final String zhName; final String symbol; }用枚举而不是字符串裸用,最大好处是编译期检查。万一某个用户手滑输入了 “usd” 小写,或者把 “EURO” 当成代码,枚举可以直接拦截。至于展示用的中文名称和货币符号,放在枚举里一起管,不会出现代码里到处散落着“美元”字样的情况。
货币对本身我用了一个不可变类:
@immutable class CurrencyPair { final CurrencyCode base; final CurrencyCode quote; final double rate; // 当前汇率 final int weight; // 默认排序权重 final DateTime updatedAt; const CurrencyPair({ required this.base, required this.quote, this.rate = 0, this.weight = 0, required this.updatedAt, }); CurrencyPair copyWith({ double? rate, DateTime? updatedAt, }) { return CurrencyPair( base: base, quote: quote, rate: rate ?? this.rate, weight: weight, updatedAt: updatedAt ?? this.updatedAt, ); } String get pairKey => '${base.code}/${quote.code}'; double get inverseRate => rate == 0 ? 0 : 1 / rate; }pairKey是很有用的一个 getter,作为 Map 的 key 和缓存键都非常方便。inverseRate用于反向展示,比如用户看的是 CNY/USD,但汇率源给的往往是 USD/CNY,直接取倒数即可。
2.2 存储层:如何设计本地缓存与预置数据
存储层我分了两套:一套是预置的默认货币对,放在 assets 里,作为首次启动的种子数据;一套是用户个性化配置,放在键值存储中,每次修改即时写入。
预置数据 assets/default_pairs.json 长这样:
{ "base": "CNY", "pairs": [ { "base": "USD", "quote": "CNY" }, { "base": "EUR", "quote": "CNY" }, { "base": "JPY", "quote": "CNY" }, { "base": "GBP", "quote": "CNY" }, { "base": "HKD", "quote": "CNY" } ] }首启动时,程序读取这段 JSON,写入内存缓存和键值存储。之所以不直接写死在 Dart 代码里,是为了以后产品想调整默认货币对时,不必改动代码逻辑,只需替换 assets 文件重发即可。
用户个性化数据的写入时机我定为:每次用户拖动排序、置顶、删除或新增货币对后,立即序列化写入键值存储,不搞防抖。原因很简单:排序这种操作的频率很低,用户不会像刷视频那样每秒动一次,每次操作后立即持久化,能避免进程被杀导致数据丢失。
存储的键名我建议带上版本号后缀:
static const kUserPairsKey = 'user_currency_pairs_v2';为什么要带 v2?缓存结构当前是纯 JSON 数组,但如果后续加了“分组”或“自定义别名”字段,v2 可以平滑地做迁移。之前见过太多团队在缓存迁移问题上栽跟头,存储键名从一开始带上版本号,是最省事的规避方式。
2.3 汇率的精度问题:double 只是权宜之计
做金融类 App,精度问题是绕不开的。我第一次实现时直接用 double 存汇率,结果在用 JPY 这种三位小数货币做转换时出现了奇怪的结果。比如 1 USD = 149.57 JPY,用户输入 100 美元,按理应该得到 14957 日元,但浮点运算可能得到 14956.99999。
这类问题的出路是:金额比较与展示用整数最小单位,或者至少在展示层用 Decimal 类型。
Dart 生态里没有Java的 BigDecimal 那样的内置类型,常见做法是:
int toMinorUnits(double amount, CurrencyCode currency) { final decimals = currency.fractionDigits; // JPY=0, USD=2, KWD=3 return (amount * pow(10, decimals)).round(); }把金额换算成最小单位整数后再运算,最后展示时再除以对应精度。SwiftRate 里我封装了一个MoneyConverter工具类:
class MoneyConverter { static String format({ required double amount, required CurrencyCode currency, int decimals = 2, }) { final minorUnits = (amount * pow(10, currency.fractionDigits)).round(); final formattedValue = minorUnits / pow(10, currency.fractionDigits); final fixedStr = formattedValue.toStringAsFixed(currency.fractionDigits); // 千分位格式化 final parts = fixedStr.split('.'); final intPart = parts[0]; final buf = StringBuffer(); for (int i = 0; i < intPart.length; i++) { if (i > 0 && (intPart.length - i) % 3 == 0) { buf.write(','); } buf.write(intPart[i]); } return currency.symbol + buf.toString() + (parts.length > 1 ? '.' + parts[1] : ''); } }这一层的工程量不大,但对用户体验的影响天差地别。你完全不想让用户看到“你转换了 100 美元,得到了 14956.9999999 日元”这种结果。
2.4 汇率源联动:常用货币对的刷新策略
常用货币对列表必须与汇率数据联动态:列表里展示的每一行都是一组“货币对 + 实时汇率”,不能把汇率与货币对结构完全割裂。
我在刷新策略上采用了三级:
- 应用启动时:先读本地缓存,页面立刻有数据,同时在后台拉取最新汇率,到达后刷新列表。用户体验上的感觉就是“秒开”。
- 启动超过 30 分钟:进入前台时触发静默刷新,防止汇率过期过久。
- 下拉刷新:用户主动刷新,更新全部常用货币对汇率。
这里有一个心思:常用货币对的刷新频率要不要跟全量刷新区分开?我的做法是:常用货币对所覆盖的货币(比如 USD、EUR、JPY、GBP、HKD)属于高频币种,对这些币种的汇率请求走短轮询,优先保证实时性;冷门币种(比如 KES、MUR)只在用户主动查询时按需拉取。这样既保证了核心体验,又节省了流量和服务器压力。
刷新实现的核心代码逻辑:
Future<void> refreshRates(List<CurrencyPair> pairs) async { final currencies = <String>{}; for (final p in pairs) { currencies.add(p.base.code); currencies.add(p.quote.code); } final fetched = await rateService.fetchRates( base: pairs.first.base.code, symbols: currencies.toList(), ); for (final pair in pairs) { final rate = fetched[pair.pairKey] ?? crossRate(fetched, pair.base.code, pair.quote.code); // 更新内存缓存 localCache[pair.pairKey] = pair.copyWith(rate: rate, updatedAt: DateTime.now()); } // 通知 UI 刷新 notifyListeners(); }交叉汇率crossRate就是为了处理 2.1 里提到的基准货币统一问题。假设用户常用货币对里有 EUR/GBP,但汇率源只返回了 EUR/USD 和 GBP/USD,那么 EUR/GBP 就需要通过交叉计算。这个逻辑放在服务层做,前端拿到标准化好的数据,避免 UI 层堆积业务计算。
3. Flutter 在 HarmonyOS 6.0 上的适配经验
3.1 鸿蒙端 Flutter 环境的搭建思路
HarmonyOS 6.0 跑 Flutter,这里要先说明一个前提:你不是把 Flutter 当作普通 Android/iOS 插件往壳里塞,而是要让 Flutter 能正常地在鸿蒙系统上渲染、进生命周期、调系统能力。
我搭环境时走过的流程供参考:
- 安装 DevEco Studio 与 HarmonyOS SDK,确认 API 版本支持 6.0;
- 拉取 Flutter 的鸿蒙适配分支,这套分支本质是把 Flutter Engine 的底层能力对接到了鸿蒙的图形与事件体系上;
- 创建一个新的 Flutter 工程后,为其添加鸿蒙原生壳工程,通过命令行执行构建任务,把 Flutter 产物打包进鸿蒙应用。
注意点:Flutter 工程与鸿蒙壳工程的版本对齐非常关键。如果 Flutter SDK 的版本高于适配分支支持的版本,可能遇到渲染异常或插件无法调用的问题。我建议记录下自己使用的确切版本组合,并把 SDK 地址冻结,不要随便升级。
3.2 生命周期与系统能力调用
做过移动端的人知道,App 从后台切回前台,涉及onResume、数据续期、动画恢复等逻辑。HarmonyOS 上 Flutter 的生命周期映射与 Android 略有差异,尤其在onPause和onStop的时机上。SwiftRate 中我的做法是:在鸿蒙原生侧通过生命周期回调显式通知 Flutter 引擎,而不是依赖 Flutter 框架自带的生命周期推导。
也就是说,原生代码里做了类似这样的事:
@Override protected void onResume() { super.onResume(); flutterEngine.getLifecycleChannel().appIsResumed(); }为什么大费周章?因为 Flutter 在鸿蒙上的生命周期通道不是在所有场景下都能自动感知窗口状态。我之前遇到过一个问题:App 退到后台再回来,汇率列表没有自动刷新,就是因为生命周期事件没有正确传递给 Flutter 层。后来显式通知之后,这个问题就消失了。
系统能力方面,比如请求网络权限、读取本地设置等,HarmonyOS 的权限模型有自己的逻辑,需要在module.json5里声明权限,同时通过鸿蒙原生侧做运行时授权,授权结果通过 MethodChannel 回调给 Dart。这个流程建议在项目初期就打通一套通用的消息通道,不要等用到时才临时去加。
3.3 构建产物与多端一致性
构建时最头疼的是产物管理。Flutter 在鸿蒙上的产物结构与 Android 不同,需要单独处理。我的经验是建立一套脚本,把 Flutter 构建产物统一复制到鸿蒙工程的固定目录下,然后由鸿蒙工程负责最终的打包签名。
多端一致性的另一个问题是字体渲染。鸿蒙系统的默认字体与 Android 不完全一致,导致中文和西文混排时行高可能出现细微差异。SwiftRate 中我显式指定了字体族,并对关键页面的文本渲染做了回归测试。这样的小细节通常不会在第一版暴露,但在实际用户设备上会显得“有点突兀”。
还有内存问题。Flutter 跑在鸿蒙上,如果打开多个页面且每个页面都持有大量的 Image 资源,内存压力会比原生应用高一些。汇率 App 的图标和旗帜资源尽量用矢量图或压缩后的点九图,我在这一点上吃足了苦头——最初用了几百 KB 的国旗 PNG,低端机上直接卡掉帧。
3.4 鸿蒙原生与 Flutter 混合栈的页面跳转
汇率 App 里的常用货币对管理页,其实有一个场景需要跳出 Flutter 页面,去调用鸿蒙原生的系统能力,比如应用设置跳转、权限引导页跳转。这时候必须走 MethodChannel 让原生侧启动一个Ability,而不是在 Dart 侧尝试通过自定义 URL scheme 模拟跳转。
我封装过这样一段结构:
class PlatformBridge { static const platform = MethodChannel('swiftrate/bridge'); static Future<void> openSystemSettings() async { try { await platform.invokeMethod('openAppSettings'); } on PlatformException catch (e) { // 处理异常 } } }原生侧收到openAppSettings后,通过 Ability 启动系统设置页。这个方法的优点是把平台差异完全隔离在桥层,Dart 侧感知不到底层系统是 HarmonyOS 还是 Android。
4. 常用货币对管理界面的交互设计
4.1 货币选择器:搜索、分组与最高频币种置顶
货币选择器是常用货币对功能里最核心的交互组件。用户需要从上百种货币里快速找到目标。我做了一个三层结构的弹层:
- 顶部搜索框:支持英文代码(USD)、中文名(美元)、货币符号($)三种方式搜索;
- 中间“高频区”:展示根据本地使用频率动态排名的前 6 个币种,需要重建索引,不要让用户每次翻到头;
- 底部全量列表:按洲际分组展示,收起未命中的分组,减少滚动长度。
高频区如何生成?我用了简单的加权计数:每次用户添加或使用一个货币对时,该货币的计数 +1,每周衰减一次权重。这样长期不用的冷门币种会自然退出高频区,符合用户习惯的演进。
4.2 长按拖动排序与置顶操作
排序交互是这类功能的体验分水岭。很多 App 的常用列表排序要么做得很深(进入管理页再编辑),要么做得很重(每次编辑弹出对话框)。SwiftRate 里我采用长按拖动手柄的方式,用户在主列表直接长按拖动,拖到顶部为置顶。
Flutter 里实现列表拖动排序,常见用ReorderableListView,但它在鸿蒙上的滚动性能和 Android 表现有细微差异。我在鸿蒙上测试发现手势识别有些滞后,为此做了一点调整:把拖动手柄区域扩大,并且给DragStartBehavior设置成start。
具体交互代码结构:
ReorderableListView.builder( buildDefaultDragHandles: false, onReorder: _handleReorder, itemBuilder: (context, index) { final item = pairs[index]; return ListTile( key: ValueKey(item.pairKey), leading: ReorderableDragStartListener( index: index, child: const Icon(Icons.drag_indicator), ), title: Text('${item.base.code}/${item.quote.code}'), subtitle: Text(MoneyConverter.format(amount: 1, currency: item.quote)), ); }, )4.3 添加货币对的防重复机制
这个点看似小事,但处理不好很蠢:用户在添加 USD/CNY 时发现列表里已经有了,怎么办?我的处理策略是,在点击确认时先比对pairKey集合,如果已存在,则弹出轻提示“该货币对已在列表中”,同时自动滚动到对应位置高亮闪烁两秒。这个反馈比单纯禁用按钮更有温度,用户也不会疑惑“我到底加没加上”。
新增货币对时的默认排序位置也有讲究。我默认把新添加的货币对插到列表次位,而不是最顶部——最顶部是用户手动置顶或最高权重的位置,新项强行占首会打乱用户的肌肉记忆。
4.4 列表项的信息密度与视觉层次
一行货币对展示什么信息是有取舍的。SwiftRate 目前的列表项布局:
- 左侧:基准货币代码 + 货币对应名(如“美元 / 人民币”)
- 中间:汇率数值,保留适当小数位(JPY 对应货币保留 0 位,美元保留 2 位,高精度币种保留 4 位)
- 右侧:更新时间与涨跌变化箭头
这个布局在 360dp 宽度的屏幕上也放得下,关键是控制前后幅度。涨跌变化我用了一个趋势箭头加颜色:绿色上涨、红色下跌(注意:不同市场的用户对红绿的认知不同,如果面向国际市场,要考虑可配置化,这里默认按国内习惯)。
4.5 冷启动策略:展示旧数据还是空白页
这是设计决策里争议较大的一个点。严格意义上,汇率是一种强时效数据,展示 3 小时前的数据是否误导用户?但从用户体验角度,冷启动页面上一片空白是更大的伤害。
我的方案是妥协:缓存数据展示,但必须明确标记“数据更新时间”。超过 24 小时未更新的数据,在列表顶部展示显著提示条“汇率数据更新于 X 小时前,请联网刷新”。超过 7 天的数据不做展示,进入加载页。
这既保证了用户体验的连续性,又不会让用户误以为看到的是实时汇率。做了这个标记后,用户投诉“汇率不准”的比例明显下降。
5. 踩坑实录:开发中遇到的高频问题
5.1 浮点精度问题在汇率类应用的“累计效应”
前面理论部分讲了精度问题,实际操作中它的影响是“累计效应”的。单个转换看起来差 0.01 无所谓,但如果用户用这个结果去记账、做汇总表,差异会被放大。
我在处理金额输入时用了一个小技巧:输入框的 TextInputFormatter 直接把用户输入的金额按字符串解析成整数分,而不是在onChanged时用double.parse。这样输入过程中就不会因为浮点误差导致 0.1 + 0.2 != 0.3 的尴尬展示。
class MoneyInputFormatter extends TextInputFormatter { @override TextEditingValue formatEditUpdate( TextEditingValue oldValue, TextEditingValue newValue, ) { final text = newValue.text; if (text == '') return newValue; // 仅允许数字和小数点,最多两位小数 final regex = RegExp(r'^\d{0,9}(\.\d{0,2})?$'); if (regex.hasMatch(text)) return newValue; return oldValue; } }5.2 汇率源接口返回字段不一致的兼容
不同汇率源服务的字段风格差异很大,有的返回rates.USD,有的返回rates["USD"],还有的把 base 字段放在外层。如果代码直接写死某个结构,换供应商时就会炸。
我的做法是在网络层和数据模型之间加一层映射:
class RateResponseAdapter { static Map<String, double> parse(dynamic json) { final rawRates = json['rates'] ?? json['data'] ?? json['conversion_rates']; final result = <String, double>{}; rawRates.forEach((key, value) { result[key.toUpperCase()] = double.parse(value.toString()); }); return result; } }这样上游返回的数据无论长什么样子,只要设计过分层,核心业务代码就不会改动。
5.3 鸿蒙上 Flutter 页面白屏与状态恢复问题
开发中遇到最典型的一次问题是:应用在鸿蒙上从后台恢复时,Flutter 页面偶尔白屏,必须杀掉进程才能恢复。排查后确认是 Engine 的 Surface 重建问题。
解决办法:在原生侧 Activity 的onRestart生命周期中,调用 Flutter Engine 的surface重建方法;同时避免在onStop时销毁 Engine,采用常驻引擎模式。汇率 App 本身不算重,保持一个常驻引擎对内存的影响可以接受。
白屏问题解决后,我又把启动时的缓存加载逻辑提前到了main()第一行,确保无论从什么分支进入首页,本地货币对数据都已经是就绪状态,而不依赖 UI 生命周期回调的先后顺序。
5.4 为什么“代码没问题”但鸿蒙上汇率刷新失败
这个是最玄学的坑。Dart 代码在 Android 上一切正常,到了鸿蒙上偶尔出现请求失败,错误信息还不具体。排查到最后发现是网络库在鸿蒙上的 CA 证书校验链和 Android 略有差异,部分接口的 HTTPS 证书没有被原生信任。
解决方案也直接:在网络层统一接入鸿蒙原生提供的网络能力,而不是继续使用 Dart 侧第三方网络库自带的证书逻辑。通过 MethodChannel 把请求转发到原生侧,原生侧完成请求后返回结果。这也能解决原生 HTTP/2、连接复用等问题。
5.5 数据缓存无法固化导致的“重启后排序丢失”
这个坑比较隐蔽:平时列表排序正常,但用户重启手机后,发现常用货币对回到了默认顺序。原因是键值存储在写入时没有显式调用 flush,进程被系统回收时部分写操作没有落盘。
解决方式:每次改动排序后不仅写内存,还要调用存储组件的 flush 方法,并在组件销毁时再次 flush。如果是异步写入,要做 await,不能只触发不等待。
6. 总结之外的经验补遗
最后再分享一个我在“常用货币对构建”上最重要的心得:不要试图用技术手段把所有用户场景都预判完全,而是设计出用户能自己调整的系统。SwiftRate 的常用货币对功能能做到现在这个可用度,不是因为我一开始就设计得多完美,而是因为排列组合的调整逻辑足够轻、足够直观,用户可以三秒内完成一次自定义。
另外一个可以扩展的方向是“智能排序”:把汇率波动幅度、用户查询频率、时间特征(比如工作日与周末)加入排序因子,让常用货币对列表不只是“用户手动维护的静态列表”,而是一个“越用越懂你的动态面板”。目前我在 SwiftRate 里只做了基础版,但这个方向值得后续迭代。
如果你也要在鸿蒙上做 Flutter 汇率类应用,建议从常用货币对这个小功能切入。它涉及数据建模、本地存储、网络刷新、跨端适配、交互设计、异常处理,几乎覆盖了一个完整移动应用的全部技术要点,而且工程量适中,特别适合做技术沉淀。把这套做好了,整个 App 的地基也就稳了。