Flutter Dart JSON 解析必坑!金额精度丢失为什么必须在网络层处理?附工业级解决方案

一、前言

做金融、支付、记账、电商类 Flutter 项目,小数精度丢失 是典型的隐形线上风险。 后端正常返回 0.19999999999.99,经过 Dart JSON 解析后,数据直接失真:

  • 0.10.10000000142108548
  • 9999999999.9910000000000.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 有固定规则:

  1. JSON 中的 number 类型,无论整数、小数,统一解析为 Dart double
  2. double 遵循 IEEE 754 64 位双精度浮点数 标准;
  3. 二进制存储特性决定:无法精确表示大部分十进制小数。

整个数据流拆解: 后端原始数字(字符串形态)jsonDecode 转为 double精度丢失存入 DTO

问题卡在解析环节,Model 层、业务层都属于 "事后补救",为时已晚。

四、金融行业规范与本方案定位

4.1 金融行业标准规范

真正的金融级行业规范:服务端必须直接返回 String 类型

  • 金额、费率、余额、高精度数值,后端必须返回 "100.00",而非 100.00
  • 这是最安全、最标准、最推荐的方案

4.2 本方案适用场景

本方案是:后端无法改造、接口无法调整时,App 侧的兜底适配方案 适用于:

  • 历史项目接口无法改动
  • 第三方接口无法协调
  • 跨团队协作成本高
  • 必须前端独立解决

五、为什么必须在网络层处理?三大核心理由

  1. 只有网络层能拿到未解析的原始 JSON 字符串 Dio 回调、Model 解析阶段,数据已经完成 JSON 解码,原始字符串被丢弃,没有修改机会。

  2. 解析动作不可逆 浮点数精度丢失是永久性数据损坏,不存在修复算法,只能在解析之前干预。

  3. 全局统一管控,业务零侵入 在网络层统一处理高精度字段,无需逐个修改接口、逐个适配 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

方案思路

  1. 继承 Dio BackgroundTransformer,拦截响应原始流;
  2. 通过字符状态机扫描原始 JSON,根据配置的字段白名单;
  3. 对指定字段对应的数值自动包裹双引号,将 数字 转为 字符串字面量
  4. 借助 Isolate 后台解析,避免主线程阻塞、卡顿 UI;
  5. 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.9910000000000.0(精度丢失)
  • ✅ 网络层预处理:9999999999.99"9999999999.99"(完整保留)

针对上文嵌套 JSON 案例,处理后效果: totalAmount: 88888888.88 → 解析为字符串,数值完全无失真。

十一、方案优势总结

  1. 遵循金融规范:后端能改优先让后端返回 String,本方案为兜底适配
  2. 安全兼容:后端已返回 String 不会重复加引号
  3. 零后端改造:无需协调后端改接口,前端独立完成适配
  4. 零业务侵入:仅修改网络层配置,原有业务代码、逻辑无需改动
  5. 状态机解析,稳定可靠:兼容嵌套 JSON、转义字符、科学计数法,优于正则方案
  6. Isolate 后台处理,不卡 UI:大数据量响应也不会造成页面卡顿
  7. 异常自动降级:解析异常时切回原生逻辑,保障线上可用性

十二、已知限制

当前实现基于字段名精确匹配 ,暂不支持 data.totalAmount 这类嵌套路径写法;若不同层级存在同名字段,会统一做字符串转换,该设计可满足绝大多数金融项目需求。

十三、适用场景

金融理财、支付钱包、电商结算、汇率换算、股票基金、GPS 经纬度等对小数精度要求极高的 Flutter 项目。

十四、结尾

本文方案是线上项目落地验证过的工业级解法,彻底解决 Flutter/Dart JSON 解析精度顽疾。 金融规范首选后端下发 String,本方案为后端无法改造时的 App 兜底最佳实践。

如果你正在开发金融类 Flutter 应用,可直接复制代码接入使用。

个人开源项目推荐

专注 Android / Flutter 金融项目工程化、高精度规范实践:

  1. github.com/brycegao/in...
  2. github.com/brycegao/an...

欢迎 Star、交流探讨

相关推荐
tangweiguo030519871 小时前
Flutter GetIt 完全指南:告别 BuildContext 依赖的终极方案
flutter
韩曙亮2 小时前
【Flutter】Flutter 中的 Android / iOS 特殊配置 ① ( 网络权限配置 | HTTP 明文传输配置 | 应用名称配置 )
android·网络·flutter·http·ios·网络权限
tangweiguo030519872 小时前
Flutter中的StreamController完全指南
flutter
hxy06013 小时前
Flutter showModalBottomSheet等弹窗宽度问题
前端·flutter
张3蜂5 小时前
Flutter Hello World!实践
flutter
恋猫de小郭1 天前
Android 官方给 Compose 搞了个不需要 UI 环境的 Composable
android·前端·flutter
喵了几个咪1 天前
基于 Flutter 的 Headless CMS 全平台前端架构:技术解析与二次开发导引
前端·flutter·架构
恋猫de小郭1 天前
真正的跨平台 AI 自动化框架,甚至还支持鸿蒙
android·前端·flutter
喵个咪2 天前
基于 Flutter 的 Headless CMS 全平台前端架构:技术解析与二次开发导引
前端·flutter·cms