在HarmonyOS应用实例系列整理到第78个的时候,我越来越觉得一件事:真正能帮到新手的,不是那种堆满图表、地图、多模块的“大而全”项目,而是一个把真实场景做清楚、把数据逻辑讲明白的“小而精”案例。这次分享的“平均数:数据调查活动”就是这样一个典型场景——你在班级里统计同学们每周的运动时长,在家庭群里收集大家的作息数据,或者在小团队里调研每天加班多少小时,本质上都是一类需求:录入一组调查数据,实时算平均值,还能随时修正或删除某条数据。今天我就用HarmonyOS的ArkTS + ArkUI把这套东西完整实现一遍,从界面搭建到状态管理,再到边界处理和扩展方向,全部讲透。
这个实例适合正在学HarmonyOS声明式开发的初学者,也适合已经把基础组件过了一遍、想看看完整业务逻辑怎么串起来的开发者。你不仅会看到一个平均数计算器,还会看到一个数据类应用最基础也最关键的骨架:数据怎么录入、状态怎么维护、列表怎么渲染、结果怎么联动。搞懂这一套,后面做问卷、记账、评分统计都是顺手的事。
1. 项目拆解:平均数数据调查活动到底在做什么
1.1 场景需求:为什么用平均数做一个HarmonyOS应用
先说场景。数据调查不是一个陌生的概念,学校里做体质测试统计、公司里填问卷收集OKR数据、小区里调研垃圾分类参与率,最终都要落到几个核心问题上面:一共收集了多少条数据、总和是多少、平均值是多少、哪些数据异常需要剔除。
平均数这个统计量刚好是“数据调查”最直观的切入点。它不需要复杂的概率分布知识,总共就两行计算:总和除以个数。但正因为简单,它对应用的容错性、交互流畅度和数据管理能力提出了更明确的要求——你输进去一条非数字内容怎么办?一条数据录错了怎么改?平均值除不尽怎么展示?这些细节处理得好不好,才是一个应用能不能真正落地使用的分水岭。
所以我在设计这个项目时,没有把它只当成一个“算平均数的计算器”来做,而是对标一个微型调查系统。功能上至少要包含:数据录入、数据列表展示、单条删除、一键清空、实时平均显示、录入不合法时的提示,以及数据为空时的占位反馈。这样用户拿过去就能直接用,而不是一个只能演示、不能实战的玩具。
1.2 功能清单与页面规划
把需求转成界面,其实就是“上中下”三个区域:顶部是标题和数据录入,中间是已录入数据列表,底部是统计结果和操作按钮。这是数据类应用非常经典的信息架构,信息流方向也是从上到下:输入 → 展示 → 汇总,符合大多数人的操作直觉。
具体功能清单我整理成下面这张表,开发前先列清楚,干活就不容易跑偏:
| 功能模块 | 具体功能 | 交互说明 |
|---|---|---|
| 数据录入 | 输入调查数据 | 数字键盘,支持小数,过滤非法字符 |
| 数据列表 | 展示已录数据 | 带序号,支持逐条删除 |
| 统计结果 | 实时显示平均值 | 数据为空时显示占位文案 |
| 数据管理 | 清空全部数据 | 带二次确认或一键清空 |
| 校验反馈 | 非法输入提示 | 空输入、非数字、极端值处理 |
你可能会问,删一条数据要不要二次确认?我的建议是单个删除不必弹窗确认,但“清空全部”建议保留一步确认逻辑,或者至少让用户能明确意识到这是不可逆操作。后面我会讲到实现细节。
1.3 技术选型:ArkTS + ArkUI的声明式开发优势
技术选型上,HarmonyOS应用开发现在的主流方式是ArkTS语言搭配ArkUI声明式框架。所谓“声明式”,核心思路是你只需要描述界面长什么样,状态是什么,框架负责在状态变化时去更新界面,而不需要你手动操作每一个组件的文本和属性。
传统的命令式开发,比如早期安卓里的XML + findViewById,改一个数字要先找到控件,再把计算好的值set进去;声明式开发的做法是:你在build里声明“这里放一个Text,显示的内容绑定到avgText状态”,只要avgText变了,界面自动就变了。这个思路对数据调查这种“频繁增删数据、结果实时联动”的场景非常匹配,开发效率和可维护性都明显更好。
2. 界面搭建:ArkUI组件用得顺不顺手
2.1 整体布局:Column + Row组合出来的表单页
页面整体用垂直布局Column撑起来,这是ArkUI页面最基础的骨架。内部再根据需要嵌套Row做水平排列,用List承载动态数据列表,用分区组件把“输入区、列表区、结果区”三个视觉区块分开。
我习惯先搭外框再填组件:最外层一个Column占满全屏,内部分成四个子区域——顶部标题占固定高度,输入区固定高度,列表区用layoutWeight(1)占掉剩余空间(这样列表长了能滚动),底部结果区固定高度。这个“固定 + 弹性 + 固定”的组合在真机上非常稳,不管屏幕多大,列表区都能自动伸缩。
有一点值得提醒:别把整个页面做成Scroll嵌套List,ArkUI里List本身就支持滚动,嵌套Scroll容易产生滑动冲突和性能问题。列表数据多了以后,滚动上List也比Scroll流畅得多,因为它有懒加载和复用机制。
2.2 输入与按钮:TextInput和Button的协作细节
录入数据用的是TextInput组件,有一个很实用的属性:type(InputType.Number)。设置了之后,真机上弹出的就是数字键盘,用户就不用再手动切换输入法,录入体验会好很多。这一点在做数据类应用时非常关键,我见过不少初学者的应用没设置这个,用户每次录入都要切键盘,很不方便。
不过数字键盘只影响软键盘的形态,不能完全避免用户输入非法内容。有些人会在输入法里给出全角数字、小数点和特殊符号,所以代码里的校验逻辑仍然不能省。我在TextInput的onChange回调里把当前输入值保存到inputValue状态,添加按钮被点击时再做统一校验,而不是每次按键都校验,这样交互更顺畅,也给用户留了修改输入的空间。
按钮这里用Button('添加'),点击回调里执行addData()。ArkUI的Button本身有多种样式,可以直接通过backgroundColor、fontColor、borderRadius这些属性调整外观,不用额外包一层容器。
2.3 数据列表:ForEach渲染与key生成器
列表渲染是声明式UI的核心环节。我在List里嵌套ForEach,遍历dataList数组,每一条数据生成一个ListItem,里面放一组Row:左侧序号和数值,右侧删除按钮。用户点击删除时,根据索引index调用removeData(index)把对应数据移除。
ForEach的第三个参数是key生成器,很多人会忽略它,但这个参数在数据重复或顺序变化时特别重要。我用的key生成器是(index) =>${index}-${item},把索引和值拼在一起,保证每条数据的key唯一且稳定。如果不写key生成器,在一些场景下ArkUI自己生成的key可能不够稳,列表项复用后会出现状态错乱、动画异常之类的问题,排查起来很费劲。宁可在写代码的时候多花两秒把key生成器写明白。
3. 核心逻辑:平均数的计算与状态管理
3.1 @State状态管理:数据一变界面就变的原理
先说你必须要理解的一个概念:@State。在ArkUI里,状态就是“会驱动界面变化的数据”。我用了三个@State变量:dataList存已经录入的所有数据,类型是number数组;inputValue存输入框当前文本;avgText存界面展示的平均结果。每次这些状态变化,依赖它们的组件会自动重建或更新。
有个很重要的原理:@State监听的是引用变化。对数组来说,你直接去改某个下标的值,比如this.dataList[0] = 100,界面通常不会刷新,因为数组本身的引用没有变,框架感知不到发生了修改。正确的做法是给数组赋一个全新的引用,用扩展运算符this.dataList = [...this.dataList, value],或者用filter、map这类返回新数组的方法来更新。这个坑我在刚开始写HarmonyOS时踩过好几次,一度以为UI没刷新是框架的Bug,查了半天结果是自己没有替换引用。你记住一句话:数组更新,永远给它一个新数组。
3.2 添加数据与合法性校验
添加数据看起来就是把输入框内容转成数字然后push进数组,但实际做的时候要处理三个边界。
第一个是空输入。用户点了添加但什么都没输,如果直接Number('')会得到0,你以为录入了一条0的数据,实际是非法的。所以第一步先对原始字符串做trim,判断是不是空字符串。
第二个是非数字。Number('abc')得到NaN,所以用isNaN方法做判断。这里还要注意,isNaN(Number(' '))为false但字符串本身是空白,所以空值的判断一定要放在数字转换之前,顺序不能反。
第三个是重复点击。如果你的添加按钮没有做防重复处理,用户快速连点两次就可能添加两条同样的数据。如果业务上允许重复,那无所谓;如果每条数据都代表一个独立样本,那可以在添加前检查数组中是否存在相同值,存在就提示“重复数据”并中断添加。我这个场景里允许重复,就没有做这层限制,但判断逻辑不难,留着注释你自己决定要不要加。
3.3 平均数计算与展示细节
平均数计算的公式就是总和除以个数,我单独封装了一个updateAverage()方法,这样每次列表变化后调用一次即可。计算时先判断数组长度是否为0,为空时直接把avgText设置成“暂无数据”,避免0/0得到NaN。
除不尽的情况是很多新手忽略的。比如3条数据分别为1、2、4,平均数是2.33333……,直接拿结果去toString会显示一大串小数。这里我做了一层格式化:先用toFixed(2)保留两位小数,再用parseFloat包一层去掉末尾多余的0,最终得到的avgText会显示成2.33而不是2.330000000001这样的样子。这里还要特别说一下浮点精度问题,JavaScript的浮点数在计算0.1+0.2时会有误差,连加多个小数后误差可能累积,所以平均值展示前做一次toFixed格式化是非常有必要的。
4. 实操过程:把调查活动跑起来的完整步骤
4.1 工程准备与页面骨架
实际操作层面,打开DevEco Studio,新建一个Empty Ability工程,语言选ArkTS,兼容API版本建议选当前稳定版本(API 11及以上)。工程建好后,默认的Index.ets就是我们写页面代码的文件。
页面骨架用Entry + Component描述。@Entry标记这是一个页面入口,@Component标记这是一个自定义组件,struct里的build方法描述界面树。下面先写一个只含骨架的版本,把页面分区都搭出来,跑一遍确认布局没问题,再往里填逻辑。
@Entry @Component struct AverageSurvey { build() { Column() { Text('数据调查 · 平均数计算') .fontSize(24) .fontWeight(FontWeight.Bold) .margin({ top: 20 }) Row() { TextInput({ placeholder: '请输入调查数据' }) .layoutWeight(1) Button('添加') } .padding(16) List() { } .layoutWeight(1) Column() { Text('调查结果') Text('暂无数据') } } } }4.2 完整代码与逐段解析
骨架确认没问题后,把状态、逻辑、事件补全,就是下面这版完整实现。代码不长,但每块都有值得品一下的地方。
@Entry @Component struct AverageSurvey { @State dataList: number[] = [] @State inputValue: string = '' @State avgText: string = '暂无数据' @State tipText: string = '' build() { Column() { // 顶部标题区 Text('数据调查 · 平均数计算') .fontSize(24) .fontWeight(FontWeight.Bold) .margin({ top: 20 }) Text('输入调查数据,自动统计平均值') .fontSize(14) .fontColor('#666666') .margin({ top: 8 }) // 输入区 Row() { TextInput({ placeholder: '请输入调查数据' }) .type(InputType.Number) .layoutWeight(1) .onChange((value: string) => { this.inputValue = value }) Button('添加') .margin({ left: 12 }) .onClick(() => { this.addData() }) } .width('100%') .padding(16) // 错误提示区 if (this.tipText !== '') { Text(this.tipText) .fontSize(12) .fontColor('#E84026') .margin({ left: 16, right: 16 }) } // 数据列表区 List() { ForEach(this.dataList, (item: number, index: number) => { ListItem() { Row() { Text(`第${index + 1}条数据`) .fontSize(16) Blank() Text(`${item}`) .fontSize(16) .fontWeight(FontWeight.Medium) Button('删除') .backgroundColor('#E84026') .fontSize(14) .onClick(() => { this.removeData(index) }) } .width('100%') .padding(12) .borderRadius(8) .backgroundColor('#F5F5F5') .margin({ bottom: 8 }) } }, (item: number, index: number) => `${index}-${item}`) } .width('100%') .layoutWeight(1) .padding(16) // 结果区 Column() { Text('调查结果') .fontSize(16) .fontColor('#666666') Text(this.avgText) .fontSize(32) .fontWeight(FontWeight.Bold) .margin({ top: 8 }) Text(`共录入 ${this.dataList.length} 条数据`) .fontSize(14) .fontColor('#888888') .margin({ top: 4 }) } .width('100%') .padding(20) .backgroundColor('#F0F8FF') .borderRadius(12) .margin({ left: 16, right: 16, bottom: 16 }) // 清空按钮 Button('清空全部数据') .backgroundColor('#999999') .width('80%') .onClick(() => { this.clearAll() }) .margin({ bottom: 24 }) } .width('100%') .height('100%') } // 添加一条数据并更新平均值 addData() { const trimmed = this.inputValue.trim() if (trimmed === '') { this.tipText = '请先输入数据' return } const value = Number(trimmed) if (isNaN(value)) { this.tipText = '请输入合法的数字' return } this.dataList = [...this.dataList, value] this.inputValue = '' this.tipText = '' this.updateAverage() } // 删除指定索引的数据并更新平均值 removeData(index: number) { this.dataList = this.dataList.filter((_, i) => i !== index) this.updateAverage() } // 清空所有数据 clearAll() { this.dataList = [] this.inputValue = '' this.tipText = '' this.avgText = '暂无数据' } // 计算并更新平均值 updateAverage() { if (this.dataList.length === 0) { this.avgText = '暂无数据' return } const sum = this.dataList.reduce((acc, cur) => acc + cur, 0) const avg = sum / this.dataList.length this.avgText = parseFloat(avg.toFixed(2)).toString() } }4.3 运行效果与交互流场
这段代码直接跑起来后,整条交互流是通顺的。用户输入一个数字,点添加,数字以新列表项的形式出现在中间列表区,底部平均结果实时更新,录入条数同步变化。如果输入为空或输入了非数字内容,输入框下方会出现一行红字提示,提示文案会在下一次成功添加后自动消失。点列表项右侧的删除按钮,对应数据移除,平均值立即重算。数据全部删除后,底部显示“暂无数据”,同时“共录入 0 条数据”。
整个过程中没有一个环节需要用户等待,也没有任何手动的“刷新”或“提交”动作。好的数据调查应用就应该这样,所见即所得:录一条,算一条,改一条,结果跟着变。
5. 常见问题与避坑指南
5.1 数字键盘与输入类型
有朋友反映,设置了type(InputType.Number)之后,真机弹出来的仍然是全键盘,或者键盘弹不出来。这种情况一般有三个可能:一是API版本比较老,建议升级到API 9以上;二是模拟器对数字键盘支持不够好,建议直接在真机上验证;三是TextInput处于List内部,键盘弹出时被遮挡,需要配合adjustKeyboardToCursor属性调整键盘避让方式。实操中最省事的做法是:先真机跑,把type设置到位,再检查页面里有没有高度固定导致键盘遮住输入框。
5.2 数组刷新不生效的坑
这是DataList类应用最容易出的问题。你写了this.dataList[index] = value,或者this.dataList = this.dataList,界面纹丝不动。原因前面说过,@State监听的是数组引用变化。改成用扩展运算符、filter、map、concat这些方法返回新数组,问题立刻消失。我把一个现象记忆法分享给你:ArkTS里的数组没有“原地修改”概念,每次更新都是“换盒”,不拆盒换盒不行。
另外还要注意,在ForEach里去修改item本身是无效的,因为item是ForEach回调里的副本,改它不会改到真实数据源。要改数据一定回到对dataList的更新操作上。
5.3 计算精度与大数据量问题
平均数显示成2.3300000000000001这种事,新手第一次遇到会以为是计算器坏了,其实是浮点数精度问题。处理方式我上面已经写了,用toFixed保留小数位再parseFloat去尾零。
大数据量方面,如果调查活动一次要录入上千条数据,每次添加都用[...this.dataList, value]这种展开方式做拷贝,性能和内存会有点压力。但作为普通调查场景,几百条数据完全无压力。真要做到几万条级别,应该考虑用LazyForEach代替ForEach做列表懒加载,同时把数据持久化到本地数据库,而不是全部存在内存里。这个扩展方向不展开写了,记住一句话作为预案:数据量上来之后,分页加载 + 懒加载是标配。
6. 扩展思考:从平均数到完整数据调查工具
6.1 增加图表与统计指标
平均数只是第一步。做一次数据调查,用户通常还想看更多统计量,比如最大值、最小值、中位数、众数、标准差。这些计算逻辑本身不复杂,都是在现有dataList上做遍历和排序,扩展起来非常顺手。界面层面可以在结果区加一排横向滚动的指标卡片,每个卡片显示一个统计量名称和数值。
图表方面,HarmonyOS有丰富的图表库可以直接集成,柱状图可以展示数据分布,折线图可以看趋势。给这个项目接一个简单的柱状图组件,把dataList里的数据变成柱形,整个应用的完成度会提升一大截,看起来就是一个正经的数据分析工具了。
6.2 数据持久化与多轮调查
现在的版本有一个局限:数据存在内存里,退出应用就没了。真实调查场景里,数据收集到一半被系统杀掉,用户心态会崩。所以要接入本地持久化。HarmonyOS里最简单的方式是使用Preferences轻量级存储,把dataList序列化成JSON字符串存进本地;如果想更规范一些,可以用关系型数据库RDB建一张调查表,字段包括id、数值、录入时间。再加上时间字段后,就能支持多轮调查,用户选择某一轮次查看历史平均数据,跨session的完整数据流就落地了。
我在实际把这类应用做成长期可用的工具时,感触最深的是设计层面的事:计算平均数本身很简单,但怎么让用户录错了能改、录入无效数据有反馈、数据量大不卡、退出应用数据不丢,这些才是决定工具成败的细节。你是不是真正站在用户使用的流畅度上思考过每个交互,一眼就能在你的页面上看出来。
最后再分享一个小技巧:给列表项加上入场动画和删除动画会让交互柔和很多。Flutter那边有AnimatedList,HarmonyOS里则可以用transition属性去控制ListItem的出场退场效果。别小看这几百毫秒的动画,数据调查应用在多人配合录入时,视觉反馈能让使用者明显感觉“数据被系统接住了”,参与录入的人心理体验会好很多。这也是我整理到第78个实例后,越来越看重的东西——功能能跑只是起点,把细节打磨到位才是工程经验真正的价值。