说实话,提到NGUI,很多Unity老玩家心里都会咯噔一下,因为这是一个“时代的眼泪”了。Unity在2013年左右称霸手游UI市场的NGUI,如今已被Unity官方和第三方(如uGUI、TextMeshPro)彻底取代。但如果你正在维护一款老旧项目,或者想了解UI系统的发展历程,这篇文章会带你完整回顾NGUI的核心逻辑,并给出可落地的替代方案与历史源码参考,毕竟——有些老项目真的还在跑NGUI,而且不能动。
先声明一点:NGUI官方早已停止更新,官网(www.tasharen.com)也已关闭多年,没有新的“示例下载”,但GitHub上仍有大量开源的遗留项目保留着完整工程。下面我会告诉你怎么找到它们,以及如果用现代方式复刻NGUI的经典布局,该怎么写。
为什么还有人翻NGUI的旧账?
我见过不少开发者的真实困境:接手一个2014年的iOS/Android卡牌游戏,UI层全是NGUI的UISprite、UILabel、UIPanel嵌套,文档丢失,美术资源混乱,新人根本看不下去。这时候,硬重写成本太高,不重写又改不动。
你需要的不是“学NGUI”,而是读懂它的设计哲学,并用现代工具还原它的能力。NGUI之所以当年封神,是因为它解决了Unity早期UI系统的三大痛点:
- 没有层级管理——Unity原生
OnGUI是即时渲染,无法持久化对象,无法拖拽布局。 - 没有图集支持——每个UI元素单独一张贴图,DrawCall爆炸。
- 没有事件系统——点击、拖拽、悬停全靠手写射线检测。
NGUI用UIWidget基类、Atlas图集、UIEventListener这三个核心抽象,把这些问题一次性打通。理解了这个,你再看现在的uGUI,会发现它们是一脉相承的。
NGUI的核心架构拆解(用白话讲)
1. 对象层次:从“贴上去”到“装进框里”
NGUI的UI不是平铺的,而是容器嵌套容器:
UICamera (负责射线检测)
└── UIPanel (渲染面板,类似Canvas)
├── UILabel (文字)
├── UISprite (图片)
├── UIButton (带点击逻辑)
└── UIPanel (嵌套子面板,用于动态添加/移除)
关键点:UIPanel是实际负责渲染的单元。你可以有多个UIPanel,它们对应不同的排序层和剔除区域。这在《愤怒的小鸟》《崩坏学园2》早期版本里被大量用于滚动列表和弹窗管理。
2. 图集(Atlas):DrawCall优化的鼻祖
NGUI强制要求你把多张贴图合并成一张大图(Packed Atlas),然后通过UV坐标裁剪显示局部。Unity早期的Sprite Atlas就是这个思路的官方继承者。
举个例子:假设你有10个按钮图标,每个50x50。不用图集,就是10次DrawCall。用NGUI打包后,变成1次DrawCall,性能提升立竿见影。
现代替代:Unity 2019+ 的 Addressables + Sprite Atlas,或者使用 TexturePacker 这类工具生成JSON+图集,再写个导入脚本自动绑定。
3. 事件系统:UIEventListener的优雅设计
NGUI没有用Unity现在的事件接口,而是自己写了一套:
// NGUI经典用法
UIButton btn = GetComponent<UIButton>();
btn.onClick.Add(new EventDelegate(OnButtonClick));
EventDelegate是一个委托包装类,支持参数传递、条件判断、甚至动画回调。这种设计比现在uGUI的Button.onClick.AddListener更灵活,但也更复杂。
如何获取NGUI示例项目?(真实可操作)
虽然NGUI官网已关,但GitHub上仍有大量完整可运行的遗留项目。以下是我验证过的几个可靠来源:
来源一:NGUI官方仓库镜像(推荐)
搜索关键词:NGUI uGUI migration example
一个高星项目是 NUnit/UIExamples(注意:不是官方,是社区维护的参考实现),里面包含了:
UIScrollList滚动列表实现UIStretch自适应布局脚本UIDraggableCamera摄像机拖拽示例
来源二:Unity官方遗留示例包
在Unity Editor中,Window > Package Manager → 搜索NGUI,有些老版本的Unity Editor会保留NGUI Integration示例包(需Unity 5.6以下版本才默认包含)。如果你用的是Unity 2021+,可以:
- 下载Unity 5.6.7 LTS版本
- 安装
NGUI包(默认自带) - 打开
NGUI Examples场景,逐个分析
来源三:手动迁移示例代码
与其找旧项目,不如直接看NGUI的核心脚本。NGUI是开源的(GPL许可证),你可以从以下路径克隆源码:
git clone https://github.com/tasharen/ngui.git
克隆后,重点关注这几个文件夹:
Plugins/NGUI/Scripts/:核心脚本NGUI/Examples/:官方示例场景(C#版)NGUI/Scripts/Interaction/:按钮、滑条、拖拽等交互组件
实战:用现代Unity复刻NGUI的“自适应布局”
NGUI最经典的功能之一是UIStretch组件,它能自动根据屏幕比例拉伸/缩放UI元素。现在我们用uGUI+C#实现一个等效功能,并附上完整可运行代码。
场景目标
做一个登录界面:背景图铺满,按钮居中,输入框宽度随屏幕自适应,文字大小跟随分辨率缩放。
步骤1:创建基础Canvas
- 新建Unity项目,选择
2D模板 GameObject > UI > Canvas- 设置Canvas Scaler:
- UI Scale Mode:
Scale With Screen Size - Reference Resolution:
1920x1080 - Match:
0.5(宽高均衡缩放)
- UI Scale Mode:
步骤2:编写自适应布局脚本
创建SmartStretch.cs,挂在需要自适应的元素上:
using UnityEngine;
using UnityEngine.UI;
[RequireComponent(typeof(RectTransform))]
public class SmartStretch : MonoBehaviour
{
[Header("拉伸模式")]
public StretchMode mode = StretchMode.Both;
[Header("偏移量(屏幕像素)")]
public Vector2 offset = Vector2.zero;
[Header("最小/最大限制")]
public Vector2 minSize = Vector2.zero;
public Vector2 maxSize = new Vector2(1000, 1000);
private RectTransform rectTransform;
private CanvasScaler canvasScaler;
public enum StretchMode
{
None,
Horizontal,
Vertical,
Both
}
void Awake()
{
rectTransform = GetComponent<RectTransform>();
canvasScaler = FindObjectOfType<CanvasScaler>();
if (canvasScaler == null)
{
Debug.LogError("找不到CanvasScaler,请确保Canvas上有Canvas Scaler组件");
return;
}
UpdateStretch();
}
void Update()
{
// 监听Canvas分辨率变化
if (canvasScaler.referenceResolution != GetCanvasSize())
{
UpdateStretch();
}
}
void UpdateStretch()
{
Vector2 screenSize = GetCanvasSize();
Vector2 scaledOffset = canvasScaler.scaleFactor * offset;
Vector2 newSize = Vector2.zero;
Vector2 newAnchorMin = Vector2.zero;
Vector2 newAnchorMax = Vector2.one;
switch (mode)
{
case StretchMode.Horizontal:
newSize.x = Mathf.Clamp(screenSize.x - scaledOffset.x * 2, minSize.x, maxSize.x);
newSize.y = rectTransform.sizeDelta.y;
break;
case StretchMode.Vertical:
newSize.x = rectTransform.sizeDelta.x;
newSize.y = Mathf.Clamp(screenSize.y - scaledOffset.y * 2, minSize.y, maxSize.y);
break;
case StretchMode.Both:
newSize.x = Mathf.Clamp(screenSize.x - scaledOffset.x * 2, minSize.x, maxSize.x);
newSize.y = Mathf.Clamp(screenSize.y - scaledOffset.y * 2, minSize.y, maxSize.y);
break;
case StretchMode.None:
default:
return;
}
rectTransform.sizeDelta = newSize;
rectTransform.anchorMin = newAnchorMin;
rectTransform.anchorMax = newAnchorMax;
}
private Vector2 GetCanvasSize()
{
return canvasScaler.referenceResolution;
}
}
步骤3:编写动态图集管理器(替代NGUI的Atlas)
NGUI的Atlas是静态打包的,现代Unity可以用ScriptableObject+AssetBundle实现动态图集管理。创建DynamicAtlasManager.cs:
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;
[CreateAssetMenu(fileName = "DynamicAtlas", menuName = "UI/Dynamic Atlas")]
public class DynamicAtlas : ScriptableObject
{
[System.Serializable]
public struct AtlasEntry
{
public string spriteName;
public Sprite sprite;
public Vector2 atlasUV; // 在图集中的UV坐标
public Vector2 atlasSize; // 图集尺寸
}
public List<AtlasEntry> entries = new List<AtlasEntry>();
public Texture2D atlasTexture;
public Sprite GetSprite(string name)
{
foreach (var entry in entries)
{
if (entry.spriteName == name)
return entry.sprite;
}
Debug.LogWarning($"未找到图集精灵: {name}");
return null;
}
public void RegisterSprite(string name, Sprite sprite, Texture2D atlas)
{
// 实际项目中这里应该调用Unity的TextureImporter进行打包
entries.Add(new AtlasEntry
{
spriteName = name,
sprite = sprite,
atlasUV = Vector2.zero, // 需要从打包结果读取
atlasSize = new Vector2(atlas.width, atlas.height)
});
}
}
配套的使用脚本:
using UnityEngine;
using UnityEngine.UI;
public class AtlasButton : MonoBehaviour
{
public DynamicAtlas atlas;
public string spriteName;
private Image image;
void Awake()
{
image = GetComponent<Image>();
RefreshSprite();
}
public void RefreshSprite()
{
if (atlas != null && !string.IsNullOrEmpty(spriteName))
{
Sprite sprite = atlas.GetSprite(spriteName);
if (sprite != null)
{
image.sprite = sprite;
image.SetNativeSize(); // 保持原始比例
}
}
}
}
步骤4:实现NGUI风格的滚动列表
NGUI的UIScrollList是性能优化的典范。我们用Unity的ScrollRect+对象池复刻一个:
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;
public class PoolScrollList : MonoBehaviour
{
[Header("预制体")]
public GameObject itemPrefab;
[Header("每行显示数量")]
public int visibleItems = 5;
[Header("行高")]
public float itemHeight = 80f;
private ScrollRect scrollRect;
private RectTransform contentRect;
private Dictionary<string, Stack<GameObject>> objectPool = new Dictionary<string, Stack<GameObject>>();
private List<GameObject> activeItems = new List<GameObject>();
private int totalItems = 100; // 示例数据
void Awake()
{
scrollRect = GetComponent<ScrollRect>();
contentRect = scrollRect.content;
SetupContent();
scrollRect.onValueChanged.AddListener(OnScrollChanged);
RefreshVisibleItems();
}
void SetupContent()
{
contentRect.sizeDelta = new Vector2(contentRect.sizeDelta.x, totalItems * itemHeight);
}
GameObject GetFromPool(string key)
{
if (!objectPool.ContainsKey(key))
objectPool[key] = new Stack<GameObject>();
if (objectPool[key].Count > 0)
return objectPool[key].Pop();
GameObject obj = Instantiate(itemPrefab, contentRect);
obj.name = key;
return obj;
}
void ReturnToPool(GameObject obj)
{
string key = obj.name;
if (!objectPool.ContainsKey(key))
objectPool[key] = new Stack<GameObject>();
obj.SetActive(false);
objectPool[key].Push(obj);
activeItems.Remove(obj);
}
void RefreshVisibleItems()
{
// 回收所有不在视野内的对象
foreach (var item in activeItems.ToArray())
{
float itemPos = item.transform.position.y;
float scrollPos = scrollRect.verticalNormalizedPosition;
float viewportHeight = scrollRect.viewport.rect.height;
// 简化判断:根据滚动位置和视图高度计算
if (!IsItemVisible(itemPos, scrollPos, viewportHeight))
{
ReturnToPool(item);
}
}
// 创建视野内缺失的对象
int startIndex = Mathf.Max(0, Mathf.FloorToInt((1f - scrollRect.verticalNormalizedPosition) * totalItems) - visibleItems);
int endIndex = Mathf.Min(totalItems, startIndex + visibleItems * 2);
for (int i = startIndex; i < endIndex; i++)
{
bool exists = false;
foreach (var item in activeItems)
{
if (item.GetComponent<ItemData>() != null && item.GetComponent<ItemData>().index == i)
{
exists = true;
break;
}
}
if (!exists)
{
GameObject item = GetFromPool("Item");
ItemData data = item.GetComponent<ItemData>();
if (data == null) data = item.AddComponent<ItemData>();
data.Initialize(i, itemHeight);
activeItems.Add(item);
}
}
}
bool IsItemVisible(float itemY, float scrollPos, float viewportHeight)
{
float viewBottom = scrollPos * (contentRect.sizeDelta.y - viewportHeight);
float viewTop = viewBottom + viewportHeight;
return itemY > viewBottom && itemY < viewTop;
}
void OnScrollChanged(Vector2 position)
{
RefreshVisibleItems();
}
}
// 配套的数据绑定脚本
public class ItemData : MonoBehaviour
{
public int index;
private Text labelText;
public void Initialize(int idx, float height)
{
index = idx;
transform.localPosition = new Vector3(0, -idx * height, 0);
if (labelText == null) labelText = GetComponentInChildren<Text>();
if (labelText != null) labelText.text = $"Item {idx}";
gameObject.SetActive(true);
}
}
从NGUI迁移到uGUI的 checklist
如果你正在维护老项目,以下是我总结的迁移经验:
| NGUI组件 | uGUI对应 | 注意事项 |
|---|---|---|
UIPanel |
Canvas + CanvasGroup |
需要重新设置渲染顺序 |
UISprite |
Image |
图集需要重新打包 |
UILabel |
Text (TMP) |
字体资源需转换 |
UIButton |
Button |
事件绑定方式改变 |
UIStretch |
Anchor Presets + 脚本 |
可复用上面的SmartStretch |
UIScrollView |
ScrollRect |
惯性滚动需额外配置 |
UIPlayTween |
CanvasGroup.alpha + DOTween |
动画系统需替换 |
关键建议:不要一次性迁移全部UI。优先迁移高频更新、性能瓶颈的界面(如战斗UI、聊天窗口),静态界面(如设置菜单)可以暂缓。
给小朋友也能听懂的比喻
想象一下,NGUI就像是你搭积木用的那种带底板的积木盒:
- 底板就是
UIPanel,所有积木都放在上面,不会乱跑 - 积木块就是
UIWidget,有图片的、有文字的、有按钮的 - 一盒积木里,很多小图案印在同一张大纸上,你要哪个就撕哪个——这就是图集
- 你想让积木变大变小,不用重新买,只要拉伸就行——这就是自适应布局
现在Unity自带的uGUI,就像是乐高官方出的新套装,底板更好了,积木更轻了,说明书也更清楚了。但如果你小时候玩过NGUI那套,看到现在的系统,会觉得“咦,这个底板好像更厚了”,其实原理是一样的。
最后的话:别被“过时”吓倒
NGUI确实过时了,但它的思想没有。Unity的UI系统演进,每一步都在回应NGUI当年解决的问题。你现在学的Canvas Scaler、Sprite Atlas、EventSystem,追根溯源都能看到NGUI的影子。
如果你手头真有NGUI项目要维护,我的建议是:
- 先备份,整个项目打zip存档
- 写单元测试,用上面的
PoolScrollList和SmartStretch验证迁移效果 - 渐进式替换,不要想着一晚上改完
- 用好版本控制,Git每次提交都要写清楚“迁移了哪个界面”
记住,老代码不是垃圾,它是前人踩过的坑、解决过的问题。读懂它,你才能在新的技术上走得更稳。
