去年有一段时间,我一直被一个问题反复困扰:门店里的维修工单、报价单、配件库存全靠纸质本子和Excel来回倒腾,客户等着报价,前台却要翻半天历史记录才能确定一个常用项目的工时价格。甲方找到我,提了一个很实际的需求——能不能在现有收银系统之外,做一套轻量的车辆维修管理系统,重点把“服务项目”这个模块单独拎出来,管清楚项目名称、工时、材料费、常用套件这些基础数据,同时能跑在不同设备上,既有平板端给前台接待用,又能装到自助查询机上让客户看价目表。
这套系统最终落地为基于 Flutter × OpenHarmony 的跨端应用,我一个人从需求梳理、数据库设计到核心代码写完,过程中把服务项目模块拆成了“基础资料管理 + 报价计算 + 跨端适配”三个层层递进的子问题。这篇博文就把服务项目这个模块从业务建模、页面实现到真实设备调试的完整过程展开讲一讲,适合正在用 Flutter 做跨端应用但又没接触过 OpenHarmony 生态的同学。
1. 项目缘起:为什么非要做这么一套跨端维修管理系统
先说说这个项目最开始的形态。门店老板给我看的需求文档其实就是几页手写笔记:一是要能自定义维修项目,比如“更换机油”“四轮定位”“发动机大修”;二是每个项目要能配置工时数和工时单价,还要能挂材料费;三是同一个项目在不同车型上价格可能不一样,最好能支持多套价格方案;四是门店希望能在立式触屏终端上展示常用项目价格,让客户等位时自己看,减少前台解释成本。
这些需求单独都不复杂,但组合起来就有意思了——它跨越了手持平板、收银PC、触屏展示机三类设备,而且展示机和收银端是常年开机、不能频繁重启的环境。如果每个设备各做一套原生应用,维护成本直接爆炸。所以我最初的想法就是找一套能在多种硬件上跑通的跨端方案。当时在 Flutter 和另一套原生跨平台方案之间犹豫了很久,最后还是定了 Flutter,原因有三:渲染一致性够好,一套UI在不同分辨率的屏幕上差别不会太大;状态管理生态成熟,适合一个人快速迭代;最关键的是当时OpenHarmony生态里已经能跑Flutter引擎,这意味着一套代码可以同时覆盖Android设备、OpenHarmony系统的触屏终端以及Windows收银机模拟器。
1.1 门店管理的真实痛点
做管理系统的第一件事不是画界面,而是先理解门店每天的作业流程。我前后在门店蹲了两天观察,发现真正耗时间的不是维修动作本身,而是“报价沟通”和“历史记录回溯”这两个环节。客户把车开进来,接待员需要根据故障描述推荐维修项目,然后手写一张报价单,再去库里翻配件价目表算材料费。如果客户觉得贵选择不做,这张纸就作废了,但下次同款车型再来,接待员又得重新查一遍资料。
所以这套系统的服务项目模块,本质上解决的并不是“计算”问题——价格计算用Excel也能做——而是“数据沉淀和复用”的问题。也就是说,每一个服务项目应该是一等公民:它要有自己的编码、名称、助记码(方便拼音检索)、所属类别、适用车型、工时数、工时单价、材料清单、默认报价、启用状态、排序权重这些字段。有了这个项目主数据,后续的工单、报价、结算、统计都可以围绕它展开。
我在设计字段时特别加了一个“是否常用”的标识位。别小看这个布尔值,它对后期UI展示影响很大——自助查询机上只显示“常用服务”,前台列表默认按“常用优先”排序,这样客户看到的永远是门店想主推的项目,而不是全量几百条冷冰冰的数据。这也是我在后续版本里不断优化的一个方向,最初版本没有任何排序逻辑,结果前台翻了几页没找到项目就开始骂人了。
1.2 为什么选中 Flutter + OpenHarmony 这对组合
单独聊 Flutter,大家都不陌生,它的热重载对单人开发太友好了,改完UI代码三秒就能在平板上看到效果。真正让我多花了两周调研的是 OpenHarmony 这一侧。虽然 Flutter 官方对 OpenHarmony 并没有像 Android、iOS 那样的一等公民支持,但社区里已经有一套相对完整的适配方案,可以让 Flutter 引擎跑在 OpenHarmony 设备上。我当时做了一个快速验证:开一个最简单的Flutter页面,分别部署到一台老旧Android平板和一台OpenHarmony开发板上,对比启动速度、页面滚动帧率、中文输入法调起这三个基础场景。结果发现普通页面问题不大,但摄像头、文件读写、系统设置这类涉及底层能力的功能,就必须通过平台通道一层层处理。
正因为这个验证,我定下一个原则:业务逻辑和UI全部用 Flutter 写,凡是涉及设备底层能力的,集中在同一个“平台适配层”里做封装,并用协议接口约束,不散落到页面代码中。这个约定在后来的开发中救了我好几次——比如自助机上的触摸键盘、收银端的鼠标滚轮、平板上的手写输入,都是通过适配层各自实现的。
我用的实际依赖组合是这样一批:Flutter SDK 用的3.10系列(适配OpenHarmony的版本分支),状态管理用了 Provider,路由用了 go_router,数据库直接基于 sqflite 的通用实现再包了一层DAO。这套组合在几个目标设备上表现都比较稳定,没有出现因为库版本冲突导致编译不过的问题。
2. 服务项目模块的业务拆解与数据库设计
服务项目这个名词听起来很简单,但它实际上是整个维修管理系统的基石。工单要引用它,报价单要引用它,配件销售要对照它,月度统计要基于它。所以数据库表结构设计时,我把它设计成一个主表 + 两个维表的结构,而不是全部塞进一张宽表。
主表叫 repair_service_item,两个维表分别是 service_item_price(多套价格方案)和 service_item_part(项目关联的配件/材料清单)。之所以拆出来,是因为业务上存在明显的多对多关联:一个项目可能在不同车型上有不同价格,一个项目也可能包含多个配件。如果全部用逗号分隔存到主表一个字段里,初期写起来快,后期查询要联表时就是灾难。
2.1 一张表装下所有维修项目?项目表到底该怎么建
我最终的建表语句大概长这样:
CREATE TABLE repair_service_item ( id INTEGER PRIMARY KEY AUTOINCREMENT, item_code TEXT NOT NULL UNIQUE, item_name TEXT NOT NULL, mnemonic TEXT, category TEXT NOT NULL, vehicle_model TEXT DEFAULT '通用', labor_hours REAL NOT NULL DEFAULT 0, hourly_rate REAL NOT NULL DEFAULT 0, base_price REAL NOT NULL DEFAULT 0, is_common INTEGER DEFAULT 0, status INTEGER DEFAULT 1, sort_order INTEGER DEFAULT 100, description TEXT, created_at TEXT DEFAULT CURRENT_TIMESTAMP, updated_at TEXT DEFAULT CURRENT_TIMESTAMP );几个关键字段的决策逻辑值得说一下。item_code 必须是唯一编码,因为前台和自助机两端要用它做离线数据同步的主键,如果只用自增ID,一旦两台设备的本地库各自插入了记录,同步时就会发生ID冲突,而编码可以做到由后台统一分配。mnemonic 是助记码,也就是中文拼音首字母,这个字段在搜索时极其好用:用户输入“hs”能模糊匹配到“换刹车片”,不用等输入法完全上屏。
category(项目类别)我用文本类型而不是建立单独的字典表,一是为了第一版快速上线,二是项目的类别其实非常稳定,无非是保养、维修、钣金、喷漆、检测这几类,后期如果真要扩展也只需要加一个枚举层。vehicle_model 字段则是用来做车型差异的,默认为“通用”,当某个项目只服务特定车型时再单独建一条记录。
这里我踩过的一个坑是:最初版本里我加了“报价精度”和“四舍五入规则”两个冗余字段,看着周全,实际上完全是过度设计,因为门店结算时全部按照“工时+材料+附加费”直接相加,没有复杂的折扣阶梯。后来我把多余字段删掉,简化了前端表单,反而让接待员更愿意录数据了——表单太多字段会让人恐惧,这是做业务系统必须记住的一点。
2.2 工时与材料的费用计算规则,必须一开始就想清楚
服务项目模块里最容易出分歧的就是价格计算。我见过好几个系统在这儿栽跟头,原因是把工时和材料费混在一起算,或者对“项目总价是否含税”没有明确定义。这个项目一开始我就和甲方定了三个铁律:
- 单价展示一律为含税价,不单独拆税,避免客户在触屏上看到“价格+税”这种让人不悦的呈现;
- 项目总价 = 工时数 × 工时单价 + 材料费合计,材料费由关联配件表中的配件数量乘配件单价得出;
- 如果存在多套价格方案,触屏设备优先展示“标准价”方案,收银端可以在标准价和会员价之间切换。
这条规则听起来简单,但实现的时候有个细节容易忽略:工时数可以是小数,比如“更换正时皮带”往往记3.5个工时。这时候界面上的输入框就应该允许小数点,而且计算时要先累加再取整,不能每一步都单独取整,否则累计误差会在月底对账时显现出来。
我在代码里专门抽了一个价格计算器类,里面用 BigDecimal 精度的整数分做运算,避免 Flutter 里直接用 double 保存金额产生0.1+0.2=0.30000000000000004这样的精度问题。所有展示位统一用“分”为单位存储,UI 层再格式化为“xxx.xx元”,这个习惯虽然基础,但真的能避免很多线上金额对不上的事故。
2.3 基础数据预置:开工就能用的100条项目模板
新系统最怕的是冷启动——里面一条数据都没有,前台打开空荡荡,也不知道该从哪录起。所以我做了个“第一次启动初始化”功能,在应用首次运行时自动往库里写入一份常用维修项目模板,大概覆盖日常保养、轮胎服务、电气系统、底盘悬挂、车身修复、空调检测这些大类。
这部分数据虽然是我根据行业经验整理的,但为了让门店后续可以自己管理,我专门做了个“数据恢复”按钮。整个初始化过程不是一个死板的只执行一次的代码路径,而是一个可重复触发的幂等操作——模板写入前先按 item_code 去重,已存在的记录会被跳过,不产生重复数据。这个设计后来在门店的另一台新设备部署时派上了用场,不用手工导库,直接跑一遍初始化就快速填充了接近零成本的首屏数据。
预置数据最重要的是“常用标记”的分配。我特意把“更换机油”“机油滤清器”“四轮定位”“空调加氟”这类高频项目标记为常用,并把 sort_order 调成个位数,让它们排在列表最前面。如果所有项目都统一排序权重,那前台最重要的那几个项目就会被淹没在一百多条数据里,这属于产品设计问题,不能用技术后置补丁去解决。
3. 核心功能落地:服务项目页面的逐屏实现
服务项目模块的界面我最终做成三个页面:列表页、新建/编辑页、详情页。外加一个嵌入在首页的组件级搜索栏。从结果来看,这个拆分是合理的:列表页解决“找得快”,编辑页解决“录得对”,详情页解决“看得清”。
3.1 首页列表:怎么把筛选、搜索、分组列表做顺手
列表页的第一版我用的是动态分页加载,每页30条,滚动到底部自动加载更多。理论上没问题,但实际在OpenHarmony触屏机上发现一个交互割裂的问题——触屏机的滚动手感不如鼠标滚轮细腻,经常轻轻一推就跳到页面底部,分页加载一闪一闪的,体验很差。后来我把策略改成:前台员工可见的常用项目和普通项目全部一次性加载进内存,列表本身不做分页,只做按类别的分组和折叠。
每组数据量也不大,一百多条记录全量加载也就几十KB,内存完全无压力。真正重要的是筛选计算不能阻塞UI线程。我写了一个方法,把 category、keyword、status 三个过滤条件交给一个独立的计算方法,并在构建UI时用 Provider 监听过滤结果的变化。
搜索这块我做了双层匹配,不只是 contains 模糊匹配。第一层按名称精确匹配,第二层按助记码匹配拼音首字母,第三层才用拼音全拼模糊匹配。这样用户输入“js”也能命中“加机油”“积碳清洗”这类项目。这个效果看起来只是一个小细节,但在实际门店场景里,接待员一只手拿电话一只手操作,根本没法切输入法,纯英文首字母检索的效率要比中文输入高得多。
3.2 新建与编辑页面:表单校验和分类联动怎么做
编辑页的交互设计我花了不少功夫。竖向表单字段开始,第一版把所有字段一屏铺开,结果在平板竖屏模式下要滑几屏才能输完,前台录入的平均耗时从原来的30秒被拖到两分钟。这件事被老板当场吐槽之后,我重构了编辑页布局,把界面切成了上下两段:上段是必填核心区(名称、助记码、类别、工时、工时单价、基础价格),下段是展开式的可选区(车型适配、描述、排序、是否常用图标展示)。
表单校验规则也是一个关键点。名称、工时、单价三个字段都做了强校验,名称不允许为空且长度限制在30个字内,工时和单价必须是大于等于0的数字,并且工时最多保留一位小数。校验出错时我用的不是弹窗提示,而是在对应输入框下方直接显示红色错误文案,这样可以减少用户模态弹窗被打断的烦躁感。提交后再统一检查一次数据模型的可持久化条件,防止脏数据进入数据库。
分类联动就更直白了:当类别选择“轮胎服务”时,下面的常用配件清单会自动过滤成轮胎相关的几项,比如轮胎螺丝、气门嘴、动平衡配重块,而不是把发动机配件也铺在那里让用户挑花眼。这里我用了响应式的监听器,Category 变化后自动重置配件列表的选中状态,并重新计算总价预览。总价展示区我放在页面底部悬浮条上,金额变化实时刷新,让接待员在录单过程中就能给客户报出最新价格,不用等全部保存之后再查详情页。
3.3 状态流转:启用、停用、删除这些按钮背后的逻辑
业务系统里最容易被轻率处理的就是“删除”功能。在这个项目里我非常坚持一个原则:已经出现在工单里的服务项目禁止物理删除,只能停用。因为如果历史单据里关联的数据被删了,后续报表统计、对账、客户投诉记录都没法追溯。
所以我在项目模型里没有提供 deleteItem 方法,只有 updateStatus(status)。列表页默认只展示启用状态的项目,停用的项目可以切换“查看已停用”过滤条件后看到。停用操作需要二次确认,并且系统会用一个红色Tag标记出来,避免前台误操作把常用项目停用了还不知道。还有一个小细节:一旦某个项目被工单引用过,我就在界面上把它的“停用”按钮置灰并提示“该项目已被历史工单引用,如需停用请联系管理员”,防止门店自己把还在关联状态的底层数据搞乱。
这个逻辑看起来损耗了一部分灵活性,但它在项目上线后的运营中保护了数据的完整性。前台偶尔输错想改价格,只需要改主表的 base_price,不会影响历史工单里已经固化的快照金额,因为工单在生成时会复制一份当前价格快照到工单明细表。这个“快照思维”是所有报价类系统的护城河,不提前设计,后期对账对不上时再想改数据模型就晚了。
4. Flutter 在 OpenHarmony 上的适配实战
跨端项目真正的重头戏不是我上面说的业务建模,而是代码写完之后怎么让它在 OpenHarmony 设备上像样地跑起来。说实话,Flutter 在 Android 上的体验我很有信心,但切到 OpenHarmony 就完全不是一回事了。这套系统的设备矩阵里既有旧Android平板,又有OpenHarmony 开发板的触屏展示机,所以我从第一天起就把“适配层”当作一等模块来设计,所有系统能力调用一律走接口,页面代码里不允许出现 Platform.isAndroid 这类分支判断。
4.1 平台通道:调取系统能力时最容易踩的坑
Flutter 调用 OpenHarmony 原生能力,核心途径是 MethodChannel,这个和 Android 的 channel 机制是同一个概念,只是原生端的实现需要用 OpenHarmony 提供的 API 而不是 Android SDK。我在实际开发中踩过的第一个坑是关于存储路径的差异。Android 上常用的 getExternalFilesDir 在 OpenHarmony 里并不完全等价,用同一个接口名调过去,返回的路径在部分机型上不可写。
后来我的解决方案是在适配层里封装一个统一的 PathService,内部根据底层运行时来判断当前环境,并返回各自合理的存储位置。这个 Service 对上层暴露的接口保持一致,页面代码完全不知道路径来自哪个平台。类似的还有状态栏高度,OpenHarmony 设备上状态栏和 Android 的状态栏高度计算方式略有差别,如果直接复用 Android 那套 MediaQuery 逻辑,页面顶部就会出现一段诡异的空白或遮挡。我最后是做了一个 SafeArea 的包裹层,再在适配层里注入一个 topPadding 修正值。
4.2 中文字体渲染与布局差异处理
第二个大坑是字体渲染。OpenHarmony 官方字体和 Android 的思源黑体在某些字形细节上存在差异,而且默认行高不一样,这就导致了一模一样的 Flutter 代码在两台设备上显示出来的文本高度不同。我的列表项是固定高度的卡片,中文单行没问题,但二维码页面上的中英文混排就出现了文字截断。
排查了一整天,最终定位到问题根源:Flutter 的 Text 组件默认使用了不同的字体族回退规则,在 OpenHarmony 上系统优先返回了系统默认字体,而系统默认字体的 metrics 和我们打包进去的自定义字体不一致。解决办法也很简单——项目里统一打包并指定了中文字体文件,在 MaterialApp 的 theme 里显式声明了全局字体族,不让系统字体参与回退。这个改动做掉之后,后续在所有测试设备上都没再出现过文字截断问题。
另外还有一个布局细节:OpenHarmony 触屏机的分辨率往往比较奇特,比如一些开发板的竖屏分辨率是 720×1280,但 dpi 换算出来的逻辑宽度比手机窄得多。这时候如果列表项里用了 Row 加 Expanded 做等宽布局,小屏幕上就会把按钮挤得只剩一半宽。我的应对方法是在列表项设计时优先使用 Wrap 而不是 Row,同时在关键操作按钮上设置了最小宽度约束,确保在窄屏上依然能点得到。
4.3 打包与签名:OS 生态发布前必须检查的三件事
很多 Flutter 开发者真正第一次接触 OpenHarmony 时都会卡在打包环节。OpenHarmony 应用的构建方式和 Android APK 不同,需要的签名配置、权限声明、资源目录结构全都不一样。我自己整理了一个三项检查清单:
第一,权限声明一定不能漏。Android 里写权限是在 AndroidManifest.xml,而 OpenHarmony 应用有自己独立的 module.json 文件,网络访问、存储读写、触屏事件这些权限都要在这个文件里显式声明。漏了网络权限之后,应用的离线数据都没问题,但只要一尝试联网同步版本就立刻失败。
第二,签名对齐。OpenHarmony 应用包发布需要配置签名证书,开发时可以先用默认的调试签名,但上真机之前一定要换成生产证书,否则装到某些设备上会被安全机制拒绝。我一开始以为只是包名差异导致安装失败,排查了半天才发现是证书类型不匹配。
第三,动态库匹配。Flutter 引擎在 OpenHarmony 上是以动态库形式集成的,不同 OpenHarmony 版本对 so 文件的兼容性有差异。打包前一定要确认当前 Flutter SDK 对应的 so 版本号,并检查当前设备的系统版本是否在兼容范围内。我在一台较早版本的开发板上就遇到过“应用安装成功但启动立即闪退”的诡异现象,最后降级了一个 Flutter 小版本后恢复正常。
5. 常见问题排查与性能优化实录
这一部分全是实打实的调试记录。我把从开发到上线遇到的三个典型问题完整复盘一遍,每个问题都包含现象、排查过程、解决方案三部分。
5.1 列表滚动掉帧的优化过程
服务项目列表页在普通平板上一开始滚动很流畅,但当我把它部署到那台触屏展示机上后,快速滚动时掉帧非常明显,FPS 一度掉到30以下。我先用 Flutter 自带的 Performance Overlay 观察到问题集中在列表项的重建上——每滚动一帧,列表里所有可见项都被重建,说明列表项没有被正确缓存。
排查发现列表项里用了一个自定义的圆形渐变图标组件,这个组件内部使用了 ImageFilter.blur 做背景模糊,而 blur 操作在 GP 较弱的设备上极其昂贵。我做的第一件事是把模糊滤镜在列表场景下禁用,改用静态渐变替代。改动后帧率恢复到了55以上,肉眼基本无卡顿。
另一个优化点是把列表项内部的按钮点击回调从匿名闭包改成了有 const 构造的独立回调对象,降低每次重建时的方法绑定开销。这还是 Flutter 社区的老经验,但真正做到位的项目并不多。
5.2 输入框焦点丢失与键盘遮挡问题
编辑页有个困扰我很久的bug:在输入“工时”字段后点一下表单其他区域,再回来继续输入,焦点偶尔会丢失,键盘缩下去又弹起来,体验非常割裂。而且当键盘弹起来时,底部悬浮的价格预览条会被直接顶出屏幕外,导致用户看不到实时价格。
焦点丢失的根源是 TextField 在列表或表单中的 Autofocus 和多焦点管理冲突。我的解决方案是给每个 TextField 明确的 FocusNode,并在全局做一个焦点管理注册表,切换输入框时先 unfocus 当前节点再 focus 下一个节点,而不是依赖系统自动传递焦点。
键盘遮挡问题则是通过设置 Scaffold 的 resizeToAvoidBottomInset 为 false,手动用 MediaQuery.viewInsets.bottom 去计算悬浮条需要上移的距离。这个做法比系统默认的全屏重绘要平滑得多,悬浮条不会再闪跳。需要注意的是,这套逻辑在 Android 和 OpenHarmony 上的 viewInsets 返回值略有不同,我在适配层里写了一个 unifiedInsets 函数统一处理。
5.3 数据库读写卡顿:索引和事务怎么用
数据量只有几百条时,数据库性能往往感觉不到问题,但当门店跑了两三个月,工单、报价明细、同步日志表都膨胀之后,服务项目查询也开始出现几百毫秒甚至秒级的延迟。我定位后发现真正拖慢查询的不是项目表本身,而是联表查询的配件表——配件表里没有给 item_id 建索引。
我的修复方案简单有效:在配件表的 item_id 和 category 上分别建立索引,并把初始化时的批量插入操作全部包进一个事务里提交,而不是一条条 commit。单纯这个改动就让批量初始化从原来的十几秒缩短到两秒以内。另外,我把频繁的统计类查询改成每晚生成一份汇总快照表,白天所有报表页面直接读快照,而不是实时跑 count 聚合。这对小团队项目来说,是一种性价比极高的优化思路。
还有一个容易被忽视的点:SQLite 的并发能力很弱。最初的代码里,我在数据访问层同时开了多个异步查询,结果偶尔出现“database is locked”的错误。后来我把所有数据库操作收敛到单一的数据库服务单例,内部用队列串行执行,彻底杜绝了并发写冲突。这也是所有数据量不大的客户端应用都应该采用的经典模式。
6. 从 Demo 到落地:跨端项目的可持续迭代思路
项目走到验收阶段,整套服务项目模块已经跑通,门店那边也实实在在用上了。但我最大的收获其实不是代码本身,而是逐渐摸索出了一套适合小团队跨端项目的组织方式。最后分享几个我认为值得长期坚持的方向。
6.1 模块化的代码组织方式
整个 Flutter 工程我没有用那种一揽子塞进 lib 根目录的做法,而是按 feature 分层。服务项目模块相关的所有 UI、BLL、DAO 都集中在 feature/service_item 目录下,对外只暴露一个 ServiceItemRepository 作为门面。这样后期如果要扩展“工单模块”或“客户模块”,只需要按照同一个模板新增 feature 目录,不会出现核心逻辑散落在各个页面里的情况。
在数据层,我坚持了 DAO 模式,每个表有对应的实体类和数据访问对象,业务逻辑层不直接写 SQL。写 SQL 虽然很多人觉得更方便,但一旦涉及联表和字段修改,散落的 SQL 让改动变成噩梦。DAO 模式初期多写一点模板代码,改起来却可以只动一个文件。
6.2 后续扩展方向
从门店运营角度出发,这个服务项目模块只是第一步。后续我觉得至少有三个明确的方向可以做:其一,把项目标准化流程做深——每个维修项目绑定操作步骤和所需工具,变为一个完整作业指引,新员工培训成本能直接降下来;其二,把价格方案做成独立版本管理,当门店调整项目价格时生成历史版本记录,方便追溯调价原因;其三,把触屏展示机上的数据展示对接云端,多门店之间共享价格基线。
这些扩展并不是技术难题,更多的是产品优先级问题。不管怎么选,前面的基础建模只要足够干净,后续都是增量开发的过程。在这个项目里,我最深的体会是:跨端技术和框架选择永远是次要的,真正决定项目成败的是模块边界的划分和数据模型的理解。一套系统能跑多长时间、好不好维护,其实在第一次建表的时候就基本注定了。希望这块“服务项目模块”的拆解过程,能给你正在做的跨端项目带来一点可复用的经验。