让状态显示独立于主窗口,保留录制快照注入入口。验证结果和完整门禁限制见 docs/architecture/studio-window-refactor-plan.zh.md。
20 KiB
Studio 软件架构
本文的当前职责说明对应 2026-10-18 主窗口重构 S1;后续工作和验收由 分步重构计划 维护。下方旧扫描图表保留为历史证据, 不能用其中的行数或“已完成”判断当前代码。
当前职责与数据流
app_window.py::StudioMainWindow 创建控件和控制器并连接信号;本次从 3,181 行减至
3,052 行。它仍承担菜单、预览、标定交互、分析配置和运行协调,尚未完成薄窗口目标。
status_presenter.py::StatusPresenter 只持有工具栏,不持有窗口或会话控制器。
窗口在 GUI 线程收集一次性的 RailState,Presenter 负责 phase、来源、标定、有效性及
录制统计的文字和控件更新。快照不缓存、不反写状态;主题和语言刷新沿用原入口。
分析结果的传感器身份来自结果 provenance,进度读取现有 AnalysisProgress.state(),
不再通过 ReplayController._progress 访问其私有控件。
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 二维与共享深度范围]
会话状态仍属于 SessionController,录制状态仍属于 RecordingCoordinator。
_record_stats 保留为 UI 检查和截图的注入入口,实际状态选择及显示已委托 Presenter。
关闭中的录制继续读取实时统计,完成后读取最终报告;丢失计数包括未能记下的 gap。
设备确认与录制仍走采集线程通道;本次未改变定时器、停止等待或 3D 懒加载。
验证入口:test_status_presenter.py 可在没有主窗口和设备的情况下检查状态显示;
test_ui_smoke.py、test_replay_wiring.py 和 tools/ui_check.py 覆盖窗口集成。
执行结果、环境及尚未验证范围记录在实施计划。状态转发暂留用于现有调用方,后续按职责迁移,
不增加新的全局状态容器或将剩余逻辑全部并入回放控制器。
历史测量的适用范围
以下第 1–6 节及配套 studio-architecture.svg、studio-architecture.png 是旧扫描快照。
原扫描的对应 revision 与完整运行输入不可用,生成脚本已于 2026-09-27 清理,无法原样复跑。
数字口径为含空行行数;fan-in/out 为模块导入关系。旧测试计数、无环结论和完成状态均未在本次
重新全量测量,不是当前保证;后续重构顺序以实施计划为准。
1. 分层视图(真实依赖方向)
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. 运行时与线程视图
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. 二维 / 三维显示子系统(最近两轮重构后的样子)
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. 评估:合理与不合理的地方
已经合理、值得保住的
- 依赖无环 + 分层单向(扫描证据)。这不是小事:有环的项目才会出现"改 A 必须改 B 再改回来"的 连锁;这里没有。新增模块时只要不反向 import 就不会破坏。
- 共享层与 UI 的物理隔离:
shared里import时不拉 Qt/GL,采集线程、CI、离线工具都能在无 GUI 环境跑;contracts甚至只依赖 numpy。 - 三维子系统的接口是显式的:
describe()(我是什么)/render_totals()(显示了什么)/capabilities()(数据源能给什么)/set_plan()(这一帧能画什么)。窗口只调这四个,不碰渲染细节—— 这几轮重构主要就是在把隐式耦合换成这四条缝。 - 降级路径是设计出来的,不是碰巧:无 GL / 无资产 / 几何不符 / 字段失效 / 场景被平台锁拒绝, 每一种都只降级三维或某个面板,2D 与记录不受影响(有专门测试)。
- 危险的知识点集中在有测试的地方:单位与坐标系(
legacy.py+ Procrustes 残差)、包格式与签名 (package.py/protect.py)、采样无效语义(sampling.py)——这些最容易静默出错的部分都有 输入样例 + 失败样例。
不合理、会随时间变贵的地方(按严重程度)
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 了四个控制器)——这正是"装配器" 应有的样子:它知道所有人,但不再替所有人做决定。- 三个"panel"名字打架:
ui/arrow_panel(2D 面板)、ui/mesh3d_panel(3D 宿主容器)、scene3d/panel(3D 参数控件)。新人第一次找"面板加个开关"要在三个文件里试。同类问题还有scene3d/replay.py(studio 回放引擎) 与shared/record/playback.py(录像读取)。 - 两套
scene3d包:orisys_app_shared.scene3d(契约/采样/合成/包,无 Qt)与orisys_studio.scene3d(coordinator/view/panel/widget/GL)。边界理由是"Qt/GL 不进共享层", 但coordinator其实无 Qt(已实测),放在 studio 只是因为"它服务 GUI 计时器"——这条理由不硬。 - 共享层
force硬依赖 SDK:calibration_io在 import 时import orisys,于是"共享层可以脱离 SDK 做单元测试"这条只对三维成立,对力不成立(力相关的测试必须装 SDK)。这也解释了为什么 离线工具与力曲线测试总是整套跑。 ui_check.py3401 行单文件:它是产品 UI 的"第二份规格"(587 项断言),但没有分层,改 UI 就改它, 且它引用了不少产品私有属性/组件名。它同时是最有价值的回归门和最脆的资产。- 测试对私有成员的依赖:
tests里直接读view._camera、widget._renderer、coordinator._inflight、loader._pending、panel._descriptor等。接口一改就要改测试(这几轮已经通过attach_view/describe/render_totals/_snapshot_diagnostic之类的公开缝减少了一批)。 - 验证面 > 生产面: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 项),且在不需要窗口的情况下可测。
这是旧扫描时的评估结论;当前仍有窗口职责待迁移,实施顺序见下节链接。
后续迁移
当前工作由 分步重构计划 的 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 + 标定收尾 + 清零确认修复)之后。