
做Flutter开发的朋友应该都遇到过类似的线上反馈用户明明连着WiFi打开App却一直转圈最后弹一个“加载失败”。状态栏明明满格信号代码里connectivity_plus返回的也是ConnectivityResult.wifi产品经理拿着截图说“报告显示有网络”但真实用户就是刷不出内容。这不是玄学而是很多Flutter开发者把“有网络连接”和“能访问互联网”当成了一回事。connectivity_plus能告诉我们手机当前有没有激活的WiFi或蜂窝数据链路但它无法告诉我们“现在能不能真的访问目标服务器”。这两者的差距就是离线模式误判、刷新按钮无响应、弱网环境反复抖动等一系列问题的根源。这篇文章我会把Flutter联网检测这件事完整讲一遍connectivity_plus的边界在哪里为什么只看它的返回值会翻车如何用DNS解析做真实的连通性校验以及一个可以直接抄走的封装模块和几个平台差异下的真实踩坑案例。如果你正在写需要离线模式、状态提示、弱网判断的App这篇应该能对得上你踩过的坑。1. 为什么“有网络连接”和“能访问互联网”是两码事先看几个真实场景。酒店WiFi手机连上之后系统会弹一个浏览器认证页需要输入房号或手机号才能上网。在完成认证之前WiFi图标已经满格IP地址也拿到了但所有App请求都会失败。如果这时用connectivity_plus检查它大概率会返回wifi因为设备确实连上了一个WLAN网络。公司访客WiFi很多公司会为访客单独开一个无线网络这个网络只开放办公区局域网权限外部域名完全不通。更麻烦的是有些访客WiFi连DNS都能正常解析但TCP出口被网关策略挡死了HTTP请求一律超时。自建WiFi路由器宽带欠费、光缆故障、或者光猫拨号异常时路由器本身还能组建一个内网。手机连上去局域网内设备互通正常但出不了外网。系统层面依然把这个网络判定为“已连接”。在这些场景下操作系统网络管理器的状态全是connected因为它的职责是维护网络接口、IP地址、路由表而不是确认“互联网通了”。Android内部虽然有网络能力校验比如NET_CAPABILITY_VALIDATEDiOS也有Captive Network检测机制但这些结果不会完整暴露给应用层。connectivity_plus拿到的只是最外层的接口状态说白了就是“房间里的插座有没有通电”至于电网是否正常、电表是否欠费、对面服务器是否开机它一概不管。所以判断联网必须拆成两层逻辑第一层设备有没有可用的网络接口。第二层设备能不能完成一次真实的外部访问。只做第一层就会出现“报告显示有网线上却一直加载失败”的尴尬只做第二层在没有任何网络接口时做DNS探测又会有不必要的等待。完整方案应该是先快速判断接口再做真实的连通性验证。2. connectivity_plus 能做什么不能做什么2.1 基本用法和版本回调变化安装插件很简单flutter pub add connectivity_plus基本检测代码import package:connectivity_plus/connectivity_plus.dart; final ListConnectivityResult results await Connectivity().checkConnectivity(); if (results.contains(ConnectivityResult.none)) { // 当前确实没有可用网络接口 } else if (results.contains(ConnectivityResult.wifi)) { // 连上了WiFi但注意这只代表网络接口存在 }监听网络切换Connectivity().onConnectivityChanged.listen((ListConnectivityResult results) { // 4.x 版本开始回调里是 ListConnectivityResult // 3.x 版本是单个 ConnectivityResult升级后要适配 });这里有一个特别容易踩的版本坑connectivity_plus从4.x开始onConnectivityChanged回调的泛型从单个枚举变成了枚举列表很多老代码写的result ConnectivityResult.wifi会在类型上直接报错或者静默失效。我当时升级完依赖之后发现网络切换监听完全不触发排查了半天才发现是回调类型变了。如果你的项目里还有result ConnectivityResult.wifi这种写法一定要改成results.contains(ConnectivityResult.wifi)。2.2 这个插件验证的边界connectivity_plus的本质是读取系统网络接口状态。它能区分WiFi、蜂窝数据、以太网、蓝牙共享网络以及完全没有网络的状态。但它不会发起任何实际的数据请求也不会验证出口连通性。我用一个词总结它的能力边界接口状态不是互联网可用性。场景connectivity_plus 返回值真实网络状态正常家庭宽带wifi可用酒店认证WiFi未登录wifi不可用公司内网WiFi无外网权限wifi不可用飞行模式none不可用蜂窝数据正常mobile可用蜂窝信号满格但没开通流量mobile不可用从表格可以看得很清楚返回值是none时基本可以确定离线但返回wifi或mobile时并不能确定在线。这里做一个生活化类比connectivity_plus相当于告诉你“房间插座已经通电”但电线杆有没有被车撞倒、电表里还有没有余额、今天小区是不是轮休停电它都不知道。要验证能不能真的用上电唯一可靠的办法是插上充电器看手机是不是在充电。3. 用 DNS 探测做健康检查原理、实现与超时控制3.1 为什么选DNS而不是直接请求业务接口很多人想到的“真实连通性验证”是直接请求自己的后端API比如访问一个/health接口。这种做法在原理上没问题但在实际项目里会引入额外干扰如果API请求失败你很难区分是网络断了还是后端服务挂了如果后端接口响应很慢你还要单独设置一套更长的超时逻辑如果后端部署了CDN或者网关限流误判的概率就更大了。DNS探测则完全不同。它做的事情是向系统配置的DNS服务器发起一次域名解析只要拿到IP列表就算成功。这个操作非常轻量解析是否成功和设备能否访问公网高度相关。DNS查询成功至少证明从设备到DNS服务器的网络路径是通的而且这个设备具备解析公网域名的能力。如果连DNS都解析不出来那基本可以断定当前网络不具备访问外部服务的能力。dart:io库提供了现成的APIInternetAddress.lookup。它会查询系统配置的DNS服务器返回一组地址。查不到或者超时就会抛异常。3.2 一段能直接跑的最小实现import dart:async; import dart:io; Futurebool hasInternet() async { try { final results await InternetAddress.lookup(example.com) .timeout(const Duration(seconds: 3)); return results.isNotEmpty; } on SocketException { // 域名解析失败说明网络不可用 return false; } on TimeoutException { // 解析超时同样按不可用处理 return false; } }这里选了example.com作为探测域名。很多人会疑惑为什么不用baidu.com或google.com原因很简单example.com是RFC 2606中预留的示例域名专门用于开发测试全球网络环境都会给它做正常解析。它不受具体业务站点下线、被某个地区封锁、或者服务商DNS劫持策略的影响所以能最大程度降低误报概率。超时时间建议控制在2到3秒之间。设太短移动网络稍微波动就容易误判设太长断网场景用户要等很久才能看到离线状态。我实际调下来3秒是比较合适的中间值。3.3 多域名冗余和并发处理单一域名存在一个隐患如果当前网络环境恰好对该域名做了特殊处理比如某些企业内网的DNS只放行了少量域名那么单点探测就不可靠了。真实项目里应该准备一个域名池只要任意一个域名能解析成功就判定网络可用。const _checkHosts [example.com, example.net, example.org]; Futurebool checkNetwork() async { final tasks _checkHosts.map((host) async { try { final addresses await InternetAddress.lookup(host) .timeout(const Duration(seconds: 3)); return addresses.isNotEmpty; } catch (_) { return false; } }); final results await Future.wait(tasks); return results.contains(true); }这里的example.com、example.net、example.org都属于RFC保留的示例域名都稳定且中立。多个域名并发探测任何一个成功就认为有网能有效抵御DNS配置异常。有一点要说明Future.wait不是“短路返回”的实现它会等所有任务完成之后才统一汇总结果。如果追求更极致的速度可以用Future.any配合错误捕获但代码会复杂一些。大多数业务场景下Future.wait加3秒超时的方案已经足够。如果你在Web平台也有需求注意dart:io在Web端不可用InternetAddress.lookup没法跑。Web端要么通过http请求一个固定地址来探测要么直接依赖后端接口的返回码来判断。移动端主战场用上面的方案就够了。4. 一个完整可复用的联网检查模块4.1 模块设计思路实践中做联网检测最忌讳的就是每个页面各自写一套判断逻辑。多个页面同时问“现在有网吗”如果各自发起DNS查询既浪费资源又会出现A页面认为在线、B页面认为离线的混乱局面。所以我建议把联网检测封装成一个单例做三件事并发去抖同一时刻多个调用方询问网络状态时只执行一次真实探测所有调用方共享同一个结果。结果缓存网络状态在短时间内不会剧烈变化缓存5秒足够避免频繁请求。主动刷新用户点击刷新按钮时允许绕过缓存强制重新探测。这个模块不需要引入Riverpod或Bloc一个简单的单例类就能解决。4.2 完整代码实现import dart:async; import dart:io; /// 互联网健康检查器 /// 负责验证设备是否真正具备访问公网的能力 class InternetHealthChecker { InternetHealthChecker._(); static final InternetHealthChecker instance InternetHealthChecker._(); static const ListString _hosts [ example.com, example.net, example.org, ]; static const Duration _timeout Duration(seconds: 3); static const Duration _cacheValid Duration(seconds: 5); DateTime? _lastCheckTime; bool _lastResult false; Futurebool? _inflight; /// 检查网络健康状态 /// [force] 为 true 时绕过缓存强制重新探测 Futurebool check({bool force false}) { // 如果当前已经有一个探测在飞行中直接复用避免重复请求 if (_inflight ! null) { return _inflight!; } final now DateTime.now(); final cacheValid _lastCheckTime ! null now.difference(_lastCheckTime!) _cacheValid; if (cacheValid !force) { return Future.value(_lastResult); } final future _doCheck().then((result) { _lastCheckTime DateTime.now(); _lastResult result; _inflight null; return result; }); _inflight future; return future; } Futurebool _doCheck() async { final tasks _hosts.map((host) async { try { final addresses await InternetAddress.lookup(host) .timeout(_timeout); return addresses.isNotEmpty; } catch (_) { return false; } }); final results await Future.wait(tasks); return results.contains(true); } }代码里几个关键点再展开解释一下。_inflight字段是整个模块最核心的设计。当多个页面同时调用check()时第一个调用会触发真实的DNS探测后续调用会拿到同一个Future等探测完成所有等待方会同时收到结果。这能避免页面切换、多个列表同时刷新时瞬间发起一堆重复请求。缓存5秒的设计是经验值。太短起不到削峰作用太长网络状态已经变化了App还拿着旧结果。5秒正好覆盖大部分页面跳转和列表刷新的时间同时不至于让结果过时。这个模块还刻意没有把结果持久化到磁盘。网络状态变化太快磁盘缓存没有意义内存缓存就够了。每次App冷启动做一次新的探测成本很低。4.3 业务层怎么接入健康检查模块不要单独用最好和connectivity_plus做一个组合判断先看接口状态再看公网可达性。Futurebool checkReachable() async { final results await Connectivity().checkConnectivity(); final hasInterface results.contains(ConnectivityResult.wifi) || results.contains(ConnectivityResult.mobile) || results.contains(ConnectivityResult.ethernet) || results.contains(ConnectivityResult.bluetooth); if (!hasInterface) { return false; } return InternetHealthChecker.instance.check(); }为什么这么设计因为ConnectivityResult.none一定代表离线直接短路返回false能省一次DNS查询而ConnectivityResult.wifi或mobile并不能保证在线所以需要健康检查来兜底。UI层的用法也很简单。可以定义一个三态枚举enum NetStatus { checking, online, offline }页面启动时先置为checking等检测结果出来后再切换到online或offline。这个过程可以封装成ValueNotifierNetStatus或者配合状态管理框架使用。注意不要把具体的wifi、mobile暴露到UI层用户不关心你连的是什么类型的网络只需要知道“有网”还是“没网”。5. 平台差异与真实踩坑案例5.1 Android 和 iOS 的差异要提前知道同样一段代码在Android和iOS上的表现会有细微差异这来自系统底层网络框架的不同。Androidconnectivity_plus基于系统的ConnectivityManager返回的是当前活跃网络的类型。部分定制ROM在飞行模式下依然允许WiFi保持打开这会导致系统明明没有实际数据通路但插件返回wifi。另外Android各厂商的网络校验逻辑也不完全一致有的ROM对“WiFi需登录”的处理是当成正常网络有的则会短暂显示无网络。iOS底层基于网络路径network path监控系统层面会先做一个可用性判断。但iOS同样不检查公网可达性。最典型的案例是苹果的“自动加入”公共WiFi并弹出Web认证页的场景在认证完成前系统可能已经把这个网络标记为可用插件也会返回wifi业务请求却被门户网关拦截。模拟器和真机差异Android模拟器只要宿主网络正常默认网络状态永远是通的断网时也不一定触发回调iOS模拟器共享宿主机网络没法精确模拟弱网。联网检测相关改动我强烈建议放真机上验证否则很容易出现“模拟器上一切正常上线后被用户骂”的情况。双端返回不一致同一个WiFi环境里Android可能返回wifiiOS如果同时开启了蜂窝数据可能会返回包含多个结果的列表。所以最稳妥的做法是把UI状态收敛成online/offline两级不要在展示层纠结具体网络类型。5.2 一个可以复现的排查链路之前我处理过一个线下门店自助终端的项目设备一直显示“网络正常”但所有业务请求全部超时。这里把完整排查链路写下来遇到类似问题可以按这个顺序快速定位查看系统网络设置WiFi已连接IP地址和网关都正常系统层面没有红色警告。打印connectivity_plus返回值返回ConnectivityResult.wifi和系统设置一致确认App层判断“有网络”的依据来自接口状态。看业务请求的异常类型全部是超时不是连接被拒绝。这说明TCP层根本没有建立起来请求出不去。测试域名解析用系统工具尝试解析一个外部域名DNS查询直接失败。查看设备网络配置DNS被设置成一个固定的内网地址这台设备根本无法解析公网域名。结论connectivity_plus报告的是“有网络接口”但实际网络连域名解析都完成不了根本不具备访问外网的能力。加上健康检查模块后App状态正确变为离线自动切入缓存模式。这个链路最有价值的一点是从用户反馈到确认根因只需要几分钟。如果只盯着业务代码调超时、改重试几天都可能找不到真正的病根。5.3 认证型WiFi带来的额外复杂度商场、机场、酒店的公共WiFi是联网检测的终极考验。这类网络的规则通常是允许DNS解析允许访问认证服务器但屏蔽其他所有外部流量。所以单纯做DNS探测结果会是“有网”因为域名解析本身能通真实业务请求却被拦截。处理方式是在业务层加一个“认证拦截”识别。当HTTP请求收到重定向响应且重定向地址指向的域名是当前WiFi的认证门户域名时UI层提示“请先完成WiFi认证”。这个属于更细的层次大多数项目的用户群体不常遇到但如果你做的是商超、机场类App提前加上这个识别会少很多客诉。6. 性能、时机和用户体验的取舍6.1 不要一有回调就疯狂探测onConnectivityChanged在做WiFi和蜂窝切换、信号不稳时会连续触发多次。如果每收到一次回调就发起一轮DNS并发探测会瞬间产生一堆孤儿请求反而拖慢其它网络任务。建议做防抖处理Timer? _debounce; void onConnectivityChanged() { _debounce?.cancel(); _debounce Timer(const Duration(seconds: 2), () { InternetHealthChecker.instance.check(force: true); }); }2秒的窗口期能把这期间的多次回调合并成一次真实探测。如果当前已经有探测在飞行直接复用结果不需要再发新的请求。6.2 提示用户的方式要克制联网检测做出来后最容易翻车的地方其实是提示语。每次状态切换都弹Toast是最招差评的做法。用户在地铁里过隧道WiFi和蜂窝来回切换手机上像复读机一样不停弹“网络已恢复”体验非常糟糕。我的建议是只在从“离线”变“在线”且用户当前停留在离线提示页时展示一次轻量提示。从“在线”变“离线”时不弹提示直接把UI切成离线状态或者展示缓存内容。同一次离线状态周期内最多提示一次不反复打扰。6.3 超时和重试要分层不要混在一起完整的网络判断其实是三层每一层的职责不同接口状态层用connectivity_plus快速判断none直接离线。连通性健康层用DNS探测判断公网是否可达。业务请求层用API自身的超时、重试、错误码处理服务端问题。有一个常见误区是健康检查通过之后就认为业务请求也一定成功于是把业务层的超时时间缩短到3秒。这会导致一个问题——健康检查通过只是证明“网络通路存在”目标服务器可能正在重启、可能5xx、可能是域名被限流。业务层必须保留独立的超时和错误分类逻辑。6.4 启动检测和离线缓存策略App启动时跑一次健康检查并缓存结果比等用户点按钮再转圈体验好很多。健康检查本质是一次DNS查询耗时通常在几十毫秒到几百毫秒之间对启动过程几乎无感。离线场景下推荐把上次成功加载的页面数据缓存到本地。没有网的时候展示旧数据远比一个空白页和转圈图标强。UI上可以区分两种状态首次加载失败和离线内容展示前者引导用户重试后者直接展示缓存并加一个“内容可能不是最新”的小提示。我自己在项目里最后沉淀下来的原则很简单用ConnectivityResult做第一层快速判断用DNS健康检查做第二层真实校验业务请求错误单独处理。这套组合在几个真实项目里跑下来能覆盖绝大多数异常场景也不会给用户反复弹提示。如果你现在正被“有网却加载失败”这种反馈折磨可以按这个思路理顺一下。另外分享一个最后的小技巧把健康检查结果缓存到内存单例里所有依赖网络的UI都统一问这个单例而不是各自判断这样错误提示的口径才能保持一致。