diff --git a/Runtime/XTable/Rendering/Component/XericUIActionTable.Api.cs b/Runtime/XTable/Rendering/Component/XericUIActionTable.Api.cs
index 8d8b5ff..7283c7d 100644
--- a/Runtime/XTable/Rendering/Component/XericUIActionTable.Api.cs
+++ b/Runtime/XTable/Rendering/Component/XericUIActionTable.Api.cs
@@ -194,6 +194,7 @@ namespace XericUI.XTable.Rendering.Component
System.Array.Copy(m_RowHeights, newArr, m_RowHeights.Length);
newArr[newLen - 1] = height;
m_RowHeights = newArr;
+ RecordStructuralChange(false);
MarkDirty(TableDirtyType.LayoutChanged);
#if UNITY_EDITOR
UnityEditor.EditorUtility.SetDirty(this);
@@ -212,6 +213,7 @@ namespace XericUI.XTable.Rendering.Component
System.Array.Copy(m_ColWidths, newArr, m_ColWidths.Length);
newArr[newLen - 1] = width;
m_ColWidths = newArr;
+ RecordStructuralChange(false);
MarkDirty(TableDirtyType.LayoutChanged);
#if UNITY_EDITOR
UnityEditor.EditorUtility.SetDirty(this);
@@ -256,6 +258,8 @@ namespace XericUI.XTable.Rendering.Component
UnityEditor.Undo.RecordObject(this, "Remove Table Row");
#endif
int colCount = m_ColWidths.Length;
+ CaptureActivePrefabReleaseCallbacks();
+ RecordStructuralChange(true);
// 先解除涉及该行的合并
for (int c = 0; c < colCount; c++)
@@ -303,6 +307,8 @@ namespace XericUI.XTable.Rendering.Component
UnityEditor.Undo.RecordObject(this, "Remove Table Column");
#endif
int rowCount = m_RowHeights.Length;
+ CaptureActivePrefabReleaseCallbacks();
+ RecordStructuralChange(true);
// 先解除涉及该列的合并
for (int r = 0; r < rowCount; r++)
@@ -410,7 +416,8 @@ namespace XericUI.XTable.Rendering.Component
private void OnHandleSelectionChanged(XTableSelectionHandle handle)
{
MarkDirty(TableDirtyType.SelectionChanged);
- RefreshSelectionOverlays();
+ if (!IsCheckingUpdate)
+ RefreshSelectionOverlays();
}
/// 裁剪选区到当前行列尺寸范围内
@@ -562,6 +569,7 @@ namespace XericUI.XTable.Rendering.Component
///
public void ClearTable()
{
+ ThrowIfCheckingUpdate(nameof(ClearTable));
m_IsRebuilding = true;
try
{
@@ -624,6 +632,8 @@ namespace XericUI.XTable.Rendering.Component
public void ApplyClipboardData(XTableClipboardData data)
{
if (data == null) return;
+ CaptureActivePrefabReleaseCallbacks();
+ RecordStructuralChange(true);
// 确保数据层存在
if (m_TableData == null)
@@ -640,9 +650,8 @@ namespace XericUI.XTable.Rendering.Component
// 写回单元格文本与合并信息
data.PopulateToTable(this);
- MarkDirty(TableDirtyType.DataChanged);
RecalculateTotalSize();
- RefreshView();
+ RequestRefresh(TableDirtyType.DataChanged | TableDirtyType.LayoutChanged, true);
SetChildrenVisible(m_ChildrenVisible);
}
}
diff --git a/Runtime/XTable/Rendering/Component/XericUIActionTable.Editor.cs b/Runtime/XTable/Rendering/Component/XericUIActionTable.Editor.cs
index c1f51b3..f00fbc7 100644
--- a/Runtime/XTable/Rendering/Component/XericUIActionTable.Editor.cs
+++ b/Runtime/XTable/Rendering/Component/XericUIActionTable.Editor.cs
@@ -39,7 +39,7 @@ namespace XericUI.XTable.Rendering.Component
return;
}
- if (m_IsRebuilding || !m_DirtyFlag.IsDirty) return;
+ if (m_IsRebuilding || IsCheckingUpdate || !m_DirtyFlag.IsDirty) return;
var flags = m_DirtyFlag.Flags;
if ((flags & ~(TableDirtyType.ScrollChanged | TableDirtyType.SelectionChanged)) == 0)
diff --git a/Runtime/XTable/Rendering/Component/XericUIActionTable.ExcelIO.cs b/Runtime/XTable/Rendering/Component/XericUIActionTable.ExcelIO.cs
index b7d192b..6620fd6 100644
--- a/Runtime/XTable/Rendering/Component/XericUIActionTable.ExcelIO.cs
+++ b/Runtime/XTable/Rendering/Component/XericUIActionTable.ExcelIO.cs
@@ -262,9 +262,10 @@ namespace XericUI.XTable.Rendering.Component
}
// 标记脏并刷新
- MarkDirty(TableDirtyType.DataChanged | TableDirtyType.LayoutChanged);
+ CaptureActivePrefabReleaseCallbacks();
+ RecordStructuralChange(true);
RecalculateTotalSize();
- RefreshView();
+ RequestRefresh(TableDirtyType.DataChanged | TableDirtyType.LayoutChanged, true);
}
#endregion
diff --git a/Runtime/XTable/Rendering/Component/XericUIActionTable.RendererFilter.cs b/Runtime/XTable/Rendering/Component/XericUIActionTable.RendererFilter.cs
index 91187b3..77572ab 100644
--- a/Runtime/XTable/Rendering/Component/XericUIActionTable.RendererFilter.cs
+++ b/Runtime/XTable/Rendering/Component/XericUIActionTable.RendererFilter.cs
@@ -225,6 +225,37 @@ namespace XericUI.XTable.Rendering.Component
#region 完整刷新
+ private void CommitStructuralRenderCleanup(bool invalidatePrefabBindings,
+ Dictionary> prefabReleaseCallbacks)
+ {
+ ReleaseAllTrackedCells();
+ ReleaseFrozenCells();
+ m_LastRenderedCells.Clear();
+
+ if (m_ActivePrefabMap != null)
+ {
+ foreach (var pair in m_ActivePrefabMap)
+ {
+ if (prefabReleaseCallbacks != null && prefabReleaseCallbacks.ContainsKey(pair.Value)) continue;
+ var data = TableData.GetCell(pair.Key.Item1, pair.Key.Item2);
+ if (data?.OnPrefabRelease != null && prefabReleaseCallbacks != null
+ && !prefabReleaseCallbacks.ContainsKey(pair.Value))
+ prefabReleaseCallbacks.Add(pair.Value, data.OnPrefabRelease);
+ }
+ m_ActivePrefabMap.Clear();
+ }
+
+ if (invalidatePrefabBindings)
+ m_PrefabPool?.ReleaseAll(instance =>
+ {
+ if (prefabReleaseCallbacks != null && prefabReleaseCallbacks.TryGetValue(instance, out var callback))
+ callback(instance);
+ });
+
+ m_QuadtreeDirty = true;
+ m_StyleNamespaceDirty = true;
+ }
+
public void RefreshView()
{
if (m_Assembler == null)
@@ -300,6 +331,7 @@ namespace XericUI.XTable.Rendering.Component
private void RefreshViewLight()
{
+ TableDirtyType refreshedFlags = m_DirtyFlag.Flags;
if (m_Assembler == null)
{
if (rectTransform != null)
diff --git a/Runtime/XTable/Rendering/Component/XericUIActionTable.UpdateScope.cs b/Runtime/XTable/Rendering/Component/XericUIActionTable.UpdateScope.cs
new file mode 100644
index 0000000..4fb2523
--- /dev/null
+++ b/Runtime/XTable/Rendering/Component/XericUIActionTable.UpdateScope.cs
@@ -0,0 +1,164 @@
+using System;
+using System.Collections.Generic;
+using UnityEngine;
+
+namespace XericUI.XTable.Rendering.Component
+{
+ public partial class XericUIActionTable
+ {
+ private sealed class UpdateContext
+ {
+ public TableDirtyType DirtyFlags;
+ public bool HasStructuralChange;
+ public bool InvalidatePrefabBindings;
+ public bool RequiresImmediateRefresh;
+ public readonly Dictionary> PrefabReleaseCallbacks = new();
+
+ public void Reset()
+ {
+ DirtyFlags = TableDirtyType.None;
+ HasStructuralChange = false;
+ InvalidatePrefabBindings = false;
+ RequiresImmediateRefresh = false;
+ PrefabReleaseCallbacks.Clear();
+ }
+
+ public void MergeFrom(UpdateContext child)
+ {
+ DirtyFlags |= child.DirtyFlags;
+ HasStructuralChange |= child.HasStructuralChange;
+ InvalidatePrefabBindings |= child.InvalidatePrefabBindings;
+ RequiresImmediateRefresh |= child.RequiresImmediateRefresh;
+ foreach (var pair in child.PrefabReleaseCallbacks)
+ if (!PrefabReleaseCallbacks.ContainsKey(pair.Key))
+ PrefabReleaseCallbacks.Add(pair.Key, pair.Value);
+ }
+ }
+
+ public sealed class UpdateScope : IDisposable
+ {
+ private XericUIActionTable m_Table;
+ private readonly long m_Token;
+ private bool m_Disposed;
+
+ internal UpdateScope(XericUIActionTable table, long token)
+ {
+ m_Table = table;
+ m_Token = token;
+ }
+
+ public void Dispose()
+ {
+ if (m_Disposed) return;
+ m_Disposed = true;
+ if (m_Table != null)
+ m_Table.EndCheckUpdate(m_Token);
+ m_Table = null;
+ }
+ }
+
+ [NonSerialized] private readonly Stack m_UpdateContextStack = new();
+ [NonSerialized] private readonly Stack m_UpdateContextPool = new();
+ [NonSerialized] private long m_NextUpdateToken;
+ [NonSerialized] private readonly Stack m_UpdateTokenStack = new();
+
+ private bool IsCheckingUpdate => m_UpdateContextStack.Count > 0;
+
+ ///
+ /// 开始一个可嵌套的批量更新作用域。作用域内的数据修改立即生效,渲染与结构资源整理延迟到最外层 Dispose。
+ ///
+ public UpdateScope CheckUpdate()
+ {
+ var context = m_UpdateContextPool.Count > 0 ? m_UpdateContextPool.Pop() : new UpdateContext();
+ context.Reset();
+ m_UpdateContextStack.Push(context);
+ long token = ++m_NextUpdateToken;
+ m_UpdateTokenStack.Push(token);
+ return new UpdateScope(this, token);
+ }
+
+ private void EndCheckUpdate(long token)
+ {
+ if (m_UpdateTokenStack.Count == 0 || m_UpdateTokenStack.Peek() != token)
+ {
+ Debug.LogError("[XericUIActionTable] CheckUpdate 作用域必须按 LIFO 顺序释放。", this);
+ return;
+ }
+
+ m_UpdateTokenStack.Pop();
+ UpdateContext context = m_UpdateContextStack.Pop();
+ if (m_UpdateContextStack.Count > 0)
+ {
+ m_UpdateContextStack.Peek().MergeFrom(context);
+ ReturnUpdateContext(context);
+ return;
+ }
+
+ try
+ {
+ CommitUpdateContext(context);
+ }
+ finally
+ {
+ ReturnUpdateContext(context);
+ }
+ }
+
+ private void ReturnUpdateContext(UpdateContext context)
+ {
+ context.Reset();
+ m_UpdateContextPool.Push(context);
+ }
+
+ private void CommitUpdateContext(UpdateContext context)
+ {
+ if (context.DirtyFlags == TableDirtyType.None) return;
+
+ if (context.HasStructuralChange)
+ CommitStructuralRenderCleanup(context.InvalidatePrefabBindings, context.PrefabReleaseCallbacks);
+
+ ApplyDirty(context.DirtyFlags);
+ if (context.RequiresImmediateRefresh)
+ RefreshView();
+ RefreshSelectionOverlays();
+ }
+
+ private void RecordStructuralChange(bool invalidatePrefabBindings)
+ {
+ if (!IsCheckingUpdate) return;
+ UpdateContext context = m_UpdateContextStack.Peek();
+ context.HasStructuralChange = true;
+ context.InvalidatePrefabBindings |= invalidatePrefabBindings;
+ }
+
+ private void CaptureActivePrefabReleaseCallbacks()
+ {
+ if (!IsCheckingUpdate || m_ActivePrefabMap == null) return;
+ var callbacks = m_UpdateContextStack.Peek().PrefabReleaseCallbacks;
+ foreach (var pair in m_ActivePrefabMap)
+ {
+ var callback = TableData.GetCell(pair.Key.Item1, pair.Key.Item2)?.OnPrefabRelease;
+ if (callback != null && !callbacks.ContainsKey(pair.Value))
+ callbacks.Add(pair.Value, callback);
+ }
+ }
+
+ private void RequestRefresh(TableDirtyType type, bool immediate)
+ {
+ MarkDirty(type);
+ if (IsCheckingUpdate)
+ {
+ m_UpdateContextStack.Peek().RequiresImmediateRefresh |= immediate;
+ return;
+ }
+ if (immediate)
+ RefreshView();
+ }
+
+ private void ThrowIfCheckingUpdate(string operation)
+ {
+ if (IsCheckingUpdate)
+ throw new InvalidOperationException($"{operation} 不能在 CheckUpdate 作用域内调用。");
+ }
+ }
+}
diff --git a/Runtime/XTable/Rendering/Component/XericUIActionTable.UpdateScope.cs.meta b/Runtime/XTable/Rendering/Component/XericUIActionTable.UpdateScope.cs.meta
new file mode 100644
index 0000000..533592f
--- /dev/null
+++ b/Runtime/XTable/Rendering/Component/XericUIActionTable.UpdateScope.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 28fb43aa1d17ba14b9fa4cf1bd2ad055
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Runtime/XTable/Rendering/Component/XericUIActionTable.cs b/Runtime/XTable/Rendering/Component/XericUIActionTable.cs
index 12c0bcb..74c356a 100644
--- a/Runtime/XTable/Rendering/Component/XericUIActionTable.cs
+++ b/Runtime/XTable/Rendering/Component/XericUIActionTable.cs
@@ -347,7 +347,7 @@ namespace XericUI.XTable.Rendering.Component
private void Update()
{
UpdateSelectionAutoScroll();
- if (m_IsRebuilding || !m_DirtyFlag.IsDirty) return;
+ if (m_IsRebuilding || IsCheckingUpdate || !m_DirtyFlag.IsDirty) return;
var flags = m_DirtyFlag.Flags;
// 仅滚动或选区变更 → 轻刷新(不重建边框/背景/预制体全回收)
@@ -385,6 +385,16 @@ namespace XericUI.XTable.Rendering.Component
#region 脏标记与滚动回调
public void MarkDirty(TableDirtyType type)
+ {
+ if (IsCheckingUpdate)
+ {
+ m_UpdateContextStack.Peek().DirtyFlags |= type;
+ return;
+ }
+ ApplyDirty(type);
+ }
+
+ private void ApplyDirty(TableDirtyType type)
{
m_DirtyFlag.Mark(type);
@@ -402,6 +412,7 @@ namespace XericUI.XTable.Rendering.Component
private void OnScrollValueChanged(Vector2 _)
{
+ if (IsCheckingUpdate) return;
// 防重入:RefreshViewLight 中操作 content 可能触发 ScrollRect 再次回调
if (m_IsRefreshingFromScroll) return;
m_IsRefreshingFromScroll = true;
diff --git a/Runtime/XTable/Rendering/Elements/PrefabPrototypePool.cs b/Runtime/XTable/Rendering/Elements/PrefabPrototypePool.cs
index 95d1f7a..9e99e8e 100644
--- a/Runtime/XTable/Rendering/Elements/PrefabPrototypePool.cs
+++ b/Runtime/XTable/Rendering/Elements/PrefabPrototypePool.cs
@@ -147,6 +147,22 @@ namespace XericUI.XTable.Rendering.Elements
KillObject(instance);
}
+ /// 销毁所有按 cellId 绑定的实例,并在销毁前通知调用方。
+ public void ReleaseAll(System.Action beforeRelease = null)
+ {
+ foreach (var pool in m_Pools.Values)
+ {
+ foreach (var instance in pool.Slots.Values)
+ {
+ beforeRelease?.Invoke(instance);
+ KillObject(instance);
+ }
+ pool.Slots.Clear();
+ }
+ m_Pools.Clear();
+ m_TotalCreated = 0;
+ }
+
/// 结束当前渲染周期。实例保留在全局槽位中,以便跨区域复用。
public void ReturnAll()
{