
在快消品这行干久了你会发现一个特别扎心的事实仓库里真正吃掉利润的不是采购价和物流费而是那一批批躺在货架深处、包装上落了灰的临期商品。我花了一个月时间基于Flutter跨平台开发实战做了一套面向鸿蒙系统的快消品库存动态与效期预警可视化工具用同一套Dart代码同时跑门店手持终端和办公室数据大屏把SKU级的库存变化和保质期预警实时摊开在画面上。这篇文章不聊大而全的ERP只讲我从需求拆解、技术选型到鸿蒙真机调试的完整过程适合正在评估Flutter与鸿蒙开发组合、以及准备做快消品库存可视化看板的开发者参考。1. 临期损耗只是表象快消品库存管理到底卡在哪儿1.1 快消品行业的库存特性和传统制造业完全不同我在做这个项目之前先跟几个做连锁便利店和食品代理商的朋友聊了一圈。他们给我的一致反馈是快消品的库存管理难点根本不在数量够不够上而在于保质期怎么监控。具体来说有三个让你头疼的行业特性。第一SKU数量极大。一家一百平米左右的便利店光食品饮料类就有上千个SKU乳制品、面包、果汁、啤酒、休闲零食每一类下还有口味、规格的区分。第二效期维度差异悬殊。常温奶保质期可能180天短保酸奶只有21天现烤面包可能就1到2天啤酒和洗发水又分别是9个月和3年。不同商品的生命周期不一样你没法用一套固定期限去管理。第三批次混放严重。同一个SKU货架上可能有三个不同生产日期的批次如果只按总库存数去管理新货压旧货的老问题根本挡不住。我遇到最典型的一个场景是仓库盘库存的时候看系统里显示还有60箱某品牌矿泉水觉得够卖结果实地一翻里面混着两个批次其中一批还有15天过期。矿泉水本身不算贵但在快消品代理商的利润模型里临期商品基本等于白送过期了就是直接报废。这个损耗率摊到全年能把三五个点的利润抹掉。1.2 这个项目要解决的三个核心问题跟业务方聊完我把问题收敛成三条主线这也决定了后面整个系统怎么建。第一个是库存动态。这不是简单做一个当前库存数量的数字看板而是要能看清趋势——今天入库多少、出库多少、哪些SKU在持续走低、哪些品类周转变慢。只有动态数据才能支撑补货决策。第二个是效期预警。这是整个项目的灵魂。系统要能精确到每个批次算出剩余可用天数然后按照预先设定的规则分级报警。比如31天以上正常15到30天进入黄色关注7到14天必须促销7天以内要强制下架处理。第三个是可视化落点。业务方提了一个很具体的要求门店手持端要能在拣货时一眼看到哪个批次最该先出办公室大屏要能俯瞰全公司的效期分布和库存健康度。这意味着同一套代码要适配小屏、大屏两组交互方式并且数据要实时联动。这三点就是我从需求到技术实现的明确抓手。后面所有表结构、页面设计、计算逻辑都是围绕这三条主线展开的。2. 工程初始化与技术选型Flutter在鸿蒙设备上的落地准备2.1 为什么押注Flutter而不是原生双端或者Tauri这个项目要做鸿蒙 可视化的组合摆在面前的路线其实有三条用ArkTS写鸿蒙原生、用Flutter的OpenHarmony/鸿蒙适配分支做跨平台、用Tauri或者Electron那套Web容器方案。我直接就排除了Tauri和Electron。原因是快消品门店的手持终端很多是低内存、弱GPU的嵌入式设备WebView方案一上去冷启动时间和内存占用就很难看而且Electron那套在鸿蒙上还停留在社区移植阶段周边能力不成熟我不想把自己的核心项目绑在一个随时可能出问题的壳上。原生ArkTS当然是正道但问题在于我要同时覆盖门店PDA、安卓旧设备、鸿蒙新设备三端。如果用原生开发等于同一套业务逻辑要维护两到三套代码对于我这种中小团队根本不现实。而且ArkUI虽然生态在快速成长但在复杂图表绘制、自定义动画方面生态成熟度还是比不上Flutter的pub.dev社区。Flutter这边Dart本身是AOT编译跑到鸿蒙设备上性能损失可控自绘引擎Skia/Impeller能保证同一个图表页面在手机和大屏上画出来的效果一致。最关键的是我只需要维护一套业务代码鸿蒙端通过社区适配分支编译安卓端走标准Flutter管线。这个一次编写多端编译的价值在快消品这种设备杂、屏幕尺寸多的场景里完全是碾压级的。2.2 创建鸿蒙Flutter工程时的目录规范我当前工程的Flutter基线是3.22左右的版本鸿蒙适配分支是采用的社区维护的OpenHarmony版本。创建项目的过程跟普通Flutter项目没有太大差别核心命令就是flutter create --org com.example.fmcg_inventory --project-name stock_alert_vis fmcg_vis有一点我特别建议你从一开始就做目录结构按业务模块切分而不是按页面切分。做可视化项目很容易写出一个堆满图表的页面文件后面一加功能就变意大利面。我的项目结构是这么设计的lib/ core/ # 网络层、路由、主题、常量 data/ # 数据模型、仓库、本地缓存服务 models/ # Sku, StockBatch, StockFlow, AlertRule 等模型 providers/ # 状态管理Riverpod的Notifier widgets/ # 通用组件图表、卡片、扫描输入框 features/ dashboard/ # 大屏看板页面 inventory/ # 库存动态页面 expiry/ # 效期预警页面 settings/ # 预警规则配置页 main.dart另外提醒一个细节就是创建的时候就要考虑鸿蒙那边的包名、权限声明华为应用市场目前对上架应用有严格的权限用途说明要求烂权限申请审核会被打回来。如果你只是做内部工具也建议把权限按模块收敛避免后面做鸿蒙适配包的时候到处挖坑。2.3 依赖选型与每个库的实际用途这一步踩过不少坑我按依赖清单逐一说明。选型原则只有一个优先选维护活跃、纯Dart实现占比高、API稳定的库。因为鸿蒙适配分支和标准Flutter并不完全同步那些重度依赖原生插件(PlatformView)的库很可能在鸿蒙上直接不可用。依赖版本参考用途鸿蒙适配注意点fl_chart^0.68.0折线图、柱状图、饼图纯Dart绘制无原生依赖安全riverpod^2.5.0全局状态管理与数据刷新纯Dart无坑dio^5.4.0后端接口请求标准网络库鸿蒙可用intl^0.19.0日期格式化、数量格式化纯Dart安全shared_preferences^2.2.0本地存预警阈值配置鸿蒙上可能需查社区分支syncfusion_flutter_charts^24.0.0进阶大屏图表免费社区许可功能强connectivity_plus^5.0.0网络状态感知插件层需真机验证你可能想问为什么图表用了两个库。因为fl_chart轻量适合移动端手持设备的快速绘制而大屏看板上要展示的象形柱图、带滚动条的时序图syncfusion的表现力更好。两个库并存确实增加了包体积但对快消品内部工具来说视觉说服力比几百KB的体积重要得多。2.4 一个被忽略的鸿蒙构建配置在鸿蒙构建时你会遇到一个很典型的Java/Gradle相关问题类似you are applying flutters main gradle plugin imperatively那句报错。这不是鸿蒙专属问题而是新版Flutter对Gradle插件应用方式的约束更严了。解决办法是检查你的android/build.gradle把插件应用方从apply方式改成插件声明方式。我在这台环境上顺手把这个错误也修了后面才知道不少人在鸿蒙构建第一步就被卡住。3. 库存动态图表的实现从数据表设计到页面实时刷新3.1 核心数据模型是怎么设计的库存动态可视化的地基是数据模型这块设计错后面前端再怎么写都别扭。我最终采用了SKU、批次、库存流水三层结构。SKU模型核心字段就是商品身份加保质期天数。注意这里一定要存一个标准保质期天数因为快消品不同批次之间的效期可能因为生产批次不同有细微波动有了标准天数预警计算才有基准值。Batch模型就是库存批次核心是到期日期和当前数量。我特别加了一个location字段用来标记库位/货柜编号这是为了方便后面做先进先出拣货时的位置提示。StockFlow模型是库存流水每次出入库、盘点差异、报废操作都记一条。具体到Dart端的模型代码我摘录Batch模型的核心class StockBatch { final int id; final int skuId; final String batchNo; // 批次号如20250113-A3 final DateTime produceDate; final DateTime expiryDate; final int quantity; // 当前批次剩余数量 final String location; // 库位编码 final DateTime updatedAt; int get remainingDays expiryDate.difference(DateTime.now()).inDays; bool get isExpired remainingDays 0; }这个remainingDays是后面所有预警逻辑的源头。我建议你不只是把到期日读出来而是实时计算剩余天数因为过期判断是现在时刻的函数你在页面上看到的每个数字倒计时都是活的。3.2 趋势折线图的实现思路库存动态页面的主图是一个30天库存趋势折线图展示所选SKU或全品类的每日库存快照。很多人的第一反应是直接实时展示当前库存但当前值是瞬时的看不出趋势。我的做法是后端每天凌晨生成一个全量库存快照表然后前端从快照数据聚合出折线。用fl_chart实现折线图非常简单核心是准备一组FlSpotLineChartData( minY: 0, lineBarsData: [ LineChartBarData( spots: stockSnapshotSpots, isCurved: true, belowBarData: BarAreaData(show: true, color: Color(0x3399CCFF)), ), ], titlesData: FlTitlesData( leftTitles: AxisTitles(sideTitles: SideTitles(showTitles: true)), bottomTitles: AxisTitles( sideTitles: SideTitles(showTitles: true, interval: 5), ), ), )实际项目里我把折线图包成了一个通用的StockTrendChart组件接收一个List 的每日库存值自动生成折线和底部的日期坐标。这样移动端卡片和大屏Widget都能复用同一个组件只是外层尺寸不同。重点说一个容易忽略的细节快消品库存每天都会因为大批量补货产生大的跳变如果你直接按原始值画折线恭喜你你会看到一条从地板冲到天花板再掉下来的心电图。我在数据层做了一个轻度的7日移动平均平滑同时保留原始值的点这样图的走势信息才具有决策价值。3.3 库存动效与实时刷新的实现页面上的数字卡片和图表我用了Riverpod的StreamProvider做数据流驱动。F5键刷新这种东西只适合后端接口调试真实门店场景里手持终端要从WMS/后台拉取最新库存变化。我的轮询周期是30秒一次网络正常时数据卡片会有轻微的数值补间动画。final inventorySummaryProvider StreamProvider.autoDisposeInventorySummary((ref) { return ref.watch(stockApiProvider).watchInventorySummary( pollInterval: const Duration(seconds: 30), ); });状态管理这块为什么选Riverpod而不是Bloc对于带实时大屏和多模块共享数据的项目来说Riverpod的Notifier和StreamProvider天然适合数据流自上而下驱动UI而且跟build方法配合时不需要手动注入一堆Bloc实例。快消品可视化的核心就是数变图变Riverpod这套响应式玩法正好跟它匹配。4. 效期预警引擎批次计算、分级规则与临期清单4.1 效期预警不是简单的日期减法效期预警是整个项目里最体现业务经验的部分。如果你只是算一下剩余天数然后弹个红点那真的太天真了。真实业务中预警要考虑三个维度批次剩余天数、批次数量占SKU总库存的比例、以及商品品类本身的敏感程度。举个真实例子。某个SKU总库存100件其中一批还有8天过期数量只有3件占比3%这种其实根本不需要拉响警报。但如果这个比例到了30%那就算剩余天数还有15天你也得提前启动促销方案因为15天后这批货会整体变成临期商品。所以我的预警引擎不是单一的剩余天数判断而是综合一个风险分AlertLevel computeAlertLevel(StockBatch batch, int totalSkuQuantity) { final days batch.remainingDays; final ratio batch.quantity / totalSkuQuantity; if (days 0) return AlertLevel.expired; if (days settings.criticalDays ratio settings.criticalRatio) { return AlertLevel.critical; } if (days settings.warningDays) return AlertLevel.warning; return AlertLevel.normal; }这里面有两个可配置阈值一个是绝对天数阈值一个是占比阈值。我默认给到门店参数是低于7天且占比超过20%为红色紧急低于15天为黄色预警其他为正常。但这套参数必须支持在设置页里按品类调整比如酸奶和啤酒的阈值完全不是一个量级。4.2 分级规则配置表我建议你做一个预警规则配置功能而不是把这些参数写死在常量里。原因很简单快消品的季节因素太强了夏天啤酒、饮料的效期压力远大于洗发水。业务负责人需要能在活动期间临时调整预警阈值。预警级别剩余天数区间占比条件处置动作提示正常 15天任意无黄色预警715天任意优先出库并置顶在任务列表红色紧急 7天 20%强制下架进入促销区已过期 0天任意冻结出库等待报废审批配置页我用了一个十分简单的表单每个预警级别对应两个数值输入框。为了不让配置炸掉我在保存的时候做了一次合法性校验红色阈值必须小于黄色阈值占比阈值必须在0到1之间。这里看起来是小逻辑但如果没有约束门店小姑娘能把红色阈值配到100天去然后第二天全店商品都飘红。4.3 临期清单的数据流实现临期清单页是整个预警引擎的出口。它的数据流是这样的页面加载时请求后端接口拿到所有批次数据在前端按AlertLevel分组黄色和红色的批次优先展示并按剩余天数升序排列。每组顶部展示一个聚合卡片黄色预警X个批次合计XX件红色紧急Y个批次合计YY件。列表项我做了库位提示和批号展示两个信息位原因是门店拣货员最需要知道的是去哪拿和拿哪批。只显示剩余天数不显示库位临期商品处理效率会大打折扣。这里我强烈建议用物理库位编码配合批次、生产日期来排序真正的仓库作业人员看一眼列表就知道先跑哪个货架。另外临期清单页面我做了一个搜索聚焦框支持用扫枪或者手机摄像头快速扫商品条码直接跳转到该SKU的所有批次效期状态。快消品仓库里面扫码是高频操作千万不要让员工在几百条列表里手动翻找。5. 大屏与手持端的联动同一套Flutter代码的适配细节5.1 大屏布局与响应式切换这套系统需要同时服务于办公室大屏和门店手持PDA所以页面适配是个大工程。我没有写两套页面而是用LayoutBuilder在布局层做了一次分流。大屏端我的Dashboard页面使用了一个自适应的Grid布局顶部一排四个KPI卡片分别显示总SKU数、库存总量、临期率、今日出入库次数中间区域是库存趋势大图和品类占比柱状图底部是临期预警滚动列表。整体用GridView来排当宽度大于900像素时启用大屏版布局小于这个值就切换成纵向滚动的紧凑版。这个阈值不是拍脑袋定的。市面上主流PDA和手机宽度在360到430像素之间平板在768像素以上而43寸以上的电视或广告机分辨率一般会超过1280像素宽。我实测了三个尺寸段手持端、平板端、大屏端。在GridView中我通过crossAxisCount动态设置列数再配合MainAxisExtent约束每个卡片的高度能实现一条代码在三种屏幕下都体面展示final isLargeScreen constraints.maxWidth 900; final columnCount isLargeScreen ? (constraints.maxWidth ~/ 280) : 2;这里有个小建议不要过度依赖MediaQuery的物理像素尺寸因为它在大屏上会给出非常夸张的宽度。用LayoutBuilder拿到约束后按逻辑宽度和预设的卡片最小宽度去动态计算列数这样即使未来换更高分辨率的广告机列数也能自动适应而不至于挤压变形。5.2 大屏数据的轮询与订阅机制大屏端的实时性要求高我一开始打算走WebSocket长连接但后来发现快消品仓库的高频数据更新时间跨度很大忙碌时段可能几秒就有出入库动作闲时十几分钟都不动。为此我采用了一个协商式刷新策略首次进入大屏时拉取全量数据同时注册到后端的变更通知频道后端有数据变更是推送一条版本号心跳前端收到心跳后只增量拉取变更的SKU维度数据。实际落地时因为自建的轻量后端不太想搞复杂的消息队列我退了一步用轮询加Etag类似思路实现。前端每15秒请求一次接口带上本地缓存的版本号后端只在数据有变化时返回200加新数据没变化时返回304。这样大屏看起来是有实时感的但网络成本很低。Flutter端配合Timer.periodic Riverpod的state覆盖写起来也十分顺手。5.3 缓存策略让同一批数据在不同页面间复用库存看板最忌讳的是每开一个页面就重新请求一次数据。在门店PDA上网络不稳定过度请求会让员工想摔机器。我的做法是进入Dashboard时就把全量库存摘要和临期批次列表缓存在Riverpod的Notifier里。当从Dashboard切到临期清单页时页面优先渲染缓存数据同时静默发起一次后台刷新。代码上实现也很简单就是让两个Provider依赖同一个数据源final stockRepositoryProvider Provider((ref) StockRepository(api: ref.watch(dioProvider))); final inventorySummaryProvider FutureProvider((ref) { return ref.watch(stockRepositoryProvider).fetchSummary(); }); final expiryListProvider FutureProvider((ref) { final summary ref.watch(inventorySummaryProvider).valueOrNull; if (summary ! null) return summary.stale; // 先复用摘要内嵌的列表数据 return ref.watch(stockRepositoryProvider).fetchExpiryList(); });这就是为什么我坚持用Riverpod而不用简单的setState或者InheritedWidget。你需要在多个页面之间共享同一份数据状态还要控制它的加载时机Riverpod这种显式依赖注入的模型简直是为大屏多页面联动这类架构量身定做的。6. 鸿蒙真机调试踩坑记录渲染、插件与状态保持6.1 Impeller渲染引擎在部分鸿蒙设备上的兼容问题这是整个项目里花时间最多的一次排错。在我的鸿蒙测试平板上Flutter应用第一次启动后部分图表页会出现文字模糊、图形撕裂的问题。当时我第一时间怀疑是图表库的问题结果排查了图表配置半天无果。后来才意识到这是渲染引擎的锅。Flutter新版本默认启用了Impeller渲染引擎但在某些鸿蒙设备的GPU驱动上Impeller的Vulkan后端支持不好回退机制又没及时触发。解决方法是切换到更稳定的Skia渲染后端命令行跑的时候加一个参数就行flutter run --enable-software-rendering如果你在打包正式安装包时需要固定渲染后端可以在AndroidManifest或鸿蒙工程的特定配置文件里设置FlutterEngine的渲染模式。我这边最终用Skia后端跑起大屏页面后所有图形绘制问题消失稳定性表现恢复了正常。这个坑给我的教训是遇到图形相关Bug先确认是不是底层渲染引擎的问题再去看应用代码。Graphical issue排查效率最高的路径永远是先排除渲染管线本身。6.2 部分插件在鸿蒙分支上缺失原生实现标准的shared_preferences插件在鸿蒙适配分支上曾经出现过无法正常写入的兼容情况。具体现象是在原生安卓设备上正常在鸿蒙设备上却拿不到已保存的值。我当时的排查链路是这样走的先加了日志确认插件是否被正确调用发现调用没问题但数据没落盘再去翻鸿蒙适配分支的插件映射文件确认shared_preferences并没有完整实现鸿蒙原生侧的方法。好在这个功能点的替换方案不难我改成自己写了一个极简的文件存储工具用path_provider拿到目录后直接写json文件几百行代码就搞定了。这里给一个通用原则你在标准Flutter生态用得越深的插件在鸿蒙分支上踩雷概率越大。做鸿蒙适配之前务必整理一张依赖清单然后逐个在鸿蒙真机上跑最小可用测试。这个验证工作最好安排在项目启动第一周而不是留到联调阶段。6.3 Flutter页面切换后的状态丢失问题在PDA和手持端上页面切换状态丢失是一个典型但容易被忽视的体验问题。现象是这样的员工在库存趋势页往下滚了好几屏切去首页处理一条临期告警再切回来图表页直接回到了顶部或者重新进入了加载动画。这个问题根源其实不在鸿蒙而是Flutter的页面切换默认状态管理机制。你在使用Navigator.push跳转时如果没有用IndexedStack或者保持路由原页面的State会被销毁。解决办法是在底部Tab导航中用IndexedStack包裹页面让各个Tab的State常驻内存。对于滚动位置则利用PageStorageKey保留各页面的滚动偏移。class DashboardTab extends StatelessWidget { override Widget build(BuildContext context) { return IndexedStack( index: _currentIndex, children: [ InventoryPage(key: PageStorageKey(inventory_page)), ExpiryPage(key: PageStorageKey(expiry_page)), SettingsPage(key: PageStorageKey(settings_page)), ], ); } }改完之后表格页、图表页的滚动位置和筛选条件全部保留员工在多个页面之间处理任务时不用反复重新加载数据。这个细节对门店作业效率的影响比你想的要大。还有一点临期清单页如果每次切换都重新加载员工会本能地放弃使用这个系统因为太慢了。最后再分享一个我自己调试时留下来的小技巧做鸿蒙大屏适配时务必把手持端的布局也放到同一个工程里反复切换预览。很多控件在小屏上看起来正常一旦拉大到几千像素图片会糊、间距会失衡。项目全部完成之后跑一次全尺寸真机矩阵再收工能让上线后的visible问题少一半以上。这个项目的核心价值不在于代码写得多花哨而在于把库存动态、效期预警、可视化看板这三件事在一个跨平台框架里稳稳当当地跑起来并且让门店员工能真的用起来。