使用 Mono.Cecil 辅助 Unity3D 手游进行性能测试
Posted
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了使用 Mono.Cecil 辅助 Unity3D 手游进行性能测试相关的知识,希望对你有一定的参考价值。
Unity3D 引擎在 UnityEngine 名字空间下,提供了 Profiler 类(Unity 5.6 开始似乎改变了这个名字空间),用于辅助对项目性能进行测试。以 android 平台为例,在构建之前,需要在 Unity 的 File/Build Settings 菜单项弹出的窗口中,勾选 Development Build 一项。后用 adb forward 的方式,将 Android 设备的 TCP 输出转发到电脑,实现和 Unity Profiler 的连接(网上很容易找到这个过程的具体描述,如这里)。但是 Unity Profiler 默认只提供部分方法/函数,尤其是 Unity 内置方法/函数的性能采样,如果想 Profile 自己项目的代码段,就必须在代码段入口和出口加上:
Profiler.BeginSample("ProfilerName"); // 代码段。 Profiler.EndSample();
对于一个已经进行了一段时间,有十几万行代码的项目,想逐个方法添加采样代码,甚至在加上预编译命令,是非常麻烦的,而且很容易出错。
幸好我遇到了 Mono.Cecil 这个库(github 链接)。
这个库所做的事情,并不难理解。我们知道 C# 通常编译为中间语言(IL),之后由 .NET 虚拟机对其进行执行。Cecil 的一部分能力就在于,可以任意修改生成的 IL。尽管 .NET 的 Mono 实现有很多缺陷,且 Unity 在 ios 平台上早已推出 IL2CPP 机制,将 IL 转换为 C++ 代码,再在目标平台进行原生的 C++ 编译链接,但在 Android 设备上,大部分项目仍然使用 Mono 作为 Scripting backend。也就是说,安装入 Android 设备的 apk 包中是带有 C# 程序集的,而程序集中其实是 IL 代码。为了方便,在 Unity 的 File/Builde Settings 中,勾选 Google Android Project 一项,要求 Unity 不要直接生成 apk,而是生成 Android 工程。这样,在 Unity 的构建过程结束后,就可以使用 Cecil 在目标文件夹夹的 C# 程序集上注入代码。
如何注入呢?大体有几个要点:
- 自定义 System.Attribute 子类,装饰必要的类或者方法。这样,之后用 Cecil 注入代码时,就可以根据这些装饰,实现白名单或者黑名单的功能,决定哪些地方要注入,哪些地方不要注入。
- 用一个简单的静态类包装 Profiler.BeginSample 和 Profiler.EndSample 。由于这两个方法只允许在 Unity 的主线程调用,如果在别的线程调用,就会发生运行时错误。所以,包装类的作用,就是在真正调用这两个方法之前,检查当前线程是否为主线程。例如,对于 Unity 而言,所有 MonoBehaviour 类的构造函数和字段初始化式(Field initializer)都不是在主线程调用的。如何检查当前线程是否为主线程呢?只需要在项目第一个 Awake 方法中,将当前线程的引用( System.Threading.Thread.CurrentThread )记录下来,调用 Profiler.BeginSample 和 Profiler.EndSample 之前判断当前线程的引用是否和主线程引用相等即可。
- 如果有自动化的打包流程,在调用 BuildPipeline.BuildPlayer 时,加入 BuildOptions.Development 和 BuildOptions.AcceptExternalModificationsToPlayer 来进行 Development Build,并且构建出 Android Studio 工程而非 apk 文件(参考这里)。构建结束时,利用 Unity 提供的后处理方法(官方文档)获取相应程序集的路径,用 Cecil 进行注入。注意 using 两个名字空间 Mono.Cecil 和 Mono.Cecil.Cil。
使用 Cecil 的要点主要是:
- 用 ModuleDefinition.ReadModule(string) 来读取一个 C# 程序集,到一个 ModuleDefinition 对象中。
- 对于需要注入代码的程序集,在读取时,要输入一个 resolver 对象,以便能解析来自该程序集之外的方法。
var resolver = new DefaultAssemblyResolver(); // 搜索目标文件夹中的程序集来解析程序集外部的方法。 resolver.AddSearchDirectory(directory); var moduleDef = ModuleDefinition.ReadModule(assemblyPath, new ReaderParameters { AssemblyResolver = resolver });
- 在模块定义( ModuleDefinition )对象中,使用其 Types 和 TypeDefinition 的 Methods 属性,辅以 Linq 中的扩展方法,找到需要的方法定义。
- 对于一个方法定义( MethodDefinition )对象,其 HasBody 属性说明是否真的有方法体。比如抽象方法,就是没有方法体的。对于有方法体的方法定义对象 targetMethod ,我们需要调用 targetMethod.Body.Instructions 来获取该方法中的全部 IL 指令。
- 本文的场景下,只需要在方法的入口和出口注入代码。入口注入代码比较简单,只要构造一条 IL 指令,将其插入到指令容器开头( Insert 方法)即可。由于 Profiler.BeginSample 方法(随即其包装方法)带有一个字符串类型的参数,所以需要两条 IL 指令。
var loadStr = Instruction.Create(OpCodes.Ldstr, myString);
instructions.Insert(0, loadStr);
var callBegin = Instruction.Create(OpCodes.Call, targetMethod.Module.Import(m_BeginSampleMethod));
instructions.Insert(1, callBegin);
- 方法出口注入代码稍微有些麻烦。尽管 IL 级别的函数都是以一个返回指令结束的,但直接在返回指令之前插入新的指令是不够的。因为很多时候,返回指令是由跳转指令直接跳转过去的。而对于我们在 C# 中获取的指令容器,跳转指令保存了其跳转目标的引用。因此,我们不仅需要在返回指令前插入我们需要的指令(对 Profiler.EndSample 包装方法的调用),还要将跳转目标为该返回指令的跳转指令的目标,修改为我们新增的指令。这里有详尽的关于 IL 指令的列表。对应 Cecil 中 OpCodes 类中的常量,我们可以过滤出跳转指令,并用 Operand 属性获取或修改其跳转目标。
- 修改完成后,需要对当前的模块对象 moduleDef 调用 moduleDef.Write(assemblyPath, new WriterParameters { WriteSymbols = true }) 来写回程序集文件。这个调用中,第二个参数的含义,是把新增的符号也写入程序集(比如我们调用的该程序集之外的方法)。
在注入完成后,继续 Android 平台的原生构建生成 apk 包,安装进设备,将设备连接电脑,即可在 Unity 的 Profiler 窗口中看到新增的性能采样信息。
以上是关于使用 Mono.Cecil 辅助 Unity3D 手游进行性能测试的主要内容,如果未能解决你的问题,请参考以下文章