鸿蒙原生应用实战(二):塔罗牌App开发 — 牌义列表与路由导航

鸿蒙原生应用实战(二):塔罗牌App开发 --- 牌义列表与路由导航

前言

在上一篇中,我们完成了鸿蒙塔罗牌App的项目搭建和首页开发。本篇将深入讲解**牌义列表页(CardListPage)牌义详情页(CardDetailPage)**这两个核心功能页面的实现,涵盖从数据渲染到页面导航的完整开发流程。

通过本篇学习,你将掌握:

  • 列表数据渲染与分类筛选的实现技巧
  • 页面间路由跳转与参数传递的最佳实践
  • ArkTS ForEach指令的正确使用方法
  • 收藏状态管理的前端交互设计
  • 返回页面数据刷新的优雅处理方案
  • ArkTS严格模式下的实战开发经验

一、牌义列表页(CardListPage)

1.1 页面功能概览

牌义列表页是整个 App 的数据中枢,用户通过它浏览全部 22 张大阿卡纳和 56 张小阿卡纳牌。页面包含:

  • 顶部导航栏:返回按钮 + 页面标题
  • 标签筛选栏:全部 | 大阿卡纳 | 小阿卡纳
  • 卡片列表:每项显示编号、名称、英文名、分类标签、收藏按钮

1.2 标签筛选实现

筛选逻辑通过 activeTab 状态变量控制:

typescript 复制代码
@State tabs: string[] = ['全部', '大阿卡纳', '小阿卡纳'];
@State activeTab: string = '全部';
@State filteredList: TarotCard[] = TAROT_CARDS;

点击标签时更新 activeTab 并重新筛选:

typescript 复制代码
selectTab(tab: string): void {
  this.activeTab = tab;
  this.applyFilter();
}

applyFilter(): void {
  if (this.activeTab === '全部') {
    this.filteredList = TAROT_CARDS;
  } else {
    const arr: TarotCard[] = [];
    for (let i = 0; i < TAROT_CARDS.length; i++) {
      if (TAROT_CARDS[i].arcana === this.activeTab) {
        arr.push(TAROT_CARDS[i]);
      }
    }
    this.filteredList = arr;
  }
}

注意 : 这里使用 for 循环 + push 而不是 filter 方法,是因为 ArkTS 严格模式对高阶函数支持有限。在实际项目中,也可使用 const arr = TAROT_CARDS.filter(card => card.arcana === this.activeTab) 来简化。

1.3 标签 UI 交互细节

标签的选中态用 backgroundColor + fontColor 双重视觉反馈:

typescript 复制代码
Text(tab)
  .fontColor(this.activeTab === tab ? this.theme.accent : this.theme.tabInactive)
  .fontWeight(this.activeTab === tab ? FontWeight.Bold : FontWeight.Normal)
  .padding({ left: 16, right: 16, top: 6, bottom: 6 })
  .backgroundColor(this.activeTab === tab ? this.theme.tagBg : 'transparent')
  .borderRadius(16)
  .onClick(() => { this.selectTab(tab); });

这种"胶囊式"标签在鸿蒙中很常见,配合 borderRadius 实现圆角效果。

1.4 ForEach 列表渲染

ArkTS 的 ForEach 类似于 Vue 的 v-for 或 React 的 map

typescript 复制代码
ForEach(this.filteredList, (item: TarotCard) => {
  CardItem({ card: item, theme: this.theme })
})

重要注意事项

  1. ForEach 的第一个参数是数据源,第二个是渲染函数
  2. 列表项建议提取为独立 @Component,方便复用和测试
  3. 数据源变化时,ForEach 会根据 key(默认使用索引)进行差异化更新

1.5 细化 CardItem 组件

将列表中的每个卡片项提取为独立组件:

typescript 复制代码
@Component
struct CardItem {
  card: TarotCard = {
    id: 0, name: '', englishName: '', arcana: '', number: '',
    keywords: '', meaningUp: '', meaningDown: '', description: '',
    color: '#FFFFFF', isFavorite: false
  };
  theme: ThemeColors = { /* 默认值 */ };
  @State isFav: boolean = false;

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

  build() {
    Row() {
      // 编号圆形圈
      Column() {
        Text(this.card.number)
          .fontColor(this.card.color);
      }
      .width(36).height(36).borderRadius(18);

      // 名称信息
      Column() {
        Text(this.card.name).fontWeight(FontWeight.Bold);
        Text(this.card.englishName).fontColor(this.theme.textSecondary);
      }

      // 分类标签
      Text(this.card.arcana);

      // 收藏按钮
      Text(this.isFav ? '★' : '☆')
        .onClick((event: ClickEvent) => { this.toggleFav(); });
    }
    .onClick(() => {
      router.pushUrl({
        url: 'pages/CardDetailPage',
        params: { id: this.card.id }
      });
    });
  }
}

设计亮点

  • 编号使用牌自身的 color 属性,每张牌的颜色不同,视觉上丰富
  • 收藏按钮单独处理点击事件(event.stopPropagation() 在 ArkTS 中需要通过事件参数控制)
  • 整行点击跳转到详情页

二、路由导航详解

2.1 基础路由操作

鸿蒙路由有三种核心操作:

typescript 复制代码
import router from '@ohos.router';

// 1. 跳转(带参数)
router.pushUrl({
  url: 'pages/CardDetailPage',
  params: { id: 0, from: 'list' }
});

// 2. 返回上一页
router.back();

// 3. 返回并传参
router.back({ url: 'pages/Index', params: { refreshed: true } });

2.2 接收路由参数

在目标页面中,通过 router.getParams() 获取参数:

typescript 复制代码
aboutToAppear(): void {
  const params = router.getParams() as Record<string, Object>;
  if (params && params['id'] !== undefined) {
    const id = Number(params['id']);
    // 根据 id 查找对应的牌
    for (let i = 0; i < TAROT_CARDS.length; i++) {
      if (TAROT_CARDS[i].id === id) {
        this.card = TAROT_CARDS[i];
        break;
      }
    }
  }
}

类型转换注意router.getParams() 返回 Object 类型,需要先 as Record<string, Object> 转型再取值。

2.3 onPageShow 的应用场景

onPageShow 在页面每次显示时触发(不同于 aboutToAppear 只在创建时触发)。这在从详情页返回列表页时刷新数据很有用:

typescript 复制代码
// CardListPage
onPageShow(): void {
  this.theme = ThemeManager.colors;   // 刷新主题
  this.applyFilter();                 // 刷新列表
}

同样的模式用在收藏页:

typescript 复制代码
// FavPage
onPageShow(): void {
  this.theme = ThemeManager.colors;
  this.loadFavorites();  // 每次回到收藏页都重新加载数据
}

三、详情页(CardDetailPage)的实现

3.1 页面布局结构

详情页展示单张塔罗牌的完整信息:

  1. 返回按钮 → 顶部的
  2. 牌面展示区 → 模拟卡片外观,显示编号、中文名、英文名
  3. 关键词 → 引号强调
  4. 牌面描述 → 详细文字说明
  5. 正逆位切换 → 两个可点击的标签
  6. 含义内容 → 根据正逆位显示不同的释义
  7. 收藏按钮 → 底部收藏/取消收藏

3.2 正逆位切换设计

塔罗牌占卜中,正位和逆位的含义完全不同。我们通过 @State showReverse 控制显示:

typescript 复制代码
@State showReverse: boolean = false;

// UI 中
Row() {
  Text('正位')
    .fontColor(!this.showReverse ? this.theme.accent : this.theme.tabInactive)
    .backgroundColor(!this.showReverse ? this.theme.tagBg : 'transparent')
    .borderRadius(16)
    .onClick(() => { this.showReverse = false; });

  Text('逆位')
    .fontColor(this.showReverse ? this.theme.accent : this.theme.tabInactive)
    .backgroundColor(this.showReverse ? this.theme.tagBg : 'transparent')
    .borderRadius(16)
    .onClick(() => { this.showReverse = true; });
}

// 内容区根据状态切换
Text(this.showReverse ? this.card.meaningDown : this.card.meaningUp)
  .fontSize($r('app.float.app_body_size'))
  .lineHeight(24);

3.3 牌面视觉效果

为了模拟塔罗牌的卡片外观,我们使用 aspectRatio 保持比例:

typescript 复制代码
Column() {
  Text(this.card.number).fontColor(this.card.color).opacity(0.6);
  Text(this.card.name).fontSize(36).fontWeight(FontWeight.Bold);
  Text(this.card.englishName).fontColor(this.theme.accent);
}
.width('75%')
.aspectRatio(0.6)      // 宽高比 1:0.6,模拟塔罗牌比例
.backgroundColor(this.theme.card)
.borderRadius(16)
.border({ width: 2, color: this.theme.cardBorder });

aspectRatio 是 ArkUI 中非常实用的布局属性,可以自动根据宽度计算高度(或相反),在卡片布局中非常有用。


四、收藏按钮的交互

详情页底部的收藏按钮有双重状态:

typescript 复制代码
Button() {
  Text(this.isFav ? '★ 已收藏' : '☆ 收藏此牌')
    .fontColor('#FFFFFF');
}
.width('80%')
.height(48)
.backgroundColor(this.isFav ? this.theme.favorite : this.theme.card)
.onClick(() => { this.toggleFav(); });

状态变化时,按钮的文字和背景色同步变化,给用户清晰的反馈。


五、ArkTS 严格模式实战经验

在鸿蒙应用开发中,ArkTS 的严格模式(Strict Mode)是确保代码质量和运行时稳定性的重要机制。它通过静态类型检查、空安全、初始化验证等方式,帮助开发者提前发现潜在问题。在塔罗牌App的开发过程中,我们积累了一些实战经验,特别是一些容易踩到的"坑"。

5.1 对象默认值初始化

@Component 中声明的属性如果没有默认值,必须在结构体初始化时提供完整默认值。这是 ArkTS 严格模式的核心要求之一,目的是避免运行时出现未定义行为。

常见错误场景:

typescript 复制代码
// ❌ 错误示例:缺少部分字段初始化
@Component
struct CardItem {
  card: TarotCard; // 没有默认值,且未在 aboutToAppear 中初始化
  
  build() {
    // 这里访问 card.name 可能导致运行时错误
    Text(this.card.name)
  }
}

正确做法:

typescript 复制代码
// ✅ 正确示例1:在属性声明时提供完整默认值
@Component
struct CardItem {
  card: TarotCard = {
    id: 0, name: '', englishName: '', arcana: '', number: '',
    keywords: '', meaningUp: '', meaningDown: '', description: '',
    color: '#FFFFFF', isFavorite: false
  };
  
  build() {
    Text(this.card.name) // 安全访问
  }
}

// ✅ 正确示例2:通过构造函数参数初始化
@Component
struct CardItem {
  private card: TarotCard;
  
  constructor(card: TarotCard) {
    this.card = card; // 通过参数确保初始化
  }
  
  build() {
    Text(this.card.name)
  }
}

// ✅ 正确示例3:在 aboutToAppear 生命周期中初始化
@Component
struct CardItem {
  private card: TarotCard = {} as TarotCard; // 使用类型断言,需谨慎
  
  aboutToAppear() {
    // 从本地存储或网络获取数据
    this.card = this.loadCardData();
  }
  
  build() {
    Text(this.card?.name ?? '加载中...') // 使用可选链和空值合并
  }
}

最佳实践建议:

  1. 优先使用声明时初始化:对于简单数据类型和固定默认值的对象,直接在属性声明时初始化。
  2. 复杂对象使用构造函数:对于需要外部传入的复杂对象,使用构造函数参数确保初始化。
  3. 避免滥用类型断言{} as TarotCard 虽然能通过编译,但可能隐藏运行时错误。
  4. 利用接口定义默认值 :可以创建一个 DEFAULT_TAROT_CARD 常量,在多个组件中复用。

5.2 组件间传参的类型匹配

ArkTS 要求自定义组件的属性类型必须与传入值严格匹配,这包括所有必填字段和可选字段。

类型匹配的常见问题:

typescript 复制代码
// 定义接口
interface ThemeColors {
  primary: string;
  secondary: string;
  background: string;
  text?: string; // 可选字段
}

// ❌ 错误1:缺少必填字段
CardItem({ card: item, theme: { primary: '#FF0000' } }) // 缺少 secondary 和 background

// ❌ 错误2:字段类型不匹配
CardItem({ card: item, theme: { primary: 123, secondary: '#00FF00', background: '#000000' } }) // primary 应该是 string

// ❌ 错误3:多余字段(严格模式下可能报错)
CardItem({ card: item, theme: { primary: '#FF0000', secondary: '#00FF00', background: '#000000', extra: 'unexpected' } })

解决方案:

typescript 复制代码
// ✅ 正确做法1:使用完整对象
const myTheme: ThemeColors = {
  primary: '#4A90E2',
  secondary: '#7B68EE',
  background: '#F5F5F5',
  text: '#333333'
};
CardItem({ card: item, theme: myTheme })

// ✅ 正确做法2:使用展开运算符补充默认值
CardItem({ 
  card: item, 
  theme: { 
    ...DEFAULT_THEME, // 包含所有必填字段的默认值
    primary: '#FF6B6B' // 覆盖特定值
  } 
})

// ✅ 正确做法3:使用类型断言(需确保类型安全)
CardItem({ 
  card: item, 
  theme: { 
    primary: '#FF0000', 
    secondary: '#00FF00', 
    background: '#000000' 
  } as ThemeColors 
})

类型安全的进阶技巧:

  1. 使用 Partial<T> 类型 :当某些字段可以后续设置时,可以定义 theme?: Partial<ThemeColors>
  2. 创建构建器函数:提供类型安全的主题构建函数。
  3. 运行时类型验证:对于从网络或本地存储加载的数据,添加运行时类型检查。

5.3 路由参数的类型安全

router.getParams() 返回的是 Object 类型,在严格模式下需要手动进行类型转换和验证。

路由参数处理的完整流程:

typescript 复制代码
import router from '@ohos.router';

// 1. 定义路由参数类型
interface CardDetailParams {
  id: number;          // 卡片ID
  from?: string;       // 来源页面(可选)
  showAnimation?: boolean; // 是否显示动画(可选)
}

// 2. 页面组件中安全获取参数
@Component
struct CardDetailPage {
  @State cardId: number = 0;
  @State fromPage: string = 'list';
  @State showAnim: boolean = true;
  
  aboutToAppear() {
    const params = router.getParams() as Record<string, Object>;
    
    // 3. 类型安全转换与默认值处理
    this.cardId = this.safeGetNumber(params, 'id', 0);
    this.fromPage = this.safeGetString(params, 'from', 'list');
    this.showAnim = this.safeGetBoolean(params, 'showAnimation', true);
    
    // 4. 参数验证
    if (this.cardId <= 0) {
      console.error('无效的卡片ID:', this.cardId);
      router.back(); // 返回上一页
      return;
    }
    
    // 5. 根据参数加载数据
    this.loadCardDetail(this.cardId);
  }
  
  // 安全获取数字参数
  private safeGetNumber(params: Record<string, Object>, key: string, defaultValue: number): number {
    const value = params[key];
    if (value === undefined || value === null) {
      return defaultValue;
    }
    
    // 多种类型转换尝试
    if (typeof value === 'number') {
      return value;
    } else if (typeof value === 'string') {
      const num = Number(value);
      return isNaN(num) ? defaultValue : num;
    } else if (typeof value === 'boolean') {
      return value ? 1 : 0;
    }
    
    return defaultValue;
  }
  
  // 安全获取字符串参数
  private safeGetString(params: Record<string, Object>, key: string, defaultValue: string): string {
    const value = params[key];
    if (value === undefined || value === null) {
      return defaultValue;
    }
    
    return String(value);
  }
  
  // 安全获取布尔参数
  private safeGetBoolean(params: Record<string, Object>, key: string, defaultValue: boolean): boolean {
    const value = params[key];
    if (value === undefined || value === null) {
      return defaultValue;
    }
    
    if (typeof value === 'boolean') {
      return value;
    } else if (typeof value === 'string') {
      return value.toLowerCase() === 'true';
    } else if (typeof value === 'number') {
      return value !== 0;
    }
    
    return defaultValue;
  }
  
  build() {
    // 使用安全转换后的参数
    Column() {
      Text(`卡片ID: ${this.cardId}`)
        .fontSize(20)
      
      if (this.showAnim) {
        // 显示动画效果
        LoadingProgress()
          .width(50)
          .height(50)
      }
    }
  }
}

路由参数的最佳实践:

  1. 定义明确的参数接口:为每个页面的路由参数定义 TypeScript 接口。
  2. 使用工具函数进行安全转换 :避免直接使用 as 类型断言,而是通过工具函数处理各种边界情况。
  3. 参数验证与回退:对关键参数进行验证,无效时提供合理的回退行为。
  4. 记录参数日志:在开发阶段记录接收到的参数,便于调试。
  5. 考虑参数编码:对于复杂对象参数,考虑使用 JSON 序列化/反序列化。

5.4 严格模式下的状态管理技巧

在严格模式下,状态管理需要特别注意类型和初始化。

typescript 复制代码
// 1. 使用 @State 装饰器
@Component
struct CardListPage {
  // 明确指定数组类型和初始值
  @State cardList: TarotCard[] = [];
  
  // 2. 使用 @Prop 进行父子组件通信
  @Prop selectedCard: TarotCard | null = null;
  
  // 3. 使用 @Link 创建双向绑定
  @Link @Watch('onFilterChange') filterType: string = 'all';
  
  // 4. 使用 @StorageLink 持久化状态
  @StorageLink('favoriteCards') favoriteIds: number[] = [];
  
  // 监听状态变化
  onFilterChange() {
    console.log('筛选类型变化:', this.filterType);
    this.filterCards();
  }
  
  // 5. 异步状态更新
  async loadCards() {
    try {
      // 使用临时变量避免直接修改 @State
      const newCards = await this.fetchCards();
      
      // 正确更新数组状态
      this.cardList = [...this.cardList, ...newCards];
    } catch (error) {
      console.error('加载卡片失败:', error);
      // 提供错误状态
      this.cardList = []; // 清空或显示错误状态
    }
  }
}

5.5 严格模式的调试与优化

  1. 启用严格模式检查 :在 tsconfig.json 中确保开启严格模式选项。
  2. 使用类型断言的情况 :仅在确定类型安全时使用 as,优先考虑类型守卫。
  3. 处理第三方库类型 :为没有类型定义的第三方库创建 .d.ts 声明文件。
  4. 定期运行类型检查:在构建流程中加入 TypeScript 类型检查步骤。

图:ArkTS严格模式在编译时进行的类型检查流程,帮助提前发现潜在问题

通过遵循这些严格模式的最佳实践,可以显著提高鸿蒙应用的代码质量和稳定性,减少运行时错误,提升开发效率。

六、小结

本篇我们完成了:

  1. ✅ 牌义列表页的完整实现(标签筛选 + ForEach 渲染)
  2. ✅ CardItem 可复用列表组件
  3. ✅ 路由跳转、参数传递、接收的完整流程
  4. ✅ 详情页的正逆位切换交互
  5. ✅ onPageShow 在返回刷新中的应用
  6. ✅ ArkTS 严格模式下的常见注意事项

下篇文章我们将进入 App 最有趣的功能------牌阵解读,实现单张牌、三张牌和凯尔特十字牌阵的随机抽取与结果展示。

项目代码 : 基于 HarmonyOS API 23 + Stage 模型 + ArkTS

涉及页面 : CardListPage.ets → CardDetailPage.ets

下篇预告: 牌阵解读 --- 随机算法、状态管理与复杂交互设计

相关推荐
星释8 小时前
鸿蒙智能体开发实战:3.创建工作流
华为·harmonyos·智能体
hahjee8 小时前
【鸿蒙 PC三方库构建系统】解决 OpenHarmony SHA 库编译问题:从动态链接错误到静态链接优化
华为·harmonyos
伶俜668 小时前
鸿蒙原生应用实战(二十)ArkUI 课程表 App:Grid 网格 + SQLite 存储 + 周次切换 + 上课提醒
华为·sqlite·harmonyos
Davina_yu8 小时前
画布Canvas:2D绘图上下文(Context2D)绘制复杂图表(33)
harmonyos·鸿蒙·鸿蒙系统
风华圆舞9 小时前
鸿蒙 Flutter 页面怎么感知防窥状态并调整 UI 可见性
flutter·ui·harmonyos
小雨下雨的雨9 小时前
HarmonyOS ArkUI训练营入门-组件掌握系列-Grid 网格布局深度解析-PC版本
学习·华为·harmonyos·鸿蒙·鸿蒙系统
Davina_yu19 小时前
定时器与任务调度:setTimeout与setInterval的正确使用(19)
harmonyos·鸿蒙·鸿蒙系统
祭曦念20 小时前
【共创季稿事节】鸿蒙原生ArkTS布局深度解析_GridRow_Row_Column混合栅格布局实战
华为·harmonyos
kiros_wang20 小时前
鸿蒙 ArkUI:V1 与 V2 装饰器全面对比与迁移指南
ubuntu·华为·harmonyos
古德new21 小时前
鸿蒙PC迁移:Photoflare Qt 图片编辑器鸿蒙PC适配全记录
qt·编辑器·harmonyos