Как я построил местные первые приложения с React Native + RXDB (и почему ваше приложение, вероятно, тоже нуждается в этом)

Как я построил местные первые приложения с React Native + RXDB (и почему ваше приложение, вероятно, тоже нуждается в этом)

11 августа 2025 г.

Вы знаете в тот момент, когда вы находитесь в глуши, ваш 4G превращается в «E», и ваше мобильное приложение решает стать очень дорогим пресс -папье? Ага. Именно поэтому я написал это.

Представьте себе драйвер доставки, смотрящий на загрузочный прядильщик, потому что приложение не может загрузить свой маршрут без Интернета. Или склад -менеджер, не может получить список инвентаризации, потому что сервер падает. Это не «причудливое поведение», это потерянное время, потерял деньги и очень злых пользователей.

Куча наших клиентов попадает в эти выбоины - удаленные сайты работы, события вне места, складские полы, логистические центры. По сути, везде, где Wi-Fi умирает. Исправление? Локальные приложения: обрабатывайте данные локально, синхронизируйте их, когда можете. Ваши пользователи остаются счастливыми, и вы не получаете полуночные звонки «приложение сломано».

В этом маленьком приключении я покажу вам, как я построил местное мобильное приложение сОтреагировать Native + RXDBПолем Вы увидите:

  • Как двухсторонняя синхронизация на самом деле работает без плавления вашего сервера.
  • Как справиться с «веселыми» ситуациями, такими как конфликты данных.
  • Чтонетзабыть при разработке одной из этих зверей.

Кроме того - я поделюсь одним трюком «Последнее обновление побед». Не всегда правильный выбор, но в нашем случае ... поцелуй шеф -повара.

Стек (a.k.a. мое выбранное оружие)

Для этой сборки я катался с:

  • Реагировать на родной-Кроссплатформенная магия: одна кодовая база, iOS + Android.
  • Реактивно-н-нитро-квлит- Потому что местное хранение без SQLite похоже на пиццу без сыра.
  • Rxdb-Оффлайт-первый, реактивный дБ, который хорошо играет с синхронизацией.
  • Nestjs + typeorm + postgresql- Бэкэндская команда мечты.

Конечный результат: приложение работает в автономном режиме, синхронизируется позже и переживает хитрые соединения, не имея распада.

Шаг 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 };

Да, это три слоя упаковки. Как лук. Или предприятие с слишком большим количеством промежуточных слоев.

Шаг 2 - Создание экземпляра базы данных

Далее я построил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();
  }
}

Этот маленький зверь:

  • Устанавливает БД.
  • Смотрит на Интернет, как липкий бывший.
  • Синхронизируется, как только мы вернемся в Интернете.

И да, это все регистрирует. Будущее меня будет благодарен при отладке в следующий раз.

Шаг 3 - начальная загрузка DB, когда приложение запускается

Когда приложение запускается, я вращаю свой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();
}, []);

Шаг 4 - Репликация данных (a.k.a. синхронизировать без слез)

Когда приложение переходит от «автономного пещерного режима» назад, онлайн,onReconnected()пожары. Это начинает синхронизацию данных между локальным DB и сервером черезreplicateRxCollectionПолем

Вот базовый обработчик тяги - RXDB отправляет контрольную точку (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,
  };
}

Шаг 5 - Нажатие локальных изменений обратно на сервер

RXDB также отслеживает то, что изменилось локально с момента последней синхронизации, и подталкивает его. Подумайте об этом, как Dropbox для данных вашего приложения - без случайных файлов «конфликтной копии» (ну ... если только вы не справляетесь с конфликтами плохо).

//instance.ts
async handler(changeRows) {
  const response = await api.push({ changeRows });
  return response.data;
},

На бэкэнд я проверяю каждое входящее изменение:

  • Если нет конфликта, он идет прямо в.
  • Если версия сервера новее, я бросаю его вconflictsкуча.
//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;
}

Шаг 6 - Резолюция конфликтов («Последнее обновление выигрывает» Гамбит)

Вот где люди обычно переполняют вещи. Да, тымогСоздайте стратегию слияния НАСА. Или… вы можете пойти с «Последним обновлением побед» и назвать это днем.

Это не всегда правильный ход, но в нашем случае - простой, быстро, достаточно хорош.

//utills.ts

export const defaultConflictHandler: RxConflictHandler<{
  updatedAt: number;
}> = {
  isEqual(a, b) {
    return a.updatedAt === b.updatedAt;
  },
  resolve({ assumedMasterState, realMasterState, newDocumentState }) {
    return Promise.resolve(realMasterState);
  },
};

Шаг 7 - Ведение пользовательского интерфейса синхронизируется

После 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>
  );
};

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;
};

Последние мысли

Мы в основном построили приложение, которое:

  • Работает в автономном режиме.
  • Синхронизируется автоматически.
  • Не заботится о том, имеет ли ваш Wi-Fi экзистенциальный кризис.

Мы использовали эту же настройку дляSizl's Dark Kitchen Riders в Чикаго, где подключение ... давайте просто скажем «режим городских приключений». Всадники теперь могут заканчивать заказы, делать фотографии с доставкой доставки и маркировать доставку без интернета. Приложение синхронизирует позже.

Конечно, дела в реальном мире могут стать гнаровыми-несколько устройств, обновляющих одну и ту же запись, связанные с ним делеции данных, массивные наборы данных. Но шаблон держит. Вам просто нужно расширить его с помощью более умного разрешения конфликтов и более гибкой архитектуры.

Я бы порекомендовал пойти по местному первому? Абсолютно - если вам не нравятся билеты на ярость пользователя, которые начинаются с «Я был офлайн и…»

Нажмите на ссылку в моей биографии для получения дополнительной информации об этом и других проектах!

Чтобы проверить нашу панель администратора с открытым исходным кодом в React, см. НашGitHubПолем


Оригинал
PREVIOUS ARTICLE
NEXT ARTICLE