iOS Xcode26 升级后 Newtonsoft 协议 JSON 解析 NRE 排查与修复

61 阅读4分钟

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++ 里那些回调特性元数据本来就在。别再往这个方向试。

排查过程

  1. 从堆栈锁定崩溃在 ResolveCallbackMethods(外层)→ 内部 GetCallbackMethodsForType(内层,做 GetMethods + IsValidCallback 循环)。
  2. 平台维度隔离:Android OK、旧 Xcode iOS OK → 判定 IL2CPP 兼容性问题。
  3. 读 IL2CPP 导出 C++ Newtonsoft.Json__4.cpp,定位到 NullCheck(L_18) 在 method.ContainsGenericParameters —— 方法数组里出现了 null 元素。
  4. 三次 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 不动),再谈逻辑修复。