一、前言
做金融、支付、记账、电商类 Flutter 项目,小数精度丢失 是典型的隐形线上风险。 后端正常返回 0.1、9999999999.99,经过 Dart JSON 解析后,数据直接失真:
0.1→0.100000001421085489999999999.99→10000000000.0
线上对账异常、资产展示错误、结算金额偏差,这些都属于 P0 级故障。
网上多数文章只给出「DTO 用 String 接收、运算使用 Decimal 库」的结论,但很少讲清楚一个核心问题: 为什么在 Model 层、业务层补救完全无效?精度问题究竟发生在哪一步?
本文结合实际踩坑案例、原理分析、完整可运行代码,告诉你:精度丢失发生在 JSON 解析瞬间,唯一根治手段是在网络层提前处理。
二、精度丢失现场:可直接复现案例
2.1 基础浮点数运算问题
dart
bash
void main() {
double a = 0.1;
double b = 0.2;
print(a + b);
// 输出:0.30000000000000004
}
2.2 普通单层 JSON 解析场景(业务高频踩坑)
dart
bash
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
bash
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
bash
// 解析后再转字符串,数据已经失真
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
bash
{ "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
bash
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
bash
final Dio dio = Dio();
dio.transformer = HighPrecisionJsonTransformer();
9.2 接口请求时声明高精度字段
支持单层、嵌套结构内的同名字段,只需填写字段名即可:
dart
bash
final response = await dio.get(
"/api/order/info",
options: Options(
extra: {
"highPrecisionFields": ["amount", "price", "rate", "totalMoney", "unitPrice"]
},
),
);
9.3 DTO 模型使用 String 接收字段
dart
bash
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 金融项目工程化、高精度规范实践:
欢迎 Star、交流探讨
