From c26d74e8464faf673890af455946dd99e1dab64e Mon Sep 17 00:00:00 2001 From: SakuraStar Date: Fri, 13 Mar 2026 18:45:14 +0800 Subject: [PATCH 1/9] perf(face-extraction): reduce preview stutter with latest-frame detection and throttled rendering --- .../camera_preview_backend/detection.py | 64 +++++++++- app/common/camera_preview_backend/workers.py | 99 ++++++++++++++- app/view/main/camera_preview.py | 118 ++++++++++++++---- 3 files changed, 248 insertions(+), 33 deletions(-) diff --git a/app/common/camera_preview_backend/detection.py b/app/common/camera_preview_backend/detection.py index 0513dac7..8b2af636 100644 --- a/app/common/camera_preview_backend/detection.py +++ b/app/common/camera_preview_backend/detection.py @@ -569,6 +569,59 @@ def create_onnx_face_detector( def detect_faces_onnx(frame_bgr, *, detector_state) -> list[Rect]: + def _prepare_detection_frame(src, *, max_side: int = 640): + h0, w0 = src.shape[:2] + if h0 <= 0 or w0 <= 0: + return src, 1.0, 1.0 + side = max(int(w0), int(h0)) + if side <= int(max_side): + return src, 1.0, 1.0 + + ratio = float(max_side) / float(side) + w1 = max(1, int(round(float(w0) * ratio))) + h1 = max(1, int(round(float(h0) * ratio))) + + with _CV2_IMPORT_LOCK: + import cv2 + + resized = cv2.resize(src, (w1, h1), interpolation=cv2.INTER_LINEAR) + scale_x = float(w0) / float(w1) + scale_y = float(h0) / float(h1) + return resized, scale_x, scale_y + + def _rescale_rects( + rects: list[Rect], + *, + scale_x: float, + scale_y: float, + frame_size: tuple[int, int], + ) -> list[Rect]: + h, w = int(frame_size[0]), int(frame_size[1]) + if not rects: + return [] + if ( + abs(float(scale_x) - 1.0) < 1e-6 + and abs(float(scale_y) - 1.0) < 1e-6 + ): + return list(rects) + scaled: list[Rect] = [] + for x, y, bw, bh in rects: + try: + x1 = int(round(float(x) * float(scale_x))) + y1 = int(round(float(y) * float(scale_y))) + w1 = int(round(float(bw) * float(scale_x))) + h1 = int(round(float(bh) * float(scale_y))) + except Exception: + continue + if w1 <= 0 or h1 <= 0: + continue + x1 = max(0, min(x1, w - 1)) + y1 = max(0, min(y1, h - 1)) + w1 = max(1, min(w1, w - x1)) + h1 = max(1, min(h1, h - y1)) + scaled.append((x1, y1, w1, h1)) + return scaled + kind = "" try: kind = str(detector_state.get("kind", "")).lower() @@ -576,20 +629,27 @@ def detect_faces_onnx(frame_bgr, *, detector_state) -> list[Rect]: kind = "" frame_size = frame_bgr.shape[:2] + detect_frame, scale_x, scale_y = _prepare_detection_frame(frame_bgr, max_side=640) if kind == "yunet": rects = detect_faces_yunet( - frame_bgr, + detect_frame, detector=detector_state["detector"], input_size=detector_state.get("input_size"), ) + rects = _rescale_rects( + rects, scale_x=scale_x, scale_y=scale_y, frame_size=frame_size + ) return merge_face_rects(frame_size, rects) if kind == "ultralight": rects = detect_faces_ultralight( - frame_bgr, + detect_frame, net=detector_state["net"], input_size=detector_state["input_size"], priors=detector_state.get("priors"), ) + rects = _rescale_rects( + rects, scale_x=scale_x, scale_y=scale_y, frame_size=frame_size + ) return merge_face_rects(frame_size, rects) raise RuntimeError("Invalid detector state") diff --git a/app/common/camera_preview_backend/workers.py b/app/common/camera_preview_backend/workers.py index 373503c2..e933763c 100644 --- a/app/common/camera_preview_backend/workers.py +++ b/app/common/camera_preview_backend/workers.py @@ -466,6 +466,15 @@ def __init__(self, parent: Optional[QObject] = None) -> None: self._input_size = None self._last_detect = 0.0 self._last_load_attempt = 0.0 + self._pending_frame = None + self._detect_timer: Optional[QTimer] = None + self._detect_scheduled = False + self._detect_inflight = False + self._target_interval_s = 0.08 + self._min_interval_s = 0.08 + self._max_interval_s = 0.14 + self._detect_interval_s = self._target_interval_s + self._recent_detect_cost_s = self._target_interval_s self._detector_state = None self._cv2 = None @@ -473,6 +482,15 @@ def __init__(self, parent: Optional[QObject] = None) -> None: @Slot(bool) def set_enabled(self, enabled: bool) -> None: self._enabled = bool(enabled) + if not self._enabled: + self._pending_frame = None + self._detect_scheduled = False + timer = self._detect_timer + if timer is not None and timer.isActive(): + timer.stop() + return + if self._pending_frame is not None: + self._schedule_detect(0) @Slot(object) def set_model_filename(self, model_filename: object) -> None: @@ -560,23 +578,72 @@ def ensure_loaded(self) -> None: @Slot(object) def process_frame(self, frame_bgr) -> None: + if frame_bgr is None: + return + self._pending_frame = frame_bgr + if self._enabled: + self._schedule_detect(0) + + def _ensure_detect_timer(self) -> QTimer: + timer = self._detect_timer + if timer is not None: + return timer + timer = QTimer(self) + timer.setSingleShot(True) + timer.timeout.connect(self._consume_pending_frame) + self._detect_timer = timer + return timer + + def _schedule_detect(self, delay_ms: int) -> None: if not self._enabled: return - if self._cv2 is None: + timer = self._ensure_detect_timer() + delay = max(0, int(delay_ms)) + if timer.isActive(): + try: + remaining = int(timer.remainingTime()) + except Exception: + remaining = delay + if remaining <= delay: + return + timer.stop() + self._detect_scheduled = True + timer.start(delay) + + @Slot() + def _consume_pending_frame(self) -> None: + self._detect_scheduled = False + if not self._enabled or self._detect_inflight: + return + + frame = self._pending_frame + if frame is None: return now = time.monotonic() - if now - self._last_detect < 0.05: + elapsed = now - self._last_detect + if self._last_detect > 0.0 and elapsed < self._detect_interval_s: + wait_ms = int(max(1.0, (self._detect_interval_s - elapsed) * 1000.0)) + self._schedule_detect(wait_ms) + return + + self._pending_frame = None + self._detect_inflight = True + started_at = time.perf_counter() + emitted_error = False + if not self._enabled: + self._detect_inflight = False return - self._last_detect = now self.ensure_loaded() try: + if self._cv2 is None: + return state = self._detector_state if state is None: return - results = detect_faces_onnx(frame_bgr, detector_state=state) + results = detect_faces_onnx(frame, detector_state=state) except Exception as exc: logger.exception("人脸检测失败: {}", exc) key = "detect_failed" @@ -587,6 +654,30 @@ def process_frame(self, frame_bgr) -> None: ): key = "model_incompatible" self.error_occurred.emit(key, "Face detection failed", msg) + emitted_error = True return + finally: + cost_s = max(0.0, time.perf_counter() - started_at) + self._recent_detect_cost_s = ( + self._recent_detect_cost_s * 0.7 + cost_s * 0.3 + ) + if self._recent_detect_cost_s > self._detect_interval_s * 1.05: + desired = min( + self._max_interval_s, + max(0.10, self._recent_detect_cost_s * 1.2), + ) + else: + desired = max( + self._target_interval_s, + self._detect_interval_s * 0.92, + ) + self._detect_interval_s = min( + self._max_interval_s, + max(self._min_interval_s, desired), + ) + self._last_detect = time.monotonic() + self._detect_inflight = False + if self._pending_frame is not None and not emitted_error: + self._schedule_detect(0) self.faces_ready.emit(results) diff --git a/app/view/main/camera_preview.py b/app/view/main/camera_preview.py index 8710438b..9b6cdb93 100644 --- a/app/view/main/camera_preview.py +++ b/app/view/main/camera_preview.py @@ -2,6 +2,7 @@ import os import random +import time from typing import Optional from PySide6.QtWidgets import * @@ -79,6 +80,10 @@ def __init__(self, parent: Optional[QWidget] = None) -> None: self._current_pick_rect: Optional[tuple[int, int, int, int]] = None self._commit_pending: bool = False self._commit_index: int = 0 + self._pending_render_frame = None + self._render_inflight: bool = False + self._render_interval_s: float = 1.0 / 30.0 + self._last_render_at: float = 0.0 self._capture_thread: Optional[QThread] = None self._capture_worker: Optional[OpenCVCaptureWorker] = None @@ -104,6 +109,9 @@ def __init__(self, parent: Optional[QWidget] = None) -> None: self._final_view_timer = QTimer(self) self._final_view_timer.setSingleShot(True) self._final_view_timer.timeout.connect(self._stop_detection_and_reset) + self._render_timer = QTimer(self) + self._render_timer.setSingleShot(True) + self._render_timer.timeout.connect(self._render_latest_frame) self._init_ui() @@ -317,6 +325,8 @@ def _stop_preview_capture(self) -> None: self._no_face_timer.stop() self._picking_timer.stop() self._final_view_timer.stop() + self._render_timer.stop() + self._pending_render_frame = None try: self._stop_detection_and_reset() @@ -913,7 +923,7 @@ def _on_no_face_timeout(self) -> None: self._show_message("no_face_detected") def _on_frame_received(self, frame_bgr) -> None: - """在 UI 中渲染新帧。""" + """接收新帧并触发限频渲染。""" self._latest_frame = frame_bgr try: h = int(frame_bgr.shape[0]) @@ -922,41 +932,92 @@ def _on_frame_received(self, frame_bgr) -> None: self._capture_resolution = (w, h) except Exception: pass + self._pending_render_frame = frame_bgr try: if self.preview_stack.currentWidget() is not self.preview_label: return except Exception: - pass - try: - qimage = bgr_frame_to_qimage(frame_bgr) - except Exception as exc: - logger.exception("帧转换失败: {}", exc) return - self._latest_qimage = qimage + self._schedule_preview_render(force=False) - pixmap = QPixmap.fromImage(qimage) - painter = QPainter(pixmap) - painter.setRenderHint(QPainter.RenderHint.Antialiasing, True) + def _schedule_preview_render(self, force: bool = False) -> None: + if self._render_inflight: + return + timer = self._render_timer + delay_ms = 0 + if not force and self._last_render_at > 0.0: + elapsed = time.monotonic() - self._last_render_at + wait_s = self._render_interval_s - elapsed + if wait_s > 0: + delay_ms = int(wait_s * 1000.0) + if timer.isActive(): + try: + remaining = int(timer.remainingTime()) + except Exception: + remaining = delay_ms + if remaining <= delay_ms: + return + timer.stop() + timer.start(max(0, delay_ms)) - if self._detection_active and self._overlay_circles: - for circle, color in zip( - self._overlay_circles, self._overlay_colors, strict=True - ): - pen = QPen(color, 6) - painter.setPen(pen) - cx, cy, r = circle - painter.drawEllipse(QPointF(float(cx), float(cy)), float(r), float(r)) + def _render_latest_frame(self) -> None: + if self._render_inflight: + return + try: + if self.preview_stack.currentWidget() is not self.preview_label: + return + except Exception: + return - painter.end() + frame_bgr = self._pending_render_frame + self._pending_render_frame = None + if frame_bgr is None: + frame_bgr = self._latest_frame + if frame_bgr is None: + return - target = self.preview_label.size() - if target.width() > 0 and target.height() > 0: - pixmap = pixmap.scaled( - target, - Qt.AspectRatioMode.KeepAspectRatio, - Qt.TransformationMode.SmoothTransformation, + self._render_inflight = True + try: + try: + qimage = bgr_frame_to_qimage(frame_bgr) + except Exception as exc: + logger.exception("帧转换失败: {}", exc) + return + self._latest_qimage = qimage + + pixmap = QPixmap.fromImage(qimage) + painter = QPainter(pixmap) + painter.setRenderHint( + QPainter.RenderHint.Antialiasing, + bool(self._detection_active and self._overlay_circles), ) - self.preview_label.setPixmap(pixmap) + + if self._detection_active and self._overlay_circles: + for circle, color in zip( + self._overlay_circles, self._overlay_colors, strict=True + ): + pen = QPen(color, 6) + painter.setPen(pen) + cx, cy, r = circle + painter.drawEllipse( + QPointF(float(cx), float(cy)), float(r), float(r) + ) + + painter.end() + + target = self.preview_label.size() + if target.width() > 0 and target.height() > 0: + pixmap = pixmap.scaled( + target, + Qt.AspectRatioMode.KeepAspectRatio, + Qt.TransformationMode.FastTransformation, + ) + self.preview_label.setPixmap(pixmap) + self._last_render_at = time.monotonic() + finally: + self._render_inflight = False + if self._pending_render_frame is not None: + self._schedule_preview_render(force=False) def _on_capture_resolution_applied(self, resolution: object) -> None: try: @@ -972,7 +1033,8 @@ def _on_capture_resolution_applied(self, resolution: object) -> None: try: if self._latest_frame is not None: - self._on_frame_received(self._latest_frame) + self._pending_render_frame = self._latest_frame + self._schedule_preview_render(force=True) except Exception: pass @@ -1462,6 +1524,8 @@ def closeEvent(self, event: QCloseEvent) -> None: self._no_face_timer.stop() self._picking_timer.stop() self._final_view_timer.stop() + self._render_timer.stop() + self._pending_render_frame = None self.detector_enabled_changed.emit(False) if self._capture_worker is not None: if self._capture_thread is not None and self._capture_thread.isRunning(): From 912ef67d5c9828ac9692c515dea51f46201cb377 Mon Sep 17 00:00:00 2001 From: SakuraStar Date: Fri, 13 Mar 2026 19:17:12 +0800 Subject: [PATCH 2/9] perf: optimize roll-call history save path --- app/common/history/roll_call_history.py | 68 ++++++++++++++++++----- app/common/roll_call/roll_call_manager.py | 10 ++++ app/common/roll_call/roll_call_utils.py | 16 ++++-- 3 files changed, 76 insertions(+), 18 deletions(-) diff --git a/app/common/history/roll_call_history.py b/app/common/history/roll_call_history.py index 7d26e26f..dc319d4f 100644 --- a/app/common/history/roll_call_history.py +++ b/app/common/history/roll_call_history.py @@ -62,17 +62,64 @@ def _get_subject_filter() -> Tuple[Optional[Dict], str]: return current_class_info, subject_filter +def _extract_student_weight(student: Dict[str, Any]) -> Any: + """从已选学生载荷中提取可直接复用的权重值""" + for key in ("next_weight", "weight"): + value = student.get(key) + if value is not None: + return value + return None + + +def _build_student_weight_map( + class_name: str, selected_students: List[Dict[str, Any]], subject_filter: str +) -> Dict[str, Any]: + """构建选中学生的权重映射,缺失时才回退到全量计算""" + weight_map: Dict[str, Any] = {} + missing_names: set[str] = set() + + for student in selected_students: + student_name = str(student.get("name", "") or "") + if not student_name: + continue + + current_weight = _extract_student_weight(student) + if current_weight is None: + missing_names.add(student_name) + continue + weight_map[student_name] = current_weight + + if not missing_names: + return weight_map + + students_dict_list = get_student_list(class_name) + students_with_weight = calculate_weight( + students_dict_list, class_name, subject_filter + ) + for student in students_with_weight: + student_name = str(student.get("name", "") or "") + if student_name in missing_names and student_name not in weight_map: + weight_map[student_name] = student.get("next_weight", 0) + + return weight_map + + def _update_student_history( history_data: Dict[str, Any], selected_students: List[Dict[str, Any]], - students_with_weight: List[Dict[str, Any]], + student_weight_map: Dict[str, Any], current_time: str, current_class_info: Optional[Dict], group_filter: Optional[str], gender_filter: Optional[str], ): """更新学生维度的历史记录""" - selected_names = [s.get("name", "") for s in selected_students] + selected_names = { + str(student.get("name", "") or "") + for student in selected_students + if student.get("name") + } + selected_count = len(selected_students) # 更新被选中学生的历史记录 for student in selected_students: @@ -95,17 +142,12 @@ def _update_student_history( student_data["last_drawn_time"] = current_time student_data["rounds_missed"] = 0 - # 获取权重 - current_student_weight = None - for sw in students_with_weight: - if sw.get("name") == student_name: - current_student_weight = sw.get("next_weight", 0) - break + current_student_weight = student_weight_map.get(student_name) history_entry = { "draw_method": 1, "draw_time": current_time, - "draw_people_numbers": len(selected_students), + "draw_people_numbers": selected_count, "draw_group": group_filter, "draw_gender": gender_filter, "weight": current_student_weight, @@ -227,17 +269,15 @@ def save_roll_call_history( # 获取课程信息 current_class_info, subject_filter = _get_subject_filter() - # 计算权重 - students_dict_list = get_student_list(class_name) - students_with_weight = calculate_weight( - students_dict_list, class_name, subject_filter + student_weight_map = _build_student_weight_map( + class_name, selected_students, subject_filter ) # 更新学生历史 _update_student_history( history_data, selected_students, - students_with_weight, + student_weight_map, current_time, current_class_info, group_filter, diff --git a/app/common/roll_call/roll_call_manager.py b/app/common/roll_call/roll_call_manager.py index ad2fd30e..93a5c09b 100644 --- a/app/common/roll_call/roll_call_manager.py +++ b/app/common/roll_call/roll_call_manager.py @@ -1082,6 +1082,15 @@ def draw_random(widget): students = widget.manager.get_random_students(display_count) selected_students = [] selected_students_dict = [] + weight_by_identity = {} + for student, weight in zip( + widget.manager.students or [], + widget.manager.weights or [], + strict=False, + ): + if len(student) < 2: + continue + weight_by_identity[(student[0], student[1])] = weight for s in students: exist = s[4] if len(s) > 4 else True selected_students.append((s[0], s[1], exist)) @@ -1095,6 +1104,7 @@ def draw_random(widget): "name": s[1], "exist": exist, "tags": (widget.manager.tags_by_id or {}).get(sid, []), + "next_weight": weight_by_identity.get((s[0], s[1])), } ) diff --git a/app/common/roll_call/roll_call_utils.py b/app/common/roll_call/roll_call_utils.py index 9d29ec0a..fb32cdee 100644 --- a/app/common/roll_call/roll_call_utils.py +++ b/app/common/roll_call/roll_call_utils.py @@ -260,16 +260,24 @@ def _perform_weighted_draw(candidates, count, weights=None): break selected_candidate = candidates[random_index] + selected_candidate_dict = dict(selected_candidate) + if ( + "next_weight" not in selected_candidate_dict + and selected_candidate_dict.get("weight") is not None + ): + selected_candidate_dict["next_weight"] = selected_candidate_dict.get( + "weight" + ) # Extract basic info tuple info_tuple = ( - selected_candidate.get("id", ""), - selected_candidate.get("name", ""), - selected_candidate.get("exist", True), + selected_candidate_dict.get("id", ""), + selected_candidate_dict.get("name", ""), + selected_candidate_dict.get("exist", True), ) selected_candidates.append(info_tuple) - selected_candidates_dict.append(selected_candidate) + selected_candidates_dict.append(selected_candidate_dict) candidates.pop(random_index) current_weights.pop(random_index) From 636cef9ada60e741606f45039dba613add25720a Mon Sep 17 00:00:00 2001 From: SakuraStar Date: Fri, 13 Mar 2026 19:46:39 +0800 Subject: [PATCH 3/9] perf: optimize roll-call history refresh path --- app/common/history/background_loader.py | 13 +- app/common/history/history_reader.py | 17 +- app/common/history/weight_utils.py | 15 +- .../history/roll_call_history_table.py | 451 ------------------ 4 files changed, 31 insertions(+), 465 deletions(-) diff --git a/app/common/history/background_loader.py b/app/common/history/background_loader.py index 82753c9f..44e468e5 100644 --- a/app/common/history/background_loader.py +++ b/app/common/history/background_loader.py @@ -3,7 +3,7 @@ from app.Language.obtain_language import get_content_name_async from app.common.history.history_reader import ( - check_class_has_gender_or_group, + check_roll_call_students_have_gender_or_group, filter_roll_call_history_by_subject, get_lottery_history_data, get_lottery_pool_list, @@ -79,7 +79,9 @@ def build_roll_call_history_payload( all_names = [name for _, name, _, _ in cleaned_students if name] base_history_data = get_roll_call_history_data(class_name) available_subjects = _collect_roll_call_subjects(base_history_data) - has_gender, has_group = check_class_has_gender_or_group(class_name) + has_gender, has_group = check_roll_call_students_have_gender_or_group( + cleaned_students + ) reverse_order = bool(sort_order_desc) if mode_index == 0: @@ -91,7 +93,12 @@ def build_roll_call_history_payload( students_data = get_roll_call_students_data( cleaned_students, history_data, subject_name ) - students_weight_data = calculate_weight(students_data, class_name, subject_name) + students_weight_data = calculate_weight( + students_data, + class_name, + subject_name, + history_data=history_data, + ) format_weight, _, _ = format_weight_for_display( students_weight_data, "next_weight" ) diff --git a/app/common/history/history_reader.py b/app/common/history/history_reader.py index 9fea63fa..96a88f5d 100644 --- a/app/common/history/history_reader.py +++ b/app/common/history/history_reader.py @@ -7,7 +7,6 @@ from loguru import logger from app.tools.path_utils import get_data_path, open_file, file_exists -from app.common.data.list import get_gender_list, get_group_list # ================================================== @@ -46,6 +45,15 @@ def get_roll_call_student_list( return [] +def check_roll_call_students_have_gender_or_group( + cleaned_students: List[Tuple[str, str, str, str]], +) -> Tuple[bool, bool]: + """根据已加载的学生快照判断是否包含性别和小组信息""" + has_gender = any(str(gender or "").strip() for _, _, gender, _ in cleaned_students) + has_group = any(str(group or "").strip() for _, _, _, group in cleaned_students) + return has_gender, has_group + + def get_roll_call_history_data( class_name: str, ) -> Dict[str, Any]: @@ -325,11 +333,8 @@ def check_class_has_gender_or_group( Returns: Tuple[bool, bool]: (has_gender, has_group) """ - gender_list = get_gender_list(class_name) - group_list = get_group_list(class_name) - has_gender = bool(gender_list) and gender_list != [""] - has_group = bool(group_list) and group_list != [""] - return has_gender, has_group + cleaned_students = get_roll_call_student_list(class_name) + return check_roll_call_students_have_gender_or_group(cleaned_students) # ================================================== diff --git a/app/common/history/weight_utils.py b/app/common/history/weight_utils.py index fb64cc02..ba08435f 100644 --- a/app/common/history/weight_utils.py +++ b/app/common/history/weight_utils.py @@ -269,7 +269,12 @@ def _check_shield_status(settings, last_drawn_time): # ================================================== # 公平抽取权重计算函数 # ================================================== -def calculate_weight(students_data: list, class_name: str, subject: str = "") -> list: +def calculate_weight( + students_data: list, + class_name: str, + subject: str = "", + history_data: dict | None = None, +) -> list: """计算学生权重 Args: @@ -281,10 +286,10 @@ def calculate_weight(students_data: list, class_name: str, subject: str = "") -> list: 更新后的学生数据列表 """ settings = _load_weight_settings() - history_data = load_history_data("roll_call", class_name) - - if subject: - history_data = filter_roll_call_history_by_subject(history_data, subject) + if history_data is None: + history_data = load_history_data("roll_call", class_name) + if subject: + history_data = filter_roll_call_history_by_subject(history_data, subject) group_stats = history_data.get("group_stats", {}) gender_stats = history_data.get("gender_stats", {}) diff --git a/app/view/settings/history/roll_call_history_table.py b/app/view/settings/history/roll_call_history_table.py index f60b888d..03afb201 100644 --- a/app/view/settings/history/roll_call_history_table.py +++ b/app/view/settings/history/roll_call_history_table.py @@ -1,8 +1,3 @@ -# ================================================== -# 导入库 -# ================================================== -import json - from loguru import logger from PySide6.QtWidgets import * from PySide6.QtGui import * @@ -19,15 +14,6 @@ from app.Language.obtain_language import * from app.common.history import * from app.common.history.background_loader import build_roll_call_history_payload -from app.common.history.history_reader import ( - get_roll_call_student_list, - get_roll_call_history_data, - filter_roll_call_history_by_subject, - get_roll_call_students_data, - get_roll_call_session_data, - get_roll_call_student_stats_data, - check_class_has_gender_or_group, -) class _HistoryLoadSignals(QObject): @@ -77,9 +63,6 @@ def __init__(self, parent=None): self.is_loading = False # 是否正在加载数据 self.has_class_record = False # 是否有课程记录 self.available_subjects = [] # 可用的课程列表 - self.cached_students_data = [] # 缓存的学生数据列表 - self.cached_sessions_data = [] # 缓存的会话数据列表 - self.cached_stats_data = [] # 缓存的统计数据列表 self.force_load_all = False self._history_request_id = 0 self._cached_row_models = [] @@ -413,373 +396,6 @@ def _load_more_data(self): finally: self.is_loading = False - def _load_more_students_data(self): - """加载更多学生数据""" - if not self.current_class_name: - return - try: - # 如果是第一次加载(current_row == 0),获取并排序数据 - if self.current_row == 0: - cleaned_students = get_roll_call_student_list(self.current_class_name) - history_data = get_roll_call_history_data(self.current_class_name) - - if self.current_subject: - history_data = filter_roll_call_history_by_subject( - history_data, self.current_subject - ) - - students_data = get_roll_call_students_data( - cleaned_students, history_data, self.current_subject - ) - - students_weight_data = calculate_weight( - students_data, self.current_class_name, self.current_subject - ) - - format_weight, _, _ = format_weight_for_display( - students_weight_data, "next_weight" - ) - - if self.sort_column >= 0: - - def sort_key(student): - if self.sort_column == 0: - return student.get("id", "") - elif self.sort_column == 1: - return student.get("name", "") - elif self.sort_column == 2: - return student.get("gender", "") - elif self.sort_column == 3: - return student.get("group", "") - elif self.sort_column == 4: - return student.get("total_count", 0) - elif self.sort_column == 5: - for weight_student in students_weight_data: - if weight_student.get("id") == student.get( - "id" - ) and weight_student.get("name") == student.get("name"): - return weight_student.get("next_weight", 1.0) - return 1.0 - return "" - - reverse_order = self.sort_order == Qt.SortOrder.DescendingOrder - students_data.sort(key=sort_key, reverse=reverse_order) - sorted_weight_data = [] - for student in students_data: - for weight_student in students_weight_data: - if weight_student.get("id") == student.get( - "id" - ) and weight_student.get("name") == student.get("name"): - sorted_weight_data.append(weight_student) - break - students_weight_data = sorted_weight_data - - # 缓存排序后的数据 - self.cached_students_data = students_data - self.cached_students_weight_data = students_weight_data - self.cached_format_weight = format_weight - else: - # 使用缓存的数据 - students_data = self.cached_students_data - students_weight_data = self.cached_students_weight_data - format_weight = self.cached_format_weight - - start_row = self.current_row - end_row = min(start_row + self.batch_size, self.total_rows) - - has_gender, has_group = check_class_has_gender_or_group( - self.current_class_name - ) - - for i in range(start_row, end_row): - if i >= len(students_data): - break - - student = students_data[i] - row = i - col = 0 - - id_item = create_table_item(student.get("id", str(row + 1))) - self.table.setItem(row, col, id_item) - col += 1 - - name_item = create_table_item(student.get("name", "")) - self.table.setItem(row, col, name_item) - col += 1 - - if has_gender: - gender_item = create_table_item(student.get("gender", "")) - self.table.setItem(row, col, gender_item) - col += 1 - - if has_group: - group_item = create_table_item(student.get("group", "")) - self.table.setItem(row, col, group_item) - col += 1 - - total_count_item = create_table_item( - str(student.get("total_count_str", student.get("total_count", 0))) - ) - self.table.setItem(row, col, total_count_item) - col += 1 - - if self.table.columnCount() > col: - if i < len(students_weight_data): - weight_item = create_table_item( - str( - format_weight( - students_weight_data[i].get("next_weight", "") - ) - ) - ) - self.table.setItem(row, col, weight_item) - - self.current_row = end_row - - except Exception as e: - logger.exception(f"加载学生数据失败: {e}") - - def _load_more_sessions_data(self): - """加载更多会话数据""" - if not self.current_class_name: - return - try: - # 如果是第一次加载(current_row == 0),获取并排序数据 - if self.current_row == 0: - cleaned_students = get_roll_call_student_list(self.current_class_name) - history_data = get_roll_call_history_data(self.current_class_name) - - students_data = get_roll_call_session_data( - cleaned_students, history_data, self.current_subject - ) - - self.has_class_record = any( - student.get("class_name", "") for student in students_data - ) - - self.update_table_headers() - - format_weight, _, _ = format_weight_for_display(students_data, "weight") - - if self.sort_column >= 0: - - def sort_key(student): - if self.sort_column == 0: - return student.get("draw_time", "") - elif self.sort_column == 1: - return student.get("id", "") - elif self.sort_column == 2: - return student.get("name", "") - elif self.sort_column == 3: - return student.get("gender", "") - elif self.sort_column == 4: - return student.get("group", "") - elif self.sort_column == 5: - return student.get("class_name", "") - elif self.sort_column == 6: - return student.get("weight", "") - return "" - - reverse_order = self.sort_order == Qt.SortOrder.DescendingOrder - students_data.sort(key=sort_key, reverse=reverse_order) - else: - students_data.sort( - key=lambda x: x.get("draw_time", ""), reverse=True - ) - - # 缓存排序后的数据 - self.cached_sessions_data = students_data - self.cached_sessions_format_weight = format_weight - else: - # 使用缓存的数据 - students_data = self.cached_sessions_data - format_weight = self.cached_sessions_format_weight - - start_row = self.current_row - end_row = min(start_row + self.batch_size, self.total_rows) - - has_gender, has_group = check_class_has_gender_or_group( - self.current_class_name - ) - - for i in range(start_row, end_row): - if i >= len(students_data): - break - - student = students_data[i] - row = i - col = 0 - - draw_time_item = create_table_item(student.get("draw_time", "")) - self.table.setItem(row, col, draw_time_item) - col += 1 - - id_item = create_table_item(student.get("id", str(row + 1))) - self.table.setItem(row, col, id_item) - col += 1 - - name_item = create_table_item(student.get("name", "")) - self.table.setItem(row, col, name_item) - col += 1 - - if has_gender: - gender = student.get("gender", "") - gender_item = create_table_item(str(gender) if gender else "") - self.table.setItem(row, col, gender_item) - col += 1 - - if has_group: - group = student.get("group", "") - group_item = create_table_item(str(group) if group else "") - self.table.setItem(row, col, group_item) - col += 1 - - if self.has_class_record: - class_name = student.get("class_name", "") - class_item = create_table_item( - str(class_name) if class_name else "" - ) - self.table.setItem(row, col, class_item) - col += 1 - - if self.table.columnCount() > col: - weight_item = create_table_item( - str(format_weight(student.get("weight", ""))) - ) - self.table.setItem(row, col, weight_item) - - self.current_row = end_row - - except Exception as e: - logger.exception(f"加载会话数据失败: {e}") - - def _load_more_stats_data(self, student_name): - """加载更多统计数据""" - if not self.current_class_name: - return - try: - # 如果是第一次加载(current_row == 0),获取并排序数据 - if self.current_row == 0: - cleaned_students = get_roll_call_student_list(self.current_class_name) - history_data = get_roll_call_history_data(self.current_class_name) - - students_data = get_roll_call_student_stats_data( - cleaned_students, history_data, student_name, self.current_subject - ) - - self.has_class_record = any( - student.get("class_name", "") for student in students_data - ) - - self.update_table_headers() - - format_weight, _, _ = format_weight_for_display(students_data, "weight") - - if self.sort_column >= 0: - - def sort_key(student): - if self.sort_column == 0: - return student.get("draw_time", "") - elif self.sort_column == 1: - return str(student.get("draw_method", "")) - elif self.sort_column == 2: - return int(student.get("draw_people_numbers", 0)) - elif self.sort_column == 3: - return str(student.get("draw_gender", "")) - elif self.sort_column == 4: - return str(student.get("draw_group", "")) - elif self.sort_column == 5: - return str(student.get("class_name", "")) - elif self.sort_column == 6: - return float(student.get("weight", "")) - return "" - - reverse_order = self.sort_order == Qt.SortOrder.DescendingOrder - students_data.sort(key=sort_key, reverse=reverse_order) - else: - students_data.sort( - key=lambda x: x.get("draw_time", ""), reverse=True - ) - - # 缓存排序后的数据 - self.cached_stats_data = students_data - self.cached_stats_format_weight = format_weight - else: - # 使用缓存的数据 - students_data = self.cached_stats_data - format_weight = self.cached_stats_format_weight - - start_row = self.current_row - end_row = min(start_row + self.batch_size, self.total_rows) - - has_gender, has_group = check_class_has_gender_or_group( - self.current_class_name - ) - - for i in range(start_row, end_row): - if i >= len(students_data): - break - - student = students_data[i] - row = i - col = 0 - - time_item = create_table_item(student.get("draw_time", "")) - self.table.setItem(row, col, time_item) - col += 1 - - draw_method = student.get("draw_method", "") - if draw_method == "0": - mode_text = get_content_name_async( - "roll_call_history_table", "draw_method_random" - ) - elif draw_method == "1": - mode_text = get_content_name_async( - "roll_call_history_table", "draw_method_weight" - ) - else: - mode_text = str(draw_method) - mode_item = create_table_item(mode_text) - self.table.setItem(row, col, mode_item) - col += 1 - - draw_people_numbers_item = create_table_item( - str(student.get("draw_people_numbers", 0)) - ) - self.table.setItem(row, col, draw_people_numbers_item) - col += 1 - - if has_gender: - draw_gender = student.get("draw_gender", "") - gender_item = create_table_item(draw_gender if draw_gender else "") - self.table.setItem(row, col, gender_item) - col += 1 - - if has_group: - draw_group = student.get("draw_group", "") - group_item = create_table_item(draw_group if draw_group else "") - self.table.setItem(row, col, group_item) - col += 1 - - if self.has_class_record: - class_name = student.get("class_name", "") - class_item = create_table_item( - str(class_name) if class_name else "" - ) - self.table.setItem(row, col, class_item) - col += 1 - - if self.table.columnCount() > col: - weight_item = create_table_item( - str(format_weight(student.get("weight", 0))) - ) - self.table.setItem(row, col, weight_item) - - self.current_row = end_row - - except Exception as e: - logger.exception(f"加载统计数据失败: {e}") - def setup_file_watcher(self): """设置文件系统监视器,监控班级历史记录文件夹的变化""" roll_call_history_dir = get_data_path("history/roll_call_history") @@ -885,73 +501,6 @@ def schedule_refresh_data(self): return self._refresh_debounce_timer.start(0) - def _update_subject_list(self): - """更新课程列表""" - if not self.current_class_name: - return - - try: - history_file = get_data_path( - "history/roll_call_history", f"{self.current_class_name}.json" - ) - - if not file_exists(history_file): - self.available_subjects = [] - return - - with open_file(history_file, "r", encoding="utf-8") as f: - history_data = json.load(f) - - # 收集所有课程名称 - subjects = set() - students = history_data.get("students", {}) - for student_info in students.values(): - history = student_info.get("history", []) - for record in history: - class_name = record.get("class_name", "") - if class_name: - subjects.add(class_name) - - self.available_subjects = sorted(list(subjects)) - - # 更新课程下拉框 - if hasattr(self, "subject_comboBox"): - # 保存当前选择的课程 - current_subject = self.current_subject - current_index = self.subject_comboBox.currentIndex() - - self.subject_comboBox.blockSignals(True) - self.subject_comboBox.clear() - self.subject_comboBox.addItems( - get_content_combo_name_async( - "roll_call_history_table", "select_subject" - ) - + self.available_subjects - ) - - # 恢复之前选择的课程 - if current_subject: - # 尝试找到之前选择的课程 - items = self.subject_comboBox.count() - for i in range(items): - if self.subject_comboBox.itemText(i) == current_subject: - self.subject_comboBox.setCurrentIndex(i) - break - else: - self.subject_comboBox.setCurrentIndex(0) - - self.subject_comboBox.blockSignals(False) - - # 根据是否有课程记录显示或隐藏课程选择框 - if not self.available_subjects: - self.subject_comboBox.hide() - else: - self.subject_comboBox.show() - - except Exception as e: - logger.exception(f"更新课程列表失败: {e}") - self.available_subjects = [] - def _update_mode_options(self, names=None): if not hasattr(self, "mode_comboBox"): return From b612c3324df74a6be7293447f7c3981d4277d3d8 Mon Sep 17 00:00:00 2001 From: SakuraStar Date: Fri, 13 Mar 2026 19:51:42 +0800 Subject: [PATCH 4/9] perf: streamline lottery history refresh path --- .../settings/history/lottery_history_table.py | 334 ------------------ 1 file changed, 334 deletions(-) diff --git a/app/view/settings/history/lottery_history_table.py b/app/view/settings/history/lottery_history_table.py index c244b0b5..a0edf34a 100644 --- a/app/view/settings/history/lottery_history_table.py +++ b/app/view/settings/history/lottery_history_table.py @@ -1,8 +1,3 @@ -# ================================================== -# 导入库 -# ================================================== -import json - from loguru import logger from PySide6.QtWidgets import * from PySide6.QtGui import * @@ -19,13 +14,6 @@ from app.Language.obtain_language import * from app.common.history import * from app.common.history.background_loader import build_lottery_history_payload -from app.common.history.history_reader import ( - get_lottery_pool_list, - get_lottery_history_data, - get_lottery_prizes_data, - get_lottery_session_data, - get_lottery_prize_stats_data, -) class _HistoryLoadSignals(QObject): @@ -75,9 +63,6 @@ def __init__(self, parent=None): self.is_loading = False # 是否正在加载数据 self.has_class_record = False # 是否有课程记录 self.available_subjects = [] # 可用的课程列表 - self.cached_lotterys_data = [] # 缓存的奖品数据列表 - self.cached_sessions_data = [] # 缓存的会话数据列表 - self.cached_stats_data = [] # 缓存的统计数据列表 self.force_load_all = False self._history_request_id = 0 self._cached_row_models = [] @@ -293,11 +278,6 @@ def _on_header_clicked(self, column): self.current_row = 0 self.table.setRowCount(0) - # 清空缓存,确保排序时重新获取数据 - self.cached_lotterys_data = [] - self.cached_sessions_data = [] - self.cached_stats_data = [] - # 重新加载数据 self.refresh_data() @@ -407,253 +387,6 @@ def _load_more_data(self): finally: self.is_loading = False - def _load_more_lotterys_data(self): - """加载更多奖品数据""" - if not self.current_pool_name: - return - try: - # 如果是第一次加载(current_row == 0),获取并排序数据 - if self.current_row == 0: - cleaned_lotterys = get_lottery_pool_list(self.current_pool_name) - history_data = get_lottery_history_data(self.current_pool_name) - - lotterys_data = get_lottery_prizes_data(cleaned_lotterys, history_data) - - format_weight, _, _ = format_weight_for_display(lotterys_data, "weight") - - if self.sort_column >= 0: - - def sort_key(lottery): - if self.sort_column == 0: - return lottery.get("id", "") - elif self.sort_column == 1: - return lottery.get("name", "") - elif self.sort_column == 2: - return lottery.get("total_count", 0) - elif self.sort_column == 3: - return lottery.get("weight", "") - return "" - - reverse_order = self.sort_order == Qt.SortOrder.DescendingOrder - lotterys_data.sort(key=sort_key, reverse=reverse_order) - - # 缓存排序后的数据 - self.cached_lotterys_data = lotterys_data - self.cached_lotterys_format_weight = format_weight - else: - # 使用缓存的数据 - lotterys_data = self.cached_lotterys_data - format_weight = self.cached_lotterys_format_weight - - start_row = self.current_row - end_row = min(start_row + self.batch_size, self.total_rows) - - for i in range(start_row, end_row): - if i >= len(lotterys_data): - break - - lottery = lotterys_data[i] - row = i - - id_item = create_table_item(lottery.get("id", str(row + 1))) - self.table.setItem(row, 0, id_item) - - name_item = create_table_item(lottery.get("name", "")) - self.table.setItem(row, 1, name_item) - - total_count_item = create_table_item( - str(lottery.get("total_count_str", lottery.get("total_count", 0))) - ) - self.table.setItem(row, 2, total_count_item) - - weight_item = create_table_item(format_weight(lottery.get("weight", 0))) - self.table.setItem(row, 3, weight_item) - - self.current_row = end_row - - except Exception as e: - logger.exception(f"加载奖品数据失败: {e}") - Dialog("错误", f"加载奖品数据失败: {e}", self).exec() - - def _load_more_sessions_data(self): - """加载更多会话数据""" - if not self.current_pool_name: - return - try: - # 如果是第一次加载(current_row == 0),获取并排序数据 - if self.current_row == 0: - cleaned_lotterys = get_lottery_pool_list(self.current_pool_name) - history_data = get_lottery_history_data(self.current_pool_name) - - lotterys_data = get_lottery_session_data( - cleaned_lotterys, history_data, self.current_subject - ) - - self.has_class_record = any( - lottery.get("class_name", "") for lottery in lotterys_data - ) - - self.update_table_headers() - - format_weight, _, _ = format_weight_for_display(lotterys_data, "weight") - - if self.sort_column >= 0: - - def sort_key(lottery): - if self.sort_column == 0: - return lottery.get("draw_time", "") - elif self.sort_column == 1: - return lottery.get("id", "") - elif self.sort_column == 2: - return lottery.get("name", "") - elif self.sort_column == 3: - return lottery.get("class_name", "") - elif self.sort_column == 4: - return lottery.get("weight", "") - return "" - - reverse_order = self.sort_order == Qt.SortOrder.DescendingOrder - lotterys_data.sort(key=sort_key, reverse=reverse_order) - else: - lotterys_data.sort( - key=lambda x: x.get("draw_time", ""), reverse=True - ) - - # 缓存排序后的数据 - self.cached_sessions_data = lotterys_data - self.cached_sessions_format_weight = format_weight - else: - # 使用缓存的数据 - lotterys_data = self.cached_sessions_data - format_weight = self.cached_sessions_format_weight - - start_row = self.current_row - end_row = min(start_row + self.batch_size, self.total_rows) - - for i in range(start_row, end_row): - if i >= len(lotterys_data): - break - - lottery = lotterys_data[i] - row = i - - draw_time_item = create_table_item(lottery.get("draw_time", "")) - self.table.setItem(row, 0, draw_time_item) - - id_item = create_table_item(lottery.get("id", str(row + 1))) - self.table.setItem(row, 1, id_item) - - name_item = create_table_item(lottery.get("name", "")) - self.table.setItem(row, 2, name_item) - - if self.has_class_record: - class_name = lottery.get("class_name", "") - class_item = create_table_item( - str(class_name) if class_name else "" - ) - self.table.setItem(row, 3, class_item) - col = 4 - else: - col = 3 - - weight_item = create_table_item(format_weight(lottery.get("weight", 0))) - self.table.setItem(row, col, weight_item) - - self.current_row = end_row - - except Exception as e: - logger.exception(f"加载会话数据失败: {e}") - Dialog("错误", f"加载会话数据失败: {e}", self).exec() - - def _load_more_stats_data(self, lottery_name): - """加载更多统计数据""" - if not self.current_pool_name: - return - try: - # 如果是第一次加载(current_row == 0),获取并排序数据 - if self.current_row == 0: - cleaned_lotterys = get_lottery_pool_list(self.current_pool_name) - history_data = get_lottery_history_data(self.current_pool_name) - - lotterys_data = get_lottery_prize_stats_data( - cleaned_lotterys, history_data, lottery_name, self.current_subject - ) - - self.has_class_record = any( - lottery.get("class_name", "") for lottery in lotterys_data - ) - - self.update_table_headers() - - format_weight, _, _ = format_weight_for_display(lotterys_data, "weight") - - if self.sort_column >= 0: - - def sort_key(lottery): - if self.sort_column == 0: - return lottery.get("draw_time", "") - elif self.sort_column == 1: - return int(lottery.get("draw_lottery_numbers", 0)) - elif self.sort_column == 2: - return lottery.get("class_name", "") - elif self.sort_column == 3: - return float(lottery.get("weight", "")) - return "" - - reverse_order = self.sort_order == Qt.SortOrder.DescendingOrder - lotterys_data.sort(key=sort_key, reverse=reverse_order) - else: - lotterys_data.sort( - key=lambda x: x.get("draw_time", ""), reverse=True - ) - - # 缓存排序后的数据 - self.cached_stats_data = lotterys_data - self.cached_stats_format_weight = format_weight - else: - # 使用缓存的数据 - lotterys_data = self.cached_stats_data - format_weight = self.cached_stats_format_weight - - start_row = self.current_row - end_row = min(start_row + self.batch_size, self.total_rows) - - for i in range(start_row, end_row): - if i >= len(lotterys_data): - break - - lottery = lotterys_data[i] - row = i - - time_item = create_table_item(lottery.get("draw_time", "")) - self.table.setItem(row, 0, time_item) - - draw_lottery_numbers_item = create_table_item( - str(lottery.get("draw_lottery_numbers", 0)) - ) - self.table.setItem(row, 1, draw_lottery_numbers_item) - - if self.has_class_record: - class_name = lottery.get("class_name", "") - class_item = create_table_item( - str(class_name) if class_name else "" - ) - self.table.setItem(row, 2, class_item) - col = 3 - else: - col = 2 - - weight_item = create_table_item( - format_weight(lottery.get("weight", "")) - ) - self.table.setItem(row, col, weight_item) - - self.current_row = end_row - - except Exception as e: - logger.exception(f"加载统计数据失败: {e}") - Dialog("错误", f"加载统计数据失败: {e}", self).exec() - def setup_file_watcher(self): """设置文件系统监视器,监控奖池历史记录文件夹的变化""" lottery_history_dir = get_data_path("history/lottery_history") @@ -846,73 +579,6 @@ def schedule_refresh_data(self): return self._refresh_debounce_timer.start(0) - def _update_subject_list(self): - """更新课程列表""" - if not self.current_pool_name: - return - - try: - history_file = get_data_path( - "history/lottery_history", f"{self.current_pool_name}.json" - ) - - if not file_exists(history_file): - self.available_subjects = [] - return - - with open_file(history_file, "r", encoding="utf-8") as f: - history_data = json.load(f) - - # 收集所有课程名称 - subjects = set() - lotterys = history_data.get("lotterys", {}) - for lottery_info in lotterys.values(): - history = lottery_info.get("history", []) - for record in history: - class_name = record.get("class_name", "") - if class_name: - subjects.add(class_name) - - self.available_subjects = sorted(list(subjects)) - - # 更新课程下拉框 - if hasattr(self, "subject_comboBox"): - # 保存当前选择的课程 - current_subject = self.current_subject - current_index = self.subject_comboBox.currentIndex() - - self.subject_comboBox.blockSignals(True) - self.subject_comboBox.clear() - self.subject_comboBox.addItems( - get_content_combo_name_async( - "lottery_history_table", "select_subject" - ) - + self.available_subjects - ) - - # 恢复之前选择的课程 - if current_subject: - # 尝试找到之前选择的课程 - items = self.subject_comboBox.count() - for i in range(items): - if self.subject_comboBox.itemText(i) == current_subject: - self.subject_comboBox.setCurrentIndex(i) - break - else: - self.subject_comboBox.setCurrentIndex(0) - - self.subject_comboBox.blockSignals(False) - - # 根据是否有课程记录显示或隐藏课程选择框 - if not self.available_subjects: - self.subject_comboBox.hide() - else: - self.subject_comboBox.show() - - except Exception as e: - logger.exception(f"更新课程列表失败: {e}") - self.available_subjects = [] - def _update_mode_options(self, names=None): if not hasattr(self, "mode_comboBox"): return From 8fe422645fd62ae67c98c2b6cad4300ff9585385 Mon Sep 17 00:00:00 2001 From: SakuraStar Date: Fri, 13 Mar 2026 20:04:20 +0800 Subject: [PATCH 5/9] perf: lazy-load main window pages --- app/core/window_manager.py | 38 ++++-- app/view/main/window.py | 265 +++++++++++++++++++++++++------------ 2 files changed, 210 insertions(+), 93 deletions(-) diff --git a/app/core/window_manager.py b/app/core/window_manager.py index 5072c077..60127815 100644 --- a/app/core/window_manager.py +++ b/app/core/window_manager.py @@ -294,16 +294,14 @@ def _connect_url_handler_signals(self) -> None: if hasattr(self.url_handler, "windowActionRequested"): self.url_handler.windowActionRequested.connect(self._handle_window_action) - def _ensure_main_window_pages_created(self) -> None: + def _ensure_main_window_page(self, page_name: str | None = None) -> None: if self.main_window is None: return try: - roll_call_page = getattr(self.main_window, "roll_call_page", None) - lottery_page = getattr(self.main_window, "lottery_page", None) - if roll_call_page is not None or lottery_page is not None: - return if hasattr(self.main_window, "createSubInterface"): self.main_window.createSubInterface() + if page_name and hasattr(self.main_window, "_ensure_main_page_loaded"): + self.main_window._ensure_main_page_loaded(page_name) except Exception as e: logger.exception("确保主窗口页面创建失败(已忽略): {}", e) @@ -312,13 +310,18 @@ def _get_roll_call_widget(self): return None try: if hasattr(self.main_window, "_get_roll_call_widget"): - return self.main_window._get_roll_call_widget( - getattr(self.main_window, "roll_call_page", None) - ) + self._ensure_main_window_page("roll_call_page") + return self.main_window._get_roll_call_widget() except Exception: pass - roll_call_page = getattr(self.main_window, "roll_call_page", None) + self._ensure_main_window_page("roll_call_page") + if hasattr(self.main_window, "_get_main_page"): + roll_call_page = self.main_window._get_main_page( + "roll_call_page", load=False + ) + else: + roll_call_page = getattr(self.main_window, "roll_call_page", None) if roll_call_page is None: return None if ( @@ -347,7 +350,18 @@ def _get_roll_call_widget(self): def _get_lottery_widget(self): if self.main_window is None: return None - lottery_page = getattr(self.main_window, "lottery_page", None) + try: + if hasattr(self.main_window, "_get_lottery_widget"): + self._ensure_main_window_page("lottery_page") + return self.main_window._get_lottery_widget() + except Exception: + pass + + self._ensure_main_window_page("lottery_page") + if hasattr(self.main_window, "_get_main_page"): + lottery_page = self.main_window._get_main_page("lottery_page", load=False) + else: + lottery_page = getattr(self.main_window, "lottery_page", None) if lottery_page is None: return None if hasattr(lottery_page, "lottery_widget") and lottery_page.lottery_widget: @@ -369,7 +383,7 @@ def _get_lottery_widget(self): def _handle_roll_call_action(self, action: str, payload) -> None: def impl(): - self._ensure_main_window_pages_created() + self._ensure_main_window_page("roll_call_page") data = payload if isinstance(payload, dict) else {} if action == "quick_draw": if hasattr(self.main_window, "_handle_quick_draw"): @@ -455,7 +469,7 @@ def impl(): def _handle_lottery_action(self, action: str, payload) -> None: def impl(): - self._ensure_main_window_pages_created() + self._ensure_main_window_page("lottery_page") data = payload if isinstance(payload, dict) else {} widget = self._get_lottery_widget() if widget is None: diff --git a/app/view/main/window.py b/app/view/main/window.py index 2999a3e1..a7de5b3c 100644 --- a/app/view/main/window.py +++ b/app/view/main/window.py @@ -2,7 +2,7 @@ # 导入库 # ================================================== from loguru import logger -from PySide6.QtWidgets import QApplication, QWidget +from PySide6.QtWidgets import QApplication, QWidget, QVBoxLayout from PySide6.QtGui import QIcon from PySide6.QtCore import QTimer, QEvent, Signal, QThreadPool, QRunnable, Qt from qfluentwidgets import FluentWindow, NavigationItemPosition @@ -84,6 +84,11 @@ def _initialize_variables(self): self.camera_preview_page = None self.history_page = None self.settingsInterface = None + self._sub_interface_created = False + self._page_shells = {} + self._page_instances = {} + self._page_factories = {} + self._registered_pages = set() self._has_been_shown = False self.pre_class_reset_performed = False @@ -260,14 +265,14 @@ def _setup_float_window(self, float_window: LevitationWindow): self.showMainPageRequested.connect(self._handle_main_page_requested) self.showTrayActionRequested.connect(self._handle_tray_action_requested) self.float_window.rollCallRequested.connect( - lambda: self._show_and_switch_to(self.roll_call_page) + lambda: self._show_and_switch_to_page("roll_call_page") ) self.float_window.quickDrawRequested.connect(self._handle_quick_draw) self.float_window.lotteryRequested.connect( - lambda: self._show_and_switch_to(self.lottery_page) + lambda: self._show_and_switch_to_page("lottery_page") ) self.float_window.faceDrawRequested.connect( - lambda: self._show_and_switch_to(self.camera_preview_page) + lambda: self._show_and_switch_to_page("camera_preview_page") ) self.float_window.timerRequested.connect( lambda: create_countdown_timer_window() @@ -509,30 +514,99 @@ def changeEvent(self, event): def createSubInterface(self): """创建子界面 搭建子界面导航系统""" - self.roll_call_page = roll_call_page(self) - self.roll_call_page.setObjectName("roll_call_page") - - self.lottery_page = lottery_page(self) - self.lottery_page.setObjectName("lottery_page") + if self._sub_interface_created: + return - self.camera_preview_page = CameraPreview(self) - self.camera_preview_page.setObjectName("camera_preview_page") + self._page_factories = { + "roll_call_page": lambda parent: roll_call_page(parent), + "lottery_page": lambda parent: lottery_page(parent), + "camera_preview_page": lambda parent: CameraPreview(parent), + "history_page": lambda parent: history_page(parent), + } - self.history_page = history_page(self) - self.history_page.setObjectName("history_page") + for page_name in self._page_factories: + self._register_main_page_shell(page_name) self.settingsInterface = QWidget(self) self.settingsInterface.setObjectName("settingsInterface") - for page in [ - self.roll_call_page, - self.lottery_page, - self.camera_preview_page, - self.history_page, - ]: - page.installEventFilter(self) - self.initNavigation() + self._sub_interface_created = True + self._ensure_main_page_loaded("roll_call_page") + + def _register_main_page_shell(self, page_name: str): + shell = QWidget(self) + shell.setObjectName(page_name) + layout = QVBoxLayout(shell) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(0) + + self._page_shells[page_name] = shell + self._page_instances[page_name] = None + setattr(self, page_name, shell) + + def _get_main_page_shell(self, page_name: str): + return self._page_shells.get(page_name, getattr(self, page_name, None)) + + def _get_loaded_main_page(self, page_name: str): + return self._page_instances.get(page_name) + + def _clear_page_shell(self, shell: QWidget): + layout = shell.layout() + if layout is None: + return + + while layout.count() > 0: + item = layout.takeAt(0) + if item is None: + break + widget = item.widget() + if widget is not None: + widget.deleteLater() + + def _ensure_main_page_loaded(self, page_name: str): + if not self._sub_interface_created: + self.createSubInterface() + + if not self._sub_interface_created: + return None + + page = self._page_instances.get(page_name) + if page is not None: + return page + + factory = self._page_factories.get(page_name) + shell = self._get_main_page_shell(page_name) + if factory is None or shell is None: + return None + + page = factory(shell) + page.installEventFilter(self) + self._clear_page_shell(shell) + shell.layout().addWidget(page) + self._page_instances[page_name] = page + logger.debug(f"主窗口页面已按需创建: {page_name}") + return page + + def _get_main_page(self, page_name: str, load: bool = False): + page = self._get_loaded_main_page(page_name) + if page is not None: + return page + if not load: + return None + return self._ensure_main_page_loaded(page_name) + + def _show_and_switch_to_page(self, page_name: str): + shell = self._get_main_page_shell(page_name) + if shell is None or page_name not in self._registered_pages: + logger.warning(f"请求切换到未注册的主页面: {page_name}") + return + + self._ensure_main_page_loaded(page_name) + self._show_main_window() + self.activateWindow() + self.raise_() + self.switchTo(shell) def initNavigation(self): """初始化导航系统 @@ -556,11 +630,12 @@ def _add_roll_call_navigation(self): ) self.addSubInterface( - self.roll_call_page, + self._get_main_page_shell("roll_call_page"), get_theme_icon("ic_fluent_people_20_filled"), get_content_name_async("roll_call", "title"), position=roll_call_position, ) + self._registered_pages.add("roll_call_page") def _add_lottery_navigation(self): """添加抽奖页面导航项""" @@ -575,11 +650,12 @@ def _add_lottery_navigation(self): ) self.addSubInterface( - self.lottery_page, + self._get_main_page_shell("lottery_page"), get_theme_icon("ic_fluent_gift_20_filled"), get_content_name_async("lottery", "title"), position=lottery_position, ) + self._registered_pages.add("lottery_page") def _add_camera_preview_navigation(self): camera_sidebar_pos = readme_settings_async( @@ -592,11 +668,12 @@ def _add_camera_preview_navigation(self): else NavigationItemPosition.BOTTOM ) self.addSubInterface( - self.camera_preview_page, + self._get_main_page_shell("camera_preview_page"), get_theme_icon("ic_fluent_video_person_sparkle_20_filled"), get_content_name_async("camera_preview", "title"), position=camera_position, ) + self._registered_pages.add("camera_preview_page") def _add_history_navigation(self): """添加历史记录页面导航项""" @@ -611,11 +688,12 @@ def _add_history_navigation(self): ) self.addSubInterface( - self.history_page, + self._get_main_page_shell("history_page"), get_theme_icon("ic_fluent_history_20_filled"), get_content_name_async("history", "title"), position=history_position, ) + self._registered_pages.add("history_page") def _add_settings_navigation(self): """添加设置页面导航项""" @@ -638,7 +716,9 @@ def _add_settings_navigation(self): settings_item.clicked.connect( lambda: self.showSettingsRequested.emit("basicSettingsInterface") ) - settings_item.clicked.connect(lambda: self.switchTo(self.roll_call_page)) + settings_item.clicked.connect( + lambda: self._show_and_switch_to_page("roll_call_page") + ) # ================================================== # 窗口切换与显示 @@ -661,6 +741,15 @@ def _show_and_switch_to(self, page): Args: page: 要切换到的页面对象 """ + if isinstance(page, str): + self._show_and_switch_to_page(page) + return + + for page_name, shell in self._page_shells.items(): + if page is shell: + self._show_and_switch_to_page(page_name) + return + self._show_main_window() self.activateWindow() self.raise_() @@ -680,13 +769,11 @@ def _handle_main_page_requested(self, page_name: str): self._show_main_window() self.raise_() self.activateWindow() - elif ( - hasattr(self, f"{page_name}") and getattr(self, f"{page_name}") is not None - ): + elif page_name in self._page_shells: logger.debug( f"MainWindow._handle_main_page_requested: 切换到页面: {page_name}" ) - self._show_and_switch_to(getattr(self, page_name)) + self._show_and_switch_to_page(page_name) else: logger.warning( f"MainWindow._handle_main_page_requested: 请求的页面不存在: {page_name}" @@ -721,7 +808,7 @@ def _connect_shortcut_signals(self): logger.debug("开始连接快捷键信号...") self.shortcut_manager.openRollCallPageRequested.connect( - lambda: self._show_and_switch_to(self.roll_call_page) + lambda: self._show_and_switch_to_page("roll_call_page") ) logger.debug("快捷键信号已连接: openRollCallPageRequested") @@ -729,7 +816,7 @@ def _connect_shortcut_signals(self): logger.debug("快捷键信号已连接: useQuickDrawRequested") self.shortcut_manager.openLotteryPageRequested.connect( - lambda: self._show_and_switch_to(self.lottery_page) + lambda: self._show_and_switch_to_page("lottery_page") ) logger.debug("快捷键信号已连接: openLotteryPageRequested") @@ -765,57 +852,39 @@ def _connect_shortcut_signals(self): def _handle_increase_roll_call_count(self): """处理增加点名人数快捷键""" - if hasattr(self, "roll_call_page") and self.roll_call_page: - if ( - hasattr(self.roll_call_page, "contentWidget") - and self.roll_call_page.contentWidget - ): - self.roll_call_page.contentWidget.update_count(1) + widget = self._get_roll_call_widget() + if widget is not None: + widget.update_count(1) def _handle_decrease_roll_call_count(self): """处理减少点名人数快捷键""" - if hasattr(self, "roll_call_page") and self.roll_call_page: - if ( - hasattr(self.roll_call_page, "contentWidget") - and self.roll_call_page.contentWidget - ): - self.roll_call_page.contentWidget.update_count(-1) + widget = self._get_roll_call_widget() + if widget is not None: + widget.update_count(-1) def _handle_increase_lottery_count(self): """处理增加抽奖人数快捷键""" - if hasattr(self, "lottery_page") and self.lottery_page: - if ( - hasattr(self.lottery_page, "contentWidget") - and self.lottery_page.contentWidget - ): - self.lottery_page.contentWidget.update_count(1) + widget = self._get_lottery_widget() + if widget is not None: + widget.update_count(1) def _handle_decrease_lottery_count(self): """处理减少抽奖人数快捷键""" - if hasattr(self, "lottery_page") and self.lottery_page: - if ( - hasattr(self.lottery_page, "contentWidget") - and self.lottery_page.contentWidget - ): - self.lottery_page.contentWidget.update_count(-1) + widget = self._get_lottery_widget() + if widget is not None: + widget.update_count(-1) def _handle_start_roll_call(self): """处理开始点名快捷键""" - if hasattr(self, "roll_call_page") and self.roll_call_page: - if ( - hasattr(self.roll_call_page, "contentWidget") - and self.roll_call_page.contentWidget - ): - self.roll_call_page.contentWidget.start_draw() + widget = self._get_roll_call_widget() + if widget is not None: + widget.start_draw() def _handle_start_lottery(self): """处理开始抽奖快捷键""" - if hasattr(self, "lottery_page") and self.lottery_page: - if ( - hasattr(self.lottery_page, "contentWidget") - and self.lottery_page.contentWidget - ): - self.lottery_page.contentWidget.start_draw() + widget = self._get_lottery_widget() + if widget is not None: + widget.start_draw() def _setup_shortcut_settings_listener(self): """设置快捷键设置监听器,监听快捷键设置变化""" @@ -858,13 +927,13 @@ def _handle_quick_draw(self): 点击悬浮窗中的闪抽按钮时调用""" logger.debug("_handle_quick_draw: 收到闪抽请求") - if not hasattr(self, "roll_call_page") or not self.roll_call_page: + roll_call_page = self._get_main_page("roll_call_page", load=True) + if not roll_call_page: logger.exception("_handle_quick_draw: roll_call_page未创建") return logger.debug("_handle_quick_draw: roll_call_page已创建") - roll_call_page = self.roll_call_page roll_call_widget = self._get_roll_call_widget(roll_call_page) if not roll_call_widget: @@ -881,7 +950,7 @@ def _handle_quick_draw(self): finally: self._restore_original_settings(roll_call_widget, original_settings) - def _get_roll_call_widget(self, roll_call_page): + def _get_roll_call_widget(self, roll_call_page=None): """获取点名页面组件 Args: @@ -890,6 +959,13 @@ def _get_roll_call_widget(self, roll_call_page): Returns: 点名组件对象或None """ + if roll_call_page is None or roll_call_page is self._get_main_page_shell( + "roll_call_page" + ): + roll_call_page = self._get_main_page("roll_call_page", load=True) + if roll_call_page is None: + return None + if ( hasattr(roll_call_page, "roll_call_widget") and roll_call_page.roll_call_widget @@ -913,6 +989,31 @@ def _get_roll_call_widget(self, roll_call_page): return None + def _get_lottery_widget(self, lottery_page=None): + if lottery_page is None or lottery_page is self._get_main_page_shell( + "lottery_page" + ): + lottery_page = self._get_main_page("lottery_page", load=True) + if lottery_page is None: + return None + + if hasattr(lottery_page, "lottery_widget") and lottery_page.lottery_widget: + return lottery_page.lottery_widget + + if hasattr(lottery_page, "contentWidget") and lottery_page.contentWidget: + return lottery_page.contentWidget + + lottery_page.create_content() + QApplication.processEvents() + + if hasattr(lottery_page, "lottery_widget") and lottery_page.lottery_widget: + return lottery_page.lottery_widget + + if hasattr(lottery_page, "contentWidget") and lottery_page.contentWidget: + return lottery_page.contentWidget + + return None + def _save_original_settings(self, roll_call_widget): """保存原始设置 @@ -1079,14 +1180,16 @@ def _perform_pre_class_reset(self): def _clear_roll_call_result(self): """清除点名页面结果""" - if self.roll_call_page and hasattr(self.roll_call_page, "clear_result"): - self.roll_call_page.clear_result() + roll_call_page = self._get_main_page("roll_call_page", load=False) + if roll_call_page and hasattr(roll_call_page, "clear_result"): + roll_call_page.clear_result() logger.info("已清除点名页面结果") def _clear_lottery_result(self): """清除抽奖页面结果""" - if self.lottery_page and hasattr(self.lottery_page, "clear_result"): - self.lottery_page.clear_result() + lottery_page = self._get_main_page("lottery_page", load=False) + if lottery_page and hasattr(lottery_page, "clear_result"): + lottery_page.clear_result() logger.info("已清除抽奖页面结果") def _clear_temp_folder(self): @@ -1100,14 +1203,14 @@ def _clear_temp_folder(self): def _refresh_page_displays(self): """刷新页面显示""" - if self.roll_call_page and hasattr( - self.roll_call_page, "update_many_count_label" - ): - self.roll_call_page.update_many_count_label() + roll_call_page = self._get_main_page("roll_call_page", load=False) + if roll_call_page and hasattr(roll_call_page, "update_many_count_label"): + roll_call_page.update_many_count_label() logger.debug("已刷新点名页面剩余人数显示") - if self.lottery_page and hasattr(self.lottery_page, "update_many_count_label"): - self.lottery_page.update_many_count_label() + lottery_page = self._get_main_page("lottery_page", load=False) + if lottery_page and hasattr(lottery_page, "update_many_count_label"): + lottery_page.update_many_count_label() logger.debug("已刷新抽奖页面显示") def _init_pre_class_reset(self): From 5ad56e7420946c7afdf4addfee4e447ddaef4d48 Mon Sep 17 00:00:00 2001 From: SakuraStar Date: Fri, 13 Mar 2026 20:14:56 +0800 Subject: [PATCH 6/9] fix: restore deferred page visibility behavior --- app/view/main/window.py | 22 +++++++++++++++++++--- app/view/settings/settings.py | 4 ++++ 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/app/view/main/window.py b/app/view/main/window.py index a7de5b3c..080072b9 100644 --- a/app/view/main/window.py +++ b/app/view/main/window.py @@ -531,6 +531,12 @@ def createSubInterface(self): self.settingsInterface.setObjectName("settingsInterface") self.initNavigation() + try: + self.stackedWidget.currentChanged.connect( + self._on_main_stacked_widget_changed + ) + except Exception: + pass self._sub_interface_created = True self._ensure_main_page_loaded("roll_call_page") @@ -608,6 +614,19 @@ def _show_and_switch_to_page(self, page_name: str): self.raise_() self.switchTo(shell) + def _on_main_stacked_widget_changed(self, index: int): + try: + widget = self.stackedWidget.widget(index) + except Exception: + return + + if widget is None: + return + + page_name = widget.objectName() + if page_name in self._registered_pages: + self._ensure_main_page_loaded(page_name) + def initNavigation(self): """初始化导航系统 根据用户设置构建个性化菜单导航""" @@ -716,9 +735,6 @@ def _add_settings_navigation(self): settings_item.clicked.connect( lambda: self.showSettingsRequested.emit("basicSettingsInterface") ) - settings_item.clicked.connect( - lambda: self._show_and_switch_to_page("roll_call_page") - ) # ================================================== # 窗口切换与显示 diff --git a/app/view/settings/settings.py b/app/view/settings/settings.py index 0cfb269a..c394e203 100644 --- a/app/view/settings/settings.py +++ b/app/view/settings/settings.py @@ -1276,7 +1276,11 @@ def _create_deferred_page(self, name: str): return try: + self._clear_placeholder_layout(container) layout.addWidget(real_page) + if not hasattr(self, "_created_pages"): + self._created_pages = {} + self._created_pages[name] = real_page logger.debug(f"后台预热创建设置页面: {name}") except RuntimeError as e: logger.exception( From 702579cd7473b40c2485693793f894ae0ce51a06 Mon Sep 17 00:00:00 2001 From: SakuraStar Date: Fri, 13 Mar 2026 20:17:35 +0800 Subject: [PATCH 7/9] perf: optimize page template lazy loading --- app/page_building/page_template.py | 73 +++++++++++------ app/view/settings/settings.py | 126 ++++++++++------------------- 2 files changed, 91 insertions(+), 108 deletions(-) diff --git a/app/page_building/page_template.py b/app/page_building/page_template.py index 083ea0eb..93ff50f8 100644 --- a/app/page_building/page_template.py +++ b/app/page_building/page_template.py @@ -21,6 +21,8 @@ class PageTemplate(QFrame): # 暂时禁用实例缓存以解决初始化问题 # _instances = {} + _resolved_content_class_cache = {} + def __new__(cls, content_widget_class=None, parent: QFrame = None, **kwargs): # 直接创建新实例,不使用缓存 return super(PageTemplate, cls).__new__(cls) @@ -87,7 +89,27 @@ def create_ui_components(self): if self.content_widget_class: self._ensure_scroll_area() self._show_loading_placeholder() - QTimer.singleShot(0, self.create_content) + self.create_content() + + @classmethod + def _resolve_content_widget_class(cls, content_widget_class): + if isinstance(content_widget_class, str): + cached_cls = cls._resolved_content_class_cache.get(content_widget_class) + if cached_cls is not None: + return cached_cls, content_widget_class + + if ":" in content_widget_class: + module_name, attr = content_widget_class.split(":", 1) + else: + module_name, attr = content_widget_class.rsplit(".", 1) + module = importlib.import_module(module_name) + resolved_class = getattr(module, attr) + cls._resolved_content_class_cache[content_widget_class] = resolved_class + return resolved_class, content_widget_class + + resolved_class = content_widget_class + resolved_name = getattr(resolved_class, "__name__", str(resolved_class)) + return resolved_class, resolved_name def _ensure_scroll_area(self): """确保滚动区域已创建 - 延迟创建以减少内存使用""" @@ -134,20 +156,9 @@ def create_content(self): # -> 动态导入模块并获取类 start = time.perf_counter() try: - content_cls = None - content_name = None - if isinstance(self.content_widget_class, str): - path = self.content_widget_class - content_name = path - if ":" in path: - module_name, attr = path.split(":", 1) - else: - module_name, attr = path.rsplit(".", 1) - module = importlib.import_module(module_name) - content_cls = getattr(module, attr) - else: - content_cls = self.content_widget_class - content_name = getattr(content_cls, "__name__", str(content_cls)) + content_cls, content_name = self._resolve_content_widget_class( + self.content_widget_class + ) # 如果内容组件尚未创建,创建并添加到布局 if not self.content_created: @@ -301,6 +312,8 @@ class PivotPageTemplate(QFrame): - 支持按需重新加载 """ + _resolved_page_class_cache = {} + def __init__(self, page_config: dict, parent: QFrame = None, is_preview_mode=False): """ 初始化 Pivot 页面模板 @@ -469,6 +482,18 @@ def _show_page_loading_placeholder( label.setAlignment(Qt.AlignmentFlag.AlignCenter) inner_layout.addWidget(label) + @classmethod + def _resolve_page_widget_class(cls, base_path: str, page_name: str): + cache_key = (base_path, page_name) + cached_cls = cls._resolved_page_class_cache.get(cache_key) + if cached_cls is not None: + return cached_cls + + module = importlib.import_module(f"{base_path}.{page_name}") + resolved_class = getattr(module, page_name) + cls._resolved_page_class_cache[cache_key] = resolved_class + return resolved_class + def _load_page_content( self, page_name: str, @@ -491,8 +516,9 @@ def _load_page_content( try: # 动态导入页面组件 start = time.perf_counter() - module = importlib.import_module(f"{self.base_path}.{page_name}") - content_widget_class = getattr(module, page_name) + content_widget_class = self._resolve_page_widget_class( + self.base_path, page_name + ) # 创建页面组件 widget = content_widget_class(self) @@ -603,14 +629,11 @@ def switch_to_page(self, page_name: str): ): self._pending_page_loads.add(page_name) self._show_page_loading_placeholder(info["layout"], info["display"]) - QTimer.singleShot( - 0, - lambda n=page_name, i=info: self._load_page_content( - n, - i["display"], - i["scroll"], - i["layout"], - ), + self._load_page_content( + page_name, + info["display"], + info["scroll"], + info["layout"], ) def _unload_excess_pages(self, exclude_page: str = None): diff --git a/app/view/settings/settings.py b/app/view/settings/settings.py index c394e203..217e6abe 100644 --- a/app/view/settings/settings.py +++ b/app/view/settings/settings.py @@ -755,21 +755,9 @@ def _create_page_placeholder( """ interface = self._make_placeholder(interface_attr) setattr(self, interface_attr, interface) - - def make_factory(method_name=page_method, iface=interface): - def factory(parent=iface, is_preview=False): - page_instance = getattr(settings_window_page, method_name)( - parent, is_preview=is_preview - ) - return page_instance - - return factory - - self._deferred_factories[interface_attr] = make_factory() - self._deferred_factories_meta[interface_attr] = { - "is_pivot": is_pivot, - "is_preview": False, - } + self._register_deferred_factory( + interface_attr, page_method, is_pivot, settings_window_page + ) def _make_placeholder(self, name: str): """创建占位符组件 @@ -811,22 +799,14 @@ def _create_special_pages(self, settings_window_page): settings_window_page: 设置窗口页面模块 """ self.updateInterface = self._make_placeholder("updateInterface") - self._deferred_factories["updateInterface"] = self._make_page_factory( - "update_page", self.updateInterface, settings_window_page + self._register_deferred_factory( + "updateInterface", "update_page", False, settings_window_page ) - self._deferred_factories_meta["updateInterface"] = { - "is_pivot": False, - "is_preview": False, - } self.aboutInterface = self._make_placeholder("aboutInterface") - self._deferred_factories["aboutInterface"] = self._make_page_factory( - "about_page", self.aboutInterface, settings_window_page + self._register_deferred_factory( + "aboutInterface", "about_page", False, settings_window_page ) - self._deferred_factories_meta["aboutInterface"] = { - "is_pivot": False, - "is_preview": False, - } def _make_page_factory(self, page_method, interface, settings_window_page): """创建页面工厂函数 @@ -848,6 +828,32 @@ def factory(parent=interface, is_preview=False): return factory + def _register_deferred_factory( + self, interface_attr, page_method, is_pivot, settings_window_page + ) -> None: + interface = getattr(self, interface_attr, None) + if interface is None: + return + + self._deferred_factories[interface_attr] = self._make_page_factory( + page_method, interface, settings_window_page + ) + self._deferred_factories_meta[interface_attr] = { + "is_pivot": is_pivot, + "is_preview": False, + } + + def _get_page_factory_definition(self, page_name: str): + for _, interface_attr, page_method, is_pivot in self._get_page_configs(): + if interface_attr == page_name: + return page_method, is_pivot + + special_pages = { + "updateInterface": ("update_page", False), + "aboutInterface": ("about_page", False), + } + return special_pages.get(page_name) + def _setup_background_warmup(self): """设置后台预热""" try: @@ -1187,63 +1193,17 @@ def _restore_page_factory(self, page_name: str, container): """ from app.page_building import settings_window_page - factory_mapping = { - "basicSettingsInterface": lambda p=container, - is_preview=False: settings_window_page.basic_settings_page( - p, is_preview=is_preview - ), - "listManagementInterface": lambda p=container, - is_preview=False: settings_window_page.list_management_page( - p, is_preview=is_preview - ), - "extractionSettingsInterface": lambda p=container, - is_preview=False: settings_window_page.extraction_settings_page( - p, is_preview=is_preview - ), - "floatingWindowManagementInterface": lambda p=container, - is_preview=False: settings_window_page.floating_window_management_page( - p, is_preview=is_preview - ), - "notificationSettingsInterface": lambda p=container, - is_preview=False: settings_window_page.notification_settings_page( - p, is_preview=is_preview - ), - "safetySettingsInterface": lambda p=container, - is_preview=False: settings_window_page.safety_settings_page( - p, is_preview=is_preview - ), - "voiceSettingsInterface": lambda p=container, - is_preview=False: settings_window_page.voice_settings_page( - p, is_preview=is_preview - ), - "themeManagementInterface": lambda p=container, - is_preview=False: settings_window_page.theme_management_page( - p, is_preview=is_preview - ), - "historyInterface": lambda p=container, - is_preview=False: settings_window_page.history_page( - p, is_preview=is_preview - ), - "moreSettingsInterface": lambda p=container, - is_preview=False: settings_window_page.more_settings_page( - p, is_preview=is_preview - ), - "updateInterface": lambda p=container, - is_preview=False: settings_window_page.update_page( - p, is_preview=is_preview - ), - "aboutInterface": lambda p=container, - is_preview=False: settings_window_page.about_page(p, is_preview=is_preview), - "courseSettingsInterface": lambda p=container, - is_preview=False: settings_window_page.linkage_settings_page( - p, is_preview=is_preview - ), - } + page_definition = self._get_page_factory_definition(page_name) + if page_definition is None: + return - if page_name in factory_mapping: - if not hasattr(self, "_deferred_factories"): - self._deferred_factories = {} - self._deferred_factories[page_name] = factory_mapping[page_name] + if container is not None: + setattr(self, page_name, container) + + page_method, is_pivot = page_definition + self._register_deferred_factory( + page_name, page_method, is_pivot, settings_window_page + ) def _create_deferred_page(self, name: str): """根据名字创建对应延迟工厂并把结果加入占位容器 From 25ddc3fb32eb8fae023c1c9afa3bca36911f6b83 Mon Sep 17 00:00:00 2001 From: SakuraStar Date: Fri, 13 Mar 2026 20:32:08 +0800 Subject: [PATCH 8/9] perf: streamline startup initialization flow --- app/core/app_init.py | 110 +++++++++++++++------------------- app/core/window_manager.py | 34 ++++------- app/view/main/window.py | 31 +++++++--- app/view/settings/settings.py | 41 +++++-------- 4 files changed, 99 insertions(+), 117 deletions(-) diff --git a/app/core/app_init.py b/app/core/app_init.py index ac821c31..a5c09b18 100644 --- a/app/core/app_init.py +++ b/app/core/app_init.py @@ -37,40 +37,60 @@ def _manage_settings_file(self) -> None: def _schedule_initialization_tasks(self) -> None: """调度所有初始化任务""" - self._apply_font_settings() - self._load_theme() - self._load_theme_color() - self._clear_restart_record() + guide_completed = readme_settings_async("basic_settings", "guide_completed") + init_delay = 0 if not guide_completed else APP_INIT_DELAY + + if init_delay > 0: + QTimer.singleShot(init_delay, self._run_startup_phase) + else: + self._run_startup_phase() + self._register_post_show_tasks() - self._create_main_window() def _register_post_show_tasks(self) -> None: self.window_manager.register_after_first_window_shown( - lambda: QTimer.singleShot( - APP_INIT_DELAY, - lambda: safe_execute( - lambda: check_for_updates_on_startup(None), - error_message="检查更新失败", - ), - ) + self._run_post_first_window_tasks ) - self.window_manager.register_after_first_window_shown( - lambda: QTimer.singleShot( - APP_INIT_DELAY + 1500, - lambda: safe_execute( - self._do_warmup_face_detector_devices, - error_message="预热摄像头设备失败", - ), - ) + + def _run_startup_phase(self) -> None: + """执行首窗显示前的关键初始化任务。""" + startup_steps = ( + (apply_font_settings, "应用字体设置失败"), + (self._apply_theme, "加载主题失败"), + (self._apply_theme_color, "加载主题颜色失败"), + (self._clear_restart_record_now, "清除重启记录失败"), + (self.window_manager.create_main_window, "创建主窗口失败"), ) - def _load_theme(self) -> None: - """加载主题设置""" + for step, error_message in startup_steps: + safe_execute(step, error_message=error_message) + + def _run_post_first_window_tasks(self) -> None: + """在首个窗口显示后启动非关键任务。""" + safe_execute( + self._run_main_window_post_show_tasks, + error_message="启动主窗口延后任务失败", + ) + safe_execute( + lambda: check_for_updates_on_startup(None), + error_message="检查更新失败", + ) QTimer.singleShot( - APP_INIT_DELAY, - lambda: safe_execute(self._apply_theme, error_message="加载主题失败"), + 1500, + lambda: safe_execute( + self._do_warmup_face_detector_devices, + error_message="预热摄像头设备失败", + ), ) + def _run_main_window_post_show_tasks(self) -> None: + main_window = getattr(self.window_manager, "main_window", None) + if main_window is None: + return + + if hasattr(main_window, "schedule_post_startup_tasks"): + main_window.schedule_post_startup_tasks() + def _apply_theme(self) -> None: """应用主题设置""" from qfluentwidgets import setTheme, Theme @@ -84,49 +104,15 @@ def _apply_theme(self) -> None: setTheme(Theme.LIGHT) ensure_application_font_point_size() - def _load_theme_color(self) -> None: + def _apply_theme_color(self) -> None: """加载主题颜色""" from qfluentwidgets import setThemeColor - QTimer.singleShot( - APP_INIT_DELAY, - lambda: safe_execute( - lambda: setThemeColor( - readme_settings_async("basic_settings", "theme_color") - ), - error_message="加载主题颜色失败", - ), - ) + setThemeColor(readme_settings_async("basic_settings", "theme_color")) - def _clear_restart_record(self) -> None: + def _clear_restart_record_now(self) -> None: """清除重启记录""" - QTimer.singleShot( - APP_INIT_DELAY, - lambda: safe_execute( - lambda: remove_record("", "", "", "restart"), - error_message="清除重启记录失败", - ), - ) - - def _create_main_window(self) -> None: - """创建主窗口实例(但不自动显示)""" - guide_completed = readme_settings_async("basic_settings", "guide_completed") - init_delay = 0 if not guide_completed else APP_INIT_DELAY - QTimer.singleShot( - init_delay, - lambda: safe_execute( - self.window_manager.create_main_window, error_message="创建主窗口失败" - ), - ) - - def _apply_font_settings(self) -> None: - """应用字体设置""" - guide_completed = readme_settings_async("basic_settings", "guide_completed") - init_delay = 0 if not guide_completed else APP_INIT_DELAY - QTimer.singleShot( - init_delay, - lambda: safe_execute(apply_font_settings, error_message="应用字体设置失败"), - ) + remove_record("", "", "", "restart") def _do_warmup_face_detector_devices(self) -> None: from app.common.camera_preview_backend import warmup_camera_devices_async diff --git a/app/core/window_manager.py b/app/core/window_manager.py index 60127815..182819c5 100644 --- a/app/core/window_manager.py +++ b/app/core/window_manager.py @@ -2,7 +2,6 @@ import time from typing import Optional, Callable, TYPE_CHECKING from loguru import logger -from PySide6.QtCore import QTimer from app.tools.settings_access import readme_settings_async from app.core.utils import safe_execute, safe_close_window, activate_window @@ -43,9 +42,9 @@ def register_after_first_window_shown(self, callback: Callable[[], None]) -> Non if self._after_first_window_shown_ran: try: - QTimer.singleShot(0, callback) - except Exception: - pass + callback() + except Exception as e: + logger.debug("执行首次窗口显示回调失败(已忽略): {}", e) return self._after_first_window_shown_callbacks.append(callback) @@ -177,19 +176,16 @@ def _configure_main_window_display(self) -> None: ) is_maximized = readme_settings_async("window", "is_maximized") if show_startup_window: + startup_page_name = None + if hasattr(self.main_window, "_get_default_startup_page_name"): + startup_page_name = self.main_window._get_default_startup_page_name() + self._ensure_main_window_page(startup_page_name) if is_maximized: - from app.tools.variable import APP_INIT_DELAY - - def show_main_window(): - try: - self.main_window.showMaximized() - finally: - self._schedule_main_window_shown_tasks() - - QTimer.singleShot(APP_INIT_DELAY, show_main_window) + self.main_window.showMaximized() + self._handle_main_window_shown() else: self.main_window.show() - self._schedule_main_window_shown_tasks() + self._handle_main_window_shown() startup_display_float = readme_settings_async( "floating_window_management", "startup_display_floating_window" @@ -200,13 +196,7 @@ def show_main_window(): if self.float_window is not None and not self.float_window.isVisible(): self.float_window.show() if not show_startup_window: - self._schedule_main_window_shown_tasks() - - def _schedule_main_window_shown_tasks(self) -> None: - try: - QTimer.singleShot(0, self._handle_main_window_shown) - except Exception: - pass + self._handle_main_window_shown() def _handle_main_window_shown(self) -> None: global pending_uiaccess_restart_after_show @@ -265,7 +255,7 @@ def _run_after_first_window_shown_callbacks(self) -> None: for callback in callbacks: try: - QTimer.singleShot(0, callback) + callback() except Exception as e: logger.debug("执行首次窗口显示回调失败(已忽略): {}", e) diff --git a/app/view/main/window.py b/app/view/main/window.py index 080072b9..85b046b6 100644 --- a/app/view/main/window.py +++ b/app/view/main/window.py @@ -11,7 +11,6 @@ from app.common.shortcut import ShortcutManager from app.tools.variable import ( MINIMUM_WINDOW_SIZE, - APP_INIT_DELAY, PRE_CLASS_RESET_INTERVAL_MS, RESIZE_TIMER_DELAY_MS, MAXIMIZE_RESTORE_DELAY_MS, @@ -74,8 +73,7 @@ def __init__(self, float_window: LevitationWindow, url_handler_instance=None): self._setup_url_handler() self._setup_tray() self._setup_float_window(float_window) - - QTimer.singleShot(APP_INIT_DELAY, lambda: (self.createSubInterface())) + self.createSubInterface() def _initialize_variables(self): """初始化实例变量""" @@ -90,6 +88,7 @@ def _initialize_variables(self): self._page_factories = {} self._registered_pages = set() self._has_been_shown = False + self._post_startup_tasks_scheduled = False self.pre_class_reset_performed = False def _setup_timers(self): @@ -103,12 +102,19 @@ def _setup_timers(self): self.pre_class_reset_timer = QTimer(self) self.pre_class_reset_timer.timeout.connect(self._check_pre_class_reset) - QTimer.singleShot(1000, self._init_pre_class_reset) - self._auto_backup_running = False self.backup_timer = QTimer(self) self.backup_timer.timeout.connect(self._check_and_run_auto_backup) - self.backup_timer.start(60 * 60 * 1000) + + def schedule_post_startup_tasks(self): + """在首个窗口可见后启动非关键后台任务。""" + if self._post_startup_tasks_scheduled: + return + + self._post_startup_tasks_scheduled = True + self._init_pre_class_reset() + if not self.backup_timer.isActive(): + self.backup_timer.start(60 * 60 * 1000) QTimer.singleShot(5000, self._check_and_run_auto_backup) def _check_and_run_auto_backup(self): @@ -538,7 +544,6 @@ def createSubInterface(self): except Exception: pass self._sub_interface_created = True - self._ensure_main_page_loaded("roll_call_page") def _register_main_page_shell(self, page_name: str): shell = QWidget(self) @@ -602,6 +607,18 @@ def _get_main_page(self, page_name: str, load: bool = False): return None return self._ensure_main_page_loaded(page_name) + def _get_default_startup_page_name(self): + preferred_pages = ( + "roll_call_page", + "lottery_page", + "camera_preview_page", + "history_page", + ) + for page_name in preferred_pages: + if page_name in self._registered_pages: + return page_name + return None + def _show_and_switch_to_page(self, page_name: str): shell = self._get_main_page_shell(page_name) if shell is None or page_name not in self._registered_pages: diff --git a/app/view/settings/settings.py b/app/view/settings/settings.py index 217e6abe..0a2112d6 100644 --- a/app/view/settings/settings.py +++ b/app/view/settings/settings.py @@ -16,13 +16,10 @@ from app.tools.variable import ( MINIMUM_WINDOW_SIZE, - APP_INIT_DELAY, RESIZE_TIMER_DELAY_MS, MAXIMIZE_RESTORE_DELAY_MS, SETTINGS_WINDOW_DEFAULT_WIDTH, SETTINGS_WINDOW_DEFAULT_HEIGHT, - SETTINGS_WARMUP_DELAY_MS, - SETTINGS_DEFAULT_PAGE_DELAY_MS, ) from app.tools.path_utils import get_data_path from app.tools.personalised import get_theme_icon @@ -63,8 +60,7 @@ def __init__(self, parent=None, is_preview=False): self._setup_url_handler() self._position_window(snapshot=self._startup_settings_snapshot) self._setup_splash_screen() - - QTimer.singleShot(APP_INIT_DELAY, lambda: (self.createSubInterface())) + self.createSubInterface() # ================================================== # 初始化方法 @@ -298,7 +294,10 @@ def _setup_splash_screen(self): self.splashScreen = SplashScreen(self.windowIcon(), self) self.splashScreen.setIconSize(QSize(256, 256)) - self.show() + if getattr(self, "_show_maximized_on_init", False): + self.showMaximized() + else: + self.show() # ================================================== # 属性访问器 @@ -367,8 +366,9 @@ def _position_window(self, snapshot=None): ) self.resize(pre_maximized_width, pre_maximized_height) self._center_window() - QTimer.singleShot(APP_INIT_DELAY, self.showMaximized) + self._show_maximized_on_init = True else: + self._show_maximized_on_init = False setting_window_width = settings_section.get("width") if setting_window_width is None: setting_window_width = readme_settings_async("settings", "width") @@ -546,6 +546,7 @@ def _handle_settings_page_request(self, page_name: str): nav_item = getattr(self, item_attr, None) if interface and nav_item: + self._ensure_deferred_page_loaded(interface_attr) logger.debug(f"切换到设置页面: {page_name}") self.switchTo(interface) trace.log("shell_visible") @@ -856,25 +857,11 @@ def _get_page_factory_definition(self, page_name: str): def _setup_background_warmup(self): """设置后台预热""" - try: - QTimer.singleShot( - SETTINGS_WARMUP_DELAY_MS, lambda: self._background_warmup_non_pivot() - ) - except Exception as e: - logger.exception("Error during settings warmup: {}", e) - try: self.stackedWidget.currentChanged.connect(self._on_stacked_widget_changed) except Exception as e: logger.exception("Error creating deferred page: {}", e) - try: - QTimer.singleShot( - SETTINGS_WARMUP_DELAY_MS, lambda: self._background_warmup_pages() - ) - except Exception as e: - logger.exception("Error scheduling background warmup pages: {}", e) - def initNavigation(self): """初始化导航系统 根据用户设置构建个性化菜单导航""" @@ -898,9 +885,6 @@ def initNavigation(self): self.splashScreen.finish() self.showMainPageRequested.connect(self._handle_main_page_requested) - if hasattr(self, "basicSettingsInterface") and self.basicSettingsInterface: - QTimer.singleShot(SETTINGS_DEFAULT_PAGE_DELAY_MS, self._load_default_page) - def _get_nav_configs(self): """获取导航配置列表 @@ -1048,8 +1032,7 @@ def _add_navigation_item( def _load_default_page(self): """加载默认页面(基础设置页面)""" try: - if "basicSettingsInterface" in getattr(self, "_deferred_factories", {}): - self._create_deferred_page("basicSettingsInterface") + self._ensure_deferred_page_loaded("basicSettingsInterface") if hasattr(self, "basicSettingsInterface") and self.basicSettingsInterface: self.switchTo(self.basicSettingsInterface) @@ -1250,6 +1233,12 @@ def _create_deferred_page(self, name: str): except Exception as e: logger.exception(f"_create_deferred_page 失败: {e}") + def _ensure_deferred_page_loaded(self, name: str) -> None: + if name in getattr(self, "_created_pages", {}): + return + if name in getattr(self, "_deferred_factories", {}): + self._create_deferred_page(name) + def _find_container_by_name(self, name: str): """根据名称查找容器 From b85975092ec2eed0a158f8743fe51d8915673a57 Mon Sep 17 00:00:00 2001 From: SakuraStar Date: Fri, 13 Mar 2026 20:35:47 +0800 Subject: [PATCH 9/9] style: apply ruff formatting --- app/common/camera_preview_backend/detection.py | 5 +---- app/common/camera_preview_backend/workers.py | 4 +--- 2 files changed, 2 insertions(+), 7 deletions(-) diff --git a/app/common/camera_preview_backend/detection.py b/app/common/camera_preview_backend/detection.py index 8b2af636..16848d23 100644 --- a/app/common/camera_preview_backend/detection.py +++ b/app/common/camera_preview_backend/detection.py @@ -599,10 +599,7 @@ def _rescale_rects( h, w = int(frame_size[0]), int(frame_size[1]) if not rects: return [] - if ( - abs(float(scale_x) - 1.0) < 1e-6 - and abs(float(scale_y) - 1.0) < 1e-6 - ): + if abs(float(scale_x) - 1.0) < 1e-6 and abs(float(scale_y) - 1.0) < 1e-6: return list(rects) scaled: list[Rect] = [] for x, y, bw, bh in rects: diff --git a/app/common/camera_preview_backend/workers.py b/app/common/camera_preview_backend/workers.py index e933763c..11e21035 100644 --- a/app/common/camera_preview_backend/workers.py +++ b/app/common/camera_preview_backend/workers.py @@ -658,9 +658,7 @@ def _consume_pending_frame(self) -> None: return finally: cost_s = max(0.0, time.perf_counter() - started_at) - self._recent_detect_cost_s = ( - self._recent_detect_cost_s * 0.7 + cost_s * 0.3 - ) + self._recent_detect_cost_s = self._recent_detect_cost_s * 0.7 + cost_s * 0.3 if self._recent_detect_cost_s > self._detect_interval_s * 1.05: desired = min( self._max_interval_s,