在开发一个名为“潮间拾”的赶海记录App时,如何利用鸿蒙的Preferences数据持久化方案来处理一对多(行程-渔获)的关联数据
·
如果你想做一个赶海记录App,需要用到Preferences数据持久化
每次赶海回来总想记录一下收获?打开鸿蒙应用市场,搜索"潮间拾",下载安装后就能记录每次赶海的行程和渔获了。支持潮汐类型记录、渔获分类、采集方式标记、海滩类型选择,帮你积累赶海经验。
写在前面
大家好,继续聊鸿蒙开发。今天这个App叫"潮间拾",是一个赶海记录工具。为什么选这个App来讲?因为它涉及一种比较特殊的数据关系:行程和渔获是一对多的关系。一次赶海行程可以有多种渔获,每种渔获有自己的分类、重量、采集方式等信息。
这种关联数据的存储在Web端和鸿蒙端都有类似的处理方式:把渔获数组嵌套在行程对象里。但读取和筛选时就需要注意数据结构的设计,否则代码会很啰嗦。
这篇文章聊什么
- 一对多关联数据的设计(行程-渔获)
- 列表筛选的多条件组合
- React版本的localStorage实现
- ArkTS版本的Preferences实现
- 数据关联查询的思路
第一步:设计数据结构
// 渔获记录
interface Catch {
id: string;
name: string; // 名称:花蛤、螃蟹、海螺、海星...
category: string; // 分类:贝类/蟹类/螺类/鱼类/其他
weight: number; // 重量(克)
quantity: number; // 数量
method: string; // 采集方式:手抓/铲子/网捞/钓
note: string; // 备注
}
// 赶海行程
interface Trip {
id: string;
date: string; // 日期
location: string; // 地点
beachType: string; // 海滩类型:礁石滩/沙滩/泥滩
tideType: string; // 潮汐类型:大潮/中潮/小潮
weather: string; // 天气
catches: Catch[]; // 渔获列表
rating: number; // 评分1-5
note: string; // 总备注
createdAt: number; // 创建时间
}
这个数据结构的关键是catches: Catch[],一个行程包含多个渔获。这种嵌套结构在存储时需要整体序列化,在筛选时需要遍历嵌套数据。
第二步:React版本 – 关联数据管理
import React, { useState, useEffect } from 'react';
function TripManager() {
const [trips, setTrips] = useState([]);
const [filterBeach, setFilterBeach] = useState('全部');
const [filterTide, setFilterTide] = useState('全部');
const [filterCategory, setFilterCategory] = useState('全部');
useEffect(() => {
const saved = localStorage.getItem('chaojianshi_trips');
if (saved) {
setTrips(JSON.parse(saved));
}
}, []);
useEffect(() => {
if (trips.length > 0) {
localStorage.setItem('chaojianshi_trips', JSON.stringify(trips));
}
}, [trips]);
// 创建行程
const createTrip = (tripData) => {
const trip = {
...tripData,
id: Date.now().toString(36) + Math.random().toString(36).substr(2),
catches: [],
createdAt: Date.now()
};
setTrips(prev => [trip, ...prev]);
};
// 添加渔获到行程
const addCatch = (tripId, catchData) => {
setTrips(prev => prev.map(trip => {
if (trip.id !== tripId) return trip;
const newCatch = {
...catchData,
id: Date.now().toString(36) + Math.random().toString(36).substr(2)
};
return { ...trip, catches: [...trip.catches, newCatch] };
}));
};
// 统计总渔获重量
const getTotalWeight = (trip) => {
return trip.catches.reduce((sum, c) => sum + c.weight, 0);
};
// 按条件筛选行程
const filteredTrips = trips.filter(trip => {
const matchBeach = filterBeach === '全部' || trip.beachType === filterBeach;
const matchTide = filterTide === '全部' || trip.tideType === filterTide;
// 如果筛选了渔获分类,检查行程中是否有该分类的渔获
const matchCategory = filterCategory === '全部' ||
trip.catches.some(c => c.category === filterCategory);
return matchBeach && matchTide && matchCategory;
});
// 统计所有行程中某类渔获的总重量
const getCategoryStats = () => {
const stats = {};
trips.forEach(trip => {
trip.catches.forEach(c => {
if (!stats[c.category]) stats[c.category] = { weight: 0, count: 0 };
stats[c.category].weight += c.weight;
stats[c.category].count += c.quantity;
});
});
return stats;
};
return (
<div className="trip-manager">
<h1>潮间拾 - 赶海记录</h1>
{/* 筛选栏 */}
<div className="filters">
<select value={filterBeach} onChange={e => setFilterBeach(e.target.value)}>
<option value="全部">全部海滩</option>
<option value="礁石滩">礁石滩</option>
<option value="沙滩">沙滩</option>
<option value="泥滩">泥滩</option>
</select>
<select value={filterTide} onChange={e => setFilterTide(e.target.value)}>
<option value="全部">全部潮汐</option>
<option value="大潮">大潮</option>
<option value="中潮">中潮</option>
<option value="小潮">小潮</option>
</select>
<select value={filterCategory} onChange={e => setFilterCategory(e.target.value)}>
<option value="全部">全部渔获</option>
<option value="贝类">贝类</option>
<option value="蟹类">蟹类</option>
<option value="螺类">螺类</option>
<option value="鱼类">鱼类</option>
</select>
</div>
{/* 行程列表 */}
<div className="trip-list">
{filteredTrips.map(trip => (
<div key={trip.id} className="trip-card">
<h3>{trip.location} - {trip.date}</h3>
<p>海滩:{trip.beachType} | 潮汐:{trip.tideType} | 天气:{trip.weather}</p>
<p>渔获:{trip.catches.length}种,总重{getTotalWeight(trip)}克</p>
<div className="catch-list">
{trip.catches.map(c => (
<div key={c.id} className="catch-item">
<span>{c.name}</span>
<span>{c.category}</span>
<span>{c.weight}g</span>
<span>{c.method}</span>
</div>
))}
</div>
</div>
))}
</div>
</div>
);
}
筛选逻辑中有一个特别的地方:filterCategory筛选的是渔获分类,但渔获是嵌套在行程里的。所以需要用trip.catches.some(c => c.category === filterCategory)来判断行程中是否包含指定分类的渔获。这种跨层级的筛选在关联数据中很常见。
第三步:ArkTS版本 – Preferences关联数据存储
import { preferences } from '@kit.ArkData';
import { common } from '@kit.AbilityKit';
interface CatchItem {
id: string;
name: string;
category: string;
weight: number;
quantity: number;
method: string;
note: string;
}
interface Trip {
id: string;
date: string;
location: string;
beachType: string;
tideType: string;
weather: string;
catches: CatchItem[];
rating: number;
note: string;
createdAt: number;
}
@Entry
@Component
struct TripPage {
@State trips: Trip[] = [];
@State filterBeach: string = '全部';
@State filterTide: string = '全部';
@State filterCategory: string = '全部';
@State selectedTripId: string = '';
private preferencesStore: preferences.Preferences | null = null;
async aboutToAppear() {
let context = getContext(this) as common.UIAbilityContext;
this.preferencesStore = await preferences.getPreferences(context, 'chaojianshi_store');
const data = await this.preferencesStore.get('trips', '');
if (data && typeof data === 'string' && data.length > 0) {
this.trips = JSON.parse(data) as Trip[];
}
}
async saveTrips() {
if (!this.preferencesStore) return;
await this.preferencesStore.put('trips', JSON.stringify(this.trips));
await this.preferencesStore.flush();
}
// 获取选中行程
getSelectedTrip(): Trip | undefined {
return this.trips.find(t => t.id === this.selectedTripId);
}
// 统计行程总重量
getTotalWeight(trip: Trip): number {
return trip.catches.reduce((sum, c) => sum + c.weight, 0);
}
// 筛选行程
getFilteredTrips(): Trip[] {
return this.trips.filter(trip => {
const matchBeach = this.filterBeach === '全部' || trip.beachType === this.filterBeach;
const matchTide = this.filterTide === '全部' || trip.tideType === this.filterTide;
const matchCategory = this.filterCategory === '全部' ||
trip.catches.some(c => c.category === this.filterCategory);
return matchBeach && matchTide && matchCategory;
});
}
// 统计所有渔获分类
getCategoryStats(): string {
const stats: Record<string, { weight: number; count: number }> = {};
this.trips.forEach(trip => {
trip.catches.forEach(c => {
if (!stats[c.category]) {
stats[c.category] = { weight: 0, count: 0 };
}
stats[c.category].weight += c.weight;
stats[c.category].count += c.quantity;
});
});
return Object.entries(stats).map(([k, v]) => `${k}: ${v.weight}g/${v.count}个`).join(',');
}
// 添加渔获
async addCatchToTrip(tripId: string, name: string, category: string, weight: number, method: string) {
this.trips = this.trips.map(trip => {
if (trip.id !== tripId) return trip;
const newCatch: CatchItem = {
id: Date.now().toString(36) + Math.random().toString(36).substring(2),
name, category, weight,
quantity: 1,
method, note: ''
};
return { ...trip, catches: [...trip.catches, newCatch] };
});
await this.saveTrips();
}
build() {
Column() {
Text('潮间拾')
.fontSize(24)
.fontWeight(FontWeight.Bold)
.margin({ top: 20, bottom: 16 })
// 筛选栏 - 海滩类型
Row() {
ForEach(['全部', '礁石滩', '沙滩', '泥滩'], (type: string) => {
Button(type)
.fontSize(13)
.backgroundColor(this.filterBeach === type ? '#45B7D1' : '#F0F0F0')
.fontColor(this.filterBeach === type ? '#FFFFFF' : '#333333')
.margin({ right: 6 })
.onClick(() => { this.filterBeach = type; })
})
}
.padding({ left: 16, right: 16 })
.margin({ bottom: 8 })
// 筛选栏 - 潮汐类型
Row() {
ForEach(['全部', '大潮', '中潮', '小潮'], (type: string) => {
Button(type)
.fontSize(13)
.backgroundColor(this.filterTide === type ? '#4ECDC4' : '#F0F0F0')
.fontColor(this.filterTide === type ? '#FFFFFF' : '#333333')
.margin({ right: 6 })
.onClick(() => { this.filterTide = type; })
})
}
.padding({ left: 16, right: 16 })
.margin({ bottom: 8 })
// 筛选栏 - 渔获分类
Row() {
ForEach(['全部', '贝类', '蟹类', '螺类', '鱼类', '其他'], (cat: string) => {
Button(cat)
.fontSize(13)
.backgroundColor(this.filterCategory === cat ? '#FF6B6B' : '#F0F0F0')
.fontColor(this.filterCategory === cat ? '#FFFFFF' : '#333333')
.margin({ right: 6 })
.onClick(() => { this.filterCategory = cat; })
})
}
.padding({ left: 16, right: 16 })
.margin({ bottom: 16 })
// 行程列表
List({ space: 12 }) {
ForEach(this.getFilteredTrips(), (trip: Trip) => {
ListItem() {
Column() {
// 行程头部信息
Row() {
Text(trip.location)
.fontSize(16)
.fontWeight(FontWeight.Bold)
.layoutWeight(1)
Text(trip.date)
.fontSize(12)
.fontColor('#999999')
}
.width('100%')
.margin({ bottom: 8 })
// 行程标签
Row() {
Text(trip.beachType)
.fontSize(12)
.fontColor('#45B7D1')
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
.backgroundColor('#E0F7FA')
.borderRadius(4)
.margin({ right: 6 })
Text(trip.tideType)
.fontSize(12)
.fontColor('#4ECDC4')
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
.backgroundColor('#E0F2F1')
.borderRadius(4)
.margin({ right: 6 })
Text(trip.weather)
.fontSize(12)
.fontColor('#FFA726')
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
.backgroundColor('#FFF3E0')
.borderRadius(4)
}
.width('100%')
.margin({ bottom: 8 })
// 渔获概要
Text(`渔获 ${trip.catches.length} 种,总重 ${this.getTotalWeight(trip)}g`)
.fontSize(13)
.fontColor('#666666')
.width('100%')
.margin({ bottom: 8 })
// 渔获列表
if (trip.catches.length > 0) {
ForEach(trip.catches, (catchItem: CatchItem) => {
Row() {
Text(catchItem.name)
.fontSize(13)
.fontWeight(FontWeight.Bold)
.width(60)
Text(catchItem.category)
.fontSize(12)
.fontColor('#666666')
.margin({ right: 8 })
Text(catchItem.weight + 'g')
.fontSize(12)
.fontColor('#333333')
.margin({ right: 8 })
Text(catchItem.method)
.fontSize(12)
.fontColor('#999999')
}
.width('100%')
.padding(8)
.backgroundColor('#F8F8F8')
.borderRadius(4)
.margin({ top: 4 })
}, (catchItem: CatchItem) => catchItem.id)
}
}
.width('100%')
.padding(16)
.backgroundColor('#FFFFFF')
.borderRadius(12)
.shadow({ radius: 4, color: '#00000010', offsetY: 2 })
}
}, (trip: Trip) => trip.id)
}
.width('100%')
.padding({ left: 16, right: 16 })
.layoutWeight(1)
}
.width('100%')
.height('100%')
.backgroundColor('#FAFAFA')
}
}
第四步:数据关系流程图
这个ER图展示了行程和渔获之间的一对多关系。在Preferences中,我们把渔获数组作为行程对象的一个字段来存储。每次保存都是保存整个trips数组。
React vs ArkTS 对比表
| 功能 | React (Web) | ArkTS (鸿蒙) |
|---|---|---|
| 嵌套数据更新 | 多层map + 展开运算符 | map + find + 修改属性 |
| 关联筛选 | some()方法 | some()方法(一样) |
| 聚合统计 | reduce方法 | reduce方法(一样) |
| 数据标签 | CSS类名 | 链式属性方法 |
总结
这篇文章我们用"潮间拾"这个赶海记录App,演示了如何用Preferences存储关联数据。核心要点:
- 一对多关系(行程-渔获)用嵌套数组实现
- 跨层级筛选用some()方法
- 聚合统计用reduce()方法
- 整体序列化存储,每次更新都是读写整个数组
关联数据的存储在Preferences中并不复杂,关键在于数据结构设计要合理,筛选逻辑要清晰。下一篇我们会聊"车管家",引入通知提醒功能。
更多推荐
所有评论(0)