Improves inventory loading performance

Implements deferred item population for inventory categories, which significantly improves the initial loading time of inventory windows, especially with a large number of items.

This change introduces a mechanism to populate inventory items in batches over multiple frames, preventing the UI from blocking during the initial load. It also correctly disposes of the looted category node when the addon is finalized, preventing potential memory leaks.
This commit is contained in:
Shawrkie Williams
2026-01-25 04:06:39 -05:00
parent 1c0917c3c1
commit 37b9d85313
3 changed files with 112 additions and 17 deletions
@@ -168,6 +168,8 @@ public unsafe class AddonInventoryWindow : InventoryAddonBase
addon->UnsubscribeAtkArrayData(1, (int)NumberArrayType.Inventory);
_lootedCategoryNode?.Dispose();
IsSetupComplete = false;
base.OnFinalize(addon);
}
+77 -6
View File
@@ -52,6 +52,9 @@ public abstract unsafe class InventoryAddonBase : NativeAddon, IInventoryWindow
protected bool RefreshQueued;
protected bool RefreshAutosizeQueued;
protected bool IsSetupComplete;
private bool _deferredPopulationInProgress;
private bool _initialPopulationComplete;
private const int ItemsPerFrame = 70;
protected abstract InventoryStateBase InventoryState { get; }
@@ -154,17 +157,18 @@ public abstract unsafe class InventoryAddonBase : NativeAddon, IInventoryWindow
float maxContentWidth = CategoriesNode.Width > 0 ? CategoriesNode.Width : ContentSize.X;
int maxItemsPerLine = CalculateOptimalItemsPerLine(maxContentWidth);
bool deferItems = !_deferredPopulationInProgress && !_initialPopulationComplete;
CategoriesNode.SyncWithListDataByKey<CategorizedInventory, InventoryCategoryNode, uint>(
dataList: categories,
getKeyFromData: categorizedInventory => categorizedInventory.Key,
getKeyFromNode: node => node.CategorizedInventory.Key,
updateNode: (node, data) =>
{
node.MaxWidth = maxContentWidth;
node.SetCategoryData(data, Math.Min(data.Items.Count, maxItemsPerLine));
node.RefreshNodeVisuals();
node.SetCategoryData(data, Math.Min(data.Items.Count, maxItemsPerLine), deferItemCreation: deferItems);
if (!deferItems) node.RefreshNodeVisuals();
},
createNodeMethod: _ => CreateCategoryNode(maxContentWidth));
createNodeMethod: _ => CreateCategoryNode());
if (HasPinning)
{
@@ -181,6 +185,72 @@ public abstract unsafe class InventoryAddonBase : NativeAddon, IInventoryWindow
LayoutContent();
CategoriesNode.RecalculateLayout();
}
if (deferItems && !_deferredPopulationInProgress)
{
StartDeferredItemPopulation();
}
else if (!deferItems && !_initialPopulationComplete)
{
_initialPopulationComplete = true;
}
}
private void StartDeferredItemPopulation()
{
_deferredPopulationInProgress = true;
Services.Framework.RunOnTick(PopulateCategoryBatch, delayTicks: 1);
}
private void PopulateCategoryBatch()
{
if (!IsOpen)
{
_deferredPopulationInProgress = false;
return;
}
int itemsPopulated = 0;
using (CategoriesNode.DeferRecalculateLayout())
{
foreach (var node in CategoriesNode.Nodes)
{
if (node is InventoryCategoryNode categoryNode && categoryNode.NeedsItemPopulation)
{
int categoryItemCount = categoryNode.CategorizedInventory.Items.Count;
if (itemsPopulated > 0 && itemsPopulated + categoryItemCount > ItemsPerFrame)
break;
categoryNode.PopulateItems();
categoryNode.RefreshNodeVisuals();
itemsPopulated += categoryItemCount;
if (itemsPopulated >= ItemsPerFrame)
break;
}
}
}
bool hasMore = false;
foreach (var node in CategoriesNode.Nodes)
{
if (node is InventoryCategoryNode categoryNode && categoryNode.NeedsItemPopulation)
{
hasMore = true;
break;
}
}
if (hasMore)
{
Services.Framework.RunOnTick(PopulateCategoryBatch);
}
else
{
_deferredPopulationInProgress = false;
_initialPopulationComplete = true;
}
}
protected readonly struct HeaderLayout
@@ -231,12 +301,11 @@ public abstract unsafe class InventoryAddonBase : NativeAddon, IInventoryWindow
BackgroundDropTarget.AttachNode(this);
}
protected virtual InventoryCategoryNode CreateCategoryNode(float? maxWidth = null)
protected virtual InventoryCategoryNode CreateCategoryNode()
{
return new InventoryCategoryNode
{
Size = ContentSize with { Y = 120 },
MaxWidth = maxWidth,
OnRefreshRequested = ManualRefresh,
OnDragEnd = () => InventoryOrchestrator.RefreshAll(updateMaps: true),
};
@@ -461,6 +530,8 @@ public abstract unsafe class InventoryAddonBase : NativeAddon, IInventoryWindow
HoverSubscribed.Clear();
RefreshQueued = false;
RefreshAutosizeQueued = false;
_deferredPopulationInProgress = false;
_initialPopulationComplete = false;
base.OnFinalize(addon);
}
@@ -25,7 +25,8 @@ public class InventoryCategoryNode : InventoryCategoryNodeBase
private readonly TextNode _categoryNameTextNode;
private readonly HybridDirectionalFlexNode<DragDropNode> _itemGridNode;
private const float FallbackItemSize = 46;
private const float ExpectedItemWidth = 42;
private const float ExpectedItemHeight = 46;
private const float HeaderHeight = 16;
private const float MinWidth = 40;
@@ -41,9 +42,11 @@ public class InventoryCategoryNode : InventoryCategoryNodeBase
private int _lastItemCount;
private ulong _lastItemsHash;
private int _lastItemsPerLine;
private float? _lastMaxWidth;
private bool _itemsNeedPopulation;
public event Action<InventoryCategoryNode, bool>? HeaderHoverChanged;
public bool NeedsItemPopulation => _itemsNeedPopulation;
public Action? OnRefreshRequested { get; set; }
public Action? OnDragEnd { get; set; }
@@ -86,11 +89,10 @@ public class InventoryCategoryNode : InventoryCategoryNodeBase
set => SetCategoryData(value, _itemGridNode.ItemsPerLine);
}
public void SetCategoryData(CategorizedInventory data, int itemsPerLine)
public void SetCategoryData(CategorizedInventory data, int itemsPerLine, bool deferItemCreation = false)
{
bool categoryChanged = data.Key != _lastCategoryKey;
bool itemsPerLineChanged = itemsPerLine != _lastItemsPerLine;
bool maxWidthChanged = _maxWidth != _lastMaxWidth;
ulong itemsHash = ComputeItemsHash(CollectionsMarshal.AsSpan(data.Items));
bool itemsChanged = data.Items.Count != _lastItemCount || itemsHash != _lastItemsHash;
@@ -99,7 +101,6 @@ public class InventoryCategoryNode : InventoryCategoryNodeBase
_lastItemCount = data.Items.Count;
_lastItemsHash = itemsHash;
_lastItemsPerLine = itemsPerLine;
_lastMaxWidth = _maxWidth;
_categorizedInventory = data;
@@ -112,24 +113,45 @@ public class InventoryCategoryNode : InventoryCategoryNodeBase
_categoryNameTextNode.TextTooltip = data.Category.Description;
if (itemsChanged || categoryChanged)
{
_itemGridNode.ItemsPerLine = itemsPerLine;
if (deferItemCreation)
{
_itemsNeedPopulation = true;
}
else
{
using (_itemGridNode.DeferRecalculateLayout())
{
_itemGridNode.ItemsPerLine = itemsPerLine;
UpdateItemGrid();
}
_itemsNeedPopulation = false;
}
}
else if (itemsPerLineChanged)
{
_itemGridNode.ItemsPerLine = itemsPerLine;
}
if (categoryChanged || itemsChanged || itemsPerLineChanged || maxWidthChanged)
if (categoryChanged || itemsChanged || itemsPerLineChanged)
{
RecalculateSize();
}
}
public void PopulateItems()
{
if (!_itemsNeedPopulation)
return;
using (_itemGridNode.DeferRecalculateLayout())
{
UpdateItemGrid();
}
_itemsNeedPopulation = false;
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static ulong ComputeItemsHash(ReadOnlySpan<ItemInfo> items)
{
@@ -238,8 +260,8 @@ public class InventoryCategoryNode : InventoryCategoryNodeBase
{
int itemCount = CategorizedInventory.Items.Count;
float cellW = _itemGridNode.Nodes.Count > 0 ? _itemGridNode.Nodes[0].Width : FallbackItemSize;
float cellH = _itemGridNode.Nodes.Count > 0 ? _itemGridNode.Nodes[0].Height : FallbackItemSize;
float cellW = ExpectedItemWidth;
float cellH = ExpectedItemHeight;
float hPad = _itemGridNode.HorizontalPadding;
float vPad = _itemGridNode.VerticalPadding;