接到“FGUI列表修改层级顺序”这个需求时,我第一反应是直接在代码里把某个列表项的 child index 调到最大,让它显示在最上层。结果运行起来是“三个没想到”:顺序变了一会儿又被列表重新拉回去;选中项确实置顶了,但触摸也优先到了它身上,把原本的滚动手势截断了;最诡异的是,虚拟列表上直接改索引后,滚动时界面整个错乱。后来我把 FairyGUI 的显示层级机制重新捋了一遍,才弄明白 GList 的层级顺序管理不是单纯改个数字那么简单。
这篇文章以 FairyGUI(FGUI)的 GList 为对象,围绕列表层级顺序的修改方法展开:先说清显示层级的底层规则,再给出可落地的几种改法,最后把我实际踩过的坑和现成的写法一并交代出来。适合正在做 Unity 客户端、需要处理背包、排行榜、聊天列表这类滚动列表的开发者参考。如果你做的是微信小程序小游戏端,后面也有一节专门说那里的差异。
1. 层级顺序的本质:子索引与 sortingOrder 的叠加规则
1.1 先记住一条:FairyGUI 里显示顺序看父容器
所有 UI 对象都在一棵显示树上。某个节点在屏幕上能不能盖住另一个节点,不看它自己的坐标,而看它在父容器里排第几。普通 GComponent 的子对象数组先后顺序,就是绘制顺序的先后顺序:index 小的先绘制,index 大的后绘制,后绘制的自然盖在上面。GList 继承自 GComponent,列表项本质上也是子对象,所以这条规则同样适用。
但 GList 为了性能引入了对象池和虚拟渲染,列表项不总是像普通子对象那样“常驻”。只要是当前显示在列表中的项,它们的先后次序依然决定覆盖关系。很多人在这一步把 GList 当成普通容器操作,直接在虚拟列表或自动排序列表上改索引,就会莫名其妙吃闷亏。
1.2 sortingOrder:另一个优先级更高的“插队权限”
GObject 有一个 sortingOrder 属性,默认值是 0。同一父容器内的排序规则是:sortingOrder 升序排列。也就是说 sortingOrder 小的在底层,大的在上层;只有在 sortingOrder 相同的情况下,才去看子索引的先后。
这就像一个队伍里混进了几个“插队特权”人员:普通成员按到达先后排(child index),特权人员无论何时都排在普通成员前面。实操里我见过最多的问题就是:SetChildIndex 怎么改都不生效,最后发现是某项在 itemRenderer 回调里被设置过 sortingOrder。只要目标项和它兄弟项的 sortingOrder 不一样,子索引就成了第二顺位的排序条件,你调它当然没用。
1.3 GList 的隐藏逻辑:池、渲染回调与索引
非虚拟列表中,list.numChildren 基本等于当前显示项数,list.GetChildAt(i) 拿到的就是这个位置的显示对象。虚拟列表则不同,它只创建可视区域内的对象,子对象数量和显示顺序都由列表内部机制接管。直接改某几个索引,一旦发生滚动或刷新就会错乱。
所以修改层级顺序前,先确认列表是哪种模式:
- 普通列表:可以直接操作子对象索引,但要注意数据回写。
- 虚拟列表:不要直接改子索引,改成数据排序后调用 Render 刷新。
把“改列表层级顺序”这个问题拆开看,其实只有两个维度:要么改显示次序(临时遮挡关系),要么改数据次序(持久业务顺序)。想清楚你要哪个,接下来的操作方法才选得对。
2. 修改层级顺序:三种可落地的操作方式
2.1 SetChildIndex:一句话把目标项置顶
最常见、最直觉的方式,代码量最少:
// 把第 index 个列表项移动到最上层 GObject target = list.GetChildAt(index); list.SetChildIndex(target, list.numChildren - 1);索引范围要记清楚:0 代表最底层,list.numChildren - 1 代表最顶层。SetChildIndex 底层会先移除再插入目标节点,中间如果有其它子对象会自然顺移。适合“临时把某项盖在其它列表项上面”的场景,比如选中某个道具后让它短暂遮挡旁边的图标。
2.2 MoveChild / SwapChildren:相邻微调更顺手
如果需求不是“到顶到底”,而是“往上挪一位”,用 SetChildIndex 反而容易算错索引,因为你心里的数据顺序和显示索引可能已经错位。这时我更推荐下面两个 API:
// 相邻交换:把 target 和它下面一个交换,相当于上移一位 int cur = list.GetChildIndex(target); if (cur > 0) { list.SwapChildren(target, list.GetChildAt(cur - 1)); } // 或者直接移动到固定位置,越界会被自动收拢到首尾 list.MoveChild(target, 3);我的习惯是:手写列表项排序算法(冒泡、插入)时用 SwapChildren;确定要移动到某个固定位置时用 MoveChild;只有“动到顶/底”这种明确意图才用 SetChildIndex。三个 API 最终都会改子索引,但面对的真实场景略有差异,选错了写起来会很别扭。
2.3 RemoveChildToPoolAt + AddChildFromPool:重建式换位
还有一种方式是先把项回收到对象池,再重新取出来。这样能得到一个“干净”的对象,但副作用也很明显:
// 单纯调层级不建议用这招,这是重建数据用的 int backupIndex = 3; list.RemoveChildToPoolAt(backupIndex); GObject newObj = list.AddChildFromPool(); list.itemRenderer(backupIndex, newObj); // 必须重新绑定数据这里的关键是:从池中取出的对象会回到模板状态,不会自动恢复之前绑定的业务数据,必须重新走一遍 itemRenderer 或手动赋值。如果只是想调整显示层级,这属于绕远路;但如果需求是“把第 3 项的数据和第 5 项的数据交换后重建对象”,那么池回收再渲染反而是正确做法:数据源换位后,直接 list.Render() 就行,不需要手动 remove/add。
2.4 一个小工具函数,顺手提炼
基于上面的方式,平时我会封装一个工具方法,专门处理“把指定 item 移到最前面”的需求:
public static void MoveItemToTop(GList list, int dataIndex) { if (list == null || dataIndex < 0 || dataIndex >= list.numItems) return; GObject obj = list.GetChildAt(dataIndex); if (obj != null) { list.MoveChild(obj, list.numChildren - 1); } }理论上 MoveChild 到 numChildren - 1 和 SetChildIndex 等价,传参更直白,不容易越界。
3. 改层级之前必须想清楚的四个细节
3.1 数据顺序和显示顺序,到底谁听谁的
改层级最容易引发的混乱,是把“显示临时顺序”当成了“业务数据顺序”。如果你只是通过 SetChildIndex 把某个项挪到上面,而数据源仍是原来的顺序,那么下一次滚动、排序、刷新数据时,显示顺序一定会被打回原形。
判断标准很简单:你的改动需要持久存在于业务逻辑中,那应该改数据源排序;只是当前这一时刻的遮挡需要,改显示层级没问题。我见过一个很典型的坏味道:为了维持显示层的“置顶效果”,在 itemRenderer 里写了一大堆补偿逻辑,每次刷新都重新把目标项往上挪一截。这基本就是选错了层,越改越乱。
3.2 自动排序(sortingChild)会让你的改动白费
FairyGUI 的 GList 内置了自动排序能力:设置好比较函数,列表在数据变化时会自动按规则重排。在这个前提下,任何手动 SetChildIndex 都是徒劳的——下一次数据变更或重新布局时,列表会按比较函数把顺序覆盖掉。
我自己踩过一个特别可笑的 bug:把“置顶”需求写进了 itemRenderer,每次渲染都对目标项设置 sortingOrder = 1,结果不光是置顶没实现,整个列表的绘制顺序频繁无规律跳动,排查了很久才意识到是渲染回调和排序逻辑互相打架。正确做法是:这类排序需求直接用 GList 的 sortingChild 配合比较函数,把规则交给列表框架,不要手动改索引。
3.3 滚动容器裁剪:置顶指的是“列表内部置顶”
还有一个高频误区:列表放在 ScrollPane 中,overflow 是 scroll 或 visible 时,列表自身的可视区域会把子项裁剪住。你把某项挪到列表最上层,也只能在列表的可视范围内遮挡,它盖不住列表外面的弹窗、页签、顶栏。
想让列表里的某一项“破出”滚动区域、漂浮到 UI 顶层,正确的做法是在列表外创建浮层,或者在列表所在容器里另加一个专门放漂浮对象的兄弟节点,根据滚动位置同步更新坐标。这不属于列表内层级调整能解决的范畴,硬改层级只会得到一项被裁剪掉一半的结果。
3.4 对象池语义:手动移动和池回收是两回事
手动 SetChildIndex / MoveChild 只改变子对象数组的位置,不触发对象池回收,对列表整体影响最小。而 RemoveChildToPoolAt + AddChildFromPool 是一条完整的池回收链,如果调用时机不对,常见症状是:列表显示项数没变,但个别项变成空白模板,或者重复触发了 itemRenderer 导致文本闪烁。
我现在的建议是把“调层级”和“重建数据”分开处理:单纯调层级用 MoveChild / SwapChildren;需要重建内容才用池接口。遇到那种“既交换位置又交换数据”的复杂诉求,优先改数据源,再一次性 Render,不要两头一起动。
4. 真实业务场景怎么处理:加载更多、置顶、列表切片和小程序端
4.1 加载更多时要“插队”,但别插在显示层
热词里有人搜“微信小程序页面列表加载更多”,这类需求我做过太多次了。很多人的第一反应是在加载更多的回调里把新 item 直接 SetChildIndex 插到顶部或底部。其实正确姿势是把它当成数据变更:先把新数据加到源数据列表的对应位置,再让 GList 重新渲染。列表项的显示顺序是数据顺序的自然结果,不会在下一次滚动时错位。
代码示例(追加到尾部,并尽量保持滚动位置):
void AppendMoreData(List<ItemData> newItems) { float oldPos = list.scrollPane != null ? list.scrollPane.posY : 0f; logicData.AddRange(newItems); list.numItems = logicData.Count; // 触发 itemRenderer 重刷 if (list.scrollPane != null) list.scrollPane.posY = oldPos; }注意一个细节:如果新项高度和旧项不一致,直接恢复 posY 后会有肉眼可见的跳动。更平滑的做法是记录 ScrollPane 内容区域高度变化差值,做一次补偿。另外这种批量追加也比逐项插入性能好,因为列表只刷新可视区域。
4.2 “置顶”需求:数据层排序优先,显示层只做临时遮挡
“最近使用的道具排在前面”“最新聊天消息钉在最上面”,这类置顶如果每次改动都去调整显示层级,代码会非常混乱。我的做法是:给源数据加一个排序字段,比如 lastUseTime,排序后让列表自然渲染;显示层只用来处理“这一帧的浮层遮挡”,两者永久分离。
如果只是临时把某行置顶盖住下层几秒(比如点击后显示高亮浮层),那 MoveChild 到顶层即可,动画结束后再移回原位。这样不会有任何数据层面的副作用。
4.3 “列表切片”式局部刷新:层级稳定的关键
“列表切片”这个词更多来自 Python,但映射到 FGUI 列表就是局部刷新、局部替换。切片式需求常见于排行榜:只有中间几名分数变化,不想整表重建。这里我建议分两个层次处理:
- 只是改某个 item 的文本、颜色、图标,直接 list.GetChildAt(i) 拿到对象去改属性,显示层级完全不受影响。
- 要替换一段连续数据(比如第 3 到第 6 项换了一批新数据),先在源数据上做切片替换,然后对受影响区间手动触发数据绑定:
for (int i = start; i <= end; i++) { GObject obj = list.GetChildAt(i); list.itemRenderer(i, obj); // 手动刷新该位置的数据绑定 }要避免的是在非虚拟列表上频繁用 RemoveChildToPoolAt + AddChildFromPool 处理切片,过程繁琐,还容易造成层级漂移。
4.4 微信小程序小游戏端的两个特殊注意点
我在微信小游戏端调试列表时发现两个点,和平常桌面开发区别挺大:
第一,触摸优先级会跟着层级走。把某个列表项置顶,它就会优先吃掉点击。如果该项只是“显示置顶”但不希望拦截触摸事件,要单独设置 touchable = false,或者把可点击区域放到浮层上。
第二,真机性能非常敏感。频繁调用 SetChildIndex 在低端安卓机上可能肉眼可见掉帧,尤其是列表项多、带动画的时候。我的建议是:不是每一帧都需要变的层级,只在状态切换时改;需要长期保持的置顶,提前用 sortingOrder 或独立浮层布局好,不要在滚动过程中反复调整。
5. 我现在的固定写法和最终选择
5.1 几种方式怎么选:直接给对照表
| 操作方式 | 典型场景 | 对象池影响 | 是否触发数据重绑 | 适用列表 |
|---|---|---|---|---|
| SetChildIndex | 把某项置顶/置底 | 无 | 否 | 普通列表 |
| MoveChild / SwapChildren | 相邻交换、手写排序逻辑 | 无 | 否 | 普通列表 |
| RemoveChildToPoolAt + AddChildFromPool | 数据项重建、切片替换 | 有 | 是 | 普通/虚拟列表均可用,但少用 |
| 数据排序后 Render | 业务排序、加载更多 | 看实现 | 是 | 所有列表,尤其虚拟列表 |
5.2 我最终封装的一个通用“列表项置顶”函数
综合踩坑经验,我现在处理 FGUI 列表项置顶需求的固定写法是这样:
public static void FocusItem(GList list, int dataIndex, bool top = true) { if (list == null || dataIndex < 0) return; int total = list.numChildren; if (total <= 1) return; GObject obj = list.GetChildAt(dataIndex); if (obj == null) return; int targetIndex = top ? total - 1 : 0; list.MoveChild(obj, targetIndex); }注意适用范围:默认是普通列表且目标项当前可见。虚拟列表直接用 GetChildAt(dataIndex) 有风险,因为 dataIndex 不一定对应可见子项;虚拟列表的正确做法是先把该项滚动到可视区域内,再在显示容器里调整,或者干脆走数据排序后 Render。
5.3 什么时候我劝你别手动改层级
最后说点实际经验。手动改列表层级本质上是一种“显示层修补”,它有三个很明显的边界:虚拟列表里不能用;带自动排序的列表里会被覆盖;大数据量列表里频繁调用会卡。满足其中任意一条,都建议回到数据层重排,让 FairyGUI 的 itemRenderer 和可视区域自然更新。
我心里始终记着一条原则:层级顺序如果是业务规则,就交给排序逻辑;只是遮挡需求,再动显示层。
我个人做项目时还有一个伴随习惯:每当要“改列表层级顺序”,先花一分钟确认这个需求是“这一帧的显示”还是“接下来的数据排序”。这两个方向对应完全不同的代码复杂度。上面这套方法是我在多个项目里反复调整后沉淀下来的,写出来给后来者省点弯路。