拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Flutter鸿蒙适配实战:剧本详情与评价模块的技术落地与踩坑

Flutter鸿蒙适配实战:剧本详情与评价模块的技术落地与踩坑

上个月我们组接到一个很现实的需求:剧本杀组队App要适配鸿蒙生态设备。团队里Android和iOS工程师都有,但没人正经写过ArkUI页面,老板排期又紧,讨论来讨论去,最后定了走Flutter for OpenHarmony这条路线,把核心页面用Flutter重写,再嵌进壳工程。这次落地过程中最典型、也最值得拿出来拆解的,就是“剧本详情与评价实现”这一段。整篇文章我会沿着选型、数据建模、UI落地、评分组件自研、提交链路,一直聊到真机踩坑,把那些文档里不写、但实际项目里一定会碰到的问题都摊开讲。

这个模块之所以适合当试点,是因为它几乎把日常开发会遇到的点全占齐了:长图文展示、多维度评分、滚动列表、表单校验、图片上传、跨页面通信、嵌套滚动、异步时序。如果你正在评估Flutter跑OpenHarmony的可行性,或者打算在新平台上复用现有Flutter页面,这篇文章应该能帮你把预期和风险都拉到真实水平。

1. 为什么先拿“剧本详情+评价”开刀:选型推演与SDK现状

1.1 这个模块为什么适合做第一个鸿蒙适配试点

业务方一开始问的是“能不能只把详情页放上去,别的模块后面再说”。我在评审会上也是顺着这个思路答的。剧本详情页是全App信息密度最高的页面之一,同时它又是一个相对独立的闭环:用户进来、看内容、看评价、进评价页、提交内容、返回刷新,全程不需要和太复杂的系统能力纠缠。

更实际的原因是,这个页面可以直接检验Flutter引擎在OpenHarmony上的基础能力。布局能不能按预期渲染,滚动是否跟手,图片解码是否正常,异步任务和微任务调度有没有异常,长列表会不会内存膨胀——这些都能在详情和评价两个场景里暴露出来。相比之下,首页、消息这类页面牵扯推送和复杂原生交互,做试点反而容易把“平台适配问题”和“业务逻辑问题”混在一起。

还有一层考虑是组件复用。评分控件、标签组件、图片九宫格、骨架屏,这些做完之后,后续的组队页、个人主页都能直接拿去用。先做详情页,本质是先搭一套可复用的鸿蒙端Flutter组件库。

1.2 Flutter for OpenHarmony的SDK分支与版本注意点

先说一个很多文章不会提的前提:目前Flutter官方主干并没有正式把OpenHarmony列为支持平台,用的是OpenHarmony社区维护的ohos分支。这个分支里包含了OpenHarmony所需的平台嵌入层、插件注册机制和一些渲染适配。拉下来编译时,会出现一条很著名的提示:The current configured Flutter SDK is not known to be fully supported. Please...。

这条提示在很多环境里只是警告,但对我们来说它是实打实的坑。因为不同commit对应的工具链能力差异很大,有的commit能正常出包,有的commit连flutter create --platforms ohos都跑不顺。我们最后用的是锁定commit的方式:把选定的commit号记在一个README里,全组统一用同一个commit建项目,而不是谁想拉分支就拉分支。这样做的好处是环境崩了之后能快速复原,不至于每个人排查半天版本差异。

项目创建流程也跟Android不一样。Android那边是flutter create生成android/目录,OpenHarmony这边生成的是ohos/目录,构建产物也不是APK而是HAP。如果你是从老工程把Flutter模块嵌进Android壳再往OpenHarmony迁,还会看到另一类报错,类似“you are applying flutter's main gradle plugin imperatively using the apply”,本质是Gradle语法更新后,插件声明方式改了。到了OpenHarmony侧虽然不用Gradle,但hvigor构建工具也有类似的“配置声明位置不对”问题。遇到这类报错,别急着翻插件版本,先把构建脚本里插件声明和工程属性的组织方式对齐新工具链的约定。

1.3 环境配置里最容易被忽略的一步

DevEco Studio不是必须装的,但强烈建议装。原因是获取OpenHarmony SDK路径最方便的方式就是通过DevEco Studio的SDK Manager。装完之后需要配一个环境变量,比如OHOS_SDK_HOME,指向SDK的实际目录,否则Flutter工程的ohos构建过程找不到系统SDK。我们当时有一台新电脑一直构建失败,查了半天才发现是环境变量没配。

另外,模拟器和真机建议都备一台。模拟器对平台桥接类的问题排查效率高,但很多渲染底层的差异只有真机才出现,比如纹理合成、视频层遮挡这些,光靠模拟器根本测不出来。后面第六部分讲到的PlatformView问题,就是在真机上冒出来的。

2. 详情与评价的数据建模:不是几个字段那么简单

2.1 剧本详情页的核心数据字段拆解

剧本杀App的业务模型很固定,但字段组织方式会直接影响前端工作量。详情页常规需要展示以下几块:

板块核心字段说明
基础信息剧本名、封面图、作者、发行方列表页与详情页共用
类型与人群类型标签、难度、适合人数难度分新手/进阶/硬核三档
时长与价格推荐时长范围、单人均价时长用分钟区间表示
图文介绍简介、背景故事、VCR页长图部分剧本支持换装
角色列表角色名、性别、角色封面、适合提示有些角色位有特殊要求
组织信息门店、可组队场次、剩余座位列表形式展示

这些字段里最容易踩坑的是“人数”和“时长”。如果后端只给字符串,比如“6-8人”,前端拿来做筛选和展示都还行,但一旦要做“按人数排序”或“显示剩余座位”,就必须有结构化的最小值和最大值字段。所以建模时我坚持用minPlayers、maxPlayers、minMinutes、maxMinutes四个数值字段,页面展示时再由前端拼文字。宁可多两个字段,也不要在客户端做字符串解析。

2.2 评价体系为什么不能只放一个rating字段

最初产品改动前,“写评价”就是给一个五角星打分,存一个rating字段。后来玩家反馈多了,才意识到选剧本这件事,用户真正关心的是“这个本是不是重推理”“恐怖程度如何”“主持人靠不靠谱”。单一的综合评分完全表达不了这些信息。

于是我们把评价模型拆成四层:

  • summaryScore:综合评分,展示时保留一位小数,列表排序也用这个字段。
  • dimensionScores:分维度评分,包括推理难度、剧情张力、氛围营造、主持人专业度四个维度。
  • tags:玩家点选的短标签,比如“新手友好”“重推理”“高自由”,这些标签会聚合到剧本详情页的标签云里。
  • content+images:长评文字和玩家实拍图。

这里有一个很关键的设计决定:综合评分是后端算,不是前端算。一开始我觉得前端拿到四个维度分数,自己乘权重加起来很简单,但产品后来要调权重,比如“主持人专业度权重从30%调到35%”。如果权重写在前端,每次调整都要发版,这完全不能接受。所以详情接口直接返回summaryScore,前端只当展示字段用。

2.3 接口层协议设计与Mock数据切换

跑OpenHarmony适配期间,我们还顺手做了一件收益很高的事:把所有接口能力抽象成Repository,并实现Remote和Mock两套数据源。

定义大概长这样:

abstract class ScriptRepository { Future<ScriptDetail> fetchScriptDetail(String scriptId); Future<List<ReviewItem>> fetchReviews(String scriptId, {int page}); Future<bool> submitReview(String scriptId, ReviewRequest request); }

线上环境用RemoteScriptRepository走HTTP,开发环境和OpenHarmony模拟器上用MockScriptRepository直接读本地assets里的JSON。切换靠一个编译期flag,不靠运行时判断。

这个设计救了大忙。前期后端接口还没完全定稿,UI却不能等,于是Mock数据先把页面交互跑通,等后端ready后一行配置切过去。而且在OpenHarmony真机上跑联调时,如果网络策略或证书有问题,至少还能用Mock数据判断问题是出在页面还是出在网络层,排查效率高很多。

3. 详情页UI落地的关键取舍:滚动容器、骨架屏与首帧加载

3.1 为什么最终选了CustomScrollView而不是ListView+Column

详情页最初版本结构很简单,外层放一个ListView,内容是一个巨大的Column,顶上是封面图,中间是图文介绍,下面再嵌一个评价列表的ListView。跑起来之后问题立刻暴露:页面的滚动是分裂的,评价列表能自己滚,但整个页面头部不会跟着收起,用户滑动体验非常割裂。

后来把结构改成标准的CustomScrollView组合:

  • 最外层用CustomScrollView提供整页滚动。
  • 顶部用SliverAppBar包封面,配合FlexibleSpaceBar做伸缩折叠。
  • 图文区、标签区、评分概览用SliverToBoxAdapter放不定高的内容。
  • 评价列表用SliverList实现长列表懒加载。

这个方案的好处是整页滚动天然联动,不会出现双List互相抢手势的问题。代价是代码组织比ListView+Column复杂一点,需要把一个页面拆成若干个sliver builder。但对于剧本详情这种头部有图、下面有长列表的页面,这个改造是值得的。

3.2 骨架屏与首帧加载顺序

详情页打开时的体验分两条路径:有缓存时直接显示旧数据,没缓存时显示骨架屏。骨架屏用Shimmer效果包一层灰色占位块,封面、标题、评分、列表各占一块区域。这比显示一个居中的CircularProgressIndicator体感好很多,因为用户能提前感知页面结构。

接口返回后,我的做法是分阶段刷新,而不是等整个详情模型解析完再一次性setState。具体是拆成两个Future,一个负责基础信息加封面,一个负责评价列表和场次。基础信息先回来就先刷基础信息,评价列表后几百毫秒回来再补。首帧“有内容”的耗时大概能提前40%,用户感知上是“秒开”而不是转圈。

这块有个细节:分阶段刷新时要做好数据竞态处理。用户快速退出页面再进来,前一个Future可能还没回,这时候不能直接拿旧数据覆盖新页面。我在页面State里加了请求序号,每次发起请求递增一个counter,回调里只认最新的序号,旧响应直接丢弃。

3.3 图片布局的三种兼容写法

剧本封面大图、VCR长图、玩家实拍九宫格、横向场次海报,四种图的需求不一样,不能一种布局通吃。我按场景分别处理:

  • 封面这种单张大图:直接用AspectRatio固定比例,避免网络图还没加载回来时高度塌陷。
  • VCR长图:用SliverToBoxAdapter包一个SingleChildScrollView横向分页翻页,而不是纵向拉伸。
  • 玩家实拍九宫格:用Wrap+ 固定尺寸的SizedBox,每张图宽度按屏幕宽减掉间距后除以3计算,避免RenderFlex overflow。
  • 评价详情里的大图:用PageView做左右滑动查看。

所有图片统一加clipBehavior: Clip.antiAlias,否则圆角图片的边缘很容易出现锯齿。OpenHarmony上图片解码对超大图的内存占用更敏感,所以我额外包了一层按需加载,用CachedNetworkImageProvider做了内存缓存上限控制,避免长列表滑动时OOM。

3.4 切换Tab后滚动位置丢失的问题

详情页有“图文”“评价”两个Tab,刚开始用TabBarView切换时出现一个很典型的问题:切到评价Tab滑到第20条,再切回图文Tab,滚动位置丢了,又回到顶部。排查半天,原因是TabBarView默认不会保存子页面的状态,每次切走都会重新build。

解法是给两个子页面的State加上AutomaticKeepAliveClientMixin,让它们离开可视区时保持存活。这个坑其实Android和iOS上的Flutter也会遇到,但在OpenHarmony上更容易误判成平台适配问题,因为切换动画显得更“重”,观感上差异更明显。

另外产品后来提了一个反常规需求:点Tab不要滑动渐变,要直接切过去。这个可以通过给TabController设置animationDuration: Duration.zero实现,但要注意不能把点击态反馈也一起禁掉,否则按钮看起来像失灵。我当时只改了动画时长,保留了高亮反馈,实测观感比较自然。

4. 自研星级评分控件的完整过程:半颗星的手势与绘制

4.1 现成评分组件为什么不能用

pub.dev上不是没有评分组件,像flutter_rating_bar这类做得还不错。但这次我坚持自研,原因有几个:

  • 评分维度有四个,每个维度都需要一个独立评分控件,现成库定制多个实例时样式容易乱。
  • 产品要的是“未选中是空心描边、选中是渐变填充”的双色星星样式,很多现成库基于Icon实现,填充渐变支持不好。
  • 评价页需要一个“横向拖动手感”的评分方式,现成库的点按逻辑为主,拖动的细节表现不一致。
  • 依赖本身在OpenHarmony上的兼容性没验证过,引入一个用了原生能力或字体渲染很重的包,后续排查成本不可控。

实际测试里也印证了这点:某个评分包在Android上正常,在OpenHarmony上字符宽度解析异常,星星间距忽大忽小。与其去适配别人的控件,不如自己画一个,至少代码在掌控范围内。

4.2 绘制与几何:五颗星的半星取整

评分组件的核心是“半星”效果。实现方式不用复杂的Canvas裁切逻辑,用两层星星叠加就行:底层画五颗空心星,上层用同一个白色星Path,但按评分比例裁剪宽度,只显示“亮”的部分。

先定义一颗五角星的Path,循环画5次:

class StarPainter extends CustomPainter { final double rating; final Color fillColor; final Color emptyColor; @override void paint(Canvas canvas, Size size) { final starSize = size.width / 5; final path = _buildStarPath(Offset(starSize / 2, starSize / 2), starSize / 2); for (int i = 0; i < 5; i++) { canvas.save(); canvas.translate(starSize * i, 0); // 画空星 canvas.drawPath(path, Paint()..color = emptyColor); canvas.restore(); } final fullWidth = size.width * (rating / 5); canvas.save(); canvas.clipRect(Rect.fromLTRB(0, 0, fullWidth, size.height)); for (int i = 0; i < 5; i++) { canvas.save(); canvas.translate(starSize * i, 0); canvas.drawPath(path, Paint()..color = fillColor); canvas.restore(); } canvas.restore(); } }

半星取整规则也要和产品对齐。我们的规则是:3.2到3.7都显示3.5,2.8到3.2显示3.0。公式是(value * 2).roundToDouble() / 2。这个取整要在交互手势时实时计算,不能在最后提交时才做,否则用户拖动过程中看到的评分会跳得很奇怪。

4.3 点按和滑动的交互细节

光能画还不行,交互才是评分组件体验的关键。我同时支持点按和横向滑动:

  • 点按时,通过GestureDetector的onTapUp拿到点击点相对于组件宽度的偏移,除以单颗星宽度,得到评分值,然后做半星取整。
  • 滑动时,用onHorizontalDragUpdate持续更新评分。这里要处理一个边界问题:手指拖出组件左边界时,评分不能变成负数;拖出右边界时,要自动封顶到满分。

手势冲突方面,评分组件放在评价页的ListView里,横向拖动和纵向滚动理论上不冲突,但我还是遇到过一次快速横向拂动被父级吸收的情况。解决方式是在GestureDetector外面包一个竞技场,声明HorizontalDragGestureRecognizer优先,保证评分滑动不被列表滚动抢走。

最后是无障碍:给评分控件包一层Semantics,读屏时可以读出“当前评分3.5星,总评5星”。这一点产品开始没提,是测试同学在无障碍专项里发现的。既然做了自研控件,这些系统能力还是顺手补齐比较好,成本很低,体验收益却很明显。

5. 评价列表与提交流程:异步时序、跨页刷新与组件通信

5.1 提交评价的完整链路设计

“我要评价”是一个独立页面,用户流程分三步:

  1. 给四个维度打分,并且勾选标签。
  2. 写文字评论。
  3. 上传实拍图片(可选)。

提交评价的按钮在表单校验完成后才可点击。校验逻辑不算复杂:综合评分必须有值,并且文字评论和图片至少二选一,避免有人直接空评价刷屏。

提交链路里最容易出错的是图片上传顺序。我采用的是“先传图、后提交评论”:先把图片通过MultipartRequest传到CDN,拿到URL数组后,再把这些URL连同评论内容一起提交给评价接口。这样设计的原因是,如果评论先提交成功但图片上传失败,就会出现一条没有图的评价,用户还得走申诉流程。反过来,图片先传成功、评论提交失败,只会留几张孤儿图片在CDN,下次用户重新提交时覆盖掉就行,影响面小得多。

整个流程要防重复提交。我用一个_submitting布尔值控制,请求期间按钮置灰加loading,直到流程完全结束才恢复。不这么做,手快的用户连点两次会重复提交评价,后端即使做了幂等,前端体验也已经是错的。

5.2 跨页面通信:发布后详情页怎么刷新

评价页和详情页的通信,我们没有引入EventBus这种全局方案,用的是Navigator返回值。详情页push评价页,等它pop回来后判断返回值:

final result = await Navigator.push<bool>( context, MaterialPageRoute(builder: (_) => ReviewPage(scriptId: scriptId)), ); if (result == true && mounted) { _refreshReviews(); }

这里有一个很多新手会踩的点:await返回之后,详情页的context可能已经不在组件树里了。比如用户在评价页提交成功返回前,又快速做了一次返回手势,导致详情页已经被pop。如果_refreshReviews()里直接用BuildContext弹ScaffoldMessenger,就会报setState() called after dispose。所以条件里必须判断mounted再往下走,所有BuildContext相关的调用都要放在这个判断之后。

5.3 Future回调与微任务队列的一个小坑

开发中遇到过一个很诡异的问题:提交成功后列表没有刷新,日志里却又已经在成功回调里了。排查下来,根因是异步链里的执行顺序问题。

Flutter里Future.then的回调默认是放进微任务队列的,微任务又优先于事件循环里的其他任务执行。听起来是好事,但如果提交函数内部先用了await挂起,后面的代码顺序会受当前事件循环状态影响。我们的提交函数是“上传图片 → 提交评价内容 → 刷新列表”两段await串联,代码里如果有一处忘了await第二段就直接调setState,列表刷新就会发生在网络请求完成前,数据当然不会变。

想定位这个问题,最快的办法是给每个环节加一行debugPrint,打印当前耗时和执行标记。当时我看到日志顺序是“刷新列表”打在了“提交成功”前面,立刻就知道是异步链断了。结论是:多段异步链不要指望执行顺序天然正确,每个环节都显式await,并且把“刷新UI”的动作放到整个异步链的最后一个环节,不要写在函数开头。

5.4 评价列表的排序与分页加载

评价列表默认按时间倒序,但产品希望用户能切到“按评分最高”看。这个排序我建议直接甩给后端接口参数,而不是前端对已加载数据排序。原因很简单:分页场景下,前端排序只对已加载的几页有效,滑到第5页时顺序会和新数据合并产生错乱。正确做法是切换排序维度时重置分页游标,重新请求第一页数据。

分页加载还要处理“下拉刷新”和“上拉加载更多”的复用。OpenHarmony上长列表我用SliverList加ScrollController监听接近底部,触底就加载下一页。踩过一个注意点:不要在ScrollController回调里直接同步发起请求,最好包一层防抖,否则列表快速滚动时会连续触发多次加载,出现重复数据或者页码跳变。我的做法是加一个_isLoadingMore开关,加载期间直接return掉后续的触底回调。

6. 真机适配踩坑记录:桥接、PlatformView 与渲染引擎

6.1 MethodChannel和EventChannel在OpenHarmony上的差异

Flutter和原生层通信主要分两种:MethodChannel主动调用,返回Future;EventChannel由原生侧主动往Dart推流。在OpenHarmony侧,这两个Channel的实现并不完全一致。

MethodChannel基本能平滑迁移,传参和回传的序列化规则没变。但EventChannel在OpenHarmony上需要原生侧自己维护一个StreamHandler,并且每次Dart侧重新订阅时都要重新attach。否则会出现一种很诡异的现象:第一次进入页面能收到原生推送,退出后再进来就怎么都收不到。我当时排查了很久,日志全正常,就是没有任何事件进入Dart层,最后确认是原生侧没有处理“第二次订阅”的情况,只attach了第一次。

另外,Channel名称在OpenHarmony上必须全局唯一。原生同事曾把同一个channel name注册了两次,导致方法调用直接报PlatformException,而且报错信息非常模糊。那次排查是用最原始的办法:把所有Channel名打表列出来,逐一确认归属模块,才定位到重复注册。

6.2 嵌入原生视频时的PlatformView适配

剧本详情页后来加了一个“试玩视频”的能力,需要直接嵌一个原生播放器。Flutter侧的承载方式是PlatformView。Android上用AndroidView,OpenHarmony上要换成对应的组件容器,但这里有两个没写在官方文档里的坑。

第一个坑是混合合成。如果不显式开启混合合成开关,原生View会盖在Flutter UI上面,把评分组件、返回按钮全部挡住。现象很像层级错乱,但其实是合成模式没配对。在真机上看到“视频播放器整个糊上去”时,第一反应不要查业务代码,先确认合成开关。

第二个坑是手势冲突。视频播放器的触摸事件和详情页的滚动手势会互相抢,表现为手指在视频区域上下滑动时,页面不滚,视频反而暂停或弹出控制条。解法是在PlatformView上声明gestureRecognizers,把纵向拖拽手势明确交还给外层CustomScrollView,播放器只保留点按和横向手势。这个配置在Android上比较成熟,OpenHarmony上的参数名会略有出入,实现时多留一点调试时间。

6.3 Impeller与图片加载:两个容易被忽略的点

OpenHarmony上的Flutter引擎,至少在我们锁定的那个commit阶段,还不能直接开Impeller渲染后端。运行参数里加了--enable-impeller之后,详情页直接黑屏,纹理全部乱掉。排查到最后就是把这行参数去掉,一切恢复正常。这个问题不属于业务层能解决的范畴,属于平台适配进度限制,排期时要提前跟团队说清楚,免得大家以为是代码问题。

图片加载在OpenHarmony上也有一个高概率踩中的点:默认的HttpClient不会自动信任系统CA证书。详情页当时用CachedNetworkImageProvider加载HTTPS封面图,在Android模拟器上一直正常,一上OpenHarmony真机就白屏,等了半天也不出图。后来把网络库的日志打开才发现是TLS握手阶段被安全策略拦了。

解法是在工程配置里显式声明信任的证书来源,并把图片加载的httpClient替换成带自定义SecurityContext的IOClient。这个问题的隐蔽性在于,它不是“所有图片都失败”,而是“部分证书链不完整的图片失败”,看起来非常像偶发的网络抖动。

我在整个项目过程中维护了一份ohos_diff.md文档,专门记录“同样代码,在OpenHarmony上表现不同”的点。目前已经积累了几十条,这里摘几个典型:

模块现象根因与解法
图片加载HTTPS图片部分失败网络安全策略拦截,配置信任证书
EventChannel二次进入页面收不到推送StreamHandler重复注册,需重新attach
PlatformView原生Video盖住Flutter控件未开启混合合成模式
Tab切换滚动位置丢失缺AutomaticKeepAliveClientMixin
字体部分自定义字体不生效OpenHarmony字体Fallback规则不同

这份清单的价值不在于记录本身,而在于给后续接手的人一个快速定位入口。哪怕文档只有一屏长,也能帮团队少踩一半的坑。

最后想多说一句:跑Flutter for OpenHarmony这件事,真正的成本不是把UI画出来,而是搞清楚每个平台能力差异对你的业务意味着什么。评分、评价这类纯Dart组件,适配成本很低,可以放心复用;一旦涉及视频播放、网络请求、系统推送这类原生能力,就要提前查OpenHarmony的限制,留足调试排期。这套详情页和评价模块跑通之后,我们后续接其他页面明显快了很多,因为已经知道哪里有坑、哪里能复用同一套组件方案了。

返回列表