如果你想做一个赶海记录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')
  }
}

第四步:数据关系流程图

contains

TRIP

string

id

string

date

string

location

string

beachType

string

tideType

string

weather

number

rating

string

note

number

createdAt

CATCH

string

id

string

name

string

category

number

weight

number

quantity

string

method

string

note

这个ER图展示了行程和渔获之间的一对多关系。在Preferences中,我们把渔获数组作为行程对象的一个字段来存储。每次保存都是保存整个trips数组。

React vs ArkTS 对比表

功能 React (Web) ArkTS (鸿蒙)
嵌套数据更新 多层map + 展开运算符 map + find + 修改属性
关联筛选 some()方法 some()方法(一样)
聚合统计 reduce方法 reduce方法(一样)
数据标签 CSS类名 链式属性方法

总结

这篇文章我们用"潮间拾"这个赶海记录App,演示了如何用Preferences存储关联数据。核心要点:

  1. 一对多关系(行程-渔获)用嵌套数组实现
  2. 跨层级筛选用some()方法
  3. 聚合统计用reduce()方法
  4. 整体序列化存储,每次更新都是读写整个数组

关联数据的存储在Preferences中并不复杂,关键在于数据结构设计要合理,筛选逻辑要清晰。下一篇我们会聊"车管家",引入通知提醒功能。

Logo

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

更多推荐