Ранее мы создали пользовательский хук для включения секундомера.
Проблема этой реализации заключается в том, что он будет работать только до тех пор, пока приложение активно/работает в фоновом режиме. Если приложение завершается, то таймер останавливается.
Сегодня мы усовершенствуем этот хук, чтобы он работал, даже если приложение будет выключено на несколько месяцев. Когда вы откроете его снова, и если таймер был запущен, он покажет вам время, прошедшее с момента запуска.
Ключом ко всему этому является пакет @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