做适配第三方接口或者维护老Unity项目时,这行红字应该能唤起不少人的回忆:JsonException: Can‘t assign value ‘xxx‘ (type System.Double) to type System.Int64 at LitJson.JsonMapper。尤其当项目里用的是LitJson做JSON反序列化,线上数据一更新,这种异常就冒出来了。它不是什么网络问题,也不是JSON格式非法,而是典型的“JSON里的值能解析出来,但塞不进你声明的字段类型里”。这篇文章我会把这条异常从现象到根因掰开讲透,再把我在实际项目里用过的几种靠谱修复方案按优先级列出来。遇到同类问题的朋友,可以直接照着排查、照着改。
1. 拿到异常先别慌:拆解这条报错到底在说什么
1.1 LitJson 是什么,为什么这个异常特别常见
LitJson是一个轻量级的JSON解析与生成库,在Unity客户端和老一代.NET项目中出镜率非常高。相比自带JSON工具,它支持字典、数组、嵌套对象,不需要到处打特性标记,还能通过JsonData做动态读写,很多团队在项目初期会直接引入它。代价就是它在类型转换上非常严格,一旦目标字段类型和JSON里值的实际运行时类型不匹配,就会直接抛JsonException。
这个异常最常出现在两类场景:第一类是服务端接口因为历史原因返回了带小数点形式的整数;第二类是客户端在定义数据模型时,把整数字段写成了long或int,而对方序列化时把同名字段浮点了。你做客户端时通常拿不到服务端的实际输出样本,只能靠日志里的异常信息反推,所以第一步不是猜,而是把异常文本拆开看。
1.2 从异常消息里挖出三条关键信息
LitJson的报错信息其实已经把病历写得很清楚了,只是很多人扫一眼就被JsonException吓住了。我们拿原始消息逐段看:
Can‘t assign value ‘xxx‘:这里面的xxx就是从JSON里解析出来的那个值,通常是一个看起来不吓人的数字,比如100或12.5。(type System.Double):这个值是Double类型,说明它在LitJson内部的运行时类型不是整数,而是浮点数。to type System.Int64:你写的目标字段类型是long,也就是C#里的System.Int64。at LitJson.JsonMapper:抛出异常的代码位于LitJson的JsonMapper类,负责把JSON文本映射到强类型对象。
这句话翻译成人话就是:LitJson在把JSON里的一个Double类型的值写入一个long类型的字段时,发现自己没法安全完成这个赋值,于是选择直接抛异常。为什么它不帮你自动转换?因为这里存在精度和范围风险,C#的强类型机制不允许double隐式转long,LitJson默认也选择稳妥处理。
1.3 数值字面量如何被“定性”:100 为什么会变成 Double
JSON规范里其实只有一种数字类型,不像C#区分int、long、double。LitJson解析数字时,会根据字面量的写法推断具体运行时类型。如果数字写成100,大概率解析成整数类型;但如果写成100.0、100.00、1e2这种,解析结果就会变成Double。也就是说,同样数值的100,只要对端序列化框架多输出一个.0,在你这边就从整数变成浮点了。
更坑的是很多序列化框架会把数据库里的decimal、float、double类型字段统一输出成带小数点的形式。比如一个字段在文档里写着“总数”,实际JSON却是"totalCount": 100.0。你一旦把这个字段声明成long,反序列化时就会踩中这条异常。这也是为什么很多人觉得奇怪:我明明看到日志里打出来的值是100,怎么会是Double?因为100.0打印出来通常也显示成100或100,可它的运行时类型已经变了。
2. 为什么会类型不匹配:C#类型系统与LitJson赋值机制的底层逻辑
2.1 C#里double与long的转换关系
要理解这条异常,绕不开C#本身的类型转换规则。double到long不存在隐式转换,因为这种转换可能丢失小数部分,也可能在数值超过9007199254740992(2的53次方)后产生精度误差,C#语言设计上就把它列为“可能不安全”的转换。你可以手动用(long)value强制转换,也可以调用Convert.ToInt64(value),但要做的时候必须明确接受潜在精度损失。
LitJson走的是反射路线,最终要靠FieldInfo.SetValue或者类似机制把值写进字段。反射赋值不会帮你在double和long之间做强制转换,它只认类型是否兼容。不兼容时,LitJson自己有一套数值转换逻辑,尝试在数值类型之间做隐式转换;当它发现从Double到Int64无法近似安全转换,也没有注册过专门的导入器时,就直接抛出JsonException。这段逻辑不是.NET框架管的事,而是LitJson内部自己判断的。
2.2 LitJson赋值路径走到哪一步抛的
正常反序列化流程大致是:读JSON文本,按语法生成内部表示,然后通过反射把内部数据依次填充到目标对象的字段和属性上。填充阶段会根据字段类型做类型检查,比如当前值是Double而字段类型是Int64,LitJson会先查有没有为Double -> Int64注册过转换器。查不到,就尝试C#的隐式转换规则。两者都不满足,代码直接进入异常分支,抛出JsonException。
这个机制的正面效果是严格,能帮你在开发期发现契约不一致;负面效果是线上场景里一旦某个接口悄悄改了数值形态,客户端就崩给你看。理解了赋值路径,你就知道修复思路本质上只有三条路:改字段类型、让JSON里不再出现浮点形态、或者在LitJson的转换链条上挂上自定义转换器。
2.3 实际业务里最容易触发异常的三大来源
根据我处理过的项目反馈,触发这个异常的原因基本集中在三类:
- 服务端字段类型是浮点型。比如数据库里存的是
decimal或double,服务端模型也定义成浮点类型,序列化出来自然就是123.4或100.0。 - 第三方接口文档不严谨。文档上写着“整数ID”,但实际返回的却是
"id": 100.0,这类接口尤其坑人,因为你不抓原始响应很难发现。 - 聚合统计或计算字段。后端把两个整数做除法后直接返回,比如
"avg": 7.5,但客户端模型里还定义成long。
这三种情况都有一个共同点:问题不在客户端解析代码写错,而在于数据契约和消费端模型没对上。所以排查时一定要拿到原始JSON,光看异常消息里的value往往不够。
2.4 精度边界:long和double各自的地盘
既然有人会想到“那我把所有整数字段都改成double不就行了”,这里得提醒一个精度问题。long的范围是-9223372036854775808到9223372036854775807,而double虽然能表示更大的指数范围,但它精确表示整数的上限只有2^53,也就是9007199254740992。超过这个数的整数,double就会产生舍入误差。
如果业务字段是订单号、用户ID这种通常不超过2^53的数值,改成double接收问题不大;但如果碰到超过这个范围的ID、金额、计数值,无脑改成double就是在埋雷。另外浮点数的相等比较也不可靠,后续如果拿这个字段做判断,得用Math.Abs(a - b) < 1e-9之类的容差写法,不能==直接比。
3. 五种能直接落地的修复方案(含代码示例和对比)
3.1 方案一:把目标字段改成Double或Float(最低成本)
如果字段在业务上本来就允许小数,或者你明确知道数值范围不大,最简单的办法就是把字段类型从long改成double。比如原始定义是:
public class DailyReport { public long totalCount; public long avgScore; }改成:
public class DailyReport { public double totalCount; public double avgScore; }这样JSON里的100.0能顺利进入totalCount,7.5也能进入avgScore,异常直接消失。改完后需要注意,使用该字段的地方如果原本按整数逻辑处理,比如拿它做循环次数、数组下标、取余运算,都要先转回整数。这个方案适合字段语义本来就带小数的场景,不适合大数据量的精确主键。
3.2 方案二:用JsonMapper.RegisterImporter做全局兜底(推荐)
LitJson其实预留了类型导入器的扩展点,你可以在程序启动时给Double -> Int64注册一个转换函数。这就是JsonMapper.RegisterImporter,注册之后,反序列化过程中遇到从double到long的赋值,会直接调用你提供的逻辑。示例代码如下:
using LitJson; public static class LitJsonCompat { public static void RegisterAll() { JsonMapper.RegisterImporter<double, long>(v => Convert.ToInt64(v)); JsonMapper.RegisterImporter<double, int>(v => Convert.ToInt32(v)); JsonMapper.RegisterImporter<double, short>(v => Convert.ToInt16(v)); JsonMapper.RegisterImporter<double, decimal>(v => Convert.ToDecimal(v)); } }注册时机越早越好,建议放在程序入口处,比如Unity的Awake、控制台项目的Main、或者框架的初始化方法里。注册一次全局生效,后续所有通过JsonMapper.ToObject<T>进行的反序列化都会遵守这套转换规则。
这里有一个细节必须提醒:Convert.ToInt64(double)和(long)double的舍入策略不一样。(long)直接截断,2.99会变成2;Convert.ToInt64(2.99)会四舍五入成3。而且Convert.ToInt64在边界上遵循银行家舍入,比如2.5会转成2,3.5会转成4,并不总是“五入”。如果你希望用截断逻辑,就写成v => (long)v,别让团队里不同人的理解产生分歧。
我还建议在importer里做一点防御:
JsonMapper.RegisterImporter<double, long>(v => { if (double.IsNaN(v) || double.IsInfinity(v)) return 0L; return Convert.ToInt64(v); });这样即使JSON里出现NaN、Infinity这种异常浮点值,也不会把主流程带崩。
3.3 方案三:中间层接住再手动转换(最保守)
有些项目不想引入全局转换规则,担心影响其他契约。这时可以用一个“中间接收类”来兜底。先定义接收类时用宽松类型,反序列化成功后手动转成业务对象。例如把字段声明成double或LitJson自带的JsonData,再手工强转。
如果你的目标是尽量保留原始表达能力,可以把字段定义成JsonData:
using LitJson; public class RawReport { public JsonData totalCount; } // 反序列化 RawReport raw = JsonMapper.ToObject<RawReport>(json); // 手动转换 long total = Convert.ToInt64(double.Parse(raw.totalCount.ToString()));注意这里不能直接Convert.ToInt64(raw.totalCount.ToString()),因为如果字符串是"100.0",Convert.ToInt64(string)会直接抛FormatException。正确做法是先过一遍double.Parse或Convert.ToDouble,再转成整数。这个细节我见过不少同事踩坑,代码抄过去之后又多了个新异常。
3.4 方案四:治本——推动服务端修正输出类型(根除型)
如果你能影响服务端接口,这个方案最值得推进。做法是先抓取接口原始响应,确认返回的JSON里确实是"totalCount": 100.0而不是"totalCount": 100,然后拿着证据让服务端同事把DTO里对应字段类型从decimal、float、double改成int或long。
为什么强调先抓原始响应?因为很多客户端代码里会二次处理,比如有人用别的解析库先把JSON转成中间对象,再打印出来看就成了100,误导排查方向。正确的证据链应该是:直接用网络层打印原始响应文本,或者用文本编辑器看抓包内容。一旦确认服务端输出了.0,这个问题就好解决了。契约规范里也可以增加一条约定:语义为整数的字段,序列化结果不允许出现小数点。
3.5 方案五:字符串字段接收后按需转换(最保险但最繁琐)
如果对接的是完全不可控的外部接口,或者对方接口一天变三次,那最保险的做法是先用string接收,业务层再做转换。比如:
public class SafeData { public string totalCount; public string message; } // 使用的时候 long total = Convert.ToInt64(double.Parse(safe.totalCount));这样无论对方返回100、100.0还是"100",字符串字段都不会抛JsonException。代价是每个数字字段都要多写一次转换,代码量变大,而且需要明确约定“解析失败时怎么处理”。这个方案适合小范围使用,比如只针对几个问题字段,不建议整个数据模型全部用string,否则维护成本会失控。
3.6 方案对比总览
| 方案 | 改造成本 | 风险点 | 适用场景 |
|---|---|---|---|
| 字段改double | 低 | 大整数精度丢失,浮点比较问题 | 字段语义本来就是小数,数值范围可控 |
| RegisterImporter | 中 | 全局生效,舍入策略需统一 | 历史接口多,想一次性兜底 |
| 中间层手动转换 | 中 | 每处都要手动处理,容易漏转 | 字段少、结构不稳定、不想影响全局 |
| 推动服务端修正 | 低(客户端侧) | 依赖对方配合,联调周期不定 | 接口协议可控,有契约评审 |
| string接收 | 低到中 | 代码繁琐,后续使用要反复转换 | 外部接口混乱,字段量少 |
4. 实操记录:从一个最小复现工程到彻底修复
4.1 最小复现工程搭建
为了验证解决方案,最直接的办法就是复现。这里我写一个最简控制台例子,模拟服务端返回了带小数点形式的JSON:
using System; using LitJson; public class DailyReport { public long totalCount; public long onlineCount; } public class Program { public static void Main() { string json = "{\"totalCount\": 100, \"onlineCount\": 100.0}"; DailyReport report = JsonMapper.ToObject<DailyReport>(json); Console.WriteLine(report.totalCount); } }执行时,totalCount能正常解析,但onlineCount会触发JsonException。因为JSON里的100.0被LitJson解析成Double,而字段类型是Int64。
4.2 用RegisterImporter修复的完整步骤
第一步,新建一个静态类,集中注册所有项目需要的转换器:
public static class LitJsonCompat { public static void RegisterAll() { JsonMapper.RegisterImporter<double, long>(v => { if (double.IsNaN(v) || double.IsInfinity(v)) return 0L; return Convert.ToInt64(v); }); JsonMapper.RegisterImporter<double, int>(v => { if (double.IsNaN(v) || double.IsInfinity(v)) return 0; return Convert.ToInt32(v); }); } }第二步,在程序入口调用注册方法:
public static void Main() { LitJsonCompat.RegisterAll(); string json = "{\"totalCount\": 100, \"onlineCount\": 100.0}"; DailyReport report = JsonMapper.ToObject<DailyReport>(json); Console.WriteLine(report.onlineCount); }第三步,重新运行,确认异常消失,onlineCount被转换成100。如果后续接口返回了98.5这样的真正小数,它也会被四舍五入成整数,这个取舍要在业务层面确认可接受。
4.3 验证要点与回归测试
修复不是“能跑就行”。我建议至少用下面几组JSON做验证,防止其他边界问题:
- 纯整数:
"score": 98,验证普通整数正常。 - 带一位小数:
"score": 98.0,验证本次异常场景。 - 真正的小数:
"score": 98.6,验证转换策略是否符合预期。 - 科学计数法:
"score": 9.86E1,验证指数形式不会引发新异常。 - 极端值:
"score": 9.22E18,观察是否溢出long范围。 - 字符串数字:
"score": "98",确认string到long不在importer范围内时,会报另一种异常。
如果目标是生产级,把这些case写成单元测试,在CI里跑一遍,后续接口再变动时能快速发现回归。
4.4 性能实测与注意事项
有人担心注册importer会影响性能。实测中,RegisterImporter的委托调用开销相比反射和JSON解析本身小到可以忽略,真正耗时还是在解析和对象构造上。要注意的是不要在热更新或业务循环里反复注册同一个转换器,尽量在启动阶段一次性注册完。不同转换器之间如果逻辑有冲突,后注册的会覆盖先注册的,所以团队里要约定好统一入口,避免两处代码各注册一套规则。
如果项目的LitJson是源码引入而不是DLL,你还可以直接在JsonMapper.cs的数值转换分支里做手动兼容。但这样一来库的代码就和业务耦合了,升级Lib版本时容易忘记合并,我一般不太推荐,除非你是那个库的唯一维护者。
5. 常见变体与排查技巧实录
5.1 这个异常最常见的五种变体
这个异常不只是Double -> Int64一种形态,把目标字段改成别的整数类型还会出现其他变体。整理了一张表,方便对照:
| 异常消息里的类型组合 | 常见原因 | 建议处理 |
|---|---|---|
| Double -> Int64 | 数值带小数点或科学计数法 | RegisterImporter或改double |
| Double -> Int32 | 字段声明成int,数值是浮点 | 同上,注册double到int |
| String -> Int64 | JSON里是"123"带引号 | 服务端不要引号,或字段用string再接 |
| JsonData -> Int64 | 字段类型不明确,LitJson包装成JsonData | 中间层手动转换 |
| Null -> Int64 | JSON里是null,字段是值类型 | 字段改成可空类型long?或做默认值处理 |
5.2 排查动作:怎么在日志里快速锁定罪魁祸首
我排查这类问题通常三步走。第一步,把抛出异常的json原文和type一起打出来,不要只打异常消息。第二步,搜索异常消息里的value值,确认它在JSON中的位置。第三步,打开目标类的定义,逐个数字字段核对类型。
为了省事,可以写一个包装方法,把原始JSON信息带在日志里:
public static T SafeToObject<T>(string json) { try { return JsonMapper.ToObject<T>(json); } catch (JsonException ex) { UnityEngine.Debug.LogError($"JSON解析失败,目标类型: {typeof(T).Name}, 原始JSON: {json}"); throw; } }这样再看到异常时,上下文信息齐全,不用再翻网络层日志。
5.3 多个JSON库混用的坑
一个项目里同时存在LitJson和其他序列化库的情况并不少见,比如Unity自带工具、LitJson、第三方强类型库共存。混用的最大问题是同一份JSON在不同库里的行为不一致。有些库自动把100.0转给long字段,LitJson却会抛异常;有些库对Long类型字段赋值宽松,LitJson又很严格。所以排查时要先搞清楚当前代码到底走的哪个库、哪条解析路径,别拿着一份LibA的行为去推理LibB的结果。
5.4 预防习惯与管理规范
要我总结预防经验,核心只有一条:数据契约测试。每次接入新接口时,拿到一份真实响应样本,把它保存成测试资源,跑一遍反序列化用例。这个动作成本很低,但能挡住大量线上崩溃。其次,DTO定义阶段就要对整数字段类型达成共识,遇到统计类、计算类字段时优先考虑double或decimal,不要默认写long。最后,代码评审时盯一下RegisterImporter的舍入策略,Convert和(long)的区别必须写清楚,免得大家各写各的。
最后分享一个小习惯。我处理过一个对接需求,对方接口文档里写“库存数量为整数”,实际返回却是"stock": 100.0,导致客户端一整天都在报这个异常。后来我把库存字段改成double,又在服务端流程上推动对方修了序列化配置。从那以后,我接任何接口都会先问一句:整数字段能保证不带小数点吗?然后把这条写进对接清单。别小看这个细节,它能帮你省下后面无数个“为什么线上又崩了”的下午。