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

一、引言

在移动应用开发中,导航栏是最基础也最关键的 UI 组件之一。几乎每一个应用的顶部都有一条导航栏:左侧显示标题或返回按钮,右侧放置操作入口。如何高效、精确地实现这种"标题在左、按钮在右"的布局,直接关系到开发效率和 UI 一致性。

鸿蒙操作系统(HarmonyOS)自诞生以来持续演进,HarmonyOS NEXT 版本去除了 AOSP 兼容层,实现了完全的鸿蒙原生体验。开发者需要使用纯正的 ArkTS 语言与 ArkUI 框架进行原生应用开发。在此背景下,深入理解 ArkUI 的弹性盒模型(Flexbox)布局体系,掌握 Row 容器的 justifyContent 属性,成为每一位鸿蒙开发者必须攻克的核心技能。

本文将以一个完整的鸿蒙原生应用项目为例,从导航栏布局切入,系统性地讲解 Row 容器的 SpaceBetween 两端对齐布局,并辐射到 SpaceAroundSpaceEvenly 的详细对比、Column 布局、弹性权重 layoutWeight、Canvas 游戏渲染、可复用组件设计、SM-2 间隔重复算法等全方位内容。文章所有代码均来自真实项目,经过 PreBuildApp 编译验证,可在 HarmonyOS NEXT 6.1.1(API 24)DevEco Studio 中直接运行。

二、项目全景概览

2.1 项目基本信息

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

项目的 build-profile.json5 文件中明确了 SDK 版本配置:

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

targetSdkVersioncompatibleSdkVersion 均设为 6.1.1(24),表明本应用仅面向 HarmonyOS NEXT 运行。caseSensitiveCheck: true 开启了大小写敏感检查,这意味着在 ArkTS 代码中,组件名、属性名、枚举值的大小写必须与 API 文档严格一致,这是鸿蒙较新版本引入的严格模式特性。

2.2 项目目录结构剖析

entry/src/main/ets/
├── entryability/               # Ability 入口层
│   └── EntryAbility.ets        # 应用启动入口,页面路由加载
├── pages/                      # 页面层(@Entry 装饰)
│   ├── Index.ets               # Column + FlexAlign.Start 演示
│   ├── RowSpaceBetweenDemo.ets # ★ Row + SpaceBetween 导航栏(核心)
│   ├── LightChaseGame.ets      # 光点追逐游戏
│   └── RunnerPage.ets          # 单键跑酷游戏(Canvas + layoutWeight)
├── components/                 # 可复用组件层
│   └── CommonComponents.ets    # 通用组件库(Card、ProgressRing 等)
└── model/                      # 数据模型与算法层
    ├── AppModel.ets            # 数据结构定义(WordItem、StudyRecord 等)
    ├── SampleData.ets          # 示例数据(30 个单词、阅读文章、语法题)
    └── SpacedRepetition.ets    # SM-2 间隔重复算法引擎

这种分层架构的设计思想:entryability 管生命周期和路由,pages 管页面展示和交互,components 管组件复用,model 管业务逻辑和数据。每层职责单一,上层依赖下层,下层不依赖上层,符合软件工程的高内聚低耦合原则。

2.3 应用入口与页面路由

EntryAbility.ets 是应用的唯一 Ability,采用 Stage 模型的 UIAbility 基类:

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/RowSpaceBetweenDemo', (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');
  }
}

windowStage.loadContent('pages/RowSpaceBetweenDemo', callback) 是页面路由的核心方法。第一个参数是页面路径,对应 resources/base/profile/main_pages.json 中注册的页面路由。第二个参数是回调函数,通过 err.code 判断加载是否成功。

值得注意的 API 变化:在 HarmonyOS NEXT(API 24)中,import 路径采用 Kit 化命名空间。如 hilog 来自 @kit.PerformanceAnalysisKit,window 来自 @kit.ArkUI。这是鸿蒙 API 治理的重要演进——所有 API 按功能领域归入对应的 Kit,取代了早期版本中零散的 @ohos.* 导入方式。

三、Row + SpaceBetween 两端对齐导航栏(核心专题)

3.1 需求场景分析

考虑一个标准的顶部导航栏,它的结构通常是这样的:

┌──────────────────────────────────────────────┐
│  ← 返回    页面标题              🔔  ☆  ⋯   │
├──────────────────────────────────────────────┤
│                                               │
│               内容区域                         │
│                                               │
└──────────────────────────────────────────────┘

这个布局有三个关键需求:

  1. 标题文本位于左侧,贴紧左边缘
  2. 操作按钮位于右侧,贴紧右边缘
  3. 标题与按钮之间自动撑满间距

如果使用绝对定位或手动计算 padding,代码会变得脆弱——屏幕尺寸变化、标题长度变化、按钮数量变化,都需要重新计算间距。而使用 Row + justifyContent(SpaceBetween) 则可以用一行代码优雅解决。

3.2 SpaceBetween 布局原理

justifyContent 是 ArkUI 弹性盒模型中控制主轴(Main Axis)排列方式的核心属性。对于 Row 容器,主轴是水平方向(从左到右)。

FlexAlign.SpaceBetween 的行为:

  • 第一个子组件:贴紧容器的主轴起始边缘(左边缘)
  • 最后一个子组件:贴紧容器的主轴结束边缘(右边缘)
  • 中间子组件:均匀分布在首尾之间的剩余空间中

间距的计算公式为:

当 N >= 2 时:
    间距数量 = N - 1
    每个间距宽度 = (容器总宽度 - 所有子组件宽度之和) / (N - 1)
当 N = 1 时:
    等同于 FlexAlign.Start,单子组件靠左排列
当 N = 0 时:
    空容器,无效果

其中 N 是 Row 中子组件的数量。这种布局模式最核心的特点就是首尾贴边,这是它与 SpaceAround(首尾有半间距)和 SpaceEvenly(全间距相等)的本质区别。

3.3 组件架构设计

导航栏的实现分为四个层次,从上到下依次封装:

NavigationBar(整体导航栏)
  ├── NavTitle(左侧标题组件)
  ├── Blank(弹性隔板)
  └── Row(操作按钮组容器)
        ├── NavActionButton(单个按钮:图标 + 角标)
        ├── NavActionButton(单个按钮:图标 + 角标)
        └── NavActionButton(单个按钮:图标 + 角标)
3.3.1 底部组件:NavTitle(标题)
@Component
struct NavTitle {
  private title: string = '';
  private subtitle: string = '';

  build() {
    Column() {
      Text(this.title)
        .fontSize(18)
        .fontWeight(FontWeight.Bold)
        .fontColor('#ffffff')
        .lineHeight(26)

      if (this.subtitle.length > 0) {
        Text(this.subtitle)
          .fontSize(11)
          .fontColor('#cce0ff')
          .margin({ top: 1 })
          .lineHeight(16)
      }
    }
    .alignItems(HorizontalAlign.Start)
  }
}

这个组件使用 Column 将主标题和副标题纵向排列。if (this.subtitle.length > 0) 实现了条件渲染:只有当副标题有内容时才显示,避免多余的空元素占用布局空间。.alignItems(HorizontalAlign.Start) 确保文字左对齐。

设计细节:主标题 18sp、副标题 11sp 的大小比例,配合 FontWeight.Bold 加粗和 #cce0ff 浅色,形成了清晰的视觉层级。主标题醒目标识当前页面,副标题补充说明页面状态,两者间距仅 1vp,视觉上紧凑不松散。

3.3.2 底部组件:NavActionButton(操作按钮)
@Component
struct NavActionButton {
  private icon: string = '';
  private onTap: () => void = () => {};
  private badge: number = 0;

  build() {
    Stack() {
      Text(this.icon)
        .fontSize(22)
        .fontColor('#ffffff')
        .width(40)
        .height(40)
        .textAlign(TextAlign.Center)

      if (this.badge > 0) {
        Text(this.badge > 99 ? '99+' : `${this.badge}`)
          .fontSize(10)
          .fontColor('#ffffff')
          .backgroundColor('#ff4757')
          .borderRadius(9)
          .width(18)
          .height(18)
          .textAlign(TextAlign.Center)
          .lineHeight(18)
          .position({ x: '65%', y: '-5%' })
      }
    }
    .width(40)
    .height(40)
    .onClick(() => {
      hilog.info(0x0000, TAG, `NavActionButton clicked: ${this.icon}`);
      this.onTap();
    })
  }
}

这里使用了 Stack 容器将图标与角标层叠在一起。

角标实现的三个关键技巧

  • 条件渲染if (this.badge > 0) 确保无角标时不影响图标显示
  • 数字截断this.badge > 99 ? '99+' : ${this.badge}`` 处理了三位数以上的情况,避免角标溢出
  • 定位偏移.position({ x: '65%', y: '-5%' }) 使用百分比坐标将角标定位到右上角。这里的 65% 和 -5% 是相对于 Stack 容器的宽高比例——x 从 50%(水平居中)调整到 65%(偏右),y 从 0(顶部)调整到 -5%(略高于顶边)

角标的视觉设计采用了红色背景(#ff4757)+ 白色文字,这是标准的通知角标配色方案。borderRadius(9) 配合固定的 width(18) / height(18) 形成了一个正圆形角标(圆角半径为宽高的一半)。

3.3.3 核心组件:NavigationBar(导航栏)
@Component
struct NavigationBar {
  private title: string = '';
  private subtitle: string = '';
  private actions: NavAction[] = [];
  private barColor: string = '#2d5f8a';

  build() {
    Row() {
      // ── 左侧:标题区 ──
      NavTitle({ title: this.title, subtitle: this.subtitle })

      // ── 中间:Blank 弹性隔板 ──
      Blank()

      // ── 右侧:操作按钮组 ──
      Row() {
        ForEach(this.actions, (action: NavAction) => {
          NavActionButton({
            icon: action.icon,
            badge: action.badge,
            onTap: action.onTap
          })
        })
      }
    }
    .justifyContent(FlexAlign.SpaceBetween)   // ← ★ 核心:两端对齐 ★
    .alignItems(VerticalAlign.Center)         // ← 交叉轴垂直居中
    .width('100%')
    .height(56)
    .padding({ left: 20, right: 8 })
    .backgroundColor(this.barColor)
  }
}

为什么需要在 SpaceBetween 的基础上再加一个 Blank?

这是一个非常精妙的设计细节。从表面上看,SpaceBetween 本身已经能让左侧标题和右侧按钮组"首尾贴边"。但实际开发中,actions 数组可能是动态的——在某些页面中可能没有任何操作按钮(空数组),此时如果不加 Blank,标题就会因为没有"对端"而直接占据整行宽度,无法保持在左侧。

Blank() 组件的作用是充当一个"弹性隔离带"——当右侧有按钮组时,它占据中间的空隙;当右侧按钮组全部消失时,它膨胀撑满剩余空间,将标题"推"在左侧。这是一个让布局在各种动态组合下都保持稳定的通用设计模式。

padding 的细节:左侧 padding 为 20vp,右侧 padding 为 8vp。这是因为右侧的图标按钮本身已有 40vp 的宽度和内置的点击区域,右侧更小的 padding 配合图标内部的点击区域,整体触感是一致的。

3.4 主页面:RowSpaceBetweenPage

主页面 RowSpaceBetweenPage 使用 @Entry @Component 装饰,是应用启动后直接加载的页面。

@Entry
@Component
struct RowSpaceBetweenPage {
  @State messageCount: number = 3;
  @State isStarred: boolean = false;
  @State currentPage: string = '首页';
  @State currentPageDesc: string = '欢迎使用,您当前在首页浏览';

  private getNavActions(): NavAction[] {
    return [
      {
        icon: '🔔',
        badge: this.messageCount,
        onTap: () => {
          this.messageCount = 0;
        }
      },
      {
        icon: this.isStarred ? '⭐' : '☆',
        badge: 0,
        onTap: () => {
          this.isStarred = !this.isStarred;
        }
      },
      {
        icon: '⋯',
        badge: 0,
        onTap: () => { }
      },
    ];
  }
}

@State 的响应式驱动:四个 @State 变量分别控制消息角标数、收藏状态、当前页面标题和描述。当用户点击导航栏的铃铛时,messageCount 被置为 0,NavActionButtonbadge 属性随之变为 0,角标自动隐藏——这一切都由 ArkUI 的响应式框架自动完成,无需手动操作 DOM。

导航栏动作配置中第二个按钮使用 this.isStarred ? '⭐' : '☆' 三元表达式实现了收藏状态的切换。图标本身即为状态指示器——实心星表示已收藏,空心星表示未收藏。

页面导航区也使用了同样的 Row + SpaceBetween 布局:

Row() {
  ForEach(this.pages, (page: PageItem) => {
    Column() {
      Text(page.icon).fontSize(24).width(48).height(48)
        .textAlign(TextAlign.Center).lineHeight(48)
        .backgroundColor(this.currentPage === page.name ? (page.color + '30') : '#f5f7fa')
        .borderRadius(24)

      Text(page.name).fontSize(11)
        .fontColor(this.currentPage === page.name ? page.color : '#888')
        .fontWeight(this.currentPage === page.name ? FontWeight.Bold : FontWeight.Normal)
        .margin({ top: 4 })
    }
    .onClick(() => {
      this.currentPage = page.name;
      this.currentPageDesc = page.desc;
    })
  })
}
.justifyContent(FlexAlign.SpaceBetween)
.alignItems(VerticalAlign.Center)
.width('100%')
.padding({ left: 20, right: 20 })

四个页面导航按钮(首页、发现、消息、个人中心)在水平方向均匀分布。选中状态的按钮通过 page.color + '30' 拼接半透明背景色高亮显示。这里 '30' 是十六进制透明度值,对应约 19% 的不透明度,实现了柔和的高亮效果。

pages.find(p => p.name === this.currentPage)?.color 使用了可选链操作符 ?.,这是 ArkTS 支持的安全访问语法——当 find 返回 undefined 时,不会抛出异常,而是返回 undefined,再由 || 运算符回退到默认值。

四、三种布局模式深度对比

在同一页面中,使用 LayoutCompareCard 组件并排展示了 SpaceBetweenSpaceAroundSpaceEvenly 三种布局模式,帮助开发者直观感受其视觉差异。

4.1 对比组件实现

@Component
struct LayoutCompareCard {
  private modeName: string = '';
  private modeDesc: string = '';
  private flexAlignValue: FlexAlign = FlexAlign.SpaceBetween;
  private dotColors: string[] = ['#ff6b6b', '#ffdd57', '#2ed573'];

  build() {
    Column() {
      Text(this.modeName).fontSize(14).fontWeight(FontWeight.Bold)
        .fontColor('#1a1a2e').margin({ bottom: 4 })

      Text(this.modeDesc).fontSize(11).fontColor('#888888')
        .lineHeight(16).margin({ bottom: 10 })

      // ★ 三个颜色圆块使用不同的 justifyContent ★
      Row() {
        ForEach(this.dotColors, (color: string) => {
          Column() {
            Column().width(28).height(28).backgroundColor(color).borderRadius(14)
            Text(`${this.dotColors.indexOf(color) + 1}`)
              .fontSize(9).fontColor('#999').margin({ top: 4 })
          }
        })
      }
      .justifyContent(this.flexAlignValue)   // ← 动态切换
      .alignItems(VerticalAlign.Center)
      .width('100%').height(60)
      .padding({ left: 4, right: 4 })
      .backgroundColor('#f8f9fc').borderRadius(10)
    }
    // ...
  }
}

4.2 间距公式详解

为了精确理解三种模式的差异,下面是具体的间距计算公式。假设 Row 容器宽度为 W,N 个子组件的自然宽度之和为 S:

SpaceBetween(两端对齐)

间距数 = N - 1
每个间距 = (W - S) / (N - 1)
首贴左边 = 0,尾贴右边 = 0

适用于导航栏、底部工具栏等需要充分利用屏幕宽度的场景。

SpaceAround(环绕均匀)

每个子组件两侧间距 = (W - S) / N
首外侧间距 = 内侧间距 / 2
尾外侧间距 = 内侧间距 / 2

适用于流式标签、卡片网格等需要视觉上"每组独立"的场景。

SpaceEvenly(全等均匀)

间距数 = N + 1
每个间距 = (W - S) / (N + 1)
首外侧间距 = 中间间距 = 尾外侧间距

适用于操作栏、工具栏等需要完全均匀分布的场景。

4.3 直观对比效果

在页面上,三组同样为红、黄、绿三个圆块的 Row 容器,分别使用三种 FlexAlign 值:

  • SpaceBetween 组:第一个红色圆块紧贴左边,最后一个绿色圆块紧贴右边,黄色圆块居中
  • SpaceAround 组:三个圆块各自居中在其分配的区域内,外侧留有一定边距(约为中间间距的一半)
  • SpaceEvenly 组:三个圆块的左间距、中间两个间距、右间距完全相等

这种"同一组数据、三种布局模式"的直观对比,比任何文字描述都更能帮助开发者理解它们的区别。

五、Column 布局与弹性权重扩展

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

虽然本文核心是 Row 布局,但 Index.ets 页面中展示了 Column 的 justifyContent(FlexAlign.Start) 布局,与 Row 形成了完整的布局能力闭环。

Column() {
  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)                   // 弹性占满剩余空间

Column 与 Row 共享同一套弹性盒模型布局体系:

容器 主轴方向 交叉轴方向 justifyContent 作用 alignItems 作用
Column 垂直(↕) 水平(↔) 垂直排列方式 水平对齐方式
Row 水平(↔) 垂直(↕) 水平排列方式 垂直对齐方式

5.2 layoutWeight 弹性权重

layoutWeight 是 ArkUI 中极为实用的弹性分配属性。当 Column 或 Row 设置了固定尺寸(如 height('100%')),其子组件的 layoutWeight 按比例分配容器的剩余空间。

RunnerPage.ets 为例,它的布局架构如下:

Column() {
  Row(){ 顶部状态栏 }     .height(50)        ← 固定高度
  Column(){ Canvas 游戏区 } .layoutWeight(1.0) ← 占 50%
  Column(){ 状态信息区 }   .layoutWeight(0.3) ← 占 15%
  Column(){ 跳跃按钮区 }   .layoutWeight(0.7) ← 占 35%
  Column(){ 说明面板 }     (内容撑高)        ← 由内容决定高度
}
.width('100%')
.height('100%')            ← ★ 必须设置,否则 layoutWeight 不生效

弹性区高度计算公式:

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

三个弹性区的 layoutWeight 之和 = 1.0 + 0.3 + 0.7 = 2.0。因此:

  • Canvas 游戏区占比 = 1.0 / 2.0 = 50%
  • 状态信息区占比 = 0.3 / 2.0 = 15%
  • 跳跃按钮区占比 = 0.7 / 2.0 = 35%

使用 layoutWeight 的绝对前提:父容器必须有明确的高度值(通过 height('100%')height(固定数值)),否则布局系统无法确定"剩余空间"有多少,layoutWeight 会失效。这是初学者最容易踩的坑。

与 justifyContent 的区别layoutWeight 解决的是"空间分配"问题——每个子组件占据多少空间;justifyContent 解决的是"空间排列"问题——子组件在已有空间内如何分布。两者可以协同使用:先用 layoutWeight 分配空间,再用 justifyContent 控制各子组件内部内容的排列。

六、可复用组件设计模式

CommonComponents.ets 文件封装了四个可以在项目中反复使用的 UI 组件,体现了 ArkTS 组件化开发的最佳实践。

6.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 的 <slot> 或 React 的 children 概念。为了实现"组件内部嵌入自定义内容"的容器模式,ArkTS 提供了 @BuilderParam 装饰器。使用流程如下:

  1. 在容器组件中声明一个 @BuilderParam 类型的属性(如 content
  2. 设置默认的 @Builder 方法(如 defaultContent),确保无内容注入时也有占位渲染
  3. build() 中通过 this.content() 调用构建函数
  4. 父组件使用时,在大括号内传入构建函数体:
Card({ cardPadding: 20 }) {
  Column() {
    Text('自定义卡片内容')
    Button('点击')
  }
}

@Prop 装饰器:用于从父组件接收单向数据流。当父组件的属性变化时,子组件自动重新渲染。@Prop 装饰的变量必须有默认值,且是深拷贝的副本——子组件对 @Prop 变量的修改不会同步回父组件。

6.2 圆形进度条(ProgressRing)

ProgressRing 使用两个 Canvas 叠加渲染圆环进度:

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

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

      // 进度圆环(根据 ringProgress 绘制)
      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();
  }
}

两个 Canvas 上下文的原因:ArkUI 的 CanvasRenderingContext2D 不支持在同一实例上保留多个独立的绘制状态。每次调用 drawRing 都会在同一个 Canvas 上覆盖绘制。因此需要创建两个实例——ringContext 绘制静态的背景环,progressContext 绘制动态的进度环,两环通过 Stack 层叠在一起呈现完整效果。

arc 绘制的起始角度:使用 -Math.PI / 2(即 -90 度)作为起始角度,这对应钟表上的 12 点钟方向,是进度环的常规起始位置。如果使用默认的 0 度(3 点钟方向),进度环会从右侧开始,不符合常见的圆环进度视觉习惯。

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 正方形
    .justifyContent(FlexAlign.Center)
    .backgroundColor((this.entryColor + '18'))  // 半透明背景
    .borderRadius(16)
    .onClick(() => { this.onClickAction(); })
  }
}

aspectRatio(1.0):设置宽高比为 1:1,即正方形。配合 .width('30%') 使用,可以让入口卡片在网格中始终保持正方形的比例。如果父容器是 Row 或 Flex,这些正方形卡片会自动整齐排列。

颜色透明度拼接.backgroundColor(this.entryColor + '18') 通过字符串拼接在颜色值后追加两位十六进制数作为 Alpha 通道。'18' 对应十进制 24,约 9.4% 不透明度。这是一种简化的半透明色实现方式,无需调用 Color 工具方法。

6.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)
        if (this.headerSubtitle.length > 0) {
          Text(this.headerSubtitle).fontSize(12).fontColor('#cce0ff')
        }
      }
      Blank()
    }
    .width('100%')
    .padding({ top: 12, bottom: 12, left: 20, right: 20 })
    .backgroundColor('#2d5f8a')
  }
}

这个标题栏组件虽然没有显式声明 justifyContent(FlexAlign.SpaceBetween),但它实际上也是通过 Row + Blank() 实现了"标题在左,Blank 在右"的两端对齐布局——只不过右侧没有按钮组,Blank() 直接将标题推到左侧。

七、Canvas 游戏开发——弹性布局的实践验证

RunnerPage.ets 是本项目中布局技术最为综合的页面。它使用 Column + layoutWeight 实现弹性布局,使用 Canvas 实现游戏渲染。

7.1 弹性布局架构回顾

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

这种弹性布局架构的好处在于:无论屏幕尺寸如何变化,游戏场景区、信息区、操作区的比例始终保持不变。在折叠屏、平板等不同设备上,应用无需额外适配即可自动调整。

7.2 Canvas 游戏引擎

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

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

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

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

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

  // 绘制画面
  this.drawScene();
}

游戏循环帧率通过 setInterval 控制,每 24ms 执行一次,约合 42 FPS。帧循环内依次执行物理更新、碰撞检测、画面绘制三个步骤。

AABB 碰撞检测:轴对齐包围盒(Axis-Aligned Bounding Box)是最基础的碰撞检测算法。它假设角色和障碍物都是矩形(与坐标轴对齐),然后检测两个矩形是否重叠。四个不等式:

角色左 < 障碍物右  &&  角色右 > 障碍物左  &&  角色上 < 障碍物下  &&  角色下 > 障碍物上

如果四个条件同时满足,则判定为碰撞。这种算法简单高效,对于像素级精度的 2D 游戏完全足够。

速度递增公式curSpeed = BASE_SPEED + score * SPEED_STEP。初始速度为 2.5,每得 1 分增加 0.08,上限 7.0。经过计算,在得分约 56 分时达到最大速度。这种"越玩越快"的设计让游戏难度平滑递增,避免玩家感到突然的难度跳跃。

7.3 游戏的 Canvas 渲染管线

drawScene 方法实现了完整的渲染管线,绘制顺序从底层到顶层依次为:

private drawScene(): void {
  // 1. 天空渐变背景(线性渐变)
  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);

  // 2. 棕色地面 + 草边 + 纹理线
  // 3. 障碍物(带 X 装饰)
  // 4. 角色(圆角矩形 + 眼睛 + 微笑 + 跳跃喷气效果)
  // 5. 游戏结束蒙层 / READY 提示
}

createLinearGradient 创建从画布顶部到底部的垂直渐变,三种颜色的过渡形成了从天空到草地的自然视觉。这是一种无需任何图片资源的纯代码背景渲染方案。

角色绘制使用了 roundRect 绘制圆角矩形身体、arc 绘制眼睛和瞳孔、arc 绘制微笑曲线。跳跃时在角色下方增加黄色圆形表示喷气效果,这种细节增强了游戏的可玩性和视觉反馈。

八、光点追逐游戏——响应式布局的体现

LightChaseGame.ets 是项目中的第二个游戏,与 RunnerPage 的 Canvas 渲染不同,它完全使用 ArkUI 的声明式组件和 @State 响应式变量驱动。

8.1 游戏机制

// 游戏状态通过 @State 管理,UI 自动响应
@State score: number = 0;
@State timeLeft: number = GAME_DURATION;
@State gamePhase: string = 'menu';     // menu | playing | over
@State dotX: number = 50;
@State dotY: number = 50;
@State dotSize: number = 48;
@State dotColor: string = '#ffdd57';
@State combo: number = 0;

每次光点被点击,spawnDot 方法更新光点的位置、大小和颜色,ArkUI 框架自动重新渲染 UI:

private spawnDot(): void {
  this.dotX = 10 + Math.random() * 80;      // 随机 X 位置
  this.dotY = 10 + Math.random() * 70;      // 随机 Y 位置

  const difficulty = Math.min(this.score / 20, 1);
  this.dotSize = 48 - difficulty * 20;       // 48px → 28px(越来越小)

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

  // 下次移动间隔越来越短
  const interval = Math.max(300, 1200 - this.score * 30);
  clearTimeout(this.moveTimerId);
  this.moveTimerId = setTimeout(() => {
    this.spawnDot();
  }, interval);
}

hsl() 颜色函数:通过 (this.score * 30) % 360 计算色相值,使光点颜色随着分数增加在色环上循环变化——从黄色(60°)逐渐过渡到红色(0°)、蓝色(240°)、绿色(120°)等。这是一种无需图片资源即可实现丰富视觉效果的技巧。

难度曲线设计:光点从 48px 逐渐缩小到 28px(缩小的 42%),移动间隔从 1200ms 逐渐缩短到 300ms(加速 4 倍)。这种双维度的难度递增让游戏体验平滑且富有挑战性。

8.2 响应式 UI 的优势

与 Canvas 的手动绘制不同,LightChaseGame 的 UI 完全由 ArkUI 的声明式系统管理:

// 光点:@State 驱动的 Text 组件
Text('●')
  .fontSize(this.dotSize)          // 尺寸自动响应
  .fontColor(this.dotColor)        // 颜色自动响应
  .shadow({
    radius: this.dotSize * 0.6,
    color: this.dotColor + '80',   // 同色系半透明光晕
    offsetX: 0, offsetY: 0
  })
  .position({
    x: `${this.dotX}%`,
    y: `${this.dotY}%`
  })
  .onClick(() => { this.onDotTap(); })

@State 变量(dotSizedotColordotXdotY)发生变化时,ArkUI 框架仅更新受影响的 UI 节点,而不需要重绘整个页面。这种细粒度更新机制是声明式 UI 框架的核心优势。

8.3 浮动提示动画

连击提示使用 opacity 属性实现淡入淡出效果:

Text(this.flashText)
  .fontSize(24).fontWeight(FontWeight.Bold).fontColor('#ffdd57')
  .opacity(this.flashAlpha)         // 0 → 1 淡入,1 → 0 淡出
  .position({ x: '50%', y: '30%' })
  .translate({ x: '-50%' })         // 水平居中偏移修正

translate({ x: '-50%' })position({ x: '50%' }) 配合使用,实现了相对于父容器的水平居中定位。position 将元素左边缘定位到 50% 位置,translate 将元素向左偏移自身宽度的一半,两者结合即精确居中。

九、数据模型与 SM-2 算法实现

9.1 数据结构体系

AppModel.ets 定义了英语学习应用所需的完整数据结构:

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;
}

export interface StudyRecord {
  wordId: number;
  reviewCount: number;
  correctCount: number;
  lastReviewTime: string;
  masteryLevel: number;  // 0.0 ~ 1.0
}

此外还有 ListeningMaterialReadingArticleGrammarExercise 等结构,覆盖了英语学习所需的全维度数据类型。

9.2 SM-2 间隔重复算法

SpacedRepetition.ets 实现了经典的 SM-2(SuperMemo 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 = 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);

    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),
    };
  }
}

SM-2 算法的核心逻辑

  1. 回答质量评级:用户每次复习时对自己的回忆质量打分(0-5),5 分表示完全正确且轻松,0 分表示完全忘记。

  2. 不合格即重置quality < 3 时,间隔重置为 1 天,连续正确次数归零。这模拟了"大脑遗忘曲线"——如果回忆失败,说明记忆还不够牢固,需要更频繁地复习。

  3. 间隔指数增长:连续正确时,间隔按 previousInterval * EF 增长。首次 1 天,第二次 3 天,第三次约 8 天(如果 EF=2.5),第四次约 19 天——呈指数级扩展。

  4. EF 动态调整:易度系数不是固定值。每次复习都会根据新的质量分计算新的 EF 值:EF' = EF + (0.1 - (5-Q) × (0.08 + (5-Q) × 0.02))。答得好 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';                         // 红 - 新词
}

颜色分级让学习进度一目了然,无需阅读文字说明即可直观掌握每个单词的学习状态。

9.3 示例数据

SampleData.ets 包含 30 个基础单词的完整数据、2 篇阅读文章及其阅读理解题目、8 道语法练习题。

30 个单词覆盖了从简单(‘challenge’)到中等(‘significant’)到困难(‘hypothesis’)的各个难度等级,包含 “基础词汇”、“核心词汇”、“进阶词汇”、“学术词汇” 四个分类。每篇阅读文章包含 main_idea(主旨)、detail(细节)、vocabulary(词汇)/ inference(推理)等不同类型的问题,覆盖了阅读理解的全维度能力考察。

十、工程化最佳实践

10.1 hilog 日志系统

项目中统一使用 hilog 进行日志输出,而非 console.log

const TAG = 'RowSpaceBetweenDemo';

// 日志格式:hilog.级别(域, 标签, 信息)
hilog.info(0x0000, TAG, 'Feature selected: ' + label);

// 格式化输出
hilog.info(0x0000, TAG, 'canvas ready: %d x %d', this.canvasW, this.canvasH);

hilog 的优势在于支持按域(domain)和标签(tag)过滤日志。在 DevEco Studio 的 Log 面板中,可以通过过滤条件 domain:0x0000tag:RowSpaceBetweenDemo 快速定位特定模块的日志。

10.2 生命周期管理

ArkTS 组件提供了两个关键的生命周期钩子:

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

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

定时器清理是强制性的LightChaseGameRunnerPage 中都使用了 setInterval / setTimeout。如果不在 aboutToDisappear 中清理,页面跳转后定时器仍然在后台运行,会导致:

  • 内存泄漏(闭包中持有组件引用)
  • 逻辑异常(游戏循环继续执行,但 Canvas 已不可见)
  • 性能开销(无意义的帧循环消耗 CPU)

10.3 @State 性能优化原则

// ✅ 良好的实践
@State private score: number = 0;    // 直接驱动 UI 显示
@State private timeLeft: number = 30; // 直接驱动 UI 显示
private timerId: number = -1;          // 不驱动 UI,使用普通变量
private ctx: CanvasRenderingContext2D; // 不驱动 UI,使用普通变量

// ❌ 不推荐
@State private timerId: number = -1;  // 会导致大量不必要的 build() 调用

@State 的核心原则:只标记那些直接影响 UI 显示的状态变量。定时器句柄、Canvas 上下文、内部计算缓存等不直接出现在 build() 中的变量,应使用普通成员变量存储。

10.4 模块配置与路由

module.json5 中的 "pages": "$profile:main_pages" 引用了资源配置文件。在 /resources/base/profile/ 目录下,main_pages.json 文件定义了所有页面的路由映射:

{
  "src": [
    "pages/Index",
    "pages/RowSpaceBetweenDemo",
    "pages/LightChaseGame",
    "pages/RunnerPage"
  ]
}

每个注册的页面路径对应一个使用 @Entry 装饰的组件。EntryAbility.ets 中的 loadContent('pages/RowSpaceBetweenDemo') 即从这个路由表中查找对应页面。

十一、总结

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

第一,Row + SpaceBetween 导航栏布局。这是本文的核心主题。通过 justifyContent(FlexAlign.SpaceBetween) 一行代码即可实现"标题在左、按钮在右"的两端对齐导航栏,配合 Blank() 弹性组件保证在动态内容下的布局稳定性。将导航栏拆分为 NavTitle、NavActionButton、NavigationBar 三层组件,每层职责单一,可独立复用。

第二,三种 Space 布局模式的精确对比。SpaceBetween(首尾贴边)、SpaceAround(两侧半间距)、SpaceEvenly(全等间距)各有不同的间距计算公式和适用场景。文中通过同一组数据并排对比的方式,直观展示了三者的视觉差异。

第三,ArkUI 弹性盒模型的完整体系。从 Row 的水平排列到 Column 的垂直排列,从 justifyContent 的主轴控制到 alignItems 的交叉轴控制,从 layoutWeight 弹性权重到 Blank 弹性占位,构成了一个完整、统一的布局解决方案。

第四,可复用组件设计模式。通过 @Prop 实现单向数据流、@BuilderParam 实现内容插槽、@Component 实现组件封装,形成了一套成熟且规范的组件化开发模式。

第五,Canvas 游戏编程与响应式 UI。RunnerPage 的 Canvas 手动渲染与 LightChaseGame 的声明式响应式渲染,代表了 ArkTS 游戏开发的两种技术路线——前者适合性能敏感的复杂场景,后者开发效率更高。

第六,数据模型与算法引擎。从基础的 interface 定义到 SM-2 间隔重复算法的完整实现,展示了 ArkTS 处理复杂业务逻辑的能力。算法代码被封装为独立的类,可单独测试和复用。

鸿蒙生态正处于快速发展的关键时期。掌握 ArkUI 的弹性布局体系、理解声明式 UI 的响应式编程思想、熟悉组件化开发的设计模式,是每一位鸿蒙开发者必须跨越的三个台阶。希望本文能够帮助开发者从零开始,系统性地构建鸿蒙原生应用的知识体系,在实际项目中写出更优雅、更高效、更易维护的 ArkTS 代码。

Logo

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

更多推荐