3. EditorWindow基础:创建编辑器窗口、GUI布局、窗口生命周期、Repaint机制

好,咱们今天来聊聊 EditorWindow。这是 Unity 编辑器扩展里最基础、也最核心的一块。

说白了,你做的所有工具,最终都要有个界面让用户操作。这个界面,就是 EditorWindow。

我记得我刚入行那会儿,第一次写编辑器工具,就是照着官方文档抄了一个窗口出来。当时觉得挺神奇的——原来 Unity 里还能自己开个窗口?后来做多了才发现,这里面的门道还真不少。

3.1 创建你的第一个编辑器窗口

创建一个窗口,其实就两步:继承 EditorWindow,然后用 GetWindow 把它打开。

using UnityEditor;
using UnityEngine;

public class MyFirstWindow : EditorWindow
{
    [MenuItem("Tools/我的第一个窗口")]
    public static void ShowWindow()
    {
        // 获取或创建窗口实例
        MyFirstWindow window = GetWindow<MyFirstWindow>();
        window.titleContent = new GUIContent("我的窗口");
        window.Show();
    }
}

这段代码,你直接扔到 Editor 文件夹下,编译完就能在菜单栏看到「Tools/我的第一个窗口」了。

点击一下,窗口就出来了。

小提示: GetWindow 和 ScriptableWizard 不一样。GetWindow 是持久化的,关掉再打开,状态还在。ScriptableWizard 是一次性的,适合做向导类工具。

我个人习惯把窗口的创建方法写成静态的,然后用 MenuItem 挂上去。这样既可以从菜单打开,也可以在其他脚本里通过代码调用。

3.2 GUI布局:从混乱到有序

窗口创建好了,接下来就是往里面塞东西。Unity 编辑器窗口的 GUI 绘制,用的是 OnGUI 方法。

private void OnGUI()
{
    GUILayout.Label("这是一个标签", EditorStyles.boldLabel);
    
    if (GUILayout.Button("点击我"))
    {
        Debug.Log("按钮被点击了");
    }
    
    // 输入框
    _myString = EditorGUILayout.TextField("输入内容", _myString);
}

这里要注意,OnGUI 每帧都会被调用。你写的布局代码,其实是在描述「每一帧怎么画」。所以不要在 OnGUI 里做耗时操作,也不要在里面 new 对象。

布局方式有两种:

  • 自动布局(GUILayout / EditorGUILayout):你只管往里塞控件,Unity 自动帮你排位置。适合快速开发。
  • 固定布局(GUI / EditorGUI):你得自己算 Rect,精确控制每个控件的位置和大小。适合做复杂界面。

我在项目中遇到过一个问题:用自动布局做了一堆折叠面板,结果窗口一多,性能就崩了。后来发现是每帧都在重新计算布局。换成固定布局后,性能好了很多。

避坑指南: 我曾经在 OnGUI 里调用了 Resources.Load,结果每帧都在加载资源,内存直接爆了。记住,OnGUI 里只做绘制,不做加载。

3.3 窗口生命周期:从打开到关闭

一个 EditorWindow 的生命周期,其实比你想的要复杂一些。

阶段 触发时机 说明
Awake 窗口被创建时 只调用一次。适合初始化一些静态数据。
OnEnable 窗口被激活时 每次窗口显示都会调用。包括反序列化后。
OnFocus 窗口获得焦点时 用户点击了你的窗口。
OnGUI 每帧绘制时 核心方法,所有界面绘制都在这里。
OnLostFocus 窗口失去焦点时 用户点了别的地方。
OnDisable 窗口被隐藏时 关闭窗口或切换布局时触发。
OnDestroy 窗口被销毁时 彻底移除窗口。

你想想看,如果你在 OnEnable 里订阅了某个事件,那在 OnDisable 里一定要取消订阅。不然窗口关了,事件还在,就会报错。

嗯,这里要注意:Unity 编辑器窗口的序列化机制和运行时 MonoBehaviour 不太一样。EditorWindow 继承自 ScriptableObject,所以它的序列化是靠 Unity 的序列化系统来做的。

private void OnEnable()
{
    // 反序列化时,恢复数据
    if (_myData == null)
    {
        _myData = new MyData();
    }
    
    // 订阅事件
    EditorApplication.playModeStateChanged += OnPlayModeChanged;
}

private void OnDisable()
{
    // 取消订阅,防止内存泄漏
    EditorApplication.playModeStateChanged -= OnPlayModeChanged;
}

3.4 Repaint机制:别让你的窗口卡死

Repaint,说白了就是「重绘」。Unity 编辑器窗口不会自动刷新,你得告诉它「嘿,该重画了」。

什么时候需要手动 Repaint?

  • 数据变了,界面需要更新
  • 有动画效果
  • 响应外部事件
private void Update()
{
    // 每帧都重绘,适合做动画
    Repaint();
}

private void OnInspectorUpdate()
{
    // 当 Inspector 更新时触发,频率比 Update 低
    // 适合做一些后台检查
    if (_needRepaint)
    {
        Repaint();
        _needRepaint = false;
    }
}
核心原则: 只在需要的时候调用 Repaint。每帧都调,性能会受影响。我见过有人把 Repaint 放在 OnGUI 里,结果死循环了,编辑器直接卡死。

我曾经做过一个资源浏览器工具,里面有个缩略图预览。一开始我每帧都 Repaint,结果打开 10 个窗口,编辑器直接卡成 PPT。后来改成只在资源变化时才 Repaint,性能瞬间就上来了。

还有一个技巧:用 EditorApplication.update 来做定时刷新。

private void OnEnable()
{
    EditorApplication.update += OnEditorUpdate;
}

private void OnDisable()
{
    EditorApplication.update -= OnEditorUpdate;
}

private void OnEditorUpdate()
{
    // 每帧调用,但比 OnGUI 轻量
    // 适合做数据检查
    if (条件满足)
    {
        Repaint();
    }
}

你想想看,Repaint 其实就是一个信号。你发出信号,Unity 在下一帧就会重新调用 OnGUI。所以不要在 OnGUI 里修改数据,不然会陷入「修改-重绘-再修改-再重绘」的死循环。

个人经验: 我习惯在数据模型里加一个 dirty 标记。数据变了,就把 dirty 设为 true。然后在 OnInspectorUpdate 里检查 dirty,为 true 就 Repaint。这样既不会漏掉更新,也不会过度重绘。

好了,关于 EditorWindow 的基础,咱们就聊这么多。记住三个核心点:

  1. 用 GetWindow 创建窗口,用 OnGUI 绘制界面
  2. 生命周期方法要配对使用,特别是事件订阅
  3. Repaint 要节制,只在需要的时候调用

下一章,咱们会深入讲讲如何用 EditorGUI 和 EditorGUILayout 做更复杂的界面。到时候我会分享一些我在实际项目中踩过的坑,保证让你少走弯路。