在这里插入图片描述

引言

本篇文章对鸿蒙(HarmonyOS)ArkTS 示例工程中的计数器应用做一次完整的技术拆解。

一、应用概述与功能

计数器应用的功能定位非常清晰:在独立页面中展示当前计数值,用户可以通过屏幕下方的三个按钮分别完成"加一""减一"与"清零"操作。与此同时,页面还附加了两个值得注意的交互细节。

第一个细节是动画反馈。无论执行哪一类计数操作,页面中央的大数字都会在 150 毫秒内先放大到原来的 1.2 倍,随后再在下一个 150 毫秒内回弹到 1 倍。这种"按下有回弹"的反馈让原本枯燥的数值变化变得生动,也顺带演示了 ArkUI 中显式动画 animateTo 的典型用法。

第二个细节是边界提示。当用户连续点击"减一",使计数进入负数区间时,系统会通过 Toast 弹出一条"已经减到 0 以下啦"的轻提示。Toast 不会打断用户当前操作,只在屏幕底部短暂停留后自动消失,非常适合这种非阻塞式的反馈场景。需要说明的是,这里允许计数变为负数,提示只是告知性质,并不会阻止用户继续减下去。

从数据模型的角度看,页面只维护两个状态:count 表示当前计数,scale 表示数字当前的缩放倍数。整个应用没有网络请求、没有持久化存储,数据完全在内存中流转,界面完全由这两个状态驱动。可以说,这是一个把"状态驱动 UI"这一思想讲得透彻的最小闭环示例,非常适合作为 ArkTS 入门的第一个实验对象。

在工程结构上,页面通过 @Entry 装饰器被声明为页面入口,并配合顶部"返回"按钮与 router 路由实现页面之间的跳转和回退。这一套"入口装饰器 + 路由导航"的组合在后续所有示例中都会反复出现,属于必须熟练掌握的基础设施。页面整体背景为浅灰色(#f2f3f5),在浅色背景上再放置蓝色按钮与深色数字,视觉层级分明。

二、核心知识点

别看示例短小,它涉及的知识点密度其实相当高,下面逐一说明。

1. @Entry 与 @Component 装饰器

@Entry 标记一个组件为页面入口,只有被 @Entry 修饰的组件才能被路由框架直接加载并展示;@Component 则把下面的 struct 声明为一个自定义组件。二者配合使用,就构成一个独立完整的页面单元。struct Index1 的名字可以随意,只要保证与文件内容对应即可,框架关心的是装饰器而不是结构体名。

2. @State 状态装饰器

@State 是 ArkTS 声明式开发中最重要的装饰器之一。被 @State 修饰的变量一旦发生赋值变化,框架会自动追踪所有依赖它的 UI 组件并触发局部刷新,开发者不需要编写任何"手动更新界面"的代码。本示例中 count 与 scale 都被声明为 @State,因此在 add、sub、clear 等方法中给 this.count 赋值后,页面上中央的大数字会立即重新渲染,这就是所谓的"声明式"。

需要特别注意的是,@State 的刷新粒度是"按组件"的。当 count 变化时,只有绑定了 count 的 Text 及其相关的表达式会被重新求值,其余未依赖该状态的组件不会被无谓重建,这也是 ArkUI 性能表现良好的原因之一。

3. animateTo 显式动画接口

animateTo 是 ArkUI 提供的显式动画接口,接收一个动画参数对象与一个闭包。闭包内对可动画属性的修改会被自动包裹上过渡动画。本示例把 scale 从 1 改为 1.2,Text 的 scale 属性便在 150 毫秒内平滑过渡,形成放大效果。动画参数里还可以配置 curve 曲线、delay 延迟、iterations 重复次数等,本示例只使用了 duration。

4. setTimeout 与动画衔接

动画有"放大"也必须要有"回弹"。这里通过 setTimeout 在 160 毫秒后再次调用 animateTo,把 scale 改回 1,从而形成完整的"放大—回弹"周期。160 毫秒略大于动画时长 150 毫秒,中间留出的 10 毫秒间隙保证前一段动画基本播放完毕后再执行回弹,视觉上更加连贯。这个"两个动画串行衔接"的小技巧在需要连环动画的场景中非常实用。

5. 路由与轻提示

router.back() 用于从当前页面返回到上一个页面,是页面栈操作中最常用的一环;promptAction.showToast() 则用于弹出短暂的文本提示。二者都从 @kit.ArkUI 包导入,属于页面级交互中最常见的系统能力。请留意示例中的导入写法:两条 import 语句分别导入 router 与 promptAction,在实际工程中它们也可以合并书写。

6. 状态最小化设计

设计页面时应该让状态尽量精简。本示例只保留了 count 与 scale 两个状态,页面上的文字内容、颜色、间距等视觉信息全部写在 build 里作为静态配置,没有额外造出多余的 @State。这样的好处是:状态越少,状态间的耦合就越小,出现"一个变化引发一串意料之外的刷新"的概率越低。判断一个值是否需要提升为 @State 有一个简单标准——它是否会在运行时被修改、并且界面需要跟随它的变化。像"当前计数"这样的提示文案永远不变,就没有必要放进状态。这一"状态最小化"的原则在待办清单示例中同样适用,值得在写业务代码时反复提醒自己。

以上这些知识点在后续待办清单示例中还会以不同的组合再次出现,建议在本文就把它们吃透。

三、源码逐段解析

接下来我们把源码按"状态区与业务方法"“动画方法”"界面构建"三个层次逐段拆开来看。

1. 状态区与三个业务方法

@Entry
@Component
struct Index1 {
  @State count: number = 0;
  @State scale: number = 1;

  private add(): void {
    this.count += 1;
    this.bounce();
  }

  private sub(): void {
    this.count -= 1;
    if (this.count < 0) {
      promptAction.showToast({ message: '已经减到 0 以下啦' });
    }
    this.bounce();
  }

  private clear(): void {
    this.count = 0;
    this.bounce();
  }
}

这一段是整页的数据源与动作源。count 与 scale 两个 @State 变量决定了页面的一切呈现。add 方法把 count 加一后调用 bounce;sub 方法先减一,再判断是否小于 0,若小于 0 则弹出 Toast 提示,最后同样调用 bounce;clear 方法直接把 count 重置为 0 并调用 bounce。可以看到三个方法结构高度一致,都是"改状态 + 播动画",这也正是状态驱动思想在业务方法层面的体现。

值得留意的是 sub 方法中"先减一、再判断"的顺序。如果写成"先判断当前值是否为 0 再决定是否减一",语义就会变成"不允许减到 0 以下",与本示例"允许出现负数、仅作提示"的设计完全不同。这种先后顺序的差别,恰恰体现了交互设计上的取舍。

2. 回弹动画方法

private bounce(): void {
  animateTo({ duration: 150 }, () => {
    this.scale = 1.2;
  });
  setTimeout(() => {
    animateTo({ duration: 150 }, () => {
      this.scale = 1;
    });
  }, 160);
}

bounce 方法封装了"放大—回弹"的完整动画过程。第一步用 animateTo 把 scale 设为 1.2,第二步用 setTimeout 延迟 160 毫秒后再把 scale 设回 1。两个步骤各自包裹在独立的 animateTo 中,因此每一段变化都是平滑的。之所以不用一个 animateTo 直接写两次赋值,是因为缩放要经历"先放大、稍作停留、再回弹"的过程,而不是线性变化;用两个动画加一个定时器来表达这种阶段性的动画节奏,是最直观的写法。

3. 数字文本与缩放绑定

Text(this.count.toString())
  .fontSize(96)
  .fontWeight(FontWeight.Bold)
  .fontColor('#1a6cff')
  .scale({ x: this.scale, y: this.scale })

这里把 count 通过 toString 转成字符串显示,字号 96、加粗、主题蓝。关键的绑定在最后一行:scale 的 x 与 y 都取 this.scale,于是当 scale 变化时,Text 会在水平与垂直两个方向同步缩放。注意 Text 的构造函数接收的是字符串,所以这里不能直接传数字,必须先转成字符串,否则编译会报类型错误。

4. 三个操作按钮

Row({ space: 16 }) {
  Button('加一 (+1)')
    .backgroundColor('#1a6cff')
    .fontColor(Color.White)
    .layoutWeight(1)
    .onClick(() => {
      this.add();
    })
}

按钮行用 Row 布局,space 为 16 表示按钮之间留 16vp 间距。每个按钮都设置了 layoutWeight(1),表示在水平方向均分剩余空间,因此三个按钮宽度一致。链式属性调用是 ArkUI 组件的标准写法,字体颜色、背景色、布局权重、点击回调等属性依次配置,最终通过 onClick 把点击事件与业务方法连接起来。"减一"与"清零"按钮与"加一"结构相同,只是背景色与回调方法不同,这里不再重复贴出。

5. 页面骨架

页面外层是铺满全屏的 Column,背景色 #f2f3f5。Column 内部从上到下依次是:顶部返回栏 Row(包含"返回"按钮、标题"计数器"和占位的 Blank)、一个弹性 Blank、居中的数字区域 Column(上为灰色小字"当前计数",下为蓝色大数字)、又一个弹性 Blank、三个操作按钮组成的 Row、底部灰色的操作提示 Text、最后一个弹性 Blank。

顶部返回栏本身也值得细看。它由 Row 承载,左侧是主题蓝的"返回"按钮,点击后调用 router.back();中间是标题 Text,字号 18 并加粗;右侧放了一个 Blank(),把标题顶到行的中部偏左位置,同时让整个返回栏的宽度撑满父容器。Row 还通过 padding({ left: 12, right: 12, top: 10, bottom: 10 }) 设置了四周的内边距,避免按钮和标题直接贴着屏幕边缘。这种"按钮 + 标题 + Blank"的返回栏模板在鸿蒙应用中非常常见,几乎可以原样复用到任何子页面,只要把标题文字替换一下即可。

多个 Blank() 组件是这段布局的精髓所在。Blank 是 ArkUI 提供的弹性占位组件,它会吃掉剩余空间,把两侧的内容顶到两端。三个 Blank 的存在,使得"中央数字区"在任意屏幕高度下都大致处于视口中部,数字不会贴着顶栏或底栏,整体重心稳定。

四、关键实现细节分析

这一节我们把一些容易被忽略、但值得深挖的实现细节单独拿出来分析。

1. 为什么数字用 Text 而不是别的组件

计数值本质上是一段文本,因此选择 Text 是最自然的。数字字体设置 96 号并加粗,配合主题蓝,在浅灰背景上形成强烈的视觉焦点。如果未来要展示更加复杂的数字图形(例如圆环进度、柱状图),则可以换成 Progress、Canvas 等组件,但就"显示一个数字"这件事而言,Text 已经足够简洁高效。

2. scale 同时作用于 x 与 y

Text 的 scale 属性接受一个对象,x 与 y 分别控制水平与垂直方向的缩放系数。本示例把两者绑定到同一个 this.scale,保证数字等比放大,不会出现变形。假如只设置 x 而不设置 y,数字就会在水平方向被拉伸,视觉效果会非常奇怪。这也是新手在编写缩放动画时最容易踩的坑之一。

3. 先减后判的执行顺序

sub 方法中,this.count -= 1 先执行,随后再判断 this.count < 0。这意味着当 count 为 0 时点击"减一",count 会变成 -1,同时弹出提示。这种设计允许负数存在,提示只是告知。如果业务上希望"不允许减到负数",则应该把判断放在减法之前,并在 count 为 0 时直接 return。两种写法表达的是两种完全不同的产品语义,示例选择的是前者。

4. 动画时序与快速连点

bounce 的动画时长为 150 毫秒,setTimeout 的延迟为 160 毫秒,二者之间留出 10 毫秒的间隙。这个间隙并不长,却能在绝大多数情况下避免"放大还没结束就开始回弹"的跳变感。但如果用户在动画播放期间快速连点,例如在第一次放大尚未回弹时再次点击,scale 会被重新置为 1.2,动画会出现轻微的重置抖动。对于本示例来说,这种程度的影响可以忽略;若追求更严格的体验,可以记录动画 ID 并在新动画开始前取消上一次,或者在动画期间禁用按钮。这是动画类应用中常见的"防抖"问题,理解其成因即可。

5. layoutWeight 与均分布局

Row 的 space 控制间距,layoutWeight(1) 控制均分。三个按钮同时设置 layoutWeight(1),意味着它们按相同的权重瓜分扣除间距后的剩余宽度,因此无论屏幕多宽,三个按钮始终等宽排列。这是 ArkUI 中实现"等宽按钮行"最标准的手法,比手写宽度百分比更抗屏幕适配。

6. Blank 撑出弹性空间

顶部栏、中央数字区、底部按钮区之间各放了一个 Blank()。Blank 会把剩余空间完全占掉,因此即使屏幕高度不同,数字也始终位于视口中央附近,而不是贴着顶栏或底栏。把 Blank 理解为"可伸缩的弹簧"是最形象的类比。

7. 链式调用的可读性

整个 build 方法几乎全部由链式属性调用构成。这种写法可读性好、层次清晰,属性一行一个,配合对齐的缩进,任何人都能一眼看出每个组件的全部配置。这也是 ArkUI 声明式语法区别于传统命令式 UI 编码的一个重要特征。

8. 颜色与尺寸单位的几种写法

源码中出现了多种颜色写法:主题蓝用字符串 ‘#1a6cff’,白色用 Color.White 枚举,提示文字用 ‘#9a9a9a’、‘#c0c0c0’ 等十六进制色值。两种写法在 ArkUI 中都是合法的:字符串色值更灵活,支持 ‘#RGB’、‘#ARGB’、‘#RRGGBB’、‘#AARRGGBB’ 等格式;Color 枚举则便于表达系统预定义的标准色,读起来更直观。尺寸方面,fontSize(16)、fontSize(96)、padding({ left: 12 }) 等处的数字默认单位都是 vp(虚拟像素),会随屏幕密度自动换算;而 width(‘100%’)、height(‘100%’) 使用了百分比字符串,表示相对父容器的比例。理解"数字单位 vp 与百分比字符串"这两套写法,是准确控制布局的基础。

9. 事件回调的写法

三个按钮的 onClick 都传入了箭头函数,在函数体内调用 this.add()、this.sub()、this.clear()。箭头函数没有自己的 this,因此可以直接访问组件的成员方法;如果误用了普通 function 回调,this 的指向就会丢失,导致调用失败。本示例中回调不需要事件参数,因此直接写 () => {…};当需要拿到点击位置等事件信息时,可以写成 (event: ClickEvent) => {…}。把"业务逻辑收进方法、回调只做转发"这一习惯保持下去,组件树会非常干净,也方便单元测试。

五、运行效果与操作指南

1. 环境准备

运行本示例需要先安装 DevEco Studio,并在 SDK Manager 中配置好 HarmonyOS SDK 与对应的模拟器或真机。工程需要在 DevEco Studio 中打开并完成工程同步,等待依赖解析完毕即可运行。

2. 运行步骤

打开工程后,在 Project 窗口定位到 entry/src/main/ets/pages/index1.ets,确认该页面已被配置为可访问的页面(示例工程通常通过首页按钮或路由进入)。点击顶部工具栏的运行按钮,选择模拟器或真机作为目标设备。编译与安装过程通常需要几十秒,完成后应用会自动拉起并进入首页;在首页中点击"计数器"入口,即可看到本页面的实际效果。

3. 操作流程与预期现象

进入页面后,首先看到浅灰背景上居中的蓝色大数字"0"。点击"加一 (+1)“,数字变为 1,并在约 150 毫秒内放大到 1.2 倍后回弹;继续点击,数字依次递增。点击"减一 (-1)”,数字递减;当数字从 0 减到 -1 时,屏幕底部弹出"已经减到 0 以下啦"的 Toast,稍后自动消失。点击"清零",数字立即回到 0 并伴随一次回弹动画。点击左上角"返回"按钮,页面通过 router.back() 回到上一级页面。

4. 值得观察的细节

运行时可留意三点:其一,数字缩放动画是否平滑,可以用真机实际体验回弹的节奏;其二,快速连点时动画是否会出现轻微抖动,这对应上一节分析过的时序问题;其三,改变窗口尺寸或切换横竖屏时,三个按钮是否始终等宽、数字是否始终居中,这验证了 layoutWeight 与 Blank 的弹性布局效果。

5. 页面间路由

计数器页本身由 @Entry 修饰,可以配置为直接入口。示例工程中,首页通常通过 router.pushUrl 或其他路由方式进入本页,而页面内则使用 router.back() 返回上一级。如果读者想跳过首页直接调试本页,可以在工程的 main_pages.json 或路由配置中调整页面顺序,也可以通过 DevEco Studio 的运行配置指定启动页面。理解"入口配置 + pushUrl/back"的配合,是后续管理多页面应用的前提。由于本页没有携带路由参数,router.back() 在返回时无需额外处理,直接回到进入前的页面即可。

六、可扩展方向

计数器虽然简单,却是承载各种扩展想法的绝佳载体。

1. 步长与范围控制

可以增加一个"步长"状态,让加一变成加 5、加 10;也可以增加最大值与最小值的约束,配合条件判断在达到边界时给出更友好的提示。

2. 长按连点

为按钮绑定 onTouch 或使用长按手势,实现按住不放连续累加的效果。这需要引入手势或触摸事件,并与定时器配合,能显著提升趣味性。

3. 历史记录

将每次操作记录到一个数组中,用 List 展示操作历史,类似于一个简易的"操作日志"。这会用上 ForEach 与 List,与待办清单示例的列表写法天然衔接。

4. 数据持久化

利用 PersistentStorage 或 AppStorage 将 count 保存到本地,使应用重启后仍能恢复上次的计数值。这是把"内存状态"升级为"持久化状态"的典型练习。

5. 动画进阶

把单一的回弹动画替换为更丰富的效果,例如使用 curve 配置曲线让回弹更自然、给数字切换时加入 transition 过渡,或配合音效在点击时发出提示音。这些都能在现有骨架之上渐进式地实现。

6. 深色模式与主题

根据系统深浅色模式动态切换背景与文字颜色,需要使用系统提供的颜色资源或响应式颜色变量,让页面在深色背景下同样清晰可读。

7. 统计与图表

为计数增加"累计点击次数""操作分布"等统计信息,并用 Canvas 或 Progress 组件绘制简单图表,把"工具型"页面升级为"数据型"页面。

8. 无障碍与本地化

可以补充无障碍语义:为数字区域添加无障碍描述,让读屏用户能听到当前计数;为每个按钮补充内容描述。同时可以把提示文案抽到资源文件中,实现多语言切换。这些虽然不改变核心逻辑,却能让示例从"能跑"走向"可用",是工程化意识的重要一环。

以上方向无论选择哪一个,核心代码结构(状态 + 事件 + 动画 + 布局)都不会改变,这正是本示例作为入门教材的价值所在。

七、常见问题与调试技巧

1. 修改 count 后界面不刷新

最常见的原因是忘记给变量加 @State。在 ArkUI 中,只有被 @State 等状态装饰器修饰的变量发生变化才会触发界面刷新,普通成员变量即便赋值也不会引起重绘。排查方法很简单:检查声明处是否有 @State,以及方法中赋值时是否用的是 this.count = 新值 这样的整值赋值。

2. 动画不生效

如果发现 scale 从 1 变为 1.2 时界面瞬间跳变、没有任何过渡,请确认赋值语句是否被包在 animateTo 的回调闭包内。只有闭包内的状态修改才会被识别为动画,写在闭包外的修改不会触发过渡效果。

3. 数字显示不出或类型报错

Text 的构造参数是 string,直接写 Text(this.count) 会报类型不匹配,必须写成 this.count.toString()。类似的类型问题在 ArkTS 严格类型检查下会在编译期暴露,属于最容易自查的一类错误。

4. setTimeout 里的 this

setTimeout 回调中使用箭头函数捕获 this,才能正确访问到当前组件的 scale。如果改用普通 function 回调,this 会指向 undefined 或全局对象,导致运行时报错。建议统一使用箭头函数。

5. Toast 没有弹出来

请确认 promptAction 已经从 ‘@kit.ArkUI’ 正确导入,并且调用发生在合法的上下文里。showToast 属于非阻塞提示,无需额外权限;若在预览器(Previewer)中调试,个别系统能力可能受限,建议在模拟器或真机上验证。

6. 调试技巧

在业务方法中插入 console.info 打印关键值,可在 Log 面板实时观察 count、scale 的变化轨迹,这对定位"状态是否更新"类问题非常有效。另外,DevEco Studio 的预览器支持实时预览,改完代码保存即可看到界面效果,是快速迭代 UI 的利器。遇到布局问题,可以临时给组件加上不同的背景色,通过"打颜色补丁"的方式直观判断各组件占用的空间区域。

7. 布局相关的小问题

如果发现三个按钮没有等宽、或者按钮行没有占满宽度,请检查两点:其一,Row 是否设置了 width(‘100%’) 或外层容器是否给了确定宽度,layoutWeight 是在容器宽度确定后才参与分配的;其二,Row 的 space 参数是否正确传入,它控制的是按钮之间的间距而不是外边距。另外,如果数字在部分机型上被裁切,可以检查外层 Column 的 alignItems 是否影响了文本行的宽度,必要时给数字所在容器补充 width(‘100%’) 与 alignItems(HorizontalAlign.Center),让内容始终居中。这类问题大多与"父容器宽度未确定"有关,理清布局的依赖链即可解决。

八、总结

计数器示例虽然只有不到 120 行代码,却完整地演示了 ArkTS 声明式开发的核心链路:用 @State 描述数据、用组件树描述界面、用事件回调描述交互、用 animateTo 描述动画、用 router 与 promptAction 调用系统能力。它的价值不在于功能本身有多复杂,而在于它把"状态驱动 UI"这一根本理念浓缩成了一个可以直接运行、可以亲手验证的最小模型。

通过本文的逐段解析,读者应当能够回答这样几个问题:状态装饰器为什么会引起界面刷新?显式动画为什么必须把赋值写进闭包?Blank 与 layoutWeight 是如何协同完成布局的?先减后判与先判后减分别表达了怎样的产品语义?这些问题背后正是 ArkUI 开发中最基础也最重要的思维习惯。

下一篇我们将阅读同目录下的待办清单示例(index2.ets),在那里会看到 List、ForEach、TextInput 等更多组件,以及数组状态更新的细节处理。把本篇的基础打牢,后续的学习会更加顺畅。

最后给读者一个实践建议:不要只停留在阅读上,可以试着亲手修改这份源码,例如把动画时长从 150 改成 300 观察节奏变化,把字体颜色改成红色,或者在 sub 方法中把"先减后判"改成"先判后减"。每改一处都重新运行,观察界面与行为的变化,这种"改一改、看一看"的循环是理解声明式框架最快的方式。遇到疑问随时回看本文对应的章节,相信收获会比单纯通读一遍大得多。

Logo

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

更多推荐