HarmonyOS智慧农业成本核算系统开发实战
刚把第十二篇的代码合进主分支我坐在电脑前对着屏幕缓了口气。做这个 HarmonyOS 智慧农业系列到第十二篇从环境搭建写到设备管理、农事记录、生长监控说实话前几篇更多是在搭骨架、铺流程写起来相对“顺”。但这篇成本核算系统不一样它真正要处理的是一个让我自己都觉得有点棘手的问题怎么在手机端把农业生产里那些琐碎、零散、没规律的开销管理起来让用户能看清每一笔钱花在了哪里。开头先交代一下背景。这个系列叫“高高种地”是我在业余时间维护的一个 HarmonyOS 应用开发项目核心思路是把农业生产场景搬进手机让种植户、小型农场主不用天天拿纸笔记账也不用对着 Excel 发愁。成本核算系统是这套管理应用里非常关键的一个模块——毕竟无论种什么、养什么最终都要落到“赚不赚钱”。但真打开代码去写的时候才发现成本核算并不是“录入一笔钱、加个总数”那么简单它牵扯到分类体系、数据持久化、统计口径、图表展示以及大量的交互细节。这篇文章算是这个模块的实战开发记录。我会从业务设计、数据模型、数据库操作、核心页面实现到统计图表把整个成本核算系统的开发过程拆开讲顺便把踩过的坑和优化思路一并放出来。适合正在做 HarmonyOS 应用开发、但又不太确定业务模块怎么落地的朋友也适合那些已经在做农业信息化、想参考移动端交互设计的同行。1. 成本核算的业务设计与技术选型1.1 需求拆分农业成本到底要核算什么在动笔写代码之前我先把需求翻来覆去地捋了一遍。农业生产成本不像工厂物料清单那样固定它的特点是分类杂、波动大、周期性长、跟地块和作物强绑定。一个普通大棚种植户一年下来可能涉及种子种苗、化肥、农药、地膜、人工、水电、机械作业、运输等十几个类别的开销而且同一笔支出往往还要关联到具体的地块或大棚。所以我在设计这版成本核算系统时没有直接套用通用记账软件的“收入—支出”大分类而是围绕农业场景做了定制。第一版规划的核心功能包括成本录入记录每一笔开销包括类型、金额、日期、关联地块、备注成本分类预设农业常用的大类和小类同时允许用户自定义成本统计按时间段、按分类、按地块进行汇总能算清某一茬作物累计投入数据展示通过列表和图表让用户直观看到成本构成与变化趋势。还有一个很重要的隐含需求离线可用。田间地头网络不稳定是常态所以成本记录的增删改查必须全部走本地数据库不上云也能用。1.2 存储选型为什么不用首选项而是关系型数据库HarmonyOS 应用开发里轻量级数据存储有两个常见选择首选项Preferences和关系型数据库RelationalStore。首选项适合存配置类的键值对比如“是否开启通知”“上次选中的地块编号”但如果拿它来存成本记录很快就发现问题没法按条件查询、没法聚合汇总、数据量大了性能扛不住。成本记录天然是结构化数据每一笔都有类型、金额、时间等多维属性必须用数据库管理。我选的是 HarmonyOS 的关系型数据库RDB理由也很实在支持 SQL 语法可以方便地做 WHERE 过滤、GROUP BY 分组、SUM 汇总数据容量和查询性能完全够用几千条成本记录毫无压力事务机制保证了批量操作的安全性API 设计相对清晰文档也比较全。1.3 整体架构单模块还是独立分包考虑到应用还在持续迭代我最终把成本核算相关代码放到了一个独立的 feature 模块里和农事记录、设备管理等项目保持同级的包结构。这样做的好处是后续如果把它拆成独立工程或者移植到别的项目代码可以直接搬走。整个模块的代码路径大概是这样的entry/src/main/ets/ ├── feature/ │ └── cost/ │ ├── model/ │ │ ├── CostRecord.ets │ │ └── CostCategory.ets │ ├── database/ │ │ └── CostDbHelper.ets │ ├── view/ │ │ ├── CostListPage.ets │ │ ├── CostAddPage.ets │ │ ├── CostStatsPage.ets │ │ └── components/ │ └── viewmodel/ │ └── CostViewModel.ets这种分层不是瞎分的。model 层管纯数据结构database 层管数据库读写viewmodel 层在中间做数据加工和状态管理view 层只负责 UI 渲染和用户交互。每个文件职责单一出了 BUG 也好定位。2. 数据模型与数据库层实现2.1 成本记录表设计字段怎么定才够用表结构是整个系统的基础我调整了三版才最终定稿。第一版只设计了类型、金额、日期三个字段写了一半发现根本不够用——统计时需要知道钱花在了哪个地块种的是什么作物是直接买的东西还是人工费用。最终确定的数据表如下字段名类型说明idINTEGER主键自增cost_typeTEXT成本大类如“农资”“人工”cost_nameTEXT具体项目如“复合肥”“西红柿苗”amountREAL金额单位元land_idTEXT关联地块编号crop_typeTEXT作物类型可空cost_dateINTEGER记录日期存毫秒时间戳remarkTEXT备注信息create_timeINTEGER创建时间时间戳这里有两个设计细节值得说一下。第一cost_date 存的是时间戳而不是“2025-06-18”这样的字符串因为时间戳在区间过滤比如查“6月到8月”的记录和排序时都更高效也更不容易格式出错。第二amount 用的是 REAL 浮点类型这在正式账务系统里会被喷“不严谨”但对于农业生产记账场景是够用的而且 HarmonyOS RDB 的 REAL 类型在 API 使用上很顺手。后面我会单独讲金额精度的问题。2.2 数据库初始化与建表脚本HarmonyOS 关系型数据库的使用第一步是配置 StoreConfig 并拿到 RdbStore 实例。我在 CostDbHelper 里封装了完整的初始化和建表逻辑防止多个页面重复创建连接。import { relationalStore } from kit.ArkData; import { common } from kit.AbilityKit; const DATABASE_NAME high_farm.db; const TABLE_COST cost_record; export class CostDbHelper { private static rdbStore: relationalStore.RdbStore | null null; // 获取单例 store避免每次操作都重新建立连接 static async getStore(context: common.Context): PromiserelationalStore.RdbStore { if (this.rdbStore) { return this.rdbStore; } const config: relationalStore.StoreConfig { name: DATABASE_NAME, securityLevel: relationalStore.SecurityLevel.S1 }; this.rdbStore await relationalStore.getRdbStore(context, config); await this.initTables(); return this.rdbStore; } private static async initTables(): Promisevoid { const sql CREATE TABLE IF NOT EXISTS ${TABLE_COST} ( id INTEGER PRIMARY KEY AUTOINCREMENT, cost_type TEXT NOT NULL, cost_name TEXT NOT NULL, amount REAL NOT NULL, land_id TEXT, crop_type TEXT, cost_date INTEGER NOT NULL, remark TEXT, create_time INTEGER ); await this.rdbStore?.executeSql(sql); } }这里有个容易被新手忽略的点getRdbStore 是异步接口而且同一个数据文件重复调用会复用连接但文档里并没有明确说“全局只需要调一次”。如果每个页面都自己调一次在高频切换页面时容易出现连接竞争。所以我直接把 store 做成静态单例整个应用生命周期里只初始化一次。另外建表用了IF NOT EXISTS这样即使重复调用也不会报错。2.3 增删改查的封装思路数据库操作是高频调用点我选择把常用的增删改查全部封装成异步方法对外暴露简洁的接口。插入一条成本记录的代码大致如下export interface CostRecord { id?: number; costType: string; costName: string; amount: number; landId?: string; cropType?: string; costDate: number; remark?: string; createTime: number; } export async function insertCost(context: common.Context, record: CostRecord): Promisenumber { const store await CostDbHelper.getStore(context); const values: relationalStore.ValuesBucket { cost_type: record.costType, cost_name: record.costName, amount: record.amount, land_id: record.landId ?? , crop_type: record.cropType ?? , cost_date: record.costDate, remark: record.remark ?? , create_time: record.createTime }; const rowId await store.insert(TABLE_COST, values); return rowId; }查询方面我用得最多的是按日期范围分组统计SQL 写法是SELECT cost_type, COALESCE(SUM(amount), 0) AS total FROM cost_record WHERE cost_date BETWEEN ? AND ? GROUP BY cost_type ORDER BY total DESC这个查询直接返回按成本大类汇总的结果配合折线图、饼图使用非常方便。RDB 的查询接口支持executeQuery和querySql我通常用querySql写原生 SQL因为它更直观、更容易排查问题。3. 核心功能模块实现从列表到录入页3.1 成本列表页日期分组与卡片化展示成本列表页是整个模块的门面用户一进来看到的就是它。我的设计思路是按月份分组展示同一个月内的记录聚合到一组组内按日期倒序排列。每一条记录用卡片形式展示左上角是成本图标中间显示项目名称和分类右侧是金额底部可以显示地块和备注。页面数据流用的是 ViewModel 模式。ViewModel 负责从数据库拉数据再加工成 UI 需要的结构。我定义了一个MonthGroup结构体来承载分组数据export interface MonthGroup { monthLabel: string; dateRange: string; totalAmount: number; records: CostRecord[]; }加载列表的核心逻辑是这样的先查出全部记录按日期倒序再在内存里做分组。虽然也可以直接在 SQL 里按月份分组但那样后续做展开收起动画时还要再补数据反而不如全量加载后在内存分组灵活。成本记录的数据量级是几千条全量加载在真机上测试过耗时基本可以忽略。3.2 成本录入页表单交互与数据校验录入页是用户操作最多的界面交互设计上要尽量减少输入成本。我用的是“顶部选择分类 中部填金额 底部选日期和地块”的布局。分类选择用了横向滚动的胶囊按钮用户点一下即可切换减少下拉选择层级。页面里最核心的状态管理是这样组织的Component struct CostAddPage { State selectedType: string 农资; State costName: string ; State amountText: string ; State selectedDate: number Date.now(); State selectedLandId: string ; State remark: string ; // ... }别人看到这段代码可能觉得平平无奇但这里其实藏着一个坑State修饰的变量所有变化都会触发 UI 刷新如果costName每敲一个字符就刷新整页输入框可能因焦点丢失而变得非常难用。我一开始直接在TextInput的onChange里做this.costName value实测发现多行输入时会偶发卡顿和焦点跳动。后来优化了结构把表单区域拆成独立的子组件让输入框只在自己组件内部刷新问题就解决了。提交时要做三层校验金额必填且大于 0项目名称不能为空日期不能晚于今天否则会出现“能记录未来支出”这种逻辑错误。校验通过后调插入方法成功则返回列表页并自动刷新。3.3 日期选择用 DatePickerDialog 还是自定义弹窗日期选择组件我踩了一次坑。最初用的是DatePickerDialog在 API 9 上跑得好好的但升级到 API 11 后发现一个边界问题如果用户从今天开始往前翻到几年前月历视图的联动偶尔会卡顿。排查后发现是onDateAccept回调里更新日期状态时又触发了列表页面刷新导致两边的状态互相挤压。最终做法是DatePickerDialog 只负责选日期选完后单独写入一个状态同时把日期转成“yyyy-MM-dd”格式展示在按钮上不触发列表刷新。这样从录入页跳回列表页时列表只在 onPageShow 时刷新一次。日期格式化的代码我单独抽了个公共函数export function formatDate(timestamp: number): string { const date new Date(timestamp); const year date.getFullYear(); const month (date.getMonth() 1).toString().padStart(2, 0); const day date.getDate().toString().padStart(2, 0); return ${year}-${month}-${day}; }3.4 统计报表页从 SQL 到可视化的完整链路统计页是整个系统最体现“核算”价值的地方。我做了一个三 Tab 的布局合计概览、分类占比、月度趋势。合计概览展示选定时间范围内的总投入、总笔数、日均支出分类占比用饼图展示成本构成月度趋势用柱状图展示逐月变化。这些图表我最初想引入第三方图表库后来发现 HarmonyOS 生态里可选的图表库并不多与其纠结引入不如先用 Canvas 自绘。自绘的好处是彻底避免依赖冲突而且可以完全按设计稿来调颜色和布局。我做的第一版饼图是用CanvasRenderingContext2D的arc方法画扇区每一块用不同颜色填充中间留出空白放文字标签。private drawPieChart(context: CanvasRenderingContext2D, items: CostStatItem[]): void { const total items.reduce((sum, item) sum item.totalAmount, 0); let startAngle -Math.PI / 2; const centerX this.chartWidth / 2; const centerY this.chartHeight / 2; const radius Math.min(this.chartWidth, this.chartHeight) / 2 - 20; items.forEach((item) { const angle (item.totalAmount / total) * 2 * Math.PI; context.beginPath(); context.moveTo(centerX, centerY); context.arc(centerX, centerY, radius, startAngle, startAngle angle); context.closePath(); context.fillStyle item.color; context.fill(); startAngle angle; }); }柱状图的自绘逻辑类似主要是算好坐标轴和每根柱子的矩形位置。这一块工作需要细心因为 Canvas 的坐标系和日常的页面布局坐标系不太一样Y 轴方向是向下的坐标换算错了柱子的位置就全乱了。4. UI 状态管理与页面联动的几个细节4.1 列表页与录入页的数据联动成本核算系统里最容易出问题的地方是数据联动用户新增了一笔成本返回列表页列表必须立刻显示新数据用户在统计页切换了时间范围再切回来数据也要同步刷新。我用的方案是页面生命周期刷新。列表页在onPageShow里重新加载数据而不是依赖上一个页面传值或全局事件。这样做的好处是简单可靠坏处是如果数据量大每次返回都全量刷新会影响性能。实测下来几千条数据在本地 RDB 里的查询耗时只有几十毫秒全量刷新完全可接受。onPageShow(): void { this.reloadData(); }这里我想强调一个实践原则在移动端本地数据库场景中简单直接的刷新策略往往比复杂的增量更新更稳。除非列表数据上了万条否则没必要为了省那几十毫秒牺牲架构的简洁性。4.2 状态管理的分层哪些用 State哪些只做本地变量HarmonyOS 的 ArkUI 状态管理有一套自己的规则用熟了很顺手但一开始容易用错。我的经验是影响 UI 渲染的变量才用State修饰比如列表数据、选中的分类索引、加载状态不直接影响 UI 的临时变量比如某个计算中间值就老老实实放在普通成员变量里不要过度状态化。还有一种场景是子组件需要修改父组件的状态。比如统计页里每个图表卡片是一个子组件用户切换图表时间范围时需要通知父组件重新查数据。我用的是Link双向绑定或者通过回调函数onRangeChange抛给父组件。两种方式都可行具体选择取决于数据流的复杂度。当前项目里回调函数的方式更直观我在代码里也是这么用的。4.3 列表滚动与吸顶标题的体验优化成本列表按月分组后月份标题如果能吸在顶部用户浏览起来会轻松很多。HarmonyOS 的List组件本身提供了sticky属性支持粘性标题默认是List.StickyStyle.None改成List.StickyStyle.Header就可以让分组标题固定在顶部。这个属性一开始我完全没注意到直到真机测试时发现标题跟着内容一起滚走了才回头查文档。所以说多看官方 API 文档真的能少走弯路特别是 ArkUI 这种快速迭代的框架很多好用的能力都在文档里躺着没用到就是浪费。5. 统计口径与金额计算别让数字骗了你5.1 成本归集逻辑一次性投入和长期摊销怎么区分农业成本和其他行业的成本最大的不同在于有大量“一次性投入但受益期很长”的项目。比如一栋大棚的棚膜可能用三年一套滴灌设备能用五年。如果把这些钱全部算进当年成本当年的利润就被严重低估了而后续年份的报表又会显得成本过低。在我这个第一版成本核算系统里采用了相对朴素的方案不做严格的折旧摊销而是通过“成本大类”来区分。一次性消耗品种子、化肥、农药直接计入当月长期资产类设备购置、棚体建设单独建一个分类“基础设施”在统计时单独展示不参与当期的成本利润率计算。这样虽然不够会计学严谨但对用户来说直观易懂不会误导决策。5.2 浮点数精度REAL 类型到底安不安全这是成本核算系统绕不开的问题。用 REAL 存金额理论上会出现 0.1 0.2 ≠ 0.3 的经典尴尬。但在实际业务中有多严重我测试过如果只是单笔记录显示个位数小数点基本没问题但如果是累计求和比如算“上半年化肥总支出”几千笔记录累加后误差可能积累到几分钱。对于农业记账场景几分钱的误差在界面上可能会引起用户的质疑。我采取的缓解方案是所有涉及金额汇总的计算先乘以 100 转成整数进行累加最后再除以 100 转回。export function sumAmount(records: CostRecord[]): number { let totalCents 0; for (const record of records) { totalCents Math.round(record.amount * 100); } return totalCents / 100; }这样处理后汇总结果在分这一位上就是精确的不会出现 0.30000000000000004 这种让人摸不着头脑的显示值。5.3 统计口径的坑空值字段要不要参与分组成本记录里的 crop_type 和 land_id 是可选字段。统计时如果按地块分组没有关联地块的记录怎么处理我一开始没处理导致统计页出现了一个“空”分组数字还特别大用户看到一脸懵。后来我在 SQL 查询里专门处理了空值SELECT CASE WHEN land_id THEN 未关联地块 ELSE land_id END AS land_label, COALESCE(SUM(amount), 0) AS total FROM cost_record WHERE cost_date BETWEEN ? AND ? GROUP BY land_label这种细节如果不做真机测试时可能都发现不了因为测试数据往往填得很完整。但只要一交给真实用户空值就冒出来了。做统计功能的通用法则先想好空值、重复值、极值怎么处理再写 SQL。6. 常见问题与排查技巧实录6.1 数据库连接失效为什么页面 A 拿到的 store 是空的有段时间我遇到一个诡异的问题从列表页跳转到添加页添加页里调用插入方法时报错“database is no longer open”。排查了半天发现是添加页里又调了一次getRdbStore而这次调用是在页面销毁后的回调里发起的RdbStore 已经关闭了。解决方案就是前文提到的单例模式。只要全应用只维护一个 store 实例并且统一通过 Helper 类访问就不存在不同页面各自持有连接的问题。6.2 列表刷新后滚动位置丢失列表数据刷新后滚动条总是跳回顶部。如果用户正在看 5 月份的记录添加一笔 3 月份的成本返回后列表刷新滚动位置被重置到最新月份体验很割裂。我用的解决办法是记录当前滚动位置刷新后恢复。List组件的scroller对象提供了currentOffset()和scrollToIndex(targetIndex)两个方法。在刷新前记录第一个可见分组的索引刷新后调用scrollToIndex跳回去。代码逻辑不复杂但很能提升实际使用体验。6.3 图标和颜色怎么处理才不显得廉价成本分类的图标和配色看起来是视觉问题其实直接影响统计页的可读性。我给每个大类固定了一套颜色和图标农资用绿色人工用橙色机械用蓝色运输用紫色。这样在饼图里用户只要记住颜色就能快速对应分类不需要每个扇区都去读文字标签。这个决策来自我观察真实用户使用测试版时的反馈——给分类做颜色记忆关联后统计图的解读效率提升很明显。HarmonyOS 里使用系统资源图标很方便SymbolGlyph组件配合资源符号可以快速指定图标名称和颜色不占包体积。这也是比较推荐的做法比用本地图片资源更灵活。6.4 真机调试时数据库文件怎么导出检查成本核算系统的数据对不对光看界面有时候不够我通常会把数据库文件从真机导出来用 SQLite 工具直接检查表结构和数据。HarmonyOS 应用沙箱里的数据库文件路径一般在/data/storage/el2/database/entry/rdb/下。真机调试时通过 DevEco Studio 的 “File Manager” 视图可以直接拉到沙箱文件。如果拉了但没权限就需要设备开开发者模式之后用 hdc 命令行工具拷贝hdc shell run-as com.example.highfarm cat /data/storage/el2/database/entry/rdb/high_farm.db local_high_farm.db这个技巧在我排查“金额为什么对不上”的问题时发挥了关键作用——面包屑排查法里最底层的数据永远是最可信的。7. 一些真实的使用反馈和迭代方向7.1 种菜的大爷说“我要看每亩花了多少”成本核算系统发给几个真实用户测试后收到一个特别有代表性的反馈一位大棚种植户问我“你这个统计只能看总共花了多少钱但我想知道我的西红柿一亩地花了多少”。这个需求本质上是“单位面积成本”也就是把成本总额除以面积得到亩均成本。不同地块面积不一样只有算成亩均成本才能横向比较哪个大棚的投入更合理。这个功能我准备在下一版里实现。实现本身不难在地块表里加一个 area 字段统计时把对应地块的成本汇总除以面积即可。难点在于部分成本是多个地块共用的比如一车肥料施给了两个棚怎么分摊这个业务规则需要产品层面先定义清楚代码反而简单。7.2 预算对比从记账到控制成本核算系统做到后面记账只是基础能力更值钱的是“预算对比”。用户年初给某块地设定一个预算上限比如 5000 元系统实时显示已经花了多少剩余多少在接近预算上限时给出提醒。这样成本系统从一个被动记录工具变成了主动的管理工具价值感知会强很多。HarmonyOS 侧要实现这个能力只需要在现有表结构上加一个 budget 表和一张成本汇总视图。但交互上需要注意预算提醒不能做成弹窗轰炸否则用户会烦。比较温和的做法是在列表页顶部用一条进度条展示预算占用情况进度条变红表示超支绿色表示健康。7.3 多端协同的可能性因为我用的是 HarmonyOS 应用后续有考虑做平板端的适配。成本录入在手机上做但月度成本分析报表在大屏上看会舒服很多。平板端主要改布局列表页改成双栏左侧是分组列表右侧是选中月份的成本构成详情。这种布局改动在 ArkUI 里用GridRow组件就能实现平板和手机共用一套代码根据断点切换排列方式。不过这些都属于“以后再说”的部分当前这版先把手机端的成本核算链路彻底跑扎实后面扩展才有地基。写代码这事儿就是这样不能老想着一步到位把当前模块做深做透才是对后续迭代最大的帮助。我现在合上这段代码脑子里其实还转着几个问题成本分摊规则怎么定才能既科学又不让小农户算糊涂账图表组件要不要在后续版本里换成性能更好的轻量方案统计分析页的加载速度还能不能再优化这些问题大概率会在第十三篇里给出答案。如果你也在做 HarmonyOS 应用开发或者对智慧农业软件设计有兴趣欢迎拿这篇里的字段设计和统计口径做参考有问题评论区聊。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →