在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

每天纠结"吃什么"是当代人的世纪难题。本文用ArkUI写了一个帮你做决定的APP——从摇一摇随机选餐到美食收藏管理,完整记录开发全过程。


一、项目缘起:为什么做"今天吃啥"

1.1 世纪难题"吃什么"

据非正式统计,一个人平均每天花在"吃什么"这个问题上的决策时间约为10-15分钟,一年就是60-90小时——相当于整整4天。如果你有选择困难症,这个数字还要翻倍。

更糟糕的是,即使做出了决定,吃完之后常常会后悔:“早知道去另一家了”“上次那个更好吃”“又踩雷了”。

1.2 产品的三个核心价值

"今天吃啥"APP要解决的就是这个问题,它的核心价值在于:

1. 帮你做决定:摇一摇随机选餐,把选择交给命运(或者说交给算法),结束纠结。

2. 帮你记住好店:收藏附近美食和食堂菜谱,打造你的私人美食地图。

3. 帮你发现规律:通过分类筛选和评分系统,发现自己的口味偏好。

1.3 技术选型

技术维度 选择 理由
开发语言 ArkTS 静态类型、原生性能
UI框架 ArkUI 声明式、组件丰富
数据持久化 Preferences KV存储,适合小型结构化数据
包结构 @kit.ArkData API 24标准包
开发工具 DevEco Studio 官方IDE

二、需求分析与架构设计

2.1 功能需求

功能 优先级 说明
随机选餐 P0 从美食库中随机选中一个,带滚动动画效果
按分类筛选 P0 全部/附近美食/食堂菜谱/其他
美食列表 P0 展示所有收藏,支持搜索和分类筛选
添加美食 P0 名称/分类/地点/价格/评分/备注
编辑/删除 P1 修改或删除已有条目
数据持久化 P0 Preferences存储,重启不丢失
默认数据 P0 首次启动自动填充默认美食

2.2 架构设计

整个APP采用经典的三层架构:

┌─────────────────────────────────────────────────┐
│               表现层 (UI Layer)                   │
│   随机选餐视图 / 收藏夹列表视图 / 添加视图        │
│   底部导航栏 / 删除确认弹窗                      │
├─────────────────────────────────────────────────┤
│               状态层 (State Layer)                │
│   @State foods / filteredFoods / selectedCategory│
│   @State randomResult / isRolling / showAddDialog│
├─────────────────────────────────────────────────┤
│               数据层 (Data Layer)                 │
│   Preferences 持久化存储                        │
│   getDefaultFoods() 默认种子数据                  │
│   loadData() / saveData()                       │
└─────────────────────────────────────────────────┘

2.3 视图导航结构

APP采用三个底部标签页,通过 currentView 状态切换:

build()
├── 顶部标题栏 (随机表情 + "今天吃啥")
├── 当前视图 (条件渲染)
│   ├── currentView === 'random' → buildRandomView()
│   ├── currentView === 'list'   → buildListView()
│   └── currentView === 'add'    → buildAddView()
├── 底部导航栏
│   ├── 🎲 随机选 → currentView = 'random'
│   ├── 📋 收藏夹 → currentView = 'list'
│   └── ➕ 添加   → currentView = 'add'
└── 删除确认弹窗 (当 showDeleteConfirm 时显示)

三、数据模型设计

3.1 类型定义

食物分类枚举:

enum FoodCategory {
  FU_JIN = '附近美食',
  SHI_TANG = '食堂菜谱',
  QI_TA = '其他'
}

食物条目接口:

interface FoodItem {
  id: number        // 自增ID,唯一标识
  name: string      // 美食名称
  category: FoodCategory  // 分类
  location: string  // 地点/餐厅名
  price: number     // 价格(元),0表示未知
  rating: number    // 评分(1-5)
  notes: string     // 备注
}

3.2 默认种子数据

首次启动时,APP没有任何数据。为了让用户有"开箱即用"的体验,我们内置了20道默认美食:

private getDefaultFoods(): FoodItem[] {
  return [
    // 附近美食(10道)
    { id: 1, name: '兰州拉面', category: FU_JIN, location: '校门口左侧', price: 15, rating: 4, notes: '量足,牛肉多' },
    { id: 2, name: '黄焖鸡米饭', category: FU_JIN, location: '后街美食街', price: 18, rating: 5, notes: '汤汁浓郁' },
    // ... 更多
  ];
}

种子数据的触发逻辑:在 loadData() 中判断,如果从 Preferences 读取到的数据为空数组,则写入默认数据:

async loadData(): Promise<void> {
  const json = await this.pref.get(STORAGE_KEY, '[]') as string;
  const raw: FoodItem[] = JSON.parse(json);
  if (raw.length === 0) {
    this.foods = this.getDefaultFoods();
    this.nextId = this.foods.length + 1;
    await this.saveData();
  } else {
    this.foods = raw.sort(...);
  }
  this.refresh();
}

这种"检测空数据→写入默认值"的模式,在需要预置数据的APP中非常实用。

3.3 @State变量的设计

@State变量 类型 作用 更新时机
currentView string 当前视图 点击底部导航
foods FoodItem[] 全部美食数据 加载/增删改
filteredFoods FoodItem[] 筛选后的数据 搜索/分类切换
selectedCategory string 当前选中的分类 点击分类标签
searchText string 搜索关键词 输入搜索框
randomResult string 随机选餐结果 摇一摇完成
randomEmoji string 结果表情 摇一摇完成
isRolling boolean 是否正在滚动 开始/结束摇一摇
rollingText string 滚动中的文字 每80ms更新
totalCount number 总条数 刷新数据
fuJinCount number 附近美食数 刷新数据
shiTangCount number 食堂菜谱数 刷新数据
showAddDialog/edit* 各种 添加/编辑表单 打开/关闭弹窗
showDeleteConfirm boolean 删除确认显隐 点击删除/确认/取消

四、核心功能实现详解

4.1 摇一摇随机选餐

这是APP最核心的交互功能,也是用户打开APP最常使用的功能。

交互流程:

用户点击"🎲 摇一摇"按钮
  → 检查:是否有美食数据?(无则提示去添加)
  → 检查:是否正在滚动中?(防止连点)
  → isRolling = true
  → 每80ms触发一次定时器:
      1. 从当前筛选列表中随机选一个
      2. 更新 rollingText 显示
      3. rollCount++
  → 当 rollCount > 20 时停止计时器:
      1. 从筛选列表中随机选区最终结果
      2. 更新 randomResult / randomEmoji
      3. 拼接地点+价格+评分显示在副标题
      4. isRolling = false

关键代码实现:

startRoll(): void {
  if (this.isRolling) return;
  if (this.foods.length === 0) {
    this.randomResult = '还没有收藏美食,先去添加吧!';
    this.randomEmoji = '😅';
    return;
  }
  this.isRolling = true;
  this.rollCount = 0;
  const candidates = this.filteredFoods.length > 0 ? this.filteredFoods : this.foods;

  this.rollTimer = setInterval(() => {
    this.rollCount++;
    const idx = Math.floor(Math.random() * candidates.length);
    const item = candidates[idx];
    this.rollingText = `${this.getCategoryEmoji(item.category)} ${item.name}`;

    if (this.rollCount > 20) {
      clearInterval(this.rollTimer);
      this.isRolling = false;
      const final = candidates[Math.floor(Math.random() * candidates.length)];
      this.randomResult = final.name;
      this.randomEmoji = this.getCategoryEmoji(final.category);
      // 拼接详细信息显示
      const detailParts: string[] = [];
      if (final.location) detailParts.push(`📍 ${final.location}`);
      if (final.price > 0) detailParts.push(`💰 ¥${final.price}`);
      if (final.rating > 0) detailParts.push(`${'★'.repeat(final.rating)}${'☆'.repeat(5 - final.rating)}`);
      this.rollingText = detailParts.join(' · ');
    }
  }, 80);
}

设计要点:

  • 滚动次数控制:20次约1.6秒,节奏适中,不会太长让人不耐烦,也不会太短没有悬念感。
  • 最终结果随机:滚动过程中的显示是纯装饰,最终结果重新随机选取,保证公平性。
  • 筛选联动:如果用户先筛选了分类(如"食堂菜谱"),摇一摇只会从该类目中选取,实现"想吃食堂就摇食堂"的精准需求。

4.2 分类筛选系统

分类筛选在"随机选餐"和"收藏夹"两个视图中都有用到。它由一组标签按钮构成:

@Builder
buildFilterChip(label: string) {
  Text(label)
    .fontSize(13)
    .fontColor(this.selectedCategory === label ? '#FFFFFF' : '#666')
    .backgroundColor(this.selectedCategory === label ? '#FF6B35' : '#F0F0F0')
    .borderRadius(16)
    .padding({ left: 14, right: 14, top: 5, bottom: 5 })
    .margin({ right: 6 })
    .onClick(() => {
      this.selectedCategory = label;
      this.refresh();
    })
}

筛选逻辑在 applyFilter() 中实现:

applyFilter(): void {
  let list = this.foods;
  if (this.selectedCategory !== '全部') {
    list = list.filter(f => f.category === this.selectedCategory);
  }
  if (this.searchText) {
    const kw = this.searchText;
    list = list.filter(f =>
      f.name.includes(kw) || f.location.includes(kw) || f.notes.includes(kw)
    );
  }
  this.filteredFoods = list;
}

筛选与摇一摇的联动:当用户选择了某个分类后,摇一摇只从该分类中选取——这背后是 startRoll()const candidates = this.filteredFoods 的逻辑。筛选列表即是候选列表。

4.3 搜索功能

搜索功能实现在收藏夹视图顶部。用户输入关键词后,实时过滤美食列表:

状态更新:

TextInput({ placeholder: '搜索美食、地点...', text: this.searchText })
  .onChange((val: string) => {
    this.searchText = val;
    this.applyFilter();
  })

每次输入变化都会触发 applyFilter(),实时更新列表。这种"即时搜索"(Instant Search)模式在移动端已经成为标配,用户在输入时立刻看到结果,无需手动提交。

4.4 添加/编辑表单

添加和编辑共用同一个表单视图,通过 editId 区分模式:

  • editId === -1:添加模式,标题显示"📝 添加美食"
  • editId !== -1:编辑模式,标题显示"💾 保存修改"

表单字段:

字段 组件 说明
名称 TextInput 必填,不能为空
分类 三个单选标签 附近美食/食堂菜谱/其他
地点 TextInput 选填
价格 TextInput (Number) 0表示未知
评分 5个可点击的星星 点击选择1-5星
备注 TextInput 选填

评分组件的实现:

Row() {
  ForEach([1, 2, 3, 4, 5], (star: number) => {
    Text(star <= this.editRating ? '⭐' : '☆')
      .fontSize(24)
      .onClick(() => { this.editRating = star; })
  }, (star: number) => star.toString())
}

通过 star <= this.editRating 判断显示实心还是空心星星,点击时设置评分值,代码只有5行。

4.5 @State数组的不可变更新

在ArkUI中,@State 数组需要遵循"不可变更新"原则。以删除功能为例:

confirmDelete(): void {
  // ❌ 错误:直接修改原数组不会触发UI更新
  // this.foods.splice(index, 1);

  // ✅ 正确:创建新数组替换
  this.foods = this.foods.filter(f => f.id !== this.deleteId);

  this.showDeleteConfirm = false;
  this.saveData();
  this.refresh();
}

filter 返回新数组,赋值给 this.foods 后ArkUI检测到引用变化,触发重新渲染。

同样地,在编辑功能中:

const old = this.foods[idx];
// 创建新对象替换数组中的元素
this.foods[idx] = {
  id: old.id,           // 保留原有ID
  name: newName,
  category: newCategory,
  // ...其他属性
};

注意:这里修改的是 this.foods 数组中的元素对象。仅仅修改 this.foods[idx] 的引用并不会触发数组级别的重新渲染。需要确保 this.foods 本身也获得新的引用。但在上述代码中,直接赋值 this.foods[idx] = { ... } 确实修改了数组内部的元素,但数组自身的引用没有变化。

正确的做法应该是:

// 创建整个数组的新引用
const newFoods = [...this.foods]; // ❌ 展开运算符ArkTS不支持

// 安全做法:手动复制
const newFoods: FoodItem[] = [];
for (let i = 0; i < this.foods.length; i++) {
  newFoods.push(this.foods[i]);
}
newFoods[idx] = { ... }; // 替换元素
this.foods = newFoods;

等等——之前的代码有Bug!

我重新审视了编辑功能中的代码:

this.foods[idx] = { id: old.id, name: ..., ... };

这行代码修改了数组内部元素,但 this.foods 的引用没有变化。在ArkUI中,修改数组元素不会触发UI更新。需要创建新的数组引用。

这是一个需要注意的ArkUI使用误区。正确的实现应该是:

const newFoods: FoodItem[] = [];
for (let i = 0; i < this.foods.length; i++) {
  if (i === idx) {
    newFoods.push({ id: old.id, name: this.editName, /* ... */ });
  } else {
    newFoods.push(this.foods[i]);
  }
}
this.foods = newFoods;

但这里有一个有趣的现象:在 saveFood() 中,我们调用了 this.refresh(),而 refresh() 中调用了 this.applyFilter(),后者重新赋值了 this.filteredFoods 这个 @State 变量。所以即使 this.foods 没有触发渲染,this.filteredFoods 的变化仍然会触发UI更新。

这种"间接触发渲染"的方式虽然能工作,但不是最佳实践。更可靠的方式是确保每次修改 this.foods 时都创建新数组。


五、UI实现详解

5.1 整体视觉效果

APP采用暖色系的视觉风格,与"美食"主题匹配:

用途 色值 说明
主色调 #FF6B35 橙色,食欲色
背景色 #FFF8F0 米白,温暖
卡片背景 #FFFFFF 纯白
食堂菜谱标签 #004E89 蓝色
附近美食标签 #FF6B35 橙色
其他标签 #1A936F 绿色
评分星色 #FFB800 金色

5.2 摇一摇视图(首页)

摇一摇视图是APP的首页,布局结构如下:

Column
├── Row: 统计标签 (🏪 附近X · 🍱 食堂X · 📌 共X)
├── Row: 分类筛选标签 (全部/附近美食/食堂菜谱/其他)
├── Column: (居中, layoutWeight=1)
│   ├── Text: 结果表情 (72fp 大号)
│   ├── Text: 结果文字/滚动文字
│   └── Text: 地点·价格·评分 (副标题)
└── Button: "🎲 摇一摇" 大按钮 (底部固定)

统计标签展示当前各类别的数量,让用户一打开APP就能了解自己收藏了多少美食。

分类筛选标签可以滚动,支持横向滑动查看更多。

摇一摇按钮使用大尺寸圆角按钮,带阴影,视觉上"想让人按下去"。

5.3 收藏夹视图

收藏夹视图的布局:

Column
├── Row: 搜索框 (🔍 输入框 + ✕ 清空)
├── Row: 分类筛选标签 (同首页)
├── List: 美食卡片列表 (可滚动)
│   ├── ListItem: 美食卡片1
│   ├── ListItem: 美食卡片2
│   └── ...
└── (空状态提示:没有找到匹配的美食)

美食卡片设计:

Row
├── Text: 分类图标 (🏪/🍱/🍽️, 28fp)
├── Column: (layoutWeight=1)
│   ├── Row: 名称 (17fp) + 分类标签 (小标签)
│   ├── Row: 📍 地点 · 💰 ¥价格
│   ├── Text: 评分星星 (★)
│   └── Text: 备注 (可选, 最多1行)
└── Row: ✏️ 编辑 · 🗑️ 删除

卡片点击行为:点击卡片直接跳转到摇一摇视图,并将该美食设置为当前选中结果。这个设计的意图是——用户在浏览收藏时看到某个想吃的,点击即可"锁定"它,然后去摇一摇确认。但实际上,点击卡片的逻辑是直接将当前美食显示在摇一摇结果区,用户可以确认这就是今天想吃的。

5.4 添加视图

添加视图是一个表单页面:

Column
├── Text: "📝 添加美食" (标题)
├── Scroll
│   └── Column
│       ├── Row: 名称 (TextInput)
│       ├── Row: 分类 (三个单选标签)
│       ├── Row: 地点 (TextInput)
│       ├── Row: 价格 (TextInput Number + "元")
│       ├── Row: 评分 (5个可点击星星)
│       ├── Row: 备注 (TextInput)
│       └── Button: "✅ 添加" 或 "💾 保存修改"

5.5 删除确认弹窗

删除操作需要用户二次确认,防止误删:

Column (宽度80%, 圆角16, 白色背景)
├── Text: "🗑️ 确认删除" (标题)
├── Text: "删除后无法恢复,确定要删除吗?" (提示)
└── Row
    ├── Button: "取消" (灰色)
    └── Button: "删除" (红色)

六、踩坑合集:今天吃啥APP中的3个关键问题

坑1:@State数组元素修改不触发渲染

问题:以下代码不会触发UI更新:

this.foods[idx] = newItem; // ❌ 数组引用不变

原因:ArkUI对 @State 数组的检测基于引用比较(reference comparison)。修改数组中的某个元素不会改变数组本身的引用地址。

解决方案:每次修改数组时创建新引用:

const newFoods: FoodItem[] = [];
for (let i = 0; i < this.foods.length; i++) {
  newFoods.push(this.foods[i]);
}
newFoods[idx] = { id: old.id, name: newName, /* ... */ };
this.foods = newFoods;

注意:在ArkTS中不可以使用展开运算符 [...arr] 来复制数组,必须使用 for 循环手动复制。

坑2:setInterval的timer类型

问题:在ArkTS中,setInterval 返回的 timer 类型与JavaScript不同。

JavaScriptsetInterval 返回 number(浏览器环境)或 NodeJS.Timeout(Node环境)。

ArkTSsetInterval 返回 number。可以使用 number 类型来存储 timer:

private rollTimer: number = -1;

清理 timer:使用 clearInterval() 时注意参数类型匹配:

clearInterval(this.rollTimer);

如果没有正确清理 timer,组件销毁后 timer 仍在运行,可能导致内存泄漏或访问已销毁组件的状态。

坑3:分类标签的滚动能力

问题:分类标签行(全部/附近美食/食堂菜谱/其他)在窄屏手机上可能显示不全。

解决方案:使用 .scrollable() 属性使行支持横向滚动:

Row() {
  this.buildFilterChip('全部')
  this.buildFilterChip('附近美食')
  this.buildFilterChip('食堂菜谱')
  this.buildFilterChip('其他')
}
.width('100%')
.padding(...)
.scrollable(ScrollAlignment.START)

但在实际测试中,Row 组件的 .scrollable() 方法在某些ArkUI版本中可能不直接支持。如果有兼容性问题,可以将标签行放在 Scroll 组件中:

Scroll() {
  Row() {
    // 标签内容
  }
}
.scrollable(ScrollDirection.HORIZONTAL)

七、数据持久化与状态恢复

7.1 Preferences的读写流程

// 写入
async saveData(): Promise<void> {
  if (this.pref) {
    await this.pref.put(STORAGE_KEY, JSON.stringify(this.foods));
    await this.pref.flush();
  }
}

// 读取
async loadData(): Promise<void> {
  const json = await this.pref.get(STORAGE_KEY, '[]') as string;
  const raw: FoodItem[] = JSON.parse(json);
  // 处理数据...
}

关键点

  1. put + flushput 将数据写入内存缓存,flush 确保写入磁盘
  2. get 的第二个参数:当键不存在时返回的默认值
  3. JSON序列化:对象数组需要序列化为字符串存储,读取时反序列化

7.2 数据加载的生命周期

async aboutToAppear(): Promise<void> {
  await this.loadData();
}

aboutToAppear 是组件的生命周期回调,在组件即将显示时调用。在这里加载数据,确保用户看到界面时数据已经就绪。

7.3 ID自增策略

private nextId: number = 1;

// 加载时更新
for (const f of this.foods) {
  if (f.id >= this.nextId) this.nextId = f.id + 1;
}

// 新增时使用
const food: FoodItem = {
  id: this.nextId++,
  // ...
};

这种"最大ID+1"的策略简单可靠,不会出现ID冲突。


八、项目结构和代码统计

8.1 文件结构

Index.ets (~730行)
├── 类型定义 (~20行)
│   ├── enum FoodCategory
│   └── interface FoodItem
│
├── 组件定义 (~700行)
│   ├── @State变量及private成员 (~30行)
│   ├── 数据加载与持久化 (~100行)
│   │   ├── loadData()
│   │   ├── getDefaultFoods() ← 20道默认美食
│   │   ├── saveData()
│   │   └── refresh() / applyFilter()
│   ├── 随机选餐逻辑 (~40行)
│   │   └── startRoll()
│   ├── CRUD操作 (~70行)
│   │   ├── openAddDialog / openEditDialog
│   │   ├── saveFood
│   │   └── requestDelete / confirmDelete
│   ├── UI辅助方法 (~20行)
│   │   ├── getCategoryEmoji / getRatingStars
│   │   └── getCategoryTagColor
│   ├── build() 入口 (~10行)
│   ├── @Builder buildBottomNav (~30行)
│   ├── @Builder buildRandomView (~100行)
│   ├── @Builder buildListView (~120行)
│   ├── @Builder buildAddView (~130行)
│   ├── @Builder buildFoodCard (~50行)
│   ├── @Builder buildFilterChip (~20行)
│   └── @Builder buildDeleteConfirmDialog (~30行)

8.2 代码量分布

模块 行数 占比
类型定义 ~20 3%
数据层(加载/持久化/CRUD) ~170 23%
UI层(@Builder视图) ~520 71%
工具方法 ~20 3%

UI代码占主导是ArkUI应用的典型特征。这也符合声明式UI的理念——UI即代码,代码即UI。


九、总结与展望

9.1 项目复盘

"今天吃啥"APP的开发周期约半天,代码量约730行,实现了三个核心功能:

功能 实现方式 用户体验
随机选餐 setInterval + 随机选取 + 滚动动画 🎲 有点紧张感,结果有仪式感
美食收藏 增删改查 + Preferences持久化 📋 构建私人美食地图
分类筛选 标签 + 搜索 + 数据过滤联动 🔍 精准定位想吃的类型

9.2 用户使用场景

  • 中午12:00:打开APP → 点击"摇一摇" → 选中"黄焖鸡米饭" → 走,去吃!
  • 晚上发现新店:打开APP → 切换到"添加" → 记录店名、价格、评分
  • 周末想换口味:筛选"其他"分类 → 摇一摇 → 选中"韩式炸鸡" → 点外卖!

9.3 可扩展方向

1. 组队模式
创建群组,多人一起摇一摇,投票决定最终吃什么。

2. 外卖比价
接入外卖平台的公开数据,展示同一道菜在不同平台的价格。

3. 口味分析
根据历史评分和点评,分析用户的口味偏好(偏辣/偏甜/偏清淡)。

4. 智能推荐
记录每次选择后的满意度,用简单的协同过滤算法推荐更精准的美食。

5. 地图集成
通过 @kit.LocationKit 获取当前位置,推荐附近收藏的美食。


附录:完整API清单

@kit.ArkData

API 用途
preferences.getPreferences(context, name) 获取/创建偏好数据库
preferences.Preferences.get(key, def) 读取值
preferences.Preferences.put(key, value) 写入值
preferences.Preferences.flush() 刷入磁盘

ArkUI组件

组件 用途
Column 垂直布局
Row 水平布局
Text 文本显示
TextInput 文本输入
Button 按钮
List 列表容器
ListItem 列表项
Scroll 滚动容器
ForEach 循环渲染

ArkUI属性方法

方法 用途
.fontSize() .fontWeight() .fontColor() 字体控制
.backgroundColor() .borderRadius() 背景与圆角
.shadow() 阴影
.layoutWeight() 弹性布局权重
.scrollable() 滚动能力
.maxLines() .textOverflow() 文本截断
.opacity() 透明度
.onClick() .onChange() 事件监听

全局JavaScript API

API 用途 ArkTS兼容
Math.random() 随机数
Math.floor() 向下取整
JSON.parse() JSON解析
JSON.stringify() JSON序列化
setInterval() 定时器 ✅ (返回number)
clearInterval() 清除定时器
Array.push() 数组追加
Array.filter() 数组过滤
Array.sort() 数组排序
Array.forEach() 数组遍历
Logo

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

更多推荐