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

一、引言

在当今移动应用开发中,横向滚动布局已经成为不可或缺的交互范式。无论是新闻客户端的分类标签栏、电商应用的商品分类导航、音乐播放器的歌单横向滚动,还是社交应用的 Story 横向浏览——「Scroll + Row」的横向滚动布局无处不在。

HarmonyOS NEXT 作为纯正的鸿蒙原生操作系统,为开发者提供了强大的 Scroll 容器组件,结合 Row 布局容器,可以优雅地实现各类横向滚动场景。然而,Scroll 容器的使用并非表面看起来那么简单——clip(true) 的裁剪机制、scrollable() 的方向控制、scrollBar() 的显隐管理、以及 Scroller 控制器的编程式滚动,每一个细节都影响着最终的交互效果。

本文将以一款完整的鸿蒙原生应用项目为蓝本,从 Scroll 横向滚动布局切入,系统性地讲解 ArkUI 滚动体系的方方面面,并辐射到 Column 布局、SpaceBetween 两端对齐、弹性权重 layoutWeight、Canvas 游戏渲染、SM-2 间隔重复算法等全方位的 ArkTS 开发技术。文章所有代码均来自真实项目,经过 PreBuildApp 编译验证,可在 HarmonyOS NEXT 6.1.1(API 24)的 DevEco Studio 中直接编译运行。

二、项目架构总览

2.1 项目基本信息

项目 规格
应用名称 MyApplication
开发工具 DevEco Studio
目标 SDK HarmonyOS NEXT 6.1.1(API 24, Stage 模型)
开发语言 ArkTS(声明式 UI)
目标设备 Phone(手机)
包名 com.example.myapplication

项目的 build-profile.json5 中明确指定了 SDK 版本,compatibleSdkVersiontargetSdkVersion 均为 6.1.1(24),表明应用专为 HarmonyOS NEXT 原生环境开发。

2.2 项目目录结构

entry/src/main/ets/
├── entryability/
│   └── EntryAbility.ets           # 应用入口,生命周期与路由
├── pages/
│   ├── Index.ets                  # Column + FlexAlign.Start 演示
│   ├── RowScrollDemo.ets          # ★ Row + Scroll 水平滚动标签(核心)
│   ├── LightChaseGame.ets         # 光点追逐小游戏
│   └── RunnerPage.ets             # 单键跑酷 + Canvas + layoutWeight
├── components/
│   └── CommonComponents.ets       # 可复用组件库
└── model/
    ├── AppModel.ets               # 数据结构定义
    ├── SampleData.ets             # 示例数据
    └── SpacedRepetition.ets       # SM-2 间隔重复算法

该架构遵循清晰的分层原则:entryability 作为启动入口,pages 管理页面生命周期和 UI 交互,components 提供跨页面复用的 UI 组件,model 封装数据模型和业务算法。每层有明确的职责边界,上层依赖下层,下层不感知上层的存在。

2.3 应用入口与路由配置

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

export default class EntryAbility extends UIAbility {
  onWindowStageCreate(windowStage: window.WindowStage): void {
    windowStage.loadContent('pages/RowScrollDemo', (err) => {
      if (err.code) {
        hilog.error(0x0000, 'EnglishApp', 'Failed: %{public}s', JSON.stringify(err));
        return;
      }
      hilog.info(0x0000, 'EnglishApp', 'Succeeded.');
    });
  }
}

windowStage.loadContent('pages/RowScrollDemo', callback) 将应用的首页设置为 RowScrollDemo 页面。第二个参数的回调函数接收一个错误对象,通过 err.code 判断页面加载是否成功。

三、Row + Scroll(.horizontal) 横向滚动布局(核心专题)

3.1 需求场景

考虑一个新闻客户端或内容平台应用的顶部标签栏:

┌─────────────────────────────────────────────────┐
│ [推荐] [科技] [设计] [产品] [开发] [商业] ... →  │  ← 可滑动
├─────────────────────────────────────────────────┤
│                                                  │
│              当前标签对应的内容                   │
│                                                  │
└─────────────────────────────────────────────────┘

当标签数量超过屏幕宽度时,标签栏必须支持水平滑动。用户可以在标签栏上左右滑动,查看被屏幕裁掉的标签。同时,每个标签应该可以点击切换,选中标签有高亮指示,并且当选中一个不在可视区域内的标签时,标签栏应该自动滚动到该标签使其居中显示。

3.2 Scroll 容器工作原理

Scroll 是 ArkUI 中最重要的滚动容器之一。它的工作原理可以概括为以下几点:

容器与内容的尺寸关系:Scroll 本身有一个固定的可视区域尺寸(由 widthheight 决定),而其子组件的尺寸由内容撑开。当子组件在滚动方向上的尺寸大于 Scroll 的可视尺寸时,Scroll 提供滚动交互。

滚动方向控制:通过 .scrollable(ScrollDirection) 方法控制:

ScrollDirection.Horizontal   // 水平滚动(本示例使用)
ScrollDirection.Vertical     // 垂直滚动(默认)
ScrollDirection.None         // 禁止滚动
ScrollDirection.Free         // 任意方向滚动

裁剪机制.clip(true) 是 Scroll 布局中至关重要的属性。当不设置 clip 时,子组件即使超出 Scroll 的可视边界也会被渲染出来(可能会溢出到父容器之外)。设置为 true 后,所有超出可视区域的内容都被裁剪掉,这是滚动容器正确显示的基本前提。

滚动条控制:通过 .scrollBar(BarState) 控制:

BarState.Auto     // 滚动时显示,无操作自动隐藏(默认)
BarState.Always   // 始终显示
BarState.Off      // 始终隐藏

在标签栏场景中,通常使用 BarState.Off 隐藏滚动条,保持界面清爽。

3.3 核心代码分段详解

3.3.1 ScrollableTab——单个标签组件
@Component
struct ScrollableTab {
  private label: string = '';
  private isActive: boolean = false;
  private onTabClick: () => void = () => {};
  private index: number = 0;

  build() {
    Column() {
      // 标签文字
      Text(this.label)
        .fontSize(15)
        .fontColor(this.isActive ? '#3742fa' : '#666666')
        .fontWeight(this.isActive ? FontWeight.Bold : FontWeight.Normal)
        .lineHeight(22)
        .padding({ left: 16, right: 16, top: 12, bottom: 8 })

      // 选中指示条(仅选中时显示)
      if (this.isActive) {
        Column()
          .width(24)
          .height(3)
          .backgroundColor('#3742fa')
          .borderRadius(2)
      }
    }
    .alignItems(HorizontalAlign.Center)
    .height('100%')
    .onClick(() => { this.onTabClick(); })
  }
}

设计要点

  • 选中状态通过 this.isActive 控制,这是一个由父组件传入的 private 属性,通过父组件的 @State 驱动
  • 选中时文字变蓝(#3742fa)、加粗(FontWeight.Bold);未选中时为灰色(#666666)、常规字重
  • 底部指示条使用 if (this.isActive) 条件渲染——选中时显示一个宽 24vp、高 3vp 的蓝色圆角矩形,未选中时完全消失
  • 左右 padding 各 16vp 为标签之间提供了足够的间隔,使得标签文字不会互相粘连

为什么指示条宽度(24vp)远小于标签文字宽度?这是一个视觉设计上的权衡——窄指示条比全宽指示条更精致,且不会因为标签文字长度不同而造成视觉上的"长短不一"。网易云音乐、微博等主流应用的标签栏都采用这种窄指示条设计。

3.3.2 核心:Scroll + Row 横向滚动标签栏
// 创建 Scroller 控制器(用于编程式滚动)
private scroller: Scroller = new Scroller();

// ════════════════════════════════════════════════════════
//  ↓↓↓ 核心:Scroll(.horizontal) + Row + clip(true) ↓↓↓
// ════════════════════════════════════════════════════════
Scroll(this.scroller) {
  Row() {
    ForEach(this.tabItems, (item: TabItemData, idx: number) => {
      ScrollableTab({
        label: item.label,
        isActive: idx === this.activeIndex,
        index: idx,
        onTabClick: () => { this.onTabClick(idx); }
      })
    })
  }
  .height(48)
  .alignItems(VerticalAlign.Center)
}
.scrollable(ScrollDirection.Horizontal)   // ← ★ 水平滚动
.clip(true)                               // ← ★ 裁剪溢出
.scrollBar(BarState.Off)                  // ← 隐藏滚动条
.width('100%')

布局结构分析

Scroll(可视宽度 = 屏幕宽度)
  └── Row(宽度 = 所有标签宽度之和 > 屏幕宽度 → 触发滚动)
       ├── ScrollableTab("推荐")
       ├── ScrollableTab("科技")
       ├── ScrollableTab("设计")
       ├── ScrollableTab("产品")
       ├── ScrollableTab("开发")
       ├── ScrollableTab("商业")
       ├── ScrollableTab("职场")
       └── ScrollableTab("生活")

关键点:Row 自身没有设置 width 属性,因此它的宽度由其子组件(标签)的总宽度撑开。8 个标签,每个标签的内容宽度约 50-70vp,加上左右各 16vp 的 padding,总宽度远超主流手机屏幕宽度(约 360vp),因此 Scroll 自动启用水平滑动。

clip(true) 在这里的作用:如果不设置 clip,当用户滑动 Scroll 时,部分标签的残影可能会溢出到 Scroll 容器之外,造成视觉错乱。clip(true) 确保只有 Scroll 可视区域内的内容被显示。

3.3.3 选中标签自动居中滚动
private onTabClick(index: number): void {
  this.activeIndex = index;

  // 估算每个标签宽度
  const tabWidth: number = 70;            // vp
  const totalWidth: number = tabWidth * this.tabItems.length;
  const scrollWidth: number = 360;        // 屏幕估算宽度

  // 计算目标偏移:目标标签在 Scroll 中居中
  let targetOffset: number = index * tabWidth - (scrollWidth - tabWidth) / 2;

  // 边界限制
  targetOffset = Math.max(0, Math.min(targetOffset, totalWidth - scrollWidth));

  // 编程式滚动(带动画)
  this.scroller.scrollTo({
    xOffset: targetOffset,
    yOffset: 0,
    animation: { duration: 300, curve: Curve.EaseInOut }
  });
}

居中滚动计算公式

目标偏移 = 标签左边缘 - 想让标签居中所需的偏移量
        = index × tabWidth - (scrollWidth / 2 - tabWidth / 2)
        = index × tabWidth - (scrollWidth - tabWidth) / 2

边界限制

  • Math.max(0, ...):偏移量不能为负,否则会露出 Scroll 左侧的空白区域
  • Math.min(..., totalWidth - scrollWidth):偏移量不能超过最大允许偏移量,否则右侧会露出空白

scrollTo 的参数详解

this.scroller.scrollTo({
  xOffset: targetOffset,    // 水平偏移量(vp)
  yOffset: 0,               // 垂直偏移量(设为 0)
  animation: {
    duration: 300,          // 动画时长(毫秒)
    curve: Curve.EaseInOut  // 动画曲线:先加速后减速
  }
});

Curve.EaseInOut 是 ArkUI 提供的内置动画曲线之一,它使滚动动画开始时缓慢加速、结束时缓慢减速,产生自然流畅的滚动效果。其他常用曲线包括 Curve.Linear(匀速)、Curve.EaseIn(加速)、Curve.EaseOut(减速)。

3.4 TabContentPanel——与标签联动的内容面板

当用户切换标签时,内容面板同步更新:

@Component
struct TabContentPanel {
  private activeTab: string = '';
  private tabDataList: TabItemData[];

  build() {
    // 从列表中匹配当前标签的数据
    const currentData = this.tabDataList.find(
      (item) => item.label === this.activeTab
    );

    Column() {
      Text(currentData?.icon || '📄').fontSize(48).margin({ bottom: 16 })
      Text(this.activeTab).fontSize(20).fontWeight(FontWeight.Bold)
      Text(currentData?.desc || '暂无内容').fontSize(14).fontColor('#666')

      Divider().height(1).width('60%').color('#e8e8e8').margin({ top: 20, bottom: 16 })

      // 颜色标识
      Row() {
        Column().width(10).height(10).backgroundColor(this.panelColor).borderRadius(5)
        Text(`类别标识 · 当前查看「${this.activeTab}`).fontSize(12).fontColor('#999')
      }
    }
    .alignItems(HorizontalAlign.Center)
    .justifyContent(FlexAlign.Center)
    .layoutWeight(1)
    .backgroundColor('#ffffff').borderRadius(16)
    .shadow({ radius: 8, color: '#15000000', offsetX: 0, offsetY: 4 })
  }
}

currentData?.icon 使用了可选链操作符 ?.,当 find 方法未匹配到数据返回 undefined 时,不会引发运行时错误,而是以 '📄' 作为默认图标。

layoutWeight(1) 使内容面板占满 Column 中的弹性空间,确保面板在不同屏幕高度下都能撑满。

3.5 CategoryQuickEntry——嵌套水平滚动

本示例在内容面板下方还嵌入了一个第二层水平滚动——快捷筛选入口,展示了 Scroll 嵌套使用的能力:

@Component
struct CategoryQuickEntry {
  private items: QuickEntryItem[] = [];

  build() {
    Column() {
      Text('🔍 快捷筛选(横向滚动)').fontSize(13).fontWeight(FontWeight.Bold)

      // ★ 嵌套水平滚动 ★
      Scroll() {
        Row() {
          ForEach(this.items, (item: QuickEntryItem) => {
            Column() {
              Text(item.icon).fontSize(24).width(48).height(48)
                .backgroundColor('#f0f4f8').borderRadius(24).lineHeight(48)
              Text(item.label).fontSize(11).fontColor('#555').margin({ top: 4 })
            }
            .width(64)
          })
        }
        .height(80)
        .alignItems(VerticalAlign.Center)
      }
      .scrollable(ScrollDirection.Horizontal)  // 水平滚动
      .scrollBar(BarState.Off)                 // 隐藏滚动条
      .clip(true)                              // 裁剪溢出
      .width('100%')
    }
    .width('100%')
    .padding(14)
    .backgroundColor('#fafbfc')
    .borderRadius(12)
  }
}

这里的 Scroll 没有传入 Scroller 控制器(不需要编程式滚动),直接使用默认构造。12 个快捷入口(头条、热榜、视频、音乐、阅读、游戏等)在 Row 中水平排列,超出宽度时可通过手势滑动查看。

嵌套滚动的注意事项:在 ArkUI 中,Scroll 可以无限嵌套,但需要注意手势冲突问题。当内层 Scroll 和外层 Scroll 的滚动方向相同时(例如都是水平方向),内层优先响应手势;当内层到达边界后,手势才会传递给外层。本示例中外层是标签导航、内层是快捷入口,两者都是水平滚动,内层优先响应,符合用户的操作直觉。

四、Row + SpaceBetween 两端对齐导航栏

项目中 RowSpaceBetweenDemo.ets 页面(原核心页面,入口由 EntryAbility 动态切换)展示了 Row 的另一种重要布局模式——SpaceBetween 两端对齐。

4.1 布局对比

RowScrollDemoRowSpaceBetweenDemo 代表了 Row 布局的两个极端场景:

场景 容器 布局模式 子组件数量 宽度处理
横向标签栏 Scroll + Row 默认(Start) 多(>5) 由内容撑开,超出可滚动
导航栏 Row SpaceBetween 固定(2-3) 首尾贴紧,中间留空

SpaceBetween 的核心代码:

Row() {
  NavTitle({ title: this.title, subtitle: this.subtitle })
  Blank()                    // 弹性隔板
  Row() {                    // 按钮组
    NavActionButton({ icon: '🔔' })
    NavActionButton({ icon: '☆' })
    NavActionButton({ icon: '⋯' })
  }
}
.justifyContent(FlexAlign.SpaceBetween)  // 首尾贴边
.alignItems(VerticalAlign.Center)
.width('100%')

Blank() 的作用:在 SpaceBetween 模式下,Blank() 充当弹性隔离带,确保当右侧按钮组动态消失时,标题不会被拉向右侧。这是保证布局鲁棒性的关键设计。

4.2 三种 Space 模式的间距对比

RowScrollDemo 页面底部的布局说明区也汇总了三种模式的对比:

模式 首贴边 尾贴边 中间间距 适用场景
SpaceBetween ✅ 是 ✅ 是 均分 导航栏、底部栏
SpaceAround ❌ 半间距 ❌ 半间距 均分且 > 外侧 标签组、图表
SpaceEvenly ✅ 全间距 ✅ 全间距 均分 工具栏、操作栏

间距公式(N 个子组件,W = 容器总宽,S = 子组件宽度和):

SpaceBetween: 间距 = (W - S) / (N - 1),首尾贴边
SpaceAround:  间距 = (W - S) / N,首尾半间距
SpaceEvenly:  间距 = (W - S) / (N + 1),全间距相等

五、Column + layoutWeight 弹性自适应布局

RunnerPage.ets 展示了 Column 布局与 layoutWeight 弹性权重的结合应用,这是一个跑酷小游戏的 UI 架构。

5.1 弹性分区架构

Column() {
  Row{ 顶部状态栏 }              固定 height(50)
  Column{ Canvas 游戏场景 }      layoutWeight(1.0) — 50%
  Column{ 状态信息区 }           layoutWeight(0.3) — 15%
  Column{ 跳跃按钮区 }           layoutWeight(0.7) — 35%
  Column{ 布局说明面板 }          内容撑高(无 layoutWeight)
}
.width('100%').height('100%')     ← ★ 必须设置 height('100%')

弹性高度计算:

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

三个弹性区的 layoutWeight 之和 = 1.0 + 0.3 + 0.7 = 2.0。当屏幕高度为 800vp 时:

  • 固定区占用 50vp + 说明面板(约 120vp)= 170vp
  • 剩余空间 = 800 - 170 = 630vp
  • Canvas 区 = 630 × 1.0/2.0 = 315vp(约 50% 弹性空间)
  • 状态信息区 = 630 × 0.3/2.0 = 94.5vp(约 15%)
  • 跳跃按钮区 = 630 × 0.7/2.0 = 220.5vp(约 35%)

5.2 Canvas 游戏引擎

游戏的物理引擎采用离散帧循环模拟:

private gameLoop(): void {
  // 1. 重力加速度
  this.playerVY += GRAVITY;
  this.playerY += this.playerVY;

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

  // 3. 障碍物移动
  this.curSpeed = BASE_SPEED + this.score * SPEED_STEP;
  // ... 每个障碍物 x -= curSpeed

  // 4. AABB 碰撞检测
  for (let i = 0; i < this.obstacles.length; i++) {
    if (px < ox + O_W && px + P_SIZE > ox &&
        py < oy + O_H && py + P_SIZE > oy) {
      this.gameOver();
    }
  }

  // 5. 绘制场景
  this.drawScene();
}

// 帧循环驱动(24ms ≈ 42fps)
this.timerId = setInterval(() => { this.gameLoop(); }, 24);

渲染管线依次绘制:天空渐变背景 → 地面(含草地边沿和纹理线)→ 障碍物(带 X 装饰)→ 角色(圆角矩形身体、眼睛、微笑、跳跃喷气效果)→ UI 覆盖层(游戏结束 / 准备蒙层)。

六、可复用组件设计模式

CommonComponents.ets 提供了四个可复用的 UI 组件,展示了 ArkTS 组件化开发的最佳实践。

6.1 Card——通用卡片容器(@BuilderParam 插槽)

@Component
export struct Card {
  @Prop cardPadding: number = 16;
  @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)
    .shadow({ radius: 4, color: '#1a000000', offsetX: 0, offsetY: 2 })
  }
}

@BuilderParam 是 ArkTS 实现插槽(Slot)功能的唯一方式。与 Vue 的 <slot> 或 React 的 children 不同,ArkTS 要求父组件通过具名参数传递构建函数,且容器组件需要显式声明 @BuilderParam 属性并提供默认实现。

使用方式:

Card({ cardPadding: 20 }) {
  Column() {
    Text('自定义内容')
    Button('点击')
  }
}

6.2 ProgressRing——圆形进度条(Stack + 双 Canvas)

@Component
export struct ProgressRing {
  @Prop ringProgress: number = 0;
  @Prop ringSize: number = 80;
  @Prop ringColor: string = '#3a7bd5';

  build() {
    Stack() {
      Canvas(this.ringContext)       // 背景环
        .onReady(() => { this.drawRing(this.ringContext, 100, true); })
      Canvas(this.progressContext)   // 进度环
        .onReady(() => { this.drawRing(this.progressContext, this.ringProgress, false); })
      Column() {                     // 中央文字
        Text(this.ringValue).fontSize(16).fontWeight(FontWeight.Bold)
      }
    }
  }
}

使用两个独立的 CanvasRenderingContext2D 实例是因为 ArkUI 的 Canvas 上下文不支持在同一实例上保留多个独立的绘制状态。两个 Canvas 通过 Stack 层叠容器在 Z 轴上重叠,配合中央的 Column 文字区域,构成完整的圆形进度条。

6.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 正方形
    .backgroundColor(this.entryColor + '18')  // 颜色 + 透明度
    .borderRadius(16)
    .onClick(() => { this.onClickAction(); })
  }
}

aspectRatio(1.0) 是维持宽高比的关键属性。无论父容器宽度如何变化,该卡片始终维持正方形比例,配合 .width('30%') 可在网格中整齐排列。

this.entryColor + '18' 通过字符串拼接为颜色值追加 Alpha 通道。'18' 对应十六进制的 24,约 9% 不透明度,实现柔和的半透明背景效果。

七、数据模型与 SM-2 算法

7.1 数据结构体系

AppModel.ets 定义的 WordItem 接口包含了英语学习核心数据字段:

export interface WordItem {
  id: number;
  word: string;
  phonetic: string;
  translation: string;
  partOfSpeech: string;
  exampleSentence: string;
  exampleTranslation: string;
  difficulty: Difficulty;  // EASY=1, MEDIUM=2, HARD=3
  category: string;        // 基础词汇 / 核心词汇 / 进阶词汇 / 学术词汇
  audioPath: string;
}

每个字段都有明确的语义和类型约束。difficulty 使用枚举类型而非硬编码数字,提供了类型安全和代码可读性。

7.2 SM-2 间隔重复算法

SpacedRepetition.ets 实现了 SuperMemo 2 算法,这是最广泛使用的间隔重复算法之一:

export class SpacedRepetitionEngine {
  static readonly DEFAULT_EF = 2.5;
  static readonly MIN_EF = 1.3;
  static readonly MAX_INTERVAL = 180;

  static schedule(
    quality: number,           // 0-5 回答质量
    previousInterval: number,  // 上次间隔天数
    repetition: number,        // 连续正确次数
    previousEf: number = SpacedRepetitionEngine.DEFAULT_EF,
  ): ReviewResult {
    // 回答不合格 → 重置为明天复习
    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天 → previousInterval × 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),
    };
  }

  private static updateEf(oldEf: number, quality: number): number {
    return Math.max(
      oldEf + (0.1 - (5 - quality) * (0.08 + (5 - quality) * 0.02)),
      SpacedRepetitionEngine.MIN_EF
    );
  }
}

算法核心逻辑

  1. 回答质量(Quality):用户每次复习时自评 0-5 分。5 分代表完全正确且流畅,0 分代表完全忘记。

  2. 不合格重置机制:当 quality < 3(回忆失败),间隔立即重置为 1 天,连续正确次数归零。这模拟了艾宾浩斯遗忘曲线——刚学过的内容如果回忆失败,说明记忆不牢固,需要更频繁地复习。

  3. 间隔指数增长:连续正确时,复习间隔从 1 天 → 3 天 → 3×EF(约 8 天)→ 8×EF(约 19 天)→ … 呈指数级扩展。这正是"间隔重复"名称的由来——通过不断拉长复习间隔来巩固长期记忆。

  4. EF 动态调整:易度系数(Ease Factor)随每次复习表现自动调整。答得好 EF 增加(间隔增长更快),答得差 EF 降低(间隔增长更慢),实现了针对每个单词的个性化学习计划。

八、工程化最佳实践

8.1 生命周期管理与资源清理

aboutToDisappear(): void {
  // 页面消失时强制清理定时器
  if (this.timerId >= 0) {
    clearInterval(this.timerId);
    this.timerId = -1;
  }
}

这是一个看似简单但极其重要的实践。setInterval / setTimeout 如果不清理,会在页面跳转后继续在后台运行,导致:

  • Canvas 渲染到不可见的画布上(性能浪费)
  • 访问已销毁的组件属性(逻辑错误)
  • 阻止垃圾回收(内存泄漏)

8.2 @State 最小化原则

// ✅ 正确的做法
@State score: number = 0;         // 直接影响 UI 显示
@State timeLeft: number = 30;     // 直接影响 UI 显示
private timerId: number = -1;     // 不影响 UI → 普通变量

// ❌ 不推荐
@State timerId: number = -1;      // 每次赋值都触发 build()

只有直接影响 build() 方法中 UI 渲染的变量才使用 @State 装饰。定时器句柄、Canvas 上下文、内部缓存计数器等不直接出现在 UI 中的变量,应使用普通成员变量存储。这可以避免大量不必要的 UI 重建,提升帧率和响应速度。

8.3 hilog 日志系统

项目使用 hilog 而非 console.log 输出日志:

const TAG = 'RowScrollDemo';
hilog.info(0x0000, TAG, 'Tab clicked: %s, index=%d', label, index);

hilog.info(domain, tag, format, ...args) 的优势:

  • 支持按域名(domain)和标签(tag)过滤日志
  • 在 DevEco Studio 的 Logcat 面板中可精确匹配调试信息
  • 生产环境下可通过日志级别控制输出量

8.4 路由注册

module.json5 中的 "pages": "$profile:main_pages" 引用了一个资源配置文件,该文件位于 resources/base/profile/main_pages.json,定义应用中所有页面的路径映射。每个注册的路径对应一个 @Entry 装饰的组件,loadContent 方法从这个路由表中查找并加载对应页面。

九、总结与展望

本文以 HarmonyOS NEXT 6.1.1(API 24)为开发平台,通过一个功能完整的鸿蒙原生应用项目,深入剖析了 ArkTS 开发中的五大核心技术领域。

第一,Row + Scroll(.horizontal) 横向滚动标签布局。这是本文的核心主题。通过 Scroll 容器配合 Row 水平排列,结合 scrollable(Horizontal)clip(true)scrollBar(Off) 三个关键属性,实现了流畅的横向标签页滚动体验。Scroller 控制器配合 scrollTo() 方法实现了选中标签的自动居中滚动,Curve.EaseInOut 动画曲线提供了自然的滚动动效。

第二,ArkUI 弹性盒模型全面掌握。从 Scroll + Row 的水平滚动,到 Row + SpaceBetween 的两端对齐,再到 Column + layoutWeight 的弹性分区——ArkUI 使用统一的弹性盒模型体系,通过 justifyContent、alignItems、layoutWeight 等核心属性,覆盖了移动应用开发中几乎所有常见的布局场景。

第三,组件化设计模式。通过 @Component 封装 UI 单元、@Prop 实现单向数据流、@BuilderParam 实现内容插槽、@State 驱动响应式更新——ArkTS 为组件化开发提供了完整的语言支持。

第四,Canvas 游戏编程与响应式 UI。RunnerPage 的 Canvas 手动渲染与 LightChaseGame 的 @State 声明式渲染,代表了 ArkTS 游戏开发的两条技术路线。前者性能可控、绘制灵活;后者开发高效、维护简单。

第五,数据模型与算法引擎。从 interface 基础类型定义到 SM-2 间隔重复算法的完整实现,展示了 ArkTS 处理复杂业务逻辑的能力。算法的每个参数都有明确的物理意义和数学推导,代码可直接移植到生产环境使用。

鸿蒙生态正处于从发展期走向成熟期的关键阶段。掌握 ArkUI 布局体系的底层原理、理解声明式 UI 的编程范式、熟悉组件化开发的工程模式,是每一位鸿蒙开发者从入门到精通的必经之路。希望本文能够为开发者在 ArkTS 的学习和实践中提供切实可用的参考,帮助大家构建出更优雅、更流畅、更易维护的鸿蒙原生应用。

Logo

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

更多推荐