HarmonyOS实战:用ArkUI构建周长计算器,掌握状态管理与条件渲染 📅 发布时间:2026/9/20 4:09:09 👁 浏览次数: 写这篇文章之前我先把这次要做的实例说清楚一个跑在HarmonyOS上的周长计算器支持长方形和正方形两种图形用户输入长、宽或边长点一下按钮就出结果。放在“HarmonyOS应用实例”这个系列里这是第47个。题目看着简单但真正动手做的时候你会发现它把ArkUI里最常用的状态管理、条件渲染、表单输入、校验处理全串起来了。不管你是刚学完ArkTS基础语法还是准备用一个小项目过渡到DevEco Studio开发拿这个实例当练手都非常合适。我在实际做这个项目之前也犹豫过“计算器是不是太简单了”。但做完之后我的判断是简单不等于没价值周长计算器是一个“麻雀虽小、五脏俱全”的典型场景。你不需要处理复杂的网络请求和数据持久化可以把注意力完全放在页面怎么组织、状态怎么流转、输入怎么校验这些基本功上。下面我会把完整的实现过程拆开讲包括页面骨架、图形选择交互、校验逻辑、计算封装以及我在真机调试时踩过的几个坑。1. 这个周长计算器到底在练哪几个基本功先说说这个实例为什么值得做。如果你点开一段官方Demo看到的往往是完整代码但你不知道代码为什么要这样组织。周长计算器这个题目刚好能用最小的成本把下面这几个能力练到。1.1 状态驱动UI的核心体验HarmonyOS的ArkUI是声明式开发范式核心思想是“状态变了界面跟着变”。在传统开发里你拿到一个输入框的值可能要手动去更新某个Text控件的内容在ArkUI里你只需要维护一个State变量然后把这个变量绑定到组件上。用户在输入框打字onChange回调触发你更新State变量界面自然刷新。这个“状态驱动”的思维转换是初学者最容易卡住的地方也是周长计算器练得最充分的地方。我在这个实例里定义了这些状态shapeType当前选中的是长方形还是正方形用来控制输入区显示哪些输入框lengthValue、widthValue、sideValue分别保存长、宽、边长的输入内容resultText最终展示给用户的计算结果或错误提示isError标识当前结果是不是错误信息方便用不同颜色展示。状态变量不是越多越好但在这个场景里这几个刚好能覆盖所有交互内容。你写代码的时候可以观察一个现象点击“正方形”按钮后输入区会立刻从两个输入框变成一个输入框不需要你手动去控制组件显隐只要你把shapeType改掉条件渲染会自动帮你完成。这种“改数据、不管UI”的开发体验就是ArkUI最核心的价值。1.2 条件渲染与组件复用的分寸感长方形需要两个输入框正方形只需要一个输入框。最简单的实现方式就是写两个分支用if/else控制显示哪一块。ArkUI里条件渲染的写法很直观if (this.shapeType rectangle) { // 渲染长方形输入区 } else { // 渲染正方形输入区 }这个写法看起来很基础但里面有一个容易被忽略的原则条件渲染的粒度要合适。如果你把整个页面都放在一个if里那切换图形类型时连标题、按钮、结果区也一起销毁重建了不仅浪费性能还可能导致焦点丢失。我更推荐的做法是只把“输入区”这一小块用条件渲染包裹住其他结构保持稳定。这样既实现了需求又不会在切换时影响页面其他部分的状态。1.3 从“能用”到“好用”的输入校验意识很多人写计算器拿到输入就Number()一下算出结果就完事了。但实际跑起来你很快会发现用户可能不填内容直接点计算可能输入0可能输入负数也可能从别处粘贴一个包含空格的字符串进来。这些情况如果不处理页面上就会出现“NaN”或者一个明显不对的结果。这个实例正好可以帮你建立“输入校验必须前置”的意识。我的做法是把校验逻辑收敛成一个独立的函数不管长方形还是正方形都走同一套解析流程从源头保证进入计算的数字是合法的。这个习惯放到真实的表单页、搜索页里同样适用。2. 工程准备与页面骨架先想清楚输入区、操作区和结果区的关系动手写代码之前我习惯先在脑子里把页面从上到下排一遍。这个周长计算器的页面结构其实很简单大致是顶部标题、图形选择按钮、输入区、计算按钮、结果提示。2.1 创建工程与文件划分打开DevEco Studio新建一个Empty Ability工程语言选择ArkTS。工程创建好之后默认会有一个pages/Index.ets页面我们主要在这个文件里完成全部演示代码。你要是不想让页面逻辑太臃肿可以单独建一个utils/CalculateUtil.ets来放纯计算函数实例代码我为了演示方便把计算函数直接写在了页面里。实际项目里我更建议把不依赖UI的逻辑独立出去方便以后做单元测试。页面结构我用的是最常规的Column嵌套Row。Column负责垂直方向排列Row负责水平方向排列这套布局方式足够覆盖当前需求。骨架代码先搭出来Entry Component struct PerimeterCalculator { build() { Column({ space: 16 }) { Text(周长计算器) .fontSize(24) .fontWeight(FontWeight.Bold) // 图形选择区 Row({ space: 12 }) { Button(长方形) Button(正方形) } // 输入区 // 计算按钮 // 结果区 } .padding(24) .width(100%) .height(100%) } }2.2 布局组件的选型逻辑为什么用Column而不是Flex也不是Grid原因很简单这个页面是单列纵向布局组件数量固定没有滚动需求Column就是最直接、最不容易出错的方案。Row用来放两个图形选择按钮正好体现水平排列关系。如果你用Flex加justifyContent去实现当然也行但对新手来说理解成本稍高没必要为了“看起来高级”而牺牲可读性。我在Column里设置了space: 16让子组件之间自动产生16vp的间距。这里有个小技巧如果不用space你可能会在每个组件上单独加margin那样代码会显得很啰嗦。space一次搞定间距而且当你增删组件时间距依然是均匀的不用回头去改一堆margin值。2.3 输入区与结果区的初始状态输入区我准备用TextInput组件来接收用户输入这是ArkUI里最常用的文本输入框。对于数字输入场景设置.type(InputType.Number)可以让系统弹出数字键盘对用户更友好。结果区我准备用Text组件展示初始为空字符串只有点击“计算”按钮后才显示内容。这里有一个值得注意的点TextInput的text参数需要绑定一个字符串状态。它的onChange回调会把这个输入框当前的值传回来。正确的做法是在onChange里同步更新状态变量保证输入内容和状态一致。TextInput({ placeholder: 请输入长, text: this.lengthValue }) .type(InputType.Number) .onChange((value: string) { this.lengthValue value })这种“受控绑定”在ArkUI里非常重要。如果你只给text赋值但不在onChange里更新状态输入框的内容很快就会变得“不正常”因为UI重新渲染时读到的还是旧状态输入内容会被重置。3. 图形选择控件的取舍我为什么不用Tabs或Radio而是用两个Button图形选择是这个实例里交互设计的关键。两种图形对应不同的输入项所以选择结果会直接影响输入区的渲染。很多教程会直接用Tabs切换两块内容或者用Radio做单选但我最终选了自定义的高亮Button方案。3.1 方案对比Tabs、Radio、Button高亮方案优点缺点适用场景Tabs自带切换动画代码结构清晰结构较重切换时会有页面滑动效果更像多页签内容较多的场景比如“详情”和“日志”两个完整页面Radio单选语义明确用户一看就知道视觉上占空间点击区域较小需要额外配对文字设置页、选项较少的表单Button高亮轻量点击区域大样式可完全自定义需要自己维护选中态这种“二选一且切换会改变下方表单”的场景我实操下来的感受是Tabs有点“杀鸡用牛刀”而且切换动画会让人觉得页面在跳转不够轻量Radio视觉上不如两个按钮直观用户点按钮的欲望更强。所以这里我选了自定义高亮Button配合条件渲染控制选中态样式。3.2 按钮选中态的样式切换实现思路是维护一个shapeType状态长方形时等于rectangle正方形时等于square。按钮是否处于选中态通过比较shapeType当前值来决定。样式上用背景色、文字颜色区分选中和未选中。Button(长方形) .backgroundColor(this.shapeType rectangle ? #317AF7 : #F1F3F5) .fontColor(this.shapeType rectangle ? Color.White : #182431) .onClick(() { this.shapeType rectangle this.resultText }) Button(正方形) .backgroundColor(this.shapeType square ? #317AF7 : #F1F3F5) .fontColor(this.shapeType square ? Color.White : #182431) .onClick(() { this.shapeType square this.resultText })这里有一个我特别想强调的细节点击按钮切换图形类型时除了更新shapeType我还把resultText清空了。原因是用户切到正方形时看一个长方形计算出来的结果会产生误导。这种“切换上下文就清空上一次反馈”的做法在移动端交互里非常常见也是个容易被新手忽略的体验细节。3.3 切换时的输入数据保留策略我最终选择的是长方形的长和宽、正方形的边长分别用三个独立状态保存。这样一来用户从长方形切到正方形再切回来长方形的输入值还在。这个体验比“一切换就全部清空”更友好也更贴近实际使用场景。如果你希望切换时彻底重置所有输入可以在onClick里把lengthValue、widthValue、sideValue全部赋值为空字符串。两种策略各有使用场景就这个计算器而言我推荐保留各自输入因为用户很可能在两种图形之间来回比较计算结果。4. 输入校验与数字解析这个实例最容易翻车的地方很多从传统前端转过来的开发者写Number(value)的时候很顺手但Number的解析逻辑比想象中宽松它会接受0x10、1e3这类字符串也能把空字符串解析成0。这些“意外输入”如果直接进入计算逻辑结果会很离谱。我在这个项目里把校验单独封装了一个函数让所有输入都走同一道关卡。4.1 一个通用的正数解析函数核心逻辑是先去除首尾空格再做空字符串判断然后用Number()转换最后判断是否为正数。private parsePositiveNumber(input: string): number { const trimmed input.trim() if (trimmed ) { return Number.NaN } const value Number(trimmed) if (Number.isNaN(value) || value 0) { return Number.NaN } return value }这个函数在长方形和正方形的计算逻辑里都能复用。注意Number.isNaN和isNaN不一样前者不会做隐式类型转换判断更严格。如果你在代码里用的是全局isNaN传入字符串时会被转成数字再判断容易造成误判所以建议统一用Number.isNaN。4.2 如果要更严格可以使用正则Number()对0x10、1e3这类字符串也能解析但对“用户输入边长”这个场景来说这些内容并不是合法输入。如果你希望只有“纯数字、可以带小数点”的形式才允许通过可以用一个简单正则约束private parsePositiveNumber(input: string): number { const trimmed input.trim() if (trimmed || !/^\d(\.\d)?$/.test(trimmed)) { return Number.NaN } const value Number(trimmed) if (Number.isNaN(value) || value 0) { return Number.NaN } return value }正则^\d(\.\d)?$表示至少一位数字开头小数部分可选。它拒绝了空格、负号、科学计数法、十六进制等格式。这样校验会更贴近真实世界的“长度输入”。当然如果你觉得这个规则太约束用前面那版Number判断也够用。4.3 InputType数字键盘的小数点问题我在写代码时把输入框类型设成了InputType.Number但实际真机调试时发现一个问题在某些HarmonyOS版本上InputType.Number弹出的键盘没有小数点用户想输入3.5根本输不了。这是非常影响体验的问题。解决思路是换用InputType.NumberDecimal这个输入类型会带上小数点。如果你用的SDK版本里没有NumberDecimal枚举也可以用默认键盘类型再配合正则校验。为了让你少踩坑建议用下面这种更稳妥的写法TextInput({ placeholder: 请输入长, text: this.lengthValue }) .type(InputType.NumberDecimal) .onChange((value: string) { this.lengthValue value })输入类型这个东西文档上和真机上偶尔会有差异做完之后一定要在模拟器和真机上分别试一下不能只看预览器里的效果。4.4 错误提示的展示方式红字还是Toast当用户输入为空或者非法时我选择了在结果区直接显示红色错误文字而不是用Toast弹窗。原因很简单Toast一闪而过用户可能还没看清就消失了而内联错误文字会一直留在页面上直到用户重新输入并点击计算。对计算器这种工具型应用来说稳定的提示比临时的弹窗更实用。if (this.resultText ! ) { Text(this.resultText) .fontSize(18) .fontColor(this.isError ? #F54A45 : #182431) }isError状态和resultText一起更新。计算成功时isError设为false显示正常深色文字校验失败时isError设为true显示红色。这样一个状态变量就区分了两种展示场景。5. 计算逻辑与状态更新别把公式憋在按钮事件里计算逻辑本身很简单长方形周长2 * (长 宽)正方形周长4 * 边长。但这部分如果直接散落在按钮的onClick里代码会越来越难维护。我在项目里把计算逻辑收敛到了类的独立方法中让按钮事件只负责“调用计算方法并处理结果”。5.1 独立计算方法的优势把计算逻辑从UI事件中抽出来的好处有三个第一按钮事件代码变短阅读时一眼就能看出“点了按钮会发生什么”第二计算方法可以被多个入口复用比如以后加一个“音量键触发计算”直接调用同一个方法即可第三计算逻辑不依赖UI组件单独写单元测试非常方便。我这里把calculate方法定义在Component结构体内部因为它需要读取并修改页面状态。如果后续逻辑更复杂可以把这个方法再下沉到独立的工具文件里通过参数传入输入值、返回计算结果。private calculate(): void { if (this.shapeType rectangle) { const length this.parsePositiveNumber(this.lengthValue) const width this.parsePositiveNumber(this.widthValue) if (Number.isNaN(length) || Number.isNaN(width)) { this.resultText 请输入合法的长和宽 this.isError true return } const perimeter 2 * (length width) this.resultText 长方形的周长是 ${this.formatResult(perimeter)} this.isError false } else { const side this.parsePositiveNumber(this.sideValue) if (Number.isNaN(side)) { this.resultText 请输入合法的边长 this.isError true return } const perimeter 4 * side this.resultText 正方形的周长是 ${this.formatResult(perimeter)} this.isError false } }5.2 浮点数结果展示的处理周长计算结果理论上可能是小数比如长3.3、宽2.2周长是11但如果你继续算面积或者更复杂的运算浮点数精度问题就会冒出来。对于展示来说我建议对结果做一次格式化。一种做法是保留两位小数private formatResult(value: number): string { return String(parseFloat(value.toFixed(2))) }toFixed(2)会把数字转成保留两位小数的字符串parseFloat再把末尾多余的0去掉。比如10.00会变成1010.50会变成10.5。这样结果展示比较干净不会出现一长串小数点。5.3 状态更新的最小化原则在ArkUI里状态变量是UI刷新的源头。每改一个State变量依赖它的组件都会重新渲染。为了性能着想我们应该尽量减少不必要的状态更新。比如结果区只有Text组件依赖resultText和isError那这两个状态的变化就只影响这一小块区域不会导致整个页面重新渲染。这也是为什么不要把输入框内容全部塞进一个大对象里。如果你定义了一个State formData包含所有字段任何字段更新都会触发依赖formData的组件刷新范围变大了。这个小项目里两者差别不大但从一开始养成“最小状态集”的习惯后面写复杂页面会舒服很多。5.4 按钮的连续点击问题用户可能连续点击“计算”按钮每次点击都会重新走一遍校验和计算逻辑。这里有一个需要留意的细节计算结果展示后如果用户不修改输入直接再次点击页面会重复刷新同样的结果。这种操作虽然无害但如果以后计算结果需要写入数据库或上报统计就需要加“结果未变化就不重复处理”的判断。本实例不涉及这个需求但我在写代码时还是通过“先校验、再计算、最后更新状态”的顺序让每次点击逻辑保持一致。6. 完整代码与DevEco Studio运行步骤上面的内容都验证没问题之后我把完整页面代码贴出来。这个版本是我在真机上跑过的注释也写了方便你做对比。6.1 完整页面代码Entry Component struct PerimeterCalculator { State shapeType: string rectangle State lengthValue: string State widthValue: string State sideValue: string State resultText: string State isError: boolean false private parsePositiveNumber(input: string): number { const trimmed input.trim() if (trimmed || !/^\d(\.\d)?$/.test(trimmed)) { return Number.NaN } const value Number(trimmed) if (Number.isNaN(value) || value 0) { return Number.NaN } return value } private formatResult(value: number): string { return String(parseFloat(value.toFixed(2))) } private calculate(): void { if (this.shapeType rectangle) { const length this.parsePositiveNumber(this.lengthValue) const width this.parsePositiveNumber(this.widthValue) if (Number.isNaN(length) || Number.isNaN(width)) { this.resultText 请输入合法的长和宽 this.isError true return } const perimeter 2 * (length width) this.resultText 长方形的周长是 ${this.formatResult(perimeter)} this.isError false } else { const side this.parsePositiveNumber(this.sideValue) if (Number.isNaN(side)) { this.resultText 请输入合法的边长 this.isError true return } const perimeter 4 * side this.resultText 正方形的周长是 ${this.formatResult(perimeter)} this.isError false } } private shapeButton(title: string, type: string): void { this.shapeType type this.resultText } build() { Column({ space: 16 }) { Text(周长计算器) .fontSize(24) .fontWeight(FontWeight.Bold) Row({ space: 12 }) { Button(长方形) .layoutWeight(1) .backgroundColor(this.shapeType rectangle ? #317AF7 : #F1F3F5) .fontColor(this.shapeType rectangle ? Color.White : #182431) .onClick(() { this.shapeButton(长方形, rectangle) }) Button(正方形) .layoutWeight(1) .backgroundColor(this.shapeType square ? #317AF7 : #F1F3F5) .fontColor(this.shapeType square ? Color.White : #182431) .onClick(() { this.shapeButton(正方形, square) }) } .width(100%) if (this.shapeType rectangle) { Column({ space: 12 }) { TextInput({ placeholder: 请输入长, text: this.lengthValue }) .type(InputType.NumberDecimal) .height(48) .padding({ left: 12, right: 12 }) .onChange((value: string) { this.lengthValue value }) TextInput({ placeholder: 请输入宽, text: this.widthValue }) .type(InputType.NumberDecimal) .height(48) .padding({ left: 12, right: 12 }) .onChange((value: string) { this.widthValue value }) } .width(100%) } else { TextInput({ placeholder: 请输入边长, text: this.sideValue }) .type(InputType.NumberDecimal) .height(48) .padding({ left: 12, right: 12 }) .width(100%) .onChange((value: string) { this.sideValue value }) } Button(计算) .width(100%) .height(48) .onClick(() { this.calculate() }) if (this.resultText ! ) { Text(this.resultText) .fontSize(18) .fontColor(this.isError ? #F54A45 : #182431) } Blank() } .padding(24) .width(100%) .height(100%) .alignItems(HorizontalAlign.Start) } }这个版本我在底部加了一个Blank()可以把内容区域撑开让结果文字不至于贴在屏幕最下方。alignItems(HorizontalAlign.Start)控制子组件左对齐输入框和按钮宽度各自适配。6.2 运行与验证步骤创建工程后把pages/Index.ets里的内容整体替换成上面的代码然后构建到模拟器或真机。建议验证这几条路径输入长5、宽3点计算结果应该是16输入边长9切到正方形点计算结果应该是36长输入0或空字符串点计算应该出现红色错误提示输入小数3.5和2.2确认数字键盘能正常输入小数点长方形和正方形反复切换确认输入内容不会被意外清空、结果提示会消失。6.3 真机调试时容易忽略的设置点第一个是键盘弹起后可能遮挡计算按钮。如果你在深色模式或小屏设备上测试建议把整个页面内容包一层“可以上下滑动的容器”或者确认当前系统版本会自动避让键盘。我测试的版本会自动避让所以代码里没有额外处理但你在旧设备上体验时需要注意。第二个是字体大小设置。系统字体如果调得很大固定高度48vp的输入框里面的文字可能出现显示不全的问题。官方推荐尽量使用默认高度或布局自适应如果确实要固定高度至少设置fontSize和最小行高保证极端情况下内容可读。第三个是InputType.NumberDecimal在部分模拟器上可能不生效。我的经验是模拟器和真机的输入法差异比较大遇到类似问题优先换真机验证不要急着改代码。7. 我踩过的坑和后续扩展思路这个实例我在实现过程中遇到了几个值得记下来的问题也想到了几个可以继续扩展的方向放在最后一起说。7.1 TextInput受控绑定带来的输入卡顿我第一次写的时候没有在onChange里同步更新lengthValue只在计算的时候去读取输入框组件引用。结果UI渲染时输入框内容每次都被重置成初始值字都打不进去。后来改成受控绑定在onChange里立刻更新状态问题就消失了。这个坑几乎每个新手都会遇到知道了能省不少排查时间。7.2 错误提示的isError状态可能残留当用户上次计算出现错误、结果区显示红字然后他修改了输入但没有重新点击“计算”时红字会一直留在那里。虽然严格来说这是合理的因为“上一次的计算结果”还没有被新的计算覆盖但从用户体验上看用户可能已经改了输入却还盯着一条之前的错误提示容易产生困惑。我目前是在切换图形类型时清空结果如果你希望输入变化时也清除结果可以在每个onChange里把resultText重置为空。这个看个人偏好。7.3 扩展思路从周长到面积再到自定义工具集这个项目做完之后我很自然地想到了几个扩展方向。第一个是增加面积计算页面几乎不用大改只要在结果区多显示一行面积结果就行。第二个是增加单位选择比如“米”“厘米”“英寸”计算时先做单位换算再算周长这涉及到单位数据结构的组织能练到枚举和选择器的使用。第三个是增加历史记录把每次计算结果保存到本地用Preferences或数据库实现这就引入了数据持久化。如果你想在同一个系列里继续深入也可以把这个计算器扩展成“多边形周长通用计算器”支持三角形底线是三条边的输入框动态变化这比长方形和正方形更进一步会让你接触不同类型的图形输入排列。7.4 我个人的一点体会这个项目做完我对ArkUI声明式开发的理解比只看文档深了不少。尤其是“状态变量是UI的唯一数据来源”这句话真正写完一个带输入框、按钮、条件渲染的小工具后才算有了身体记忆。你在照着做的时候如果发现预览器和真机效果不一样不用怀疑自己写错了ArkUI在不同设备上的表现确实存在差异多看日志、多跑真机经验积累起来之后心里就有底了。如果你之前只是照着教程敲代码敲完就忘我建议你从这个小计算器开始试着不给答案自己从头写一遍。什么时候你能独立把这个页面从零写出来并且把刚才提到的边界输入都处理干净基础就算打牢了。