面板小程序倒计时前后台切换时间偏差问题怎么解决
Posted: 2026年 May 8日 18:22
我在做 Tuya/Ray 面板小程序时,遇到一个倒计时在前后台切换后的时间同步问题,想请教一下有没有更稳妥的处理方式。
问题现象:
- 设备有倒计时 DP,例如
countdown_left(单位是分钟)。 - 面板本地为了让 UI 每秒递减,自己维护了一个本地倒计时。
- 小程序切到后台一段时间后再回到前台,我会在
onAppShow里刷新当前时间。 - 但实际测试时,仍然可能出现本地显示时间和设备真实剩余时间存在偏差的问题。
我现在的处理思路:
- 不直接每秒减
countdown_left - 而是把设备上报的剩余分钟数换算成一个本地截止时间
deadlineAt - 页面显示时用
deadlineAt - nowTs算remainingSeconds - 小程序回到前台时,在
onAppShow里调用setNowTs(Date.now()) - 同时为了避免“本地下发新倒计时后,设备旧回包把本地 UI 拉回去”,加了
pendingLocalCountdown逻辑
我想请教的问题:
- 这种“本地 deadlineAt + onAppShow 刷新”的方案是否合理?
- 小程序前后台切换后,倒计时偏差一般应该如何规避?
- 是否应该完全依赖设备重新上报
countdown_left,而不是只在前台恢复时刷新本地时间? - 如果设备上报的是分钟级 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,
};
}