☰
Android扫码连接WiFi:CameraX+ZXing与双通道版本适配
2026/10/26 3:53:16 网站建设 项目流程

简介:面向具备一定Android基础的开发者,这份项目源码围绕“扫描WiFi二维码后自动解析并连接网络”的完整流程展开,涉及二维码识别、WiFi配置信息解析、WifiManager网络添加与连接、权限声明及连接结果反馈等核心环节,可用于学习Android网络管理机制、第三方扫码库集成以及运行时权限处理,也适合作为无线网络工具类应用的参考实现。压缩包共141个文件,约2.16MB,包含20个java源文件、41个class编译文件、13个xml布局与配置、25个png图片资源,以及jar依赖、apk成品和工程配置等,源码、资源与可运行产物相对完整,便于对照阅读和验证功能。已有2015人学习下载。读者可从中梳理扫码到联网的调用链路,理解WifiConfiguration构造、加密类型匹配、权限适配与异常反馈等关键实现,并借助工程结构和APK快速定位问题、拆解模块,适合作为课程设计或小型工具开发的参考资料。

1. 从吧台那张 WiFi 二维码说起:这个 Android 项目把「手输密码」这一步彻底删掉了

上周在朋友开的咖啡店,吧台后面贴着一张 WiFi 二维码贴纸。我让店员给我演示客人怎么用:扫码,手机弹出一串WIFI:S:xxxx;P:xxxx;;的纯文本,然后客人得自己复制密码、打开设置、找到网络、粘贴、连接。整条链路五六步,客人一般做到第三步就放弃了,转头问店员要密码。

这个 Android 项目干的事情很直接:打开相机对准那张二维码,CameraX 抓帧、ZXing 解码、解析出 SSID 和密码,再调系统 API 发起连接,用户只需要点一次系统确认。技术上它是三个模块的拼接——相机预览、二维码解析、WiFi 连接,每一个单独拎出来都不复杂,凑在一起才会撞上 Android 版本碎片化的各种边界。适合做门店迎宾、内网工具箱、IoT 设备配网演示的同学,也适合想找一个完整 CameraX + 系统网络 API 练手项目的开发者。

2. 项目骨架与选型:相机、解析、连接三段链路怎么拆

先把工程的骨架摆出来再谈选型,不然容易一上来就扎进 CameraX 的回调里出不来。这个项目我建议按「输入 → 转换 → 副作用」三层来切,解析层必须是纯函数,不碰 Context、不碰相机、不碰网络,这样扫码逻辑可以脱离真机跑单元测试,后面加二维码内容类型(比如 vCard、URL)也不用动相机代码。

2.1 一个能跑起来的最小目录结构

app/src/main/java/com/example/wifiscanner/ ├── MainActivity.kt // 权限申请 + 相机预览宿主 ├── camera/ │ ├── QrCameraController.kt // CameraX 用例绑定与生命周期 │ └── QrImageAnalyzer.kt // ImageProxy -> ZXing 解码 ├── parse/ │ ├── WifiQrParser.kt // 纯函数:字符串 -> WifiQrModel │ └── WifiQrModel.kt // 数据模型 + 合法性校验 ├── connect/ │ ├── WifiConnector.kt // 版本分发入口 │ ├── ModernConnector.kt // API 29+ WifiNetworkSpecifier │ └── LegacyConnector.kt // API 28- WifiManager └── ui/ └── ConnectResultSheet.kt // 结果反馈(成功/失败原因)

connect目录下那个WifiConnector是分发的门面,Activity 只认它一个入口,Build.VERSION.SDK_INT的判断全部收敛在门面里面。这样做的原因是:连接逻辑在 Android 10 前后是两套完全不同的 API 模型,一个是「请求网络 + 回调」,一个是「配置网络 + 广播」,混在 Activity 里会让代码迅速变成一坨。分层之后,将来要接入新的连接方式(比如 Android 11 的 WifiNetworkSuggestion),只需要在门面里加一个分支。

2.2 三段链路的技术选型对照

选型这块不用纠结太久,每个环节其实只有一两个真正能打的选项:

环节常见候选这个项目选它的理由需要接受的代价
相机预览CameraX / Camera2 / 第三方扫码 SDKCameraX 的ImageAnalysis用例开箱即用,绑定生命周期不用自己管出帧格式偏 YUV,解码前要处理 rowStride
二维码解码ZXing core / ML Kit / 商业 SDKZXing 纯 Java、无 Google 服务依赖、体积可控,PlanarYUVLuminanceSource可以直接吃 YUV 平面识别率对模糊、反光场景不如 ML Kit
网络连接WifiNetworkSpecifier / WifiNetworkSuggestion / WifiManagerAPI 29+ 用 Specifier 拿回调,API 28- 回退 WifiManagerSpecifier 必然弹系统确认框,绕不过去

有个细节值得单独说:很多教程会用journeyapps:zxing-android-embedded,它自带一个扫码 Activity,几行代码就能出结果,对 Demo 非常友好。但它的封装层把相机参数、裁剪区域、连续解码策略都锁死了,一旦你要做「扫到即停」「限定扫码区域」「连续解码去重」这类需求,就得反过来跟它的封装打架。这个项目要的是可控的帧分析流水线,所以直接用com.google.zxing:core自己接。

2.3 依赖与权限:别等真机跑到一半才想起 NEARBY_WIFI_DEVICES

Gradle 依赖部分,版本号按你仓库里已经对齐的那一档填,别为了照抄这段强行升级 CameraX:

// app/build.gradle.kts dependencies { val camerax = "1.3.4" // 按项目已有版本对齐 implementation("androidx.camera:camera-core:$camerax") implementation("androidx.camera:camera-camera2:$camerax") implementation("androidx.camera:camera-lifecycle:$camerax") implementation("androidx.camera:camera-view:$camerax") // 只引 core,不引 embedded,解码链路自己掌控 implementation("com.google.zxing:core:3.5.3") }

权限这块是踩坑重灾区,直接给完整的 manifest 片段:

<!-- AndroidManifest.xml --> <uses-permission android:name="android.permission.CAMERA" /> <uses-permission android:name="android.permission.ACCESS_WIFI_STATE" /> <uses-permission android:name="android.permission.CHANGE_WIFI_STATE" /> <uses-permission android:name="android.permission.CHANGE_NETWORK_STATE" /> <uses-permission android:name="android.permission.INTERNET" /> <!-- API 32 及以下:扫描/添加网络依赖定位权限 --> <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" android:maxSdkVersion="32" /> <!-- API 33+:用 neverForLocation 声明不用于定位,避免被应用商店额外问询 --> <uses-permission android:name="android.permission.NEARBY_WIFI_DEVICES" android:usesPermissionFlags="neverForLocation" tools:targetApi="tiramisu" /> <uses-feature android:name="android.hardware.camera.any" android:required="true" />

几个参数的用意得讲清楚:ACCESS_FINE_LOCATION加maxSdkVersion="32"是因为从 Android 13 开始,WiFi 相关能力被拆到了NEARBY_WIFI_DEVICES,老权限在新系统上不再必要,声明上限能少一次运行时申请。neverForLocation是一个声明式标记,告诉系统这个应用拿 WiFi 信息不是为了推断地理位置,某些分发渠道会据此免掉额外的隐私说明流程。CHANGE_NETWORK_STATE容易漏,ConnectivityManager.requestNetwork在部分机型上会检查它。

提示:uses-feature里camera.any设成 required,能避免没有摄像头的设备(部分平板、车机)装上后崩溃。如果要做兼容,就设 false 然后在运行时判断packageManager.hasSystemFeature。

3. 扫码链路拆解:从 CameraX 的一帧 YUV 到 ZXing 解出的字符串

这条链路的核心诉求是「快」和「不卡」。二维码识别本质是个 CPU 密集活,相机一秒出 30 帧,如果每帧都老老实实转成 Bitmap 再解码,中低端机上预览立刻掉帧。所以要做的两件事:用背压策略丢掉来不及处理的帧,以及让解码器直接吃 YUV 的灰度平面,跳过 RGB 转换。

3.1 CameraX 用例绑定与帧分析器配置

class QrCameraController( private val lifecycleOwner: LifecycleOwner, private val previewView: PreviewView, private val onDecoded: (String) -> Unit ) { private val executor = Executors.newSingleThreadExecutor() fun start() { val providerFuture = ProcessCameraProvider.getInstance(previewView.context) providerFuture.addListener({ val provider = providerFuture.get() val preview = Preview.Builder().build().also { it.setSurfaceProvider(previewView.surfaceProvider) } val analysis = ImageAnalysis.Builder() // 只保留最新一帧,处理不过来的帧直接丢 .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST) .setOutputImageFormat(ImageAnalysis.OUTPUT_IMAGE_FORMAT_YUV_420_888) .build() analysis.setAnalyzer(executor, QrImageAnalyzer(onDecoded)) provider.unbindAll() provider.bindToLifecycle( lifecycleOwner, CameraSelector.DEFAULT_BACK_CAMERA, preview, analysis ) }, ContextCompat.getMainExecutor(previewView.context)) } fun stop() { executor.shutdown() } }

STRATEGY_KEEP_ONLY_LATEST这个参数是整个扫码流畅度的关键。默认策略是阻塞式,分析器不返回就不给下一帧,一旦解码耗时超过 33ms,预览就会被拖到卡顿;换成只保留最新帧之后,慢帧会被直接丢弃,用户看到的是流畅预览加稍慢的识别,体感好得多。OUTPUT_IMAGE_FORMAT_YUV_420_888是显式声明输出格式,不写的话部分机型默认给的是YUV_420_888但带额外的转换,浪费一次拷贝。

3.2 把 ImageProxy 喂给 ZXing:留意 rowStride 这个隐藏参数

class QrImageAnalyzer( private val onDecoded: (String) -> Unit ) : ImageAnalysis.Analyzer { private val reader = MultiFormatReader().apply { setHints(mapOf( DecodeHintType.POSSIBLE_FORMATS to listOf(BarcodeFormat.QR_CODE), DecodeHintType.TRY_HARDER to true )) } private var lastResult: String? = null private var lastTime = 0L override fun analyze(image: ImageProxy) { image.use { proxy -> // 必须 close,否则相机管线会堵死 val source = proxy.toLuminanceSource() ?: return val bitmap = BinaryBitmap(HybridBinarizer(source)) val text = runCatching { reader.decodeWithState(bitmap)?.text }.getOrNull() ?: return // 同一个码连续命中时做去重,避免重复触发连接 val now = SystemClock.elapsedRealtime() if (text == lastResult && now - lastTime < 1500) return lastResult = text lastTime = now onDecoded(text) } } private fun ImageProxy.toLuminanceSource(): PlanarYUVLuminanceSource? { val plane = planes.firstOrNull() ?: return null val buffer = plane.buffer val rowStride = plane.rowStride val width = width val height = height // rowStride 可能大于 width(有 padding),必须逐行拷贝,直接 buffer.get 会错位 val data = ByteArray(width * height) val row = ByteArray(rowStride) var offset = 0 for (r in 0 until height) { val len = minOf(rowStride, buffer.remaining()) if (len <= 0) break buffer.get(row, 0, len) System.arraycopy(row, 0, data, offset, minOf(width, len)) offset += width } return PlanarYUVLuminanceSource(data, width, height, 0, 0, width, height, false) } }

逐段说明几个关键点。image.use {}是硬性要求,ImageProxy不 close,相机管线很快就拿不到新帧,现象是预览看着正常但识别再也不出结果,非常难查。decodeWithState复用同一个MultiFormatReader实例,比每帧new一个省下可观的对象分配;配套的reset()在异常路径上需要补一次,否则 reader 内部状态可能残留。

toLuminanceSource里那段逐行拷贝是血泪经验。YUV 的 Y 平面每行末尾经常带 padding,plane.rowStride会大于图像实际宽度,很多博客直接buffer.get(byteArray)一网打尽,在高分辨率机型上能识别,在中低端机型上就是死活扫不出来,因为数据整体错位了。另外false这个参数控制是否反转亮度,正常情况下二维码是深色模块浅色底,传 false 即可;如果你扫的是反色码(浅色模块深色底),传 true 或者构建时加DecodeHintType.ALSO_INVERTED。

最后那个 1.5 秒的去重窗口,是为了避免二维码一直在取景框里导致连接接口被连续调用,requestNetwork反复发起会让系统弹窗排队,用户体验很糟。

3.3WIFI:字符串的语法与转义规则

ZXing 定义的 WiFi 二维码格式长这样:

WIFI:T:WPA;S:MyNetwork;P:p@ss\;word;H:true;;

字段含义和约束:

字段含义是否必填取值与约束
T认证类型否,缺省nopassWPA/WEP/nopass/SAE/WPA2-EAP
SSSID是1~32 字节(UTF-8 编码后)
P密码视T而定WPA 为 8~63 字符,WEP 为 5 或 13 字符
H是否为隐藏网络否true/false

真正容易翻车的是转义。SSID 或密码里如果出现\、;、,、:、"这五个字符,生成端会用反斜杠转义,解析端必须先反转义再按分隔符切分,否则密码被从中间截断,连接必然失败。所以正确做法是写一个状态机一次扫完,而不是先split(";")再替换。

object WifiQrParser { fun parse(raw: String): WifiQrModel? { val text = raw.trim() if (!text.startsWith("WIFI:", ignoreCase = true)) return null val body = text.substring(5) val fields = HashMap<String, String>() val sb = StringBuilder() var currentKey: String? = null var i = 0 while (i < body.length) { val c = body[i] when { // 转义字符:下一个字符原样保留,不做分隔符解释 c == '\\' && i + 1 < body.length -> { sb.append(body[i + 1]); i += 2; continue } c == ':' && currentKey == null -> { currentKey = sb.toString().uppercase(); sb.clear() } c == ';' -> { currentKey?.let { fields[it] = sb.toString() } currentKey = null; sb.clear() } else -> sb.append(c) } i++ } val ssid = fields["S"]?.takeIf { it.isNotBlank() } ?: return null val security = when (fields["T"]?.uppercase()) { null, "", "NOPASS" -> SecurityType.NOPASS "WEP" -> SecurityType.WEP "SAE" -> SecurityType.SAE else -> SecurityType.WPA } val model = WifiQrModel( ssid = ssid, password = fields["P"].orEmpty(), securityType = security, hidden = fields["H"]?.equals("true", true) == true ) return if (model.isValid()) model else null } }

状态机里c == '\\'那个分支是关键:遇到反斜杠就直接吞掉它并追加下一个字符,i += 2配合continue跳过本轮循环,这样p@ss\;word会正确解析成p@ss;word。currentKey == null的判断保证只在键名位置把第一个冒号当分隔符,密码里的冒号(比如WIFI:S:xx;P:a:b;;)就不会被误切。

3.4 解析结果的合法性校验

校验写在WifiQrModel.isValid()里,把「格式合法」和「参数能被系统接受」分成两层:

fun isValid(): Boolean = when (securityType) { SecurityType.NOPASS -> true SecurityType.WPA, SecurityType.SAE -> password.length in 8..63 SecurityType.WEP -> password.length == 5 || password.length == 13 }.also { ok -> // SSID 是字节长度限制,不是字符数,中文 SSID 要按 UTF-8 算 if (ok && ssid.toByteArray(Charsets.UTF_8).size !in 1..32) return false }

这里有个容易忽略的细节:SSID 的 32 是字节上限而不是字符上限,中文 SSID 一个汉字占 3 字节,10 个汉字就到 30 字节了,第 11 个就会被系统拒绝。密码长度校验同理,WPA 的 8~63 是字符数,但如果密码是纯十六进制 64 位,那是直接当 PSK 用的另一种形式,二维码里很少见,遇到可以单独走setWpa2Passphrase之外的路径。

4. 连接链路拆解:WifiNetworkSpecifier 与 WifiManager 的双通道实现

连接是整条链路上版本差异最大的一段。Android 10(API 29)之后,WifiManager.addNetwork对普通应用基本失效,官方推的是WifiNetworkSpecifier配合ConnectivityManager.requestNetwork;而 API 28 及以下只能用WifiManager那套老接口。两条路的回调模型、权限要求、失败表现都不一样,必须分开写。

4.1 API 29 及以上:WifiNetworkSpecifier 的请求-回调模型

@RequiresApi(Build.VERSION_CODES.Q) class ModernConnector(private val context: Context) { private val cm = context.getSystemService(ConnectivityManager::class.java) private val main = Handler(Looper.getMainLooper()) private var callback: ConnectivityManager.NetworkCallback? = null fun connect(model: WifiQrModel, onResult: (Boolean, String) -> Unit) { release() val specifier = WifiNetworkSpecifier.Builder() .setSsid(model.ssid) .apply { when (model.securityType) { SecurityType.WPA -> setWpa2Passphrase(model.password) SecurityType.SAE -> setWpa3Passphrase(model.password) else -> {} } } .setIsHiddenSsid(model.hidden) .build() val request = NetworkRequest.Builder() .addTransportType(NetworkCapabilities.TRANSPORT_WIFI) // 关键:不要让系统把这个网络当作默认上网出口去校验 .removeCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) .setNetworkSpecifier(specifier) .build() val cb = object : ConnectivityManager.NetworkCallback() { override fun onAvailable(network: Network) { main.post { onResult(true, "已连接 ${model.ssid}") } } override fun onUnavailable() { main.post { onResult(false, "用户取消,或密码错误/信号未覆盖") } } override fun onLost(network: Network) { main.post { onResult(false, "连接已断开") } } } callback = cb // 30 秒超时,超时后系统自动走 onUnavailable cm.requestNetwork(request, cb, 30_000) } fun release() { callback?.let { runCatching { cm.unregisterNetworkCallback(it) } } callback = null } }

这段里有三个参数值得展开。removeCapability(NET_CAPABILITY_INTERNET)的作用是告诉系统「我连这个 WiFi 不是为了上网」,系统因此不会对这个网络做联网能力探测,也不会因为它没有外网就判定为不可用——很多门店的内网 WiFi 就是这样,能连但上不了外网,不加这一行会一直回调onUnavailable。setIsHiddenSsid(true)用于隐藏网络,隐藏 SSID 时系统需要主动探测,不声明的话请求直接失败。requestNetwork的第三个参数是超时毫秒数,超时后走onUnavailable。

必须提前跟用户讲明白的一点:API 29+ 的这个接口,普通应用调用时系统一定会弹一个确认对话框,问「是否连接到 XXX」,用户点取消就是onUnavailable。这是平台设计,不是 bug,任何号称能静默连接的方案,要么是系统应用/设备所有者(Device Owner),要么是走了WifiNetworkSuggestion那条需要用户点通知的路。所以 UI 上要把文案写清楚,别让用户以为点了没反应。

4.2 API 28 及以下:WifiManager 添加网络的老路子

@Suppress("DEPRECATION") class LegacyConnector(private val context: Context) { private val wm = context.applicationContext.getSystemService(WifiManager::class.java) fun connect(model: WifiQrModel, onResult: (Boolean, String) -> Unit) { if (!wm.isWifiEnabled) wm.isWifiEnabled = true val config = WifiConfiguration().apply { // SSID 和密码必须带双引号,这是最容易漏的一步 SSID = "\"${model.ssid}\"" when (model.securityType) { SecurityType.NOPASS -> { allowedKeyManagement.set(WifiConfiguration.KeyMgmt.NONE) } SecurityType.WEP -> { allowedKeyManagement.set(WifiConfiguration.KeyMgmt.NONE) wepKeys[0] = "\"${model.password}\"" wepTxKeyIndex = 0 } else -> { allowedKeyManagement.set(WifiConfiguration.KeyMgmt.WPA_PSK) preSharedKey = "\"${model.password}\"" } } hiddenSSID = model.hidden } val netId = wm.addNetwork(config) if (netId == -1) { onResult(false, "添加网络配置失败,检查定位权限是否开启") return } wm.disconnect() if (!wm.enableNetwork(netId, true)) { onResult(false, "启用网络失败") return } wm.reconnect() // 老系统没有请求式回调,用连接状态广播或轮询兜底 waitForConnection(model.ssid, onResult) } private fun waitForConnection(ssid: String, onResult: (Boolean, String) -> Unit) { val deadline = SystemClock.elapsedRealtime() + 15_000 val handler = Handler(Looper.getMainLooper()) val task = object : Runnable { override fun run() { val info = wm.connectionInfo if (info?.ssid?.trim('"') == ssid) { onResult(true, "已连接 $ssid") return } if (SystemClock.elapsedRealtime() > deadline) { onResult(false, "连接超时,密码可能不正确") return } handler.postDelayed(this, 800) } } handler.postDelayed(task, 800) } }

代码里的"\"${model.ssid}\""是经典坑:WifiConfiguration的SSID和preSharedKey要求带引号的字符串字面量,不带引号会被当成十六进制 PSK 解释,结果就是「配置添加成功但永远连不上」。wepKeys的索引 0 是必填项,wepTxKeyIndex也要同步设置,WEP 虽然已经过时,但二维码里T:WEP依然存在,做兼容的时候不能当它不存在。

waitForConnection用轮询而不是广播,是因为广播方式在不同厂商 ROM 上的行为差异太大,有的机型NETWORK_STATE_CHANGED_ACTION在连接成功后会延迟好几秒甚至不广播,代码里再叠一层广播时序处理,反而更难排查。800ms 轮询、15 秒总超时,在真机上表现是比较可控的。

4.3 版本分发门面与生命周期收尾

class WifiConnector(private val context: Context) { private val modern by lazy { ModernConnector(context) } private val legacy by lazy { LegacyConnector(context) } fun connect(model: WifiQrModel, onResult: (Boolean, String) -> Unit) { if (!model.isValid()) { onResult(false, "二维码里的 SSID 或密码格式不合法") return } if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { modern.connect(model, onResult) } else { legacy.connect(model, onResult) } } // Activity onDestroy 里必须调用 fun release() { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) modern.release() } }

release()这一步很多人会漏。requestNetwork注册的NetworkCallback如果不注销,系统会一直认为应用还需要这个网络,用户手动切走 WiFi 之后又会被拉回来,现象就是「手机自己反复连上某个 WiFi,关都关不掉」。unregisterNetworkCallback用runCatching包一层,是因为重复注销会抛IllegalArgumentException,onDestroy和「连接成功主动释放」两个时机容易撞上。

4.4 权限申请与定位开关检查

运行时权限这块,用ActivityResultContracts最省事:

private val cameraPerm = registerForActivityResult( ActivityResultContracts.RequestPermission() ) { granted -> if (granted) cameraController.start() else showCameraHint() } private fun requestWifiPermissionIfNeeded() { val perm = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { Manifest.permission.NEARBY_WIFI_DEVICES } else { Manifest.permission.ACCESS_FINE_LOCATION } if (ContextCompat.checkSelfPermission(this, perm) == PackageManager.PERMISSION_GRANTED) return registerForActivityResult(ActivityResultContracts.RequestPermission()) { /* 记录状态 */ } .launch(perm) }

除了权限本身,API 28 及以下还依赖系统的定位总开关。ACCESS_FINE_LOCATION授权了,但用户在快捷面板里把定位关掉,addNetwork依然会失败且不抛异常,只是返回 -1。所以连接前要加一次检查:

val lm = getSystemService(LocationManager::class.java) if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.P && !lm.isLocationEnabled) { toast("请先打开系统定位开关,否则无法添加 WiFi 配置") return }

5. 踩坑与排查:五个只在真机上才会暴露的问题

5.1 连接类问题:连不上、连上又断、提示配置成功却没生效

现象一:API 29+ 上调用requestNetwork之后什么反应都没有,几秒后onUnavailable。

原因是系统确认弹窗被用户点了取消,或者密码错误、信号不在覆盖范围。onUnavailable这个回调本身不携带原因,这是它最坑的地方。解决办法是把三种可能都在 UI 上写出来,同时做一次WifiManager.getScanResults()(有权限时)看看目标 SSID 是否在扫描结果里,如果压根扫不到,那大概率是频段或距离问题,而不是密码问题。另外注意requestNetwork的回调线程不是主线程,直接在里面更新 UI 会崩,必须切回Handler(Looper.getMainLooper())。

现象二:API 28 上wm.addNetwork(config)返回 -1,代码没抛异常。

八成是定位权限没给,或者系统定位开关关着。从 Android 6 开始,WiFi 扫描和配置读取就被归到定位权限下,很多开发者只申请了CHANGE_WIFI_STATE就以为够了。解决方式是连接前同时检查checkSelfPermission(ACCESS_FINE_LOCATION)和LocationManager.isLocationEnabled,两个都满足再往下走,不满足就明确提示用户,别让它静默失败。

现象三:配置显示「已保存」,但设备就是不连,手动点一下又能连上。

原因是enableNetwork(netId, true)之后没有调reconnect()。enableNetwork的第二个参数attemptConnect在部分 ROM 上只是把网络标记为可用,并不主动发起关联,必须补一次reconnect()。另外如果设备当前连在别的网络上,disconnect()要放在enableNetwork之前,顺序错了会出现在两个网络之间反复横跳的情况,日志里能看到NETWORK_STATE_CHANGED_ACTION高频刷屏。

现象四:隐藏 SSID 的网络怎么都连不上,但密码确认没错。

隐藏网络要显式声明。API 29+ 用setIsHiddenSsid(true),API 28- 用WifiConfiguration.hiddenSSID = true,两边都要设。还有一个附带条件:隐藏网络往往需要指定 BSSID 或让系统做主动探测,如果设置了hiddenSSID还是失败,可以尝试在WifiNetworkSpecifier.Builder上不指定 SSID 而是用setSsidPattern,代价是会弹出一个网络列表让用户选。

5.2 扫码与相机类问题:识别慢、卡顿、扫到了没反应

现象五:预览很流畅,二维码也在框里,但永远识别不出来,或者低端机识别不出来高端机正常。

第一嫌疑是ImageProxy没 close,相机管线被堵住,analyze回调次数骤降。第二嫌疑是 Y 平面的rowStride没处理,直接整块拷贝导致像素错位,在 stride 恰好等于 width 的机型上碰巧能识别,其他机型就完全不行。第三嫌疑是解码线程和分析线程抢资源,Executors.newSingleThreadExecutor()是正确的,用newFixedThreadPool(4)反而会因为乱序处理帧导致解码器状态错乱。排查手法很简单:在analyze里打一行日志统计每秒回调次数,正常应该有 10~30 次,低于 5 次就是管线堵了。

现象六:扫到码了,回调也触发了,但连接接口没被调用。

多半是去重窗口写错了。如果lastResult和lastTime的更新放在return之前,逻辑上没问题;但如果把onDecoded放在image.use {}的闭包外面,而此时ImageProxy已经 close,回调里再访问图像数据就会拿到空。另一个常见原因是解析器返回了null但没打日志,二维码内容前缀不是WIFI:(比如是WPA:或者带了 BOM 头),解析直接判空返回,上层看起来就是「扫到了没反应」。解析失败一定要打一行 warn 日志把原始字符串带出来,这是排查效率最高的一步。

6. 二次开发:从「单次扫码」改成「批量配网 + 反向生成二维码」

把单次扫码跑通之后,最自然的两个改造方向,一个是「一次配多个网络」,一个是「反过来生成二维码给别人的手机扫」。前者要换连接接口,后者要引入 ZXing 的编码器,两件事都不复杂,但各有各的坑。

批量配网建议从WifiNetworkSpecifier切到WifiNetworkSuggestion。它不弹确认框,而是把网络以「建议」的形式提交给系统,系统在合适时机弹一条通知让用户点确认,一次可以提交多个:

@RequiresApi(Build.VERSION_CODES.Q) fun submitSuggestions(list: List<WifiQrModel>): Int { val wm = context.getSystemService(WifiManager::class.java) val suggestions = list.map { m -> WifiNetworkSuggestion.Builder() .setSsid(m.ssid) .apply { if (m.securityType == SecurityType.WPA) setWpa2Passphrase(m.password) } .build() } // 返回值是状态码:0 成功,1 部分失败,2 全部失败 return wm.addNetworkSuggestions(suggestions) }

要注意的是,WifiNetworkSuggestion提交后应用无法主动触发连接,只能等系统决策,setPriority也只是提高优先级而非强制。所以它的定位是「配网辅助」而不是「一键直连」,产品文案上要区分清楚,别让用户以为点了立刻能连上。反过来,WifiNetworkSpecifier更适合「扫码后立刻连」这个场景,两者可以共存,按场景选。

反向生成二维码就是纯编码,把模型拼回标准字符串,注意转义要对称:

fun buildWifiQrText(m: WifiQrModel): String { fun esc(s: String) = s.replace("\\", "\\\\") .replace(";", "\\;").replace(",", "\\,") .replace(":", "\\:").replace("\"", "\\\"") val t = when (m.securityType) { SecurityType.NOPASS -> "nopass" SecurityType.SAE -> "SAE" SecurityType.WEP -> "WEP" else -> "WPA" } return buildString { append("WIFI:T:").append(t).append(';') append("S:").append(esc(m.ssid)).append(';') if (m.securityType != SecurityType.NOPASS) { append("P:").append(esc(m.password)).append(';') } if (m.hidden) append("H:true;") append(';') } }

然后用QRCodeWriter().encode(text, BarcodeFormat.QR_CODE, 600, 600)拿到 BitMatrix,转 Bitmap 显示或分享即可。转义函数必须和解析端的反转义严格对称,我在这一对上翻过车——编码时漏了:的转义,生成的码在自己 App 里能扫(因为解析器容错),发给别的扫码工具就解析成两截。从那以后我每次改这对函数,都会写一组往返测试用例,把包含;、,、:、\、"五种字符的 SSID 和密码各跑一遍 encode→decode 断言,不通过就不提交。这套习惯比任何文档都靠谱,希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询