1. 先弄清楚:你检测的到底是“有网”还是“有链路”
做 Flutter 应用的人,大概率都遇到过类似的反馈:“我 WiFi 明明是满格的,为什么 App 一直提示没有网络?”第一次被用户这么问时,我以为是玄学,后来查了一下午才发现,手机的系统状态、插件返回值、以及用户嘴里说的“能上网”,在技术上其实是三个完全不同的概念。如果你只盯着“是否有 WiFi 信号”来判断是否已连接到互联网,那这个判断从一开始就是错的。这篇文章会把 Flutter 场景下检测网络状态这件事从头到尾讲透,包括原理、插件选型、完整代码、参数配置和排障清单,适合正在做跨平台 App、或者在网络模块上反复被坑的 Flutter 开发者。
1.1 系统告诉你“已连接”,并不是真的“已连接”
先做一个简单的实验:关掉路由器的 WAN 口拨号,但把手机继续连在路由器的 WiFi 上。这时候手机右上角依然是 WiFi 图标,系统设置里也显示“已连接”,但打开任何 App 都会转圈圈。对用户来说,这就是“断网”;对系统来说,却是“网络正常”。问题出在哪里?因为手机和路由器之间只有一条物理链路,而互联网在链路的另一端根本没有通。
Flutter 社区常用的 connectivity_plus 插件,返回的 ConnectivityResult.wifi、ConnectivityResult.mobile、ConnectivityResult.ethernet、ConnectivityResult.none,本质上描述的就是这个物理链路。它告诉你的只是“系统当前启用了哪张网卡、连上了哪个热点或基站”,完全不关心链路对面能不能真的发出一个请求、收到一个响应。类似的,iOS 上的网络路径监控框架拿到的结果,反映的也是接口层面的可达性,而不是外网可达性。把这两个概念混为一谈,是绝大多数网络检测 bug 的总根源。
1.2 判定“已连接到互联网”的三个层级
在动手写代码之前,我建议先把“网络状态”拆成三个层级,心里有个坐标,后面所有方案都围绕这个坐标展开。
| 层级 | 判定内容 | 典型表现 | 说明 |
|---|---|---|---|
| L1 | 系统网络接口存在 | 状态栏有信号图标 | 飞行模式关闭、WiFi 开启,但可能还没关联到任何热点 |
| L2 | 接口拿到 IP、网关、DNS | WiFi 显示“已连接” | 已经关联到 AP,但路由器可能没拨号、没分配可用外网 IP |
| L3 | 能成功发起对外请求并得到响应 | App 能正常拉取数据 | 这才是用户感知的“有网” |
对大多数业务来说,L1、L2 都是过程量,L3 才是终态。比如你要做一个只有在线才能下单的页面,用户在机场连上了需要网页认证的 WiFi,这时候系统显示 L2,浏览器却打不开任何网页,如果你在 App 里只判断到 L2 就放行,后面所有请求都会失败,然后用户就会产生“这是个半成品”的第一印象。反过来,如果你在 L1 阶段就提示“无网”,用户明明看着信号满格,也会觉得你 App 有问题。真正稳妥的检测,必须把判定推进到 L3。
1.3 检测需求拆解:启动时、切换时、操作前
在选型之前,还要想清楚一个事:你到底是“什么时候需要知道网络状态”?不同时机,对方案的延迟和成本要求完全不一样。
- 启动时:App 冷启动进入首页,这时候只需要一次快速判定,告诉 UI 显示“加载中”还是“没网”。
- 切换时:从 WiFi 切到蜂窝、从蜂窝回到 WiFi、飞行模式开关,需要马上重新判定,避免状态残留。
- 操作前:用户点击“提交订单”这类关键动作,可以用一次性检测兜底,防止提交后失败。
这三类场景对应的代码形态分别是:Future 一次性调用、Stream 持续监听、以及 Future 再次调用并配合超时。后面第 3 部分的模块会把三者全部覆盖到。这里先说结论:不要只依赖插件的回调做“永久判断”,一定要把“接口状态变化”当作“重新发起探测”的触发器,而不是把插件的结果当作最终答案。只要有这个意识,后面一半的坑你已经提前绕开了。
2. 工具选型:光靠插件不够,得自己补一个探测层
2.1 connectivity_plus 的能力边界
很多刚接触 Flutter 的开发者,看到 connectivity_plus 里有 checkConnectivity(),又有个 onConnectivityChanged 回调,就以为网络检测解决了。实际上这个插件只解决了两件事:一是拿到当前“正在使用的网络接口类型”,二是在“接口类型发生变化”时通知你。它内部在 Android 上走的是系统的连接管理服务,在 iOS 上走的是网络路径监控,这些都是系统级能力,本身没有问题。
问题在于,系统级能力的设计目标不是“判断互联网是否可达”,而是“判断设备有没有一个正在工作的网络接口”。这个差异在日常使用中会被掩盖,因为大多数时候“有接口”和“能上网”是同时成立的。但一旦遇到热点未拨号、运营商出口故障、WiFi 需要网页认证、DNS 配置异常这类边缘情况,两者就会分道扬镳。所以我的结论是:connectivity_plus 可以要,但只能把它当“信号”,不能把它当“结果”。它告诉你“网络可能变了”,至于到底通不通,得你自己去验证。
2.2 三种补充探测方案对比
既然要补一个探测层,常见做法有三种,我直接列一张对比表,把优缺点摊开。
| 方案 | 原理 | 优点 | 缺点 | 推荐度 |
|---|---|---|---|---|
| HTTP 请求探测 | 用 http 包请求一个稳定页面或接口,判断是否返回预期状态码 | 最贴近真实业务,结果可靠 | 耗时相对长,依赖 DNS 和证书,可能被鉴权拦截 | 业务关键场景可用 |
| TCP Socket 探测 | 与一个稳定主机建立 TCP 连接,能完成握手就算通 | 耗时短、轻量、不受 HTTP 层约束 | 只能证明 TCP 可达,不能证明业务接口可用 | 日常判断首推 |
| ICMP Ping 探测 | 发送 ICMP 报文,看是否有回包 | 网络工程师习惯用,定位直观 | 移动端系统对 ICMP 限制多,部分路由器防火墙直接丢弃,误判率偏高 | 不推荐 |
我最终选择 TCP Socket 探测作为主方案,原因是它快、稳、可预期。一次 TCP 握手在正常网络下通常几十毫秒到几百毫秒完成,超时控制在 3 秒左右,比走完整 HTTP 请求要快不少,也不会被某个页面的内容改动、接口限流、证书过期之类的业务因素干扰。如果你要探测的是一个后端业务接口,那 HTTP 方案更合适,因为它验证的其实是“业务链路是否通畅”,而不是“公网是否通畅”,两者要分开理解。
2.3 探测主机和端口怎么选
Socket 探测的核心是“连哪里”。这个点很多人不重视,随手写一个 IP 就上线,结果在不同网络环境下误判率忽高忽低。我的建议是遵循三条原则。
第一条,用公共基础设施而不依赖单个域名。可以选 8.8.8.8、1.1.1.1 这类通用公共地址,也可以选 example.com 这种永久保留域名,关键是这个目标不能是某个小公司的临时服务器,否则对方一挂,你的全量用户都会“断网”。第二条,端口优先选 53 或 443。53 是 DNS 服务端口,几乎不会被主动屏蔽;443 是 HTTPS 默认端口,绝大多数网络都放行。第三条,至少准备两个端点,主端点失败后自动换下一个,避免单个目标抖动造成误判。
我实际项目里的端点列表大概是这样的:一组 IP 直连地址加端口 53,一组域名地址加端口 443,两端都失败才判定为无网。这样做的好处是,即使某个网络把 ICMP 和特定端口都挡住了,只要它允许公网 HTTPS 流量,443 那一路就能成功;而 IP 直连那一路又能绕开 DNS 解析本身出问题的情况。两条腿走路,比单点探测可靠得多。
2.4 为什么不直接用一个现成的探测包
社区里其实有现成的探测包,比如 internet_connection_checker,它把“主动探测”这件事封装好了,初始化时传入一个地址列表,调用方法就能返回布尔值。对于只想快速解决“App 老是误判断网”的团队,直接用现成包是可以的,代码量少,看起来也清晰。
但我在自己的项目里还是选择了自建模块,原因有三个。第一,端点不可控。现成包默认的探测地址是写死的,一旦某个地址在你覆盖的区域表现不佳,你改起来很麻烦,甚至要等社区更新。第二,内部逻辑是黑盒。它对超时、重试、缓存的处理方式不一定符合你的业务节奏,出了误判问题你只能猜。第三,依赖体积和维护成本。为一个几十行的探测逻辑引入一个包,还要跟着它的版本升级走,性价比并不高。如果你要的是精细控制、动态配置、和埋点上报,自建一个几十行的模块反而更省心。
3. 完整实现:一个生产可用的网络探测模块
3.1 模块的整体设计
现在进入正题。我先说设计,再给代码。整个模块我拆成三层:探测层(真正发 Socket 请求)、判定层(合并接口状态与探测结果)、通知层(把结果通过 Stream 和一次性 Future 暴露给外部)。
用一个类 ConnectivityService 串起来,内部维护三个关键成员:connectivity_plus 的实例、一个提供给外部订阅的流控制器、以及上一次探测结果的缓存。为什么要缓存?因为网络状态检测是一个被高频触发的事件,WiFi 和蜂窝切换的瞬间,系统回调可能连续触发好几次,如果没有缓存和节流,UI 会被无意义地重建,甚至出现“有网-无网-有网”来回跳的闪烁。
缓存这里我建议不要做得太复杂,一个布尔字段加一个时间戳就够了。比如探测成功后在 15 秒内再次查询,直接返回缓存结果,不重复发 Socket;超过 15 秒则重新探测。这个窗口期要根据业务调整,电商类 App 可以短一点,内容类 App 可以长一点。窗口太短会放大并发探测的问题,太长又会让结果过于陈旧,15 秒是我试下来比较均衡的默认值。
3.2 核心探测:Socket 握手的正确写法
先给核心的探测函数。这里用的是 dart:io 自带的 Socket,不需要引入额外依赖。
import 'dart:async'; import 'dart:io'; class ConnectivityService { ConnectivityService({this.probeHosts = defaultProbeHosts}); static const List<ProbeTarget> defaultProbeHosts = [ ProbeTarget('8.8.8.8', 53), ProbeTarget('example.com', 443), ]; final List<ProbeTarget> probeHosts; Future<bool> probeInternet({Duration timeout = const Duration(seconds: 3)}) async { for (final target in probeHosts) { final ok = await _probeOnce(target, timeout); if (ok) return true; } return false; } Future<bool> _probeOnce(ProbeTarget target, Duration timeout) async { Socket? socket; try { socket = await Socket.connect(target.host, target.port, timeout: timeout); return socket != null; } catch (_) { return false; } finally { socket?.destroy(); } } } class ProbeTarget { const ProbeTarget(this.host, this.port); final String host; final int port; }这段代码的要点有三个。第一个是超时必须显式传,Socket.connect 在部分平台上如果不传 timeout,可能会长时间阻塞,用户都切换到飞行模式了,你还傻等十几秒。第二个是成功后要立刻 destroy,Socket 连接建立只是为了确认握手,不需要保持,不释放会造成文件描述符泄漏。第三个是循环探测时,一个失败立刻换下一个,而不是把超时时间累加,三个端点都试完的总耗时仍然控制在 3 秒左右。
顺带说一句,为什么我要把 probeHosts 作为构造函数参数传进去?因为不同地区的网络对目标地址的连通性差异很大。你自己维护一个白名单端点、或者从后端配置下发,比写死在代码里灵活得多。后面第 5 部分我会专门说端点的管理。
3.3 监听网络变化并自动重测
有了基础探测函数,接下来把它接到系统的网络变化事件上。connectivity_plus 的 onConnectivityChanged 是一个流,每个事件代表接口状态变了,我们在收到事件后重新探测一次,再把结果发给业务方。
StreamSubscription<List<ConnectivityResult>>? _subscription; final _statusController = StreamController<bool>.broadcast(); bool _cached = true; DateTime _lastChecked = DateTime.fromMillisecondsSinceEpoch(0); Stream<bool> get statusStream => _statusController.stream; void startMonitoring() { _subscription ??= connectivity.onConnectivityChanged.listen((results) { _handleConnectivityChanged(results); }); } Future<void> _handleConnectivityChanged(List<ConnectivityResult> results) async { final hasInterface = results.isNotEmpty && !results.contains(ConnectivityResult.none); if (!hasInterface) { _publishResult(false); return; } // 接口刚从无到有,网络栈可能还没就绪,稍微等一等 await Future<void>.delayed(const Duration(milliseconds: 400)); final reachable = await probeInternet(); _publishResult(reachable); } void _publishResult(bool reachable) { _cached = reachable; _lastChecked = DateTime.now(); if (!_statusController.isClosed) { _statusController.add(reachable); } }这里有两个细节非常值得注意。第一,connectivity_plus 新版本的回调参数是 List ,因为在部分系统上可能同时存在多个可用接口,判空时要先检查列表是否为空、再检查是否包含 none,顺序写反会漏判。第二,接口从“无”变“有”之后,不要立刻探测,我在代码里特意延迟了 400 毫秒。原因是 WiFi 关联成功后,系统可能还在拿 IP、配置网关,这个窗口期去连 Socket,大概率超时,于是你会把一次正常连接误判成“断网”,之后得等下一次事件触发才能恢复,体验非常差。
排查过这类问题的人应该懂,这种“连接成功后头几百毫秒内探测失败”是线上问题重灾区,不是你代码写错了,是时序没给够。加一个小延迟是最省事、也最稳妥的办法。
3.4 一次性检查接口与 UI 层结合
持续监听适合全局提示“网络已断开”,但很多业务场景只需要在某个时刻查一次。比如用户点“重试加载”按钮时,你希望同步返回“当前到底有没有网”。这两个需求我都暴露出去。
Future<bool> get hasConnection async { if (DateTime.now().difference(_lastChecked) < const Duration(seconds: 15)) { return _cached; } final result = await connectivity.checkConnectivity(); final hasInterface = result.isNotEmpty && !result.contains(ConnectivityResult.none); if (!hasInterface) { _publishResult(false); return false; } final reachable = await probeInternet(); _publishResult(reachable); return reachable; } void dispose() { _subscription?.cancel(); _statusController.close(); }UI 层用起来就非常简单了。全局网络状态提示可以这样写:
StreamBuilder<bool>( stream: service.statusStream, initialData: true, builder: (context, snapshot) { return snapshot.data == false ? const OfflineBanner() : const SizedBox.shrink(); }, )关键操作前的兜底检查则可以写成:
Future<void> submitOrder() async { if (!await service.hasConnection) { showToast('当前无网络,请检查连接后再试'); return; } // 继续提交逻辑 }到这里,一个最小可用的网络探测模块就齐了。注意,我说的是“最小可用”,如果项目要求更严谨,第 4 部分的坑你必须提前看完,否则上线后大概率会收到“明明有网却提示无网”之类的新问题。
3.5 接入状态管理:以 Provider 为例
很多项目不是直接把类 new 出来用,而是挂在状态管理框架里。以 Provider 为例,可以把这个服务注册成单例,同时用一个 ChangeNotifier 包一层,让页面可以直接监听。
class ConnectivityNotifier extends ChangeNotifier { ConnectivityNotifier(this._service) { _sub = _service.statusStream.listen((value) { _online = value; notifyListeners(); }); _service.startMonitoring(); } final ConnectivityService _service; StreamSubscription<bool>? _sub; bool _online = true; bool get online => _online; Future<void> refresh() async { _online = await _service.hasConnection; notifyListeners(); } @override void dispose() { _sub?.cancel(); _service.dispose(); super.dispose(); } }这样页面里通过 context.read 拿到 notifier,直接读 online 字段就行;需要手动刷新时调用 refresh。整个模块的职责边界很清楚:探测逻辑在 ConnectivityService,UI 绑定在 notifier,业务代码只面向 bool 和回调,不碰任何 Socket 细节。后续如果换探测策略,改动也被限制在一个类内部,不会波及整个项目。
4. 常见问题与排查技巧实录
4.1 权限没配就开跑,Android 上会先翻车
Android 上如果 Manifest 里没有声明网络权限,Socket.connect 会直接抛出异常,而且这个异常在 debug 模式下不一定显眼,容易被当成一般错误忽略。正确配置是这两行:
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />第一行管 Socket 和 HTTP,第二行管 connectivity_plus 读取网络状态,缺一不可。另外提醒一句:如果你的 App 只检测 WiFi,不要只写 ACCESS_WIFI_STATE 而漏了 ACCESS_NETWORK_STATE,后者覆盖面更广,前者只适用于读取热点列表这类场景。
iOS 这边,Socket 探测本身不需要额外权限,系统没有“使用 Socket 必须先申请”的规则。如果你用的是 HTTP 探测,并且目标地址是明文 http,那就要看 App Transport Security 的设置了,默认不允许明文流量,需要在 Info.plist 里配置例外规则。不过我更推荐直接走 https,一是避免 ATS 的麻烦,二是防止某些公共 WiFi 对明文流量做拦截。
4.2 WiFi 满格却上不了网:那 400 毫秒之外的真相
这种问题通常分三类,排查的时候按顺序过一遍。第一类,路由器本身没拨号成功,手机能拿到内网 IP,但所有外网请求都出不去,表现为 Socket 超时。代码层面能做的就是如实上报“无网”,同时在提示语里加一句“检查路由器或宽带状态”,别让用户以为你 App 坏了。第二类,WiFi 是开放的公共热点,连接后需要网页认证才能上网,用户没打开浏览器完成认证,系统却已经显示“已连接”。这类情况最棘手,因为 TCP 探测有时能通,业务请求却全被重定向到认证页。
对于第二类,我试过比较有效的方法是:探测时不要停在 TCP 握手层,再多走一步,发一个 HTTP HEAD 请求到一个固定的探测地址,检查返回的状态码。如果返回 200 或者重定向到已知域名,说明正常;如果返回的是某个陌生页面的 200,说明很可能被强制门户劫持了。这时候 App 可以提示用户“当前 WiFi 需要认证,请在浏览器中完成登录”,用户完成认证后回来自动重测。这个方案比单纯 TCP 探测更接近真实用户体验,代价是多一点延迟,但值得。
第三类,DNS 配置异常。偶尔会遇到路由器分配的 DNS 解析不出来,表现为域名永远超时,但 IP 直连正常。这也是为什么我在探测端点里坚持保留至少一个 IP 直连目标。如果你的日志里出现“IP 探测成功但域名探测失败”的组合,基本就能锁定是 DNS 的问题。
4.3 网络切换瞬间的状态闪烁
WiFi 和蜂窝切换、飞行模式开关的瞬间,系统的事件回调可能在一两秒内连续触发多次,现象是顶部离线提示条出现又消失,或者页面上的网络状态一会儿一变。这种问题不是探测逻辑错了,是没做防抖。
处理思路是给“无网”状态加一个确认窗口。我的做法是:收到 none 事件时,不立刻广播 false,而是先等 800 毫秒左右,如果这期间没有新的“有接口”事件回来,再确认断网;如果马上又收到有接口事件,就重新走探测流程。这里要注意,防抖窗口和前面说的 400 毫秒就绪延迟是两个东西,一个是为了避免误报,一个是为了避免探测过早失败,别混在一起。
4.4 后台运行和锁屏后,监听就失灵了
很多开发者把网络监听放在全局单例里,以为 App 退到后台还能正常收到回调。实际情况是,系统在省电模式下会挂起 App 的网络进程和事件分发,connectivity_plus 的流在后台可能延迟很久才推一次,甚至直接不推。等用户切回前台,界面上还停留在几十分钟前的状态。
我的经验是:不要指望后台监听,而是在 App 生命周期回到前台时,主动调用一次 hasConnection 强制刷新。Flutter 里可以用生命周期组件的回调感知前后台切换,回到前台后触发一次完整探测。这样既省电,又能保证用户看到的第一眼状态是可信的。另外,如果你需要在后台保持网络长连接做业务,那就不是这个模块的范畴了,该考虑的是系统级推送通道或者常驻机制,方向完全不同。
4.5 超时时间拍脑袋写,误判率翻倍
超时时间是最容易被忽视的参数。给太短,比如 800 毫秒,在弱网环境下几乎必触发,用户本来在地铁里用蜂窝网络,你硬说人家“断网”;给太长,比如 10 秒,用户断网后要等 10 秒才看到离线提示,体验同样糟糕。
我踩过几次坑之后固定下来的方案是分档超时:探测阶段用 3 秒,这是 Socket 握手比较合理的等待上限;HTTP 阶段用 5 秒,因为要算上 DNS 解析和 TLS 握手。如果你面向的地区网络质量偏差,可以把 Socket 超时放宽到 4 秒,但不要超过 5 秒,超过之后误判率下降,但用户等待时间明显上升,性价比很低。还有一个小技巧:把每次探测的实际耗时记录下来,线上日志里统计平均耗时和长尾分布,你会发现不同地区差异很大,再用数据反推参数,比拍脑袋准得多。
4.6 模拟器上一切正常,真机却各种翻车
Android 模拟器默认走宿主机的网络,你在电脑上能上网,模拟器里大概率也能过探测;iOS 模拟器直接共享宿主机的网络栈,表现更接近真机。所以模拟器只能验证逻辑,不能验证网络栈差异。真机上一个常见差异是,部分设备在系统层对后台 Socket 做了限制,即使你的 App 在前台,一些深度休眠模式下 Socket 也可能被延迟建立,表现为偶发超时。遇到这种情况,先看日志里的探测耗时,如果分布在 2.5 秒到 3 秒边缘,说明触碰到了超时阈值,可以考虑把探测端点换成延迟更低的本地公共基础设施,而不是盲目调大超时。
另外提醒一句,真机调试时可以把系统开发者选项里的模拟弱网模式打开,再跑一遍探测流程,能提前复现很多用户场景,比上线后被反馈强多了。弱网下 Socket 握手偶尔失败是正常现象,只要你最终的判定逻辑没有把单次失败直接升级成“永久无网”,问题就不大。
5. 工程化落地:放进生产环境前,再做四件事
5.1 探测端点可配置,不要写死在代码深处
模块上线一段时间后,你一定会遇到需要更换端点的情况。可能是某个公共目标在你覆盖的地区不稳定,也可能是想换成更贴近业务区域的探测地址。如果端点写死在代码里,每次调整都要发版,非常被动。
我的建议是:默认配置保留一份内置端点列表,同时预留一个从远程配置读取覆盖值的通道。远程配置更新后,模块在下次探测时自动使用新列表。这里要注意一个边界条件:如果远程配置为空或者列表全不可达,要能回退到内置列表,否则后端配置下发失败会导致全量用户“无网”。另外,端点列表不要包含太多项,三到四个足够,多了只是徒增探测耗时。
5.2 加一层短时缓存,别让每个页面都触发一次探测
一个页面三五个组件同时监听网络状态,每个都在初始化阶段调用 hasConnection,如果不做缓存,一次网络抖动就能打出几十个 Socket 请求,浪费电量和流量。我在模块里做的缓存规则是:15 秒内重复查询直接返回上一次结果;超过 15 秒才重新探测。同时订阅事件触发的探测结果也会写入缓存,这样业务方拿到的结果始终是“最近的、有效的”。如果你的业务对时效性要求高,把窗口缩到 5 秒也可以,但别直接取消缓存,否则并发请求重复探测的问题马上会回来。
5.3 把“探测通过”和“业务请求成功”分开看待
很多团队把网络探测模块当成业务请求的前置校验,探测失败就拦住请求。这个思路方向没错,但要注意,探测成功不等于业务请求一定成功。用户此时可能处于某个受限网络里,TCP 握手能通,但业务接口的域名解析异常,或者接口所在端口被限制。所以更稳妥的做法是:业务请求失败时,先区分错误类型,超时、连接被拒绝、证书错误分别处理,再结合网络探测模块的结果,决定是提示“无网”、提示“服务器开小差”还是弹出重试。不要把所有失败都归给网络,这样排查问题时会少走很多弯路。
5.4 埋点与日志:让“无网”问题可被追溯
最后一项是我强烈建议加的。把每次探测的结果、耗时、端点、触发来源(启动、网络切换、手动查询)都打一条日志并埋点上报。线上用户抱怨“没网”时,如果没有这些数据,你只能靠猜;有了数据,你可以直接查这批用户当时的探测耗时是否接近超时、哪个端点失败了、是接口切换触发的还是启动时就失败,很多问题马上就能定位。
我见过一个真实案例:某个版本更新后,大量用户反馈“首页一直提示没有网络”。埋点数据显示,他们的 Socket 探测全部超时,但日志里又显示手机确实有 WiFi 信号。后来定位到是更新包把探测目标地址写错了,解析出来的是内网地址,所有用户自然都“无网”。这种问题如果没有埋点,光靠用户描述,几天都不一定能查出来。
5.5 Web 与桌面端的行为差异
如果你的 Flutter 工程同时支持 Web 和桌面端,网络检测要单独看待。Web 端浏览器的网络策略和移动端差异很大,connectivity_plus 在 Web 上能获取到的信息非常有限,很多时候拿不到具体的接口类型。Socket 连接在 Web 端也不能直接用 dart:io,需要走浏览器提供的网络能力,比如通过一个资源请求来间接判断。桌面端相对好一些,dart:io 的 Socket 可用,但桌面系统对网络接口的管理更复杂,虚拟网卡、多网卡并存时,接口状态可能频繁变化,防抖和缓存的节奏要重新调。
我的处理方式是:工程内按平台抽象一个网络探测接口,移动端走 Socket 加 HTTP 的组合,Web 端退化为 HTTP 资源探测,桌面端沿用移动端逻辑但把防抖窗口拉大。这样做虽然代码多一点,但避免了“移动端调试好好的,一跑 Web 全断网”的尴尬。
最后分享一个小习惯:每次改完探测逻辑,我都在真机上把飞行模式开关、WiFi 开关、热点认证这三件事轮流做一遍,确认状态响应能在一两秒内收敛。做过一轮之后,心里就有底了。你按第 3 部分的代码起步,把第 4 部分的坑对着过一遍,再按第 5 部分补上配置、缓存和埋点,这套网络检测模块基本就能扛住绝大多数线上场景。我自己在实际项目里最深的体会是:网络检测的难点从来不在“能不能写出探测代码”,而在于愿不愿意去处理那些系统状态和真实体验不一致的边界情况,把这些边界一条条理清楚,比多写一百行业务代码更有价值。