图表能显示七根柱子,并不意味着它可以可靠地交互。用户在“衡能”看板里选中“周五”,明细卡片显示 6.6 kWh,随即切换到“今天”,如果页面仍保存着旧的 selectedIndex = 4,四个时段的新图表根本没有第五项。更隐蔽的情况是:用户选择某根柱子后点击刷新,旧请求晚返回,页面虽然凭借上一轮的 requestSeq 挡住了过期响应,却没有处理“选中项是否仍属于当前数据快照”这个新问题。

“衡能”前三篇已经建立共同数据模型 EnergyBar { key, label, kwh }:近7日分别是 5.2、4.8、6.1、5.4、6.6、5.1、5.4,总量 38.6 kWh;今天四个时段为 1.4、1.6、2.2、1.0,总量 6.2 kWh。activeRange 指示筛选范围,displayBars 保存已经接受的结果,requestSeq 拦住迟到的 Promise,GridRow 在 720vp 断点下安排手机和平板布局。这一篇保持这些契约不变,只增加“点选柱子,明细联动”的交互层。

衡能交互式能耗图表封面

一、把选中索引换成稳定业务键

为什么用 selectedIndex 容易出错?因为索引描述的是当前位置,不是一条数据的身份。近7日有七根柱子,今天只有四根;未来如果添加排序、隐藏缺失值或合并设备,索引指向的内容还会随之变化。稳定键则不同:d5 只代表周五的那条示例记录,h16 只代表今天 16 时,两个范围从命名上就互不混淆。先用数据的 key 表达选择,再根据当前可展示数组查找对应记录,才有资格显示明细。

这也解释了为何不能直接把展示数组拆成两份:图表卡片自己持有 bars,明细卡片又从另一份缓存计算。一旦异步刷新,两个组件可能分别接受不同版本的数据。更稳妥的安排是让 EnergyDashboard 继续拥有一个被认可的展示快照,图表和明细只根据它计算,不保存第二套能耗数字。刷新尚未完成期间,旧图表应统一隐藏或明确标记为历史数据;本篇沿用第三篇的清空策略,避免“旧明细 + 新筛选标题”的短暂错配。

图表上的柱子可以用不同颜色表达选中态,但颜色不能成为唯一信息。点击时同时显示“当前选择:周五 · 6.6 kWh”,这样在大字体、低亮度或屏幕阅读工具环境下,用户仍有文字线索。数字统一保留一位小数;不要因为绘制高度的浮点计算多出不必要的小数位,更不能把柱形像素高度当作电量。

二、父页面只保存一个 selectedKey

在 EnergyDashboard.ets 中,状态继续是 activeRange、displayBars、phase、message,增加 selectedKey。选择行为只有在 phase === 'ready' 且键存在于 displayBars 时才会生效;这里的双重判断很重要,避免刷新中来自旧组件的点击把无效键写回新页面。

interface EnergyBar { key: string; label: string; kwh: number; }
type RangeKey = '今天' | '近7日';
type LoadPhase = 'idle' | 'loading' | 'ready' | 'error';

// 以下字段和函数加入上篇 EnergyDashboard
@State private selectedKey: string = '';

private selectBar(key: string): void {
  if (this.phase !== 'ready') { return; }
  const exists: boolean = this.displayBars.some(
    (item: EnergyBar): boolean => item.key === key);
  if (!exists) { return; }
  this.selectedKey = key;
}

get selectedBar(): EnergyBar | undefined {
  if (this.phase !== 'ready') { return undefined; }
  return this.displayBars.find(
    (item: EnergyBar): boolean => item.key === this.selectedKey);
}

private choose(range: RangeKey): void {
  this.activeRange = range;
  this.selectedKey = '';
  void this.load(false);
}

上面是增量代码,EnergyBar 及枚举如果已经放在数据模块里,不要重复声明。核心是保证每次换范围时先让旧选中键失效。随后修改第三篇 load() 的成功分支:requestSeq 的比较必须发生在更新 displayBars 和 selectedKey 之前,只有通过检查的新结果才有资格确定默认选中项。失败分支清空选择并显示错误提示,不能把上一次的 6.6 kWh 继续放在失败页上冒充最新明细。

private async load(fail: boolean): Promise<void> {
  const seq: number = ++this.requestSeq;
  const range: RangeKey = this.activeRange;
  this.phase = 'loading';
  this.displayBars = [];
  this.selectedKey = '';
  this.message = '';
  try {
    const rows: EnergyBar[] = await this.source.load(range, fail);
    if (seq !== this.requestSeq) { return; }
    this.displayBars = rows;
    this.selectedKey = rows.length > 0 ? rows[0].key : '';
    this.phase = 'ready';
  } catch (err) {
    if (seq !== this.requestSeq) { return; }
    this.displayBars = [];
    this.selectedKey = '';
    this.phase = 'error';
    this.message = '加载失败,可点击重试';
  }
}

有一个常被忽略的细节:如果页面在同一范围内重复点击“刷新”,即便数据数组中的 key 没变,本篇仍刻意将选择重置到第一项。这是一种明确的产品策略,不是技术限制。将来如果要保留原选中项,可以先记住旧键,等新请求被接受后检查它是否仍存在,再决定沿用还是回退第一项。但这个判断必须发生在同一份新的 rows 上,不能在加载中先预设旧索引有效。

三、图表与明细不再各自维护数据

渲染使用 ForEach 的显式 keyGenerator,保证每根柱形由 bar.key 标识,而非数组下标或 JSON.stringify(bar)。图表组件接收 bars、selectedKey 和一个点击回调;它不会自己调用 DemoEnergySource,也不负责重新请求。这样一来,无论屏幕在 720vp 断点前后如何变换,改变的是卡片摆放方式,不会增加第二套请求或选中状态。

@Component
struct EnergyBarsPanel {
  @Prop bars: EnergyBar[] = [];
  @Prop selectedKey: string = '';
  onPick: (key: string) => void = (_: string) => {};

  private barHeight(kwh: number): number {
    const max: number = Math.max(1,
      ...this.bars.map((x: EnergyBar): number => x.kwh));
    return Math.max(10, Math.round(kwh / max * 140));
  }

  build() {
    Row({ space: 12 }) {
      ForEach(this.bars, (bar: EnergyBar) => {
        Column({ space: 8 }) {
          Column()
            .width(24)
            .height(this.barHeight(bar.kwh))
            .borderRadius(7)
            .backgroundColor(bar.key === this.selectedKey ?
              '#126C8D' : '#47C5AE')
          Text(bar.label).fontSize(12)
        }
        .onClick(() => this.onPick(bar.key))
      }, (bar: EnergyBar): string => bar.key)
    }
    .height(188)
    .alignItems(VerticalAlign.Bottom)
  }
}

父页面沿用此前 GridRow({columns:12, breakpoints:{value:['720vp'],reference:BreakpointsReference.WindowSize}}) 与 GridCol({span:{xs:12,sm:6}});图表所在的 chartPanel() 内替换为 EnergyBarsPanel({bars:this.displayBars, selectedKey:this.selectedKey, onPick:(key:string)=>this.selectBar(key)})。明细卡片则从 this.selectedBar 读取 label 和 kwh,找不到时明确显示“请选择柱形”。这里是组合到原页面的增量,已有 source、load()、筛选按钮、总量卡片都应保留。

衡能近7日柱形点选后的 9:16 界面

按钮的最小触控区域、焦点处理、颜色对比和系统字体缩放需要在真机与适配设备上进一步检查。示例柱形较窄,适合作为逻辑示例;真实应用可将整个柱形与标签所在列设置为点击区域,或补充列表式明细入口,避免小屏上难点选。图表如果以后换成第三方绘图库,也要让图层只负责报告选中 key,而非自行决定哪份异步结果应被接受。

能耗图表从范围、结果到稳定选中键的状态关系

四、用四条交互路径检验数据归属

第一条,默认“近7日”请求完成后默认选中周一,明细显示 5.2 kWh;点周五后显示 6.6 kWh,顶部累计仍是 38.6 kWh。单项明细与总量是两个维度,不能把选择某根柱子误认为改变总量筛选。第二条,从周五切到“今天”,旧的 d5 立即失效;新数据返回后默认选中 08时 · 1.4 kWh,今天累计是 6.2 kWh。第三条,快速切“今天 → 近7日”,迟到的“今天”结果仍由 requestSeq 丢弃;同样,过期回调绝不能改变 selectedKey。第四条,点击“构造失败”后,页面展示错误和重试按钮,不再显示过去的周五明细;重试成功后恢复当前范围第一条记录。

验证还要跨宽度:在 720vp 左右改变窗口,图表和明细可能从上下关系变为并排关系,但 selectedKey 和 displayBars 的来源保持不变。不要只拿截图证明状态一致,建议在调试时记录每次 requestSeq、activeRange 和被接受的 bar.key,但不可把拟真 UI 的文字当作已经取得的设备日志。这里的 800/300 毫秒只是前文 DemoEnergySource 用来构造乱序的示例延时,不构成响应速度指标。

本篇实际上为“衡能”建立了第三种数据边界:范围表示用户意图,快照表示被接受的结果,选中键表示当前快照中的焦点。三者分别维护、按顺序更新,才不会因换范围或异步返回而展示错位信息。下一篇若要进一步提升大屏阅读性、组件拆分或图表性能,仍可复用这套可核验的输入输出关系,而不必重新发明一套数据流。

还可以用一个很小的契约测试固定这套行为:给 EnergyBarsPanel 输入七条记录和 selectedKey='d5',预期选中颜色只落在第五根柱形;将父组件的数据换成四条 h 系列后,旧键在任何时候都不能形成明细。加载中、错误态和空数据态也应该采用同一套规则,不因组件自身残留局部状态而出现“看似选中、实际没有数据”的卡片。视觉方案可以随着设计规范调整,但键的归属检查不能被删掉。遇到不同数据源返回重复 key 时,应在适配器的校验阶段拒绝或规范化数据,而不是把重复值交给 ForEach 试运行;否则组件复用可能出现意料之外的结果。

参考华为开发者文档:ForEach 循环渲染与稳定键、ArkUI 状态管理概览、GridRow 布局。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐