ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Android扫码连Wi-Fi全攻略:从二维码解析到系统API适配

Android扫码连Wi-Fi全攻略:从二维码解析到系统API适配

简介:面向Android开发者,提供“扫描WiFi二维码自动连接”功能的完整工程实现与源码解析。资源基于常用扫码库(Zxing/ML Kit)完成二维码识别,支持实时扫描与离线图片解析,实现WiFi配置信息(SSID、密码、加密方式)的提取,并通过WifiManager、WifiConfiguration等API完成自动添加与连接;同时覆盖Android 6.0+动态权限申请、用户确认弹窗、连接失败反馈等交互细节,适合学习WiFi管理机制与第三方库集成的初中级开发者。包内共141个文件,主要包含Java源码、Android XML配置、class文件、界面PNG素材及可安装APK,压缩包仅2.16MB,目录层次清晰,方便直接导入工程或逐文件对照学习。已有2014人学习下载,配套代码与解析能够帮助读者快速掌握扫码到连接的全链路实现,并可扩展处理密码错误、网络不可达、配置已存在等常见异常场景,节省自行摸索时间。

1. 为什么我把“扫码连Wi-Fi”当成Android项目里的硬骨头来啃

在Android上做“扫一扫二维码自动连Wi-Fi”,听起来只是“找到热点、输入密码”的自动化版本,真动手后会发现三块硬骨头:二维码格式不统一、连接API随版本分叉、国产ROM的权限行为各不一样。我最早接手这个需求是某公司内部访客网络接入工具,访客在前台扫一下就自动连网。最开始我也以为用开源库解出二维码、再调系统WifiManager就完了,结果真机联调时,一台扫码后停在“已保存”,另一台直接报身份验证错误,最后靠抓WifiManager返回码一层层定位,才明白这些坑大多出在权限和API分支选择上。

这篇文章会从二维码文本结构讲起,给出实际能用的解析器,再按Android版本差异给出连接模块的完整代码,最后把我在真机上踩过的坑展开细讲。适合两类人:一是要快速在项目里实现“扫码连Wi-Fi”功能的Android工程师,二是想搞清Wi-Fi连接API边界和权限细节的开发者。

2. 实操前必须验证的二维码格式:不同来源的Wi-Fi二维码到底长什么样

2.1 标准格式与各字段含义

Wi-Fi二维码在Android和iOS之间形成了一套事实格式,最常见的内容是这样一段文本:

WIFI:T:WPA;S:MyHome;P:password123;;

T表示加密类型,常见值有WPA、WPA2、WEP、nopass;S表示SSID热点名称;P表示密码;H是可选的隐藏网络标志,值为true或false。分号分隔每个键值对,末尾允许有两个分号或一个分号,解析时必须同时容忍这两种写法。

我刚写第一版解析器时图省事,直接对整个字符串按分号做split,然后按数组位置取S和P值。试了大约三十张各种来源的二维码后发现,这种粗暴做法对密码中带分号的二维码会直接解析出错。比如密码是abc;def,split后会把P:abc当作密码,而def后面全被当成新字段,连接必然失败。所以解析器不能盲切分,必须按字段结构逐项匹配。

2.2 解析器的兼容性处理:转义、大小写和空格

我在项目里把它做成一个独立静态方法,兼容转义、大小写差异和多余空格,代码大约三十行:

public Map<String, String> parseWifiQr(String raw) { Map<String, String> result = new HashMap<>(); if (raw == null || raw.trim().isEmpty()) return result; String trimmed = raw.trim(); if (!trimmed.toUpperCase().startsWith("WIFI:")) { return result; } String body = trimmed.substring(5); // 负向回溯断言:只按没有被反斜杠转义的分号切分 String[] segments = body.split("(?<!\\\\);"); for (String seg : segments) { if (seg.length() < 2) continue; if (seg.charAt(1) != ':') continue; char key = seg.charAt(0); String value = seg.substring(2) .replace("\\;", ";") .replace("\\\\", "\\") .trim(); result.put(String.valueOf(key).toUpperCase(), value); } return result; }

这段代码的两个关键点:一是split正则用了负向回溯,只有前一个字符不是反斜杠的分号才作为分割点;二是值的还原顺序,先还原\;为分号,再还原\\为单个反斜杠。如果顺序反过来,\\;这类组合会被错误地先削掉反斜杠,最终密码对不上。

解析完成后得到类似这样的字段集合:

字段示例值是否必填说明
TWPA/WPA2/WEP/nopass必填加密类型,解析时建议统一转大写
SMyHome_2G必填SSID,内部空格保留
Ppassword123按需WEP/WPA必有,nopass可省略
Htrue可选是否为隐藏网络,用于连接参数

Android系统在保存Wi-Fi配置时,SSID是不做trim的。这意味着二维码里若写了S:MyHome(尾部空格),系统保存时会把空格算进SSID名称,而路由器上若真实名称没有空格,两边永远匹配不上。我统一的做法是解析阶段trim掉字段两端的空白,后续连接阶段不再做任何trim,保证传入系统的字符串与解析结果完全一致。

2.3 样张回放:开放网络、WEP和隐藏网络的解析差异

真机调试时不要只测WPA一个类型。开放网络、WEP和隐藏网络各有细节。我一般会提前准备好这样一组样张,扫码后逐条验证解析结果:

样张文本期望的解析结果注意点
WIFI:T:nopass;S:GuestNet;;T=nopass,S=GuestNet不需要密码,连接时用开放网络配置
WIFI:T:WPA;S:Office;P:abc;123;;密码应为abc;123测试分号转义
WIFI:T:WEP;S:Lobby;P:12345678;H:true;;T=WEP,H=true隐藏网络需额外设置隐藏标志,WEP的KeyMgmt配置不同

这几个样张能覆盖绝大多数解析器变体。遇到带H:true的隐藏网络时,连接阶段还必须给网络配置打上hiddenSSID标记,否则系统扫不到BSSID,会一直处于“已保存但不在范围内”的状态。下一章连接模块里会具体写如何把H字段转换成系统参数。还有一类生成器会把S字段写成带英文引号的样式,比如S:"MyHome",解析时同样需要把引号剥掉并trim,因为Android系统保存的SSID会自动加引号,你的配置里再手动加一遍就会变成双重引号,反而匹配不上。

3. Android扫码连接Wi-Fi的完整实现:从权限配置到状态回调

3.1 权限配置与依赖选择

连接权限是整个方案里最容易被忽略的部分。Android的Wi-Fi连接权限在不同版本上要求不同,直接决定你能不能拿到扫描结果和写入配置:

Android版本必须申请的权限说明
Android 8-9ACCESS_FINE_LOCATION、ACCESS_WIFI_STATE、CHANGE_WIFI_STATE连Wi-Fi也需要定位权限,即使应用本身不定位
Android 10-12ACCESS_FINE_LOCATION扫描和连接Wi-Fi必须在定位服务开启时进行
Android 13+NEARBY_WIFI_DEVICES、ACCESS_FINE_LOCATIONNEARBY_WIFI_DEVICES为运行时权限,定位权限在多数厂商ROM上仍是隐性要求

NEARBY_WIFI_DEVICES必须在AndroidManifest里声明,targetSdk 33以上要在运行时申请。有个坑:如果给这个权限加了android:usesPermissionFlags="neverForLocation",系统会认为应用不涉及定位,扫描结果会被过滤掉。扫码连Wi-Fi这个场景,我建议不加这个标志,同时保留定位权限申请,兼容国内ROM。

依赖方面不必自己写相机解码,接一个常用的开源扫码库就行,注意它内部依赖CameraX还是Camera2,决定你的minSdkVersion选多少。扫码页面直接用库自带的CaptureActivity,省去自己维护取景框的麻烦。

3.2 扫码模块:复用扫码库但别丢掉返回码

启动扫码页:

Intent scanIntent = new Intent(this, CaptureActivity.class); scanIntent.putExtra("PROMPT_MESSAGE", "请对准Wi-Fi二维码"); startActivityForResult(scanIntent, REQUEST_CODE_SCAN);

在结果回调里拿到原始解码字符串,再交给解析器:

@Override protected void onActivityResult(int requestCode, int resultCode, Intent data) { super.onActivityResult(requestCode, resultCode, data); if (requestCode == REQUEST_CODE_SCAN) { if (resultCode == RESULT_OK && data != null) { String raw = data.getStringExtra("SCAN_RESULT"); Map<String, String> info = parseWifiQr(raw); if (info.isEmpty() || !info.containsKey("S")) { Toast.makeText(this, "不是有效的Wi-Fi二维码", Toast.LENGTH_SHORT).show(); return; } connectToWifi(info); } else { Toast.makeText(this, "扫码已取消", Toast.LENGTH_SHORT).show(); } } }

这里要区分“SCAN_RESULT为空”“解析结果无S字段”“扫码取消”三种情况。很多实现把所有失败都提示成“连接失败”,最后调试时根本看不出是扫码问题还是连接问题。我的习惯是每种失败都打一条独立Log,并在Toast上给出不同文案,方便测试人员一眼分清。

3.3 连接模块:Android 10前后两套逻辑并行落地

连接模块是版本分叉最重的地方。Android 10之前用WifiManager.addNetwork + enableNetwork,Android 10之后系统推荐用WifiNetworkSuggestion,同时废弃了enableNetwork。但从真机测试看,Android 10以上用suggestion并不保证立刻连接,部分定制ROM要用户手工确认。所以我的做法是保留两套逻辑,按SDK版本选择,并留一个手动补充连接入口。

private void connectToWifi(Map<String, String> info) { String ssid = info.get("S"); if (TextUtils.isEmpty(ssid)) return; String pwd = info.get("P"); String type = info.getOrDefault("T", "WPA").toUpperCase(); boolean hidden = "true".equalsIgnoreCase(info.get("H")); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { connectWithSuggestion(ssid, pwd, type, hidden); } else { connectWithLegacy(ssid, pwd, type, hidden); } }

type统一转大写,可以避开二维码里写wpa或Wpa导致的分支误判。hidden的默认值必须为false,不能因为没写H字段就把它当null处理,否则隐藏网络的条件判断会出错。

Android 10以下使用老式WifiConfiguration:

private void connectWithLegacy(String ssid, String pwd, String type, boolean hidden) { WifiManager wm = (WifiManager) getApplicationContext() .getSystemService(Context.WIFI_SERVICE); // 清理相同SSID的旧配置,避免系统沿用错误密码 List<WifiConfiguration> existing = wm.getConfiguredNetworks(); if (existing != null) { for (WifiConfiguration cfg : existing) { String cfgSsid = cfg.SSID; if (cfgSsid != null && cfgSsid.equals("\"" + ssid + "\"")) { wm.removeNetwork(cfg.networkId); } } } WifiConfiguration config = new WifiConfiguration(); config.SSID = "\"" + ssid + "\""; config.hiddenSSID = hidden; if ("NOPASS".equals(type)) { config.allowedKeyManagement.set(WifiConfiguration.KeyMgmt.NONE); } else if ("WEP".equals(type)) { config.allowedKeyManagement.set(WifiConfiguration.KeyMgmt.NONE); config.allowedAuthAlgorithms.set(WifiConfiguration.AuthAlgorithm.OPEN); config.allowedGroupCiphers.set(WifiConfiguration.GroupCipher.WEP40); config.allowedGroupCiphers.set(WifiConfiguration.GroupCipher.WEP104); config.wepKeys[0] = "\"" + pwd + "\""; config.wepTxKeyIndex = 0; } else { config.preSharedKey = "\"" + pwd + "\""; config.allowedKeyManagement.set(WifiConfiguration.KeyMgmt.WPA_PSK); } int networkId = wm.addNetwork(config); if (networkId != -1) { wm.disconnect(); wm.enableNetwork(networkId, true); wm.reconnect(); } }

这段代码有三处需要解释:第一,config.SSID和preSharedKey都必须手动加上英文双引号,Android系统内部保存的SSID和密码策略就是带引号的,省略会导致匹配不上。第二,清理相同SSID的动作必须有,否则系统会沿用旧的错误密码配置。第三,WEP的配置和WPA不一样,二维码里如果是WEP加密就按wepKeys[0]写入。

Android 10以上的Suggestion API版本:

@RequiresApi(api = Build.VERSION_CODES.Q) private void connectWithSuggestion(String ssid, String pwd, String type, boolean hidden) { WifiNetworkSuggestion.Builder builder = new WifiNetworkSuggestion.Builder() .setSsid(ssid) .setIsHiddenNetwork(hidden); if ("NOPASS".equals(type)) { // 开放网络不要调用setWpa2Passphrase,也不要强制OWE } else { builder.setWpa2Passphrase(pwd); } List<WifiNetworkSuggestion> suggestions = Collections.singletonList(builder.build()); WifiManager wm = (WifiManager) getApplicationContext() .getSystemService(Context.WIFI_SERVICE); int status = wm.addNetworkSuggestions(suggestions); if (status == WifiManager.STATUS_NETWORK_SUGGESTIONS_SUCCESS) { // 建议已生效,系统会在合适时机自动连接 Log.i(TAG, "suggestion accepted"); } else if (status == WifiManager.STATUS_NETWORK_SUGGESTIONS_ERROR_ADD_DUPLICATE) { // 重复建议,先移除再添加 wm.removeNetworkSuggestions(suggestions); wm.addNetworkSuggestions(suggestions); } }

Suggestion API有几个边界:setIsHiddenNetwork在隐藏网络时不可省;NOPASS开放网络在部分系统上必须保持默认增强开放设置,不要强制置true。addNetworkSuggestions成功只表示系统接受了建议,不一定立刻连接,所以要结合状态检测和用户引导完成闭环。

3.4 连接结果检测:广播加轮询双保险

连接结果检测我建议广播和轮询都做。只依赖广播在定制ROM上会漏消息,只依赖轮询又不够及时。广播部分监听两个Action,一个是网络状态变化,一个是Supplicant状态变化:

private final BroadcastReceiver wifiReceiver = new BroadcastReceiver() { @Override public void onReceive(Context context, Intent intent) { String action = intent.getAction(); if (WifiManager.NETWORK_STATE_CHANGED_ACTION.equals(action)) { NetworkInfo netInfo = intent.getParcelableExtra(WifiManager.EXTRA_NETWORK_INFO); if (netInfo != null && netInfo.isConnected()) { WifiInfo wifiInfo = wm.getConnectionInfo(); String currentSsid = normalizeSsid(wifiInfo.getSSID()); if (targetSsid.equals(currentSsid)) { notifySuccess(); } } } else if (WifiManager.SUPPLICANT_STATE_CHANGED_ACTION.equals(action)) { int errorCode = intent.getIntExtra(WifiManager.EXTRA_SUPPLICANT_ERROR, -1); if (errorCode == WifiManager.ERROR_AUTHENTICATING) { notifyPasswordError(); } } } };

normalizeSsid就是把系统中带引号的SSID剥离掉首尾引号再比较;如果系统返回<unknown ssid>,说明当前还没关联到热点,直接跳过。

轮询代码用一个短定时器,每2秒查一次连接状态:

private void startPollingCheck() { if (pollRunnable != null) return; pollRunnable = new Runnable() { @Override public void run() { WifiInfo info = wm.getConnectionInfo(); String ssid = normalizeSsid(info.getSSID()); if (targetSsid.equals(ssid) && info.getIpAddress() != 0) { notifySuccess(); return; } handler.postDelayed(this, 2000); } }; handler.postDelayed(pollRunnable, 2000); }

轮询不能只看SSID,还要看getIpAddress()不为0。有些场景系统已经关联上但没有拿到IP,此时还不算真正可用网络。轮询要设置超时,比如90秒后没连上就取消并提示失败,避免后台一直空转。

3.5 WifiManager返回码的调试方法

连接失败时最怕的是系统给你一个“已保存”却没有任何失败提示。定位这类问题我都是直接抓返回码。addNetwork返回-1表示配置未生效,enableNetwork返回false表示系统拒绝切换调用。在Suggestion API里,addNetworkSuggestions的返回码分四种:成功、添加重复、添加失败、权限被拒。建议把每一种都打日志,并保留一份在内存里,方便用户反馈时导出。

4. 避坑清单:从“扫不出来”到“连接失败”的五个常见问题

4.1 现象一:解析正确、返回成功,但系统一直停在“已保存”

现象:扫码出来的SSID和密码肉眼核对完全正确,addNetwork或addNetworkSuggestions都返回成功,但热点状态一直停在“已保存”,不自动连接。

原因:手机上此前已经保存过同名热点,系统优先沿用了旧配置,旧密码和当前二维码不一致时,就会反复出现认证失败但不弹框。也有一类是隐藏网络没有设置hiddenSSID或setIsHiddenNetwork(true),系统扫描不到目标BSSID,配置就一直处于“已保存但不满足连接条件”。

解决:连接前先清理同名旧配置。legacy阶段遍历getConfiguredNetworks(),比较SSID时注意引号;suggestion阶段先调removeNetworkSuggestions再重新添加。隐藏网络必须显式传入H字段的值。清理同名配置时要注意,getConfiguredNetworks()在高版本系统上可能因为权限不足返回null或空列表,要做null保护,不能直接遍历。

4.2 现象二:Android 13权限开了,扫描列表却是空的

现象:targetSdk 33,AndroidManifest已声明NEARBY_WIFI_DEVICES,并且在设置里已经授权,但wm.startScan()后回调列表为空,或者扫码后系统一直提示“不在范围内”。

原因:很多项目给NEARBY_WIFI_DEVICES加了android:usesPermissionFlags="neverForLocation"标志。这个标志一加,系统在Android 13上会隐藏扫描到的BSSID和部分SSID信息,直接导致扫码连接时系统拿不到足够信息去匹配热点。

解决:在“扫码连Wi-Fi”场景里不要加neverForLocation,必要时连ACCESS_FINE_LOCATION一起申请。真机测试时先确保应用在前台时有完整位置服务授权。如果只是解析二维码后连接,不手动扫描Wi-Fi列表,这个坑可以避开;但只要调用了startScan或希望系统自动识别周边网络,就必须按上述方式配权限。

4.3 现象三:定制ROM不回调连接状态广播

现象:系统设置里Wi-Fi已经显示连上,但应用的NETWORK_STATE_CHANGED_ACTION回调一直没触发,或者收到的NetworkInfo.isConnected()为false。

原因:部分定制ROM的Wi-Fi状态广播存在延迟,把网络切换的成功回调绑定在系统界面自己的逻辑上;应用侧只监听官方广播时,无法第一时间收到状态翻转。suggestion方案在部分手机上也会出现“系统已收下建议,但没有回投系统广播”的情况。

解决:把广播和轮询组合起来。广播用于快速路径,轮询用于兜底。轮询时每2秒取一次getConnectionInfo(),连续两次SSID匹配且IP非0就判定成功。我还会再比对getRssi()的值是否大于等于-90,这个阈值能过滤掉“已保存但信号弱”的假成功。

4.4 现象四:二维码里SSID大小写不一致导致反复重连

现象:二维码里SSID是“OfficeWiFi”,路由器实际热点是“officewifi”,扫码后显示已连接,却拿不到IP或反复重连。

原因:SSID匹配在Wi-Fi协议层面区分大小写。解析器里如果做了toUpperCase()后直接把值用于连接,会把原SSID大小写改掉;如果只是用toUpperCase做前缀判断,但原值保留,则没有这个问题。有些开发者图省事,把整个字段值也转成了大写,保存的SSID就和路由器不一致。

解决:前缀判断可以toUpperCase(),但字段值必须保留原始大小写。唯一允许trim的是头尾空白。连接成功与否的比对也只能用原始大小写值,不能用转换后的值。做日志输出时也要保持原样,方便肉眼对照。

4.5 现象五:WEP或开放网络环境下,配置看着没问题却连不上

现象:扫码内容为T:WEP或T:nopass,按普通代码写入wepKeys或空密码,系统一直提示“网络不可用”或一直转圈。

原因:WEP的WifiConfiguration至少需要设置KeyMgmt.NONE和两个GroupCipher之一;开放网络在某些Android版本上不能同时设置预共享密钥。部分二维码生成器把WEP密码错误地写成了十六进制ASCII串,也会导致系统拒绝。

解决:WEP连接按上文代码处理,设置wepKeys和wepTxKeyIndex;nopass则只设置KeyMgmt.NONE,不要设置preSharedKey。如果确认二维码是对的但仍然失败,抓系统日志看wpa_supplicant的错误码,能区分是密码错误还是加密类型不匹配。WEP本身兼容性就不如WPA,实际项目里遇到扫码WEP的现场,我优先建议改成WPA2再出样张。

5. 最后的验证:用一组样张二维码回放整条连接链路

5.1 准备样张集

真机调试时不要每次拿真实前台的热点去试,那样连错密码反复影响别人上网。我习惯准备一组固定的样张文本,覆盖WPA2正常密码、WPA2密码含分号、开放网络、WEP、隐藏网络五种场景,在应用里加一个隐藏的“回放测试”入口,把样张文本直接传入解析器和连接模块。这样可以绕开相机扫码,快速验证解析和连接逻辑在特定手机上是否正常。

5.2 在真机上跑一遍回放

回放测试的代码核心是这样一段:

private void replayTest(String[] samples) { for (String sample : samples) { Map<String, String> info = parseWifiQr(sample); Log.i(TAG, "ssid=" + info.get("S") + ", type=" + info.get("T")); connectToWifi(info); // 等待8秒后检查是否连上目标SSID Handler handler = new Handler(Looper.getMainLooper()); handler.postDelayed(() -> { WifiInfo current = wm.getConnectionInfo(); boolean ok = targetSsid.equals(normalizeSsid(current.getSSID())) && current.getIpAddress() != 0; Log.i(TAG, "replay result: " + ok); }, 8000); } }

回放时注意一次只测一个样张,因为Wi-Fi切换本身需要几秒,连续写两个热点会发生“刚连上A又被B覆盖”的假失败。我通常每个样张之间间隔至少20秒,等前一个结果落定后再测下一个。

这套回放流程帮我发现过不少玄学问题:某个型号的手机对WEP支持很差,某个系统版本对带引号的SSID匹配有兼容性问题,还有一次是同一台设备上残留的旧配置导致新二维码始终不生效。从那以后,我每次接手Wi-Fi连接相关需求,都先把样张回放跑一遍,再上真实热点联调,能省掉大半调试时间。希望帮到你。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进