《移动游戏产品的架构·客户端与移动运行时》3.1 引擎负责什么,原生壳负责什么

引擎与原生壳的边界不按技术栈划分,而按十项职责的所有权划分:生命周期、主线程与权限归壳,渲染线程归引擎,文件路径、网络传输与传感器共享。本节把这份判定表写成可查询、可审计的 Host 契约,并用五条断言防止职责重复或缺席;表外的灰区必须显式登记,不能留给下一版口头约定。

一次 onPause 之后,游戏还在跑

《潮汐消消》的一个版本里,玩家切到后台接电话,回到前台发现关卡倒计时仍在走,体力也被扣了一次。排查记录有两个独立现象:Android 的 onPause 只给引擎传了一个布尔标志,引擎主循环并没有因此停帧;支付回调从商店 SDK 的后台线程直接改了游戏状态对象,和渲染线程撞在一起。

这两个现象看起来分别是生命周期问题和线程问题,根因却是同一个:没有人写清楚 onPause 之后哪些职责暂停、由谁执行、执行在哪个线程。团队当时的共识是「引擎管游戏,壳管系统」,这句话在评审时无法反驳,也无法执行,因为它没有回答「支付回调算游戏还是算系统」。

本节不讨论引擎怎么渲染,也不讨论 Activity 的生命周期回调顺序,那些内容属于引擎与平台文档。本节只做一件事:把「谁拥有什么」拆成可枚举、可查询、可审计的职责表,再用一个 Host 接口把平台事实注入引擎。

边界不是「引擎 vs 原生」,而是十项职责

「引擎负责游戏,壳负责系统」之所以没用,是因为“游戏”和“系统”在具体调用上会重叠。支付是系统能力,但订单结果要改游戏状态;传感器是系统能力,但采样率和降级策略属于游戏体验;渲染是引擎能力,但后台时必须由壳通知引擎停帧。

把重叠的职责继续拆,直到每一项都能回答三个问题:谁接收系统回调、谁承担这项能力失败时的后果、谁能在没有对方的情况下单独测试。拆完之后得到十项职责,覆盖了绝大多数移动游戏客户端的边界争议。

职责所有权判定理由交接物
应用生命周期壳Activity / UIViewController 是唯一能收到系统回调的一方统一的 resume / pause / destroy 事件
主线程壳UI 与输入由平台主线程调度,引擎不能假设自己拥有它每帧回到主线程的回调点
渲染线程引擎渲染循环与 GPU 资源必须由同一线程持有只暴露提交与停帧接口
权限壳申请、拒绝、「不再询问」都由系统弹窗决定权限状态快照,不含申请细节
支付壳商店 SDK、订单回调、恢复购买都绑定平台账号商品 id 进,订单结果出
推送壳token 注册、通知点击、前后台展示都由系统通道处理通知点击事件与 token
隐私弹窗壳弹窗必须使用原生控件并遵守平台审核要求同意项集合与结果
文件路径共享壳知道可写目录,引擎知道资源该如何组织壳给根目录,引擎只在其下创建
网络传输共享壳提供网络状态与代理配置,引擎持有连接与重试状态变化事件 + 传输实现
传感器共享壳采集原始数据,引擎决定采样率与降级原始样本 + 采样配置

这张表里出现「共享」的三项,是最容易出问题的地方。共享不等于双方都能改,而是一方提供事实、另一方提供策略,并且交接物必须写进接口。比如文件路径:壳提供根目录,引擎不得自己拼系统路径;网络传输:壳报告「当前无网」,引擎决定是否重试以及退避多久。

三个问题决定归属

给一项新能力找归属时,不要从“它像游戏还是像系统”开始,而是依次问三个问题。

第一,谁接收系统回调? 只有 Activity、UIViewController、Application 这些平台对象能收到生命周期、权限结果和商店回调。接收方必须是所有权方,否则它会变成一个只会转发的空壳,而真正的状态藏在引擎里。

第二,谁承担失败后果? 支付失败导致订单丢失,责任在壳的 SDK 适配;渲染线程卡死导致 ANR,责任在引擎的线程模型。所有权应该跟着后果走,而不是跟着代码放在哪个目录走。

第三,谁能在没有对方的情况下测试? 生命周期、权限、支付这些可以通过 mock 平台对象单独测试,归壳;渲染与资源加载需要引擎运行时,归引擎。共享项则要求双方都能对着接口测试,这也是 Host 接口存在的理由。

这三个问题没有一条提到编程语言或技术栈。边界是责任边界,不是 Java/Kotlin 与 C++/C# 的边界;把边界画成语言边界,最后一定会出现「这段逻辑用哪种语言写都行,所以谁都不管」的灰区。

Host 接口:把平台事实变成可注入的依赖

引擎不应该 import 任何 Android 或 iOS 的 SDK,也不应该直接持有 Activity 引用。它只需要一组平台事实:当前是什么平台、壳是哪一种、主线程和渲染线程叫什么、现在几点、某个权限是什么状态。把这些事实收进一个 Host 接口,引擎代码就只依赖接口,不依赖设备。

Host 的五个成员对应三类事实。platform 与 shell 是身份事实,决定壳的类型;mainThread 与 renderThread 是线程事实,决定回调应该投递到哪里;nowMs() 与 permissionState() 是运行时事实,分别用于超时计算和权限分支。

身份事实和线程事实为什么不能写死?因为同一份引擎代码要同时跑在 Android 和 iOS 上,而两边的壳类型、线程命名并不一致。把它们放进 Host,测试里就能用快照构造出任意组合,而不需要真的启动一个模拟器。

运行时事实之所以用方法而不是字段,是为了让 Host 能表达「随时间变化」。hostFromSnapshot() 是一个最小实现:它把一份快照固定下来,权限缺省为 not-determined。它不是生产实现,而是给 L1 测试用的确定性种子;真正的 mock 会在 3.2 里接上手动时钟和可变权限。

代码:职责表与 Host 契约

下面的文件把十项职责写成数据,并提供两个查询:ownerOf() 回答单项归属,auditOwnership() 检查是否缺项或重复。测试不验证“架构图好不好看”,只验证这张表仍然完整、Android 与 iOS 的壳类型没有写反、快照 Host 的缺省值符合预期。

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

export type Platform = "android" | "ios";
export type ShellKind = "activity" | "ui-view-controller";
export type Owner = "shell" | "engine" | "shared";

export const CONCERNS = [
  "app-lifecycle",
  "main-thread",
  "render-thread",
  "permissions",
  "billing",
  "push",
  "consent-ui",
  "file-paths",
  "network-transport",
  "sensors",
] as const;

export type Concern = (typeof CONCERNS)[number];

export interface Ownership {
  readonly concern: Concern;
  readonly owner: Owner;
  readonly contract: string;
}

export const OWNERSHIP: readonly Ownership[] = [
  {
    concern: "app-lifecycle",
    owner: "shell",
    contract: "onCreate/onResume/onPause/onDestroy 或 viewDidAppear/viewDidDisappear 转成统一事件",
  },
  {
    concern: "main-thread",
    owner: "shell",
    contract: "UI 与输入只在主线程;引擎每帧回调必须回到主线程",
  },
  {
    concern: "render-thread",
    owner: "engine",
    contract: "渲染循环与 GPU 资源由引擎线程持有,壳不得直接调用",
  },
  {
    concern: "permissions",
    owner: "shell",
    contract: "申请、拒绝与「不再询问」由原生壳处理,引擎只读状态",
  },
  {
    concern: "billing",
    owner: "shell",
    contract: "商店 SDK、订单回调与恢复购买由壳适配,引擎只发商品 id",
  },
  {
    concern: "push",
    owner: "shell",
    contract: "token 注册、通知点击与前后台展示由壳处理",
  },
  {
    concern: "consent-ui",
    owner: "shell",
    contract: "隐私弹窗与系统级跳转由壳渲染,引擎只声明需要哪些同意项",
  },
  {
    concern: "file-paths",
    owner: "shared",
    contract: "壳给出可写目录,引擎只在该目录内创建文件",
  },
  {
    concern: "network-transport",
    owner: "shared",
    contract: "壳提供网络状态与代理配置,引擎持有连接与重试策略",
  },
  {
    concern: "sensors",
    owner: "shared",
    contract: "壳采集原始传感器,引擎负责采样率与降级",
  },
];

export interface OwnershipAudit {
  readonly ok: boolean;
  readonly missing: readonly Concern[];
  readonly duplicated: readonly Concern[];
}

export function ownerOf(concern: Concern): Owner {
  const rule = OWNERSHIP.find((item) => item.concern === concern);
  if (!rule) {
    throw new Error(`unknown concern: ${concern}`);
  }
  return rule.owner;
}

export function auditOwnership(rules: readonly Ownership[] = OWNERSHIP): OwnershipAudit {
  const seen = new Map<Concern, number>();
  for (const rule of rules) {
    seen.set(rule.concern, (seen.get(rule.concern) ?? 0) + 1);
  }
  const missing = CONCERNS.filter((concern) => !seen.has(concern));
  const duplicated = [...seen.entries()]
    .filter(([, count]) => count > 1)
    .map(([concern]) => concern);
  return { ok: missing.length === 0 && duplicated.length === 0, missing, duplicated };
}

export type PermissionKind = "notifications" | "tracking" | "storage" | "network";
export type PermissionState = "granted" | "denied" | "not-determined";

export interface HostSnapshot {
  readonly platform: Platform;
  readonly nowMs: number;
  readonly permissions?: Partial<Record<PermissionKind, PermissionState>>;
}

export interface Host {
  readonly platform: Platform;
  readonly shell: ShellKind;
  readonly mainThread: string;
  readonly renderThread: string;
  nowMs(): number;
  permissionState(kind: PermissionKind): PermissionState;
}

export function shellKindFor(platform: Platform): ShellKind {
  return platform === "android" ? "activity" : "ui-view-controller";
}

export function hostFromSnapshot(snapshot: HostSnapshot): Host {
  const permissions = snapshot.permissions ?? {};
  return {
    platform: snapshot.platform,
    shell: shellKindFor(snapshot.platform),
    mainThread: "main",
    renderThread: "render",
    nowMs: () => snapshot.nowMs,
    permissionState: (kind) => permissions[kind] ?? "not-determined",
  };
}

测试里有一条容易被忽略:修改职责表时,OWNERSHIP.length 必须等于 CONCERNS.length。这条断言的价值不在数字本身,而在于它强迫新增职责的人同时更新枚举和规则。只加枚举不加规则,auditOwnership() 会报缺项;只加规则不加枚举,类型系统在编译期就会拒绝。两边的约束合起来,职责表才不会在几次迭代后悄悄漂移。

// file: test/platform/host.test.ts
// tier: L1
// verify: node --test test/platform/host.test.ts

import { test } from "node:test";
import assert from "node:assert/strict";

import {
  CONCERNS,
  OWNERSHIP,
  auditOwnership,
  hostFromSnapshot,
  ownerOf,
  shellKindFor,
} from "../../src/platform/host.ts";

test("android and ios map to different shell kinds", () => {
  assert.equal(shellKindFor("android"), "activity");
  assert.equal(shellKindFor("ios"), "ui-view-controller");
});

test("every concern has exactly one owner", () => {
  const audit = auditOwnership();
  assert.equal(audit.ok, true);
  assert.deepEqual(audit.missing, []);
  assert.deepEqual(audit.duplicated, []);
  assert.equal(OWNERSHIP.length, CONCERNS.length);
});

test("lifecycle, main thread and permissions belong to the shell", () => {
  assert.equal(ownerOf("app-lifecycle"), "shell");
  assert.equal(ownerOf("main-thread"), "shell");
  assert.equal(ownerOf("permissions"), "shell");
  assert.equal(ownerOf("render-thread"), "engine");
});

test("audit reports missing and duplicated concerns", () => {
  const withoutBilling = OWNERSHIP.filter((rule) => rule.concern !== "billing");
  const duplicated = [
    ...withoutBilling,
    { concern: "push" as const, owner: "shell" as const, contract: "dup" },
  ];
  const audit = auditOwnership(duplicated);
  assert.equal(audit.ok, false);
  assert.deepEqual(audit.missing, ["billing"]);
  assert.deepEqual(audit.duplicated, ["push"]);
});

test("snapshot host defaults unset permissions to not-determined", () => {
  const host = hostFromSnapshot({
    platform: "ios",
    nowMs: 1_000,
    permissions: { notifications: "granted" },
  });
  assert.equal(host.shell, "ui-view-controller");
  assert.equal(host.nowMs(), 1_000);
  assert.equal(host.permissionState("notifications"), "granted");
  assert.equal(host.permissionState("tracking"), "not-determined");
  assert.equal(host.mainThread, "main");
  assert.equal(host.renderThread, "render");
});

谁拥有状态,谁就拥有恢复责任

职责表还有一个下游影响:状态恢复。生命周期归壳,意味着「应用被系统杀掉后回到哪个页面」的第一手信息在壳;但游戏进度归引擎,意味着「恢复到哪一帧」必须由引擎提供快照。3.1 只确定所有权,不展开恢复协议;第 4 章会把这两类状态接起来。

支付和推送同样如此。壳拿到订单结果或通知点击,但它不应该知道当前关卡打到一半还是已经结算。正确做法是壳把结果转成一条带类型的事件,交给引擎在正确的线程上处理。这条事件怎么带版本、怎么超时、怎么取消,是 3.2 的主题。

与相邻章节的关系

第 2 章把客户端分成 platform、system、gameplay、presentation 四层,本节只处理 platform 层内部的引擎与壳。分层解决的是「谁能依赖谁」,本节解决的是「同一层里谁负责什么」。两者不能互相替代:只分层不分工,platform 层会变成一个什么都装的大口袋;只分工不分层,职责表会被上层直接绕过。

通用分层原则可以参考四层职责划分 和依赖方向 。原生桥接的实现细节可以参考 native-jsb 桥接 ,那里讨论的是调用机制;本节讨论的是调用之前先决定谁该拥有这项能力。

小结

  • 边界不按技术栈划分,而按职责所有权划分;「引擎管游戏、壳管系统」无法评审。
  • 十项职责覆盖生命周期、线程、权限、支付、推送、隐私、文件、网络与传感器。
  • 所有权跟着系统回调、失败后果和可测试性走,不跟着代码目录走。
  • 共享项必须写成「一方提供事实、另一方提供策略」,并把交接物放进接口。
  • Host 把平台事实变成可注入依赖;职责表可查询、可审计,新增职责必须同时改枚举和规则。

阅读导航:上一节:2.3 边界测试:依赖规则写成可执行断言 · 下一节:3.2 桥接层设计:消息、线程与生命周期 。

常见错误与边界

  • 把「共享」当成「双方都能改」。共享项必须指定事实提供方和策略持有方;两边都能写状态,最终一定出现两套真相。
  • 让引擎直接持有 Activity 或 UIViewController 引用。一旦持有,引擎就无法脱离设备测试,也无法复用到另一个平台;只传 Host 接口。
  • 用语言边界代替责任边界。Kotlin 写的模块也可能归引擎,C++ 写的模块也可能归壳;按回调来源和失败后果判断。
  • 权限只判断「已授权/未授权」。iOS 与 Android 都有「未决定」和「不再询问」,缺省成未授权会让首次启动的引导逻辑永远走错分支。
  • 新增职责只改文档不改表。CONCERNS 与 OWNERSHIP 必须同时更新,否则审计会报缺项;这条约束要写进测试,而不是写在评审意见里。
  • 边界:本节不定义桥接协议。Host 只描述平台事实,消息版本、错误码、线程队列和超时取消都在 3.2;不要把两者混成一个接口。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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