Files
iAOP/templates/ti-cl4/perf-budget/lazy_load.py
T
bot_dev1 5da367dfd8 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:冒烟验证达标/超预算/懒加载分流。
2026-08-05 05:26:09 +08:00

329 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- 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),
)