ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

HarmonyOS ArkTS实战:智慧农业地块管理系统的数据模型与列表页构建

HarmonyOS ArkTS实战:智慧农业地块管理系统的数据模型与列表页构建

这个系列写到第6篇了,前面几篇把工程骨架、登录流程、首页仪表盘这些基础内容摸了一遍,今天开始进入真正意义上的第一个业务模块——地块管理系统。做过农业类应用开发的兄弟应该能理解,地块这个实体在整个智慧农业项目里就是「数据底座」,面积、作物、传感器、灌溉任务、告警记录全都挂在它身上。所以这篇先把数据模型和列表页搞定,属于整个模块的地基工程,地基歪了后面全得返工。

这篇内容面向的是已经跑通 HarmonyOS 基础工程的开发者,对 ArkTS 语法和 ArkUI 布局有基本了解,还没有独立写过业务模块。我会把地块数据模型的设计思路、ArkTS 类定义方式、列表页的组件拆分、数据渲染以及实际开发中容易踩的坑都讲一遍。所有代码都是直接在 DevEco Studio 里验证过的写法,你可以照着敲。

1. 篇前回顾:地块管理在整套应用里的定位

先回到业务本身。一个智慧农业应用,不管底下接了多少硬件传感器、控制了多少个灌溉阀门,最后落到用户界面上,用户首先要看到的一定是「我有哪些地块,每块地现在什么状态」。地块列表就是整个应用的流量入口,用户从这块点进详情、查看设备、下发控制指令,一条业务链全是从这里发起的。

所以「高高种地」这个项目里,我把地块管理系统设计成了三个递进的层次:第一层是地块列表(展示概览信息),第二层是地块详情(展示传感器实时数据、历史趋势),第三层是地块操作(灌溉控制、告警处理)。这篇先做第一层,数据模型是贯穿三层的骨架,列表页是第一层的外壳。

技术选型上,我用的是 Stage 模型 + ArkTS 语言,API 版本选择 HarmonyOS 3.1 以上(对应 API 9),这个版本对 List 组件的懒加载、ForEach 渲染的稳定性都有保障。如果你用的 API 8,语法上差别不大,但建议直接上 API 9 之后,部分组件行为做了优化,后面遇到问题排查起来也简单。

页面路由我选择了 Navigation 组件而不是 router 方式。原因有两个:一是 Navigation 组件可以省掉在 main_pages.json 里反复注册页面的麻烦,二是后续做详情页跳转、返回传参的时候,Navigation 的栈管理更直观。如果你已经用 router 把前面的页面都串起来了,保持一致也没问题,本篇传参方式我会分别说明。

2. 数据模型:把地块这个业务实体的底子打牢

数据模型是整个地块管理系统的核心,甚至可以说,模型设计占了整个模块 60% 的工作量。因为 ArkTS 对类型的约束比 JavaScript 严格得多,字段定义好了,页面、组件、交互全都在这个基础上展开;字段没定义好,后面加一个字段可能牵连四五个文件要改。

2.1 字段设计:从业务需求倒推

我不建议一上来就埋头写代码,先拿张纸把业务问题列清楚。一个地块在用户眼里需要展示什么,管理者需要关注什么,传感器数据要挂接什么。我们按场景来推:

首先是身份信息:地块编号、地块名称、地理位置,这是所有地块都有的基础字段。名称用户要直接看到,编号用于后台关联设备、做数据链路的唯一标识,位置字段将来可以在地图页面上打点。

然后是种植信息:当前作物种类、生长阶段、负责人。作物种类决定了后续环境参数阈值的判定逻辑,比如番茄和叶菜对温度湿度的要求完全不同;生长阶段用于推算种植周期,也是后面「农事建议」模块的数据来源;负责人字段在多人协作的农场场景下必不可少,设备告警要推送给对应的人。

接着是环境数据:土壤湿度、土壤温度、光照强度、更新时间。这四个字段来自传感器,地块列表页要展示它们的当前值,让用户一进页面就能判断各地块的健康状况。为什么这个字段放在列表里而不是只放在详情里?因为列表页的主要价值就是「扫一眼」,把异常地块抓出来,如果环境数据只放详情页,用户得一个个点进去才能看到,效率太低。

最后是状态字段:当前状态(正常/预警/离线)。这个字段由系统根据阈值计算得出,不是用户手动改的,但在数据模型里必须有,因为列表页要对状态做颜色标识和筛选。至于状态的判定逻辑,本篇先不展开,放在详情系统那一篇结合传感器历史数据一起讲。

字段基本确定下来了,需要说明的是,我刻意没有放「地理位置经纬度」和「历史数据数组」这两个字段。经纬度目前列表用不上,等到做地图模块再扩展;历史数据数组应该是独立的「环境记录」模型,通过地块编号关联,不能塞在单个地块对象里,否则内存占用和序列化都会出问题。

2.2 状态枚举与字段约束

状态字段我用枚举而不是布尔类型或者字符串散值。布尔类型只能表达两种状态,不够用;字符串散值容易写错,比如一处写"normal"另一处写"Normal",对不上就出 bug。枚举把取值范围锁死,编译期就能发现拼写错误。

export enum PlotStatus { Normal = 'normal', Warning = 'warning', Offline = 'offline' }

等等,有人会问,为什么枚举值要用字符串而不是默认的数字 0、1、2?这里有一个实际考量:后端接口返回的数据里,地块状态通常是以字符串形式存在的,比如"status": "warning",如果枚举值是数字,前端每次对接接口都要做一层映射转换,多写一堆样板代码。直接用字符串枚举,后端返回什么值,前端就能直接比较,省掉转换。

生长阶段目前我用字符串,比如"苗期""开花期""结果期"。它本质上也是一个有限的取值集合,理论上也可以用枚举,但生长阶段在不同作物上有差异,有的作物还要分成十几个细粒度的阶段,枚举会变得非常臃肿。考虑到这个字段在列表页只做展示用、不做逻辑判断,字符串的灵活性更好。这里的原则是:参与逻辑判断的字段用枚举,仅展示的字段用字符串。

面积字段我统一用数字类型,单位是「亩」。很多项目会在面积字段上直接拼接单位,比如存一个"2.5亩"字符串,这个做法在后端做面积汇总计算时会非常痛苦,你得先解析字符串再转数字,还要处理异常格式。正确的做法是存数字,单位在显示层拼接。列表里显示"2.5 亩",后台计算总面积直接用reduce累加就行。

2.3 ArkTS 中类模型的写法与初始化细节

模型定义我用 ArkTS 的 class。ArkTS 基于 TypeScript,但语法约束更严格,有几个细节必须注意,否则编译器直接报错。

第一个坑:类属性必须初始化。在标准 TypeScript 里,你可以写name: string然后在构造函数里赋值,但 ArkTS 要求所有类属性要么有默认值,要么在构造器里明确赋值。更严格的是,如果属性没初始化,编译时会要求你「添加初始化器」或者「在构造函数中明确分配」。

第二个坑:构造函数重载有限制。ArkTS 不建议写复杂的构造器重载,简单的做法是定义一个不带参数的构造器,所有属性取默认值,然后单独写一个工厂方法或者静态方法去创建实例。这样做的好处是,页面里拿到模型对象时,属性一定是完整的,不会出现undefined。

来看具体代码,我定义一个地块数据模型:

// model/PlotModel.ets export enum PlotStatus { Normal = 'normal', Warning = 'warning', Offline = 'offline' } export class PlotModel { id: string = '' name: string = '' area: number = 0 crop: string = '' cropStage: string = '' owner: string = '' status: PlotStatus = PlotStatus.Normal soilMoisture: number = 0 soilTemperature: number = 0 lightIntensity: number = 0 location: string = '' updateTime: string = '' constructor() { } }

这里要重点说下id字段。我用字符串类型而不是数字,是因为地块编号往往有业务含义,比如"PLOT001""PLOT002",而且对接后端时,接口返回的主键经常是字符串形式的雪花 ID。字符串 ID 可以兼容更多场景,不用在前后端之间来回转换类型。

关于updateTime字段,我用字符串存格式化后的时间,比如"2025-01-12 10:30:00"。为什么不存时间戳数字?因为列表页要直接展示这个值,字符串不需要再做格式化转换。如果某天需要按时间排序,字符串格式的时间恰好可以按字典序排列,效果等同于时间排序。这个技巧在数据量不大时完全够用。

数据模型类写好后,我通常还会在同一个文件里加一个「辅助函数」,用于从接口返回的原始数据创建模型实例。因为后端接口的字段命名习惯跟前端不一定一致,可能字段名是下划线风格,或者部分字段缺失。辅助函数把脏数据挡在模型层外面,页面组件拿到的永远是干净、完整的对象。

export function parsePlotFromJson(json: object): PlotModel { let model = new PlotModel() const obj = json as Record<string, object | number | string> model.id = obj['plot_id'] as string ?? '' model.name = obj['name'] as string ?? '' model.area = obj['area'] as number ?? 0 model.crop = obj['crop'] as string ?? '' model.cropStage = obj['crop_stage'] as string ?? '' model.owner = obj['owner'] as string ?? '' model.status = PlotStatus[obj['status'] as keyof typeof PlotStatus] ?? PlotStatus.Normal // 环境字段类似,不再重复 return model }

严格来说,ArkTS 里对as转换和??空值合并的支持是有的,但有些写法编译器比较挑剔。实际项目中,我更推荐的做法是在「本地模拟数据阶段」直接手写PlotModel实例,等接入真实接口时再用parsePlotFromJson这类函数。这篇后面用的就是手写实例的方式,重点讲清楚模型的形状和用法。

3. 列表页面的骨架设计

模型定义好了,接下来是列表页。ArkUI 的布局方式跟传统前端差异很大,它不是 HTML 标签流式布局,而是组件树 + 容器约束的布局思路。第一次接触的开发者容易一股脑把所有内容堆在一个页面里,等到要维护的时候发现改一个卡片要动几十行代码。所以先把页面拆分想清楚。

3.1 页面结构与组件拆分

地块列表页我拆成了四个部分:顶部标题栏、搜索输入区、状态筛选标签区、列表内容区。搜索和筛选是列表页的两个操作入口,列表是信息展示主体,标题栏负责页面级导航。

拆分的思路是:每个区域对应一个独立的 UI 块,区域之间用垂直布局(Column)堆叠。列表内容区继续往下拆,每一行是一个独立的地块卡片组件。父页面只负责管理数据源和交互事件,卡片组件只负责渲染单条数据。这样后续维护的时候,改卡片的样式不会影响页面其他部分,改数据逻辑也不用关心卡片长什么样。

ArkUI 里组件化拆分用@Component,注意子组件接收数据用@Prop而不是直接在构造器里传参。@Prop装饰器的特点是单向同步:父组件数据变化会自动更新子组件,但子组件里不能反向修改父组件数据。这个设计对列表场景很合适,因为一个卡片不拥有数据,只是展示数据。

@Component export struct PlotCard { @Prop plot: PlotModel = new PlotModel() build() { // 卡片内容 } }

这里需要注意,@Prop接收对象类型时,ArkUI 会做一次深拷贝。对于地块列表这种数据量不会特别大的场景,性能没问题。但如果一个页面要同时渲染几百上千条复杂对象,就要考虑换成普通成员变量 + 手动刷新控制,这个后续在性能优化篇单独展开。

页面外层结构大致是这样:

@Entry @Component struct PlotListPage { @State plotList: PlotModel[] = [] build() { Column() { // 1. 标题栏 // 2. 搜索框 // 3. 筛选标签行 // 4. List 列表 } } }

@State修饰的plotList是页面级数据源,搜索、筛选的结果都通过计算得到,并重新赋值给它。所有 UI 的变化,追根溯源都是这个数组的变化。

3.2 搜索与状态筛选的实现

搜索功能我用TextInput组件,用户在输入框里输入关键字,实时过滤列表。ArkUI 的TextInput提供了onChange回调,每次输入内容变化都会触发。关键点在「实时」两个字:如果每次输入都去修改整个数据数组,性能会有损耗;更合理的方式是维护一个搜索关键字状态,然后通过一个计算函数从完整数据里过滤。

状态筛选也是一样。我定义了一个「当前选中筛选」的状态,取值是所有状态(all)或者某个具体状态(normal/warning/offline)。筛选条件变化时,同样走过滤函数。

@State searchKey: string = '' @State activeFilter: PlotStatus | 'all' = 'all'

过滤函数的写法:

getFilteredPlots(): PlotModel[] { let result: PlotModel[] = this.plotList if (this.activeFilter !== 'all') { result = result.filter((plot: PlotModel) => plot.status === this.activeFilter) } let keyword = this.searchKey.trim() if (keyword !== '') { result = result.filter((plot: PlotModel) => { return plot.name.includes(keyword) || plot.crop.includes(keyword) || plot.location.includes(keyword) }) } return result }

搜索条件我同时匹配了地块名称、作物种类和位置,用户输入"番茄"能搜到所有种番茄的地块,输入"东区"能搜到东区下的所有地块。这个匹配逻辑是列表页很实用的一点,很多新手只做名称搜索,等到用户反馈"我想按作物找地块"才想起来补。

筛选标签行我用Row组件横向排列几个标签,每个标签是Text组件加边框背景,点击时切换选中态。用 ArkUI 的if/else条件渲染来切换不同的样式,虽然代码看起来有点繁琐,但胜在直观。

3.3 地块卡片的布局细节

地块卡片是列表项的主体,直接决定用户第一眼看到的信息质量。卡片我设计成上下两个区域:上方是地块名称、状态标签、地理位置;下方是一行环境数据摘要(土壤湿度/土壤温度/光照强度)。

卡片的容器用Column,外层加圆角、背景色和阴影。这里有一个细节:ArkUI 的阴影(.shadow())要加在容器上,而且要在背景色设置之后才生效,否则阴影可能被背景色覆盖看不见。另外圆角需要搭配clip(true),否则背景色溢出圆角范围。

卡片内部的信息行布局,用Row+Space实现两端对齐。某个环境指标的展示,我用一个小结构:上方是数值,下方是单位标签。这种「两段式」布局比一行塞下"湿度:45%"要清爽得多,而且下面还可以配上一个小图标或者进度条。

地块状态的视觉区分:正常状态用绿色标签,预警状态用橙色标签,离线状态用灰色标签。标签本身只是一个Text加圆角背景色,但颜色的选择要刻意做区分,色盲用户也能通过明暗差异辨认,这是很多界面设计里容易忽略的点。

卡片的高度不要写死,让内容撑起来。ArkUI 里如果给ListItem设置了固定高度,内容变化时会出现截断或者空白,不如让列表项自适应高度。

4. 数据渲染:从模拟数据到真实列表

页面结构搭好了,数据从哪来?在还没有后端接口的阶段,先造模拟数据把渲染链路跑通。这一步很重要,它能验证模型的字段、型别、组件的通信方式是否正常,同时也能给后续接入真实接口提供一个清晰的替换点。

4.1 模拟数据的构造

模拟数据我直接写在页面文件外部的独立数据文件里,一般建一个mock/PlotMock.ets。因为地块列表的模拟数据以后很可能也要给详情页复用,放独立文件可以避免页面之间互相引用造成循环依赖。

// mock/PlotMock.ets import { PlotModel, PlotStatus } from '../model/PlotModel' export function getMockPlots(): PlotModel[] { let plots: PlotModel[] = [] let plot1 = new PlotModel() plot1.id = 'PLOT001' plot1.name = '一号西红柿棚' plot1.area = 2.5 plot1.crop = '番茄' plot1.cropStage = '开花期' plot1.owner = 'A管理员' plot1.status = PlotStatus.Normal plot1.soilMoisture = 45.6 plot1.soilTemperature = 22.3 plot1.lightIntensity = 32000 plot1.location = '东区' plot1.updateTime = '2025-01-12 10:30:00' plots.push(plot1) // 再构造几个不同状态的例子 return plots }

模拟数据里我会刻意保留几个不同状态的样本:一条正常、一条预警(比如土壤湿度过低)、一条离线(传感器无数据)。这样列表页的筛选、状态标签、视觉区分这些功能都能一次性验证到位,不用调试的时候再去改数据。

4.2 ForEach 渲染与 key 值管理

列表渲染是 ArkUI 的核心操作。List组件配合ForEach,在列表数据到达一定规模(比如 50 条以上)时,List内部的ListItem有懒加载机制,不会一次性创建所有组件实例,性能比Scroll+Column手工渲染要好得多。

List() { ForEach(this.getFilteredPlots(), (plot: PlotModel) => { ListItem() { PlotCard({ plot: plot }) } .width('100%') .margin({ bottom: 12 }) }, (plot: PlotModel) => plot.id) }

ForEach的第三个参数是 key 生成器,这个参数很多人会忽略,但它决定了列表数据变化时可以复用哪些组件实例。key 值必须满足两个条件:在列表内唯一、在元素生命周期内稳定。用plot.id是最合适的,因为 id 是数据库层的唯一标识。千万不要用index作为 key,当你删除列表中间一条数据时,后面的 index 全部变了,ArkUI 会认为整个列表都变了,导致不必要的重新渲染,甚至出现状态错乱。

ArkUI 的ForEach对 key 值有一个隐藏要求:key 不能发生重复。如果出现两条相同 id 的数据,控制台会报 "key is duplicated" 的警告,然后渲染结果不可预期。这个问题在真实项目里出现过,原因是后端接口返回的数据有重复记录,前端没有做去重就塞进了列表,查了半天才发现是数据源的问题。所以我在写列表的aboutToAppear生命周期里,会做一个简单的数组去重防御。

4.3 点击跳转与传参占位

地块卡片不仅是展示,点击后应该进入地块详情页。这一篇详情页还没实现,先做好跳转的「占位」逻辑,保证点击行为有反馈,后续实现详情页时直接填内容。

使用 Navigation 组件的话,页面跳转前要先用Navigation(this.pageStack)包裹页面结构,然后通过this.pageStack.pushPath跳转。传参可以直接放一个对象进去。

@Provide('pageStack') pageStack: NavPathStack = new NavPathStack() // 在卡片点击时 this.pageStack.pushPath({ name: 'PlotDetailPage', param: plot.id })

使用 router 方式的话,调用router.pushUrl传入参数,然后在详情页的onPageShow或构造参数里通过router.getParams()取回。

传参这里有一个经验之谈:只传 id 或者最小必要信息,不要直接把整个PlotModel对象塞给详情页。原因有两点:一是PlotModel里的环境字段是动态变化的,详情页应该根据 id 拉取最新数据,而不是显示列表页当时的旧快照;二是路由参数传递复杂对象时,序列化/反序列化开销大,还容易碰到一些类型被转成普通 Object 后取不到属性的情况。

5. 常见问题与排查技巧实录

每次写系列教程,我都要把实际开发中踩过的坑单独整理出来,这些内容在官方文档里往往找不到直接答案,但对正在开发的人来说,可能比正文更有价值。

5.1 列表数据显示了但页面不刷新

这是新手遇到最多的问题。现象是:数据源plotList数组里的某个对象的属性变了,比如状态从"正常"变成了"预警",但界面上完全没有反应。

原因在于 ArkUI 的状态管理机制:@State装饰的数组,如果直接修改数组中某个对象的属性,比如this.plotList[0].status = PlotStatus.Warning,ArkUI 的响应式系统不会感知到这次修改。因为@State观察到的是数组引用变化,而对象属性的内部修改对它来说是不可见的。

解决办法有三个。第一个是整体替换数组,创建一个新数组再赋值给plotList,能触发刷新但性能一般;第二个是给数据模型加@Observed装饰器,页面里用@ObjectLink接收,能让对象属性级别的修改被感知;第三个最实际——在列表这种展示场景,每次数据更新本来就可以认为是整批更新,直接把新数据赋给一个新数组就行,避免过度设计。

// 触发刷新的方式 this.plotList = this.plotList.map((item: PlotModel) => { if (item.id === targetId) { item.status = PlotStatus.Warning } return item })

map返回新数组,引用变化了,ArkUI 就能感知到。这个方法代码量少,也不破坏数据模型的结构。

5.2 ForEach 的 key 值重复导致渲染错乱

症状是:列表里某些卡片内容重复,或者删除一条数据后,界面显示的数据跟数据源对不上。

排查思路第一站永远是 key 生成器。如果 key 生成器返回的是一个常数,那所有列表项都会被认为同一个 item,ArkUI 只渲染一个;如果返回的是可能重复的值(比如所有地块的crop字段),就会出现数据错乱。我在项目里就见过有人用plot.crop作为 key 的,一旦两条数据都是"番茄",渲染立刻出问题。

用plot.id作为 key 后,还要在数据源层面保证 id 不重复。如果数据来自接口,建议在aboutToAppear里做一次去重,用Set存储已有 id,遇到重复的就跳过。

5.3 ListItem 高度和间距的布局坑

List 组件默认的行为是ListItem铺满整个 List 宽度,但高度会根据内容自适应。如果在ListItem里直接设置height('100%'),它参考的是父容器 List 的高度而不是内容高度,列表会出现大量空白。正确做法是让卡片容器内部自适应,或者给ListItem明确的固定高度。

间距我建议用ListItem的margin而不是在卡片内部加padding。原因很简单:列表项的 margin 控制的是卡片与卡片之间的距离,视觉上更自然;卡片内部的 padding 控制的是内容与卡片边缘的距离,两者混用容易计算出奇怪的间距表现。

5.4 环境数据展示位小数点处理

传感器数据往往是小数,直接显示45.666666很丑。处理方式是在展示格式化时保留一位小数,用toFixed(1)。但注意toFixed返回的是字符串,如果你将来要把这个值参与计算,记得先parseFloat转回数字。这个细节不影响列表页功能,但养成习惯可以避免后续做图表时的类型问题。

6. 从列表页到整个地块管理系统的路线图

数据模型和列表页完成后,地块管理系统的地基已经算是打好了。后续的详情页可以直接复用这篇定义的PlotModel,通过地块 id 拉取更完整的数据;环境数据的历史曲线图,也需要这块地里挂接的传感器数据作为数据源。所以写代码的时候,要把模型文件、模拟数据文件跟页面文件分开,不要让页面文件变成一个所有功能都往里塞的「大杂烩」,后期维护的体验会好很多。

我在实际开发中还有一个习惯:每完成一个模块,就把页面里的关键交互点、数据流走向在项目 README 里写清楚。这个习惯在项目规模小的时候看不出价值,等模块多起来、需要回头查看某个列表的分页逻辑或筛选逻辑时,能省下大量回忆时间。

另外一个值得做的小事是,把列表页的空数据状态和加载失败状态先占位。虽然现在模拟数据永远有内容,但接入接口后一定会遇到后端返回空列表或者网络波动的情况。提前留好空状态提示,比如"暂无地块数据"和重试按钮,界面不会因为数据异常而出现空白。

最后聊几句关于「技术选型的认识」。HarmonyOS 的开发范式跟传统 Web 前端或者 Android 原生都不太一样,很多人初学时会拿习惯性的思路去套,结果处处碰壁。其实调整一下心态:ArkUI 的组件树 + 装饰器的状态管理模式,跟 Vue 的响应式思想有相似之处,跟 Flutter 的组件构建方式也有相通的地方。遇到问题的时候,先想想手里的工具(@State、@Prop、ForEach)能做什么,比硬背组件 API 更有效。

到了这一步,地块列表已经能在模拟器上流畅跑起来了。下一篇我会写地块详情页:场地可视化布局、环境数据的动态刷新、设备关联信息展示。到时候你会发现,这篇打好的模型基础会帮你节省大量重复工作。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进