2.4 WebSocket通信:实时数据流的桥梁

WebSocket通信:CLI和App怎么握手

SmartInspector的架构是CLI跑在电脑上,性能采集靠Android设备上的App。两边的通信我选了WebSocket------不是因为时髦,是因为真的简单好用。

但"简单好用"不等于没坑。ping/pong超时、僵尸连接、ACK机制、日志膨胀......每一个都是在深夜debug中踩出来的。

这篇文章拆解整个WS通信链路的设计和实现。

为什么用WebSocket而不是别的

先说选项。CLI和App之间通信,常见的方案有三种:

  1. adb shell + stdout:最简单,但只能CLI→App单向推命令,App没法主动回传数据
  2. HTTP轮询:能双向,但每次都要建立连接,延迟高,采集场景不够实时
  3. WebSocket:长连接、双向、低延迟,adb reverse一行命令就能打通

最终选WebSocket的核心原因是:SmartInspector需要在采集trace之前 确认App端的Hook已经就绪,在采集之后拉取App端缓存的block事件。这两个场景都需要"发一条命令、等一个确认"的交互模式,WebSocket天然适合。

网络拓扑也很简单:App通过adb reverse连接到CLI启动的WS Server。

bash 复制代码
adb reverse tcp:9876 tcp:9876

一行命令,App访问ws://127.0.0.1:9876就是电脑上的WS Server。

整体架构:Server在CLI,Client在App

arduino 复制代码
┌─────────────────────┐          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命令时才启动。

python 复制代码
# 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主线程:

python 复制代码
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格式,统一结构:

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已经在记录了。

握手流程:

python 复制代码
# 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的实现:

python 复制代码
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后的处理很简洁:

kotlin 复制代码
// 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

python 复制代码
# 发送端
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回调里第一件事就是把当前配置推上来:

kotlin 复制代码
override fun onOpen(webSocket: WebSocket, response: Response) {
    connected = true
    // 连接成功立即上报当前配置
    sendConfig(HookConfigManager.getConfig())
    notifyConnected()
}

Server端收到后缓存到_latest_config,后续collector_node读取perfetto参数时就从这拿:

python 复制代码
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事件:

python 复制代码
ws_events = server.request_block_events(timeout=5.0)

为什么不用Perfetto SQL查?两个原因:

  1. Perfetto的android_logs表经常是空的------不是所有设备的Perfetto版本都支持atrace写入用户自定义事件
  2. WS的block事件带有完整堆栈信息(stack_trace),SQL里只有方法名

所以最终方案是合并 :SQL数据有精确的时间戳(ts_ns),WS数据有完整的堆栈。按msgClass + dur_ms做匹配:

python 复制代码
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。

最终修复方案:

  1. 调大ping_timeout:从默认20秒调到30秒,通过环境变量可配置
python 复制代码
# 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(),
)
  1. App端也设pingInterval:OkHttp默认不发ping,加上30秒一次
kotlin 复制代码
this.httpClient = OkHttpClient.Builder()
    .readTimeout(0, TimeUnit.MILLISECONDS)
    .pingInterval(30, TimeUnit.SECONDS)
    .build()
  1. 控制日志级别 :重连日志用debug而非info,生产环境不输出

僵尸连接处理:broadcast时自动清理

Server维护了一个连接集合_connections。broadcast时如果某个连接已经断了,ws.send()会抛异常。利用这个特性做自动清理:

python 复制代码
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秒重连,除非是主动断开:

kotlin 复制代码
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重连:

bash 复制代码
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收到后做帧分析,结果实时推回前端。

python 复制代码
# 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交互

踩坑清单:

  1. ping/pong超时导致重连循环 → 调大timeout + 控制日志级别
  2. adb reverse端口映射不稳定 → App端也加pingInterval
  3. block事件SQL和WS数据不一致 → 合并策略,以精确时间戳+完整堆栈为目标
  4. asyncio线程和主线程的桥接run_coroutine_threadsafe + threading.Event
  5. 僵尸连接 → broadcast时惰性清理,不需要专门的检测线程

整个WS通信层不到400行Python + 150行Kotlin,覆盖了配置同步、命令握手、数据拉取、心跳检测四个核心场景。够用就好,不过度设计。

相关推荐
mascon4 小时前
unity性能优化
性能优化
四六的六6 小时前
WebView 性能优化实战:从首屏1.5秒到300毫秒
性能优化·个人开发·性能调优·前端优化·移动端h5·webview性能优化
懂AI的老郑7 小时前
YOLO检测系统性能优化三大核心:并行、队列与缓存
缓存·性能优化
光影少年7 小时前
react性能优化比较好的办法有哪些?
前端·react.js·性能优化
我是菜鸟0713号8 小时前
记一次软件性能优化实践
性能优化
数据库小学妹8 小时前
锁机制(Locking):解决数据库“死锁”与“阻塞”的终极指南
数据库·sql·mysql·性能优化·学习方法
前端技术9 小时前
机器学习性能评估_指标偏差与工程实践
机器学习·性能优化·混淆矩阵·交叉验证·分布偏移
xcLeigh19 小时前
KES数据库性能优化实战
数据库·sql·性能优化·sql优化·数据性能
昇腾CANN1 天前
TileLang-Ascend 算子性能优化方法与实操
开发语言·javascript·性能优化·昇腾·cann