路由壳升级:TaskWorker 三信号 + main_router→main_view 迁移(54/54 smoke 通过)

This commit is contained in:
DXC
2026-06-16 18:23:38 +08:00
parent bd4263d2ca
commit 61bd8582e5
8 changed files with 86 additions and 55 deletions

View File

@ -16,7 +16,7 @@ src/new/
│ ├── __init__.py │ ├── __init__.py
│ ├── step1_view.py # Step 1 真实视图(继承 BaseView │ ├── step1_view.py # Step 1 真实视图(继承 BaseView
│ └── placeholder_view.py # step2-step13 占位视图 │ └── placeholder_view.py # step2-step13 占位视图
└── main_router.py # 路由与调度壳QMainWindow + QThread └── main_view.py # 路由与调度壳QMainWindow + QThread
``` ```
## 端到端调用链 ## 端到端调用链
@ -28,13 +28,13 @@ Step1View._on_run_clicked (绿色按钮)
BaseView.dispatch_execute (沿父链上溯) BaseView.dispatch_execute (沿父链上溯)
│ ancestor.run_single_step(step_id, config) │ ancestor.run_single_step(step_id, config)
MainRouter.run_single_step (查 ROUTES 表 → 注入 work_dir) MainView.run_single_step (查 ROUTES 表 → 注入 work_dir)
│ TaskWorker(service_func, config).start() │ TaskWorker(service_func, config).start()
services.step1_service.execute_step1(config) services.step1_service.execute_step1(config)
│ 调 WaterMaskStep.run(...) → 包装成 dict 返回 │ 调 WaterMaskStep.run(...) → 包装成 dict 返回
MainRouter._on_step_done (按 status 写日志) MainView._on_step_done (按 status 写日志)
``` ```
## 运行验证 ## 运行验证
@ -52,13 +52,13 @@ python _smoke_new_arch.py
```cmd ```cmd
cd D:\111\office\ZHLduijie\1.WQ\WQ_GUI cd D:\111\office\ZHLduijie\1.WQ\WQ_GUI
python -m src.new.main_router python -m src.new.main_view
``` ```
或: 或:
```cmd ```cmd
python src\new\main_router.py python src\new\main_view.py
``` ```
启动后: 启动后:

View File

@ -3,8 +3,8 @@
三层冒烟端到端模块化新架构src/new/ 三层冒烟端到端模块化新架构src/new/
Layer 1 service 单元execute_step1 在各种错误路径返回正确 dict Layer 1 service 单元execute_step1 在各种错误路径返回正确 dict
Layer 2 view offscreenStep1View UI 行为符合契约(不依赖 main_router Layer 2 view offscreenStep1View UI 行为符合契约(不依赖 main_view
Layer 3 end-to-endMainRouter 创建 → 切到 step1 → 点击 → 日志验证 Layer 3 end-to-endMainView 创建 → 切到 step1 → 点击 → 日志验证
运行方式(项目根):: 运行方式(项目根)::
@ -247,17 +247,17 @@ def smoke_view():
# ========================================================================= # =========================================================================
def smoke_e2e(): def smoke_e2e():
print("\n" + "=" * 70) print("\n" + "=" * 70)
print("Layer 3 —— end-to-endMainRouter 路由 + TaskWorker 调度)") print("Layer 3 —— end-to-endMainView 路由 + TaskWorker 调度)")
print("=" * 70) print("=" * 70)
from PyQt5.QtCore import QEventLoop, QTimer from PyQt5.QtCore import QEventLoop, QTimer
from PyQt5.QtWidgets import QApplication from PyQt5.QtWidgets import QApplication
app = QApplication.instance() or QApplication(sys.argv) app = QApplication.instance() or QApplication(sys.argv)
from src.new.main_router import MainRouter, ROUTES from src.new.main_view import MainView, ROUTES
win = MainRouter() win = MainView()
report("L3", "MainRouter 实例化成功", win is not None) report("L3", "MainView 实例化成功", win is not None)
report("L3", "ROUTES 共 13 条", len(ROUTES) == 13, f"实际={len(ROUTES)}") report("L3", "ROUTES 共 13 条", len(ROUTES) == 13, f"实际={len(ROUTES)}")
report("L3", "nav_list 项数 == 13", win.nav_list.count() == 13, report("L3", "nav_list 项数 == 13", win.nav_list.count() == 13,
f"count={win.nav_list.count()}") f"count={win.nav_list.count()}")

View File

@ -8,12 +8,12 @@ WQ_GUI 端到端模块化新架构
* ``core/`` 基础通讯接口BaseView 等契约) * ``core/`` 基础通讯接口BaseView 等契约)
* ``views/`` 前端皮囊 —— 仅 PyQt UI零业务 * ``views/`` 前端皮囊 —— 仅 PyQt UI零业务
* ``services/`` 后端大脑 —— 纯函数计算,零 PyQt * ``services/`` 后端大脑 —— 纯函数计算,零 PyQt
* ``main_router.py`` 路由与调度壳,连接前端按钮与后端服务 * ``main_view.py`` 路由与调度壳,连接前端按钮与后端服务
调用链: 调用链:
Step1View._on_run_clicked Step1View._on_run_clicked
→ BaseView.dispatch_execute(step_id, config) → BaseView.dispatch_execute(step_id, config)
→ MainRouter.run_single_step(step_id, config) → MainView.run_single_step(step_id, config)
→ TaskWorker 线程调用 services.step1_service.execute_step1(config) → TaskWorker 线程调用 services.step1_service.execute_step1(config)
→ 返回 dict 结果 → 日志区 → 返回 dict 结果 → 日志区
""" """

View File

@ -1,35 +1,40 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
""" """
MainRouter 端到端模块化新架构的路由与调度壳 MainView 端到端模块化新架构的路由与调度壳主窗口
================================================= =========================================================
职责 职责
---- ----
1. **路由**根据 ``ROUTES`` 表动态加载每个 step view前端皮囊 1. **路由**根据 ``ROUTES`` 表动态加载每个 step view前端皮囊
service后端大脑左侧 ``QListWidget`` 导航右侧 service后端大脑左侧 ``QListWidget`` 导航右侧
``QStackedWidget`` 承载 view ``QStackedWidget`` 承载 view
2. **解耦通讯**view 不直接持有 main_router 引用按钮点击通过 2. **解耦通讯**view 不直接持有 main_view 引用按钮点击通过
``BaseView.dispatch_execute`` 沿父链回到本类 ``run_single_step`` ``BaseView.dispatch_execute`` 沿父链回到本类 ``run_single_step``
3. **后台执行**内置 ``TaskWorker(QThread)`` service 调用丢进 3. **后台执行**内置 ``TaskWorker(QThread)``三信号协议
子线程避免阻塞 UI ``log_msg / finished / error`` service 调用丢进子线程
避免阻塞 UI
4. **日志** :底部 ``QTextEdit`` 集中输出路由与 service 结果 4. **日志** :底部 ``QTextEdit`` 集中输出路由与 service 结果
本文件由原 ``src/new/main_router.py`` 迁移而来类名 ``MainRouter`` 改名为
``MainView`` 以贴合"视图壳"语义同时按用户原意把 ``TaskWorker`` 从单信号
``finished_with_result``升级为三信号协议
运行方式 运行方式
-------- --------
:: ::
# 方式 A作为模块运行推荐 # 方式 A作为模块运行推荐
cd D:/111/office/ZHLduijie/1.WQ/WQ_GUI cd D:/111/office/ZHLduijie/1.WQ/WQ_GUI
python -m src.new.main_router python -m src.new.main_view
# 方式 B直接脚本运行 # 方式 B直接脚本运行
python src/new/main_router.py python src/new/main_view.py
""" """
from __future__ import annotations from __future__ import annotations
import importlib import importlib
import os
import sys import sys
import traceback import traceback
from pathlib import Path from pathlib import Path
@ -41,7 +46,7 @@ from PyQt5.QtWidgets import (
QWidget, QWidget,
) )
# 让 ``python src/new/main_router.py`` 直接运行时也能 import ``src.xxx`` # 让 ``python src/new/main_view.py`` 直接运行时也能 import ``src.xxx``
_HERE = Path(__file__).resolve().parent # .../src/new _HERE = Path(__file__).resolve().parent # .../src/new
_SRC_ROOT = _HERE.parent # .../src _SRC_ROOT = _HERE.parent # .../src
if str(_SRC_ROOT) not in sys.path: if str(_SRC_ROOT) not in sys.path:
@ -80,15 +85,27 @@ ROUTES = [
# ========================================================================= # =========================================================================
# TaskWorkerQThread 后台执行器 # TaskWorkerQThread 后台执行器(三信号协议)
# ========================================================================= # =========================================================================
class TaskWorker(QThread): class TaskWorker(QThread):
"""后台执行 ``service_func(config)`` 并把结果 emit 回主线程 """后台执行 ``service_func(config)`` 并通过三信号协议回报主线程
信号协议
--------
* ``log_msg(str)`` 启动 / 完成 / 异常等关键节点的人类可读日志
* ``finished(dict)`` service 正常返回 service 内部 try/except
转成的 error 状态 dict
* ``error(str)`` worker 自身捕获的未预期异常 traceback
finished 中的 status='error' 语义不同error 表示 worker
完全崩溃无法产出任何结果 dict
为什么用 QThread 而不是 Threadservice_func 可能涉及 rasterio / 为什么用 QThread 而不是 Threadservice_func 可能涉及 rasterio /
matplotlib 等原生扩展主线程与子线程的 Qt 事件循环隔离更稳 matplotlib 等原生扩展主线程与子线程的 Qt 事件循环隔离更稳
""" """
finished_with_result = pyqtSignal(dict)
log_msg = pyqtSignal(str)
finished = pyqtSignal(dict)
error = pyqtSignal(str)
def __init__(self, service_func, config: dict): def __init__(self, service_func, config: dict):
super().__init__() super().__init__()
@ -96,22 +113,25 @@ class TaskWorker(QThread):
self.config = config self.config = config
def run(self): def run(self):
func_name = getattr(self.service_func, "__name__", "<anonymous>")
self.log_msg.emit(f"[Worker] 启动 {func_name}")
try: try:
result = self.service_func(self.config) result = self.service_func(self.config)
except Exception as e: # noqa: BLE001 except Exception as e: # noqa: BLE001 —— worker 兜底捕获所有未预期异常
result = { tb = traceback.format_exc()
"status": "error", self.log_msg.emit(f"[Worker] 异常 {type(e).__name__}: {e}")
"output_path": None, self.error.emit(f"{type(e).__name__}: {e}\n{tb}")
"message": f"{type(e).__name__}: {e}", return
"traceback": traceback.format_exc(),
} status = result.get("status", "unknown") if isinstance(result, dict) else "unknown"
self.finished_with_result.emit(result) self.log_msg.emit(f"[Worker] 完成status={status}")
self.finished.emit(result)
# ========================================================================= # =========================================================================
# MainRouterQMainWindow 路由壳 # MainViewQMainWindow 路由壳
# ========================================================================= # =========================================================================
class MainRouter(QMainWindow): class MainView(QMainWindow):
"""新架构的轻量路由主窗口""" """新架构的轻量路由主窗口"""
def __init__(self): def __init__(self):
@ -128,7 +148,7 @@ class MainRouter(QMainWindow):
self._build_ui() self._build_ui()
self._build_routes() self._build_routes()
self._log("[Boot] MainRouter 初始化完成") self._log("[Boot] MainView 初始化完成")
self._log(f"[Boot] 已注册 {len(ROUTES)} 条路由step1 真实 / step2-step13 占位)") self._log(f"[Boot] 已注册 {len(ROUTES)} 条路由step1 真实 / step2-step13 占位)")
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@ -249,14 +269,15 @@ class MainRouter(QMainWindow):
1. 查路由表 1. 查路由表
2. 注入 ``work_dir``避免 view 重复感知 2. 注入 ``work_dir``避免 view 重复感知
3. 启动 ``TaskWorker`` 在子线程运行 service 3. 启动 ``TaskWorker`` 在子线程运行 service
4. 完成后 emit 回主线程并写日志 4. 三信号路由log_msg 日志框finished 状态分支日志
error 未预期异常写日志 + traceback
""" """
route = self._route_map.get(step_id) route = self._route_map.get(step_id)
if route is None: if route is None:
self._log(f"[Router] 未知 step_id: {step_id}") self._log(f"[Router] 未知 step_id: {step_id}")
return return
# 注入 work_dir仅当 view 未带且 main_router 已配置时) # 注入 work_dir仅当 view 未带且 main_view 已配置时)
if self.work_dir and "work_dir" not in config_dict: if self.work_dir and "work_dir" not in config_dict:
config_dict = {**config_dict, "work_dir": self.work_dir} config_dict = {**config_dict, "work_dir": self.work_dir}
@ -272,7 +293,9 @@ class MainRouter(QMainWindow):
return return
self.worker = TaskWorker(service_func, config_dict) self.worker = TaskWorker(service_func, config_dict)
self.worker.finished_with_result.connect(self._on_step_done) self.worker.log_msg.connect(self._log)
self.worker.finished.connect(self._on_step_done)
self.worker.error.connect(self._on_step_error)
self.worker.start() self.worker.start()
def _load_callable(self, module_name: str, attr_name: str): def _load_callable(self, module_name: str, attr_name: str):
@ -286,7 +309,11 @@ class MainRouter(QMainWindow):
return None return None
def _on_step_done(self, result: dict): def _on_step_done(self, result: dict):
"""TaskWorker 完成回调——按 status 分支写日志""" """TaskWorker finished 回调——按 status 分支写日志
service 内部 try/except 转换的 status='error' 也走这里
这与 error 信号worker 自身崩溃语义不同
"""
status = result.get("status", "unknown") status = result.get("status", "unknown")
message = result.get("message", "") message = result.get("message", "")
output_path = result.get("output_path") output_path = result.get("output_path")
@ -298,12 +325,16 @@ class MainRouter(QMainWindow):
self._log(f"[Service↻] {message}(已复用历史结果)") self._log(f"[Service↻] {message}(已复用历史结果)")
elif status == "not_implemented": elif status == "not_implemented":
self._log(f"[Service…] {message}") self._log(f"[Service…] {message}")
else: # error else: # errorservice 内部已转 dict
self._log(f"[Service✗] {message}mode={mode or 'n/a'}") self._log(f"[Service✗] {message}mode={mode or 'n/a'}")
tb = result.get("traceback") tb = result.get("traceback")
if tb: if tb:
self._log(tb) self._log(tb)
def _on_step_error(self, msg: str):
"""TaskWorker error 信号回调——worker 自身捕获的未预期异常"""
self._log(f"[Worker✗] 未预期异常:\n{msg}")
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# 日志:所有日志统一走这里 # 日志:所有日志统一走这里
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@ -319,7 +350,7 @@ class MainRouter(QMainWindow):
def main(): def main():
from PyQt5.QtWidgets import QApplication from PyQt5.QtWidgets import QApplication
app = QApplication(sys.argv) app = QApplication(sys.argv)
win = MainRouter() win = MainView()
win.show() win.show()
sys.exit(app.exec_()) sys.exit(app.exec_())

View File

@ -2,7 +2,7 @@
""" """
PlaceholderService —— 通用占位后端(用于尚未迁移的步骤) PlaceholderService —— 通用占位后端(用于尚未迁移的步骤)
返回 ``{"status": "not_implemented", ...}``main_router 据此在日志区 返回 ``{"status": "not_implemented", ...}``main_view 据此在日志区
输出友好提示;不抛异常、不阻塞 UI 线程。 输出友好提示;不抛异常、不阻塞 UI 线程。
""" """
@ -15,7 +15,7 @@ def execute_placeholder(config: Dict[str, Any]) -> Dict[str, Any]:
"""通用占位执行函数 """通用占位执行函数
Args: Args:
config: 由 view.get_config() 序列化、再经 main_router 注入 work_dir 的字典 config: 由 view.get_config() 序列化、再经 main_view 注入 work_dir 的字典
Returns: Returns:
标准结果字典 ``{status, output_path, message}``,其中 status="not_implemented" 标准结果字典 ``{status, output_path, message}``,其中 status="not_implemented"

View File

@ -3,14 +3,14 @@
Step1 后端计算服务(水域掩膜生成) Step1 后端计算服务(水域掩膜生成)
===================================== =====================================
这是一个**纯计算函数**——绝对不引用 PyQt、绝对不引用 main_router 这是一个**纯计算函数**——绝对不引用 PyQt、绝对不引用 main_view
绝对不读写全局变量。它只: 绝对不读写全局变量。它只:
1. 从 ``config`` 字典读取参数; 1. 从 ``config`` 字典读取参数;
2. 调用旧版 ``WaterMaskStep.run`` 执行水域掩膜生成; 2. 调用旧版 ``WaterMaskStep.run`` 执行水域掩膜生成;
3. 返回结果字典 ``{status, output_path, message}``。 3. 返回结果字典 ``{status, output_path, message}``。
调用入口(由 main_router 在后台 QThread 中调用): 调用入口(由 main_view 在后台 QThread 中调用):
execute_step1({ execute_step1({
"mask_path": "D:/xx.shp", # 现有掩膜路径NDWI 模式下可为 None "mask_path": "D:/xx.shp", # 现有掩膜路径NDWI 模式下可为 None
@ -18,7 +18,7 @@ Step1 后端计算服务(水域掩膜生成)
"ndwi_threshold": 0.4, # NDWI 阈值 "ndwi_threshold": 0.4, # NDWI 阈值
"img_path": "D:/ref.dat", # 参考影像 "img_path": "D:/ref.dat", # 参考影像
"output_path": "D:/mask.dat", # NDWI 输出路径(可选) "output_path": "D:/mask.dat", # NDWI 输出路径(可选)
"work_dir": "D:/workspace", # 工作目录main_router 注入) "work_dir": "D:/workspace", # 工作目录main_view 注入)
}) })
返回字典字段: 返回字典字段:
@ -56,7 +56,7 @@ def execute_step1(config: Dict[str, Any]) -> Dict[str, Any]:
"""Step 1 后端计算入口——纯函数 """Step 1 后端计算入口——纯函数
Args: Args:
config: 由前端 view.get_config() 序列化、再经 main_router 注入 work_dir 的字典 config: 由前端 view.get_config() 序列化、再经 main_view 注入 work_dir 的字典
Returns: Returns:
标准结果字典 ``{status, output_path, message, mode}`` 标准结果字典 ``{status, output_path, message, mode}``
@ -87,7 +87,7 @@ def execute_step1(config: Dict[str, Any]) -> Dict[str, Any]:
generate_png=True, generate_png=True,
output_path=output_path, output_path=output_path,
water_mask_dir=water_mask_dir, water_mask_dir=water_mask_dir,
callback=None, # 日志由 main_router 统一接管 callback=None, # 日志由 main_view 统一接管
) )
except FileNotFoundError as e: except FileNotFoundError as e:
return { return {

View File

@ -5,7 +5,7 @@ PlaceholderView —— 通用占位视图(用于尚未迁移的步骤)
仅显示 step 名称与"待实现"提示,仍遵循 BaseView 契约: 仅显示 step 名称与"待实现"提示,仍遵循 BaseView 契约:
* ``get_config()`` 返回空 dictservice 端据此走占位分支) * ``get_config()`` 返回空 dictservice 端据此走占位分支)
* 点击"执行"按钮仍会触发 ``dispatch_execute``,便于在 main_router * 点击"执行"按钮仍会触发 ``dispatch_execute``,便于在 main_view
中验证路由链路完整通畅 中验证路由链路完整通畅
""" """
@ -42,7 +42,7 @@ class PlaceholderView(BaseView):
hint = QLabel( hint = QLabel(
"此步骤的 View 与 Service 尚未迁移到端到端模块化新架构。\n" "此步骤的 View 与 Service 尚未迁移到端到端模块化新架构。\n"
"按钮仍可点击以验证 main_router 路由链路完整性。" "按钮仍可点击以验证 main_view 路由链路完整性。"
) )
hint.setAlignment(Qt.AlignCenter) hint.setAlignment(Qt.AlignCenter)
hint.setWordWrap(True) hint.setWordWrap(True)

View File

@ -17,9 +17,9 @@ Step1View —— Step 1水域掩膜生成的前端视图端到端模块
Step1View._on_run_clicked Step1View._on_run_clicked
→ BaseView.dispatch_execute("step1", config) → BaseView.dispatch_execute("step1", config)
→ MainRouter.run_single_step(step_id, config) → MainView.run_single_step(step_id, config)
→ TaskWorker → services.step1_service.execute_step1(config) → TaskWorker → services.step1_service.execute_step1(config)
→ 返回结果 dict → MainRouter 日志区 → 返回结果 dict → MainView 日志区
""" """
import os import os
@ -279,6 +279,6 @@ class Step1View(BaseView):
所有跨层通讯一律走 ``dispatch_execute`` 所有跨层通讯一律走 ``dispatch_execute``
Step1View → 沿父链上溯 → MainRouter.run_single_step(step_id, config) Step1View → 沿父链上溯 → MainView.run_single_step(step_id, config)
""" """
self.dispatch_execute("step1", self.get_config()) self.dispatch_execute("step1", self.get_config())