改造EmmyLua(LuaLS)的UnityAPI注解生成器
在 Unity 的 Lua 业务中调用 C#,经常会遇到一种割裂感:代码运行没有问题,编辑器却不知道对象是什么类型,也无法继续补全后面的字段。尤其经过单例、委托和 CS 表之后,一条本来有完整类型信息的调用链,很容易变成一串未知字段。
我在开源生成器上做了些修改,把这些类型提示补齐。
这批改动从 2025 年 1 月持续到 3 月,最终通过 PR 合并回上游。本文记录其中几个最有价值的处理。
工具的 GitHub README,点击图片可打开仓库。
生成注解,并不改变运行时
工具在 Unity 编辑器中通过反射读取 C# 程序集,再生成 Lua 语言服务能够理解的类型声明。注解文件提供给编辑器分析,不应该作为业务脚本被加载执行。
1 | C# 程序集 |
例如一个普通类型可以生成这样的声明:
1 | ---@class Demo.InventoryService |
这里的方法体是空的,因为它的作用是告诉语言服务“存在这个方法”,不是重新实现 C# 逻辑。真正的跨语言调用仍然由 xLua 等运行时桥接完成。
当时的补全效果,裁去业务代码,仅保留类型与成员提示。
泛型继承会把类型信息丢在哪
最常见的情况是泛型单例:
1 | public class Singleton<T> |
如果生成器只看泛型定义,Instance 的类型只能写成 T。但对 InventoryService 来说,继承关系已经明确提供了实参,编辑器本来可以知道 Instance 就是 InventoryService。
我补充的处理是:在生成具体派生类时检查父类的泛型信息,把能够确定的字段与属性类型落实到派生类的声明中。于是调用 InventoryService.Instance.Capacity 时,类型链可以继续下去。
1 | ---@class Demo.InventoryService : Demo.Singleton |
这不是一套完整的 C# 泛型推导系统。这个阶段的实现主要覆盖非泛型派生类、直接父类只有一个泛型参数、继承字段与属性的常见场景;多参数映射、复杂嵌套以及方法的泛型参数和返回值仍需另外处理。
把边界说明清楚很有必要:类型提示在大部分调用链上改善,并不意味着所有泛型组合都能正确生成。
类型名与 CS 表的访问路径
xLua 中常通过 CS.UnityEngine.Vector3 访问 C# 类型,但已有注解可能写成 UnityEngine.Vector3。如果把两种写法当成互不相干的类型,赋值与字段访问就会出现无意义的提示。
工具默认生成正常的类型名,再提供可选的 CS. 前缀兼容声明。目标是让这两种注解写法描述同一个类型,同时让访问 CS 表时能够获得成员提示。
1 | ---@type UnityEngine.Vector3 |
这里要同时关心类型别名和表的字段声明。仅仅建立一个名称别名,并不自动保证语言服务在 CS.UnityEngine 这条访问路径上能够继续识别成员;这也是改造中实际修过的问题之一。
项目需要保留哪种写法,最好在接入时明确下来,避免生成文件与手写注解形成两套相互不兼容的命名习惯。
需要区分的是,类型别名只改变编辑器怎样理解注解,不会创建新的运行时全局变量。即使启用了兼容别名,xLua 代码真正访问类型时仍然需要 CS.UnityEngine.Vector3;直接写 UnityEngine.Vector3.zero 不会因为生成了注解就自动可用。
C# 委托与 Lua function 的兼容
运行时桥接通常允许把 Lua 函数传给某些 C# 委托,但语言服务未必知道这条转换规则。例如:
1 | local onDone = function() end |
没有额外信息时,这可能被诊断为 function 不能赋给 System.Action。
我增加了可配置的兼容类型列表,在需要的声明位置将指定委托表达为联合类型:
1 | ---@param callback System.Action | function |
这样保留了原 C# 类型,同时允许 Lua 函数传入。这个处理也要覆盖方法重载,不能只修普通声明,否则同一套 API 会因为是否进入重载分支而产生不同提示。
代价是 function 本身比较宽泛,它不能替代精确的参数与返回值检查。所以兼容列表只应包含运行时确实支持转换的类型。若某个回调需要严格签名,更合适的办法是进一步生成明确的 fun(...) 声明。
静态方法与实例方法不能混淆
Lua 的点调用与冒号调用在参数上有区别,冒号会隐式传入 self。如果把 C# 静态方法生成为实例方法,编辑器看到的参数列表就会错位。
1 | -- 静态方法 |
生成器需要根据反射结果中的 IsStatic 决定声明方式。我也修复了静态函数仍然携带实例参数的情况,并整理了泛型名称后缀、匿名类型等不适合直接暴露的类型名称。
这些问题看起来零碎,但它们决定了使用者能否信任提示。如果日常面对很多错误警告,人反而会习惯性忽略真正的问题。
让生成过程适合反复使用
除了类型映射,我还补了几项使用上的功能:
- 按命名空间选择要生成的程序集内容,避免把无关类型全部塞进索引。
- 为 C# 注入的全局变量和
typeof等入口生成声明。 - 按配置行数拆分注解文件,便于管理较大的输出。
- 用编辑器窗口保存生成选项,减少每次修改源码配置的操作。
文件拆分是否改善分析速度,与语言服务和项目规模有关,我没有做跨编辑器的统一性能基准。更确定的收益是生成范围和输出组织都可以控制。
使用 Lua Language Server 时,可以把生成目录加入 workspace.library。无论文件放在哪里,都应让语言服务只索引需要的声明,避免旧输出和新输出同时存在、重复定义同一类型。
生成目录也应专用。这个工具会清理目标目录中的旧注解,因此不能把手写业务脚本和生成文件混放。
在项目中接入
将仓库克隆到 Unity 项目中,等待编辑器编译后,从顶部的 LuaType 菜单先打开设置,再执行生成。README 中的几项配置可以按下面的顺序准备:
| 配置 | 设置方法 |
|---|---|
| 配置文件目录 | 指向一个可写目录;默认值来自 AppDomain.CurrentDomain.BaseDirectory,在 Unity 中可能落到引擎安装目录 |
| 注解输出目录 | 使用专用目录,按工具要求以路径分隔符结尾;不要存放业务文件 |
| C# 命名空间 | 根据项目引用补充,用空格分隔,例如 DG FairyGUI |
| 全局变量 | 使用 名称:类型,例如 UNITY_EDITOR:boolean DEBUG_LEVEL:number |
| Lua 函数兼容类型 | 填写完整类型名,例如 System.Action FairyGUI.EventCallback0 |
使用 LuaLS 时,如果输出在工作区之外,把它加入语言服务的库目录即可。下面是 .luarc.json 的一个简化示例,路径需要替换成自己的注解目录:
1 | { |
生成后先找一条真实调用链验证:能否识别 CS 下的类型、泛型单例后的成员,以及委托参数接受 Lua 函数时的提示。新增程序集、调整生成选项或 C# API 变化后,再重新生成对应的声明。
不同语言服务对注解的兼容细节可能不同,README 所说的 EmmyLua 与 SumnekoLua / LuaLS 支持,并不意味着任意版本组合都完全一致。接入时用项目实际使用的编辑器验证,比只确认生成了文件更可靠。
从项目改动到上游合并
这次改造建立在原作者的生成逻辑上,我主要补充了项目实际需要的能力与问题修复。提交上游时,把新增选项、兼容范围和使用说明一起整理,比只提交一份能在自己工程里运行的脚本更容易让其他人理解。
PR #2 于 2025 年 3 月 27 日合并。工具仓库:EmmyLua-Snippet-Generator;原项目:EmmyLuaXLuaSnippetGenerator。
跨语言类型提示的价值,首先体现在日常写代码时少一点猜测。把运行时已经掌握的类型信息传给编辑器,就能让调用链、参数和字段更早暴露问题;但最终执行是否合法,仍然要由真实的运行时规则与测试确认。

