perf: FrameDataStore 改为内存缓存+结束时批量写入
RecordFrame: 仅内存追加, 零磁盘IO Flush: 仿真Stop时单次事务批量InsertAll BeginRecording/Discard/BufferedFrameCount 新API SimulationEngine: 移除 _frameDb 持久连接, RecordFrames默认true SimulationRunner: 移除所有 Debug.Log 每帧日志 ReplayController: 适配 ReadFrames 新API FrameDataStoreTests: 6项覆盖 录制/刷新/读取/丢弃/清理
This commit is contained in:
parent
cc1a0628d8
commit
5c2b95d777
@ -6,33 +6,39 @@ using SQLite;
|
||||
|
||||
namespace CounterDrone.Core.Simulation
|
||||
{
|
||||
/// <summary>帧数据存储 — 内存缓存,仿真结束时批量写入 SQLite</summary>
|
||||
public class FrameDataStore
|
||||
{
|
||||
private readonly IPathProvider _paths;
|
||||
private readonly List<SimFrameRecord> _buffer = new();
|
||||
private string _taskId = string.Empty;
|
||||
/// <summary>帧数据保留天数,默认 180 天(6 个月)</summary>
|
||||
public int RetentionDays { get; set; } = 180;
|
||||
public int BufferedFrameCount => _buffer.Count > 0
|
||||
? _buffer[^1].FrameIndex + 1
|
||||
: 0;
|
||||
|
||||
public FrameDataStore(IPathProvider paths)
|
||||
{
|
||||
_paths = paths;
|
||||
}
|
||||
|
||||
public SQLiteConnection CreateOrOpen(string taskId)
|
||||
/// <summary>开始录制。清空缓冲区,准备接收帧数据。</summary>
|
||||
public void BeginRecording(string taskId)
|
||||
{
|
||||
var dbPath = Path.Combine(_paths.GetFramesDir(), $"{taskId}.db");
|
||||
var db = new SQLiteConnection(dbPath);
|
||||
db.CreateTable<SimFrameRecord>();
|
||||
db.CreateIndex("SimFrameRecord", new[] { "TaskId", "FrameIndex" });
|
||||
return db;
|
||||
_taskId = taskId;
|
||||
_buffer.Clear();
|
||||
}
|
||||
|
||||
public void WriteFrame(SQLiteConnection db, string taskId, int frameIdx, float timestamp, List<EntitySnapshot> snapshots)
|
||||
/// <summary>记录一帧所有实体的快照(仅内存,不写磁盘)</summary>
|
||||
public void RecordFrame(int frameIdx, float timestamp, List<EntitySnapshot> snapshots)
|
||||
{
|
||||
foreach (var s in snapshots)
|
||||
for (int i = 0; i < snapshots.Count; i++)
|
||||
{
|
||||
db.Insert(new SimFrameRecord
|
||||
var s = snapshots[i];
|
||||
_buffer.Add(new SimFrameRecord
|
||||
{
|
||||
TaskId = taskId,
|
||||
TaskId = _taskId,
|
||||
FrameIndex = frameIdx,
|
||||
Timestamp = timestamp,
|
||||
EntityId = s.EntityId,
|
||||
@ -48,8 +54,26 @@ namespace CounterDrone.Core.Simulation
|
||||
}
|
||||
}
|
||||
|
||||
public List<SimFrameRecord> ReadFrames(SQLiteConnection db, string taskId, int fromFrame, int toFrame)
|
||||
/// <summary>将缓冲区所有帧数据批量写入 SQLite(事务级别,单次 IO)</summary>
|
||||
public void Flush()
|
||||
{
|
||||
if (_buffer.Count == 0) return;
|
||||
var dbPath = Path.Combine(_paths.GetFramesDir(), $"{_taskId}.db");
|
||||
Directory.CreateDirectory(_paths.GetFramesDir());
|
||||
using var db = new SQLiteConnection(dbPath);
|
||||
db.CreateTable<SimFrameRecord>();
|
||||
db.CreateIndex("SimFrameRecord", new[] { "TaskId", "FrameIndex" });
|
||||
db.InsertAll(_buffer);
|
||||
_buffer.Clear();
|
||||
}
|
||||
|
||||
public void Discard() => _buffer.Clear();
|
||||
|
||||
public List<SimFrameRecord> ReadFrames(string taskId, int fromFrame, int toFrame)
|
||||
{
|
||||
var dbPath = Path.Combine(_paths.GetFramesDir(), $"{taskId}.db");
|
||||
if (!File.Exists(dbPath)) return new List<SimFrameRecord>();
|
||||
using var db = new SQLiteConnection(dbPath);
|
||||
return db.Table<SimFrameRecord>()
|
||||
.Where(f => f.TaskId == taskId && f.FrameIndex >= fromFrame && f.FrameIndex <= toFrame)
|
||||
.OrderBy(f => f.FrameIndex)
|
||||
@ -63,25 +87,20 @@ namespace CounterDrone.Core.Simulation
|
||||
}
|
||||
|
||||
/// <summary>清理过期的帧数据库文件</summary>
|
||||
/// <returns>被删除的文件数量</returns>
|
||||
public int CleanupExpired()
|
||||
{
|
||||
var dir = _paths.GetFramesDir();
|
||||
if (!Directory.Exists(dir)) return 0;
|
||||
|
||||
var cutoff = DateTime.Now.AddDays(-RetentionDays);
|
||||
var deleted = 0;
|
||||
|
||||
foreach (var file in Directory.GetFiles(dir, "*.db"))
|
||||
{
|
||||
var creationTime = File.GetCreationTime(file);
|
||||
if (creationTime < cutoff)
|
||||
if (File.GetCreationTime(file) < cutoff)
|
||||
{
|
||||
File.Delete(file);
|
||||
deleted++;
|
||||
}
|
||||
}
|
||||
|
||||
return deleted;
|
||||
}
|
||||
}
|
||||
|
||||
@ -20,6 +20,8 @@ namespace CounterDrone.Core.Simulation
|
||||
public int FrameIndex { get; private set; }
|
||||
public float SimulationTime { get; private set; }
|
||||
public float TimeScale { get; set; } = 1f;
|
||||
/// <summary>是否录制逐帧数据(回放用)。默认开启。</summary>
|
||||
public bool RecordFrames { get; set; } = true;
|
||||
private float _tickInterval;
|
||||
|
||||
public IReadOnlyList<DroneEntity> Drones => _drones;
|
||||
@ -46,7 +48,6 @@ namespace CounterDrone.Core.Simulation
|
||||
private readonly List<FireEvent> _fireSchedule = new();
|
||||
private int _nextFireIndex;
|
||||
private readonly List<SimEvent> _allEvents = new();
|
||||
private SQLiteConnection _frameDb = null!;
|
||||
private string _taskId = string.Empty;
|
||||
private readonly IDefensePlanner _planner;
|
||||
private int _entityCounter;
|
||||
@ -156,7 +157,8 @@ namespace CounterDrone.Core.Simulation
|
||||
}
|
||||
}
|
||||
|
||||
_frameDb = _frameStore.CreateOrOpen(_taskId);
|
||||
if (RecordFrames)
|
||||
_frameStore.BeginRecording(_taskId);
|
||||
State = SimulationState.Running;
|
||||
}
|
||||
|
||||
@ -335,11 +337,16 @@ namespace CounterDrone.Core.Simulation
|
||||
|
||||
_allEvents.AddRange(_frameEventPool);
|
||||
|
||||
var snapshots = CollectSnapshots();
|
||||
_frameStore.WriteFrame(_frameDb, _taskId, FrameIndex, SimulationTime, snapshots);
|
||||
if (FrameIndex % 50 == 0) _frameDb.Commit();
|
||||
|
||||
_frameResultPool.EntitySnapshots = snapshots;
|
||||
if (RecordFrames)
|
||||
{
|
||||
var snapshots = CollectSnapshots();
|
||||
_frameStore.RecordFrame(FrameIndex, SimulationTime, snapshots);
|
||||
_frameResultPool.EntitySnapshots = snapshots;
|
||||
}
|
||||
else
|
||||
{
|
||||
_frameResultPool.EntitySnapshots = null!;
|
||||
}
|
||||
_frameResultPool.NewEvents = _frameEventPool;
|
||||
_frameResultPool.State = State;
|
||||
_frameResultPool.FrameIndex = FrameIndex;
|
||||
@ -349,7 +356,7 @@ namespace CounterDrone.Core.Simulation
|
||||
|
||||
public void Pause() { if (State == SimulationState.Running) State = SimulationState.Paused; }
|
||||
public void Resume() { if (State == SimulationState.Paused) State = SimulationState.Running; }
|
||||
public void Stop() { State = SimulationState.Stopped; _frameDb?.Close(); }
|
||||
public void Stop() { State = SimulationState.Stopped; _frameStore.Flush(); }
|
||||
|
||||
private static List<FireUnit> BuildFireUnits(TaskFullConfig config)
|
||||
{
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@ -25,19 +25,12 @@ namespace CounterDrone.Unity
|
||||
|
||||
public void LoadReplay(string taskId)
|
||||
{
|
||||
var db = _frameStore.CreateOrOpen(taskId);
|
||||
CurrentFrames = db.Table<SimFrameRecord>()
|
||||
.Where(f => f.TaskId == taskId)
|
||||
.OrderBy(f => f.FrameIndex)
|
||||
.ToList();
|
||||
db.Close();
|
||||
CurrentFrames = _frameStore.ReadFrames(taskId, 0, int.MaxValue);
|
||||
|
||||
TotalFrames = CurrentFrames.Count > 0
|
||||
? CurrentFrames.Max(f => f.FrameIndex) + 1
|
||||
: 0;
|
||||
CurrentFrameIndex = 0;
|
||||
|
||||
Debug.Log($"Replay loaded: {CurrentFrames.Count} records, {TotalFrames} frames");
|
||||
}
|
||||
|
||||
/// <summary>获取指定帧的所有实体快照</summary>
|
||||
|
||||
@ -15,8 +15,6 @@ namespace CounterDrone.Unity
|
||||
{
|
||||
public class SimulationRunner : MonoBehaviour
|
||||
{
|
||||
private bool _loggedStart;
|
||||
|
||||
[SerializeField] private GameObject _dronePrefab;
|
||||
[SerializeField] private GameObject _cloudPrefab;
|
||||
[SerializeField] private GameObject _platformPrefab;
|
||||
@ -67,17 +65,8 @@ namespace CounterDrone.Unity
|
||||
{
|
||||
if (_engine == null || _engine.State != SimulationState.Running) return;
|
||||
|
||||
if (!_loggedStart) { Debug.Log($"Ticking: drones={_engine.Drones.Count}, timescale={_engine.TimeScale}"); _loggedStart = true; }
|
||||
|
||||
var result = _engine.Tick(Time.deltaTime);
|
||||
|
||||
// 每秒输出无人机位置
|
||||
if (Time.frameCount % 60 == 0 && _engine.Drones.Count > 0)
|
||||
{
|
||||
var d = _engine.Drones[0];
|
||||
Debug.Log($"Drone: ({d.PosX:F0}, {d.PosY:F0}, {d.PosZ:F0}) Status={d.Status}");
|
||||
}
|
||||
|
||||
foreach (var snap in result.EntitySnapshots)
|
||||
{
|
||||
if (!_entityVisuals.TryGetValue(snap.EntityId, out var go) || go == null)
|
||||
@ -145,8 +134,6 @@ namespace CounterDrone.Unity
|
||||
}
|
||||
}
|
||||
|
||||
if (result.State == SimulationState.Completed)
|
||||
Debug.Log($"Simulation done. Drone: {_engine.Drones[0].Status}, HP: {_engine.Drones[0].Hp:F2}");
|
||||
}
|
||||
|
||||
public void Stop() => _engine?.Stop();
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using CounterDrone.Core;
|
||||
using CounterDrone.Core.Models;
|
||||
using CounterDrone.Core.Simulation;
|
||||
using Xunit;
|
||||
|
||||
@ -23,22 +25,22 @@ namespace CounterDrone.Core.Tests
|
||||
if (Directory.Exists(_testDir)) Directory.Delete(_testDir, true);
|
||||
}
|
||||
|
||||
private void CreateFakeDb(string taskId)
|
||||
{
|
||||
_store.BeginRecording(taskId);
|
||||
_store.RecordFrame(0, 0f, new List<EntitySnapshot> { new EntitySnapshot { EntityId = "d1" } });
|
||||
_store.Flush();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CleanupExpired_DeletesOldFiles()
|
||||
{
|
||||
// 设置为 0 天保留,所有文件都是"过期"的
|
||||
_store.RetentionDays = 0;
|
||||
|
||||
// 创建一些假帧数据库
|
||||
var db1 = _store.CreateOrOpen("task_old_1");
|
||||
db1.Close();
|
||||
var db2 = _store.CreateOrOpen("task_old_2");
|
||||
db2.Close();
|
||||
CreateFakeDb("task_old_1");
|
||||
CreateFakeDb("task_old_2");
|
||||
|
||||
var deleted = _store.CleanupExpired();
|
||||
Assert.Equal(2, deleted);
|
||||
|
||||
// 文件应该被删除
|
||||
Assert.False(File.Exists(Path.Combine(_testDir, "frames", "task_old_1.db")));
|
||||
Assert.False(File.Exists(Path.Combine(_testDir, "frames", "task_old_2.db")));
|
||||
}
|
||||
@ -46,48 +48,57 @@ namespace CounterDrone.Core.Tests
|
||||
[Fact]
|
||||
public void CleanupExpired_KeepsRecentFiles()
|
||||
{
|
||||
_store.RetentionDays = 365; // 保留 1 年
|
||||
_store.RetentionDays = 365;
|
||||
CreateFakeDb("task_new");
|
||||
|
||||
var db = _store.CreateOrOpen("task_new");
|
||||
db.Close();
|
||||
|
||||
// 手动修改创建时间为"最近"
|
||||
var filePath = Path.Combine(_testDir, "frames", "task_new.db");
|
||||
File.SetCreationTime(filePath, DateTime.Now);
|
||||
|
||||
var deleted = _store.CleanupExpired();
|
||||
Assert.Equal(0, deleted);
|
||||
|
||||
// 文件应该保留
|
||||
Assert.True(File.Exists(filePath));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CleanupExpired_EmptyDir_ReturnsZero()
|
||||
{
|
||||
var deleted = _store.CleanupExpired();
|
||||
Assert.Equal(0, deleted);
|
||||
Assert.Equal(0, _store.CleanupExpired());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReadFrames_ReturnsCorrectRange()
|
||||
public void RecordAndFlush_ThenReadFrames()
|
||||
{
|
||||
var db = _store.CreateOrOpen("task_read");
|
||||
var snapshots = new System.Collections.Generic.List<EntitySnapshot>
|
||||
_store.BeginRecording("task_rw");
|
||||
var snapshots = new List<EntitySnapshot>
|
||||
{
|
||||
new EntitySnapshot { EntityId = "d1", EntityType = Models.EntityType.Drone, PosX = 100 },
|
||||
};
|
||||
_store.RecordFrame(0, 0f, snapshots);
|
||||
_store.RecordFrame(1, 0.05f, snapshots);
|
||||
_store.RecordFrame(2, 0.10f, snapshots);
|
||||
_store.Flush();
|
||||
|
||||
_store.WriteFrame(db, "task_read", 0, 0f, snapshots);
|
||||
_store.WriteFrame(db, "task_read", 1, 0.05f, snapshots);
|
||||
_store.WriteFrame(db, "task_read", 2, 0.10f, snapshots);
|
||||
|
||||
var frames = _store.ReadFrames(db, "task_read", 1, 2);
|
||||
var frames = _store.ReadFrames("task_rw", 1, 2);
|
||||
Assert.Equal(2, frames.Count);
|
||||
Assert.Equal(1, frames[0].FrameIndex);
|
||||
Assert.Equal(2, frames[1].FrameIndex);
|
||||
}
|
||||
|
||||
db.Close();
|
||||
[Fact]
|
||||
public void Flush_EmptyBuffer_DoesNothing()
|
||||
{
|
||||
_store.BeginRecording("task_empty");
|
||||
_store.Flush(); // no crash
|
||||
Assert.False(File.Exists(Path.Combine(_testDir, "frames", "task_empty.db")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Discard_ClearsBuffer()
|
||||
{
|
||||
_store.BeginRecording("task_discard");
|
||||
_store.RecordFrame(0, 0f, new List<EntitySnapshot> { new EntitySnapshot { EntityId = "d1" } });
|
||||
_store.Discard();
|
||||
Assert.Equal(0, _store.BufferedFrameCount);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -136,6 +136,7 @@ namespace CounterDrone.Core.Tests
|
||||
SetupScenario();
|
||||
_engine.Initialize(_taskId);
|
||||
for (int i = 0; i < 10; i++) _engine.Tick(1f / 20f);
|
||||
_engine.Stop(); // 触发 Flush
|
||||
|
||||
var framePath = Path.Combine(_testDir, "frames", $"{_taskId}.db");
|
||||
Assert.True(File.Exists(framePath));
|
||||
|
||||
Loading…
Reference in New Issue
Block a user