React Native 定时器按钮逻辑设计

372 阅读4分钟

一个基本的定时器按钮设计需要这么两个功能

  1. 点击触发倒计时
  2. 倒计时结束自动恢复

我们可以把上面的功能抽象成 React Hook 函数。


function useTimer() {
  const [count, setCount] = React.useState(0);

  React.useEffect(() => {
    if (count <= 0) {
      return;
    }    

    const handler = setTimeout(() => {
      setCount(prev => prev - 1);
    });

    return () => {
      clearTimeout(handler);
    }
  }, [count]);

  return {
    count, 
    start(second) {
      setCount(second);
    }
  };
}

在组件中调用 useTimer

function Compo() {
  const {count, start} = useTimer();

  const press = async () => {
    start(60);
    // 调用一些接口
    await fetch();
  }
  return (
    <Button onPress={press} disabled={count !== 0}>
      {count === 0 ? "press me" : `${count} s`}
    </Button>
  );
}

但是,这样就足够了吗?反正我是不够的。

功能增强

上面的设计存在几个问题:

  • 无法恢复
  • 无法退出计时
  • 没有精细化的生命周期

无法恢复

当组件卸载时,重新加载组件的时候,无法恢复到原来的状态。这个解决方法是存结束的时间戳。


// 使用这个库来存时间戳
// npm install @react-native-async-storage/async-storage
import AsyncStorage from "@react-native-async-storage/async-storage";

function useTimer(id) {
  const [count, setCount] = React.useState(0);

  // 使用结束时的时间戳
  React.useEffect(() => {
    (async () => {
      // 使用结束时的时间戳
      const timestamp = parseInt(await AsyncStorage.getItem(id), 10);
      // 判断时间戳是否存在
      if (Number.isNaN(timestamp)) {
        return;
      }
      const current = Date.now();
      const second = Math.floor((timestamp - current) / 1000);
      // 判断时间戳是否过期了
      if (second <= 0) {
        return;
      }
      setCount(second);
    })();
  }, []);

  React.useEffect(() => {
    if (count <= 0) {
      // 移除相关存储对象
      AsyncStorage.removeItem(id);
      return;
    }

    const handler = setTimeout(() => {
      setCount(prev => prev - 1);
    }, 1000);

    return () => {
      clearTimeout(handler);
    }
  }, [count]);

  return {
    count, 
    start(second) {
      const timestamp = Date.now() + second * 1000;
      // 存结束的时间戳
      AsyncStorage.setItem(id, timestamp.toString());
      setCount(second);
    }
  };
}

在这里,增加了一个id参数,这个参数的目的是为了存储时间戳的标识,建议用组件名。

function Compo() {
  ...
  const {count, start} = useTimer("Compo");
  ...
}

在 React Native 中,当应用在后台时,系统会中断应用的定时器。这样的中断过程并不会触发组件卸载,那么即便恢复过来,也会有很明显的误差。所以我们需要使用通过AppState 来获取应用状态,并记录下应用退出时的时间戳。

import { AppState } from "react-native";

function useAppState(fn, deps = []) {
  const appState = React.useRef();
  React.useEffect(() => {
    const subscription = AppState.addEventListener("change", (nextState) => {
      fn(appState.current, nextState);
      appState.current = nextState;
    });

    return () => {
      subscription?.remove();
    };
  }, deps);
}

function useAppTimeOffset() {
  const timestampRef = React.useRef(0);
  useAppState((state, nextState) => {
    if (state === "active" && nextState.match(/inactive|background/)) {
      timestampRef.current = Date.now();
    }
  });

  return () => {
    let offset = 0;
    if (timestampRef.current > 0) {
      offset = Date.now() - timestampRef.current;
      timestampRef.current = 0;
    }
    return Math.floor(offset / 1000);
  };
}

userTimer 中的定时器恢复时,就能计算出来应用在后台经过了多久。

function userTimer(id) {
  ...
  const offset = useAppTimeOffset();

  React.useEffect(() => {
    ...
    const handler = setTimeout(() => {
      setCount(prev => prev - 1 - offset());
    }, 1000);
    ...
  }, []);
}

无法退出计时

针对这一点,可以增加一个结束调用。

function useTimer(id) {
  ...
  
  return {
    ...
    end() {
      AsyncStorage.removeItem(id);
      setCount(0);
    }
  };
}

一般来说,当组件执行的相关提交操作成功,可以判定结束。

function Compo() {
  ...
  const { count, start, end } = useTimer("Compo");
  const submit = async () => {
    await fetch("submit");
    end();
  }
  ...
}

没有精细化的生命周期

这一点怎么理解呢?可以直接看定时器的生命周期图。

timer_lifetime.jpg

一般来说,定时器的有这三个状态:

  • 关闭
  • 开启
  • 暂停

为了实现上述状态,我们需要规定计数器的初始值为-1,只有这样才能保证开启和关闭状态不冲突。针对暂停状态,我们需要得到组件卸载的钩子函数。

下面给出代码。

function useTimer({ id, onStart, onPause, onResume, onEnd }) {
  ...
  // 使用 -1 作为起始值
  const [count, setCount] = React.useState(-1);

  // 恢复期注意的是,有成功和失败两个状态
  React.useEffect(() => {
    (async () => {
      // 使用结束时的时间戳
      const timestamp = parseInt(await AsyncStorage.getItem(id), 10);
      const current = Date.now();
      const second = Math.floor((timestamp - current) / 1000);
      // 判断时间戳是否过期了
      if (Number.isNaN(second) || second <= 0) {
        setCount(-1);
        onResume?.(false);
      } else {
        setCount(second);
        onResume?.(true);      
      }
    })();
  }, []);
  ...

  React.useEffect(() => {
    if (count < 0) {
      return;
    }

    if (count === 0) {
      onEnd?.();
      
      // 回到初始值
      setCount(-1);
      
      return;
    }
    ...
  }, [count]);
  
  useTimerPause(count, onPause);

  ...
  return {
    ...
    start() {
      ...
      onStart?.();
    },
    end() {
      ...
      onEnd?.();
      
      // 回到初始值
      setCount(-1);
    }
  }
}

// 注册定时器的卸载函数,只有组件卸载的时候且定时器在运行中才进入暂停
function useTimerPause(count, onPause) {
  const countRef = React.useRef(-1);
  React.useEffect(() => {
    countRef.current = count;
  }, [count]);

  React.useEffect(() => () => {
    if (countRef.current <= 0) {
      return;
    }
    onPause?.();
  }, []);
}

在组件中,就可以这样调用。

function Compo() {
  ...
  const [authCodeSended, setAuthCodeSended] = React.useState(false);
  const {
    count,
    start,
    end
  } = useTimer({
    id,
    onStart() {
      setAuthCodeSended(true);
    },
    onResume() {
      setAuthCodeSended(true);
    },
    onEnd() {
      setAuthCodeSended(false);      
    }
  });
  ...

  const press = () => {
    await fetch("authcode");
    // 调用成功才执行
    start(60);
  };

  const submit = () => {
    await fetch("submit");
    end();
  }

  return (
    ...
  )
}

完整代码

import React from "react";
import { AppState } from "react-native";
import AsyncStorage from "@react-native-async-storage/async-storage";


function useAppState(fn, deps = []) {
  const appState = React.useRef();
  React.useEffect(() => {
    const subscription = AppState.addEventListener("change", (nextState) => {
      fn(appState.current, nextState);
      appState.current = nextState;
    });

    return () => {
      subscription?.remove();
    };
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, deps);
}

function useAppTimeOffset() {
  const timestampRef = React.useRef(0);
  useAppState((state, nextState) => {
    if (state === "active" && nextState.match(/inactive|background/)) {
      timestampRef.current = Date.now();
    }
  });

  return () => {
    let offset = 0;
    if (timestampRef.current > 0) {
      offset = Date.now() - timestampRef.current;
      timestampRef.current = 0;
    }
    return Math.floor(offset / 1000);
  };
}

function useTimerPause(count, onPause) {
  const countRef = React.useRef(-1);
  React.useEffect(() => {
    countRef.current = count;
  }, [count]);

  React.useEffect(() => () => {
    if (countRef.current <= 0) {
      return;
    }
    onPause?.();
  }, []);
}



function useTimer({ id, onStart, onEnd, onResume }) {
  const idRef = React.useRef(id);
  const [count, setCount] = React.useState(-1);

  // 使用结束时的时间戳
  React.useEffect(() => {
    (async () => {
      // 使用结束时的时间戳
      const timestamp = parseInt(await AsyncStorage.getItem(idRef.current), 10);
      
      const current = Date.now();
      const second = Math.floor((timestamp - current) / 1000);
      // 判断时间戳是否过期了
      if (Number.isNaN(second) || second <= 0) {
        onResume(false);
        setCount(-1);
        return;
      }
      setCount(second);
      onResume(true);
    })();
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  const offset = useAppTimeOffset();

  React.useEffect(() => {
    if (count < 0) {
      return;
    }
    
    if (count === 0) {
      // 移除相关存储对象
      AsyncStorage.removeItem(idRef.current);
      setCount(-1);
      onEnd?.();
      return;
    }

    const handler = setTimeout(() => {
      setCount(prev => {
        const result = prev - 1 - offset();
        return result >= 0 ? result : 0;
      });
    }, 1000);

    return () => {
      clearTimeout(handler);
    };
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [count]);

  useTimerPause(count, onPause);

  return {
    count,
    start(second) {
      const timestamp = Date.now() + second * 1000;
      // 存结束的时间戳
      AsyncStorage.setItem(idRef.current, timestamp.toString());
      setCount(second);
      onStart?.();
    },
    end() {
      AsyncStorage.removeItem(idRef.current);
      setCount(-1);
      onEnd?.();
    }
  };
}

做个总结

本文介绍了如何在 React Native 里面使用定时器按钮逻辑并给出了设计思路和代码。如果喜欢本文,欢迎点赞和收藏。如果对本文有任何疑问,欢迎在评论区指出,不胜感激。