前言

学习 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、目标版本和运行设备应分别说明。

效果预览

首页由四项现场指标、三条待办和五项底部导航组成。所有机台编号和生产数字均为脱敏演示数据。

在这里插入图片描述

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

在这里插入图片描述

本篇实现目标

本篇完成以下功能:

  1. 创建可构建、可安装、可启动的 ArkTS Stage 模型工程。
  2. 用数据模型承载首页统计值,并对数值范围做基础保护。
  3. 用 Repository 隔离演示数据,避免把数据直接写死在页面组件中。
  4. 抽取主题令牌、指标卡和待办项,减少重复样式。
  5. 使用 @State 保存父组件状态,使用 @Link 让底部导航读取同一份状态。
  6. 通过回调把点击事件交还给页面容器处理。
  7. 对五个合法页签进行真实交互检查。

一、项目结构怎么拆

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

逐项来看:

  1. signingConfigs 当前为空,因此构建产物是 unsigned HAP。它可以服务本地调试,但正式发布前必须配置签名。
  2. compatibleSdkVersion 表示期望的最低兼容版本。本项目把 API 24 作为持续兼容目标。
  3. targetSdkVersion 表示当前工程面向 API 26 行为构建。
  4. runtimeOS 明确运行系统为 HarmonyOS。
  5. caseSensitiveCheck 能更早发现导入路径大小写不一致的问题。Windows 文件系统往往不敏感,但代码进入其他环境后可能出错,构建阶段提前拦截更稳妥。
  6. 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. 为什么不直接用四个独立变量

如果页面上分别声明 debugCountbatchCountyieldRateexceptionCount,短期也能显示,但这四个值没有形成一个明确的业务概念。封装为 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,调用方无需猜测字段结构。当前实现同步返回演示值,后续可以新增 LocalDashboardRepositoryRemoteDashboardRepository,让数据来自关系型数据库、键值存储或后端服务。

为什么不直接在 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 组件属性给出默认值后,组件自身始终处于可构建状态。调用方可以只传必要字段,例如普通指标沿用默认强调色,异常指标再覆盖 tonetoneSoft

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

不过,防御性回退不能替代错误监控。真实项目中如果频繁进入回退分支,应记录日志并修复状态来源,而不是让错误长期静默存在。

十三、根据状态切换页面内容

Indexbuild() 根据当前页签选择内容:

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 中的 @LinkForEach

底部导航核心代码如下:

@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 的所有者是父页面 IndexBottomNavigation@Link 连接这份状态。当前页签变化后,导航组件会重新计算每一项的字体色、背景色和字重,因此选中态自然跟着改变。

如果子组件这里只声明普通 selectedTab: string,它得到的更像一次参数传值,不表达“这是父组件状态的联动引用”。在需要父子共享可变状态的场景,@Link 能更准确地描述关系。

2. ForEach 的三个关键部分

第一个参数是数据源 NAVIGATION_ITEMS;第二个参数把每个 item 转换成一段 UI;第三个参数 (item) => item.id 提供稳定 key。

当导航配置变化时,ArkUI 可以依靠 key 判断哪些节点保留、插入或删除。使用业务 ID 比使用数组下标更可靠。

3. 选中态如何形成

每一项都判断 this.selectedTab === item.id。命中时,图标文字使用白色和强调色背景,标签使用强调色;未命中时使用次级文字色与透明背景。

判断逻辑只依赖一个状态源,不再额外维护 isHomeSelectedisDebugSelected 等五个布尔变量,因此不会出现多个页签同时高亮的矛盾状态。

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_HOMEJAVA_HOMEDEVECO_SDK_HOME。这解决了本机同时安装多套 Java 或 Node.js 时的版本漂移问题,构建行为更容易复现。

最后的 assembleHap 负责编译 Entry 模块并组装 HAP。当前构建输出包含 TYPE CHECK SUCCESSFULBUILD 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 无法解析依赖

从其他项目直接复制 hvigorwhvigorw.bathvigorw.js 后,脚本会按错误目录查找依赖。最终没有继续修补这套错误 wrapper,而是让构建脚本调用 DevEco Studio 安装目录中的 hvigorw.bat

排错经验是:构建入口文件存在,不代表它与当前工具链匹配。先确认 wrapper 的来源、依赖路径和 DevEco 版本,再决定使用项目 wrapper 还是 IDE 内置 Hvigor。

问题 3:构建成功但出现签名警告

signingConfigs 为空时,M1 仍能生成 unsigned HAP,这满足当前本地开发与模拟器检查。警告不能直接删除或假装不存在,应记录为发布前置事项。正式上架、真机分发和持续交付阶段都必须补齐正确签名。

十七、如何验证不是“只看起来能用”

本次验证分成四层:

  1. ArkTS 类型检查通过,证明当前源码满足编译器检查。
  2. Hvigor 组装成功并生成非空 HAP。
  3. 通过 HDC 确认 API 24 模拟器已安装 com.atan.enotebook,Bundle 元数据显示编译 SDK 为 26.0.0.23。
  4. 依次点击首页、机台、调机、异常、报表,通过布局树核对标题和选中态。

五项导航结果如下:

页签 预期标题 结果
首页 注塑工程师助手 通过
机台 机台 通过
调机 调机 通过
异常 异常 通过
报表 报表 通过

这套检查覆盖了 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 进入页面容器;用户操作由子组件上报,容器执行规则并更新状态;状态再统一驱动正文和导航渲染。

下一步将在这套骨架上实现机台档案列表、运行状态筛选和机台详情。届时会继续说明列表数据如何建模、空状态如何处理,以及如何让真实业务代码保持可测试、可替换。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐