Создание крючка для секундомера, который работает даже при выходе из приложения

Ранее мы создали пользовательский хук для включения секундомера.

Проблема этой реализации заключается в том, что он будет работать только до тех пор, пока приложение активно/работает в фоновом режиме. Если приложение завершается, то таймер останавливается.

Сегодня мы усовершенствуем этот хук, чтобы он работал, даже если приложение будет выключено на несколько месяцев. Когда вы откроете его снова, и если таймер был запущен, он покажет вам время, прошедшее с момента запуска.

Ключом ко всему этому является пакет @react-native-async-storage/async-storage, который позволит нам сохранять данные на диск.

Убедитесь, что вы установили библиотеку, прежде чем продолжить.

Этот пост был первоначально опубликован на React Native School.

Начальный код

Ниже показан код, с которого мы начнем. Чтобы узнать, зачем он нужен, вы можете прочитать предыдущий учебник, в котором все описано шаг за шагом.

// hooks/useStopWatch.ts

import { useState, useRef, useEffect } from "react"

export type LapData = {
  time: string
  lap: number
}

const padStart = (num: number) => {
  return num.toString().padStart(2, "0")
}

const formatMs = (milliseconds: number) => {
  let seconds = Math.floor(milliseconds / 1000)
  let minutes = Math.floor(seconds / 60)
  let hours = Math.floor(minutes / 60)

  // using the modulus operator gets the remainder if the time roles over
  // we don't do this for hours because we want them to rollover
  // seconds = 81 -> minutes = 1, seconds = 21.
  // 60 minutes in an hour, 60 seconds in a minute, 1000 milliseconds in a second.
  minutes = minutes % 60
  seconds = seconds % 60
  // divide the milliseconds by 10 to get the tenths of a second. 543 -> 54
  const ms = Math.floor((milliseconds % 1000) / 10)

  let str = `${padStart(minutes)}:${padStart(seconds)}.${padStart(ms)}`

  if (hours > 0) {
    str = `${padStart(hours)}:${str}`
  }

  return str
}

export const useStopWatch = () => {
  const [time, setTime] = useState(0)
  const [isRunning, setIsRunning] = useState(false)
  const [startTime, setStartTime] = useState<number>(0)
  const [timeWhenLastStopped, setTimeWhenLastStopped] = useState<number>(0)
  const [laps, setLaps] = useState<number[]>([])

  const interval = useRef<ReturnType<typeof setInterval>>()

  useEffect(() => {
    if (startTime > 0) {
      interval.current = setInterval(() => {
        setTime(() => Date.now() - startTime + timeWhenLastStopped)
      }, 1)
    } else {
      if (interval.current) {
        clearInterval(interval.current)
        interval.current = undefined
      }
    }
  }, [startTime])

  const start = () => {
    setIsRunning(true)
    setStartTime(Date.now())
  }

  const stop = () => {
    setIsRunning(false)
    setStartTime(0)
    setTimeWhenLastStopped(time)
  }

  const reset = () => {
    setIsRunning(false)
    setStartTime(0)
    setTimeWhenLastStopped(0)
    setTime(0)
    setLaps([])
  }

  const lap = () => {
    setLaps(laps => [time, ...laps])
  }

  let slowestLapTime: number | undefined
  let fastestLapTime: number | undefined

  const formattedLapData: LapData[] = laps.map((l, index) => {
    const previousLap = laps[index + 1] || 0
    const lapTime = l - previousLap

    if (!slowestLapTime || lapTime > slowestLapTime) {
      slowestLapTime = lapTime
    }

    if (!fastestLapTime || lapTime < fastestLapTime) {
      fastestLapTime = lapTime
    }

    return {
      time: formatMs(lapTime),
      lap: laps.length - index,
    }
  })

  return {
    start,
    stop,
    reset,
    lap,

    isRunning,
    time: formatMs(time),

    laps: formattedLapData,
    currentLapTime: laps[0] ? formatMs(time - laps[0]) : formatMs(time),
    hasStarted: time > 0,
    slowestLapTime: formatMs(slowestLapTime || 0),
    fastestLapTime: formatMs(fastestLapTime || 0),
  }
}
Вход в полноэкранный режим Выход из полноэкранного режима

Сохранение данных

Первое, что нам нужно сделать, это персистировать (сохранить) данные в AsyncStorage. Для нашего случая мы хотим сохранить следующие фрагменты состояния

  • timeWhenLastStopped
  • isRunning
  • startTime
  • круги

Примечание: Каждый фрагмент данных должен храниться как строка в AsyncStorage.

// hooks/useStopWatch.ts

import { useState, useRef, useEffect } from "react"
import AsyncStorage from "@react-native-async-storage/async-storage"

export type LapData = {
  time: string
  lap: number
}

const padStart = (num: number) => {
  /* ... */
}

const formatMs = (milliseconds: number) => {
  /* ... */
}

const ASYNC_KEYS = {
  timeWhenLastStopped: "useStopWatch::timeWhenLastStopped",
  isRunning: "useStopWatch::isRunning",
  startTime: "useStopWatch::startTime",
  laps: "useStopWatch::laps",
}

export const useStopWatch = () => {
  const [time, setTime] = useState(0)
  const [isRunning, setIsRunning] = useState(false)
  const [startTime, setStartTime] = useState<number>(0)
  const [timeWhenLastStopped, setTimeWhenLastStopped] = useState<number>(0)
  const [laps, setLaps] = useState<number[]>([])

  const interval = useRef<ReturnType<typeof setInterval>>()

  useEffect(() => {
    // persist the latest data to async storage to be used later, if needed
    const persist = async () => {
      try {
        await AsyncStorage.multiSet([
          [ASYNC_KEYS.timeWhenLastStopped, timeWhenLastStopped.toString()],
          [ASYNC_KEYS.isRunning, isRunning.toString()],
          [ASYNC_KEYS.startTime, startTime.toString()],
          [ASYNC_KEYS.laps, JSON.stringify(laps)],
        ])
      } catch (e) {
        console.log("error persisting data")
      }
    }

    persist()
  }, [timeWhenLastStopped, isRunning, startTime, laps])

  useEffect(() => {
    /* ... */
  }, [startTime])

  const start = () => {
    /* ... */
  }

  const stop = () => {
    /* ... */
  }

  const reset = () => {
    /* ... */
  }

  const lap = () => {
    /* ... */
  }

  let slowestLapTime: number | undefined
  let fastestLapTime: number | undefined

  const formattedLapData: LapData[] = laps.map((l, index) => {
    /* ... */
  })

  return {
    /* ... */
  }
}
Вход в полноэкранный режим Выйти из полноэкранного режима

В приведенном выше коде я пошел дальше и использовал useEffect для запуска при каждом изменении одного из наших целевых фрагментов состояния (добавив каждый фрагмент состояния в качестве зависимости), а затем использовал функцию multiSet в AsyncStorage для сохранения всех данных за один раз.

Я вытащил ключи, которые я использую для ссылок на различные части данных, в объект, поскольку нам понадобятся те же ключи для извлечения данных из AsyncStorage в ближайшее время.

Загрузка данных из AsyncStorage

Теперь нам нужно фактически загрузить данные из AsyncStorage при первой инициализации хука.

Для этого я снова использую хук useEffect, но без зависимостей (передав пустой массив зависимостей). Таким образом, он будет запущен только при первом подключении компонента, вызывающего этот хук.

// hooks/useStopWatch.ts

import { useState, useRef, useEffect } from "react"
import AsyncStorage from "@react-native-async-storage/async-storage"

export type LapData = {
  time: string
  lap: number
}

const padStart = (num: number) => {
  /* ... */
}

const formatMs = (milliseconds: number) => {
  /* ... */
}

const ASYNC_KEYS = {
  timeWhenLastStopped: "useStopWatch::timeWhenLastStopped",
  isRunning: "useStopWatch::isRunning",
  startTime: "useStopWatch::startTime",
  laps: "useStopWatch::laps",
}

export const useStopWatch = () => {
  const [time, setTime] = useState(0)
  const [isRunning, setIsRunning] = useState(false)
  const [startTime, setStartTime] = useState<number>(0)
  const [timeWhenLastStopped, setTimeWhenLastStopped] = useState<number>(0)
  const [laps, setLaps] = useState<number[]>([])

  const interval = useRef<ReturnType<typeof setInterval>>()

  useEffect(() => {
    // load data from async storage in case app was quit
    const loadData = async () => {
      try {
        const persistedValues = await AsyncStorage.multiGet([
          ASYNC_KEYS.timeWhenLastStopped,
          ASYNC_KEYS.isRunning,
          ASYNC_KEYS.startTime,
          ASYNC_KEYS.laps,
        ])

        const [
          persistedTimeWhenLastStopped,
          persistedIsRunning,
          persistedStartTime,
          persistedLaps,
        ] = persistedValues

        setTimeWhenLastStopped(
          persistedTimeWhenLastStopped[1]
            ? parseInt(persistedTimeWhenLastStopped[1])
            : 0
        )
        setIsRunning(persistedIsRunning[1] === "true")
        setStartTime(
          persistedStartTime[1] ? parseInt(persistedStartTime[1]) : 0
        )
        setLaps(persistedLaps[1] ? JSON.parse(persistedLaps[1]) : [])
      } catch (e) {
        console.log("error loading persisted data", e)
      }
    }

    loadData()
  }, [])

  useEffect(() => {
    // persist the latest data to async storage to be used later, if needed
    /* ... */
  }, [timeWhenLastStopped, isRunning, startTime, laps])

  useEffect(() => {
    /* ... */
  }, [startTime])

  const start = () => {
    /* ... */
  }

  const stop = () => {
    /* ... */
  }

  const reset = () => {
    /* ... */
  }

  const lap = () => {
    /* ... */
  }

  let slowestLapTime: number | undefined
  let fastestLapTime: number | undefined

  const formattedLapData: LapData[] = laps.map((l, index) => {
    /* ... */
  })

  return {
    /* ... */
  }
}
Войти в полноэкранный режим Выйти из полноэкранного режима

Этот код немного запутан из-за природы API multiGet.

При использовании multiGet ответ выглядит следующим образом

[
  ["useStopWatch::timeWhenLastStopped", "1000"],
  ["useStopWatch::isRunning", "false"],
  ["useStopWatch::startTime", "0"],
  ["useStopWatch::laps", "[]"],
]
Войти в полноэкранный режим Выйти из полноэкранного режима

Таким образом, example[1] вы видите повсюду. Это просто для того, чтобы мы могли получить доступ к значению этого свойства.

После того как мы извлекли значение из AsyncStorage, нам нужно преобразовать его в правильный тип для этой части состояния или установить значение по умолчанию, если такового в AsyncStorage не было.

Ожидание загрузки данных

Вы можете подумать, что мы закончили, но если вы попытаетесь использовать приложение прямо сейчас, то увидите, что при обновлении приложения все возвращается к значениям по умолчанию.

Это потому, что хук, сохраняющий данные, может быть запущен до того, как данные будут извлечены из AsyncStorage, тем самым переопределяя его и устанавливая значения по умолчанию.

Поэтому нам нужно дождаться загрузки данных из AsyncStorage, прежде чем сохранять что-то новое. Мы добавим новый элемент состояния, dataLoaded, для обработки этого. Посмотрите на // NEW LINE в коде ниже, чтобы увидеть, что было добавлено.

// hooks/useStopWatch.ts

import { useState, useRef, useEffect } from "react"
import AsyncStorage from "@react-native-async-storage/async-storage"

export type LapData = {
  time: string
  lap: number
}

const padStart = (num: number) => {
  /* ... */
}

const formatMs = (milliseconds: number) => {
  /* ... */
}

const ASYNC_KEYS = {
  timeWhenLastStopped: "useStopWatch::timeWhenLastStopped",
  isRunning: "useStopWatch::isRunning",
  startTime: "useStopWatch::startTime",
  laps: "useStopWatch::laps",
}

export const useStopWatch = () => {
  const [time, setTime] = useState(0)
  const [isRunning, setIsRunning] = useState(false)
  const [startTime, setStartTime] = useState<number>(0)
  const [timeWhenLastStopped, setTimeWhenLastStopped] = useState<number>(0)
  const [laps, setLaps] = useState<number[]>([])
  const [dataLoaded, setDataLoaded] = useState(false)

  const interval = useRef<ReturnType<typeof setInterval>>()

  useEffect(() => {
    // load data from async storage in case app was quit
    const loadData = async () => {
      try {
        const persistedValues = await AsyncStorage.multiGet([
          ASYNC_KEYS.timeWhenLastStopped,
          ASYNC_KEYS.isRunning,
          ASYNC_KEYS.startTime,
          ASYNC_KEYS.laps,
        ])

        const [
          persistedTimeWhenLastStopped,
          persistedIsRunning,
          persistedStartTime,
          persistedLaps,
        ] = persistedValues

        setTimeWhenLastStopped(
          persistedTimeWhenLastStopped[1]
            ? parseInt(persistedTimeWhenLastStopped[1])
            : 0
        )
        setIsRunning(persistedIsRunning[1] === "true")
        setStartTime(
          persistedStartTime[1] ? parseInt(persistedStartTime[1]) : 0
        )
        setLaps(persistedLaps[1] ? JSON.parse(persistedLaps[1]) : [])
        setDataLoaded(true) // NEW LINE
      } catch (e) {
        console.log("error loading persisted data", e)
        setDataLoaded(true) // NEW LINE
      }
    }

    loadData()
  }, [])

  useEffect(() => {
    // persist the latest data to async storage to be used later, if needed
    const persist = async () => {
      try {
        await AsyncStorage.multiSet([
          [ASYNC_KEYS.timeWhenLastStopped, timeWhenLastStopped.toString()],
          [ASYNC_KEYS.isRunning, isRunning.toString()],
          [ASYNC_KEYS.startTime, startTime.toString()],
          [ASYNC_KEYS.laps, JSON.stringify(laps)],
        ])
      } catch (e) {
        console.log("error persisting data")
      }
    }

    // NEW LINE
    if (dataLoaded) {
      persist()
    }
  }, [timeWhenLastStopped, isRunning, startTime, laps, dataLoaded])

  useEffect(() => {
    /* ... */
  }, [startTime])

  const start = () => {
    /* ... */
  }

  const stop = () => {
    /* ... */
  }

  const reset = () => {
    /* ... */
  }

  const lap = () => {
    /* ... */
  }

  let slowestLapTime: number | undefined
  let fastestLapTime: number | undefined

  const formattedLapData: LapData[] = laps.map((l, index) => {
    /* ... */
  })

  return {
    start,
    stop,
    reset,
    lap,

    isRunning,
    time: formatMs(time),

    laps: formattedLapData,
    currentLapTime: laps[0] ? formatMs(time - laps[0]) : formatMs(time),
    hasStarted: time > 0,
    slowestLapTime: formatMs(slowestLapTime || 0),
    fastestLapTime: formatMs(fastestLapTime || 0),

    dataLoaded,
  }
}
Вход в полноэкранный режим Выход из полноэкранного режима

Вы можете увидеть несколько изменений выше

  • После успешной или неуспешной загрузки данных из AsyncStorage мы устанавливаем dataLoaded в true
  • Мы проверяем, является ли dataLoaded истиной, перед вызовом функции persist().

Избежание вспышки в пользовательском интерфейсе

Сейчас, если бы вы запустили приложение, все работало бы отлично, но при открытии приложения пользователь на короткое время увидел бы 00:00.00, даже если был запущен таймер.

Мы можем избежать этого, используя dataLoaded в компоненте и возвращая null, пока все не будет загружено.

import { StyleSheet } from "react-native";

import { Text, View, StatusBar, SafeAreaView } from "components/themed";
import { CircleButton } from "components/buttons";
import { useStopWatch } from "hooks/useStopWatch";
import { LapList } from "components/lists";

const StopWatch = () => {
  const {
    time,
    isRunning,
    start,
    stop,
    reset,
    lap,
    laps,
    currentLapTime,
    hasStarted,
    slowestLapTime,
    fastestLapTime,
    dataLoaded,
  } = useStopWatch();

  if (!dataLoaded) {
    return null;
  }

  return (
    /* ... */
  );
};

const styles = StyleSheet.create({
  /* ... */
});

export default StopWatch;
Вход в полноэкранный режим Выход из полноэкранного режима

Теперь таймер будет работать вечно! Окончательный вариант кода можно посмотреть на Github

Оцените статью
devanswers.ru
Добавить комментарий