1. 内容整体设计与思路拆解
1.1 这个需求到底想要解决什么问题
先说结论:React Native(简称RN)在Android端做长连接,最难的不是写Socket代码,而是怎么让这条连接在App退到后台、系统休眠、网络切换之后还能活着。
很多团队做IM、做消息推送、做实时行情,第一步想到的都是用WebSocket或者TCP长连接保活。在iOS上事情简单一些,因为系统对后台任务的约束是“时间限制”而非“进程杀死”,APNs又是一条现成的保活通道。但在Android上情况完全不同:进程优先级、Doze模式、厂商省电策略、后台限制,每个环节都在想方设法把你觉得“应该活着”的进程按死。RN项目还有一个额外的麻烦——JS引擎本身就活在原生层之上,一旦原生进程被回收,JS上下文要么被杀,要么被Headless JS这种不完整环境接管,聊胜于无。
所以这篇实践的路线很明确:把长连接的“生命周期”从JS层下沉到Android原生层,用前台服务(Foreground Service)拉起一条高优先级通道,在服务内部自己管理Socket、心跳、断线重连和指数退避。RN侧只负责发起启动/停止命令,以及通过事件总线接收连接状态。这套方案适合对内推送、对长连接可靠性要求高、又不想把业务全部迁移到原生的团队。
1.2 为什么首选“原生Service + RN桥接”这个组合
先看看有哪些可选方案,再说明为什么最终选原生Service。
- 纯JS层用WebSocket:代码最省事,但App一旦退到后台,JS引擎很快被系统挂起,Socket消息没人处理,系统网络栈在Doze模式下还会推迟网络访问。
- React Native社区的
react-native-netinfo加WebSocket:只能解决“网络状态变化感知”,解决不了“进程活着”的问题。 - 原生
Service直接托管Socket连接:RN层完全拿不到连接实例,但可以通过Bridge与JS层通信,JS层依然是业务主逻辑所在。 - PushKit/FCM之类的厂商通道:解决省电、保活确实靠谱,但依赖厂商服务器,对于内网私有化部署或者自研协议就是不可行的。
- 第三方长连接SDK(如各种开源IM SDK):对于不愿意造轮子的团队最省事,但定制协议、私有化部署、安全性审计,还是得自己握Socket核心逻辑。
我选择原生Service托管Socket连接,核心原因有三个。第一,前台服务能让进程优先级提升到系统不那么轻易回收的水平;第二,Socket、心跳、重连逻辑放在原生层,代码不受RN生命周期影响,即使JS层因为内存压力被系统回收,连接依然在维持;第三,所有跨进程通信通过DeviceEventEmitter和NativeModules收口,RN层随时可以接管和展示状态。
这里有一个很重要的工程认知:长连接从来不是“连上就行”的问题,而是要同时解决“建立、维持、恢复”三个环节。把“维持”和“恢复”放在原生层,把“建立”入口(用户登录触发)放在RN层,才能做到连得稳、死得快、恢复得及时。
1.3 这套方案的历史经验和我踩过的大坑
我在正式落地这套方案之前,第一版是用纯JS的WebSocket做的。当时事情看起来很简单:登录成功后new WebSocket(ws://...),监听onmessage,解析JSON,更新UI。上线之后问题就慢慢出来了:用户锁屏放一个晚上,第二天早上点开App,消息收不到,连接也已经是Closed状态;甚至有的用户在后台待几分钟再回App,就会发现没收到任何推送。后来用adb shell dumpsys activity processes查进程,发现RN所在的进程早被LMK(Low Memory Killer)标记成了可回收状态。
还有一版尝试过用react-native-background-timer在JS层做心跳,但这个方案的问题是:JS定时器在后台会被系统收起调度,无法保证精确触发,而且定时器一多就导致JS线程CPU偏高。最后我彻底放弃在JS层做维持逻辑,把心路历程写出来就是希望大家避开这个弯路:RN层可以做业务展示,但不要依赖JS的定时器去维持长连接。
这套方案最终形态是这样一张逻辑图(用文字描述):
RN JS 层(业务UI/状态) ↓ NativeModules 命令 Android 原生层(LongConnectService 前台服务) ↓ OkSocket 或原生 Socket(TCP) ↓ 内部线程:心跳定时器 / 读线程 / 写线程 ↓ 异常回调、状态回调 DeviceEventEmitter → RN JS 层(更新UI、重试逻辑)后面我会逐个环节讲清楚,从前台服务的配置,到心跳包的设计,再到指数退避重连的算法实现,全程附上关键代码和我在实际调试中总结出来的坑点。
2. 前台服务的落地实现细节
2.1 manifest配置与服务骨架
原生前台服务的第一步就是AndroidManifest.xml里声明Service和权限。
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" /> <uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" /> <uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <service android:name=".service.LongConnectService" android:exported="false" android:foregroundServiceType="dataSync" android:stopWithTask="false" />这里有两个点需要注意:
android:stopWithTask="false":表示就算用户把App从最近任务列表里划掉,服务也不随之销毁。这是很多长连接保活实践里的关键开关。android:foregroundServiceType="dataSync":Android 14(targetSdk 34及以后)强制要求前台服务声明类型。如果类型不对,对应API会被安全异常拒掉。长连接我们用dataSync是合理的,因为它在同步数据。
Service内部骨架:
public class LongConnectService extends Service { private static final String CHANNEL_ID = "long_connect_channel"; private static final int NOTIFICATION_ID = 1001; @Nullable @Override public IBinder onBind(Intent intent) { return null; } @Override public void onCreate() { super.onCreate(); startForeground(NOTIFICATION_ID, buildNotification()); // 初始化连接管理器,启动Socket线程 } @Override public int onStartCommand(Intent intent, int flags, int startId) { // 接收RN侧传递过来的连接参数(服务器地址、端口、token等) return START_STICKY; } @Override public void onDestroy() { super.onDestroy(); // 释放Socket、关闭定时器、取消前台通知 } }这里有一个关键细节:onCreate里调用startForeground,比在onStartCommand里调用更好。因为Service第一次创建时onCreate先执行,立刻变成前台服务就能尽早提高进程优先级,减少系统回收窗口。
2.2 通知渠道与Android 13+权限适配
从Android 8.0开始,所有通知必须绑定一个通知渠道(NotificationChannel)。前台服务通知也不例外,否则会直接抛RemoteServiceException或只显示默认渠道,用户完全无法管理。
private Notification buildNotification() { NotificationManager manager = (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { NotificationChannel channel = new NotificationChannel( CHANNEL_ID, "长连接服务", NotificationManager.IMPORTANCE_LOW ); channel.setDescription("维持消息推送的长连接服务"); channel.setShowBadge(false); manager.createNotificationChannel(channel); } NotificationCompat.Builder builder = new NotificationCompat.Builder(this, CHANNEL_ID) .setSmallIcon(R.drawable.ic_notification) .setContentTitle("连接服务运行中") .setContentText("正在接收实时消息") .setOngoing(true) .setPriority(NotificationCompat.PRIORITY_LOW); return builder.build(); }注意事项:
- 优先级设置
IMPORTANCE_LOW或PRIORITY_LOW,避免通知声音打扰用户,又不至于被系统折叠得完全不可见。 - Android 13(targetSdk 33起)需要在运行时动态申请
POST_NOTIFICATIONS权限。不申请权限会导致前台服务通知不显示,部分机型甚至会导致前台服务启动失败。实际开发的正确做法是:在RN层启动连接前弹出系统权限请求,拿到授权后再调原生启动服务。 - Android 14(targetSdk 34)的
dataSync类型前台服务还有额外限制:在App完全退出后台后,系统会给你大约6小时的临时豁免窗口。这个时间之外,应用如果没有“可见活动”,部分行为会受限。好在我们的服务只要还在前台,连接就能一直维持,只不过不要试图在前台服务里无限循环做耗时操作,系统会盯着的。
2.3 RN侧如何与原生Service通信
RN侧不能直接startService,必须通过NativeModule桥接。
先写原生Module:
public class LongConnectModule extends ReactContextBaseJavaModule { @ReactMethod public void startConnect(String address, int port, String token) { Intent intent = new Intent(getReactApplicationContext(), LongConnectService.class); intent.putExtra("ADDRESS", address); intent.putExtra("PORT", port); intent.putExtra("TOKEN", token); ContextCompat.startForegroundService(getReactApplicationContext(), intent); } @ReactMethod public void stopConnect() { Intent intent = new Intent(getReactApplicationContext(), LongConnectService.class); getReactApplicationContext().stopService(intent); } }RN侧:
import { NativeModules, DeviceEventEmitter } from 'react-native'; const { LongConnectModule } = NativeModules; // 启动长连接 LongConnectModule.startConnect('ws://192.168.1.100', 8080, token); // 监听连接状态事件 useEffect(() => { const sub = DeviceEventEmitter.addListener('LongConnectStatus', (event) => { console.log('连接状态:', event.status); }); return () => sub.remove(); }, []);这里有一个容易踩的坑:如果应用进程已经被系统回收,RN JS入口重新启动时会再次执行startConnect。此时Service可能早就以START_STICKY的方式被系统重新拉起了。所以RN侧在App.js启动时,要先检查服务是否存在于当前进程,如果已存在就不重复start,最稳妥的做法是把“是否需要连接”的状态持久化存到本地,服务重启后自己恢复Socket连接,而不是完全依赖RN侧再发一次命令。
这个逻辑我们可以这样实现:Service启动时读SharedPreferences里存的“用户会话状态”,如果会话有效就直接建立连接。RN层启动时只做“保证Service是活的”这一个动作。这样就算JS侧晚于原生层启动,也不影响连接恢复。
2.4 前台服务启动限制的适配
Android 12(API 31)开始,系统对从后台启动前台服务做了更严格限制:Context.startForegroundService()从后台发起来会被拒绝并抛ForegroundServiceStartNotAllowedException。
实际业务中用户触发启动长连接的动作一般发生在App前台,比如登录后立刻调init。这个操作不受影响。但如果要做一个“开机自启”或者“App在前台但Activity已经关闭一段时间后自恢复”的机制,就必须走WorkManager或者前台Activity来发起。
我们项目的解决方案是:RN侧启动时先判断AppState.currentState是否为active,如果不是就先等到active回调触发再让原生发起前台服务;同时在原生侧增加一个try/catch,万一被系统拒绝,至少不让崩溃直接导致用户可见的问题。
3. 心跳机制:怎样才能既省电又防“死链”
3.1 为什么TCP连接看上去是通的,实际却收不到消息
很多人在开发调试时发现,即便没有心跳,消息也能正常收发,但一到真实网络环境下就出问题。原因可能是链路中间设备(NAT网关、运营商防火墙、代理服务器)会默默回收没有流量的连接,且双方不会立刻感知。比如基站NAT表项空闲超过一定时间就会把映射关系清除,之后服务端下发的数据全部丢进黑洞,客户端不知道,服务端也不知道,等下一次客户端主动发数据时才发现写不进去。
TCP自身的keepalive不是不能开,但默认参数通常在2小时级别,移动网络中基本不靠谱,不适合长连接场景。业务心跳是不得已的选择:为了让链路每一跳都“有话说”,达到保活网关表项的目的。
3.2 心跳包的设计与实现
我的心跳设计原则有三条:
- 心跳包必须轻量,最好控制在几十字节以内。
- 需要区分“应用层心跳”与“Socket写超时”的概念,不能把写成功当作对端存活。
- 需要对服务端回包做超时判定,连续收不到回包就要断开重连。
以我们常用的自定义文本协议为例,一条心跳包就是一行JSON,比如{"type":"ping","seq":1001}。服务端收到后返回{"type":"pong","seq":1001}。客户端需要在发送后启动一个超时窗口,如果在窗口内收到对应seq的pong,则本轮心跳成功;否则累计失败次数,超过阈值触发重连。
public class HeartbeatTask extends TimerTask { private int seq = 0; private int failedCount = 0; @Override public void run() { if (!socketManager.isConnected()) { return; } int currentSeq = seq++; socketManager.sendPing(currentSeq); handler.postDelayed(() -> { if (!socketManager.isPongReceived(currentSeq)) { failedCount++; if (failedCount >= MAX_HEARTBEAT_FAILURES) { socketManager.disconnect(); socketManager.reconnect(); } } else { failedCount = 0; } }, HEARTBEAT_TIMEOUT); } }这里要注意:TimerTask里面的耗时代码不要太多,纯粹发送一个包是OK的,但是不要发起网络IO重操作。如果一次心跳里既发ping又等待pong再递归调度,逻辑会变复杂,建议拆分为“定时发送”和“超时回收”两个独立动作。
3.3 心跳间隔选多久才合适
心跳间隔不能太长,也不能太短。太短(比如5秒一次)纯属电量焦虑,服务器压力也大;太长(比如30分钟一次)则中间设备的NAT超时可能比这更短,保活效果就没意义。
常规经验推荐30秒到60秒之间。我个人的项目里用的是45秒。选45秒没有玄学,主要考虑是:
- 运营商NAT超时一般在1~5分钟,45秒可以比绝大多数阈值提前一轮,安全性高。
- 45秒的心跳频率对CPU和电量开销都极低。一个空包,走TCP,几乎不占什么带宽。
- 如果用户网络较差(比如隧道网络、园区代理),45秒也能比较快地发现断线,不至于让用户在“假连接”状态下等好几分钟才收到重连结果。
如果你面对的场景是金融行情、游戏对战这种对时效性极其敏感的,可以考虑把心跳压到15~20秒。但相应地,服务器端的负载会呈线性上涨。我的建议是:先按45秒起调,线上观察连接稳定性,再根据实际的数据中心指标(连接存活率、无效连接占比)做调整。
3.4 心跳与节能策略结合
前台服务虽然保住了进程优先级,但发起网络包依然会让手机从休眠中短暂唤醒。为了减少无谓唤醒,我在Doze模式下做了心跳降频策略:
- 屏幕亮着、App在前台:45秒心跳。
- App进后台但屏幕还亮:45秒不变,因为用户可能随时回来。
- 屏幕关闭(
ACTION_SCREEN_OFF):立即切换到90秒心跳,并注册一个PARTIAL_WAKE_LOCK保证Socket线程能定时执行。 - 电量低于20%:切换到60秒心跳,但不做其他激进降级,因为长期断线重连反而更耗电。
这里有一点容易被忽略:降频降的是心跳发送频率,不是Socket读线程频率。读线程必须一直保持可读状态,否则服务端下发的消息无法被及时消费,还会积累TCP缓冲区,造成“延时到达”的假象。
4. 指数退避重连:从第一次失败到稳定恢复
4.1 为什么不能用固定间隔重连
固定间隔重连,比如每5秒重试一次,问题很明显:
- 服务器或网络处于大规模故障时,客户端会像“死循环”一样疯狂撞服务器,造成雪崩式压力。
- 移动网络下,重连本身就是一个高功耗操作。每次重连失败,都会触发DNS解析、TCP握手、TLS握手,失败越频繁电量消耗越大。
- 用户可能只是短暂坐电梯、过隧道,30秒后网络就恢复了。固定间隔重连没法在这段时间里做出合理退避,反而是高频尝试浪费资源。
指数退避重连的核心思想很简单:失败后等待时间先短后长,重试间隔按指数增长,直到达到上限。初期快速重试,适合网络闪断;后期缓慢重试,避免耗尽客户端的电量和服务器的资源。
4.2 指数退避的算法与实现
经典公式是:
delay = min(baseDelay * 2^attempt, maxDelay)baseDelay通常设为1秒到2秒,maxDelay设为30秒到60秒。attempt是连续失败次数,每次重连失败后自增。
但真实网络没那么规律,光是线性退避还可能造成“同步重连”问题——同一批客户端同时失败、同时重试,形成脉冲请求。为了打散这种同步,业界通常会在退避时间上加入随机抖动(jitter)。抖动有两种做法:
- 完全抖动:
delay = random(0, base * 2^attempt) - 上下抖动:
delay = base * 2^attempt + random(0, jitterRange)
第二种更常用,既保留指数趋势,又加了随机性。我的实现:
public class ExponentialBackoff { private static final long BASE_DELAY_MS = 1000; private static final long MAX_DELAY_MS = 30 * 1000; private static final Random RANDOM = new Random(); private int attempt = 0; public void reset() { attempt = 0; } public long nextDelay() { long expDelay = Math.min(BASE_DELAY_MS * (1L << Math.min(attempt, 8)), MAX_DELAY_MS); long jitter = RANDOM.nextInt(1000); long delay = expDelay + jitter; attempt++; return delay; } }代码很简单,但有几个细节需要单独说明:
1L << attempt会指数增长,所以我对attempt做了上限限制(Math.min(attempt, 8)),避免位运算溢出。- jitter固定0~1000ms,不随指数增长,目的是避免重试间隔过长后抖动占比太高,导致实际等待时间不稳定。
- 重连成功后的第一件事是
reset(),否则下次断线会从很高的指数继续重试。
使用时配合Handler延迟触发:
private void scheduleReconnect() { long delay = backoff.nextDelay(); handler.postDelayed(this::doReconnect, delay); } private void doReconnect() { boolean connected = socketManager.connect(); if (!connected) { scheduleReconnect(); } else { backoff.reset(); } }4.3 重连状态机与边界条件处理
重连不能只做一个死循环,必须把状态和触发源理清楚。我把连接生命周期划分为以下几种状态:
DISCONNECTED:初始状态,或用户主动断开。CONNECTING:正在建立TCP连接。CONNECTED:连接已建立,正在等待业务数据。RECONNECTING:连接异常断开,正在等待退避计时结束。STOPPED:用户主动关闭,不自动重连。
只有当状态处于CONNECTED或RECONNECTING之间切换时,指数退避才生效。以下边界情况一定要处理:
- 用户主动登出时调用
stopConnect(),必须把状态置为STOPPED,并清空所有定时任务,否则用户都退出了,后台还在疯狂重连,就属于自产垃圾流量了。 - 网络切换(比如Wi-Fi切到4G)时,可能Socket已经异常关闭,也可能还没被系统通知。安全做法是收到网络变更广播后,主动断开现有连接,触发一次“立即重连”,而不是等到心跳超时才恢复。
- 前后台切换时,App回到前台应立刻检查连接是否还健康(发一次ping探活),而不是被动等待下次心跳。
- 应用进程被系统杀死后又因为
START_STICKY拉起服务时,需要重置退避计数器,否则上一个进程的重试次数可能遗留在内存里,导致新进程第一次重连就直接等30秒。
关于状态机,我推荐在Native层维护一个AtomicInteger state,状态变更时通过DeviceEventEmitter向RN层广播。RN层的UI(比如顶部一条“连接已断开”横幅)只需要监听事件更新状态,不直接参与重连决策。
4.4 连接级超时与握手超时的区分
一个很容易被忽略的点:TCP超时重连和TLS/业务握手超时,应当分别管理。TCP连接超时设短一点,比如5秒;TLS握手和业务登录(发给服务端的鉴权包)超时设更长一些,比如10秒。如果统一用10秒,有可能TCP已经挂了,但业务层还在傻等服务端返回登录结果,导致重连时机延后。我的做法是:
Socket.connect(address, 5000)设置连接超时。- 连接建立后,启动一个10秒的
loginTimeoutHandler,如果在这个时间内没收到服务端的登录成功包,判定为握手失败,断开后走指数退避重连。 - 连接成功后的心跳超时判定,单独用45秒心跳 + 10秒pong等待窗口。
这样三层超时互不干扰,日志里也能清晰区分“TCP连接失败”、“登录超时”、“心跳失败”三种不同的断连原因。
5. 网络监听与前后台切换策略
5.1 动态监听系统网络状态
长连接不能对“网络变化”无感知。Android里最常用的方案是动态注册ConnectivityManager.NetworkCallback:
ConnectivityManager cm = (ConnectivityManager) getSystemService(Context.CONNECTIVITY_SERVICE); NetworkRequest request = new NetworkRequest.Builder() .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) .build(); cm.registerNetworkCallback(request, new ConnectivityManager.NetworkCallback() { @Override public void onAvailable(Network network) { // 网络恢复,立刻主动重连 socketManager.forceReconnect(); } @Override public void onLost(Network network) { // 当前网络丢失,关闭Socket,等待下一次onAvailable触发重连 socketManager.disconnect(); } });这里要注意,onAvailable不保证外网一定通,比如连上一个需要门户认证的Wi-Fi,INTERNET能力可能已经暴露,但实际80/443端口全被重定向。所以在网络回调里做重连时,要套用普通重连逻辑:TCP连接可能成功,但登录握手大概率失败,最终由登录超时兜底再次退避重连。
5.2 App前后台切换时的连接策略
除了网络变化,前后台切换也很重要。用户从后台切回App时,系统可能已经让Socket“假死”了一段时间。此时App应该主动探活,而不是干等下一次心跳。
探测方式有两种:
- 发一个轻量级的
ping包,看是否能收到pong。若连续两次无响应,立即断开重连。 - 更简单粗暴:收到
AppState.active回调后,调用disconnect()再重连。但这会打断正常会话,造成不必要的消息丢失,我不太推荐。
我的做法是:
AppState.addEventListener('change', (state) => { if (state === 'active') { NativeModules.LongConnectModule.probeConnection(); } else if (state === 'background') { NativeModules.LongConnectModule.onAppBackground(); } });原生侧probeConnection()实现为:如果当前连接已断开,立即执行一次重连(重置退避);如果连接状态正常,则发送一次快速ping,超过3秒没响应判定为失效,触发重连。
5.3 Doze模式和省电策略的妥协方案
真实线上环境,很多用户会在系统设置里加白名单,但也有不配合的用户。Doze模式下,系统对网络访问和AlarmManager做了严格限制,前台服务虽然会减少限制,但不是完全免疫。
长连接在Doze模式下的最优解,其实是两个选择:
- 在线消息走厂商推送通道(华为、小米、OPPO、vivo)或FCM,长连接只在App前台时使用。
- 或者维持心跳但接受一定延迟:比如Doze窗口期内心跳只有80%的概率被准时触发,这会导致部分服务端下发的消息延迟几分钟才到。
我最后采用的是混合方案:App进入后台后,如果检测到设备进入Doze状态,就把长连接降级为“被动模式”——关闭主动心跳,但保留Socket读线程和唤醒锁。等用户点亮屏幕或App回到前台,立刻恢复心跳与重连逻辑。这样既不伤害在线消息的实时体验,也不会因为Doze下强制心跳触发系统报警导致服务被限制。降级策略的缺点是,服务端如果在Doze期间给客户端发消息,客户端是收不到的,要等服务端支持离线消息补充机制。所以这个方案只适用于业务上可以容忍短暂延迟的场景。
如果业务对实时性要求极高,不得不让长连接在Doze下完全保活,那就只能引导用户去系统设置里关掉电池优化。这可以在RN层加一个判断,检测到连接频繁中断时,弹出一个“请允许后台运行”的引导浮层,把用户带到对应设置页。
6. 常见问题与排查技巧实录
6.1 Android 8+太容易忘记的通知渠道
现象:明明Service启动成功,通知栏却看不到常驻通知,或者看到了也静音,甚至Android 8以下设备上却不显示。
排查思路:
- 确认
startForeground之前已经创建了NotificationChannel。渠道ID必须和buildNotification()里使用的一致。 - 检查渠道
IMPORTANCE是否设置成了NONE。如果是NONE,通知不显示,前台服务依然启动但系统会在5秒内直接判定非法调用。设置成LOW或MIN,页面肉眼可见。 - targetSdk 33以上确认运行时动态申请了
POST_NOTIFICATIONS。如果用户拒绝授权,前台服务不能强行创建通知,否则会走异常分支。
6.2 Android 12+前台服务启动崩溃
我在targetSdk 31的设备上踩过一次这个坑:App退到后台,过一会儿某个模块调用了startForegroundService(),结果直接闪退,日志里是ForegroundServiceStartNotAllowedException。
解决方案:
- 检查是否有从后台发起的可能。建议在RN层维护一个“当前是否在前台”的标记,只有标记为active时才允许直接启动服务。
- 如果一定要在后台启动,请改用
WorkManager或系统闹钟服务作为触发媒介。 - 更稳妥的做法:把前台服务常驻作为应用主进程的一部分,不要频繁去
start/stop。如果服务已经在运行,再次调用startForegroundService等于onStartCommand重新回调,不会真的新起一个进程,但要避免每次都带全参数以免重复初始化。
6.3 系统回收服务后,连接能否自愈
很多入门实践会写onStartCommand返回START_STICKY,以为这样就万事大吉。实际上系统在内存不足时杀进程是不给机会执行的,进程被杀后START_STICKY只能做到系统在合适的时机“重建服务”,但重建后的Intent是null,原参数丢失、Socket状态全无。
我的自愈策略分三层:
- 第一层:
START_STICKY,保证系统有空闲时能重新拉起服务。 - 第二层:Service启动时从
SharedPreferences恢复用户会话信息,拿到服务器地址和token,自动重新连接。 - 第三层:RN App回到前台时发现本地标记“应该连接但没有连接”,就重新执行
startConnect(),作为兜底。
三层能力互补,才算真正做到“断线自愈”。
6.4 用adb命令来验证前台服务和连接状态
这个技巧对调试特别有用。以前我总是靠打日志来排查服务是否存在,效率低还容易漏。后来学会用adb命令快速看状态:
# 查看前台服务列表,确认LongConnectService是否存在 adb shell dumpsys activity services | grep LongConnectService # 查看进程优先级,确认是否处于“可感知”级别 adb shell dumpsys activity processes | grep <包名> # 杀掉进程模拟系统回收场景 adb shell am kill <包名> # 查看当前Activity栈,确认用户当前是否在App内 adb shell dumpsys activity activities | grep topResumedActivity还有一种更粗暴的验证方式:用另一台设备向客户端连着的服务端端口持续发数据,然后手动把手机切飞行模式再关掉,观察重连日志是否符合指数退避的时间序列。我在调试退避算法时就是靠这种方式,把每个阶段的延迟时间逐一打出来,确认没有出现“首试延迟过高”、“抖动范围异常”等问题。
6.5 常见问题排查速查表
| 现象 | 可能原因 | 处理方案 |
|---|---|---|
| 前台服务启动立即崩溃 | 缺少FOREGROUND_SERVICE权限或通知渠道未创建 | 检查manifest和startForeground调用顺序 |
| 连接收不到消息但也没报错 | TCP连接被中间设备静默回收 | 加业务心跳,缩短心跳间隔 |
| 后台一段时间后消息延迟 | 设备进入Doze模式或厂商省电策略干涉 | 引导用户关闭电池优化,或降级为推送通道 |
| 重连过早过频繁 | 固定间隔重连且无抖动 | 换成指数退避 + 抖动 |
| 服务被系统杀死后不再启动 | 只依赖START_STICKY | 增加本地会话恢复和RN兜底启动 |
| Android 13以上通知不显示 | 缺少POST_NOTIFICATIONS动态权限 | 在RN层申请通知权限后再启动服务 |
| 网络切换后连接断开无法恢复 | 没有注册NetworkCallback | 动态监听网络变化,网络恢复后主动重连 |
| Android 14上启动前台服务抛安全异常 | 未声明foregroundServiceType或类型不符 | 加上dataSync类型声明并在代码里对应使用 |
6.6 调试日志规范化的建议
最后分享一个经验:长连接问题最难排查的原因往往不是“没有日志”,而是“日志太多,不知道哪条是该看的”。我建议把日志分成三种级别:
- 连接状态日志:
[LongConnect] 状态切换: IDLE -> CONNECTING,用于快速定位主流程。 - 异常日志:
[LongConnect] 心跳超时 2次,触发重连,用于判断业务层自愈是否生效。 - 数据包日志:默认关闭,仅调试协议时开启。
日志格式统一加时间戳、触发源、当前重试次数。实测下来,这个日志规范能让你在线上复盘时迅速定位问题点,不用再靠猜。
我在实际项目中把协议解析、心跳、重连三个模块的日志分别打上PROTO、HB、RECONNECT前缀,后面接毫秒级时间戳和状态码。排查时一条grep就过滤出来了,效率比之前看全量日志高出一大截。
如果这篇实践能帮你少踩几个坑,那么写这些字就值了。后续如果你在落地时遇到更诡异的问题,欢迎按照上面的排查思路逐层查,先看服务是否存活,再看网络是否切换,最后看心跳是否超时,大概率能快速揪出问题所在。