
1. 为什么UI控件绑定值得单独做个编辑器工具把Unity UGUI 一键绑定 UI 控件这件小事单独拎出来做一个编辑器拓展很多人第一反应是至于吗拖个引用能有多累。我在接手过几个中大型项目、也维护过别人写了一半的界面之后可以很确定地说至于。一个项目里界面脚本占比往往超过三成每个界面脚本开头都是十几二十个[SerializeField] private Button xxx;全靠手拖这套流程的隐性成本远比表面看着高。这篇内容适合三类人一是正在被 UGUI 海量引用拖拽折磨的前端向开发者二是想入门 Unity 编辑器拓展但找不到合适练手项目的人三是有一定工程意识、希望把团队里约定沉淀成工具的技术负责人。核心目标很明确——做一个基于命名约定的自动生成自动绑定工具选中面板节点点一下菜单脚本里的字段声明和 Inspector 引用全部自动补齐不需要你手动拖任何一个引用。这里说的绑定控件具体指什么就是把场景或预制体层级里的Button、Text、Image、Slider、Toggle、ScrollRect这些常见 UGUI 组件按照一套命名规则自动映射成 C# 脚本里的私有字段并且把组件引用写进序列化数据里。整个过程在编辑器下完成运行时不引入任何反射开销生成出来的还是最普通的[SerializeField]字段跟你手写的没有任何区别。我做这个东西的契机很朴素某次改一个设置面板光是把二十多个按钮重新拖一遍就花了四十多分钟中间还拖错过两次运行时才报空引用。后来一咬牙写了第一版工具代码量不到四百行但从此以后那个项目里再没有人手动拖过 UI 引用。这篇文章会把完整思路、可直接抄的代码、踩过的坑全部摊开讲不藏私。2. 工具的整体设计与命名约定拆解2.1 手动拖引用到底亏在哪里先算一笔账。一个中等复杂度的界面面板假设有 15 个需要交互的控件手动绑定这件事的成本包含这几块第一次拖 15 次假设每个平均 5 秒约 75 秒拖完之后你要在代码里写 15 行字段声明又要 60 秒左右如果中途改了层级结构或者删了某个节点对应引用会变成Missing (GameObject)这种问题不会编译报错只在运行时炸最后预制体上的引用数据.prefab文件里的m_GameObject和m_Script对应的对象引用块在多人协作时极易产生合并冲突两个人在同一个面板上加了不同控件合并时基本只能二选一重做。第二块隐性成本更烦人拖引用这个过程本身没有校验。你把BtnClose拖到了m_BtnConfirm上编辑器不会拦你因为类型都是Button。这类错误在测试阶段往往表现为点确认按钮结果面板关了排查起来非常费时间。第三块是心态成本。当你知道改个界面要拖二十次引用你就会本能地抗拒重构界面结构于是层级越堆越乱越乱越不敢动最后变成一个没人敢碰的面板。做工具真正的收益是把这块心理阻力拿掉。2.2 四种主流方案的横向对比写工具之前我把能想到的绑定方案都试了一遍结论放在下面这张表里你选方案的时候可以直接参考。方案实现难度运行时开销类型安全预制体友好度适用规模手动拖拽无无弱易拖错差易冲突小型面板transform.FindGetComponent低中等启动时字符串查找弱字符串写错不报错好简单脚本特性/反射注入中有反射有开销且IL2CPP下需注意裁剪中中中型项目编辑器代码生成 序列化引用中无强编译期检查好中大型项目transform.Find那套方案很多人用过问题是路径字符串一旦改层级就失效而且启动时遍历查找在面板多的时候会拖慢打开速度低端机型上尤其明显。反射注入看着优雅但 IL2CPP 打包下反射涉及的类型可能被裁剪需要额外配置 link.xml维护成本反而更高而且它跟 Unity 的序列化体系是两套逻辑Prefab 变体Variant改起来很容易出意外。最终我选的是代码生成 序列化引用。字段是编译期真实存在的写错名字编译不过引用是正常的[SerializeField]走 Unity 原生序列化Prefab 变体、Addressable、AssetBundle 全都天然兼容运行时零开销跟手写代码完全等价。唯一要额外处理的是生成代码怎么写入已有脚本又不覆盖手写内容这个问题后面用标记块解决了。2.3 命名约定整个工具的地基工具能不能靠得住八成取决于命名约定设计得好不好。我的规则很简单节点名 类型前缀 下划线 业务名例如btn_login、txt_title、img_avatar、sld_volume。前缀决定这个节点上的哪个组件被绑定业务名决定生成出来的字段叫什么。下划线只是分隔符不区分大小写btn_login、btnLogin、Btn_Login我都做了兼容处理最终统一转成帕斯卡命名m_BtnLogin。m_前缀是 Unity 官方代码风格里对私有字段的约定保留它能让生成出来的代码跟手写代码混在一起时分不出彼此这对团队新成员理解代码很重要。前缀到组件的映射关系我用一张静态字典维护方便后续扩展前缀目标组件生成字段示例btnButtonm_BtnLogintxtText/TMP_Textm_TxtTitleimgImagem_ImgAvatarsldSliderm_SldVolumetglTogglem_TglAutoPlayscrScrollRectm_ScrListinpInputField/TMP_InputFieldm_InpUserNamegoGameObject节点本身m_GoRedDot关于txt映射到Text还是TMP_Text我的做法是运行时优先取TMP_Text取不到再退回Text这样一套命名能同时兼容两种文本方案不用为了换 TextMeshPro 把整个项目的节点名重命名一遍。命名约定必须写进团队规范文档并且在新人入职时明确告知。工具本身不会强制你遵守约定它只是发现符合约定的节点就绑定不符合就跳过如果团队里有人随手把节点命名成Button (1)工具只会安静地忽略它最后你在运行时收到空引用反而更难查。3. 编辑器工具的核心实现逐段拆解3.1 目录结构与入口设计先定目录。所有编辑器代码必须放在名为Editor的文件夹下否则打包时会因为引用了UnityEditor命名空间而编译失败。我习惯这样组织Assets/ Scripts/ UI/ Panels/ LoginPanel.cs LoginPanel.Generated.cs ← 生成的部分 Editor/ UIBindTool/ UIBindWindow.cs UIBindGenerator.cs UIBindSettings.asset关于代码到底写进原文件还是写进.Generated.cs我两个版本都用过各有取舍。写进原文件的好处是一个面板只有一个脚本找起来直观缺点是生成逻辑要小心处理插入位置一旦正则写崩就会毁掉手写代码。拆成独立partial文件的好处是生成文件可以整体覆盖永远不担心误伤手写代码而且可以在文件头写// auto-generated /让 Git 把它标成生成物。我最终选的是独立 partial 文件方案这也是踩过坑之后的选择。早期版本我直接在原文件里做正则替换有一次正则贪婪匹配跨过了好几行注释把一个面板的输入逻辑代码全删了还好提交前看了眼 diff。用 partial 之后生成文件每次整文件重写最坏情况只是字段丢失重新点一次菜单就能恢复不会波及手写逻辑。入口我用[MenuItem]特性挂两个命令一个对当前选中的节点生效一个批量对选中目录下的所有面板生效[MenuItem(Tools/UI/一键绑定选中面板 %#b)] private static void BindSelected() { var panel Selection.activeGameObject; if (panel null) { EditorUtility.DisplayDialog(UI绑定, 请先在 Hierarchy 中选中面板根节点。, 知道了); return; } UIBindGenerator.Generate(panel); } [MenuItem(Tools/UI/打开发布窗口 %#w)] private static void OpenWindow() { var window EditorWindow.GetWindowUIBindWindow(UI一键绑定); window.minSize new Vector2(420, 300); window.Show(); }%#b是快捷键声明%代表 CtrlmacOS 上是 Cmd#代表 Shift所以是CtrlShiftB。这套快捷键语法记不住的话可以先不写等用顺手了再补。3.2 递归遍历与组件匹配核心逻辑是从面板根节点开始深度优先遍历对每个节点检查名字是否符合约定、是否挂载了目标组件。这里有两个容易忽略的细节第一遍历要用transform.GetChild(i)而不是GetComponentsInChildrenTransform(true)一次性拿因为前者能保证父节点先于子节点被访问生成的字段顺序跟 Hierarchy 视觉顺序一致读代码的人会觉得自然第二includeInactive一定要传true否则隐藏状态下的控件会被漏掉。public static void Collect(GameObject root, ListBindEntry result) { var prefixMap UIBindSettings.Instance.prefixMap; // 前缀 - 类型 string nodeName root.name; foreach (var pair in prefixMap) { if (!nodeName.StartsWith(pair.Key, StringComparison.OrdinalIgnoreCase)) continue; // 前缀后必须紧跟分隔符避免 btnClose 被当成 btn Close 之外的误匹配 int len pair.Key.Length; if (nodeName.Length len) { char sep nodeName[len]; if (sep ! _ sep ! - char.IsLetterOrDigit(sep)) continue; } Component target ResolveComponent(root, pair.Value); if (target null) continue; string fieldName m_ ToPascal(pair.Key) ToPascal(nodeName.Substring(len)); result.Add(new BindEntry { FieldName fieldName, TypeName pair.Value.Name, Target target, NodePath GetPath(root.transform, root.transform) }); break; // 一个节点只匹配一个前缀先匹配先生效 } for (int i 0; i root.transform.childCount; i) Collect(root.transform.GetChild(i).gameObject, result); }ResolveComponent里处理txt前缀的优先级问题private static Component ResolveComponent(GameObject go, Type type) { if (type typeof(TMP_Text)) return go.GetComponentTMP_Text() ?? (Component)go.GetComponentText(); if (type typeof(TMP_InputField)) return go.GetComponentTMP_InputField() ?? (Component)go.GetComponentInputField(); return go.GetComponent(type); }重名处理也是必须的。同一个面板里出现两个btn_close是完全可能的比如主面板一个、子面板一个这时候字段名会撞车C# 直接编译不过。我在收集完成后加了一道去重同名字段自动追加序号m_BtnClose、m_BtnClose_2同时在生成文件的注释里标注对应的节点路径方便你事后判断哪个是哪个。3.3 生成 partial 文件收集完数据接下来把它写成 C# 代码。生成文件的模板我固定成这样private const string FileTemplate // auto-generated // 由 UIBindTool 自动生成请勿手动修改。 // 重新生成会覆盖本文件全部内容。 // /auto-generated using UnityEngine; using UnityEngine.UI; using TMPro; public partial class {0} : MonoBehaviour { {1} } ;字段行按类型分组Button、Text、Image各排在一起视觉上比乱序舒服很多。每个字段上面加一行注释写节点路径这是后期维护时最省事的做法——你在代码里看到m_BtnConfirm注释里直接写着Panel/Bottom/btn_confirm找节点不用再猜。private static string BuildFields(ListBindEntry entries) { var sb new StringBuilder(); var groups entries.GroupBy(e e.TypeName).OrderBy(g g.Key); foreach (var g in groups) { sb.AppendLine($ // ---- {g.Key} ----); foreach (var e in g.OrderBy(e e.FieldName)) { sb.AppendLine($ // Path: {e.NodePath}); sb.AppendLine($ [SerializeField] private {e.TypeName} {e.FieldName};); } sb.AppendLine(); } return sb.ToString(); }路径获取要注意Unity 里transform.name已经包含了节点名拼接时用/分隔即可。如果节点在面板根下面从根的子节点开始拼不要在开头带上 (Clone) 之类的运行时后缀。命名转换函数是整个工具里被调用最频繁的一段要处理下划线、短横线、空格、数字开头等边界public static string ToPascal(string raw) { if (string.IsNullOrEmpty(raw)) return raw; var sb new StringBuilder(raw.Length); bool upperNext true; foreach (char c in raw) { if (c _ || c - || c ) { upperNext true; continue; } sb.Append(upperNext ? char.ToUpperInvariant(c) : c); upperNext false; } // 数字开头补下划线保证是合法标识符 if (sb.Length 0 char.IsDigit(sb[0])) sb.Insert(0, _); return sb.ToString(); }3.4 把引用写回序列化数据代码写完了字段是空的引用还得写回去。这一步用SerializedObject是最稳的因为它会自动处理 Prefab 覆盖Override、Undo 记录和脏标记。private static void ApplyReferences(MonoBehaviour target, ListBindEntry entries) { Undo.RecordObject(target, UIBind 自动绑定); var so new SerializedObject(target); foreach (var e in entries) { var prop so.FindProperty(e.FieldName); if (prop null) { Debug.LogWarning($[UIBind] 未在 {target.GetType().Name} 上找到字段 {e.FieldName} $确认脚本已重新编译。); continue; } prop.objectReferenceValue e.Target; } so.ApplyModifiedProperties(); EditorUtility.SetDirty(target); AssetDatabase.SaveAssets(); }这里有个顺序问题必须强调生成代码和写引用分两步中间必须等 Unity 编译完成。因为字段还没编译出来FindProperty是找不到的。我的做法是生成文件后调用AssetDatabase.ImportAsset强制刷新然后通过EditorApplication.delayCall延迟一帧再执行写引用。如果延迟一帧还是失败大项目编译慢可以退化成提示用户再点一次。如果你在 Prefab 编辑模式下操作Undo.RecordObject加SetDirty组合是有效的但记得在写完后调用一次PrefabUtility.SavePrefabAsset如果是编辑 Prefab 资源或者让用户在 Prefab Mode 里手动保存。批量操作时千万不要在循环里反复SaveAssets几十个面板会卡到让人怀疑人生攒到最后统一存一次。3.5 批量处理与进度条面板多的时候逐个点菜单太低效。我加了一个批量入口多选 Hierarchy 节点或者选中一个 Prefab 目录工具遍历其中所有挂载了面板基类的组件逐个执行生成。这里用进度条提示很重要因为批量操作可能持续几秒没有反馈用户会以为卡死了。private static void BatchBind(GameObject[] roots) { try { for (int i 0; i roots.Length; i) { EditorUtility.DisplayProgressBar(UIBind 批量绑定, roots[i].name, (float)i / roots.Length); Generate(roots[i]); } } finally { EditorUtility.ClearProgressBar(); } }4. 从零跑通完整实操流程4.1 环境与前置准备Unity 版本我建议 2020.3 LTS 及以上实际在 2021.3 和 2022.3 上跑了很久都很稳定。编辑器拓展 API 这块从 2018 到现在基本没动过所以你用更老的版本大概率也能跑。需要装的包如果你用 TextMeshProcom.unity.textmeshpro2023 之后并入了 UGUI 包必须在项目里否则TMP_Text相关代码编译不过。如果你暂时不用 TMP可以把生成模板里的using TMPro;和对应的两个类型映射删掉或者用宏#if TMP_PRESENT包起来。创建脚本的步骤在Assets/Editor/下新建UIBindTool文件夹注意目录名必须叫Editor大小写敏感。新建UIBindSettings.cs用ScriptableObject存前缀映射表放在Assets/Editor/UIBindTool/下方便团队自定义。新建UIBindGenerator.cs把前面几节的逻辑填进去。新建UIBindWindow.cs做可视化的配置界面。回 Unity 让它编译菜单栏出现Tools/UI/一键绑定选中面板就成功了。配置表我建议用ScriptableObject而不是硬编码常量原因是不同团队对前缀的喜好差别很大有人喜欢btn有人喜欢Btn有人干脆用中文拼音anniu。做成资源文件后改配置不需要动代码也不用担心改错了还要回滚。[CreateAssetMenu(fileName UIBindSettings, menuName Tools/UIBind Settings)] public class UIBindSettings : ScriptableObject { [Serializable] public class PrefixEntry { public string prefix; public string typeName; // 存类型全名运行时反射解析 } public ListPrefixEntry entries new ListPrefixEntry { new PrefixEntry { prefix btn, typeName UnityEngine.UI.Button }, new PrefixEntry { prefix txt, typeName TMPro.TMP_Text }, new PrefixEntry { prefix img, typeName UnityEngine.UI.Image }, new PrefixEntry { prefix sld, typeName UnityEngine.UI.Slider }, new PrefixEntry { prefix tgl, typeName UnityEngine.UI.Toggle }, }; private static UIBindSettings _instance; public static UIBindSettings Instance { get { if (_instance null) { var guid AssetDatabase.FindAssets(t:UIBindSettings); if (guid.Length 0) _instance AssetDatabase.LoadAssetAtPathUIBindSettings( AssetDatabase.GUIDToAssetPath(guid[0])); } return _instance; } } }4.2 一次完整绑定的现场记录假设我要做一个登录面板层级结构是这样的LoginPanel (挂 LoginPanel 脚本继承 UIPanelBase) bg txt_title (Text) inp_username (TMP_InputField) inp_password (TMP_InputField) btn_login (Button) btn_close (Button) img_logo (Image)操作流程选中LoginPanel节点。按CtrlShiftB或者菜单点Tools/UI/一键绑定选中面板。工具自动生成LoginPanel.Generated.cs内容大致如下// auto-generated // 由 UIBindTool 自动生成请勿手动修改。 // /auto-generated using UnityEngine; using UnityEngine.UI; using TMPro; public partial class LoginPanel : MonoBehaviour { // Path: LoginPanel/btn_close [SerializeField] private Button m_BtnClose; // Path: LoginPanel/btn_login [SerializeField] private Button m_BtnLogin; // Path: LoginPanel/img_logo [SerializeField] private Image m_ImgLogo; // Path: LoginPanel/inp_password [SerializeField] private TMP_InputField m_InpPassword; // Path: LoginPanel/inp_username [SerializeField] private TMP_InputField m_InpUsername; // Path: LoginPanel/txt_title [SerializeField] private TMP_Text m_TxtTitle; }等 Unity 编译完Inspector 上LoginPanel组件的对应槽位已经自动填好了。手写逻辑放在LoginPanel.cs里直接引用字段public partial class LoginPanel : MonoBehaviour { private void Awake() { m_BtnLogin.onClick.AddListener(OnLoginClicked); m_BtnClose.onClick.AddListener(OnCloseClicked); } private void OnLoginClicked() { string user m_InpUsername.text; string pwd m_InpPassword.text; // ... 校验与请求 } private void OnCloseClicked() gameObject.SetActive(false); }从选中节点到能用熟练之后整个过程不超过五秒。字段名字母顺序排列找起来也快。4.3 单节点匹配多个组件怎么办实际项目里经常遇到一个节点上需要同时拿到Button和它自己的Image比如要改按钮底图颜色或者一个ScrollRect节点上还要拿RectTransform做位置计算。我的处理方式是不去猜而是要求你把同一个节点复制成两种前缀名——听起来笨但比工具自作聪明可靠得多。更实用的做法是加两个万能前缀ui_表示绑定该节点上的第一个Graphic派生组件rect_表示绑定RectTransform。这样rect_list这种节点名就能直接拿到矩形不用再写m_ScrList.GetComponentRectTransform()。需要更多类型的场景直接在配置表里加前缀就行工具本身是数据驱动的。还有一种情况是脚本需要绑定的对象不在自己节点下比如弹窗需要引用一个全局的遮罩层。这种跨层级的引用我不建议硬塞进一键绑定流程因为它脱离了面板自治的原则交给一个独立的[SerializeField]字段手动维护更清晰。4.4 嵌套面板与前缀隔离面板嵌套是 UGUI 里绕不开的话题。一个MainPanel里嵌了SettingsPanel和ShopPanel这三个面板都有自己的脚本。如果你在MainPanel上点一键绑定工具会递归到所有子节点把子面板的控件也一并绑进MainPanel的脚本里这显然不是你要的。解决办法是在配置里加一个停止节点前缀名单比如以panel_开头的节点不再继续向下遍历。当遍历遇到panel_settings时直接跳过它的整棵子树交给它自己的脚本处理。这样每个面板只关心自己直属层级的控件职责边界清晰也符合每个面板一个脚本的工程习惯。5. 常见问题与排查实录5.1 排查速查表工具用久了遇到问题基本就那么几类。我把它们整理成一张表出问题时按顺序对照着查比盲目打日志快得多。现象可能原因处理方式生成的字段是空的写引用那一步在编译完成前执行了用delayCall延迟或手动再点一次某个控件完全没被绑定节点名不符合前缀约定检查名字或往配置表加前缀编译报错字段已存在手写脚本里也声明了同名字段删掉手写的重复声明引用显示Missing节点被删或改名了重新执行一次绑定报错找不到 partial 定义生成文件没被 Unity 导入AssetDatabase.Refresh()字段名带奇怪后缀_2面板里有重名节点重命名节点消除歧义5.2 字段冲突与手写代码共存最大的坑其实不是技术问题而是习惯问题。工具上线初期最常见的场景是同事已经手写了private Button m_BtnLogin;然后又点了一次一键绑定生成文件里出现同名字段编译器直接报The type already contains a definition for m_BtnLogin。我的处理方式是两步走。第一步生成前扫描手写文件提取其中已有的[SerializeField]字段名加入排除列表已经在手写文件里声明过的名字不再生成。第二步在编辑器窗口里显示一个待迁移字段列表提示你这些字段已经被手写代码占用建议把手写声明搬进生成流程。这个提示不是强制性的但用过几次之后大家都会主动把手写字段清掉代码就自然收敛了。关于字段是否应该暴露成public我的建议是坚决不要。生成出来的字段一律private需要给外部访问就加只读属性或者方法。序列化字段在 Inspector 上能改本身就是一种外部可写再暴露成 public 会让面板内部状态变得不可追踪。5.3 隐藏控件的绑定与显示切换热词里有个讨论度挺高的问题UI 显示隐藏到底用SetActive、改localScale还是移出相机。这个问题跟绑定工具其实强相关因为隐藏方式会影响你的引用行为。SetActive(false)的节点工具在绑定时会用includeInactive: true正常收集引用不会丢运行时SetActive(true)之后所有字段依然有效这是最推荐的隐藏方式。改localScale的方式虽然避免了重建布局的开销但节点其实还在渲染树里只是尺寸为 0如果里面有LayoutGroup或者Mask容易出现意料之外的重排移出相机的方式只适合特殊场景比如做性能优化时把远景内容挪走日常 UI 隐藏不要用。还有一个细节如果你把面板根节点SetActive(false)之后再打开Awake不会重新执行但字段引用是序列化在组件上的本来就还在不用担心。真正会出问题的是运行时Instantiate出来的面板副本——如果生成文件里的字段正确声明实例化后的引用也是完整拷贝的这一点跟手写代码没有区别。5.4 关于节点改名的处理节点改名是另一个高频场景。改完名字之后旧字段还在脚本里新名字对应的字段还没生成工具再跑一次会生成新字段旧字段就变成孤儿但引用其实还指着同一个对象。这种状态如果不管脚本里会慢慢堆积一批没用的字段。我的解决办法是引入清理模式一键绑定时默认只增不减但在窗口里提供一个清理失效字段的勾选项勾上之后会对比当前节点树和生成文件里的字段列表把找不到对应节点的字段注释掉而不是直接删——注释掉更安全万一你只是临时改了名字又改回来取消注释就行。这个设计参考了 Swagger 生成代码时的思路宁可留下垃圾注释也不要在你手抖的时候删掉真实逻辑。6. 几个让我少走弯路的实操心得先说性能相关的。生成文件的大小要控制住一个面板生成三四十个字段是正常的超过一百个就该考虑拆面板了。字段数量过多会拖慢 Inspector 的绘制速度SerializedObject每次刷新都要遍历所有属性节点多的时候能明显感觉到 Inspector 卡顿。这也是我前面强调嵌套面板要切断遍历的原因之一。再说一个很多人会忽略的点生成文件记得加进版本控制但要在.gitattributes里把它标记为linguist-generated。这不是为了好看而是为了让 Git 的 diff 统计和代码审查工具自动折叠这类文件。团队里没人愿意在 review 时看一份自动生成的字段列表折叠掉之后注意力才能真正放在手写逻辑上。还有个小技巧是关于命名转换的一个边界情况如果节点名里有连续大写缩写比如btn_HTTPRetry朴素的 Pascal 转换会得到HttpRetry跟你预期的HTTPRetry不一样。Unity 官方代码风格对缩写词的处理没有统一标准我的建议是团队内部约定节点名全小写缩写也小写btn_http_retry转换出来是HttpRetry清晰且没有歧义。约定比技巧更重要。最后一个我觉得值得分享的设计取舍我一开始想给工具加自动在代码里生成事件绑定模板也就是不但生成字段还自动写好onClick.AddListener的骨架。试了一版之后删掉了原因是自动生成的绑定代码会不断被覆盖而事件绑定逻辑往往需要参数、需要条件判断模板化之后反而更难改。工具应该只做机械重复的那部分涉及业务判断的地方留给人这个边界划清楚工具才用得长久。工具本身不到五百行但它改变的是整个团队对待界面的方式。不用再纠结引用有没有拖漏不用再担心预制体合并冲突炸掉半天的引用数据面板改起来心里没负担结构自然就越改越干净。我在把它推广出去之后最大的反馈是这玩意儿看起来没必要用一周之后就回不去了——某种程度上这大概就是一个编辑器工具能拿到的最好评价。