HarmonyOS智慧农业地块管理:增删改查一次讲透
HarmonyOS智慧农业管理应用开发这个系列写到第7篇上篇我们把“高高种地”的地块管理页面从零搭好数据库和列表框架已经立起来了。这篇接着把地块管理系统的增删改查一次讲透。增删改查是智慧农业项目里最基础也最绕不开的一组动作新增一个地块、查看地块信息、修改种植数据、删除废弃地块。用HarmonyOS的ArkTS配合关系型数据库RDB四个动作串起来就是一个完整的信息管理闭环。这篇教程适合两类人一类是正在从零写HarmonyOS应用的初学者跟着把代码敲一遍就能跑通全流程另一类是已经会基础UI、想搞清楚RDB怎么真正落地到业务场景的开发者。我会把每一步为什么这么做、踩过什么坑都写出来而不是丢一段代码就完事。代码基于API 12版本老版本项目可能需要小改导入语句后面会专门提。1. 设计先行先把地块数据表立起来1.1 字段设计决定了后面少返工做增删改查之前第一步不是写代码而是想清楚一个地块需要存哪些信息。我最早做这个模块时偷懒只存了名称和面积结果后来要按作物筛选、要按状态展示只能回头改表结构连带着所有查询代码都要动。这次“高高种地”的地块表我建议直接按下面的字段来字段名类型说明约束idINTEGER地块唯一标识主键自增nameTEXT地块名称非空areaREAL面积单位亩非空cropTypeTEXT当前种植作物可空soilTypeTEXT土壤类型可空statusINTEGER状态1正常0暂停默认1createTimeTEXT创建时间非空updateTimeTEXT更新时间非空面积字段我特别用REAL而不是TEXT是因为后面做统计、做排序都会用到数字计算存文本到时候还得转型纯给自己找麻烦。状态字段用INTEGER而不是TEXT也是同理——判断状态时直接比较数字比比较字符串更稳妥而且后续如果想扩展更多状态加数字枚举就行。时间字段用TEXT存ISO格式的字符串比如“2025-01-08T10:30:00”在ArkTS里生成和比较都很方便。真要做时间区间查询字符串比较在ISO格式下也能正常工作前期够用。1.2 为什么选关系型数据库而不是PreferencesHarmonyOS本地存储有好几种方案最简单的是Preferences键值对存储用起来像Map存个开关状态、用户配置很合适。但地块数据是结构化数据而且需要按名称查、按状态过滤、按时间排序这些操作如果用Preferences实现得把所有数据读出来自己写循环过滤代码量大不说性能也不如SQL。关系型数据库RDBRelational Database在HarmonyOS里封装的是SQLite支持SQL、事务、索引这些正是列表类业务需要的。地块以后还可能跟农事记录、物资投入数据做关联查询有RDB打底后面扩展起来不慌。所以这个项目里地块模块直接用RDBPreferences只留给登录状态这类简单配置。1.3 建表与数据库初始化RDB初始化在HarmonyOS里是通过getRdbStore完成的。一个应用可以建多个数据库文件也可以一个库多张表。地块管理我建议单独用smart_farm.db后面农事记录、库存管理都放在这个库里方便统一管理。import { relationalStore } from kit.ArkData; import { hilog } from kit.PerformanceKit; const TAG FieldDB; const STORE_CONFIG: relationalStore.StoreConfig { name: smart_farm.db, securityLevel: relationalStore.SecurityLevel.S1, }; const CREATE_FIELD_TABLE_SQL: string CREATE TABLE IF NOT EXISTS field ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, area REAL NOT NULL, cropType TEXT, soilType TEXT, status INTEGER DEFAULT 1, createTime TEXT, updateTime TEXT ) ; export async function initRdb(context: Context): PromiserelationalStore.RdbStore { const store await relationalStore.getRdbStore(context, STORE_CONFIG); await store.executeSql(CREATE_FIELD_TABLE_SQL); hilog.info(TAG, field table ready); return store; }这里有个细节建表SQL一定要加IF NOT EXISTS否则每次启动应用都执行一次会直接报“表已存在”的错。getRdbStore本身只要传入的StoreConfig一样重复调用也不会重复创建文件但executeSql不是幂等的得靠SQL语句自身保证安全。关于导入路径API 12以后推荐用import { relationalStore } from kit.ArkData老一点的项目里也常见import relationalStore from ohos.data.relationalStore。这两种写法本质上是同一个模块只是导出方式不同遇到编译错误换一下导入方式就行。2. 查询加载从数据表到界面渲染2.1 用RdbPredicates构造查询条件增删改查里“查”是使用频率最高的操作页面一打开就要执行。RDB查询的核心是RdbPredicates它相当于SQL里的WHERE子句用来描述查询条件、排序规则和limit限制。export class FieldInfo { id: number -1; name: string ; area: number 0; cropType: string ; soilType: string ; status: number 1; } export async function queryFieldList(store: relationalStore.RdbStore): PromiseFieldInfo[] { const predicates new relationalStore.RdbPredicates(field); predicates.orderByDesc(updateTime); const resultSet await store.query(predicates); const list: FieldInfo[] []; while (resultSet.goToNextRow()) { const item new FieldInfo(); item.id resultSet.getLong(resultSet.getColumnIndex(id)); item.name resultSet.getString(resultSet.getColumnIndex(name)); item.area resultSet.getDouble(resultSet.getColumnIndex(area)); item.cropType resultSet.getString(resultSet.getColumnIndex(cropType)); item.soilType resultSet.getString(resultSet.getColumnIndex(soilType)); item.status resultSet.getLong(resultSet.getColumnIndex(status)); list.push(item); } resultSet.close(); return list; }这段代码有几个关键点。第一orderByDesc(updateTime)会让最近更新的地块排最前面用户改完数据重新加载列表时一眼就能看到改动过的记录。第二query返回的是ResultSet结果集它像游标一样指向当前行必须调用goToNextRow()逐行遍历。第三getColumnIndex拿索引、再通过对应类型的get方法取值这个操作是常规操作但取错类型会抛异常比如area是REAL就得用getDouble用getString就会崩。ResultSet用完一定要close()。我见过不少新手漏掉这一步短时间看不出问题连续增删改查几次之后数据库连接耗尽页面直接卡死。这是RDB开发里最常见的坑之一后面排坑部分还会细讲。2.2 数据模型与列表卡片把查询结果封装成FieldInfo这样的数据模型类UI层操作起来就很清爽。ArkTS对属性类型要求比较严格类属性必须初始化所以id默认-1、name默认空字符串避免出现“未初始化属性”的编译问题。列表UI我建议直接用List ForEach来实现。每个地块用一张卡片展示卡片里放名称、面积、当前作物右侧放状态标识。卡片式展示比纯文本列表更符合农业管理场景地块信息一眼扫过去就能看清。Builder FieldCard(item: FieldInfo) { Row() { Column({ space: 4 }) { Text(item.name) .fontSize(16) .fontWeight(FontWeight.Medium) Text(${item.area} 亩 · ${item.cropType ? item.cropType : 未种植}) .fontSize(13) .fontColor(#666666) } .alignItems(HorizontalAlign.Start) Blank() Text(item.status 1 ? 进行中 : 暂停) .fontSize(12) .fontColor(item.status 1 ? #2E7D32 : #999999) .padding({ left: 8, right: 8, top: 2, bottom: 2 }) .backgroundColor(item.status 1 ? #E8F5E9 : #F5F5F5) .borderRadius(4) } .padding(16) .backgroundColor(Color.White) .borderRadius(12) .width(100%) }这里用Builder封装卡片组件主页面build里通过ForEach循环调用。每张卡片的key用item.id.toString()保证了列表项的唯一性ArkTS的ForEach要求key必须唯一且是string类型这是规范。2.3 空状态和页面刷新时机列表页最容易被忽略的是空状态。地块刚初始化时数据库一张表可能是空的没有数据时页面直接白茫茫一片用户还以为应用出bug了。我习惯在List外层判断if (this.fieldList.length 0) { Column({ space: 8 }) { Text(暂无地块数据) .fontSize(16) .fontColor(#999999) Text(点击右上角“新增地块”开始添加) .fontSize(13) .fontColor(#BBBBBB) } .width(100%) .layoutWeight(1) .justifyContent(FlexAlign.Center) } else { List({ space: 12 }) { ForEach(this.fieldList, (item: FieldInfo) { ListItem() { this.FieldCard(item) } }, (item: FieldInfo) item.id.toString()) } .layoutWeight(1) .width(100%) .padding({ left: 12, right: 12, top: 4 }) }刷新时机也很讲究。页面onPageShow生命周期里重新加载数据是最稳妥的做法因为用户从编辑弹窗返回、从详情页返回时都需要重新拉一遍列表。如果你只在aboutToAppear里加载一次改完数据再返回就只能看到旧数据体验很差。我之前就犯过这个错后来统一改成onPageShow里刷新问题直接消失。3. 新增与编辑一个弹窗两套逻辑3.1 表单弹窗的复用设计新增和编辑在UI层面高度相似都要填地块名称、面积、作物、土壤类型只是编辑时表单里要先回填数据保存时走update而不是insert。与其写两个弹窗不如用一个弹窗组件加两个模式状态。我习惯定义三个状态变量State isEdit: boolean false; State editingId: number -1; State showFormDialog: boolean false;新增时isEdit设为falseeditingId重置为-1表单字段全部清空。编辑时先从列表里取出要编辑的地块信息回填到name、area这些输入框里然后isEdit设为true、editingId设为该地块的id。弹窗里用TextInput、TextArea、Select这几个组件就够了。TextInput用于名称和面积Select搞一个下拉选择土壤类型可选值像“壤土”“沙土”“黏土”这些作物类型用TextArea或者TextInput都行看自己输入习惯。if (this.showFormDialog) { AlertDialog.show({ title: this.isEdit ? 编辑地块 : 新增地块, message: , autoCancel: false, alignment: DialogAlignment.Center, customStyle: true, builder: () { Column({ space: 12 }) { TextInput({ placeholder: 地块名称, text: this.fieldName }) .onChange((value: string) { this.fieldName value; }) .width(100%) TextInput({ placeholder: 面积亩, text: this.fieldArea }) .type(InputType.NUMBER_DECIMAL) .onChange((value: string) { this.fieldArea value; }) .width(100%) TextInput({ placeholder: 种植作物, text: this.cropType }) .onChange((value: string) { this.cropType value; }) .width(100%) Row({ space: 10 }) { Button(取消) .backgroundColor(#EEEEEE) .fontColor(#333333) .layoutWeight(1) .onClick(() { this.showFormDialog false; }) Button(保存) .backgroundColor(#2E7D32) .layoutWeight(1) .onClick(() { this.handleSave(); }) } .width(100%) } .padding(20) .width(100%) } }) }AlertDialog用builder加载自定义内容时customStyle要设为true否则弹窗背景和默认样式可能会打架。autoCancel设为false是为了防止用户点弹窗外区域误关弹窗表单填一半被关掉确实挺烦人的。3.2 输入校验与数字处理校验是表单里绝不能省的一步。地块名称是必填面积必须是大于0的数字我见过用户直接输入中文或负数的情况。校验逻辑我单独提取成函数方便复用validateField(name: string, areaStr: string): boolean { if (!name.trim()) { promptAction.showToast({ message: 请输入地块名称 }); return false; } if (areaStr.trim() ) { promptAction.showToast({ message: 请输入面积 }); return false; } const area Number.parseFloat(areaStr); if (Number.isNaN(area) || area 0) { promptAction.showToast({ message: 面积必须是大于0的数字 }); return false; } return true; }调用promptAction需要import { promptAction } from kit.ArkUI。TextInput的类型设置成InputType.NUMBER_DECIMAL之后弹出来的键盘是数字键盘Shift键是减号但用户还是可能输入乱码所以parseFloat之后的NaN判断必须保留。这里还有一个容易忽略的点parseFloat(12abc)不会报NaN它会解析出12。也就是说用户输入“12亩”这样的内容parseFloat也能拿到12。看起来是好事但对业务来说其实是脏数据进入系统的漏洞。我在处理时会在校验前先判断正则/^\d(.\d)?$/只允许纯数字格式。不过普通场景下parseFloat配合NaN判断也够用看你对数据的洁癖程度。3.3 保存逻辑insert和update的分流保存按钮的onClick统一走handleSave方法内部根据isEdit分流。新增走insert返回的是新记录的rowId编辑走update通过predicates限定id更新对应行。async handleSave() { if (!this.validateField(this.fieldName, this.fieldArea)) { return; } const area Number.parseFloat(this.fieldArea); const now new Date().toISOString(); const values: relationalStore.ValuesBucket { name: this.fieldName.trim(), area: area, cropType: this.cropType, soilType: this.soilType, status: this.isEdit ? this.editingStatus : 1, updateTime: now, }; try { if (this.isEdit) { const predicates new relationalStore.RdbPredicates(field); predicates.equalTo(id, this.editingId); await this.rdbStore.update(values, predicates); } else { values[createTime] now; await this.rdbStore.insert(field, values); } this.showFormDialog false; this.refreshList(); promptAction.showToast({ message: this.isEdit ? 修改成功 : 新增成功 }); } catch (err) { hilog.error(TAG, save field failed: ${JSON.stringify(err)}); promptAction.showToast({ message: 保存失败请重试 }); } }注意ValuesBucket的key必须和表的字段名完全一致大小写也不能错。之前有同事把name拼成nmae编译不报错跑起来才报“column nmae does not exist”排查半天。insert方法返回的是自增的rowId如果表里有自增之外的特殊需求还可以拿到这个id做后续关联但地块这个场景不太用得上。保存成功之后要做什么关闭弹窗、重新拉列表、Toast提示。这三个动作顺序不能乱先关弹窗再刷新最后提示。你先刷新再关弹窗用户会看到弹窗底下列表闪了一下你先提示再刷新提示文字还没消失列表变了视觉上很突兀。按顺序来体验最顺。4. 删除操作确认、执行、刷新三步走4.1 为什么删除前要加确认弹窗删除操作是四个动作里破坏性最强的。用户手指一抖点了删除一整块地从数据库里就没了没有后悔药。所以在删除前加一个确认弹窗是必须的这既是交互规范也是数据安全底线。常见做法是卡片右侧放一个“更多”按钮点开用ActionSheet弹出菜单菜单里有“编辑”和“删除”两个选项。这样既避免了误触又能把编辑入口和删除入口收拢在一起页面看起来干净。如果直接在每个卡片上放一个大红色删除按钮视觉上太难看了而且误触概率很高。4.2 删除确认弹窗的实现HarmonyOS里弹确认框用AlertDialog.show最方便支持两个按钮取消和删除。删除按钮用红色字体给用户足够的视觉警示。confirmDelete(item: FieldInfo) { AlertDialog.show({ title: 删除确认, message: 确定要删除地块“${item.name}”吗删除后不可恢复。, autoCancel: true, primaryButton: { value: 取消, action: () {} }, secondaryButton: { value: 删除, fontColor: Color.Red, action: () { this.handleDelete(item.id); } } }); } async handleDelete(id: number) { try { const predicates new relationalStore.RdbPredicates(field); predicates.equalTo(id, id); await this.rdbStore.delete(predicates); this.refreshList(); promptAction.showToast({ message: 删除成功 }); } catch (err) { hilog.error(TAG, delete field failed: ${JSON.stringify(err)}); promptAction.showToast({ message: 删除失败请重试 }); } }message里拼接了地块名称这个细节很重要。用户看到“确定要删除地块‘东区一号地’吗”比看到干巴巴的“确认删除”要能唤起警惕心误操作概率小得多。autoCancel这里我设置为true因为删除确认弹窗不需要强制用户做选择点外部关闭相当于取消符合用户直觉。4.3 删除后的列表同步删除成功后有两种刷新方式一种是调用refreshList重新查询全表简单粗暴但数据量大了会浪费性能另一种是在本地数组里过滤出被删的那条然后整体赋值触发UI刷新。我用的方案是感知型的先拿到被删除的id然后把this.fieldList里不等于该id的项过滤出来重新赋值。这样列表更新是即时的不用等数据库重新查询。refreshListAfterDelete(deletedId: number) { const newList this.fieldList.filter((item: FieldInfo) item.id ! deletedId); this.fieldList [...newList]; }这里有一个ArkTS状态管理的经典坑直接this.fieldList.filter再赋值能触发刷新但如果你在filter之后直接this.fieldList filteredList在某些版本上UI不刷新。最稳妥的写法是展开成一个新数组再赋值让State感知到数组引用发生了变化。我后面的排坑部分会展开说。4.4 删除操作的扩展思考当前删除是物理删除数据直接没了。如果后续要支持“回收站”功能可以在表里加一个deleted字段做逻辑删除delete操作变成update操作把deleted置为1。查询时统一过滤deleted0的数据。这个改造并不难关键是表结构设计时最好提前留好deleted字段或者给现有表做一次表结构升级。另外如果地块下面还挂着农事记录、投入品记录块删掉之后这些关联数据会变成孤儿数据。我在“高高种地”的规划里后续会把农事记录表也建起来届时删除地块时就要考虑关联数据的处理方案要么联动删除要么提示用户“该地块有X条农事记录删除后将一并清理”。这个现在不用做但设计时心里要有数。5. 现场排坑地块系统开发中遇到的6个典型问题5.1 问题速查表这块内容是我自己实际调试过程中踩过的坑整理成表方便大家对照。问题现象根本原因解决方案连续增删改查后页面卡死ResultSet未关闭数据库连接耗尽每次query后finally里close或封装到工具类统一释放修改数据后返回列表UI不更新State数组是直接修改而非整体替换用[...newList]或splice重新赋值让数组引用变化编辑弹窗回填的数据不显示弹窗内容builder里对State的赋值时机不对回填逻辑必须在showFormDialogtrue之前完成数字输入框能输入字母TextInput未设置type为NUMBER_DECIMAL设置InputType.NUMBER_DECIMAL并加入NaN校验快速连续点击保存插入多条重复数据异步插入未加防抖或保存中状态保存时加isSaving标志位提交完成前禁用按钮建表SQL反复执行报错未使用IF NOT EXISTS建表语句加上IF NOT EXISTS5.2 三个最多人踩的坑详细展开ResultSet不关闭的问题我在刚用RDB时遇到过。当时是做一个简单的查询列表功能写完了自测感觉没问题但频繁切换页面、反复下拉刷新之后应用开始越来越卡最后直接无响应。排查日志才发现是query返回的结果集没关底层连接一直被占用。这个问题的隐蔽性在于小数据量下不关闭结果集可能运行几十次都不报错但一旦多了就暴露。而且ResultSet是同步API在异步方法里调用就得特别小心异常分支也要记得关。我现在的习惯是建一个工具类封装所有query调用用try/finally把close写死从根上杜绝这个问题。State数组不刷新的问题本质是ArkTS状态管理的对象引用机制。State监听到的是属性或数组引用的变化如果你直接修改原始数组的某个元素比如this.fieldList[0].name testUI是不会动的。正确做法是生成一个新数组然后整体赋给State变量。我在项目里统一用this.fieldList [...tempList]这种写法Array的展开运算符会创建新数组触发刷新。这个点很多教程没讲透等你自己写列表业务时就会遇到。连续点击保存按钮导致重复插入的问题交互层就能解决。我习惯给保存按钮额外加一个isSaving状态进入保存逻辑时置为true保存完成或失败后再置为false。按钮在isSaving时禁用并且变灰这样即使用户狂点第二次点击也会被拦截。数据库层当然可以用唯一索引来防重但地块名称允许重名的话唯一索引不适用所以前端拦截是性价比最高的方案。还有一个编译层面的坑API 12的RDB批量操作接口有些是老API已废弃比如旧版本的executeSql传参方式如果你是从网上抄的老代码编译时可能会提示deprecated。遇到这种情况不要慌看编译警告里的替代方案改成新的store.executeSql(sql, args)写法就行。6. 地块模块还能往哪扩展增删改查做完地块管理系统的骨架就完整了。但一个智慧农业应用的地块模块可以做的远不止这些。我整理几个接下来值得扩展的方向按优先级排序。搜索和筛选项是性价比最高的扩展。地块多了以后用户从几十个地块里找一个光靠滚动列表会很累。加一个搜索栏按名称模糊查询再加一个状态筛选Tab比如“进行中”“暂停”“全部”。实现上RDB原生就支持RdbPredicates有like方法和equalTo方法组合起来就是一条带条件的查询语句。这个功能我已经在下一版计划里了。分页加载也是地块数量过百之后必须面对的。现在这个实现是一次性把整个表查出来几百条数据还好上千条就会明显拖慢列表滑动。RdbPredicates里可以用limit和offset实现上拉加载更多。注意跟搜索条件、排序条件组合使用避免分页状态下数据顺序错乱。统计和图表是我个人最看重的扩展。地块数据攒起来之后可以统计总面积、每种作物的种植面积占比、各土壤类型的分布情况。HarmonyOS有图表组件库可用把查询结果聚合一下往图表组件里一塞就是可视化大屏的雏形。这部分我打算等农事记录模块做完再一起搞因为单看地块静态数据统计维度还是太单薄等跟产量、投入数据关联起来统计才有实际价值。还有一环是跟农事记录联动。未来每个地块可以挂多条农事记录浇水、施肥、打药都记一笔。要支撑这个现在的地块表结构是够用的因为农事记录表通过fieldId关联到地块表不需要改表。这也印证了开头那句话建表时字段设计多想一步后面功能扩展就能少改好几处。写在最后这块写到最后我想分享一个自己养成的习惯凡是涉及数据库读写的方法我都会把结果封装成数据模型返回而不是直接把ResultSet抛给UI层。这次地块模块的增删改查能写得这么顺靠的就是这个习惯。UI层永远面对干净的FieldInfo数组数据库细节全部收在数据操作层里后面换存储方案、改表结构UI代码基本不用动。下一篇我们开始做农事记录管理那块会比地块系统更复杂一些但套路是相通的——先设计好表结构再写增删改查最后补交互细节。感兴趣的可以先把手里的地块模块多点点、多跑跑有报错的欢迎把日志贴出来我们一起看看。