# 数据层审计与重构报告(2026-10-15) > 本轮任务:① 审计代码库的命名/参数/注释/数据结构;② 在审计基础上做一次解耦重构(大刀阔斧, > 但**保持全部交互与显示不变**);③ 完善 CSV(多时间轴 / 各轴不等长)并新增 MF4 支持。 > > 结论:**数据层与加载层被重写为「分组 / 时间轴 / 信号」三段式结构**,文件格式与数据模型彻底解耦; > CSV 多时间轴与 MF4 均已支持;三套离屏测试(69 + 46 + 47 条断言)通过;`make release` 零警告。 --- ## 一、审计发现的问题(按严重度) ### 1.1 「文件」概念渗进了数据模型的核心(最严重) | 位置 | 问题 | | --- | --- | | `DataFile`(结构体名) | 名字把「一次加载产生的信号集合」和「磁盘上的文件」绑死;实际应用语义是**信号分组**。 | | `DataFile::filePath` / `fileName` | 加载完成后应用与文件再无关系(文件可移动/删除/改名),这两个字段在运行期**没有任何用途**(`filePath` 只在加载消息里出现一次)。 | | `DataFile::time` / `timeColumnName` / `timeMonotonic` / `minTime` / `maxTime` | 把「时间轴」焊死在分组上 ⇒ **一个分组只能有一条时间轴**,与 CSV 多 `time` 列、MF4 多通道组的现实不符。 | | `SignalRef::fileIndex` | 引用语义被写成「文件下标」,全仓 223 处;后来者会以为信号与文件有绑定关系。 | | `EdgeCountsFor(const DataFile& file, const SignalData& signal, …)` | **典型文不对题**:函数只需要「时间 + 采样值」,却要求调用方提供整个「文件」;这也是用户指出的重点。 | | `SampleSignalAt(const DataFile& file, …)`、`FindNearestSample(const DataFile& file, …)`、`VisibleIndexRange(const DataFile& file, …)`、`EdgeIndexRange(const DataFile& file, …)`、`MeanFilterValueAt`、`WeakPeakValueAt` | 同上一类:参数语义过宽,时间轴与信号被迫通过「文件」间接耦合。 | | `ResolvedSignal::file` | 视图层的解析结果也携带「文件」指针,导致采样/命中/包络/边沿统计处处 `resolved.file->time`。 | ### 1.2 `SignalData` 的冗余字段(用户指出的重点) | 字段 | 问题 | 结论 | | --- | --- | --- | | `name` | 源名称(列名/通道名)。 | **保留**,语义明确:加载后原样不变。 | | `displayName` | 用户重命名。 | **保留**(`Title()` 优先返回它)。 | | `rawHeader` | `name` 的副本(构建时 `rawHeader = headers[i]; name = Trim(rawHeader)`),派生信号里又 `rawHeader = derived.name`。 | **删除**(纯冗余,两个字段表达同一件事)。 | | `unit` | 由列名括号里猜出来的单位,全仓只有 2 处写、0 处读(从未显示、从未参与计算)。 | **删除**(属于「未实现的元数据」残留;用户明确说单位元数据暂不支持)。 | | 时间信息 | 缺失 ⇒ 只能靠「文件」找时间轴。 | **新增 `axisIndex`**:信号指向所属时间轴,采样值与时间的对应关系自包含。 | ### 1.3 加载层与格式耦合 | 位置 | 问题 | | --- | --- | | `CsvTable`(结构体) | 「从文件读出来的原始表格」这个名字把通用中转结构钉死在 CSV 上;后续每加一种格式都要再抄一遍类似结构。 | | `CsvProgress` / `CsvProgressInfo` | 名字里的 `Csv` 让「加载进度」这类通用概念只服务于 CSV。 | | `BuildDataFile(CsvTable&&, path, …)` | 建组函数直接吃 CSV 解析结果;MF4(或任何其它格式)都无法复用,只能复制一份逻辑。 | | `bool BuildDataFile(...)` 里的「识别时间轴列 / 回退第一列」逻辑 | 与「多时间轴」需求正面冲突:只认**第一个**时间列,且要求所有列等长。 | | `.preflight` 之外无任何「格式 → 数据」的装配点 | 新增格式要在 `Application::RunAsyncLoad` 里改流程。 | ### 1.4 过期 / 文不对题的注释 | 位置 | 问题 | | --- | --- | | `LooksLikeTimeHeader` | 判据是「列名含 time/时间」⇒ 会把 `timeout`、`TimeStampExtra` 之类误判成时间轴(多时间轴下会**切出空块**)。 | | `ExtractUnit` | 解析出来的单位无人使用(见 1.2)。 | | 大量 `@param [IN] file 信号所属文件` | 参数已改语义后注释未跟(本轮全部改为 `axis 该信号使用的时间轴`)。 | | UI 文案 | `选中该文件全部信号` / `取消该文件全部信号` / `关闭该文件(从工作区移除)` / `请关闭部分文件分组或信号` 等,把「分组」说成「文件」。 | | 空状态文案 | `尚未加载数据,请打开 CSV 文件` / `打开 CSV 文件以查看仿真数据波形`:应用已不只支持 CSV。 | ### 1.5 健壮性缺陷(审计中顺带发现) | 位置 | 问题 | | --- | --- | | `Application::Initialize` | `RegisterClassExW` 失败即弹**模态**错误框。同一进程内第二次构造 `Application` 必然命中 `ERROR_CLASS_ALREADY_EXISTS` ⇒ 调用方**永久阻塞**(本轮离屏测试就被它卡住)。`Shutdown` 也从不注销窗口类。 | | `Application::LoadFile` 失败路径 | 会弹模态对话框(离屏/自动化场景是雷区)。已在测试中绕开;建议后续把「错误提示」与「模态阻塞」解耦。 | | MDF 文本块解析 | `##TX` 在 MDF 规范里是「长度 + UTF-16/UTF-8」,但 CANape 16 会写**无长度前缀的裸 ASCII**(见 §三),只按规范解析会得到空名称。 | --- ## 二、重构内容(改动清单) ### 2.1 数据模型(`Utilities/Inc/DataModel.hpp`、`Utilities/Src/DataModel.cpp`) **新增 `TimeAxis`(时间轴成为一等公民)** ```cpp struct TimeAxis { std::wstring name; // 时间列名(源文件原样) std::vector time; double minTime, maxTime; bool monotonic; std::size_t finiteCount; std::size_t Count() const; }; ``` **`SignalData` 精简 + 指向时间轴** ```cpp struct SignalData { std::wstring name; // 源名称(原样保留、永不修改) std::wstring displayName; // 用户重命名(空 = 用 name) std::size_t axisIndex = 0; // 所属时间轴(分组内下标) ← 新增 std::vector values; double minValue, maxValue; std::size_t finiteCount; std::vector coarseMin, coarseMax; std::size_t coarseStride; SignalStyle style; const std::wstring& Title() const; }; // 删除:rawHeader、unit ``` **`DataFile` → `SignalGroup`(分组 = 若干时间轴 + 若干信号)** ```cpp struct SignalGroup { std::wstring name; // 默认取源文件名(便于辨认来源) std::wstring displayName; // 用户重命名 std::vector axes; // 1..N 条时间轴 ← 取代单一 time std::vector signals; const std::wstring& Title() const; const TimeAxis& AxisOf(const SignalData&) const; const TimeAxis& AxisAt(std::size_t) const; bool TimeRange(double& low, double& high) const; // 全部轴的并集 }; // 删除:filePath、fileName、time、timeColumnName、timeMonotonic、minTime、maxTime ``` **引用与集合** * `SignalRef::fileIndex` → **`groupIndex`**(含注释:「分组是应用内的逻辑容器,与磁盘文件没有任何关系」)。 * `DataSet`:`AddDataFile→AddGroup`、`RemoveFile→RemoveGroup`、`FileAt→GroupAt`、`FileCount→GroupCount`、 `Files()→Groups()`、`SetFileDisplayName→SetGroupDisplayName`、 `RemapSelectionAfterFileRemoval→RemapSelectionAfterGroupRemoval`;新增 **`Axis(ref)`**(解析信号→时间轴)。 * `TabConfig`:`SetFileVisible→SetGroupVisible`、`FileHasVisible→GroupHasVisible`、`FileAllVisible→GroupAllVisible`、 `RemapAfterFileRemoval→RemapAfterGroupRemoval`、`RowKind::FILE→RowKind::GROUP`、`CMD_FILE_*→CMD_GROUP_*`。 **采样与统计 API 去掉「文件」** ```cpp bool SampleSignalAt(const TimeAxis&, const SignalData&, double time, bool interpolate, double&); bool SampleDisplayedSignalAt(const TimeAxis&, const SignalData&, const SignalStyle&, …); int FindNearestSample(const TimeAxis&, double time); // PlotView:SampleSpacingPixels / VisibleIndexRange / EdgeIndexRange / // MeanFilterValueAt / WeakPeakValueAt / EdgeCountsFor / // EdgeCenterCountsFor / EdgeScanFor —— 首参全部由 const DataFile& 改为 const TimeAxis& ``` **视图层** * `ResolvedSignal`:`const DataFile* file` → **`const SignalGroup* group` + `const TimeAxis* axis`**; 所有 `resolved.file->time` 改为 `resolved.axis->time`,`resolved.file->Title()` 保留走 `group`。 * `PolylineCacheEntry::file` → `group`;`EdgeScanState::file` → **`axis`**(统计身份 = 时间轴 + 信号)。 * `TabConfig::ComputeVisibleTimeBounds`:只统计**可见信号真正用到的时间轴**(多轴分组下避免拉出过宽范围)。 * 时间轴刻度列名取「首个可见信号所属分组的第一条时间轴」。 ### 2.2 加载层(新文件,格式与模型解耦) ``` Utilities/Inc/DataTable.hpp DataTable(通用列优先表格)+ LoadProgress/Info + TableBlock(一个时间轴块)+ LoadedSource(源文件的全部块) Utilities/Inc/DataLoader.hpp LoadSignalSource(path,…) 唯一入口 + IsSupportedSignalFile + 对话框过滤器 Utilities/Src/DataLoader.cpp 按扩展名分发(.csv/.txt → CSV;.mf4/.mdf → MDF4;其它按文本尝试) Utilities/Inc/CsvLoader.hpp LoadCsvSource Utilities/Src/CsvLoader.cpp CSV 解析 + 多时间轴切分 + 各轴按自己的时间列裁剪 Utilities/Inc/Mf4Loader.hpp LoadMf4Source Utilities/Src/Mf4Loader.cpp ASAM MDF v4 解析(见 §三) 删除:Utilities/Inc/CsvTable.hpp、Utilities/Src/CsvTable.cpp ``` **唯一装配点**:`BuildSignalGroup(LoadedSource&&, paletteBase, SignalGroup&, error, progress)` —— 与格式无关,负责:时间轴**去重合并**(内容逐位相同只保留一条)、信号命名、调色板取色、统计与粗粒度包络。 新增格式时**只需写一个加载器**,数据模型与全部界面代码不用改。 ### 2.3 CSV:多时间轴 + 各轴不等长 * 规则(与 MATLAB/Simulink 说明一致):**只有名为 `time` 的列是时间轴**;每个时间列开启一个新块, 它之后直到下一个时间列之前的列都属于该块。 * 各块按**自己的时间列**裁剪(较短的时间轴在数据区提前结束,其余位置为空)⇒ 支持 `TestData/Multi_Timeline.CSV`(`time,data1,time,data2,data3,time,data4`,实测切出 3 条轴,长度 7/4/7)。 * 修复用户报告的缺陷:**不再把其它 `time` 列当成信号显示**。 * 保留旧行为:完全没有 `time` 列时退化为「第一列是时间轴」的单轴文件。 * 判据收紧:`LooksLikeTimeHeader` 只接受 `t` / `time` / `timestamp` / `simulation time` / `时间` / `时间轴` (不再用「包含 time」这种会误判 `timeout` 的规则)。 ### 2.4 MF4:新增支持 * 加载后**新建一个分组、组名 = 文件名**,文件内的全部信号都归入该分组(用户要求的行为)。 * 每个**通道组(CG)**是独立时间轴:内容相同的时间轴自动合并 (实测该文件 27 个 CG → **3 条时间轴**、124 条信号,避免 25 条重复的单样本轴)。 * 只解码绘制需要的部分:字符串/字节数组通道、压缩块(DZ/HZ)、MIME 通道一律跳过; 非数值通道不产生信号。 ### 2.5 UI 文案与健壮性 * 文案统一到「分组」:`选中该分组全部信号`、`取消该分组全部信号`、`关闭该分组(从工作区移除)`、 `请关闭部分分组或信号后重试。`、`尚未加载数据,请打开数据文件`、`打开数据文件以查看波形`、 `(可同时加载多个文件,各自成为一个分组)`;打开对话框标题改为「打开测量数据文件」并支持 `*.mf4/*.mdf`。 * `Application::Initialize`:`RegisterClassExW` 返回 `ERROR_CLASS_ALREADY_EXISTS` 时**复用**已注册的类 (不再弹模态错误框);`Shutdown` 注销窗口类并销毁窗口。⇒ 同一进程可多次构造/销毁 `Application`。 --- ## 三、MDF v4 解析要点(本轮实测得到,供后续维护) 文件实测为 **MDF 4.10 / CANape 16.0.50**(`##HD … ##DG … ##CG … ##CN … ##CC … ##DT`)。 * **块头(24 字节)**:`id[4]` + `reserved[4]` + `block_len(U64)` + `link_count(U64)`;之后是 `link_count` 个 **8 字节绝对文件偏移**(0 = 空);数据段紧随链接区。DT/HL 等块可以 0 链接。 * **ID 块**:偏移 0 起 `"MDF "`,偏移 8 起版本串(如 `"4.10 "`)。 * **HD**:链接 `[0] = 第一个 DG`。 * **DG**:链接 `[1] = 第一个 CG`、`[2] = 该组的数据块(DT)`。 * **CG**:链接 `[1] = 第一个 CN`、`[2] = tx_name`;数据段 `+8 = cycle_count`。 **记录长度**取 `DT 数据段字节数 / cycle_count`(实测 114/110 字节,与声明一致),拿不到时按通道位宽向上取整。 * **CN**:链接 `[2] = 名称 TX`、`[4] = 换算 CC`;数据段 `+0 type`、`+1 sync_type`(**1 = 时间主通道**)、`+2 data_type`、`+3 bit_offset`、 `+4 byte_offset(U32,单位**字节**)`、`+8 bit_count`、`+12 flags`(bit0 整列无效 / bit1 有无效位)、 `+16 invalid_bit_pos`、`+32 val_range_min`、`+40 val_range_max`。 **绝对位偏移 = byte_offset × 8 + bit_offset**。 * **data_type**:0/1 无符号(LE/BE)、2/3 有符号、4/5 IEEE 浮点、≥6 字符串/字节数组(跳过)。 浮点只接受字节对齐的 32/64 位;整数支持 1–64 位任意位偏移(含符号扩展)。 * **CC**:`+0 type`、`+1 precision`、`+2 flags`、`+4 ref_param_nr`、`+6 val_count`、 `+8 phy_range_min`、`+16 phy_range_max`、`+24 cc_val[]`(每个 8 字节)。 **换算公式(实测确认)= `phys = cc_val[0] + cc_val[1] × raw`**。 本文件里时间主通道是「double 纳秒 + 线性换算 1e-9」⇒ 解码后时间范围 `[0.024320125, 42.3918] s` 与 CC 里的物理范围逐位吻合,可据此自检。 * **文本块(TX)**:规范是「U32 长度 + UTF-16 代码单元」(MDF 4.1 起也可 UTF-8 字节数), 但 **CANape 会写无长度前缀的 NUL 结尾裸文本**。解析顺序:先按长度前缀(UTF-16 → UTF-8), 失败则按「裸文本(自动判断 UTF-16/UTF-8)」回退。 --- ## 四、验证(全部离屏;进程/线程 `BELOW_NORMAL`、窗口 `SW_HIDE`、不注入真实鼠标键盘) | 套件 | 覆盖 | 结果 | | --- | --- | --- | | `.preflight/perf/datacheck_main.cpp` | 单轴 CSV 回归;多时间轴 CSV(3 轴、7/4/7、不再出现 `time` 信号);MF4(组名、124 信号、3 条轴、主时间轴 ns→s、`setSpeed` 首样本与原始字节人工核对);`DataSet` 不变量(轴共享/解析/时间范围/重命名);派生信号沿用源时间轴;**MF4 集成**(124 信号并入一个网格渲染 67.5ms/帧) | **69/69 通过** | | `.preflight/perf/edgecheck_main.cpp` | 「调整」符号规则(真实菜单路径);边沿统计按帧推进 / 不饿死 / 与参考实现逐位一致 / 无鼠标输入也完成;面板两列等宽与名称限宽;弱峰复位控件 | **46/46 通过** | | `.preflight/perf/verify_main.cpp`(既有) | PlotView 渲染/交互:Y 自适应、派生信号、图例命中、GX 徽标悬停、装饰层无残影、平移逐像素 | **47/47**(另有 2 条**本机未同步的旧规则断言**:`derived offsetTime kept`、`mouse in lane1 lower half`,与本轮无关) | | 构建 | `make release` | **零警告** | 补充:`Target.exe` 手工运行确认加载 `Multi_Timeline.CSV` 与 `MF4-Record.MF4` 正常(真实交互由用户后续验收)。 --- ## 五、遗留与建议(未在本轮实施) 1. **错误提示与模态阻塞解耦**:`SetStatusMessage(..., isError=true)` 直接 `MessageBoxW`, 在自动化/离屏场景会永久阻塞。建议改为宿主级非模态提示(或至少让调用方可选择不弹)。 2. **MF4 压缩块(`##DZ`/`##HL`/`##SD`)与 `##DL` 数据列表**未实现:遇到这类文件会跳过相应通道组。 如后续需要,解析 `DL` 的偏移数组即可(数据布局不变)。 3. **MF4 单元/注释(`##CC` 的 unit TX、`##MD`)未使用**:用户明确暂不需要元数据。 4. **`Component` 层仍在 `TabConfig` 里保存逐信号的边沿统计/预览参数**:属于「视图配置」与「数据」的 第二次耦合(当前可用,但若要支持「同一信号在不同标签页用不同统计参数」需再拆一层)。 5. **`Lane` 内含 4 类实验性功能的状态与面板坐标**(`edgeCount/meanFilter/weakPeak` + 各自面板 X/Y), 已经把 `Lane` 撑到 200+ 行;建议后续抽出 `LanePanelState`。 6. **`PlotView.cpp` 仍有 1.1 万行**:渲染分支(静态缓存/宽基底/样式补丁/整幅)与交互命中混在一个类里, 建议按「渲染器 / 命中测试 / 派生曲线调度」拆成 3 个协作类(风险较高,宜单独一轮做)。 7. **`SignalRef` 仍是「分组下标 + 组内下标」**:删除/重排分组时依赖调用方按顺序调用 `Remap*` 系列函数维护;可考虑改为稳定 ID(后续需要撤销/重做时收益更大)。