面板小程序倒计时前后台切换时间偏差问题怎么解决

小程序开发相关产品技术讨论,包括面板、智能小程序、React Native、Ray跨端框架、Panel SDK、微信小程序、小程序开发工具(IDE)及其他开发技术相关等话题


Post Reply
17825290950
Posts: 4

我在做 Tuya/Ray 面板小程序时,遇到一个倒计时在前后台切换后的时间同步问题,想请教一下有没有更稳妥的处理方式。

问题现象:

  1. 设备有倒计时 DP,例如 countdown_left(单位是分钟)。
  2. 面板本地为了让 UI 每秒递减,自己维护了一个本地倒计时。
  3. 小程序切到后台一段时间后再回到前台,我会在 onAppShow 里刷新当前时间。
  4. 但实际测试时,仍然可能出现本地显示时间和设备真实剩余时间存在偏差的问题。

我现在的处理思路:

  • 不直接每秒减 countdown_left
  • 而是把设备上报的剩余分钟数换算成一个本地截止时间 deadlineAt
  • 页面显示时用 deadlineAt - nowTsremainingSeconds
  • 小程序回到前台时,在 onAppShow 里调用 setNowTs(Date.now())
  • 同时为了避免“本地下发新倒计时后,设备旧回包把本地 UI 拉回去”,加了 pendingLocalCountdown 逻辑

我想请教的问题:

  1. 这种“本地 deadlineAt + onAppShow 刷新”的方案是否合理?
  2. 小程序前后台切换后,倒计时偏差一般应该如何规避?
  3. 是否应该完全依赖设备重新上报 countdown_left,而不是只在前台恢复时刷新本地时间?
  4. 如果设备上报的是分钟级 DP,本地按秒级展示,大家一般如何处理同步精度问题?

Code: Select all

import { offAppShow, onAppShow } from '@ray-js/ray';
import { useEffect, useMemo, useState } from 'react';

const MINUTE_IN_SECONDS = 60;
const DEVICE_SYNC_TOLERANCE_SECONDS = 60;

export interface CountdownOption {
  key: string;
  value: number; // minutes
}

interface UseCountdownParams {
  isDeviceReady: boolean;
  countdownLeft?: number; // minutes
  countdownLeftReportedAt?: number; // ms timestamp
  suspendDeviceSync?: boolean;
  countdownSet?: string;
  onSetCountdown: (key: string) => void;
  options: CountdownOption[];
}

export function useCountdown({
  isDeviceReady,
  countdownLeft,
  countdownLeftReportedAt,
  suspendDeviceSync = false,
  countdownSet,
  onSetCountdown,
  options,
}: UseCountdownParams) {
  const [deadlineAt, setDeadlineAt] = useState<number | null>(null);
  const [nowTs, setNowTs] = useState<number>(() => Date.now());
  const [selectedIndex, setSelectedIndex] = useState<number>(0);
  const [pendingLocalCountdown, setPendingLocalCountdown] = useState<boolean>(false);

  const keyToIndex = useMemo(() => {
    const map = new Map<string, number>();
    options.forEach((opt, idx) => map.set(opt.key, idx));
    return map;
  }, [options]);

  const remainingSeconds = useMemo(() => {
    if (!isDeviceReady || !deadlineAt) {
      return 0;
    }

return Math.max(0, Math.ceil((deadlineAt - nowTs) / 1000));
  }, [deadlineAt, isDeviceReady, nowTs]);

  const isCountdownActive = isDeviceReady && deadlineAt !== null && remainingSeconds > 0;

  useEffect(() => {
    if (suspendDeviceSync) {
      console.log('[useCountdown] 等待最新 countdown_left 上报,暂时跳过设备同步', {
        countdownLeft,
        countdownLeftReportedAt,
        pendingLocalCountdown,
      });
      return;
    }

const minutes = typeof countdownLeft === 'number' ? countdownLeft : 0;
const now = Date.now();

if (!isDeviceReady) {
  console.log('[useCountdown] 设备未就绪,重置本地倒计时', {
    countdownLeft,
  });
  setDeadlineAt(null);
  setPendingLocalCountdown(false);
  return;
}

if (pendingLocalCountdown && deadlineAt) {
  const expectedMinutes = Math.max(
    0,
    Math.ceil(Math.max(0, deadlineAt - now) / 1000 / MINUTE_IN_SECONDS)
  );
  const staleThreshold = Math.max(0, expectedMinutes - 1);

  if (minutes < staleThreshold) {
    console.log('[useCountdown] 本地下发后收到旧的 countdown_left,上报已忽略', {
      deviceMinutes: minutes,
      expectedMinutes,
      staleThreshold,
      countdownLeftReportedAt,
    });
    return;
  }

  console.log('[useCountdown] 设备 countdown_left 已追上本地倒计时', {
    deviceMinutes: minutes,
    expectedMinutes,
    countdownLeftReportedAt,
  });
  setPendingLocalCountdown(false);
}

const deviceSeconds = Math.max(0, minutes * MINUTE_IN_SECONDS);
const reportBaseAt =
  typeof countdownLeftReportedAt === 'number' && countdownLeftReportedAt > 0
    ? Math.min(countdownLeftReportedAt, now)
    : now;

const nextDeadlineAt = reportBaseAt + deviceSeconds * 1000;
const nextRemainingSeconds = Math.max(0, Math.ceil((nextDeadlineAt - now) / 1000));

if (nextRemainingSeconds <= 0) {
  setNowTs(now);
  setDeadlineAt(prev => {
    if (prev !== null) {
      console.log('[useCountdown] 根据设备状态同步倒计时:清除本地倒计时', {
        previousDeadlineAt: prev,
        deviceSeconds,
        countdownLeftReportedAt,
        countdownLeft,
      });
    }
    return null;
  });
  return;
}

setNowTs(now);
setDeadlineAt(prev => {
  if (prev === null) {
    console.log('[useCountdown] 根据设备状态同步倒计时:初始化本地倒计时', {
      deviceSeconds: nextRemainingSeconds,
      reportedAt: reportBaseAt,
      countdownLeft,
    });
    return nextDeadlineAt;
  }

  const previousRemainingSeconds = Math.max(0, Math.ceil((prev - now) / 1000));
  const drift = nextRemainingSeconds - previousRemainingSeconds;

  const shouldResync =
    previousRemainingSeconds <= 0 ||
    nextRemainingSeconds > previousRemainingSeconds ||
    Math.abs(drift) >= DEVICE_SYNC_TOLERANCE_SECONDS;

  if (shouldResync) {
    console.log('[useCountdown] 根据设备状态同步倒计时:重新校准本地倒计时', {
      previousRemainingSeconds,
      deviceSeconds: nextRemainingSeconds,
      drift,
      reportedAt: reportBaseAt,
      countdownLeft,
    });
    return nextDeadlineAt;
  }

  console.log('[useCountdown] 根据设备状态同步倒计时:保留当前本地倒计时', {
    previousRemainingSeconds,
    deviceSeconds: nextRemainingSeconds,
    drift,
    reportedAt: reportBaseAt,
    countdownLeft,
  });
  return prev;
});
  }, [
    countdownLeft,
    countdownLeftReportedAt,
    deadlineAt,
    isDeviceReady,
    pendingLocalCountdown,
    suspendDeviceSync,
  ]);

  useEffect(() => {
    if (countdownSet && keyToIndex.has(countdownSet)) {
      setSelectedIndex(keyToIndex.get(countdownSet) as number);
      return;
    }
    setSelectedIndex(0);
  }, [countdownSet, keyToIndex]);

  const applySelectedCountdown = () => {
    if (!isDeviceReady) return;

const opt = options[selectedIndex];
const now = Date.now();

console.log('[useCountdown] 应用当前选中的倒计时档位', {
  selectedIndex,
  key: opt.key,
  value: opt.value,
});

onSetCountdown(opt.key);

setNowTs(now);
setDeadlineAt(opt.value > 0 ? now + opt.value * MINUTE_IN_SECONDS * 1000 : null);
setPendingLocalCountdown(true);
  };

  useEffect(() => {
    if (!isCountdownActive) {
      return undefined;
    }

console.log('[useCountdown] 启动本地倒计时计时器', {
  remainingSeconds: Math.max(0, Math.ceil(((deadlineAt as number) - Date.now()) / 1000)),
});

const timer = setInterval(() => {
  setNowTs(Date.now());
}, 1000);

return () => {
  console.log('[useCountdown] 停止本地倒计时计时器', {
    remainingSeconds: Math.max(0, Math.ceil(((deadlineAt as number) - Date.now()) / 1000)),
  });
  clearInterval(timer);
};
  }, [deadlineAt, isCountdownActive]);

  useEffect(() => {
    const handleAppShow = () => {
      if (!isDeviceReady || !deadlineAt) {
        console.log('[useCountdown] 小程序回到前台,当前无需刷新倒计时', {
          isDeviceReady,
          deadlineAt,
        });
        return;
      }

  setNowTs(Date.now());
  console.log('[useCountdown] 小程序回到前台,刷新本地倒计时', {
    remainingSeconds: Math.max(0, Math.ceil((deadlineAt - Date.now()) / 1000)),
  });
};

console.log('[useCountdown] 注册小程序前台生命周期监听', {
  isDeviceReady,
});
onAppShow(handleAppShow);

return () => {
  console.log('[useCountdown] 注销小程序前台生命周期监听', {
    isDeviceReady,
  });
  offAppShow(handleAppShow);
};
  }, [deadlineAt, isDeviceReady]);

  return {
    remainingSeconds,
    selectedIndex,
    setSelectedIndex,
    applySelectedCountdown,
  };
}
Post Reply