鸿蒙原生应用实战(二):塔罗牌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 })
})
重要注意事项:
- ForEach 的第一个参数是数据源,第二个是渲染函数
- 列表项建议提取为独立
@Component,方便复用和测试 - 数据源变化时,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 页面布局结构
详情页展示单张塔罗牌的完整信息:
- 返回按钮 → 顶部的
← - 牌面展示区 → 模拟卡片外观,显示编号、中文名、英文名
- 关键词 → 引号强调
- 牌面描述 → 详细文字说明
- 正逆位切换 → 两个可点击的标签
- 含义内容 → 根据正逆位显示不同的释义
- 收藏按钮 → 底部收藏/取消收藏
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 ?? '加载中...') // 使用可选链和空值合并
}
}
最佳实践建议:
- 优先使用声明时初始化:对于简单数据类型和固定默认值的对象,直接在属性声明时初始化。
- 复杂对象使用构造函数:对于需要外部传入的复杂对象,使用构造函数参数确保初始化。
- 避免滥用类型断言 :
{} as TarotCard虽然能通过编译,但可能隐藏运行时错误。 - 利用接口定义默认值 :可以创建一个
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
})
类型安全的进阶技巧:
- 使用
Partial<T>类型 :当某些字段可以后续设置时,可以定义theme?: Partial<ThemeColors>。 - 创建构建器函数:提供类型安全的主题构建函数。
- 运行时类型验证:对于从网络或本地存储加载的数据,添加运行时类型检查。
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)
}
}
}
}
路由参数的最佳实践:
- 定义明确的参数接口:为每个页面的路由参数定义 TypeScript 接口。
- 使用工具函数进行安全转换 :避免直接使用
as类型断言,而是通过工具函数处理各种边界情况。 - 参数验证与回退:对关键参数进行验证,无效时提供合理的回退行为。
- 记录参数日志:在开发阶段记录接收到的参数,便于调试。
- 考虑参数编码:对于复杂对象参数,考虑使用 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 严格模式的调试与优化
- 启用严格模式检查 :在
tsconfig.json中确保开启严格模式选项。 - 使用类型断言的情况 :仅在确定类型安全时使用
as,优先考虑类型守卫。 - 处理第三方库类型 :为没有类型定义的第三方库创建
.d.ts声明文件。 - 定期运行类型检查:在构建流程中加入 TypeScript 类型检查步骤。

图:ArkTS严格模式在编译时进行的类型检查流程,帮助提前发现潜在问题
通过遵循这些严格模式的最佳实践,可以显著提高鸿蒙应用的代码质量和稳定性,减少运行时错误,提升开发效率。
六、小结
本篇我们完成了:
- ✅ 牌义列表页的完整实现(标签筛选 + ForEach 渲染)
- ✅ CardItem 可复用列表组件
- ✅ 路由跳转、参数传递、接收的完整流程
- ✅ 详情页的正逆位切换交互
- ✅ onPageShow 在返回刷新中的应用
- ✅ ArkTS 严格模式下的常见注意事项
下篇文章我们将进入 App 最有趣的功能------牌阵解读,实现单张牌、三张牌和凯尔特十字牌阵的随机抽取与结果展示。
项目代码 : 基于 HarmonyOS API 23 + Stage 模型 + ArkTS
涉及页面 : CardListPage.ets → CardDetailPage.ets
下篇预告: 牌阵解读 --- 随机算法、状态管理与复杂交互设计