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 flutters 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玩家点选的短标签比如“新手友好”“重推理”“高自由”这些标签会聚合到剧本详情页的标签云里。contentimages长评文字和玩家实拍图。这里有一个很关键的设计决定综合评分是后端算不是前端算。一开始我觉得前端拿到四个维度分数自己乘权重加起来很简单但产品后来要调权重比如“主持人专业度权重从30%调到35%”。如果权重写在前端每次调整都要发版这完全不能接受。所以详情接口直接返回summaryScore前端只当展示字段用。2.3 接口层协议设计与Mock数据切换跑OpenHarmony适配期间我们还顺手做了一件收益很高的事把所有接口能力抽象成Repository并实现Remote和Mock两套数据源。定义大概长这样abstract class ScriptRepository { FutureScriptDetail fetchScriptDetail(String scriptId); FutureListReviewItem fetchReviews(String scriptId, {int page}); Futurebool submitReview(String scriptId, ReviewRequest request); }线上环境用RemoteScriptRepository走HTTP开发环境和OpenHarmony模拟器上用MockScriptRepository直接读本地assets里的JSON。切换靠一个编译期flag不靠运行时判断。这个设计救了大忙。前期后端接口还没完全定稿UI却不能等于是Mock数据先把页面交互跑通等后端ready后一行配置切过去。而且在OpenHarmony真机上跑联调时如果网络策略或证书有问题至少还能用Mock数据判断问题是出在页面还是出在网络层排查效率高很多。3. 详情页UI落地的关键取舍滚动容器、骨架屏与首帧加载3.1 为什么最终选了CustomScrollView而不是ListViewColumn详情页最初版本结构很简单外层放一个ListView内容是一个巨大的Column顶上是封面图中间是图文介绍下面再嵌一个评价列表的ListView。跑起来之后问题立刻暴露页面的滚动是分裂的评价列表能自己滚但整个页面头部不会跟着收起用户滑动体验非常割裂。后来把结构改成标准的CustomScrollView组合最外层用CustomScrollView提供整页滚动。顶部用SliverAppBar包封面配合FlexibleSpaceBar做伸缩折叠。图文区、标签区、评分概览用SliverToBoxAdapter放不定高的内容。评价列表用SliverList实现长列表懒加载。这个方案的好处是整页滚动天然联动不会出现双List互相抢手势的问题。代价是代码组织比ListViewColumn复杂一点需要把一个页面拆成若干个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.52.8到3.2显示3.0。公式是(value * 2).roundToDouble() / 2。这个取整要在交互手势时实时计算不能在最后提交时才做否则用户拖动过程中看到的评分会跳得很奇怪。4.3 点按和滑动的交互细节光能画还不行交互才是评分组件体验的关键。我同时支持点按和横向滑动点按时通过GestureDetector的onTapUp拿到点击点相对于组件宽度的偏移除以单颗星宽度得到评分值然后做半星取整。滑动时用onHorizontalDragUpdate持续更新评分。这里要处理一个边界问题手指拖出组件左边界时评分不能变成负数拖出右边界时要自动封顶到满分。手势冲突方面评分组件放在评价页的ListView里横向拖动和纵向滚动理论上不冲突但我还是遇到过一次快速横向拂动被父级吸收的情况。解决方式是在GestureDetector外面包一个竞技场声明HorizontalDragGestureRecognizer优先保证评分滑动不被列表滚动抢走。最后是无障碍给评分控件包一层Semantics读屏时可以读出“当前评分3.5星总评5星”。这一点产品开始没提是测试同学在无障碍专项里发现的。既然做了自研控件这些系统能力还是顺手补齐比较好成本很低体验收益却很明显。5. 评价列表与提交流程异步时序、跨页刷新与组件通信5.1 提交评价的完整链路设计“我要评价”是一个独立页面用户流程分三步给四个维度打分并且勾选标签。写文字评论。上传实拍图片可选。提交评价的按钮在表单校验完成后才可点击。校验逻辑不算复杂综合评分必须有值并且文字评论和图片至少二选一避免有人直接空评价刷屏。提交链路里最容易出错的是图片上传顺序。我采用的是“先传图、后提交评论”先把图片通过MultipartRequest传到CDN拿到URL数组后再把这些URL连同评论内容一起提交给评价接口。这样设计的原因是如果评论先提交成功但图片上传失败就会出现一条没有图的评价用户还得走申诉流程。反过来图片先传成功、评论提交失败只会留几张孤儿图片在CDN下次用户重新提交时覆盖掉就行影响面小得多。整个流程要防重复提交。我用一个_submitting布尔值控制请求期间按钮置灰加loading直到流程完全结束才恢复。不这么做手快的用户连点两次会重复提交评价后端即使做了幂等前端体验也已经是错的。5.2 跨页面通信发布后详情页怎么刷新评价页和详情页的通信我们没有引入EventBus这种全局方案用的是Navigator返回值。详情页push评价页等它pop回来后判断返回值final result await Navigator.pushbool( 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主动调用返回FutureEventChannel由原生侧主动往Dart推流。在OpenHarmony侧这两个Channel的实现并不完全一致。MethodChannel基本能平滑迁移传参和回传的序列化规则没变。但EventChannel在OpenHarmony上需要原生侧自己维护一个StreamHandler并且每次Dart侧重新订阅时都要重新attach。否则会出现一种很诡异的现象第一次进入页面能收到原生推送退出后再进来就怎么都收不到。我当时排查了很久日志全正常就是没有任何事件进入Dart层最后确认是原生侧没有处理“第二次订阅”的情况只attach了第一次。另外Channel名称在OpenHarmony上必须全局唯一。原生同事曾把同一个channel name注册了两次导致方法调用直接报PlatformException而且报错信息非常模糊。那次排查是用最原始的办法把所有Channel名打表列出来逐一确认归属模块才定位到重复注册。6.2 嵌入原生视频时的PlatformView适配剧本详情页后来加了一个“试玩视频”的能力需要直接嵌一个原生播放器。Flutter侧的承载方式是PlatformView。Android上用AndroidViewOpenHarmony上要换成对应的组件容器但这里有两个没写在官方文档里的坑。第一个坑是混合合成。如果不显式开启混合合成开关原生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重复注册需重新attachPlatformView原生Video盖住Flutter控件未开启混合合成模式Tab切换滚动位置丢失缺AutomaticKeepAliveClientMixin字体部分自定义字体不生效OpenHarmony字体Fallback规则不同这份清单的价值不在于记录本身而在于给后续接手的人一个快速定位入口。哪怕文档只有一屏长也能帮团队少踩一半的坑。最后想多说一句跑Flutter for OpenHarmony这件事真正的成本不是把UI画出来而是搞清楚每个平台能力差异对你的业务意味着什么。评分、评价这类纯Dart组件适配成本很低可以放心复用一旦涉及视频播放、网络请求、系统推送这类原生能力就要提前查OpenHarmony的限制留足调试排期。这套详情页和评价模块跑通之后我们后续接其他页面明显快了很多因为已经知道哪里有坑、哪里能复用同一套组件方案了。
返回列表