Files
orisys-app/docs/architecture/studio-architecture.md
T
ykcheungab 8d374e5850 refactor(studio): 回放引擎改名 ReplayEngine —— 把「引擎」与「窗口级控制器」的名字分开
- What: `apps/studio/src/orisys_studio/replay.py` 的 `ReplayController` 改名为 `ReplayEngine`
  (引用只有 4 处:`app_window.py` 的 import 与构造、`ui_check.py` 的一处 import、模块内定义);
  模块 docstring 写明它为什么叫 engine(只回答"t 时刻是什么",不知道传输策略、不知道控件),
  并指路即将落地的窗口级 `ReplayController`;`app_window.replay` 这个属性名**不动**(36 处使用)。
  同步更新两份既有文档里的类名(`apps/studio/docs/shell-lifecycle.md`、`record-channel.md`、
  `docs/architecture/studio-architecture.md` 的框图标签)。
- Why: 用户已同意分拆 `app_window.py`,并选定"引擎改名 ReplayEngine + 新类叫 ReplayController"
  这一方案(另一方案是让两个名字 `ReplayController`/`ReplayCoordinator` 并存,容易进错门)。
  这一步是后续把窗口里那 30 个回放方法搬进 `ReplayController` 的前提:先把名字腾出来,
  否则搬迁时无法区分"引擎"与"控制器"。
- Verification: shared+apps 同一次运行 **588 项 0 失败 15 跳过**;`ui_check` 587 项 0 失败;
  全仓 `grep ReplayController` 只剩两处有意保留的提及(`replay.py` docstring 里指路、
  `app_window.py` 注释里指路),没有悬空引用。
- Not in this change: 不搬运任何方法、不改行为——`app_window.py` 仍是 3043 行;`SessionController`、
  `ReplayController`、`ViewLayoutController`、`DisplayPrefsController` 四个模块的搬迁尚未开始
  (顺序与契约见 docs/architecture/studio-architecture.md 建议 1)。
2026-09-15 08:41:44 +08:00

16 KiB
Raw Blame History

Studio 软件架构(测量版)

本文不是"理想架构",而是当前代码的真实依赖结构:所有框图、数字与结论都由下面的脚本从源码里扫出来, 每条判断后面都跟着它的证据。配套的矢量图是 studio-architecture.svg(浏览器直接打开)。

生成方式(可复现,改完代码重跑即可):

$env:PYTHONPATH = 'D:\orisys_ws\orisys-app\shared\src;D:\orisys_ws\orisys-app\apps\studio\src'
& $py D:\orisys_ws\.repro\studio_arch_scan.py       # 模块清单、包级依赖、环、Qt/GL/SDK 归属
& $py D:\orisys_ws\.repro\studio_arch_imports.py    # 哪些模块在 import 时就拉进 Qt/GL/SDK,哪些是惰性

统计口径:行数含空行;fan-in = 有多少模块 import 它;fan-out = 它 import 了多少模块(同一模块多次 import 只算一条边)。


1. 分层视图(真实依赖方向)

graph TB
    subgraph L0["① 入口"]
        MAIN["main.py<br/>110 行 · QApplication + 主题 + 首选项"]
    end
    subgraph L1["② 窗口外壳(只有它能看见全部 UI)"]
        WIN["app_window.py · StudioMainWindow<br/>3044 行 · 158 个方法 · fan-out 36"]
        BAR["brand_bar · toolbar · record_status"]
    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/>记录协调 · 268 行"]
        REP["replay.py<br/>回放引擎 · 286 行(QObject)"]
        CALSVC["calibration_service.py<br/>131 行"]
        COORD["scene3d/coordinator.py<br/>560 行 · **无 Qt**"]
        CAM["scene3d/camera.py<br/>210 行 · **无 Qt**"]
        VIEW["scene3d/view.py<br/>758 行 · 3D 唯一入口"]
        ASSET["scene3d/asset_source.py<br/>257 行 · 场景加载线程"]
        GLB["scene3d/gl_backend.py<br/>871 行 · **GL 只在运行时惰性 import**"]
    end
    subgraph L4["⑤ 共享领域层(采集/记录/力/三维契约,无 Qt 依赖)"]
        AQ["acquisition<br/>2343 行 · worker / packet / sinks / 图像后端"]
        RC["record<br/>2673 行 · 录像读写 / CSV / session.json"]
        FORCE["force<br/>2645 行 · 力曲线源 / 标定 / 滤波"]
        S3D["scene3d<br/>4057 行 · 契约 / 采样 / 合成 / 包格式 / 签名"]
        MISC["camera · prefs · paths · i18n · theme · logging"]
    end
    MAIN --> WIN
    WIN --> BAR
    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
                      ├─ 面板: 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. 边界规则与证据

规则 是否成立 证据
模块级无循环依赖 ✅ 扫描 89 个模块(studio 32 + shared 57)的 import:环 0 个
共享层不依赖 Qt ✅(有一条惰性例外) import 时拉进 PyQt5 的 21 个模块全部在 orisys_studio.*;shared/theme/qss.py 用 PyQt5 画图标时才 import(函数内 + try 保护)
OpenGL 从不在 import 时拉入 ✅ 唯一使用者 scene3d/gl_backend.py 是函数内惰性 import;widget.py 同理
三维调度与相机无 Qt,可脱离 GUI 测试 ✅ 新解释器里 import orisys_studio.scene3d.coordinator / .camera → 不含 PyQt5/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 3044 36 158 个方法,其中 50 个 _on_* 处理器;唯一 fan-in=1 的中心
orisys_studio.ui.force_plots 1243 8 曲线、量程、光标、概览、单位徽章都在一个类里
orisys_studio.scene3d.gl_backend 871 2 着色器、打包、纹理、统计;被 4 个模块依赖
orisys_studio.ui.cal_panel 793 7 标定表 + 采样 + 拟合入口
orisys_studio.scene3d.view 758 8 3D 的唯一入口(刻意的)

稳定核心(被依赖最多):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 文件 / 5992 行;应用测试 10 文件 / 5641 行;工具 ui_check.py 3168 行、 bench_scene.py 608、soak_scene.py 503、validate_scene.py 302、ui_snapshot.py 292、probe_gl.py 192。

生产面 vs 验证面(同一口径:含空行):

行数 其中三维
apps/studio/src(生产) 13 376 4 326
shared(生产,无 Qt) 14 820 4 576
测试(shared 5992 + studio 5641) 11 633 —
工具(ui_check 3168 + scene_assets 工具 2104) 5 272 —

即验证面(16.9k 行)比 studio 的生产代码(13.4k 行)还大。共享层里记录 2673、力 2645、采集 2343 行, 三维 4576 行——三维不是附属功能,它和"记录/力"是同一量级的子系统。

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 生命周期控制器 ③业务决策(单位回退、量程、重算/回放互斥、面板尺寸) ④持久化与诊断聚合。后果:任何功能改动都要动它(本次两轮修 bug 里,_load_replay_video、 _sync_replay_actions、_update_run_enabled、_render_* 全在同一个文件里互相影响); 它是唯一 fan-in=1 的中心,也就是说没有第二个人能独立改它的任一部分而不碰冲突热点。
  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 3168 行单文件:它是产品 UI 的"第二份规格"(587 项断言),但没有分层,改 UI 就改它, 且它引用了不少产品私有属性/组件名。它同时是最有价值的回归门和最脆的资产。
  6. 测试对私有成员的依赖:tests 里直接读 view._camera、widget._renderer、coordinator._inflight、 loader._pending、panel._descriptor 等。接口一改就要改测试(这几轮已经通过 attach_view / describe / render_totals / _snapshot_diagnostic 之类的公开缝减少了一批)。
  7. 验证面 > 生产面:16k 行测试/工具 vs 12.9k 行 studio 生产代码。好处是回归门硬;代价是每次改接口 的成本被放大,尤其是 ui_check 里的字符串/布局断言。

一句话结论

组织是合理的、容易维护的(无环、分层单向、共享层无 Qt、危险知识集中在有测试的地方), 但有一个明确的单点:app_window.py。它现在是"能维护"和"改一处要被它绊住"之间的分界线; 再往上加功能(下一个面板、下一种传感器、下一个回放能力)之前,把它拆开是性价比最高的一件事。

7. 建议(按性价比排序)

# 建议 收益 成本/风险 回归门
1 拆 app_window 为三个控制器:SessionController(worker 生命周期 + sinks + 记录)、RunViewPresenter(面板可见性/尺寸/单位/量程)、ReplayCoordinator(把窗口里回放相关逻辑并进已有的 replay.py),窗口只剩装配 + 信号转发 改动热点从 1 个 3044 行文件变成 4 个 400–700 行文件;并行改动不再互相踩 中(纯搬运,不改行为);风险是搬运时把某个"隐式顺序依赖"弄丢 现有 test_ui_smoke/test_scene3d_view/ui_check 全绿;git diff 只应有移动与转发
2 给共享层加"依赖规则测试":AST 扫描断言 shared 不得 eager import PyQt5/OpenGL(现状成立),以及 studio.scene3d 不得 import acquisition;把 force → orisys 的现状登记为"已知例外"或改为惰性 把"现在对"变成"以后不会错";顺手暴露第 4 条风险 小(一个测试文件,用现成扫描脚本改) 新测试 + 现有套件
3 消歧命名:scene3d/panel.py → scene3d/controls.py;ui/mesh3d_panel.py → ui/scene3d_host.py;scene3d/replay 与 record/playback 在 docstring 里互相指路 新人少走 3 次错门 小(改名 + import 更新),但会触及 ui_check 的引用 全套 + ui_check
4 把 force → SDK 改为惰性(calibration_io 里改成函数内 import,或把 SDK 相关函数拆出去) 力曲线的纯计算(history/filters/scale)可在无 SDK 环境测试 中(要区分哪些函数真的需要 SDK) 现有力测试 + 无 SDK 的 import 检查
5 ui_check 拆模块(按场景拆 tools/ui_checks/*.py,保留单一入口与退出码) 最脆的资产变成可分工、可 diff 的几块 中(3168 行搬运,语义不变) 检查项计数不变(587 项 0 失败)
6 测试私有访问清单化:把所有 obj._x 访问集中到 tests/support/ 一层小适配器 内部重构不再一次性打碎几十个测试 小-中 全套

不建议做的事:不要为了"看起来分层漂亮"把 scene3d 从 studio 搬进 shared(第 3 条第 5 项理由不硬, 但现在搬动没有收益,反而打断三维子系统已经稳定的接口);也不要为了统一而重写 ui_check。


本文与图由源码扫描生成;扫描脚本在 .repro/studio_arch_scan.py、.repro/studio_arch_imports.py, 原始数据在 .repro/studio_arch.json、.repro/studio_arch_imports.json。