前阵子把一个智能家居 App 的 Flutter 工程往 OpenHarmony 设备上迁移,页面、状态管理、网络层都还算顺利,卡得最久的反而是一个平时没人注意的插件:flutter_iot_wifi。这个插件干的是 IoT 设备 WiFi 配网里最基础的事——扫描附近热点、读取当前连接 SSID、连接指定 WiFi,是配网链路里绕不开的一环。如果你也在做 Flutter 鸿蒙化适配,尤其是智能家居、摄像头、门锁这类需要配网能力的项目,这篇文章应该能帮你少踩几个坑。
我把这次适配过程完整复盘一遍,包括工程结构差异、MethodChannel 桥接方式、@ohos.wifiManager的具体调用姿势、权限和 Context 的坑,以及最后真机验证的清单。整个过程不复杂,但细节特别多,任何一个地方没对齐,配网就静默失败。
1. 被低估的 flutter_iot_wifi:项目立项时没人想到它是鸿蒙化的硬骨头
1.1 这个插件在配网链路里的真实职责
先捋清楚一件事:flutter_iot_wifi不是什么炫技插件,它在 IoT 配网里扮演的是"系统 WiFi 能力翻译官"。常见的配网方式有两种,一种是设备开热点,手机连上设备热点后把目标 WiFi 的 SSID 和密码发给设备;另一种是手机和设备处在同一个局域网,通过广播或扫码方式把 WiFi 凭证下发。不管哪种方式,App 端都需要能扫描 WiFi 列表、获取当前连接的 WiFi 名称、发起连接切换,这些能力在 Android/iOS 上被封装成了插件方法。
这个插件实际暴露给 Dart 层的方法通常是这几个:
getSSID:获取当前手机连接的 WiFi 名称。scan:触发一次 WiFi 扫描。getScanResults:拿到扫描到的热点列表,包括 SSID、BSSID、信号强度、加密方式等。connect:连接指定 WiFi,配网场景里一般用于从设备热点切回目标路由器。
在 Android 上这些功能全靠WifiManager配合一组动态权限实现;在 iOS 上由于系统限制,大部分插件只能做到跳转系统设置页,真正能扫描和连接 WiFi 的少之又少。所以这个插件天然有很强的 Android 倾向,鸿蒙化的时候没法简单平移,得对着系统 API 重新实现一遍。
1.2 为什么说最难的是"系统能力适配"
Flutter 框架本身确实是跨平台的,Dart 层代码基本不用改,但插件不跨平台。插件本质是 MethodChannel 桥接到原生能力,Android 端写得再好,到了 OpenHarmony 上也得重写原生侧。难点集中在三块:
第一,WiFi 系统 API 完全不同。Android 是WifiManager,OpenHarmony 是@ohos.wifiManager,方法名、参数类型、回调模型全不一样。第二,权限模型不一样。Android 的 WiFi 权限集中在ACCESS_FINE_LOCATION、CHANGE_WIFI_STATE这类,OpenHarmony 则是ohos.permission.GET_WIFI_INFO、ohos.permission.SET_WIFI_INFO、ohos.permission.MANAGE_WIFI_CONNECTION等,命名体系基本不重叠。第三,设备环境差异大。同一个 API 在不同的 OpenHarmony 版本、不同芯片方案上表现可能差很多,x86 模拟器上 WiFi 模块甚至可能是摆设。
理解了这个背景,你就明白为什么说"Flutter 鸿蒙化"最花时间的往往不是 UI,而是这些系统能力插件。下面我按实际适配顺序讲。
2. 鸿蒙端 Flutter 插件开发:从 Dart 通道到 ArkTS 原生实现的桥接差异
2.1 先搭好 OpenHarmony Flutter SDK 环境
鸿蒙化适配第一步,是确认你手上的 Flutter 是不是支持 OpenHarmony 的版本。标准 Flutter SDK 目前还不会直接识别 OpenHarmony 设备,得用社区维护的 OpenHarmony 分支,或者华为提供的 Flutter OHOS SDK。这一步最容易踩的坑是环境串了:电脑上既有标准 Flutter 又有 OHOS 分支,命令敲错就构建到错误目标上。
我的建议是用fvm管理多版本 Flutter。项目根目录加一个.fvmrc,里面锁定用于 OpenHarmony 构建的 Flutter 版本,然后统一用fvm flutter执行命令。这样标准 Flutter 和 OHOS 分支互不干扰,哪天切回 Android/iOS 构建,flutter pub get也不会把平台目录搞乱。
环境准备好之后,用flutter doctor确认设备发现是否正常。能识别到 OpenHarmony 设备通常意味着 SDK 路径、系统镜像都对齐了。如果识别不到,先检查 DevEco Studio 版本和 Flutter OHOS 分支的匹配关系,不要一上来就怀疑项目配置。
2.2 插件工程的 MethodChannel 注册方式
flutter_iot_wifi的 Dart 侧代码不用动,还是那套MethodChannel。比如定义通道名和调用方法:
class FlutterIotWifi { static const MethodChannel _channel = MethodChannel('flutter_iot_wifi'); static Future<String?> get ssid async { return await _channel.invokeMethod('getSSID'); } static Future<List<dynamic>> scan() async { return await _channel.invokeMethod('scan'); } static Future<bool> connect(String ssid, String password, String securityType) async { return await _channel.invokeMethod('connect', { 'ssid': ssid, 'password': password, 'securityType': securityType, }); } }鸿蒙原生侧要做的事,是把这个flutter_iot_wifi通道注册到插件的原生模块里,实现对应的 MethodCallHandler。OpenHarmony 的 Flutter 插件原生侧推荐用 ArkTS 写,注册方式大致是:
import { MethodChannel } from '@ohos/flutter_ohos'; let channel = new MethodChannel('flutter_iot_wifi'); channel.setMethodCallHandler((call) => { switch (call.method) { case 'getSSID': return getCurrentSsid(); case 'scan': return scanWifi(); case 'connect': let args = call.arguments as Record<string, string>; return connectWifi(args['ssid'], args['password'], args['securityType']); default: return Promise.reject('method not found'); } });注意不同版本的 OHOS Flutter SDK 里MethodChannel的导入路径和构造函数可能有差异,但注册逻辑基本是这个套路。重点是通道名必须和 Dart 侧完全一致,大小写都不能差;方法名、参数 key 也一样。我建议把方法名和参数名抽成常量,Dart 和 ArkTS 各自维护一份,避免改了 Dart 侧忘改原生侧。
2.3 Flutter 版本和 OpenHarmony SDK 版本的匹配关系
还有一点必须提前摸清:Flutter 版本和 OpenHarmony SDK 版本是有对应关系的。社区维护的 OHOS Flutter 分支一般会滞后上游 Flutter 几个版本,不可能上游刚发 3.24 你立刻在 OpenHarmony 上用。项目如果用了很新的 Flutter 特性,要看 OHOS 分支是否已经支持,否则编译期就会挂。
这个版本匹配问题还牵扯到渲染。OpenHarmony 设备上跑 Flutter,某些版本默认开启 Impeller 渲染后可能出现画面渲染异常,比如黑屏、残影、文字不刷新。热搜词里 "openharmony 画面渲染异常" 和 "flutter impeller" 其实说的就是这一类问题。遇到这种情况先别怀疑插件适配,可以用启动参数临时切回 Skia 渲染试试,比如在启动时加渲染相关参数。我之前因为渲染异常白屏,一度以为是flutter_iot_wifi把主线程卡死了,查了很久才发现是渲染器兼容性问题。
另外注意构建工具链的差异。OpenHarmony 的 Flutter 工程构建 HAP 包用的是hvigor,不是 Android 的 Gradle。如果在构建时报 "you are applying flutter's main gradle plugin imperatively using the apply" 这类错误,多半是用了错误的构建命令或者分支混用。这个提示会误导人往 Gradle 方向排查,实际上鸿蒙侧根本不走那套链路。
3. 配网能力逐段翻译:从 Android WifiManager 到 OpenHarmony wifiManager
这一章是适配的核心,我把flutter_iot_wifi的每个方法怎么在鸿蒙上实现讲清楚。这里所有 API 我基于 OpenHarmony 常见的 WiFi 能力接口来说明,具体方法名可能随 SDK 版本略有调整,但实现思路是一样的。
3.1 扫描周边 Wi-Fi:方法名一样,回调模型完全不同
Android 上扫描 WiFi 是两步走:先wifiManager.startScan(),然后监听SCAN_RESULTS_AVAILABLE_ACTION广播,广播到了再调getScanResults()。OpenHarmony 的做法更像 Promise 风格,扫描和拿结果都是直接调用模块方法。
import wifiManager from '@ohos.wifiManager'; async function scanWifi(): Promise<Array<Record<string, Object>>> { let results: Array<Record<string, Object>> = []; if (!wifiManager.isWifiActive()) { await wifiManager.setWifiEnabled(true); } await wifiManager.scan(); // 扫描结果不是立即生效,需要给系统一点时间缓存 let scanInfos = await wifiManager.getScanResults(); scanInfos.forEach((item) => { results.push({ 'ssid': item.ssid, 'bssid': item.bssid, 'securityType': securityToString(item.securityType), 'rssi': item.rssi, 'frequency': item.frequency, }); }); return results; }这中间有一个非常隐蔽的坑:scan()返回成功不代表扫描结果已经刷新,系统固件从扫描完成到结果可查之间可能有几百毫秒甚至更长的延迟。如果scan()之后立刻调getScanResults(),拿到的很可能是上一次扫描的缓存。保险的做法是拿到scan()的完成信号后,稍微等一下再读结果,或者注册 WiFi 扫描完成事件,事件到了再读取。具体等多久因设备方案而异,保守起见 1 秒是底线。
另一个坑是权限。扫描结果里包含热点名称和 BSSID,这在很多系统里属于敏感信息,除了 WiFi 权限外可能还需要位置权限。App 没授权位置信息时,getScanResults()可能直接抛错,也可能返回空数组,不同版本表现不一样。适配的时候一定要把错误码透传回 Flutter 层,而不是吞掉之后返回空列表,否则业务层会误以为是"周围没有 WiFi"。
3.2 连接指定 Wi-Fi:从 WifiConfiguration 到 CandidateConfig
Android 上连接 WiFi 的传统写法是构造一个WifiConfiguration,设置 SSID、密码、安全类型,然后addNetwork再enableNetwork。OpenHarmony 的接口设计有所不同,一种常见思路是用候选配置的方式:先把目标 WiFi 配置加入候选列表,再发起连接。
async function connectWifi(ssid: string, password: string, securityType: string): Promise<boolean> { let security = parseSecurityType(securityType); let config: wifiManager.WifiDeviceConfig = { ssid: ssid, preSharedKey: password, securityType: security, }; let configId = await wifiManager.addCandidateConfig(config); await wifiManager.connectToCandidateConfig(configId); return true; }这里有个细节需要特别小心:不同 OpenHarmony 版本对"连接 WiFi"的 API 命名差异很大。有的版本提供的是connectToDevice(config),有的版本提供addCandidateConfig + connectToCandidateConfig,甚至部分版本还要求传入 BSSID 才能保证连接的是目标路由器而非同名热点。我在适配时专门翻了好几版 SDK 的接口差异,最后选定候选配置方案,因为它在无密码和 WPA2 场景下表现最稳定。
安全类型映射也是个容易翻车的地方。Dart 层传来的是字符串,比如'WPA'、'WPA2'、'OPEN',鸿蒙侧必须要转成系统枚举。映射写错最典型的情况是:开放网络却填了 WPA2,结果手机一直连不上目标 WiFi。建议在插件层做一次严格的白名单映射,遇到不认识的字符串直接返回错误,而不是默认当 WPA2 处理。
3.3 获取当前 SSID:看似简单,却最容易暴露权限配置问题
获取当前连接 WiFi 的 SSID 在 Android 上是wifiManager.connectionInfo.ssid,OpenHarmony 上对应的是wifiManager.getLinkedInfo():
async function getSSID(): Promise<string> { let linkedInfo = await wifiManager.getLinkedInfo(); return linkedInfo.ssid; }这个接口看起来简单,但实际踩坑概率很高。如果你的权限配置只声明了ohos.permission.GET_WIFI_INFO,某些版本上能拿到 WiFi 开关状态,但拿不到 SSID 字段,返回空字符串。需要把权限补齐,比如增加ohos.permission.GET_WIFI_CONFIG或者位置相关权限。最头疼的是这种缺失不会让接口抛异常,而是静默返回空值,等传到 Flutter 层就变成"没有连接 WiFi"的假象,排查起来非常绕。
所以适配这个模块时,最好在getSSID的返回值里同时带上当前连接的 BSSID 和 IP 信息,这样排查问题时有更多线索。另外,如果设备没有连接任何 WiFi,不同版本可能抛errorCode,也可能返回一个有默认值的对象,Dart 侧要统一做空安全处理。
3.4 配网特有的"热点模式":从设备热点切回目标路由器的完整链路
IoT 配网里最典型的一个场景是设备热点配网。手机先连接设备开出来的热点(比如SmartLife_XXXX),然后 App 自动调用插件把手机切回目标 WiFi。这个切换过程不是发一条命令就结束,需要等待系统真正连上目标 WiFi,并且要感知到"手机已经和旧热点断开"这个状态变化。
OpenHarmony 侧可以用wifiManager.on('wifiConnectionChange', callback)监听连接状态变化。需要在插件里把这种事件通过 EventChannel 暴露给 Flutter 层,Dart 侧才能拿到连接成功或失败的结果。如果不想引入 EventChannel,也可以在 Dart 侧轮询getSSID,每 500ms 查一次,等 SSID 变成目标 WiFi 名称就认为切换成功。轮询方案实现简单,适配初期先用它快速跑通,后期待 UI 稳定了再替换成事件驱动。
热点模式还有一个体验问题:切换 WiFi 会让当前 App 的 Socket 连接全部断开,如果配网进程正在和设备做数据交互,必须先让设备侧进入"等待 WiFi 凭证"的状态,再发起切换,否则设备那边会直接超时。
3.5 返回数据结构保持兼容,别让业务层到处"if (isOpenHarmony)"
最后一点很重要:鸿蒙端实现的返回数据结构,要和 Android 端保持一致。比如 Android 版的扫描结果返回Map,key 是字符串,那么鸿蒙版也必须是同样的 key 集合;安全类型返回的是字符串'WPA2',鸿蒙版就不要返回枚举值。这样业务层代码不用为鸿蒙单独写一套分支,适配成本和回归风险都小很多。
如果 Android 版某些字段在鸿蒙上拿不到,宁可先不返回该字段,也不要返回一个类型不匹配的占位值。Dart 层解析时用as Map<dynamic, dynamic>再做一次类型收窄,比依赖StandardMethodCodec自动转换更安全。
4. 权限、Context 与线程:三处最容易让真机崩溃的细节
4.1 权限声明:不是写在 module.json5 里就完事
Android 的权限配置在AndroidManifest.xml,OpenHarmony 对应的是entry/src/main/module.json5里的requestPermissions。以 WiFi 配网能力为例,通常要声明以下几类权限:
{ "module": { "name": "entry", "requestPermissions": [ { "name": "ohos.permission.GET_WIFI_INFO" }, { "name": "ohos.permission.SET_WIFI_INFO" }, { "name": "ohos.permission.LOCATION" }, { "name": "ohos.permission.MANAGE_WIFI_CONNECTION" } ] } }这里有两个陷阱。第一,不同 OpenHarmony 版本的权限名不完全一致,比如MANAGE_WIFI_CONNECTION在某个 API 等级之后可能改名或拆分,直接照抄网上旧教程会掉坑。第二,位置权限通常属于动态权限,光声明还不够,运行时得通过系统能力发起用户授权,用户点击"允许"之后才能正常扫描 WiFi。如果跳过动态授权,扫描结果就是空。
另外,插件的模块和应用主模块的权限声明是叠加关系。如果这是你自定义插件,记得把权限声明写到最终会打包进 HAP 的模块里,而不是写在纯插件工程的配置里就算了。我之前在 DevEco Studio 里改的是插件子工程的配置文件,打包出来 HAP 里根本没有权限,真机上直接表现为功能失效,这个问题花了大半天才定位到。
4.2 用对 Context:Stage 模型下别乱拿全局上下文
OpenHarmony 应用默认使用 Stage 模型,整个应用的生命周期和 Android 的 Activity 模型很不一样。WiFi 接口本身大多不需要 Context,但如果你要实现"跳转到系统 WLAN 设置页"这类功能,就必须要有正确的UIAbilityContext,不能随手在模块顶层调getContext()。
我的做法是在 EntryAbility 的生命周期里拿到 context,然后注入到插件模块中:
onWindowStageCreate(windowStage: window.WindowStage): void { let context = this.context; // 注入到插件或工具类中 WifiBridge.getInstance().setContext(context); }千万注意不要在onCreate阶段就调用需要 UI 上下文的方法,这时候窗口还没创建完,一些系统服务未必可用。我见过有同事把 WiFi 初始化逻辑放在 Application 级入口里,结果在冷启动阶段直接崩了。
跳转 WLAN 设置页在 OpenHarmony 上也没有 Android 那么简单,不同的系统版本可能要通过不同的 Ability 名称拉起设置应用。如果只是配网场景,我的建议是不做跳转系统设置,而是把"去设置页手动连接"当作备用引导文案,让用户自己打开系统设置,省去几十行系统能力差异的适配代码。
4.3 线程与回调:别把扫描结果序列化全塞到 Flutter 主线程
wifiManager.scan()是异步 Promise,本身不会阻塞主线程,但要注意整个链路的线程模型。Flutter 的 MethodChannel 调用进来之后,ArkTS 侧的处理逻辑如果太耗时,比如把大量扫描结果做字符串拼接、类型转换、JSON 序列化,仍然会卡住 UI 响应。
OpenHarmony 上做耗时操作的正规姿势是用TaskPool或 Worker 把计算任务分发出去,整理好结果再通过 Promise 返回给 Flutter。不过配网场景的扫描结果数据量通常不会特别大,一般几十个 AP,直接在主模块里处理问题不大。
真正需要加锁的是重复调用问题。用户手快,点了三次扫描按钮,Dart 侧连着三次调scan(),底层就可能出现上一次扫描还没结束、下一次又发起的情况,结果返回的数据错乱或直接超时。我在适配时给扫描动作加了一个信号量:如果上一次扫描尚未完成,新的扫描请求直接返回最近一次成功的结果,而不是重新触发系统扫描。这样用户感知不到异常,代码也不会崩。
4.4 状态监听:EventChannel 的鸿蒙实现和释放逻辑
如果你要做连接状态感知,就要在插件里实现 EventChannel。Android 用 BroadcastReceiver 注册监听,鸿蒙用wifiManager.on('wifiConnectionChange', ...)。对应关系是:
wifiManager.on('wifiConnectionChange', this.onWifiConnectionChange); wifiManager.off('wifiConnectionChange', this.onWifiConnectionChange);EventChannel 的 StreamHandler 里,onListen回调触发时注册系统监听,onCancel回调触发时注销。这里最容易被忽略的是:多个页面同时监听同一个 EventChannel 时,StreamHandler 的onCancel可能在还有页面需要监听的时候就被触发,导致其余页面收不到事件。我建议状态监听只做一层封装,由单例对象持有真正的系统监听,页面订阅和取消订阅只在 Dart 侧维护计数,不要反复on/off系统监听。
另外,如果 App 进入后台,WiFi 状态变化事件依然可能触发,Dart 侧收到事件后如果去做 UI 更新,要考虑页面是否在前台。我见过配网页退到后台后,连接成功回调回来时页面已经释放,导致状态错乱。适配时可以在 Dart 侧加一个生命周期标记,只有页面可见时才处理状态流转。
5. 真机与模拟器上的实测复盘:现象、根因、验证清单
5.1 模拟器上扫不到热点:是真的不行,不是代码错
适配过程中最打击人的一幕是:代码逻辑检查了好几遍,权限也补了,API 也对齐了,但在 OpenHarmony 官方 x86 模拟器上跑,getScanResults()永远是空数组。一开始我怀疑是权限动态申请没生效,反复切授权流程,最后才发现是这个模拟器镜像的 WiFi 模块本身就不完整。
这不是个例,OpenHarmony 的 x86 模拟器对无线网卡的虚拟化支持一直很弱,很多镜像里 WiFi 功能就是一个空壳,能开关但不能真正扫描。如果你也遇到"模拟器上跑不了配网功能",不要死磕,尽早换到 arm64 开发板或真机上验证。热搜词里 "openharmony x86" 和 "liteos-m openharmony 设备兼容性测评" 能看出大家在不同设备形态上适配时都栽过类似的跟头。
我在 RK3568 开发板上测试时,同一份代码在半小时内就扫到了一堆热点,顺利得让人害怕。现在我的习惯是:凡是涉及系统硬件能力的插件适配,第一时间就用开发板做真实验证,不要在模拟器上浪费超过一晚上。
5.2 Impeller 渲染异常与 Flutter 分支切换的连带问题
前面说过渲染异常容易干扰适配排查。这里补充一个实际场景:我把 App 跑在 OpenHarmony 平板上,第一次进入配网页时页面正常,从配网页退出再进入,页面偶尔会变成灰屏。一开始我以为是 EventChannel 泄漏导致内存暴涨,抓了半天内存才发现是渲染层问题。
后来切到软件渲染模式后灰屏消失。虽然软件渲染性能差一些,但配网页本身 UI 不复杂,对性能要求不高。如果你的 App 在鸿蒙上遇到偶发性白屏、残影、文字重叠,可以先切渲染模式验证,排除掉渲染问题后再回头排查插件逻辑。不然很容易被表面现象带偏,把时间浪费在明明没问题的代码上。
另外,用fvm切换分支时注意一个顺序问题:切换 Flutter 版本之后,要重新执行fvm flutter pub get,否则ohos平台目录可能是旧的。我遇到过切分支后直接构建 HAP,结果插件原生目录缺失,报错信息又给得模棱两可,最后重新 pub get 才解决。
5.3 一份可复用的验证清单
整个适配完成后,我用一份清单在开发板上逐项过,避免遗漏。你也照着测一遍,能省不少返工时间。
| 验证项 | 操作 | 预期结果 |
|---|---|---|
| 设备发现 | flutter doctor | 能识别到 OpenHarmony 设备 |
| 权限声明 | 检查 module.json5 | 包含 WiFi 相关权限且版本正确 |
| 动态授权 | 首次启动配网页 | 弹出位置授权框,拒绝后不崩溃 |
| 扫描热点 | 调 scan + getScanResults | 能列出周边 AP,含 SSID、RSSI、安全类型 |
| 连接开放网络 | connect(无密码) | 手机成功连接目标 WiFi |
| 连接 WPA2 网络 | connect(WPA2) | 手机成功连接目标 WiFi |
| 获取当前 SSID | getSSID | 返回正确 WiFi 名称,非空 |
| 状态变化事件 | 手动断开 WiFi | Dart 侧能收到连接状态变化 |
| 异常输入 | 传空 SSID 或错误安全类型 | 返回明确错误码,不崩溃 |
| 回归 Android/iOS | 同一份 Dart 代码切回标准 Flutter | 配网功能不受鸿蒙分支影响 |
适配完成的那天晚上,我反复来回验证这十项,发现最值得骄傲的不是把代码跑通了,而是找到了一套不会误导自己的排查路径:先确认 Flutter 分支和渲染模式,再确认权限三件套,最后才怀疑 API 调用本身。flutter_iot_wifi的鸿蒙化适配本身不算难,难的是在一堆容易混淆的现象里,准确找到真正的问题根源。
最后再分享一个小技巧:调试阶段在插件原生侧把每个方法的入参、错误码、耗时都打日志,输出到文件。配网场景涉及手机和设备的交互,一旦出错,App 端、设备端、路由器三方都可能成为疑点。有了完整日志,你能快速判定是插件没发起扫描,还是扫描到了但连接失败,省下来的是半夜抓耳挠腮的时间。