☰
鸿蒙混合开发实战:Flutter公告模块架构与优化全解析
2026/9/28 5:43:55 网站建设 项目流程

1. 为什么"享家社区"最终选择了Flutter做鸿蒙公告模块

1.1 项目场景与核心需求拆解

"享家社区"是一个面向智慧社区场景的APP,业主需要在这里查收物业公告、社区活动通知、停水停电提醒、业委会决议公示等。公告管理功能听起来简单,实际拆解下来并不轻松——它要在同一个页面里把不同公告类型(紧急通知、日常公告、活动报名)动态聚合,还要处理已读/未读状态、置顶排序、过期隐藏这些交互细节。更麻烦的是,公告内容通常不是纯文本,而是运营后台配置的富文本,包括图片、表格、甚至视频链接。

我先说结论:公告管理这类功能,技术上拼的不是高并发、高难度算法,而是细节体验。一个公告列表页,下拉刷新、上拉加载、分页、状态回写、缓存穿透、富文本渲染,哪一环做得粗糙都会直接拉低业主的使用评价。在鸿蒙生态里做这个模块,选型问题就变得更加敏感。

当时摆在我们面前的有三条路:全套ArkTS原生开发、WebView套壳、Flutter跨端方案。考虑到"享家社区"本身有Android和iOS版本,团队已经积累了一套Flutter代码库,再为鸿蒙单独维护一套ArkTS明显不划算。WebView套壳虽然能快速上线,但体验、性能都达不到预期,尤其在低端鸿蒙设备上首屏白屏问题很难根治。最终我们决定走Flutter + 鸿蒙原生混合方案——业务层用Flutter承载,系统级能力通过鸿蒙原生平台通道补齐。

1.2 HarmonyOS上跑Flutter,环境适配的三个大坑

先说环境。鸿蒙生态跑Flutter已经不是新鲜事,OpenHarmony SIG团队维护的flutter_flutter分支一直在跟进新版适配,但如果你直接用官方Flutter SDK去做鸿蒙打包,基本会卡死在环境检测。

第一个坑是Flutter SDK版本与鸿蒙编译链的匹配问题。我们初期用的是Flutter 3.16版本的官方SDK,接入鸿蒙工程时编译器直接抛出了类似"the current configured Flutter SDK is not known to be fully supported"的警告。这个提示不是闹着玩的,它意味着当前SDK没有通过鸿蒙侧的兼容性验证。解决办法是切换到OpenHarmony SIG维护的flutter_flutter分支,并锁定到与鸿蒙SDK版本配套的tag上。我们最后用的是基于Flutter 3.22的分支版本,配合HarmonyOS SDK 5.x才稳定下来。

第二个坑是Gradle插件和Flutter插件的配置方式。鸿蒙工程使用DevEco Studio构建,Gradle配置和Android工程的差异比想象中大。构建时遇到过一个非常典型的报错——"you are applying Flutter's main Gradle plugin imperatively using the apply script",根本原因是在settings.gradle里手动apply了Flutter的gradle插件脚本,而新版Flutter要求改用插件声明式应用机制。修正方式是在settings.gradle中使用pluginManagement配置Flutter插件仓库,然后通过plugin id方式引入,而不是直接apply script。

第三个坑是三方原生依赖的冲突。Flutter插件生态里很多插件默认只有Android和iOS实现,在鸿蒙工程里编译时会出现MissingPluginException或直接链接失败。我们公告模块中用到的路径缓存类插件,在鸿蒙上就没有现成实现,最后只能自己写一个基于鸿蒙AVLibraries的插件代理。这个我在后面章节详细说。

注意:如果你也想在鸿蒙上跑Flutter,开工前一定先去检查OpenHarmony-SIG的flutter_flutter仓库,确认当前版本对鸿蒙NEXT的支持状态,别拿官方SDK硬碰。

1.3 框架分工:Flutter负责界面,鸿蒙负责系统能力

公告管理模块的整体架构是这样的:Flutter侧负责公告列表页、公告详情页、未读红点逻辑、本地缓存,这些是纯UI和业务逻辑;鸿蒙侧负责推送注册、角标、系统通知栏、网络状态监听等系统级能力。两侧通过MethodChannel、EventChannel和BasicMessageChannel通信。

我见过不少团队把跨端方案做成了"Flutter套WebView"或者"鸿蒙套FlutterFragment",架构混乱不说,维护成本还高。我们的原则是:能用Dart写的业务绝不用原生重写,必须用原生的才开平台通道。公告的富文本内容解析、列表渲染、状态管理全部在Dart层完成;只有通知栏点击跳转、角标更新这类系统能力才通过鸿蒙侧暴露的通道接口调用。

2. 公告模块的数据模型与接口层设计

2.1 公告模型怎么建,才能撑起多种业务场景

公告不是一张简单的"标题+正文"表,尤其是社区场景下的公告,类型差异很大。紧急通知需要强提醒和显眼的视觉标识;活动公告需要关联报名入口和截止时间;日常公告则需要按时间流排序和高效的已读回收。

我设计的Notice模型长这样(简化版):

class Notice { final String id; final String title; final String summary; final String content; // 富文本HTML final NoticeType type; // 枚举:emergency/activity/daily/system final String coverUrl; // 封面图 final List<String> imageUrls; // 图文公告用 final String videoUrl; // 可选 final int publishTimestamp; // 发布时间 final int expireTimestamp; // 过期时间,0表示永不过期 final bool isPinned; // 是否置顶 final bool isRead; // 本地已读状态 final String actionUrl; // 点击跳转链接,可能为空 final Map<String, dynamic> extra; // 扩展字段,活动公告放报名地址、截止时间等 }

这个模型有几个决策点值得展开。

第一个决策点是type字段用枚举而不是直接用String。运营后台可能会新增公告类型,枚举在Dart里扩展需要改代码,看似不够灵活,但换来的是前端可以针对每种类型做差异化UI和交互逻辑。比如紧急公告需要红色标签和震动提醒,活动公告要显示报名按钮,这个用String写if判断容易失控。

第二个决策点是expireTimestamp单独拎出来。很多公告功能做得粗糙,直接把过期公告从列表里过滤掉就完事。但真实场景中,过期公告可能还要能在"历史公告"里查到,而且置顶公告过期后要自动取消置顶而不是直接消失。所以过期时间单独管理,列表查询时SQL条件里加一个expireTimestamp > now的判断即可。

第三个决策点是isRead字段设计成本地字段,而不是服务端字段。这里我踩过坑——早期版本把已读状态放服务端,每次进列表页都要拉一次已读ID列表,接口压力大不说,离线状态下没法判断已读状态,体验很糟糕。后来改成服务端只下发公告数据,已读状态存本地Cache,通过公告ID做本地映射。

2.2 接口层封装:列表和详情为什么要分开

公告管理模块的接口设计我采用"列表接口轻、详情接口全"的策略。列表接口只返回当前页公告的id、title、summary、type、publishTime、isPinned等摘要字段,不返回content正文。详情接口根据公告id返回完整内容,包括富文本HTML、图片URL列表、附件信息等。

为什么这么拆?因为列表页滑动性能对数据量很敏感,如果每个列表项都塞一份完整HTML,页面加载速度和内存占用都会爆。而且公告列表通常还要做本地缓存,缓存的也是摘要数据。详情内容虽然也可能缓存,但缓存策略可以更激进——只缓存用户真正打开过的公告详情。

接口层用Dio封装,统一处理超时、重试、token刷新。公告列表接口我们做了个优化,支持增量拉取:客户端本地缓存里存有上一次拉取的最新公告ID,下次请求时带上这个ID,服务端只返回比这个ID更新的公告。这样下拉刷新时的网络消耗和数据解析成本都大幅降低。

class NoticeApi { static Future<NoticePageResult> fetchNoticePage({ required int page, int pageSize = 20, String? afterId, // 增量拉取用 NoticeType? type, // 按类型筛选,空为全部 }) async { final resp = await DioManager.shared.get( '/api/community/notices', queryParameters: { 'page': page, 'pageSize': pageSize, 'afterId': afterId ?? '', if (type != null) 'type': type.name, }, ); // 解析并返回NoticePageResult } static Future<Notice> fetchNoticeDetail(String noticeId) async { final resp = await DioManager.shared.get('/api/community/notices/$noticeId'); return Notice.fromJson(resp.data); } }

2.3 状态管理:Provider还是Bloc,我的选型逻辑

公告模块的状态管理,我在项目初期试过Provider,后来部分页面换成了Bloc。不是说Provider不行,而是公告模块的状态流转确实比较复杂——列表页有加载中、加载成功、加载失败、空数据、下拉刷新、上拉加载更多六种状态,详情的已读回写还涉及异步操作。用Provider写这些状态切换虽然也能实现,但状态一多,代码就变得散乱。

Bloc把状态流转集中管理,事件驱动的方式让状态变更路径变得清晰。特别是已读状态回写这个动作,需要先更新本地缓存、再调接口上报服务端、失败还要回滚——用Bloc的event-stream模式处理起来逻辑链条很清晰。

最终公告列表页用的是flutter_bloc,每个关键动作都对应一个event:

sealed class NoticeEvent {} class FetchNotices extends NoticeEvent {} // 首次加载 / 下拉刷新 class LoadMoreNotices extends NoticeEvent {} // 上拉加载更多 class MarkRead extends NoticeEvent { // 已读回写 final String noticeId; MarkRead(this.noticeId); } class FilterByType extends NoticeEvent { // 类型筛选 final NoticeType type; }

对应的状态:

sealed class NoticeState {} class NoticeInitial extends NoticeState {} class NoticeLoading extends NoticeState {} class NoticeLoaded extends NoticeState { final List<Notice> notices; final bool hasMore; final bool isRefreshing; final String? errorMessage; }

这个设计的好处是,每个状态都是不可变的,UI只根据当前状态渲染。有了Bloc的加持,列表页的"下拉刷新"和"加载更多"可以走同一个FetchNotices事件的不同参数来分派,配合hasMore字段控制是否还有下一页,代码结构非常清晰。

3. 公告列表页的核心实现细节

3.1 列表页UI设计:视觉层级决定体验上限

公告列表的UI设计遵循"一眼就能看出哪些公告重要"的原则。视觉层级从高到低依次是:紧急通知 > 活动公告 > 置顶日常公告 > 普通公告。

紧急通知用红色标签+红色左边框,标题加粗;活动公告用蓝色标签,如果还在报名期内会显示报名截止倒计时;置顶公告右上角会有"置顶"角标。列表项的左下角显示公告类型图标和发布时间,右下角显示已读状态图标——未读显示绿点/红点,已读后变灰。

列表项我直接用自定义的NoticeListItem组件,没有用第三方列表库。因为公告列表项的布局和交互都比较插件化,自绘可控性最高。用ListView.builder+AutomaticKeepAliveClientMixin保持滚动位置和已读状态的平滑更新。

其中一个容易被忽略的细节是置顶公告的吸顶效果。产品经理希望置顶公告始终固定在列表最上方,哪怕用户往下滑,置顶区也要保持在可视区域顶部。这个效果的实现方案是列表第一个item固定为置顶公告区,外面包一层CustomScrollView的SliverPersistentHeader。具体来说,把置顶公告做成SliverPersistentHeader的header内容,钉在视图顶部;下面滚动区域是普通公告列表。这样既实现了吸顶,又不会影响滚动性能。

另外,公告列表页有一个"未读优先"的排序规则:未读公告排在已读前面(置顶公告永远在最高层)。这个排序逻辑放在Bloc里做,而不是后端做,因为已读状态在本地,后端不知道哪些是已读的。每次从本地缓存读取已读ID集合后,把当前页的公告分组成已读/未读两组,未读组在前,然后拼接渲染。这样"未读优先"交互的一致性可以完全本地保证。

3.2 分页加载与滚动性能优化

公告列表的分页加载是移动端开发的基础功,但优化空间往往藏在细节里。

先定义分页参数:page从1开始,pageSize默认20。上拉加载更多时有几种边界情况要处理——下拉刷新时重置页码、加载更多时如果触底且仍有下一页才继续请求、下拉刷新与加载更多互斥禁止并发请求。

滚动性能上,我们用列表项懒加载的方式处理图片:coverUrl缩略图通过cached_network_image配合width: 120, height: 90的合理尺寸加载,避免加载原图造成的卡顿。富文本缩略图摘要的生成在服务端完成,客户端只渲染纯文本摘要。

还有一个在Flutter里容易踩坑的点:ListView.builder的itemExtent。如果你的列表项高度是固定的,应该手动设置itemExtent,而不是靠列表项内容自适应。公告列表项每项高度是固定的(因为摘要文本限制最大两行,图片高度固定),设置itemExtent: 112后,列表的滚动性能会明显提升,因为Flutter不需要在滚动过程中动态测量item高度。

另外,公告列表页做了内存优化:列表页只保留当前渲染的公告项数据,滚动离开的item会被自动回收。已读ID集合用Set<String>存储,查询复杂度O(1)。

3.3 已读状态的交互链路:点击、回写、缓存三同步

已读状态是公告模块最核心的交互逻辑。整个链路是:用户点击列表项 -> 详情页加载 -> 标记该公告为已读 -> 返回列表页时已读状态实时刷新 -> 已读状态持久化到本地缓存 -> 异步回写服务端。

这里我详细说实现方案,因为这部分坑最多。

第一步:点击跳转前先延迟标记已读。

用户点击列表项后,不要立即跳转,先调Bloc的MarkRead事件,把本地已读状态更新掉,同时把公告ID写入SharedPreferences中的已读列表。然后跳转到详情页。这样做的原因是,如果用户进详情页后马上退出,已读状态也必须记录上,否则下次进来还是未读,体验很割裂。

Future<void> _handleTap(Notice notice) async { if (!notice.isRead) { context.read<NoticeBloc>().add(MarkRead(notice.id)); } Navigator.push(...); // 跳转详情页 }

第二步:详情页打开后,页面上标记已读。

详情页的initState里调用MarkRead事件,确保详情页打开时状态是最新的。这一步和第一步看起来重复,但实际场景中用户可能从其他入口(如推送通知栏)直接打开详情页,此时列表页没有经过点击跳转流程,所以详情页自身也要负责标记已读。

第三步:返回列表页时,根据缓存中的已读ID集合重新渲染。

列表页返回时会触发Bloc重新计算已读/未读分组,这一步用的是本地缓存数据,不会发网络请求。

第四步:异步回写服务端。

已读状态回写接口是异步的,失败不阻塞UI。如果回写失败,本地状态保留已读,但下次打开列表页时会重新尝试回写。这里有一个细节:回写接口应该是批量接口,不要每次点击都请求一次,而是在本地维护一个待回写已读ID队列,每5秒批量上报一次,减少服务端压力。

注意:已读状态的"用户手动点击"和"自动标记已读"要区分。公告详情页滑到底部时才标记已读,这个逻辑本来是产品需求,但我们发现如果用户只看了一半详情就退出,手动标记已读会造成"假已读"——用户根本没看完。所以最终方案是:列表页点击不算已读,只有详情页完整访问后(或者滑到底部)才算已读。这个细节如果不注意,很容易被测试和用户反馈打回来。

4. 公告详情页与富文本渲染的选型与踩坑

4.1 富文本渲染方案对比:WebView、flutter_html、还是自绘

公告详情页的核心难点是富文本渲染。运营后台编辑的公告内容是HTML格式,包括段落、图片、表格、视频、超链接等。在Flutter里渲染富文本,有三条路线可以走。

路线一:WebView加载HTML。这是最稳妥的方案,HTML渲染交给WebView内核,兼容性最好。但缺点是:加载速度慢(WebView初始化有耗时)、内存占用高、和Flutter原生交互需要桥接层。"享家社区"的公告详情页在这个方案下首屏白屏时间大约1.5秒,不过可以通过在WebView容器上覆盖一个原生Flutter骨架屏来缓解体验问题。

路线二:flutter_html组件解析HTML。flutter_html会把HTML标签映射成Flutter组件,纯Dart实现,不需要WebView。优点是加载快,缺点是复杂HTML兼容性差——特别是<table>表格、<video>标签、内联CSS的渲染效果经常出问题。社区版做基础公告足够,稍微复杂点的HTML布局就会解析得歪七扭八。

路线三:服务端下发结构化数据,客户端自绘。这是最彻底的方案,服务端把HTML转换成JSON格式的"富文本块"数组:[{type: 'paragraph', text: '...'}, {type: 'image', url: '...'}, {type: 'table', rows: [...]}, ...],客户端用一个自绘的渲染引擎逐块渲染。优点是可定制性强、性能好、离线缓存也方便;缺点是服务端做HTML解析需要投入开发成本,初期运营后台的编辑器输出格式也需要规范统一。

我们最终选择了服务端下发结构化数据 + 客户端自绘的方案,理由是公告内容的展示形式相对固定(段落+图片+表格是最主要的三种),不需要处理任意站点的HTML。而且这个方案彻底绕开了WebView,详情页首屏速度做到了300毫秒以内。

4.2 自绘富文本渲染引擎的架构

自绘渲染引擎的核心是一个NoticeContentRenderer组件,接收一个List<NoticeContentBlock>数据,按顺序渲染成Column里的子组件。

enum NoticeBlockType { paragraph, image, heading, table, video, divider, quote } class NoticeContentBlock { final NoticeBlockType type; final String? text; // paragraph / heading / quote final String? imageUrl; // image final String? videoUrl; // video final List<List<String>>? table; // table }

每个block对应一个渲染widget。段落用Text.rich支持行内加粗、斜体、链接;图片用CachedNetworkImage,做等比缩放和懒加载;表格用Table组件封装,列数多时可以横向滚动;视频用视频组件库加载;引用块用带左边框的容器样式化渲染。

这个方案另外一个好处是离线缓存很自然:结构化数据可以直接序列化成JSON缓存到本地,变明文文本加载,比缓存HTML再解析干净得多。

不过自绘方案也有一个代价——运营后台需要保证发下来的结构化数据是合法的。我们在服务端加了校验层,如果HTML解析失败,会降级成纯文本模式下发,保证公告能正常展示。

4.3 图片加载的三种策略,以及大图时的内存管理

公告富文本里图片是最容易出问题的部分。图片比例不对、原图过大、网络慢时加载失败,都会影响阅读体验。

我们的图片加载策略是三级降级:

  1. 缩略图优先加载:列表页和详情页首屏的图片,都加载服务端生成的缩略图(宽度最大1200px),一方面减少网络传输,另一方面减少内存开销。
  2. 原图按需加载:用户点击图片可查看原图,此时才加载原始分辨率图片,并且用InteractiveViewer包一层支持双指缩放。原图不做一次性加载,而是用渐进式JPEG加载,先显示模糊图再显示清晰图。
  3. 加载失败重试与占位图:图片加载失败时展示占位图,点击可重试,重试走一个新请求。

内存管理上,我在Flutter启动时统一通过PaintingBinding.instance.imageCache设置maximumSizeBytes,限制为磁盘Cache大小的五分之一。大图加载完不用的立即evict释放。

注意:HarmonyOS上Flutter的图片解码内存管理跟Android有差异,部分鸿蒙设备上的图片解码内核和Android不完全一样,测试时一定要用低内存配置的鸿蒙设备跑一轮图片列表滚动,看看是否有内存增长不回收的问题。

5. 新公告提醒与本地缓存机制的实现

5.1 红点与角标:新公告提醒的关键路径

"享家社区"的公告模块需要支持两类新公告提醒:APP内部的红点提醒和桌面图标角标提醒。红点是Flutter侧完成的,角标需要鸿蒙原生侧配合。

红点逻辑:首页公告入口处显示一个未读公告数量红点,这个数字来源于本地已读ID集合的差集——所有未过期公告ID总数减去已读ID数量。因为列表页每次拉取公告时会把新公告ID写入一个latestNoticeIds缓存,所以红点数字可以本地实时计算,不需要额外请求接口。

角标逻辑:桌面角标必须用鸿蒙原生能力,Flutter侧收到新公告推送后,通过MethodChannel调鸿蒙原生接口,由原生更新桌面应用角标。这里的关键点是角标数字的统计口径要和红点保持统一。因为Flutter侧的缓存可能和原生侧不一致,所以每次更新角标时,Flutter侧把计算好的未读数量直接传给原生,原生侧只负责显示,不做二次计算。

class NotificationHelper { static const _channel = MethodChannel('com.xiangjia.community/badge'); static Future<void> updateBadge(int count) async { try { await _channel.invokeMethod('updateBadge', {'count': count}); } on PlatformException catch (e) { // 角标更新失败不阻塞业务,做降级日志处理 debugPrint('updateBadge failed: ${e.message}'); } } }

5.2 离线缓存策略:列表缓存与详情缓存的分级管理

公告模块的缓存设计分成三层。

第一层是已读ID集合缓存,用SharedPreferences存一份Set<String>序列化后的字符串。因为已读ID集合的更新频率最高(几乎每次点击公告都会更新),所以要做高频写缓存。SharedPreferences本身支持高频写,但是要注意序列化方式——我用的是jsonEncode(ids.toList()),每次更新时重新encode再写入,公告上万条时会有轻微的IO开销,但实测可接受。

第二层是公告列表缓存,用文件缓存,存的是最近一页的公告摘要JSON。这一层的作用是用户打开APP时,即使网络不可用,也能先显示上一次浏览的公告列表,减少白屏等待。缓存过期时间设为24小时,过了有效期就重新拉网络数据。

第三层是公告详情缓存,按公告ID做键值缓存。详情内容因为包含结构化富文本数据,所以也用文件缓存。详情缓存不设置自动过期,但只保留最近阅读的50条,超过50条后按时间淘汰最旧的一条。

缓存策略里有一个容易被忽略的坑:列表缓存和已读状态的同步。如果列表缓存里存了公告数据,但已读ID集合更新后没有同步刷新列表数据,用户看到的列表还是旧的已读状态。所以每次缓存刷新时,读出的公告数据要和最新的已读ID集合做一次状态合并,合并逻辑我放在缓存读取的repository层统一处理。

5.3 消息推送机制的接入与落地

新公告提醒的推送链路是基于鸿蒙推送服务实现的。鸿蒙侧注册Push Token后把Token上报到服务端;服务端在有新公告发布时,通过Push服务向目标设备推送一条透传消息。

Push消息的payload里包含公告ID和公告类型,Flutter侧收到透传消息后先更新本地红点状态,再决定是否需要弹通知栏通知。通知栏通知的策略是:如果当前用户正在公告列表页,则不弹通知栏,只需要更新列表数据和红点即可;如果用户在其他页面,则通过鸿蒙原生发一条通知栏消息。

这里有一个协调点:Flutter侧接收Push消息的通道怎么和鸿蒙原生通信。我在鸿蒙侧写了一个消息接收代理,通过EventChannel往Flutter侧发送消息。EventChannel的好处是消息是双向的——原生可以主动往Flutter推送消息,Flutter也能监听。我在Flutter侧用EventChannel.receiveBroadcastStream().listen去订阅。

注意:EventChannel的消息内容是JSON字符串,接收端要做异常保护。Push消息到达时如果Flutter侧还没初始化完成(比如冷启动过程中),消息会丢失。处理方式是在原生侧维护一个离线消息队列,等Flutter侧注册了EventChannel监听之后再补发。这个坑不提前做好,会经常漏掉公告提醒。

6. 与鸿蒙原生能力的协作:平台通道与事件通道实践

6.1 MethodChannel、EventChannel、BasicMessageChannel的职责划分

在鸿蒙Flutter混合开发中,Dart侧和原生侧的通信就靠三种Channel。很多初学者会把三种通道混用,结果代码一团乱麻。我给出的划分原则很简单:

  • MethodChannel:一对一的调用,适合请求-响应模型,比如查询设备状态、更新角标、拉起系统分享。
  • EventChannel:原生主动向Flutter推送数据流,比如Push消息订阅、网络状态监听、系统通知栏事件。
  • BasicMessageChannel:双向通信,适合传递比较自由的消息结构,比如Flutter和原生互相传递JSON数据结构,不做严格的方法签名约束。

在公告模块里,我们三者都用到了:角标更新走MethodChannel;Push消息透传走EventChannel;公告数据跨端同步(比如原生侧从通知栏点击跳转后需要告知Flutter刷列表)走BasicMessageChannel。

6.2 通知栏点击跳转到公告详情的跨端协作

通知栏点击跳转是公告模块和原生系统交互最频繁的场景。用户收到新公告通知后,点击通知栏条目,系统拉起APP并需要直接定位到对应公告详情页。

这套链路的实现流程是:

  1. 鸿蒙原生侧在通知栏点击事件中取出公告ID,通过BasicMessageChannel发送{event: 'openNotice', noticeId: 'xxx'}给Flutter侧。
  2. Flutter侧注册一个全局消息监听器,收到openNotice事件后,通过导航栈找到当前页面。如果当前是首页,直接用Navigator.push打开详情页;如果当前详情页正好是同一公告ID,则不需要重复跳转。
  3. 如果APP是冷启动状态——通知栏点击发生时Flutter还没初始化完成,事件会先进入原生侧的离线消息队列,等Flutter侧注册完监听后补发。

这套链路里最麻烦的是冷启动的导航栈状态管理。APP冷启动时默认停在首页,但如果用户点击的是通知栏进入,产品期望是直接到达详情页。我在全局导航配置里加了一个descriptor机制:Flutter侧启动时检查是否有pending的开详情事件,有就先push详情页,没有就走首页。这个逻辑放在runApp后的第一个帧回调里做,需要用WidgetsBinding.instance.addPostFrameCallback确保导航栈已经构建完毕。

6.3 原生和Flutter两侧的状态同步策略

跨端状态同步是混合开发最磨人的问题。公告模块涉及的状态有:红点数量、角标数量、已读ID集合、当前页面路由。这些状态在Flutter侧和鸿蒙侧都可能变化,如果不做同步,会出现"Flutter侧已读清零了,角标还挂着5"这类不一致问题。

我的做法是以Flutter侧为状态中枢。所有业务状态(已读ID、红点数量、角标数量)的计算和存储都在Flutter侧完成,鸿蒙侧只是被动的展示器和事件源。鸿蒙侧不会主动修改业务状态,只负责把用户操作(比如通知栏点击)通过EventChannel/MessageChannel透传给Flutter;Flutter侧计算完角标数字后,再通过MethodChannel告诉鸿蒙侧"角标该显示数字X了"。

这个"单侧状态中枢"的原则,避免了竞态条件。比如用户在Flutter侧清空了全部已读,角标更新为0,同时鸿蒙侧又在后台收到一条推送,把角标设置为5——如果不做同步策略,角标就会错乱。以Flutter侧为中枢后,推送到达鸿蒙侧的那一刹那也会先通过EventChannel上报给Flutter侧,Flutter侧重新计算后再调原生更新角标,最终展示结果始终是全局状态的一致结果。

7. 公告模块上线前的检查清单与经验收尾

7.1 我在真机上踩过的那几个性能坑

公告模块开发完,上真机测试时暴露了一堆开发环境里发现不了的问题。我挑几个典型的说说。

第一个是列表滚动时的掉帧。开发时用的是模拟器,看不出问题。上真机后发现公告列表快速滑动时帧率掉到40fps以下。排查后发现是已读状态更新时,每个列表项都触发了重建——因为MarkRead事件处理后会把整个NoticeLoaded状态里的notices列表替换成新的列表,导致所有item都重新build了。解决办法是给NoticeListItem包一层RepaintBoundary,并且在Bloc的equatable比较里让列表项数据尽量只在受影响item的引用变化时才触发重建。如果你用的是ListView.builder,加上itemExtent固定高度也能减少不必要的布局计算。

第二个是详情页图片加载的内存峰值。公告详情页一次加载多张大图,内存峰值能到700MB,低端鸿蒙设备直接闪退。优化方案是图片加载前统一做预处理——先用Image.resolve获取图片宽度高度,如果超过屏幕宽度的1.5倍就先压缩到目标宽度再渲染。配合图片缓存上限的设置,内存峰值降到了300MB以内。

第三个是WebView残留导致的内存泄漏。我们在某些历史版本用了WebView加载公告HTML,退出详情页后WebView的进程残留导致内存涨不回去。换到自绘渲染引擎之后问题彻底消失,这也算从侧面验证了当时选型决定的正确性。

7.2 发布前的检查项,照着列就行

公告模块要发版时,我会把下面这份清单过一遍,省得上一线踩完坑又踩一遍:

  • 已读状态在离线状态下能正常标记,并且网络恢复后能批量回写。
  • 列表页下拉刷新、上拉加载、置顶公告吸顶、类型筛选四类操作组合起来不出现状态错乱。
  • 冷启动状态下点通知栏能直接打开对应公告详情页,而不是停在首页。
  • 公告详情页图片在多分辨率设备上等比缩放正常,大图内存峰值在可接受范围。
  • 角标数字和红点数字始终一致,不会出现Flutter侧清零后角标残留。
  • 缓存清理(手动清缓存或自动过期)后,App能正常重新拉取公告数据。
  • Push消息通知栏点击,在Flutter侧页面未初始化时不会丢失。

7.3 关于"享家社区"公告模块后续的扩展思路

公告模块做到这个程度,算是把基础打扎实了。后续有几件事值得优先做:

第一,公告的个性化推荐。不同楼栋、不同户型的业主收到的公告应该是有差异的,目前的公告服务端还没有做人群定向,等数据积累到一定量级,可以在公告模型里增加targetScope字段,通过服务端下发人群过滤规则。

第二,公告的已读统计。运营侧需要知道每一条公告的阅读率,这个数据目前只有服务端有已读回写记录,但还没有可视化报表。可以在Flutter侧增加一个数据埋点,把公告曝光、点击、阅读完成这些行为全链路上报。

第三,公告模块的组件化沉淀。"享家社区"如果是多APP矩阵运营,公告模块完全可以抽成一个独立的Flutter组件库,通过pub私有仓库分发,不同APP直接引用,省去重复开发。

我在做这个模块的过程中,最大的体会是:跨端方案最怕的不是技术难,而是边界不清晰。谁负责UI、谁负责系统能力、状态以哪侧为准,一开始就定好原则,后面所有开发都会顺。Flutter在鸿蒙生态里成熟度虽然在爬坡期,但公告这类功能性模块确实是可以吃螃蟹的——收益立竿见影,坑也都能一个个填平。希望这篇拆解能帮到正在做鸿蒙+Flutter混合开发的朋友。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询