汇率转换类的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 的生态用 DartDart 的强类型特性非常适合这种有明确约束的领域模型。我先定义货币对的不可变实体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; // JPY0, USD2, KWD3 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只在用户主动查询时按需拉取。这样既保证了核心体验又节省了流量和服务器压力。刷新实现的核心代码逻辑Futurevoid refreshRates(ListCurrencyPair 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 Futurevoid 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 MapString, 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 的地基也就稳了。