feat(#54): 性能预算与懒加载策略引擎(首屏≤2s,图表懒加载)

纯标准库实现,对齐 PRD 5.5「⑤ 配置化驾驶舱」性能口径:

- perf_budget.py:PerformanceBudget(首屏 2000ms + 6 类资源预算)、
  ResourceMeasurement(block_render 区分关键路径)、BudgetVerifier
  (阻塞串行+非阻塞取最大口径,逐类型比对预算,PASS/FAIL/EMPTY +
  类型化优化建议,reason 可解释)。
- lazy_load.py:LazyLoadStrategy(immediate/visible/idle/never)、
  LazyLoadPolicy(视口行数+预加载+分页阈值+虚拟滚动)、LazyLoadPlanner
  (widget 坐标+滚动位置 → 决策+分页计划+虚拟滚动窗口)。
- tests:32 用例覆盖预算构造/查询、关键路径口径、达标/超预算场景、
  always_load 强制加载、视口边界、分页/虚拟滚动、输入校验。
- _sanity_check.py:冒烟验证达标/超预算/懒加载分流。
This commit is contained in:
2026-08-05 05:26:09 +08:00
parent 2afea16005
commit 5da367dfd8
8 changed files with 1286 additions and 0 deletions
+328
View File
@@ -0,0 +1,328 @@
# -*- coding: utf-8 -*-
"""图表 / Widget 懒加载策略引擎(Issue #54 / PRD 5.5「⑤ 配置化驾驶舱」)。
PRD 5.5 验收口径含「图表懒加载」:首屏只渲染视口(viewport)内的图表,
视口外的 widget(趋势/KPI/告警/NL 查询)推迟到滚动可见或空闲时加载,
把首屏 JS/接口预算留给工艺流程主视图。本模块把该口径落为**可判定的懒加载
策略引擎**——给定 widget 的布局坐标 + 视口尺寸 + 滚动位置,决定每个 widget
的加载时机(immediate/visible/idle/never),并产出分页/虚拟滚动计划。
设计要点
--------
1. **懒加载策略即规格**(``LazyLoadStrategy``):4 种加载时机:
- ``immediate`` 首屏立即加载(工艺流程主视图);
- ``visible`` 进入视口时加载(IntersectionObserver 口径);
- ``idle`` 浏览器空闲时加载(requestIdleCallback);
- ``never`` 不加载(隐藏 tab/折叠面板内的 widget)。
2. **策略规则**(``LazyLoadPolicy``):可配置——首屏视口高度、根边距
(rootMargin,预加载视口外 N px)、分页阈值(视口外 widget 超过阈值则
分页加载)、虚拟滚动开关。
3. **决策器**(``LazyLoadPlanner``):对一组 widget(坐标 + 尺寸 + 滚动位置)
逐个判定策略,产出 :class:`LazyLoadDecision`(含 reason 可解释)+ 分页计划
+ 虚拟滚动窗口。
4. **纯标准库**:无依赖,与 iAOP 零运行时依赖原则一致。
用法::
policy = LazyLoadPolicy.default_cockpit()
plan = LazyLoadPlanner(policy).plan(widgets, viewport_h=900, scroll_y=0)
for d in plan.decisions:
print(d.widget_id, d.strategy.value, d.reason)
"""
from __future__ import annotations
from dataclasses import dataclass, field
from enum import Enum
from typing import Dict, List, Optional, Sequence, Tuple
class LazyLoadError(ValueError):
"""懒加载策略/决策错误(坐标非法、视口尺寸非正等)。"""
class LazyLoadStrategy(str, Enum):
"""Widget 加载时机(懒加载策略取值)。"""
IMMEDIATE = "immediate" # 首屏立即加载
VISIBLE = "visible" # 进入视口时加载
IDLE = "idle" # 浏览器空闲时加载
NEVER = "never" # 不加载(隐藏/折叠)
@property
def label(self) -> str:
return {
LazyLoadStrategy.IMMEDIATE: "首屏立即加载",
LazyLoadStrategy.VISIBLE: "进入视口加载",
LazyLoadStrategy.IDLE: "空闲时加载",
LazyLoadStrategy.NEVER: "不加载",
}[self]
@dataclass
class WidgetLayout:
"""单个 widget 的布局坐标(12 列网格 + 行坐标,对齐 cockpit layout v1)。
Attributes:
widget_id: widget 唯一 id(对应布局资产里的 widget)。
kind: widget 类型(process_view/trend/kpi_card/alarm_panel/nl_query)。
y: 纵向起始行(像素化的行号 × row_height_px 近似视口位置)。
h: 纵向占行数(h × row_height_px = 视口内高度 px)。
priority: 优先级(0 最高,默认 5;用于 idle 排序)。
always_load: 强制立即加载(如工艺流程主视图,覆盖视口判定)。
"""
widget_id: str
kind: str
y: float
h: float = 1.0
priority: int = 5
always_load: bool = False
def __post_init__(self) -> None:
if not self.widget_id:
raise LazyLoadError("WidgetLayout.widget_id 不能为空")
if self.h <= 0:
raise LazyLoadError(
f"widget {self.widget_id!r} h 必须 > 0,实际 {self.h}")
if self.y < 0:
raise LazyLoadError(
f"widget {self.widget_id!r} y 不能为负,实际 {self.y}")
@dataclass
class LazyLoadPolicy:
"""懒加载策略规则(可配置,换行业只改规则不改前端代码)。
Attributes:
first_viewport_rows: 首屏视口覆盖的行数(视口高度 / 行高,默认 4 行)。
preload_rows: rootMargin 预加载视口外的行数(默认 1 行预加载)。
idle_batch: 空闲时每批加载的 widget 数(默认 2)。
pagination_threshold: 视口外 widget 数超过该阈值启用分页加载(默认 6)。
page_size: 分页大小(每页 widget 数,默认 4)。
virtual_scroll: 是否启用虚拟滚动(视口外不挂载 DOM)。
"""
first_viewport_rows: float = 4.0
preload_rows: float = 1.0
idle_batch: int = 2
pagination_threshold: int = 6
page_size: int = 4
virtual_scroll: bool = True
def __post_init__(self) -> None:
if self.first_viewport_rows <= 0:
raise LazyLoadError(
f"first_viewport_rows 必须 > 0,实际 {self.first_viewport_rows}")
if self.preload_rows < 0:
raise LazyLoadError(
f"preload_rows 不能为负,实际 {self.preload_rows}")
if self.idle_batch <= 0:
raise LazyLoadError(
f"idle_batch 必须 > 0,实际 {self.idle_batch}")
if self.pagination_threshold < 0:
raise LazyLoadError(
f"pagination_threshold 不能为负,实际 {self.pagination_threshold}")
if self.page_size <= 0:
raise LazyLoadError(
f"page_size 必须 > 0,实际 {self.page_size}")
@classmethod
def default_cockpit(cls) -> "LazyLoadPolicy":
"""驾驶舱默认懒加载策略(首屏 4 行视口 + 1 行预加载 + 分页/虚拟滚动)。"""
return cls(
first_viewport_rows=4.0,
preload_rows=1.0,
idle_batch=2,
pagination_threshold=6,
page_size=4,
virtual_scroll=True,
)
@dataclass
class LazyLoadDecision:
"""单个 widget 的懒加载决策(含 reason 可解释)。"""
widget_id: str
kind: str
strategy: LazyLoadStrategy
in_viewport: bool
page: Optional[int] = None # 分页加载时的页码(从 1 起);None=不分页
reason: str = "" # 为何选该策略(事实陈述)
def to_dict(self) -> dict:
return {
"widget_id": self.widget_id,
"kind": self.kind,
"strategy": self.strategy.value,
"in_viewport": self.in_viewport,
"page": self.page,
"reason": self.reason,
}
@dataclass
class LazyLoadPlan:
"""懒加载计划:全部 widget 决策 + 分页信息 + 虚拟滚动窗口。"""
decisions: List[LazyLoadDecision] = field(default_factory=list)
pages: Dict[int, List[str]] = field(default_factory=dict) # page → widget_ids
virtual_window: Optional[Tuple[float, float]] = None # (y_start, y_end)
reason: str = ""
@property
def immediate_ids(self) -> List[str]:
"""首屏立即加载的 widget id。"""
return [d.widget_id for d in self.decisions
if d.strategy is LazyLoadStrategy.IMMEDIATE]
@property
def visible_ids(self) -> List[str]:
return [d.widget_id for d in self.decisions
if d.strategy is LazyLoadStrategy.VISIBLE]
@property
def idle_ids(self) -> List[str]:
return [d.widget_id for d in self.decisions
if d.strategy is LazyLoadStrategy.IDLE]
@property
def never_ids(self) -> List[str]:
return [d.widget_id for d in self.decisions
if d.strategy is LazyLoadStrategy.NEVER]
def to_dict(self) -> dict:
return {
"decisions": [d.to_dict() for d in self.decisions],
"pages": {str(p): ids for p, ids in self.pages.items()},
"virtual_window": (list(self.virtual_window)
if self.virtual_window else None),
"reason": self.reason,
"counts": {
"immediate": len(self.immediate_ids),
"visible": len(self.visible_ids),
"idle": len(self.idle_ids),
"never": len(self.never_ids),
},
}
class LazyLoadPlanner:
"""懒加载决策器:widget 布局 + 视口/滚动 → 加载计划。
Args:
policy: 懒加载策略规则。
row_height_px: 行高(px),用于把行号换算为视口像素;默认 120px。
"""
def __init__(self, policy: Optional[LazyLoadPolicy] = None,
row_height_px: float = 120.0) -> None:
self.policy = policy or LazyLoadPolicy.default_cockpit()
if row_height_px <= 0:
raise LazyLoadError(f"row_height_px 必须 > 0,实际 {row_height_px}")
self.row_height_px = float(row_height_px)
# ------------------------------------------------------------------
def in_viewport(self, w: WidgetLayout, scroll_y_rows: float) -> bool:
"""widget 是否在当前视口(含预加载边距)内。
视口范围:``[scroll_y - preload, scroll_y + first_viewport + preload]``。
"""
top = w.y
bottom = w.y + w.h
vp_top = scroll_y_rows - self.policy.preload_rows
vp_bottom = (scroll_y_rows
+ self.policy.first_viewport_rows
+ self.policy.preload_rows)
# 区间相交即视为视口内
return not (bottom <= vp_top or top >= vp_bottom)
# ------------------------------------------------------------------
def plan(self, widgets: Sequence[WidgetLayout],
scroll_y_rows: float = 0.0) -> LazyLoadPlan:
"""对所有 widget 生成懒加载决策 + 分页/虚拟滚动计划。
Args:
widgets: widget 布局列表(按 y 升序更稳定,但本方法不强求)。
scroll_y_rows: 当前滚动位置(以"行"为单位;scroll_y_px/row_height_px)。
"""
if scroll_y_rows < 0:
raise LazyLoadError(f"scroll_y_rows 不能为负,实际 {scroll_y_rows}")
if not widgets:
return LazyLoadPlan(reason="无 widget,无需懒加载计划")
policy = self.policy
decisions: List[LazyLoadDecision] = []
# 1) 判定每个 widget:always_load → immediate;在视口 → immediate/visible;
# 视口外 → idle/never
out_of_viewport: List[WidgetLayout] = []
for w in widgets:
in_vp = self.in_viewport(w, scroll_y_rows)
if w.always_load:
decisions.append(LazyLoadDecision(
widget_id=w.widget_id, kind=w.kind,
strategy=LazyLoadStrategy.IMMEDIATE, in_viewport=in_vp,
reason=f"{w.kind} 标记 always_load,强制首屏立即加载"))
continue
if in_vp:
decisions.append(LazyLoadDecision(
widget_id=w.widget_id, kind=w.kind,
strategy=LazyLoadStrategy.IMMEDIATE, in_viewport=True,
reason=f"{w.kind} 位于首屏视口(行 {w.y}~{w.y + w.h}),"
f"立即加载"))
else:
out_of_viewport.append(w)
# 2) 视口外 widget:视数量决定分页/虚拟滚动
use_pagination = (len(out_of_viewport) > policy.pagination_threshold)
# 按 y 排序后分页(页内顺序稳定)
sorted_out = sorted(out_of_viewport, key=lambda x: (x.y, x.priority))
if use_pagination:
pages: Dict[int, List[str]] = {}
for idx, w in enumerate(sorted_out):
page_no = idx // policy.page_size + 1
pages.setdefault(page_no, []).append(w.widget_id)
decisions.append(LazyLoadDecision(
widget_id=w.widget_id, kind=w.kind,
strategy=LazyLoadStrategy.IDLE, in_viewport=False,
page=page_no,
reason=f"{w.kind} 在视口外(行 {w.y}),分页加载第 {page_no} 页,"
f"空闲时按 batch={policy.idle_batch} 加载"))
else:
pages = {}
# 视口外但数量少 → idle 加载(不分页)
for w in sorted_out:
decisions.append(LazyLoadDecision(
widget_id=w.widget_id, kind=w.kind,
strategy=LazyLoadStrategy.IDLE, in_viewport=False,
reason=f"{w.kind} 在视口外(行 {w.y}),数量未达分页阈值"
f"({policy.pagination_threshold}),空闲时加载"))
# 3) 虚拟滚动窗口(仅视口 + 预加载边距内的 widget 挂载 DOM)
virtual_window: Optional[Tuple[float, float]] = None
if policy.virtual_scroll:
vp_top = max(0.0, scroll_y_rows - policy.preload_rows)
vp_bottom = (scroll_y_rows
+ policy.first_viewport_rows
+ policy.preload_rows)
virtual_window = (vp_top, vp_bottom)
reason_parts = [
f"视口 {policy.first_viewport_rows} 行 + 预加载 {policy.preload_rows} 行",
f"视口外 {len(out_of_viewport)} 个 widget",
]
if use_pagination:
reason_parts.append(
f"超分页阈值 {policy.pagination_threshold},分 {len(pages)} 页加载"
f"(每页 {policy.page_size})")
if virtual_window:
reason_parts.append(
f"虚拟滚动窗口行 {virtual_window[0]:.1f}~{virtual_window[1]:.1f}")
return LazyLoadPlan(
decisions=decisions,
pages=pages,
virtual_window=virtual_window,
reason=";".join(reason_parts),
)