在这里插入图片描述
在这里插入图片描述

一、引言

鸿蒙操作系统(HarmonyOS)自诞生以来,始终致力于构建面向全场景的分布式操作系统。随着 HarmonyOS NEXT 的发布,系统去除了 AOSP 兼容层,实现了完全的鸿蒙内核底座,这意味着开发者需要以纯正的 ArkTS 语言和 ArkUI 框架进行原生应用开发。在此背景下,深入理解 ArkTS 的声明式 UI 布局体系,成为每一位鸿蒙开发者必须掌握的核心技能。

本文将以一个真实的英语学习辅助应用项目为例,从项目架构、布局体系、组件设计、游戏交互到数据算法,全方位剖析 HarmonyOS NEXT 6.1.1(API 24)环境下的 ArkTS 开发实践。项目代码可在 DevEco Studio 中直接编译运行,所有示例均采用 Stage 模型与 ArkTS 语言,帮助开发者从零掌握鸿蒙原生应用的核心开发范式。

本文涵盖以下核心内容:

  • 项目整体架构与目录规划
  • ArkUI 布局体系深度解析(Column、Row、Stack、FlexAlign、layoutWeight)
  • 可复用组件设计模式(Card、ProgressRing、ModuleEntryCard)
  • Canvas 2D 游戏引擎实现(跑酷与光点追逐)
  • SM-2 间隔重复算法在鸿蒙中的实现
  • 工程化最佳实践与调试技巧

二、项目概览与架构设计

2.1 技术栈与开发环境

项目 版本/规格
操作系统 HarmonyOS NEXT
SDK 版本 6.1.1(API 24)
开发工具 DevEco Studio
编程语言 ArkTS(声明式 UI)
应用模型 Stage 模型
目标设备 Phone
编译模式 Stage Mode

2.2 项目目录结构

项目采用了模块化的目录组织结构,将所有源代码放置于 entry/src/main/ets/ 目录下,按照功能分目录管理:

entry/src/main/ets/
├── entryability/           # Ability 入口
│   └── EntryAbility.ets
├── pages/                  # 页面级组件
│   ├── Index.ets           # Column + Start 演示
│   ├── RowSpaceEvenlyDemo.ets  # Row + SpaceEvenly 演示
│   ├── RunnerPage.ets      # 跑酷游戏 + layoutWeight
│   └── LightChaseGame.ets  # 光点追逐游戏
├── components/             # 可复用组件
│   └── CommonComponents.ets
└── model/                  # 数据模型与算法
    ├── AppModel.ets
    ├── SampleData.ets
    └── SpacedRepetition.ets

这种分层架构体现了良好的关注点分离原则:

  • entryability:应用入口层,管理 Ability 生命周期与页面路由
  • pages:页面层,每个页面是一个独立的 @Entry @Component,负责完整的页面 UI 与交互
  • components:组件层,封装可复用的 UI 组件,供页面或其他组件调用
  • model:数据层,定义数据结构、枚举、接口以及业务算法(如间隔重复引擎)

2.3 应用入口设计

应用的入口文件 EntryAbility.ets 采用 Stage 模型的生命周期管理方式。Stage 模型是 HarmonyOS NEXT 推荐的 Ability 框架,它将 Ability 分为 EntryAbility 和 ExtensionAbility,每个 Ability 拥有独立的窗口和生命周期。

import { AbilityConstant, UIAbility, Want } from '@kit.AbilityKit';
import { hilog } from '@kit.PerformanceAnalysisKit';
import { window } from '@kit.ArkUI';

const DOMAIN = 0x0000;

export default class EntryAbility extends UIAbility {
  onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void {
    hilog.info(DOMAIN, 'EnglishApp', 'Ability onCreate');
  }

  onDestroy(): void {
    hilog.info(DOMAIN, 'EnglishApp', 'Ability onDestroy');
  }

  onWindowStageCreate(windowStage: window.WindowStage): void {
    hilog.info(DOMAIN, 'EnglishApp', 'Ability onWindowStageCreate');
    windowStage.loadContent('pages/RowSpaceEvenlyDemo', (err) => {
      if (err.code) {
        hilog.error(DOMAIN, 'EnglishApp', 'Failed to load content: %{public}s', JSON.stringify(err));
        return;
      }
      hilog.info(DOMAIN, 'EnglishApp', 'Succeeded in loading content.');
    });
  }

  onWindowStageDestroy(): void {
    hilog.info(DOMAIN, 'EnglishApp', 'Ability onWindowStageDestroy');
  }

  onForeground(): void {
    hilog.info(DOMAIN, 'EnglishApp', 'Ability onForeground');
  }

  onBackground(): void {
    hilog.info(DOMAIN, 'EnglishApp', 'Ability onBackground');
  }
}

其中最关键的便是 onWindowStageCreate 方法中的 windowStage.loadContent() 调用,它决定了应用启动后首屏加载的页面。通过更换参数 'pages/RowSpaceEvenlyDemo' 即可在同一应用内切换不同的演示页。

hilog 日志系统在此处扮演了关键角色:hilog.info(0x0000, TAG, 'message') 是鸿蒙原生的日志输出 API,相比 console.log,hilog 支持按域(domain)和标签(tag)过滤日志,在生产环境下的调试效率更高。

三、ArkUI 布局体系深度解析

ArkUI 的布局体系基于 Flexbox 弹性盒模型,核心容器为 Column(垂直排列)和 Row(水平排列)。理解主轴(Main Axis)与交叉轴(Cross Axis)是掌握布局的关键前提。

3.1 主轴与交叉轴

在 ArkUI 中,每个容器都有一个主轴和一个交叉轴:

容器 主轴方向 交叉轴方向 justifyContent 作用 alignItems 作用
Column 垂直(从上到下) 水平(从左到右) 垂直方向排列方式 水平方向对齐方式
Row 水平(从左到右) 垂直(从上到下) 水平方向排列方式 垂直方向对齐方式

项目的 build-profile.json5 确认了目标 SDK 版本为 6.1.1(API 24),这是 HarmonyOS NEXT 的重要版本,Stage 模型下的布局 API 成熟稳定。

3.2 Row + SpaceEvenly 均分布局(核心示例)

RowSpaceEvenlyDemo.ets 是本项目最新创建的页面,专门演示 Row + justifyContent(FlexAlign.SpaceEvenly) 的均分布局效果。这一布局技巧在实际项目中应用广泛,尤其适合顶部操作工具栏、底部导航栏等需要按钮均匀排列的场景。

3.2.1 布局效果说明

SpaceEvenly(均匀分布)的核心特点在于:第一个子组件前侧的间距、相邻子组件之间的间距、最后一个子组件后侧的间距完全相等。这与 SpaceBetween(首尾贴边)和 SpaceAround(两侧半间距)形成鲜明对比。

假设 Row 容器总宽度为 W,容器内有 N 个子组件,每个子组件的自然宽度为它的内容宽度,则:

  • SpaceBetween:首尾子组件紧贴容器边缘,中间 N-1 个间距相等。每个间距 = (W - 所有子组件宽度之和) / (N - 1)
  • SpaceAround:每个子组件两侧的间距相等,但首尾子组件的外侧间距仅为中间间距的一半。每个子组件两侧间距 = (W - 所有子组件宽度之和) / N,首尾外侧间距 = 此值的一半
  • SpaceEvenly:所有间距完全相等。间距数 = N + 1(首、中间、尾一共 N+1 个间距),每个间距 = (W - 所有子组件宽度之和) / (N + 1)
3.2.2 完整代码实现
/**
 * 鸿蒙原生 ArkTS 布局示例 — Row + justifyContent(SpaceEvenly)
 * 功能:演示 Row 主轴(水平方向)均分布局
 * 子组件在容器中均匀排列,两端的间距与中间间距相等
 * 场景:顶部操作栏 / 底部导航栏 / 工具栏 / 菜单栏
 * 核心技术:
 *   - Row 容器(主轴:水平方向)
 *   - justifyContent(FlexAlign.SpaceEvenly) — 子组件在主轴均匀分布
 *   - alignItems(VerticalAlign.Center) — 子组件在交叉轴(垂直)居中
 */

import { hilog } from '@kit.PerformanceAnalysisKit';

const TAG = 'RowSpaceEvenlyDemo';

首先导入 hilog 日志工具。在鸿蒙开发中,@ohos.hilog 已被整合为 @kit.PerformanceAnalysisKit 下的 hilog,这是 API 24 版本的变化——所有 Kit 化的 API 都按照领域进行分组管理。

接下来定义三个子组件:

工具栏图标按钮(ToolbarItem)

@Component
struct ToolbarItem {
  private icon: string = '';
  private label: string = '';
  private onClickAction: () => void = () => {};

  build() {
    Column() {
      // 图标区:圆形背景 + Emoji
      Text(this.icon)
        .fontSize(28)
        .width(52)
        .height(52)
        .textAlign(TextAlign.Center)
        .backgroundColor('#f5f7fa')
        .borderRadius(26)
        .shadow({ radius: 3, color: '#15000000', offsetX: 0, offsetY: 2 })

      // 文字标签
      Text(this.label)
        .fontSize(12)
        .fontColor('#555555')
        .margin({ top: 6 })
        .lineHeight(16)
    }
    .alignItems(HorizontalAlign.Center)
    .onClick(() => {
      hilog.info(0x0000, TAG, `ToolbarItem clicked: ${this.label}`);
      this.onClickAction();
    })
  }
}

设计要点

  • 使用 borderRadius(26) 配合固定的 width(52) / height(52) 实现圆形背景,这是 ArkUI 中制作圆形的标准做法——圆角半径 = 宽度一半
  • shadow 属性为图标添加阴影增强立体感,#15000000 表示 RGBA 颜色模式下的半透明黑色阴影
  • 每个 ToolbarItem 内部使用 Column 纵向排列图标和文字,外部 Row 容器负责水平均分布局

竖直分隔线(ToolbarDivider)

@Component
struct ToolbarDivider {
  build() {
    Column()
      .width(1)
      .height(36)
      .backgroundColor('#e0e0e0')
  }
}

这里使用了一个宽度为 1px 的 Column 模拟竖线。ArkUI 的 Divider 组件默认是水平方向的,如果需要竖直分割线,用这种窄 Column 实现是最简洁的方案。

功能面板(FeaturePanel)

@Component
struct FeaturePanel {
  private activeFeature: string = '';
  private onClose: () => void = () => {};

  build() {
    Column() {
      Row() {
        Text(this.activeFeature)
          .fontSize(16)
          .fontWeight(FontWeight.Bold)
          .fontColor('#2d5f8a')

        Blank()  // 弹性占位

        Text('✕')
          .fontSize(18)
          .fontColor('#999999')
          .onClick(() => { this.onClose(); })
      }
      .width('100%')
      .alignItems(VerticalAlign.Center)

      Divider()
        .height(1)
        .width('100%')
        .color('#e8e8e8')
        .margin({ top: 10, bottom: 14 })

      Text(this.getFeatureDescription())
        .fontSize(14)
        .fontColor('#666666')
        .lineHeight(22)
    }
    .width('100%')
    .padding(16)
    .backgroundColor('#ffffff')
    .borderRadius(12)
    .shadow({ radius: 8, color: '#1a000000', offsetX: 0, offsetY: 4 })
  }

  private getFeatureDescription(): string {
    const descriptions: Record<string, string> = {
      '📁 文件': '点击此按钮可打开文件管理器...',
      '✏️ 编辑': '进入编辑模式...',
      '📤 分享': '将当前内容通过系统分享面板...',
      '⭐ 收藏': '将当前内容添加到收藏夹...',
      '⚙️ 设置': '打开应用设置页面...',
    };
    return descriptions[this.activeFeature] || '此功能待开发,敬请期待。';
  }
}

设计要点

  • Blank() 组件是 ArkUI 中的弹性空白占位符,它会自动占据 Row 容器中的剩余空间,从而将关闭按钮推到右侧
  • Record<string, string> 是 ArkTS 中的索引签名类型,用于存储键值对映射
  • FeaturePanel 本身是可交互的,点击关闭按钮触发 onClose 回调,体现了 ArkTS 组件化设计中的回调传递模式
3.2.3 主页面核心实现
@Entry
@Component
struct RowSpaceEvenlyPage {
  @State activeFeature: string = '';
  @State isPanelVisible: boolean = false;

  private readonly toolbarItems: ToolbarItemData[] = [
    { icon: '📁', label: '文件' },
    { icon: '✏️', label: '编辑' },
    { icon: '📤', label: '分享' },
    { icon: '⭐', label: '收藏' },
    { icon: '⚙️', label: '设置' },
  ];

  build() {
    Column() {
      // ===== 区域1:页面标题区 =====
      Column() {
        Text('📐 Row + justifyContent(SpaceEvenly)')
          .fontSize(20).fontWeight(FontWeight.Bold).fontColor('#ffffff')
        Text('水平均分布局 · 工具栏按钮均匀排列(首尾间距 = 中间间距)')
          .fontSize(12).fontColor('#cce0ff').margin({ top: 4 })
      }
      .alignItems(HorizontalAlign.Start)
      .width('100%')
      .padding({ top: 20, bottom: 16, left: 20, right: 20 })
      .backgroundColor('#2d5f8a')

      // ===== 区域2:核心演示区 =====
      Column() {
        Text('📋 顶部操作工具栏(均分布局)')
          .fontSize(15).fontWeight(FontWeight.Bold).fontColor('#1a1a2e')
          .margin({ bottom: 14 })

        // ★★★ 核心:Row + SpaceEvenly ★★★
        Row() {
          ForEach(this.toolbarItems, (item: ToolbarItemData, idx: number) => {
            ToolbarItem({
              icon: item.icon,
              label: item.label,
              onClickAction: () => { this.onToolbarItemClick(item.label); }
            })
            if (idx < this.toolbarItems.length - 1) {
              ToolbarDivider()
            }
          })
        }
        .justifyContent(FlexAlign.SpaceEvenly)   // ← 核心属性
        .alignItems(VerticalAlign.Center)        // ← 垂直居中
        .width('100%')
        .height(80)
        .padding({ left: 4, right: 4 })
        .backgroundColor('#ffffff')
        .borderRadius(14)
        .shadow({ radius: 6, color: '#1a000000', offsetX: 0, offsetY: 2 })
      }
      // ... 面板和说明区域
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#eef2f7')
  }

  private onToolbarItemClick(label: string): void {
    this.activeFeature = label;
    this.isPanelVisible = true;
  }

  private closePanel(): void {
    this.isPanelVisible = false;
    this.activeFeature = '';
  }
}

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

SpaceEvenly 核心代码仅需两行链式调用:

.justifyContent(FlexAlign.SpaceEvenly)   // 水平方向均匀分布
.alignItems(VerticalAlign.Center)        // 垂直方向居中

justifyContent 控制主轴(Row 的主轴是水平方向)的排列方式,alignItems 控制交叉轴(Row 的交叉轴是垂直方向)的对齐方式。这两个属性是 ArkUI 弹性盒模型的核心,理解它们的区别是正确使用布局的关键。

ArkTS 链式调用说明:在 ArkUI 的声明式 UI 中,组件的属性设置通过链式调用 .属性名(参数) 的方式完成。这种语法源自 TypeScript 的 Builder 模式,每个属性方法返回组件实例本身,因此可以无限串联。注意与 HTML/CSS 的属性写法的区别——在 ArkUI 中,所有属性都是方法调用,而非键值对赋值。

3.3 Column + FlexAlign.Start 顶部起始布局

Index.ets 页面演示了 Column 容器的 justifyContent(FlexAlign.Start) 布局。这一布局模式适用于信息流列表、消息中心、设置页等需要内容从顶部开始紧凑排列的场景。

3.3.1 核心实现逻辑
@Entry
@Component
struct ColumnStartPage {
  private readonly infoList: InfoItem[] = [
    { title: '📌 系统通知', desc: '您的鸿蒙应用已通过安全检测...' },
    { title: '📊 数据报告', desc: '本周活跃用户较上周增长 12%...' },
    { title: '⚙️ 版本更新', desc: 'v3.2.0 发布:新增 ColumnStart 布局组件...' },
    { title: '🎯 优化建议', desc: '检测到 3 处可优化项...' },
  ];

  build() {
    Column() {
      // 标题区(固定高度)
      // 核心演示区
      Column() {
        // 信息卡片通过 ForEach 渲染
        ForEach(this.infoList, (item: InfoItem, idx: number) => {
          InfoCard({ item: item, index: idx })
        }, (item: InfoItem) => item.title)
      }
      .alignItems(HorizontalAlign.Start)
      .justifyContent(FlexAlign.Start)   // ★ 核心:从顶部开始排列
      .width('100%')
      .height(0)
      .layoutWeight(1)                   // 占满剩余空间
      .backgroundColor('#ffffff')
    }
    .width('100%')
    .height('100%')
  }
}
3.3.2 layoutWeight 与 justifyContent 的协同

此处值得关注的是 layoutWeight(1)justifyContent(FlexAlign.Start) 的组合效果:

  • layoutWeight(1) 使 Column 占满父容器中未被固定高度组件占据的剩余空间
  • justifyContent(FlexAlign.Start) 确保柱内所有子组件从顶部开始排列,底部留空

这种组合在需要"内容紧凑于顶部,底部留空"的场景中非常实用,例如空白状态页、信息流页面加载更多时的底部留白。

layoutWeight 的底层逻辑:当 Column 设置了 height('100%') 且包含多个子组件时,固定高度的子组件(通过 .height() 指定)优先占据空间,剩余空间按照各子组件的 layoutWeight 值的比例分配。如果某个子组件未设置 layoutWeight,它的大小由内容撑开。如果一个 Column 中的所有子组件都未设置 layoutWeight,则每个子组件按内容自然高度排列,justifyContent 决定它们在主轴上的分布方式。

3.4 JustifyContent 五种取值对比

ArkUI 的 FlexAlign 枚举提供了五种主轴排列方式,它们在 Row 和 Column 中的表现对应如下:

FlexAlign 取值 Row(水平方向)效果 Column(垂直方向)效果
Start 子组件靠左排列 子组件靠顶排列
Center 子组件水平居中 子组件垂直居中
End 子组件靠右排列 子组件靠底排列
SpaceBetween 首尾贴边,中间等距 首尾贴顶底,中间等距
SpaceAround 两侧半间距,中间等距 上下半间距,中间等距
SpaceEvenly 所有间距完全相等 所有间距完全相等

这些取值的间距计算方式可以用下面的公式来记忆(N 为子组件数量):

  • SpaceBetween:间距数 = N - 1,首尾无外侧间距
  • SpaceAround:间距数 = 2N,每个子组件两侧间距相等但首尾外侧 = 内侧的一半
  • SpaceEvenly:间距数 = N + 1,包含首尾外侧

四、弹性布局与游戏开发实战

RunnerPage.ets 是项目中布局技术最为复杂的页面,它是 Column + layoutWeight 弹性布局的经典案例,同时也融合了 Canvas 2D 图形绘制、物理引擎模拟、帧循环动画等技术。

4.1 弹性布局架构

Column() {                          // 全屏容器
  ┌──────────────────────────────┐
  │  固定顶部(height: 50)       │  ← 固定高度
  │  🏃 单键跑酷  得分: 12      │
  ├──────────────────────────────┤
  │  ★ layoutWeight(1.0)         │  ← 弹性区 A(50%)
  │     游戏主场景(Canvas)      │
  ├──────────────────────────────┤
  │  ★ layoutWeight(0.3)         │  ← 弹性区 B(15%)
  │     游戏状态信息区            │
  ├──────────────────────────────┤
  │  ★ layoutWeight(0.7)         │  ← 弹性区 C(35%)
  │     【🦘 跳跃!】按钮         │
  └──────────────────────────────┘
}

弹性区的高度计算公式为:

弹性区高度 = (Column总高度 - 固定区高度之和) × 该区layoutWeight / 所有弹性区layoutWeight之和

在此示例中:

  • Column 总高度 = 屏幕高度(100%)
  • 固定区高度 = 50px(顶部状态栏)+ 底部说明面板(内容撑高)
  • 三个弹性区 layoutWeight 之和 = 1.0 + 0.3 + 0.7 = 2.0
  • 弹性区 A 的高度占比 = 1.0 / 2.0 = 50%
  • 弹性区 B 的高度占比 = 0.3 / 2.0 = 15%
  • 弹性区 C 的高度占比 = 0.7 / 2.0 = 35%

使用 layoutWeight 时必须注意:父容器(Column)必须设置 .height('100%') 或固定的高度值,否则 layoutWeight 无法计算剩余空间,会导致布局异常。这是 ArkTS 开发中常见的踩坑点。

4.2 Canvas 游戏场景实现

RunnerPage 使用 Canvas 组件 + CanvasRenderingContext2D 实现游戏画面的实时绘制。这是一种轻量级的游戏渲染方案,不需要引入额外的游戏引擎。

4.2.1 Canvas 初始化与帧循环
private ctx: CanvasRenderingContext2D = new CanvasRenderingContext2D();

// 在 build 中使用 Canvas
Canvas(this.ctx)
  .width('100%')
  .height('100%')
  .onReady(() => {
    this.canvasW = this.ctx.width;
    this.canvasH = this.ctx.height;
    this.drawScene();  // 绘制首帧
  })

// 游戏循环驱动
this.timerId = setInterval(() => {
  this.gameLoop();
}, 24);  // 约 42 FPS

关键要点

  • CanvasRenderingContext2D 必须通过构造函数传入 Canvas 组件,不能通过内部 getContext 获取
  • onReady 回调是 Canvas 组件就绪的信号,此时才能获取实际的 canvas 宽高
  • setInterval 实现固定帧率的游戏循环,每帧约 24ms ≈ 42 FPS
4.2.2 物理引擎模拟

游戏的物理系统模拟了重力、跳跃和地面碰撞:

// 物理常量
const GRAVITY: number = 0.55;     // 重力加速度
const JUMP_VEL: number = -9.0;    // 跳跃初速度(负值=向上)

// 物理更新(每帧执行)
private gameLoop(): void {
  // 1. 重力作用
  this.playerVY = this.playerVY + GRAVITY;
  this.playerY = this.playerY + this.playerVY;

  // 2. 地面碰撞检测
  if (this.playerY >= 0) {
    this.playerY = 0;
    this.playerVY = 0;
  }

  // 3. 障碍物移动(速度随分数递增)
  this.curSpeed = BASE_SPEED + this.score * SPEED_STEP;
  // ...
}

物理运动公式

  • 速度:v(t) = v(0) + g × t
  • 位移:y(t) = y(0) + v(0) × t + 0.5 × g × t²
  • 在离散的帧循环中,近似为:v += g; y += v;
4.2.3 Canvas 绘制管线

drawScene 方法实现了完整的游戏画面绘制管线,按照从后到前的顺序绘制各层:

private drawScene(): void {
  // 1. 清空画布
  ctx.clearRect(0, 0, w, h);

  // 2. 绘制天空渐变背景
  const grad = ctx.createLinearGradient(0, 0, 0, h);
  grad.addColorStop(0, '#87CEEB');
  grad.addColorStop(0.7, '#E0F7FA');
  grad.addColorStop(1, '#A5D6A7');
  ctx.fillStyle = grad;
  ctx.fillRect(0, 0, w, h);

  // 3. 绘制地面
  ctx.fillStyle = '#5D4037';
  ctx.fillRect(0, groundY, w, h - groundY);

  // 4. 绘制障碍物
  for (let i = 0; i < this.obstacles.length; i++) {
    // 障碍物主体、边框、X装饰...
  }

  // 5. 绘制角色
  // 身体(圆角矩形)、眼睛、瞳孔、微笑...

  // 6. 绘制 UI 叠加层(游戏结束/READY状态)
}

绘制层级顺序是 Canvas 渲染的核心概念——后绘制的图形覆盖在先绘制的图形之上。Pipeline 的绘制顺序(从底到顶)为:天空背景 → 地面 → 草地边沿 → 地面纹理 → 障碍物 → 角色 → UI 覆盖层。

4.3 游戏交互设计

RunnerPage 采用"单键操作"设计理念,整个游戏仅需一个按钮即可完成所有交互:

private doJump(): void {
  if (this.gameState === GameState.READY || this.gameState === GameState.OVER) {
    this.startGame();        // 开始/重新开始
    return;
  }
  if (this.gameState === GameState.PLAYING) {
    if (this.playerY >= -2) {  // 只有在接近地面时才能跳跃
      this.playerVY = JUMP_VEL;
    }
  }
}

跳跃限制条件 this.playerY >= -2 防止了"空中二段跳",这是平台跳跃游戏中的常见设计,避免玩家重复跳跃影响游戏体验。

4.4 光点追逐游戏(LightChaseGame)

LightChaseGame.ets 是本项目中的第二个完整游戏,与 RunnerPage 不同,它采用了 Stack 布局 + @State 响应式状态管理的方式,完全不使用 Canvas,完全依靠 ArkUI 原生组件实现游戏逻辑。

游戏核心机制

  • 光点随机出现在屏幕上,玩家需要在限定时间内点击光点得分
  • 难度动态递增:得分越高,光点越小、移动越快
  • 连击系统:连续点击光点获得连击奖励提示
  • 30 秒限时,结束后根据分数评级
// 难度递增逻辑
const difficulty = Math.min(this.score / 20, 1);
this.dotSize = 48 - difficulty * 20;          // 48 → 28 逐渐缩小

const hue = (this.score * 30) % 360;
this.dotColor = `hsl(${hue}, 100%, 65%)`;     // 颜色随分数变化

const interval = Math.max(MIN_INTERVAL, BASE_INTERVAL - this.score * 30);
// 移动间隔从 1200ms 逐渐缩短至 300ms

hsl() 颜色函数是此处的亮点——通过改变色相(Hue)值,光点颜色随着分数增加而循环变化,从黄色渐变到红、蓝、绿等各种颜色。这是一种无需额外资源即可实现视觉多样性的技巧。

@State 的响应式原理:在 LightChase 中,所有需要触发 UI 重新渲染的变量都标注了 @State,例如 scoretimeLeftdotXdotY。当这些变量的值发生改变时,ArkUI 框架会自动重新调用 build() 方法生成新的 UI 树,并通过 Diff 算法高效地更新实际的渲染节点。而对于不需要触发 UI 重绘的内部状态(如 timerIdmoveTimerId),则不使用 @State,避免不必要的性能开销。

五、通用组件设计模式

CommonComponents.ets 是项目的组件库,包含了四个可复用的 UI 组件。在 ArkTS 中,组件的复用主要有两种方式:@Component 装饰的结构体组件和 @Builder 装饰的构建函数。

5.1 卡片容器组件(Card)

@Component
export struct Card {
  @Prop cardPadding: number = 16;
  @Prop cardMargin: number = 12;
  @Prop cardColor: string = '#ffffff';
  @Prop cardRadius: number = 16;
  @BuilderParam content: () => void = this.defaultContent;

  @Builder
  defaultContent(): void {
    Text('卡片内容').fontSize(14).fontColor('#888')
  }

  build() {
    Column() {
      this.content()  // 插槽:注入自定义内容
    }
    .width('100%')
    .padding(this.cardPadding)
    .backgroundColor(this.cardColor)
    .borderRadius(this.cardRadius)
    .margin({ bottom: this.cardMargin })
    .shadow({ radius: 4, color: '#1a000000', offsetX: 0, offsetY: 2 })
  }
}

@BuilderParam 插槽机制:ArkTS 的自定义组件不支持 Vue 或 React 中的默认 children / slot 机制,因此要实现"容器组件"模式,必须使用 @BuilderParam 装饰器。父组件在使用 Card 时,通过 content 属性传入一个 @Builder 构建函数:

Card({ cardPadding: 20 }) {
  Column() {
    Text('自定义卡片内容')
    // 任意子组件...
  }
}

@Prop 装饰器:用于从父组件接收单向数据。当父组件的属性值发生变化时,子组件会自动更新。@Prop 装饰的变量必须有默认值,且是单向绑定(父 → 子)。

5.2 Canvas 圆形进度条(ProgressRing)

ProgressRing 组件使用两个 Canvas 叠加在同一位置,分别绘制背景圆环和进度圆环:

@Component
export struct ProgressRing {
  @Prop ringProgress: number = 0;      // 进度值(0-100)
  @Prop ringSize: number = 80;
  @Prop ringColor: string = '#3a7bd5';
  @Prop ringBgColor: string = '#e8ecf0';

  build() {
    Column() {
      Stack() {
        // 背景圆环
        Canvas(this.ringContext)
          .width(this.ringSize).height(this.ringSize)
          .onReady(() => { this.drawRing(this.ringContext, 100, true); })

        // 进度圆环
        Canvas(this.progressContext)
          .width(this.ringSize).height(this.ringSize)
          .onReady(() => { this.drawRing(this.progressContext, this.ringProgress, false); })

        // 中央文字
        Column() {
          Text(this.ringValue).fontSize(16).fontWeight(FontWeight.Bold)
          if (this.ringLabel.length > 0) {
            Text(this.ringLabel).fontSize(10).fontColor('#888')
          }
        }
      }
    }
  }

  drawRing(ctx: CanvasRenderingContext2D, value: number, isBg: boolean): void {
    const startAngle = -Math.PI / 2;    // 从12点钟方向开始
    const endAngle = startAngle + (value / 100) * 2 * Math.PI;
    ctx.beginPath();
    ctx.arc(cx, cy, r, startAngle, endAngle);
    ctx.strokeStyle = isBg ? this.ringBgColor : this.ringColor;
    ctx.lineWidth = stroke;
    ctx.lineCap = 'round';             // 圆角端点
    ctx.stroke();
  }
}

Stack 布局的层叠特性:Stack 容器允许子组件在 Z 轴上重叠排列,后声明的子组件在前。这里利用 Stack 将背景圆环、进度圆环、中央文字三层叠在同一位置,实现了复合视觉组件。

两个独立的 Canvas 上下文:由于 ArkUI 的 Canvas 不支持在同一个上下文中同时维护两个独立的绘制状态(drawRing 会覆盖之前的绘制结果),因此需要创建两个 CanvasRenderingContext2D 实例,分别用于背景和进度。

5.3 模块入口卡片(ModuleEntryCard)

@Component
export struct ModuleEntryCard {
  @Prop entryIcon: string = '';
  @Prop entryLabel: string = '';
  @Prop entryColor: string = '#3a7bd5';
  onClickAction: () => void = () => {};

  build() {
    Column() {
      Text(this.entryIcon).fontSize(32).margin({ bottom: 8 })
      Text(this.entryLabel).fontSize(13).fontColor('#333')
    }
    .width('30%')
    .aspectRatio(1.0)                    // 1:1 正方形比例
    .justifyContent(FlexAlign.Center)
    .backgroundColor((this.entryColor + '18'))  // 添加 18 十六进制透明度
    .borderRadius(16)
    .onClick(() => { this.onClickAction(); })
  }
}

aspectRatio(1.0):这是一个非常实用的属性,强制组件的宽高比为 1:1。在网格布局中,结合 .width('30%') 可以保证所有卡片无论内容多少都保持相同的正方形比例,实现整齐的网格排列。

颜色透明度技巧this.entryColor + '18' 通过字符串拼接的方式在颜色十六进制值后追加透明度通道(18 十六进制 ≈ 9% 透明度),这是一种轻量级实现半透明背景的方式,无需调用 Color.hexToRgba 等转换方法。

5.4 顶部标题栏(AppHeader)

@Component
export struct AppHeader {
  @Prop headerTitle: string = '';
  @Prop headerSubtitle: string = '';
  @Prop showBack: boolean = false;
  onBack: () => void = () => {};

  build() {
    Row() {
      if (this.showBack) {
        Text('←').fontSize(22).fontColor('#ffffff')
          .onClick(() => { this.onBack(); })
          .margin({ right: 8 })
      }
      Column() {
        Text(this.headerTitle).fontSize(20).fontWeight(FontWeight.Bold)
          .fontColor('#ffffff')
        if (this.headerSubtitle.length > 0) {
          Text(this.headerSubtitle).fontSize(12)
            .fontColor('#cce0ff').margin({ top: 2 })
        }
      }
      .alignItems(HorizontalAlign.Start)
      Blank()  // 将内容推到左侧
    }
    .width('100%')
    .padding({ top: 12, bottom: 12, left: 20, right: 20 })
    .backgroundColor('#2d5f8a')
  }
}

showBack 条件渲染:通过 @Prop showBack 控制返回按钮的显示与隐藏,这是一个典型的组件配置模式。父组件通过设置 showBack: true 开启返回功能,并传入 onBack 回调处理返回事件。

六、数据模型与算法实现

6.1 数据结构定义

AppModel.ets 使用 ArkTS 的 interfaceenum 定义了完整的数据模型体系:

/** 难度等级 */
export enum Difficulty {
  EASY = 1,
  MEDIUM = 2,
  HARD = 3,
}

/** 单词条目 */
export interface WordItem {
  id: number;
  word: string;
  phonetic: string;
  translation: string;
  partOfSpeech: string;
  exampleSentence: string;
  exampleTranslation: string;
  difficulty: Difficulty;
  category: string;
  audioPath: string;
}

/** 学习记录 */
export interface StudyRecord {
  wordId: number;
  reviewCount: number;
  correctCount: number;
  lastReviewTime: string;
  masteryLevel: number;   // 0.0 ~ 1.0
}

此外还包括听力材料(ListeningMaterial)、阅读文章(ReadingArticle)、语法练习(GrammarExercise)等数据模型,覆盖了英语学习应用所需的全部数据结构。

6.2 间隔重复算法(SM-2)

SpacedRepetition.ets 实现了经典的 SM-2 间隔重复算法,这是 SuperMemo 2 的核心算法,广泛应用于 Anki、Mnemosyne 等记忆类应用。

算法核心参数

  • 易度系数(EF, Ease Factor):初始值 2.5,范围 [1.3, +∞),反映单词的"易学程度"
  • 连续正确次数(Repetition):连续正确回答的次数
  • 回答质量(Quality):0~5 的评分,0 = 完全忘记,5 = 完全正确
export class SpacedRepetitionEngine {
  private static readonly DEFAULT_EF = 2.5;
  private static readonly MIN_EF = 1.3;
  private static readonly MAX_INTERVAL = 180;

  static schedule(
    quality: number,
    previousInterval: number,
    repetition: number,
    previousEf: number = SpacedRepetitionEngine.DEFAULT_EF,
  ): ReviewResult {
    // 质量 < 3:回答不合格,重置进度
    if (quality < 3) {
      return {
        nextInterval: 1,               // 明天就复习
        newRepetition: 0,              // 连续正确次数归零
        newEf: SpacedRepetitionEngine.updateEf(previousEf, quality),
        nextReview: new Date(Date.now() + 86400000),
      };
    }

    const newEf = SpacedRepetitionEngine.updateEf(previousEf, quality);

    // 计算间隔:首次1天,二次3天,之后按 EF 指数增长
    let nextInterval: number;
    if (repetition === 0) {
      nextInterval = 1;
    } else if (repetition === 1) {
      nextInterval = 3;
    } else {
      nextInterval = Math.round(previousInterval * newEf);
    }

    nextInterval = Math.min(nextInterval, SpacedRepetitionEngine.MAX_INTERVAL);

    return {
      nextInterval,
      newRepetition: repetition + 1,
      newEf,
      nextReview: new Date(Date.now() + nextInterval * 86400000),
    };
  }

  /** EF 更新公式:EF' = EF + (0.1 - (5-Q) × (0.08 + (5-Q) × 0.02)) */
  private static updateEf(oldEf: number, quality: number): number {
    const newEf = oldEf + (0.1 - (5 - quality) * (0.08 + (5 - quality) * 0.02));
    return Math.max(newEf, SpacedRepetitionEngine.MIN_EF);
  }
}

SM-2 算法的关键特点

  1. 针对单个单词灵活调整:每个单词独立维护自己的 EF 和 Repetition 值,算法根据每次复习的实际表现动态调整。

  2. 指数增长的复习间隔:当用户连续正确回答时,复习间隔会从 1 天 → 3 天 → 按 EF 倍数增长。例如 EF=2.5 时,间隔为 1→3→8→19→48→120… 天,呈指数级增长,这正是"间隔重复"名称的由来。

  3. 失分即重置:一旦用户答错(quality < 3),间隔立即重置为 1 天,repetition 归零,这体现了"大脑遗忘曲线"的客观规律。

  4. EF 双向调整:EF 值不是固定的,它会根据每次回答的质量进行调整。答得好 EF 增加(更快进入长间隔),答得差 EF 降低(缩短间隔,增加复习频率)。

掌握度映射

static getMasteryColor(mastery: number): string {
  if (mastery >= 0.8) return '#00b894';   // 绿色 - 已掌握
  if (mastery >= 0.5) return '#ff9f43';   // 橙色 - 学习中
  if (mastery >= 0.2) return '#e17055';   // 浅红 - 需加强
  return '#d63031';                         // 红色 - 新词/陌生
}

这种颜色映射让学习进度一目了然,无需阅读文字说明即可快速识别当前掌握状态。

6.3 示例数据

SampleData.ets 提供了 30 个基础词汇的完整数据以及阅读文章和语法练习题。每个单词条目包含 9 个字段,覆盖了英语学习所需的信息维度:

export const SAMPLE_WORDS: WordItem[] = [
  {
    id: 1,
    word: 'abandon',
    phonetic: '/əˈbændən/',
    translation: '放弃;遗弃',
    partOfSpeech: 'v.',
    exampleSentence: 'They had to abandon the project due to lack of funds.',
    exampleTranslation: '由于缺乏资金,他们不得不放弃这个项目。',
    difficulty: Difficulty.MEDIUM,
    category: '基础词汇',
    audioPath: '',
  },
  // ... 29 个更多单词
];

单词分类体系:30 个词汇覆盖了"基础词汇"、“核心词汇”、“进阶词汇”、"学术词汇"四个类别,难度从 EASY 到 HARD 三个等级,为后续的排序学习算法提供了丰富的维度。

七、工程化最佳实践

7.1 项目构建设置

项目根目录的 build-profile.json5 定义了关键 SDK 版本和目标设备配置:

{
  "app": {
    "products": [
      {
        "name": "default",
        "targetSdkVersion": "6.1.1(24)",
        "compatibleSdkVersion": "6.1.1(24)",
        "runtimeOS": "HarmonyOS",
        "buildOption": {
          "strictMode": {
            "caseSensitiveCheck": true,
            "useNormalizedOHMUrl": true
          }
        }
      }
    ]
  }
}

targetSdkVersioncompatibleSdkVersion:前者表示应用开发时依赖的最新 SDK 特性,后者表示应用可以兼容的最低 SDK 版本。两者都设为 6.1.1(24) 意味着应用仅在 HarmonyOS NEXT(API 24)上运行。

7.2 hilog 日志规范

项目中统一使用 hilog 进行日志输出,而非 console.log。hilog 是鸿蒙系统的日志基础设施,支持日志分级和按域过滤:

const TAG = 'RowSpaceEvenlyDemo';  // 标签常量,按模块命名

// 日志输出格式:hilog.level(domain, tag, message)
hilog.info(0x0000, TAG, 'Feature selected: ' + label);
// 或使用格式化字符串:
hilog.info(0x0000, TAG, 'canvas ready: %d x %d', this.canvasW, this.canvasH);

日志级别:hilog 支持 DEBUG、INFO、WARN、ERROR 四个级别,strictMode 开启时,WARN 及以上级别的日志会触发编译警告。

7.3 页面路由管理

module.json5 配置了应用的页面路由:

{
  "module": {
    "pages": "$profile:main_pages",
    "abilities": [
      {
        "name": "EntryAbility",
        "srcEntry": "./ets/entryability/EntryAbility.ets",
        "startWindowBackground": "$color:start_window_background",
        "exported": true,
        "skills": [
          {
            "entities": ["entity.system.home"],
            "actions": ["action.system.home"]
          }
        ]
      }
    ]
  }
}

"pages": "$profile:main_pages" 引用了一个资源配置文件,该文件位于 resources/base/profile/main_pages.json 中,定义了应用中所有页面的路径映射。每个页面路径对应 @Entry 装饰的组件,例如 'pages/RowSpaceEvenlyDemo' 对应 RowSpaceEvenlyDemo.ets 中的 RowSpaceEvenlyPage 结构体。

7.4 生命周期管理

ArkTS 组件提供了完整的生命周期回调:

aboutToAppear(): void {
  // 组件即将显示时调用
  // 适合初始化数据、启动定时器
}

aboutToDisappear(): void {
  // 组件即将销毁时调用
  // 必须在此清理定时器、释放资源
  if (this.timerId >= 0) {
    clearInterval(this.timerId);
    this.timerId = -1;
  }
}

资源清理的重要性:在 LightChaseGame.ets 中,aboutToAppear 中创建了背景色渐变动画定时器(setInterval),aboutToDisappear 中必须清理 timerIdmoveTimerId 两个定时器。否则页面跳转后,定时器仍在后台运行,会导致内存泄漏和逻辑异常。

RunnerPage 直接将 setInterval 的返回值(timerId)存储在普通成员变量中(非 @State),因为定时器句柄不需要驱动 UI 重绘。这是一个重要的性能优化策略——尽量减少 @State 变量的数量,避免不必要的 UI 更新。

7.5 @State 与性能优化

在 ArkTS 中,@State 装饰器的使用需要遵循以下原则:

  1. 最小化 @State 范围:只有直接影响 UI 展示的变量才标记为 @State
  2. 避免大对象作为 @State:如果一个大对象只有个别字段影响 UI,考虑将其拆分为多个独立的 @State
  3. 不变的属性用 private:仅用于子组件传入的数据,使用 private 而非 @State
// 正确做法
@State private score: number = 0;         // 驱动显示
@State private timeLeft: number = 30;     // 驱动显示
private timerId: number = -1;             // 不驱动 UI,不用 @State
private obstacles: Obstacle[] = [];       // 内部状态,由 Canvas 重绘

// 错误做法
@State private timerId: number = -1;      // 不需要,会导致多余的 build() 调用

八、ArkTS 声明式 UI 与传统命令式 UI 的对比

8.1 数据流方向

传统命令式(Java/XML):业务逻辑中通过类似 findViewById(id) 获取控件实例,然后调用 setText()setOnClickListener() 等方法手动更新 UI。

ArkTS 声明式:数据与 UI 通过 @State / @Prop / @Link 自动绑定。开发者只需修改数据,框架自动完成 UI 更新。数据流是单向的:Model → View。

8.2 代码组织方式

传统方式:布局文件(XML)与逻辑代码(Java)分属不同文件,布局与逻辑的关联通过 ID 隐式绑定,大型项目中维护困难。

ArkTS 方式:UI 结构与业务逻辑在同一个文件中,通过 build() 方法声明 UI 结构,通过装饰器定义数据绑定关系。组件的"UI + 逻辑"完全内聚,不存在跨文件隐式依赖。

8.3 样式管理

传统方式:通常使用独立的样式文件(如 Android 的 styles.xml)或在布局文件中直接写内联样式,缺乏类型检查。

ArkTS 方式:所有样式属性通过链式方法调用设置,具有完整的类型安全。IDE 可以提供自动补全、参数类型校验和重构支持。

九、多页面切换与导航

当前项目中,EntryAbility.etswindowStage.loadContent() 决定了首页的加载。要实现多页面间的导航,可以通过以下方式扩展:

// 方式一:使用页面路由
import { router } from '@kit.ArkUI';

router.pushUrl({
  url: 'pages/LightChaseGame',
  params: { }
});

// 方式二:条件渲染(适用于页面较少的场景)
build() {
  Column() {
    if (this.currentPage === 'home') {
      ColumnStartPage()
    } else if (this.currentPage === 'toolbar') {
      RowSpaceEvenlyPage()
    }
    // ...
  }
}

router.pushUrl 是鸿蒙推荐的页面路由方式,支持 URL 跳转、参数传递和页面栈管理(router.back() 返回上一页)。条件渲染方式适合页面数量较少的场景,无需配置路由表,但所有页面的代码都会被打包到同一个文件中。

十、总结与展望

本文以 HarmonyOS NEXT 6.1.1(API 24)为开发平台,通过一个完整的英语学习应用实例,深入剖析了鸿蒙 ArkTS 开发的五大核心领域:

10.1 布局体系

ArkUI 的弹性盒模型(Flexbox)通过 Column 和 Row 两个容器,配合 justifyContentalignItems 属性,能够实现几乎所有常见的 UI 布局。layoutWeight 弹性权重机制为自适应屏幕尺寸提供了优雅的解决方案。SpaceEvenly 均分布局在工具栏场景中的表现尤为突出——仅需一行代码即可实现精确等距排列,相比传统布局方案(手动计算间距或使用嵌套 LinearLayout)大幅降低了复杂度。

10.2 组件化开发

通过 @Component 装饰器定义可复用组件,配合 @Prop@BuilderParam 等装饰器实现灵活的数据传递和内容注入。这种组件化模式天然支持单一职责原则,每个组件只关注自己的功能和视图,通过清晰的接口与外部交互。

10.3 Canvas 游戏编程

ArkUI 的 Canvas 组件和 CanvasRenderingContext2D 提供了 2D 图形绘制能力,足以支撑轻量级游戏开发。从物理引擎模拟到帧循环动画,从碰撞检测到图形绘制管线,全部使用纯 ArkTS 代码实现,无需引入任何第三方游戏引擎。

10.4 数据模型与算法

interface 数据模型定义到 SM-2 间隔重复算法的实现,展示了在 ArkTS 中处理复杂业务逻辑的能力。数据类型安全、模块化设计、可测试性等工程原则在这里得到了充分的体现。

10.5 工程化实践

hilog 日志管理、生命周期资源清理、@State 最小化、Stage 模型配置等,构成了鸿蒙应用开发的工程化基础。这些看似细节的实践,恰恰是保障应用长期可维护性的关键。

随着 HarmonyOS 生态的持续完善,ArkTS 作为鸿蒙原生开发的首选语言,其类型系统、声明式 UI 和丰富的 API 能力将越来越强大。希望本文能够帮助开发者快速上手鸿蒙原生开发,掌握 ArkUI 布局体系的核心原理,在实际项目中构建出优雅、高效、可维护的鸿蒙应用。

从 Column + Start 的顶部排列,到 Row + SpaceEvenly 的均分布局,再到 layoutWeight 的弹性自适应——ArkUI 的布局哲学始终围绕着"用最简单的代码,实现最灵活的界面"。这也是鸿蒙原生开发一直追求的核心理念:让开发者专注于创造价值,而不是与布局框架做斗争。

Logo

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

更多推荐