EnergyNest 的七日图终于从示例数组接到了本地数据库,却出现了一个比布局更棘手的问题:当某天没有采集记录,界面应该画出多高的柱子?如果某天真实记录为零,又应该如何区别?如果某天突然读到远高于其余日期的电量,是否应该直接从周总量中删掉?这三个问题的答案决定了账本是否值得信任。上一章已经用 energy_day(day_key, wh, source, updated_at) 和日期唯一键完成了本地增量写入,本章延续相同表结构,不改动保存策略,而是在查询结果和视图之间增加一层可解释的数据质量整理。

EnergyNest 用电数据质量与异常峰值技术封面

一、缺失、零值和异常峰值必须分开建模

先沿用本系列的周区间 2026-11-02 至 2026-11-08,数据单位仍以数据库中的整数 Wh 为准。上篇更正后的七天记录依次是 4800、5600、6100、7200、8400、7900、7100 Wh;它们都是正常存储记录,合计 47100 Wh。本篇引入一组专门用于解释边界的预览场景:周三没有记录、周四有合法的零用电记录、周六出现 28600 Wh 的峰值,其他日期保留原来的数值。预览样本与数据库真实账本相互独立,不能在用户没有确认时自动写回本地表。

这组边界样本在七个日历位置里只有六条数据库意义上的记录,已记录总量是 54500 Wh,即 54.5 kWh;其中周四的零值参与已记录日的统计,周三没有数据,不参与均值分母。用 54.5 除以 6,得到“已记录日平均约 9.1 kWh”。如果除以七,则计算的是“日历日均摊”,两者并非同一指标。界面要明确命名,而不是把“9.1”不加说明放在卡片里。峰值在没有证据判定它不可信之前,也不应该偷偷删掉,否则总量与原始记录不一致。

wh = 0 是一条带有日期和来源的有效记录;“不存在该日主键”则是数据缺失。最忌讳的写法是 record?.wh || 0:这里把 undefined 和合法零值压成了同一条路径。即使渲染结果都没有柱高,语义也完全不同。为避免页面反复做空值判断,整理层把每个日历位置写成明确状态:missing、zero、normal、spike。异常峰值只是“需要核对”的提示,不是系统擅自宣布的电表故障。

// entry/src/main/ets/model/EnergyQuality.ets
export type QualityFlag = 'missing' | 'zero' | 'normal' | 'spike';

export interface EnergyRecord {
  date: string;
  wh: number;
  source: string;
  updatedAt: number;
}

export interface EnergyPoint {
  date: string;
  wh: number | null;
  flag: QualityFlag;
}

// 用于本文可复现的“异常模式预览”;不会写入 RdbStore。
export const previewRecords: EnergyRecord[] = [
  { date: '2026-11-02', wh: 4800, source: 'demo', updatedAt: 1 },
  { date: '2026-11-03', wh: 5600, source: 'demo', updatedAt: 1 },
  { date: '2026-11-05', wh: 0, source: 'demo', updatedAt: 1 },
  { date: '2026-11-06', wh: 8400, source: 'demo', updatedAt: 1 },
  { date: '2026-11-07', wh: 28600, source: 'demo', updatedAt: 1 },
  { date: '2026-11-08', wh: 7100, source: 'demo', updatedAt: 1 }
];

这里的 updatedAt: 1 只是预览对象的占位时间戳,不提供实际采样时间含义;页面仅用于示例分类,不应该用该值排序。真实查询仍调用上一章 EnergyRepository.list(from, to),按日期升序取得存储结果。这样模型从一开始就说明:预览样本是教学输入,数据库才是持久记录;两种来源可以经过同一套整理规则,却不能彼此冒名。

二、以日期日历为左表,将查询行合并成七个位置

上一章 ResultSet 返回什么行,页面就显示什么行,这适合完整数据,却无法自然展示“周三缺失”的占位。新方案反过来:先由业务区间生成七个日期键,再用 Map 根据 day_key 找到记录。这个步骤可以看成一个内存中的左连接——日历是左侧的完整结构,记录是右侧的可选内容。规则非常清楚:没找到记录时返回 wh: null;找到零值返回 zero;超过本篇示例阈值 18000 Wh 返回 spike;其余为 normal。

阈值不是统一的家居用电标准。18 kWh 只是本示例为了让 28.6 kWh 显示提示而设置的界面规则,正式项目必须结合户型、采集周期、设备基线和用户确认来校准。尤其是商用空调、蓄热设备和大功率充电场景,较高电量也可能完全正常。用固定阈值把它标黄,最终仍要让用户查看原始值与来源。

// entry/src/main/ets/model/EnergyQuality.ets 继续
export const WEEK_DATES: string[] = [
  '2026-11-02', '2026-11-03', '2026-11-04', '2026-11-05',
  '2026-11-06', '2026-11-07', '2026-11-08'
];

export function inspectWeek(records: EnergyRecord[]): EnergyPoint[] {
  const byDate: Map<string, EnergyRecord> = new Map();
  records.forEach((item: EnergyRecord) => {
    if (WEEK_DATES.includes(item.date) &&
        Number.isSafeInteger(item.wh) && item.wh >= 0) {
      byDate.set(item.date, item);
    }
  });
  return WEEK_DATES.map((date: string): EnergyPoint => {
    const found: EnergyRecord | undefined = byDate.get(date);
    if (found === undefined) {
      return { date: date, wh: null, flag: 'missing' };
    }
    if (found.wh === 0) {
      return { date: date, wh: 0, flag: 'zero' };
    }
    return {
      date: date, wh: found.wh,
      flag: found.wh > 18000 ? 'spike' : 'normal'
    };
  });
}

export function sumObservedWh(days: EnergyPoint[]): number {
  return days.reduce((sum: number, item: EnergyPoint) =>
    sum + (item.wh === null ? 0 : item.wh), 0);
}

export function observedCount(days: EnergyPoint[]): number {
  return days.filter((item: EnergyPoint) => item.wh !== null).length;
}

对数据库产生的重复日期记录,理论上前一章的唯一主键已经在存储层限制了它们。Map.set() 在相同日期再次出现时会覆盖旧对象,因此它不是跨来源冲突仲裁算法。未来如果有两个设备分别上传同一天的读数,不能靠遍历顺序决定哪一条是真的,而需要先按来源、时间戳、业务版本做明确合并。本篇只接受已经经由本地仓库裁定后的单条日记录。

EnergyNest 质量检查的 DevEco 白色主题工程界面示意

三、让用户看见“少了一天”,而不是把柱子悄悄补齐

EnergyNest 原先的图表使用 kwh * 18 计算柱高,预设每个位置都有数据。如果直接把空值改成零,就会让周三看起来与周四一样;如果直接过滤空值,星期标签又会向左移动。这次保留七个位置:周三画虚线占位并写“缺失”,周四保留零高度的基线标记并写“0.0”,周六则用琥珀色和“需核对”标记峰值。提示通过文字与颜色同时表达,避免依赖红绿两色让用户猜测。

页面仍延续第一篇的 GridRow 响应式布局,数据进入 @State 后统一刷新。为了让示例可复现,先采用点击“查看异常模式”后替换视图数组的方式,原来的 RdbStore 记录不会改动。返回账本时调用已存在的仓库读取,再用 inspectWeek 整理。这个设计体现了“模型结果驱动 UI”:列表、摘要卡、已记录天数和被选中日都来自同一个 EnergyPoint[],不让几个组件保有互相冲突的统计副本。

// entry/src/main/ets/pages/QualityPage.ets(展示关键交互片段)
import { EnergyPoint, previewRecords, inspectWeek,
  observedCount, sumObservedWh } from '../model/EnergyQuality';

@Entry
@Component
struct QualityPage {
  @State private days: EnergyPoint[] = inspectWeek(previewRecords);
  @State private selectedDate: string = '2026-11-07';
  @State private isPreview: boolean = true;

  private label(item: EnergyPoint): string {
    if (item.flag === 'missing') { return '缺失'; }
    if (item.flag === 'zero') { return '0.0'; }
    return ((item.wh ?? 0) / 1000).toFixed(1);
  }

  @Builder
  private bar(item: EnergyPoint) {
    Column({ space: 8 }) {
      Text(this.label(item)).fontSize(13)
      if (item.flag === 'missing') {
        Column().height(100).width(25)
          .border({ width: 1, color: '#9DAAA2', style: BorderStyle.Dashed })
      } else {
        Column().height(Math.min((item.wh ?? 0) / 250, 150))
          .width(25)
          .backgroundColor(item.flag === 'spike' ? '#E7A548' : '#36876D')
          .borderRadius(7)
        if (item.flag === 'zero') { Text('●').fontSize(12) }
      }
      Text(item.date.substring(8)).fontSize(13)
    }.height(212).justifyContent(FlexAlign.End)
      .onClick(() => { this.selectedDate = item.date; })
  }

  build() {
    Scroll() {
      Column({ space: 16 }) {
        Text('EnergyNest').fontSize(20).fontColor('#268363')
        Text('七日用电质量').fontSize(28).fontWeight(FontWeight.Bold)
        Text(this.isPreview ? '异常模式预览 · 不修改账本' : '本地账本记录')
        Text(`已记录 ${observedCount(this.days)} / 7 天`)
        Text(`已记录总量 ${(sumObservedWh(this.days) / 1000).toFixed(1)} kWh`)
        Row({ space: 9 }) {
          ForEach(this.days, (item: EnergyPoint) => {
            this.bar(item)
          }, (item: EnergyPoint) => item.date)
        }.justifyContent(FlexAlign.SpaceEvenly).width('100%')
        ForEach(this.days.filter((p: EnergyPoint) =>
          p.date === this.selectedDate), (p: EnergyPoint) => {
          Text(`${p.date} · ${p.flag === 'spike' ? '需核对峰值' :
            p.flag === 'missing' ? '缺少记录' : '已记录'}`)
        }, (p: EnergyPoint) => p.date)
        Button('查看异常模式').onClick(() => {
          this.days = inspectWeek(previewRecords);
          this.isPreview = true;
        })
      }.padding(18).width('100%')
    }.height('100%').backgroundColor('#F5F9F6')
  }
}

上面代码展示异常模式的独立页面入口。若要与第二篇的数据库页面合并,应把 EnergyRepository 放在可复用的数据访问层,增加 loadFromStore() 方法:先 await repository.list(...),再赋值 this.days = inspectWeek(rows),最后把 isPreview 设置为 false。这里没有再给出一套新数据库初始化代码,是为了避免与既有 energynest.db 和 energy_day 表重复建库。实际工程可以把 QualityPage 放到 entry/src/main/ets/pages,通过页面配置或 Navigation 挂接;只要保持输入输出契约,既有 GridRow 图卡无需更换数据源。

四、检验统计口径,也检验读者的理解成本

预览场景的验证可以不启动设备,先对 inspectWeek(previewRecords) 做纯逻辑断言:输出数组长度为七;周三 wh 为 null 且状态为 missing;周四 wh 精确等于零且状态为 zero;周六 wh 为 28600 且标注 spike。聚合函数应该返回 54500 Wh,已记录天数返回六。这些预期值来自显式输入,能够被单元测试重复验证,并不是某次运行过程中采集出来的性能数据。

接着检查有效性和异常边界。数据库已经通过 CHECK(wh >= 0) 拒绝负电量,但来自旧版本或外部数据流的坏行仍值得在整理层做校验。当前函数跳过无效整数,它们最终表现为“缺失”;正式实现最好同时输出独立的“数据不合法”问题清单,告诉用户原来有一条坏数据,而不只是把它消失。对同日的重复数据,还要依赖上一章的唯一主键与写入顺序,而不是把前端视图当成约束数据库的地方。

如果某天因为同步延迟而没有记录,一般的“缺失”提示已经足够;如果缺失连续三天,则可以在未来增加连续缺失检测,提示检查采集设备或网络链路。反过来,一条 28.6 kWh 的高值不应直接触发自动删除,也不适合未经证据就写成“发生故障”。更稳妥的交互是显示“高于本例提示阈值,建议核对”,提供原始来源 source 和采样时间 updated_at 的明细入口,然后让用户查看是否为家电集中使用或采集重复记账。技术看板的责任是解释数据,不是替用户做不可逆的业务判断。

七日质量状态:缺失、零值与峰值的对照图

最后,在窄屏和宽屏各检查一遍。窄屏下七个标签可能过挤,应该保留横向滚动或缩短日期,只缩小字体会损害可读性;宽屏下可以让异常解释卡与趋势图并排,但不能让“有效天数”随窗口宽度变化。若业务需要同比与环比,必须先确认两个比较周期的数据覆盖率相近,不能拿一个完整七天与一个只有三天记录的周期直接比较百分比并作因果推断。

五、为收官篇留一套可追溯的质量契约

这一轮改造没有更换数据库,也没有引入无法解释的“智能算法”。它是在 EnergyRepository.list() 和 ArkUI 视图之间补上一条清晰的语义边界:用日历日期承载缺失、用整数 Wh 承载精度、用独立状态承载风险提示。页面不应在展示过程中偷偷改写原始记录,异常预览和真实账本更要用明显的标签区分。对采集质量尚未稳定的产品来说,这样简单但诚实的表达比一张看似完美的七日柱图更有用。

第四篇会回到工程收尾:让这些质量标志进入异常日志和回归用例,结合窗口布局的临界宽度检查,验证“写入—读取—解释—展示”四环节能否共同保持一致。到那时我们才有条件回答 EnergyNest 的完整问题:一条家庭电量记录从采集进入本地账本,最终以什么方式被用户可靠地理解,而不是只给出一个漂亮数字。

参考资料:华为 RelationalStore RdbStore API、ResultSet 游标与关闭规范、ArkUI GridRow 参考。

Logo

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

更多推荐