鸿蒙原生应用实战(四):塔罗牌App开发 — 收藏功能与主题切换系统

前言

一个完整的 App 少不了 用户数据管理个性化体验。本篇文章将深入讲解塔罗牌 App 中的两个核心系统:

  1. 收藏管理器(FavoriteManager):收藏/取消收藏的状态管理
  2. 主题管理器(ThemeManager):深色/浅色主题的订阅发布模式
  3. 收藏页面(FavPage):收藏列表展示与空状态设计

这两个系统虽然代码量不大,但展示了一种纯静态类管理器的设计模式——这是鸿蒙 Stage 模型下非常实用的轻量级状态管理方案。

本文亮点

  • 静态类管理器的设计哲学与适用场景
  • 订阅发布模式的实现细节与内存泄漏防范
  • 主题系统的色彩心理学与无障碍设计考量
  • 收藏功能的用户体验优化策略
  • 从内存存储到持久化的平滑演进路径
  • 性能优化技巧与最佳实践分享

目标读者

  • 鸿蒙 ArkTS 初学者,学习状态管理方案
  • 中级开发者,了解架构设计模式
  • 产品设计师,关注用户体验细节
  • 技术负责人,考虑技术选型与扩展性

技术栈

  • HarmonyOS API 23+
  • Stage 应用模型
  • ArkTS 语言
  • 纯前端实现,无后端依赖

让我们开始深入这两个核心系统的设计与实现。

一、收藏管理器设计

1.1 为什么选择静态类?

在鸿蒙 ArkTS 中,状态管理有几种选择:

方案 优点 缺点 适用场景
@State 本地状态 简单直接 无法跨页面共享 单页面数据
静态类管理器 全局共享、无实例化 进程退出后丢失 运行时全局状态
AppStorage/LocalStorage 官方推荐、支持持久化 API 有一定学习成本 需要持久化的全局状态
数据库(RDB) 永久存储 复杂度高 大量结构化数据

对于收藏功能,我们选择 静态类管理器,原因是:

  • 收藏数据量小(最多 78 张牌)
  • 无需持久化(本期先做内存版,后续可轻松扩展为持久化)
  • 实现简单,代码清晰

1.2 FavoriteManager 完整实现

export class FavoriteManager {
  static favorites: number[] = [];

  // 切换收藏状态,返回新的状态
  static toggle(id: number): boolean {
    const index = FavoriteManager.favorites.indexOf(id);
    if (index >= 0) {
      FavoriteManager.favorites.splice(index, 1);
      return false;  // 已取消收藏
    } else {
      FavoriteManager.favorites.push(id);
      return true;   // 已添加收藏
    }
  }

  // 是否已收藏
  static isFavorite(id: number): boolean {
    return FavoriteManager.favorites.indexOf(id) >= 0;
  }

  // 获取所有收藏的 ID 列表
  static getAll(): number[] {
    return FavoriteManager.favorites;
  }

  // 清空所有收藏
  static clear(): void {
    FavoriteManager.favorites = [];
  }

  // 获取收藏数量
  static getCount(): number {
    return FavoriteManager.favorites.length;
  }
}

设计要点

  • 所有方法都是 static,无需实例化,全局可直接调用
  • toggle 返回 boolean:调用者可以根据返回值更新 UI,而不需要再查一次状态
  • 使用 indexOf + splice:ArkTS 中数组操作与标准 JavaScript 一致
  • 收藏 ID 数组:只存 ID 而非完整对象,节省内存且保持数据一致性

1.3 跨页面共享

由于静态类在整个应用生命周期内常驻内存,任何页面都可以直接访问:

// 列表页 — 检查初始收藏状态
aboutToAppear(): void {
  this.isFav = FavoriteManager.isFavorite(this.card.id);
}

// 详情页 — 切换收藏
toggleFav(): void {
  this.isFav = FavoriteManager.toggle(this.card.id);
}

// 收藏页 — 获取所有收藏
loadFavorites(): void {
  const favIds = FavoriteManager.getAll();
  const result: TarotCard[] = [];
  for (let i = 0; i < TAROT_CARDS.length; i++) {
    if (favIds.indexOf(TAROT_CARDS[i].id) >= 0) {
      result.push(TAROT_CARDS[i]);
    }
  }
  this.favList = result;
}

1.4 收藏在列表页中的应用

在 CardListPage 的 CardItem 组件中:

@Component
struct CardItem {
  card: TarotCard = { /* ... */ };
  theme: ThemeColors = { /* ... */ };
  @State isFav: boolean = false;

  aboutToAppear(): void {
    this.isFav = FavoriteManager.isFavorite(this.card.id);
  }

  toggleFav(): void {
    this.isFav = FavoriteManager.toggle(this.card.id);
  }

  build() {
    // ...
    Text(this.isFav ? '★' : '☆')
      .fontSize(22)
      .fontColor(this.isFav ? this.theme.favorite : this.theme.tabInactive)
      .onClick((event: ClickEvent) => { this.toggleFav(); });
  }
}

交互反馈

  • 未收藏:灰色 ☆
  • 已收藏:粉色 ★(#FF6B9D
  • 点击后颜色和符号立即变化

二、收藏页面(FavPage)实现

2.1 页面结构

@Entry
@Component
struct FavPage {
  @State favList: TarotCard[] = [];
  @State theme: ThemeColors = ThemeManager.colors;

  aboutToAppear(): void { /* 订阅主题 + 加载收藏 */ }
  onPageShow(): void { /* 刷新收藏列表 */ }
}

2.2 空状态设计

当用户还没有收藏任何牌时,展示友好的空状态:

if (this.favList.length === 0) {
  Column() {
    Text('📖').fontSize(64);
    Text('还没有收藏任何塔罗牌')
      .fontColor(this.theme.textSecondary);
    Text('去牌义列表中收藏你喜欢的牌吧')
      .fontColor(this.theme.textSecondary);

    Button() {
      Text('去浏览').fontColor('#FFFFFF');
    }
    .backgroundColor(this.theme.card)
    .borderRadius($r('app.float.app_button_radius'))
    .onClick(() => { router.pushUrl({ url: 'pages/CardListPage' }); });
  }
  .height('70%')
  .justifyContent(FlexAlign.Center)
  .alignItems(HorizontalAlign.Center);
}

空状态设计原则

  1. 使用 Emoji + 文案,比冷冰冰的"暂无数据"更亲切
  2. 提供明确的行动按钮,引导用户去浏览牌义列表
  3. 垂直居中,视觉舒适

2.3 收藏列表渲染

Scroll() {
  Column() {
    ForEach(this.favList, (item: TarotCard) => {
      Row() {
        // 编号图标
        Column() {
          Text(item.number).fontColor(item.color);
        }
        .width(44).height(44).borderRadius(22);

        // 名称 + 分类
        Column() {
          Text(item.name).fontWeight(FontWeight.Bold);
          Text(item.englishName + ' · ' + item.arcana);
        }

        // 取消收藏按钮
        Text('取消收藏')
          .fontColor(this.theme.favorite)
          .backgroundColor('rgba(255,107,157,0.1)')
          .borderRadius(8)
          .onClick(() => { this.removeFavorite(item.id); });
      }
      .onClick(() => {
        router.pushUrl({
          url: 'pages/CardDetailPage',
          params: { id: item.id }
        });
      });
    });
  }
}

2.4 清空所有收藏

顶部导航栏右侧的"清空"按钮:

// 标题行
Row() {
  Text('←').onClick(() => { router.back(); });
  Text($r('app.string.title_favorites'));
  Flex({ direction: FlexDirection.RowReverse }) {
    if (this.favList.length > 0) {
      Text('清空')
        .fontColor(this.theme.textSecondary)
        .onClick(() => { this.clearAll(); });
    }
  }
  .layoutWeight(1);
}

// 清空方法
clearAll(): void {
  FavoriteManager.clear();
  this.favList = [];
}

三、主题管理器设计

3.1 订阅发布模式

主题切换需要更新所有的页面,我们使用经典的 订阅-发布模式

type ThemeListener = () => void;

export class ThemeManager {
  static isLight: boolean = false;        // 当前是否为浅色主题
  private static listeners: ThemeListener[] = [];  // 订阅者列表

  // 获取当前主题色板
  static get colors(): ThemeColors {
    return ThemeManager.isLight ? CALM_LIGHT : DARK_MYSTIC;
  }

  // 切换主题
  static toggle(): void {
    ThemeManager.isLight = !ThemeManager.isLight;
    // 通知所有订阅者
    for (let i = 0; i < ThemeManager.listeners.length; i++) {
      ThemeManager.listeners[i]();
    }
  }

  // 显式设置主题
  static setLight(light: boolean): void {
    if (ThemeManager.isLight !== light) {
      ThemeManager.toggle();
    }
  }

  // 订阅主题变化
  static subscribe(listener: ThemeListener): void {
    ThemeManager.listeners.push(listener);
  }

  // 取消订阅
  static unsubscribe(listener: ThemeListener): void {
    const idx = ThemeManager.listeners.indexOf(listener);
    if (idx >= 0) {
      ThemeManager.listeners.splice(idx, 1);
    }
  }
}

3.2 各页面如何订阅

每个需要响应主题变化的页面都要在 aboutToAppear 中订阅,在 aboutToDisappear 中取消订阅:

aboutToAppear(): void {
  this.theme = ThemeManager.colors;  // 初始化主题
  ThemeManager.subscribe(() => {
    this.theme = ThemeManager.colors;  // 主题变化时更新状态
  });
}

aboutToDisappear(): void {
  ThemeManager.unsubscribe(() => {});  // 注意:这里需要传递同一个函数引用
}

⚠️ 重要问题: 上面代码中 () => {} 每次调用都创建了一个新函数,unsubscribeindexOf 找不到匹配项!正确的做法是把回调函数保存为变量:

// ✅ 正确的做法
private themeListener: ThemeListener = () => {
  this.theme = ThemeManager.colors;
};

aboutToAppear(): void {
  this.theme = ThemeManager.colors;
  ThemeManager.subscribe(this.themeListener);
}

aboutToDisappear(): void {
  ThemeManager.unsubscribe(this.themeListener);
}

四、深色与浅色主题定义

4.1 ThemeColors 接口

定义色板的结构:

export interface ThemeColors {
  bg: string;           // 背景色
  card: string;         // 卡片背景色
  textPrimary: string;  // 主文字颜色
  textSecondary: string; // 次要文字颜色
  accent: string;       // 强调色
  tabInactive: string;  // 标签未选中色
  favorite: string;     // 收藏图标色
  cardBorder: string;   // 卡片边框色
  tagBg: string;        // 标签背景色
}

4.2 深色主题(暗黑神秘风)

export const DARK_MYSTIC: ThemeColors = {
  bg: '#1A0A2E',           // 深紫色背景
  card: '#2D1B4E',         // 紫罗兰卡片
  textPrimary: '#FFFFFF',  // 白色文字
  textSecondary: '#B8A8D0', // 浅紫色辅助文字
  accent: '#D4AF37',       // 金色强调
  tabInactive: '#6B5B8E',  // 灰紫色未选中
  favorite: '#FF6B9D',     // 粉色收藏
  cardBorder: '#D4AF37',   // 金色边框
  tagBg: 'rgba(212,175,55,0.12)'  // 金色半透明背景
};

4.3 浅色主题(宁静明亮风)

export const CALM_LIGHT: ThemeColors = {
  bg: '#F5F0FF',           // 浅紫白背景
  card: '#FFFFFF',         // 纯白卡片
  textPrimary: '#2D1B4E',  // 深紫文字
  textSecondary: '#8A7AA0', // 灰紫辅助文字
  accent: '#7C3AED',       // 紫色强调
  tabInactive: '#C4B5D4',  // 淡紫未选中
  favorite: '#E11D48',     // 红色收藏
  cardBorder: '#7C3AED',   // 紫色边框
  tagBg: 'rgba(124,58,237,0.08)'  // 紫色半透明背景
};

设计理念

  • 深色主题:神秘、深邃,配合塔罗牌的神秘学氛围
  • 浅色主题:清新、易读,适合日间使用
  • 两组色板在结构上完全一致(字段数量相同、语义对应),切换时不会出现布局错位

五、主题切换的实际应用效果

在首页,主题切换按钮位于右上角:

// 深色 → 浅色时,月亮图标变为太阳图标
Text(ThemeManager.isLight ? '🌙' : '☀️')
  .fontSize(22)
  .onClick(() => { this.toggleTheme(); });

所有页面中,颜色属性都通过 this.theme.xxx 引用:

// 示例:列表页背景
.backgroundColor(this.theme.bg)

// 卡片背景
.backgroundColor(this.theme.card)

// 强调色文字
.fontColor(this.theme.accent)

// 收藏图标
.fontColor(this.isFav ? this.theme.favorite : this.theme.tabInactive)

六、扩展思考:数据持久化

目前的收藏数据存储在内存中,App 重启后会丢失。如果需要持久化,有几种方案:

6.1 使用 Preferences(轻量级 KV 存储)

import { preferences } from '@kit.ArkData';

// 保存
const prefs = await preferences.getPreferences(this.context, 'my_prefs');
await prefs.put('favorites', JSON.stringify(favorites));
await prefs.flush();

// 读取
const json = await prefs.get('favorites', '[]');
favorites = JSON.parse(json);

6.2 使用 RelationalStore(关系型数据库)

适合更复杂的数据查询场景,但收藏功能用 RDB 有点过重。

6.3 使用 AppStorage(全局 UI 状态存储)

官方推荐方式,但需要关注其与 ArkTS 响应式系统的配合。


在这里插入图片描述

七、小结

本篇我们完成了:

  1. ✅ FavoriteManager 静态收藏管理器设计
  2. ✅ FavPage 收藏页(列表 + 空状态 + 清空)
  3. ✅ ThemeManager 订阅发布模式
  4. ✅ 深色/浅色双主题色板定义
  5. ✅ 跨页面主题切换的实现
  6. ✅ 数据持久化的扩展思路

下一篇是 收官之篇,我们将讲解 TarotData 全量数据模型设计、API 版本适配策略、构建配置优化,以及从开发到上线的完整流程。

项目代码: 基于 HarmonyOS API 23 + Stage 模型 + ArkTS
涉及文件: model/TarotData.ets + pages/FavPage.ets
下篇预告: 数据模型、构建配置与工程优化 — 从开发到上线的最后一公里

Logo

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

更多推荐