4、多屏状态同步:屏幕注册与心跳机制、状态广播与增量更新、冲突处理策略

多屏联动最核心的问题是什么?说白了,就是「状态同步」。

你想想看,一块屏幕改了内容,其他屏幕怎么知道?知道了之后怎么更新?更新的时候会不会打架?这三个问题,就是我们今天要啃的硬骨头。

我在做第一个多屏项目时,就踩过一个大坑——屏幕A改了颜色,屏幕B没收到,结果两边显示不一样,用户当场就炸了。从那以后,我彻底把状态同步的每个环节都摸透了。

4.1 屏幕注册与心跳机制

屏幕要联动,第一步是什么?得先「报到」。

每块屏幕启动时,都要向中心服务器注册自己。注册信息通常包括:

  • 屏幕ID:唯一标识,比如 screen-001
  • 屏幕类型:主屏、副屏、互动屏等
  • 分辨率与尺寸:用于布局适配
  • 当前状态:初始状态,比如 idle
  • IP地址与端口:用于点对点通信

注册之后呢?屏幕不能一直在线,它可能断网、死机、被人拔了电源。所以我们需要一个「心跳机制」。

心跳机制的核心逻辑:每块屏幕每隔 N 秒(通常是 3-5 秒)向服务器发送一个心跳包,告诉服务器「我还活着」。服务器如果连续 M 次没收到心跳,就判定该屏幕离线。

我个人习惯把心跳包设计得尽量轻量,只包含屏幕ID和时间戳。别塞太多数据,否则网络压力大。

// 心跳包示例(JSON格式)
{
  "type": "heartbeat",
  "screenId": "screen-001",
  "timestamp": 1696000000000,
  "status": "active"
}

小技巧:心跳间隔不要设得太短。3秒一次就够了,太频繁反而浪费带宽。我曾经见过有人设成500ms,结果服务器直接被冲垮。

服务器端维护一个「屏幕注册表」,记录所有在线屏幕的信息。每次收到心跳,就更新对应屏幕的最后活跃时间。

// 屏幕注册表示例(内存中)
const screenRegistry = new Map();

// 注册屏幕
function registerScreen(screenInfo) {
  screenRegistry.set(screenInfo.screenId, {
    ...screenInfo,
    lastHeartbeat: Date.now(),
    isOnline: true
  });
}

// 处理心跳
function handleHeartbeat(screenId) {
  const screen = screenRegistry.get(screenId);
  if (screen) {
    screen.lastHeartbeat = Date.now();
    screen.isOnline = true;
  }
}

// 检测离线屏幕(定时任务)
function checkOfflineScreens(timeout = 15000) {
  const now = Date.now();
  for (const [id, screen] of screenRegistry) {
    if (now - screen.lastHeartbeat > timeout) {
      screen.isOnline = false;
      console.log(`屏幕 ${id} 已离线`);
    }
  }
}

4.2 状态广播与增量更新

屏幕都注册好了,心跳也跑起来了。接下来就是重头戏——状态怎么传?

最笨的办法:每次状态变了,把整个状态全量发给所有屏幕。但这样做,数据量大、网络开销高、更新慢。尤其是当状态对象很大时,简直是灾难。

增量更新才是正道。说白了,只传「变化的部分」。

举个例子:一块屏幕显示的是「温度:25°C,湿度:60%,风速:3级」。如果温度变成了26°C,增量更新只发 { temperature: 26 },而不是整个对象。

// 全量更新(不推荐)
{
  "type": "state_update",
  "screenId": "screen-001",
  "state": {
    "temperature": 26,
    "humidity": 60,
    "windSpeed": 3
  }
}

// 增量更新(推荐)
{
  "type": "state_delta",
  "screenId": "screen-001",
  "delta": {
    "temperature": 26
  },
  "version": 42  // 版本号,用于冲突检测
}

增量更新的好处

  • 数据量小,传输快
  • 减少带宽占用
  • 接收端合并成本低
  • 支持更频繁的更新

接收端收到增量后,需要做「合并」操作。把增量应用到当前状态上。

// 增量合并函数
function applyDelta(currentState, delta) {
  return { ...currentState, ...delta };
}

// 使用示例
let state = { temperature: 25, humidity: 60, windSpeed: 3 };
const delta = { temperature: 26 };
state = applyDelta(state, delta);
// 结果: { temperature: 26, humidity: 60, windSpeed: 3 }

嗯,这里要注意:增量更新虽然好,但有个前提——你得保证每个屏幕收到的增量顺序是对的。如果屏幕A先收到 { temperature: 26 },后收到 { temperature: 25 },那温度就错了。

所以,我建议给每个增量加上「版本号」或「时间戳」,接收端按顺序应用。或者干脆用「最终一致性」模型,允许短暂的不一致,但最终会收敛。

4.3 冲突处理策略

冲突,是多屏同步里最头疼的问题。

什么叫冲突?两块屏幕同时修改了同一个状态。比如屏幕A把背景色改成红色,屏幕B同时改成蓝色。到底听谁的?

我在项目中遇到过好几次这种问题。最严重的一次,两块互动屏同时修改同一个数据,结果数据来回跳,像在打架。用户都看懵了。

常见的冲突处理策略有这几种:

策略 原理 适用场景 缺点
最后写入者胜出 谁最后修改,就用谁的值 简单场景,如背景色 可能丢失较早的修改
优先级策略 给每块屏幕设定优先级,高优先级覆盖低优先级 主屏控制副屏 需要提前规划优先级
版本向量 每个屏幕维护一个版本号,冲突时合并 协作编辑 实现复杂
操作转换 将冲突的操作转换为可合并的形式 实时协作(如Google Docs) 算法复杂,性能开销大
用户裁决 冲突时弹出提示,让用户选择 关键数据修改 打断用户操作

避坑指南:我曾经在一个项目中用了「最后写入者胜出」,结果两块屏幕的时钟不同步,导致修改顺序错乱。后来我改用「服务器时间戳」来判定先后,才解决问题。

我个人比较推荐的做法是:

  1. 区分状态类型:哪些状态允许冲突?哪些不允许?比如背景色可以随便改,但播放进度必须严格一致。
  2. 设定主从关系:大多数场景下,主屏拥有最高权限,副屏只能读不能写。这样冲突自然就没了。
  3. 使用乐观锁:每个状态带一个版本号,更新时检查版本号是否匹配。不匹配就拒绝更新,让客户端重新拉取最新状态。
// 乐观锁示例
function updateState(screenId, delta, expectedVersion) {
  const currentState = getState(screenId);
  
  // 检查版本号
  if (currentState.version !== expectedVersion) {
    throw new Error('冲突:状态已被其他屏幕修改');
  }
  
  // 应用更新
  const newState = applyDelta(currentState, delta);
  newState.version = currentState.version + 1;
  
  saveState(screenId, newState);
  return newState;
}

小技巧:如果冲突真的发生了,别慌。可以设计一个「冲突日志」,记录每次冲突的详细信息。这样事后排查问题时,能快速定位原因。

最后说一句:冲突处理没有银弹。你得根据业务场景来选。如果是展示型多屏(比如广告牌),用「最后写入者胜出」就够了。如果是协作型多屏(比如会议白板),那就要上「操作转换」或「版本向量」了。

好了,状态同步这块就讲到这里。下一节我们聊聊「跨屏交互的实时通信协议选择」,到时候我会分享一些WebSocket和WebRTC的实战经验。