在这里插入图片描述

引言

收藏功能是应用中重要的用户交互功能,允许用户保存喜欢的内容以便日后查看。本文将实现完整的收藏功能,包括:

  • 收藏列表展示
  • 添加收藏
  • 删除收藏
  • 收藏数据持久化
  • 空状态处理

通过本文,你将掌握如何实现一个功能完善的收藏系统。


学习目标

完成本文后,你将能够:

  • ✅ 实现收藏列表展示
  • ✅ 添加收藏功能
  • ✅ 删除收藏功能
  • ✅ 实现数据持久化
  • ✅ 处理空状态

需求分析

功能模块设计

模块 功能描述 技术要点
收藏列表 展示用户收藏的内容 List布局、卡片组件
添加收藏 将内容加入收藏 状态切换、动画效果
删除收藏 移除收藏内容 确认弹窗、动画效果
数据持久化 保存收藏数据 Storage API
空状态 无收藏时的提示 图片+文字提示

设计思路

方案对比

方案 优点 缺点 适用场景
单列列表 简单直观,性能最优 信息展示有限,视觉效果一般 内容较少或移动端低配设备
左图右文卡片 视觉效果好,信息层次清晰 代码稍复杂,需要处理事件冒泡 推荐 - 适合大多数场景
网格布局 展示更多内容,密度高 信息不够详细,交互区域小 图片为主的内容或内容量极大
瀑布流布局 错落有致,视觉吸引力强 实现复杂,性能开销大 图片分享类应用

关键决策

决策1: 使用左图右文卡片布局

  • 原因:收藏内容需要展示图片、标题、描述、时间等多维度信息
  • 优势:信息展示完整,用户一眼就能获取关键信息
  • 技术实现:使用 Row + Column 组合布局,图片固定宽高保证一致性

决策2: 删除按钮放在卡片内右侧

  • 原因:方便用户快速删除,无需进入详情页
  • 优势:减少操作步骤,提升删除效率
  • 注意点:需要阻止事件冒泡,避免触发卡片点击跳转

决策3: 支持多种类型收藏(节气/文章)

  • 原因:应用包含节气和文章两种主要内容类型,用户可能同时收藏
  • 优势:统一收藏管理入口,用户体验一致
  • 实现方式:通过 type 字段区分,跳转时根据类型路由到不同页面

决策4: 收藏时间展示

  • 原因:帮助用户回忆收藏时间,便于管理收藏内容
  • 优势:增加信息维度,提升可追溯性
  • 格式选择:使用 YYYY-MM-DD 格式,清晰易读

架构设计

// 收藏数据模型设计
interface CollectionItem {
  id: string;                    // 唯一标识
  type: 'holiday' | 'article';   // 类型区分
  title: string;                 // 标题
  image: string;                 // 封面图
  desc: string;                  // 描述
  collectTime: string;           // 收藏时间
}

// 页面状态管理
@State collections: CollectionItem[] = [];  // 收藏列表
@State isLoading: boolean = false;          // 加载状态

数据持久化策略

方案 存储位置 优点 缺点 适用场景
AppStorage 内存 读写快 应用退出后数据丢失 临时数据
Preferences 本地文件 持久化,API简单 数据量有限(建议<1MB) 推荐 - 收藏数据
SQLite 数据库 支持复杂查询 实现复杂 大数据量场景
云端同步 服务器 跨设备同步 需要网络,有服务器成本 需要多端同步时

核心实现

步骤1: 页面结构设计

完整代码
// pages/Collections.ets

import router from '@ohos.router';
import prompt from '@ohos.prompt';
import type { CollectionItem } from '../models/CollectionModel';

@Entry
@Component
struct Collections {
  // 收藏列表
  @State collections: CollectionItem[] = [];
  
  /**
   * 页面加载时执行
   */
  aboutToAppear() {
    this.loadCollections();
  }
  
  /**
   * 加载收藏数据
   */
  loadCollections(): void {
    // Mock数据
    this.collections = [
      {
        id: '1',
        type: 'holiday',
        title: '立春',
        image: 'lichun.png',
        desc: '二十四节气之首,标志着春天的开始',
        collectTime: '2024-01-10'
      },
      {
        id: '2',
        type: 'article',
        title: '二十四节气的由来',
        image: 'article_01.png',
        desc: '深入了解二十四节气的历史渊源',
        collectTime: '2024-01-08'
      },
      {
        id: '3',
        type: 'holiday',
        title: '清明',
        image: 'qingming.png',
        desc: '祭祖扫墓,缅怀先人',
        collectTime: '2024-01-05'
      },
      {
        id: '4',
        type: 'article',
        title: '节气养生指南',
        image: 'article_02.png',
        desc: '根据节气变化调整养生方式',
        collectTime: '2024-01-03'
      }
    ];
  }
  
  /**
   * 删除收藏
   */
  deleteCollection(id: string): void {
    const index = this.collections.findIndex((item) => item.id === id);
    if (index > -1) {
      this.collections.splice(index, 1);
      prompt.showToast({ message: '已取消收藏' });
    }
  }
  
  /**
   * 清空所有收藏
   */
  clearAll(): void {
    this.collections = [];
    prompt.showToast({ message: '已清空收藏' });
  }
  
  /**
   * 跳转到详情页
   */
  goToDetail(item: CollectionItem): void {
    try {
      if (item.type === 'holiday') {
        router.pushUrl({
          url: 'pages/Detail',
          params: { holidayId: item.id }
        });
      } else {
        router.pushUrl({
          url: 'pages/ArticleDetail',
          params: { articleId: item.id }
        });
      }
    } catch (error) {
      console.error('路由跳转失败: ' + JSON.stringify(error));
    }
  }
  
  /**
   * 构建UI
   */
  build() {
    Column({ space: 0 }) {
      // 1. 顶部导航
      this.buildHeader()
      
      // 2. 内容区域
      this.buildContent()
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#F8F7F2')
  }
}

interface CollectionItem {
  id: string;
  type: 'holiday' | 'article';
  title: string;
  image: string;
  desc: string;
  collectTime: string;
}
代码解析

1. 状态管理

  • collections: 收藏列表数据

2. 数据加载

  • loadCollections(): 加载收藏数据(Mock)

3. 功能方法

  • deleteCollection(): 删除单个收藏
  • clearAll(): 清空所有收藏
  • goToDetail(): 跳转到详情页

步骤2: 顶部导航

/**
 * 构建顶部导航
 */
@Builder
buildHeader(): void {
  Row({ space: 16 }) {
    Image($r('app.media.ic_back'))
      .width(24)
      .height(24)
      .fillColor('#333333')
      .onClick(() => {
        try {
          router.back();
        } catch (error) {
          console.error('返回失败: ' + JSON.stringify(error));
        }
      })
    
    Text('我的收藏')
      .fontSize(18)
      .fontWeight(FontWeight.Bold)
      .fontColor('#333333')
    
    Blank()
    
    if (this.collections.length > 0) {
      Text('清空')
        .fontSize(14)
        .fontColor('#FF5252')
        .onClick(() => {
          this.clearAll();
        })
    }
  }
  .width('100%')
  .height(56)
  .padding({ left: 16, right: 16 })
  .backgroundColor('#FFFFFF')
}

设计要点:

  • 返回按钮
  • 标题
  • 清空按钮(有收藏时显示)

步骤3: 内容区域

/**
 * 构建内容区域
 */
@Builder
buildContent(): void {
  if (this.collections.length === 0) {
    // 空状态
    this.buildEmptyState();
  } else {
    // 收藏列表
    this.buildCollectionList();
  }
}

/**
 * 构建空状态
 */
@Builder
buildEmptyState(): void {
  Column({ space: 16 }) {
    Image($r('app.media.ic_empty_collect'))
      .width(120)
      .height(120)
      .opacity(0.5)
    
    Text('暂无收藏')
      .fontSize(16)
      .fontColor('#999999')
    
    Text('快去收藏喜欢的内容吧')
      .fontSize(14)
      .fontColor('#BBBBBB')
    
    Button('去探索')
      .width(120)
      .height(40)
      .backgroundColor('#4A9B6D')
      .fontColor('#FFFFFF')
      .borderRadius(20)
      .margin({ top: 16 })
      .onClick(() => {
        try {
          router.back();
        } catch (error) {
          console.error('返回失败: ' + JSON.stringify(error));
        }
      })
  }
  .width('100%')
  .height('100%')
  .justifyContent(FlexAlign.Center)
}

/**
 * 构建收藏列表
 */
@Builder
buildCollectionList(): void {
  List({ space: 12 }) {
    ForEach(this.collections, (item: CollectionItem) => {
      ListItem() {
        this.buildCollectionItem(item)
      }
    }, (item: CollectionItem) => item.id)
  }
  .width('92%')
  .padding({ top: 12, bottom: 100 })
}

设计要点:

  • 空状态处理
  • 收藏列表展示
  • 列表布局

步骤4: 收藏项组件

/**
 * 构建收藏项
 */
@Builder
buildCollectionItem(item: CollectionItem): void {
  Card() {
    Row({ space: 12 }) {
      // 图片
      Image('rawfile://collections/' + item.image)
        .width(80)
        .height(60)
        .borderRadius(8)
        .objectFit(ImageFit.Cover)
      
      // 内容
      Column({ space: 6 }) {
        Row({ space: 8 }) {
          Text(item.title)
            .fontSize(15)
            .fontWeight(FontWeight.Medium)
            .fontColor('#333333')
          
          Text(item.type === 'holiday' ? '节气' : '文章')
            .fontSize(11)
            .fontColor('#4A9B6D')
            .padding({ left: 6, right: 6, top: 2, bottom: 2 })
            .backgroundColor('#E8F5E9')
            .borderRadius(4)
        }
        
        Text(item.desc)
          .fontSize(13)
          .fontColor('#666666')
          .maxLines(1)
          .textOverflow({ overflow: TextOverflow.Ellipsis })
        
        Text('收藏于 ' + item.collectTime)
          .fontSize(12)
          .fontColor('#999999')
      }
      .flexGrow(1)
      
      // 删除按钮
      Image($r('app.media.ic_delete'))
        .width(20)
        .height(20)
        .fillColor('#CCCCCC')
        .onClick((event: ClickEvent) => {
          // 阻止事件冒泡
          event.stopPropagation();
          this.deleteCollection(item.id);
        })
    }
    .width('100%')
    .padding(12)
    .onClick(() => {
      this.goToDetail(item);
    })
  }
}

设计要点:

  • 左图右文布局
  • 类型标签(节气/文章)
  • 删除按钮
  • 点击跳转到详情页

常见问题与解决方案

问题1: 删除按钮点击触发父元素点击

现象:
点击删除按钮时,同时触发了卡片的点击事件,导致跳转到详情页。

原因分析:
事件冒泡机制导致点击事件从子元素(删除按钮)向上传递到父元素(卡片)。

解决方案:

Image($r('app.media.ic_delete'))
  .onClick((event: ClickEvent) => {
    event.stopPropagation();  // 阻止事件冒泡
    this.deleteCollection(item.id);
  })

预防措施:

  • 所有嵌套点击元素都应考虑事件冒泡问题
  • 在复杂嵌套结构中使用 event.stopPropagation() 是最佳实践

问题2: 收藏数据不持久

现象:
退出应用后重新进入,收藏数据丢失。

原因分析:

  • 数据仅存储在内存中(@State)
  • 页面销毁时没有保存到持久化存储

解决方案:

// 使用Preferences持久化存储
import dataPreferences from '@ohos.data.preferences';

// 加载收藏数据
async loadCollections(): Promise<void> {
  try {
    const preferences = await dataPreferences.getPreferences(this.context, 'collections');
    const data = await preferences.get('collectionList', '[]');
    this.collections = JSON.parse(data);
  } catch (error) {
    console.error('加载收藏数据失败: ', error);
  }
}

// 保存收藏数据
async saveCollections(): Promise<void> {
  try {
    const preferences = await dataPreferences.getPreferences(this.context, 'collections');
    await preferences.put('collectionList', JSON.stringify(this.collections));
    await preferences.flush();
  } catch (error) {
    console.error('保存收藏数据失败: ', error);
  }
}

// 生命周期钩子
aboutToAppear() {
  this.loadCollections();
}

aboutToDisappear() {
  this.saveCollections();
}

关键要点:

  • aboutToAppear 时加载数据
  • aboutToDisappear 时保存数据
  • 使用 flush() 确保数据写入磁盘

问题3: 图片加载失败显示空白

现象:
收藏项的图片无法显示,显示空白区域。

原因分析:

  • 图片路径错误
  • 图片资源不存在
  • 网络请求失败(远程图片)

解决方案:

Image(this.getImagePath(item.image))
  .width(80)
  .height(60)
  .borderRadius(8)
  .objectFit(ImageFit.Cover)
  .onError(() => {
    console.error('图片加载失败: ', item.image);
  })

// 获取图片路径,提供默认图片
getImagePath(image: string): Resource | string {
  if (!image || image.trim() === '') {
    return $r('app.media.ic_default_cover');
  }
  try {
    // 尝试构建资源路径
    return 'rawfile://collections/' + image;
  } catch {
    return $r('app.media.ic_default_cover');
  }
}

优化方案:

// 使用条件渲染提供占位图
buildImage(item: CollectionItem) {
  if (!item.image) {
    Image($r('app.media.ic_default_cover'))
      .width(80)
      .height(60)
      .borderRadius(8)
  } else {
    Image('rawfile://collections/' + item.image)
      .width(80)
      .height(60)
      .borderRadius(8)
      .objectFit(ImageFit.Cover)
      .onError(() => {
        // 加载失败时显示默认图
        Image($r('app.media.ic_default_cover'))
          .width(80)
          .height(60)
          .borderRadius(8)
      })
  }
}

问题4: 大量收藏时列表滚动卡顿

现象:
当收藏数量较多时,列表滚动出现明显卡顿。

原因分析:

  • 使用 ForEach 而非 LazyForEach
  • 列表项渲染没有虚拟优化
  • 图片没有懒加载

解决方案:

// 使用LazyForEach实现虚拟列表
List({ space: 12 }) {
  LazyForEach(this.collectionDataSource, (item: CollectionItem) => {
    ListItem() {
      this.buildCollectionItem(item)
    }
    .width('100%')
  }, (item: CollectionItem) => item.id)
}
.width('92%')
.padding({ top: 12, bottom: 100 })
.cachedCount(5)  // 设置预加载数量

数据来源封装:

class CollectionDataSource extends BasicDataSource<CollectionItem> {
  private data: CollectionItem[] = [];

  constructor(data: CollectionItem[]) {
    super();
    this.data = data;
  }

  totalCount(): number {
    return this.data.length;
  }

  getData(index: number): CollectionItem {
    return this.data[index];
  }

  // 数据变更通知
  notifyDataChange() {
    this.notifyDataReload();
  }
}

问题5: 清空收藏没有确认提示

现象:
点击"清空"按钮后直接清空所有收藏,没有确认提示,容易误操作。

原因分析:
缺少二次确认机制,用户体验不佳。

解决方案:

// 清空按钮点击处理
onClearAll(): void {
  // 弹窗确认
  prompt.showDialog({
    title: '确认清空',
    message: '确定要清空所有收藏吗?此操作不可恢复。',
    buttons: [
      {
        text: '取消',
        color: '#666666'
      },
      {
        text: '确定',
        color: '#FF5252'
      }
    ]
  }).then((result) => {
    if (result.index === 1) {
      this.collections = [];
      this.saveCollections();
      prompt.showToast({ message: '已清空收藏' });
    }
  }).catch((error) => {
    console.error('弹窗错误: ', error);
  });
}

问题6: 删除操作没有动画效果

现象:
删除收藏项时直接消失,视觉体验突兀。

解决方案:

// 使用animateTo添加删除动画
deleteCollection(id: string): void {
  const index = this.collections.findIndex((item) => item.id === id);
  if (index > -1) {
    animateTo({
      duration: 300,
      curve: Curve.EaseOut
    }, () => {
      this.collections.splice(index, 1);
    });
    prompt.showToast({ message: '已取消收藏' });
    this.saveCollections();
  }
}

动画效果优化:

animateTo({
  duration: 300,
  curve: Curve.EaseOut,
  delay: 0,
  iterations: 1,
  playMode: PlayMode.Normal
}, () => {
  // 动画期间执行的UI变更
});

问题7: 收藏状态与其他页面不同步

现象:
在详情页收藏/取消收藏后,收藏列表页面没有同步更新。

原因分析:

  • 各页面独立维护收藏状态
  • 没有全局状态同步机制

解决方案:

// 使用AppStorage实现全局状态同步
@StorageLink('collections') collections: CollectionItem[] = [];

// 在详情页更新收藏状态
updateCollectionStatus(id: string, collected: boolean) {
  AppStorage.SetOrCreate('collections', [...currentCollections]);
}

完整实现:

// 全局收藏状态管理
class CollectionManager {
  private static KEY = 'global_collections';

  static getCollections(): CollectionItem[] {
    const data = AppStorage.Get(this.KEY);
    return data ? JSON.parse(data) : [];
  }

  static setCollections(collections: CollectionItem[]): void {
    AppStorage.SetOrCreate(this.KEY, JSON.stringify(collections));
  }

  static addCollection(item: CollectionItem): void {
    const collections = this.getCollections();
    if (!collections.find(c => c.id === item.id)) {
      collections.push(item);
      this.setCollections(collections);
    }
  }

  static removeCollection(id: string): void {
    const collections = this.getCollections();
    const filtered = collections.filter(c => c.id !== id);
    this.setCollections(filtered);
  }

  static isCollected(id: string): boolean {
    const collections = this.getCollections();
    return collections.some(c => c.id === id);
  }
}

问题8: 路由参数传递失败

现象:
点击收藏项跳转到详情页时,目标页面无法获取正确的参数。

原因分析:

  • 参数传递格式错误
  • 参数命名不一致

解决方案:

// 正确的路由跳转
goToDetail(item: CollectionItem): void {
  try {
    const params: Record<string, string> = {};
    
    if (item.type === 'holiday') {
      params.holidayId = item.id;
      router.pushUrl({
        url: 'pages/Detail',
        params: params
      });
    } else {
      params.articleId = item.id;
      router.pushUrl({
        url: 'pages/ArticleDetail',
        params: params
      });
    }
  } catch (error) {
    console.error('路由跳转失败: ', error);
    prompt.showToast({ message: '跳转失败' });
  }
}

// 目标页面获取参数
aboutToAppear() {
  const params = router.getParams() as Record<string, string>;
  this.holidayId = params?.holidayId || '';
}

调试技巧:

// 在目标页面打印参数
console.log('路由参数: ', JSON.stringify(router.getParams()));

本章小结

核心知识点

本文完成了收藏功能的实现:

1. 收藏列表

  • 卡片式布局展示收藏项
  • 图片+标题+描述+时间
  • 类型标签区分节气和文章

2. 添加收藏

  • 在详情页实现收藏功能
  • 状态切换动画
  • Toast提示

3. 删除收藏

  • 单个删除
  • 清空全部
  • 确认操作

4. 数据持久化

  • 使用Storage保存数据
  • 页面加载时读取

5. 空状态处理

  • 提示图片
  • 引导文字
  • 操作按钮

下一步预告

收藏功能已经完成!在下一篇文章中,我们将学习:

  • 📊 学习记录页面
  • 学习统计
  • 学习历程
  • 成就系统

节气通应用一发布上线,可在应用市场下载体验


相关链接

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐