HarmonyOS ArkTS API 24+ 实战:从零搭建注塑工程师助手首页与底部导航
前言
学习 ArkTS 时,一个常见困难是:组件语法看懂了,真正开始做项目却不知道数据、状态和页面应该放在哪里。为了避开“只讲几个控件,写完仍然不会组织项目”的问题,本文从一个真实业务需求出发,搭建“注塑工程师助手”的首个可运行版本。
这款 App 面向注塑现场,后续会逐步记录注塑机档案、调机参数、生产批次、不良原因和异常闭环。本篇先完成工程骨架、首页现场数据看板,以及“首页、机台、调机、异常、报表”五项底部导航。看起来只是一个首页,实际会覆盖 ArkTS 初学者很值得掌握的几条主线:数据模型、仓库边界、页面状态、父子组件双向联动和列表渲染。
本文使用的代码不是静态 HTML 原型。工程已经由 DevEco Studio 的 ArkTS 工具链真实编译,并在 HarmonyOS 模拟器中安装、启动和操作。文中的两张界面图也直接来自该模拟器。
先说明版本与验证口径
当前本机环境没有可供工程编译的 API 24 SDK,实际编译基线是 HarmonyOS SDK API 26 Beta1。工程配置如下:
| 项目 | 本次实际值 |
|---|---|
| DevEco Studio | Beta 26.0.0.461 |
| 编译 SDK | API 26 Beta1,26.0.0.23 |
compatibleSdkVersion |
6.1.1(24) |
targetSdkVersion |
26.0.0 |
| 运行观察设备 | API 24 模拟器 |
| Bundle Name | com.atan.enotebook |
这里有一个容易混淆的点:compatibleSdkVersion 声明的是工程希望支持的最低版本,并不会自动证明全部代码都完成了兼容测试。本次可以确认的是“API 26 Beta SDK 编译成功,当前 M1 包在一个 API 24 模拟器中完成安装、启动和五项导航检查”。这不是“API 24 SDK 已完成编译验证”,也不能代表后续所有功能已经全面兼容 API 24。
把这条边界写清楚不是咬文嚼字。技术文章中的版本结论会直接影响读者复现,编译 SDK、目标版本和运行设备应分别说明。
效果预览
首页由四项现场指标、三条待办和五项底部导航组成。所有机台编号和生产数字均为脱敏演示数据。

点击“调机”后,页面进入当前里程碑的占位态,同时底部选中状态切换到“调机”。这种做法能先把应用的信息架构和状态链路跑通,再逐个开发业务模块。

本篇实现目标
本篇完成以下功能:
- 创建可构建、可安装、可启动的 ArkTS Stage 模型工程。
- 用数据模型承载首页统计值,并对数值范围做基础保护。
- 用 Repository 隔离演示数据,避免把数据直接写死在页面组件中。
- 抽取主题令牌、指标卡和待办项,减少重复样式。
- 使用
@State保存父组件状态,使用@Link让底部导航读取同一份状态。 - 通过回调把点击事件交还给页面容器处理。
- 对五个合法页签进行真实交互检查。
一、项目结构怎么拆
M1 的核心源码目录如下:
entry/src/main/ets/
├── components/
│ └── BottomNavigation.ets
├── entryability/
│ └── EntryAbility.ets
├── features/home/
│ └── HomeDashboard.ets
├── models/
│ ├── DashboardSummary.ets
│ └── NavigationItem.ets
├── pages/
│ └── Index.ets
├── repositories/
│ └── DemoDashboardRepository.ets
├── stores/
│ └── AppNavigationStore.ets
└── utils/
└── ThemeTokens.ets
这个结构没有为了“看起来像大项目”而拆出大量空目录,每层都有明确职责:
models只描述数据长什么样,以及数据本身必须满足哪些约束。repositories负责回答“数据从哪里来”。M1 返回演示值,未来可以替换为数据库或网络请求。stores保存和校验页面状态规则。features放具体业务界面。components放能被多个页面复用的通用组件。pages负责把数据、状态和组件装配起来。
对于初学者,判断一个文件放在哪里,可以先问一句:“它是在描述数据、获取数据、维护状态,还是渲染界面?”答案通常会自然指向对应目录。
二、配置 SDK 目标
工程根目录的 build-profile.json5 保存产品和 SDK 配置,核心片段如下:
{
"app": {
"signingConfigs": [],
"products": [
{
"name": "default",
"compatibleSdkVersion": "6.1.1(24)",
"targetSdkVersion": "26.0.0",
"runtimeOS": "HarmonyOS",
"buildOption": {
"strictMode": {
"caseSensitiveCheck": true,
"useNormalizedOHMUrl": true
}
}
}
]
}
}
逐项来看:
signingConfigs当前为空,因此构建产物是 unsigned HAP。它可以服务本地调试,但正式发布前必须配置签名。compatibleSdkVersion表示期望的最低兼容版本。本项目把 API 24 作为持续兼容目标。targetSdkVersion表示当前工程面向 API 26 行为构建。runtimeOS明确运行系统为 HarmonyOS。caseSensitiveCheck能更早发现导入路径大小写不一致的问题。Windows 文件系统往往不敏感,但代码进入其他环境后可能出错,构建阶段提前拦截更稳妥。useNormalizedOHMUrl让模块引用采用规范化形式,减少依赖解析差异。
需要再次强调:配置文件中的“兼容版本”是目标,不是测试报告。真正的兼容结论要来自构建、安装、启动和业务流程检查。
三、先让数据模型守住边界
首页需要四个统计值。DashboardSummary.ets 的代码如下:
export class DashboardSummary {
debugCount: number;
productionBatchCount: number;
yieldRate: number;
pendingExceptionCount: number;
constructor(
debugCount: number,
productionBatchCount: number,
yieldRate: number,
pendingExceptionCount: number
) {
this.debugCount = Math.max(0, debugCount);
this.productionBatchCount = Math.max(0, productionBatchCount);
this.yieldRate = Math.max(0, Math.min(100, yieldRate));
this.pendingExceptionCount = Math.max(0, pendingExceptionCount);
}
}
1. 为什么不直接用四个独立变量
如果页面上分别声明 debugCount、batchCount、yieldRate 和 exceptionCount,短期也能显示,但这四个值没有形成一个明确的业务概念。封装为 DashboardSummary 后,函数参数、Repository 返回值和组件入参都能直接表达“首页摘要”,可读性更好。
2. 构造函数为什么要做范围保护
计数不应该出现负数,所以三个计数字段都用 Math.max(0, value) 保底。良品率应处于 0 到 100 之间,内层 Math.min(100, yieldRate) 先截断上限,外层 Math.max(0, ...) 再截断下限。
这类约束放在模型构造入口有一个直接收益:无论数据来自演示仓库、本地数据库还是后端接口,只要创建了 DashboardSummary,页面拿到的就是可显示值。组件不必在每个 Text 前重复判断。
3. 这是不是完整的数据校验
还不是。真实项目仍应判断 NaN、接口字段缺失和类型转换失败。M1 先建立边界意识,后续接入接口时再补充错误对象和加载状态。
四、用 Repository 隔离数据来源
M1 使用脱敏演示数据,但没有把数字散落在 UI 中:
import { DashboardSummary } from '../models/DashboardSummary';
export class DemoDashboardRepository {
loadSummary(): DashboardSummary {
return new DashboardSummary(6, 12, 98.6, 3);
}
}
这段代码很短,作用却很明确。
loadSummary() 的返回类型固定为 DashboardSummary,调用方无需猜测字段结构。当前实现同步返回演示值,后续可以新增 LocalDashboardRepository 或 RemoteDashboardRepository,让数据来自关系型数据库、键值存储或后端服务。
为什么不直接在 HomeDashboard 中写 Text('6')?因为那样会把“数据是什么”和“数据怎么展示”绑死。Repository 是一条替换边界:UI 只依赖模型,不关心数字来自哪里。真实开发中,这一点会明显降低接入接口时的改动范围。
五、把导航配置建模
底部导航的每一项都有 ID、标签和当前使用的文字图标:
export class NavigationItem {
id: string;
label: string;
iconText: string;
constructor(id: string, label: string, iconText: string) {
this.id = id;
this.label = label;
this.iconText = iconText;
}
}
export const NAVIGATION_ITEMS: NavigationItem[] = [
new NavigationItem('home', '首页', '首'),
new NavigationItem('machines', '机台', '机'),
new NavigationItem('debug', '调机', '调'),
new NavigationItem('exceptions', '异常', '!'),
new NavigationItem('reports', '报表', '表')
];
这里没有在 BottomNavigation 中连续手写五份几乎相同的 Column。导航数据被整理成数组后,组件可以用 ForEach 统一渲染。
id 用于状态比较,保持稳定且不直接展示给用户;label 用于界面显示;iconText 是 M1 的临时图标载体。后续接入正式 Symbol 或媒体资源时,只需调整模型和渲染表达,不需要重写导航的状态逻辑。
列表的 key 使用 item.id,而不是数组索引。稳定 key 有助于框架识别同一个渲染节点,导航顺序调整时也不会因为索引变化而错误复用状态。
六、Store 只接受合法页签
AppNavigationStore 负责保存当前页签,并在更新前校验输入:
import { NAVIGATION_ITEMS, NavigationItem } from '../models/NavigationItem';
export class AppNavigationStore {
private selectedTabId: string = 'home';
select(tabId: string): string {
const target: NavigationItem | undefined = NAVIGATION_ITEMS.find(
(item: NavigationItem) => item.id === tabId
);
if (target !== undefined) {
this.selectedTabId = target.id;
}
return this.selectedTabId;
}
current(): string {
return this.selectedTabId;
}
}
1. 默认值为什么是 home
应用启动后首先展示首页,因此 selectedTabId 初始化为 home。页面第一次创建 @State 时会通过 current() 读取这个值。
2. 为什么 find() 的类型包含 undefined
数组中不一定存在传入 ID。显式写成 NavigationItem | undefined,是在告诉编译器和读者:查找可能失败,后续代码必须处理失败分支。
3. 非法 ID 会发生什么
只有 target !== undefined 时才更新状态。如果误传了 settings,而导航配置中没有这一项,Store 会保留上一个合法值并返回。这样页面不会进入无法渲染、底部也没有选中项的悬空状态。
Store 目前是普通类,不是全局状态容器,因为 M1 只有一个页面容器使用它。等机台详情、调机编辑和跨页面共享出现后,再根据真实复杂度选择更合适的状态管理方式。
七、集中管理视觉令牌
页面颜色集中放在 ThemeTokens.ets:
export class ThemeTokens {
static readonly pageBackground: string = '#F4F6F8';
static readonly surface: string = '#FFFFFF';
static readonly textPrimary: string = '#17212B';
static readonly textSecondary: string = '#637080';
static readonly border: string = '#DDE3E8';
static readonly accent: string = '#006C7A';
static readonly accentSoft: string = '#E1F3F5';
static readonly success: string = '#247A52';
static readonly successSoft: string = '#E8F5EE';
static readonly warning: string = '#A45B00';
static readonly warningSoft: string = '#FFF0D8';
static readonly danger: string = '#B43B34';
static readonly dangerSoft: string = '#FCE9E7';
}
static readonly 表示这些值属于类本身,并且创建后不应被修改。组件可以直接使用 ThemeTokens.accent,不需要实例化对象。
集中令牌不是单纯为了少写几次颜色值。它能保证“选中态”“成功态”“警告态”“异常态”在不同组件中表达一致。未来做暗色模式或品牌色调整时,也有明确的修改入口。
本项目是现场工具,界面优先保证扫读速度:背景与卡片保持清楚层次,主文字对比度较高,状态色只用于需要识别的指标和标签,避免整个界面被单一强调色占满。
八、抽取指标卡组件
首页四项指标的结构一致,只是文案、数值和状态色不同。因此先抽取 MetricCard:
@Component
struct MetricCard {
label: string = '';
value: string = '';
unit: string = '';
tone: string = ThemeTokens.accent;
toneSoft: string = ThemeTokens.accentSoft;
build() {
Column({ space: 10 }) {
Row() {
Text(this.label)
.fontSize(13)
.fontColor(ThemeTokens.textSecondary)
Blank()
Text('实时')
.fontSize(10)
.fontColor(this.tone)
.padding({ left: 7, right: 7, top: 3, bottom: 3 })
.backgroundColor(this.toneSoft)
.borderRadius(6)
}
.width('100%')
Row({ space: 4 }) {
Text(this.value)
.fontSize(26)
.fontWeight(FontWeight.Bold)
.fontColor(ThemeTokens.textPrimary)
Text(this.unit)
.fontSize(12)
.fontColor(ThemeTokens.textSecondary)
.margin({ bottom: 4 })
}
.width('100%')
.alignItems(VerticalAlign.Bottom)
}
.layoutWeight(1)
.height(108)
.padding(14)
.backgroundColor(ThemeTokens.surface)
.border({ width: 1, color: ThemeTokens.border })
.borderRadius(8)
}
}
1. 参数为什么都有默认值
ArkTS 组件属性给出默认值后,组件自身始终处于可构建状态。调用方可以只传必要字段,例如普通指标沿用默认强调色,异常指标再覆盖 tone 和 toneSoft。
2. Blank() 在这里做什么
指标名称和“实时”状态标签位于同一行。Blank() 占据中间剩余空间,把指标名称推向左侧、状态标签推向右侧,不需要手工计算间距。数值与单位放在下一行,并通过 VerticalAlign.Bottom 保持稳定的视觉对齐。
3. 为什么同时传深色和浅色
tone 用于“实时”文字,toneSoft 用于状态标签背景。只把同一个深色同时用作文字和背景,会让对比度失控;成对设计能保持状态语义,又不会让卡片过重。主要数值仍统一使用主文字色,避免四张卡片因状态色过多而降低扫读效率。
4. layoutWeight(1) 为什么重要
两个指标卡放在同一个 Row 中时,都设置相同权重,就会平分可用宽度。这样数值从一位变为两位时不会改变两列的结构。
九、渲染首页统计值
HomeDashboard 接收一个 DashboardSummary,页面本身不负责获取数据:
@Component
export struct HomeDashboard {
summary: DashboardSummary = new DashboardSummary(0, 0, 0, 0);
build() {
Scroll() {
Column({ space: 18 }) {
// 标题区省略
Column({ space: 12 }) {
Row({ space: 12 }) {
MetricCard({
label: '今日调机',
value: this.summary.debugCount.toString(),
unit: '次'
})
MetricCard({
label: '生产批次',
value: this.summary.productionBatchCount.toString(),
unit: '批'
})
}
Row({ space: 12 }) {
MetricCard({
label: '平均良品率',
value: this.summary.yieldRate.toFixed(1),
unit: '%',
tone: ThemeTokens.success,
toneSoft: ThemeTokens.successSoft
})
MetricCard({
label: '待处理异常',
value: this.summary.pendingExceptionCount.toString(),
unit: '项',
tone: ThemeTokens.danger,
toneSoft: ThemeTokens.dangerSoft
})
}
}
}
}
}
}
这段代码体现了几个实用细节。
第一,组件入参是完整模型,不是四个互不相关的参数。调用方以后增加更新时间、班次或统计范围时,数据边界仍然清晰。
第二,toString() 把整数转换为文本;良品率使用 toFixed(1) 保留一位小数,让 98.6% 的展示格式稳定。显示格式属于界面表达,所以在组件渲染前处理是合理的。
第三,页面外层使用 Scroll。现场设备的字体缩放、系统栏高度和后续新增内容都可能压缩可视区域,允许垂直滚动比假定所有内容永远放得下更稳妥。
第四,四张卡采用两个固定 Row,每行两列。对于指标数量固定的 M1,这种结构直观且容易控制;如果后续指标由服务端动态配置,再考虑 Grid。
十、待办项为什么单独封装
现场待办包含标题、说明和状态标签。组件通过最大行数和溢出规则保护布局:
@Component
struct WorkItem {
title: string = '';
detail: string = '';
badge: string = '';
badgeColor: string = ThemeTokens.accent;
badgeBackground: string = ThemeTokens.accentSoft;
build() {
Row({ space: 12 }) {
Column({ space: 5 }) {
Text(this.title)
.fontSize(14)
.fontWeight(FontWeight.Medium)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
Text(this.detail)
.fontSize(12)
.fontColor(ThemeTokens.textSecondary)
.maxLines(2)
.textOverflow({ overflow: TextOverflow.Ellipsis })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
Text(this.badge)
.fontSize(11)
.fontColor(this.badgeColor)
.backgroundColor(this.badgeBackground)
.borderRadius(6)
}
}
}
左侧内容列设置 layoutWeight(1),会占用除状态标签之外的剩余宽度。右侧标签保持由内容决定的宽度,因此短标签不会被拉伸。
标题最多一行,详情最多两行,超出后显示省略号。这不是为了隐藏数据,而是保护首页的扫描节奏。完整说明应在待办详情页展示:首页负责快速识别,详情页负责完整阅读。
后续待办改为服务端数据时,可以使用 ForEach 渲染数组。M1 先保留三条固定脱敏数据,目的是验证信息密度和样式,不把尚未实现的数据接口写成假功能。
十一、父页面装配数据和状态
Index.ets 是当前页面容器,先看属性部分:
@Entry
@Component
struct Index {
private dashboardRepository: DemoDashboardRepository =
new DemoDashboardRepository();
private navigationStore: AppNavigationStore =
new AppNavigationStore();
private summary: DashboardSummary =
this.dashboardRepository.loadSummary();
@State selectedTab: string = this.navigationStore.current();
}
@Entry 表示这是页面入口组件,@Component 表示该结构体由 ArkUI 管理并参与声明式构建。
前三个普通属性分别保存数据仓库、导航规则和首页摘要。它们不直接驱动当前界面频繁变化,因此没有加 @State。
selectedTab 会在点击导航后改变,并决定首页还是占位页被渲染,所以使用 @State。当这个值更新,ArkUI 会重新计算依赖它的 UI 片段,开发者不需要手工查找控件并修改颜色。
这就是状态驱动 UI 的核心:代码描述“某个状态下界面应该是什么样”,框架负责把状态变化反映到界面。
十二、安全取得当前导航项
页面需要根据 selectedTab 找到当前项:
private selectedItem(): NavigationItem {
const item: NavigationItem | undefined = NAVIGATION_ITEMS.find(
(entry: NavigationItem) => entry.id === this.selectedTab
);
return item === undefined ? NAVIGATION_ITEMS[0] : item;
}
即使 Store 已经阻止非法 ID,页面仍然提供首页回退值。这属于显示层的防御性处理:一个组件不应该因为状态暂时异常而在取 label 时直接失败。
返回类型明确写为 NavigationItem,因此调用方可以直接使用 .label 和 .iconText,不用在每一处继续判断 undefined。
不过,防御性回退不能替代错误监控。真实项目中如果频繁进入回退分支,应记录日志并修复状态来源,而不是让错误长期静默存在。
十三、根据状态切换页面内容
Index 的 build() 根据当前页签选择内容:
build() {
Column() {
if (this.selectedTab === 'home') {
HomeDashboard({ summary: this.summary })
.layoutWeight(1)
} else {
Column({ space: 14 }) {
Text(this.selectedItem().iconText)
Text(this.selectedItem().label)
Text('该模块将在后续里程碑中完成,当前页面只验证导航状态。')
}
.layoutWeight(1)
.width('100%')
.justifyContent(FlexAlign.Center)
.backgroundColor(ThemeTokens.pageBackground)
}
BottomNavigation({
selectedTab: $selectedTab,
onSelect: (tabId: string) => {
this.selectedTab = this.navigationStore.select(tabId);
}
})
}
.width('100%')
.height('100%')
}
1. 为什么首页和底部导航放在同一个 Column
主体内容设置 layoutWeight(1),占据除底部导航之外的剩余高度;导航拥有稳定高度。这样正文增加或减少时,不会把导航挤出屏幕。
2. 为什么未开发模块使用占位页
M1 的目标是验证导航结构,不应把机台、调机、异常和报表伪装成已完成页面。明确的占位态既能验证状态,又让开发范围保持诚实。
3. $selectedTab 是什么
传给 @Link 属性时,需要传递状态引用,而不是当前字符串的普通副本。$selectedTab 表示父组件中这份状态的双向引用,子组件读取到的是同一状态源。
4. 为什么还需要 onSelect
虽然子组件能通过 @Link 访问状态,但这里仍让点击事件通过回调交给父页面处理。父页面调用 navigationStore.select() 完成合法性校验,再更新 @State。这样底部组件只负责表达用户意图,不绕过业务规则直接写入任意 ID。
这是一条值得保留的边界:子组件发出事件,容器执行规则,状态更新后再驱动子组件高亮。
十四、BottomNavigation 中的 @Link 与 ForEach
底部导航核心代码如下:
@Component
export struct BottomNavigation {
@Link selectedTab: string;
onSelect: (tabId: string) => void = () => {};
build() {
Row() {
ForEach(NAVIGATION_ITEMS, (item: NavigationItem) => {
Column({ space: 3 }) {
Text(item.iconText)
.fontColor(
this.selectedTab === item.id
? ThemeTokens.surface
: ThemeTokens.textSecondary
)
.backgroundColor(
this.selectedTab === item.id
? ThemeTokens.accent
: Color.Transparent
)
Text(item.label)
.fontColor(
this.selectedTab === item.id
? ThemeTokens.accent
: ThemeTokens.textSecondary
)
}
.layoutWeight(1)
.height(58)
.justifyContent(FlexAlign.Center)
.onClick(() => {
this.onSelect(item.id);
})
}, (item: NavigationItem) => item.id)
}
}
}
1. @Link 与 @State 的关系
@State 的所有者是父页面 Index,BottomNavigation 用 @Link 连接这份状态。当前页签变化后,导航组件会重新计算每一项的字体色、背景色和字重,因此选中态自然跟着改变。
如果子组件这里只声明普通 selectedTab: string,它得到的更像一次参数传值,不表达“这是父组件状态的联动引用”。在需要父子共享可变状态的场景,@Link 能更准确地描述关系。
2. ForEach 的三个关键部分
第一个参数是数据源 NAVIGATION_ITEMS;第二个参数把每个 item 转换成一段 UI;第三个参数 (item) => item.id 提供稳定 key。
当导航配置变化时,ArkUI 可以依靠 key 判断哪些节点保留、插入或删除。使用业务 ID 比使用数组下标更可靠。
3. 选中态如何形成
每一项都判断 this.selectedTab === item.id。命中时,图标文字使用白色和强调色背景,标签使用强调色;未命中时使用次级文字色与透明背景。
判断逻辑只依赖一个状态源,不再额外维护 isHomeSelected、isDebugSelected 等五个布尔变量,因此不会出现多个页签同时高亮的矛盾状态。
4. 点击后发生了什么
点击某项时,子组件调用 onSelect(item.id)。父页面收到 ID 后交给 Store 校验,把返回的合法值写回 selectedTab。@State 更新触发正文分支和底部高亮一起刷新。
完整链路是:
用户点击页签
-> BottomNavigation 发出 item.id
-> Index 调用 AppNavigationStore.select()
-> Store 校验并返回合法 ID
-> Index 更新 @State selectedTab
-> 正文和 BottomNavigation 重新渲染
理解这条链路后,再看 ArkUI 的状态管理就不会只停留在记装饰器名称。
十五、真实构建脚本解析
为了避免系统 Java、Node.js 与 DevEco Studio 工具链版本不一致,项目使用 build.ps1 显式选择 IDE 自带环境:
$ErrorActionPreference = 'Stop'
$projectRoot = Split-Path -Parent $MyInvocation.MyCommand.Path
$devecoRoot = 'D:\Program Files\Huawei\DevEco Studio Beta'
$hvigor = Join-Path $devecoRoot 'tools\hvigor\bin\hvigorw.bat'
$nodeHome = Join-Path $devecoRoot 'tools\node'
$javaHome = Join-Path $devecoRoot 'jbr'
$sdkHome = Join-Path $devecoRoot 'sdk'
$env:NODE_HOME = $nodeHome
$env:JAVA_HOME = $javaHome
$env:DEVECO_SDK_HOME = $sdkHome
$env:Path = "$javaHome\bin;$nodeHome;$env:Path"
& $hvigor --mode module `
-p module=entry@default `
-p product=default `
assembleHap
$ErrorActionPreference = 'Stop' 让 PowerShell 遇到错误立即进入失败路径,避免脚本继续执行并给出误导性的成功印象。
Split-Path -Parent $MyInvocation.MyCommand.Path 取得脚本自身目录,所以无论从哪个工作目录调用,Hvigor 都能在正确工程根执行。
脚本显式设置 NODE_HOME、JAVA_HOME 和 DEVECO_SDK_HOME。这解决了本机同时安装多套 Java 或 Node.js 时的版本漂移问题,构建行为更容易复现。
最后的 assembleHap 负责编译 Entry 模块并组装 HAP。当前构建输出包含 TYPE CHECK SUCCESSFUL 和 BUILD SUCCESSFUL,产物位于:
entry/build/default/outputs/default/entry-default-unsigned.hap
十六、这次遇到的三个真实问题
问题 1:SDK Manager 中看不到 API 24 SDK
本机重装 DevEco Studio Beta 后,可用完整 SDK 是 API 26 Beta1,API 24 则有模拟器镜像。处理策略不是篡改文章结论,而是把开发基线调整为 API 26 Beta,并把 API 24 保留为兼容目标。
工程可以声明 compatibleSdkVersion 为 API 24,也可以在 API 24 模拟器做运行观察,但仍要如实写明实际 compileSdkVersion 是 26.0.0.23。
问题 2:复制来的 Hvigor wrapper 无法解析依赖
从其他项目直接复制 hvigorw、hvigorw.bat 和 hvigorw.js 后,脚本会按错误目录查找依赖。最终没有继续修补这套错误 wrapper,而是让构建脚本调用 DevEco Studio 安装目录中的 hvigorw.bat。
排错经验是:构建入口文件存在,不代表它与当前工具链匹配。先确认 wrapper 的来源、依赖路径和 DevEco 版本,再决定使用项目 wrapper 还是 IDE 内置 Hvigor。
问题 3:构建成功但出现签名警告
signingConfigs 为空时,M1 仍能生成 unsigned HAP,这满足当前本地开发与模拟器检查。警告不能直接删除或假装不存在,应记录为发布前置事项。正式上架、真机分发和持续交付阶段都必须补齐正确签名。
十七、如何验证不是“只看起来能用”
本次验证分成四层:
- ArkTS 类型检查通过,证明当前源码满足编译器检查。
- Hvigor 组装成功并生成非空 HAP。
- 通过 HDC 确认 API 24 模拟器已安装
com.atan.enotebook,Bundle 元数据显示编译 SDK 为 26.0.0.23。 - 依次点击首页、机台、调机、异常、报表,通过布局树核对标题和选中态。
五项导航结果如下:
| 页签 | 预期标题 | 结果 |
|---|---|---|
| 首页 | 注塑工程师助手 | 通过 |
| 机台 | 机台 | 通过 |
| 调机 | 调机 | 通过 |
| 异常 | 异常 | 通过 |
| 报表 | 报表 | 通过 |
这套检查覆盖了 M1 的核心链路,但范围仍然有限:只检查了一个手机模拟器实例,没有覆盖平板、2in1、网络异常、数据持久化和后续业务流程。因此文章只报告已观察到的事实,不把局部测试扩大成全面兼容结论。
十八、常见问题 FAQ
Q1:为什么不直接使用 Tabs 组件?
本篇的重点是让初学者看清 @State、@Link、回调和条件渲染之间的关系,因此用自定义底部导航展示完整状态链路。后续如果业务需要手势切换、懒加载和标准页签行为,可以评估 Tabs,并继续保持导航配置与页面状态的单一来源。
Q2:@Link 已经能修改父状态,为什么点击事件还要回调?
技术上可以让子组件直接赋值,但那会让底部 UI 组件同时承担业务校验。当前设计让子组件只报告“用户选择了哪个 ID”,父页面再通过 Store 决定是否接受。以后增加权限判断、未保存表单提醒或埋点时,规则都有统一入口。
Q3:Repository 现在只有一行返回值,是否过度设计?
如果项目永远只有一个静态首页,确实没有必要。但本项目明确会接入机台、调机和生产数据,数据来源一定会变化。此时提前建立一个很薄的替换边界,成本低,能避免后续把接口逻辑从 UI 中艰难剥离。
Q4:为什么首页数据没有使用 @State?
M1 的摘要只在页面创建时同步加载一次,没有刷新和异步更新,因此普通属性足够。接入数据库或接口后,加载中、成功、空数据、失败和刷新结果都会变化,届时应把真正驱动 UI 的数据状态纳入可观察状态管理。
Q5:在 API 24 模拟器运行,能否直接写“API 24 已验证”?
不建议。应写清验证对象和范围,例如“API 26 Beta SDK 编译的 M1 包,在某个 API 24 模拟器完成安装、启动和五项导航检查”。API 24 SDK 编译、不同设备和完整业务兼容性仍是不同问题。
Q6:为什么暂时使用文字图标?
M1 优先验证信息架构与状态链路,文字图标能减少素材依赖。它不是最终视觉方案。后续会替换为 HarmonyOS Symbol 或正式图标资源,同时补充无障碍描述和不同状态下的视觉检查。
十九、本篇小结
这一版首页没有堆叠复杂功能,但已经形成一个能继续生长的 ArkTS 工程骨架:
DashboardSummary统一首页数据结构并保护数值边界。DemoDashboardRepository把数据来源从 UI 中分离。NavigationItem让底部导航由配置驱动。AppNavigationStore拒绝非法页签状态。HomeDashboard用小组件组织指标和待办。Index持有@State,装配数据、状态和页面内容。BottomNavigation通过@Link读取同一状态,通过回调上报点击意图。- 真实构建、安装、交互和截图为文章提供了可追溯证据。
对于 ArkTS 初学者,最值得带走的不是某个颜色值或卡片尺寸,而是这条数据流:数据先被模型约束,经 Repository 进入页面容器;用户操作由子组件上报,容器执行规则并更新状态;状态再统一驱动正文和导航渲染。
下一步将在这套骨架上实现机台档案列表、运行状态筛选和机台详情。届时会继续说明列表数据如何建模、空状态如何处理,以及如何让真实业务代码保持可测试、可替换。
更多推荐



所有评论(0)