你写了一个TCP客户端,在电脑模拟器里跑得飞起,换成真机却连不上服务器。Logcat里有时报Permission denied,有时报Cleartext HTTP traffic not permitted,还有时候什么都不报,就是一直卡在connect。这三个错误我全都踩过,而且顺序极其统一:先补权限,再开明文,最后处理线程。后来才明白,这根本不是代码逻辑问题,而是Android系统在“前提配置”这个环节上,给开发者设了至少三道关卡。如果你正好在做Android端的TCP网络通信,或者是第一次把Socket长连接跑在手机上,那这篇文章值得看完。我会把权限、明文策略、线程模型、网络切换监听、超时与DNS这些“连接之前”的事,一件件讲透。
1. 权限清单那两行:INTERNET与ACCESS_NETWORK_STATE的真正作用
1.1 为什么INTERNET是普通权限,漏了却照样连不上
Android的权限体系分成了普通权限、危险权限和特殊权限。INTERNET属于普通权限,意味着它在应用安装时就会被系统自动授予,不需要像相机、定位那样弹窗请求。但“自动授予”不等于“不需要声明”。很多第一次接触Socket开发的同事,都会觉得既然不用运行时申请,那在AndroidManifest.xml里写不写都无所谓——然后就被打脸了。
在Android 6.0以下的设备上,漏掉INTERNET权限时,新创建的Socket会直接抛出Permission denied。而Android 6.0以上的设备,某些厂商的ROM会把这种错误显示成更让人摸不着头脑的connect failed: ECONNREFUSED。这就很阴险了,因为ECONNREFUSED通常意味着服务器那边拒绝了连接,你会下意识地去查服务器日志、查端口监听,最后折腾半天发现是自家权限没写。
所以第一行配置永远是这个:
<uses-permission android:name="android.permission.INTERNET" />这行代码要放在<manifest>节点下、<application>节点外。如果项目里有多个module或者引入了第三方SDK,最好在合并后的Manifest里确认一下,避免被覆盖。用aapt dump permissions可以快速验证最终打包的APK里到底有没有这个权限:
aapt dump permissions your-app.apk1.2 ACCESS_NETWORK_STATE不是可选项,是网络判断的前提
如果你只是写一个纯Socket长连接,可能确实只需要INTERNET就够了。但只要你的代码里需要判断“当前WiFi是否正常”“移动网络有没有连上”,或者是注册网络变化监听器,那就必须加第二个权限:
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />这个权限同样属于普通权限,安装时自动授予。但是漏掉它之后,ConnectivityManager.getActiveNetworkInfo()在很多高版本系统上会直接返回null,哪怕此时网络其实是通的。你拿着一个null去判断,以为设备断网了,于是走了一大堆“网络不可用”的逻辑,实际上只是权限没给。
我见过一个具体案例:某个开发者做了一个TCP心跳重连,逻辑是“只有在网络可用时才尝试重连”。漏了ACCESS_NETWORK_STATE后,手机连着WiFi,但应用判断网络一直不可用,于是重连机制彻底失效。排查了很久,最后发现是权限问题。这类问题在日志里通常不会报Permission denied,而是表现为“业务逻辑不走”,排查起来特别费劲。
所以我的建议是:只要你做任何和网络相关的事情,这两行权限直接都写上,没有任何坏处。
1.3 权限配置的坑:targetSdkVersion的影响
有个细节很多人忽略:如果你的targetSdkVersion低于28,系统为了兼容会默认允许某些明文流量;一旦你为了上架只把targetSdkVersion升到28以上,系统的网络策略会立刻收紧。很多老项目的TCP通道在升级targetSdk之前一切正常,升级之后突然连不上服务器,大概率就是撞上了明文流量策略。这并不是权限本身的问题,但如果你在排查权限时发现INTERNET和ACCESS_NETWORK_STATE都在,下一步就该检查targetSdkVersion和明文策略。
权限只是网络通信的入场券,真正让“有权限却连不上”这件事成为日常的,是Android 9之后的那一刀。
2. Android 9之后的明文流量策略:很多连接失败是“被系统拦了”
2.1 这个策略到底管不管原始Socket
Android 9(API 28)有一个重要的行为变更:默认情况下,应用不允许使用明文流量。这个策略的原始目的是鼓励开发者使用HTTPS等加密方案。但你要注意,它主要拦截的是基于HTTP协议的库和组件,比如HttpURLConnection、OkHttp、WebView,以及WebSocket中的ws://连接。对于直接new出来的java.net.Socket,如果你只是用TCP传输自定义二进制数据,系统并没有一个强制拦截的机制。
那为什么很多教程依然告诉你要配置明文?因为真实项目里几乎没有“纯TCP裸奔”的场景。常见的架构是:HTTP接口做登录鉴权,然后通过Socket维持长连接推送;或者用WebSocket协议替代自定义Socket,而WebSocket底层走的是HTTP升级流程,ws://会被明文策略拦截。也就是说,你只开明文不一定影响当前Socket,但只要你同时用到HTTP接口,就会撞上Cleartext HTTP traffic not permitted。
另外,部分国产ROM会在系统层面对明文Socket做一些“额外的保护”,导致你在真机上遇到和原生AOSP不一致的行为。所以,作为一个成熟的Android开发者,不要赌系统会不会网开一面。既然你的应用里同时存在TCP和HTTP的几率极高,那么在开发阶段直接允许明文,在生产阶段用网络安全配置精确放行,才是稳妥的做法。
2.2 两种配置方式:粗暴开关 vs 精准白名单
最简单的做法是在<application>标签上加上一个属性:
<application android:usesCleartextTraffic="true" ... >一行搞定,所有明文流量都放行。但说实话,这只能算开发期的临时方案。如果你的应用需要上架,或者安全评审比较严格,直接用true会给人一种“不设防”的感觉。更专业的做法是引入networkSecurityConfig:
<application android:networkSecurityConfig="@xml/network_security_config" ... >然后在res/xml/network_security_config.xml里这样写:
<?xml version="1.0" encoding="utf-8"?> <network-security-config> <base-config cleartextTrafficPermitted="false" /> <domain-config cleartextTrafficPermitted="true"> <domain includeSubdomains="true">192.168.1.100</domain> <domain includeSubdomains="true">api.example.com</domain> </domain-config> </network-security-config>这里base-config的默认值设成false,只对特定IP和域名放行。注意:domain节点里既可以写域名,也可以写IP地址。如果你要连的是局域网服务器,直接写IP就行。这个文件不仅适用于HTTP,也会影响WebView加载的URL。如果你们App里某个页面需要加载本地HTTP资源,也可以在这里把那个IP加进去。
2.3 debug和release分开配置的正确姿势
很多团队开发环境和生产环境用的是不同的服务器地址,开发环境可能还是HTTP内网地址。这时你可以利用networkSecurityConfig里一个专门给调试用的标签:debug-overrides。
<network-security-config> <base-config cleartextTrafficPermitted="false" /> <debug-overrides> <trust-anchors> <certificates src="user" /> </trust-anchors> </debug-overrides> <domain-config cleartextTrafficPermitted="true"> <domain includeSubdomains="true">192.168.1.100</domain> </domain-config> </network-security-config>debug-overrides只在debug构建下生效,release构建不会包含这些放宽的配置。这样你就不用每次切换环境都去改那个XML文件了。不过要注意,debug-overrides主要是用来信任用户证书的,如果你只是单纯想“debug包允许所有明文,release包不允许”,那更好的做法是建两个不同目录下的network_security_config.xml,分别对应src/debug/res/xml和src/release/res/xml。这样源码资源本身就区分了环境。
我自己的习惯是:debug包直接usesCleartextTraffic="true",确保本地联调不被卡;release包一定用networkSecurityConfig并列出线上真实域名。这套组合在多个项目里都很稳。
3. 主线程网络操作:一个很多新手反复撞的强约束
3.1 NetworkOnMainThreadException其实是StrictMode在救你
把Socket连接写在Activity的onCreate里,是所有Android新人几乎都会做的事情。在Android 4.0之前,这么干只是会让界面卡顿;4.0之后系统直接抛NetworkOnMainThreadException。这个异常机制来自于StrictMode的默认策略,目的就是防止主线程被阻塞。
TCP的connect和read都是阻塞操作。connect等服务器握手,read等数据到达,在网络抖动时可能卡几秒甚至几十秒。如果这些操作占用主线程,用户看到的App就是“点了没反应”,系统检测到输入事件在5秒内得不到响应,会直接弹出“Application is not responding”,也就是大名鼎鼎的ANR。
所以,TCP通信的前提配置里,线程模型是最重要的一条“软配置”。它不需要你写哪个清单文件,但决定着你后面所有代码能不能跑得起来。
3.2 读写分离:一个Socket至少需要两条工作线程
很多人知道不在主线程连Socket,但会在主线程里让一个Thread又连接又读又写,结果逻辑乱成一团。Socket是全双工的,这意味着你可以同时收发数据。一个简单的连接流程至少需要两个线程:
- 一个线程负责连接和读数据;
- 另外一个线程负责发送数据。
读操作是阻塞的,如果读线程和写线程混在一起,你在等待read()返回时就没法发心跳包了。我见过一个土办法:把“写”放在一个单独的线程,用队列串行发送,避免多个线程同时write导致数据包交错;把“读”放在另一个线程,收到数据后用Handler或者LiveData抛到主线程更新UI。这样结构清晰,也方便以后做断线重连。
如果你用的是Kotlin协程,可以用Dispatchers.IO来跑阻塞操作:
lifecycleScope.launch { withContext(Dispatchers.IO) { val socket = Socket() socket.connect(InetSocketAddress(serverIp, serverPort), 3000) socket.soTimeout = 10000 // 开始读循环 } }协程的好处是你可以把“读循环”放到一个协程里,取消的时候通过Job.cancel()来中断。但要注意,read()是阻塞的,协程被取消时并不会自动关闭Socket,所以还是要配合use或者finally块来释放资源。
3.3 SO_TIMEOUT和线程卡死的关联
线程模型不仅要管并发,还要管阻塞时间。一个没有设置setSoTimeout的Socket,它的read()会一直等待服务器数据,直到服务器关闭连接。这种“无限等待”在客户端来看就是线程卡死。如果你在某个业务里要判断“对端是否还活着”,没有超时的话永远判断不出来。
设置读超时很简单:
socket.soTimeout = 15000 // 15秒内没有数据进来就抛SocketTimeoutException连接超时则要在connect时指定:
socket.connect(InetSocketAddress(serverIp, serverPort), 5000)这两个超时值的意义不同,最好都设。否则,服务器IP不可达时,光connect就能卡你一分钟。
这些明明是“运行时配置”,为什么放在“前提配置”里讲?因为如果你在写代码前没想好线程模型和超时,后面连最基本的连接流程都写不利索。与其说这是配置,不如说这是设计约束。
4. 网络切换瞬间,Socket为什么悄悄断开又该怎么监听
4.1 WiFi和移动数据切换对长连接的致命打击
手机从WiFi切到4G/5G时,IP地址会变,路由表会变,之前建立的TCP连接几乎必然失效。即使IP没变,移动网关的NAT映射也可能在一分钟内超时释放。你可能会发现一个现象:长连接在App刚启动时一切都好,过一晚上屏幕熄灭后再点亮,连接已经悄悄断掉了,而且没有任何错误回调,因为服务器和客户端都没有主动发数据来检测对方状态。
这一类问题不完全是“前提配置”能解决的,但如果你提前监听网络变化,至少能在切换瞬间感知到连接失效,然后立刻重连。否则你只能等心跳超时,体验会差很多。
4.2 动态注册广播接收器
监听网络变化需要用到ConnectivityManager。由于Android 7.0之后对隐式广播有限制,很多系统广播无法在Manifest里静态注册,所以我们通常动态注册。
一个简化版的实现是:
val receiver = object : BroadcastReceiver() { override fun onReceive(context: Context?, intent: Intent?) { val cm = context?.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager val networkInfo = cm.activeNetworkInfo val isConnected = networkInfo?.isConnected ?: false if (isConnected) { reconnectIfNeeded() } else { markSocketStopped() } } } registerReceiver(receiver, IntentFilter(ConnectivityManager.CONNECTIVITY_ACTION))这段逻辑用起来没问题,但CONNECTIVITY_ACTION在部分系统上会有延迟,也不一定能拿到精确的网络类型。Android 5.0之后就推荐用NetworkRequest回调,API 24(Android 7.0)又加入了registerDefaultNetworkCallback,更推荐的做法是:
val connectivityManager = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager connectivityManager.registerDefaultNetworkCallback(object : ConnectivityManager.NetworkCallback() { override fun onAvailable(network: Network) { reconnectIfNeeded() } override fun onLost(network: Network) { markSocketStopped() } })NetworkCallback能更及时地感知网络状态,也不依赖广播的行为差异。需要提醒的是,这个回调并没有解决“连接是否真的失效”的判定问题,你还是要结合心跳机制来判断。比较常见的做法是:网络状态变化时,主动关闭旧连接并触发一次重连;同时让应用在后台时也定期发送心跳,用SocketTimeoutException或IOException来发现失效连接。
4.3 重连机制不要用固定间隔,退避策略更可靠
很多初级方案是每5秒重连一次,如果服务器重启需要两分钟,这两分钟里客户端就在高频地敲服务器,既浪费流量又容易被服务器封IP。更好的做法是“指数退避”:第一次失败等1秒,第二次等2秒,第三次等4秒,最多不超过60秒。连接成功后重置重连次数。这个策略在TCP长连接领域几乎是标准答案,代码量也不大,用一个小状态机就能实现。
当然了,这些都是“前提配置”之上的进阶内容。但如果你不在第一次写Socket的时候就考虑网络切换和重连,等产品上线后用户反馈“切个WiFi就要杀掉App重进”,那可就真的晚了。
5. 超时、DNS与局域网联调:三个容易掩盖“配置正确”的干扰项
5.1 连接超时和读超时一定要分开设
前面提到了两个超时,我再展开一下。Socket.connect(address, timeout)里的timeout是指建立TCP连接的最大等待时间。如果服务器IP是公网地址,而当前网络无法访问这个地址,你会一直卡到超时。如果不设置,默认值是-1,也就是“无限等待”,这是很可怕的。
setSoTimeout是指read()读取数据的最大阻塞时间,单位是毫秒。它只在连接建立之后有效。如果服务器收了包但不回包,read()会在超时后抛SocketTimeoutException。注意,这个异常并不会关闭Socket,你依然可以继续写数据。所以如果你把超时异常统一当“连接断开”处理,反而会误杀正常连接。正确姿势是捕获SocketTimeoutException后继续读,但如果连续多次超时再判定为死链。
5.2 DNS解析不可控:延迟和超时都很难设
有些服务器在公网用的是域名,比如api.example.com。在Android上,Socket.connect(InetSocketAddress.createUnresolved(host, port))会先做DNS解析,而DNS解析过程本身不受connect超时控制,它受系统DNS解析器的影响。如果DNS服务器无响应,getByName可能卡住很久,而且你没法像connect一样给它一个超时时间。
解决办法有几种:第一,如果服务器有固定IP,直接在客户端配置IP,跳过DNS;第二,在子线程里做DNS预解析并缓存结果,启动时同步一次,之后直接连IP;第三,如果必须用域名,那就接受DNS阻塞的存在,安排心跳和看门狗,保证卡住之后能重启连接流程。
5.3 局域网联调:服务器那边也要确认
这是经常被忽略的“前提配置”。要让手机连上局域网服务器,除了客户端配置,服务器端至少要满足几个条件:
- 服务器进程监听的是
0.0.0.0,而不是127.0.0.1; - 服务器防火墙放行了对应TCP端口;
- 手机和服务器在同一个网段,且没有开启AP隔离之类的路由器限制。
我遇到过最典型的场景:服务器用某开发框架启动时默认只监听回环地址,手机上连不上,电脑上用telnet也本机连得上,但用局域网IP去测就失败。排查这种问题最快的工具是手机上一个网络调试助手App,直接填IP和端口,如果也连不上,问题就基本确定不在客户端了。
另外一个很容易中招的是:有些路由器默认开启了“客户端隔离”,手机和电脑虽然都连同一个WiFi,但设备之间不能互访。这种情况不是代码能解决的,需要登录路由器后台关掉隔离功能,或者把其中一台设备挪到同一个AP下。
5.4 一份可以直接抄的配置检查清单
为了让你在写第一行TCP代码之前就能把所有前提条件准备好,我把上面讲的内容整理成了一个检查清单,你可以直接对照着排查:
| 序号 | 检查项 | 配置位置 | 失败典型表现 |
|---|---|---|---|
| 1 | INTERNET权限 | AndroidManifest.xml | Permission denied/ECONNREFUSED误报 |
| 2 | ACCESS_NETWORK_STATE权限 | AndroidManifest.xml | getActiveNetworkInfo()返回null |
| 3 | 明文流量策略 | AndroidManifest.xml+network_security_config.xml | HTTP/WebSocket 报Cleartext HTTP traffic not permitted |
| 4 | 网络操作不在主线程 | 线程/协程 | NetworkOnMainThreadException/ ANR |
| 5 | 连接超时与读超时 | Socket.connect(timeout)/setSoTimeout | 线程卡死、无法发现死链 |
| 6 | 网络变化监听 | registerDefaultNetworkCallback | WiFi切4G后连接静默失效 |
| 7 | 服务器监听地址/防火墙 | 服务器侧 | 局域网telnet失败 |
| 8 | targetSdkVersion与系统行为的一致性 | build.gradle | 高版本系统突然连不上 |
这张表在多人项目里也能当沟通工具用。以后谁说“客户端连不上服务器”,你先让他对着这份清单逐项过一遍,大部分问题五分钟内就能定位。
我个人做TCP长连接这段时间,最大的体会就是:大部分“网络问题”其实都不是网络的问题,而是“连之前”那几步配置出了问题。权限、明文策略、线程模型、网络监听、超时,这五件事只要有一件没考虑到,后面都得用血泪去填。最后的建议是,新建项目时先建一个NetworkConfig相关的类,把权限、安全配置、Socket参数集中管理,不要散落在各个页面里。这样以后换服务器地址、调超时时间,都只需要改一个地方,省心得多。