1. 项目概述与核心价值最近在做一个大型的Unity项目策划案、美术需求、程序逻辑、待办事项各种零散的信息散落在聊天记录、本地文档和脑子里找起来特别费劲。我就在想能不能在Unity编辑器里直接搞一个笔记工具就像游戏里的任务日志一样随时记录随时查看还能跟项目资产关联起来。这就是“Unity笔记编辑器”这个想法的由来。它不是一个独立的软件而是深度集成在Unity编辑器内部的一个工具窗口专门为Unity开发者、技术美术、策划甚至项目经理设计用来管理项目开发过程中的所有非代码类信息。这个工具的核心价值在于“场景化”和“一体化”。你不用再AltTab切出去打开记事本或Notion在Unity里选中一个Prefab想记录它的设计意图或已知Bug直接在这个笔记编辑器里新建一页写下来就行。它支持多页面就像浏览器标签页一样你可以为“角色系统”、“战斗数值”、“场景BUG列表”分别开一个页面。更重要的是它支持重命名和持久化保存所有笔记数据会以序列化的形式保存在项目里提交到版本控制系统如Git、SVN后团队成员都能看到最新的笔记实现了信息的同步和沉淀。对于Unity开发者而言这不仅仅是记笔记。你可以把一些常用的Shader参数组合、编辑器扩展的快捷键、或者某个复杂算法的伪代码记在里面相当于一个内置的、可搜索的“开发手册”。对于技术美术可以记录材质球的调试参数、特效的迭代思路。对于策划可以直接在Unity里对照场景写配置说明。它解决的核心痛点是将碎片化的、临时的项目知识结构化地沉淀在项目工程内部降低沟通成本防止知识流失。2. 整体架构设计与思路拆解2.1 技术选型为什么是EditorWindow与ScriptableObject要实现一个Unity编辑器内的工具首选自然是继承自EditorWindow类。这是Unity提供创建自定义编辑器窗口的基石。通过它我们可以像创建Scene视图、Game视图一样创建一个独立的、可停靠的窗口。数据持久化是另一个关键。我们当然可以用PlayerPrefs但它更适合存储玩家偏好设置对于结构化的、可能很庞大的笔记数据来说并不合适。也可以用纯文本文件如.json, .txt但管理多个页面、处理序列化/反序列化会稍显繁琐。这里我选择了ScriptableObject。选择ScriptableObject的理由原生序列化支持ScriptableObject是Unity原生支持序列化的资产可以像Prefab、Material一样保存在项目中完美融入Unity的资产管理系统。版本控制友好数据以.asset文件形式存在可以被Git等版本控制系统完美追踪方便团队协作和变更历史查看。易于扩展ScriptableObject本身是一个类我们可以轻松地为它添加字段如页面列表、创建时间、作者等未来扩展性极强。编辑器集成度高可以方便地在Project窗口创建、选中甚至可以为我们的笔记数据资产定制一个专属的Inspector界面。因此整体架构很清晰一个NoteEditorWindow继承自EditorWindow负责界面绘制和用户交互一个NoteData继承自ScriptableObject作为数据模型存储所有笔记页面和内容两者通过序列化引用关联。2.2 核心数据结构设计一个笔记系统核心是“笔记本”和“页面”。我们的NoteData资产就是一个“笔记本”。using UnityEngine; using System.Collections.Generic; [CreateAssetMenu(fileName NewNoteData, menuName Tools/Note Data)] public class NoteData : ScriptableObject { [System.Serializable] public class NotePage { public string pageId; // 页面唯一标识用于查找和持久化引用 public string pageName; // 页面显示名称 public string content; // 页面内容支持富文本 public System.DateTime lastModifiedTime; // 最后修改时间 } public ListNotePage pages new ListNotePage(); public int currentSelectedPageIndex 0; // 当前选中的页面索引 }这里有几个设计要点pageId使用System.Guid.NewGuid().ToString()生成。为什么不用索引或名称因为重命名页面时名称会变删除页面时索引会变。一个全局唯一的ID可以确保在任何操作下对特定页面的引用都不会错乱这对于持久化和未来可能的功能如页面链接至关重要。content字段我们直接使用string类型并计划用Unity的GUIStyle或EditorGUILayout.TextArea来渲染。这样可以直接支持Unity富文本标签如b粗体/b、colorred红色/color简单又实用。更复杂的格式如Markdown可以后期通过解析来实现。lastModifiedTime记录修改时间方便用户了解哪些内容是最新的。这在团队协作中尤其有用。3. 核心功能模块实现详解3.1 编辑器窗口(EditorWindow)的创建与布局首先创建我们的主窗口类NoteEditorWindow。using UnityEditor; using UnityEngine; public class NoteEditorWindow : EditorWindow { private NoteData currentNoteData; // 当前加载的笔记数据资产 private Vector2 scrollPosition; // 滚动视图位置 private string searchFilter ; // 页面搜索过滤器 // 添加菜单项 [MenuItem(Tools/Note Editor)] public static void ShowWindow() { var window GetWindowNoteEditorWindow(); window.titleContent new GUIContent(Note Editor); window.Show(); } private void OnGUI() { DrawToolbar(); DrawMainArea(); } }OnGUI是编辑器窗口的“心脏”每一帧都会调用。我们需要在里面划分两个主要区域顶部的工具栏和主体的编辑区。绘制工具栏(DrawToolbar)工具栏需要提供加载/创建笔记资产、保存、新建页面、删除页面、搜索等功能按钮。private void DrawToolbar() { EditorGUILayout.BeginHorizontal(EditorStyles.toolbar); { // 加载/创建按钮 if (GUILayout.Button(Load/Create, EditorStyles.toolbarButton, GUILayout.Width(100))) { // 弹出窗口让用户选择或创建NoteData资产 HandleLoadOrCreateNoteData(); } // 如果已加载资产显示资产名和保存按钮 if (currentNoteData ! null) { GUILayout.Label($Notebook: {currentNoteData.name}, EditorStyles.miniLabel); GUILayout.FlexibleSpace(); // 占位将后续按钮推到右边 // 搜索框 searchFilter EditorGUILayout.TextField(searchFilter, EditorStyles.toolbarSearchField, GUILayout.Width(200)); if (GUILayout.Button(Save, EditorStyles.toolbarButton, GUILayout.Width(60))) { EditorUtility.SetDirty(currentNoteData); // 标记资产为“脏”需要保存 AssetDatabase.SaveAssets(); // 保存所有资产 Debug.Log(Note saved.); } } } EditorGUILayout.EndHorizontal(); }注意EditorUtility.SetDirty(currentNoteData)这行代码至关重要。Unity不会自动检测到对ScriptableObject字段的修改。任何通过代码改变了currentNoteData的内容如修改了pages列表都必须调用此方法告诉Unity这个资产被修改了否则关闭项目时所有更改都会丢失。3.2 多页面管理与标签页交互主体区域我们采用左右分栏的经典布局左侧是页面列表右侧是选中页面的编辑区。private void DrawMainArea() { if (currentNoteData null) { EditorGUILayout.HelpBox(Please load or create a Note Data asset first., MessageType.Info); return; } EditorGUILayout.BeginHorizontal(); { // 左侧页面列表 (占30%宽度) EditorGUILayout.BeginVertical(GUILayout.Width(position.width * 0.3f)); DrawPageList(); EditorGUILayout.EndVertical(); // 右侧页面编辑区 (占70%宽度) EditorGUILayout.BeginVertical(); DrawPageEditor(); EditorGUILayout.EndVertical(); } EditorGUILayout.EndHorizontal(); }绘制页面列表(DrawPageList):这里要处理页面的增、删、改重命名、查筛选以及切换选中页面。private void DrawPageList() { // 列表标题和新建按钮 EditorGUILayout.BeginHorizontal(); GUILayout.Label(Pages, EditorStyles.boldLabel); if (GUILayout.Button(, GUILayout.Width(25))) { CreateNewPage(); } EditorGUILayout.EndHorizontal(); EditorGUILayout.Space(5); // 绘制过滤后的页面列表 var filteredPages currentNoteData.pages.FindAll(p p.pageName.ToLower().Contains(searchFilter.ToLower())); scrollPosition EditorGUILayout.BeginScrollView(scrollPosition); for (int i 0; i filteredPages.Count; i) { var page filteredPages[i]; EditorGUILayout.BeginHorizontal(); // 通过一个单选框样式按钮来实现页面切换 bool isSelected (currentNoteData.currentSelectedPageIndex currentNoteData.pages.IndexOf(page)); if (GUILayout.Toggle(isSelected, page.pageName, EditorStyles.miniButton, GUILayout.ExpandWidth(true))) { if (!isSelected) { currentNoteData.currentSelectedPageIndex currentNoteData.pages.IndexOf(page); EditorUtility.SetDirty(currentNoteData); } } // 重命名按钮 if (GUILayout.Button(R, EditorStyles.miniButton, GUILayout.Width(20))) { StartRenamePage(page); } // 删除按钮 if (GUILayout.Button(X, EditorStyles.miniButton, GUILayout.Width(20))) { if (EditorUtility.DisplayDialog(Delete Page, $Are you sure to delete {page.pageName}?, Yes, No)) { DeletePage(page); } } EditorGUILayout.EndHorizontal(); } EditorGUILayout.EndScrollView(); }页面重命名实现技巧直接弹出一个输入框是最简单的方式但为了体验更流畅我们可以实现一个“就地编辑”的效果。这需要一点状态管理。private NotePage pageBeingRenamed null; private string tempRenameString ; private void StartRenamePage(NotePage page) { pageBeingRenamed page; tempRenameString page.pageName; } // 在DrawPageList的循环中针对正在重命名的页面绘制一个文本框而不是标签 if (pageBeingRenamed page) { GUI.SetNextControlName(RenameField); tempRenameString EditorGUILayout.TextField(tempRenameString); // 如果按下回车或失去焦点完成重命名 if (Event.current.type EventType.KeyDown Event.current.keyCode KeyCode.Return GUI.GetNameOfFocusedControl() RenameField) { FinishRename(); } } else { // ... 原来的绘制单选框和按钮的代码 } private void FinishRename() { if (pageBeingRenamed ! null !string.IsNullOrEmpty(tempRenameString)) { pageBeingRenamed.pageName tempRenameString; pageBeingRenamed.lastModifiedTime System.DateTime.Now; EditorUtility.SetDirty(currentNoteData); pageBeingRenamed null; tempRenameString ; GUI.FocusControl(null); // 移除焦点 } }实操心得在编辑器扩展中处理文本输入框的焦点和回车键确认是一个常见模式。GUI.SetNextControlName和GUI.GetNameOfFocusedControl()是控制焦点的关键。记得在重命名完成后调用GUI.FocusControl(null)来清除焦点否则后续的键盘事件可能被意外捕获。3.3 富文本编辑与持久化保存绘制页面编辑器(DrawPageEditor):这是用户输入内容的核心区域。我们使用EditorGUILayout.TextArea来提供一个多行文本输入框。private void DrawPageEditor() { if (currentNoteData.pages.Count 0 || currentNoteData.currentSelectedPageIndex 0 || currentNoteData.currentSelectedPageIndex currentNoteData.pages.Count) { EditorGUILayout.HelpBox(No page selected or available., MessageType.Warning); return; } var currentPage currentNoteData.pages[currentNoteData.currentSelectedPageIndex]; // 显示页面标题和最后修改时间 EditorGUILayout.LabelField(currentPage.pageName, EditorStyles.largeLabel); EditorGUILayout.LabelField($Last Modified: {currentPage.lastModifiedTime:yyyy-MM-dd HH:mm:ss}, EditorStyles.miniLabel); EditorGUILayout.Space(10); // 富文本编辑区 EditorGUI.BeginChangeCheck(); // 开始检查变更 string newContent EditorGUILayout.TextArea(currentPage.content, GUILayout.ExpandHeight(true)); if (EditorGUI.EndChangeCheck()) // 如果内容发生了改变 { currentPage.content newContent; currentPage.lastModifiedTime System.DateTime.Now; EditorUtility.SetDirty(currentNoteData); // 标记为脏 } // 可以在这里添加一个简单的工具栏用于插入粗体、颜色等富文本标签 DrawSimpleFormatToolbar(currentPage); }简单的格式工具栏示例private void DrawSimpleFormatToolbar(NotePage page) { EditorGUILayout.BeginHorizontal(); if (GUILayout.Button(Bold)) { InsertTagAroundSelection(ref page.content, b, /b); } if (GUILayout.Button(Italic)) { InsertTagAroundSelection(ref page.content, i, /i); } // ... 可以添加更多按钮 EditorGUILayout.EndHorizontal(); } // 一个辅助函数用于在文本区域选中的内容周围插入标签 // 注意这是一个简化版实际需要处理TextEditor来获取精确的选中位置这里用替换整个字符串模拟 private void InsertTagAroundSelection(ref string content, string openTag, string closeTag) { // 在实际项目中这里需要获取TextArea的选中文本和位置比较复杂。 // 作为简化我们假设用户想在整个内容前后加标签实际不可用仅示意。 // 真正实现需要用到 EditorGUIUtility.systemCopyBuffer 和 TextEditor 类代码量较大。 content openTag content closeTag; EditorUtility.SetDirty(currentNoteData); }关于持久化保存我们已经多次提到EditorUtility.SetDirty。这是编辑器模式下手动触发保存的“信号枪”。当用户点击我们工具栏的“Save”按钮或者我们希望自动保存时比如在OnDestroy窗口关闭时调用AssetDatabase.SaveAssets()即可将内存中所有标记为“脏”的资产包括我们的NoteData写入磁盘。private void OnDestroy() { // 窗口关闭时尝试自动保存 if (currentNoteData ! null EditorUtility.IsDirty(currentNoteData)) { if (EditorUtility.DisplayDialog(Unsaved Changes, Save changes to the note?, Yes, No)) { AssetDatabase.SaveAssets(); } } }4. 高级功能与性能优化4.1 实现页面搜索与过滤我们在工具栏已经集成了一个搜索框。过滤逻辑在DrawPageList中通过List.FindAll实现。对于页面数量不多几百个的情况这种方式完全够用且简单直接。var filteredPages currentNoteData.pages.FindAll(p p.pageName.ToLower().Contains(searchFilter.ToLower()));性能考虑如果预期笔记页面数量会非常多上千每次OnGUI都进行字符串查找和ToList操作可能会有性能压力。此时可以引入缓存机制只在searchFilter发生变化时重新计算过滤列表。将过滤结果缓存起来在searchFilter未变时直接使用缓存。使用更高效的数据结构如预先建立小写的页面名称列表。但根据经验Unity编辑器扩展的工具类应用页面数量通常不会达到需要这种优化的级别。保持代码简洁和可读性更重要。4.2 数据备份与版本兼容性数据备份ScriptableObject资产直接保存在项目中本身就受版本控制系统保护。但我们还可以提供一个“导出为文本”的功能将整个笔记本或单个页面导出为.md或.txt文件作为额外备份。private void ExportAllPagesToMarkdown() { string path EditorUtility.SaveFilePanel(Export Notes, Application.dataPath, MyNotes, md); if (!string.IsNullOrEmpty(path)) { System.Text.StringBuilder sb new System.Text.StringBuilder(); sb.AppendLine($# {currentNoteData.name}); sb.AppendLine($*Exported on {System.DateTime.Now}*); sb.AppendLine(); foreach (var page in currentNoteData.pages) { sb.AppendLine($## {page.pageName}); sb.AppendLine($*Last Modified: {page.lastModifiedTime}*); sb.AppendLine(); sb.AppendLine(page.content); sb.AppendLine(); sb.AppendLine(---); sb.AppendLine(); } System.IO.File.WriteAllText(path, sb.ToString()); EditorUtility.RevealInFinder(path); // 在资源管理器中显示文件 } }版本兼容性随着迭代我们可能会在NoteData或NotePage类中添加新字段。为了确保旧版本的数据文件在新版本的编辑器工具中能正常打开我们需要考虑序列化兼容性。最佳实践为NoteData类添加[System.Serializable]并尽量使用Unity可序列化的类型。使用[SerializeField]将私有字段序列化。避免破坏性变更如非必要不要删除已有字段。可以添加新字段并为其提供默认值。Unity的序列化系统在遇到旧数据缺少新字段时通常会使用类型的默认值初始化这在一定程度上保证了向前兼容。4.3 编辑器UI优化与用户体验自动换行与滚动TextArea默认是带有滚动条的。GUILayout.ExpandHeight(true)确保了编辑区会填满可用垂直空间。快捷键支持可以增加常用操作的快捷键提升效率。private void OnGUI() { // 检查快捷键事件 Event e Event.current; if (e.type EventType.KeyDown currentNoteData ! null) { bool ctrl e.control || e.command; // 兼容Mac if (ctrl e.keyCode KeyCode.S) { // 保存 EditorUtility.SetDirty(currentNoteData); AssetDatabase.SaveAssets(); e.Use(); // 消耗此事件防止传递 } if (ctrl e.keyCode KeyCode.N) { // 新建页面 CreateNewPage(); e.Use(); } } DrawToolbar(); DrawMainArea(); }窗口大小记忆EditorWindow的position属性本身会被Unity编辑器记住所以窗口大小和位置通常不需要额外处理。视觉反馈在保存、删除等重要操作后使用Debug.Log或EditorUtility.DisplayDialog给用户明确的反馈。5. 常见问题排查与调试技巧5.1 数据不保存或丢失这是新手开发编辑器扩展时最常遇到的问题。症状关闭笔记窗口或Unity后重新打开发现笔记内容变回原来的样子或者新增的页面不见了。原因与排查忘记调用EditorUtility.SetDirty这是最主要的原因。任何对ScriptableObject实例字段的修改都必须调用EditorUtility.SetDirty(asset)。请检查所有修改currentNoteData或其内部pages列表、page内容的地方是否都跟上了这行代码。没有调用AssetDatabase.SaveAssetsSetDirty只是标记SaveAssets才是真正的保存操作。确保在用户点击保存按钮或窗口关闭等时机调用了它。也可以开启“Auto Save”项目设置但显式调用更可控。序列化问题确保NoteData和NotePage类都是[System.Serializable]的并且所有需要保存的字段都是public或带有[SerializeField]属性。使用了Unity无法序列化的类型如某些第三方类、委托也会导致保存失败。调试方法在OnGUI中临时添加一个按钮打印EditorUtility.IsDirty(currentNoteData)的结果。如果修改了内容但结果是false说明SetDirty没生效或没调用到。5.2 编辑器窗口布局错乱或UI不更新症状窗口控件重叠、位置不对或者输入内容后UI没有即时刷新。原因与排查OnGUI调用频率OnGUI每帧都可能调用多次布局和重绘事件。确保你的布局代码GUILayout.Width,BeginHorizontal/Vertical是幂等的不会因为多次调用而产生副作用或累积错误。GUILayoutvsGUIGUILayout是自动布局GUI是绝对坐标布局。混用时容易出错。建议在自定义编辑器窗口中优先使用GUILayout更简单自适应。状态变量未标记导致UI不更新如果你在OnGUI外定义了一些控制UI状态的变量比如pageBeingRenamed修改它们后需要告诉Unity窗口需要重绘。可以调用this.Repaint()来强制立即重绘或者依靠Event.current等输入事件来触发自然的OnGUI调用。调试方法在OnGUI开头添加Debug.Log(“OnGUI Called”)观察调用频率。使用Unity编辑器的“Game”视图调试工具并不适用应关注编辑器控制台的输出。5.3 页面列表与编辑区不同步症状选中了A页面编辑区显示的却是B页面的内容或者删除页面后索引越界。原因与排查索引管理错误currentSelectedPageIndex直接使用列表的索引。当进行过滤搜索时列表显示的是过滤后的子集但currentSelectedPageIndex指向的是原始列表的索引。点击过滤列表中的项目时需要找到该页面在原始列表(currentNoteData.pages)中的真实索引。解决方案在DrawPageList的循环中不要直接用过滤列表的索引i。应该通过page对象在原始列表中查找索引int realIndex currentNoteData.pages.IndexOf(page); bool isSelected (currentNoteData.currentSelectedPageIndex realIndex); if (GUILayout.Toggle(isSelected, page.pageName, ...)) { if (!isSelected) { currentNoteData.currentSelectedPageIndex realIndex; // 使用真实索引 EditorUtility.SetDirty(currentNoteData); } }页面删除后的索引修正删除一个页面后如果被删除的页面索引小于当前选中索引需要将currentSelectedPageIndex减1如果删除的就是当前选中页可以将索引设为上一个或0但要防止越界。private void DeletePage(NotePage page) { int indexToDelete currentNoteData.pages.IndexOf(page); currentNoteData.pages.RemoveAt(indexToDelete); // 修正当前选中索引 if (currentNoteData.currentSelectedPageIndex indexToDelete) { currentNoteData.currentSelectedPageIndex--; if (currentNoteData.currentSelectedPageIndex 0 currentNoteData.pages.Count 0) { currentNoteData.currentSelectedPageIndex 0; } else if (currentNoteData.pages.Count 0) { currentNoteData.currentSelectedPageIndex -1; } } EditorUtility.SetDirty(currentNoteData); }5.4 脚本编译后数据重置症状在Play Mode或修改脚本后编辑器重新编译发现笔记窗口的数据如currentNoteData引用变成了null。原因EditorWindow是非持久化的脚本重编译会导致窗口实例被重新创建所有未序列化的成员变量都会重置。解决方案使用ScriptableObject来存储窗口状态本身或者使用EditorPrefs/SessionState来存储轻量级状态如最近打开的笔记资产路径。SessionState是Unity提供的用于在编辑器会话期间直到Unity关闭存储临时数据的键值对。它比EditorPrefs更轻量且不会写入磁盘适合存储临时UI状态。private const string SESSION_KEY_LAST_NOTE_PATH “NoteEditor_LastNotePath”; private void OnEnable() { // 窗口启用时从SessionState恢复上次打开的笔记路径并加载 string lastPath SessionState.GetString(SESSION_KEY_LAST_NOTE_PATH, “”); if (!string.IsNullOrEmpty(lastPath)) { var data AssetDatabase.LoadAssetAtPathNoteData(lastPath); if (data ! null) currentNoteData data; } } private void LoadNoteData(NoteData data) { currentNoteData data; string path AssetDatabase.GetAssetPath(data); if (!string.IsNullOrEmpty(path)) { // 将路径存入SessionState SessionState.SetString(SESSION_KEY_LAST_NOTE_PATH, path); } }开发这个笔记编辑器的过程让我对Unity编辑器扩展的生命周期、数据持久化和UI交互有了更深的体会。最大的收获不是做出了一个多么强大的工具而是理解了如何让一个工具真正“好用”——即时刻关注用户的操作流处理好每一个细节状态并提供清晰的反饋。比如那个“就地重命名”的功能代码量增加了不少但换来的体验提升是巨大的。工具开发很多时候就是在这种细节里打磨。