鸿蒙原生 ArkTS「行两端对齐导航栏」布局实战——基于 HarmonyOS NEXT 6.1.1(API 24)完整应用开发全解析


一、引言
在移动应用开发中,导航栏是最基础也最关键的 UI 组件之一。几乎每一个应用的顶部都有一条导航栏:左侧显示标题或返回按钮,右侧放置操作入口。如何高效、精确地实现这种"标题在左、按钮在右"的布局,直接关系到开发效率和 UI 一致性。
鸿蒙操作系统(HarmonyOS)自诞生以来持续演进,HarmonyOS NEXT 版本去除了 AOSP 兼容层,实现了完全的鸿蒙原生体验。开发者需要使用纯正的 ArkTS 语言与 ArkUI 框架进行原生应用开发。在此背景下,深入理解 ArkUI 的弹性盒模型(Flexbox)布局体系,掌握 Row 容器的 justifyContent 属性,成为每一位鸿蒙开发者必须攻克的核心技能。
本文将以一个完整的鸿蒙原生应用项目为例,从导航栏布局切入,系统性地讲解 Row 容器的 SpaceBetween 两端对齐布局,并辐射到 SpaceAround 与 SpaceEvenly 的详细对比、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
}
}
}
]
}
}
targetSdkVersion 和 compatibleSdkVersion 均设为 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 需求场景分析
考虑一个标准的顶部导航栏,它的结构通常是这样的:
┌──────────────────────────────────────────────┐
│ ← 返回 页面标题 🔔 ☆ ⋯ │
├──────────────────────────────────────────────┤
│ │
│ 内容区域 │
│ │
└──────────────────────────────────────────────┘
这个布局有三个关键需求:
- 标题文本位于左侧,贴紧左边缘
- 操作按钮位于右侧,贴紧右边缘
- 标题与按钮之间自动撑满间距
如果使用绝对定位或手动计算 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,NavActionButton 的 badge 属性随之变为 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 组件并排展示了 SpaceBetween、SpaceAround、SpaceEvenly 三种布局模式,帮助开发者直观感受其视觉差异。
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 装饰器。使用流程如下:
- 在容器组件中声明一个
@BuilderParam类型的属性(如content) - 设置默认的
@Builder方法(如defaultContent),确保无内容注入时也有占位渲染 - 在
build()中通过this.content()调用构建函数 - 父组件使用时,在大括号内传入构建函数体:
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 变量(dotSize、dotColor、dotX、dotY)发生变化时,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
}
此外还有 ListeningMaterial、ReadingArticle、GrammarExercise 等结构,覆盖了英语学习所需的全维度数据类型。
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 算法的核心逻辑:
-
回答质量评级:用户每次复习时对自己的回忆质量打分(0-5),5 分表示完全正确且轻松,0 分表示完全忘记。
-
不合格即重置:
quality < 3时,间隔重置为 1 天,连续正确次数归零。这模拟了"大脑遗忘曲线"——如果回忆失败,说明记忆还不够牢固,需要更频繁地复习。 -
间隔指数增长:连续正确时,间隔按
previousInterval * EF增长。首次 1 天,第二次 3 天,第三次约 8 天(如果 EF=2.5),第四次约 19 天——呈指数级扩展。 -
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:0x0000 或 tag:RowSpaceBetweenDemo 快速定位特定模块的日志。
10.2 生命周期管理
ArkTS 组件提供了两个关键的生命周期钩子:
aboutToAppear(): void {
// 组件即将显示时调用
// 适合初始化数据、启动定时器、请求网络
}
aboutToDisappear(): void {
// 组件即将销毁时调用
// **必须清理定时器、释放资源**
if (this.timerId >= 0) {
clearInterval(this.timerId);
this.timerId = -1;
}
}
定时器清理是强制性的:LightChaseGame 和 RunnerPage 中都使用了 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 代码。
更多推荐



所有评论(0)