Luban 魔改:CustomProcessor 扩展

Luban 是一款出色的导表工具,但项目实际开发中常面临一些痛点需求:例如多语言文本Id自动分配配置表提炼为代码强类型常量运行时自动化多维索引字典等。为了在不修改 Luban 底层核心代码的前提下实现这些定制,我们基于其管线引入了 CustomProcessor 扩展机制。

本文将从使用方式框架原理实现与设计三个层面完整阐述这套扩展体系。


0、示例工程github仓库

Github仓库

一、l10n — 多语言文本配置 (使用篇·通用)

本节说明 l10n 的日常使用,策划和程序均需了解。

每个需要多语言支持的文本字段,在 Excel 中都是两列Id 列存唯一编号,Text 列存原文。翻译人员在 l10n.xlsx 总表里按 Id 维护各语言译文。

1. 字段长什么样

l10n字段示例

##type 行填 l10n 的字段,每个 l10n 字段展开成两列子列,标题行固定为 IdText

2. 导表完整逻辑

excel侧l10n导表流程

3. 日常操作流程

新增文本(让工具自动分配 Id)

最常用的方式,Text 填文本、Id 留空,工具自动处理:

  1. Text 列填文本,Id留空
  2. 关闭 Excel,跑 gen.bat
  3. 重新打开 Excel,Id 列已自动填入

提示:Excel 打开期间也可跑 gen.bat,工具检测被占用会分配临时负数 Id(如 -1-2),本次可用,但不持久化。关闭 Excel 后重跑一次即写入正式正整数 Id。

手动指定 Id(独立行)

明确不想走自动复用、需要独立 Id 时,直接在 Id 列填一个总表中不存在的正整数
举例:总表已有 Id=5 对应文本”确认”,但这里的”确认”语境不同,需要单独翻译 → Id 列手填 50,Text 填 确认 → 导表后总表新增 Id=50 这一行,和 Id=5 独立维护。

强制复用已有 Id(跨表引用)

明确要复用某个已有 Id,Id 列直接填已有的正整数,Text 填对应原文。工具识别为已有 Id 后直接使用。

修改文本

直接改 Text 列,Id 列不动。导表时工具检测到文本变更,在 l10n.xlsx数据状态 列自动标记「待重译」,提示翻译人员跟进。

相同文本自动复用同一 Id

Id 为空时,工具会在总表里查找是否已有逐字符完全一致(含空格、标点)的文本,有则直接复用该 Id,不新分配。
举例系统_灵感.xlsx系统_道具.xlsx 都有文本”获取失败”,优先分配的拿到 Id=30,后来另一表留空导表后也会填入 30。

删除行

直接删整行(Id 和 Text 一起删)。导表日志会提示该 Id「可能已过期」,确认不再使用后去 l10n.xlsx 手动删对应行即可。

严禁「只删 Text 保留 Id」,此举会留下悬挂的空文本 Id。

4. l10n.xlsx 总表

l10n总表示例

说明
文本key 唯一 Id,工具自动写入,不要手动改
原文本 原始文本,工具自动写入和更新,不要手动改
数据状态 文本变更后工具填「待重译」,翻译完成后手动清空
各语言列 翻译人员填写(如 zh_CN

5. 常见问题

  • Id 列写回负数:导表时 Excel 未关,工具分配了临时 Id。关闭 Excel 重跑即可。
  • 跑完 Id 仍为空:Unity Editor 运行中锁住了 .bytes 文件导致系统拒绝写入。关闭 Unity 或退出 Play Mode 后重跑。
  • **报错”id=X 存在两个不同文本值”**:同一个 Id 对应了两处不同的 Text。把文本统一下,或把其中一行 Id 清空让工具重新分配。

6. Prefab 静态 UI 文本流程 (针对游戏内界面)

痛点与目的:为什么要区分“静态文本”?
在游戏中,多语言文本分为两类:

  1. 动态文本:比如”获得道具:苹果”、剧情对话等。这部分数据天然存在于配置表中,策划照常在 Excel 对应业务表里填即可。
  2. 静态文本:比如设置界面的”音量”、”画质”,或者通用按钮”确定”、”返回”。它们死死地长在 UI 预制体(Prefab)上,逻辑并不需要配表。

如果强迫策划把所有 UI 界面的”返回”、”确定”都手动抄录到 Excel 的某张「全局静态表」里,配好一个 Id,再切回 Unity 界面把这个 Id 复制填到组件上……这种做法不仅反直觉、工作量极大,还极易漏配和填错 Id

解决方案:策划在拼 UI 时,直接在 Unity 里写中文即可。后续的“搜集文本、分配 Id、回写 Id”全部由工具一键全自动完成。不需要在 Excel 建表去存这些文字,导表工具会自动把它们归纳抽离到 l10n.xlsx 总表里,供外包翻译使用。

日常操作:如何在 Prefab 中使用静态文本?

  1. 拼 UI 时,文字组件使用项目定制的 **UIText**(平时用来代替原生的 TextMeshProUGUI)。
  2. 在 Inspector 面板中,勾选 Is Static 选项。
  3. 直接在文本框里输入你要显示的中文(像平时拼 UI 一样写,比如“点击继续”)。
  4. 下方的 Static Text Key(即多语言 Id)保持默认的 0不需要填

UIText组件面板结构

导表与自动化完整逻辑

第一步:全量扫描 Prefab 静态文本

点击顶部菜单栏 「Tools / L10n / 扫描Prefab分配静态文本Id」,工具会先强制保存当前工程,然后遍历所有 Prefab,收集所有勾选了 Is Static 且文本非空的 UIText 组件。

点击扫描菜单入口

收集完成后弹出确认框,显示本次找到的静态文本条数,点击「确定」继续。

确认扫描结果,显示收集条数

第二步:拉起导表,等待完成

点击确认后,工具在后台自动唤起一个独立的 CMD 窗口执行 gen.bat。窗口会实时显示导表日志,看到「bye~」表示成功完成。

导表终端窗口,等待完成

导表窗口自动关闭后,Unity 侧会轮询到完成信号,自动进入回写环节。

第三步:查看回写结果

回写完成后弹出结果提示,显示本次共回写了多少条 Id。

回写完成提示

回到 Inspector,原本 Static Text Key0 的组件现在已经自动填入了正式 Id。

组件 Static Text Key 已自动写入 Id

打开 l10n.xlsx 总表,可以看到新分配的条目已经追加进来,原文本 列为 Unity 里填写的中文内容。

l10n总表中新增的 Prefab 静态文本条目

整个过程策划只需点一下菜单,剩下全部自动完成。

后续怎么修改和维护?

  • 单纯改字:不需要去 Excel 里搜!直接在 Unity 的 UIText 输入框里把原来的”确认”改成”立刻确认”。改完后,只要再点一次顶部的「扫描Prefab」菜单,工具会检测到文字变更,自动把 l10n.xlsx 总表里的这一条状态标为「待重译」,同步提醒翻译人员。
  • 复制 UI 节点(非常高频):拼 UI 时一定经常 Ctrl+D 复制文字节点。放心复制,工具做了极强的防呆:它一旦察觉这个组件是被复制出来的老油条,会自动把新节点的 Static Text Key 强制清零。你尽管改它的文字,最后点一次扫描菜单收尾即可,系统绝不可能因此出现 Id 冲突。

二、Const 机制扩展 (使用篇·程序)

纯程序代码维度需求,策划无需关注实现细节,只需按既定规则配表即可。

1. ConstDict — 运行时多维索引字典

目的:Luban 原生只生成基于主键(如 Id)的 DataMap。我们常需要”按 Layer 获取所有界面 Id”。ConstDict 可在配表时通过简单的标记,运行时自动构建出 Dictionary<K, V>,避免手动遍历 Where() 导致的性能和代码冗余。

配置写法
__tables__.xlsxtags 列填写,每个字典一条,以 #ConstDict.序号= 开头,括号内用 # 分隔选项:

1
2
#ConstDict.1=(class=Layer2Id#key=Layer#value=Id#mode=list)
#ConstDict.2=(class=ToastById#key=Id#value=@row#mode=one#conflict=first)
选项 必填 说明
class 代码里暴露的字典属性名
key 字典的键字段
value 字典的值字段,填 @row 表示拿整行实例
mode one (1v1字典) / list (1vN列表字典)
conflict key 重复策略(error / first / last)
phase 构建时机(ctor反序列化时 / resolve引用连接后)

代码中即可直接 Tables.CfgUI.Layer2Id[EUILayer.Popup] 访问。

2. ConstField — 静态常量类提炼

目的:UI 面板的配表资源路径、固定道具 Id 常被硬编码为 "ToastDefault",一旦策划改名或删表,运行时才会引发空指针。ConstField 将配表的指定列直接提炼生成为 C# public static string xxx 静态常量类,IDE 自带类型检查与跳转。

配置写法
在字段的 type 列(非 tags 列)填写:

1
ConstField#class=DefToast#value=Id

此列就是常量名value=Id 表示使用 Id 列的值作为常量值
Id列=ToastDefault,ConstField列=TOAST_DEFAULT,产出代码即为:

1
2
3
4
public static partial class DefToast
{
public static string TOAST_DEFAULT { get; private set; } // 运行时加载值为 "ToastDefault"
}

三、CustomProcessor 扩展框架 (原理与结构篇)

从此节开始为底层实现和设计理念说明,为后续维护和功能拓展提供参考。

1. 业务痛点与设计背景

最初面临定制需求时,普遍做法是在 Luban 源码的各个核心解析类里加分支(例如在 DefAssembly.csSheetDataCreator.cs 里硬编码 if(tag=="__l10n") 处理特殊逻辑)。此类「传统魔改」暴露出致命缺陷:

  1. 代码极度散乱、高耦合:Schema 读取、Data 检测、后端代码生成这三段逻辑被污染,牵一发而动全身。
  2. 阻断升级路径:上游框架代码一旦产生冲突,几乎无法完成 rebase 或版本跟进。
  3. 不同项目互相干扰:工具链无法复用,复用要连带着把 A 项目的魔改一起带入 B 项目。

解决思路:插件化生命周期 Hook(Observer者模式)
我们确立了最小侵入原则:底层 DefaultPipeline 仅需向外抛出事件,外部插件(CustomProcessor)通过订阅指定周期的节点来实施操作。

2. 框架结构

1
2
3
4
5
6
7
8
9
10
11
12
Luban 核心                        CustomProcessorManager
───────────────────────────── ──────────────────────────────────────
DefaultPipeline.Run()
→ manager.OnGenerationStart() → Dispatch(p => p.OnGenerationStart())
→ LoadSchema()
→ manager.OnDefAssemblyCreated() → Dispatch(p => p.OnDefAssemblyCreated())
→ LoadData() ↓ ConstDictProcessor 构建字典结构
→ 逐行 TryLoadCustomField() → TryLoadCustomField()(触发各 Processor)
→ GenerateCode() ↓ C# 模板注入和 partial 文件输出
→ GenerateData() ↓ 二进制数据追加
→ manager.OnGenerationFinished() → Dispatch(p => p.OnGenerationFinished())
↓ L10nProcessor 执行双向回写 Excel

各自职责划分

  • **CustomProcessorBase**:纯虚基类,包含 OnRawAssemblyLoadedOnDataLoadFinishedOnCodeTargetGenerated 等涵盖编译全期的钩子函数。
  • **CustomProcessorContext**:所有 Processor 的共享容器,使用 context.SetState(Key, Obj) 来实现跨阶段、跨插件数据通讯。提供向原生生成文件动态插入 partial 代码槽的方法。
  • 拓展隔离:核心层(Luban.Core/CustomProcessor)仅包含语言无关通用解析;具体平台的生成和文件读写放在表现层(Luban.CSharp/CustomProcessor),天然支持多语言。开发新的 Golang 拓展无需碰触现有的 CSharp 拓展。

3. 如何新增一个功能拓展?

以「生成自动接取条件反查表」为例:

  1. 建类继承 CustomProcessorBase,打上特性注解 [CustomProcessorAttribute("my-feature", Priority = 200)]
  2. 覆写关心的节点:例如在 OnDefAssemblyCreated 获取带有标记的配表定义;在 OnCodeTargetGenerated 把收集好的反查数据写成 C# Enum / 结构体。
  3. 全过程无需触碰 DefaultPipeline,配置后直接跑 gen.bat 即可独立运行、独立排错。

四、具体拓展的设计细节 (实现篇)

1. l10n 模块:健壮性设计与难点

将一个配置表系统魔改为带状态的”多源合流、分配与双向写回”,存在诸多技术陷阱。

Id 分配优先级的多态对齐
同文本需要复用,但这套复用有着严密的领域划分与边界:

  • 优先级 1 (_existingTextToId):总表中已经登记的文本,直接沿用它。
  • 优先级 2 (_newTextToFormalId):如果当次扫描发现有两个全新的 “确定”,不能生成两个不同 Id,而是当次分配记录内部互通。
  • 优先级 3:完全找不到,则走 AllocFormalId() 递增分配。
  • Prefab Id 与 Excel Id 的物理隔离:Prefab 走 900,000 专用段位。虽然可能都叫”确定”,但 Prefab 和 剧情对话的本地化语境截然不同,因此内部建立 prefabTextToId 时,我们人为排除了对 Excel 段的交叉命中。

数据写回的原子性(两阶段提交)
写回多个 Excel 文件十分危险,极有可能产生部分写成、部分坏档的中间态。因此引入了二段式事务机制

  • 阶段 1:预构建内存结构 (BuildWriteBackPackage)。读 xlsx、验证文本是否被 Trim,将改变缓存到对象中。一旦某一文件抛异常,触发 Catch 直接 Dispose 所有相关资源,表不变,不落地。
  • 阶段 2:统一落盘。内存包验证全部正常,逐一调用 .Save() 写入系统文件,规避单点引发的数据瘫痪。

文本不 Trim 保持语义完整
回写比对时最容易犯的错就是用 Trim()。诸如 " ({0}/{1})" 前带空格的翻译字符串,一旦 Trim 处理,就导致扫描分配阶段以去空格比对、写回时由于原文首位带空格发现匹配不上。必须遵守原始获取、原始匹配

无阻塞的临时 Id(文件锁应对)
遇到 Excel 被策划或外放进程占用锁定锁死,不应当抛出阻塞式异常让流程停服导致卡流程。我们的容灾是分发「临时负数 Id」(Id = -1,-2 等)。负数避开了正向生成段(1+ 、900k+),不会对运行时造成业务污染。等策划关闭应用下一次合表时,便会自动分配回归正式。

2. ConstDict 模块:零负担与动态切入

Partial 方法注入机制
如果在运行时生成字典对象,怎么织入实例化脚本而又不影响主工具链?
Luban 本身生成的对象 TablesCfgXxxx 时都有 partial void __CustomProcessor_Xxxx() 形态。由于 Partial Method 在无实现体时编译器会直接「优化抹除不占用消耗」,我们巧妙利用 CustomProcessorContext 向槽中插入对应的方法执行指令进行对接。这不仅使得 ConstDict 在运行阶段自动通过 Ctor 阶段构建完毕,也不额外增添对生成产物的强耦合干扰,性能毫无损耗。

3. ConstField 模块:解耦与统一化

为什么不用 JSON 取代?
ConstField 的生成与普通配表的 .bytes 在统一加载流水线(基于同一 ByteBuf 读取协议)。如果在数据流上强行走类似 JSON 的读取,相当于启动时要准备两套完全相异的数据源底层支撑以及异步协同库。沿用 ByteBuf 使得常熟类的注入无缝衔接原有的 loader

多维跨表合并与依赖解耦
不同的独立表,由于共享同一个 ConstField#class,需要整合为一个单一 CS 脚本与一个单一的 Byte 流。借助 OnDefAssemblyCreated 的全局收集步骤进行预检与依赖抽象,我们在代码输出阶段将其并于统一上下文中,使得无论是物品Id、场景Id、系统UI_Id,都能最终融合进一个 DefConstConfig 里,便于 IDE 的一次性查询与补全预测。


以下内容面向程序,策划不需要阅读。

为什么 Excel 占用时用临时负数 id 而不是直接报错

导表经常在开发过程中跑,策划不可能每次都保证关闭所有 Excel。报错中断会让其他已有 id 的字段也无法导出,影响程序联调。临时负数 id 让本次导表正常完成,数据可以使用,只是新文本的 id 还没持久化。负数和正整数天然隔离,不会污染已有数据,关闭 Excel 重跑一次即可。


程序:Prefab 侧 l10n 自动化机制

除了配置表,游戏内大量静态 UI 文本也需要多语言支持。如果每次都让策划手动把文本腾挪到 Excel 再把 id 填回 Prefab,工作量极大且极易出错。因此我们在 Unity 侧编写了配合的自动化管线:

1. 核心组件 UIText 与复制防呆检测

我们封装了 UIText 继承自 TextMeshProUGUI。除了多出一个 staticTextKey 字段外,最大的难点是防复制污染
策划在拼 UI 时极为频繁地使用 Ctrl+D 复制节点。如果把带有正确 Id 的原节点复制,新节点会带着相同的 Id;一旦两者的文本被修改得不一致,就会产生「同 Id 不同 Text」的致命冲突。
解决方案
利用 Unity 内部的 m_LocalIdentifierInFile (Local File ID)。

  • 首次分配时,记录当前的 LocalIdentifierInFile 到序列化的隐藏字段 editorBoundLocalId 中。
  • 借由 OnValidate 等时机检查:当前真实的 LocalIdentifier 是否等于之前绑定的 editorBoundLocalId?如果不等,说明这个组件是刚刚被 Ctrl+D 生成的新实例(Unity 会为其分发新的 LocalID)。
  • 此时立刻将 staticTextKey 重置为 0,并重新绑定新的 LocalID。从根源上杜绝了 Id 意外重复的可能。

2. Scanner 通讯管道协议

Unity 编辑器如何与 Luban 无缝交互并实现回写?通过 JSON 与 本地 IO 短短串联:

  1. 收集输入L10nPrefabScanner 遍历 AssetDatabase 中的 Prefab,找出 isStatic=trueUIText 取出 (PrefabPath, ComponentPath, Text, OldId) 序列化并落地至缓存目录 L10nScanInput.json
  2. 唤醒子进程与状态轮询
    • 写入临时的 l10n_gen.cmd 脚本,内部调用 call gen.bat 且结束时 echo done > gen.done
    • 使用 Process.Start 以前台无占用方式弹出 CMD 窗口执行。
    • Unity 通过 EditorApplication.update 每帧轮询 gen.done 标记文件。
  3. Luban 处理(桥接层)
    • Luban 的 l10n CustomProcessor 启动,读取 L10nScanInput.json,在自己独立的 900000 空间里进行同前文一致的复用计算。
    • 结算后将分配好的 Id 列表落地至 L10nScanOutput.json
  4. Unity 响应与回写
    • 轮询碰触到 gen.done,读取 L10nScanOutput.json
    • 通过 AssetDatabase.LoadAssetAtPath<GameObject> 寻址对象,利用事先记录的 ComponentPath(Transform 相对层级路径)精准定位到 UIText 组件。
    • 操作 SerializedObject.FindProperty("staticTextKey").intValue = result.Id 并使用 PrefabUtility.SavePrefabAsset 落盘完成闭环。

3. 护航 Prefab Stage (预制体模式)

这是最容易翻车的地方:用户有时正处于「预制体编辑模式」内部并点击了扫描。
如果不干预,AssetDatabase 读取的是磁盘上的旧版本数据,而扫描回写后,用户一退出 PrefabStage 点击保存,之前的回写数据就被覆盖抹除了。
护航策略

  • 扫描前置拦截:使用 PrefabStageUtility.GetCurrentPrefabStage(),若处于此模式且内容存在 Dirty,强行执行 PrefabUtility.SaveAsPrefabAsset 帮其提前落盘。
  • 扫描回写补偿:如果回写时判断该 Prefab 正处于打开的 Prefab Stage 环境中,我们在将 Id 写入源资源的同时,也必须对 Stage 里的运行时 GameObject 克隆体同步执行 SerializedObject 操作并设脏,确保所见即所得。

程序:维护与扩展

本节内容主要面向程序开发者,涉及到框架底层的维护与功能扩展。

1. 核心概念

  • CustomProcessor:用户自定义处理器,通过实现 ICustomProcessor 接口来扩展 Luban 的导表流程。
  • Pipeline:处理器的执行管线,Luban 内置了多个管线阶段(如 OnGenerationStartOnDefAssemblyCreated 等),处理器可以订阅这些阶段来插入自定义逻辑。
  • Context:处理器上下文,提供了共享数据存取、事件触发等功能。

2. 常见问题排查

  • 导表无反应或报错:检查 Excel 是否关闭,确认没有进程占用 .bytes 文件。
  • Id 分配异常:确认是否有手动修改 Id 的情况,导致自动分配逻辑失效。
  • 文本回写不生效:检查 l10n.xlsx 的权限设置,确保导表工具有写入权限。

3. 扩展指引

  • 新增处理器:继承 CustomProcessorBase 类,重写需要的虚方法(如 OnDefAssemblyCreated)。
  • 配置处理器:在 __tables__.xlsxtags 列中添加处理器标识,格式为 #Processor.序号=参数
  • 调试与测试:使用 Unity 的 Console 视窗查看日志输出,便于定位问题。

4. 性能优化建议

  • 减少不必要的字段扫描:在 Excel 中使用 ##ignore 标记不需要导出的字段。
  • 合并相似表结构:尽量将结构相似的表合并,减少处理器的重复执行次数。
  • 使用字典缓存:对于频繁访问的配置数据,考虑使用 ConstDict 进行缓存。

5. 版本更新与兼容

  • 保持与 Luban 核心同步:定期检查 Luban 的更新日志,关注核心功能的变动。
  • 处理器兼容性:新版本 Luban 可能会对处理器接口或管线阶段进行调整,需及时更新自定义处理器以保持兼容。

至此,Luban 魔改的 CustomProcessor 扩展框架及其在 l10n 自动化中的应用说明完毕。通过本文档的学习与实践,希望能帮助开发者更高效地使用 Luban 工具链,解决实际项目中的各种需求与挑战。