uni-app 原生插件实战:Android WiFi 模块与双向通信全解
简介面向希望借助uni-app打通Android原生能力的开发者这份资源聚焦uni-app与Android原生通信及WiFi开关控制适合需要实现跨端硬件交互的移动端工程师。内容从uni.$invokeMethod调用原理讲起依次涵盖BroadcastReceiver广播通信机制、WifiManager开关封装、Android 6.0以上运行时权限动态申请并配有uni_demo示例项目可直接查看原生插件与JS调用侧的完整写法。资源共38个文件以js、vue、json等前端代码为主另含aar原生插件包、apk安装包、png图片及CSS样式可直接安装体验压缩包约11.71MB目录结构简洁便于对照学习。已有2487人学习下载。借助源码可系统梳理前端调用、原生插件注册、权限处理、真机调试的完整链路还可延伸理解iOS原生插件的实现差异对想要自主封装uni-app原生能力的人很有参考价值。1. uni-app 调用 Android 原生能做什么一个模块打通双端通信与 WiFi 开关产品提了个需求“App 里点个按钮把 WiFi 打开顺便把周围热点列出来”。前端说只能跳系统设置客户端说排期下个月。这个项目用 uni-app 的原生插件机制给了第三条路写一个继承 UniModule 的 Android 模块在 assets 里注册一次前端用uni.requireNativePlugin(WifiModule)就能拿到原生能力。通信上它覆盖了三种形态——同步拿状态、异步回调结果、原生主动推事件功能上落地了打开 WiFi、跳系统设置、扫描附近热点三件套。适合被原生排期卡住的前端也适合想沉淀公用原生能力的客户端。按我拆这个项目的顺序下面依次讲通信架构、WiFi 核心实现、工程配置和避坑记录最后给两条能直接复用的改造路线。2. 通信架构拆解dcloud_uniplugins.json 与 UniModule 的方法桥2.1 为什么选 module 扩展而不是 component 或原生 Activityuni-app 的原生插件协议把 Android 扩展能力分成三类。第一类是 module纯方法级扩展前端调用时走方法分发适合打开 WiFi、读取电量、触发支付这种无界面逻辑。第二类是 component自定义 View适合地图、扫码这类需要原生 UI 控件的场景。第三类是直接塞一个原生 Activity 或 BroadcastReceiver前端用plus.android启动它。三种形态里module 是成本最低、复用面最广的一种——它不依赖页面生命周期不引入额外布局文件老工程里加个类就能接入。这个项目选 module 的原因也在这WiFi 操作本身是系统服务调用不需要自定义 View标题里的“通信”落点其实是双向数据流而 module 扩展的UniJSCallback模型恰好能把这件事讲清楚。页面 UI 继续由 Vue 模板负责原生只负责系统能力职责边界很干净。如果你后续要做的能力是扫码、地图这种需要原生视图的再考虑 component那是另一套继承树。2.2 插件注册清单dcloud_uniplugins.json 逐字段拆解只要 Android 工程不是用 uni-app 离线 SDK 模板初始化的它就不会自动知道你要扩展什么。运行时插件加载的入口是一个 JSON 文件路径固定在app/src/main/assets/dcloud_uniplugins.json。这个文件的内容决定了 App 启动后插件管理器能扫描到哪些原生类。{ nativePlugins: [ { plugins: [ { type: module, name: WifiModule, class: com.example.uniappwifi.WifiModule } ] } ] }三个字段各有各的坑。type决定插件被装进哪一类管理器module 扩展按模块名索引。name是前端uni.requireNativePlugin(WifiModule)里填的那个字符串一一对应大小写敏感。class必须是编译期内真实存在且继承了 UniModule 的类全路径写错了类应用倒是能启动但requireNativePlugin永远拿不到实例。我在拆这个项目时见过一种翻车场景开发在 Android Studio 里改了类名assets 里 json 没同步旧名字躺了两周。因为 HBuilderX 打包自定义基座时并不会校验这个文件的内容它只是把 assets 原样塞进包。所以我的习惯是每次改完插件类名解压 apk 看一眼 assets 目录确认 json 已更新。2.3 原生方法暴露与参数契约UniJSMethod 和 JSONObject让一个方法能被 JS 调用核心是两件事方法标上UniJSMethod注解参数按框架约定排列。前端传进来的参数对象会被封装成一个 fastjson 的JSONObject放在方法签名的对应位置。回调则是一个UniJSCallback实例它和前端传入的箭头函数一一对应。package com.example.uniappwifi; import android.content.Context; import android.net.wifi.WifiManager; import com.alibaba.fastjson.JSONObject; import io.dcloud.feature.uniapp.annotation.UniJSMethod; import io.dcloud.feature.uniapp.bridge.UniJSCallback; import io.dcloud.feature.uniapp.common.UniModule; public class WifiModule extends UniModule { private WifiManager getWifiManager() { Context context mUniSDKInstance.getContext(); return (WifiManager) context.getSystemService(Context.WIFI_SERVICE); } UniJSMethod(uiThread true) public void toggleWifi(JSONObject options, UniJSCallback callback) { if (options null) { options new JSONObject(); } WifiManager manager getWifiManager(); boolean enable options.getBooleanValue(enable); boolean needFeedback options.getBooleanValue(feedback, true); boolean changed manager.setWifiEnabled(enable); JSONObject result new JSONObject(); result.put(changed, changed); result.put(enabled, manager.isWifiEnabled()); if (needFeedback callback ! null) { callback.invoke(result); } } }代码逻辑很直接方法先用options判空再取出布尔参数enable和feedback接着调用系统 WiFi 服务改状态把结果封装成 JSONObject 后只有当前端要求反馈时才回调callback.invoke(result)。如果不调用 callback前端传入的回调函数不会触发页面也就收不到任何响应——这是 module 扩展里“选择性返回”的实现方式不是 bug。参数说明部分容易被忽略的是getBooleanValue第二个参数充当默认值。当前端没传feedback字段时用它兜底不会抛空指针。我在真实项目里习惯在回调的 JSONObject 里带回完整状态而非只回成功或失败因为前端 UI 的渲染往往需要真实状态而不是你判断出来的状态。2.4 三条通信路径同步返回、异步回调、主动事件推送一个模块里其实并存三种通信路径。同步返回好理解像读 WiFi 开关状态这种只读方法前端调用后立即拿一个 JSONObject。异步回调用的是UniJSCallback关键点是它可以被多次调用——原生方法里把它存成成员变量就能在广播接收器回调、异步任务完成后把结果推回前端这是扫描 WiFi 的核心姿势。第三条路径是原生主动推事件用mUniSDKInstance.fireGlobalEventCallback(wifiStateChanged, data)可以在任意线程发起适合 WiFi 断开提醒、网络切换这类“不是调用响应”的异步场景。前端接主动事件时要在 onLoad 里注册一个与原生约定好的事件名监听// pages/wifi/index.vue原生主动事件接收示例 onLoad() { uni.onNativeEvent(wifiStateChanged, (data) { this.wifiState data.wifiState console.log(原生推送的 WiFi 状态, data.wifiState) }) }注意这段代码有个对称性问题原生侧在registerNetworkListener里注册了WIFI_STATE_CHANGED_ACTION广播接收器那么前端 onUnload 时必须主动调用模块里的注销方法否则页面反复进出会累积接收器。很多项目会在内存暴涨时才发现这个对称性问题后面避坑章里我会专门展开。3. 核心模块实战WiFi 开关、系统设置兜底与异步扫描3.1 权限矩阵不同 Android 版本对 WiFi 能力的收窄WiFi 模块的权限不是一个 WiFi 权限就够的。放权限这件事从 Android 6 到 Android 13 一直在收口同一个功能在不同系统版本的权限要求完全不一样。这个项目里我最终保留的权限清单是这样uses-permission android:nameandroid.permission.ACCESS_WIFI_STATE / uses-permission android:nameandroid.permission.CHANGE_WIFI_STATE / uses-permission android:nameandroid.permission.ACCESS_FINE_LOCATION / uses-permission android:nameandroid.permission.ACCESS_COARSE_LOCATION / uses-permission android:nameandroid.permission.NEARBY_WIFI_DEVICES /前两个是 WiFi 开关和状态查询的基础权限。第三个和第四个是扫描附近热点必须的定位权限从 Android 9 开始系统把 WiFi 扫描结果视为隐私数据应用必须持有定位权限才能拿getScanResults()。最后一个NEARBY_WIFI_DEVICES是 Android 13 引入的附近设备权限用来替代定位权限但真机上仍然可能需要同时保留定位权限各家 ROM 处理并不完全一致。权限声明只是第一步运行时的动态权限请求必须在调用扫描之前完成。这个项目里我把权限检查放在原生模块的scanWifi方法内部——前端只管调用原生侧统一处理权限、返回统一格式的错误码这样的封装对多个前端页面复用模块时是省事的。3.2 打开 WiFi 的两种实现以及 Android 10 的限制“打开 WiFi”这个需求在业务上要拆成两种实现。一种是通过WifiManager.setWifiEnabled(true)直接由应用改开关这个 API 在 Android 10 之后被系统标记为 deprecated部分机型上调用没有任何效果系统会忽略非系统应用对 WiFi 开关的修改。另一种是拉起系统的 WiFi 设置页让用户手动打开开关。UniJSMethod(uiThread true) public void openWifi(JSONObject options, UniJSCallback callback) { WifiManager manager getWifiManager(); boolean direct options ! null options.getBooleanValue(direct, false); JSONObject result new JSONObject(); result.put(enabled, manager.isWifiEnabled()); if (direct) { result.put(opType, setWifiEnabled); result.put(accepted, manager.setWifiEnabled(true)); } else { Context context mUniSDKInstance.getContext(); context.startActivity(new android.content.Intent( android.provider.Settings.ACTION_WIFI_SETTINGS) .addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK)); result.put(opType, openSettings); } if (callback ! null) { callback.invoke(result); } }逻辑说明方法接收一个direct布尔参数为 true 时走直接强制开关为 false 时拉起系统设置页。返回对象的opType会标明实际走的是哪条路径方便前端定位问题。setWifiEnabled的返回值代表系统是否接受了这个请求但不代表 WiFi 真的开了——系统可能正在切换中所以状态判断要用isWifiEnabled()重新读。参数这里有一个细节startActivity必须加FLAG_ACTIVITY_NEW_TASK因为原生模块拿到的 context 不一定是 Activity 类型不加这个 flag 在某些场景下会直接崩。我在实际开发中的默认选择是按钮文案写“打开 WiFi”点击后拉起系统设置页而不是赌setWifiEnabled在新版本上生效。只有明确知道目标机型是 Android 8 以下的场景才把direct设为 true。3.3 扫描附近热点广播接收加延迟回调callback 的进阶用法扫描附近 WiFi 是异步操作调用startScan()后结果要通过SCAN_RESULTS_AVAILABLE_ACTION广播取回来。这正是UniJSCallback不做一次性限制的价值所在——把 callback 存起来等广播到了再调用。UniJSMethod(uiThread false) public void scanWifi(JSONObject options, UniJSCallback callback) { Context context mUniSDKInstance.getContext(); WifiManager manager getWifiManager(); if (!checkLocationPermission(context)) { JSONObject ret new JSONObject(); ret.put(code, -1); ret.put(message, 缺少定位权限); if (callback ! null) callback.invoke(ret); return; } UniJSCallback savedCallback callback; context.registerReceiver(new BroadcastReceiver() { Override public void onReceive(Context ctx, Intent intent) { if (!WifiManager.SCAN_RESULTS_AVAILABLE_ACTION.equals(intent.getAction())) { return; } ListScanResult results manager.getScanResults(); com.alibaba.fastjson.JSONArray arr new com.alibaba.fastjson.JSONArray(); for (ScanResult sr : results) { JSONObject item new JSONObject(); item.put(ssid, sr.SSID); item.put(bssid, sr.BSSID); item.put(level, sr.level); arr.add(item); } JSONObject ret new JSONObject(); ret.put(code, 200); ret.put(data, arr); savedCallback.invoke(ret); ctx.unregisterReceiver(this); } }, new IntentFilter(WifiManager.SCAN_RESULTS_AVAILABLE_ACTION)); boolean started manager.startScan(); if (!started) { JSONObject ret new JSONObject(); ret.put(code, -2); ret.put(message, 扫描启动失败可能扫描过于频繁); savedCallback.invoke(ret); } }逻辑说明方法先查定位权限没有权限直接回错误码不往下走。权限通过后注册一个BroadcastReceiver监听扫描结果广播把 callback 暂存为局部变量等广播里拿到getScanResults()后组装成 JSONArray 回传。这里SSID、BSSID、level三个字段是前端渲染 WiFi 列表的最低配置列表展示用 SSID网络标识用 BSSID信号强度用 level 换算。回传后立刻unregisterReceiver注销避免接收器泄漏。参数说明scanWifi方法的 options 在这个版本里没用到但保留这个参数占位是为了将来扩展过滤条件比如按信号强度过滤、按 SSID 关键字过滤。回调里我统一用code: 200表示成功负数统一代表各种失败分支前端只需要判断res.code 200就能走同一套渲染逻辑。3.4 前端页面封装从模块调用到列表渲染原生侧封装好之后前端页面只是纯粹的消费方。下面这段代码是一个完整的扫码触发流程template view classwifi-panel button typeprimary clickhandleScan扫描附近 WiFi/button view classlist view v-foritem in wifiList :keyitem.bssid classrow text{{ item.ssid }}/text text classsignal信号 {{ item.level }}/text /view /view /view /template script const wifiModule uni.requireNativePlugin(WifiModule) export default { data() { return { wifiList: [] } }, methods: { handleScan() { wifiModule.scanWifi({}, (res) { if (res.code ! 200) { uni.showToast({ title: res.message || 扫描失败, icon: none }) return } this.wifiList res.data }) } } } /script这段代码的逻辑链路是点击按钮 →scanWifi进原生模块 → 原生请求权限、注册广播、启动扫描 → 广播到达后回调 invoke → 前端res.code校验 → 更新 data 渲染列表。整个过程走的是异步回调页面不会白屏也不会卡住主线程。参数说明上的要点是res.data是从原生侧 fastjson JSONArray 直接映射过来的数组前端不需要做类型转换。key用 BSSID 而不是 SSID因为不同信号源可能重名BSSID 才是唯一标识如果你在真实房间测出来列表只有一条多半是定位权限没给足或者当前环境可见热点确实少先检查权限再看列表。4. 工程跑通流程自定义基座、清单注册与真机联调4.1 为什么必须用自定义基座而不是默认基座默认的 uni-app 基座只包含官方内置模块你写的原生插件代码不在里面。这就是为什么很多人把插件代码写完前端一跑uni.requireNativePlugin(WifiModule)拿回 undefined——用的还是默认基座。解决办法是 HBuilderX 打自定义调试基座本地把 Android 工程编译出来的 apk 打包到 App 里前端运行时会优先加载自定义基座里的模块。自定义基座的原理不复杂把离线 SDK 的工程和你写的原生模块一起编进一个基座 apkHBuilderX 连接真机安装这个基座页面代码再跑一个 uni-app 前端包。前端代码可以不断热更新原生模块变了只需要重新编译基座不用每次重新打包整个 App。4.2 Android 工程目录与关键文件清单这个项目的 Android 工程目录结构如下我按文件职责做了标注uniapp-wifi-plugin/ ├── app/ │ ├── src/main/ │ │ ├── assets/ │ │ │ ├── dcloud_uniplugins.json # 插件注册清单 │ │ │ └── apps/ # uni-app 前端资源包目录 │ │ ├── java/com/example/uniappwifi/ │ │ │ └── WifiModule.java # 原生模块主类 │ │ ├── AndroidManifest.xml # 权限与组件声明 │ │ └── res/ # 资源文件 │ └── build.gradle # 依赖配置 ├── build.gradle └── settings.gradle文件作用改动频率dcloud_uniplugins.json插件清单决定哪些模块被加载新增模块时WifiModule.java原生逻辑WiFi 操作全在这功能迭代时AndroidManifest.xml权限声明新增权限时改这里权限变更时build.gradle依赖版本引入三方库时改依赖变更时关键点是dcloud_uniplugins.json必须放在assets根目录下不能嵌套进子目录。gradle 的 shrinkResources 如果开启有可能会把 json 文件当作无用资源清掉遇到插件加载不到时先排查这一项后面避坑章细说。build.gradle 里除了 uni-app 离线 SDK 的依赖还需要 fastjson 依赖因为模块方法签名里的 JSONObject 和 JSONArray 都来自它。4.3 自定义基座打包与真机联调步骤联调流程按下面这几步走每一步做完再进下一步不要跳。第一步用 Android Studio 打开原生工程确认 WifiModule 在src/main/java下编译一次确保没有语法错误。第二步在 HBuilderX 里创建或打开 uni-app 项目菜单选择“发行 - 原生 App 云打包”在弹出的窗口勾选“使用自定义基座”并且选择离线打包或云打包本地有 Android 环境建议选“本地打包”方便看 logcat。第三步把 Android Studio 里编译出来的插件 module 加入离线 SDK 的 app 依赖或者直接把整个原生工程作为离线打包工程。第四步连接真机HBuilderX 安装自定义基座到手机确定安装的是基座而不是普通包。第五步在 uni-app 项目里写调用代码跑起来后打开 Android Studio 的 Logcat过滤UniPlugin或WifiModule关键字观察插件加载日志。联调时最好先做一个探针页面页面 onLoad 里执行const m uni.requireNativePlugin(WifiModule); console.log(m)如果打印出对象说明插件已加载后续调用方法出现undefined is not a function就是方法名对不上。如果打印 undefined则回到清单文件排查先确认 json 语法、再确认 class 全路径、再确认基座是否真的重新安装。4.4 前端工程引用插件模块的正确方式uni-app 前端工程里uni.requireNativePlugin是全局 API不需要 import 任何头文件。调用时机上有一个细节方法最好放在 data 初始化之后、页面 onLoad 之前或者用一个 getter 懒加载。原因很简单如果模块实例在页面实例化时就去拿某些情况下的容器初始化顺序会导致拿不到对象。// 推荐写法懒加载避免页面初始化时序问题 let _wifiModule null function getWifiModule() { if (!_wifiModule) { _wifiModule uni.requireNativePlugin(WifiModule) } return _wifiModule } export default { methods: { handleToggle() { const mod getWifiModule() mod.toggleWifi({ enable: true }, (res) { console.log(toggle result:, res) }) } } }这段代码用模块级变量_wifiModule缓存实例第一次调用时初始化后续复用。这样写有两个好处一是避免 onLoad 时序问题二是减少频繁的插件查找开销。真实项目里模块实例是轻量的但缓存这个习惯一旦养成换成重型模块时不用回头改架构。5. 避坑指北uni-app 原生插件开发的高频翻车记录5.1 现象uni.requireNativePlugin返回 undefined前端拿不到模块这个坑出现的概率在我拆过的项目里排第一。原因有三种一是 HBuilderX 还在用默认基座跑没打自定义基座二是dcloud_uniplugins.json里的 name 或 class 写错class 尤其是没写完整包路径三是 gradle 开启资源压缩后清单文件被清掉了。解决方式点 HBuilderX 控制台右上角的基座管理确认当前基座类型解压 apk 的 assets 目录查看 json 是否在包里关掉 shrinkResources 重新构建基座。排查顺序按照这三条来多半五分钟能找到根因。5.2 现象方法在 Android Studio 里能调通但前端调用报is not a function这个现象坑住我两次。方法在原生类里存在注解也标了但前端调用时报方法不存在。原因基本是方法没有用UniJSMethod注解或者注解写成了别家框架的包名比如把io.dcloud.feature.uniapp.annotation.UniJSMethod误写成 weex 的JSMethod。解决方式是全局搜索代码里UniJSMethod的 import 路径确认包名带io.dcloud.feature.uniapp前缀。另外如果方法声明了多个重载而 JS 调用时参数个数和某个重载不匹配框架也会报方法找不到。5.3 现象扫描接口返回 code 200但 data 是空数组这个现象最容易让人怀疑前端渲染问题其实根源在权限链。WiFi 扫描结果属于隐私数据从 Android 9 开始要求定位权限而且注意不仅仅是ACCESS_FINE_LOCATION系统还要求定位服务本身处于开启状态。我遇到过用户把定位权限和 WiFi 开关都打开了但系统级“位置信息”总开关没开getScanResults()返回空列表。解决办法是扫描前做三层检查动态权限是否授予、系统定位开关是否开启、WiFi 当前是否连接。如果定位开关关闭引导去设置页打开而不是反复重试扫描。5.4 现象setWifiEnabled(true)调用返回 trueWiFi 没实际打开从 Android 10 开始setWifiEnabled被系统标记为废弃非系统应用调用它基本会被忽略返回值却依然是 true成功假象很迷惑人。原因是系统对 WiFi 开关的修改权限收紧了只有系统应用或设备所有者应用才允许直接改开关。解决方式不要依赖这个 API 做新版本的主路径把逻辑改成调用Settings.ACTION_WIFI_SETTINGS跳系统设置页。我见过直接把返回值当成功处理的业务用户点半天按钮没反应体验特别差。改造时给前端传一个opType字段前端根据返回路径决定提示文案是“已拉起设置页”还是“WiFi 已开启”。5.5 现象页面反复进出内存在涨最终 OOM这个坑会在做完 scanWifi 之后出现因为BroadcastReceiver是动态注册的前端页面不断进入离开每次 scan 都注册一个新的接收器接收器持有 ScanResult 列表GC 回收不掉。原因就是注册和注销没有对称。解决方式在原生侧维护一个模块级 receiver 引用注册前先判断是否已注册注销时调用unregisterReceiver前端 onUnload 里显式调用stopScan()方法做收尾。我在这块还强调过一件事回调 callback 如果存成了成员变量也要在接收器注销时置空否则旧 callback 会捕获住整个页面上下文这类泄漏比接收器泄漏更隐蔽。6. 二次开发把 WiFi 模块重构成通用通信桥这个模块的下一步是把它从“WiFi 专用”改造成“通信中转站”。改造思路是把UniJSMethod的方法抽象成一个统一入口invoke(action, params, callback)。action 是字符串决定原生侧走 WiFi 还是走别的系统能力params 透传给具体业务方法callback 按统一格式返回。这样前端每种操作不再需要不同的方法名一个入口就能覆盖全部调用面。UniJSMethod(uiThread true) public void invoke(JSONObject options, UniJSCallback callback) { String action options.getString(action); JSONObject params options.getJSONObject(params); if (wifi.scan.equals(action)) { scanWifi(params, callback); } else if (wifi.toggle.equals(action)) { toggleWifi(params, callback); } else if (event.register.equals(action)) { registerNetworkListener(params, callback); } else { JSONObject ret new JSONObject(); ret.put(code, 404); ret.put(message, unknown action: action); callback.invoke(ret); } }逻辑说明这个统一入口把 action 当路由 key每个分支转到对应的业务方法。前端调用时不管干什么都走module.invoke({action, params}, cb)新增一个原生能力只需要加一个 else if 分支和业务方法不用改前端调用侧。回调格式统一成{code, message, data}前端可以写一层通用拦截器处理错误码比每个方法各回各的格式省心得多。改造时要注意两点。第一callback 的延迟调用场景仍然保留wifi.scan是异步的扫描广播到达后才触发统一入口不能因为方法返回就丢回调。第二action 字符串要约定好命名空间模块不断扩张时建议wifi.scan、bt.scan这样前缀分组避免冲突。这套桥结构稳定之后我遇到新需求就只加业务方法不再动通信层前端也不用改调用代码。从那以后我每次新增桥方法之前都强制走一遍三件事写清单项、写方法注解、写前端调用示例。这个习惯帮我拦下了不少漏注册和签名不对的意外。希望这篇笔记也能帮到你。本文还有配套的精品资源点击获取