拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

HarmonyOS智慧农业开发:农事记录模块的状态管理与数据建模

HarmonyOS智慧农业开发:农事记录模块的状态管理与数据建模 写到一个系列的第10篇说实话我自己都没想到《高高种地》这个HarmonyOS智慧农业开发系列能坚持这么久。前面几篇我们把项目框架、设备接入、环境监测一条线串下来了这一篇要聊的是农业app里最“不起眼”但也是农户每天打开次数最多的功能——农事记录与操作管理。如果你觉得“农事记录”就是做个表单填一填、存个数据库那你大概率会在真实场景里被各种业务细节打回来。农事记录的核心难点不在于“记”而在于“记完之后怎么变成管理动作”谁的任务、干了什么、干完没有、有没有异常这些都需要设计进数据流里。我把这一篇按实际开发顺序拆成四块数据模型怎么设计、页面交互怎么做、核心链路怎么打通以及我在真机和模拟器上调式时踩到的一堆坑。这篇适合已经掌握HarmonyOS基础语法、正在做实战项目的开发者也适合产品经理拿去理解农事管理的业务边界。1. 先想清楚农事记录模块到底在记什么1.1 农事记录不是“写日记”很多刚接触智慧农业的同学拿到需求第一反应是搞一张表字段就是时间、地点、做了什么然后做一个新增页面完事。这个思路不能说错但它只解决了“记录”没解决“管理”。农事记录管理的真实场景是这样的农场管理员或者系统根据生产计划派一个任务比如“3号大棚今天下午要打药目标作物是番茄注意安全间隔期”然后操作员在手机上看到任务开始执行执行完上传图片、填写说明、提交结果。如果今天天气突然下雨打药被迫中断操作员需要上报异常管理员再决策是延期还是调整方案。所以农事记录模块的本质是一套“计划-执行-反馈”闭环而不是一张流水账表。这一篇我们把“农事任务”计划和“农事记录”执行结果分开建模原因就在这儿。任务表管状态流转记录表管事实沉淀。以后要做产量追溯、农药残留分析、人效统计都从这两张表出发不返工。1.2 数据模型任务表和记录表怎么拆分我把数据模型拆成两张核心表farm_task农事任务和farm_record农事记录。farm_task表的关键字段如下字段类型说明idINTEGER主键自增crop_idINTEGER关联作物ID决定这个任务种的是什么plot_idINTEGER关联地块ID任务落到哪一块地task_typeINTEGER任务类型0播种、1施肥、2灌溉、3打药、4收获、5其他titleTEXT任务标题例如“3号棚番茄打药”descriptionTEXT详细说明例如药品配比、注意事项plan_dateTEXT计划执行日期格式yyyy-MM-ddstatusINTEGER状态0待执行、1执行中、2已完成、3已取消、4异常priorityINTEGER优先级0低、1中、2高operatorTEXT操作人create_timeTEXT创建时间update_timeTEXT更新时间farm_record表记录实际执行结果核心字段是字段类型说明idINTEGER主键自增task_idINTEGER关联的任务ID可为空允许直接补录crop_idINTEGER关联作物IDplot_idINTEGER关联地块IDrecord_typeINTEGER记录类型和task_type枚举一致contentTEXT执行内容描述imagesTEXT图片URI列表JSON数组字符串operatorTEXT实际执行人weatherTEXT天气情况temperatureREAL温度humidityREAL湿度create_timeTEXT记录时间这套设计的核心逻辑是任务可以单独存在还没执行记录必须对应事实已经干完。任务和记录通过task_id关联但记录并不强制绑定任务因为农户在田间忙起来可能直接补录一条记录这时候没有对应任务。任务表和记录表分开的好处就是灵活统计又不混乱。1.3 状态机让农事操作“动起来”状态字段看起来简单但它是整个管理功能的心脏。我的状态定义是五态待执行0任务已派发没人动过执行中1操作员点“开始”任务进入执行中已完成2操作完成结果已提交已取消3管理员取消了任务异常4执行过程中出现异常比如打药中途下雨、设备故障状态流转规则待执行 --开始-- 执行中 执行中 --完成-- 已完成 执行中 --异常-- 异常 待执行 --取消-- 已取消 异常 --重新执行-- 执行中为什么不直接给用户一个“删除任务”的按钮因为农业任务一旦派出去操作员可能已经在手机上看过这个任务甚至已经准备了农资这时候直接删除会让追溯断链。取消和异常这两个状态本质上是在保护业务事实。这个设计我在真实项目里被农户夸过因为“误删”在农业场景里是真的很要命。在ArkTS里状态建议用枚举写死不要用魔法数字散落在业务代码里export enum TaskStatus { PENDING 0, PROCESSING 1, DONE 2, CANCELED 3, ABNORMAL 4 }状态流转的合法性校验可以做一个统一方法后面所有按钮都调用它避免出现“已完成的任务又改成执行中”这种数据混乱。2. 页面层怎么搭列表、筛选、表单的取舍2.1 列表页一屏看清“今天该干什么”农事列表页如果只是把所有记录按时间倒序列出来农户用起来会崩溃。农业操作有极强的时效性今天该打药、明天该浇水列表页的首要任务是回答“现在该干什么”。所以我用了“状态Tab时间范围筛选任务类型筛选”三层结构而不是一个无脑长列表。状态Tab我用的是Tabs组件分为全部、待执行、执行中、已完成、异常五个页。每个Tab下面是一个List用LazyForEach做懒加载。实际操作中要注意LazyForEach虽然高效但它对数据源的要求是必须实现IDataSource接口。开发时最省事的做法是让每个Tab维护一个独立的数据源实例避免多个Tab共用一个数据源时出现列表错乱。卡片布局长这样左侧是任务类型图标中间是标题和关联地块/作物右侧是状态标签和时间。状态标签我用了一个颜色映射表const STATUS_COLOR: Recordnumber, string { [TaskStatus.PENDING]: #FFB300, [TaskStatus.PROCESSING]: #2196F3, [TaskStatus.DONE]: #4CAF50, [TaskStatus.CANCELED]: #9E9E9E, [TaskStatus.ABNORMAL]: #F44336 };颜色不要散落在组件里统一管理后面做深色模式也方便。列表页还有一个细节每个卡片的“开始”按钮按下后要立刻更新本地状态并刷新UI不要等服务器响应再改UI。在HarmonyOS里就是先改State包裹的数组再异步调RDB更新给用户的反馈最快。2.2 表单页日期、图片、联动字段的处理农事记录表单页是整个模块交互最重的地方。字段不算多但每个都有坑。先说日期。计划日期和执行日期我统一用系统DatePickerDialog选择结果格式化后存yyyy-MM-dd字符串。之所以不存时间戳是因为后续SQL查询和按天统计都基于字符串最简单。这里有个血泪教训千万不要直接用new Date().toISOString()去存日期它会带时区偏移在东八区会导致存进去的日期比实际小一天具体原因我放在第4章讲。再说图片上传。HarmonyOS的PhotoViewPicker是首选方案它能拉起系统相册选择器选完返回图片URI列表。这里要澄清一个常见误区使用PhotoViewPicker选照片并不需要申请ohos.permission.READ_IMAGEVIDEO权限系统picker已经处理好授权了。很多教程还在让你先申请一堆权限其实是走弯路。选完图片后绝对不要把图片直接存进数据库。正确做法是把图片拷贝到应用沙箱的filesDir目录下数据库里只存相对路径或URI字符串。这样数据库不会膨胀备份恢复也方便。最后说联动字段。表单里有“作物”“地块”两个下拉框它们不是独立的。选了作物之后地块列表应该只显示该作物当前种植的地块。我用Watch监听作物ID变化然后重新查询地块列表State cropId: number -1; State plotList: PlotInfo[] []; Watch(onCropChange) State selectedCropId: number -1; onCropChange() { this.plotList this.loadPlotsByCrop(this.selectedCropId); }注意Watch的回调会在依赖变化时触发但不要在回调里再修改同一个被监听的值会造成死循环。我当时就因为这个原因卡了一个下午。2.3 权限申请相机和通知别漏农事记录场景里操作员通常会在现场拍照片一张图胜过千言万语。拍照的方案我建议优先拉起系统相机应用而不是直接用CameraKit自研拍照界面。理由很直接第三方应用用系统相机不需要申请相机权限系统会自己处理授权流程CameraKit虽然能做自定义相机但权限敏感、界面工作量大对农事记录这个功能来说属于过度设计。拉起系统相机的简化思路是用UIAbilityContext.startAbilityByType传入camera类型和输出URI参数拍照完成后系统会把图片写到指定URI你再读取展示。另外本地通知权限也要提一下。农事操作最怕错过时间窗口所以模块里我加了“任务到期提醒”功能每天早上8点检查当天待执行任务通过本地通知推给操作员。HarmonyOS本地通知需要申请ohos.permission.NOTIFICATION代码如下import { notificationManager } from kit.NotificationKit; notificationManager.requestEnableNotification(this.context) .then(() { console.info(notification enabled); }) .catch((err: BusinessError) { console.error(requestEnableNotification failed, err: ${JSON.stringify(err)}); });这个权限建议在用户首次进入列表页时申请和业务场景绑定用户点击率高别等推送的时候再申请。3. 核心代码实现从数据库到界面的完整链路3.1 数据库初始化和建表HarmonyOS的本地数据库首选relationalStoreRDB它是系统提供的关系型数据库API设计类似Android的SQLite但多了ValuesBucket、RdbPredicates这些封装。初始化代码import { relationalStore } from kit.ArkData; const STORE_CONFIG: relationalStore.StoreConfig { name: farm.db, securityLevel: relationalStore.SecurityLevel.S1 }; let rdbStore: relationalStore.RdbStore | undefined; export async function initDatabase(context: Context) { rdbStore await relationalStore.getRdbStore(context, STORE_CONFIG); await rdbStore.executeSql(CREATE_TASK_TABLE); await rdbStore.executeSql(CREATE_RECORD_TABLE); console.info(database initialized); }建表SQL我这边贴任务表的记录表同理CREATE TABLE IF NOT EXISTS farm_task ( id INTEGER PRIMARY KEY AUTOINCREMENT, crop_id INTEGER NOT NULL, plot_id INTEGER NOT NULL, task_type INTEGER NOT NULL, title TEXT NOT NULL, description TEXT, plan_date TEXT, status INTEGER DEFAULT 0, priority INTEGER DEFAULT 1, operator TEXT, create_time TEXT, update_time TEXT ); CREATE INDEX IF NOT EXISTS idx_task_status_plan ON farm_task(status, plan_date);索引是必须的。列表页默认查询“待执行按计划日期排序”没有索引的时候数据量到几千条就开始肉眼可见地卡。我还给farm_record表加了一个task_id索引因为查某任务对应的执行记录是高频操作。补充一句securityLevel这个字段很多人会忽略它在多设备协同场景下有实际意义。当前应用只是单机使用S1够用如果以后要做分布式数据同步必须提高安全等级而且要在一开始就定好后面改安全等级会涉及数据迁移。3.2 数据层封装CRUD别写得稀碎我用一个TaskRepository类封装所有任务表的操作页面上不直接写SQL。插入任务的代码export class TaskRepository { static async insertTask(task: Task): Promisenumber { if (!rdbStore) { throw new Error(rdbStore not initialized); } const values: relationalStore.ValuesBucket { crop_id: task.cropId, plot_id: task.plotId, task_type: task.taskType, title: task.title, description: task.description, plan_date: task.planDate, status: task.status, priority: task.priority, operator: task.operator, create_time: new Date().toISOString(), update_time: new Date().toISOString() }; return await rdbStore.insert(farm_task, values); } }查询待执行任务的代码注意resultSet用完必须close()这个不关会持续占用数据库连接多次操作后就会出现“数据库busy”的诡异错误static async queryTasksByStatus(status: number): PromiseTask[] { const predicates new relationalStore.RdbPredicates(farm_task); predicates.equalTo(status, status); predicates.orderByDesc(plan_date); const resultSet await rdbStore.query(predicates, [ id, crop_id, plot_id, task_type, title, plan_date, status, priority, operator ]); const tasks: Task[] []; while (resultSet.goToNextRecord()) { tasks.push({ id: resultSet.getLong(resultSet.getColumnIndex(id)), cropId: resultSet.getLong(resultSet.getColumnIndex(crop_id)), // ... 其他字段 }); } resultSet.close(); return tasks; }这里有个小经验用getColumnIndex获取列索引后复用不要在循环里反复字符串查找列名性能差别在数据量大的时候很明显。3.3 状态流转和待办提醒的落地状态流转本身就是一个更新操作但业务上要把它“锁死”。我不想让调用方自己拼SQL改状态所以提供带状态校验的专用方法static async transitionTaskStatus(id: number, fromStatus: TaskStatus, toStatus: TaskStatus): Promiseboolean { const predicates new relationalStore.RdbPredicates(farm_task); predicates.equalTo(id, id); predicates.equalTo(status, fromStatus); const values: relationalStore.ValuesBucket { status: toStatus, update_time: new Date().toISOString() }; const rows await rdbStore.update(values, predicates); return rows 0; }注意这个更新的谓词里带上了fromStatus相当于用一条SQL实现了乐观锁只有当前状态确实是预期状态时更新才生效。如果操作员手滑连点两次“完成”第二次的fromStatus还是PROCESSING但数据库已经改成DONE了所以第二行更新影响0行自然被拦下来。这是我最推荐的状态流转写法简洁且安全。待办提醒的逻辑也在这层实现。每天早上8点一个定时任务可以通过setInterval或系统后台任务机制触发查询当天待执行任务const predicates new relationalStore.RdbPredicates(farm_task); predicates.equalTo(status, TaskStatus.PENDING); predicates.equalTo(plan_date, todayStr);查到之后逐条组装NotificationRequest发送本地通知。通知里带上任务标题和地块名称点击通知可以跳转到应用详情页这里用WantAgent实现代码量不大但能明显提升使用体验。4. 常见问题与调试心得4.1 没有虚拟机也没有真机怎么调试这个模块这个问题后台经常有人问尤其是学生党或者手头没有HarmonyOS设备的开发者。先说我的实践经验DevEco Studio自带的Previewer预览器是一个被很多人低估的工具。农事记录模块本身以列表、表单、弹窗为主没有太多传感器依赖这类UI逻辑在Previewer里基本都能跑通。你可以直接在Previewer里点击、滑动、输入文本调试页面的布局和交互流程。对于数据库操作Previewer同样支持relationalStore所以建表、增删改查都能在本机模拟。我建议把业务逻辑尽量抽到不依赖系统UI的模块里这样在Previewer和自动化测试里都能跑减少对特定设备的依赖。如果Previewer跑不了某些能力比如相机、通知那就用云真机。华为开发者平台提供远程真机调试能力需要在AGC控制台申请有免费额度。它的本质是一台远程设备你在本地编码远程真机执行日志回传。体验接近真机特别适合验证通知、相机这类系统能力。最后是本地模拟器Local EmulatorDevEco Studio可以直接创建但特别吃内存老旧笔记本跑一个模拟器基本就卡死了。我的个人排序UI交互调试用Previewer系统能力验证用云真机性能测试和上线前回归再用本地模拟器或实体真机。4.2 新手机首次启动白屏、权限不弹的排查白屏的问题90%出在module.json5配置。HarmonyOS应用的入口页面是第一个Ability里pages列表的第一项。如果你把列表页放在第二个启动时会找不到默认页面。排查方法很简单用DevEco Studio打开ability的启动配置确认srcEntry和pages顺序。还有一种情况是首页在启动后就崩溃了表现为白屏一闪然后退回桌面。这种一定要看Logcat里有没有Fatal exception。我遇到过最典型的崩溃原因是首页onPageShow里立刻访问了尚未初始化的rdbStore导致空指针。解决方案是在AbilityStage里提前初始化数据库并且加一个isInitialized标志位页面拿到标志位之后再执行查询。权限不弹的问题先说重点HarmonyOS的权限分“系统授权”和“用户授权”两类。像网络、本地通知这类需要用户授权的必须在module.json5里声明requestPermissions同时在代码里用abilityAccessCtrl.requestPermissionsFromUser动态申请。只在代码里申请但没在配置文件里声明弹窗永远不会出现只声明不动态申请除了安装时默认授权的权限外其他权限也不会弹。4.3 RDB版本升级的一个大坑农事记录模块上线后你一定会加字段。比如我在第二个版本给任务表加了priority字段然后发现一张严重的问题CREATE TABLE IF NOT EXISTS对已存在的表不会做任何改动新字段不会自动加到旧表里。也就是说用户手机里已有的farm_task表结构还是旧的代码里却在查priority列直接报“column not found”。正确做法是使用数据库版本迁移StoreConfig里加version字段每次结构变更把版本号1然后在getRdbStore的onUpgrade回调里执行ALTER TABLEconst STORE_CONFIG: relationalStore.StoreConfig { name: farm.db, version: 2, securityLevel: relationalStore.SecurityLevel.S1 }; relationalStore.getRdbStore(context, STORE_CONFIG, (err, store) { // onUpgrade 由系统在版本变化时自动回调 });onUpgrade里写ALTER TABLE farm_task ADD COLUMN priority INTEGER DEFAULT 1;我强烈建议每加一个字段都写一段独立的升级SQL并且用PRAGMA table_info(farm_task)检查一下列是否存在避免重复执行ALTER TABLE导致崩溃。这个坑我在正式环境栽过一次教训很深。4.4 日期和图片的两个细节坑日期问题前面提过再展开说一次。new Date().toISOString()返回的是UTC时间东八区会比本地时间早8小时。如果你在晚上8点后创建记录toISOString()取出来的是当天中午12点日期字符串切出来还是当天但如果你在凌晨0点到8点之间创建toISOString()会取到前一天日期就错了。农事记录里“日期错一天”是特别严重的数据事故影响后续统计。所以我统一用本地时间格式化函数function formatDate(date: Date): string { const y date.getFullYear(); const m String(date.getMonth() 1).padStart(2, 0); const d String(date.getDate()).padStart(2, 0); return ${y}-${m}-${d}; }图片的坑则在于PhotoViewPicker返回的URI并不是稳定的文件路径它可能指向媒体库的临时访问地址直接存到数据库里下次冷启动再加载可能就访问不到了。正确做法是选完图立即把文件拷贝到应用沙箱目录import { fileIo as fs } from kit.CoreFileKit; async function copyToSandbox(sourceUri: string, destName: string): Promisestring { const file fs.openSync(sourceUri, fs.OpenMode.READ_ONLY); const destPath ${getContext().filesDir}/${destName}; const destFile fs.openSync(destPath, fs.OpenMode.READ_WRITE | fs.OpenMode.CREATE); fs.copyFileSync(file.fd, destFile.fd); fs.closeSync(file.fd); fs.closeSync(destFile.fd); return destPath; }列表页加载图片时用Image(destPath)直接加载本地沙箱路径又快又稳定。压缩图片的操作可以在拷贝之后用kit.ImageKit的image.createImagePacker处理建议把单张图片控制在200KB以内列表滑动的流畅度会明显改善。农事记录这个模块做到这里已经不是一个简单的“增删改查”页面了。它包含计划派发、状态流转、异常上报、图片取证、到期提醒一整条闭环每一步都对应着田里的真实劳作流程。我个人在实际操作中的最大体会是农业app的功能宁可做得“笨”一点也要把状态变化和操作留痕做清楚。很多管理价值都藏在这些看似枯燥的记录里——等到年底做生产复盘、算投入产出比的时候你会感谢当初在设计表结构时多花的那两天时间。下一篇我打算写农事数据的可视化报表到时候我们把这两张表里的数据翻出来看看怎么变成农户真正看得懂的图表。
返回列表