ArkTS 游戏引擎与智能学习算法 —— HarmonyOS NEXT 跨场景应用开发实战


一、引言:一个项目,三种场景
在移动应用开发中,很少有项目能同时涵盖"游戏引擎级渲染"、"智能学习算法"和"可复用组件库"这三个截然不同的技术领域。本文要剖析的这个 HarmonyOS NEXT 项目恰好做到了这一点——同一个项目中包含了三套独立的应用场景:
场景一——单键跑酷游戏:使用 Canvas 2D API 实现完整的物理引擎、游戏循环和碰撞检测,配合 Column + layoutWeight 弹性布局适配不同屏幕。
场景二——智能英语学习系统:基于 SM-2 间隔重复算法(Spaced Repetition)的单词记忆引擎,包含完整的单词库、阅读材料、听力训练和语法练习的数据模型。
场景三——可复用组件库:通用卡片容器、圆形进度条、模块入口卡、顶部标题栏等跨场景共享的 UI 组件。
这三个场景在技术层面有各自的核心挑战:
- 游戏场景需要解决"高帧率渲染"和"物理模拟"的问题
- 学习系统需要解决"记忆曲线建模"和"复习计划调度"的问题
- 组件库需要解决"跨场景复用"和"参数化配置"的问题
本文将从这三个维度出发,逐一剖析每个场景的设计思路和实现细节,展现 ArkTS 在不同类型应用开发中的表达能力。
二、单键跑酷游戏 —— Canvas 物理引擎 + Column 弹性布局
2.1 游戏设计概述
「单键跑酷」是一款极简的横版跑酷小游戏。玩家只需点击屏幕下方一个巨大的跳跃按钮,控制角色跳过从右侧不断出现的障碍物。每越过一个障碍物得一分,撞上则游戏结束。
游戏设计的核心理念是"单键操作"(One-Button Game)。整个游戏只有一个交互入口——跳跃按钮。点击跳跃、点击开始、点击重新开始,全部由同一个按钮完成。这种设计将学习成本降到最低,玩家打开游戏即刻上手。
2.2 UI 布局架构:Column + layoutWeight
游戏的 UI 布局采用了 Column + layoutWeight 弹性布局模式。这是 ArkTS 中最强大的自适应布局方案之一。
Column() { // 全屏容器(高度 100%)
├── 固定段 1: 顶部状态栏 .height(50) ← 固定 50vp
├── 弹性段 A: 游戏主场景 .layoutWeight(1.0) ← 50.0%
├── 弹性段 B: 状态信息区 .layoutWeight(0.3) ← 15.0%
├── 弹性段 C: 跳跃按钮区 .layoutWeight(0.7) ← 35.0%
└── 固定段 2: 布局说明面板 内容撑高 ← 不占弹性
}
.width('100%').height('100%')
每个弹性段的高度计算公式为:
弹性段高度 = (Column总高度 - 固定段高度之和)
× (该段 layoutWeight / 所有弹性段 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 没有剩余空间可以分配。这是一个非常容易踩的坑——如果你发现 layoutWeight 没有任何效果,请第一时间检查父容器是否设置了明确的宽度和高度。
2.3 区块实现代码
固定段 1:顶部状态栏
顶部状态栏固定高度为 50vp,显示游戏标题、实时得分和最高分。它不参与弹性分配的原因是:状态栏在任何设备上都应该保持相同的高度,不应该随着屏幕变化而拉伸或压缩。
Row() {
// 左侧标题
Row() {
Text('🏃').fontSize(18)
Text(' 单键跑酷 ').fontSize(15)
.fontWeight(FontWeight.Bold).fontColor('#ffffff')
}
.alignItems(VerticalAlign.Center).width(120)
Blank()
// 当前得分
Text('得分 ').fontSize(12).fontColor('#B3E5FC')
Text('' + this.score).fontSize(18)
.fontWeight(FontWeight.Bold).fontColor('#ffffff')
.margin({ right: 12 })
// 最高分
Text('最高 ').fontSize(12).fontColor('#B3E5FC')
Text('' + this.bestScore).fontSize(16)
.fontWeight(FontWeight.Bold).fontColor('#FFD54F')
}
.alignItems(VerticalAlign.Center)
.width('100%')
.height(50) // ← 固定高度,不参与弹性分配
.padding({ left: 12, right: 12 })
.backgroundColor('#1565C0')
.borderRadius({ bottomLeft: 8, bottomRight: 8 })
弹性段 A:游戏主场景
游戏主场景使用 Canvas 组件进行实时绘制。Canvas 通过 onReady 回调获取实际尺寸并绘制首帧。
Column() {
Canvas(this.ctx)
.width('100%')
.height('100%')
.onReady(() => {
// 获取 Canvas 的实际像素尺寸
this.canvasW = this.ctx.width;
this.canvasH = this.ctx.height;
this.drawScene(); // 绘制首帧画面
})
}
.alignItems(HorizontalAlign.Center)
.width('100%')
.layoutWeight(1.0) // ← 弹性权重 1.0(占 50%)
.backgroundColor('#E0F7FA')
这里有一个重要细节:Canvas 的 onReady 回调是异步的,这意味着在 build() 函数执行时 Canvas 可能还没有完成初始化。因此所有依赖 Canvas 尺寸的逻辑(如角色位置、障碍物生成等)都必须放在 onReady 内部执行,或在 onReady 之后通过其他方式触发。
弹性段 C:跳跃按钮区
跳跃按钮占据了整个弹性段 C 的空间。按钮本身宽高都是 100%,填满整个区域。
Column() {
Button(this.btnLabel)
.width('100%')
.height('100%')
.backgroundColor('#FF6F00')
.fontColor('#ffffff')
.fontSize(24)
.fontWeight(FontWeight.Bold)
.borderRadius(16)
.shadow({ radius: 8, color: '#4DFF6F00', offsetX: 0, offsetY: 4 })
.onClick(() => {
this.doJump();
})
}
.alignItems(HorizontalAlign.Center)
.justifyContent(FlexAlign.Center)
.width('100%')
.layoutWeight(0.7) // ← 弹性权重 0.7(占 35%)
.padding(10)
.backgroundColor('#FFF3E0')
按钮的文字 this.btnLabel 会根据游戏状态动态变化:
- 准备状态:
"🦘 点击跳跃!" - 游戏中:
"🦘 跳跃!" - 游戏结束:
"🔄 重新开始"
2.4 游戏状态机与生命周期
游戏使用枚举类型 GameState 定义了三个状态:
enum GameState {
READY = 0, // 等待开始(显示欢迎画面)
PLAYING = 1, // 游戏中(物理引擎运行中)
OVER = 2, // 游戏结束(显示得分蒙层)
}
游戏的状态机转换关系如下:
READY ──点击跳跃──▶ PLAYING ──碰撞障碍物──▶ OVER
▲ │
└────────────点击重新开始──────────────────────┘
关键的生命周期方法 aboutToDisappear 在页面消失时被 ArkTS 框架自动调用。我们在这个方法中清理游戏定时器,避免页面已经销毁但定时器仍在运行导致的资源泄漏:
aboutToDisappear(): void {
if (this.timerId >= 0) {
clearInterval(this.timerId);
this.timerId = -1;
}
}
2.5 物理引擎与游戏循环
游戏的物理引擎和渲染循环全部在 setInterval 驱动的 gameLoop 方法中完成。每 24ms 执行一次,约合每秒 42 帧。
private startGame(): void {
this.resetGame();
this.gameState = GameState.PLAYING;
this.stateLabel = '🏃 跑酷中...';
this.btnLabel = '🦘 跳跃!';
if (this.timerId < 0) {
this.timerId = setInterval(() => {
this.gameLoop();
}, 24); // 约 42fps
}
}
游戏循环的逻辑分三步:物理更新 → 碰撞检测 → 绘制画面。
2.5.1 物理更新
物理更新模拟了重力加速度和跳跃速度:
// 重力作用
this.playerVY = this.playerVY + GRAVITY; // 垂直速度增加(向下)
this.playerY = this.playerY + this.playerVY; // 位置变化
// 地面碰撞检测
if (this.playerY >= 0) {
this.playerY = 0;
this.playerVY = 0; // 落地后速度归零
}
物理常量定义:
const GRAVITY: number = 0.55; // 重力加速度(每帧叠加)
const JUMP_VEL: number = -9.0; // 跳跃初速度(负值=向上)
当玩家点击跳跃按钮时,doJump 方法检查角色是否在地面附近(playerY >= -2),如果是则给予向上的初速度:
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; // 施加向上的初速度
}
}
}
2.5.2 障碍物生成与管理
障碍物从 Canvas 右侧边缘出现,以当前速度向左移动。生成间隔随机,但控制在最小 60 帧到最大 110 帧之间,避免障碍物过于密集或稀疏。
// 障碍物移动——每帧向左移动 curSpeed 像素
const moveDist: number = this.curSpeed;
for (let i: number = 0; i < this.obstacles.length; i++) {
this.obstacles[i].x = this.obstacles[i].x - moveDist;
}
// 移除已完全移出屏幕的障碍物,同时加分
let alive: Obstacle[] = [];
for (let i: number = 0; i < this.obstacles.length; i++) {
if (this.obstacles[i].x > -O_W) {
alive[alive.length] = this.obstacles[i]; // 保留还在屏幕内的
} else {
this.score = this.score + 1; // 安全通过,加分
this.speedDisp = this.curSpeed.toFixed(1);
}
}
this.obstacles = alive;
// 生成新障碍物
this.spawnCD = this.spawnCD - 1;
if (this.spawnCD <= 0) {
this.spawnCD = SPAWN_MIN + Math.floor(Math.random() * (SPAWN_MAX - SPAWN_MIN));
this.obstacles[this.obstacles.length] = { x: this.canvasW };
}
速度随得分递增,但设有上限:
this.curSpeed = BASE_SPEED + this.score * SPEED_STEP;
if (this.curSpeed > MAX_SPEED) {
this.curSpeed = MAX_SPEED; // 硬上限
}
2.5.3 碰撞检测(AABB 轴对齐包围盒)
碰撞检测使用 AABB(Axis-Aligned Bounding Box)算法。AABB 是最简单的碰撞检测算法,它将每个物体抽象为一个不与坐标轴旋转的矩形,通过比较两个矩形的边界来判断是否重叠。
// 角色位置
const px: number = this.canvasW * 0.2; // 固定 X
const py: number = this.canvasH * P_GROUND + this.playerY - P_SIZE;
for (let i: number = 0; i < this.obstacles.length; i++) {
const ox: number = this.obstacles[i].x;
const oy: number = this.canvasH * P_GROUND - O_H;
// AABB 碰撞检测:检查两个矩形是否重叠
if (px < ox + O_W && px + P_SIZE > ox) { // X 轴重叠
if (py < oy + O_H && py + P_SIZE > oy) { // Y 轴重叠
// 重叠 → 碰撞发生
this.gameOver();
return;
}
}
}
AABB 碰撞检测的核心逻辑是:两个矩形在 X 轴和 Y 轴上同时存在重叠时,即判定为碰撞。这是一种"粗略但高效"的碰撞检测方法,对于像素风格的游戏来说精度完全够用。
2.6 Canvas 游戏画面绘制
drawScene 方法负责绘制完整的游戏画面。它使用 Canvas 2D API,按照从远到近的顺序绘制各层:
天空渐变:使用 createLinearGradient 创建从天空蓝到浅绿色的垂直渐变,模拟户外环境。
const grad: CanvasGradient = 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);
地面与草地边沿:棕色地面搭配绿色草地边沿,草地边沿使用 4vp 高的绿色条带。
const groundY: number = h * P_GROUND;
ctx.fillStyle = '#5D4037'; // 棕色地面
ctx.fillRect(0, groundY, w, h - groundY);
ctx.fillStyle = '#66BB6A'; // 绿色草地边沿
ctx.fillRect(0, groundY - 4, w, 4);
障碍物绘制:矩形主体 + 深色边框 + 叉号装饰。
// 主体
ctx.fillStyle = '#8D6E63';
ctx.fillRect(ox, oy, O_W, O_H);
// 边框
ctx.strokeStyle = '#5D4037';
ctx.lineWidth = 1.5;
ctx.strokeRect(ox, oy, O_W, O_H);
// 叉号装饰
ctx.strokeStyle = '#D7CCC8';
ctx.beginPath();
ctx.moveTo(ox + 3, oy + 5);
ctx.lineTo(ox + O_W - 3, oy + O_H - 5);
ctx.moveTo(ox + O_W - 3, oy + 5);
ctx.lineTo(ox + 3, oy + O_H - 5);
ctx.stroke();
角色绘制:圆角矩形身体 + 白色眼睛和瞳孔 + 微笑弧线 + 跳跃喷气效果。角色的细节体现了 Canvas 绘制的灵活性——尽管代码量不大,但通过基本几何图形的组合(圆角矩形、圆形、弧形、线段),可以创造出有表情、有性格的游戏角色。
游戏结束蒙层:半透明黑色蒙层覆盖整个画面,居中显示得分和最高分。
if (this.gameState === GameState.OVER) {
ctx.fillStyle = '#00000080'; // 半透明黑色
ctx.fillRect(0, 0, w, h);
ctx.fillStyle = '#ffffff';
ctx.font = '20px sans-serif';
ctx.textAlign = 'center';
ctx.fillText('💥 游戏结束', w / 2, h / 2 - 20);
ctx.font = '14px sans-serif';
ctx.fillText('得分:' + this.score + ' | 最高:' + this.bestScore, w / 2, h / 2 + 16);
}
2.7 Canvas 的关键问题与解决方案
尺寸获取时机:CanvasRenderingContext2D 的 width 和 height 属性在 onReady 回调之前是未定义的。因此所有依赖于 Canvas 尺寸的初始化逻辑都必须放在 onReady 内部。在游戏循环中,我们通过成员变量 canvasW 和 canvasH 来存储并复用这些尺寸值。
清除与重绘:每帧开始时调用 ctx.clearRect(0, 0, w, h) 清除整个画布,然后完整重绘所有元素。对于我们的简单场景,这种"全量重绘"策略完全胜任。
性能考量:对于 42fps 的更新频率,每次 gameLoop 的执行时间必须控制在 24ms 以内。如果后续需要增加更多绘制元素(粒子效果、多角色等),建议使用"脏矩形"技术——只重绘发生变化的区域,而非全量重绘。
三、可复用组件库 —— @BuilderParam 与 @Prop 的实践
3.1 为什么需要自定义组件?
在项目包含多个独立应用(游戏、学习工具、布局演示)的情况下,必然会遇到 UI 模式的复用需求。例如:
- 所有页面都需要一个统一样式的标题栏
- 列表页的卡片容器有相同的圆角、阴影和边距
- 学习统计页面需要一个可定制进度的圆形进度条
- 功能入口需要有统一样式的图标按钮
HarmonyOS 的 ArkTS 提供了 @Component 装饰器来定义自定义组件,并结合 @Prop、@BuilderParam 等装饰器实现灵活的组件复用。
3.2 通用卡片容器(Card 组件)
Card 组件是最基础的复用容器,它提供固定的样式(圆角、阴影、背景色),而内部内容由调用方通过 @BuilderParam 注入。
@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() // ★ 使用 @BuilderParam 注入的内容
}
.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 的工作原理:@BuilderParam 装饰一个函数类型的属性,这个属性可以由父组件传入一个 @Builder 方法来替换。在 Card 组件内部,this.content() 调用这个函数,渲染出调用方传入的 UI 内容。如果没有传入,则使用默认值 this.defaultContent。
使用示例:
Card({
cardPadding: 20,
cardColor: '#f9f9f9',
cardRadius: 12,
content: () => {
this.myCardContent() // 传入自定义 Builder
}
})
// 自定义 Builder
@Builder
myCardContent(): void {
Column() {
Text('自定义卡片内容').fontSize(16)
Text('这是一个使用 @BuilderParam 注入的卡片').fontSize(12).fontColor('#666')
}
.alignItems(HorizontalAlign.Center)
}
3.3 圆形进度条组件(ProgressRing)
ProgressRing 组件使用 Canvas 2D API 绘制圆形进度条,实现了进度环 + 中心文字的组合。
@Component
export struct ProgressRing {
@Prop ringProgress: number = 0; // 0-100 的百分比
@Prop ringSize: number = 80; // 组件尺寸
@Prop ringStroke: number = 6; // 环的线宽
@Prop ringColor: string = '#3a7bd5'; // 前景色
@Prop ringBgColor: string = '#e8ecf0'; // 背景色
@Prop ringLabel: string = ''; // 下方标签
@Prop ringValue: string = ''; // 中心文字
private ringContext: CanvasRenderingContext2D = new CanvasRenderingContext2D();
private progressContext: CanvasRenderingContext2D = new CanvasRenderingContext2D();
绘制进度环的核心是 Canvas 的 arc 方法。这里使用了两个 Canvas 上下文叠加:第一个绘制背景环(完整 360 度,灰色),第二个绘制前景进度环(从 -90 度开始,根据 ringProgress 计算终止角度)。
drawRing(ctx: CanvasRenderingContext2D, value: number, isBg: boolean): void {
const size = this.ringSize;
const stroke = this.ringStroke;
const cx = size / 2;
const cy = size / 2;
const r = (size - stroke) / 2; // 圆弧半径
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();
}
在 build() 中,使用 Stack 层叠两个 Canvas 和一个文字列:
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).fontColor('#1a1a2e')
if (this.ringLabel.length > 0) {
Text(this.ringLabel).fontSize(10).fontColor('#888').margin({ top: 2 })
}
}
}
.width(this.ringSize).height(this.ringSize)
}
.alignItems(HorizontalAlign.Center)
}
这个组件的设计体现了多个 Canvas 上下文配合使用的技巧。由于每个 Canvas 都是独立的组件,它们可以独立地触发 onReady 回调,独立地进行 clearRect 和重绘。背景环只需绘制一次(静态的灰色圆环),前景环在 ringProgress 变化时重新绘制。
3.4 模块入口卡片与顶部标题栏
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').fontWeight(FontWeight.Medium)
}
.width('30%')
.aspectRatio(1.0) // ★ 保持正方形
.justifyContent(FlexAlign.Center)
.backgroundColor(this.entryColor + '18') // ★ 背景色 = 主色 + 18% 透明度
.borderRadius(16)
.onClick(() => { this.onClickAction(); })
.shadow({ radius: 2, color: '#10000000', offsetX: 0, offsetY: 1 })
}
}
有两个值得学习的设计细节:
aspectRatio(1.0) 确保卡片保持 1:1 的正方形比例。这对于网格布局来说非常重要——无论屏幕宽度如何变化,每个卡片都是等大的正方形,视觉上整齐划一。
this.entryColor + '18' 将十六进制颜色与透明度字符串拼接,得到一个带 alpha 通道的颜色值。例如 '#3a7bd5' 变为 '#3a7bd518'(18 十六进制 = 24% 不透明度)。这是一种动态生成透明背景的技巧,让卡片的背景色随主色变化,始终保持视觉一致性。
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')
}
}
AppHeader 使用条件渲染 if (this.showBack) 来控制返回按钮的显示隐藏,通过 onBack 回调将点击事件冒泡到父组件。这种模式是 ArkTS 组件通信的典型实践。
3.5 自定义组件的最佳实践
-
默认值原则:所有
@Prop属性都应提供合理的默认值。这确保了即使调用方没有传入任何参数,组件也能正常渲染。 -
回调函数而非事件:对于子组件向父组件通信的场景(如按钮点击),使用函数类型的属性(如
onClickAction、onBack)而非 emit 事件。这种模式更符合 ArkTS 的声明式风格。 -
@BuilderParam 作为插槽:ArkTS 没有 React 的
children或 Vue 的slot机制。@BuilderParam是实现"内容注入"的标准方式,它允许父组件传入一段可复用的 UI 片段。 -
组件命名与导出:使用
export struct导出组件,使用清晰的命名前缀(如Card、ProgressRing、AppHeader)。每个组件文件只包含一个主要组件。
四、智能英语学习系统 —— 数据模型与间隔重复算法
4.1 学习系统架构
这个英语学习系统的核心数据结构定义在 AppModel.ets 中,包含单词、学习记录、阅读文章、听力材料、口语练习和语法练习六大模块。整个系统涵盖了语言学习的各个方面:
英语学习系统
├── 单词学习(WordItem + StudyRecord + SpacedRepetition)
├── 阅读训练(ReadingArticle + ReadingQuestion)
├── 听力训练(ListeningMaterial + ListeningQuestion)
├── 口语练习(SpeakingExercise)
└── 语法练习(GrammarExercise)
4.2 数据模型设计
单词条目 WordItem:每个单词包含拼写、音标、翻译、词性、例句、难度等级和分类标签。
export interface WordItem {
id: number;
word: string; // 单词拼写
phonetic: string; // 音标
translation: string; // 中文翻译
partOfSpeech: string; // 词性
exampleSentence: string; // 例句
exampleTranslation: string; // 例句翻译
difficulty: Difficulty; // 难度:EASY / MEDIUM / HARD
category: string; // 分类:基础词汇 / 核心词汇 / 进阶词汇 / 学术词汇
}
学习记录 StudyRecord:记录每个单词的学习进度,包括复习次数、正确次数、上次复习时间和掌握度。
export interface StudyRecord {
wordId: number;
reviewCount: number; // 复习次数
correctCount: number; // 正确次数
lastReviewTime: string; // 上次复习时间
masteryLevel: number; // 掌握度(0.0 ~ 1.0)
}
4.3 间隔重复算法(SM-2 改进版)
间隔重复(Spaced Repetition)是一种基于记忆曲线的学习算法。其核心理念是:在记忆即将遗忘的临界点进行复习,可以最大化记忆效率。SM-2 算法由 SuperMemo 的创始人 Piotr Wozniak 提出,是间隔重复领域最经典的算法之一。
我们的 SpacedRepetitionEngine 类实现了改进版的 SM-2 算法:
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, // 回答质量 0~5
previousInterval: number, // 上次间隔天数
repetition: number, // 连续正确次数
previousEf: number = 2.5, // 之前的易度系数
): ReviewResult {
// 质量 < 3:回答不合格,重置进度
if (quality < 3) {
return {
nextInterval: 1, // 明天就复习
newRepetition: 0, // 连续正确归零
newEf: this.updateEf(previousEf, quality),
nextReview: new Date(Date.now() + 86400000), // 明天
};
}
// 质量 >= 3:合格,按 SM-2 公式计算
const newEf = this.updateEf(previousEf, quality);
let nextInterval: number;
if (repetition === 0) { // 第一次正确
nextInterval = 1;
} else if (repetition === 1) { // 第二次连续正确
nextInterval = 3;
} else { // 第三次及以上
nextInterval = Math.round(previousInterval * newEf);
}
nextInterval = Math.min(nextInterval, this.MAX_INTERVAL);
return {
nextInterval,
newRepetition: repetition + 1,
newEf,
nextReview: new Date(Date.now() + nextInterval * 86400000),
};
}
算法的核心逻辑:
回答质量参数 quality 的取值从 0 到 5:
| 质量分 | 含义 | 算法行为 |
|---|---|---|
| 0 | 完全忘记 | 重置进度,明天复习 |
| 1 | 错误但能回忆起部分 | 重置进度,明天复习 |
| 2 | 错误但感觉容易 | 重置进度,明天复习 |
| 3 | 正确但很困难 | 合格,按公式计算间隔 |
| 4 | 正确且略有犹豫 | 合格,易度系数提升 |
| 5 | 完全正确 | 合格,易度系数大幅提升 |
易度系数(EF)的更新公式:
易度系数衡量一个单词对用户来说的"固有难度"。每次复习后根据质量分更新:
private static updateEf(oldEf: number, quality: number): number {
// EF' = EF + (0.1 - (5-Q) * (0.08 + (5-Q) * 0.02))
const newEf = oldEf + (0.1 - (5 - quality) * (0.08 + (5 - quality) * 0.02));
return Math.max(newEf, this.MIN_EF); // 不低于下限
}
这个公式包含了一个有趣的非线性变化:当 quality = 5 时,EF 增加 0.1;当 quality = 3 时,EF 减少 0.14。这意味着越容易的单词复习间隔增长越快,越困难的单词复习间隔增长越慢甚至缩短。
复习间隔的指数增长:
当用户连续正确回答一个单词时,复习间隔会经历一个快速增长的过程:
第一次正确:1 天后 ← 隔天复习
第二次正确:3 天后 ← 间隔增长 3 倍
第三次正确:3 × EF 天后 ← 开始乘积增长
第四次正确:前一次 × EF ← 指数级增长
...
例如,对于 EF = 2.5 的单词:
- 间隔序列:1 → 3 → 7 → 18 → 45 → 112 → …
- 约 6 次复习后间隔可达 3 个月
这种指数增长模式与人类的遗忘曲线高度吻合:新学的知识容易忘记,需要频繁复习;学过的知识记得更牢,可以逐渐拉长复习间隔。
掌握度映射函数:
为了方便 UI 展示,我们还提供了掌握度到颜色和标签的映射:
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'; // 红色 - 新词/陌生
}
static getMasteryLabel(mastery: number): string {
if (mastery >= 0.8) return '已掌握';
if (mastery >= 0.5) return '学习中';
if (mastery >= 0.2) return '需加强';
return '新词';
}
这四个等级对应四种不同的复习策略:
- 新词(红色):需要重点关注,每天复习
- 需加强(浅红):已有印象但不牢固,隔天复习
- 学习中(橙色):基本掌握,可以适当拉长间隔
- 已掌握(绿色):已形成长期记忆,每两周或每月回顾即可
4.4 样本数据与课程设计
SampleData.ets 提供了 30 个核心词汇的样本数据,词汇覆盖率从基础词汇(EASY)到学术词汇(HARD),涵盖了英语学习的多个难度层级。同时提供了两篇阅读文章(初级和中级)供学习使用。
词汇的设计遵循了"逐步进阶"的原则:
- 基础词汇(如 journey, recognize)适合初学者
- 核心词汇(如 benefit, maintain)适合中级学习者
- 进阶词汇(如 elaborate, negotiate)适合中高级学习者
- 学术词汇(如 hypothesis, sustainable)适合高级学习者和备考者
每篇阅读文章都配有多道阅读理解题,题型覆盖主旨题、细节题和词汇题,全面检验学习者的理解能力。
五、跨场景的技术对比与选型思考
5.1 三种渲染模式对比
本项目中的三个应用场景使用了三种完全不同的渲染策略:
| 场景 | 渲染方式 | 刷新机制 | 适用场景 |
|---|---|---|---|
| 跑酷游戏 | Canvas 2D API | setInterval 驱动,42fps | 高帧率游戏、实时动画 |
| 圆形进度条 | Canvas 2D API | 属性变化时 onReady 重绘 | 低频 UI 更新、数据可视化 |
| 列表/卡片 UI | ArkTS 声明式渲染 | @State 变化自动触发 | 交互界面、内容展示 |
Canvas 渲染适合频繁变化、内容复杂的图形场景。游戏中的角色、障碍物、地面纹理每帧都在变化,使用 Canvas 可以精确控制每一个像素。
声明式渲染适合状态变化不频繁的 UI 场景。卡片列表、状态栏、按钮等 UI 元素由 ArkTS 框架自动管理,开发者只需关注 @State 值的变化。
混合使用两种渲染方式是 HarmonyOS NEXT 应用的最佳实践。在跑酷游戏中,顶部的得分状态栏使用声明式渲染(@State 驱动),游戏场景本身使用 Canvas 渲染,两种方式各司其职。
5.2 ArkTS 状态管理的演变
游戏场景中的状态管理揭示了 ArkTS @State 的一个重要特性:@State 只应修饰需要触发 UI 重绘的变量。
在 RunnerPage 中,我们刻意地将游戏内部状态(playerY、playerVY、obstacles 等)放在 @State 之外:
// @State 修饰的变量(UI 文本需要响应变化)
@State private score: number = 0;
@State private bestScore: number = 0;
@State private stateLabel: string = '🔄 点击跳跃开始';
@State private speedDisp: string = '2.5';
@State private btnLabel: string = '🦘 点击跳跃!';
// 非 @State 变量(游戏引擎内部状态,由 Canvas 驱动渲染)
private gameState: GameState = GameState.READY;
private playerY: number = 0;
private playerVY: number = 0;
private obstacles: Obstacle[] = [];
private curSpeed: number = BASE_SPEED;
这样设计的原因是:Canvas 的绘制由 gameLoop 中的 drawScene 方法驱动,不依赖 @State 的变更检测。如果将 playerY 设为 @State,它的每一次微小变化都会触发 ArkTS 框架进行 UI diff 比较——而对于 Canvas 场景,这个比较是完全没有必要的开销。正确地将"引擎内部状态"与"UI 展示状态"分离,可以显著减少不必要的重绘。
5.3 算术技巧:不使用 for 循环的障碍物过滤
在游戏代码中,障碍物的过滤没有使用 Array.filter 方法,而是使用传统的 for 循环加手动索引管理:
let alive: Obstacle[] = [];
for (let i: number = 0; i < this.obstacles.length; i++) {
if (this.obstacles[i].x > -O_W) {
alive[alive.length] = this.obstacles[i]; // 等价于 alive.push()
} else {
this.score = this.score + 1;
}
}
this.obstacles = alive;
alive[alive.length] = this.obstacles[i] 是一种不使用 push 方法向数组追加元素的技巧。alive.length 是当前数组的长度,将新元素赋值到这个索引位置等价于 push。这种方式在某些 JavaScript 引擎中性能略高于 push。
5.4 调试策略:日志标签与 hilog 使用
在应用开发过程中,有效的日志输出对于排查问题至关重要。我们的 RunnerPage 使用 hilog 模块输出结构化的日志信息:
import { hilog } from '@kit.PerformanceAnalysisKit';
const TAG: string = 'RunnerGame';
// 使用示例
hilog.info(0x0000, TAG, 'game started'); // 游戏开始
hilog.info(0x0000, TAG, 'game over, score=%d', this.score); // 游戏结束
hilog.info(0x0000, TAG, 'canvas ready: %d x %d', this.canvasW, this.canvasH); // Canvas 就绪
hilog.info(0x0000, TAG, 'page destroyed, timer cleaned'); // 页面销毁
hilog 的 API 签名中包含三个关键参数:domain(领域标识)、tag(日志标签)和 format(格式化字符串)。日志标签 TAG = 'RunnerGame' 让我们可以在 DevEco Studio 的 Log 面板中通过标签过滤快速定位游戏模块的日志。
hilog 与 console.log 的主要区别在于:
hilog日志在最终发布包中可以被移除(通过混淆规则),不会占用用户设备的存储空间。hilog支持格式化字符串(如%d表示整数占位符),避免字符串拼接的性能开销。hilog支持不同级别:info、warn、error、debug、fatal,便于按严重程度过滤。
六、工程化最佳实践与常见陷阱
6.1 Canvas 尺寸获取的异步陷阱
CanvasRenderingContext2D 的 width 和 height 属性在 onReady 回调触发之前是未定义的。这是一个常见的异步陷阱——如果在 build() 方法中直接访问 ctx.width,得到的值是 0。
// ❌ 错误:onReady 还未触发,width = 0
build() {
Column() {
Canvas(this.ctx)
.width('100%').height('100%')
.onReady(() => { /* 此时才可用 */ })
}
}
// 这里访问 ctx.width 得到 0
// ✅ 正确:在 onReady 中获取并存储尺寸
Canvas(this.ctx)
.width('100%').height('100%')
.onReady(() => {
this.canvasW = this.ctx.width; // 存储尺寸
this.canvasH = this.ctx.height;
this.drawScene(); // 绘制首帧
})
解决方案是:在 onReady 中将 Canvas 的实际尺寸存储到成员变量中,后续在游戏循环等位置使用这些存储的值。
6.2 setInterval 的页面生命周期管理
setInterval 创建的游戏循环必须在页面销毁时清理。否则,即使用户已经导航到其他页面,gameLoop 仍然在后台运行,造成 CPU 和电量的浪费。
aboutToDisappear(): void {
if (this.timerId >= 0) {
clearInterval(this.timerId); // 清除定时器
this.timerId = -1;
}
}
aboutToDisappear 是 ArkTS 组件生命周期方法,在当前页面即将被销毁时自动调用。类似的还有 aboutToAppear(页面即将显示时调用),可以在其中进行初始化操作。
6.3 layoutWeight 的生效条件
layoutWeight 是 ArkTS 弹性布局中最容易被误解的属性。它只在以下条件全部满足时才会生效:
- 父容器必须是 Column 或 Row:Flex 容器也支持
layoutWeight,但 Stack、RelativeContainer 等不支持。 - 父容器必须有明确的高度(Column)或宽度(Row):对于 Column,必须设置
height('100%')或明确的高度值;对于 Row,必须设置width('100%')或明确的宽度值。如果父容器高度是"由内容撑高"的(即没有设置固定高度),那么没有剩余空间可分配,layoutWeight 不会生效。 - 子组件不设置固定 height(Column)或 width(Row):如果一个子组件同时设置了
layoutWeight和.height(100),后者会优先使用,前者被忽略。
6.4 @BuilderParam 的使用限制
@BuilderParam 虽然强大,但有几个需要特别注意的限制:
- 不能与 @State 同时修饰同一属性:
@BuilderParam和@State是互斥的装饰器。 - 默认值必须是一个 @Builder 方法:如
this.defaultContent,不能是内联函数或箭头函数。 - @Builder 方法不能有返回值:它只用于构建 UI 片段,不返回任何值。
- 父组件传入的 @Builder 方法:在父组件中,调用
@Builder方法时需要使用() => { this.myBuilder() }的形式,而不能直接传入this.myBuilder。这是因为@Builder方法需要绑定正确的this上下文。
六、总结与工程启示
6.1 三大场景的核心收获
游戏场景(RunnerPage) 告诉我们:ArkTS + Canvas 2D API 可以胜任完整的 2D 游戏开发。从物理引擎到碰撞检测,从帧循环到事件处理,Canvas 提供了足够的底层能力。配合 Column + layoutWeight 弹性布局,游戏 UI 可以在不同尺寸的设备上自动适配。
组件库场景(CommonComponents) 告诉我们:@BuilderParam 是 ArkTS 组件化复用的关键。通过将"内容"作为参数注入,我们可以构建出高度可配置的通用组件。@Prop 的默认值机制确保了组件的健壮性。
学习系统场景(AppModel + SpacedRepetition) 告诉我们:纯 ArkTS 代码(不依赖任何外部库)可以实现经典的智能算法。SM-2 间隔重复算法的实现不到 80 行,却承载了完整的智能学习调度能力。
6.2 项目中体现的 ArkTS 最佳实践
-
@State 最小化原则:只将需要触发 UI 重绘的变量标记为 @State。游戏引擎内部状态应保持在 @State 之外,由 Canvas 驱动渲染,避免不必要的 Diff 开销。
-
生命周期管理:在
aboutToDisappear中清理定时器、网络请求等资源,防止页面销毁后的资源泄漏。 -
弹性布局 vs 固定布局:固定内容使用
.height()设置确切高度,可变内容使用.layoutWeight()分配剩余空间。两者的组合是实现自适应布局的基础。 -
Canvas 与声明式 UI 的混合使用:高频渲染的场景用 Canvas,低频交互的 UI 用声明式组件。两者通过 @State 变量进行数据通信。
-
纯算法实现不依赖外部库:SM-2 算法、AABB 碰撞检测、颜色透明度计算等都可以用纯 ArkTS 实现,不增加包的体积。
6.3 进一步优化的方向
- 游戏场景:可增加粒子效果(得分时的庆祝粒子)和音效反馈。Canvas 粒子系统在 ArkTS 中实现并不复杂,可以大幅提升游戏的可玩性。
- 学习系统:可以扩展到完整的复习计划调度器,结合本地数据库(@kit.StorageKit)持久化学习记录。
- 组件库:可以增加更多通用组件(如弹窗、加载骨架屏、空状态占位图等),形成完整的设计系统。
更多推荐



所有评论(0)