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:
tian 2026-06-15 18:25:21 +08:00
parent cc1a0628d8
commit 5c2b95d777
8 changed files with 91 additions and 73 deletions

View File

@ -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;
}
}

View File

@ -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)
{

View File

@ -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>

View File

@ -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();

View File

@ -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);
}
}
}

View File

@ -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));