第09章 - 平台适配层与音频实现

平台层(packages/platform)是游戏代码与真实运行环境之间唯一的接缝。存档、音效、朗读、前后台切换、本地事件记录全部从这里出去;十个玩法与八个模板里没有一处 localStorage、IndexedDB、fetch。这一层只有四个源文件——web.ts、runtime.ts、index.ts 与外壳的 shell/platform.ts——却决定了三件事能不能成立:进度不会静默丢失、声音在没有音频文件时也能响、同一份站点产物能在浏览器、Tauri 桌面壳与 Capacitor 安卓壳里都工作。

9.1 PlatformServices:游戏唯一被允许触碰环境的入口

契约在 packages/game-kit/src/contract.ts,平台实现只负责把它填满:

// packages/game-kit/src/contract.ts
export interface PlatformServices {
  storage: ProgressStore;
  audio: AudioBus;
  lifecycle: LifecycleBus;
  telemetry: Telemetry;
  now(): number;
}

五个成员各有明确边界:

成员 能力 游戏侧用法
storage load() / save(data) 只在读到 GameContext 时拿到,实际存档由外壳的 ProgressManager 调用;玩法只发 task:complete,不自己写存档
audio unlock / play / tone / speak / stopSpeech / setMuted / isMuted 场景里放音效、念旁白;按钮文案与语音都走同一个静音状态
lifecycle onPause / onResume,各自返回解绑函数 宿主在切后台时暂停场景并停朗读,回前台再恢复
telemetry track(type, payload) / flush() 只留在本机,默认不上报
now() 当前毫秒时间戳 让纯逻辑层可以注入假时钟;测试不依赖真实时间

ProgressStore 本身只有两个方法,读与写都是异步的:load() 返回一份 SaveData,save(data) 负责落盘。平台层刻意不提供“按 key 读写”的通用 KV 接口,因为那会诱使界面层自己拼存档;只给整份对象,迁移与体检就只能集中在 @bamboo/progress。now() 同样不是装饰:所有日期判断都以它为准,单测注入一个固定时间戳,就能稳定复现“跨天衰减”“连续三天得贴纸”这类与真实时钟强相关的行为,而不必用假定时器。

游戏侧拿到的是构造好的上下文,而不是全局对象:

// apps/site/src/shell/createGameContext.ts(节选)
return {
  container,
  manifest,
  task,
  services: platform,
  locale,
  reducedMotion: window.matchMedia('(prefers-reduced-motion: reduce)').matches,
  debug: readDebugOptions(),
  completedBefore,
  // …
};

这条约束还有一层“依赖方向”的意义:game-kit 只声明接口,platform 只实现接口,games/* 只消费接口。三者谁都不 import 谁的具体实现,所以替换实现不会产生循环依赖,也不会把 IndexedDB 或语音 API 的兼容代码拖进 Phaser 的 chunk。打包结果也能验证这一点:vendor-phaser 里没有平台层代码,平台层里也没有一行 import Phaser。

这条约束的价值在换壳时才显现:要加云存档,只需换掉 storage 的实现;要加真配音,只改 audio;玩法与模板一行不用动。反过来,如果某天在 games/daily 里看到 window.localStorage,那就是一个架构 bug,而不是“小技巧”。

9.2 三种运行环境:探测而不依赖 SDK

同一份 apps/site/dist 会被三种壳加载:浏览器(含 PWA)、Tauri 2 桌面壳、Capacitor 8 安卓壳。平台层不 import 任何壳的 SDK,只在运行时看全局标记:

// packages/platform/src/runtime.ts
export type RuntimeKind = 'web' | 'tauri' | 'capacitor';

export function detectRuntime(): RuntimeKind {
  const w = window as RuntimeWindow;
  if (w.__TAURI_INTERNALS__ !== undefined || w.__TAURI__ !== undefined) return 'tauri';
  if (w.Capacitor?.isNativePlatform?.() === true) return 'capacitor';
  return 'web';
}

export function isNativeRuntime(): boolean {
  return detectRuntime() !== 'web';
}

三种环境真正的差异只有两处:有没有原生 TTS、有没有系统返回键。渲染、路由、存档、语音降级策略全部共用同一套代码,所以“同一份 dist 喂给两个壳”不是口号,而是默认路径。Web 包不会因为多了一个桌面壳就变大,桌面壳也不会因为多了一个安卓插件而在启动时报“插件未定义”。

不 import SDK 带来两个直接收益:Web 包体积零增长;浏览器里不会因为插件未注册而抛错。需要原生能力时,从运行时桥接对象上按名字取,取不到就返回 null:

// packages/platform/src/runtime.ts
export interface NativeTextToSpeech {
  speak(options: {
    text: string; lang?: string; rate?: number; pitch?: number;
    volume?: number; category?: string;
  }): Promise<void>;
  stop(): Promise<void>;
}

export function nativeTextToSpeech(): NativeTextToSpeech | null {
  const plugin = capacitor()?.Plugins?.['TextToSpeech'];
  return (plugin as NativeTextToSpeech | undefined) ?? null;
}

export interface NativeApp {
  addListener(event: 'backButton', callback: () => void): void;
  exitApp(): Promise<void>;
}

export function nativeApp(): NativeApp | null {
  const plugin = capacitor()?.Plugins?.['App'];
  return (plugin as NativeApp | undefined) ?? null;
}

nativeTextToSpeech() 不是可选的锦上添花:Android WebView 没有实现 Web Speech API。如果只依赖 speechSynthesis,安卓版会全程没有旁白,而且不会报错。第 9.6 节会看到 speak() 如何优先走原生桥。

9.3 IndexedDB 存档:三秒超时与内存兜底

存档只有一份,位置固定:

// packages/platform/src/web.ts
const DB_NAME = 'bamboobuddy';
const STORE_NAME = 'kv';
const SAVE_KEY = 'save';

打开数据库的代码比常规写法多了一个计时器,因为无痕模式、存储被禁、旧标签页占着版本升级时,indexedDB.open 的回调可能永远不来:

function openDb(): Promise<IDBDatabase | null> {
  return new Promise((resolve) => {
    if (typeof indexedDB === 'undefined') { resolve(null); return; }
    const timer = setTimeout(() => resolve(null), 3000);
    const request = indexedDB.open(DB_NAME, 1);
    const settle = (db: IDBDatabase | null): void => { clearTimeout(timer); resolve(db); };
    request.onupgradeneeded = () => {
      const db = request.result;
      if (!db.objectStoreNames.contains(STORE_NAME)) db.createObjectStore(STORE_NAME);
    };
    request.onsuccess = () => settle(request.result);
    request.onerror = () => settle(null);
    request.onblocked = () => settle(null);
  });
}

为什么不用 localStorage?一是容量只有几 MB,日记录累积后容易顶到上限;二是它只有同步 API,写入时会阻塞主线程,正好卡在 Phaser 渲染的关键帧上;三是隐私模式下的行为并不一致。IndexedDB 的事务模型更适合“整份存档一次写入”,失败也能通过 onerror / onabort 明确感知。

打开失败不抛错,而是降级为内存存档:孩子这次仍然能玩,只是关掉页面后进度消失。这个选择是刻意的——启动流程绝不能挂在一个可能永不回调的 API 上。如果 open 被其他标签页阻塞,也只按失败处理,不会去弹“请关闭其他标签页”之类的技术提示;对 4–8 岁的使用者来说,那不是他能处理的错误。

数据库只有 1 个版本、1 个对象仓库、1 个键。没有按天分表,也没有把每局记录单独存一条:整份存档是一个普通对象,大小在几十 KB 量级,一次事务写完,天然避免“写了一半”的中间态。日记录只保留最近 30 天,这一步在 migrate() 与 pruneDays() 里完成(第 10 章会展开),平台层只负责把最终对象放进 save 这个键。

读取时还有两层兜底:load() 把数据库里的原始对象交给 @bamboo/progress 的 migrate() 体检,迁移或解析抛错就返回空档;写入时先更新内存,再尝试写库,写失败会把异常抛回调用方。后者是第 9.4 节的主题。

另外,createWebPlatform() 在可用时会申请一次持久存储:

if (typeof navigator !== 'undefined' && navigator.storage?.persist) {
  void navigator.storage.persist();
}

移动端浏览器在存储压力下可能清理 IndexedDB,申请持久化能降低概率,但不能保证;所以真正的保障仍然是“写失败要说出来”。

9.4 写失败必须被看见

“存档静默丢失”是这类产品最坏的结果:孩子今天做了五件小事,第二天打开什么都没有。平台层不掩盖错误——save() 在事务失败时 reject:

await new Promise<void>((resolve, reject) => {
  try {
    const tx = db.transaction(STORE_NAME, 'readwrite');
    tx.objectStore(STORE_NAME).put(data, SAVE_KEY);
    tx.oncomplete = () => resolve();
    tx.onerror = () => reject(tx.error ?? new Error('存档写入失败'));
    tx.onabort = () => reject(tx.error ?? new Error('存档写入被中止'));
  } catch (cause) {
    reject(cause instanceof Error ? cause : new Error(String(cause)));
  }
});

外壳的 Pinia store 把所有写操作包进同一个出口:

// apps/site/src/stores/progress.ts(节选)
async function run<T>(action: () => Promise<T>): Promise<T | null> {
  try {
    const result = await action();
    unsaved.value = false;
    return result;
  } catch (cause) {
    unsaved.value = true;
    console.warn('[bamboo] 存档写入失败:本次进度可能不会保留', cause);
    return null;
  } finally {
    syncSave();
  }
}

失败后界面给出两句人话之一:初始化失败是“存档读取失败,本次从头开始。”;写入失败是“这次可能没存上,请检查浏览器的存储空间。”。玩法的奖励 toast 里也会追加一句“这次可能没存上,请让家长看一看”(complete.saveWarn)。注意这里的顺序:奖励照常发放、动画照常播放,只是额外提示。孩子不该因为存储配额满而失去这一局的反馈。

真实会触发写入失败的场景至少有四类:浏览器隐私模式、存储配额耗尽、用户手动清除了站点数据、以及某些 WebView 在后台被系统冻结后事务中止。它们的共同点是“界面看起来一切正常”,所以必须由产品主动说出来。家长页也提供了对应的出口:长按 3 秒清除本机存档,清空后会立刻落盘(ProgressManager.clear()),而不是只把内存对象换掉。

9.5 零音频文件的音效总线

整个仓库没有一张位图、没有一个音频文件。音效由 WebAudio 振荡器实时合成,Phaser 自己的音频系统直接关掉:

// packages/phaser-kit/src/createGame.ts
audio: { noAudio: true },

AudioBus 暴露 12 个音效键(SfxKey):tap、correct、retry、pop、bubble、water、chew、jump、sleep、unlock、complete、sparkle。每个键对应一个短促的合成图案:

音效 听感 实现要点
tap 轻点 560 Hz 三角波,70 ms
correct 做对了 660 Hz 后接 880 Hz,两音上行
retry 再试一次 392 Hz → 330 Hz,下行但不刺耳
pop / bubble 泡泡 760 Hz 单音 / 620 Hz 与 760 Hz 叠奏
water 冲水 520 Hz 锯齿波 + 700 Hz 正弦波
chew 咀嚼 220 Hz 与 190 Hz 方波
jump 跳跃 520 Hz → 780 Hz 三角波,上行
sleep 入睡 440 Hz 与 330 Hz 两个长音
unlock 解锁 C5–E5–G5 三音
complete 完成 C5–E5–G5–C6 四音
sparkle 星星 1175 Hz / 1568 Hz 高频轻响

合成核心是一个带包络的 tone():起始增益压到 0.0001 再指数上升到目标音量,避免方波与锯齿波产生“咔哒”爆音;结束时再指数衰减回 0.0001。AudioContext 是延迟创建的:第一次真正需要出声时才 new,避免页面一加载就占用音频设备;如果浏览器没有 AudioContext(或 webkitAudioContext),context() 返回 null,所有音效静默跳过,调用方不需要写 try/catch。

振荡器类型也不是随手选的。sine 用于气泡、星星、完成音,最柔和;triangle 用于轻点、跳跃与“再试一次”,比正弦多一点存在感又不刺耳;square 只用于咀嚼,短促且音量压到 0.08;sawtooth 只用于水流,配合低增益模拟“沙沙”的质感。低频轰鸣被明确排除,因为它在小扬声器上容易失真,也容易让家长觉得吵。

function tone(freq: number, duration: number, delay = 0,
  type: OscillatorType = 'sine', gainLevel = 0.18): void {
  const ctx = context();
  if (!ctx || muted) return;
  const start = ctx.currentTime + delay;
  const osc = ctx.createOscillator();
  const gain = ctx.createGain();
  osc.type = type;
  osc.frequency.setValueAtTime(freq, start);
  gain.gain.setValueAtTime(0.0001, start);
  gain.gain.exponentialRampToValueAtTime(gainLevel, start + 0.02);
  gain.gain.exponentialRampToValueAtTime(0.0001, start + duration);
  osc.connect(gain).connect(ctx.destination);
  osc.start(start);
  osc.stop(start + duration + 0.05);
}

所有图案都是“柔和的正向反馈”:没有低频轰鸣,也没有失败音效。唯一的“负向”音效 retry 也只是两个温和的下行音,不模拟错误蜂鸣。

iOS 与微信要求音频必须由用户手势解锁,因此 unlock() 会唤醒被挂起的 AudioContext,并顺手 speechSynthesis.resume()。外壳在第一次 pointerdown 时调用一次:

// apps/site/src/App.vue
function unlockAudio(): void { void platform.audio.unlock(); }
onMounted(() => {
  window.addEventListener('pointerdown', unlockAudio, { once: true });
});

静音状态由 setMuted() 统一管理:置位后 tone() 直接返回,同时停掉正在进行的朗读。这样“关声音”不会只关掉下一次音效,而是立刻让耳边安静下来。

9.6 TTS:原生桥、浏览器语音与静默降级

朗读接口返回一个必须 resolve 的句柄:

export type SpeechOutcome = 'ended' | 'unavailable';
export interface SpeechHandle {
  readonly done: Promise<SpeechOutcome>;
}

“两种情况都必须 resolve”是界面不卡死的前提:如果有设备既不触发 onend 也不触发 onerror,按钮会永远停在“正在听”。实现里用 settled 标记保证只结算一次。

speak() 分三级降级:

  1. 原生 TTS(安卓壳):nativeTextToSpeech() 存在时优先调用,语言用 speechLang(locale),失败 catch 成 unavailable;
  2. 浏览器 Web Speech:speechSynthesis 与 SpeechSynthesisUtterance 都存在才走;
  3. 静默:两者都没有,立即返回 unavailable。
// packages/platform/src/web.ts(节选)
speak(text: string, locale: Locale): SpeechHandle {
  if (muted) return { done: Promise.resolve('unavailable') };

  const native = nativeTextToSpeech();
  if (native) {
    const done = native.speak({
      text,
      lang: speechLang(locale),
      rate: locale === 'en' ? 0.95 : 0.92,
      pitch: 1.08,
      volume: 0.8,
    }).then((): SpeechOutcome => 'ended')
      .catch((): SpeechOutcome => 'unavailable');
    return { done };
  }

  if (typeof speechSynthesis === 'undefined' ||
      typeof SpeechSynthesisUtterance === 'undefined') {
    return { done: Promise.resolve('unavailable') };
  }
  speechSynthesis.cancel();
  const utterance = new SpeechSynthesisUtterance(text);
  utterance.lang = speechLang(locale);
  utterance.rate = locale === 'en' ? 0.95 : 0.92;
  utterance.pitch = 1.08;
  utterance.volume = 0.8;
  const voices = speechSynthesis.getVoices();
  const prefix = locale === 'en' ? 'en' : 'zh';
  const match = voices.find((voice) => voice.lang.toLowerCase().startsWith(prefix));
  if (match) utterance.voice = match;
  // …onend / onerror 结算 done
}

几个细节值得单独说:

  • 语言标签必须映射:locale 是 'zh-CN' | 'en',而 'en' 不是合法的 BCP-47 语音标签。speechLang() 把它映射成 'en-US',zh-CN 原样保留。
  • 语速与音量偏保守:0.92–0.95 倍速、0.8 音量、1.08 音高。孩子常在耳边使用设备,默认音量容易“炸响”;慢一点也更容易听清。
  • 嗓音按前缀挑:浏览器返回的语音列表里,zh-CN 与 en-US 往往各有多个;用 startsWith('zh') / startsWith('en') 找第一个匹配项,找不到就用系统默认。
  • 静音时立刻停:setMuted(true) 会调用 stopSpeech();原生桥走 native.stop(),浏览器走 speechSynthesis.cancel()。
  • 先取消再播:浏览器分支在 speak() 前先 cancel(),避免连续点日记页的朗读按钮时两段声音叠在一起。

日记页是这条链路最典型的消费者。它把今天所有日记行用空格拼成一段,再调用 platform.audio.speak();按钮在 done 结算前显示“听一听”,结算后恢复“听竹竹说”。如果设备没有语音包,done 会以 unavailable 结束,按钮照常恢复,不会永远停在“正在听”。静音时函数直接返回 { done: Promise.resolve('unavailable') },既不朗读,也不改变界面状态。

这里还有一条儿童向的取舍:朗读不阻塞操作。与“听完故事才能玩”的叙事游戏不同,日常模仿需要即时反馈,孩子可以一边听引导一边动手;语音只是并行的提示通道。

朗读文本的长度也需要控制。任务详情里的 intro / done / retry 都是一两句短句,日记行也不长;没有人会把整段关卡说明交给 TTS。短句的好处不只是听得懂:TTS 在部分安卓设备上排队延迟明显,句子越短,孩子点完到听见声音的间隔越短,反馈才像“立刻发生”。

9.7 生命周期与二十分钟休息提醒

前后台切换用 visibilitychange 实现,接口是两个返回解绑函数的订阅:

export function createVisibilityLifecycle(): LifecycleBus {
  return {
    onPause(cb: () => void): () => void {
      const handler = (): void => {
        if (document.visibilityState === 'hidden') cb();
      };
      document.addEventListener('visibilitychange', handler);
      return () => document.removeEventListener('visibilitychange', handler);
    },
    onResume(cb: () => void): () => void {
      const handler = (): void => {
        if (document.visibilityState === 'visible') cb();
      };
      document.addEventListener('visibilitychange', handler);
      return () => document.removeEventListener('visibilitychange', handler);
    },
  };
}

外壳有两处消费它。App.vue 在回到前台时补一次跨天刷新:

offResume = platform.lifecycle.onResume(() => {
  void progress.refresh();
});

应用可能在平板上挂一整夜,如果不补这一次,第二天打开会看到昨天的状态与昨天的日记录。PlayView.vue 则在切后台时暂停场景并停朗读,回前台再恢复:

offPause = platform.lifecycle.onPause(() => {
  mod.value?.pause();
  platform.audio.stopSpeech();
});
offResume = platform.lifecycle.onResume(() => {
  mod.value?.resume();
});

休息提醒不放在平台层,而是由“玩耍时长”驱动。玩法页每 30 秒记一次:

playTimer = setInterval(() => { void progress.addSeconds(30); }, 30_000);

store 里的阈值与判定:

export const REST_SECONDS = 20 * 60;

const restDue = computed(
  () => todaySeconds.value >= REST_SECONDS && !todayRecord.value.restDismissed,
);

小屋页据此显示一次提醒卡片,点“知道啦”后写入 restDismissed,当天不再出现。这里有三个刻意的限制:只统计玩法页的时长(在小屋点按钮不算)、一天只提醒一次、提醒文案是“和竹竹一起看看远处的绿色”,不是警告。它服务于家长,但不打断孩子。

之所以是 20 分钟而不是 15 或 30 分钟:单局 30–90 秒,20 分钟大约等于连续玩二十局上下,已经足以让家长意识到“该歇一会儿了”;再短会频繁打断,再长就失去意义。提醒也不做成全屏弹窗,而是小屋页里一张可点掉的卡片——孩子回到小屋才看到,不会在游戏中途被强制中断。

9.8 本地 telemetry 与调试桥

Telemetry 的实现只有二十来行,且 flush() 是空实现:

export function createLocalTelemetry(): Telemetry {
  const buffer: { type: string; at: number }[] = [];
  return {
    track(type): void {
      buffer.push({ type, at: Date.now() });
      if (buffer.length > 500) buffer.shift();
    },
    async flush(): Promise<void> {
      // 刻意不发送:儿童产品默认不上报任何信息
    },
  };
}

缓冲上限 500 条,只记事件名与时间,不记参数内容;满了丢最旧的。它的用途是本地排查与将来的家长报告,不是分析用户。flush() 保留在接口里,是为了给未来留一个明确的“如果要上报,只能从这里出去”的出口;当前实现刻意留空,代码评审时这一行空函数本身就是一条隐私声明。

调试桥与 telemetry 是两件事:telemetry 记录的是“发生过什么类型的事件”,调试桥记录的是“具体哪一个场景、哪一个任务、哪一种退出原因”,并且额外暴露最新存档快照。后者只给开发与自动化看,不参与任何产品逻辑;它的字段形状被当作契约维护,加字段之前要先改端到端测试。

同一条事件还会进调试桥 window.__bamboo:

// apps/site/src/shell/createGameContext.ts
emit(type, payload) {
  platform.telemetry.track(type, payload);
  recordDebugEvent(type, detailOf(type, payload));
  hooks.onEvent(type, payload);
},

调试桥形状固定为 { events, save, lastError? },events 里是 'session:start'、'scene:enter:bathroom'、'task:complete:brush' 这样的字符串。它生产环境也开着,因为只有事件名与存档快照,不含个人信息;端到端测试靠它判断“这一局真的完成了”,而不是靠截图。

9.9 装配与测试替身

真正的装配只有一行:

// apps/site/src/shell/platform.ts
import { createWebPlatform } from '@bamboo/platform';

export const platform = createWebPlatform();

但“可替换”不是为了好看,而是为了测试。ProgressManager 的构造函数接收 ProgressStore 与时钟:

new ProgressManager(store, () => platform.now());

单测里换成内存实现,就能精确控制“读档抛错”“写盘失败”“跨了两天”等场景:

// packages/progress/src/logic.test.ts(节选)
function memoryStore(initial: SaveData | null = null): ProgressStore & { writes: number } {
  const store = {
    current: initial,
    writes: 0,
    async load(): Promise<SaveData> {
      if (!store.current) throw new Error('empty');
      return store.current;
    },
    async save(data: SaveData): Promise<void> {
      store.current = data;
      store.writes += 1;
    },
  };
  return store;
}

原生返回键的测试替身更能说明问题。installBackButtonHandler(route, bridge, goHome) 把原生桥与跳转都做成参数,浏览器里传 null 即可:

// apps/site/src/shell/backButton.ts
export function installBackButtonHandler(
  route: Ref<Route>,
  bridge: BackBridge | null = nativeApp(),
  goHome: NavigateTo = navigate,
): void {
  if (bridge === null) return;
  bridge.addListener('backButton', () => {
    if (route.value.name !== 'home') { goHome('/'); return; }
    void bridge.exitApp();
  });
}

backButton.test.ts 用假桥覆盖了五种情形:游戏/任务/装扮/日记/家长页按返回都只回小屋、已经在小屋才退出、监听只注册一次、路由变化后仍按最新路由判断、浏览器里没有原生桥时不报错。这段逻辑以前在真机上才暴露问题——“孩子在游戏里误按返回键,应用直接关掉”——现在被五个用例钉住。

测试替身还解决了一个更隐蔽的问题:router.ts 在模块加载时就读取 window.location.hash,在 Node 环境里直接 import 会崩。backButton.test.ts 先 vi.stubGlobal('window', …),再用动态 import() 加载被测模块,顺序不能反。这类“测试基础设施”的细节不会出现在产品截图里,却决定了原生路径有没有回归保护。

9.10 小结

平台层把四件容易散落各处的脏活收进一个包:

  • 存档:IndexedDB 三秒超时、写失败抛出、内存兜底,配合外壳显示“这次可能没存上”;
  • 声音:12 个 WebAudio 合成音效,零音频文件,iOS 手势解锁;
  • 朗读:原生 TTS → 浏览器语音 → 静默三级降级,语言标签映射,静音立即停止;
  • 生命周期与边界:前后台暂停/恢复、跨天刷新、20 分钟休息提醒、本地 telemetry 与调试桥。

这一层没有一条业务规则:它不知道什么是刷牙、什么是成长值,也不知道“连续三天”意味着什么。它只回答“东西存在哪里、声音怎么出、什么时候该停”。正因为如此,换存储、换语音、换壳都不会波及玩法;而玩法里出现环境 API,也就成了一眼能看出的越界。

从验收角度看,平台层的测试不追求覆盖率数字,而是盯着“最坏情况”:数据库打不开、写入被拒绝、没有语音包、浏览器不支持 WebAudio、原生桥不存在。这五种情况都不会让应用崩溃,只会让某一种能力降级,并在界面上用一句人话交代清楚。这是儿童产品与普通 Demo 的分界线。

下一章进入数据与规则的核心:这些服务被谁调用、一次完成如何变成竹笋、成长值与连续天数,以及“今天推荐什么”为什么每次打开都一样。


← 上一章 目录 下一章 →