库存和效期,是快消品行业两件最让运营头疼的事。一个SKU往往对应十几个生产批次,每批保质期又不一样,货铺出去了,效期就很难追得回来。这个项目是我最近用Flutter做的一套跑在鸿蒙设备上的快消品库存动态与效期预警可视化客户端:仓库平板上打开就能看到库存水位、批次到期分布,哪些临期、哪些再不出手就要报废,一屏内清清楚楚。它最大的价值不是把Excel搬到平板上,而是把“到期风险”从一堆流水账里捞出来,变成颜色、曲线和可以直接操作的预警列表。对正在琢磨Flutter跨端方案、或者被鸿蒙生态适配问题困扰的客户端同学,这篇实操记录应该能帮你省掉不少折腾时间。
1. 项目定位与整体设计思路
1.1 快消品库存“总账对、批次乱”的痛点
快消品的库存管理,跟重工业或耐消品有个很大的差别:SKU数量庞大,单品价值不高,但流转速度极快。饮料、零食、日化、乳品,每个品类都有各自的保质期逻辑,长的一年多,短的可能只有七天。如果只按照SKU汇总库存总量,仓库账面上确实显示“还有500箱”,可这500箱里如果有三分之一是三个月后就要过期的批次,等到发现的时候,只能走报废流程。
我在项目开始前调研过运营同事的日常工作,他们每周要导一次库存明细表,自己用Excel拉透视表,看每个批次到期日,再手工把临期批次标黄。这种方式有几个天然缺陷:第一,数据是T+几天的,不是实时的;第二,临期判断完全靠人眼,批次多了容易漏;第三,信息和实物脱节,运营看到风险的时候,往往已经错过了促销窗口。所以这个项目从需求上就是奔着“把效期风险前置可视化”去的,不是做一张电子台账,而是做一个让仓管和运营每天愿意打开看一眼的操作界面。
1.2 为什么选Flutter做鸿蒙客户端:跨端复用的底气
技术上最先要回答的问题是:为什么选Flutter,而不是直接用ArkTS写鸿蒙原生应用?原因有三层。
第一层是设备形态。这个系统的使用者分布在仓库平板、手持终端、办公室大屏和普通手机上,如果每个端都用原生重写一遍,光UI适配就够喝一壶。Flutter的自绘渲染机制在这些不同尺寸的屏幕上能保持高度一致的视觉效果,一套代码编译出多个端,对快消这种“设备杂、预算紧”的场景非常合适。
第二层是团队资产。我们团队本身就有Dart和Flutter的基础,服务端接口设计也偏RESTful,前端迁移成本低。如果强行全员转ArkTS,学习成本和交付周期都会拉长,而业务方并不关心你用什么框架,只看功能和体验。
第三层是鸿蒙适配现状。Flutter官方主线目前还没有把ohos平台正式合入,但社区和厂商已经有比较成熟的ohos适配分支,核心渲染引擎可以跑起来,纯Dart代码基本能做到零成本迁移。实际问题主要集中在原生插件和平台通道上,这些后面我会详细讲。Flutter在鸿蒙上的角色,相当于一个“自绘UI + Dart运行时”,鸿蒙只是承担宿主能力。
1.3 模块划分与一条完整的数据链路
我把整个客户端拆成三个核心模块:库存总览、批次效期、预警中心。库存总览负责看整体水位和趋势,批次效期负责回答“哪批货什么时候到期”,预警中心负责把风险转成待办动作。三个模块共用一套数据模型和预警算法,避免各写各的逻辑。
数据链路是这样的:后台库存服务通过消息推送把库存变动事件发到鸿蒙原生宿主,宿主解析后通过eventChannel把事件推给Flutter侧;Flutter侧先更新本地状态,再触发对应模块的UI刷新。用户主动查询时走methodChannel,由原生侧调用库存查询接口,返回批次快照。本地还用sqlite做了一份缓存,保证仓库网络不好的时候也能看到上一次同步的数据。
2. Flutter与鸿蒙通信:项目的中枢神经
2.1 Flutter在鸿蒙上的运行层级:自绘引擎带来的移植优势
要理解Flutter适配鸿蒙这件事,得先理解Flutter到底是怎么工作的。Flutter的UI不依赖操作系统的原生控件,而是由自己的渲染引擎画出来的,Dart代码负责业务逻辑,渲染引擎负责把Widget变成像素。所以只要有人能把Flutter引擎挂到鸿蒙的Ability生命周期里,把引擎的视图挂载到页面上,剩下的UI和业务代码就跟Android、iOS没有任何区别。
这跟传统的WebView套壳方案不一样。WebView的页面交互和原生能力之间有一条很深的鸿沟,JS与原生通信靠桥接,性能损耗明显;而Flutter引擎和宿主之间走的是标准平台通道,延迟远低于WebView桥接,复杂列表滚动和动画表现也更接近原生。鸿蒙适配版Flutter本质上就是提供了一层ohos的embedding,把FlutterViewController以子页面的形式挂到UIAbility上,具体的初始化写法在不同SDK版本间会有包装差异,但整体思路是一致的。
2.2 库存变动实时推送:eventChannel事件流设计
库存系统的高频场景是“支出一笔、入一笔”,如果客户端每次都主动轮询,仓库设备一多,服务端压力很大;但如果轮询间隔设得太大,临期预警又有延迟。所以我们设计成事件推送:库存服务把变动事件推给鸿蒙宿主,宿主通过eventChannel持续往Dart侧推送。
eventChannel是Flutter平台通道里专门负责“单向、持续数据流”的机制,非常契合这个场景。Dart侧的核心代码很简单:
class StockEventBus { static const _channel = EventChannel('com.quickfmcg.stock/events'); Stream<StockEvent> start() { return _channel .receiveBroadcastStream() .map((event) => StockEvent.fromJson( Map<String, dynamic>.from(event as Map), )); } }原生侧在初始化时注册同一个通道名,之后往Dart侧发送字符串形式的JSON数据。这里有几个坑要提前说清楚。
第一,通道名必须完全一致,一个字符都不能差,否则接收端直接静默失败,而且不会有明显报错。第二,eventChannel不保证粘性,也就是Dart侧监听之前的消息会丢掉,所以客户端启动时必须主动拉一次全量库存快照,用快照兜底。第三,消息格式要统一,我这边定了一套事件协议,把事件分成四种:stock.updated(库存变动)、batch.expiring(新批次进入临期)、batch.expired(批次过期)、system.error(同步异常),每种事件携带独立的payload,时间统一用毫秒时间戳,数量统一用double类型。
2.3 主动取数:methodChannel的用法与边界
事件推送是“别人告诉我”,主动查询是“我问别人”。比如进入批次详情页时,需要拿到这个批次最近30天的出入库流水,这时候就走methodChannel,由Flutter发起调用,原生侧执行查询后把结果返回。
final result = await _methods.invokeMethod<String>( 'queryBatchMovements', {'batchId': batchId}, ); final movements = MovementsResponse.fromJson(jsonDecode(result));methodChannel的参数传递有个边界要注意:Flutter标准平台通道支持基本类型、List、Map和ByteBuffer,不支持自定义对象。跨端传对象时,最省事的做法是传JSON字符串,让接收方自己解析。千万不要试图把DateTime对象直接塞进Map里,不同平台对日期对象的编码方式不一致,老老实实用毫秒时间戳。另外,methodChannel的调用要处理超时和异常,实际网络环境不会像开发时那么友好,接口超时、对端Crash都会导致PlatformException,UI侧要给出降级提示,而不是直接白屏。
2.4 第三方插件适配:纯Dart和原生依赖要分开评估
选Flutter做跨平台,最大的隐性成本不是框架本身,而是插件生态在鸿蒙上的覆盖度。我在项目开始前做了一个插件盘底,结论可以概括成一句话:纯Dart包随便用,原生依赖包要单独评估。
纯Dart包,比如网络库dio、状态管理riverpod、图表库fl_chart、日期处理intl,完全不碰原生代码,在鸿蒙上跑起来没有任何问题。但凡是依赖原生功能的插件,比如扫码、生物识别、系统权限、地图、一键登录,基本都要看有没有鸿蒙版本。我们登录模块最初选了okta_flutter,Android和iOS都很顺,但鸿蒙上没有官方实现。最后采用的是自己用ArkTS封装鸿蒙SDK,暴露给methodChannel,Flutter侧再包一层repository的思路。这个方案比找社区插件靠谱,至少出了问题自己能改。
所以立项时,建议把功能清单里所有涉及原生能力的点都列出来,逐个确认鸿蒙支持情况,别等开发到一半再换方案。
3. 库存数据模型与效期预警算法
3.1 按批次建模,而不是按SKU汇总
库存可视化做得准不准,前提是数据模型本身合不合理。快消品库存如果只按SKU汇总,根本算不了效期预警,因为同一种商品不同批次的到期日可能差好几个月。所以我的数据模型从设计上就坚持“批次独立建模”:一个SKU对应多个批次,每个批次有自己的生产日期、到期日、数量和库位。
class Product { final String skuId; final String name; final String category; final int shelfLifeDays; } class StockBatch { final String batchId; final String skuId; final double quantity; final DateTime productionDate; final DateTime expiryDate; final String warehouseZone; } class InventoryMovement { final String batchId; final String changeType; // INBOUND / OUTBOUND / ADJUSTMENT final double quantity; final DateTime movementTime; }批次模型带来的直接好处是能追溯和定位。某个批次出了问题,可以查到它什么时候入库、存在哪个库位、经过哪些出入库操作。移动端本地也维护了三张表:批次快照表、出入库流水表、预警配置表,用sqlite管理。开发期间排查数据异常,我直接用DB Browser for SQLite打开本地库文件,比写一坨调试SQL方便得多。
3.2 效期预警规则:四档分级与品类差异化阈值
预警模块的核心算法并不复杂,就是算剩余天数,然后按阈值分级:
int daysToExpiry(DateTime expiry) { final today = DateTime(DateTime.now().year, DateTime.now().month, DateTime.now().day); final expireDay = DateTime(expiry.year, expiry.month, expiry.day); return expireDay.difference(today).inDays; }这里有个容易犯的错:直接用DateTime.now()和到期日做差,会带上时分秒,导致明明还有一天到期,显示出来的剩余天数却不是整数。所以必须先统一把两个时间都归到当天零点再算差。
分级规则我设计成四档,外加过期一档:
| 状态 | 剩余天数 | 视觉颜色 | 业务动作 |
|---|---|---|---|
| 正常 | > 90天 | 绿色 | 正常销售 |
| 关注 | 31 ~ 90天 | 黄色 | 关注动销,准备促销预案 |
| 警惕 | 8 ~ 30天 | 橙色 | 主动促销,优先出库 |
| 临期 | 0 ~ 7天 | 红色 | 强制处理,下架或特价 |
| 过期 | < 0天 | 深灰 | 冻结出库,走报损流程 |
阈值不能写死,不同品类的保质期差异太大了。酸奶保质期21天,你给它设90天的黄色阈值等于所有批次永远都是黄色;常温饮料保质期12个月,7天的红色阈值又太宽松。所以预警阈值是配置化的,按品类维护一套参数,甚至同一个品类下再按包装规格微调。
3.3 预警刷新策略:全量扫描、事件驱动与前后台兜底
预警计算分三条路径并行。
第一条是启动时全量扫描。App启动后拉取本地缓存的批次快照,全部跑一遍预警算法,把结果写入内存状态,UI立即渲染。这条路径用来兜底事件推送丢失的极端情况。
第二条是事件驱动刷新。收到batch.expiring或stock.updated事件时,只针对相关批次重新计算,避免全量扫描的CPU开销。
第三条是定时补漏。客户端在后台运行期间,eventChannel可能因为系统调度被挂起,恢复前台时可能错过一批事件。我在AppLifecycleState恢复到resumed时强制拉一次全量快照,确保数据不会因为前后台切换而失真。
这里有个和Dart异步机制相关的经验:Future的then回调会进入微任务队列,如果在then里面做大批量批次的数据解析和预警计算,会占用UI的isolate时间,明显掉帧。批量预警计算这种CPU密集任务,我一开始直接写在then里,结果列表滚动一卡一卡的,后来改成丢到compute隔离线程执行,UI线程才解放出来。
4. 可视化页面的落地实现
4.1 总览仪表盘:KPI卡片与30天趋势折线图
库存总览页的第一屏是四张KPI卡片:总库存金额、临期批次数量、今日过期批次、库存周转天数。这四张卡片不是展示型数字,而是预警入口。比如“临期批次数量”这个卡片会直接显示红色数字,点进去就是预警列表。
第二屏核心是一张近30天库存趋势折线图,展示库存总量和每日出库量两条曲线。折线图选的是fl_chart,它是纯Dart实现,在鸿蒙上不需要额外适配,这一点在选型时给我省了不少心。用fl_chart画折线图有几个实用经验:横轴日期标签如果都显示会很挤,要按间隔抽稀;tooltip的触发区域要按设备的devicePixelRatio做位置校正,否则在部分鸿蒙真机上会出现点击位置偏移的问题;曲线记得开isCurved,快消品库存趋势本身是很硬的数据,平滑处理后视觉上更符合看板调性。
4.2 批次效期热力矩阵:用GridView自绘一片“雷达区”
整个项目里最有价值的可视化,是批次效期热力矩阵。它用一张类似热力图的矩阵,把“未来12周内,每个品类的到期批次数量”用颜色块展示出来。横轴是周数,纵轴是品类,格子的颜色越深,代表那周到期的库存量越大,管理者一眼就能看出未来哪个品类、哪个时间点会出现“效期悬崖”。
这个矩阵我没有用图表库,而是用GridView自己画的。原因是fl_chart没有现成的日历热力图组件,而echarts虽然能做这类图,但在鸿蒙上往往要套WebView,性能和启动白屏都是隐患。自绘方案其实更简单:每个格子就是一个Container,背景色由该周的到期数量映射得到,颜色从浅黄到深红渐变;点击格子下钻,弹出该周到期的批次列表。
实现上有一个细节要提醒:热力矩阵的格子数量不算多,但在仓库平板设备上可能会和其他模块同时滚动,如果不加控制会造成整页重绘。我在每个格子的外层包了RepaintBoundary,把单格重绘隔离在一个独立图层里,实测整体滚动流畅度提升非常明显。
4.3 预警中心、下钻详情与页面状态保持
预警中心就是按剩余天数升序排列的批次列表。列表项展示SKU、批次号、到期日、剩余天数、库位和操作按钮。操作按钮根据预警级别动态变化:黄色档显示“申请促销”,橙色档显示“优先出库”,红色档显示“强制下架”,过期批次显示“报损登记”。这样预警不只是告诉你有风险,还直接给出处理路径。
点击列表项进入批次详情页,展示这个批次的完整出入库流水和库存变化曲线。这里涉及一个Flutter页面状态的经验:Navigator push出新页面后,原页面默认不会销毁,但如果你在ListView外面套了其他容器或者手动处理了滚动位置,就可能出现切回来滚动位置丢失的情况。解决办法是在列表组件上挂PageStorageKey,必要时用AutomaticKeepAliveClientMixin保住状态。我在批次详情页用了这个方法,从详情返回总览页面,滚动位置和筛选条件都还在,体验差别很大。
5. 真机联调、踩坑与性能优化实录
5.1 鸿蒙工程创建与Flutter SDK环境搭建
如果是从零开始搭环境,建议按这个顺序来:先准备鸿蒙适配版的Flutter SDK,再装DevEco Studio和HarmonyOS SDK,最后用flutter doctor验证环境。
创建工程时,用flutter create --platforms ohos可以直接生成ohos平台目录,这个目录的结构跟android目录、ios目录是并列关系,里面是鸿蒙的工程配置和ArkTS入口。千万注意,不要想着把Android模块的gradle配置直接复制到ohos工程里,我试过一次,直接报出Flutter主gradle插件被命令式apply的错误,原因就是Flutter的构建插件在ohos工程里走的是完全独立的注册机制,得按适配版SDK的示例工程来配置。
连接真机调试前,还需要在DevEco Studio里配置签名信息,然后在开发者模式下开启调试。建议先用官方示例工程跑通一遍再换自己的业务代码,这样能把环境问题跟业务问题分开排查。
5.2 高频踩坑清单:现象、原因与解法
| 表现 | 原因 | 解决办法 |
|---|---|---|
| 中文字体显示成方块 | 系统默认字体回退不到位 | 应用内打包中文字体,设置fontFamilyFallback |
| 后台回前台,最新库存事件缺失 | eventChannel不保证粘性事件 | 生命周期切前台时主动拉全量快照 |
| 折线图tooltip点击位置不准 | 未按devicePixelRatio校正 | 换算触摸坐标时乘上MediaQuery的DPR |
| 批次列表滑动卡顿 | 布局无复用,重绘范围过大 | ListView.builder优化,加上RepaintBoundary隔离 |
| 部分鸿蒙设备上图表渲染花屏 | Impeller与GPU驱动兼容问题 | 按SDK能力关闭Impeller,回退到Skia渲染 |
| 首次进入预警中心白屏较久 | 同步加载全量批次 | 改为异步加载,先出骨架屏再填充数据 |
| 接口数据对不上,本地库疑似脏数据 | 缓存未按批次版本失效 | 本地库增加批次版本号,版本不一致时丢弃缓存 |
这七条是从真实联调过程里整理出来的,每一条都花过不少时间。最值得警惕的是第一条字体问题,它不会报错,只会默默显示成方块,开发时用测试机可能一直正常,到了某些仓库平板上突然爆发,排查成本很高。所以我后来直接在全局Theme里配置了字体回退链,不再依赖系统字体。
5.3 调试链路:日志、抓包与渲染性能
跨端调试最怕的是不知道问题出在哪一侧。我把调试日志做了统一约定:Dart侧的事件处理统一加STOCK_EVENT前缀,鸿蒙原生侧的日志单独打OHOS_STOCK前缀,这样日志混在一起时也能一眼分清来源,配合hilog按关键字过滤,定位问题效率高很多。
接口联调阶段我用Charles抓包查看HTTP请求链路,确认数据到没到原生宿主、返回格式是否符合预期。实际抓包时记得在鸿蒙设备上安装Charles的根证书,否则HTTPS请求只能看到加密流量,看不到实际内容。另外还要注意,有些仓库平板网络环境特殊,需要确认设备能正常访问后台服务域名,否则抓包看到的全是连接失败,容易误判成代码问题。
渲染性能方面,我用DevEco Studio自带的性能分析工具看帧率和内存占用,排查可视化页面的卡顿来源;配合Flutter DevTools看Widget的rebuild次数,找到不必要的重建。两套工具要对照着看:Flutter侧的FPS下降,先排查Dart侧是否有大计算量任务;原生侧的内存异常,回到鸿蒙工程看宿主侧的资源管理。
写在最后的一点体会
就个人实际体验来说,Flutter适配鸿蒙已经不是一个“能不能用”的问题,而是“用什么姿势用”的问题。纯Dart的UI代码迁移几乎零成本,真正的工作量集中在原生通道和插件适配,而这些工作只要在项目启动时规划清楚,就能控制在一个可接受的范围内。这套库存效期看板目前已经在仓库平板上实际使用,最大的变化就是仓管不再每周翻一次Excel,临期批次用颜色直接暴露出来,运营能够提前一个月安排促销节奏。下一步我正打算把出库速率和历史损耗数据加进来,做一个“这批货能不能在到期前卖完”的预测模型,让预警从被动看变成主动算。要是你也正准备在鸿蒙上落地Flutter跨端业务,建议先把插件清单和通道协议定明白,后面能少走很多弯路。