只在真机上出现的 MethodAccessException:代码裁剪的锅
为了压包体,我把托管代码裁剪(managed stripping)从 Minimal 提到了更激进的档位。 包体确实小了,代价是启动直接崩:
MethodAccessException: Attempt to access the method failed:
SomeFramework.IFileSystem.RequestPackageVersionAsync() on type ''
两个特征很有意思,也正是解题的线索:编辑器里完全正常,只有真机复现; 异常消息末尾的类型名是空字符串。
为什么编辑器不复现
因为裁剪只发生在 AOT 编译的正式构建里。编辑器跑的是完整的托管程序集, 什么方法都在。所有"编辑器好、真机炸"的问题,第一个该怀疑的就是构建期做了什么手脚: 裁剪、AOT 编译、序列化后端差异、平台条件编译。
这一条对排查效率影响很大。我一开始花了不少时间在业务逻辑上找空引用, 方向完全错了——业务代码在两个环境里是同一份,不同的是构建管线。
为什么类型名是空的
这就是"方法被删了"的指纹。裁剪器是静态分析工具:它从入口点出发, 顺着调用关系标记可达的类型和方法,没被标记的就删掉。
问题在于只通过接口调用的实现方法,静态分析看不出来。
代码里写的是 IFileSystem.RequestPackageVersionAsync(),
具体调用哪个实现类是运行时才确定的。裁剪器扫不到任何地方"直接"引用那个实现类的这个方法,
就认为它是死代码。等运行时真的去找实现,找到的是一个已经被掏空的类型——
所以类型名打印出来是空的。
同一类根因还会以别的形式出现:
- 反射调用的方法被删(
MissingMethodException)。 - 只在配置/资源里按名字引用的类型被删(反序列化时得到 null)。
- 泛型实例化在 AOT 下没有生成对应代码(
ExecutionEngineException)。
共同点是:调用关系不在编译期的静态图里。任何依赖运行时解析的机制 都要和裁剪器打招呼。
解法:精准 preserve,而不是关掉裁剪
最省事的做法当然是把裁剪调回 Minimal,问题立刻消失。但那等于把包体优化的收益全退回去。
正确做法是用 link.xml 告诉裁剪器"这几个程序集别动",其余照旧裁:
<linker>
<!-- 通过接口调用实现类的资源框架 -->
<assembly fullname="SomeFramework" preserve="all"/>
<!-- 自己的启动引导层,被反射/接口间接调用 -->
<assembly fullname="MyGame.Bootstrap" preserve="all"/>
</linker>
<!-- 其他程序集不写,继续正常裁剪 -->
把这个文件放进工程的资源目录即可,构建时会自动被收集。几个实操注意点:
- 先确认现有的 link.xml 覆盖了什么。我工程里已经有两个 link.xml (框架自带的和平台插件自带的),但都不包含出问题的那个程序集。 它们不会互相覆盖,是叠加生效,所以新增一个自己的文件是安全的。
- 粒度可以更细。
preserve="all"是最粗的做法。 也能只保留某个类型或某个方法,包体收益更好,代价是每加一个新实现类都要维护这个文件。 对于会持续演进的框架,我倾向整个程序集 preserve,省心。 - 不要用
--verbose的输出去猜哪些被删了。 更快的路径是让错误自己暴露:每提高一档裁剪等级,就完整跑一遍真机启动流程, 崩了就把报错涉及的程序集加进 link.xml,直到跑通。
一点方法论
这类问题的排查框架其实是通用的:
- 先问"两个环境差什么",而不是"我的代码哪里错了"。 只在一个环境复现的问题,答案九成在环境差异里。
- 把报错当指纹读。空类型名、异常类型是
MethodAccess而不是NullReference,这些细节直接指向"符号在运行时不存在"这一类原因, 能一步跳过大量无效猜测。 - 优化措施要能回退到最小影响面。裁剪的问题不该用"不裁剪"来解决, 而该用"精确指定不裁什么"。同理,遇到编译器优化导致的 bug, 也应该找到具体那一处而不是整体降级优化等级。