1. 为什么是 eventsource:实时推送场景下的选型复盘
先说个背景。我们团队之前做了一款跨端应用,Flutter 统一了 iOS 和 Android 的业务代码,服务端下发的实时数据走的是 SSE(Server-Sent Events)。集成的是 Flutter 生态里的 eventsource 三方库,线上跑了快一年,一直很稳。然后时间点一到,产品要上鸿蒙设备,我拿到鸿蒙样机后第一件事就是把整个 Flutter 工程往鸿蒙 Flutter 环境里搬。搬完后编译过了、页面渲染了,但一测实时推送,直接哑火——连接建立了,事件却收不到。
于是就有了这篇东西:把 Flutter 的 eventsource 库做鸿蒙化适配,从依赖体检、网络层替换、SSE 协议兼容,到连接生命周期管理和线上治理,完整走一遍。如果你也在做鸿蒙化改造,尤其是实时推送链路,这篇文章的踩坑记录和验证清单应该能帮你节省至少一周的调试时间。
先说选型问题。为什么当初在 Flutter 里选 SSE 而不是 WebSocket?这是很多人的第一反应。实时推送嘛,大家下意识就是 WebSocket。但 SSE 有几个 WebSocket 比不了的优势:
- 基于普通 HTTP,不用额外协议握手,服务端实现成本低,负载均衡、代理、日志体系全都能直接复用。
- 自动重连是协议内建的,
Last-Event-ID机制天然支持断点续传,WebSocket 的重连要自己造轮子。 - 单向推送足够覆盖大部分场景。我们当时的需求是行情变动、公告、订单状态这种"服务端往下推,客户端只收不传"的场景,双向通道里那另外一半能力根本用不上。
当然 WebSocket 也不是不存在优点,但 SSE 的轻量、可靠和天然 HTTP 友好让它在我们的场景里成了更优解。Flutter 这边社区也有现成的 eventsource 实现,Dart 层直接解析流式响应,不需要平台原生插件,跨端一致性天然就有优势。
这个选择在鸿蒙化的时候被验证是对的。因为 eventsource 这类库本质上是纯 Dart 实现,依赖的协议栈在鸿蒙 Flutter 运行时上都有对应实现,不需要像推送 SDK 那样找原生插件适配。但也正因为它是纯 Dart,鸿蒙化之后有些东西反而容易被忽视——比如网络权限模型、后台策略、生命周期差异,这些后面逐个讲。
2. 鸿蒙化适配前的体检:看清这个库的依赖栈和边界
拿到一个 Flutter 三方库,先别急着改代码。第一件事是搞清楚这个库到底依赖了什么、哪些部分是平台相关的、哪些是纯 Dart。这个判断决定了后续适配的工作量和技术路线。
2.1 先确认是不是纯 Dart 实现
Flutter 的三方库分两类:一类是纯 Dart 包,只依赖dart:*内置能力和别的纯 Dart 包;另一类是平台插件,通过MethodChannel调 iOS/Android 的原生代码。后者鸿蒙化的时候要重写原生侧,工作量完全不同。
eventsource 属于前者,它内部核心逻辑就两件事:用 HTTP 客户端发起流式请求、把字节流按 SSE 协议解析成事件对象。这两件事在鸿蒙 Flutter 运行时上都有对应能力,理论上不需要碰平台代码。但"理论上"三个字在鸿蒙开发里经常不成立,后面会解释。
判断方法很简单,在项目根目录跑:
flutter pub deps --style=compact看输出里有没有hooks、plugin这类关键字,或者去看这个包在pubspec.yaml里声明的依赖列表。如果只有http、dart:async这类纯 Dart 依赖,那它就是可移植的。我们当时检查完确认了 eventsource 是纯 Dart,心里先松了口气。
2.2 真正会出问题的不是 Dart 代码,是它下面的网络栈
纯 Dart 不意味着万事大吉。Dart 的http包底层在移动端是走dart:io的HttpClient,而dart:io在鸿蒙 Flutter 运行时上是被适配过的。适配层的稳定性、支持的特性子集,跟 iOS/Android 上原生的dart:io不是完全等价的。
这就带来一个现实问题:在 iOS/Android 上验证过的"HTTP 长连接行为",在鸿蒙上未必一致。比如连接超时、读超时、主动断开、代理设置、TLS 校验,这些在网络栈适配层都可能存在差异。你以为你在调 eventsource,其实你在调鸿蒙的 HTTP 栈。
所以体检清单应该是这样的:
- [ ] 确认 eventsource 是纯 Dart 包,无原生插件依赖
- [ ] 确认它使用的 HTTP 客户端在当前鸿蒙 Flutter 环境的兼容路径
- [ ] 确认鸿蒙侧对长连接套接字没有额外的系统级超时策略
- [ ] 确认应用的
module.json5已配置网络权限 - [ ] 先建一个最小测试页,直连一个可控的 SSE 端点验证通路
最后一条是体检的关键动作。不要直接在项目里跑完整业务,而是先用最小复现确认"鸿蒙设备上能不能通过 eventsource 或普通 HTTP 流式请求收到 SSE 数据"。我们就是在这个最小验证里发现了问题——编译能过,但请求永远拿不到数据,最后定位到网络权限配置漏了。这个事单独放在后面避坑清单里讲。
2.3 鸿蒙环境的引入方式决定了你的改动范围
鸿蒙 Flutter 环境的接入方式,不同团队可能有不同做法:有的用社区维护的鸿蒙 Flutter SDK,有的基于 OpenHarmony 的 Flutter 适配版本自建,有的走的是逆向兼容方案。这直接决定了 eventsource 库鸿蒙化的改动范围。
如果是纯 Dart 包,理论上换了 SDK 就能直接编译。但如果你用了MethodChannel相关的封装,或者依赖了某个插件包,改动范围就大了。我们的项目相对干净,核心链路就是 Flutter 页面 + eventsource + 业务状态管理,所以鸿蒙化适配集中在这三块:工程配置、网络请求适配、连接生命周期管理。
3. 从 Dart 到鸿蒙网络栈:逐层改造的关键动作
确认了边界,接下来就是动手。这一章讲具体改造动作,每一步都会解释为什么要这么做,坑在哪里。
3.1 工程接入:引入鸿蒙 Flutter 环境并确认编译链路
先把整个 Flutter 工程切换到鸿蒙 Flutter 适配环境。这个过程在不同项目里差异较大,我这里只讲跟 eventsource 相关的部分。
切换环境后,第一件事是确认pubspec.yaml里的依赖都能在新环境下重新拉取解析。eventsource 这个包本身没有平台相关约束,所以解析一般是能过的。真正要注意的是它传递依赖的那些包,比如http,不同版本对dart:io的特性和 API 有差异,鸿蒙的适配层如果没跟上,编译期会报一些看起来莫名其妙的错误。
这时候我的处理方式是锁定 versions:
dependencies: eventsource: x.y.z http: a.b.c锁定版本是为了让鸿蒙适配层的测试范围可控。不要指望鸿蒙 Flutter 环境能跟原生 Flutter 一样兼容所有版本组合,能锁就锁,至少出问题知道是哪一层的事。
编译通过之后,用最小页面连一个 SSE 端点做冒烟测试。这一步别跳。我们当时的冒烟测试代码大概像这样:
final source = EventSource( Uri.parse('https://your-sse-endpoint/stream'), options: EventSourceOptions( headers: {'Authorization': 'Bearer your_token'}, ), ); source.onMessage.listen((event) { print('收到事件: ${event.data}'); });跑起来如果onMessage能收到事件,说明 protocol 层没问题,可以进入下一步。如果收不到,先排查网络权限、URL 可访问性、证书校验,再考虑代码问题。
3.2 网络权限:鸿蒙的 module.json5 配置
这是我们在鸿蒙化过程中踩的第一个大坑,也是很多人最容易忽略的。
在 iOS 和 Android 上,Flutter 应用的网络权限是平台工程里默认配置好的,Flutter 开发者在写 Dart 代码时基本不会感知到这一点。但鸿蒙的权限模型不一样,网络权限需要在 entry 模块的module.json5里显式声明,不声明就默认没有网络访问能力。
漏掉这个配置的表现非常隐蔽:编译能过,没有任何报错,但所有网络请求静默失败,超时后 eventsource 会触发自动重连,然后继续失败,循环往复。你看到的现象是"永远连不上",根本不会想到是权限问题。
正确的配置是在module.json5的requestPermissions数组里加入:
{ "module": { "requestPermissions": [ { "name": "ohos.permission.INTERNET" } ] } }配置完重新签名安装。这里有个细节:修改module.json5之后不能只做热重载,要重新打包安装到设备上才生效。我们最初改完配置后直接跑重载,还是老样子,浪费了半小时才反应过来是没重装。
3.3 网络请求的底层实现:HTTP 客户端兼容性改造
eventsource 库在 Dart 层解析 SSE 流的时候,依赖的是 HTTP 客户端返回的Stream<List<int>>字节流。它本身不关心这个流来自dart:io的HttpClient还是别的实现,只要能拿到字节流,协议解析逻辑就照常工作。
这给了我们一个非常方便的改造空间:如果鸿蒙运行时上的dart:io行为跟 iOS/Android 有差异,我们可以替换 HTTP 客户端的实现,而不用动 eventsource 的协议解析代码。
具体怎么替换,取决于 eventsource 库是否暴露了自定义 HTTP 客户端的入口。大部分实现会允许你注入一个http.Client实例。如果鸿蒙适配层的dart:ioHttpClient在长连接上有问题,可以考虑用基于鸿蒙网络框架封装的 Dart 侧 HTTP 客户端。
不过这里我有一个更加务实的建议:不要一上来就换底层实现。先用默认实现,把连接建立起来,观察几分钟。SSE 长连接最常见的问题不是连不上,而是连上了之后被系统静默断掉、心跳失效、重连风暴这些"运行时症状"。这些问题换了底层 HttpClient 也解决不了,得从协议治理层面入手。
3.4 事件流解析的兼容性细节
eventsource 的解析逻辑是按 W3C EventSource 规范实现的,正常情况下在鸿蒙上不需要改。但有几个边缘情况值得注意:
多行 data 字段的处理。SSE 协议允许一条事件的消息体跨多个data:行,解析时要用换行符拼接。有些服务端实现不规范,会在data:行后面带空格,或者漏掉空行分隔,解析器对这些情况的容忍度好坏直接影响你收到的事件数据完整性。建议在鸿蒙化测试阶段,专门构造几条包含多行 data、注释行、无 data 字段的 ping 事件的用例,验证解析器行为。
BOM 头问题。如果服务端返回的流最前面带了 UTF-8 BOM(EF BB BF),部分解析器会把它当成 data 的一部分。鸿蒙的网络栈在不同系统版本上对 BOM 的处理可能不一致,所以在解析层做一次 BOM 剥离是稳妥的:
final bytes = chunk; if (bytes.length >= 3 && bytes[0] == 0xEF && bytes[1] == 0xBB && bytes[2] == 0xBF) { bytes.removeRange(0, 3); }流式切包。SSE 本质是流式响应,一个事件可能被网络栈切成多个 chunk 到达,一个 chunk 里也可能包含多个事件。eventsource 内部是累积缓冲区再按\n\n切分,只要实现遵循规范,这块问题不大。但如果你做了自定义的 header 注入或者网络层包装,要小心不要破坏流的连续性。
4. SSE 在鸿蒙侧让工程师真正头疼的:连接治理实战
协议跑通了只是开始。SSE 连接的"黄金法则"是:连接一定会断,断了一定要能自动恢复,恢复了一定不能丢数据,丢了数据也要让服务端能补回来。这句话在鸿蒙上尤其成立,因为鸿蒙的系统级后台策略比 iOS/Android 更激进,应用切后台后长连接很容易被系统回收。
4.1 自动重连与 Last-Event-ID 断点续传
SSE 的自动重连是协议自带的,但这不代表你可以什么都不做。要让它真正可靠,核心是Last-Event-ID机制。
服务端下发的每条事件如果不带id字段,客户端是无法做断点续传的。重连的时候 eventsource 会带上Last-Event-ID请求头,服务端根据这个值把客户端断线期间错过的事件重新下发。这个设计非常优雅,但工程上有一个前提:服务端必须保证事件的 id 是有序且连续的,并且客户端侧要维护好这个值。
鸿蒙化之后,我建议把Last-Event-ID的持久化做重一点。不要在内存里存一个变量就完事,因为应用可能被系统杀掉再重启。用本地存储保存lastEventId,在创建EventSource时通过自定义请求头带上,这样哪怕应用进程被系统回收了,下次启动也能从断点续上。
伪代码逻辑大概是:
final lastEventId = await storage.read('last_event_id'); final source = EventSource( uri, headers: { 'Last-Event-ID': lastEventId ?? '', }, );在收到每条事件时更新本地缓存。这里要注意:不是内存缓存,是持久化缓存。写入频率别太高,防抖一下,不然会频繁触发本地存储写操作。
4.2 心跳保活与超时判定
HTTP 长连接的一大问题是中间设备(网关、运营商代理、系统网络栈)会在一段时间没有数据传输时静默断开连接。这个"一段时间"没有统一标准,有的说 30 秒,有的说 5 分钟。
SSE 社区的标准做法是服务端定期发送注释行:
: ping注意这行不是事件数据,它只是为了让连接保持活跃,不触发onMessage。客户端这边,你需要自己实现超时检测——如果超过 N 秒没有任何数据到来(包括注释行),就要认为连接已经死了,主动断开触发重连。
为什么这里强调"自己实现"?因为 Flutter 的 eventsource 三方库不一定都内置了 idle timeout 检测。如果库没这个能力,重连就只能依赖服务端断开后的 readyState 变化,而被动检测通常会比主动检测慢很多,表现为用户看到"卡住不动",等你发现的时候连接其实早就断了。
我在鸿蒙适配的时候给 eventsource 包了一层超时看护:
timer = Timer.periodic(Duration(seconds: 10), (timer) { if (DateTime.now().difference(lastReceivedAt) > Duration(seconds: 30)) { // 主动断开并触发重连 source.close(); } });这个 30 秒的阈值要结合服务端心跳频率来定。如果服务端每 15 秒发一个注释行,阈值可以设成 45 秒而不是 30 秒,留足抖动余量。设得太小会导致误判,频繁重连比不断线更伤。
4.3 与页面生命周期的绑定:前台才连、后台就断
这是鸿蒙项目里我体会最深的一点。iOS/Android 上应用切后台,Socket 往往会再存活一段时间。鸿蒙的调度策略更严格,后台进程很容易被挂起,长连接没法长期维持。
一开始我们试图对抗这个策略,让 SSE 在后台也能保持连接。后来发现方向就错了——鸿蒙应用要后台持续运行,需要申请对应的长时任务权限,而且必须在前台时通过特定接口申请,不是随便一个后台任务就能稳定保持长连接的。与其跟系统策略对抗,不如顺着业务需求设计:
- 应用在前台:建立 SSE 连接,正常接收实时事件。
- 应用切后台:主动关闭连接,释放资源。
- 应用回前台:重新建立连接,通过
Last-Event-ID补拉离线期间的事件。
这个方案的收益是双重的:一方面避免了系统后台杀进程导致的异常状态,另一方面也省了不必要的心跳流量。代价是回前台那一下会有短暂的数据延迟,但对我们这个场景完全可接受。
实现上要监听应用生命周期:
WidgetsBindingObserver { AppLifecycleState.paused => disconnect(); AppLifecycleState.resumed => reconnect(); }这里的disconnect()和reconnect()必须保持幂等,因为你可能收到重复的生命周期回调。把连接对象收敛到单例里统一管理,不要散落在业务页面,否则会出资源泄漏和事件重复消费的问题。
4.4 连接风暴治理:全局单例 + 指数退避
说到重复消费,这是重连机制里最容易翻车的地方。举一个真实场景:业务页面 A 和页面 B 都用到了同一个实时数据源,如果各自创建了一个EventSource实例连到同一个端点,就会造成两条连接并存。服务端下发的同一条事件被两个实例同时消费,UI 上看到的就是数据反复跳动、状态互相覆盖。
这种事在开发阶段很难测出来,因为你的注意力都在"能不能收到事件",不在"是不是两份实例都收到了"。等上线之后线上日志一查,发现网关侧同一客户端的连接数异常,或者用户反馈数据闪跳,才意识到问题。
治理方案就一句话:SSE 连接必须全局单例。整个应用里同一个端点只允许有一个EventSource实例,所有页面通过事件总线或状态管理框架订阅这个实例的事件流,而不是各自建连。
重连的另一个问题是风暴。当网络抖动或服务端短暂不可用时,所有客户端的重连会集中在一个时间点发起,把服务端打挂。SSE 自带的retry字段是服务端用来控制重连间隔的,但客户端侧也应该加指数退避。断开后不要立即重连,而是按 1s、2s、4s、8s……的节奏拉长间隔,最大到 30s 或 60s,同时加入少量随机抖动,避免所有客户端在同一时间点重连。
这个逻辑可以直接包在连接管理单例里面:
int retryDelay = 1; void scheduleReconnect() { retryDelay = min(retryDelay * 2, 60); Timer(Duration(seconds: retryDelay + Random().nextInt(3)), connect); }连接成功后把retryDelay重置回 1,下次断线重新走退避节奏。
4.5 可观测性:连接状态必须能被量化监控
最后一件别省的事是监控。SSE 连接不像普通接口那样有个明确的结果,它是"持续存在"的,所以监控的维度也不一样。
我在鸿蒙适配版本里加了三个基本指标:
- 连接时长分布:每次连接的持续时间,如果大量连接只存活了几十秒,说明网络不稳或心跳失效。
- 重连次数与原因:自动重连累计发生多少次,最后一次断开是服务端关闭、网络错误、还是客户端主动断开。
- 事件拉取延迟:从服务端下发到客户端收到的时间差,这个指标最能反映真实体验。
这三个指标上报到现有的日志分析平台,线上问题就能提前发现。比如某次鸿蒙系统版本升级后,有用户反馈实时数据不更新了,我们通过连接时长分布第一时间发现有大量"连接建立后 5 秒内断开"的记录,定位到是 TLS 握手阶段跟新系统版本不兼容,走了一遍证书链路排查才解决。
5. 鸿蒙化适配踩坑记录:四个典型案例的完整排查链路
这一章不是理论,是我这次鸿蒙化适配里真正踩过的坑。每个案例我都会把整个排查链路写出来,因为踩坑的过程比正确的答案更有价值。
5.1 案例一:网络权限遗漏导致的静默失败
现象:最小测试页面连不上 SSE 端点,eventsource 一直处于 CONNECTING 状态,onError事件不断触发。
排查过程:
- 第一步怀疑端点问题。把同一个 URL 放到鸿蒙自带的浏览器里访问,能正常拿到事件流,说明服务端没问题。
- 第二步怀疑 Flutter 环境网络栈没适配。在 Dart 侧手动发一个普通 HTTP GET 到任意 HTTPS 地址,发现也一样失败。
- 第三步查鸿蒙系统日志,发现错误信息是网络权限被拒绝的提示。
- 第四步检查模块配置文件,果然
requestPermissions里没有ohos.permission.INTERNET。
结论:鸿蒙的网络权限必须在module.json5里显式声明,漏了就是一切请求静默失败。这不是 eventsource 的问题,但影响 eventsource 的排查判断,先写在这里,希望大家少走这个弯路。
5.2 案例二:页面生命周期切换导致的事件漏收
现象:用户把应用切到后台,再切回前台,实时数据停在后台某个时间点,要杀掉进程重进才能恢复。
排查过程:
- 第一步确认是切后台期间连接被系统回收。查系统日志,发现后台后 TCP 连接被系统释放了。
- 第二步复盘我们当时的做法:后台时让连接自身保持存活,依赖 eventsource 的自动重连恢复。但问题是重连发生在后台,鸿蒙对后台的调度限制导致重连迟迟没被执行,回到前台后才恢复,中间的事件已经过了有效期。
- 第三步改成主动策略:切后台主动关闭,回前台立即重连,配合
Last-Event-ID做补偿。事件漏收问题消除。
结论:在鸿蒙上,不要试图通过后台保活的思路维护 SSE 长连接。主动断开 + 回前台重连 + 断点续传,是更可靠的设计。这条经验跟具体应用场景有关,如果你的业务确实需要后台持续收推送,SSE 并不是合适的技术选型,应该考虑系统级推送服务。
5.3 案例三:多页面多实例导致的事件重复消费
现象:用户反馈进入某个二级页面后,行情数据出现明显闪跳,像是被重复刷新。
排查过程:
- 第一步看代码,发现二级页面和首页都各自创建了
EventSource实例,连的是同一个端点。 - 第二步看网关侧日志,确认同一用户同时存在两条活跃连接。
- 第三步把连接收敛到全局单例,页面侧通过
Stream订阅同一事件源。数据闪跳消失。
结论:SSE 客户端实例必须全局唯一。这是治理问题,不是一个功能需求,但在鸿蒙化改造中很容易被"先让它跑起来"的心态带过去。建议在一开始设计连接管理时就把单例写进规范。
5.4 案例四:自签名证书导致的 TLS 握手失败
现象:内网测试环境用的是自签名证书,鸿蒙设备连不上,错误信息提示证书校验失败。
排查过程:这是环境问题不是代码问题,但很容易让人误判为 eventsource 的适配问题。排查时先确认同一个环境在 iOS/Android 上行为是否一致——如果一致,说明不是鸿蒙特有的问题,只是内网证书在测试机上没做信任配置。
结论:鸿蒙上处理自签名证书的信任配置方式跟 Android 不完全一样,需要在系统设置或工程配置里单独处理。建议测试环境统一使用受信任的测试 CA,不要在生产环境跟证书较劲。
5.5 上线前的鸿蒙 SSE 验证清单
最后给一份我在这次适配中整理的上线验证清单,照着过一遍,基本能覆盖大部分连接问题:
- [ ]
module.json5已配置ohos.permission.INTERNET - [ ] 最小测试页面能稳定收到事件流超过 5 分钟不断线
- [ ] 断网 30 秒再恢复,连接自动重建,事件从
Last-Event-ID续传,无重复无遗漏 - [ ] 应用切后台再回前台,连接重新建立,后台期间的事件能通过断点续传补齐
- [ ] 服务端重启期间,客户端按退避节奏重连,恢复后能自动续上
- [ ] 多个页面共享一个连接实例,无事件重复消费
- [ ] 连接状态指标已上报监控系统,重连次数和连接时长可查询
- [ ] 长时间运行超过 12 小时,无内存泄漏、无异常断开风暴
我自己在做完这套适配和治理之后最大的感受是:鸿蒙化一个三方库,表面上是在改配置、换依赖、调网络栈,实际上是对整个实时链路做了一次压力测试。eventsource 只是入口,真正花时间的是把连接的可靠性模型搞清楚,再把鸿蒙的运行时差异融进去。这套方法论不局限于 eventsource,你换任何实时数据组件,在鸿蒙上走一遍,都会遇到类似的三个问题:权限怎么配、生命周期怎么挂、断线怎么补。把这三个问题想清楚了,鸿蒙实时推送这条路基本就顺了。