你知道那一刻,当你在无处不在的时候,你的4G变成“E”,你的移动应用决定成为一个非常昂贵的纸重吗?是的。 想象一个交付驱动程序盯着一个加载旋转器,因为应用程序无法在没有互联网的情况下加载其路线,或者一个仓库经理无法拉出库存列表,因为服务器处于低迷状态。 我们的许多客户都遇到这些连接漏洞 - 远程工作场所,离场活动,仓库楼层,物流中心. 基本上,在任何地方 Wi-Fi 都会死亡. 解决方案? 局部第一应用程序:本地处理数据,随时同步。 在这个小冒险中,我将向你展示我如何建立一个本地的首个移动应用程序。 你会看到: React Native + RxDB 如何双向同步实际上没有融化您的服务器。 如何处理数据冲突等“有趣”情况。 在设计这些动物时不要忘记什么。 另外 — 我会分享一个“最后的更新赢得”的技巧. 不是总是正确的选择,但在我们的情况下...厨师的吻。 The Stack (a.k.a. My Chosen Weapons) 《我所选择的武器》(My Chosen Weapons) 对于这个建筑,我滚了: React Native - 跨平台魔法:一个代码库,iOS + Android。 react-native-nitro-sqlite - 因为没有SQLite的本地存储就像没有奶酪的披萨。 RxDB - 离线第一,反应式DB,与同步玩得很好。 NestJS + TypeORM + PostgreSQL - 后端梦想团队。 最终结果:应用程序离线运行,后期同步,并在没有崩溃的情况下存活在隐患的连接中。 Step 1 — Local Storage via SQLite 步骤 1 – 通过 SQLite 进行本地存储 首先,我需要一个本地数据库,RxDB可以很高兴地滥用SQLite是显而易见的选择,但我用一些额外的权力 - 验证和加密 - 因为数据完整性和隐私很重要(也因为我的未来自己会感谢我)。 //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 }; 是的,这就是包装的三个层次,就像一根洋葱,或者是一个企业应用程序,中间件层太多了。 Step 2 — Creating the Database Instance 步骤 2:创建数据库实例 接下来,我建了一个 因为如果你认为有多个例子你的DB是一个好主意......你可能也喜欢在生产中的混合冲突。 RxDatabaseManager 这里是整个荣耀的班级: //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(); } } 这个小野兽: 把 DB 放进去。 看互联网就像一个脏的前身。 当我们回到网上时,我们会同步。 是的,它记录了一切,未来我会在下次调试时感激。 Step 3 — Bootstrapping the DB When the App Starts 步骤 3 – 启动应用程序启动时启动 DB 当 app 启动时,我将我的 没有魔法在这里 - 只有好Ol’ 做自己的事。 RxDatabaseManager useEffect 如果 init 期间有什么东西爆炸,我会记录它,因为假装错误不存在,这就是你如何得到迷惑的应用程序。 //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) 步骤 4 – 数据复制(即无眼泪的同步) 当应用程序从“离线洞穴模式”返回在线时, 启动了本地DB和服务器之间的数据同步 . onReconnected() replicateRxCollection 这里是基本的拉动处理器 - RxDB 发送检查点( , )所以服务器知道我们在哪里停下来,因为没有人想要接收 每一次DB updatedAt id 全部 //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, }; }, 在服务器上,我只查询自上次检查点以来的新 / 更新的东西,因为带宽是宝贵的,我的耐心也是如此。 //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); } 然后,服务器会发送两个文件并发送一个闪亮的新检查点: //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 步骤 5 – 将本地更改推回服务器 RxDB 还跟踪自上次同步以来的局部变化,并将其推高。 //instance.ts async handler(changeRows) { const response = await api.push({ changeRows }); return response.data; }, 在后端,我检查每一个进入的变化: 如果没有冲突,它直接进入。 如果服务器的版本更新,我会把它扔进冲突堆。 //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) 步骤 6 – 冲突解决(“最后更新获胜”Gambit) 这是人们通常过于复杂的事情的地方。 建立NASA级的合并策略,或者......你可以用“最后的更新获胜”,并称之为一天。 能 它并不总是正确的举动,但在我们的情况下 - 简单,快速,足够好。 //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 步骤 7 – 保持 UI 在同步 在 init 之后,应用程序状态会转向 没有奇怪的手动刷新按钮,没有“点击重新加载”的荒谬。 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> ); }; 订阅了DB的更改,所以用户界面更新了自己. 这是你不应该太思考的“像魔法一样工作”的时刻之一 - 只是享受它。 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 最后的想法 我们基本上建立了一个应用程序,该应用程序: 离线工作。 自动同步。 不管你的Wi-Fi是否处于生存危机。 我们使用了相同的设置为 现在,骑手可以完成订单,拍摄交付证明照片,并标记没有互联网的交付完成。 Sizl’s dark kitchen riders in Chicago 当然,现实世界的案例可能会变得更糟糕 - 更新相同记录的多个设备,相关数据删除,巨大的数据集。 我会建议先去本地吗?绝对 — 除非你喜欢以“我离线并...”开始的用户愤怒票。 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 吉普赛