搞Android网络通信,最烦的不是Socket本身的代码逻辑,而是你明明照着示例写完了,一运行在模拟器上直接给你抛个java.net.SocketException: Permission denied,或者连接倒是能建立,但数据死活发不出去。这些问题的根源,大部分不在业务代码,而在你正式写网络请求之前,工程环境里有一堆“前提配置”没做对。
这个标题里“前提配置”四个字,其实覆盖了三层东西:Manifest里的权限声明、Android高版本系统强制的明文流量限制、以及代码工程里为了能稳定跑网络任务而不得不处理的线程与超时准备。这篇文章就把这几层一次说清楚,适合刚接触Android网络编程、准备在App里做TCP通信的开发者,尤其是那些在模拟器和真机上反复踩坑、被各种异常信息绕晕了还不知道该去哪改的人。
1. 搞懂“前提配置”到底在配什么
1.1 一次TCP通信在Android上的完整链路
在动手写代码之前,我建议你先在心里过一遍,你在App里写Socket("192.168.1.100", 8080)这一行的时候,系统其实要经过多少道关卡。
第一关是权限校验。Android的沙箱机制决定了,任何应用默认都没有访问网络的资格。你必须先在Manifest里声明INTERNET权限,应用进程才能去创建网络连接。这一步漏了,Socket构造函数本身可能不报错,但等真正执行connect()时就会直接抛Permission denied。
第二关是网络安全策略。从Android 9(API 28)开始,系统默认禁止应用使用明文流量。注意,这里的“明文流量”不只是HTTP,未经TLS加密的TCP数据同样会被拦截。系统会直接丢给你一句Cleartext HTTP traffic to ... not permitted,很多老项目从旧手机迁移到高版本设备时,都是被这一下卡住的。
第三关是线程策略。Android不允许在主线程里执行网络操作,一旦检测到,就会抛NetworkOnMainThreadException。这条规则不是靠自觉,而是系统强制的,目的就是避免阻塞UI线程导致界面卡死。
第四关是系统服务策略。如果你的TCP通信是长连接,需要长时间在后台运行,那还牵扯到前台服务声明、通知权限这些更高层的配置。这是Android从13、14开始新增的约束,很多人根本不知道还有这一层。
这四关全部通过,你那一行connect()才是真正有机会被系统放行。哪一关没配好,后面全是各种莫名其妙的问题,而且报错信息往往会误导你往业务代码方向去查,越查越偏。
1.2 一张配置清单先摆到台面上
为了不让你觉得抽象,我先把结论性的清单放出来。标准情况下,一个正常的TCP通信App,Manifest里至少应该包含下面这些配置项:
| 配置项 | 声明位置 | 作用 | 不配会怎样 |
|---|---|---|---|
| INTERNET权限 | AndroidManifest.xml | 允许应用访问网络 | connect时抛Permission denied |
| ACCESS_NETWORK_STATE权限 | AndroidManifest.xml | 允许监听网络状态变化 | 无法感知Wi-Fi和移动网络切换 |
| usesCleartextTraffic | application标签属性 | 放行明文TCP/HTTP流量 | Android 9以上默认拒绝明文连接 |
| networkSecurityConfig | application标签属性 | 精确控制哪些域名/IP可走明文 | 局部放行时比全局开关更安全 |
| FOREGROUND_SERVICE | AndroidManifest.xml | 允许启动前台服务(长连接场景) | 后台启动服务时抛异常 |
| 前台服务类型声明 | Service标签的foregroundServiceType | Android 14要求明确服务用途 | 目标SDK 34以上不写会直接崩 |
| POST_NOTIFICATIONS | AndroidManifest.xml | Android 13以上通知功能需要动态授权 | 长连接的通知栏消息不会显示 |
后面所有内容,都是围绕这张表逐项展开的。每一项我都会讲清楚为什么需要、什么时候需要、以及最常见的坑在哪里。
2. 网络权限与明文流量:90%新手会卡住的配置点
2.1 INTERNET权限为什么必须写在Manifest里
先看最基础的。在你项目的AndroidManifest.xml里,<manifest>根标签下、<application>标签上方,加上这两行:
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />INTERNET是Android的普通权限,不需要像定位、相机那样在运行时动态申请,只要在Manifest里声明了,安装后自动生效。ACCESS_NETWORK_STATE也不是必须的,它本身不会让你的Socket能用,但它能让你读取当前网络状态,比如判断Wi-Fi是否连接、移动数据是否开启。做TCP长连接时,这个权限几乎是必备的,因为你要监听网络变化来做断线重连。
我遇到过好几个项目,TCP连接在Wi-Fi下正常,切到移动网络就失败,查到最后才发现是少加了这条权限,代码里根本拿不到网络类型,重连逻辑怎么判断都是错的。权限这个东西,Manifest里写一行字的事,漏掉之后排查成本却特别高。所以我建议无论你做的是短连接还是长连接,两条一起写上,反正没有副作用。
2.2 Android 9以后默认禁止明文流量,两种放行方式
下面这个坑,是升级Android高版本系统后最典型的。Android从API 28开始,targetSdkVersion28以上的应用,系统默认cleartextTrafficPermitted为false,也就是所有未加密的网络流量,包括TCP裸连接、HTTP明文请求,全部不允许。
这就导致一个现象:同一个App,在Android 8手机上跑得好好的,换到Android 10的机器上,Socket连不上,或者请求直接超时,日志里出现:
java.io.IOException: Cleartext HTTP traffic to 192.168.1.100 not permitted注意这句话里说的是HTTP traffic,但其实底层的含义是整个明文TCP都被限制。很多人一看到HTTP就往Web方向查,查半天才发现是自己的TCP Socket被系统拦了。
解决办法有两种。
第一种,全局放行。在<application>标签上加一行属性:
<application android:usesCleartextTraffic="true" ... >这一行最省事,但代价是全局所有域名、所有端口的明文流量都放行。如果你的App还涉及WebView加载网页、SDK回调等逻辑,全局放开会降低整体安全性,上架审核时也可能被问询。
第二种,局部放行。通过networkSecurityConfig文件精确控制。这也是我更推荐的做法,尤其是你的TCP通信只针对固定IP或固定域名时。
<application android:networkSecurityConfig="@xml/network_security_config" ... >两种方式的取舍,我后面单独说。如果你的场景只是内网设备通信、演示Demo、或者自建服务器测试,那全局放行其实问题不大;但要做正式产品,我建议你花五分钟把局部放行配置写清楚。
2.3 networkSecurityConfig的实操细节与域名/IP坑
局部放行的配置文件放在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">example.com</domain> <domain includeSubdomains="true">api.example.com</domain> </domain-config> </network-security-config>这个配置的意思是,全局默认禁止明文流量,只有example.com和api.example.com这两个域名及它们的子域名允许明文访问。
但这里有个特别坑的细节:<domain>标签填域名没问题,填IP地址时行为不一致。在部分Android版本上,<domain>配置对纯IP地址根本不生效,你写了192.168.1.100,它还是照样拦你。
我自己遇到过一次,设备端的服务跑在局域网IP上,配置了<domain>192.168.1.100</domain>,结果在Android 12设备上测试时依然报Cleartext not permitted,最后只能用base-config放行。所以如果你要连接的目标就是IP地址,要么改用全局usesCleartextTraffic="true",要么把base-config直接设为cleartextTrafficPermitted="true",别在domain-config里折腾IP了。
另外补充一个点,如果你的TCP通信涉及自定义证书——比如内网设备用的自签名证书,那还需要在network_security_config里配置trust-anchors。这个问题展开讲会很长,这里先记住一个结论:走明文的TCP内网通信,优先用base-config放行;走TLS但证书不受信任的,要配trust-anchors,否则TLS握手会直接失败。
3. 长连接场景必须提前做的前台服务与通知配置
3.1 短连接好办,长连接为什么绕不开前台服务
如果你的TCP通信是“发一条请求、收一条响应、然后关闭连接”这种短连接模式,那么只配好权限和明文流量就够了。但如果你的App是需要维持一个长连接,比如IM消息推送、远程控制指令通道、硬件设备实时状态上报,那问题就复杂了。
Android系统对后台应用有严格限制。进程切到后台之后,CPU、网络资源都可能被冻结,系统还会根据内存压力杀掉优先级低的后台进程。长连接在后台存活不了几分钟,就会断掉。前台服务就是Android提供的一个机制,让你在通知栏常驻一条通知,告诉用户“这个App正在干某件事”,从而把进程优先级抬高,降低被杀的概率。
这里我要纠正一个很多新手会犯的错误:前台服务的核心价值不是给用户看通知,而是通过“可见性”换进程存活率。你如果把长连接直接塞进一个普通Service,不带前台服务声明,那么用户把App切后台后,连接基本撑不过几分钟。
3.2 Android 14以后前台服务类型怎么声明
用前台服务承载TCP长连接,在Manifest里要声明的东西多起来了。从Android 14(API 34)开始,系统要求前台服务必须声明类型,否则启动时直接抛MissingForegroundServiceTypeException,App崩溃。
以“维持设备连接”这个场景为例,比较合适的类型是connectedDevice。Manifest里的写法是这样:
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE" /> <application ...> <service android:name=".TcpConnectionService" android:exported="false" android:foregroundServiceType="connectedDevice" /> </application>connectedDevice类型适用于蓝牙、USB、以及网络连接类设备交互场景。如果你的长连接做的是内容上传下载、媒体播放、位置上报,那对应类型又不一样,分别是dataSync、mediaPlayback、location。类型声明错了一样会被系统拒绝,所以别顺手抄一个就完事。
有一点务必注意:FOREGROUND_SERVICE_CONNECTED_DEVICE这个普通权限是Android 14才开始有的,如果你把targetSdkVersion设到了34,却没加这条权限,启动前台服务时同样会崩。老项目升级目标SDK时,这是最容易遗漏的一环。
3.3 通知权限:Android 13新增的运行时权限
前台服务必须配套通知栏通知,但从Android 13(API 33)开始,通知的显示需要用户授权。也就是说,即使你在Manifest里声明了POST_NOTIFICATIONS权限,代码里也得在运行时向用户弹窗申请。
Manifest里加上这一行:
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />代码里申请的逻辑放在MainActivity里:
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { if (checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) { requestPermissions( new String[]{Manifest.permission.POST_NOTIFICATIONS}, 1001 ); } }用户拒绝这个权限会导致什么后果?前台服务依然能启动,但通知栏不会有任何展示。问题在于Android对“没有可见通知的前台服务”容忍度很低,遇到激进的内存清理策略,进程照样会被回收。所以做长连接App,启动阶段尽快引导用户打开通知权限,不是体验问题,而是技术问题。
4. 代码层面的前置准备:线程限制、超时与目标地址
4.1 主线程直接跑网络任务会怎样
配置全对了,还有一个代码层面绕不开的限制:网络操作不能在主线程执行。
Android有一个叫StrictMode的机制,专门检测主线程上的耗时操作。你在onCreate里直接写new Socket("192.168.1.100", 8080),一运行,系统立刻抛android.os.NetworkOnMainThreadException。不是连接失败,而是应用直接闪退。
正确做法是把Socket连接放到子线程里。最简单的方式是直接用线程池:
ExecutorService executor = Executors.newSingleThreadExecutor(); executor.execute(() -> { try { Socket socket = new Socket(); socket.connect(new InetSocketAddress(host, port), 5000); // 收发数据逻辑 } catch (IOException e) { // 处理异常 } });如果你用的是Kotlin,也可以走协程,本质是一样的。关键在于任何网络IO都不能出现在主线程上,包括Socket的读写、OutputStream的写入、InputStream的读取,全都必须在子线程里跑。
这里还有个细节很多人不知道:不只是创建连接不能在主线程,连接建立后的read()阻塞读取更不能在主线程。一旦InputStream阻塞等待服务器数据,主线程就卡住了,几秒后系统弹“App未响应”,用户直接把App杀了。所以长连接项目,最好单独维护一个线程或者一个协程作用域,专管Socket读写,跨线程的数据交互再通过回调或Handler切回主线程更新UI。
4.2 三个超时参数要怎么设
TCP通信里的超时参数,我建议你在写第一版代码时就定下来,不然后面调试的时候,一个异常要等好几分钟才反馈,非常折磨。
Java的Socket有几个常用的超时设置:
Socket socket = new Socket(); // 连接超时:5秒内连不上就放弃 socket.connect(new InetSocketAddress(host, port), 5000); // 读超时:10秒内没有收到任何数据就抛SocketTimeoutException socket.setSoTimeout(10000); // 心跳保活:系统层面每隔一段时间探测连接是否存活 socket.setKeepAlive(true);这三个参数不是随便填的。
连接超时设太短,网络波动时容易误判服务器不可达;设太长,服务器真挂了你要等几十秒才弹错误。我一般习惯5秒起步,内网环境可以改成3秒,公网环境放到8到10秒都不过分。
读超时取决于你的业务特性。如果是请求-响应模式,设10到15秒比较合理;如果是长连接主动推送模式,读超时就不能这么设,因为服务器可能很久都不推一条数据。这种场景下,你更适合不设setSoTimeout,改用应用层心跳包来判断连接是否活着。
KeepAlive这个开关,直白讲就是TCP层的探活机制。默认参数比较保守,可能要两小时才探测一次,所以别指望它替代应用层心跳。真正的长连接保活,还是要在应用层定时发心跳包,跟KeepAlive配合用。
4.3 模拟器、真机、局域网环境的地址差异
地址写错是TCP通信里最隐蔽的坑。你在电脑上起了一个TCP服务端,监听127.0.0.1:8080,想用Android模拟器连它,结果发现怎么都连不上。这很正常,因为Android模拟器里的127.0.0.1指向的是模拟器自己,不是你的电脑。
模拟器访问宿主机,要用特殊地址10.0.2.2。
// 模拟器里连接电脑本机服务,host要写10.0.2.2 Socket socket = new Socket("10.0.2.2", 8080);如果是真机通过USB调试连电脑,或者真机和你的电脑在同一个Wi-Fi下,那host要填电脑的局域网IP。怎么看电脑的局域网IP?Windows上命令行敲ipconfig,Mac上敲ifconfig,找到无线网卡那个192.168.x.x的地址。
这里还有两个特别容易遇到的问题。
第一,Wi-Fi隔离。有些路由器或者企业Wi-Fi开启了AP隔离,设备之间禁止互访,你的手机能找到电脑的IP,但TCP包就是发不过去。这种情况网络层看起来一切正常,Socket connect迟迟不返回,最后超时。遇到这种问题别死磕代码,去路由器后台关掉隔离模式,或者换一个热点测试。
第二,服务器防火墙。电脑上的防火墙如果拦截了入站端口,外部设备连进来会被直接丢弃。调试阶段可以把对应端口临时加入防火墙放行规则,也可以直接在防火墙里放行Java或者对应进程的网络访问。
关于目标地址,我最后再强调一点:不要用域名写死,至少把host作为可配置项拿出来。因为内网设备的IP经常变,今天是192.168.1.100,明天路由器重启可能就变成192.168.1.105。把IP放在配置文件或者设置界面里,比每次改代码重新打包要省太多事了。
5. 常见异常速查:报错对照与排查思路
5.1 高频异常速查表
下面这张表,基本覆盖了TCP通信前提配置阶段能遇到的大部分问题。建议收藏,遇到报错先来这里对照一下。
| 异常信息 | 根本原因 | 处理方式 |
|---|---|---|
java.net.SocketException: Permission denied | 缺少INTERNET权限 | Manifest里加<uses-permission android:name="android.permission.INTERNET" /> |
java.io.IOException: Cleartext HTTP traffic to ... not permitted | Android 9以上默认禁止明文流量 | 配置usesCleartextTraffic="true"或networkSecurityConfig |
android.os.NetworkOnMainThreadException | Socket操作在主线程执行 | 把网络操作放到子线程或协程 |
java.net.SocketTimeoutException: connect timed out | 目标不可达、防火墙拦截、或地址错误 | 检查IP端口、防火墙、Wi-Fi隔离、超时参数 |
java.net.ConnectException: Connection refused | 目标端口确实没有服务在监听 | 确认服务端已启动,且监听端口正确 |
android.app.MissingForegroundServiceTypeException | 目标SDK 34以上,前台服务未声明类型 | 在Service标签加foregroundServiceType |
SecurityException: Starting FGS with type ... | 缺少对应前台服务类型权限 | 加FOREGROUND_SERVICE_XXX权限 |
| 通知栏不显示前台服务通知 | 未申请通知权限,或未创建通知渠道 | 运行时申请POST_NOTIFICATIONS,创建NotificationChannel |
5.2 一个典型的连接失败排查实录
我接手过一个联调了三天都没通的TCP项目,现象很经典:Android端报ConnectException: Connection refused,但服务端明明在电脑上监听9999端口,用电脑本机调试工具去连也通。
那天我按顺序排了一遍:
第一步,查权限。Manifest里INTERNET已声明,排除。
第二步,查地址。代码里host写的是127.0.0.1,但运行环境是Android模拟器。模拟器里的127.0.0.1指向自己,不是宿主机。按前面说的,改成10.0.2.2。
第三步,还是不通,这次报连接超时。继续检查防火墙,发现这台电脑上装了第三方安全软件,把Java进程的入站连接默认拦截了。在安全软件里放行后,连接立刻通了。
这个案例特别典型,因为它中间踩了“地址写错”和“防火墙拦截”两个雷。很多时候排查TCP连接问题,真的不需要一上来就抓包看协议细节,先把权限、地址、防火墙这三板斧砍完再说,往往就已经解决了八成问题。
5.3 用抓包和日志验证连接是否真的建立
配置都检查过了还是不放心,怎么确认连接层是通的?除了在业务代码里加日志,还有一个更直接的验证手段:抓包。
在Android端抓TCP包,最常用的思路是借助系统日志和网络层工具。你可以在命令行用adb logcat过滤看异常栈;想看到更底层的TCP握手细节,就需要在对应的网络接口上用抓包工具抓取报文,观察SYN、SYN-ACK、ACK的三次握手过程。如果报文里只有SYN发出,没有SYN-ACK返回,说明包被丢弃了,问题大概率在网络路径或防火墙;如果三次握手完成,说明Socket连接本身没问题,接下来该查的就是业务层收发逻辑了。
我习惯的做法是分两端验证。先在服务器端启动监听,用本机工具连接测试,确认服务本身没问题;再在Android端用简单Demo连接,逐个环节剥离变量。这样配合抓包结果,基本能定位到具体是哪一层出的问题。
6. 最后说点实际操作中的体会
这些前提配置,单看每一项都不难,但组合在一起就很容易让人栽跟头。我现在的习惯是,新项目起步时直接把前面那张配置清单贴进工程模板里,权限、明文流量、前台服务类型一次性写好,省得后面每次联调都从Permission denied开始排查。这个习惯帮我省了很多时间,尤其是项目交到别人手里的时候,对方不会再因为环境问题来一遍遍打扰你。
还有一个小技巧:把网络相关的配置项集中写注释,包括host、port、超时时间、放行的IP段,都放在项目的配置文件里统一管理。这样出问题的时候,别人接你的代码,扫一眼就能知道这个项目的网络边界在哪里,不用从密密麻麻的代码里一个一个挖。
如果你现在正被TCP连接问题卡住,回去检查一下Manifest和network_security_config,有很大概率能直接找出答案。配置这东西,配好之后什么都没有发生,配错的时候折磨你一整天。但理解清楚它背后的逻辑之后,你就再也不会怕那几行报错信息了。