去年下半年我接了一个很有意思的活儿:把团队维护的画师接稿平台“画栈”的接稿需求模块,原封不动地跑上 HarmonyOS 6.0。画栈的业务核心很简单——甲方发布接稿需求,画师浏览需求并申请,双方在平台上完成交付和验收。问题在于,鸿蒙新版本已经不能直接跑安卓应用了,摆在面前的选择只有重写原生,或者让 Flutter 跨端逻辑硬啃鸿蒙这条新链路。我的判断是后者能走通,而且值得走。这篇文章就是把“画栈”接稿需求模块从零搭建到鸿蒙适配的完整复盘:架构选型、数据建模、需求列表和详情页实现、申请流程的并发保护,以及我们一路踩过去的适配坑。如果你也在做 Flutter 跨端,正在评估鸿蒙,或者在做内容交易、接单类产品,这篇应该能帮你少走不少弯路。
1. 为什么敢把接稿需求模块押在 Flutter + 鸿蒙 6.0 上
1.1 鸿蒙 6.0 上 Flutter 的真实运行链路
团队内部一开始对 Flutter 上鸿蒙的争论焦点不是“能不能跑”,而是“跑起来之后到底是 Flutter 还是鸿蒙”。答案取决于引擎层怎么工作:在鸿蒙 6.0 上,Flutter 引擎通过自绘渲染管线把 Dart 侧构建的视图树直接绘制到 FlutterView 上,并不是转换成 ArkUI 组件再去渲染。也就是说,你写的 Row、ListView、CustomPaint,在鸿蒙上依然由 Flutter 自己控制绘制,UI 表现和安卓、iOS 高度一致,不需要为鸿蒙重写界面。
真正需要变化的是系统能力层。相册、存储、输入法、网络状态这些能力,Flutter 官方插件在鸿蒙上没有直接对应实现,必须通过鸿蒙侧的通道桥接原生服务。我们的做法是把这类能力全部收敛到一个 PlatformAdapter 接口后面,Dart 业务代码只面向接口编程,不直接 import 任何平台插件。这个约束在后期带来的收益非常大,后面第五部分会展开讲。
1.2 为什么业务上值得做这个跨端试点
画栈的用户结构决定了我们必须做跨端。接稿的画师几乎全天泡在手机上刷需求列表,发稿的甲方则经常在平板甚至桌面端看参考图、整理需求说明。一套完整的业务逻辑天然需要多端复用,而 Flutter 是我们手里唯一一份能覆盖多端的前端资产,单独开一个原生团队为鸿蒙重写,不仅周期长,后续业务双端对齐的成本也极高。
但更重要的是业务切入点的选择。我们一开始就没有把支付、即时聊天、大图编辑器塞进试点范围。支付涉及收单资质、风控回调,鸿蒙侧的回调协议链路长,不适合做验证性项目;IM 涉及长连接、推送保活,在鸿蒙上的表现差异太大,排查成本会吃掉整个迭代预算。接稿需求模块本质是服务端数据驱动的 CRUD 加状态流转,列表、筛选、详情、申请、发布表单都清晰可验证,它又是整个交易闭环的入口,拿它试水,正好。
1.3 选型之前,我们清点了哪些风险
全部盘完风险之后,真正阻碍我们的就剩下两个:一是 Flutter 引擎在鸿蒙上的稳定程度,这在当时没有太多公开的完整案例可以参考;二是第三方插件的地基,很多 Flutter 插件在安卓上用得好好的,到鸿蒙上连初始化都过不去。
针对第一个风险,我们的对策是先用一个最小 Demo 跑通了完整链路,再做正式模块迁移。那个 Demo 大概只有三个页面:一个带图片的列表、一个表单、一个相册选图。正是这个 80 行的 Demo,让我们确认了自绘渲染、通道调用、基本的文件读写都成立,才敢进入真正的开发。针对第二个风险,我们在选型阶段就定了个规矩:凡是涉及系统能力的依赖,一律不直接写在页面里,先过 PlatformAdapter 包一层。事实证明,这层包装保了我们很多次。
2. 接稿需求模块的数据模型与状态机设计
2.1 需求模型:把甲方需求商品化
在设计数据模型时,我们始终有一个考虑:画栈的“需求”本质是商品,要同时服务列表曝光和画师筛选。最初版本只设计了一个 category 字段,很快被产品否掉了——画师的筛选习惯是“我想找古风、半身像、预算一千以内”这种组合条件,用单个分类字段根本无法支持。
class Requirement { final String id; final String title; final RequirementType type; // avatar, illustration, scene, icon final List<String> tags; final List<String> refImageUrls; final int budgetMin; final int budgetMax; final DateTime deadline; final LicenseScope licenseScope; // commercial, nonCommercial final String deliverables; final String description; final String ownerNickname; final String ownerAvatar; final int applyCount; final int viewCount; }字段本身不复杂,但有三个产品层面的细节值得记录下来。第一,参考图用 List 存多张,封面图固定取第一张,列表和详情共用同一套 URL 结构,只是通过图片处理参数控制尺寸,这一条对后面长列表性能优化很关键。第二,预算一定是区间(min/max)而不是单一总价,甲方发布时对心理预期的上下限比定值更符合直觉。第三,状态字段没有放在模型里,而是单独走状态机,因为同一个需求在不同端看到的可操作状态可能不同。
2.2 从待申请到交付验收:状态机怎么定
接稿需求模块最容易被写乱的就是状态流转。如果你在 if 里写一长串状态判断,很快每个页面都会复制粘贴一坨逻辑,加新状态时到处漏改。我们的方案是在 model 层用一个枚举约束全部状态:
enum RequirementStatus { waiting, // 待申请 inProgress, // 已接单/制作中 delivered, // 画师已交付 accepted, // 甲方已验收 expired, // 已过期 cancelled // 已取消 }状态流转的完整规则由服务端裁决,客户端不维护状态机副本,只做展示层的状态投影。
| 当前状态 | 触发动作 | 目标状态 | 关键说明 |
|---|---|---|---|
| waiting | 画师申请被确认 | inProgress | 服务端保证只有一个画师进入制作 |
| inProgress | 画师上传交付物 | delivered | 交付后进入验收流程 |
| delivered | 甲方验收通过 | accepted | 验收通过后订单完成 |
| waiting / inProgress | 超时未处理 | expired | 系统定时任务触发 |
| waiting / inProgress | 甲方主动取消 | cancelled | 取消后不可恢复 |
为什么不做客户端完整状态机?因为需求的任务状态可能被其他端随时修改:甲方在另一端取消,系统定时任务自动过期。客户端保存一份状态机副本只会徒增不同步的问题。客户端真正要做的只有两件事:渲染当前状态对应的 UI,以及在用户操作后做一次乐观更新。比如画师点击“申请接稿”,客户端立即把按钮置为“已申请”避免重复点击,但真正的状态变更以服务端返回为准,如果因为需求已被他人申请而被拒绝,再回滚并弹出提示。
2.3 列表缓存:为什么放弃 Hive 改用轻量 JSON
初始方案里,需求列表的本地缓存想用 Hive,因为 Flutter 生态里它性能好、无原生依赖。真正适配鸿蒙时发现,Hive 底层二进制文件读写本身没问题,但获取存储目录的初始化环节在鸿蒙侧没有成熟的插件实现,要么自己写桥接,要么等社区适配,成本超过了收益。
最后我们用 SharedPreferences 存了一个精简缓存:只缓存首页第一页数据,按需求 id 作 key,外加最近一次筛选条件,缓存 15 分钟。定位就是冷启动占位,目标是用户弱网或断网状态下打开 App 不白屏。内容交易类产品对数据新鲜度要求很高,离线浏览的价值本来就不大,把复杂缓存方案留到真正需要时再上,比为了技术完整性硬搬一个存储框架要实际得多。这个妥协不是退缩,而是把精力留给更值得打磨的部分,比如列表滚动体验和申请流程。
3. 需求列表与筛选:长列表在鸿蒙设备上的调优实录
3.1 布局方案:为什么没有直接上瀑布流
需求列表是接稿模块的门面,布局方案我们纠结了很久。最开始想用瀑布流,参考图大,视觉效果最好,能充分展示需求里的作品样例。但瀑布流在 Flutter 里实现依赖 SliverGrid 或者第三方 waterfall,高度反复计算对低端机压力不小。纯文本列表性能好,但信息密度低,刷起来没有食欲,点击率也会受影响。
最终采用了一种混合策略:整体用 ListView.builder 驱动,每个列表项内部采用固定 4:3 长宽比的图片区域加信息区。视觉上接近卡片化瀑布流,但渲染路径是固定 itemExtent 的列表,滚动性能好控制。我们把真正的瀑布流做成灰度开关,后续如果鸿蒙端表现稳定再逐步放开。对于需求列表这种以图为主的 Feed,优先保证滚动流畅,再考虑视觉张力,顺序不能反。
3.2 分页、预加载和骨架屏
分页接口不要传页码,传 pageToken。页码在无限列表里会遭遇数据插入、删除导致的分页错乱,pageToken 是游标,永远表示“从服务器返回的某个位置接着取”,不会偏移。Flutter 端在列表滚动到剩余 200 像素时触发下一页加载,加载期间在列表底部显示一个占位 item,而不是弹 loading 浮层打断滚动。
骨架屏这个环节值得多说几句。需求列表的布局高度规整,首次进入页面且缓存缺失时,直接按真实 item 结构渲染 4 个骨架块,图片区、标题区、标签区分别用灰色条块表示,再配一个淡淡的渐变动画。用户感知上几乎是“内容马上就会出来”。实现也不复杂,一个带 AnimationController 的 Container 而已,不需要引入额外库。这个细节对鸿蒙测试机的首屏观感贡献很大,因为我们知道鸿蒙端冷启动比安卓慢,骨架屏能在等待期留住用户注意力。
3.3 图片:长列表流畅度的最大杀手
参考图质量对画师决策至关重要,但大图是列表帧率的直接威胁。我们做了三层处理。第一层是 URL 规格化,服务端返回的图片地址统一附带缩略图处理参数,列表请求约 600px 宽的版本,详情页再请求原图。第二层是内存缓存上限,用 cached_network_image 时把最大缓存图片数量设为 100 张、总缓存控制在 80MB 以内,防止长列表滑动期间内存暴涨。第三层是占位图策略,加载中显示轻灰底加 icon,而不是弹跳 loading,避免视觉跳动。
鸿蒙端这里有一个特别值得提醒的坑:独立的 image 加载插件在鸿蒙 6.0 上的通道初始化方式与安卓不同,我们最初遇到的崩溃点就在这。直接把图片加载也收进 PlatformAdapter 后,业务代码无感知切换,问题就只在适配层内解决了。长列表性能调优没有银弹,图片这一关过不了,后面所有优化都是白搭。
4. 需求详情页与接稿申请流程的实现细节
4.1 详情页的三个关键交互
详情页承担两个核心任务:让画师充分理解需求,以及让画师快速、低门槛地申请接稿。页面从上到下依次是参考图画廊、需求信息卡、预算与授权范围、交付物说明、发布者信息。底部固定操作栏,根据状态动态渲染:待申请状态显示“申请接稿”主按钮和“发消息”次按钮;已申请状态显示“已申请,等待甲方确认”禁用态;进入制作中状态则显示“提交交付物”。
参考图画廊使用了 InteractiveViewer 做双指缩放。这里有个细节坑:如果 Image 组件没给宽高约束,InteractiveViewer 在鸿蒙上缩放手感会发飘,因为渲染层拿到的是零尺寸约束下的原始图。我们强制把画廊容器设成 16:10 的 AspectRatio,内部图片 BoxFit.contain,缩放立刻恢复正常。这类问题在安卓上不明显,到了鸿蒙上全暴露出来了,所以真机测试从第一天就要开始。
4.2 申请接口的幂等与并发保护
申请接稿是最容易出并发问题的操作。画师在详情页反复点击申请按钮,或者弱网下请求超时后手动重试,服务端可能建出多条申请记录。客户端做了两层防护:第一层是按钮防抖,点击后短时间内禁用;第二层是请求级别的幂等控制。
Future<String?> applyForRequirement(String requirementId) async { // requestId 在同一页面会话内复用,服务端用这个字段去重 final requestId = _applyRequestId ??= generateRequestId(requirementId); try { final result = await platformAdapter.post('/apply', { 'requirementId': requirementId, 'requestId': requestId, }); return result['applicationId']; } catch (e) { return null; } }服务端对相同 requestId 的请求直接返回已有申请结果,不插入新记录。这样即使弱网下触发三次,真正创建成功也只有一条。我们后来把同一套幂等逻辑用在了“提交交付物”和“确认验收”上,凡是会产生业务变更的按钮,都带幂等标识。花半天时间把幂等落实,能省掉未来无数个重复订单的客诉。
4.3 发布需求表单:校验规则与草稿恢复
甲方发布需求的表单校验规则不复杂,但必须完整:标题不能为空且超过 30 字;参考图至少传 1 张;预算区间最小值不能大于最大值;截止时间必须晚于当前时间至少 24 小时;授权范围必选。校验不通过时,错误文案直接显示在对应字段下方,并触发该输入框获得焦点,而不是在顶部弹个 Toast 让用户自己猜。
容易忽略的是草稿恢复。甲方写一条需求可能要十分钟,中途切走、App 被系统清理是常有的事。我们在 SharedPreferences 里做了 5 分钟自动草稿,记录表单字段和参考图 URL,下次进入发布页时提示“检测到未提交的草稿,是否恢复”。当时觉得这是小事,上线后发现使用率意外地高。编辑型业务跨端后,输入中断恢复这种细节比想象中更影响留存。
5. 鸿蒙 6.0 适配实录:从权限到生命周期,那些不试不知道的坑
5.1 相册权限与文件保存路径差异
画栈有“保存参考图到相册”的功能。在安卓上,请求权限后拿回调,通过 MediaStore 写入;在鸿蒙 6.0 上,相册权限的授权逻辑是“拉起授权页,用户操作,返回结果”的异步链路,如果按安卓的时序去等一个静态回调,一定会偶发拿不到权限结果。
我们的解法是把整个“保存图片”动作封装成一个 Future 交给 PlatformAdapter,鸿蒙侧通过事件通道监听授权结果,再决定走保存还是走取消分支。业务层只感知 SaveResult.ok 或 SaveResult.denied。保存路径上有个容易踩的坑:鸿蒙的图片目录命名和安卓不同,直接用相对相册根目录的文件名保存,拼绝对路径在真机上一定会失败。这个坑我们花了两个晚上才定位出来,最后发现就是多拼了一段目录。
5.2 软键盘顶起与底部操作栏
详情页底部操作栏和发布表单的键盘问题,在鸿蒙 6.0 上表现和安卓不一致。安卓的 Scaffold 默认 resizeToAvoidBottomInset 会把底部栏顶起来,但鸿蒙端部分测试场景里底部栏没有跟动,键盘直接盖住按钮。排查发现是键盘高度回调的时序差异:安卓在键盘完全展开时回调一次最终高度,鸿蒙侧会先回调一个中间过渡高度,页面会先跳一下再落定。
修复方式是让 PlatformAdapter 暴露一个 keyboardInset 流,鸿蒙侧监听安全区变化,等高度值稳定后再给底部栏的 padding 赋值。我们宁可让底部栏晚 100ms 跟上,也不让它来回跳。这种问题在模拟器上几乎复现不出来,必须拿着真机反复点开输入框才能发现。所以鸿蒙适配阶段,真机测试的优先级要提到最高。
5.3 后台回前台与生命周期回调
App 切后台再回来,在鸿蒙 6.0 上部分场景会丢失 resumed 状态,导致需求列表的自动刷新不触发。这个问题的隐蔽点在于:它不影响正常点击,只是刷新逻辑不跑,用户切回来看到的还是旧数据,在 UI 层几乎无法感知,只有靠埋点数据才能发现。
我们的解法是双保险。第一层是在鸿蒙侧增加应用前后台切换的桥接回调,把 lifecycle 事件主动推给 Dart 侧,统一走 AppLifecycleListener 分发。第二层是列表页增加可见性检测兜底,页面重新可见且距离上次刷新超过 30 秒就重新拉取列表。这套组合落地之后,基本消除了陈旧列表问题。生命周期这类基础能力,跨端时最容易被想当然,结果都是真机数据教你做人。
5.4 日志与崩溃定位:没有集成调试工具的日子
鸿蒙的调试体验和我习惯的集成式调试工具不一样,崩溃日志的组织方式也不同。我们最开始只能靠试错定位问题,效率极低。后来自建了一个轻量日志缓冲:把关键业务事件、状态流转、网络请求耗时写入环形内存,崩溃时自动 dump 到本地日志文件,下次启动时上报。定位问题先看业务事件链,再结合鸿蒙侧拉日志的命令看引擎层报错,效率一下就上来了。
这个方法在团队协作时价值更大。因为不是每个人都有鸿蒙真机,自建日志缓冲让远程同学也能拿到一手崩溃链路。跨端开发不能总依赖别人把调试工具给你备好,自己把可观测性提前做上,比什么都管用。
6. 性能实测:鸿蒙端和安卓端到底差多少
6.1 冷启动与首帧渲染
在某鸿蒙 6.0 测试机上,冷启动进入需求列表的首帧时间大约 800ms,同一套代码在安卓参考机上是 550ms 左右。差距主要来自 Flutter 引擎在鸿蒙上的初始化路径,需要完成系统通道注册和渲染 surface 对接,这部分绕不开。首帧之后的页面切换和滚动,已经没有明显卡顿。
对于内容型应用,800ms 冷启动可以接受,但启动页要把心理预期管好,让用户感觉 App 已经响应了。我们没有为了压缩冷启动去做预启动原生引擎之类的优化,收益有限且维护成本高。鸿蒙上的 Flutter 运行时还在快速演进,这个差距会持续缩小,我们只需要跟踪基线,确保没有劣化就行。
6.2 滚动帧率与内存表现
长列表滚动帧率实测数据:在 100 条需求数据、卡片式布局下,鸿蒙端平均帧率稳定在 55-60fps,滚动过程出现过几次因图片解码导致的掉帧,但持续时间很短。内存峰值约 280MB,图片缓存和页面组件是主要占用;安卓参考机同场景内存峰值约 240MB,差距不算离谱。
主要优化手段是固定 item 高度、缩略图 URL 化、以及页面销毁时主动释放图片缓存引用。帧率数据只能说明一部分问题,真实流畅度还是要靠手感。开发者在鸿蒙端调性能,建议用真机反复快速滚动列表、频繁进出详情页,人为感知到的卡顿才是优化目标,不要只盯帧率图表。
6.3 包体与产物管理
去掉未使用的平台插件、开启 tree shaking 和混淆之后,当前的鸿蒙产物包体约 28MB。包体对移动端不是大问题,真正需要提前规划的是 CI 构建产物管理。我们目前的做法是每个渠道独立产物目录,鸿蒙产物单独留档并打上版本标签,方便回滚和对比。多端同时维护时,构建产物管理混乱会浪费大量时间,这个看似不起眼的工程化细节,值得在项目一开始就定好。
最后说一点个人体会。跨端开发最怕的是把差异藏起来,表面上代码统一,一到鸿蒙真机上就崩。我们在画栈接稿需求模块上摸索出的核心方法是:把平台差异收口到 PlatformAdapter 接口,业务层永远面向能力编程,而不是面向某个系统的具体实现编程。这个约束前期多写几行桥接代码,后期省下来的排查时间远超投入。
再补一个小技巧:如果你也在做 Flutter 和鸿蒙 6.0 的适配,建议把“保存图片到相册”“选择图片”“获取键盘高度”这类高频系统能力全部抽象成 Future 接口,并且给每个接口设置超时时间。鸿蒙的通道调用在部分场景下没有默认超时,如果不做兜底,一次系统服务无响应会把用户的操作彻底卡死,而且没有任何报错提示。这个坑我踩过一次,当时排查了一整天,最后发现只是缺了一个 withTimeout。提前加上,能让你的跨端代码健壮很多。