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/Build 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 窗口中看到新增的性能采样信息。
旧文搬运,2017-05-12 首发于博客园。