鸿蒙应用开发实战:充电记录App统计聚合方案
如果你想做一个充电记录App,需要用到Preferences和统计图表
开新能源车想记录每次充电的详情?打开鸿蒙应用市场,搜索"电桩记",下载安装后就能记录充电数据了。SOC追踪、费用统计、充电桩图鉴、充电效率分析,让你的充电记录一目了然。
写在前面
大家好,今天聊一个数据统计比较重的App。"电桩记"是一个新能源车充电记录工具。它的核心功能是记录每次充电的详细信息,然后做各种统计:月度费用、平均充电量、充电效率、充电桩使用频率等。
选这个App来讲,是因为它展示了如何在Preferences存储的基础上做统计聚合。统计聚合的思路是:把原始数据存起来,每次需要统计时从原始数据计算。这种"存储原始数据,计算派生数据"的模式在实际开发中非常常见。
这篇文章聊什么
- 充电记录的数据结构设计
- SOC(电池电量)追踪的逻辑
- 费用统计和月度汇总
- 充电桩图鉴(充电桩信息库)
- React版本的localStorage实现
- ArkTS版本的Preferences实现
第一步:设计数据结构
// 充电记录
interface ChargeRecord {
id: string;
date: string; // 充电日期
stationName: string; // 充电站名称
stationType: string; // 充电桩类型:直流快充/交流慢充
connectorType: string; // 接口类型:CCS/CHAdeMO/GB/T
startSoc: number; // 开始SOC(%)
endSoc: number; // 结束SOC(%)
chargedKwh: number; // 充电电量(kWh)
duration: number; // 充电时长(分钟)
cost: number; // 费用(元)
power: number; // 充电功率(kW)
location: string; // 位置
note: string; // 备注
}
// 充电桩图鉴
interface StationInfo {
id: string;
name: string;
brand: string; // 品牌:特来电/星星充电/国家电网...
address: string;
type: string; // 类型:直流快充/交流慢充
maxPower: number; // 最大功率(kW)
price: string; // 价格
rating: number; // 评分1-5
useCount: number; // 使用次数
note: string;
}
SOC是State of Charge的缩写,表示电池当前的电量百分比。startSoc和endSoc的差值就是这次充电充了多少电(百分比),而chargedKwh是实际充了多少度电。这两个数据可以互相验证。
第二步:React版本 – 统计聚合
import React, { useState, useEffect, useMemo } from 'react';
function ChargeManager() {
const [records, setRecords] = useState([]);
const [stations, setStations] = useState([]);
const [filterMonth, setFilterMonth] = useState('全部');
useEffect(() => {
const savedRecords = localStorage.getItem('dianzhuangji_records');
const savedStations = localStorage.getItem('dianzhuangji_stations');
if (savedRecords) setRecords(JSON.parse(savedRecords));
if (savedStations) setStations(JSON.parse(savedStations));
}, []);
useEffect(() => {
if (records.length > 0) {
localStorage.setItem('dianzhuangji_records', JSON.stringify(records));
}
}, [records]);
// 添加充电记录
const addRecord = (recordData) => {
const record = {
...recordData,
id: Date.now().toString(36) + Math.random().toString(36).substr(2),
date: new Date().toISOString().split('T')[0]
};
setRecords(prev => [...prev, record]);
// 更新充电桩使用次数
const station = stations.find(s => s.name === recordData.stationName);
if (station) {
setStations(prev => prev.map(s =>
s.id === station.id ? { ...s, useCount: s.useCount + 1 } : s
));
}
};
// 按月筛选
const filteredRecords = useMemo(() => {
if (filterMonth === '全部') return records;
return records.filter(r => r.date.startsWith(filterMonth));
}, [records, filterMonth]);
// 获取所有月份(用于筛选选项)
const allMonths = useMemo(() => {
const months = new Set(records.map(r => r.date.substring(0, 7)));
return [...months].sort().reverse();
}, [records]);
// 月度统计
const monthlyStats = useMemo(() => {
const stats = {};
records.forEach(r => {
const month = r.date.substring(0, 7);
if (!stats[month]) {
stats[month] = { totalCost: 0, totalKwh: 0, count: 0, totalDuration: 0 };
}
stats[month].totalCost += r.cost;
stats[month].totalKwh += r.chargedKwh;
stats[month].count++;
stats[month].totalDuration += r.duration;
});
return stats;
}, [records]);
// 总体统计
const overallStats = useMemo(() => {
if (filteredRecords.length === 0) {
return { totalCost: 0, totalKwh: 0, avgCost: 0, avgKwh: 0, avgPower: 0 };
}
const totalCost = filteredRecords.reduce((sum, r) => sum + r.cost, 0);
const totalKwh = filteredRecords.reduce((sum, r) => sum + r.chargedKwh, 0);
const avgCost = totalCost / filteredRecords.length;
const avgKwh = totalKwh / filteredRecords.length;
const avgPower = filteredRecords.reduce((sum, r) => sum + r.power, 0) / filteredRecords.length;
return { totalCost, totalKwh, avgCost, avgKwh, avgPower };
}, [filteredRecords]);
// 平均充电效率(kWh/小时)
const avgEfficiency = useMemo(() => {
const validRecords = filteredRecords.filter(r => r.duration > 0);
if (validRecords.length === 0) return 0;
const totalKwh = validRecords.reduce((sum, r) => sum + r.chargedKwh, 0);
const totalHours = validRecords.reduce((sum, r) => sum + r.duration, 0) / 60;
return totalKwh / totalHours;
}, [filteredRecords]);
// 每度电费用
const costPerKwh = useMemo(() => {
if (overallStats.totalKwh === 0) return 0;
return overallStats.totalCost / overallStats.totalKwh;
}, [overallStats]);
return (
<div className="charge-manager">
<h1>电桩记 - 充电记录</h1>
{/* 总体统计 */}
<div className="stats">
<div>充电次数:{filteredRecords.length}</div>
<div>总费用:{overallStats.totalCost.toFixed(1)}元</div>
<div>总电量:{overallStats.totalKwh.toFixed(1)}kWh</div>
<div>平均费用:{overallStats.avgCost.toFixed(1)}元/次</div>
<div>每度电:{costPerKwh.toFixed(2)}元/kWh</div>
<div>充电效率:{avgEfficiency.toFixed(1)}kWh/h</div>
</div>
{/* 月份筛选 */}
<div className="filter">
<button className={filterMonth === '全部' ? 'active' : ''}
onClick={() => setFilterMonth('全部')}>全部</button>
{allMonths.map(month => (
<button key={month}
className={filterMonth === month ? 'active' : ''}
onClick={() => setFilterMonth(month)}>
{month}
</button>
))}
</div>
{/* 月度统计列表 */}
<div className="monthly-stats">
<h3>月度汇总</h3>
{Object.entries(monthlyStats).sort().reverse().map(([month, stats]) => (
<div key={month} className="month-card">
<h4>{month}</h4>
<p>充电 {stats.count} 次,{stats.totalKwh.toFixed(1)}kWh</p>
<p>费用 {stats.totalCost.toFixed(1)}元</p>
<p>平均 {(stats.totalCost / stats.count).toFixed(1)}元/次</p>
</div>
))}
</div>
{/* 充电记录列表 */}
<div className="record-list">
{filteredRecords.map(record => (
<div key={record.id} className="record-card">
<h4>{record.stationName}</h4>
<p>{record.date} | {record.stationType}</p>
<p>SOC:{record.startSoc}% → {record.endSoc}%</p>
<p>{record.chargedKwh}kWh | {record.duration}分钟 | {record.cost}元</p>
<p>功率:{record.power}kW</p>
</div>
))}
</div>
</div>
);
}
这段代码的统计逻辑比较多,我们重点聊几个。
月度统计:用对象做分组,key是月份(如"2024-03"),value是聚合数据。遍历所有记录,按月份累加费用、电量、次数。这种"分组聚合"的模式在统计类App中非常常见。
充电效率:总电量除以总时长(换算成小时)。这个指标反映了充电桩的实际输出效率。理论上功率越大效率越高,但实际受温度、电池状态等因素影响。
每度电费用:总费用除以总电量。这个指标可以帮你比较不同充电桩的性价比。
第三步:ArkTS版本 – Preferences统计聚合
import { preferences } from '@kit.ArkData';
import { common } from '@kit.AbilityKit';
interface ChargeRecord {
id: string;
date: string;
stationName: string;
stationType: string;
connectorType: string;
startSoc: number;
endSoc: number;
chargedKwh: number;
duration: number;
cost: number;
power: number;
location: string;
note: string;
}
interface StationInfo {
id: string;
name: string;
brand: string;
address: string;
type: string;
maxPower: number;
price: string;
rating: number;
useCount: number;
note: string;
}
@Entry
@Component
struct ChargePage {
@State records: ChargeRecord[] = [];
@State stations: StationInfo[] = [];
@State filterMonth: string = '全部';
private preferencesStore: preferences.Preferences | null = null;
async aboutToAppear() {
let context = getContext(this) as common.UIAbilityContext;
this.preferencesStore = await preferences.getPreferences(context, 'dianzhuangji_store');
const recordsData = await this.preferencesStore.get('records', '');
if (recordsData && typeof recordsData === 'string' && recordsData.length > 0) {
this.records = JSON.parse(recordsData) as ChargeRecord[];
}
const stationsData = await this.preferencesStore.get('stations', '');
if (stationsData && typeof stationsData === 'string' && stationsData.length > 0) {
this.stations = JSON.parse(stationsData) as StationInfo[];
}
}
async saveRecords() {
if (!this.preferencesStore) return;
await this.preferencesStore.put('records', JSON.stringify(this.records));
await this.preferencesStore.flush();
}
// 按月筛选
getFilteredRecords(): ChargeRecord[] {
if (this.filterMonth === '全部') return this.records;
return this.records.filter(r => r.date.startsWith(this.filterMonth));
}
// 获取所有月份
getAllMonths(): string[] {
const months = new Set<string>();
this.records.forEach(r => months.add(r.date.substring(0, 7)));
return Array.from(months).sort().reverse();
}
// 总体统计
getOverallStats(): { totalCost: number; totalKwh: number; avgCost: string; avgKwh: string; avgPower: string } {
const filtered = this.getFilteredRecords();
if (filtered.length === 0) {
return { totalCost: 0, totalKwh: 0, avgCost: '0', avgKwh: '0', avgPower: '0' };
}
const totalCost = filtered.reduce((sum, r) => sum + r.cost, 0);
const totalKwh = filtered.reduce((sum, r) => sum + r.chargedKwh, 0);
const avgCost = (totalCost / filtered.length).toFixed(1);
const avgKwh = (totalKwh / filtered.length).toFixed(1);
const avgPower = (filtered.reduce((sum, r) => sum + r.power, 0) / filtered.length).toFixed(1);
return { totalCost, totalKwh, avgCost, avgKwh, avgPower };
}
// 月度统计
getMonthlyStats(): Record<string, { totalCost: number; totalKwh: number; count: number }> {
const stats: Record<string, { totalCost: number; totalKwh: number; count: number }> = {};
this.records.forEach(r => {
const month = r.date.substring(0, 7);
if (!stats[month]) {
stats[month] = { totalCost: 0, totalKwh: 0, count: 0 };
}
stats[month].totalCost += r.cost;
stats[month].totalKwh += r.chargedKwh;
stats[month].count++;
});
return stats;
}
// 充电效率
getAvgEfficiency(): string {
const filtered = this.getFilteredRecords();
const validRecords = filtered.filter(r => r.duration > 0);
if (validRecords.length === 0) return '0';
const totalKwh = validRecords.reduce((sum, r) => sum + r.chargedKwh, 0);
const totalHours = validRecords.reduce((sum, r) => sum + r.duration, 0) / 60;
return (totalKwh / totalHours).toFixed(1);
}
// 每度电费用
getCostPerKwh(): string {
const stats = this.getOverallStats();
if (stats.totalKwh === 0) return '0';
return (stats.totalCost / stats.totalKwh).toFixed(2);
}
build() {
let overallStats = this.getOverallStats();
let monthlyStats = this.getMonthlyStats();
Column() {
Text('电桩记')
.fontSize(24)
.fontWeight(FontWeight.Bold)
.margin({ top: 20, bottom: 16 })
// 总体统计卡片
Column() {
Row() {
Column() {
Text(this.getFilteredRecords().length + '次')
.fontSize(20)
.fontWeight(FontWeight.Bold)
Text('充电次数')
.fontSize(12)
.fontColor('#999999')
}
.layoutWeight(1)
Column() {
Text(overallStats.totalCost.toFixed(1) + '元')
.fontSize(20)
.fontWeight(FontWeight.Bold)
.fontColor('#FF6B6B')
Text('总费用')
.fontSize(12)
.fontColor('#999999')
}
.layoutWeight(1)
Column() {
Text(overallStats.totalKwh.toFixed(1) + 'kWh')
.fontSize(20)
.fontWeight(FontWeight.Bold)
.fontColor('#4ECDC4')
Text('总电量')
.fontSize(12)
.fontColor('#999999')
}
.layoutWeight(1)
}
.width('100%')
.margin({ bottom: 12 })
Row() {
Text(`平均 ${overallStats.avgCost}元/次`)
.fontSize(13)
.fontColor('#666666')
.layoutWeight(1)
Text(`${this.getCostPerKwh()}元/kWh`)
.fontSize(13)
.fontColor('#FFA726')
.layoutWeight(1)
Text(`${this.getAvgEfficiency()}kWh/h`)
.fontSize(13)
.fontColor('#45B7D1')
.layoutWeight(1)
}
.width('100%')
}
.padding(16)
.backgroundColor('#FFFFFF')
.borderRadius(12)
.margin({ left: 16, right: 16, bottom: 16 })
// 月份筛选
Row() {
Button('全部')
.fontSize(13)
.backgroundColor(this.filterMonth === '全部' ? '#4ECDC4' : '#F0F0F0')
.fontColor(this.filterMonth === '全部' ? '#FFFFFF' : '#333333')
.margin({ right: 6 })
.onClick(() => { this.filterMonth = '全部'; })
ForEach(this.getAllMonths().slice(0, 6), (month: string) => {
Button(month)
.fontSize(13)
.backgroundColor(this.filterMonth === month ? '#4ECDC4' : '#F0F0F0')
.fontColor(this.filterMonth === month ? '#FFFFFF' : '#333333')
.margin({ right: 6 })
.onClick(() => { this.filterMonth = month; })
})
}
.padding({ left: 16, right: 16 })
.margin({ bottom: 16 })
// 月度汇总
Column() {
Text('月度汇总')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.margin({ bottom: 8 })
ForEach(
Object.entries(monthlyStats).sort((a, b) => b[0].localeCompare(a[0])),
(entry: [string, { totalCost: number; totalKwh: number; count: number }]) => {
Row() {
Text(entry[0])
.fontSize(14)
.fontWeight(FontWeight.Bold)
.width(80)
Text(`${entry[1].count}次 / ${entry[1].totalKwh.toFixed(1)}kWh`)
.fontSize(13)
.fontColor('#666666')
.layoutWeight(1)
Text(entry[1].totalCost.toFixed(1) + '元')
.fontSize(14)
.fontColor('#FF6B6B')
}
.width('100%')
.padding(12)
.backgroundColor('#F8F8F8')
.borderRadius(8)
.margin({ bottom: 4 })
}
)
}
.padding(16)
.backgroundColor('#FFFFFF')
.borderRadius(12)
.margin({ left: 16, right: 16, bottom: 16 })
// 充电记录列表
List({ space: 8 }) {
ForEach(this.getFilteredRecords(), (record: ChargeRecord) => {
ListItem() {
Column() {
Row() {
Text(record.stationName)
.fontSize(16)
.fontWeight(FontWeight.Bold)
.layoutWeight(1)
Text(record.cost.toFixed(1) + '元')
.fontSize(14)
.fontColor('#FF6B6B')
}
.width('100%')
.margin({ bottom: 6 })
Row() {
Text(record.date)
.fontSize(12)
.fontColor('#999999')
.margin({ right: 8 })
Text(record.stationType)
.fontSize(12)
.fontColor('#45B7D1')
.padding({ left: 4, right: 4, top: 1, bottom: 1 })
.backgroundColor('#E0F7FA')
.borderRadius(3)
.margin({ right: 8 })
Text(record.power + 'kW')
.fontSize(12)
.fontColor('#666666')
}
.width('100%')
.margin({ bottom: 6 })
// SOC进度条
Row() {
Text(`SOC ${record.startSoc}%`)
.fontSize(12)
.fontColor('#999999')
Text(` → ${record.endSoc}%`)
.fontSize(12)
.fontColor('#4ECDC4')
.fontWeight(FontWeight.Bold)
Text(` (${record.chargedKwh}kWh)`)
.fontSize(12)
.fontColor('#666666')
}
.width('100%')
.margin({ bottom: 4 })
Text(`${record.duration}分钟`)
.fontSize(12)
.fontColor('#999999')
}
.width('100%')
.padding(16)
.backgroundColor('#FFFFFF')
.borderRadius(12)
.shadow({ radius: 4, color: '#00000010', offsetY: 2 })
}
}, (record: ChargeRecord) => record.id)
}
.width('100%')
.padding({ left: 16, right: 16 })
.layoutWeight(1)
}
.width('100%')
.height('100%')
.backgroundColor('#FAFAFA')
}
}
第四步:统计计算流程图
React vs ArkTS 对比表
| 功能 | React (Web) | ArkTS (鸿蒙) |
|---|---|---|
| 分组聚合 | 对象做Map | 对象做Map(一样) |
| 月份提取 | substring(0, 7) | substring(0, 7)(一样) |
| Set去重 | new Set() | new Set()(一样) |
| 排序 | sort + localeCompare | sort + localeCompare(一样) |
| 统计显示 | JSX模板 | Text组件(一样) |
总结
这篇文章我们用"电桩记"这个充电记录App,演示了如何在Preferences存储的基础上做统计聚合。核心要点:
- "存储原始数据,计算派生数据"的模式:只存充电记录,统计数据实时计算
- 分组聚合用对象做Map,key是分组字段(月份),value是聚合结果
- 多种统计指标:总量、平均值、效率指标,根据需求选择合适的计算方式
- 月份筛选用startsWith匹配,简单高效
统计聚合是数据驱动App的核心能力。Preferences虽然不如数据库强大,但对于个人工具类App的统计需求完全够用。最后一篇我们会聊"叠杯计时",演示高精度计时和动画的组合使用。
更多推荐



所有评论(0)