iOS Xcode26 升级后 Newtonsoft 协议 JSON 解析 NRE
现象
Xcode 升级到 26(新 Apple 链接器)之后,原本正常的收包协议 JSON 解析开始报 NullReferenceException,崩溃点在 Newtonsoft 的序列化回调扫描:
NullReferenceException
at Newtonsoft.Json.Serialization.DefaultContractResolver.ResolveCallbackMethods (Newtonsoft.Json.Serialization.JsonContract contract, System.Type t)
at Newtonsoft.Json.Serialization.DefaultContractResolver.InitializeContract (Newtonsoft.Json.Serialization.JsonContract contract)
at Newtonsoft.Json.Serialization.DefaultContractResolver.CreateObjectContract (System.Type objectType)
at Newtonsoft.Json.Serialization.DefaultContractResolver.ResolveContract (System.Type type)
at Newtonsoft.Json.Serialization.JsonSerializerInternalReader.Populate (Newtonsoft.Json.JsonReader reader, System.Object target)
at Newtonsoft.Json.JsonSerializer.PopulateInternal (Newtonsoft.Json.JsonReader reader, System.Object target)
at GameCore.PacketBody.Populate[T] (T target, System.String propertyName)
at GameCore.NetResMediator.HandleNetworkResource (GameCore.PacketBody packetBody)
at GameCore.Connection.OnRecvPacket (System.Byte[] buffer, System.Int32 length)
at GameCore.TlsSocket.Update ()
触发场景:协议交互时服务端下发 xxxMember.UserRecords: {} 空对象,收包路径解析即崩。
影响范围:只有 iOS 崩溃。Android、以及旧版 Xcode(16.x) 打的 iOS 包全部正常 —— 是 IL2CPP + 新 Xcode 的兼容性问题,不是协议或业务逻辑问题。
环境
- 引擎版本:Unity 2022.3.62f2(IL2CPP,
managedStrippingLevel保持 Low) - 平台 / 设备:iOS,Xcode 26
- 复现概率:进特定模块会触发
- Newtonsoft.Json 库版本:13.0.3
根因
提交注释里的初始假设(不成立)
SafeContractResolver.cs 注释最初假设:Xcode 26 新链接器 ld-prime 比 ld64 更激进地剥掉序列化回调特性的元数据 → typeof(OnDeserializedAttribute) 返回 null → IsValidCallback 第一条指令 attributeType.IsDefined(...) 无 null 防护 → NRE。
实际根因(读导出 C++ 确认)
DefaultContractResolver.GetCallbackMethodsForType 里 type.GetMethods(DeclaredOnly|Instance|Public|NonPublic) 返回的数组里出现 null 元素,Newtonsoft 取出后直接调 method.ContainsGenericParameters 而无 null 防护(导出 C++ Newtonsoft.Json__4.cpp 的 NullCheck(L_18) 处崩溃)。
即:不是特性元数据被剥,是 GetMethods() 返回了 null 数组元素 —— 这是 IL2CPP + Xcode 26 的代码生成行为差异,和业务类型、和特性本身都没关系。
已证伪:link.xml preserve
三种 link.xml 尝试全部出包后照旧崩溃,全部排除:
- 程序集级
<assembly fullname="Scripts" preserve="all" />/Newtonsoft.Json/Assembly-CSharp三个preserve="all" - 5 条序列化回调特性类型 preserve(
OnSerializing/OnSerialized/OnDeserializing/OnDeserialized/StreamingContext)
原因:preserve 管的是类型可达性,管不到 GetMethods() 数组元素这层;导出 C++ 里那些回调特性元数据本来就在。别再往这个方向试。
排查过程
- 从堆栈锁定崩溃在
ResolveCallbackMethods(外层)→ 内部GetCallbackMethodsForType(内层,做GetMethods+IsValidCallback循环)。 - 平台维度隔离:Android OK、旧 Xcode iOS OK → 判定 IL2CPP 兼容性问题。
- 读 IL2CPP 导出 C++
Newtonsoft.Json__4.cpp,定位到NullCheck(L_18)在method.ContainsGenericParameters—— 方法数组里出现了 null 元素。 - 三次 link.xml 尝试(+14 / +11 / +1 行)逐一出包验证 → 全部无效,证伪 link.xml 路线。
检索关键字:
ResolveCallbackMethods、GetCallbackMethodsForType、NullCheck(L_18)、method.ContainsGenericParameters
修复
全部改动 #if !UNITY_EDITOR && UNITY_IOS 收口,Android 编译层面不受影响(硬约束:只修这个崩溃,绝不能影响正常的安卓端)。
1. SafeContractResolver.cs(新增类,139 行)
DefaultContractResolver 子类,静态单例 Instance:
- 重写
CreateObjectContract:先base.CreateObjectContract,catch NRE 后走BuildFallbackContract—— 只扫属性、不扫回调特性(CreateProperties不会触发GetCallbackMethodsForType)。 - fallback 手动补
DefaultCreator(无参 public → 非 public →Activator),否则 Newtonsoft 后续CreateNewObject会抛 "Unable to find a constructor"。 CreateProperties再失败则PopulatePropertiesManually逐成员CreateProperty兜底。- 静态构造函数挂
JsonConvert.DefaultSettings = () => new JsonSerializerSettings { ContractResolver = Instance },覆盖所有不带 settings 的JsonConvert调用和CreateDefault()。 [RuntimeInitializeOnLoadMethod(BeforeSceneLoad)] Init保证初始化时机。
2. xxx/PacketBody.cs
Populate<T> iOS 分支提前走新增的 PopulateiOS:先对 JToken 做空值 / 空 JObject / 空 JArray 防护,再用挂了 SafeContractResolver + ObjectCreationHandling.Replace 的 serializer 执行 Populate;catch NRE 时打出 Type / Property / TokenType / 原始 Json(这是后续精确收敛根因的唯一线索)。
参考代码
fallback 核心(SafeContractResolver.cs —— 重写 CreateObjectContract catch NRE,BuildFallbackContract 手动补 DefaultCreator + 只走 CreateProperties):
protected override JsonObjectContract CreateObjectContract(Type objectType)
{
try
{
return base.CreateObjectContract(objectType);
}
catch (NullReferenceException ex)
{
UnityEngine.Debug.LogError(
$"[SafeContractResolver] NRE in base.CreateObjectContract, " +
$"Type={objectType?.FullName}, fallback to BuildFallbackContract. " +
$"Msg={ex?.Message}");
return BuildFallbackContract(objectType);
}
}
private JsonObjectContract BuildFallbackContract(Type objectType)
{
var contract = new JsonObjectContract(objectType);
// base.CreateObjectContract 正常会设 DefaultCreator;fallback 跳过了 base,
// 这里手动补无参构造,否则 Newtonsoft 后续 CreateNewObject 会抛
// "Unable to find a constructor to use for type X"。
try
{
var defaultCtor = objectType.GetConstructor(
BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic,
null, Type.EmptyTypes, null);
if (defaultCtor != null)
{
contract.DefaultCreator = () => defaultCtor.Invoke(null);
contract.DefaultCreatorNonPublic = !defaultCtor.IsPublic;
}
else
{
// 没有无参构造时退到 Activator(fallback 已是最兜底)
contract.DefaultCreator = () => System.Activator.CreateInstance(objectType);
}
}
catch (System.Exception ex) { /* 日志 */ }
// 只扫属性、不扫回调特性(CreateProperties 不触发 GetCallbackMethodsForType)
var props = CreateProperties(objectType, MemberSerialization.OptOut);
foreach (var p in props)
{
contract.Properties.AddProperty(p);
}
return contract;
}
[OnDeserialized] 被跳过的兜底(xxxMember.cs —— iOS 专用 lazy-init getter):
#if !UNITY_EDITOR && UNITY_IOS
private xxxMemberRecords _userRecords;
/// <summary>
/// SafeContractResolver fallback 路径在 IL2CPP+Xcode26 下会跳过 [OnDeserialized]
/// 回调扫描,因此这里改成 lazy-init getter,保证访问时永远不为 null。
/// OnDeserializedMethod 仍保留作为非 fallback 路径的双保险。
/// </summary>
public xxxMemberRecords UserRecords
{
get => _userRecords ??= new xxxMemberRecords();
set => _userRecords = value;
}
#else
public xxxMemberRecords UserRecords { get; set; }
#endif
验证
- 全程
#if !UNITY_EDITOR && UNITY_IOS隔离,Android 编译路径未触碰 - 原复现路径(进公会
UserRecords:{})iOS 实机验证 —— 出包后已在真机复跑确认验证
教训
- Xcode 大版本升级是 IL2CPP 行为变量的引入点:新的 Apple 链接器会改变 IL2CPP 生成的原生代码行为,且只在真机出包后暴露,Editor 里复现不了。
- IL2CPP 裁剪类问题先分清是**"类型被剥"(link.xml 可救)还是"数组元素为 null"**(link.xml 管不到)—— 用导出的
Newtonsoft.Json__N.cpp当 ground truth,NullCheck行号就是铁证。 - Newtonsoft 在 IL2CPP 下 NRE 时,别先怀疑业务代码;先看生成 C++ 确认 null 出现在哪一层。
- 跨平台兼容性问题先用条件编译在平台维度收口(Android 不动),再谈逻辑修复。