·构建与发布

只在真机上出现的 MethodAccessException:代码裁剪的锅

为了压包体,我把托管代码裁剪(managed stripping)从 Minimal 提到了更激进的档位。 包体确实小了,代价是启动直接崩:

MethodAccessException: Attempt to access the method failed:
SomeFramework.IFileSystem.RequestPackageVersionAsync() on type ''

两个特征很有意思,也正是解题的线索:编辑器里完全正常,只有真机复现异常消息末尾的类型名是空字符串

为什么编辑器不复现

因为裁剪只发生在 AOT 编译的正式构建里。编辑器跑的是完整的托管程序集, 什么方法都在。所有"编辑器好、真机炸"的问题,第一个该怀疑的就是构建期做了什么手脚: 裁剪、AOT 编译、序列化后端差异、平台条件编译。

这一条对排查效率影响很大。我一开始花了不少时间在业务逻辑上找空引用, 方向完全错了——业务代码在两个环境里是同一份,不同的是构建管线。

为什么类型名是空的

这就是"方法被删了"的指纹。裁剪器是静态分析工具:它从入口点出发, 顺着调用关系标记可达的类型和方法,没被标记的就删掉。

问题在于只通过接口调用的实现方法,静态分析看不出来。 代码里写的是 IFileSystem.RequestPackageVersionAsync(), 具体调用哪个实现类是运行时才确定的。裁剪器扫不到任何地方"直接"引用那个实现类的这个方法, 就认为它是死代码。等运行时真的去找实现,找到的是一个已经被掏空的类型—— 所以类型名打印出来是空的。

同一类根因还会以别的形式出现:

共同点是:调用关系不在编译期的静态图里。任何依赖运行时解析的机制 都要和裁剪器打招呼。

入口点 直接调用的方法 标记保留 IFileSystem 实现类的同名方法 当作死代码删除 运行时才确定 静态分析看不到虚线这一段,所以实现被删;运行时去找实现,得到一个被掏空的类型 —— 异常里的类型名因此是空的。
裁剪器只沿实线(编译期可见的直接调用)标记可达。 任何依赖运行时解析的机制——接口派发、反射、按名字加载——都必须显式告知裁剪器。

解法:精准 preserve,而不是关掉裁剪

最省事的做法当然是把裁剪调回 Minimal,问题立刻消失。但那等于把包体优化的收益全退回去。 正确做法是用 link.xml 告诉裁剪器"这几个程序集别动",其余照旧裁:

<linker>
  <!-- 通过接口调用实现类的资源框架 -->
  <assembly fullname="SomeFramework" preserve="all"/>
  <!-- 自己的启动引导层,被反射/接口间接调用 -->
  <assembly fullname="MyGame.Bootstrap" preserve="all"/>
</linker>
<!-- 其他程序集不写,继续正常裁剪 -->

把这个文件放进工程的资源目录即可,构建时会自动被收集。几个实操注意点:

把这件事纳入发布流程。裁剪问题的可怕之处是它只在正式包里出现, 而正式包通常是发布前才打。我现在的做法是:任何涉及构建配置、程序集划分、 引导层代码的改动,都必须打一次真机包跑通启动流程才算完成,不能只在编辑器里验证。

一点方法论

这类问题的排查框架其实是通用的:

  1. 先问"两个环境差什么",而不是"我的代码哪里错了"。 只在一个环境复现的问题,答案九成在环境差异里。
  2. 把报错当指纹读。空类型名、异常类型是 MethodAccess 而不是 NullReference,这些细节直接指向"符号在运行时不存在"这一类原因, 能一步跳过大量无效猜测。
  3. 优化措施要能回退到最小影响面。裁剪的问题不该用"不裁剪"来解决, 而该用"精确指定不裁什么"。同理,遇到编译器优化导致的 bug, 也应该找到具体那一处而不是整体降级优化等级。
← 返回文章列表