WebSocket通信:CLI和App怎么握手
SmartInspector的架构是CLI跑在电脑上,性能采集靠Android设备上的App。两边的通信我选了WebSocket——不是因为时髦,是因为真的简单好用。
但"简单好用"不等于没坑。ping/pong超时、僵尸连接、ACK机制、日志膨胀……每一个都是在深夜debug中踩出来的。
这篇文章拆解整个WS通信链路的设计和实现。
为什么用WebSocket而不是别的
先说选项。CLI和App之间通信,常见的方案有三种:
- adb shell + stdout:最简单,但只能CLI→App单向推命令,App没法主动回传数据
- HTTP轮询:能双向,但每次都要建立连接,延迟高,采集场景不够实时
- WebSocket:长连接、双向、低延迟,adb reverse一行命令就能打通
最终选WebSocket的核心原因是:SmartInspector需要在采集trace之前确认App端的Hook已经就绪,在采集之后拉取App端缓存的block事件。这两个场景都需要"发一条命令、等一个确认"的交互模式,WebSocket天然适合。
网络拓扑也很简单:App通过adb reverse连接到CLI启动的WS Server。
adb reverse tcp:9876 tcp:9876
一行命令,App访问ws://127.0.0.1:9876就是电脑上的WS Server。
整体架构:Server在CLI,Client在App
┌─────────────────────┐ adb reverse ┌────────────────────┐
│ CLI (电脑) │◄──────── tcp:9876 ──────────►│ App (Android) │
│ │ │ │
│ SIServer (WS Server)│ │ SIClient (WS客户端)│
│ - 配置下发 │ │ - 配置上报 │
│ - start_trace握手 │ │ - ACK确认 │
│ - block事件拉取 │ │ - block事件缓存 │
└─────────────────────┘ └────────────────────┘
Server端是Python(websockets库),Client端是Kotlin(OkHttp的WebSocket实现)。两边通过JSON协议通信。
SIServer:懒启动的单例服务器
Server不是一开始就启动的。用户可能只用CLI分析本地trace文件,根本不需要App连接。所以WS Server是懒启动——第一次执行/config或/hook命令时才启动。
# server.py — 单例模式,懒启动
class SIServer:
_instance = None
_lock = threading.Lock()
@classmethod
def get(cls, port: int = 9876) -> "SIServer":
with cls._lock:
if cls._instance is None:
cls._instance = cls(port=port)
return cls._instance
启动在后台daemon线程中跑,不阻塞CLI主线程:
def start(self) -> None:
if self.is_running():
return
self._thread = threading.Thread(target=self._run_loop, daemon=True)
self._thread.start()
if self._ready_event.wait(timeout=2.0):
print(f" WS server started on port {self.port}")
这里有个细节——_ready_event。Server启动是异步的,调用方需要知道Server是否真的起来了。用threading.Event做一个简单的同步:_run_loop里创建好server后set这个event,start()方法wait它。
消息协议:7种消息类型
整个协议只有7种消息类型,覆盖了所有交互场景:
| 方向 | type | 用途 |
|---|---|---|
| App→Server | config_sync | App主动上报当前Hook配置 |
| App→Server | config_request | App请求Server端的配置 |
| App→Server | ack | 确认收到Server的命令 |
| App→Server | block_events | 返回缓存的卡顿事件 |
| Server→App | config_update | Server下发配置变更 |
| Server→App | config_response | 响应App的config_request |
| Server→App | start_trace | 通知App即将开始采集 |
所有消息都是JSON格式,统一结构:
{
"type": "start_trace",
"msg_id": "uuid-for-ack",
"payload": null
}
msg_id是ACK机制的关键——发命令时带一个UUID,App收到后回一个{"type": "ack", "msg_id": "同一个UUID"}。
start_trace握手:采集前的"准备好了吗"
这是整个WS通信最关键的交互。
采集trace之前,CLI需要确认App端的Hook已经初始化完毕。否则trace可能抓到一堆无关数据——App的Hook还在注册中,Perfetto已经在记录了。
握手流程:
# collector.py — 采集前的握手
if server.has_connections():
ack_ok = server.send_start_trace(timeout=5.0)
if ack_ok:
logger.info("Hook ACK received, hooks ready")
else:
logger.warning("Hook ACK timeout, proceeding anyway")
send_start_trace的实现:
def send_start_trace(self, timeout: float = 5.0) -> bool:
msg_id = str(uuid.uuid4())
msg = json.dumps({"type": "start_trace", "msg_id": msg_id, "payload": None})
ack_event = threading.Event()
self._pending_acks[msg_id] = ack_event
# 发送给所有已连接的App
future = asyncio.run_coroutine_threadsafe(self._broadcast(msg), self._loop)
future.result(timeout=3)
# 阻塞等ACK
ack_event.wait(timeout=timeout)
return ack_event.is_set()
App端收到start_trace后的处理很简洁:
// SIClient.kt
} else if ("start_trace".equals(type)) {
String msgId = msg.optString("msg_id", "");
boolean ready = TraceHook.isInitialized();
Log.i(TAG, "WS received start_trace, hooks ready: " + ready);
JSONObject ack = new JSONObject();
ack.put("type", "ack");
ack.put("msg_id", msgId);
webSocket.send(ack.toString());
}
这里有个设计决策:不管Hook有没有初始化完,都回ACK。为什么?因为CLI只需要知道"App收到了这条命令"。如果Hook没就绪,trace数据可能不完整,但不会导致程序崩溃。让用户决定要不要重新采集,比自动阻塞要好。
ACK机制:用threading.Event做跨线程等待
WS Server跑在daemon线程的asyncio事件循环里,但调用方(collector_node)跑在主线程。跨线程的"发消息、等回复"怎么搞?
我的方案是threading.Event + dict:
# 发送端
self._pending_acks: dict[str, threading.Event] = {}
def send_start_trace(self, timeout=5.0):
msg_id = str(uuid.uuid4())
ack_event = threading.Event()
self._pending_acks[msg_id] = ack_event
self._broadcast(msg) # async,通过run_coroutine_threadsafe桥接
ack_event.wait(timeout=timeout) # 阻塞主线程
return ack_event.is_set()
# 接收端(async handler里)
async def _dispatch(self, ws, msg):
if msg.get("type") == "ack":
msg_id = msg.get("msg_id", "")
event = self._pending_acks.get(msg_id)
if event:
event.set() # 唤醒主线程
优点:简单,不需要额外的队列或回调。缺点:每个pending ACK占一个Event对象,但在SmartInspector场景下同时pending的ACK不会超过几个,完全没问题。
配置同步:连接即上报
App每次连接WS Server,onOpen回调里第一件事就是把当前配置推上来:
override fun onOpen(webSocket: WebSocket, response: Response) {
connected = true
// 连接成功立即上报当前配置
sendConfig(HookConfigManager.getConfig())
notifyConnected()
}
Server端收到后缓存到_latest_config,后续collector_node读取perfetto参数时就从这拿:
def _read_perfetto_config() -> dict:
server = SIServer.get()
config_str = server.get_config()
if not config_str:
return {}
config = json.loads(config_str)
return config.get("perfetto_collection", {})
这个设计的好处是:配置永远以App端为准。CLI不需要维护一份"正确的配置",只需要缓存App上报的最新值。App的重连会自动触发配置同步,不用担心过期问题。
Block事件拉取:WS比Perfetto SQL更可靠
采集完trace后,collector还会通过WS拉取App端缓存的block事件:
ws_events = server.request_block_events(timeout=5.0)
为什么不用Perfetto SQL查?两个原因:
- Perfetto的
android_logs表经常是空的——不是所有设备的Perfetto版本都支持atrace写入用户自定义事件 - WS的block事件带有完整堆栈信息(
stack_trace),SQL里只有方法名
所以最终方案是合并:SQL数据有精确的时间戳(ts_ns),WS数据有完整的堆栈。按msgClass + dur_ms做匹配:
def _merge_block_events(sql_events, ws_events):
# 用 (msg_class, dur_ms) 做精确匹配
# 匹配到就把WS的stack_trace补到SQL事件上
# 没匹配到的WS事件也保留(时间戳置0)
...
心跳和ping/pong:那个3.9GB日志的教训
这是全文最痛的一个坑。
OkHttp的WebSocket默认ping间隔是0(不主动发ping)。Python websockets库默认ping_interval=20(每20秒发一次ping),ping_timeout=20(20秒内没收到pong就断开)。
问题出在adb reverse的连接不稳定上。USB线松了、adb server重启了、设备休眠了——各种原因导致连接"半死不活":TCP连接还在,但数据已经传不过去了。
这时候Python端的心跳机制会频繁触发:发ping → 超时 → 断开 → App重连 → 又断开 → 又重连……如果日志级别没控制好,每次重连都打一堆堆栈信息。一天下来,日志文件能涨到3.9GB。
最终修复方案:
- 调大ping_timeout:从默认20秒调到30秒,通过环境变量可配置
# config.py
def get_ws_ping_timeout() -> int:
return int(os.environ.get("SI_WS_PING_TIMEOUT", "30"))
# server.py
websockets.serve(
self._handler,
"0.0.0.0",
self.port,
ping_interval=20,
ping_timeout=get_ws_ping_timeout(),
)
- App端也设pingInterval:OkHttp默认不发ping,加上30秒一次
this.httpClient = OkHttpClient.Builder()
.readTimeout(0, TimeUnit.MILLISECONDS)
.pingInterval(30, TimeUnit.SECONDS)
.build()
- 控制日志级别:重连日志用
debug而非info,生产环境不输出
僵尸连接处理:broadcast时自动清理
Server维护了一个连接集合_connections。broadcast时如果某个连接已经断了,ws.send()会抛异常。利用这个特性做自动清理:
async def _broadcast(self, msg: str) -> None:
dead = set()
for ws in self._connections:
try:
await ws.send(msg)
except Exception:
dead.add(ws)
self._connections -= dead
不需要定时检查连接是否存活,每次发消息时顺便清理,简单有效。
App端自动重连:3秒间隔
App端的重连逻辑很简单——断线后3秒重连,除非是主动断开:
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
connected = false
if (!intentionalClose) {
scheduleReconnect()
}
}
private fun scheduleReconnect() {
mainHandler.postDelayed({
if (!intentionalClose && !connected) {
connect()
}
}, RECONNECT_DELAY_MS) // 3000ms
}
intentionalClose标志位防止主动断开时还触发重连循环。这个flag在disconnect()里设为true。
另外,CLI还可以通过adb广播主动触发App重连:
adb shell am broadcast -a com.smartinspector.WS_RECONNECT
这在App已经连上但需要刷新配置时很有用。
两个WS Server:9876和9877
SmartInspector其实有两个WS Server:
- 9876(SIServer):CLI↔App通信,配置同步、start_trace握手、block事件拉取
- 9877(BridgeServer):Perfetto UI↔Agent通信,交互式帧分析
BridgeServer的职责完全不同——它serve Perfetto UI的静态文件,同时提供一个/bridge WebSocket端点给Perfetto UI插件发送frame_selected事件。Agent收到后做帧分析,结果实时推回前端。
# bridge_server.py — 进度推送
async def send_progress(step: str, detail: str = ""):
await ws.send(json.dumps({
"type": "analysis_progress",
"payload": {"step": step, "detail": detail},
}))
两个Server端口分开,互不影响。Perfetto UI不需要App连接就能工作,App也不需要Perfetto UI就能同步配置。
总结:设计要点和踩坑清单
设计要点:
- 懒启动:只有用到才开Server,省资源
- 单例模式:全局一个SIServer,多命令共享连接状态
- ACK机制:threading.Event实现跨线程等待,简单可靠
- 配置以App为准:连接即上报,CLI只做缓存
- 双Server隔离:9876做App通信,9877做UI交互
踩坑清单:
- ping/pong超时导致重连循环 → 调大timeout + 控制日志级别
- adb reverse端口映射不稳定 → App端也加pingInterval
- block事件SQL和WS数据不一致 → 合并策略,以精确时间戳+完整堆栈为目标
- asyncio线程和主线程的桥接 →
run_coroutine_threadsafe+threading.Event - 僵尸连接 → broadcast时惰性清理,不需要专门的检测线程
整个WS通信层不到400行Python + 150行Kotlin,覆盖了配置同步、命令握手、数据拉取、心跳检测四个核心场景。够用就好,不过度设计。