【鸿蒙应用开发实战·食光篇】第四篇:收藏功能与个人中心——数据同步与统计面板

一、前言

经过前三篇的开发,我们已经完成了首页、列表页和详情页。本篇将完成最后两个页面——收藏页(FavPage)个人中心(ProfilePage),同时深入探讨鸿蒙的全局状态同步机制。

与「阅迹」相比,「食光」的收藏页和个人中心在UI细节和数据展示上有所不同,体现了同一技术框架下的不同设计思路。

1.1 技术背景与设计哲学

在鸿蒙应用开发中,状态管理是构建复杂应用的核心挑战之一。ArkUI 3.0 提供了多种状态管理方案,从简单的 @State 到跨页面的 @StorageLink,再到全局的 AppStorage,形成了一个完整的状态管理生态。本篇的两个页面正是这个生态的典型应用场景:

  • 收藏页:展示了如何将全局状态(收藏ID列表)与本地状态(收藏菜谱详情)高效结合
  • 个人中心:展示了如何聚合多个数据源,提供统一的用户视图

1.2 数据流架构设计

「食光」应用采用了分层的数据流架构:

┌─────────────────────────────────────┐
│           UI 层 (View)              │
│  ┌─────────────┐  ┌─────────────┐  │
│  │  收藏页     │  │  个人中心   │  │
│  └─────────────┘  └─────────────┘  │
├─────────────────────────────────────┤
│        状态管理层 (ViewModel)        │
│  ┌─────────────────────────────┐    │
│  │  @StorageLink + @State      │    │
│  │  本地缓存 + 全局同步        │    │
│  └─────────────────────────────┘    │
├─────────────────────────────────────┤
│        数据源层 (Model)             │
│  ┌─────────────┐  ┌─────────────┐  │
│  │  AppStorage │  │  本地数据   │  │
│  └─────────────┘  └─────────────┘  │
└─────────────────────────────────────┘

这种架构确保了数据的一致性和UI的响应性,同时保持了代码的可维护性。

二、收藏页(FavPage)

2.1 页面功能与用户体验设计

收藏页的核心功能不仅仅是展示已收藏的菜谱列表,更重要的是提供流畅的用户交互体验:

  1. 智能展示:根据收藏时间、菜系类型或用户偏好进行排序
  2. 快速操作:点击✕按钮可立即移除收藏,无需二次确认(考虑到操作频率)
  3. 无缝跳转:点击卡片直接跳转到详情页,保持浏览连续性
  4. 情感化空状态:当用户未收藏任何菜谱时,提供友好的引导而非冷冰冰的空白
2.1.1 设计思考

与「阅迹」的收藏页相比,「食光」在以下方面做了优化:

  • 信息密度更高:每张卡片展示了菜系、烹饪时长、难度和评分
  • 视觉层次更清晰:通过颜色、字体大小和间距区分主次信息
  • 操作更直观:移除按钮使用醒目的红色,但位置相对隐蔽,避免误触

2.2 状态管理:原理与最佳实践

收藏页的状态管理是鸿蒙状态管理体系的典型应用。我们使用了两种状态装饰器:

@Entry
@Component
struct FavPage {
  // 全局状态:与AppStorage双向绑定
  @StorageLink('favoriteIds') favoriteIds: number[] = [];
  
  // 本地状态:仅在本组件内响应变化
  @State favoriteRecipes: Recipe[] = [];

  aboutToAppear(): void {
    this.loadFavorites();
  }

  loadFavorites(): void {
    // 性能优化:避免在每次渲染时都重新计算
    this.favoriteRecipes = RECIPES.filter(
      r => this.favoriteIds.indexOf(r.id) >= 0
    );
  }

  removeFavorite(recipeId: number): void {
    const idx = this.favoriteIds.indexOf(recipeId);
    if (idx >= 0) {
      // 注意:直接修改数组不会触发UI更新
      const newIds = [...this.favoriteIds];
      newIds.splice(idx, 1);
      
      // 正确做法:创建新数组并赋值
      this.favoriteIds = newIds;
      
      // 同步到AppStorage(@StorageLink会自动同步)
      // AppStorage.set<number[]>('favoriteIds', this.favoriteIds);
      
      this.loadFavorites();
    }
  }
}
2.2.1 @StorageLink 与 @State 的区别
特性 @StorageLink @State
作用范围 跨页面全局 组件内部
数据源 AppStorage 组件内部变量
同步机制 双向自动同步 单向组件内响应
适用场景 需要跨页面共享的数据 仅当前组件使用的临时状态
性能影响 可能触发多个页面更新 仅影响当前组件
2.2.2 性能优化技巧
  1. 避免不必要的重新渲染loadFavorites() 只在数据变化时调用
  2. 使用记忆化过滤:对于大型数据集,可以考虑缓存过滤结果
  3. 分批加载:如果收藏数量很多,可以实现虚拟滚动或分页加载

2.3 收藏列表卡片:组件化设计

每张卡片都是一个独立的UI单元,展示了菜系、烹饪时长和难度,比「阅迹」的卡片信息更丰富。这里我们采用了组件化设计思想:

@Component
struct RecipeCard {
  @Prop item: Recipe;
  @Link favoriteIds: number[];
  
  build() {
    Row() {
      // 封面区域 - 使用Builder封装可复用样式
      this.renderCover()
      
      // 信息区域 - 主内容
      Column() {
        Text(this.item.name)
          .fontSize(16)
          .fontWeight(FontWeight.Bold)
          .fontColor('#2D1F14')
          .maxLines(1)
          .textOverflow({ overflow: TextOverflow.Ellipsis })
        
        // 元信息行
        Row() {
          Text('⏱ ' + this.item.cookTime)
            .fontSize(12)
            .fontColor('#8B7355')
          
          Text('| ' + this.item.difficulty)
            .fontSize(12)
            .fontColor(getDifficultyColor(this.item.difficulty))
            .margin({ left: 8 })
        }.margin({ top: 4 })
        
        // 评分显示
        Text(getStarString(this.item.rating))
          .fontSize(12)
          .fontColor('#F39C12')
          .margin({ top: 4 })
      }
      .layoutWeight(1)
      .alignItems(HorizontalAlign.Start)
      .padding({ left: 12 })
      
      // 操作区域
      this.renderActionButton()
    }
    .padding(12)
    .backgroundColor('#FFFFFF')
    .borderRadius(14)
    .shadow({ radius: 4, color: '#0A000000', offsetY: 2 })
    .onClick(() => {
      router.pushUrl({ 
        url: 'pages/RecipeDetailPage', 
        params: { recipeId: this.item.id } 
      });
    })
  }
  
  @Builder
  renderCover() {
    Column() {
      Text(this.item.name.substring(0, 1))
        .fontSize(26)
        .fontColor('#FFFFFF')
      
      Text(this.item.cuisine)
        .fontSize(10)
        .fontColor('rgba(255,255,255,0.7)')
        .margin({ top: 4 })
    }
    .width(72)
    .height(88)
    .backgroundColor(this.item.color)
    .borderRadius(12)
    .justifyContent(FlexAlign.Center)
  }
  
  @Builder
  renderActionButton() {
    Column() {
      Text('✕')
        .fontSize(18)
        .fontColor('#E74C3C')
        .padding(10)
    }
    .onClick((e: ClickEvent) => {
      e.stopPropagation(); // 阻止事件冒泡,避免触发卡片点击
      this.removeFavorite();
    })
  }
  
  removeFavorite(): void {
    // 移除逻辑...
  }
}
2.3.1 设计系统一致性

卡片设计遵循「食光」的设计系统:

  • 圆角:14px,与整个应用的圆角风格一致
  • 阴影:4px 轻微阴影,提升层次感
  • 颜色:使用应用主色系(#2D1F14、#8B7355、#E67E22)
  • 间距:12px 内边距,符合8px网格系统

2.4 空状态设计:情感化交互

空状态设计是用户体验的重要环节。一个好的空状态应该:

  1. 提供情感连接:使用emoji和温暖的语言
  2. 明确下一步:告诉用户可以做什么
  3. 保持品牌一致性:使用应用的颜色和设计语言
@Builder
emptyState() {
  Column() {
    // 情感化图标
    Text('🍳')
      .fontSize(80)
      .opacity(0.8)
    
    // 主要提示
    Text('还没有收藏的菜谱')
      .fontSize(18)
      .fontColor('#8B7355')
      .margin({ top: 20 })
    
    // 次要说明
    Text('去首页发现美食吧')
      .fontSize(14)
      .fontColor('#E67E22')
      .margin({ top: 12 })
    
    // 行动召唤按钮
    Button('去逛逛')
      .backgroundColor('#E67E22')
      .fontColor('#FFFFFF')
      .borderRadius(20)
      .width(140)
      .height(42)
      .margin({ top: 20 })
      .onClick(() => { 
        router.pushUrl({ url: 'pages/Index' }); 
      })
  }
  .layoutWeight(1)
  .justifyContent(FlexAlign.Center)
  .padding(40)
}
2.4.1 空状态设计原则
  1. 减少认知负荷:信息简洁明了
  2. 提供价值:即使是空状态也要为用户提供价值
  3. 保持一致性:与应用的视觉风格一致
  4. 可操作性:提供明确的下一步操作
2.4.2 进阶空状态

对于更复杂的应用,可以考虑:

  • 个性化推荐:根据用户浏览历史推荐可能喜欢的菜谱
  • 教育性内容:解释收藏功能的价值
  • 进度指示:如果正在加载,显示加载状态

EDE0`。

四、AppStorage 数组同步的注意事项

这是开发中最容易踩的坑——当使用 @StorageLink 绑定数组时:

// ❌ 这样修改不会触发其他页面刷新
this.favoriteIds.splice(idx, 1);  // 数组变了,但没有通知AppStorage

// ✅ 必须手动同步
this.favoriteIds.splice(idx, 1);
AppStorage.set<number[]>('favoriteIds', this.favoriteIds);

为什么需要手动set? @StorageLink 对数组的监听是浅层的——它检测到数组引用没变(还是同一个数组对象),就不会触发同步。而 splice 是在原数组上修改,引用不变。必须通过 AppStorage.set() 强制通知所有监听者。

五、与「阅迹」的对比总结

特性 「阅迹」阅读App 「食光」美食App
主题色 #C4956A / #FFF8F0 #E67E22 / #FFF5EE
封面形状 矩形 圆形/正圆
详情Tab 食材+步骤
数据复杂度 单层文本 嵌套数组
新增功能 难度颜色映射
导航icon 🏠📚❤️👤 🍜📋❤️👤

六、效果预览(请插入截图位置)

在这里插入图片描述

七、小结

本篇完成了:
✅ 收藏页的列表展示与取消收藏
✅ 空状态设计(引导用户去首页)
✅ 个人中心统计面板(总菜谱/已收藏/菜系)
✅ 功能菜单与关于设置
✅ AppStorage 数组同步的正确方式

至此,「食光」的5个页面全部开发完成!下篇将是系列的收官之作——构建优化与发布准备,敬请期待!


#鸿蒙开发 #ArkTS #状态管理 #HarmonyOS #全局数据同步

Logo

纵情码海钱塘涌,杭州开发者创新动! 属于杭州的开发者社区!致力于为杭州地区的开发者提供学习、合作和成长的机会;同时也为企业交流招聘提供舞台!

更多推荐