
简介面向 Android 开发者的「扫描 WiFi 二维码自动连接」实战项目聚焦扫码解析网络信息并完成配网的需求适合具备基础组件与权限知识、希望理解 WifiManager 连接流程的开发者参考。压缩包共 141 个文件约 2.16MB以 41 个 class、20 个 java 源码和 13 个 xml 配置为主另含 png 界面素材、jar 依赖及可运行 apk 等兼顾源码、资源与演示产物。已有 2015 人学习下载。内容围绕二维码扫描、SSID 与密码及加密类型解析、WifiConfiguration 构建、addNetwork 与 connect 调用以及 WiFi 状态与修改权限处理展开并涉及连接结果提示和失败场景反馈。借助现成工程可对照理解相机扫码、配置保存和自动连接链路定位权限申请、网络添加与兼容性排错思路。1. 扫一扫就连上二维码里装的是什么谁在替你完成连接门店换了路由器墙上那张二维码得重打客人问“扫这个能连吗”你说能结果他扫出来只看到一段WIFI:S:Shop-5G;T:WPA;P:abc12345;;的纯文本复制粘贴到设置里还提示“密码错误”——这是最典型的一次翻车。扫码连 WiFi 这件事真正的难点从来不在“扫码”而在扫码之后识别出的字符串怎么拆、拆出来的密码怎么转义还原、不同 Android 版本该走哪套连接 API。多数“扫了个寂寞”的案例都是这三步里至少断了一环。这篇拆的是一份 Android 端的 WiFi 二维码扫描自动连接实现。它的能力边界很清楚相机取帧识别二维码解析出 SSID 和密码然后按系统版本降级到WifiConfiguration或升级到WifiNetworkSpecifier把用户从“打开设置、找网络、输密码”里解放出来。适合谁做门店/会议室/展台的一线开发做设备配网的工具类 App 开发以及要给客户演示“一贴即连”却总被吐槽连不上的那些人。新手能照着步骤跑通熟手能在这里看到 Android 10 之后那些不写在文档首页的限制。2.WIFI:串拆解六个字段、一次转义和一段必须自己写的状态机二维码里那串东西不是随便定出来的它有一个被各家相机 App 和系统设置共同沿用的约定格式以WIFI:开头后面是一串用分号分隔的键:值对结尾再用一个分号收掉。看起来像 key-value实际上它比 key-value 难缠——因为值里可以合法地出现冒号、分号、逗号和双引号靠split(;)硬切必然出事。这一章先把格式吃透再写解析器最后反过来讲生成端最容易踩的转义坑。2.1 六个字段与一张反斜杠转义表字段名都是单字母最常用的只有四个键含义示例备注T认证类型WPA/WEP/nopass/SAE/WPA2-EAP省略时按nopass兜底SSSIDShop-5G中文 SSID 走 UTF-8别用 GBKP密码abc12345nopass时不存在该字段H是否隐藏网络true不写就是falseE/I/A/PH2企业级认证相关PEAP/user/anon/MSCHAPV2个人场景基本用不到转义规则只有一条但很多人不写\、;、,、:、这五个字符出现在值里时前面必须加一个反斜杠。比如 SSID 叫Coffee;Bar写成二维码就是WIFI:S:Coffee\;Bar;T:WPA;P:12345678;;。如果生成端漏了这一步解析端就会在分号处截断SSID 变成Coffee剩下Bar被当成一个没有冒号的野字段丢掉——现象就是“扫出来连上了但连的是隔壁那个叫 Coffee 的网络”。还有一个容易忽略的点T的值大小写不敏感但S和P是大小写敏感的密码里的大写字母不能被任何环节规范化。我见过一个实现为了“容错”把整串转成小写再解析结果所有含大写字母的密码全线崩盘排查了一下午才发现是lowercase()写在了parse的第一行。2.2 解析器不要用 split写个四十行的状态机先把最容易翻车的写法贴出来不是让你抄是让你认出来自己代码里有没有它// 反例看起来简洁实际把转义吃掉了 val parts raw.substring(5).split(;) val ssid parts.first { it.startsWith(S:) }.substring(2)问题有两个。第一S:Coffee\;Bar会被切成S:Coffee\和Bar第二P值里如果出现:虽然少见但合法substring(2)之后的处理会带上前缀判断的歧义。正确做法是扫描一遍字符流遇到\就吞掉它并把下一个字符原样收进当前字段遇到;才断字段。断完字段后再取第一个字符当键、校验第二个字符是:剩下的一整段做反转义。data class WifiQr( val ssid: String, val password: String?, val type: AuthType, val hidden: Boolean ) enum class AuthType { WPA, WEP, NOPASS, SAE, EAP } object WifiQrParser { private const val PREFIX WIFI: fun parse(raw: String): ResultWifiQr { val text raw.trim() if (!text.startsWith(PREFIX, ignoreCase true)) { return Result.failure(IllegalArgumentException(缺少 WIFI: 前缀)) } val map HashMapChar, String() for (field in splitFields(text.substring(PREFIX.length))) { // 字段形如 S:xxx第一个字符是键第二个必须是冒号 if (field.length 2 || field[1] ! :) continue map[field[0].uppercaseChar()] unescape(field.substring(2)) } val ssid map[S] if (ssid.isNullOrEmpty()) return Result.failure(IllegalArgumentException(缺少 SSID)) val type when (map[T]?.uppercase()) { null, , NOPASS - AuthType.NOPASS WEP - AuthType.WEP SAE - AuthType.SAE WPA2-EAP, WPA-EAP - AuthType.EAP else - AuthType.WPA } val pwd map[P] if (type ! AuthType.NOPASS pwd.isNullOrEmpty()) { return Result.failure(IllegalArgumentException(加密网络缺少密码)) } return Result.success( WifiQr(ssid, pwd, type, map[H]?.equals(true, true) true) ) } /** 只按未转义的 ; 切分转义序列原样保留交给 unescape 处理 */ private fun splitFields(body: String): ListString { val out ArrayListString() val sb StringBuilder() var i 0 while (i body.length) { val c body[i] when { c \\ i 1 body.length - { sb.append(c).append(body[i 1]); i 2 } c ; - { out.add(sb.toString()); sb.clear(); i } else - { sb.append(c); i } } } if (sb.isNotEmpty()) out.add(sb.toString()) return out } private fun unescape(s: String): String { val sb StringBuilder(s.length) var i 0 while (i s.length) { if (s[i] \\ i 1 s.length) { sb.append(s[i 1]); i 2 } else { sb.append(s[i]); i } } return sb.toString() } }逻辑上的关键决策是“切分阶段不反转义”。如果先 unescape 再 split\;会退化成;后面的切分就再也分不清哪些分号是结构分隔、哪些是内容。同理取键值也不能用indexOf(:)因为值里的:虽然被转义了但已经混在字符串里按位置取前两个字符才是唯一稳的做法。splitFields里遇到\时同时把反斜杠和下一个字符一起塞进缓冲区就是为了让转义信息多活一会儿。参数上只有两处可调PREFIX的大小写判断我用了ignoreCase true因为确实有生成工具写成wifi:T的映射表里把不认识的值一律兜底为WPA这比报错友好——很多老路由出的码T是空的但实际就是 WPA2。真正该报错的只有 SSID 缺失和加密网络密码缺失这两种其余都该容错。2.3 反向生成密码少一位多半是转义顺序写反了生成端的坑比解析端更隐蔽因为它是“写错了也能出图只是扫出来不对”。最典型的错误是转义顺序必须先把\换成\\再处理其他四个字符。反过来的话你把;变成\;紧接着又把那个刚加进去的\再转义一次变成\\;解析端还原出来就是\;而不是;。密码里带反斜杠的人不多但 SSID 里带分号的咖啡店、带逗号的餐厅太多了。# 生成端转义顺序不能反反斜杠必须第一个处理 SPECIAL [\\, ;, ,, :, ] def escape(value: str) - str: out value for ch in SPECIAL: out out.replace(ch, \\ ch) return out def build_wifi_qr(ssid: str, password: str , auth: str WPA, hidden: bool False) - str: parts [fT:{auth}, fS:{escape(ssid)}] if auth.upper() ! NOPASS: parts.append(fP:{escape(password)}) if hidden: parts.append(H:true) return WIFI: ;.join(parts) ;;注意SPECIAL里\\排第一这不是随便排的——str.replace是顺序执行的先处理反斜杠能保证后续插入的转义符不会被二次转义。最后的;;是格式约定每个字段自带一个分号末尾再补一个收尾缺了它有些老设备的解析器会认为串没结束。auth传nopass时直接不写P字段而不是写P:空值这两种在部分系统实现里行为不同空值可能被当成空密码去尝试关联结果是反复失败。生成完别急着打印先自己解析一遍做闭环校验把build_wifi_qr的输出喂给你 Android 侧那个parse断言parse(build(...)).ssid 原 SSID。这条断言能挡掉八成以上的转义问题成本只有五行代码。3. 扫码链路CameraX 抓帧 ML Kit 出串把识别率从 60% 拉到 95%二维码解析再漂亮识别不出来等于零。这一段的常见失败不是“完全扫不到”而是“要举着手机对准三秒才出结果”用户体验上跟坏了没区别。原因是很多人用PreviewView截图再去解码或者把ImageAnalysis的分辨率设成默认的 640×480二维码在画面里只占几十个像素解码器自然吃力。3.1 依赖、权限与一个容易漏的 manifest 标记依赖就三块相机、分析器、条码识别。// build.gradle.kts版本号以你仓库里能拉到的最新稳定版为准 implementation(androidx.camera:camera-camera2:1.3.4) implementation(androidx.camera:camera-lifecycle:1.3.4) implementation(androidx.camera:camera-view:1.3.4) implementation(com.google.mlkit:barcode-scanning:17.2.0)权限这块是 Android 版本差异最集中的地方建议一次性写全靠maxSdkVersion让系统自己裁剪uses-permission android:nameandroid.permission.CAMERA / uses-permission android:nameandroid.permission.ACCESS_WIFI_STATE / uses-permission android:nameandroid.permission.CHANGE_WIFI_STATE / uses-permission android:nameandroid.permission.ACCESS_NETWORK_STATE / !-- Android 12 及以下WiFi 相关操作被视为可推断位置必须给精确定位 -- uses-permission android:nameandroid.permission.ACCESS_FINE_LOCATION android:maxSdkVersion32 / !-- Android 13 起用独立权限声明 neverForLocation 可免掉定位授权弹窗 -- uses-permission android:nameandroid.permission.NEARBY_WIFI_DEVICES android:usesPermissionFlagsneverForLocation tools:targetApi33 /neverForLocation这个标记值得单独说一句。它的语义是“我承诺不用 WiFi 扫描结果去推断地理位置”加上之后 Android 13 不会再要求用户额外授予定位权限弹窗从两次变一次。但代价是一旦你声明了它就不能再调用任何会被判定为位置推断的 API比如主动扫描周围 AP 列表并上报。只做“按 SSID 直连”的话完全够用所以门店场景我一般都会加上。反之如果你的 App 还要展示附近可用网络列表这个标记就得去掉老老实实申请定位。ACCESS_FINE_LOCATION的maxSdkVersion32也不是可选项。Android 12 及以下只要涉及 WiFi 连接系统就要求精确定位权限给ACCESS_COARSE_LOCATION不够Android 13 起这个要求被NEARBY_WIFI_DEVICES接管旧的定位权限反而变成冗余。两条并存、靠版本裁剪是唯一不用写版本分支的写法。3.2 用 ImageAnalysis 抓帧别用 Preview 截图PreviewView的bitmap是给用户看的经过了缩放和裁切拿去解码等于自己给自己降分辨率。正确路径是ImageAnalysis拿到ImageProxy转InputImage直接喂给识别器。分辨率选择器我一般显式设成 1280×720再高解码耗时上去了再低小码识别不了。private val cameraExecutor Executors.newSingleThreadExecutor() private var lastRaw: String? null private var lastTime 0L private fun startScanner(lifecycleOwner: LifecycleOwner, previewView: PreviewView) { val analyzer ImageAnalysis.Builder() .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST) .setResolutionSelector( ResolutionSelector.Builder() .setResolutionStrategy( ResolutionStrategy( Size(1280, 720), ResolutionStrategy.FALLBACK_RULE_CLOSEST_HIGHER_THEN_LOWER ) ).build() ) .build() val scanner BarcodeScanning.getClient( BarcodeScannerOptions.Builder() .setBarcodeFormats(Barcode.FORMAT_QR_CODE) .build() ) analyzer.setAnalyzer(cameraExecutor) { proxy - val media proxy.image if (media null) { proxy.close(); returnsetAnalyzer } val input InputImage.fromMediaImage(media, proxy.imageInfo.rotationDegrees) scanner.process(input) .addOnSuccessListener { codes - val raw codes.firstOrNull()?.rawValue ?: returnaddOnSuccessListener val now SystemClock.elapsedRealtime() // 同一张码 2 秒内只处理一次避免连续回调把连接请求打爆 if (raw lastRaw now - lastTime 2000) returnaddOnSuccessListener lastRaw raw lastTime now onQrDecoded(raw) } .addOnCompleteListener { proxy.close() } } val provider ProcessCameraProvider.getInstance(previewView.context) provider.addListener({ val cameraProvider provider.get() val preview Preview.Builder().build() preview.setSurfaceProvider(previewView.surfaceProvider) cameraProvider.unbindAll() cameraProvider.bindToLifecycle( lifecycleOwner, CameraSelector.DEFAULT_BACK_CAMERA, preview, analyzer ) }, ContextCompat.getMainExecutor(previewView.context)) }几个参数值得掰开说。STRATEGY_KEEP_ONLY_LATEST是必须的默认的KEEP_ONLY_LATEST在识别器处理不过来时会积压帧表现为“画面在动但结果延迟两秒”设成只保留最新帧后识别永远跟着当前画面走。FALLBACK_RULE_CLOSEST_HIGHER_THEN_LOWER表示优先挑不低于 720p 的档位挑不到再往下降比精确匹配稳因为不同机型支持的预览尺寸差异很大。proxy.close()的位置是另一个血泪点。ImageProxy是有数量上限的缓冲区你不关它几帧之后分析器就再也不回调了现象是“第一次能扫之后就死了”。上面代码把它放在addOnCompleteListener里保证成功失败都会释放。lastRaw那个去重也必要识别器一秒能回调十几次不去重的话连接请求会被连续触发WifiNetworkSpecifier那边会直接抛“已有请求在进行中”。3.3 从 Barcode 到 WifiQr中间加一层容错拿到rawValue之后不要直接扔给parse中间隔一层处理更稳。真实场景里二维码可能是从别的工具导出的前缀写成WIFI:也可能写成wifi:末尾可能少分号SSID 前后可能带空格。我一般做三件事trim()去掉首尾空白、大小写不敏感地校验前缀、解析失败时用宽松模式再试一次比如把没有T字段的直接当 WPA 处理。private fun onQrDecoded(raw: String) { val result WifiQrParser.parse(raw) if (result.isSuccess) { val qr result.getOrThrow() // 交给下一章的连接分派逻辑 connect(qr) } else { // 明确告诉用户是哪一步失败而不是笼统的“二维码无效” showError(result.exceptionOrNull()?.message ?: 无法识别的二维码) } }这层的价值体现在错误提示上。解析失败的原因就那么几种前缀不对、没有 SSID、加密网络没密码。把它们分别提示出来用户才知道是“扫错码了”还是“这张码印坏了”。笼统地报“二维码无效”用户唯一能做的就是再扫一次然后你收到第二条同样的日志。4. 连接链路Android 10 这道分水岭两套 API 与三条权限链到这一步SSID 和密码都在手上了剩下的看起来只是“调个 API”。但 Android 10API 29把旧的WifiManager.addNetwork标记为废弃并收紧了权限同一个功能从此分裂成两条完全不同的实现路径。更麻烦的是新路径还分叉成两个语义不同的 APIWifiNetworkSpecifier是临时的、应用级的、退到后台可能断WifiNetworkSuggestion是持久的、系统级的、需要用户点一次通知同意。选错了不是连不上是“连上了但过一会儿自己断了”这种问题最难解释。4.1 API 29 以下WifiConfiguration 直连注意引号老路径绕不开WifiConfiguration它的字段赋值有几个格式要求写错了addNetwork直接返回 -1。Suppress(DEPRECATION) fun connectLegacy(wifiManager: WifiManager, qr: WifiQr): Boolean { val config WifiConfiguration().apply { // SSID 与密码必须带双引号包裹这是 WifiConfiguration 的格式要求 SSID \${qr.ssid}\ hiddenSSID qr.hidden when (qr.type) { AuthType.NOPASS - { allowedKeyManagement.set(WifiConfiguration.KeyMgmt.NONE) } AuthType.WEP - { allowedKeyManagement.set(WifiConfiguration.KeyMgmt.NONE) allowedAuthAlgorithms.set(WifiConfiguration.AuthAlgorithm.OPEN) allowedAuthAlgorithms.set(WifiConfiguration.AuthAlgorithm.SHARED) wepKeys[0] \${qr.password}\ wepTxKeyIndex 0 } else - { allowedKeyManagement.set(WifiConfiguration.KeyMgmt.WPA_PSK) preSharedKey \${qr.password}\ } } } val netId wifiManager.addNetwork(config) if (netId -1) return false wifiManager.disconnect() val enabled wifiManager.enableNetwork(netId, true) return enabled wifiManager.reconnect() }三个参数细节。第一SSID和preSharedKey的双引号是硬性要求少了的话系统会把它当成十六进制原始的写法去解析返回 -1 且没有任何错误信息这是最容易卡住新手的地方。第二enableNetwork(netId, true)第二个参数是attemptConnect必须传true传false只是把网络标记为可用但不主动连。第三先disconnect()再enableNetwork再reconnect()这个顺序不是玄学某些定制 ROM 上如果不先断开新网络会一直处于“已保存但未连接”的状态。还需要注意addNetwork的返回值语义它返回的是系统分配的netId同一个 SSID 且密码相同的情况下重复调用会返回同一个 ID幂等但密码变了会新建一条配置旧的那条还留着。做门店应用的话连上之后我一般会顺手把除当前netId之外的旧配置removeNetwork掉否则配置文件越积越多用户下次手动进设置会看到一堆重名网络体验很糟。4.2 API 29 及以上Specifier 管当下Suggestion 管长期新路径的两个 API 语义完全不同选错就是线上事故。WifiNetworkSpecifier配合ConnectivityManager.requestNetwork使用特点是会弹出系统选择框让用户确认、连接是应用级的、App 进程退到后台或销毁时网络会被回收、不需要用户去系统设置里操作。它适合“扫完码马上要用一下”的场景比如扫码后立刻打开一个局域网配置页面。RequiresApi(Build.VERSION_CODES.Q) fun connectWithSpecifier(context: Context, qr: WifiQr) { val specifier WifiNetworkSpecifier.Builder() .setSsid(qr.ssid) .apply { when (qr.type) { AuthType.NOPASS - Unit AuthType.WEP - setWepKey(qr.password!!) AuthType.SAE - setWpa3Passphrase(qr.password!!) else - setWpa2Passphrase(qr.password!!) } } .build() val request NetworkRequest.Builder() .addTransportType(NetworkCapabilities.TRANSPORT_WIFI) // 关键移除 INTERNET 能力避免系统对这个网络做连通性校验 .removeCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) .setNetworkSpecifier(specifier) .build() val cm context.getSystemService(ConnectivityManager::class.java) cm.requestNetwork(request, object : ConnectivityManager.NetworkCallback() { override fun onAvailable(network: Network) { // 需要把业务流量绑到这个网络上否则仍走默认网络 cm.bindProcessToNetwork(network) } override fun onUnavailable() { // 用户点了取消或密码错误 } override fun onLost(network: Network) { cm.bindProcessToNetwork(null) } }) }removeCapability(NET_CAPABILITY_INTERNET)这行是整个方法里最值得抄的一行。加上它系统不会对这个 WiFi 做“是否能访问公网”的探测也就不会弹出那个“此网络无法访问互联网是否保持连接”的二次确认。门店的 WiFi 很多是内网、没有外网出口不加这行的话用户会被多问一次很多人就在这里点了“否”然后反馈“你们这个扫码连不上”。bindProcessToNetwork也不能省。requestNetwork只是“建立”了连接你的 App 默认流量还是走原来的网络可能是移动数据。不绑的话你以为连上了 WiFi实际请求还是从 4G 出去的访问局域网 IP 自然全超时。WifiNetworkSuggestion则是另一套逻辑RequiresApi(Build.VERSION_CODES.Q) fun suggestNetwork(context: Context, qr: WifiQr): Int { val suggestion WifiNetworkSuggestion.Builder() .setSsid(qr.ssid) .apply { when (qr.type) { AuthType.NOPASS - Unit AuthType.SAE - setWpa3Passphrase(qr.password!!) else - setWpa2Passphrase(qr.password!!) } } .setIsHiddenSsid(qr.hidden) .build() return context.getSystemService(WifiManager::class.java) .addNetworkSuggestions(listOf(suggestion)) }它不会立刻建立连接而是把这张网络“推荐”给系统用户会在通知栏收到一条“某应用想要连接到 xxx”的提示同意之后系统就会像对待自己保存的网络一样自动连接。返回值是STATUS_NETWORK_SUGGESTIONS_SUCCESS之类的状态码注意它只代表“建议已提交”不代表“用户已同意”真正的连接结果要靠监听ACTION_WIFI_NETWORK_SUGGESTION_POST_CONNECTION广播。所以选型结论很清楚扫码后马上就要用网络配网、局域网控制——用WifiNetworkSpecifier希望用户下次来门店自动连上——用WifiNetworkSuggestion。两个都要的话就都调先 specifier 保当下再 suggestion 保下次。4.3 权限与版本分派一张表把分支写清楚运行时权限的申请顺序也有讲究。相机权限和 WiFi 权限不要一次性全申请用户看到连着弹三个框会直接拒绝。我的做法是进页面先要相机扫到码之后再要 WiFi 相关权限这样用户的理解链路是连贯的——“我要扫码” → “扫到了现在要连网”。版本分派写成表更清楚系统版本连接 API必须权限用户交互≤ API 28WifiConfigurationenableNetworkCHANGE_WIFI_STATE、ACCESS_FINE_LOCATION无弹窗直接连API 29–32WifiNetworkSpecifierACCESS_FINE_LOCATION、ACCESS_NETWORK_STATE系统选择框 可能的“无互联网”确认API 29–32WifiNetworkSuggestionACCESS_FINE_LOCATION、CHANGE_WIFI_STATE通知栏同意API 33同上两者NEARBY_WIFI_DEVICES带neverForLocation同上分派代码本身很短但要用Build.VERSION.SDK_INT卡在 29而不是 30 或 31——WifiNetworkSpecifier是 29 引入的卡错版本会在 Android 10 设备上走到废弃分支然后静默失败。fun connect(qr: WifiQr) { if (Build.VERSION.SDK_INT Build.VERSION_CODES.Q) { connectWithSpecifier(appContext, qr) // 顺手提交一条持久化建议下次进店免扫码 if (qr.password ! null) suggestNetwork(appContext, qr) } else { val wm appContext.getSystemService(WifiManager::class.java) connectLegacy(wm, qr) } }一个额外提醒WifiNetworkSpecifier在同一时刻只能有一个活跃请求。用户连扫两张不同的码第二个requestNetwork会抛IllegalStateException。所以调用前必须先unregisterNetworkCallback掉上一个或者用一个布尔量挡住重复点击。这个异常在文档里写得很轻线上却是高频崩溃点。5. 避坑排查五类扫描后连不上的现场复盘前面四章讲的是“应该怎么写”这一章讲“写完为什么还是不通”。下面五条都是我在真机上反复遇到过的按“现象 → 原因 → 解决”记每条都对应一个具体的日志特征方便你对照自己的现场。5.1 现象扫出来了提示密码错误但密码肉眼看着没错原因基本锁定在转义还原。生成端把;、,、:、转义了解析端却没还原导致传给系统的密码里还带着反斜杠abc\;123被当成密码去认证当然失败。还有一种变体解析端做了一次unescape连接层又做了一次\\被还原成\之后又被当成转义符吃掉一个字符密码少一位。解决在parse返回之前打一条日志把最终password的长度和hashCode打出来跟生成端对比。长度对不上就是转义问题长度对得上但连不上就是加密类型判断错了。我现在的习惯是parse方法里常驻一行Log.d(WifiQr, ssid${ssid.length} pwdLen${password?.length})不为别的就为了这种时候不用改代码重打包。5.2 现象addNetwork一直返回 -1没有任何报错在 API 28 及以下的机型上addNetwork返回 -1 最常见的原因是 SSID 或密码没加双引号。WifiConfiguration.SSID期望的是带引号的字面量你直接塞Shop-5G系统会尝试按十六进制解析解析失败就返回 -1日志里连个 warning 都没有堪称黑匣子。解决SSID \${qr.ssid}\preSharedKey \${qr.password}\两个都要。另外检查allowedKeyManagement有没有设置只填preSharedKey不设WPA_PSK同样返回 -1。这两条占了我早期踩坑记录的绝大部分。5.3 现象Android 10 上弹窗点了“连接”然后什么也没发生requestNetwork的回调有三个onAvailable、onUnavailable、onLost。很多人只处理了onAvailable用户点取消走onUnavailable代码里静默无声界面就卡在那里。更隐蔽的一种是onAvailable触发了但业务请求还是超时——因为没调bindProcessToNetwork流量仍然走默认网络。解决三个回调全实现onUnavailable里给明确提示“已取消或密码错误”onAvailable里立刻bindProcessToNetwork(network)onLost里置回null。验证方法是连上之后访问一个局域网内的固定 IP能通才说明绑定生效。用公网地址验证是无效的因为公网走哪条路都能通。5.4 现象连着连着网断了尤其是切到后台再回来WifiNetworkSpecifier建立的连接生命周期绑在发起请求的进程上。App 退到后台被系统回收、或者用户手动划掉网络就会被移除。这不是 bug是设计如此。把它当“临时连接”用没问题当“连上就一直在”用必然翻车。解决需要长期保持的场景改用WifiNetworkSuggestion让系统接管这条网络的生命周期。如果两个都要就在 specifier 连上之后再提交一次 suggestion用户同意之后下次就由系统自动连不再依赖你的进程。这里有个细节suggestion 提交后用户没点同意的话那条通知会一直挂着所以提交时机最好放在“用户已经成功连上一次”之后转化率会高很多。5.5 现象部分机型扫得飞快部分机型死活扫不出来同一张码某厂商 Android 13 的机器一秒出结果另一台 Android 11 的机器举十秒都没反应。原因通常是相机分辨率协商失败——ResolutionSelector请求 1280×720但该机型不支持这个预览尺寸组合回退到了 640×480二维码在画面里像素太少。另一种可能是该机型的ImageAnalysis默认走了 YUV_420_888 之外的格式InputImage.fromMediaImage转换失败process一直不回调。解决ImageAnalysis.Builder().setOutputImageFormat(ImageAnalysis.OUTPUT_IMAGE_FORMAT_YUV_420_888)显式指定格式兼容性最好。分辨率那边把回退规则设成FALLBACK_RULE_CLOSEST_HIGHER_THEN_LOWER之外再加一层兜底连续 5 秒没有识别结果就主动unbindAll后用 1920×1080 重试一次。这个“重试一次”的逻辑听着土但在中低端机型上救回过好几个现场。6. 生成端与自检自己造码、批量回连把兼容性验证做成流水线真机兼容性这件事靠手动一台台试是撑不住的。我的做法是把验证拆成两段先在生成端造一批“故意刁难”的码再在连接端跑一遍自动化回连把兼容性问题在发版前逼出来。6.1 造一批边界码把转义和长度全覆盖正常码谁都能过边界码才是照妖镜。我一般固定造这么一组SSID 含分号、SSID 含中文、SSID 正好 32 字节、密码含反斜杠和双引号、密码 8 位与 63 位各一、无密码开放网络、隐藏网络。用第 2 章那个build_wifi_qr生成再配qrcode库出图。import qrcode from qr_builder import build_wifi_qr # 第 2 章的生成函数 CASES [ (Normal-2G, 12345678, WPA, False), (Coffee;Bar, pa\\ss\word, WPA, False), # 分号、反斜杠、双引号全覆盖 (中文网络-5G, 12345678, WPA, False), (Open-Guest, , nopass, False), (Hidden-Lab, 12345678, WPA, True), (A * 32, 1 * 63, WPA, False), # SSID 与密码长度上限 ] for ssid, pwd, auth, hidden in CASES: payload build_wifi_qr(ssid, pwd, auth, hidden) img qrcode.make(payload) img.save(fqr_{abs(hash(ssid)) % 10000}.png) print(payload)跑完先别急着扫码做一次闭环校验把每一条payload喂给你 Kotlin 侧的parse可以把解析逻辑抽成一个纯 JVM 模块跑单元测试断言还原出的ssid和password跟输入完全一致。这一步能挡掉绝大部分转义错误而且跑一次只要几百毫秒比真机试快两个数量级。6.2 用 adb 把连接结果捞出来别靠肉眼看真机回连的时候界面提示“连接成功”并不可信因为onAvailable只代表链路建立不代表能通信。我会用几条命令做交叉验证# 看当前连的是哪个 SSID部分机型需要 root 或调试版本 adb shell dumpsys wifi | grep -i mWifiInfo | head -n 3 # 看系统里保存的网络建议与其状态 adb shell dumpsys wifi | grep -i -A2 NetworkSuggestion # 确认进程绑定的网络配合 App 内日志一起看 adb shell dumpsys connectivity | grep -i Bound to第一条命令的输出里能找到当前SSID:字段跟你要连的那个比对。如果显示的还是旧网络说明bindProcessToNetwork没生效或者连接压根没建立。第二条用来确认 suggestion 是否真的被系统接受——addNetworkSuggestions返回成功只代表提交成功系统侧可能因为重复提交或策略限制而丢弃。6.3 一个收尾习惯把“连不上”的日志字段固定下来排查这类问题最耗时间的不是修复是复现。所以我现在每接一个 WiFi 相关需求都会在连接失败的分支里固定打这几个字段sdkInt、ssid 长度、password 长度、authType、走的分支legacy/specifier/suggestion、回调名onUnavailable/onLost。这几项加起来不到一百字节但基本能把问题定位到具体某一步不用再问用户“你手机什么系统”。吃过一次亏某批门店的 Android 11 设备反馈“扫码后弹窗闪一下就没了”日志里只有一句笼统的失败。后来补上分支标记才发现是WifiNetworkSpecifier抛了IllegalStateException——上一个请求没注销。加了“先unregisterNetworkCallback再发新请求”之后同类问题再没出现过。从那以后我每次发版前都会强制走一遍这套边界码加 adb 交叉验证的流程宁可多花半小时也不想再在群里被追着问“为什么我这台连不上”。希望帮到你。本文还有配套的精品资源点击获取