《移动游戏产品的架构·客户端与移动运行时》3.2 桥接层设计:消息、线程与生命周期

桥接层不是一组回调,而是一套有版本、有错误码、有线程归属的消息协议。本节用三条队列表达主线程、渲染线程与后台线程的边界,让每次调用都可超时、可取消、可测试,并给出 mock Host 的最小实现与五类错误语义;队列过滤与迟到响应处理是两处最容易被忽略的细节。

一次 SDK 升级,订单停在待支付

《潮汐消消》接入新版本商店 SDK 后,一笔礼包订单在客户端一直显示待支付,服务端却已经发货。日志里只有旧桥接打印的一行 warn: undefined,没有错误码,也没有订单号。排查发现旧消息只有 { action, data } 两个字段,SDK 升级后把 data.orderId 改成了 data.order_id,引擎读到 undefined 后把它当成「用户取消」,整条链路因此静默走偏。

这不是字段命名问题,而是桥接层缺少三样东西:协议版本让双方知道该按哪套字段解释;错误语义让失败可以被分类处理;线程归属让回调知道该在哪个队列上执行。三者缺一,桥接层就会退化成一组靠约定维持的回调。

本节以消消的支付与生命周期桥接为主线,给出一个可以在 Node 里真实跑起来的消息协议与队列模型。它不模拟商店 SDK,也不验证真机时序;所有平台行为都由 mock Host 表达,正文会明确标注哪些结论只能上设备验证。

一条消息必须带四样东西

消息的第一个字段是 id,由引擎侧单调递增生成,用于把响应和请求对上。第二个是 name,采用 域.动作 的命名,例如 permission.state、billing.buy,避免不同 SDK 的同名方法互相覆盖。第三个是 version,表示协议版本,不是应用版本;双方版本不一致时必须失败,而不是尽量兼容。第四个是 thread,声明这条消息应该在哪个队列上被消费。

payload 保持为 unknown,由每个 handler 自己校验。桥接层不负责业务字段的完整校验,但它必须保证:handler 抛出的异常会变成 host-error,缺少 handler 会变成 unsupported,版本不一致会变成 version-mismatch。调用方拿到的不是异常,而是一个带错误码的结果。

错误码产生位置调用方应该做什么
timeout桥接层按截止时间清扫可重试一次;仍失败则走降级
cancelled引擎主动取消不重试,释放等待中的 UI 状态
version-mismatch壳侧分发时校验禁止重试,记录双方版本并上报
unsupported壳侧找不到 handler走备用能力或明确置灰
host-errorhandler 内部抛出或校验失败按业务决定;必须保留原因字符串

这张表的关键不是五个名字,而是「谁产生、谁处理」的对应关系。timeout 只能由桥接层产生,不能由业务代码伪造;host-error 必须携带原因,否则调用方无法区分「参数错了」和「SDK 崩了」。错误码一旦进入日志和埋点,就成为跨端契约的一部分,不能随意改名。

三条队列:主线程、渲染线程、后台线程

桥接层不提供「同步调用」。任何跨边界调用都先进入目标线程的队列,由该线程在安全点取出并执行。这样做牺牲了一点实时性,换来两个确定的好处:调用方不会被对方的线程阻塞;每条消息的执行线程在消息里写死,不依赖调用时碰巧在哪个线程。

队列典型写入方消费方允许的工作禁止的工作
main引擎请求壳在主线程边界取出权限、支付、隐私弹窗、账号文件 IO、网络请求
worker引擎请求壳在后台执行器取出存档写入、埋点上报、资源校验修改 UI 或渲染状态
render壳事件引擎在帧开始时取出前后台切换、可见性、安全区变化阻塞等待、同步调用壳

引擎到壳的方向用 outbox,壳到引擎的方向用 inbox。两者都按 thread 过滤:drain("main") 只取主线程消息,pump("render") 只投递渲染线程事件。一个常见的错误是让壳在任何线程都能调用引擎回调,结果是同一帧里既有渲染线程写的状态,又有主线程写的状态,锁和时序都无法推理。

生命周期事件特别适合用 inbox 表达。壳收到 onPause 后,把一条 app.pause 事件推进 render 队列;引擎在下一帧开始时 pump("render"),在帧边界统一处理停帧、存档和音频暂停。这样「什么时候暂停」由壳决定,「暂停时做什么」由引擎决定,符合 3.1 的所有权划分。

超时不是 setTimeout

很多桥接层用 setTimeout 做超时,问题是它依赖真实时间,测试会随机器负载漂移,而且定时器可能在 Activity 销毁后才触发。本节的做法是把截止时间写进 pending 记录:deadlineMs = clock.nowMs() + timeoutMs,由引擎在帧边界调用 sweep() 清扫。

clock 是注入的,测试里用 ManualClock 推进,生产里用 SystemClock。这样超时行为可以在毫秒级确定性重放,不依赖 CI 机器的调度。sweep() 返回被清扫的消息 id,方便调用方记日志或补发指标。

清扫之后,迟到响应必须被忽略。实现上,sweep() 会把该消息从 pending 表里删掉;壳稍后调用 resolve(id, value) 时找不到记录,返回 false。这一条比超时本身更重要:如果迟到响应还能写回状态,超时就只是把「永远等」换成了「晚点写坏」。

取消要同时做两件事

取消不是把 Promise 标记为 rejected 就结束。引擎调用 cancel(id) 时,桥接层必须同时做两件事:把消息从 outbox 里移除,避免壳之后还执行一次;把 pending 记录结算为 cancelled,让等待方立即释放。只做前者,调用方会一直等;只做后者,壳仍会执行已经取消的操作。

如果消息已经被壳取走并开始执行,取消就返回 false,响应仍然有效。这不是缺陷,而是明确的所有权规则:消息一旦离开队列,执行权就归壳,引擎只能等待结果或忽略它。要支持真正的执行中取消,需要协议里增加取消消息和壳侧协作,复杂度远高于收益;消消的支付与权限调用都不是长任务,不需要这一层。

host-mock:让桥接可测

createMockHost() 实现 3.1 的 Host 接口,平台、时钟和权限状态都由参数注入。createMockBridgeHost() 在它之上注册三个 handler:权限查询、线程查询、支付下单。支付 handler 会校验 productId,缺少时返回 host-error,用来演示「业务校验失败也要走统一错误语义」。

这三个 handler 不是要模拟真实 SDK,而是给桥接层提供可预测的对端。测试可以推进时钟验证超时、在分发前取消验证队列清理、用错误版本构造 BridgeHost 验证版本拒绝。所有这些都在一个 Node 进程里完成,不需要模拟器。

// file: src/platform/host-mock.ts
// tier: L2
// verify: node --test test/platform/host-mock.test.ts

import { SystemClock, type Clock } from "../core/clock.ts";
import type { Host, PermissionKind, PermissionState, Platform } from "./host.ts";
import { shellKindFor } from "./host.ts";

export const PROTOCOL_VERSION = 2;

export type ThreadId = "main" | "render" | "worker";

export interface BridgeMessage {
  readonly id: string;
  readonly name: string;
  readonly version: number;
  readonly thread: ThreadId;
  readonly payload: unknown;
}

export interface BridgeEvent {
  readonly name: string;
  readonly version: number;
  readonly thread: ThreadId;
  readonly payload: unknown;
}

export type BridgeErrorCode =
  | "timeout"
  | "cancelled"
  | "version-mismatch"
  | "unsupported"
  | "host-error";

export type BridgeResult<T> =
  | { readonly ok: true; readonly value: T }
  | { readonly ok: false; readonly code: BridgeErrorCode; readonly message: string };

export interface BridgeCall<T> {
  readonly id: string;
  readonly result: Promise<BridgeResult<T>>;
}

export type BridgeHandler = (payload: unknown) => BridgeResult<unknown>;

export class BridgeHost {
  #version: number;
  #handlers = new Map<string, BridgeHandler>();

  constructor(version: number = PROTOCOL_VERSION) {
    this.#version = version;
  }

  get version(): number {
    return this.#version;
  }

  on(name: string, handler: BridgeHandler): void {
    this.#handlers.set(name, handler);
  }

  handle(message: BridgeMessage): BridgeResult<unknown> {
    if (message.version !== this.#version) {
      return {
        ok: false,
        code: "version-mismatch",
        message: `expected ${this.#version}, got ${message.version}`,
      };
    }
    const handler = this.#handlers.get(message.name);
    if (!handler) {
      return { ok: false, code: "unsupported", message: `no handler for ${message.name}` };
    }
    try {
      return handler(message.payload);
    } catch (error) {
      return { ok: false, code: "host-error", message: String(error) };
    }
  }
}

type Pending = {
  readonly message: BridgeMessage;
  readonly timeoutMs: number;
  readonly deadlineMs: number;
  readonly settle: (result: BridgeResult<unknown>) => void;
};

export interface BridgeOptions {
  readonly clock?: Clock;
  readonly host?: BridgeHost;
  readonly timeoutMs?: number;
}

export class Bridge {
  #clock: Clock;
  #host: BridgeHost;
  #defaultTimeoutMs: number;
  #seq = 0;
  #pending = new Map<string, Pending>();
  #outbox: BridgeMessage[] = [];
  #inbox: BridgeEvent[] = [];
  #listeners = new Map<string, Set<(payload: unknown) => void>>();

  constructor(options: BridgeOptions = {}) {
    this.#clock = options.clock ?? new SystemClock();
    this.#host = options.host ?? new BridgeHost();
    this.#defaultTimeoutMs = options.timeoutMs ?? 5_000;
  }

  get pendingCount(): number {
    return this.#pending.size;
  }

  call<T>(
    name: string,
    payload: unknown,
    options: { thread?: ThreadId; timeoutMs?: number } = {},
  ): BridgeCall<T> {
    this.#seq += 1;
    const id = `m${this.#seq}`;
    const message: BridgeMessage = {
      id,
      name,
      version: PROTOCOL_VERSION,
      thread: options.thread ?? "main",
      payload,
    };
    const timeoutMs = options.timeoutMs ?? this.#defaultTimeoutMs;
    const result = new Promise<BridgeResult<T>>((resolve) => {
      this.#pending.set(id, {
        message,
        timeoutMs,
        deadlineMs: this.#clock.nowMs() + timeoutMs,
        settle: (outcome) => resolve(outcome as BridgeResult<T>),
      });
    });
    this.#outbox.push(message);
    return { id, result };
  }

  drain(thread: ThreadId): readonly BridgeMessage[] {
    const picked = this.#outbox.filter((message) => message.thread === thread);
    this.#outbox = this.#outbox.filter((message) => message.thread !== thread);
    return picked;
  }

  dispatch(message: BridgeMessage): BridgeResult<unknown> {
    const outcome = this.#host.handle(message);
    this.#settle(message.id, outcome);
    return outcome;
  }

  resolve<T>(id: string, value: T): boolean {
    return this.#settle(id, { ok: true, value });
  }

  reject(id: string, code: BridgeErrorCode, message: string): boolean {
    return this.#settle(id, { ok: false, code, message });
  }

  cancel(id: string): boolean {
    if (!this.#pending.has(id)) {
      return false;
    }
    this.#outbox = this.#outbox.filter((message) => message.id !== id);
    return this.#settle(id, { ok: false, code: "cancelled", message: "cancelled by engine" });
  }

  sweep(): readonly string[] {
    const now = this.#clock.nowMs();
    const expired = [...this.#pending.values()].filter((pending) => pending.deadlineMs <= now);
    const ids: string[] = [];
    for (const pending of expired) {
      const id = pending.message.id;
      this.#outbox = this.#outbox.filter((message) => message.id !== id);
      this.#settle(id, {
        ok: false,
        code: "timeout",
        message: `no response within ${pending.timeoutMs}ms`,
      });
      ids.push(id);
    }
    return ids;
  }

  on(name: string, listener: (payload: unknown) => void): () => void {
    let set = this.#listeners.get(name);
    if (!set) {
      set = new Set();
      this.#listeners.set(name, set);
    }
    set.add(listener);
    return () => set.delete(listener);
  }

  push(event: BridgeEvent): BridgeResult<null> {
    if (event.version !== PROTOCOL_VERSION) {
      return {
        ok: false,
        code: "version-mismatch",
        message: `expected ${PROTOCOL_VERSION}, got ${event.version}`,
      };
    }
    this.#inbox.push(event);
    return { ok: true, value: null };
  }

  pump(thread: ThreadId): number {
    const due = this.#inbox.filter((event) => event.thread === thread);
    this.#inbox = this.#inbox.filter((event) => event.thread !== thread);
    for (const event of due) {
      const set = this.#listeners.get(event.name);
      if (!set) {
        continue;
      }
      for (const listener of [...set]) {
        listener(event.payload);
      }
    }
    return due.length;
  }

  #settle(id: string, outcome: BridgeResult<unknown>): boolean {
    const pending = this.#pending.get(id);
    if (!pending) {
      return false;
    }
    this.#pending.delete(id);
    pending.settle(outcome);
    return true;
  }
}

export interface MockHostOptions {
  readonly platform?: Platform;
  readonly clock?: Clock;
  readonly permissions?: Partial<Record<PermissionKind, PermissionState>>;
}

export interface MockHost extends Host {
  setPermission(kind: PermissionKind, state: PermissionState): void;
}

export function createMockHost(options: MockHostOptions = {}): MockHost {
  const platform = options.platform ?? "android";
  const clock = options.clock ?? new SystemClock();
  const permissions = new Map<PermissionKind, PermissionState>(
    Object.entries(options.permissions ?? {}) as [PermissionKind, PermissionState][],
  );
  return {
    platform,
    shell: shellKindFor(platform),
    mainThread: "main",
    renderThread: "render",
    nowMs: () => clock.nowMs(),
    permissionState: (kind) => permissions.get(kind) ?? "not-determined",
    setPermission: (kind, state) => {
      permissions.set(kind, state);
    },
  };
}

export function createMockBridgeHost(host: MockHost): BridgeHost {
  const bridgeHost = new BridgeHost();
  bridgeHost.on("permission.state", (payload) => {
    const kind = (payload as { kind: PermissionKind }).kind;
    return { ok: true, value: host.permissionState(kind) };
  });
  bridgeHost.on("app.threads", () => ({
    ok: true,
    value: { main: host.mainThread, render: host.renderThread },
  }));
  bridgeHost.on("billing.buy", (payload) => {
    const productId = (payload as { productId?: string }).productId;
    if (!productId) {
      return { ok: false, code: "host-error", message: "missing productId" };
    }
    return { ok: true, value: { productId, state: "pending" } };
  });
  return bridgeHost;
}

与相邻章节的关系

3.1 决定了生命周期、支付、权限归壳,本节把这套所有权落成消息协议。3.3 会在这套协议之上定义轻壳与重壳的差异:同样的 Host 契约,消消把账号和支付放在可延迟路径,方舟把它们放在阻塞路径。

原生桥接的调用机制可以参考 native-jsb 桥接 ,那里关注 JS 与原生如何互相调用;本节关注调用前后的版本、错误与线程约束。支付链路的服务端幂等可以参考支付渠道与发货幂等 ,客户端桥接只能保证「不重复提交」,最终一致性仍由服务端保证。

小结

  • 桥接消息必须带 id、name、version、thread 与 payload;版本不一致时失败关闭。
  • 错误语义收敛为五类:timeout、cancelled、version-mismatch、unsupported、host-error。
  • 主线程、后台线程、渲染线程各有一条队列;跨边界调用一律入队,不做同步调用。
  • 超时用注入时钟算截止时间并在帧边界清扫,迟到响应必须被忽略。
  • 取消要同时移除队列消息并结算等待方;消息一旦被取走,执行权归壳。

阅读导航:上一节:3.1 引擎负责什么,原生壳负责什么 · 下一节:3.3 双案例壳层差异:轻壳与重壳 。

常见错误与边界

  • 协议只带 action 和 data。字段改名或增删时双方无法判断兼容性;必须带协议版本,并在不一致时拒绝。
  • 用 setTimeout 做超时。真实时间不可重放,定时器还可能在壳销毁后触发;用注入时钟加帧边界清扫。
  • 取消只 reject Promise。消息仍留在队列里会被继续执行;取消必须同时清理队列和等待方。
  • 允许壳从任意线程回调引擎。同一帧出现多线程写入后,锁和时序都无法推理;按 thread 路由到固定队列。
  • 把 host-error 当成字符串日志。调用方无法区分参数错误、SDK 崩溃和版本不兼容;错误码和原因必须分开。
  • 边界:本节不验证真机桥接时延。Node 里的队列模型只证明接线与错误分支正确,主线程卡顿、JNI 开销和商店 SDK 回调时机必须在设备矩阵上验证。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《移动游戏产品的架构·商业化与发行工程》附录 C 商业化与发行评审清单
  2. 《移动游戏产品的架构·商业化与发行工程》附录 B 双案例商业化事实表
  3. 《移动游戏产品的架构·商业化与发行工程》附录 A 商业化与发行术语速查