Files
orisys-app/docs/architecture/studio-architecture.md
T
ykcheungab d477a4d8b7 接入 Studio 控件与菜单构建模块
将界面构建从主窗口移出,保留现有业务回调与标定路径。验证结果和完整门禁限制见 docs/architecture/studio-window-refactor-plan.zh.md。
2026-10-19 10:14:58 +08:00

355 lines
20 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Studio 软件架构
本文说明已接线的 S1–S2;后续工作和验收由
[分步重构计划](studio-window-refactor-plan.zh.md) 维护。下方旧扫描图表保留为历史证据,
不能用其中的行数或“已完成”判断当前代码。
## 当前职责与数据流
`app_window.py::StudioMainWindow` 装配界面、控制器并连接信号。2026-10-19 的 S2 接线
从 3,070 行减至 2,894 行。窗口仍承担业务接线、预览、标定交互、分析配置和运行协调,
尚未完成薄窗口目标。
`status_presenter.py::StatusPresenter` 只持有工具栏,不持有窗口或会话控制器。
窗口在 GUI 线程收集一次性的 `RailState`,Presenter 负责 phase、来源、标定、有效性及
录制统计的文字和控件更新。快照不缓存、不反写状态;主题和语言刷新沿用原入口。
分析结果的传感器身份来自结果 provenance,进度读取现有 `AnalysisProgress.state()`,
不再通过 `ReplayController._progress` 访问其私有控件。
```mermaid
graph TD
W[StudioMainWindow 装配与接线] --> S[SessionController 会话与遥测]
W --> R[ReplayController 回放与分析执行]
W --> L[ViewLayoutController 布局]
W --> D[DisplayPrefsController 显示偏好]
W --> C[RecordingCoordinator 记录与设备确认]
W --> P[StatusPresenter 状态显示]
F[RailState 只读显示事实] --> P
P --> T[ToolbarWidget 状态轨]
W --> A[AcquirePresenter 实时呈现]
W --> V[FrameView 二维与共享深度范围]
W --> U[WindowUi 控件与布局]
W --> M[WindowMenus 菜单与快捷键]
```
会话状态仍属于 `SessionController`,录制状态仍属于 `RecordingCoordinator`。
`_record_stats` 保留为 UI 检查和截图的注入入口,实际状态选择及显示已委托 Presenter。
关闭中的录制继续读取实时统计,完成后读取最终报告;丢失计数包括未能记下的 gap。
设备确认与录制仍走采集线程通道;本次未改变定时器、停止等待或 3D 懒加载。
验证入口:`test_status_presenter.py` 可在没有主窗口和设备的情况下检查状态显示;
`test_ui_smoke.py`、`test_replay_wiring.py` 和 `tools/ui_check.py` 覆盖窗口集成。
执行结果、环境及尚未验证范围记录在实施计划。状态转发暂留用于现有调用方,后续按职责迁移,
不增加新的全局状态容器或将剩余逻辑全部并入回放控制器。
## S2 界面构建与接线
`ui/window_ui.py::WindowUi` 创建控件和布局,提供局部主题、文字更新;
`ui/window_menus.py::WindowMenus` 创建菜单、快捷键和勾选初值,不绑定业务回调。
窗口通过 `ui`、`menus` 使用它们,旧控件和 action 属性保留同一对象引用供控制器和工具访问。
窗口在控制器接线前装配 FrameView、共享 domain latch 和曲线 store;菜单初值先于业务
回调绑定。偏好保存、全局主题与语言、reset 文字、pause 状态和操作可用性仍由原负责人管理。
标定读取与应用路径保持原实现。`test_window_surfaces.py` 覆盖独立模块与主窗口接线;
布局、传感器切换和回放由现有集成测试覆盖,结果及验证限制见实施计划。
## 历史测量的适用范围
以下第 1–6 节及配套 `studio-architecture.svg`、`studio-architecture.png` 是旧扫描快照。
原扫描的对应 revision 与完整运行输入不可用,生成脚本已于 2026-09-27 清理,无法原样复跑。
数字口径为含空行行数;fan-in/out 为模块导入关系。旧测试计数、无环结论和完成状态均未在本次
重新全量测量,不是当前保证;后续重构顺序以实施计划为准。
## 1. 分层视图(真实依赖方向)
```mermaid
graph TB
subgraph L0["① 入口"]
MAIN["main.py<br/>109 行 · QApplication + 主题 + 首选项"]
end
subgraph L1["② 窗口外壳(只有它能看见全部 UI)"]
WIN["app_window.py · StudioMainWindow<br/>2309 行 · 158 个方法 · fan-out 37"]
BAR["brand_bar · toolbar · record_status"]
end
subgraph L15["②b Studio 控制器(一个变更驱动一个文件)"]
SESS["session_controller.py<br/>会话与相位 · 437 行"]
RPLC["replay_controller.py<br/>回放策略 · 641 行"]
LAY["view_layout.py<br/>面板布局 · 398 行"]
DISP["display_prefs.py<br/>显示偏好 · 227 行"]
end
subgraph L2["③ 面板(各自的画布与控件)"]
ARROW["ui/arrow_panel<br/>2D 图像 · 519 行"]
MESH["ui/mesh3d_panel<br/>3D 宿主容器 · 405 行"]
PLOTS["ui/force_plots<br/>三轴曲线 · 1243 行"]
CAL["ui/cal_panel<br/>标定表 · 793 行"]
DLG["sensor_dialog · plot_settings_dialog · cal_point_dialog"]
end
subgraph L3["④ Studio 领域服务(无 Qt 的与有 Qt 的分开)"]
REC["recording.py<br/>记录协调 · 267 行"]
REP["replay.py<br/>回放引擎 · 291 行(QObject)"]
CALSVC["calibration_service.py<br/>标定文件与归属 · 194 行 · 无 Qt"]
COORD["scene3d/coordinator.py<br/>619 行 · **无 Qt**"]
CAM["scene3d/camera.py<br/>238 行 · **无 Qt**"]
VIEW["scene3d/view.py<br/>757 行 · 3D 唯一入口"]
ASSET["scene3d/asset_source.py<br/>256 行 · 场景加载线程"]
GLB["scene3d/gl_backend.py<br/>870 行 · **GL 只在运行时惰性 import**"]
end
subgraph L4["⑤ 共享领域层(采集/记录/力/三维契约,无 Qt 依赖)"]
AQ["acquisition<br/>2627 行 · worker / packet / sinks / 图像后端"]
RC["record<br/>3008 行 · 录像读写 / CSV / session.json"]
FORCE["force<br/>3057 行 · 力曲线源 / 标定 / 滤波"]
S3D["scene3d<br/>4565 行 · 契约 / 采样 / 合成 / 包格式 / 签名"]
MISC["camera · prefs · paths · i18n · theme · logging"]
end
MAIN --> WIN
WIN --> BAR
WIN --> SESS
WIN --> RPLC
WIN --> LAY
WIN --> DISP
WIN --> ARROW
WIN --> MESH
WIN --> PLOTS
WIN --> CAL
WIN --> DLG
MESH --> VIEW
VIEW --> COORD
VIEW --> ASSET
VIEW --> GLB
VIEW --> CAM
COORD --> S3D
VIEW --> S3D
ASSET --> S3D
WIN --> REC
WIN --> REP
WIN --> CALSVC
REC --> RC
REP --> RC
CALSVC --> FORCE
WIN --> AQ
WIN --> FORCE
WIN --> RC
PLOTS --> FORCE
CAL --> FORCE
ARROW --> AQ
MESH --> S3D
```
纯文本版(无 Mermaid 渲染器时看这个):
```
main.py
└─ app_window.py ──┬─ 外壳: brand_bar / toolbar / record_status
├─ 控制器: session_controller · replay_controller · view_layout · display_prefs
├─ 面板: arrow_panel(2D) · mesh3d_panel(3D) · force_plots · cal_panel · 对话框
├─ 领域服务: recording · replay · calibration_service · scene3d.{view,asset_source,coordinator,keys}
└─ 共享层(无 Qt): acquisition · record · force · scene3d · camera/prefs/paths/i18n/theme/logging
mesh3d_panel → scene3d/view (3D 的唯一入口)
view → { coordinator(无 Qt) , asset_source(线程) , gl_backend(惰性 GL) , camera(无 Qt) , widget }
coordinator → composer(共享) → contracts(共享)
```
## 2. 运行时与线程视图
```mermaid
graph LR
subgraph GUI["GUI 线程(Qt 事件循环)"]
W["StudioMainWindow"]
T1["运行定时器 16 ms<br/>_on_run_tick"]
T2["回放定时器 33 ms<br/>_on_play_tick"]
T3["预览定时器"]
PANELS["面板:2D / 3D / 曲线 / 标定"]
end
subgraph ACQ["采集线程(每会话一条)"]
FW["FingerAcquisitionWorker / BoxWorker"]
PKT["SdkPacket"]
end
subgraph RECW["记录写线程"]
EXP["ExperimentRecorder<br/>视频 + forces.csv + session.json"]
end
subgraph S3DW["三维合成线程(1 条)"]
CMP["SceneComposer.compose"]
end
subgraph S3DA["场景加载线程(1 条)"]
LOAD["SceneAssetLoader<br/>读包 + 验签 + 设备几何校验"]
end
FW --> PKT
PKT -->|记录 sink| EXP
PKT -->|UI sink<br/>submit_packet| W
W -->|ImageFrame→image_backend| PANELS
W -->|"submit(容量 1 mailbox,尝试不阻塞)"| CMP
CMP -->|结果在 GUI 线程校验 epoch| W
LOAD -->|loaded 信号| W
T1 --> W
T2 --> W
T3 --> W
W --> PANELS
```
关键性质(都有测试钉住):
- **采集线程永不等待显示**:`submit` 只做身份/形状的廉价校验,容量 1 的 mailbox 由后来的帧替换前者;
生产者还会在"已有一帧在等"时干脆不做那次 O(H×W) 的拷贝(`skipped_producer`)。
- **合成线程与加载线程各只有 1 条**,且同时最多一次合成在飞行;结果必须通过 `epoch` + 身份(source /
session / generation / request_id)校验才会被 GUI 线程接受。
- **记录通道与显示通道共用同一个 packet,但计数完全分开**:显示丢帧(`superseded`)永不进记录。
## 3. 二维 / 三维显示子系统(最近两轮重构后的样子)
```mermaid
graph TB
subgraph SRC["生产者(4 条)"]
LIVE["实时 packet.processed_frame + flow + depth"]
RECOMP["重算:同一采集链跑在录像上"]
REPLAY["回放:ReplayEngine.video_frame_bgr() 原始帧"]
PREV["预览:摄像头原始帧"]
end
IB["image_backend.py(共享)<br/>ImageFrame → plan_image() → render_image()"]
PANEL2D["ui/arrow_panel<br/>模式集/注解来自 plan"]
AP["packet → adapters → coordinator(mailbox+epoch)→ SceneComposer(线程)"]
VIEW3D["scene3d/view<br/>describe() / render_totals() / capabilities()"]
PANEL3D["scene3d/panel(参数+图例+色条)"]
GLW["scene3d/widget → gl_backend(GL 惰性)"]
LIVE --> IB
RECOMP --> IB
REPLAY --> IB
PREV --> IB
IB --> PANEL2D
LIVE --> AP
RECOMP --> AP
AP --> VIEW3D
VIEW3D --> GLW
VIEW3D --> PANEL3D
```
两条显示路径**互不干扰**:2D 是一条纯数据流水线(`ImageFrame → 一个渲染函数 → 一个 QImage`),
3D 是"契约 + 合成线程 + GL"的另一条。它们的共同上游只是同一个 `SdkPacket`。
## 4. 边界规则与证据
| 规则 | 是否成立 | 证据 |
|---|---|---|
| 模块级无循环依赖 | ✅ | 扫描 **94 个模块**(studio 37 + shared 57)的 import:**环 0 个** |
| 共享层不依赖 Qt | ✅(有一条惰性例外) | `import` 时拉进 PySide6 的模块**全部**在 `orisys_studio.*`;`shared/theme/qss.py` 用 PySide6 **画图标时**才 import(函数内 + try 保护)。两个 app 的 `test_studio_binding_purity.py`/`test_binding_purity.py` 现在静态强制"只允许 PySide6"(在此之前 `qss.py` 的 import 因为写的是 PyQt5 而静默失败,AutoCal 的箭头图标一直没有画出来) |
| OpenGL 从不在 import 时拉入 | ✅ | 唯一使用者 `scene3d/gl_backend.py` 是函数内惰性 import;`widget.py` 同理 |
| 三维调度与相机无 Qt,可脱离 GUI 测试 | ✅ | 新解释器里 `import orisys_studio.scene3d.coordinator` / `.camera` → 不含 PySide6/OpenGL/SDK |
| 采集侧可在无 GUI 环境导入 | ✅ | `acquisition.arrows` / `.sinks`、`record.playback`、`scene3d.contracts`、`scene3d.composer` 全部"干净" |
| 共享层与 SDK 解耦 | ⚠️ **不成立** | `orisys_app_shared.force.calibration_io` 在 import 时 `import orisys`;于是 `import orisys_app_shared.force.history` 也会把 SDK 拉进来 |
| 三维是单向流动 | ✅ | `window → mesh3d_panel → view → {coordinator, composer, widget, gl_backend, camera}`;`coordinator` 不知道 Qt/GL/IO |
| 契约集中在一处 | ✅ | `orisys_app_shared.scene3d.contracts`:1185 行、**fan-in 22**(全仓最高),fan-out 0(只依赖 numpy) |
## 5. 指标
**改动最贵的地方(行数 × 扇出)**
| 模块 | 行数 | fan-out | 说明 |
|---|---:|---:|---|
| `orisys_studio.app_window` | 2309 | **37** | 158 个方法,其中约 40 个是给测试/`ui_check` 的一层转发;装配 + 信号转发 |
| `orisys_studio.ui.force_plots` | 1242 | 8 | 曲线、量程、光标、概览、单位徽章都在一个类里 |
| `orisys_studio.scene3d.gl_backend` | 870 | 2 | 着色器、打包、纹理、统计;被 4 个模块依赖 |
| `orisys_studio.ui.cal_panel` | 792 | 7 | 标定表 + 采样 + 拟合入口 |
| `orisys_studio.scene3d.view` | 757 | 8 | 3D 的唯一入口(刻意的) |
| `orisys_studio.replay_controller` | 641 | 5 | 回放策略(本轮从 app_window 拆出) |
| `orisys_studio.scene3d.coordinator` | 619 | 3 | 三维调度(**无 Qt**) |
| `orisys_studio.session_controller` | 437 | 3 | 会话/相位/遥测(本轮从 app_window 拆出) |
`app_window` 的 **fan-out 仍然最高(37)**,因为它本来就是装配器:它 import 的模块多,
但**规则**已经不在它里面——单次功能改动落在 `session_controller`(会话)、
`replay_controller`(回放)、`view_layout`(布局)、`display_prefs`(显示偏好)或
`calibration_service`(标定)之一,而不是"改哪都要动同一个 2300 行文件"。
**稳定核心(被依赖最多)**:`scene3d.contracts`(22) > `i18n`(16) > `paths`(11) = `theme`(11) >
`record.sample`(8) > `acquisition.models`(5) = `force.filters`(5) = `force.session`(5) = `scene3d.digest`(5)…
**验证面**:共享库测试 22 文件 / 7226 行 / 334 项;应用测试 15 文件 / 8413 行 / 310 项;工具
`ui_check.py` 3401 行(587 项 UI 断言)、`bench_scene.py`、`soak_scene.py`、`validate_scene.py`、
`ui_snapshot.py`、`probe_gl.py`。
**生产面 vs 验证面**(同一口径:含空行):
| | 行数 | 其中三维 |
|---|---:|---:|
| `apps/studio/src`(生产,37 模块) | 14 655 | — |
| `shared`(生产,57 模块,无 Qt) | 14 764 | 4 565 |
| 测试(shared 7226 + studio 8413) | 15 639 | — |
| 工具(ui_check 3401 + scene_assets 工具 645) | 4 046 | — |
即**验证面(19.7k 行)比 studio 的生产代码(14.7k 行)还大**。共享层里记录 3008、力 3057、采集 2627 行,
三维 4565 行——三维不是附属功能,它和"记录/力"是同一量级的子系统。
## 6. 评估:合理与不合理的地方
### 已经合理、值得保住的
1. **依赖无环 + 分层单向**(扫描证据)。这不是小事:有环的项目才会出现"改 A 必须改 B 再改回来"的
连锁;这里没有。新增模块时只要不反向 import 就不会破坏。
2. **共享层与 UI 的物理隔离**:`shared` 里 `import` 时不拉 Qt/GL,采集线程、CI、离线工具都能在无 GUI
环境跑;`contracts` 甚至只依赖 numpy。
3. **三维子系统的接口是显式的**:`describe()`(我是什么)/`render_totals()`(显示了什么)/
`capabilities()`(数据源能给什么)/`set_plan()`(这一帧能画什么)。窗口只调这四个,不碰渲染细节——
这几轮重构主要就是在把隐式耦合换成这四条缝。
4. **降级路径是设计出来的,不是碰巧**:无 GL / 无资产 / 几何不符 / 字段失效 / 场景被平台锁拒绝,
每一种都只降级三维或某个面板,2D 与记录不受影响(有专门测试)。
5. **危险的知识点集中在有测试的地方**:单位与坐标系(`legacy.py` + Procrustes 残差)、包格式与签名
(`package.py`/`protect.py`)、采样无效语义(`sampling.py`)——这些最容易静默出错的部分都有
输入样例 + 失败样例。
### 不合理、会随时间变贵的地方(按严重程度)
1. **`app_window.py` 曾经是上帝对象(已拆)**:拆之前 3044 行 / 158 方法 / fan-out 36,
四个角色(视图装配、会话与 worker 生命周期、业务决策、持久化与诊断)都在一个文件里。
现在它 2309 行 / fan-out 37,规则分到四个控制器 + 标定服务:
`session_controller`(谁在跑、哪一相位、报了什么数)、`replay_controller`(回放策略与重算)、
`view_layout`(哪些面板、什么布局、哪个视图)、`display_prefs`(单位/量程/箭头/fps)、
`calibration_service`(标定文件与"这份标定是谁的")。
**仍然留在窗口里的**:装配与信号转发、菜单/对话框、状态栏与轨道、
预览(`_start_preview`/`_stop_preview`/`_on_preview_tick`)与 2D 渲染
(`_show_image_frame`/`_render_run_image`/`_depth_range`)——它们同时属于面板与相机,
目前没有第二个变更驱动,硬拆只会把耦合搬家。
**代价与风险(诚实记账)**:为了让 tests 与 `tools/ui_check`(它们通过窗口驱动 UI)
保持全绿,窗口保留了一层转发(约 40 个成员)。它们是**过渡层**,不是最终形态:
下一次改动落在某个控制器时,应顺手把对应的转发与测试调用点改成 `self.<controller>.*`。
另外窗口的 fan-out 反而从 36 升到 37(它多 import 了四个控制器)——这正是"装配器"
应有的样子:它知道所有人,但不再替所有人做决定。
2. **三个"panel"名字打架**:`ui/arrow_panel`(2D 面板)、`ui/mesh3d_panel`(3D 宿主容器)、
`scene3d/panel`(3D 参数控件)。新人第一次找"面板加个开关"要在三个文件里试。同类问题还有
`scene3d/replay.py`(studio 回放引擎) 与 `shared/record/playback.py`(录像读取)。
3. **两套 `scene3d` 包**:`orisys_app_shared.scene3d`(契约/采样/合成/包,无 Qt)与
`orisys_studio.scene3d`(coordinator/view/panel/widget/GL)。边界理由是"Qt/GL 不进共享层",
但 `coordinator` 其实无 Qt(已实测),放在 studio 只是因为"它服务 GUI 计时器"——这条理由不硬。
4. **共享层 `force` 硬依赖 SDK**:`calibration_io` 在 import 时 `import orisys`,于是"共享层可以脱离
SDK 做单元测试"这条**只对三维成立,对力不成立**(力相关的测试必须装 SDK)。这也解释了为什么
离线工具与力曲线测试总是整套跑。
5. **`ui_check.py` 3401 行单文件**:它是产品 UI 的"第二份规格"(587 项断言),但没有分层,改 UI 就改它,
且它引用了不少产品私有属性/组件名。它同时是**最有价值**的回归门和**最脆**的资产。
6. **测试对私有成员的依赖**:`tests` 里直接读 `view._camera`、`widget._renderer`、`coordinator._inflight`、
`loader._pending`、`panel._descriptor` 等。接口一改就要改测试(这几轮已经通过
`attach_view` / `describe` / `render_totals` / `_snapshot_diagnostic` 之类的公开缝减少了一批)。
7. **验证面 > 生产面**:19.7k 行测试/工具 vs 14.7k 行 studio 生产代码。好处是回归门硬;代价是每次改接口
的成本被放大,尤其是 `ui_check` 里的字符串/布局断言(拆 `app_window` 时为保住这套门而留的转发,就是这个代价)。
### 历史评估结论
**组织是合理的、容易维护的**(无环、分层单向、共享层无 Qt、危险知识集中在有测试的地方)。
原来唯一的明确单点 `app_window.py` 已按职责拆成
`session_controller` / `replay_controller` / `view_layout` / `display_prefs` + 标定服务,
窗口退回装配与转发;每个新模块都有自己的公开接口单测(会话元数据 15 + 四个控制器 37 +
标定服务 4 = 56 项),且在不需要窗口的情况下可测。
这是旧扫描时的评估结论;当前仍有窗口职责待迁移,实施顺序见下节链接。
## 后续迁移
当前工作由 [分步重构计划](studio-window-refactor-plan.zh.md) 的 S2–S6 管理,
包括界面构建、标定交互、分析配置、运行与预览以及显示入口和兼容桥收尾。
每一步完成后更新本节之前的当前职责;历史指标不随搬运代码自动改写。
---
_以上历史快照与图由源码扫描生成;扫描脚本 `.repro/studio_arch_scan.py`、`.repro/studio_arch_imports.py`
与图表脚本 `.repro/make_studio_svg.py` → `.repro/svg_to_png.py`、原始数据 `.repro/studio_arch.json`、
`.repro/studio_arch_imports.json` **均已在 2026-09-27 的工作区整理中删除**,未迁移。保留下来的是
`.repro/` 之外的本文与图本身(`studio-architecture.svg`、`studio-architecture.png`)。
数字口径:行数含空行,`fan-out` = 该模块 import 了多少个模块。_
_生成时间:拆分 `app_window`(A1/A2 + B + C1 + C2 + 标定收尾 + 清零确认修复)之后。_