Você sabe aquele momento em que você está no meio do nada, seu 4G se transforma em “E”, e seu aplicativo móvel decide se tornar um peso de papel muito caro? Imagine um driver de entrega olhando para um spinner de carregamento porque o aplicativo não pode carregar sua rota sem a internet. ou um gerente de armazém incapaz de puxar a lista de inventário porque o servidor está embaixo. Um bando de nossos clientes atingiu esses buracos de conectividade – locais de trabalho remotos, eventos fora do local, pisos de armazém, centros de logística. Basicamente, onde quer que o Wi-Fi morra. Nesta pequena aventura, mostrarei como construí um aplicativo móvel local com Você vai ver: React Native + RxDB Como a sincronização bidirecional realmente funciona sem derreter seu servidor. Como lidar com situações “divertidas”, como conflitos de dados. O que não se deve esquecer ao desenhar um desses animais. Também — vou compartilhar um truque de “última atualização ganha”.Nem sempre a escolha certa, mas no nosso caso... o beijo do chef. The Stack (a.k.a. My Chosen Weapons) The Stack (a.k.a. As Minhas Armas Escolhidas) Para esta construção, eu rolou com: React Native – mágica cross-platform: uma base de código, iOS + Android. react-native-nitro-sqlite – porque o armazenamento local sem SQLite é como pizza sem queijo. RxDB – primeiro offline, DB reativo que joga bem com a sincronização. NestJS + TypeORM + PostgreSQL – a equipe de sonho do backend. O resultado final: o aplicativo funciona offline, sincroniza mais tarde e sobrevive a conexões desconhecidas sem ter um colapso. Step 1 — Local Storage via SQLite Passo 1 – Armazenamento local via SQLite O SQLite é a escolha óbvia, mas eu o envolvi com alguns poderes extras – validação e criptografia – porque a integridade dos dados e a privacidade são importantes (e também porque meu eu futuro me agradecerá). //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 }; Sim, são três camadas de embrulho, como uma cebola, ou um aplicativo corporativo com muitas camadas de middleware. Step 2 — Creating the Database Instance Passo 2 – Criar uma instância de banco de dados Em seguida, construímos a Porque se você acha que ter múltiplas instâncias do seu DB é uma boa ideia... você provavelmente também gosta de conflitos de fusão na produção. RxDatabaseManager Aqui está a classe em toda a sua glória: //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(); } } Esta pequena besta: Coloque o DB. Relógios para a internet como um ex clingy. Sincroniza-se assim que voltarmos online. E sim, ele registra tudo.O futuro me agradecerá na próxima vez que debugue. Step 3 — Bootstrapping the DB When the App Starts Passo 3 – Iniciar o DB quando o aplicativo é iniciado Quando o aplicativo é lançado, eu coloquei o meu Não há mágica aqui – apenas o bom ol’ fazer a sua coisa. RxDatabaseManager useEffect Se algo explodir durante o init, eu o log. Porque fingir que erros não existem é como você obtém aplicativos assombrados. //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) Passo 4 – Replicação de dados (a.k.a. sincronização sem lágrimas) Quando o aplicativo passa do "modo caverna offline" de volta online, Isso desliga a sincronização de dados entre o DB local e o servidor via . onReconnected() replicateRxCollection Aqui está o gerenciador de pull básico - RxDB envia um checkpoint ( , em ) para que o servidor saiba onde paramos. porque ninguém quer pegar o Cada vez que o DB. updatedAt id inteira //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, }; }, No servidor, eu interrogo apenas as coisas novas / atualizadas desde o último checkpoint. Porque a largura de banda é preciosa, e assim é a minha paciência. //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); } E então o servidor envia de volta os dois documentos e um novo ponto de verificação brilhante: //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 Passo 5 - Puxar as alterações locais de volta para o servidor O RxDB também rastreia o que mudou localmente desde a última sincronização e o empurra para cima.Pense nisso como o Dropbox para os dados do seu aplicativo - sem os arquivos aleatórios "cópia conflituosa" (bem... a menos que você gerencie conflitos mal). //instance.ts async handler(changeRows) { const response = await api.push({ changeRows }); return response.data; }, No backend, eu verifique cada mudança de entrada: Se não houver conflito, ele vai direto. Se a versão do servidor for mais recente, lanço-a na pilha de conflitos. //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) Passo 6 – Resolução de conflitos (A “Última Atualização Ganha” Gambit) Aqui é onde as pessoas geralmente complicam as coisas.Sim, você Construa uma estratégia de fusão de classe da NASA. Ou... você poderia ir com “última atualização ganha” e chamá-lo de um dia. Poderia Não é sempre o movimento certo, mas no nosso caso - simples, rápido, bom o suficiente. //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 Passo 7 – Mantenha a UI em sincronização Após o init, o status do aplicativo flips para , e nós apenas... usá-lo. sem estranhos botões de atualização manual, sem "tap para recarregar" bobagem. 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> ); }; Assine as alterações no DB, então a interface se atualiza.Este é um daqueles momentos "que funcionam como mágica" que você não deve pensar muito sobre - apenas aproveite. 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 Pensamentos finais Criamos um aplicativo que: Funciona offline sincronização automática. Não importa se o seu Wi-Fi está tendo uma crise existencial. Usamos a mesma ferramenta para , onde a conectividade é ... digamos apenas “modo de aventura urbana”. os pilotos podem agora finalizar pedidos, tirar fotos de prova de entrega e marcar entregas completas sem internet. Sizl’s dark kitchen riders in Chicago Certamente, os casos do mundo real podem ficar piores – vários dispositivos atualizando o mesmo registro, apagando dados relacionados, conjuntos de dados maciços.Mas o padrão permanece. Absolutamente — a menos que você goste de bilhetes de raiva do usuário que começam com “Eu estava offline e...” 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 O GitHub