如何实现帧图片的定格动画播放

111 阅读3分钟

背景

    项目中需要对采集的数据做大屏展示,其中一部分数据是图片形式。每个点位一张图片,一系列点位按照顺序构成视频流。需要对图片依次播放实现定格动画。

原理

    假设,定格动画播放速度是 5 fps,在播放时需要提前缓存 1s 的图片数据。据此,我们需要 6 个格子来存储预缓存的数据。同时,我们需要一个“指针”(为方便文书,后直接称为指针,指针所指的格子称指针格)来记录当前播放到了第几个格子。基础准备工作完成,下面开始介绍原理。
    首先初始化,指针指向 0 号格子,根据接口获取到下一秒的播放数据,并依次缓存到格子中。如下图第一列所示。
    开始播放,指针指向 1 号格子,0 号格子失效,如下图第二列所示。同时根据当前播放数据查询后面 5 条数据,即第 2 ~ 6 条的数据,从接口中取出最新一条数据,填入第 0 号格子,如下图第三列所示。
    随着播放进行,指针会走到第 5 号格子,继续播放,需要将指针重置到第 0 位。以此循环。
    原理图如下。 预缓存原理图.png

方案设计

    实际考虑到除了正向播放外,还要支持倒向播放。为了播放的顺畅,于是在指针格的左侧同样需要 5 个缓存帧。除此之外,暂停/播放两个功能也需要支持。
    前端功能设计完成,需要考虑接口内容该如何设计了,具体的接口如何设计这里不做讨论,这里主要讨论站在前端视角有哪些可行的方案。好的,首先我们的接口内容需要包含至少以下两个信息。

  1. 图片的序列号。
  2. 图片的访问地址。

    除此之外,还期望接口返回给前端的是一个list,接口请求时将当前查询的 图片ID/序列号 给后端。后端返回给前端的数据是 该帧的 5 条数据 + 该帧的数据 + 该帧的 5 条数据。如果没有携带 图片ID/序列号 给后端,后端则返回 起始位置 以及后面 5 条数据
    基础准备工作有了,具体如何实施呢?其实也很简单。当我们开始播放时,指针就会开始移动,当他移动完后,获取到当前帧图片的数据,以此帧的数据查询接口,最后返回数据的最后(或第 0 条)一条一定是与缓存数据的最新一帧。再将这一帧数据填补到失效的那一格即可。

方案实现

    下面以 react 为例,写一份实现方案。

import { useCallback, useEffect, useState } from 'react';
import { isEmpty } from 'lodash';

import useEffectInterval from './useEffectInterval';

export type PlayDirective = 'none' | 'forward' | 'backward';

export interface UseFrameCacheProps<F extends object = any, R extends Partial<F> = any> {
  cacheLen: number;
  frameInterval: number;
  fetchList: (search?: R) => F[] | Promise<F[]>;
  playDirective: PlayDirective;
}

export const useFrameCache = <F extends object = any, R extends Partial<F> = any>({
  cacheLen = 5,
  frameInterval = 200,
  fetchList,
  playDirective = 'forward',
}: UseFrameCacheProps) => {
  const totalLen = cacheLen * 2 + 1;
  const [played, setPlayed] = useState(false);
  const [playIndex, setPlayIndex] = useState(0);
  const [cacheList, setCacheList] = useState<F[]>([]);

  const init = useCallback(async () => {
    const data = await fetchList();
    setPlayIndex(0);
    setCacheList(data ?? []);
  }, [fetchList]);

  const handleStop =() => setPlayed(false);

  const handleResume =() => setPlayed(true);

  const handlePlay = useCallback(async () => {
    const nextIndex = ((playDirective === 'forward' ? playIndex + 1 : playIndex - 1) + totalLen) % totalLen;
    const nextInfo = cacheList?.[nextIndex];

    try {
      if (isEmpty(nextInfo)) {
        throw new Error();
      }

      const data = await fetchList({
        ...nextInfo,
      } as unknown as R) ?? [];

      const replaceData = playDirective === 'forward' ? data?.[totalLen - 1] : data?.[data.length - totalLen];
      const insertIndex = ((playDirective === 'forward' ? playIndex + 1 + 5 : playIndex - 1 - 5) + totalLen) % totalLen;

      setCacheList((prev) => {
        const newList = [...prev];

        newList.splice(insertIndex, 1, replaceData);

        return newList;
      });
    } catch {
      setPlayed(false);
    }
  }, [cacheLen, fetchList, playDirective, playIndex, totalLen, cacheList]);

  useEffectInterval({
    fn: handlePlay,
    delay: frameInterval,
    active: played,
    options: {
      immediate: true,
    }
  });

  useEffect(() => {
    init();
  }, []);

  return {
    playIndex,
    cacheList,
    currFrame: cacheList?.[playIndex],
    handleStop,
    handleResume,
  };
};

export default useFrameCache;

    上面例子中有使用 useEffectInterval,有不清楚的小伙伴可以查看我的 个人笔记React Hook设计,如何更轻松使用Interval与Timeout