При разработке мобильного приложения у нас не всегда был внутренний сервер/база данных для хранения пользовательских данных, вместо этого мы могли использовать сторонних облачных провайдеров, таких как google drive, one drive или icloud drive для хранения файлов данных. В системе Apple icloud drive является первым выбором, потому что он встроен в систему.
В этой статье я выберу базу данных realm для хранения пользовательских данных, диск icloud для резервного копирования пользовательских данных и использую react-native для разработки мобильного приложения, и эта статья не будет охватывать темы, связанные с Android.
Мы разработаем простое приложение для заметок под названием «inote», оно будет поддерживать добавление заметок, удаление заметок, резервное копирование и восстановление.
Во-первых, запустите реактивный проект, используя npx react-native init inote --template react-native-template-typescript, я предпочитаю использовать xcode для запуска моего проекта, поэтому откройте папку ios с помощью xcode и запустите ее на симуляторе, убедившись, что все работает.
Проблема, с которой вы можете столкнуться: на момент написания этой статьи проект, инициализированный с помощью react-native cli, был v0.70.5, при запуске проекта вы можете получить ошибку Библиотека не загружена: @rpath/hermes.framework/hermes» , просто перейдите по ссылке, чтобы узнать, как ее решить.
Далее давайте установим базу данных области:
yarn add realm npx pod-install
А затем установите пакет, связанный с реакцией, этот пакет предоставит нам некоторые удобные компоненты и хуки:
yarn add @realm/react
Теперь давайте напишем код, чтобы убедиться, что область работает, замените App.tsx кодом ниже:
import React from 'react';
import { RealmContext } from './src/context';
import IndexScreen from "./src/index.screen";
const App = () => {
return (
<RealmContext.RealmProvider>
<IndexScreen />
</RealmContext.RealmProvider>
);
};
export default App;
Создайте файл src/context.ts и напишите:
import { createRealmContext } from "@realm/react";
import { NoteSchema } from "./schema";
export const RealmContext = createRealmContext({
schema: [NoteSchema],
deleteRealmIfMigrationNeeded: true
})
Создайте файл src/schema.ts и напишите:
import Realm from "realm";
export class NoteSchema extends Realm.Object<NoteSchema, "content"> {
// @ts-ignore
static name = "MyNote"
static primaryKey = "_id"
_id = new Realm.BSON.ObjectId()
createdAt = new Date()
content!: string
}
Позвольте мне объяснить больше о src/schema.ts: схема — это понятие в сфере, точно так же, как модель данных в базе данных SQL. До версии realm-js v11 при определении схемы нам нужно было написать много шаблонного кода, который является редуктивным и ненужным, как показано ниже:
export class NoteSchema {
_id!: Realm.BSON.ObjectId
createdAt!: Date
content!: string
static generate(content: string) {
return {
_id: new Realm.BSON.ObjectId(),
createdAt: new Date(),
content
}
}
static schema = {
name: 'MyNote',
primaryKey: '_id',
properties: {
_id: 'objectId',
content: 'string',
createdAt: 'date'
},
};
}
Начиная с realm-js v11+ и с помощью плагина realm babel мы можем просто определить поля один раз, поэтому здесь нам нужно установить плагин babel:
yarn add @realm/babel-plugin -D
И обновить babel.config.js:
module.exports = {
presets: ['module:metro-react-native-babel-preset'],
plugins: [
// add plugin here
'@realm/babel-plugin',
],
};
Предупреждение: вам нужно остановить сервер метро и перезапустить его с помощью --no-cache, иначе наш плагин babel может не сработать:
react-native start --no-cache
Ок, продолжаем создавать нужные нам левые файлы, создаем файл src/index.screen.tsx:
import { Button, View, Text, SafeAreaView } from "react-native";
import { NoteSchema } from "./schema";
import { RealmContext } from "./context";
const IndexScreen = () => {
const realm = RealmContext.useRealm()
const notes = RealmContext.useQuery(NoteSchema)
const createNote = () => {
realm.write(() => {
const note = new NoteSchema(realm,{
content: 'sth',
})
})
}
return <SafeAreaView style={{flex:1}}>
<Button title={"create"} onPress={createNote} />
<View>
{
notes.map(note => {
return <View key={note._id.toHexString()}>
<Text>{note.content}</Text>
<Text>{note.createdAt.toString()}</Text>
</View>
})
}
</View>
</SafeAreaView>
}
export default IndexScreen
Данные, возвращаемые хуком useQuery, предоставленным @realm/react, обновляются в реальном времени, что означает, что добавление или удаление заметок вызовет повторную визуализацию автоматически.
Теперь вы можете повторно запустить симулятор, нажать кнопку «Создать», под кнопкой появится заметка, а затем вы можете перезагрузить приложение, заметки все еще здесь, отлично!
Реализуем операцию удаления, обновив src/index.screen.tsx комментарием «новый код»:
const IndexScreen = () => {
// ...
// new code
const deleteNote = (note: NoteSchema) => {
realm.write(() => {
realm.delete(note)
})
}
return <SafeAreaView style={{flex:1, backgroundColor: 'pink'}}>
<Button title={"create"} onPress={createNote} />
<View>
{
notes.map(note => {
return <View key={note._id.toHexString()}>
<Text>{note.content}</Text>
<Text>{note.createdAt.toString()}</Text>
{/* new code */}
<Button title={"delete"} onPress={deleteNote.bind(null, note)}/>
</View>
})
}
</View>
</SafeAreaView>
}
export default IndexScreen
Наш UI сейчас выглядит очень плохо, но вы можете попробовать сделать его лучше, здесь это не важная тема.
Теперь пришло время реализовать логику резервного копирования, мы будем использовать react-native-cloud-store для обработки резервного копирования icloud, прежде чем углубляться, убедитесь, что у вас есть платная учетная запись Apple.
Мы можем просто следовать руководству по установке для установки и настройки, после установки давайте проверим, правильно ли был настроен icloud.
В этой части нам нужно протестировать его на физическом устройстве, потому что icloud не работает согласованно с реальным устройством, поэтому подключите свой iPhone к Mac с помощью USB и запустите с помощью xcode.
Проблема, с которой вы можете столкнуться: при запуске на физическом устройстве я снова получил ошибку Библиотека не загружена: @rpath/hermes.framework/hermes, я решил ее, следуя этому комментарию к проблеме.
Теперь давайте добавим новый компонент для тестирования icloud:
const BackupArea = () => {
return <View>
<Button title={"is available"} onPress={async () => {
console.log('available:', await CloudStore.isICloudAvailable());
}} />
<Button title={"get default container id"} onPress={async () => {
console.log('default:', CloudStore.defaultICloudContainerPath);
}} />
</View>
}
Нажимайте кнопки, и если консоль записала «true» и строку пути, например «/private/var/mobile/Library/Mobile Documents/iCloud~x~y~z», это означает, что наша конфигурация icloud была правильной.
Теперь мы можем сделать резервную копию локальных данных в icloud, заменив компонент BackupArea на:
const BackupArea = () => {
const realm = RealmContext.useRealm()
return <View>
<Button title={"backup"} onPress={async () => {
realm.writeCopyTo({
path: CloudStore.PathUtils.join(CloudStore.defaultICloudContainerPath, `backup_${Date.now()}.realm`)
})
console.log('backup success');
}} />
</View>
}
Мы используем realm.writeCopyTo для объединения текущей открытой базы данных в сжатый файл, путь назначения был просто путем icloud. Использовать этот метод для резервного копирования просто, но вы не можете прослушивать ход загрузки. Если вы хотите прослушать ход загрузки, мы должны использовать метод upload:
const BackupArea = () => {
const realm = RealmContext.useRealm()
useEffect(() => {
// we need listen global event
const r = CloudStore.registerGlobalUploadEvent()
return () => {
r?.remove()
}
}, [])
return <View>
<Button title={"backup with progress"} onPress={async () => {
const backupFileName = `backup.realm`
const localPath = Dirs.DocumentDir + `/${backupFileName}`
realm.writeCopyTo({
path: localPath
})
const cloudPath = CloudStore.PathUtils.join(CloudStore.defaultICloudContainerPath, backupFileName)
// remove cloud file before upload
await CloudStore.unlink(cloudPath);
await CloudStore.upload(localPath, cloudPath, {
onProgress(data) {
console.log('upload progress:', data)
if(data.progress === 100) {
console.log('backup success')
}
}
})
}} />
</View>
}
Далее реализуем функцию восстановления, перед ней нам нужно получить список файлов резервных копий на icloud:
const BackupArea = () => {
const [cloudFiles, setCloudFiles] = useState<string[]>([])
return <View>
<Button title={"get cloud list"} onPress={async () => {
const paths = await CloudStore.readDir(CloudStore.defaultICloudContainerPath)
setCloudFiles(paths)
// you can get detailed info using `stat`
for (const path of paths) {
const info = await CloudStore.stat(path);
console.log('item detail:',info);
}
}} />
<View style={{borderWidth: 1, borderColor: 'black'}}>
<Text style={{fontWeight:'bold'}}>cloud files:</Text>
{cloudFiles.map(f => {
return <Text key={f} style={{marginBottom: 5}}>{f}</Text>
})}
</View>
</View>
}
Как и в приведенном выше коде, мы можем просто прочитать содержимое нашей папки резервного копирования.
Примечание: поскольку я использовал корневой каталог, а не конкретную папку для хранения файлов резервных копий, поэтому папка Documents также была напечатана, в реальных ситуациях мы не должны отображать несвязанные папки или файлы, а здесь просто для простоты.
Теперь мы можем реализовать при нажатии на путь к облачному файлу, мы будем предлагать пользователю восстановить или удалить файл резервной копии:
const BackupArea = () => {
// new: used for control realm update
const {setVersion} = useContext(RealmVersionContext)
useEffect(() => {
const uploadEvent = CloudStore.registerGlobalUploadEvent()
// new: listen downlaod progress
const downloadEvent = CloudStore.registerGlobalDownloadEvent()
return () => {
uploadEvent?.remove()
downloadEvent?.remove()
}
}, [])
// new: restore and delete actions
const restoreFile = (path: string) => {
ActionSheetIOS.showActionSheetWithOptions({
options: ["Restore", "Delete", "Cancel"],
cancelButtonIndex: 2,
destructiveButtonIndex: 1,
}, async (idx) => {
// restore
if(idx === 0) {
await CloudStore.download(path, {
async onProgress(data) {
console.log('download onProgress:', data);
// when download finished:
if(data.progress === 100) {
// remove old file
await FileSystem.unlink(realm.path)
// then copy downloaded file to 'replace' original file
await FileSystem.cp(path, realm.path)
// update version to make RealmProvider re-render
setVersion((v: any) => v+1)
}
}
})
return
}
// delete
if(idx === 1) {
await CloudStore.unlink(path)
// after delete, we can re-read backup folder or just remove the path from array to re-render list
const paths = await CloudStore.readDir(CloudStore.defaultICloudContainerPath)
setCloudFiles(paths)
return
}
})
}
return <View>
{/* ... */}
<View style={{borderWidth: 1, borderColor: 'black'}}>
<Text style={{fontWeight:'bold'}}>cloud files:</Text>
{cloudFiles.map(f => {
// new: add onPress for each path
return <Text key={f} style={{marginBottom: 5}} onPress={restoreFile.bind(null, f)}>{f}</Text>
})}
</View>
</View>
}
export default IndexScreen
Логика удаления понятна, подробнее расскажу о логике восстановления, есть много способов восстановить файл бэкапа, самый простой способ это просто удалить старый и заменить его новым файлом БД, это явно не изящный способ, потому что удаление используемого файла может привести к неожиданным результатам, а если файл был большим, полная замена может занять слишком много времени и места.
Чтобы новый файл вступил в силу, нам нужно закрыть старый файл и снова открыть нашу базу данных, поскольку мы используем @realm/react , мы должны сделать это с помощью стиля реагирования, поэтому здесь вступает в игру setVersion, version является глобальным состояние, как только пользователь восстанавливается с новым файлом резервной копии, мы меняем состояние version, чтобы выполнить перемонтирование компонента:
Замените App.tsx ниже:
import React, { useContext } from "react";
import { RealmContext, RealmVersionContext, RealmVersionProvider } from "./src/context";
import IndexScreen from "./src/index.screen";
const App = () => {
return <RealmVersionProvider>
<MyApp />
</RealmVersionProvider>;
};
const MyApp = () => {
const {version} = useContext(RealmVersionContext)
return (
<RealmContext.RealmProvider key={version}>
<IndexScreen />
</RealmContext.RealmProvider>
);
};
export default App;
Переименуйте src/context.ts в src/context.tsx и замените его следующим:
import { createRealmContext } from "@realm/react";
import { NoteSchema } from "./schema";
import { createContext, FC, PropsWithChildren, useState } from "react";
export const RealmContext = createRealmContext({
schema: [NoteSchema],
deleteRealmIfMigrationNeeded: true
})
export const RealmVersionContext = createContext<any>(null as any)
export const RealmVersionProvider: FC<PropsWithChildren> = ({children}) => {
const [v, setV] = useState(0)
return <RealmVersionContext.Provider value={{
version: v,
setVersion: setV
}}>
{children}
</RealmVersionContext.Provider>
}
Как показано в приведенном выше коде, мы создали глобальное состояние version и сделали его ключом RealmProvider, поэтому, когда версия будет обновлена с помощью setVersion, RealmProvider будет перемонтировано из-за алгоритма реагирования на различия, и новый файл базы данных вступит в силу внутри. , @realm/react закроет текущую базу данных и снова откроет ее с новой конфигурацией.
Вот и все.
Самой сложной частью, как мне казалось, было восстановление, если поставщик базы данных обеспечивает функциональность слияния двух файлов базы данных, жизнь была бы проще, но на то, как объединять данные, больше влияет наша бизнес-логика, но если вы хотите быть проще, просто сделайте полная замена данных, что экономит время, но менее изящно.
Вот финальный репозиторий GitHub.