Sie wissen, dass der Moment, wenn Sie in der Mitte des Nichts sind, Ihr 4G in "E" umgeht, und Ihre mobile App beschließt, ein sehr teures Papiergewicht zu werden? Stellen Sie sich einen Lieferant vor, der auf einen Ladespinner schaut, weil die App ihre Route ohne Internet nicht laden kann. oder ein Lagermanager, der nicht in der Lage ist, die Inventarliste zu ziehen, weil der Server nach unten ist. Eine Reihe unserer Kunden traf diese Konnektivitätslöcher – entfernte Arbeitsplätze, Offsite-Veranstaltungen, Lagerböden, Logistik-Hubs. Grundsätzlich stirbt überall Wi-Fi. Die Lösung? Lokale Apps: Verarbeiten Sie Daten lokal, synchronisieren Sie sie, wenn Sie können. Ihre Benutzer bleiben glücklich, und Sie erhalten nicht Mitternacht „die App ist kaputt“ Anrufe. In diesem kleinen Abenteuer zeige ich Ihnen, wie ich eine lokale mobile App mit Du wirst sehen: React Native + RxDB Wie Zwei-Wege-Synchronisierung tatsächlich funktioniert, ohne Ihren Server zu schmelzen. Wie man mit „lustigen“ Situationen wie Datenkonflikten umgeht. Was man beim Entwerfen eines dieser Tiere nicht vergessen sollte. Auch – ich werde einen „letzte Aktualisierung gewinnt“ Trick teilen. Nicht immer die richtige Wahl, aber in unserem Fall ... der Kuss des Kochs. The Stack (a.k.a. My Chosen Weapons) Der Stapel (a.k.a. Meine gewählten Waffen) Für diesen Bau rollte ich mit: React Native – Plattformübergreifende Magie: eine Codebase, iOS + Android. react-native-nitro-sqlite – denn lokale Speicherung ohne SQLite ist wie Pizza ohne Käse. RxDB – Offline-First, reaktive DB, die mit Sync gut spielt. NestJS + TypeORM + PostgreSQL – das Traumteam im Hintergrund. Das Endergebnis: Die App funktioniert offline, synchronisiert später und überlebt dodgy Verbindungen, ohne einen Zusammenbruch zu haben. Step 1 — Local Storage via SQLite Schritt 1 – Lokaler Speicher über SQLite SQLite ist die offensichtliche Wahl, aber ich wickelte es mit einigen zusätzlichen Kräften - Validierung und Verschlüsselung - weil Datenintegrität und Privatsphäre wichtig sind (und auch weil mein zukünftiges Selbst mir danken wird). //storage.ts import { getRxStorageSQLiteTrial, getSQLiteBasicsQuickSQLite, } from 'rxdb/plugins/storage-sqlite'; import { open } from 'react-native-nitro-sqlite'; import { wrappedValidateAjvStorage } from 'rxdb/plugins/validate-ajv'; import { wrappedKeyEncryptionCryptoJsStorage } from 'rxdb/plugins/encryption-crypto-js'; const sqliteBasics = getSQLiteBasicsQuickSQLite(open); const storage = getRxStorageSQLiteTrial({ sqliteBasics }); const validatedStorage = wrappedValidateAjvStorage({ storage }); const encryptedStorage = wrappedKeyEncryptionCryptoJsStorage({ storage: validatedStorage, }); export { encryptedStorage }; Ja, das sind drei Schichten des Verpackens, wie eine Zwiebel oder eine Enterprise-App mit zu vielen Middleware-Schichten. Step 2 — Creating the Database Instance Schritt 2 – Erstellen der Datenbankinstanz Danach baute ich ein Denn wenn Sie denken, dass es eine gute Idee ist, mehrere Exemplare Ihrer DB zu haben ... Sie genießen wahrscheinlich auch Fusionskonflikte in der Produktion. RxDatabaseManager Hier ist die Klasse in all ihrer Herrlichkeit: //Instance.ts import { addRxPlugin, createRxDatabase, RxDatabase, WithDeleted } from 'rxdb'; import { RxDBDevModePlugin } from 'rxdb/plugins/dev-mode'; import { replicateRxCollection } from 'rxdb/plugins/replication'; import NetInfo from '@react-native-community/netinfo'; import { CheckPointType, MyDatabaseCollections, ReplicateCollectionDto, } from './types.ts'; import { encryptedStorage } from './storage.ts'; import { defaultConflictHandler } from './utills.ts'; import { usersApi, userSchema, UserType } from '../features/users'; import { RxDBMigrationSchemaPlugin } from 'rxdb/plugins/migration-schema'; import { RxDBQueryBuilderPlugin } from 'rxdb/plugins/query-builder'; import { RxDBUpdatePlugin } from 'rxdb/plugins/update'; // for support query.update method addRxPlugin(RxDBUpdatePlugin); // for support chained query methods addRxPlugin(RxDBQueryBuilderPlugin); // for enabling data migration addRxPlugin(RxDBMigrationSchemaPlugin); export class RxDatabaseManager { private static instance: RxDatabaseManager; private db: RxDatabase<MyDatabaseCollections> | null = null; private isOnline = false; private constructor() {} public static getInstance(): RxDatabaseManager { if (!RxDatabaseManager.instance) { RxDatabaseManager.instance = new RxDatabaseManager(); } return RxDatabaseManager.instance; } public async init(): Promise<RxDatabase<MyDatabaseCollections>> { if (this.db) return this.db; if (__DEV__) { // needs to be added in dev mode addRxPlugin(RxDBDevModePlugin); } this.db = await createRxDatabase<MyDatabaseCollections>({ name: 'myDb', storage: encryptedStorage, multiInstance: false, // No multi-instance support for React Native closeDuplicates: true, // Close duplicate database instances }); await this.db.addCollections({ users: { schema: userSchema, conflictHandler: defaultConflictHandler, migrationStrategies: { // 1: function (oldDoc: UserType) {}, }, }, }); this.setupConnectivityListener(); return this.db; } public getDb(): RxDatabase<MyDatabaseCollections> { if (!this.db) { throw new Error('Database not initialized. Call init() first.'); } return this.db; } private replicateCollection<T>(dto: ReplicateCollectionDto<T>) { const { collection, replicationId, api } = dto; const replicationState = replicateRxCollection<WithDeleted<T>, number>({ collection: collection, replicationIdentifier: replicationId, pull: { async handler(checkpointOrNull: unknown, batchSize: number) { const typedCheckpoint = checkpointOrNull as CheckPointType; const updatedAt = typedCheckpoint ? typedCheckpoint.updatedAt : 0; const id = typedCheckpoint ? typedCheckpoint.id : ''; const response = await api.pull({ updatedAt, id, batchSize }); return { documents: response.data.documents, checkpoint: response.data.checkpoint, }; }, batchSize: 20, }, push: { async handler(changeRows) { console.log('push'); const response = await api.push({ changeRows }); return response.data; }, }, }); replicationState.active$.subscribe(v => { console.log('Replication active$:', v); }); replicationState.canceled$.subscribe(v => { console.log('Replication canceled$:', v); }); replicationState.error$.subscribe(async error => { console.error('Replication error$:', error); }); } private async startReplication() { const db = this.getDb(); this.replicateCollection<UserType>({ collection: db.users, replicationId: '/users/sync', api: { push: usersApi.push, pull: usersApi.pull, }, }); } private setupConnectivityListener() { NetInfo.addEventListener(state => { const wasOffline = !this.isOnline; this.isOnline = Boolean(state.isConnected); if (this.isOnline && wasOffline) { this.onReconnected(); } }); } private async onReconnected() { this.startReplication(); } } Dieses kleine Tier: Setzen Sie die DB auf. Uhren für das Internet wie ein klingy ex. Synchronisieren, sobald wir online sind. Und ja, es protokolliert alles. Future me wird beim Debuggen beim nächsten Mal dankbar sein. Step 3 — Bootstrapping the DB When the App Starts Schritt 3 – Bootstrapping der DB, wenn die App gestartet wird Wenn die App startet, spinne ich meine Keine Magie hier – nur der gute Ol’ Seine Sache zu tun. RxDatabaseManager useEffect Wenn etwas während der init explodiert, protokolliere ich es. Weil Fehler so tun, als ob sie nicht existieren, ist es so, wie Sie Apps verhaftet bekommen. //App.tsx useEffect(() => { const init = async () => { const dbManager = RxDatabaseManager.getInstance(); dbManager .init() .then(() => { setAppStatus('ready'); }) .catch((error) => { console.log('Error initializing database:', error); setAppStatus('error'); }); }; init(); }, []); Step 4 — Data Replication (a.k.a. Syncing Without Tears) Schritt 4 – Replikation von Daten (a.k.a. Sync ohne Tränen) Wenn die App aus dem "Offline-Höhlenmodus" zurück ins Netz geht, Das deaktiviert die Datensynchronisierung zwischen der lokalen DB und dem Server über . onReconnected() replicateRxCollection Hier ist der grundlegende Pull Handler - RxDB sendet einen Checkpoint ( , der So weiß der Server, wo wir stehen geblieben sind, denn niemand will die DB jedes Mal. updatedAt id Gesamt //instance.ts async handler(checkpointOrNull: unknown, batchSize: number) { const typedCheckpoint = checkpointOrNull as CheckPointType; const updatedAt = typedCheckpoint ? typedCheckpoint.updatedAt : 0; const id = typedCheckpoint ? typedCheckpoint.id : ''; const response = await api.pull({ updatedAt, id, batchSize }); return { documents: response.data.documents, checkpoint: response.data.checkpoint, }; }, Auf dem Server frage ich nur die neuen / aktualisierten Dinge seit dem letzten Checkpoint. weil Bandbreite kostbar ist, und so ist meine Geduld. //users.query-repository.ts async pull(dto: PullUsersDto): Promise<UserViewDto[]> { const { id, updatedAt, batchSize } = dto; const users = await this.users .createQueryBuilder('user') .where('user.updatedAt > :updatedAt', { updatedAt }) .orWhere('user.updatedAt = :updatedAt AND user.id > :id', { updatedAt, id, }) .orderBy('user.updatedAt', 'ASC') .addOrderBy('user.id', 'ASC') .limit(batchSize) .getMany(); return users.map(UserViewDto.mapToView); } Und dann sendet der Server sowohl die Dokumente als auch einen glänzenden neuen Checkpoint zurück: //user.service.ts async pull(dto: PullUsersDto) { const users = await this.usersRepository.pull(dto); const newCheckpoint = users.length === 0 ? { id: dto.id, updatedAt: dto.updatedAt } : { id: users.at(-1)!.id, updatedAt: users.at(-1)!.updatedAt, }; return { documents: users, checkpoint: newCheckpoint, }; } Step 5 — Pushing Local Changes Back to the Server Schritt 5 – Lokale Änderungen zurück zum Server schieben Denken Sie darüber nach wie Dropbox für Ihre App-Daten - ohne die zufälligen "konfliktreichen Kopien" -Dateien (nein ... es sei denn, Sie handhaben Konflikte schlecht). //instance.ts async handler(changeRows) { const response = await api.push({ changeRows }); return response.data; }, Im Backend überprüfe ich jede eingehende Änderung: Wenn es keinen Konflikt gibt, geht es direkt hinein. Wenn die Version des Servers neuer ist, werfe ich sie in den Konfliktstapel. //user.service.ts async push(dto: PushUsersDto) { const changeRows = dto.changeRows; const existingUsers = await this.usersRepository.findByIds( changeRows.map((changeRow) => changeRow.newDocumentState.id), ); const existingMap = new Map(existingUsers.map((user) => [user.id, user])); const toSave: UserViewDto[] = []; const conflicts: UserViewDto[] = []; for (const changeRow of changeRows) { const newDoc = changeRow.newDocumentState; const existing = existingMap.get(newDoc.id); const isConflict = existing && existing.updatedAt > newDoc?.updatedAt; if (isConflict) { conflicts.push(existing); } else { toSave.push(newDoc); } if (toSave.length > 0) { await this.usersRepository.save(toSave); } } return conflicts; } Step 6 — Conflict Resolution (The “Last Update Wins” Gambit) Schritt 6 – Konfliktlösung (Das „Last Update gewinnt“ Gambit) Hier ist, wo Menschen in der Regel Dinge überkomplizieren. Entwerfen Sie eine NASA-Grade-Fusion-Strategie oder ... Sie könnten mit "Last Update Wins" gehen und es einen Tag nennen. könnten Es ist nicht immer der richtige Schritt, aber in unserem Fall - einfach, schnell, gut genug. //utills.ts export const defaultConflictHandler: RxConflictHandler<{ updatedAt: number; }> = { isEqual(a, b) { return a.updatedAt === b.updatedAt; }, resolve({ assumedMasterState, realMasterState, newDocumentState }) { return Promise.resolve(realMasterState); }, }; Step 7 — Keeping the UI in Sync Schritt 7 – Halten Sie die UI in Sync Nach init dreht sich der App-Status auf Keine seltsamen manuellen Erfrischungsschaltflächen, keine "Taste to Reload" Unsinn. Ready //UsersScreen.tsx export const UsersScreen = () => { const users = useUsersSelector({ sort: [{ updatedAt: 'desc' }], }); const { createUser, deleteUser, updateUser } = useUsersService(); return ( <View style={styles.container}> {users.map(user => ( <Text key={user.id}>{user.name}</Text> ))} <Button title={'Create new user'} onPress={createUser} /> <Button disabled={users.length === 0} title={'Update user'} onPress={() => updateUser(users[0].id)} /> <Button disabled={users.length === 0} title={'Delete user'} onPress={() => deleteUser(users[0].id)} /> </View> ); }; Abonniert Änderungen in der DB, so dass die Benutzeroberfläche sich selbst aktualisiert. Dies ist einer jener "Werkt wie Magie" Momente, über die Sie nicht zu hart denken sollten - genießen Sie es einfach. useUsersSelector //user.selector.tsx export const useUsersSelector = (query?: MangoQuery<UserType>) => { const userModel = RxDatabaseManager.getInstance().getDb().users; const [users, setUsers] = useState<UserType[]>([]); useEffect(() => { const subscription = userModel.find(query).$.subscribe(result => { setUsers(result); }); return () => subscription.unsubscribe(); }, [userModel, query]); return users; }; Final Thoughts Endgültige Gedanken Grundsätzlich haben wir eine App entwickelt, die: Es funktioniert offline. Synchronisieren automatisch Es ist egal, ob Ihr Wi-Fi eine existenzielle Krise hat. Wir haben den gleichen Aufbau für , wo die Konnektivität ist ... sagen wir einfach „urban adventure mode.“ Fahrer können jetzt Bestellungen abschließen, Proof-of-Delivery-Fotos aufnehmen und Lieferungen ohne Internet markieren. Sizl’s dark kitchen riders in Chicago Natürlich können reale Fälle schlimmer werden – mehrere Geräte aktualisieren die gleiche Aufzeichnung, verwandte Daten löschen, massive Datensätze. Würde ich empfehlen, zuerst lokal zu gehen? Absolut – es sei denn, Sie genießen Benutzerwut-Tickets, die mit "Ich war offline und ..." beginnen. Click the link in my bio for more info on this one & other projects! To check out our open source admin panel on React see our . Github Github