技术引言

HarmonyOS 6.1.1 作为华为鸿蒙生态的最新演进版本,带来了更加成熟的原生应用开发框架与更完善的分布式能力。在这一版本中,HarmonyOS ArkTS API 24 提供了丰富的声明式 UI 语法、状态管理装饰器、动画与粒子特效支持,使开发者能够以极简的代码构建出兼具视觉冲击力与交互流畅度的原生应用界面。

ArkTS 是鸿蒙生态主推的应用编程语言,它在 TypeScript 的基础上进行了深度定制和扩展,引入了 @Entry@Component@State@Builder@Observed 等一系列装饰器,使声明式 UI 编程更加直观高效。本文以"QQ星座·星盘馆"星座运势占星 App 为应用场景,通过一个完整的演示页面,深入剖析 ArkTS 在数据建模、状态驱动、粒子动画、多维图表渲染以及弹窗交互等方面的核心技术与最佳实践。读者将看到如何利用 HarmonyOS 6.1.1 的原生能力,打造出暗夜紫与星空金交织的神秘占星风格界面。


一、整体架构概览

在深入逐行代码之前,我们先从宏观层面理解这个星座运势 App 的整体架构设计。该应用采用单页面多 Tab 切换的架构模式,底部导航分为"运势、星座、塔罗、我的"四个主功能模块,而运势模块内部又细分为"今日、明日、本周、本月、年运、配对"六个子 Tab。整个页面通过一个 Stack 容器承载,底层是功能内容区,上层叠加了星空粒子特效层和多个弹窗层。

Entry Index 组件

Stack 根容器

Column 主内容区

粒子特效层 ForEach

弹窗层 Modal Layer

Header 顶部信息栏

TopBar 子导航栏

Scroll 内容滚动区

BottomTab 底部导航

运势内容 Fortune

星座内容 Zodiac

塔罗内容 Tarot

我的内容 Mine

记录运势弹窗

修改信息弹窗

清除记录弹窗

星座详情弹窗

从架构图可以看出,整个应用的数据流是单向的:用户通过底部 Tab 切换触发 currentBottomTab 状态变化,进而驱动内容区的条件渲染;同时,顶部子 Tab 的切换会触发 currentTopTab 状态变化,驱动运势子模块的切换。弹窗层则通过独立的布尔状态变量控制显隐,实现了清晰的关注点分离。


二、逐段代码深度分析

代码段 1:色彩体系定义(接口与常量)

interface ColorPalette {
  nightPurple: string;
  nightDeep: string;
  starGold: string;
  starGoldLight: string;
  mysticViolet: string;
  mysticVioletLight: string;
  bg: string;
  cardBg: string;
  cardBgDark: string;
  textPrimary: string;
  textSecondary: string;
  textHint: string;
  white: string;
  border: string;
  danger: string;
  success: string;
}

在这里插入图片描述

在 HarmonyOS ArkTS 的开发实践中,接口(interface)扮演着类型契约的核心角色。这里定义的 ColorPalette 接口,为整个应用的色彩体系建立了一套完整的类型规范。

ArkTS 中的接口与 TypeScript 中的接口在语法层面高度相似,但 ArkTS 对类型安全的要求更加严格。每一个色彩属性都被明确声明为 string 类型,确保在后续使用过程中不会出现类型不匹配的问题。

这种将色彩体系抽象为接口的设计模式,带来了三个显著的工程优势。首先,它实现了色彩定义的集中管理,所有颜色值在一个地方维护,修改时不会遗漏;其次,它提供了编译期的类型检查,任何拼写错误都会在编译阶段被捕获;最后,它为后续的主题切换功能预留了扩展空间,只需替换一个对象实例即可实现整站换肤。

从视觉设计角度分析,这个调色板涵盖了暗夜紫(nightPurple)、星空金(starGold)、神秘紫(mysticViolet)等核心色系,精确地对应了"暗夜紫+星空金"的神秘占星风格定位。文本色被细分为三级(Primary、Secondary、Hint),这是移动端 UI 设计中常见的信息层级表达手法。

接下来定义色彩常量对象:

const COLORS: ColorPalette = {
  nightPurple: '#2A1B47',
  nightDeep: '#1A1030',
  starGold: '#F5C242',
  starGoldLight: '#FFD970',
  mysticViolet: '#7B52D6',
  mysticVioletLight: '#A98BEB',
  bg: '#150E26',
  cardBg: '#241A3D',
  cardBgDark: '#1C1430',
  textPrimary: '#F0E8FF',
  textSecondary: '#B0A0D0',
  textHint: '#7A6890',
  white: '#FFFFFF',
  border: '#3D2B5E',
  danger: '#E84A4A',
  success: '#4DD9A0'
};

在这里插入图片描述

这里通过 const 关键字声明了一个 COLORS 常量,并使用 ColorPalette 接口对其进行类型约束。在 ArkTS 中,const 声明的常量在运行时不可重新赋值,这保证了色彩配置的全局唯一性和不可变性。

色彩值统一采用十六进制格式(#RRGGBB),这是 HarmonyOS 原生渲染引擎最易解析的色彩表示方式。值得注意的是,背景色 bg 的值 #150E26 是一种极深的紫黑色,这种近乎纯黑但带有紫色调的背景,能够营造出夜空的深邃感。

卡片背景被设计为两级层次:cardBg#241A3D)用于普通卡片,cardBgDark#1C1430)用于卡片内的嵌套区域。这种两级背景色设计,在没有阴影系统的情况下,通过色差实现了视觉层次感,是暗色主题设计中的经典手法。

文本色的三级体系也值得关注。textPrimary 为淡紫白色,用于主要标题和正文;textSecondary 为灰紫色,用于次要说明文字;textHint 为暗紫色,用于最弱的提示信息。这套色彩层级在保证可读性的同时,有效地传达了信息的优先级。

代码段 2:导航数据与运势常量

interface TabItem {
  label: string;
  icon: string;
}

const BOTTOM_TABS: TabItem[] = [
  { label: '运势', icon: '🔮' },
  { label: '星座', icon: '♈' },
  { label: '塔罗', icon: '🃏' },
  { label: '我的', icon: '👤' }
];

const TOP_TABS: TabItem[] = [
  { label: '今日', icon: '☀️' },
  { label: '明日', icon: '🌅' },
  { label: '本周', icon: '📅' },
  { label: '本月', icon: '🗓️' },
  { label: '年运', icon: '🌟' },
  { label: '配对', icon: '💕' }
];

在这里插入图片描述

TabItem 接口定义了导航项的数据结构,包含文字标签(label)和图标符号(icon)两个字符串属性。这种简洁的数据建模方式,使得导航配置高度数据驱动化,后续增加或修改 Tab 只需操作数据数组即可。

底部导航 BOTTOM_TABS 定义了四个主功能入口,分别对应运势占卜、星座百科、塔罗牌占卜和个人中心。顶部导航 TOP_TABS 则定义了六个时间维度的运势查询入口,从今日到年度,覆盖了用户对运势的时间维度需求。

在 ArkTS 中,数组常量通过 : TabItem[] 进行类型标注,确保数组中每个元素都符合接口定义。这种强类型约束在开发阶段就能防止数据结构错误,大大降低了运行时异常的概率。

值得注意的是,图标采用了 Emoji 字符而非图片资源。这一设计决策在减小应用体积的同时,也保证了跨设备的一致性显示——Emoji 字符由系统字体直接渲染,无需额外的资源文件加载。在 HarmonyOS 的渲染引擎中,Emoji 字符能够被正确解析并显示为彩色图标。

此外还定义了周运势的数据常量:

const LUCK_DAYS: string[] = ['周一', '周二', '周三', '周四', '周五', '周六', '周日'];
const LUCK_VALUES: number[] = [78, 65, 82, 90, 72, 95, 88];

这两组常量为周运势柱状图提供数据源。LUCK_DAYS 是星期标签数组,LUCK_VALUES 是对应的运势分数数组,两者通过索引一一对应。

这种将数据与视图分离的设计,是声明式 UI 编程的核心思想之一。在传统命令式编程中,开发者需要手动创建柱状图的每一根柱子并设置高度;而在 ArkTS 的声明式范式中,开发者只需描述"数据长什么样,视图就该长什么样",框架会自动完成数据到视图的映射。

两个数组通过索引关联,形成了一种隐式的数据契约。在实际应用中,也可以将它们合并为一个对象数组以提高内聚性。这里采用分离数组的方式,可能出于对渲染性能的考量——在 ForEach 遍历时,只需访问一个数组的单个元素,减少了对象属性访问的开销。

从数据内容来看,周六的运势最高(95分),周四次之(90分),这符合占星文化中"周末运势更佳"的普遍预期。数据的设计服务于用户体验,让用户在查看周运势时产生积极的心理暗示。

代码段 3:星座数据模型与构建

@Observed
class ZodiacSign {
  id: number;
  name: string;
  symbol: string;
  dateRange: string;
  element: string;
  ruler: string;
  luck: number;
  love: number;
  career: number;
  wealth: number;
  health: number;
  luckyColor: string;
  luckyNumber: number;
  luckyDirection: string;
  description: string;

  constructor(id: number, name: string, symbol: string, dateRange: string,
    element: string, ruler: string, luck: number, love: number, career: number,
    wealth: number, health: number, luckyColor: string, luckyNumber: number,
    luckyDirection: string, description: string) {
    this.id = id;
    this.name = name;
    // ...其余属性赋值
  }
}

在这里插入图片描述

@Observed 装饰器是 ArkTS 状态管理体系中的关键一环。被 @Observed 标注的类,其实例的属性变化能够被框架自动追踪,并触发依赖该实例的 UI 组件重新渲染。

ZodiacSign 类封装了一个星座的全部信息,包含基本属性(id、name、symbol、dateRange)、元素与守护星(element、ruler)、五维运势分数(luck、love、career、wealth、health)、幸运信息(luckyColor、luckyNumber、luckyDirection)以及描述文本。

构造函数采用了完整的参数列表形式,确保每个实例在创建时就能被完整初始化。虽然参数较多,但这种"全量构造"的方式保证了对象创建后即处于完整可用状态,避免了部分初始化带来的空值风险。

五维运势的设计(综合、感情、事业、财运、健康)对应了用户最关心的生活维度,这也是现代星座运势 App 的标准信息架构。将这些维度数值化,使得运势信息可以被可视化呈现(如进度条、雷达图等),增强了信息的直观性。

接下来定义星座数据工厂函数来构建完整数据集:

function buildSigns(): ZodiacSign[] {
  return [
    new ZodiacSign(1, '白羊座', '♈', '3.21-4.19', '火', '火星', 85, 72, 88, 65, 80, '红色', 9, '东方', '今天行动力爆棚,适合开启新计划'),
    new ZodiacSign(2, '金牛座', '♉', '4.20-5.20', '土', '金星', 70, 88, 60, 90, 75, '绿色', 6, '东南', '财运稳步上升,但感情需要更多沟通'),
    new ZodiacSign(3, '双子座', '♊', '5.21-6.21', '风', '水星', 78, 65, 82, 58, 85, '黄色', 5, '北方', '思维活跃的一天,适合学习与交流'),
    // ...其余星座
    new ZodiacSign(12, '双鱼座', '♓', '2.19-3.20', '水', '海王星', 75, 92, 58, 60, 85, '海蓝', 7, '东方', '直觉敏锐,艺术灵感涌现')
  ];
}

const SIGNS: ZodiacSign[] = buildSigns();

buildSigns 是一个工厂函数,负责创建十二星座的完整数据集合。将数据创建逻辑封装在函数中而非直接声明数组常量,有两个考量:一是函数内部的 new 操作发生在运行时,确保每个 ZodiacSign 实例都是独立创建的;二是为后续可能的动态数据加载(如从网络获取)预留了接口。

十二星座的数据遵循了占星学的标准分类体系。四象(火、土、风、水)分布在十二个星座中,每个象对应三个星座。守护星也各有不同,从火星、金星到冥王星、海王星,体现了占星学的行星对应体系。

每个星座的运势分数都是精心设计的数值。例如狮子座的综合运势为 92 分(最高),这与狮子座作为"王者星座"的文化定位一致。白羊座的事业运为 88 分,体现了火象星座的行动力和开创性。

SIGNS 常量在模块加载时即被初始化,作为全局数据源供整个应用使用。在 ForEach 渲染中,这个数组会被遍历并映射为 UI 组件,实现数据到视图的自动转换。

代码段 4:塔罗牌与运势记录数据模型

interface TarotCard {
  id: number;
  name: string;
  symbol: string;
  meaning: string;
  reversed: string;
}

const TAROT_CARDS: TarotCard[] = [
  { id: 1, name: '愚者', symbol: '🃏', meaning: '新的开始·无限可能', reversed: '鲁莽·缺乏计划' },
  { id: 2, name: '魔术师', symbol: '✨', meaning: '创造力·主动行动', reversed: '欺骗·才能浪费' },
  { id: 3, name: '女祭司', symbol: '🌙', meaning: '直觉·神秘智慧', reversed: '隐藏秘密·被动' },
  // ...其余塔罗牌
  { id: 15, name: '恶魔', symbol: '😈', meaning: '束缚·物欲执念', reversed: '解脱·觉醒' }
];

在这里插入图片描述

TarotCard 接口定义了塔罗牌的数据结构,与 ZodiacSign 不同,这里没有使用 @Observed 装饰器。这是因为塔罗牌数据在整个应用生命周期中是静态的,不需要追踪属性变化,使用普通接口更加轻量。

每张塔罗牌包含正位含义(meaning)和逆位含义(reversed),这是塔罗牌占卜的核心概念。同一张牌在正位和逆位时含义截然不同,例如"愚者"正位代表"新的开始",逆位则代表"鲁莽"。

数据涵盖了塔罗大阿卡纳的前 15 张牌,从 0 号"愚者"到 15 号"恶魔"。在真实应用中,大阿卡纳共 22 张,这里选取了最具代表性的前 15 张进行展示。每张牌的含义描述简洁凝练,用关键词加短语的形式呈现,适合在移动端小屏幕上快速阅读。

Emoji 符号的选择也颇具匠心。例如"女祭司"对应 🌙(月亮),暗示其与月亮和直觉的关联;"皇帝"对应 ⚡(闪电),象征权威与力量;"死神"对应 💀(骷髅),直白地表达终结与重生的主题。

此外还定义了用户运势记录的数据模型:

interface LuckRecord {
  id: number;
  date: string;
  sign: string;
  score: number;
  note: string;
}

const LUCK_RECORDS: LuckRecord[] = [
  { id: 1, date: '8月24日', sign: '狮子座', score: 92, note: '光芒四射,领导力满分' },
  { id: 2, date: '8月23日', sign: '狮子座', score: 85, note: '财运小旺,注意社交' },
  { id: 3, date: '8月22日', sign: '狮子座', score: 78, note: '工作压力大,需放松' },
  { id: 4, date: '8月21日', sign: '狮子座', score: 90, note: '感情运极佳的一天' },
  { id: 5, date: '8月20日', sign: '狮子座', score: 72, note: '健康注意,少熬夜' },
  { id: 6, date: '8月19日', sign: '狮子座', score: 88, note: '创意迸发,灵感不断' },
  { id: 7, date: '8月18日', sign: '狮子座', score: 80, note: '平稳的一天,按部就班' }
];

LuckRecord 接口建模了用户的运势记录条目,包含日期、星座、分数和笔记四个核心字段。这个数据结构代表了用户主观记录的运势体验,与前面客观的星座运势数据形成互补。

七条记录对应一周的每日运势,分数从 72 到 92 不等,呈现了运势的自然波动。每条记录的 note 字段都是一句简短的感受描述,如"光芒四射,领导力满分",既保留了用户的个人体验,又与星座运势的主题紧密相关。

在真实应用中,这些记录数据应当持久化存储在本地数据库中(如 HarmonyOS 的 relationalStore 或 preferences)。此处的静态数组仅用于演示 UI 渲染效果。但即便如此,数据结构的设计已经为后续的持久化扩展做好了准备——id 字段作为唯一标识,可以方便地映射到数据库主键。

代码段 5:粒子系统模型与动画函数

interface ParticleItem {
  id: number;
  x: number;
  y: number;
  size: number;
  opacity: number;
  icon: string;
}

const PARTICLE_ICONS: string[] = ['✦', '✧', '★', '✩', '✪'];

function buildParticles(): ParticleItem[] {
  const arr: ParticleItem[] = [];
  for (let i = 0; i < 18; i++) {
    arr.push({
      id: i,
      x: (i * 37) % 340 + 10,
      y: 60 + (i * 53) % 580,
      size: 8 + (i * 4) % 12,
      opacity: 0.15 + (i % 5) * 0.08,
      icon: PARTICLE_ICONS[i % PARTICLE_ICONS.length]
    });
  }
  return arr;
}

在这里插入图片描述

星空粒子特效是这个应用的视觉灵魂。ParticleItem 接口定义了单个粒子的完整状态:位置(x, y)、大小(size)、透明度(opacity)、图标符号(icon)和唯一标识(id)。

buildParticles 函数通过循环创建 18 个粒子,利用取模运算(%)生成分布看似随机但有规律的位置坐标。x 坐标通过 (i * 37) % 340 + 10 计算,确保分布在 10 到 350 的水平范围内;y 坐标通过 60 + (i * 53) % 580 计算,分布在 60 到 640 的垂直范围内。

粒子大小通过 8 + (i * 4) % 12 计算,范围在 8 到 20 之间,模拟了星空中星星大小不一的自然效果。透明度通过 0.15 + (i % 5) * 0.08 计算,范围在 0.15 到 0.47 之间,营造出星星明暗闪烁的层次感。

五种星形符号()通过 i % 5 循环分配,增加了视觉多样性。这种用字符模拟星空粒子的方式,是 ArkTS 中一种轻量级的特效实现方案,相比 Canvas 绘制或 WebGL 渲染,字符粒子的性能开销极低。

接下来定义粒子漂移动画的核心逻辑函数:

function driftParticles(list: ParticleItem[]): ParticleItem[] {
  const next: ParticleItem[] = [];
  for (let i = 0; i < list.length; i++) {
    const p = list[i];
    const ny = p.y - 3;
    next.push({
      id: p.id,
      x: p.x + Math.sin(p.id + p.y / 60) * 2,
      y: ny < 40 ? 640 : ny,
      size: p.size,
      opacity: p.opacity,
      icon: p.icon
    });
  }
  return next;
}

driftParticles 函数是粒子动画的核心逻辑。它接收当前的粒子数组,返回一组新的粒子状态,实现了粒子的"上升漂移"效果。这种"纯函数"式的状态变换,是函数式编程思想在 ArkTS 中的应用。

每个粒子的 y 坐标每次减少 3(p.y - 3),模拟星星向上飘移的效果。当粒子飘出顶部边界(ny < 40)时,通过 ny < 40 ? 640 : ny 三元表达式将其重置到底部(640),形成循环往复的连续动画。

x 坐标通过 Math.sin(p.id + p.y / 60) * 2 进行微调,使粒子在水平方向产生轻微的左右摆动。Math.sin 函数的周期性特性,使粒子呈现出自然的飘摆轨迹,而非机械的直线上升。这种细节处理极大地提升了动画的真实感和观赏性。

函数返回全新的数组而非修改原数组,这是 ArkTS 状态管理的最佳实践。因为 @State 装饰器需要检测到数组引用的变化才能触发重渲染,直接修改原数组元素可能无法被框架正确感知。

代码段 6:辅助工具函数与配对逻辑

function luckColor(score: number): string {
  if (score >= 85) {
    return COLORS.starGold;
  }
  if (score >= 70) {
    return COLORS.mysticVioletLight;
  }
  return COLORS.textSecondary;
}

function barHeight(v: number): string {
  return (v * 1.2).toFixed(0) + 'vp';
}

function elementColor(el: string): string {
  if (el === '火') {
    return '#E85A3C';
  }
  if (el === '土') {
    return '#8B7355';
  }
  if (el === '风') {
    return '#5AC8E8';
  }
  return '#5A7AD6';
}

在这里插入图片描述

这三个工具函数体现了 ArkTS 中"将逻辑抽取为纯函数"的代码组织理念。luckColor 根据运势分数返回对应颜色:85 分以上为金色,70-84 分为紫色,70 分以下为灰色。这种颜色分级机制,使用户能够通过颜色快速判断运势高低。

barHeight 函数将数值转换为带单位的字符串,如 78 变为 "93.6vp"vp(virtual pixel)是 HarmonyOS 的响应式长度单位,能够根据设备屏幕密度自动缩放。乘以 1.2 的系数是为了让柱状图在视觉上更加饱满。

elementColor 函数为四象星座分配对应的主题色:火象为橙红色(#E85A3C),土象为棕黄色(#8B7355),风象为天蓝色(#5AC8E8),水象为蓝色(#5A7AD6)。这种颜色编码与元素的直觉联想高度一致,增强了信息的可理解性。

这些函数都是无副作用的纯函数,输入相同则输出相同,非常适合在声明式 UI 的 build 方法中调用。它们不会触发任何状态变化,仅负责数据的视图化转换。

此外还有一个配对评分函数:

function matchScore(s1: string, s2: string): number {
  if (s1 === s2) {
    return 100;
  }
  if (s1 === '白羊座' && s2 === '狮子座') {
    return 95;
  }
  if (s1 === '金牛座' && s2 === '处女座') {
    return 92;
  }
  return 75;
}

matchScore 函数实现了星座配对评分的核心逻辑。两个相同星座配对得 100 分,体现了"同类相吸"的占星理念。特定的星座组合(白羊+狮子、金牛+处女)获得高分,这基于占星学中同象星座(火+火、土+土)亲和力强的理论。

其余组合默认返回 75 分,这是一个中性的及格分数。在真实应用中,这个函数可以扩展为一个完整的配对矩阵,包含 12x12=144 种组合的评分数据。此处采用条件判断的简化版本,是为了演示逻辑结构。

函数的参数是星座名称字符串而非索引或对象,这种设计降低了函数的耦合度。调用方只需传入星座名称即可获得评分,无需关心星座数据的内部结构。这种基于字符串的接口设计,在数据来源多样化时更具灵活性。

代码段 7:入口组件状态定义

@Entry
struct Index {
  @State currentBottomTab: number = 0;
  @State currentTopTab: number = 0;
  @State showRecordModal: boolean = false;
  @State showEditModal: boolean = false;
  @State showClearModal: boolean = false;
  @State showDetailModal: boolean = false;
  @State selectedSign: ZodiacSign | null = null;
  @State particles: ParticleItem[] = buildParticles();
  @State recordNote: string = '';
  @State recordScore: number = 80;
  @State editBirthDate: string = '8月15日';
  @State editGender: number = 0;
  @State matchSign1: number = 4;
  @State matchSign2: number = 0;
  private timerId: number = -1;

@Entry 装饰器标记 Index 结构体为应用的入口组件。@State 装饰器则声明了组件的响应式状态变量——每当这些变量的值发生变化时,框架会自动触发组件的重新渲染。

状态变量可以分为四组。第一组是导航状态:currentBottomTabcurrentTopTab 控制当前显示的功能模块和子模块。第二组是弹窗状态:四个布尔变量分别控制四种弹窗的显隐。第三组是数据状态:selectedSign 存储当前选中的星座对象,particles 存储粒子数组。第四组是表单状态:recordNoterecordScoreeditBirthDate 等记录用户在弹窗中的输入。

selectedSign 的类型是 ZodiacSign | null,联合类型中的 null 表示初始状态下未选中任何星座。在 ArkTS 中,联合类型需要配合非空断言操作符(!)使用,后续在访问 selectedSign 的属性时会用到 this.selectedSign!.name 这样的语法。

timerId 使用 private 关键字声明为私有属性,它不是状态变量,不会触发重渲染,仅用于存储定时器的 ID 以便后续清除。这种区分状态变量与普通属性的做法,是 ArkTS 性能优化的关键——只有需要驱动 UI 变化的数据才应该标注 @State

代码段 8:组件生命周期函数

aboutToAppear() {
  this.timerId = setInterval(() => {
    this.particles = driftParticles(this.particles);
  }, 150);
}

aboutToDisappear() {
  if (this.timerId >= 0) {
    clearInterval(this.timerId);
  }
}

aboutToAppearaboutToDisappear 是 ArkTS 组件生命周期中的两个关键回调。aboutToAppear 在组件创建后、build 方法执行前调用,适合进行初始化操作;aboutToDisappear 在组件销毁前调用,适合进行资源释放。

aboutToAppear 中,通过 setInterval 创建了一个每 150 毫秒执行一次的定时器。每次执行时,调用 driftParticles 函数更新粒子数组,并将新数组赋值给 this.particles。由于 particles@State 变量,赋值操作会触发粒子层的重新渲染,从而实现连续的星空动画效果。

150 毫秒的间隔对应约 6.7 帧每秒的刷新率。虽然低于 60fps 的流畅标准,但对于星空粒子的缓慢漂移效果来说已经足够,同时大幅降低了 CPU 占用。这是一个性能与视觉效果的平衡选择。

aboutToDisappear 中,通过 clearInterval 清除定时器,防止组件销毁后定时器继续运行导致的内存泄漏。这是 ArkTS 开发中必须注意的资源管理实践——所有在 aboutToAppear 中申请的资源,都应该在 aboutToDisappear 中释放。

渲染引擎 Timer 定时器 Index 组件 用户 渲染引擎 Timer 定时器 Index 组件 用户 打开应用 aboutToAppear() setInterval(150ms) 每150ms回调 particles = driftParticles() 触发重渲染 更新粒子位置 关闭应用 aboutToDisappear() clearInterval()

代码段 9:build 主布局结构

build() {
  Stack() {
    Column() {
      this.headerBuilder()
      if (this.currentBottomTab === 0) {
        this.fortuneTopTabs()
      } else if (this.currentBottomTab === 1) {
        this.zodiacTopBar()
      } else if (this.currentBottomTab === 2) {
        this.tarotTopBar()
      } else {
        this.mineTopBar()
      }
      Scroll() {
        Column() {
          if (this.currentBottomTab === 0) {
            this.fortuneContent()
          } else if (this.currentBottomTab === 1) {
            this.zodiacContent()
          } else if (this.currentBottomTab === 2) {
            this.tarotContent()
          } else {
            this.mineContent()
          }
        }
        .width('100%')
        .alignItems(HorizontalAlign.Start)
      }
      .layoutWeight(1)
      .scrollable(ScrollDirection.Vertical)
      .scrollBar(BarState.Off)
      this.bottomTabs()
    }
    .width('100%')
    .height('100%')
    .backgroundColor(COLORS.bg)

    ForEach(this.particles, (p: ParticleItem) => {
      Text(p.icon)
        .fontSize(p.size)
        .opacity(p.opacity)
        .position({ x: p.x, y: p.y })
    }, (p: ParticleItem) => p.id.toString() + '_' + p.y.toFixed(0))
  }
  .width('100%')
  .height('100%')
}

在这里插入图片描述

build 方法是组件的核心,它以声明式语法定义了整个页面的 UI 结构。最外层的 Stack 容器实现了层叠布局——底层是 Column 主内容区,上层是粒子特效和弹窗。

Column 内部从上到下依次排列:头部信息栏(headerBuilder)、子导航栏(条件渲染)、可滚动内容区(Scroll)和底部导航栏(bottomTabs)。if-else 条件语句根据 currentBottomTab 的值决定渲染哪个子模块,这是 ArkTS 中条件渲染的标准用法。

Scroll 组件通过 layoutWeight(1) 占据剩余空间,scrollable(ScrollDirection.Vertical) 启用垂直滚动,scrollBar(BarState.Off) 隐藏滚动条以保持视觉整洁。内容区的 Column 设置 alignItems(HorizontalAlign.Start) 使内容左对齐。

粒子层通过 ForEach 遍历 particles 数组,将每个粒子渲染为一个 Text 组件,并使用 position 设置绝对位置。ForEach 的第三个参数是键生成器,通过 p.id.toString() + '_' + p.y.toFixed(0) 生成唯一键,确保框架能够正确识别粒子的变化并高效更新。

Stack 的层叠顺序遵循声明顺序:先声明的在底层,后声明的在顶层。因此粒子层会覆盖在主内容区之上,而弹窗层(在 ForEach 之后声明)会覆盖在粒子层之上。

代码段 10:头部信息栏构建

@Builder
headerBuilder() {
  Column() {
    Row() {
      Column() {
        Text('🔮 QQ星座')
          .fontSize(22)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.starGold)
        Text('今日有 12,840 人查看运势')
          .fontSize(11)
          .fontColor(COLORS.textSecondary)
          .margin({ top: 2 })
      }
      .alignItems(HorizontalAlign.Start)

      Column() {
        Text('♌')
          .fontSize(20)
          .fontColor(COLORS.white)
        Text('狮子座')
          .fontSize(10)
          .fontColor(COLORS.white)
      }
      .padding({ left: 12, right: 12, top: 6, bottom: 6 })
      .borderRadius(16)
      .backgroundColor(COLORS.mysticViolet)
      .onClick(() => {
        this.showEditModal = true;
      })
    }
    .width('100%')
    .justifyContent(FlexAlign.SpaceBetween)
    .padding({ left: 16, right: 16, top: 10, bottom: 8 })
    // ...运势摘要区域
  }
  .width('100%')
  .backgroundColor(COLORS.nightDeep)
}

@Builder 装饰器用于声明可复用的 UI 构建方法。headerBuilder 将头部信息栏的构建逻辑封装为独立方法,使 build 主方法的结构更加清晰。

头部信息栏包含两部分。上半部分是一个 Row,左侧是应用名称和在线人数,右侧是当前星座标签。justifyContent(FlexAlign.SpaceBetween) 使两端对齐,创建了左右分开的布局。右侧的星座标签设置了紫色背景和圆角,点击后触发编辑弹窗。

下半部分是运势摘要区域,通过四个 Column 展示今日运势、感情、事业、财运的分数。列与列之间使用 Divider 组件添加竖直分隔线,增强了信息的视觉分隔。

margin({ top: 2 }) 这样的属性链式调用是 ArkTS 声明式语法的特色。每个 UI 组件通过点号调用方法来设置属性,方法返回组件自身,支持链式调用。这种语法虽然看起来类似 CSS,但实际是函数调用,具有更强的类型安全性。

颜色搭配也值得品味。运势分数用金色,感情用紫色,事业用绿色(success),财运用红色(danger),通过颜色传达了不同维度的运势好坏暗示。

代码段 11:顶部运势 Tab 导航

@Builder
fortuneTopTabs() {
  Scroll() {
    Row() {
      ForEach(TOP_TABS, (t: TabItem, idx: number) => {
        Column() {
          Text(t.icon)
            .fontSize(16)
          Text(t.label)
            .fontSize(11)
            .fontColor(this.currentTopTab === idx ? COLORS.starGold : COLORS.textSecondary)
            .margin({ top: 2 })
        }
        .padding({ left: 12, right: 12, top: 6, bottom: 6 })
        .borderRadius(12)
        .backgroundColor(this.currentTopTab === idx ? '#33F5C242' : 'transparent')
        .margin({ right: 4 })
        .onClick(() => {
          this.currentTopTab = idx;
        })
      }, (t: TabItem) => t.label)
    }
    .padding({ left: 8, right: 8, top: 8, bottom: 8 })
  }
  .scrollable(ScrollDirection.Horizontal)
  .scrollBar(BarState.Off)
  .width('100%')
  .backgroundColor(COLORS.nightDeep)
}

fortuneTopTabs 构建了运势模块的六项子导航。由于六个 Tab 在窄屏设备上可能无法全部显示,因此使用 Scroll 包裹 Row 并设置水平滚动(ScrollDirection.Horizontal),实现可横向滑动的 Tab 栏。

ForEach 遍历 TOP_TABS 数组,为每个 Tab 项创建一个 Column 包含图标和文字。选中状态的视觉反馈通过两种方式表达:文字颜色变为金色(COLORS.starGold),背景色变为半透明金色(#33F5C242)。#33 是十六进制的透明度前缀,表示约 20% 的不透明度。

onClick 回调中通过 this.currentTopTab = idx 更新选中索引,触发条件渲染切换内容。这种"点击修改状态,状态驱动视图"的模式,是声明式 UI 的核心思想。

ForEach 的键生成器使用 t.label(Tab 标签名)作为唯一键。由于 Tab 标签是唯一的,这种键生成方式能够确保框架在 Tab 数组变化时正确识别哪些项发生了变化,从而进行最小化的 DOM 更新。

代码段 12:今日运势详情卡片

@Builder
todayFortune() {
  Column() {
    Column() {
      Row() {
        Text(SIGNS[4].symbol)
          .fontSize(56)
          .margin({ right: 14 })
        Column() {
          Text('今日狮子座运势')
            .fontSize(17)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.white)
          Text('8月24日 · 农历七月十一')
            .fontSize(11)
            .fontColor(COLORS.textSecondary)
            .margin({ top: 4 })
          Text('👑 综合运势 85分 · ★★★★☆')
            .fontSize(13)
            .fontColor(COLORS.starGold)
            .margin({ top: 4 })
        }
        .alignItems(HorizontalAlign.Start)
        .layoutWeight(1)
      }
      .width('100%')

      Text(SIGNS[4].description)
        .fontSize(12)
        .fontColor(COLORS.textPrimary)
        .margin({ top: 12 })
        .padding(10)
        .borderRadius(10)
        .backgroundColor(COLORS.cardBgDark)
        .width('100%')
      // ...运势维度进度条、幸运信息
    }
    .width('100%')
    .padding(14)
    .borderRadius(16)
    .backgroundColor(COLORS.cardBg)
    .alignItems(HorizontalAlign.Start)
  }
  .width('100%')
  .alignItems(HorizontalAlign.Start)
}

todayFortune 是今日运势的详情卡片,展示了当前星座(狮子座,SIGNS[4])的完整运势信息。卡片头部使用 Row 横向排列大号星座符号和运势标题,fontSize(56) 的星座符号成为视觉焦点。

描述文本 SIGNS[4].description 被放置在一个带圆角和深色背景的容器中,形成"引用块"的视觉效果。padding(10) 提供内边距,borderRadius(10) 设置圆角,backgroundColor(COLORS.cardBgDark) 设置深色背景,三者协同营造了卡片内嵌区域的效果。

运势维度部分通过 ForEach 遍历四个维度名称,为每个维度渲染一个水平进度条。进度条由两层 Column 叠加实现:底层是灰色背景条(满宽),上层是彩色进度条(按分数百分比设宽)。这种通过宽度百分比模拟进度条的方式,是 ArkTS 中不使用 Progress 组件时的常见替代方案。

layoutWeight(1)Row 中用于让右侧内容占据剩余宽度。这种弹性布局策略确保了在不同屏幕宽度下,星座符号始终在左侧,文字内容自适应填充右侧空间。

代码段 13:明日运势列表渲染

@Builder
tomorrowFortune() {
  Column() {
    Text('🌅 明日运势预告')
      .fontSize(15)
      .fontWeight(FontWeight.Bold)
      .fontColor(COLORS.white)
      .margin({ bottom: 10 })

    ForEach(SIGNS, (s: ZodiacSign, idx: number) => {
      Row() {
        Text(s.symbol)
          .fontSize(24)
          .width(36)
          .textAlign(TextAlign.Center)
        Column() {
          Text(s.name + ' · ' + s.dateRange)
            .fontSize(13)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.white)
          Text(s.description)
            .fontSize(10)
            .fontColor(COLORS.textSecondary)
            .maxLines(1)
            .textOverflow({ overflow: TextOverflow.Ellipsis })
            .margin({ top: 3 })
        }
        .alignItems(HorizontalAlign.Start)
        .layoutWeight(1)
        .margin({ left: 10 })
        Column() {
          Text(s.luck.toString())
            .fontSize(16)
            .fontWeight(FontWeight.Bold)
            .fontColor(luckColor(s.luck))
          Text('运势')
            .fontSize(9)
            .fontColor(COLORS.textHint)
            .margin({ top: 2 })
        }
        .alignItems(HorizontalAlign.End)
      }
      .width('100%')
      .padding(10)
      .borderRadius(12)
      .backgroundColor(idx < 3 ? '#2E2152' : COLORS.cardBgDark)
      .border({
        width: idx < 3 ? 1 : 0,
        color: COLORS.mysticViolet
      })
      .margin({ bottom: 8 })
      .onClick(() => {
        this.selectedSign = s;
        this.showDetailModal = true;
      })
    }, (s: ZodiacSign) => s.id.toString())
  }
  .width('100%')
  .alignItems(HorizontalAlign.Start)
}

tomorrowFortune 以列表形式展示十二星座的明日运势预告。ForEach 遍历整个 SIGNS 数组,为每个星座渲染一行信息:左侧是星座符号,中间是名称和描述,右侧是运势分数。

maxLines(1)textOverflow({ overflow: TextOverflow.Ellipsis }) 的组合实现了单行文本溢出省略。当描述文字过长时,自动截断并显示省略号,保持列表行的视觉一致性。这是移动端列表设计的标准实践。

前三名星座(idx < 3)获得了特殊的视觉强调:更亮的背景色(#2E2152)和紫色边框(border)。这种通过索引条件区分视觉样式的设计,在排行榜类界面中非常常见,能够有效引导用户关注高运势的星座。

点击任意星座行,会将该星座对象赋值给 selectedSign 并显示详情弹窗。this.selectedSign = s 直接传递对象引用,由于 ZodiacSign@Observed 类,弹窗中对它的属性访问能够正确响应。

代码段 14:本周运势柱状图

@Builder
weekFortune() {
  Column() {
    Column() {
      Text('📈 本周运势走势')
        .fontSize(14)
        .fontWeight(FontWeight.Bold)
        .fontColor(COLORS.white)
      Row() {
        ForEach(LUCK_DAYS, (d: string, idx: number) => {
          Column() {
            Column() {
              Text('')
                .width('100%')
                .height(1)
            }
            .width(18)
            .height(barHeight(LUCK_VALUES[idx]))
            .borderRadius({ topLeft: 4, topRight: 4 })
            .backgroundColor(luckColor(LUCK_VALUES[idx]))
            .justifyContent(FlexAlign.End)
            Text(d)
              .fontSize(9)
              .fontColor(COLORS.textSecondary)
              .margin({ top: 4 })
          }
          .margin({ left: 8, right: 8 })
          .justifyContent(FlexAlign.End)
        }, (d: string) => d)
      }
      .width('100%')
      .justifyContent(FlexAlign.Center)
      .alignItems(VerticalAlign.Bottom)
      .padding({ top: 16, bottom: 6 })

      Text('本周最佳日:周六 95分 · 幸运色:金色')
        .fontSize(11)
        .fontColor(COLORS.starGold)
        .margin({ top: 8 })
    }
    // ...关键日列表
  }
  .width('100%')
  .alignItems(HorizontalAlign.Start)
}

weekFortune 构建了一个纯 ArkTS 实现的柱状图,展示了本周七天的运势走势。这是整个应用中最具技术亮点的组件之一——没有使用任何图表库,仅通过 ColumnForEach 就实现了柱状图的渲染。

每根柱子由一个 Column 实现,高度通过 barHeight(LUCK_VALUES[idx]) 计算(数值乘以 1.2 转为 vp 单位)。borderRadius({ topLeft: 4, topRight: 4 }) 只设置上方两个圆角,模拟了柱状图柱子顶部的圆角效果。

柱子的颜色通过 luckColor(LUCK_VALUES[idx]) 根据分数动态分配,高分为金色,中分为紫色,低分为灰色。这种数据驱动的颜色映射,使柱状图不仅展示了数值高低,还通过颜色强化了视觉信息。

外层 Row 设置 alignItems(VerticalAlign.Bottom) 使所有柱子底部对齐,这是柱状图的标准对齐方式。每根柱子外层的 Column 设置 justifyContent(FlexAlign.End) 确保柱子和标签从底部开始排列。

Text('') 这种空文本组件作为柱子的内部内容,配合 .height(1).width('100%') 实际上是一个不可见的填充元素。这种技巧是为了让 Column 容器能够正确计算高度——ArkTS 的容器有时需要内部有实际内容才能正确渲染高度。

代码段 15:月运势日历网格

@Builder
monthFortune() {
  Column() {
    Text('🗓️ 8月运势日历')
      .fontSize(15)
      .fontWeight(FontWeight.Bold)
      .fontColor(COLORS.white)
      .margin({ bottom: 10 })

    Grid() {
      ForEach([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30], (day: number) => {
        GridItem() {
          Column() {
            Text(day.toString())
              .fontSize(11)
              .fontColor(day === 24 ? COLORS.starGold : COLORS.textSecondary)
            Text(day === 24 ? '★' : (day % 7 === 0 ? '●' : ''))
              .fontSize(8)
              .fontColor(day === 24 ? COLORS.starGold : COLORS.mysticVioletLight)
          }
          .width('100%')
          .padding({ top: 8, bottom: 8 })
          .borderRadius(8)
          .backgroundColor(day === 24 ? '#33F5C242' : COLORS.cardBgDark)
        }
      }, (day: number) => day.toString())
    }
    .columnsTemplate('1fr 1fr 1fr 1fr 1fr 1fr 1fr')
    .rowsGap(6)
    .columnsGap(6)
    .height(240)
    // ...关键事件列表
  }
  .width('100%')
  .alignItems(HorizontalAlign.Start)
}

monthFortune 使用 Grid 组件构建了一个日历视图,展示八月份的 30 天。Grid 是 ArkTS 中专门用于网格布局的容器组件,比通过 FlexRow 模拟网格更加高效。

columnsTemplate('1fr 1fr 1fr 1fr 1fr 1fr 1fr') 定义了七列等宽的网格模板,对应一周七天。1fr 是分数单位,表示每列占据相等比例的可用空间。rowsGap(6)columnsGap(6) 分别设置行间距和列间距。

每个日期格子通过条件渲染实现视觉差异化。当日(24日)显示金色文字和星号标记,背景为半透明金色;每七天(day % 7 === 0)显示紫色圆点标记,可能代表特殊日;其余日期为普通灰色文字。

ForEach 直接遍历一个内联的数字数组 [1, 2, ..., 30],这种在参数中直接声明数组的方式在 ArkTS 中是合法的,适用于数据量小且不需要复用的场景。键生成器使用日期数字转为字符串,确保每个格子的唯一性。

Gridheight(240) 固定了网格区域的高度,配合七列模板,使 30 个日期自动排列为约五行。这种自动换行的网格布局,是日历类 UI 的理想选择。

代码段 16:年运势与十二宫位

@Builder
yearFortune() {
  Column() {
    Text('🌟 2026年年度运势')
      .fontSize(15)
      .fontWeight(FontWeight.Bold)
      .fontColor(COLORS.white)
      .margin({ bottom: 10 })

    Column() {
      Text('♌ 狮子座 · 年度星盘')
        .fontSize(14)
        .fontWeight(FontWeight.Bold)
        .fontColor(COLORS.starGold)
      Text('关键词:蜕变·突破·光芒')
        .fontSize(11)
        .fontColor(COLORS.textSecondary)
        .margin({ top: 4 })

      Text('今年是狮子座大放异彩的一年。木星在事业宫加持...')
        .fontSize(11)
        .fontColor(COLORS.textPrimary)
        .margin({ top: 10 })
        .padding(10)
        .borderRadius(10)
        .backgroundColor(COLORS.cardBgDark)
    }
    .width('100%')
    .padding(14)
    .borderRadius(16)
    .backgroundColor(COLORS.cardBg)
    .alignItems(HorizontalAlign.Start)

    Text('🔮 十二宫位年度运势')
      .fontSize(15)
      .fontWeight(FontWeight.Bold)
      .fontColor(COLORS.white)
      .margin({ top: 14, bottom: 8 })

    ForEach(SIGNS, (s: ZodiacSign, idx: number) => {
      Row() {
        Text(s.symbol)
          .fontSize(20)
          .width(30)
        Text(s.name)
          .fontSize(12)
          .fontColor(COLORS.textPrimary)
          .layoutWeight(1)
        Column() {
          Column() {
            Text('')
              .width('100%')
              .height(1)
          }
          .width(60)
          .height(6)
          .borderRadius(3)
          .backgroundColor(COLORS.cardBgDark)
          Column() {
            Text('')
              .width('100%')
              .height(1)
          }
          .width((s.luck * 0.6).toFixed(0) + 'vp')
          .height(6)
          .borderRadius(3)
          .backgroundColor(luckColor(s.luck))
        }
        .width(60)
        Text(s.luck.toString())
          .fontSize(12)
          .fontWeight(FontWeight.Bold)
          .fontColor(luckColor(s.luck))
          .width(24)
      }
      .width('100%')
      .padding(8)
      .borderRadius(10)
      .backgroundColor(COLORS.cardBgDark)
      .margin({ bottom: 4 })
    }, (s: ZodiacSign) => 'year_' + s.id.toString())
  }
  .width('100%')
  .alignItems(HorizontalAlign.Start)
}

yearFortune 展示了年度运势和十二星座的运势排行。上半部分是当前星座的年度运势概览,包含关键词和详细描述文本。下半部分通过 ForEach 遍历十二星座,为每个星座渲染一个迷你进度条。

迷你进度条的设计与今日运势的进度条类似,但更加紧凑。固定宽度为 60vp 的外层 Column 作为背景,内层 Column 宽度为 s.luck * 0.6 vp,实现了进度条的按比例填充。乘以 0.6 的系数是因为进度条总宽为 60vp,而运势分数最高为 100,100 * 0.6 = 60vp 正好填满。

ForEach 的键生成器使用了 'year_' + s.id.toString() 的前缀格式。这种带前缀的键生成方式,是为了避免与同一数据源在其他 ForEach 中的键冲突。因为多个 ForEach 可能遍历同一个 SIGNS 数组,如果键仅使用 s.id.toString(),可能导致框架混淆不同位置的渲染项。

年度描述文本展示了详细的运势预测,包含事业、感情、财运三个维度的全年展望。这种长文本在移动端需要注意换行和可读性,fontSize(11) 保证了在有限空间内能显示完整信息。

代码段 17:星座配对模块

@Builder
matchFortune() {
  Column() {
    Text('💕 星座配对')
      .fontSize(15)
      .fontWeight(FontWeight.Bold)
      .fontColor(COLORS.white)
      .margin({ bottom: 10 })

    Row() {
      Column() {
        Text(SIGNS[this.matchSign1].symbol)
          .fontSize(40)
        Text(SIGNS[this.matchSign1].name)
          .fontSize(12)
          .fontColor(COLORS.white)
          .margin({ top: 4 })
      }
      .width(80)
      .padding(12)
      .borderRadius(12)
      .backgroundColor(COLORS.cardBgDark)

      Text('💕')
        .fontSize(24)
        .margin({ left: 10, right: 10 })

      Column() {
        Text(SIGNS[this.matchSign2].symbol)
          .fontSize(40)
        Text(SIGNS[this.matchSign2].name)
          .fontSize(12)
          .fontColor(COLORS.white)
          .margin({ top: 4 })
      }
      .width(80)
      .padding(12)
      .borderRadius(12)
      .backgroundColor(COLORS.cardBgDark)
    }
    .width('100%')
    .justifyContent(FlexAlign.Center)

    Column() {
      Text('配对指数')
        .fontSize(12)
        .fontColor(COLORS.textSecondary)
      Text(matchScore(SIGNS[this.matchSign1].name, SIGNS[this.matchSign2].name).toString() + '%')
        .fontSize(36)
        .fontWeight(FontWeight.Bold)
        .fontColor(COLORS.starGold)
      Text(matchScore(SIGNS[this.matchSign1].name, SIGNS[this.matchSign2].name) >= 85 ? '天作之合' : (matchScore(...) >= 70 ? '佳偶天成' : '需要磨合'))
        .fontSize(13)
        .fontColor(COLORS.mysticVioletLight)
        .margin({ top: 4 })
    }
    .width('100%')
    .padding(14)
    .borderRadius(16)
    .backgroundColor(COLORS.cardBg)
    .alignItems(HorizontalAlign.Center)
    .margin({ top: 10 })

    Flex({ wrap: FlexWrap.Wrap }) {
      ForEach(SIGNS, (s: ZodiacSign, idx: number) => {
        Text(s.symbol + ' ' + s.name)
          .fontSize(11)
          .fontColor(this.matchSign1 === idx ? COLORS.white : COLORS.textSecondary)
          .padding({ left: 10, right: 10, top: 5, bottom: 5 })
          .borderRadius(12)
          .backgroundColor(this.matchSign1 === idx ? COLORS.mysticViolet : COLORS.cardBgDark)
          .margin({ right: 6, top: 6 })
          .onClick(() => {
            this.matchSign1 = idx;
          })
      }, (s: ZodiacSign) => 'match1_' + s.id.toString())
    }
    .width('100%')
  }
  .width('100%')
  .alignItems(HorizontalAlign.Start)
}

matchFortune 实现了星座配对功能。上方展示两个配对星座的大号符号和名称,中间显示配对指数和评级文字。配对指数通过 matchScore 函数实时计算,当用户切换星座时,分数和评级会自动更新。

配对评级使用了嵌套的三元表达式:85 分以上为"天作之合",70-84 分为"佳偶天成",70 分以下为"需要磨合"。这种通过条件表达式映射文案的方式,简洁但可读性一般,在更复杂的场景中建议抽取为函数。

星座选择区使用了 Flex({ wrap: FlexWrap.Wrap }) 组件。FlexWrap.Wrap 启用自动换行,使十二个星座选择标签能够根据屏幕宽度自动排列为多行。相比 Row 的单行布局,Flex 的换行能力更适合标签选择器的场景。

每个星座标签通过条件渲染区分选中/未选中状态:选中时文字为白色、背景为紫色;未选中时文字为灰色、背景为深色。onClick 回调中修改 matchSign1 状态变量,触发配对指数的重新计算和渲染。

代码段 18:星座网格展示

@Builder
zodiacContent() {
  Column() {
    Text('♈ 十二星座一览')
      .fontSize(15)
      .fontWeight(FontWeight.Bold)
      .fontColor(COLORS.white)
      .margin({ bottom: 10 })

    Grid() {
      ForEach(SIGNS, (s: ZodiacSign) => {
        GridItem() {
          Column() {
            Text(s.symbol)
              .fontSize(28)
            Text(s.name)
              .fontSize(11)
              .fontColor(COLORS.white)
              .margin({ top: 6 })
            Text(s.dateRange)
              .fontSize(9)
              .fontColor(COLORS.textHint)
              .margin({ top: 2 })
            Text('⭐' + s.luck)
              .fontSize(10)
              .fontColor(luckColor(s.luck))
              .margin({ top: 2 })
            Text(s.element)
              .fontSize(9)
              .fontColor(COLORS.white)
              .padding({ left: 5, right: 5, top: 1, bottom: 1 })
              .borderRadius(5)
              .backgroundColor(elementColor(s.element))
              .margin({ top: 2 })
          }
          .width('100%')
          .padding(10)
          .borderRadius(12)
          .backgroundColor(COLORS.cardBg)
          .onClick(() => {
            this.selectedSign = s;
            this.showDetailModal = true;
          })
        }
      }, (s: ZodiacSign) => s.id.toString())
    }
    .columnsTemplate('1fr 1fr 1fr')
    .rowsGap(10)
    .columnsGap(10)
    .height(560)
    // ...运势排行榜
  }
  .width('100%')
  .alignItems(HorizontalAlign.Start)
}

zodiacContent 使用三列 Grid 展示十二星座的卡片式网格视图。每个 GridItem 包含星座符号、名称、日期范围、运势分数和元素标签,信息密度适中。

元素标签 Text(s.element) 通过 elementColor 函数获取对应颜色,使用 paddingborderRadius 渲染为一个小药丸标签。这种将文本标签化的设计,增加了信息的视觉辨识度——用户一眼就能通过颜色判断星座的元素属性。

GridcolumnsTemplate('1fr 1fr 1fr') 定义三列等宽布局,十二个星座自动排列为四行。height(560) 固定了网格区域高度,如果内容超出则可能需要滚动,但此处的固定高度经过精心计算,恰好容纳四行卡片。

点击任意星座卡片,会设置 selectedSign 并打开详情弹窗。这种"网格点击进入详情"的交互模式,是内容浏览类应用的标准范式。

排行榜部分使用 ForEach 遍历 SIGNS,为每个星座渲染一行排名信息。前三名使用奖牌 Emoji(🥇🥈🥉)作为排名标识,其余使用数字。这种通过条件渲染实现排名视觉差异的方式,简单直观。

代码段 19:塔罗牌展示

@Builder
tarotContent() {
  Column() {
    Column() {
      Text('🃏 今日抽牌')
        .fontSize(14)
        .fontWeight(FontWeight.Bold)
        .fontColor(COLORS.white)
      Text(TAROT_CARDS[0].symbol)
        .fontSize(60)
        .margin({ top: 10 })
      Text(TAROT_CARDS[0].name)
        .fontSize(18)
        .fontWeight(FontWeight.Bold)
        .fontColor(COLORS.starGold)
        .margin({ top: 6 })
      Text(TAROT_CARDS[0].meaning)
        .fontSize(12)
        .fontColor(COLORS.mysticVioletLight)
        .margin({ top: 4 })
      Text('逆位:' + TAROT_CARDS[0].reversed)
        .fontSize(10)
        .fontColor(COLORS.textHint)
        .margin({ top: 4 })
    }
    .width('100%')
    .padding(20)
    .borderRadius(16)
    .backgroundColor(COLORS.cardBg)
    .alignItems(HorizontalAlign.Center)

    Text('🎴 大阿卡纳牌阵')
      .fontSize(15)
      .fontWeight(FontWeight.Bold)
      .fontColor(COLORS.white)
      .margin({ top: 14, bottom: 8 })

    Grid() {
      ForEach(TAROT_CARDS, (c: TarotCard) => {
        GridItem() {
          Column() {
            Text(c.symbol)
              .fontSize(24)
            Text(c.name)
              .fontSize(10)
              .fontColor(COLORS.white)
              .margin({ top: 4 })
            Text(c.meaning)
              .fontSize(8)
              .fontColor(COLORS.textHint)
              .maxLines(1)
              .margin({ top: 2 })
          }
          .width('100%')
          .padding(8)
          .borderRadius(10)
          .backgroundColor(COLORS.cardBgDark)
          .border({ width: 1, color: COLORS.border })
        }
      }, (c: TarotCard) => c.id.toString())
    }
    .columnsTemplate('1fr 1fr 1fr')
    .rowsGap(8)
    .columnsGap(8)
    .height(420)
    // ...牌意解读列表
  }
  .width('100%')
  .alignItems(HorizontalAlign.Start)
}

tarotContent 构建了塔罗牌占卜模块。顶部是"今日抽牌"的大卡片,展示第一张塔罗牌的完整信息——大号符号(fontSize(60))、牌名、正位含义和逆位含义。居中对齐的布局强调了这张牌的"主角"地位。

中部是三列 Grid 展示的牌阵,每张牌以紧凑的卡片形式呈现。maxLines(1) 确保含义文本不换行,保持卡片高度一致。border({ width: 1, color: COLORS.border }) 为每张牌添加了边框,增强了卡片的边界感。

底部是牌意解读列表,通过 ForEach 遍历所有塔罗牌,以行列表形式展示每张牌的正逆位含义。这种"网格+列表"的双视图设计,让用户既能快速浏览所有牌,又能阅读详细的含义解读。

三种视图的层次设计体现了信息架构的思考:大卡片用于突出今日推荐,网格用于全局概览,列表用于详细阅读。这种渐进式信息展示方式,在内容丰富的应用中非常有效。

代码段 20:个人中心与运势记录

@Builder
mineContent() {
  Column() {
    Column() {
      Row() {
        Text('♌')
          .fontSize(40)
          .width(64)
          .height(64)
          .textAlign(TextAlign.Center)
          .borderRadius(32)
          .backgroundColor('#3D2B5E')
        Column() {
          Text('星语者')
            .fontSize(18)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.white)
          Text('狮子座 · 太阳守护 · 火象星座')
            .fontSize(11)
            .fontColor(COLORS.textSecondary)
            .margin({ top: 3 })
          Text('🔮 已查看 248 次运势 · 抽牌 36 次')
            .fontSize(10)
            .fontColor(COLORS.starGold)
            .margin({ top: 3 })
        }
        .alignItems(HorizontalAlign.Start)
        .layoutWeight(1)
        .margin({ left: 12 })
        Column() {
          Text('编辑')
            .fontSize(12)
            .fontColor(COLORS.white)
        }
        .padding({ left: 12, right: 12, top: 6, bottom: 6 })
        .borderRadius(14)
        .backgroundColor(COLORS.mysticViolet)
        .onClick(() => {
          this.showEditModal = true;
        })
      }
      .width('100%')
    }
    .width('100%')
    .padding(14)
    .borderRadius(16)
    .backgroundColor(COLORS.cardBg)
    .alignItems(HorizontalAlign.Start)

    Row() {
      Column() {
        Text('248')
          .fontSize(18)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.starGold)
        Text('查看运势')
          .fontSize(11)
          .fontColor(COLORS.textSecondary)
          .margin({ top: 2 })
      }
      .layoutWeight(1)
      // ...其余三个统计项
    }
    .width('100%')
    .padding(14)
    .borderRadius(16)
    .backgroundColor(COLORS.cardBg)
    .margin({ top: 10 })
    // ...运势记录列表、清除按钮
  }
  .width('100%')
  .alignItems(HorizontalAlign.Start)
}

mineContent 构建了个人中心页面。顶部是用户信息卡片,包含头像(用星座符号模拟)、昵称、星座属性和活动统计。头像通过 borderRadius(32)width(64).height(64) 创建了一个圆形容器,背景色为深紫色。

用户昵称"星语者"和星座属性"狮子座·太阳守护·火象星座"构成了用户的身份标识。下方的"已查看 248 次运势·抽牌 36 次"以金色文字显示,为用户提供了使用数据反馈,增强了应用的互动粘性。

统计栏使用四等分布局,通过 layoutWeight(1) 让四个统计项平均占据宽度。每个统计项包含大号数字和小号标签,数字颜色各不相同(金色、紫色、绿色、红色),对应不同的数据维度。

运势记录列表通过 ForEach 遍历 LUCK_RECORDS,为每条记录渲染日期、分数和笔记。分数通过 luckColor 函数着色,笔记使用 maxLines(1) 单行显示。底部的"清除所有运势记录"按钮使用红色边框和红色文字,通过视觉警示传达了操作的破坏性。

代码段 21:记录运势弹窗

@Builder
recordLuckModal() {
  Column() {
    Column() {
      Column() {
        Text('📝 记录今日运势')
          .fontSize(17)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.white)
        Text('写下你的运势感受,留作纪念')
          .fontSize(11)
          .fontColor('#FFD970')
          .margin({ top: 4 })
      }
      .width('100%')
      .padding(16)
      .alignItems(HorizontalAlign.Start)
      .linearGradient({
        angle: 135,
        colors: [['#7B52D6', 0], ['#2A1B47', 1]]
      })

      Scroll() {
        Column() {
          Text('运势评分')
            .fontSize(13)
            .fontColor(COLORS.textSecondary)
            .margin({ top: 14 })
          Row() {
            ForEach([1, 2, 3, 4, 5], (n: number, idx: number) => {
              Text('⭐')
                .fontSize(22)
                .fontColor(this.recordScore / 20 > idx ? COLORS.starGold : COLORS.textHint)
                .margin({ right: 6, top: 8 })
                .onClick(() => {
                  this.recordScore = (idx + 1) * 20;
                })
            }, (n: number) => n.toString())
          }
          .width('100%')
          Text(this.recordScore.toString() + '分')
            .fontSize(14)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.starGold)
            .margin({ top: 6 })

          Text('运势笔记')
            .fontSize(13)
            .fontColor(COLORS.textSecondary)
            .margin({ top: 14 })
          TextInput({ placeholder: '记录今天的感受...', text: this.recordNote })
            .fontSize(13)
            .fontColor(COLORS.textPrimary)
            .placeholderColor(COLORS.textHint)
            .backgroundColor(COLORS.cardBgDark)
            .borderRadius(10)
            .padding({ left: 12, right: 12 })
            .height(42)
            .margin({ top: 6 })
            .onChange((v: string) => {
              this.recordNote = v;
            })
        }
        .width('100%')
        .alignItems(HorizontalAlign.Start)
        .padding({ left: 16, right: 16 })
      }
      .constraintSize({ maxHeight: '55%' })
      .scrollBar(BarState.Off)

      Row() {
        Text('取消')
          .fontSize(14)
          .fontColor(COLORS.textSecondary)
          .layoutWeight(1)
          .textAlign(TextAlign.Center)
          .padding({ top: 12, bottom: 12 })
          .onClick(() => {
            this.showRecordModal = false;
          })
        Text('保存记录')
          .fontSize(14)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.white)
          .layoutWeight(1)
          .textAlign(TextAlign.Center)
          .padding({ top: 12, bottom: 12 })
          .linearGradient({
            angle: 90,
            colors: [['#7B52D6', 0], ['#2A1B47', 1]]
          })
          .onClick(() => {
            this.showRecordModal = false;
          })
      }
      .width('100%')
      .border({ width: 1, color: COLORS.border })
    }
    .width('90%')
    .borderRadius(16)
    .backgroundColor(COLORS.cardBg)
    .clip(true)
  }
  .width('100%')
  .height('100%')
  .justifyContent(FlexAlign.Center)
  .alignItems(HorizontalAlign.Center)
  .backgroundColor('99000000')
  .onClick(() => {
    this.showRecordModal = false;
  })
}

recordLuckModal 是最复杂的弹窗组件。弹窗顶部使用 linearGradient 线性渐变背景,从紫色(#7B52D6)渐变到暗夜紫(#2A1B47),角度为 135 度,营造了神秘占星氛围。linearGradient 是 ArkTS 中实现渐变背景的核心属性。

评分系统通过五个星星实现。ForEach 遍历 [1,2,3,4,5],通过 this.recordScore / 20 > idx 判断当前星星是否应该点亮。点击星星时设置 this.recordScore = (idx + 1) * 20,将 1-5 的星级转换为 20-100 的分数。这种星级与分数的转换设计,使评分既有直观的星级表达,又有精确的数值输出。

TextInput 组件配合 onChange 回调实现了文本输入的双向绑定。text: this.recordNote 设置初始值,onChange 中的 this.recordNote = v 更新状态变量,状态变化又通过 text 属性反馈到输入框,形成完整的双向数据流。

constraintSize({ maxHeight: '55%' }) 限制了内容滚动区域的最大高度,确保弹窗在小屏幕设备上不会超出可视区域。.clip(true) 裁剪超出圆角边界的子内容,保持弹窗的圆角外观。

外层 Column 设置 backgroundColor('99000000') 实现半透明遮罩层。99 是十六进制透明度,约 60% 不透明度,使背景内容变暗但不完全遮蔽。点击遮罩区域会关闭弹窗(this.showRecordModal = false),这是移动端弹窗的标准交互模式。

代码段 22:编辑与清除弹窗

@Builder
editProfileModal() {
  Column() {
    Column() {
      Text('⚙️ 修改出生信息')
        .fontSize(16)
        .fontWeight(FontWeight.Bold)
        .fontColor(COLORS.white)
      Text('修改后将重新计算你的星盘')
        .fontSize(11)
        .fontColor(COLORS.textSecondary)
        .margin({ top: 4 })

      Column() {
        Text('出生日期')
          .fontSize(12)
          .fontColor(COLORS.mysticVioletLight)
          .alignSelf(ItemAlign.Start)
        TextInput({ text: this.editBirthDate })
          .fontSize(13)
          .fontColor(COLORS.textPrimary)
          .backgroundColor(COLORS.cardBgDark)
          .borderRadius(8)
          .height(40)
          .margin({ top: 6 })
          .onChange((v: string) => {
            this.editBirthDate = v;
          })
      }
      .width('100%')
      .alignItems(HorizontalAlign.Start)
      .margin({ top: 16 })

      Column() {
        Text('性别')
          .fontSize(12)
          .fontColor(COLORS.mysticVioletLight)
          .alignSelf(ItemAlign.Start)
        Row() {
          ForEach(['男', '女', '保密'], (g: string, idx: number) => {
            Text(g)
              .fontSize(12)
              .fontColor(this.editGender === idx ? COLORS.white : COLORS.textSecondary)
              .padding({ left: 14, right: 14, top: 6, bottom: 6 })
              .borderRadius(12)
              .backgroundColor(this.editGender === idx ? COLORS.mysticViolet : COLORS.cardBgDark)
              .margin({ right: 8, top: 8 })
              .onClick(() => {
                this.editGender = idx;
              })
          }, (g: string) => g)
        }
        .width('100%')
      }
      // ...保存按钮
    }
    .width('86%')
    .padding(18)
    .borderRadius(16)
    .backgroundColor(COLORS.cardBg)
    .border({ width: 1, color: COLORS.mysticViolet })
  }
  .width('100%')
  .height('100%')
  .justifyContent(FlexAlign.Center)
  .alignItems(HorizontalAlign.Center)
  .backgroundColor('99000000')
  .onClick(() => {
    this.showEditModal = false;
  })
}

editProfileModal 是编辑出生信息的弹窗,包含日期输入和性别选择两个表单元素。性别选择器通过 ForEach 遍历 ['男', '女', '保密'],渲染三个可点击的标签按钮,选中状态通过背景色区分。这种自定义的单选按钮组,比系统原生的 Radio 组件在视觉上更加灵活。

alignSelf(ItemAlign.Start) 是弹性布局中对单个子元素覆盖父级对齐方式的属性。这里用于让标签文字在 Column 中左对齐,而 Column 的默认对齐方式可能是居中。

clearRecordModal 是清除记录的确认弹窗,设计上使用了红色(COLORS.danger)作为"确认清除"按钮的背景色,通过视觉警示传达了操作的破坏性。这种"二次确认"的交互模式,在涉及不可逆操作时是必不可少的。

弹窗的卡片区域设置 border({ width: 1, color: COLORS.mysticViolet }),使用紫色边框增强视觉边界感。width('86%') 使弹窗占据屏幕宽度的 86%,留出两侧的边距,形成居中浮层的效果。

代码段 23:星座详情弹窗

@Builder
signDetailModal() {
  Column() {
    Column() {
      Column() {
        Row() {
          Text('♈ 星座详情')
            .fontSize(16)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.starGold)
          Text('✕')
            .fontSize(16)
            .fontColor(COLORS.textSecondary)
            .padding({ left: 8, right: 8, top: 2, bottom: 2 })
            .onClick(() => {
              this.showDetailModal = false;
            })
        }
        .width('100%')
        .justifyContent(FlexAlign.SpaceBetween)
      }
      .width('100%')
      .padding({ left: 16, right: 16, top: 14, bottom: 14 })
      .backgroundColor(COLORS.cardBgDark)

      Scroll() {
        Column() {
          Row() {
            Text(this.selectedSign!.symbol)
              .fontSize(48)
              .width(70)
              .height(70)
              .textAlign(TextAlign.Center)
              .borderRadius(35)
              .backgroundColor('#3D2B5E')
            Column() {
              Text(this.selectedSign!.name)
                .fontSize(17)
                .fontWeight(FontWeight.Bold)
                .fontColor(COLORS.white)
              Text(this.selectedSign!.dateRange + ' · ' + this.selectedSign!.element + '象星座')
                .fontSize(10)
                .fontColor(COLORS.textHint)
                .margin({ top: 3 })
              Text('守护星:' + this.selectedSign!.ruler)
                .fontSize(10)
                .fontColor(COLORS.starGold)
                .margin({ top: 3 })
            }
            .alignItems(HorizontalAlign.Start)
            .layoutWeight(1)
            .margin({ left: 12 })
          }
          .width('100%')
          // ...五维运势进度条、幸运信息、操作按钮
        }
        .width('100%')
        .alignItems(HorizontalAlign.Start)
        .padding({ left: 16, right: 16 })
      }
      .constraintSize({ maxHeight: '60%' })
      .scrollBar(BarState.Off)
    }
    .width('90%')
    .borderRadius(16)
    .backgroundColor(COLORS.cardBg)
    .clip(true)
  }
  .width('100%')
  .height('100%')
  .justifyContent(FlexAlign.Center)
  .alignItems(HorizontalAlign.Center)
  .backgroundColor('99000000')
  .onClick(() => {
    this.showDetailModal = false;
  })
}

signDetailModal 是星座详情弹窗,展示了选中星座的完整信息。this.selectedSign! 使用了非空断言操作符 !,告诉编译器"这个变量此时一定不为 null"。因为在打开弹窗前一定会设置 selectedSign,所以这种断言是安全的。

弹窗分为头部标题栏和可滚动内容区两部分。头部使用 justifyContent(FlexAlign.SpaceBetween) 实现标题和关闭按钮的两端对齐。内容区使用 Scroll 包裹,constraintSize({ maxHeight: '60%' }) 限制最大高度为屏幕的 60%,超出部分可滚动查看。

五维运势进度条使用了嵌套的三元表达式来获取各维度的分数:idx === 0 ? this.selectedSign!.luck : (idx === 1 ? this.selectedSign!.love : ...)。虽然这种写法略显冗长,但清晰地表达了维度索引与属性的映射关系。在实际工程中,可以考虑将五维分数存入数组,通过索引直接访问。

详情弹窗的信息层次分明:基本属性(符号、名称、日期、元素、守护星)→ 描述文本 → 五维运势 → 幸运信息 → 操作按钮。这种从概览到详情、从只读到可操作的信息递进,是详情页设计的经典模式。

代码段 24:底部导航栏

@Builder
bottomTabs() {
  Row() {
    ForEach(BOTTOM_TABS, (t: TabItem, idx: number) => {
      Column() {
        Text(t.icon)
          .fontSize(20)
          .opacity(this.currentBottomTab === idx ? 1 : 0.5)
        Text(t.label)
          .fontSize(10)
          .fontColor(this.currentBottomTab === idx ? COLORS.starGold : COLORS.textHint)
          .margin({ top: 3 })
      }
      .layoutWeight(1)
      .padding({ top: 8, bottom: 8 })
      .onClick(() => {
        this.currentBottomTab = idx;
        this.currentTopTab = 0;
      })
    }, (t: TabItem) => t.label)
  }
  .width('100%')
  .backgroundColor(COLORS.nightDeep)
  .border({ width: 1, color: COLORS.border })
}

bottomTabs 构建了底部导航栏,是整个应用的导航中枢。ForEach 遍历 BOTTOM_TABS,为每个 Tab 创建图标加文字的组合。layoutWeight(1) 使四个 Tab 等宽分布,填满整个屏幕宽度。

选中态的视觉反馈通过两种方式实现:图标使用 opacity 变化(选中 1.0,未选中 0.5),文字使用颜色变化(选中金色,未选中灰色)。这种双重反馈确保了选中状态的高辨识度。

onClick 回调同时修改了 currentBottomTabcurrentTopTab 两个状态变量。切换底部 Tab 时重置 currentTopTab = 0,确保每次进入新模块时都从第一个子 Tab 开始,这是导航逻辑的标准实践。

底部导航栏设置了上边框(border({ width: 1, color: COLORS.border })),与内容区形成视觉分隔。背景色使用 COLORS.nightDeep(深暗紫色),与整体暗色主题保持一致。

切换顶部Tab

切换顶部Tab

切换顶部Tab

切换顶部Tab

切换顶部Tab

切换底部Tab

切换底部Tab

切换底部Tab

切换底部Tab

点击记录按钮

关闭弹窗

点击星座卡片

关闭弹窗

点击编辑按钮

关闭弹窗

点击清除按钮

关闭弹窗

运势今日

运势明日

运势本周

运势本月

运势年运

运势配对

星座列表

塔罗占卜

个人中心

记录弹窗

详情弹窗

编辑弹窗

清除弹窗


三、核心技术要点对比分析

对比表 1:状态管理装饰器对比

装饰器 适用场景 数据变化触发重渲染 跨组件共享 典型用例
@State 组件内部状态 是(仅基本类型和对象引用) Tab 索引、弹窗显隐、表单输入
@Observed 可观察类定义 是(深层属性变化) 需配合 @ObjectLink 星座数据模型、复杂对象
@Prop 父子单向同步 是(父到子) 单向 父组件传递只读数据给子组件
@Link 父子双向同步 是(双向) 双向 父子组件共同操作同一数据
@Provide 跨层级向下传递 是(后代组件可接收) 主题色、全局配置
@Consume 接收祖先传递 是(配合 @Provide 接收全局主题、用户信息

在本应用中,@State 用于管理所有组件内部状态(导航索引、弹窗开关、表单数据),@Observed 用于 ZodiacSign 类以实现对象属性变化的自动追踪。这种组合在中等复杂度的单页面应用中是最常见的状态管理方案。

对比表 2:布局组件特性对比

布局组件 排列方式 是否自动换行 典型场景 性能特征
Column 垂直线性 页面主体结构、卡片内部 高,适合简单垂直排列
Row 水平线性 头部信息栏、列表行 高,适合简单水平排列
Stack 层叠(Z轴) 弹窗叠加、粒子特效层 中,需注意层级管理
Flex 弹性(水平/垂直) 可配置 标签选择器、自适应布局 中,灵活性最高
Grid 网格(行列) 自动排列 星座卡片网格、日历视图 中,适合规则网格
Scroll 滚动容器 长内容滚动、横向 Tab 栏 低,需要滚动计算

本应用综合运用了所有这些布局组件。Stack 作为根容器实现层叠效果,ColumnRow 构建页面骨架,Grid 实现星座和塔罗牌的网格展示,Flex 实现配对标签的自动换行,Scroll 提供内容滚动和横向 Tab 能力。

对比表 3:弹窗实现方案对比

实现方案 代码量 灵活性 动画支持 遮罩层 适用场景
条件渲染 + Stack 叠加 极高 需手动实现 手动设置背景色 自定义弹窗、复杂交互
AlertDialog 系统内置 系统内置 简单确认/提示
CustomDialog 系统内置 系统内置 中等复杂度弹窗
bindSheet 系统内置 系统内置 底部抽屉

本应用采用了"条件渲染 + Stack 叠加"的方案,虽然代码量较大,但获得了最大的灵活性——弹窗的布局、样式、动画和交互完全由开发者掌控。遮罩层通过 backgroundColor('99000000') 手动实现半透明效果,点击遮罩关闭弹窗的逻辑也在 onClick 中手动处理。这种方案在需要高度定制化弹窗视觉效果时是最佳选择。


四、数据流与渲染机制深度剖析

理解这个应用的渲染机制,需要从 ArkTS 的声明式渲染原理入手。整个应用的数据流可以用"单向数据流"来概括:状态变量变化 -> 框架检测变化 -> 重新执行 build 方法 -> 生成虚拟 DOM 树 -> Diff 对比 -> 更新实际 UI。

用户交互

状态变量赋值

框架检测变化

重新执行 build

生成虚拟 DOM

Diff 对比

最小化更新 UI

定时器 150ms

particles = driftParticles

particles 数组引用变化

ForEach 检测键变化

更新粒子位置

以粒子动画为例,每 150 毫秒定时器触发一次 driftParticles 调用,返回全新的粒子数组。由于 particles@State 变量,赋值操作使数组引用发生变化,框架检测到变化后重新执行 build 方法中的 ForEach 部分。

ForEach 的键生成器 p.id.toString() + '_' + p.y.toFixed(0) 起着关键作用。框架通过比较新旧键值,确定哪些粒子发生了位置变化,从而只更新发生变化的粒子的 position 属性。这种基于键的 Diff 机制,是 ArkTS 高效渲染的核心。

在条件渲染方面,if (this.currentBottomTab === 0) 这样的条件语句会在每次 build 执行时被求值。当 currentBottomTab 从 0 变为 1 时,运势内容的 UI 子树会被销毁,星座内容的 UI 子树会被创建。这种组件的创建和销毁是有性能开销的,但对于 Tab 切换这种低频操作来说完全可以接受。

@Observed 装饰的 ZodiacSign 类,在属性被赋值时会通知框架。虽然本应用中星座数据是静态的,但如果未来需要动态更新某个星座的运势分数(如从服务器拉取最新数据),@Observed 能够确保依赖该属性的 UI 自动更新。


五、视觉效果与动画总结

星空粒子系统的实现原理

粒子系统是这个应用最具技术含量的部分。它通过三个环节协同工作:数据层(ParticleItem 接口和 buildParticles 函数)、逻辑层(driftParticles 函数)和渲染层(ForEach + Text + position)。

数据层在组件初始化时调用 buildParticles() 生成 18 个粒子,每个粒子拥有位置、大小、透明度和图标四个核心属性。这些属性通过取模运算生成了看似随机的分布,实际上是确定性的计算,确保了每次启动应用时粒子初始状态一致。

逻辑层 driftParticles 是一个纯函数,接收旧粒子数组,返回新粒子数组。每个粒子的 y 坐标递减 3 实现上升运动,x 坐标通过 Math.sin 函数实现水平摆动。当粒子飘出顶部边界(y < 40)时,重置到底部(y = 640),形成无限循环的粒子流。

渲染层通过 ForEach 将每个粒子映射为一个 Text 组件,使用 position({ x: p.x, y: p.y }) 设置绝对定位。fontSizeopacity 直接绑定粒子的 sizeopacity 属性。每 150 毫秒的状态更新触发 ForEach 的键值 Diff,仅更新位置发生变化的粒子,实现了高效的局部刷新。

渐变与色彩效果

linearGradient 属性在记录运势弹窗中实现了 135 度角的紫色渐变效果。colors 数组定义了渐变断点:[['#7B52D6', 0], ['#2A1B47', 1]] 表示从 0% 位置的紫色到 100% 位置的暗紫色。这种线性渐变为弹窗头部增添了视觉深度,比纯色背景更具设计感。

半透明背景色 '99000000' 是一种巧妙的遮罩层实现方式。前两位 99 是 Alpha 通道值(十六进制 153,约 60% 不透明度),后六位 000000 是纯黑色。这种 ARGB 格式的颜色值在 ArkTS 中被广泛用于半透明遮罩效果。

半透明选中态 '#33F5C242' 同理,33 表示约 20% 不透明度,F5C242 是金色。这种通过 Alpha 通道实现的半透明效果,在 Tab 选中态、日历当日标记等场景中被反复使用,形成了统一的视觉语言。


安装DevEco Studio程序

在这里插入图片描述
选择目标安装目录:

在这里插入图片描述
设置环境变量,但是需要重启一下:

在这里插入图片描述
新建一个空白模板:

在这里插入图片描述
设置API为24的模板项目:
在这里插入图片描述
初始化项目,自动下载相关依赖:

在这里插入图片描述


完整代码:

// ============================================================
// 风格:暗夜紫 + 星空金 · 神秘占星风
// 底部4tab:运势 / 星座 / 塔罗 / 我的
// 顶部6tab:今日 / 明日 / 本周 / 本月 / 年运 / 配对
// 弹框:记录运势(新增) / 修改生日(编辑) / 清除记录(删除) / 星座详情
// 特效:星空粒子 + 7天运势柱状图
// ============================================================

interface ColorPalette {
  nightPurple: string;
  nightDeep: string;
  starGold: string;
  starGoldLight: string;
  mysticViolet: string;
  mysticVioletLight: string;
  bg: string;
  cardBg: string;
  cardBgDark: string;
  textPrimary: string;
  textSecondary: string;
  textHint: string;
  white: string;
  border: string;
  danger: string;
  success: string;
}

const COLORS: ColorPalette = {
  nightPurple: '#2A1B47',
  nightDeep: '#1A1030',
  starGold: '#F5C242',
  starGoldLight: '#FFD970',
  mysticViolet: '#7B52D6',
  mysticVioletLight: '#A98BEB',
  bg: '#150E26',
  cardBg: '#241A3D',
  cardBgDark: '#1C1430',
  textPrimary: '#F0E8FF',
  textSecondary: '#B0A0D0',
  textHint: '#7A6890',
  white: '#FFFFFF',
  border: '#3D2B5E',
  danger: '#E84A4A',
  success: '#4DD9A0'
};

interface TabItem {
  label: string;
  icon: string;
}

const BOTTOM_TABS: TabItem[] = [
  { label: '运势', icon: '🔮' },
  { label: '星座', icon: '♈' },
  { label: '塔罗', icon: '🃏' },
  { label: '我的', icon: '👤' }
];

const TOP_TABS: TabItem[] = [
  { label: '今日', icon: '☀️' },
  { label: '明日', icon: '🌅' },
  { label: '本周', icon: '📅' },
  { label: '本月', icon: '🗓️' },
  { label: '年运', icon: '🌟' },
  { label: '配对', icon: '💕' }
];

const LUCK_DAYS: string[] = ['周一', '周二', '周三', '周四', '周五', '周六', '周日'];
const LUCK_VALUES: number[] = [78, 65, 82, 90, 72, 95, 88];

@Observed
class ZodiacSign {
  id: number;
  name: string;
  symbol: string;
  dateRange: string;
  element: string;
  ruler: string;
  luck: number;
  love: number;
  career: number;
  wealth: number;
  health: number;
  luckyColor: string;
  luckyNumber: number;
  luckyDirection: string;
  description: string;

  constructor(id: number, name: string, symbol: string, dateRange: string,
    element: string, ruler: string, luck: number, love: number, career: number,
    wealth: number, health: number, luckyColor: string, luckyNumber: number,
    luckyDirection: string, description: string) {
    this.id = id;
    this.name = name;
    this.symbol = symbol;
    this.dateRange = dateRange;
    this.element = element;
    this.ruler = ruler;
    this.luck = luck;
    this.love = love;
    this.career = career;
    this.wealth = wealth;
    this.health = health;
    this.luckyColor = luckyColor;
    this.luckyNumber = luckyNumber;
    this.luckyDirection = luckyDirection;
    this.description = description;
  }
}

function buildSigns(): ZodiacSign[] {
  return [
    new ZodiacSign(1, '白羊座', '♈', '3.21-4.19', '火', '火星', 85, 72, 88, 65, 80, '红色', 9, '东方', '今天行动力爆棚,适合开启新计划'),
    new ZodiacSign(2, '金牛座', '♉', '4.20-5.20', '土', '金星', 70, 88, 60, 90, 75, '绿色', 6, '东南', '财运稳步上升,但感情需要更多沟通'),
    new ZodiacSign(3, '双子座', '♊', '5.21-6.21', '风', '水星', 78, 65, 82, 58, 85, '黄色', 5, '北方', '思维活跃的一天,适合学习与交流'),
    new ZodiacSign(4, '巨蟹座', '♋', '6.22-7.22', '水', '月亮', 65, 92, 55, 72, 68, '银色', 2, '西北', '情感丰沛,注意避免过度敏感'),
    new ZodiacSign(5, '狮子座', '♌', '7.23-8.22', '火', '太阳', 92, 78, 90, 70, 82, '金色', 1, '东方', '光芒四射的一天,适合展示自我'),
    new ZodiacSign(6, '处女座', '♍', '8.23-9.22', '土', '水星', 72, 60, 85, 68, 88, '藏青', 7, '西南', '细节决定成败,今日工作效率极高'),
    new ZodiacSign(7, '天秤座', '♎', '9.23-10.23', '风', '金星', 80, 90, 72, 75, 70, '粉色', 6, '西方', '平衡是关键词,在感情中找到和谐'),
    new ZodiacSign(8, '天蝎座', '♏', '10.24-11.22', '水', '冥王星', 88, 70, 82, 92, 78, '暗红', 4, '北方', '洞察力极强,财运与事业双丰收'),
    new ZodiacSign(9, '射手座', '♐', '11.23-12.21', '火', '木星', 85, 75, 80, 65, 85, '紫色', 3, '东南', '冒险精神旺盛,适合规划旅行'),
    new ZodiacSign(10, '摩羯座', '♑', '12.22-1.19', '土', '土星', 68, 55, 92, 85, 72, '棕色', 8, '南方', '稳扎稳打收获回报,事业运极佳'),
    new ZodiacSign(11, '水瓶座', '♒', '1.20-2.18', '风', '天王星', 82, 70, 78, 62, 80, '天蓝', 11, '西北', '创意迸发的一天,适合突破常规'),
    new ZodiacSign(12, '双鱼座', '♓', '2.19-3.20', '水', '海王星', 75, 92, 58, 60, 85, '海蓝', 7, '东方', '直觉敏锐,艺术灵感涌现')
  ];
}

const SIGNS: ZodiacSign[] = buildSigns();

interface TarotCard {
  id: number;
  name: string;
  symbol: string;
  meaning: string;
  reversed: string;
}

const TAROT_CARDS: TarotCard[] = [
  { id: 1, name: '愚者', symbol: '🃏', meaning: '新的开始·无限可能', reversed: '鲁莽·缺乏计划' },
  { id: 2, name: '魔术师', symbol: '✨', meaning: '创造力·主动行动', reversed: '欺骗·才能浪费' },
  { id: 3, name: '女祭司', symbol: '🌙', meaning: '直觉·神秘智慧', reversed: '隐藏秘密·被动' },
  { id: 4, name: '皇后', symbol: '👑', meaning: '丰饶·母性力量', reversed: '过度依赖·放纵' },
  { id: 5, name: '皇帝', symbol: '⚡', meaning: '权威·秩序掌控', reversed: '独断·固执' },
  { id: 6, name: '恋人', symbol: '💕', meaning: '选择·和谐关系', reversed: '分离·错误选择' },
  { id: 7, name: '战车', symbol: '🏆', meaning: '胜利·意志坚定', reversed: '失控·方向不明' },
  { id: 8, name: '力量', symbol: '🦁', meaning: '勇气·内在力量', reversed: '自我怀疑·软弱' },
  { id: 9, name: '隐者', symbol: '🕯️', meaning: '内省·寻求真理', reversed: '孤立·固执' },
  { id: 10, name: '命运之轮', symbol: '🎡', meaning: '转折·命运流转', reversed: '逆流·坏运' },
  { id: 11, name: '正义', symbol: '⚖️', meaning: '公平·因果平衡', reversed: '不公·偏见' },
  { id: 12, name: '倒吊人', symbol: '🔄', meaning: '牺牲·换个角度', reversed: '无谓牺牲·停滞' },
  { id: 13, name: '死神', symbol: '💀', meaning: '终结·重生蜕变', reversed: '抗拒变化·停滞' },
  { id: 14, name: '节制', symbol: '🏺', meaning: '调和·平衡之道', reversed: '失衡·过度' },
  { id: 15, name: '恶魔', symbol: '😈', meaning: '束缚·物欲执念', reversed: '解脱·觉醒' }
];

interface LuckRecord {
  id: number;
  date: string;
  sign: string;
  score: number;
  note: string;
}

const LUCK_RECORDS: LuckRecord[] = [
  { id: 1, date: '8月24日', sign: '狮子座', score: 92, note: '光芒四射,领导力满分' },
  { id: 2, date: '8月23日', sign: '狮子座', score: 85, note: '财运小旺,注意社交' },
  { id: 3, date: '8月22日', sign: '狮子座', score: 78, note: '工作压力大,需放松' },
  { id: 4, date: '8月21日', sign: '狮子座', score: 90, note: '感情运极佳的一天' },
  { id: 5, date: '8月20日', sign: '狮子座', score: 72, note: '健康注意,少熬夜' },
  { id: 6, date: '8月19日', sign: '狮子座', score: 88, note: '创意迸发,灵感不断' },
  { id: 7, date: '8月18日', sign: '狮子座', score: 80, note: '平稳的一天,按部就班' }
];

interface ParticleItem {
  id: number;
  x: number;
  y: number;
  size: number;
  opacity: number;
  icon: string;
}

const PARTICLE_ICONS: string[] = ['✦', '✧', '★', '✩', '✪'];

function buildParticles(): ParticleItem[] {
  const arr: ParticleItem[] = [];
  for (let i = 0; i < 18; i++) {
    arr.push({
      id: i,
      x: (i * 37) % 340 + 10,
      y: 60 + (i * 53) % 580,
      size: 8 + (i * 4) % 12,
      opacity: 0.15 + (i % 5) * 0.08,
      icon: PARTICLE_ICONS[i % PARTICLE_ICONS.length]
    });
  }
  return arr;
}

function driftParticles(list: ParticleItem[]): ParticleItem[] {
  const next: ParticleItem[] = [];
  for (let i = 0; i < list.length; i++) {
    const p = list[i];
    const ny = p.y - 3;
    next.push({
      id: p.id,
      x: p.x + Math.sin(p.id + p.y / 60) * 2,
      y: ny < 40 ? 640 : ny,
      size: p.size,
      opacity: p.opacity,
      icon: p.icon
    });
  }
  return next;
}

function luckColor(score: number): string {
  if (score >= 85) {
    return COLORS.starGold;
  }
  if (score >= 70) {
    return COLORS.mysticVioletLight;
  }
  return COLORS.textSecondary;
}

function barHeight(v: number): string {
  return (v * 1.2).toFixed(0) + 'vp';
}

function elementColor(el: string): string {
  if (el === '火') {
    return '#E85A3C';
  }
  if (el === '土') {
    return '#8B7355';
  }
  if (el === '风') {
    return '#5AC8E8';
  }
  return '#5A7AD6';
}

function matchScore(s1: string, s2: string): number {
  if (s1 === s2) {
    return 100;
  }
  if (s1 === '白羊座' && s2 === '狮子座') {
    return 95;
  }
  if (s1 === '金牛座' && s2 === '处女座') {
    return 92;
  }
  return 75;
}

@Entry
struct Index {
  @State currentBottomTab: number = 0;
  @State currentTopTab: number = 0;
  @State showRecordModal: boolean = false;
  @State showEditModal: boolean = false;
  @State showClearModal: boolean = false;
  @State showDetailModal: boolean = false;
  @State selectedSign: ZodiacSign | null = null;
  @State particles: ParticleItem[] = buildParticles();
  @State recordNote: string = '';
  @State recordScore: number = 80;
  @State editBirthDate: string = '8月15日';
  @State editGender: number = 0;
  @State matchSign1: number = 4;
  @State matchSign2: number = 0;
  private timerId: number = -1;

  aboutToAppear() {
    this.timerId = setInterval(() => {
      this.particles = driftParticles(this.particles);
    }, 150);
  }

  aboutToDisappear() {
    if (this.timerId >= 0) {
      clearInterval(this.timerId);
    }
  }

  build() {
    Stack() {
      Column() {
        this.headerBuilder()
        if (this.currentBottomTab === 0) {
          this.fortuneTopTabs()
        } else if (this.currentBottomTab === 1) {
          this.zodiacTopBar()
        } else if (this.currentBottomTab === 2) {
          this.tarotTopBar()
        } else {
          this.mineTopBar()
        }
        Scroll() {
          Column() {
            if (this.currentBottomTab === 0) {
              this.fortuneContent()
            } else if (this.currentBottomTab === 1) {
              this.zodiacContent()
            } else if (this.currentBottomTab === 2) {
              this.tarotContent()
            } else {
              this.mineContent()
            }
          }
          .width('100%')
          .alignItems(HorizontalAlign.Start)
        }
        .layoutWeight(1)
        .scrollable(ScrollDirection.Vertical)
        .scrollBar(BarState.Off)
        this.bottomTabs()
      }
      .width('100%')
      .height('100%')
      .backgroundColor(COLORS.bg)

      ForEach(this.particles, (p: ParticleItem) => {
        Text(p.icon)
          .fontSize(p.size)
          .opacity(p.opacity)
          .position({ x: p.x, y: p.y })
      }, (p: ParticleItem) => p.id.toString() + '_' + p.y.toFixed(0))

      if (this.showRecordModal) {
        this.recordLuckModal()
      }
      if (this.showEditModal) {
        this.editProfileModal()
      }
      if (this.showClearModal) {
        this.clearRecordModal()
      }
      if (this.showDetailModal) {
        this.signDetailModal()
      }
    }
    .width('100%')
    .height('100%')
  }

  @Builder
  headerBuilder() {
    Column() {
      Row() {
        Column() {
          Text('🔮 QQ星座')
            .fontSize(22)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.starGold)
          Text('今日有 12,840 人查看运势')
            .fontSize(11)
            .fontColor(COLORS.textSecondary)
            .margin({ top: 2 })
        }
        .alignItems(HorizontalAlign.Start)

        Column() {
          Text('♌')
            .fontSize(20)
            .fontColor(COLORS.white)
          Text('狮子座')
            .fontSize(10)
            .fontColor(COLORS.white)
        }
        .padding({ left: 12, right: 12, top: 6, bottom: 6 })
        .borderRadius(16)
        .backgroundColor(COLORS.mysticViolet)
        .onClick(() => {
          this.showEditModal = true;
        })
      }
      .width('100%')
      .justifyContent(FlexAlign.SpaceBetween)
      .padding({ left: 16, right: 16, top: 10, bottom: 8 })

      Row() {
        Column() {
          Text('⭐ 今日运势')
            .fontSize(10)
            .fontColor(COLORS.textSecondary)
          Text('85分')
            .fontSize(15)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.starGold)
        }
        .alignItems(HorizontalAlign.Start)

        Divider()
          .vertical(true)
          .height(26)
          .color(COLORS.border)
          .margin({ left: 14, right: 14 })

        Column() {
          Text('💕 感情')
            .fontSize(10)
            .fontColor(COLORS.textSecondary)
          Text('72分')
            .fontSize(15)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.mysticVioletLight)
        }
        .alignItems(HorizontalAlign.Start)

        Divider()
          .vertical(true)
          .height(26)
          .color(COLORS.border)
          .margin({ left: 14, right: 14 })

        Column() {
          Text('💼 事业')
            .fontSize(10)
            .fontColor(COLORS.textSecondary)
          Text('88分')
            .fontSize(15)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.success)
        }
        .alignItems(HorizontalAlign.Start)

        Column() {
          Text('💰 财运')
            .fontSize(10)
            .fontColor(COLORS.textSecondary)
          Text('65分')
            .fontSize(15)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.danger)
        }
        .alignItems(HorizontalAlign.Start)
      }
      .width('100%')
      .padding({ left: 16, right: 16, bottom: 10 })
    }
    .width('100%')
    .backgroundColor(COLORS.nightDeep)
  }

  @Builder
  fortuneTopTabs() {
    Scroll() {
      Row() {
        ForEach(TOP_TABS, (t: TabItem, idx: number) => {
          Column() {
            Text(t.icon)
              .fontSize(16)
            Text(t.label)
              .fontSize(11)
              .fontColor(this.currentTopTab === idx ? COLORS.starGold : COLORS.textSecondary)
              .margin({ top: 2 })
          }
          .padding({ left: 12, right: 12, top: 6, bottom: 6 })
          .borderRadius(12)
          .backgroundColor(this.currentTopTab === idx ? '#33F5C242' : 'transparent')
          .margin({ right: 4 })
          .onClick(() => {
            this.currentTopTab = idx;
          })
        }, (t: TabItem) => t.label)
      }
      .padding({ left: 8, right: 8, top: 8, bottom: 8 })
    }
    .scrollable(ScrollDirection.Horizontal)
    .scrollBar(BarState.Off)
    .width('100%')
    .backgroundColor(COLORS.nightDeep)
  }

  @Builder
  zodiacTopBar() {
    Row() {
      Text('十二星座')
        .fontSize(14)
        .fontWeight(FontWeight.Bold)
        .fontColor(COLORS.starGold)
    }
    .width('100%')
    .padding({ left: 16, right: 16, top: 10, bottom: 10 })
    .backgroundColor(COLORS.nightDeep)
  }

  @Builder
  tarotTopBar() {
    Row() {
      Text('🃏 每日塔罗')
        .fontSize(14)
        .fontWeight(FontWeight.Bold)
        .fontColor(COLORS.starGold)
      Text('抽牌')
        .fontSize(12)
        .fontColor(COLORS.white)
        .padding({ left: 10, right: 10, top: 4, bottom: 4 })
        .borderRadius(12)
        .backgroundColor(COLORS.mysticViolet)
    }
    .width('100%')
    .justifyContent(FlexAlign.SpaceBetween)
    .padding({ left: 16, right: 16, top: 10, bottom: 10 })
    .backgroundColor(COLORS.nightDeep)
  }

  @Builder
  mineTopBar() {
    Row() {
      Text('个人星盘')
        .fontSize(14)
        .fontWeight(FontWeight.Bold)
        .fontColor(COLORS.starGold)
    }
    .width('100%')
    .padding({ left: 16, right: 16, top: 10, bottom: 10 })
    .backgroundColor(COLORS.nightDeep)
  }

  @Builder
  fortuneContent() {
    Column() {
      if (this.currentTopTab === 0) {
        this.todayFortune()
      } else if (this.currentTopTab === 1) {
        this.tomorrowFortune()
      } else if (this.currentTopTab === 2) {
        this.weekFortune()
      } else if (this.currentTopTab === 3) {
        this.monthFortune()
      } else if (this.currentTopTab === 4) {
        this.yearFortune()
      } else {
        this.matchFortune()
      }
    }
    .width('100%')
    .padding(12)
    .alignItems(HorizontalAlign.Start)
  }

  @Builder
  todayFortune() {
    Column() {
      Column() {
        Row() {
          Text(SIGNS[4].symbol)
            .fontSize(56)
            .margin({ right: 14 })
          Column() {
            Text('今日狮子座运势')
              .fontSize(17)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.white)
            Text('8月24日 · 农历七月十一')
              .fontSize(11)
              .fontColor(COLORS.textSecondary)
              .margin({ top: 4 })
            Text('👑 综合运势 85分 · ★★★★☆')
              .fontSize(13)
              .fontColor(COLORS.starGold)
              .margin({ top: 4 })
          }
          .alignItems(HorizontalAlign.Start)
          .layoutWeight(1)
        }
        .width('100%')

        Text(SIGNS[4].description)
          .fontSize(12)
          .fontColor(COLORS.textPrimary)
          .margin({ top: 12 })
          .padding(10)
          .borderRadius(10)
          .backgroundColor(COLORS.cardBgDark)
          .width('100%')

        Text('📊 运势维度')
          .fontSize(14)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.white)
          .margin({ top: 14 })

        ForEach(['感情', '事业', '财运', '健康'], (dim: string, idx: number) => {
          Row() {
            Text(dim)
              .fontSize(12)
              .fontColor(COLORS.textSecondary)
              .width(40)
            Column() {
              Text('')
                .width('100%')
                .height(1)
            }
            .width('60%')
            .height(14)
            .borderRadius(7)
            .backgroundColor(COLORS.cardBgDark)
            Column() {
              Text('')
                .width('100%')
                .height(1)
            }
            .width((idx === 0 ? '72' : (idx === 1 ? '88' : (idx === 2 ? '65' : '80'))) + '%')
            .height(14)
            .borderRadius(7)
            .backgroundColor(luckColor(idx === 0 ? 72 : (idx === 1 ? 88 : (idx === 2 ? 65 : 80))))
          }
          .width('100%')
          .margin({ bottom: 8 })
        }, (dim: string) => dim)

        Row() {
          Column() {
            Text('🎨 幸运色')
              .fontSize(10)
              .fontColor(COLORS.textSecondary)
            Text(SIGNS[4].luckyColor)
              .fontSize(14)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.starGold)
          }
          .alignItems(HorizontalAlign.Start)

          Column() {
            Text('🔢 幸运数字')
              .fontSize(10)
              .fontColor(COLORS.textSecondary)
            Text(SIGNS[4].luckyNumber.toString())
              .fontSize(14)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.starGold)
          }
          .alignItems(HorizontalAlign.Start)
          .margin({ left: 20 })

          Column() {
            Text('🧭 幸运方位')
              .fontSize(10)
              .fontColor(COLORS.textSecondary)
            Text(SIGNS[4].luckyDirection)
              .fontSize(14)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.starGold)
          }
          .alignItems(HorizontalAlign.Start)
          .margin({ left: 20 })
        }
        .width('100%')
        .margin({ top: 10 })

        Row() {
          Text('📝 记录今日运势')
            .fontSize(13)
            .fontColor(COLORS.white)
            .padding({ left: 16, right: 16, top: 8, bottom: 8 })
            .borderRadius(18)
            .backgroundColor(COLORS.mysticViolet)
            .onClick(() => {
              this.showRecordModal = true;
            })
        }
        .width('100%')
        .margin({ top: 14 })
      }
      .width('100%')
      .padding(14)
      .borderRadius(16)
      .backgroundColor(COLORS.cardBg)
      .alignItems(HorizontalAlign.Start)
    }
    .width('100%')
    .alignItems(HorizontalAlign.Start)
  }

  @Builder
  tomorrowFortune() {
    Column() {
      Text('🌅 明日运势预告')
        .fontSize(15)
        .fontWeight(FontWeight.Bold)
        .fontColor(COLORS.white)
        .margin({ bottom: 10 })

      ForEach(SIGNS, (s: ZodiacSign, idx: number) => {
        Row() {
          Text(s.symbol)
            .fontSize(24)
            .width(36)
            .textAlign(TextAlign.Center)
          Column() {
            Text(s.name + ' · ' + s.dateRange)
              .fontSize(13)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.white)
            Text(s.description)
              .fontSize(10)
              .fontColor(COLORS.textSecondary)
              .maxLines(1)
              .textOverflow({ overflow: TextOverflow.Ellipsis })
              .margin({ top: 3 })
          }
          .alignItems(HorizontalAlign.Start)
          .layoutWeight(1)
          .margin({ left: 10 })
          Column() {
            Text(s.luck.toString())
              .fontSize(16)
              .fontWeight(FontWeight.Bold)
              .fontColor(luckColor(s.luck))
            Text('运势')
              .fontSize(9)
              .fontColor(COLORS.textHint)
              .margin({ top: 2 })
          }
          .alignItems(HorizontalAlign.End)
        }
        .width('100%')
        .padding(10)
        .borderRadius(12)
        .backgroundColor(idx < 3 ? '#2E2152' : COLORS.cardBgDark)
        .border({
          width: idx < 3 ? 1 : 0,
          color: COLORS.mysticViolet
        })
        .margin({ bottom: 8 })
        .onClick(() => {
          this.selectedSign = s;
          this.showDetailModal = true;
        })
      }, (s: ZodiacSign) => s.id.toString())
    }
    .width('100%')
    .alignItems(HorizontalAlign.Start)
  }

  @Builder
  weekFortune() {
    Column() {
      Column() {
        Text('📈 本周运势走势')
          .fontSize(14)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.white)
        Row() {
          ForEach(LUCK_DAYS, (d: string, idx: number) => {

      .borderRadius(16)
      .backgroundColor(COLORS.cardBg)
      .clip(true)
    }
    .width('100%')
    .height('100%')
    .justifyContent(FlexAlign.Center)
    .alignItems(HorizontalAlign.Center)
    .backgroundColor('99000000')
    .onClick(() => {
      this.showDetailModal = false;
    })
  }
}


在这里插入图片描述

六、总结

本文通过对 QQ 星座星盘馆星座运势占星 App 的完整源码逐段深度解析,全面展示了基于 HarmonyOS 6.1.1 和 HarmonyOS ArkTS API 24 开发复杂交互式应用的技术方案与最佳实践。

从技术架构层面来看,该应用采用了典型的单页面多 Tab 架构,通过 @State 状态变量驱动条件渲染实现模块切换,通过 @Observed 类实现对象级状态追踪,通过 Stack 层叠布局实现内容层、粒子层和弹窗层的叠加。整个应用的状态管理清晰有序,数据流单向可追踪。

从 UI 渲染层面来看,应用充分运用了 ArkTS 的声明式 UI 语法,通过 ForEach 实现列表和网格的数据驱动渲染,通过 Grid 组件实现日历和卡片网格,通过纯组件方式(Column + 宽度百分比)实现了柱状图和进度条的自定义可视化。没有引入任何第三方图表库,全部通过原生组件实现了丰富的数据可视化效果。

从动画与特效层面来看,星空粒子系统通过 setInterval 定时器驱动纯函数状态变换,配合 ForEach 的键值 Diff 机制实现了高效的局部刷新。渐变背景、半透明遮罩、Emoji 图标等视觉技巧的综合运用,以极低的性能开销营造出了神秘占星风格的视觉氛围。

从交互设计层面来看,四种弹窗(记录、编辑、清除、详情)通过条件渲染实现,每个弹窗都包含表单输入、状态同步和遮罩点击关闭等完整的交互能力。底部导航和顶部子导航的双层 Tab 设计,配合状态重置逻辑,确保了导航的一致性和可预测性。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐