背包和商店,这两个系统在Unity UGUI项目里出现频率实在太高了。无论是RPG的背包、合成台,还是模拟经营类的商店面板,核心骨架其实是一模一样的:格子网格 + 物品数据 + 交互反馈 + 数据刷新。我第一次做背包的时候,走了不少弯路,比如用Instantiate疯狂创建格子导致卡顿、物品堆叠之后图标刷新不及时、ScrollView在真机上滚动一多就掉帧。后来把整个方案重新梳理了一遍,用“数据驱动界面刷新”的思路重新实现,效果立刻就稳了。这篇文章就把我梳理后的完整实现方案拆开讲清楚,从UI搭建到数据设计,再到买卖逻辑跑通,适合刚学UGUI没多久、想快速在项目里落地背包或商店的开发者。
1. 动手之前:先理清背包系统的三个核心问题
很多教程一上来就让你拖控件、写代码,结果做完发现改个需求就得推倒重来。我建议动手之前先想清楚三个问题:背包本质要解决什么,界面和数据的边界在哪里,以及操作方式到底有哪几种。想明白了,后面写代码会顺畅非常多。
1.1 背包/商店的本质是“数据 + 表现 + 交互”
背包和商店从用户角度看是两个界面,但从程序角度其实是同一套东西:一份物品列表数据,一个把它显示出来的UI视图,以及一组让玩家操作它的交互逻辑。商店无非是比背包多了价格字段,多了买入卖出的接口。
我用一句话概括:背包和商店是同一套列表框架的两个业务分支。所以在架构设计上不要给背包和商店各写一套完全独立的代码,而是抽象出“物品格子”和“物品容器”这两个复用单位。这样以后做仓库、做锻造系统、做邮件附件,都是换数据源、换业务校验的事。
具体拆解下来,任何背包/商店系统都逃不开这几块:
- 物品数据源:有哪些物品、物品属性是什么
- 背包存储结构:玩家当前持有哪些物品、每格数量多少
- 格子UI组件:把一条物品数据显示成一个可交互的格子
- 容器视图管理器:管理格子生成、销毁、排序、刷新
- 业务交互层:点击查看详情、拖拽换位置、购买出售、堆叠拆分
1.2 方案选型:为什么用UGUI + ScriptableObject + 事件驱动
我放弃了最开始那种“每个格子一个MonoBehaviour脚本,自己监听点击然后改自己”的做法。那种写法单看一个格子没问题,但两个格子之间要联动(比如点击一个格子,另一个格子高亮)时,代码就开始互相引用,非常乱。
我最终选的组合是:
- 界面层:UGUI,自带Canvas、GridLayoutGroup、ScrollRect,做网格列表效率高
- 数据层:ScriptableObject做物品数据库,运行时用普通类管理背包数据
- 通知层:C#的Action事件,数据一变就广播,UI收到通知统一刷新
用ScriptableObject做物品库是我特别推荐的一个习惯。好处在于:物品配置和代码完全分离,策划改价格、改图标不用动程序;同一个物品资源可以多处引用,省内存;还能利用Unity的序列化自动生成配置文件,以后扩展物品类型非常方便。
运行时背包数据不要用ScriptableObject直接当玩家背包,因为ScriptableObject是全局共享资源,玩家数据必须运行时可序列化存储。所以物品库用ScriptableObject只读,玩家背包用普通List结构,两者通过物品ID关联。
1.3 整体功能拆解:从格子到交易的最小闭环
我们要做的这个Demo,功能范围不需要很夸张,但一定要闭环。我拆了四个阶段:
- 展示阶段:背包面板打开,读取玩家背包数据,生成格子网格,每个格子显示物品图标和数量
- 查看阶段:点击格子弹出物品详情面板,显示名称、描述、属性
- 交易阶段:商店面板显示商品列表,点击购买后扣金币、增加背包物品;背包物品可拖到商店出售,增加金币
- 刷新阶段:所有操作完成后,通知UI重新渲染相关格子
这四条链路跑通,已经能覆盖市面上大多数背包和商店的基础需求。后续什么背包整理排序、装备对比、物品合成,都是在这套骨架上做增量。
2. UI搭建:十分钟搭出可用的背包界面
界面搭建阶段最容易出问题的是Canvas的层级关系和格子布局。我在这里踩过一个很经典的坑:把物品详情面板放到了背包面板的子节点里,结果背包面板一旦关闭或整体透明度变化,详情面板也跟着受影响。正确做法是把详情面板独立成一个顶层Canvas。
2.1 Canvas与自适应设置
项目里我用了三层Canvas:
| Canvas | 作用 | 要点 |
|---|---|---|
| Canvas_Main | 主背包界面 | Render Mode 设为 Screen Space - Overlay |
| Canvas_Tooltip | 详情提示层 | 放在最高层,避免被界面挡住 |
| Canvas_EventSystem | EventSystem所在层 | 负责所有UI点击事件转发 |
关于Render Mode,很多教程说用Overlay就够了,我建议对性能敏感的移动端项目考虑Screen Space - Camera模式,把Canvas放在主相机下,配合相机Clear Flags的Depth,能够在UI和3D物件之间做正确的穿插。如果只是纯2D背包界面,Overlay省事且不用担心相机设置。
Canvas下面的Canvas Scaler是适配的关键。我用的是:
- UI Scale Mode:Scale With Screen Size
- Reference Resolution:1920 x 1080
- Screen Match Mode:Match Width Or Height,比例设0.5
这样不同分辨率的手机都能拿到比较稳定的缩放结果。如果只选0或者1,在极端宽屏或者竖屏上会出现一侧溢出。0.5是折中方案。
2.2 格子布局:GridLayoutGroup和Content Size Fitter的搭配
背包格子我推荐用三件套组合:ScrollRect + GridLayoutGroup + Content Size Fitter。
ScrollRect负责滚动和裁剪,GridLayoutGroup负责把格子自动摆成网格,Content Size Fitter负责让Content区域的高度可以根据格子数量自动撑开。
GridLayoutGroup的关键参数我给个可直接抄的起始配置:
- Cell Size:100 x 100(格子本体大小)
- Spacing:10 x 10(格子间距)
- Padding:10 x 10 x 10 x 10
- Constraint:Fixed Column Count,Constraint Count = 5
这里有一个细节:Cell Size最好比实际物品图标尺寸大一圈。为了处理选中态高亮、触摸热区、拖拽边缘,我通常会在格子预制体里放三层:背景边框层(100x100)、图标层(80x80)、数量文本层。背景层负责整个格子的点击响应区域,图标层纯粹显示美术图。
Content Size Fitter的Vertical Fit设为Preferred Size,然后GridLayoutGroup内的格子数量变化时,Content高度会自动调整。有个坑后面我会细讲:Content Size Fitter在ScrollRect里有时不触发重建,需要手动调用LayoutRebuilder。
2.3 物品图标与Tooltip的层级设定
物品图标我建议用RawImage而不是Image来放动态加载的贴图,但Unity 2022之后的Image也可以,加载思路是一样的。重点是让图标的Sprite更换和Alpha控制抽成一个独立方法,而不是直接挂子物体的同名属性。
Tooltip面板需要放在Canvas_Tooltip下,脚本控制时要注意两点:一是每次打开前先清空上一次的内容和大小,二是在屏幕边缘显示时做反向偏移,避免面板超出屏幕被裁剪。这部分我会在核心逻辑章节给具体代码。
2.4 格子预制体与UI复用的准备
格子预制体我取名为ItemSlotPrefab,结构如下:
ItemSlot (Button + Image背景) ├── Icon (Image, 用于物品图标) └── CountText (TextMeshProUGUI, 用于数量显示)在这个预制体上挂三个组件:Button、Image、以及自定义的ItemSlotView脚本。Button用于捕获点击事件,Image作为背景响应区域,ItemSlotView负责把数据和格子显示绑起来。
我特别强调一点:不要在ItemSlotView里持有具体的ItemData类引用,而是持有SlotIndex(格子编号),通过这个编号去背包数据里查询。因为格子是复用的,持有引用容易出现“格子滚动之后显示的是上一页数据”的错位问题。
这里有个很重要的思维转变,也是我后面整套实现的核心思想:格子本身不存数据,格子只是数据的镜子。
3. 数据层:把物品、背包和商店的数据模型一次说清
UI搭建完成只是皮,真正的重量级工作在这儿。数据层设计好了,后面功能怎么加都不乱,设计得不好,光一个物品堆叠就能改得你怀疑人生。
3.1 物品数据:用ScriptableObject做物品库
ScriptableObject做物品库,核心是让策划能够直接在Inspector里配物品。我的物品数据类大概长这样:
public enum ItemType { None, Weapon, Armor, Medicine, Material, Key } [CreateAssetMenu(fileName = "NewItem", menuName = "Game/ItemData")] public class ItemData : ScriptableObject { public string itemName; public string itemID; public Sprite icon; public ItemType type; public int maxStack = 1; public int buyPrice; public int sellPrice; [TextArea] public string description; }maxStack决定这个物品最多能堆叠多少个到同一格子里,比如药水可以堆99个,武器只能堆1个。这个字段是后面堆叠逻辑的判断依据。
创建物品库文件之后,我习惯在项目里建一个ItemDatabase的静态管理类:
public class ItemDatabase : MonoBehaviour { public static ItemDatabase Instance { get; private set; } [SerializeField] private List<ItemData> allItems; private Dictionary<string, ItemData> itemDict; private void Awake() { Instance = this; itemDict = new Dictionary<string, ItemData>(); foreach (var item in allItems) { if (item != null && !itemDict.ContainsKey(item.itemID)) { itemDict.Add(item.itemID, item); } } } public ItemData GetItemByID(string id) { if (itemDict.TryGetValue(id, out var item)) return item; return null; } }这一步相当于把物品从“让人直接拖拽引用的对象”转换成了“通过ID查询的对象”,为后续存档、网络同步打好基础。
3.2 背包数据:用字典管理格子
这里我对比过三种方案,直接说结论。
| 方案 | 适合场景 | 问题 |
|---|---|---|
| List<ItemData> 直接存 | Demo、物品不堆叠 | 不支持堆叠、排序困难 |
| Dictionary<int, ItemSlot> 按格子索引存 | 固定格子背包,比如格子数固定 | 数据离散,遍历成本高 |
| List<ItemSlot> 每格一项滚动管理 | 无限滚动背包,格子索引可复用 | 需要在滚动回收时处理数据绑定 |
我做的Demo是固定格子+堆叠需求的背包,所以采用Dictionary索引+List辅助遍历的方式,但为了方便把数据序列化存档,最终用List<ItemSlot>来做容器,再配合Dict做快速查询。ItemSlot是带堆叠信息的数据条目:
[System.Serializable] public class ItemSlot { public string itemID; public int count; public bool IsEmpty => string.IsNullOrEmpty(itemID); public void Clear() { itemID = null; count = 0; } }值得注意的是,ItemSlot里的itemID我建议直接用字符串而不要直接持有ItemData引用。这样存档序列化时不会带着一堆Unity资源引用,加载也更轻快。数据层需要ItemData的时候再去ItemDatabase查询。
3.3 商店数据:价格体系与库存数量
商店的每一行商品数据,本质也是在描述一种关系:商品ID + 出售数量 + 当前库存。价格我直接从ItemData读取,但没有把商店商品的库存写进ItemData,因为同一个物品在多个商店里库存状态应该是独立的。所以我单独定义了一个ShopItem类:
[System.Serializable] public class ShopItem { public string itemID; public int stock; public bool available = true; }stock为-1的话代表无限库存,适合卖基础消耗品的商店。商店界面从中读取商品列表、显示价格,点击购买时扣掉对应商品的stock。
还要补一个玩家金币数据,这个很简单,一个int字段就够了,单独放在PlayerWallet类里,和背包数据分开,方便以后接入成就或者交易记录系统。
3.4 事件系统:数据变化如何通知UI刷新
这是我整个方案里最关键的环节,值得单独拿出来讲。如果背包数据被玩家操作改变了,而UI没有任何感知,那画面就会错乱。我不用Update每帧去检查数据有没有变化,而是让数据层主动广播“我变了”。
定义事件中心:
public static class GameEvents { // 背包变更:参数为格子索引 public static Action<int> OnSlotChanged; // 金币变更 public static Action<int> OnMoneyChanged; // 背包整体刷新 public static Action OnInventoryRefreshed; public static void NotifySlotChanged(int slotIndex) { OnSlotChanged?.Invoke(slotIndex); } }UI层在打开时订阅事件,关闭时取消订阅。接收事件后,只刷新对应索引的格子,或者全量刷新。这个思路理解起来很简单:数据是圆心,UI是贴在圆心外层的接收器,圆心一动,接收器跟着动,数据不动的时候,UI绝对不会去主动查数据。
使用Action事件时记得在OnDestroy里退订,否则场景里对象被销毁后事件还挂着引用,会产生“幽灵回调”,这是我在实际项目中排查过很久的一个坑。
4. 核心逻辑实现:从显示到交易完整跑通
这一部分我把整个背包从打开到买卖的代码串起来写一遍。你可以直接照着这个结构去搭,所有方法名和类名我尽量保持稳定,方便你替换到自己的项目里。
4.1 格子初始化与刷新:固定池子,按索引绑数据
刚才提过,不要用Instantiate去创建格子,用池子思路。先在Content节点下提前创建20个(或者等于背包容量),游戏运行时只做数据绑定,不增减节点。
public class InventoryPanel : MonoBehaviour { [Header("UI引用")] [SerializeField] private Transform slotParent; [SerializeField] private GameObject slotPrefab; [SerializeField] private int capacity = 20; private ItemSlotView[] slotViews; private void Awake() { slotViews = new ItemSlotView[capacity]; for (int i = 0; i < capacity; i++) { var go = Instantiate(slotPrefab, slotParent); var view = go.GetComponent<ItemSlotView>(); view.Init(i); slotViews[i] = view; } gameObject.SetActive(false); } private void OnEnable() { GameEvents.OnSlotChanged += RefreshSlot; GameEvents.OnInventoryRefreshed += RefreshAll; RefreshAll(); } private void OnDisable() { GameEvents.OnSlotChanged -= RefreshSlot; GameEvents.OnInventoryRefreshed -= RefreshAll; } public void RefreshSlot(int index) { if (index < 0 || index >= slotViews.Length) return; ItemSlot slot = PlayerInventory.Instance.GetSlot(index); slotViews[index].SetData(slot); } public void RefreshAll() { for (int i = 0; i < slotViews.Length; i++) { RefreshSlot(i); } } }这套代码的核心逻辑就是的每个格子通过Init传入自己的索引,然后每次刷新都带上索引,从背包数据源里查最新数据。这样滚动回收后格子位置变化,也只是把自己的索引变一下,数据依然对得上。
ItemSlotView的SetData方法:
public class ItemSlotView : MonoBehaviour { private int slotIndex; private bool isEmpty; [SerializeField] private Image iconImg; [SerializeField] private TextMeshProUGUI countText; [SerializeField] private GameObject chooseFrame; public void Init(int index) { slotIndex = index; } public void SetData(ItemSlot slot) { if (slot == null || slot.IsEmpty) { isEmpty = true; iconImg.gameObject.SetActive(false); countText.gameObject.SetActive(false); chooseFrame.SetActive(false); return; } isEmpty = false; var item = ItemDatabase.Instance.GetItemByID(slot.itemID); if (item != null) { iconImg.sprite = item.icon; iconImg.gameObject.SetActive(true); countText.gameObject.SetActive(true); countText.text = slot.count > 1 ? slot.count.ToString() : string.Empty; } else { Debug.LogError($"找不到物品:{slot.itemID}"); } } public void OnClick() { if (!isEmpty) { TooltipPanel.Instance.Show(slotIndex); } } }数量那一行我用了slot.count > 1 ? slot.count.ToString() : string.Empty,意思是数量为1时不显示数字,视觉更清爽。这个细节可以根据策划需求调整,比如所有堆叠物品都显示数量也行。
4.2 点击查看祥情Tooltip的3个要点
详情面板我单独做了一层,前面提到了Canvas_Tooltip。这个面板从背包的ItemSlot读取数据,然后显示在鼠标位置附近。这里重点讲三个容易出错的点。
第一个点是Tooltip的初始化。每次打开前,先恢复默认状态,清除残留的文本和数据引用。不然上一次显示“铁剑+10攻击”的文本在关闭面板时还没来得及清掉,下次打开时就会闪一下上一件物品的信息。
第二个点是屏幕边缘检测。Tooltip面板打开时使用锚点定位在鼠标旁,但如果鼠标在屏幕右侧,面板就会超出屏幕,右侧内容被切掉。所以打开后要判断面板宽度+当前位置是否超过屏幕宽度,是就往左翻转。
public class TooltipPanel : MonoBehaviour { public static TooltipPanel Instance; [SerializeField] private CanvasGroup canvasGroup; [SerializeField] private TextMeshProUGUI itemNameText; [SerializeField] private TextMeshProUGUI descriptionText; [SerializeField] private RectTransform selfRect; private void Awake() { Instance = this; canvasGroup.alpha = 0; canvasGroup.blocksRaycasts = false; } public void Show(int slotIndex) { ItemSlot slot = PlayerInventory.Instance.GetSlot(slotIndex); if (slot == null || slot.IsEmpty) return; ItemData item = ItemDatabase.Instance.GetItemByID(slot.itemID); itemNameText.text = $"{item.itemName} x{slot.count}"; descriptionText.text = item.description; canvasGroup.alpha = 1f; transform.position = Input.mousePosition; ClampToScreen(); } private void ClampToScreen() { Vector3 pos = selfRect.position; Vector2 pivot = selfRect.pivot; float rightX = pos.x + selfRect.rect.width * (1f - pivot.x); if (rightX > Screen.width) { pos.x = Screen.width - selfRect.rect.width * (1f - pivot.x) - 5f; } float bottomY = pos.y - selfRect.rect.height * pivot.y; if (bottomY < 0f) { pos.y = selfRect.rect.height * pivot.y + 5f; } selfRect.position = pos; } public void Hide() { canvasGroup.alpha = 0f; } }第三个点是点击穿透问题。当Tooltip显示在某个格子正上方时,玩家点击Tooltip上的文字不应该触碰到下面的格子。处理方式是:Tooltip面板上挂一个Image作为全尺寸背景,同时把自身的GraphicRaycaster打开,并确保BlocksRaycasts为true,把它当作一个全屏挡板。这样它打开时就天然拦截了后续点击。
4.3 背包到商店的购买逻辑:金币计算、库存检查
商店的购买逻辑,看着简单,但边界条件必须处理全。我写一个最典型的购买方法,这里面包含了完整的校验链路。
public class ShopPanel : MonoBehaviour { [SerializeField] private TextMeshProUGUI moneyText; private List<ShopItem> shopItems; public void OnBuyButtonClicked(int shopIndex) { if (shopIndex < 0 || shopIndex >= shopItems.Count) return; ShopItem shopEntry = shopItems[shopIndex]; ItemData item = ItemDatabase.Instance.GetItemByID(shopEntry.itemID); // 校验1:商品是否存在 if (item == null || !shopEntry.available) return; // 校验2:库存充足 if (shopEntry.stock != -1 && shopEntry.stock <= 0) { Debug.Log("该商品已售罄"); return; } // 校验3:金币足够 int totalPrice = item.buyPrice; if (PlayerWallet.Instance.Coins < totalPrice) { Debug.Log("金币不足"); return; } // 校验4:背包能否容纳 var result = PlayerInventory.Instance.AddItem(item.itemID, 1); if (!result) { Debug.Log("背包已满"); return; } // 扣款 + 减库存 PlayerWallet.Instance.Spend(totalPrice); if (shopEntry.stock > 0) { shopEntry.stock--; } // 界面刷新 GameEvents.NotifyMoneyChanged(PlayerWallet.Instance.Coins); GameEvents.NotifyInventoryRefreshed(); } }这里有个很重要的细节:先尝试加入背包,再扣款。如果先把金币扣掉了,背包却满了,玩家就白付钱了。顺序搞反就会出这种不可逆的错,我在早期版本里就被测试人员揪过这个问题。
购买后还要看库存是否需要刷新到UI按钮上,比如剩余可购买数量显示。
回过来写出售逻辑。出售走的是一个拖拽流程,但其实核心方法就一行:
public void SellItem(int slotIndex) { ItemSlot slot = PlayerInventory.Instance.GetSlot(slotIndex); if (slot == null || slot.IsEmpty) return; ItemData item = ItemDatabase.Instance.GetItemByID(slot.itemID); int sellTotal = item.sellPrice * slot.count; bool removed = PlayerInventory.Instance.RemoveItem(slotIndex, slot.count); if (removed) { PlayerWallet.Instance.AddMoney(sellTotal); GameEvents.NotifyMoneyChanged(PlayerWallet.Instance.Coins); GameEvents.NotifyInventoryRefreshed(); } }这是整背包卖掉,实际项目里可能要做“按住Shift拆分一半出售”之类的交互,那时再把数量作为参数传入即可。
4.4 拖拽功能的两种实现方案对比
拖拽是很多背包的标配功能。实现方式上,我试过两种,直接给你对比结论。
方案一:实现IBeginDragHandler / IDragHandler / IEndDragHandler
适用场景:拖拽物品图标跟随鼠标,松手放置到另一个格子。
核心思路:拖拽开始时把物品图标复制一份到一个专门的拖拽层(Canvas_Drag),拖拽过程中这个拖拽对象跟随鼠标移动,结束时根据鼠标位置判断落在哪个格子上面,然后执行数据移动。
public class DraggableItem : MonoBehaviour, IBeginDragHandler, IDragHandler, IEndDragHandler { [SerializeField] private ItemSlotView view; public void OnBeginDrag(PointerEventData eventData) { if (view.IsEmpty) return; DragManager.Instance.StartDrag(view.SlotIndex); } public void OnDrag(PointerEventData eventData) { if (DragManager.Instance.IsDragging) { DragManager.Instance.FollowMouse(eventData); } } public void OnEndDrag(PointerEventData eventData) { if (!DragManager.Instance.IsDragging) return; DragManager.Instance.EndDrag(eventData); } }DragManager里存的是被拖物品的格子索引和一个跟随鼠标的Image。EndDrag时通过EventSystem.current.RaycastAll获取鼠标下的所有UI,找到第一个ItemSlotView,把源格子和目标格子的数据交换即可。
方案二:直接用Unity自带的Event Trigger + DragAndDrop插件
如果不想手写太多拖拽代码,可以直接用Unity Asset Store上的一些拖拽扩展。但要注意,插件往往绑定了一套自己的数据模型,接你的背包数据结构未必省事。我后来还是换回了手写方案,因为拖拽逻辑本身不复杂,手写可控性强,还能方便地加“拖拽时目标格子高亮”这类反馈。
手写拖拽时有个细节:拖拽过程中的图标不要在源格子上直接隐藏,否则拖拽结束回不到原位时看起来很奇怪。建议只隐藏数量文本,图标透明度降低。
4.5 构造函数式刷新:为什么用接口而不是频繁Instantiate
在背包格子的数据绑定上,我见过一种常见但很低效的写法:背包每变化一次,就把Content底下所有子物体全销毁,再Instantiate一遍新格子。当时那个项目格子数量还不多(十几个),实际跑起来还没什么问题,但一旦背包扩到50格、加上滚动列表,卡顿感就非常明显。
我这边坚持的原则是:
- 格子池在初始化时一次创建定长
- 每次刷新只调SetData
- 数据变化只通知受影响的格子
这样无论背包多大,刷新永远只处理一个格子的显示变化。这个思路推广到商店列表、任务列表、公会成员列表都一样,核心是“把创建和显示的职责分开”。
5. 避坑指南:实际开发中我踩过的那些坑
最后这部分是真正的干货,全都来自我被测试拿锤子砸过的实践经验。每一个坑背后都是一次痛苦的排查。
5.1 滚动列表下的格子复用与卡顿问题
用ScrollRect做背包时,最经典的问题就是Content里格子数量一大,滑动就发卡。跑一次Profile就发现,卡顿来自于滚出视野的格子依然在重新创建对象,或者每帧都在改Text。
我的标准解法是配合对象池:格子数量按一屏能容纳数+缓冲容量来创建,滚动时通过视图内的格子索引映射到背包数据的实际索引,具体映射公式:
实际索引 = 滚动位置对应起始索引 + 视口内格子序号如果格子的高度和间距固定,这个映射是可以直接用数学算出来的,不需要每帧去遍历全部格子。就算不用虚拟列表,只要你的格子池数量控制在屏幕显示数量+少量缓冲,性能就已经好很多了。
另外,TextMeshProUGUI在大量更新时比旧版Text效率高很多,建议所有背包数量文本都用TMP。别小看这个,同样三五十条文本,TMP和旧Text在移动端的帧率差距能到一两个毫秒的耗时。
5.2 UI层级遮挡与EventSystem穿透问题
Layer和SortingOrder的坑非常多。我第一次做商店时,商品详情面板打开在背包面板上层,但点击详情面板时居然穿透到下面商品格子,触发购买。排查了半天,发现详情面板上没有任何Image组件去挡住RaycastTarget。
解法简单粗暴:给详情面板根物体挂一个透明Image,并且不勾选Raycast Target的格子事件会被它吞掉。
另外还要注意Canvas的SortingOrder。多个Canvas互相嵌套时,谁的SortingOrder大谁在前面。建议给Tooltip和拖拽层固定一个很大的数值,比如30000,它们必须在任何普通面板上面。
5.3 数值驱动的边界条件
背包系统的边界条件多得吓人,我列个清单,你可以直接对着自查:
| 边界场景 | 预期行为 | 常见错误 |
|---|---|---|
| 金币刚好等于价格 | 允许购买 | 用了>而不是>=导致刚好够却买不了 |
| 物品数量达到maxStack | 不再堆叠,新建格子 | 继续在当前格子累加,超过上限 |
| 背包最后一格装满 | 提示背包已满 | 无提示或直接崩溃 |
| 出售时物品数量和金额计算 | 按格子总数量出售 | 只按单价卖,少给钱 |
| 商店库存为0 | 按钮置灰不可点 | 还能继续点,金币被扣 |
金币判断就是一个经典的>=和>问题,写成Coins > totalPrice在金币刚好等于总价时就判断失败了。这种问题写的时候很不容易发现,一旦发现又非常无奈。
5.4 真机适配与Canvas缩放的坑
真机测试时最容易遇到的就是UI缩放比例不一致。我遇到过一个问题:背包面板在模拟器上一切正常,换到某款高分辨率手机上,格子间距突然被拉扯变大,格子数量明显少于设计值。
排查后发现问题出在Canvas Scaler的Match Width Or Height设成了0(完全按宽度适配)。在高分辨率设备上,高度方向被过度缩放,导致Content区域被拉伸。改成0.5之后就好了。
Content Size Fitter配合ScrollRect也常出问题。当格子数量变化后,Content的高度没有立刻更新,滚动范围还是旧值,导致鼠标滚轮能滚的范围不对,甚至滚不到最底部。解决办法是在数据刷新并强制布局重建后,调用:
LayoutRebuilder.ForceRebuildLayoutImmediate(contentRect);或者延迟一帧再调用ScrollRect的StartDrag重置滚动位置。这个是在Canvas里持续计算布局结果需要时间,立即读取会拿到旧数据。
根据我个人经验,背包和商店这类系统,最值得投入时间的就是数据绑定和事件通知那一层。我前前后后在各种项目里写了三版背包,第一版每个格子独立更新,第二版每次全量重建,第三版就是上面这套数据驱动+格子池方案。到了第三版,后面接交易行、接拍卖、接邮件附件,几乎都是换数据源的活儿。所以如果你准备把背包做进一个长线运营的项目里,建议一上来就把数据层和UI层分开,别为了一时省事把逻辑全堆在格子脚本里。最后再分享一个小技巧:把商店和背包共用一套ItemSlotView后,我在生命周期内部署了“统一数据源”接口,后续每加一个新类型界面,配置数据源ID就能自动支持显示和点击,这个扩展思路在需求频繁变动的项目里非常省心。