引子:一个"玄学"应用背后的技术含量

"星空运势"应用。需求很明确:一个命运转盘,转完显示今日运势;一个星座星图,点击星座能看到星点分布;一个塔罗牌阵,抽三张牌翻开解读。

听起来是个"玄学"应用,但做起来发现技术含量不低——转盘的旋转动画、星图的坐标定位、卡牌的翻转效果,每一个都涉及 ArkUI 的核心机制。更关键的是,这三个功能要在一个页面里通过 Swiper 切换,状态管理变得复杂。

完整效果
在这里插入图片描述

技术栈与环境

  • 开发框架:HarmonyOS ArkTS (ArkUI)
  • 开发工具:DevEco Studio 5.0+
  • 目标平台:HarmonyOS NEXT
  • 语言:TypeScript 的超集 ArkTS

项目结构:单文件的"重量级"应用

和之前的骰子应用不同,这个星空运势应用把所有代码都放在一个 Index.ets 文件里。这不是最佳实践,但对于演示 ArkUI 的复合功能来说,单文件更方便讲解。

entry/src/main/ets/
├── pages/
│   └── Index.ets        # 主页面(所有逻辑都在这里)
├── entryability/
│   └── EntryAbility.ets  # 应用入口
└── resources/            # 资源文件

代码量超过 500 行,包含了三个完整的功能模块。如果要做成生产级应用,建议拆分成独立的组件文件,但单文件的好处是所有逻辑一目了然,方便学习。

数据建模:为什么用 class 而不是 interface

代码开头定义了四个类:

class Pos2D {
  x: number = 0;
  y: number = 0;
  constructor(x: number, y: number) { this.x = x; this.y = y; }
}

class StarPt {
  x: number = 0;
  y: number = 0;
  sz: number = 0;
  constructor(x: number, y: number, sz: number) { this.x = x; this.y = y; this.sz = sz; }
}

class SignData {
  name: string = '';
  emoji: string = '';
  date: string = '';
  elem: string = '';
  trait: string = '';
  stars: StarPt[] = [];
  constructor(n: string, e: string, d: string, el: string, t: string, s: StarPt[]) {
    this.name = n; this.emoji = e; this.date = d; this.elem = el; this.trait = t; this.stars = s;
  }
}

class TarotInfo {
  name: string = '';
  emoji: string = '';
  key: string = '';
  text: string = '';
  constructor(n: string, e: string, k: string, t: string) {
    this.name = n; this.emoji = e; this.key = k; this.text = t;
  }
}

在这里插入图片描述

为什么用 class 而不是 interface?

在之前的骰子应用中,我们用 interface 定义数据结构。但这个应用用 class,原因是:

  1. 需要实例化Pos2DStarPt 需要通过 new Pos2D(x, y) 创建实例,interface 不能实例化
  2. 需要默认值class 可以给属性设置默认值(x: number = 0),interface 不行
  3. 需要构造函数class 可以有构造函数,简化实例创建过程

class 也有缺点:编译后会生成额外的 JavaScript 代码,增加包体积。对于小项目影响不大,但如果要极致优化,可以用 interface + 工厂函数代替:

interface Pos2D {
  x: number;
  y: number;
}

function createPos2D(x: number, y: number): Pos2D {
  return { x, y };
}

数据设计的考量

SignData 类包含了星座的所有信息:名称、emoji、日期范围、元素、特质、星点坐标。这种"胖数据"设计的好处是:

  1. 内聚性:所有相关数据都在一个对象里,不会散落在各处
  2. 可扩展性:如果要添加新字段(比如"幸运数字"),只需修改 SignData
  3. 类型安全:编译器会检查所有使用 SignData 的地方,确保字段名正确

但也要注意:不要把不相关的数据塞进一个类。比如"转盘颜色"和"星座数据"是两个概念,应该分开管理。

Swiper 组件:多页导航的实现

应用的核心导航是通过 Swiper 组件实现的:

build() {
  Column() {
    this.TopNav();
    Swiper() {
      this.WheelPage();
      this.StarPage();
      this.TarotPage();
    }
    .index(this.page)
    .indicator(false)
    .loop(false)
    .duration(300)
    .onChange((idx: number) => { this.page = idx; })
    .layoutWeight(1)
    .width('100%')
  }
  .width('100%')
  .height('100%')
  .backgroundColor('#06060F')
}

在这里插入图片描述

Swiper vs Tabs

ArkUI 提供了 SwiperTabs 两种多页导航组件。区别是:

  • Swiper:支持手势滑动切换,适合内容独立的页面
  • Tabs:底部或顶部 Tab 导航,适合需要频繁切换的页面

这个应用用 Swiper 而不是 Tabs,原因是:

  1. 手势交互:用户可以左右滑动切换页面,体验更流畅
  2. 视觉效果Swiper 的切换动画更平滑
  3. 状态同步:通过 onChange 事件同步 page 状态,和顶部导航联动

Swiper 的关键属性

Swiper() { ... }
  .index(this.page)        // 当前页索引
  .indicator(false)        // 隐藏默认指示器
  .loop(false)             // 禁用循环滑动
  .duration(300)           // 切换动画时长
  .onChange((idx) => { })  // 页面切换回调
  .layoutWeight(1)         // 占据剩余空间

为什么禁用 loop?

如果启用 loop,Swiper 会在首尾添加"克隆页",实现无限循环。但对于三个独立功能的页面,循环滑动没有意义,反而可能导致状态混乱。

为什么隐藏 indicator?

默认的指示器是小圆点,但这个应用有自定义的顶部导航栏,保留默认指示器会重复。通过 .indicator(false) 隐藏,用自定义 UI 代替。

命运转盘:旋转动画的核心实现

转盘是这个应用最复杂的部分,涉及旋转动画、坐标计算和状态管理。

转盘的布局结构

Stack() {
  // 底部圆形背景
  Text('◉').fontSize(290).fontColor('#150F30').opacity(0.6)
  
  // 星座图标(通过坐标定位)
  ForEach(SIGNS, (s: SignData, i: number) => {
    Column() {
      Text(s.emoji).fontSize(18)
      Text(s.name.substring(0, 2)).fontSize(7).fontColor('#BBAAE0').margin({ top: 2 })
    }
    .width(56).height(56).justifyContent(FlexAlign.Center)
    .borderRadius(28).backgroundColor(WHEEL_COLORS[i] + '44')
    .position(this.wheelPos(i))  // 关键:通过坐标定位
  })
  
  // 中心装饰
  Column() {
    Text('✦').fontSize(28).fontColor('#FFD700')
  }
  .width(50).height(50).borderRadius(25)
  .backgroundColor('#0A0820')
  .border({ width: 2, color: '#FFD700', style: BorderStyle.Solid })
}
.width(290).height(290)
.rotate({ angle: this.wheelAngle })
.animation({ duration: this.spinning ? 3500 : 0, curve: Curve.EaseOut })

在这里插入图片描述

这里用了 Stack 组件来叠加三个层次:背景、星座图标、中心装饰。Stack 的特点是子组件可以重叠,适合做这种"层叠"效果。

坐标计算:wheelPos 方法

转盘有 12 个星座,均匀分布在圆周上。坐标计算是关键:

wheelPos(idx: number): Pos2D {
  const a: number = -Math.PI / 2 + idx * (2 * Math.PI / 12);
  return new Pos2D(
    145 - 28 + 105 * Math.cos(a),
    145 - 28 + 105 * Math.sin(a)
  );
}

这个方法把极坐标转换成直角坐标:

  • 角度计算:从 -Math.PI / 2(12 点钟方向)开始,每个星座间隔 2 * Math.PI / 12(30 度)
  • 半径:105 像素(从中心到星座图标的距离)
  • 偏移145 - 28 是为了把坐标原点从 Stack 左上角移到中心

这种"极坐标转直角坐标"的方法在圆形布局中很常见,掌握后可以做很多效果(比如环形菜单、时钟界面)。

旋转动画:.rotate() 和 .animation()

转盘的旋转效果是通过 .rotate().animation() 配合实现的:

.rotate({ angle: this.wheelAngle })
.animation({ duration: this.spinning ? 3500 : 0, curve: Curve.EaseOut })

为什么用 .rotate() 而不是 animateTo()?

.rotate() 是属性动画,当 wheelAngle 变化时自动添加过渡效果。而 animateTo() 是显式动画,需要手动控制。

在这个场景中,.rotate() 更合适,因为:

  1. 声明式:只需设置目标角度,框架自动处理过渡
  2. 条件动画:通过 this.spinning ? 3500 : 0 控制动画时长,停止时不播放动画
  3. 缓动函数Curve.EaseOut 让动画结束时减速,更自然

旋转逻辑:doSpin 和 doReveal

doSpin(): void {
  if (this.spinning) { return; }
  this.spinning = true;
  this.resultIdx = -1;
  this.wheelAngle += 1800 + this.dateSeed(360);
}

doReveal(): void {
  if (!this.spinning) { return; }
  const n: number = ((360 - (this.wheelAngle % 360)) % 360 + 360) % 360;
  this.resultIdx = Math.floor(n / 30) % 12;
  this.spinning = false;
}

旋转角度的计算

1800 + this.dateSeed(360) 保证至少旋转 5 圈(1800 度),再加上一个基于日期的随机偏移(0-359 度)。这样每天的结果不同,但同一天内结果固定。

结果的计算

doReveal 方法计算指针指向哪个星座。关键是 (360 - (this.wheelAngle % 360)) % 360——因为转盘是逆时针旋转,指针是固定的,所以要用 360 减去当前角度。

日期种子:dateSeed 方法

dateSeed(max: number): number {
  const d: Date = new Date();
  return (d.getFullYear() * 397 + d.getMonth() * 31 + d.getDate()) % max;
}

这个方法用日期生成一个伪随机数,保证每天的结果固定。用质数(397、31)是为了让不同日期的结果尽量分散。

为什么不直接用 Math.random()?因为用户可能多次旋转,如果用真随机,每次结果都不同,失去了"今日运势"的意义。

星座星图:Grid 布局与坐标定位

星座星图页面用了 Grid 组件来展示 12 个星座:

Grid() {
  ForEach(SIGNS, (s: SignData, i: number) => {
    GridItem() {
      Column() {
        Text(s.emoji).fontSize(i === this.selectedSign ? 36 : 28)
        Text(s.name).fontSize(10)
          .fontColor(i === this.selectedSign ? '#FFD700' : '#8B7BC0')
        Text(s.date).fontSize(8).fontColor('#4B3F70').margin({ top: 2 })
      }
      .width('100%').height(90).justifyContent(FlexAlign.Center)
      .borderRadius(12)
      .backgroundColor(i === this.selectedSign ? '#1C1545' : '#0E0B25')
      .border({ width: i === this.selectedSign ? 1 : 0.5, color: ... })
      .onClick(() => {
        if (this.selectedSign === i) { this.selectedSign = -1; }
        else { this.selectedSign = i; }
      })
    }
  })
}
.columnsTemplate('1fr 1fr 1fr')
.rowsGap(10).columnsGap(10)

Grid vs Flex

为什么用 Grid 而不是 Flex

  • Grid:适合二维布局,可以精确控制列数和间距
  • Flex:适合一维布局(行或列),需要嵌套才能实现二维

这里需要 3 列 4 行的网格,Grid 更合适。.columnsTemplate('1fr 1fr 1fr') 定义了三等分的列宽,rowsGapcolumnsGap 定义了间距。

星点的坐标定位

点击星座后,会显示星图详情,星点通过坐标定位:

ForEach(SIGNS[this.selectedSign].stars, (pt: StarPt) => {
  Text('·')
    .fontSize(pt.sz * 2.5).fontColor('#FFFFFF')
    .position(new Pos2D(pt.x - 2, pt.y - 2))
})

每个星点的位置和大小都存储在 SignData.stars 数组中。.position() 方法把星点定位到指定坐标,pt.sz * 2.5 控制大小。

这种"数据驱动"的布局方式很灵活——如果要修改星图,只需修改数据,不需要改 UI 代码。

塔罗牌阵:卡牌翻转的实现

塔罗牌阵是最有趣的部分,涉及卡牌的堆叠、发牌和翻转动画。

牌堆的视觉效果

@Builder
StackedDeck() {
  Stack() {
    Column().width(100).height(148).borderRadius(12).backgroundColor('#1C1258')
      .border({ width: 1, color: '#5B4FAF', style: BorderStyle.Solid })
      .position(new Pos2D(0, 0))
    Column().width(100).height(148).borderRadius(12).backgroundColor('#181050')
      .border({ width: 1, color: '#4B3F8F', style: BorderStyle.Solid })
      .position(new Pos2D(3, 2))
    Column().width(100).height(148).borderRadius(12).backgroundColor('#150D48')
      .border({ width: 1, color: '#3B2F78', style: BorderStyle.Solid })
      .position(new Pos2D(6, 4))
    Column() {
      Text('✦').fontSize(36).fontColor('#8B7BC0')
      Text('塔 罗').fontSize(12).fontColor('#5B4FAF').margin({ top: 6 })
    }
    .width(100).height(148).justifyContent(FlexAlign.Center).borderRadius(12)
    .linearGradient({ angle: 135, colors: [['#1C1258',0],['#2D1F70',1]] })
    .border({ width: 1.5, color: '#7B6FBF', style: BorderStyle.Solid })
    .position(new Pos2D(9, 6))
  }
  .width(120).height(170)
}

这里用 Stack 叠加了四层,每层偏移一点,制造"牌堆"的视觉效果。最上层用了 linearGradient(线性渐变),让牌面更有质感。

卡牌的翻转逻辑

doFlip(i: number): void {
  if (this.tarotOpen[i]) { return; }
  const o: boolean[] = [...this.tarotOpen];
  o[i] = true;
  this.tarotOpen = o;
}

翻转逻辑很简单:点击未翻开的牌,把对应的 tarotOpen[i] 设为 true

为什么要用展开运算符 [...this.tarotOpen]

因为 @State 数组是引用类型,直接修改 this.tarotOpen[i] = true 不会触发重新渲染。必须创建新数组,框架才能检测到变化。

发牌动画

@Builder
DealtCards() {
  Column() {
    Row() {
      ForEach([0, 1, 2], (i: number) => {
        Column() {
          Text(this.posLabel(i)).fontSize(10).fontColor('#5B4FAF')
            .fontWeight(FontWeight.Medium).margin({ bottom: 8 })
          if (this.tarotOpen[i]) {
            // 已翻开:显示牌面
            Column() {
              Text(TAROT[this.tarotIdx[i]].emoji).fontSize(38)
              Text(TAROT[this.tarotIdx[i]].name)
                .fontSize(11).fontWeight(FontWeight.Bold).fontColor('#FFD700').margin({ top: 4 })
            }
            .width(94).height(138).justifyContent(FlexAlign.Center).borderRadius(12)
            .linearGradient({ angle: 180, colors: [['#1C1258',0],['#2D1F70',1]] })
            .border({ width: 1, color: '#FFD700', style: BorderStyle.Solid })
            .animation({ duration: 350 })
          } else {
            // 未翻开:显示牌背
            Column() {
              Text('✦').fontSize(32).fontColor('#8B7BC0')
              Text('点击翻开').fontSize(9).fontColor('#5B4FAF').margin({ top: 6 })
            }
            .width(94).height(138).justifyContent(FlexAlign.Center).borderRadius(12)
            .backgroundColor('#181050')
            .border({ width: 1, color: '#5B4FAF', style: BorderStyle.Dashed })
            .onClick(() => { this.doFlip(i); })
            .animation({ duration: 350 })
          }
        }
        .alignItems(HorizontalAlign.Center).margin({ left: 5, right: 5 })
      })
    }
    .width('100%').justifyContent(FlexAlign.Center)
  }
  .width('100%')
}

这里用了条件渲染来切换牌面和牌背。animation({ duration: 350 }) 让切换过程有过渡效果。

洗牌算法:Fisher-Yates Shuffle

shuffle(): void {
  const dk: number[] = [];
  for (let i: number = 0; i < TAROT.length; i++) { dk.push(i); }
  let s: number = Date.now() % 10007;
  for (let i: number = dk.length - 1; i > 0; i--) {
    s = (s * 1103515245 + 12345) % 2147483648;
    const j: number = s % (i + 1);
    const t: number = dk[i]; dk[i] = dk[j]; dk[j] = t;
  }
  this.tarotIdx = [dk[0], dk[1], dk[2]];
  this.tarotOpen = [false, false, false];
  this.dealt = false;
}

这是经典的 Fisher-Yates 洗牌算法,时间复杂度 O(n),空间复杂度 O(1)。

为什么不用 Math.random()?

和转盘一样,用日期种子保证每天的洗牌结果固定。但这里用了一个简单的线性同余生成器(LCG),而不是 Math.random(),原因是:

  1. 可重现:同一天运行多次,结果相同
  2. 性能:LCG 比 Math.random() 快
  3. 可控:可以调整参数控制随机性

踩坑记录

在开发过程中,遇到了几个值得注意的问题:

坑 1:Stack 的坐标系

Stack 组件的坐标原点在左上角,而不是中心。如果要让星座图标围绕中心旋转,需要手动计算偏移量。代码中 145 - 28 就是为了把坐标原点移到中心。

坑 2:animation 的触发时机

.animation() 只在属性值变化时触发。如果属性值不变,动画不会播放。比如转盘停止后,wheelAngle 不再变化,即使再次点击"停止"按钮,也不会有动画效果。

坑 3:数组状态的更新

直接修改 @State 数组的某个元素不会触发重新渲染。必须创建新数组(用展开运算符或 slice()),框架才能检测到变化。这是 ArkTS 和 React 的一个重要区别。

坑 4:Swiper 的性能

Swiper 组件会预加载相邻页面,如果页面很复杂,可能导致内存占用过高。可以通过 .cachedCount(1) 控制预加载数量,但会影响切换流畅度。

代码改进建议

虽然这个应用功能完整,但还有一些可以优化的地方:

1. 组件拆分

把三个页面拆分成独立的组件:

@Component
struct WheelPage { ... }

@Component
struct StarPage { ... }

@Component
struct TarotPage { ... }

这样每个组件职责单一,也方便独立测试。

2. 数据分离

把星座数据和塔罗数据移到独立的文件中:

// data/Signs.ts
export const SIGNS: SignData[] = [ ... ];

// data/Tarot.ts
export const TAROT: TarotInfo[] = [ ... ];

这样主页面更简洁,数据也更容易维护。

3. 主题系统

当前颜色值散落在代码各处。建议像骰子应用一样,用主题系统统一管理颜色。

4. 性能优化

转盘的旋转动画每帧都会重新渲染 12 个星座图标。如果性能有问题,可以用 Canvas 组件来绘制转盘,减少 UI 层级。

总结

这个星空运势应用虽然功能简单,但涉及了 ArkUI 开发的多个核心知识点:Swiper 多页导航、Stack 坐标布局、旋转动画、Grid 网格布局、卡牌翻转效果、Fisher-Yates 洗牌算法。在实际开发中,这些知识点会反复出现,只是复杂度不同。

适用边界:这个应用适合用作 ArkUI 复合动画和复杂布局的学习案例,涵盖了 Swiper、Stack、Grid、旋转动画、坐标计算等核心知识点。但如果要上架应用商店,还需要补充性能优化、组件拆分、主题系统、错误处理、无障碍支持等内容。建议在此基础上逐步扩展,而不是一次性做完所有功能。

对于 ArkTS 新手,建议从类似的小项目入手,逐步理解框架的设计哲学。ArkTS 的声明式 UI 和 React 有相似之处,但状态管理和生命周期有明显区别,需要花时间适应。

对于有经验的开发者,重点是理解 ArkTS 的约束——它不是 TypeScript 的简单扩展,而是一个有自己规则的框架。遵循框架的最佳实践,才能写出可维护、高性能的代码。

Logo

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

更多推荐