一、前言
做金融、支付、记账、电商类 Flutter 项目,小数精度丢失是典型的隐形线上风险。 后端正常返回0.1、9999999999.99,经过 Dart JSON 解析后,数据直接失真:
0.1→0.100000001421085489999999999.99→10000000000.0
线上对账异常、资产展示错误、结算金额偏差,这些都属于 P0 级故障。
网上多数文章只给出「DTO 用 String 接收、运算使用 Decimal 库」的结论,但很少讲清楚一个核心问题:为什么在 Model 层、业务层补救完全无效?精度问题究竟发生在哪一步?
本文结合实际踩坑案例、原理分析、完整可运行代码,告诉你:精度丢失发生在 JSON 解析瞬间,唯一根治手段是在网络层提前处理。
二、精度丢失现场:可直接复现案例
2.1 基础浮点数运算问题
dart
void main() { double a = 0.1; double b = 0.2; print(a + b); // 输出:0.30000000000000004 }2.2 普通单层 JSON 解析场景(业务高频踩坑)
dart
import 'dart:convert'; void main() { String responseStr = '{"amount": 9999999999.99, "price": 0.1}'; Map<String, dynamic> jsonData = jsonDecode(responseStr); double amount = jsonData["amount"]; double price = jsonData["price"]; print("原始解析-金额:$amount"); // 输出:10000000000.0 print("原始解析-单价:$price"); // 输出:0.10000000142108548 }2.3 嵌套 JSON 解析案例(实际项目主流结构)
真实接口大多采用嵌套 JSON 结构,精度问题同样存在:
dart
import 'dart:convert'; void main() { // 嵌套结构 JSON String nestedJson = ''' { "code": 200, "data": { "orderId": 10086112233445566, "totalAmount": 88888888.88, "goods": [ { "name": "理财产品", "unitPrice": 123.45 } ] } } '''; Map<String, dynamic> jsonData = jsonDecode(nestedJson); Map<String, dynamic> data = jsonData["data"]; print("订单总金额:${data["totalAmount"]}"); // 输出:88888888.87999999 print("商品单价:${data["goods"][0]["unitPrice"]}"); // 输出:123.44999999999999 }2.4 误区:解析后转 String 无法修复
很多开发者误以为解析完成后调用toString()就能还原精度,这是典型错误思路:
dart
// 解析后再转字符串,数据已经失真 String amountStr = jsonData["amount"].toString(); print("解析后转字符串:$amountStr"); // 误以为依旧输出 10000000000.0核心结论:一旦被解析为 double,精度就彻底损坏,后续任何类型转换都无法还原原始数值。
三、问题根源:Dart JSON 解析机制
Dart 内置jsonDecode有固定规则:
- JSON 中的
number类型,无论整数、小数,统一解析为 Dartdouble; - double 遵循IEEE 754 64 位双精度浮点数标准;
- 二进制存储特性决定:无法精确表示大部分十进制小数。
整个数据流拆解:后端原始数字(字符串形态)→jsonDecode 转为 double→精度丢失→存入 DTO
问题卡在解析环节,Model 层、业务层都属于 “事后补救”,为时已晚。
四、金融行业规范与本方案定位
4.1 金融行业标准规范
真正的金融级行业规范:服务端必须直接返回 String 类型
- 金额、费率、余额、高精度数值,后端必须返回
"100.00",而非100.00 - 这是最安全、最标准、最推荐的方案
4.2 本方案适用场景
本方案是:后端无法改造、接口无法调整时,App 侧的兜底适配方案适用于:
- 历史项目接口无法改动
- 第三方接口无法协调
- 跨团队协作成本高
- 必须前端独立解决
五、为什么必须在网络层处理?三大核心理由
只有网络层能拿到未解析的原始 JSON 字符串Dio 回调、Model 解析阶段,数据已经完成 JSON 解码,原始字符串被丢弃,没有修改机会。
解析动作不可逆浮点数精度丢失是永久性数据损坏,不存在修复算法,只能在解析之前干预。
全局统一管控,业务零侵入在网络层统一处理高精度字段,无需逐个修改接口、逐个适配 DTO,团队维护成本最低,符合工程化规范。
六、关键特性说明(重要)
6.1 后端已返回 String → 不会重复加引号
如果后端字段已经是字符串类型,例如:
json
{ "amount": "99.99" }网络层不会做任何处理,不会注入多余引号,不会出现 ""99.99"" 格式错误。
6.2 仅对数字类型自动加引号
只有当字段是number 数字类型时,才会自动包裹引号,保证兼容性与安全性。
七、主流解决方案横向对比
目前业内针对 Flutter/Dart 浮点精度问题共有 4 类主流方案,下表从改造成本、兼容性、侵入性、适用场景多维度对比,方便选型:
表格
| 解决方案 | 实现思路 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| 后端改造:数字统一返回字符串 | 接口侧将 number 改为 string 类型 | 前端零处理,最稳定 | 需协调后端、历史接口改造成本高、跨团队沟通成本大 | 金融规范首选、新项目 |
| 业务层转 Decimal 计算 | 解析为 double 后,借助 decimal 库二次转换运算 | 无需改动网络层 | 解析阶段已丢精度,转换无效;侵入业务代码 | 临时应急、非核心金额场景 |
| 正则表达式替换 JSON | 拿到原始字符串后,通过正则匹配数字并添加引号 | 实现简单、代码量少 | 无法兼容嵌套 JSON、转义字符、科学计数法,容错率极低 | 简单单层 JSON、内部测试接口 |
| 本文方案:Dio 转换器 + 状态机 | 网络层拦截原始流,状态机解析 + 指定字段白名单,后台 Isolate 处理 | 零后端改动、零业务侵入、兼容嵌套 / 转义字符、异常降级、性能优秀 | 需熟悉 Dio 扩展与字符状态机逻辑 | 后端无法改造时的线上项目(推荐) |
总结:后端能改 → 优先让后端返回 String;后端不能改 → 使用本方案。
八、最终方案:Dio 自定义转换器 + 状态机改写 JSON
方案思路
- 继承 Dio
BackgroundTransformer,拦截响应原始流; - 通过字符状态机扫描原始 JSON,根据配置的字段白名单;
- 对指定字段对应的数值自动包裹双引号,将
数字转为字符串字面量; - 借助
Isolate后台解析,避免主线程阻塞、卡顿 UI; - DTO 统一使用
String类型接收,全程保留原始精度。
完整实现代码
dart
import 'dart:convert'; import 'dart:isolate'; import 'package:dio/dio.dart'; /// 高精度 JSON 解析转换器 /// 解决 Dart/Flutter JSON 解析浮点数精度丢失问题 class HighPrecisionJsonTransformer extends BackgroundTransformer { HighPrecisionJsonTransformer(); @override Future<Object?> transformResponse( RequestOptions options, ResponseBody responseBody, ) async { final fields = options.extra['highPrecisionFields']; if (fields is List && fields.isNotEmpty && options.responseType == ResponseType.json) { final rawString = await utf8.decoder.bind(responseBody.stream).join(); try { final fieldNames = fields.cast<String>().toList(); // 后台 Isolate 执行解析,不阻塞 UI return Isolate.run(() => _parseHighPrecision(rawString, fieldNames)); } catch (e, _) { // 异常降级:使用原生解析 return jsonDecode(rawString); } } return super.transformResponse(options, responseBody); } } // 在子 Isolate 中执行 JSON 处理逻辑 Object? _parseHighPrecision(String raw, List<String> fields) { final fieldSet = fields.toSet(); final safeJson = _wrapFieldDecimals(raw, fieldSet); return jsonDecode(safeJson); } /// 状态机逐字符扫描,为指定字段的数值添加引号 String _wrapFieldDecimals(String raw, Set<String> fields) { if (fields.isEmpty) return raw; final buffer = StringBuffer(); final len = raw.length; int i = 0; String? lastKey; bool wrapNextDecimal = false; while (i < len) { final c = raw[i]; // 跳过空白字符 if (c == ' ' || c == '\n' || c == '\r' || c == '\t') { buffer.write(c); i++; continue; } // 处理 JSON 字符串 if (c == '"') { final (content, end) = _extractString(raw, i); buffer.write(raw.substring(i, end)); int j = end; while (j < len && _isWhitespace(raw[j])) { j++; } lastKey = (j < len && raw[j] == ':') ? content : null; wrapNextDecimal = false; i = end; continue; } // 冒号:标记下一个值是否需要转字符串 if (c == ':') { buffer.write(c); wrapNextDecimal = lastKey != null && fields.contains(lastKey); lastKey = null; i++; continue; } // 匹配数字并添加引号 if (_isNumberStart(c) && wrapNextDecimal) { final (numStr, end) = _extractNumber(raw, i); final hasDecimal = numStr.contains('.') || numStr.contains('e') || numStr.contains('E'); buffer.write(hasDecimal ? jsonEncode(numStr) : numStr); wrapNextDecimal = false; i = end; continue; } // 其他符号重置标记 wrapNextDecimal = false; if (c == '{' || c == '[') { lastKey = null; } buffer.write(c); i++; } return buffer.toString(); } /// 提取 JSON 字符串内容 (String, int) _extractString(String raw, int start) { int i = start + 1; while (i < raw.length && raw[i] != '"') { if (raw[i] == '\\') i++; i++; } return (raw.substring(start + 1, i), i + 1); } /// 提取 JSON 数字内容(支持负数、小数、科学计数法) (String, int) _extractNumber(String raw, int start) { int i = start; if (raw[i] == '-') i++; while (i < raw.length && _isDigit(raw[i])) { i++; } if (i < raw.length && raw[i] == '.') { i++; while (i < raw.length && _isDigit(raw[i])) { i++; } } if (i < raw.length && (raw[i] == 'e' || raw[i] == 'E')) { i++; if (i < raw.length && (raw[i] == '+' || raw[i] == '-')) i++; while (i < raw.length && _isDigit(raw[i])) { i++; } } return (raw.substring(start, i), i); } bool _isWhitespace(String c) => c == ' ' || c == '\n' || c == '\r' || c == '\t'; bool _isDigit(String c) => c.codeUnitAt(0) >= 48 && c.codeUnitAt(0) <= 57; bool _isNumberStart(String c) => c == '-' || _isDigit(c);九、项目接入使用步骤
9.1 全局初始化 Dio
dart
final Dio dio = Dio(); dio.transformer = HighPrecisionJsonTransformer();9.2 接口请求时声明高精度字段
支持单层、嵌套结构内的同名字段,只需填写字段名即可:
dart
final response = await dio.get( "/api/order/info", options: Options( extra: { "highPrecisionFields": ["amount", "price", "rate", "totalMoney", "unitPrice"] }, ), );9.3 DTO 模型使用 String 接收字段
dart
class OrderDto { final String amount; final String price; OrderDto.fromJson(Map<String, dynamic> json) : amount = json["amount"], price = json["price"]; }十、方案效果对比
- ❌ 原生解析:
9999999999.99→10000000000.0(精度丢失) - ✅ 网络层预处理:
9999999999.99→"9999999999.99"(完整保留)
针对上文嵌套 JSON 案例,处理后效果:totalAmount: 88888888.88→ 解析为字符串,数值完全无失真。
十一、方案优势总结
- 遵循金融规范:后端能改优先让后端返回 String,本方案为兜底适配
- 安全兼容:后端已返回 String 不会重复加引号
- 零后端改造:无需协调后端改接口,前端独立完成适配
- 零业务侵入:仅修改网络层配置,原有业务代码、逻辑无需改动
- 状态机解析,稳定可靠:兼容嵌套 JSON、转义字符、科学计数法,优于正则方案
- Isolate 后台处理,不卡 UI:大数据量响应也不会造成页面卡顿
- 异常自动降级:解析异常时切回原生逻辑,保障线上可用性
十二、已知限制
当前实现基于字段名精确匹配,暂不支持data.totalAmount这类嵌套路径写法;若不同层级存在同名字段,会统一做字符串转换,该设计可满足绝大多数金融项目需求。
十三、适用场景
金融理财、支付钱包、电商结算、汇率换算、股票基金、GPS 经纬度等对小数精度要求极高的 Flutter 项目。
十四、结尾
本文方案是线上项目落地验证过的工业级解法,彻底解决 Flutter/Dart JSON 解析精度顽疾。金融规范首选后端下发 String,本方案为后端无法改造时的 App 兜底最佳实践。
如果你正在开发金融类 Flutter 应用,可直接复制代码接入使用。
个人开源项目推荐
专注 Android / Flutter 金融项目工程化、高精度规范实践:
- https://github.com/brycegao/invest-record-pro
- https://github.com/brycegao/android-finance-spec
欢迎 Star、交流探讨