WatermelonDB w React Native

Poniższy artykuł to jedna z części tworzonego na bieżąco kursu omawiającego najważniejsze aspekty pracy z React Native. Chcesz przejść do pozostałych lekcji?

Znajdziesz je tutaj: Wprowadzenie do React Native

Częstym problemem przy tworzeniu aplikacji mobilnych działających w trybie online jest wybór odpowiedniej bazy danych działającej lokalnie na urządzeniu. W tym wpisie postaram się przybliżyć działanie na takiej bazie od strony praktycznej, tworząc prostą aplikację To Do we frameworku React Native z wykorzystaniem bazy danych WatermelonDB.

WatermelonDB jest biblioteką zbudowaną na podstawie SQLite przystosowaną specjalnie dla React / React Native, wnoszącą znaczną poprawę wydajności i oferującą ciekawe narzędzia. Jednak najciekawszą funkcją z perspektywy widoku aplikacji jest możliwość obserwowania zbiorów danych, a w przypadku ich zmiany automatyczne ponowne zrenderowanie widoku.

Nowy projekt

Przed rozpoczęciem pracy, musimy przygotować środowisko. Szczegółowa instrukcja jest dostępna tutaj: https://reactnative.dev/docs/environment-setup.

Następnie w nowym oknie terminala, po wybraniu odpowiedniego katalogu stwórzmy nowy projekt React Native, wykorzystując polecenie:

react-native init ToDoApp

Kolejnym elementem początkowej konfiguracji jest instalacja sytemu bazy danych. Na początku zainstalujmy wymagane biblioteki używając poleceń:

Instalacja WatermelonDB

Kolejnym elementem początkowej konfiguracji jest instalacja sytemu bazy danych. Na początku zainstalujmy wymagane biblioteki używając poleceń:

yarn add @nozbe/watermelondb
yarn add @nozbe/with-observables
yarn add --dev @babel/plugin-proposal-decorators

Następnie utwórzmy nowy plik konfiguracyjny .babelrc umożliwiający stosowanie dekoratorów w kodzie Javascript.

{ 
  "presets": ["module:metro-react-native-babel-preset"],
  "plugins": [
    ["@babel/plugin-proposal-decorators", { "legacy": true }]
  ] 
}

Konfiguracja iOS

Aby poprawnie zalinkować bibliotekę w aplikacji iOS, wejdźmy w xCode i w katalogu głównym aplikacji stwórzmy nowy plik z rozszerzeniem swift (nazwa nie odgrywa tutaj znaczącej roli). Po stworzeniu Xcode zapyta nas czy chcemy utworzyć również tzw. Bridging Header – wybieramy Create Bridging Header.

Dodawanie możliwości wykonywania plików Swift.
Dodawanie możliwości wykonywania plików Swift

Na dzień dzisiejszy niestety brak jest wsparcia dla CocoaPods, więc będziemy musieli zaimportować bibliotekę manualnie.

W Xcode w bocznym panelu wybieramy główny projekt i katalog Libraries. Klikamy na nim PPM oraz wybieramy opcję Add Files.
Odnajdujemy plik node_modules/@nozbe/watermelondb/native/ios/WatermelonDB.xcodeproj dodajemy referencję do projektu.

Następnie wybieramy po kliknięciu na projekt w bocznym panelu, przechodzimy do sekcji Build Phases. W zakładce Link Binary With Libraries, dodajemy nowy element o nazwie libWatermelonDB.a.

Poprawnie dodana biblioteka WatermelonDB w Xcode
Poprawnie dodana biblioteka WatermelonDB w Xcode

Konfiguracja Android

Niestety WatermelonDB ma spory problem z automatycznym zalinkowaniem biblioteki na Androidzie, więc trzeba jej odrobinę pomóc. Dlatego potrzebne jest wykonanie szeregu akcji modyfikacji plików projektu.
Poniżej lista modyfikacji:

android/settings.gradle:

include ':watermelondb'
project(':watermelondb').projectDir =
    new File(rootProject.projectDir, '../node_modules/@nozbe/watermelondb/native/android')

android/app/build.gradle:

apply plugin: "com.android.application"
apply plugin: 'kotlin-android'

... 

dependencies {  
  ... 
  implementation project(':watermelondb')
}

android/build.gradle:

buildscript {
  ext.kotlin_version = '1.3.21'

  ...

  dependencies { 
    ...
    classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" 
  }
}

android/app/src/main/java/com/todoapp/MainApplication.java:

...

import com.nozbe.watermelondb.WatermelonDBPackage;

...

@Override protected List<ReactPackage> getPackages() {
  return Arrays.<ReactPackage>asList( new MainReactPackage(), new WatermelonDBPackage());
}

Po tak przeprowadzonej konfiguracji początkowej, możemy utworzyć bazę danych.

Konfiguracja bazy danych

Po przygotowaniu biblioteki od strony natywnej, przechodzimy do tworzenia struktury i modelu bazy danych. Naszym celem będzie przygotowanie odpowiedniego schematu zawierającego informacje istotne z punktu widzenia działania naszej aplikacji.

Aby odseparować pliki związane z powyższym tematem utwórzmy w katalogu głównym projektu nowy katalog o nazwie database, w którym będziemy przechowywać pliki bazy danych i modeli.

Schemat bazy

Najpierw musimy utworzyć plik odpowiedzialny za schemat tabel naszej bazy. Utwórzmy więc plik schema.js o zawartości:

import {appSchema, tableSchema} from '@nozbe/watermelondb';

export const mySchema = appSchema({
  version: 1,
  tables: [],
});

W tablicy będącej wartością pola tables, będziemy definiować schematy tabel bazy danych. W naszej aplikacji (jak sama nazwa To Do wskazuje), będziemy pracować na zadaniach. Dlatego musimy stworzyć odpowiednią tabelę, która będzie przechowywała o nich informacje. W tablicy tej dodajmy więc:

tableSchema({
  name: 'tasks',
  columns: [
    {name: 'name', type: 'string'},
    {name: 'completed', type: 'boolean'},
  ],
}),

Nasza tabela będzie zawierała dwie kolumny: nazwę i status wykonania. Przy ich tworzeniu należy również podać typ danych.

Typy kolumn

Każda kolumna może mieć przypisany jeden z trzech typów: stringnumber oraz boolean. W przypadku gdy dopuszczamy by pole było opcjonalne ustawiając wartość isOptional: true to w polu może znaleść się również wartość null.

Przygotowanie providera

Aby z poziomu aplikacji uzyskać dostęp do bazy danych, należy skonfigurować tzw. Provider’a. W tym celu zmodyfikujemy plik App.js znajdującym się w katalogu głównym projektu.

Na początku zaimportujmy potrzebne metody biblioteki:

import {Database} from '@nozbe/watermelondb';
import DatabaseProvider from '@nozbe/watermelondb/DatabaseProvider';
import SQLiteAdapter from '@nozbe/watermelondb/adapters/sqlite';

Jak wcześniej wspomniałem, WatermelonDB jest biblioteką działającą nad SQLite. Dlatego dodajmy odpowiedni adapter:

import {mySchema} from './database/schema';

...

const adapter = new SQLiteAdapter({
  schema: mySchema,
});

Schematem mySchema jest tutaj nasz wyżej utworzony schemat tabel. Następnie przechodzimy do zainicjowania samej bazy danych:

const database = new Database({
  adapter,
  modelClasses: [],
  actionsEnabled: true,
});

W polu modelClasses, będziemy za chwilę umieszczać model naszego zadania. Zanim to jednak zrobimy, przyjrzyjmy się głównej klasie App.js:

export default class App extends Component {
  render = () => {
    return (
      <DatabaseProvider database={database}>
        <SafeAreaView style={styles.container}>
          {/* ... */}
        </SafeAreaView>
      </DatabaseProvider>
    );
  };
}

Dzięki zastosowaniu komponentu DataProvider, mamy możliwość odwoływania się do bazy danych z poziomu wszystkich dzieci tego komponentu.

Model zadania

Definicja schematu nie jest definicją modelu. Głównym obiektem na którym będziemy działać w naszej aplikacji jest Zadanie, więc musimy stworzyć jego model (definicję klasy). W katalogu database dodajmy nowy plik o nazwie Task.js.

import {Model} from '@nozbe/watermelondb';

export default class Task extends Model {
  static table = 'tasks';
}

Następnie dodajmy odpowiednie atrybuty. Nazwy w field odpowiadają nazwie kolumny wcześniej utworzonej tabeli. 

import {field} from '@nozbe/watermelondb/decorators';

...

@field('name') name;
@field('completed') completed;

Całość pliku wygląda tak:

import {Model} from '@nozbe/watermelondb';
import {field} from '@nozbe/watermelondb/decorators';

export default class Task extends Model {
  static table = 'tasks';

  @field('name') name;
  @field('completed') completed;
}

Powróćmy do pliku App.js. Przy definicji bazy danych dodaliśmy pole modelClasses, które zawierało pustą tablicę. Dodajmy jako jej element wyżej utworzony model Task:

import Task from './database/Task';

...

const database = new Database({
  adapter,
  modelClasses: [Task],
  actionsEnabled: true,
});

Aby sprawdzić czy wszystko działa jak należy, możemy uruchomić aplikację używając polecenia:

react-native run-ios

Jeśli biblioteka została poprawnie zainstalowana i zalinkowana oraz konfiguracja została przeprowadzona w prawidłowy sposób, na ekranie powinno się pojawić białe tło oraz brak żadnego błędu.

Po poprawnym skonfigurowaniu WatermelonDB oraz przygotowaniu modelu i schematu tabeli możemy przystępować do kolejnych czynności.

Widok aplikacji

Najpierw warto przygotować widok. Zastanówmy się co konkretnie będziemy potrzebować. Każde nowe zadanie powinno być elementem listy, dodatkowo powinniśmy każde zadanie móc usunąć, zmienić nazwę, odznaczyć i zaznaczyć. Na początku utwórzmy nowy katalog o nazwie pages w których będziemy przechowywali nasz widok, a w nim nowy plik TasksPage.js. W pliku tym dodajmy na początku prosta klasę (pamiętając o zaimportowaniu potrzebnych bibliotek), w której będzie nasza lista zadań.

class TasksPage extends Component {
  render = () => {
    return (
      <>
      </>
    );
  };
}

Istotną cechą biblioteki WatermelonDB, jest możliwość wykorzystania komponentów, które będą reagować na zmiany w bazie. W przypadku zmiany elementów listy zadań w bazie danych, będzie ona mogła zostać zaktualizowana na widoku. Aby to zrobić, należy umożliwić zaobserwowanie komponentu:

import withObservables from '@nozbe/with-observables';
import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider';

...

export default withDatabase(
  withObservables([], ({database}) => ({
    tasks: database.collections.get('tasks').query().observe(),
  }))(TasksPage),
);

Teraz używając props komponentu możemy odwoływać się do elementu tasks, który jest kolekcją rekordów tabeli tasks w bazie danych. Dodajmy Flatlistę w celu wyświetlenia elementów w funkcji render():

render = () => {
  const {tasks} = this.props;

  return (
    <>
      <FlatList
        data={tasks}
        ListHeaderComponent={this.renderHeader}
        renderItem={this.renderTask}
        keyExtractor={(item) => 'item' + item.id}
      />
    </>
  );
};

Aby przygotować renderowanie elementu dodajmy nową bibliotekę dodającą komponent CheckBox. W terminalu w katalogu projektu wykonajmy polecenie:

yarn add react-native-check-box

Teraz dodajmy metodę renderTask():

renderTask = ({item}) => (
  <View style={[styles.itemContainer, styles.insideContainer]}>
    <View style={styles.insideContainer}>
      <CheckBox
        isChecked={item.completed}
        style={styles.checkbox}
        checkBoxColor="#666666"
        uncheckedCheckBoxColor="#666666"
        checkedCheckBoxColor="#38d13b"
        onClick={() => this.onChangeItemCompleted(item)}
      />
      <TextInput
        style={styles.input}
        value={item.name}
        onChangeText={(value) => this.onChangeItemName(item, value)}
      />
    </View>
    <TouchableOpacity onPress={() => this.removeTask(item)}>
      <Image style={styles.icon} source={require('../assets/remove.png')} />
    </TouchableOpacity>
  </View>
);

Funkcje które są używane, zostaną za chwilę wyjaśnione. Przejdźmy jednak do dodania pola, umożliwiającego dodawanie nowego zadania:

renderHeader = () => (
  <View style={[styles.itemContainer, styles.insideContainer]}>
    <View style={styles.insideContainer}>
      <TextInput
        style={[styles.input, styles.headerInput]}
        placeholder="Dodaj nazwę zadania"
        onChangeText={this.onChangeNewTaskName}
        value={this.state.newTaskName}
      />
    </View>
    <TouchableOpacity style={[styles.rightButton, styles.addButton]}>
      <Image style={styles.icon} source={require('../assets/add.png')} />
    </TouchableOpacity>
  </View>
);

Dodajmy też kilka styli, w celu delikatnego poprawienia wyglądu naszej aplikacji:

const styles = StyleSheet.create({
  itemContainer: {
    padding: 15,
  },
  insideContainer: {
    flexDirection: 'row',
    alignItems: 'center',
    justifyContent: 'space-between',
    flex: 1,
  },
  checkbox: {
    width: 30,
    marginRight: 15,
    borderColor: '#666666',
  },
  icon: {
    height: 30,
    width: 30,
  },
  input: {
    flex: 1,
  },
  headerInput: {
    marginLeft: 45,
  },
});

Po przygotowaniu widoku, należy obsłużyć logikę aplikacji.

Akcje bazy danych

Krok 1

Dodawanie nowego zadania

Przy dodawaniu nowego zadania, konieczne jest podanie jego nazwy. w tym celu w klasie dodajmy definicję state oraz metodę umożliwiająca jej zmianę.

state = {
  newTaskName: '',
};

...

onChangeNewTaskName = (value) => {
  this.setState({newTaskName: value});
};

Po kliknięciu w przycisk dodawania nowego elementu, wywołujemy metodę

addTask = async () => {
  const {database} = this.props;
  const {newTaskName} = this.state;
  const tasksCollection = database.collections.get('tasks');

  await database.action(async () => {
    await tasksCollection.create((task) => {
      task.name = newTaskName;
      task.completed = false;
    });
    this.setState({newTaskName: ''});
  });
};

Najpierw musimy wskazać z której kolekcji będziemy pobierać elementy, a jako że w naszym przypadku jej nazwa to tasks, to taką właśnie pobieramy. Następnie na niej przeprowadzamy akcję na bazie danych dodania nowego elementu kolekcji. Po dodaniu elementu do bazy, warto wyczyścić pole tekstowe z nazwy zadania, dlatego w tym celu została użyta funkcja setState().

Krok 2

Edycja zadania

Edycję można podzielić na dwa elementy. Pierwszy z nich to zmiana stanu wykonania zadania, bo kliknięciu na pole. Druga natomiast, to zmiana nazwy zadania. W celu większej przejrzystości podzielimy to na dwie metody, wśród których każda będzie wykonywała inne zadanie.

onChangeItemName = async (item, name) => {
  const {database} = this.props;
  const tasksCollection = database.collections.get('tasks');
  const taskToUpdate = await tasksCollection.find(item.id);

  await database.action(async () => {
    await taskToUpdate.update((task) => {
      task.name = name;
    });

    this.setState({refreshing: !this.state.refreshing});
  });
};

onChangeItemCompleted = async (item) => {
  const {database} = this.props;
  const tasksCollection = database.collections.get('tasks');
  const taskToUpdate = await tasksCollection.find(item.id);

  await database.action(async () => {
    await taskToUpdate.update((task) => {
      task.completed = !item.completed;
    });

    this.setState({refreshing: !this.state.refreshing});
  });
};

Jest to dość proste zadanie. Po wybraniu odpowiedniej kolekcji, znajdujemy element który chcemy edytować, na podstawie jego automatycznie generowanego id.

W tym przypadku należy zastosowac jednak pewną sztuczkę. Flatlist z definicji nie będzie informowana o zmianie w elementach tablicy data. Dlatego należy ją o tym poinformować. W tym celu w state widoku utworzyłem atrybut typu boolean, który zmienia swoją wartość gdy wykonana zostanie jakaś zmiana w bazie. Dodajemy również do flatlisty atrybut extraData, którego wartością będzie wyżej wymieniona wartość state. Dzięki temu po każdej akcji zmiany elementu w bazie, flatlista będzie mogła przeładować zmienione elementy.

Krok 3

Usuwanie zadania

Ostatnią funkcjonalnością będzie usunięcie zadania z listy. Ponownie tworzymy oddzielną metodę za to odpowiedzialną. Znajdujemy kolekcję oraz element który chcemy z niej usunąć i przeprowadzamy akcję usunięcia permanentnego. Możemy skorzystać w zastępstwie z metody markAsDeleted(), która doda do pola flagę informującą o usunięciu, jednak fizycznie rekord taki będzie można dalej odtworzyć.

removeTask = async (item) => {
  const {database} = this.props;
  const tasksCollection = database.collections.get('tasks');
  const taskToRemove = await tasksCollection.find(item.id);

  await database.action(async () => {
    await taskToRemove.destroyPermanently();
  });
};

Testy

Przetestujmy zatem działanie naszej aplikacji.

Test operacji modyfikacji zadań

Istotne będzie również sprawdzenie, jak zachowa się aplikacja po jej zrestartowaniu (tj. czy baza rzeczywiście działa w trybie offline).

Test działania w trybie offline

Podsumowanie

Przykład tej aplikacji pokazuje, że stworzenie aplikacji z lokalną bazą danych nie jest trudnym zadaniem. Dzięki gotowym bibliotekom bazodanowym mamy dostęp do znacznie szybszych i bardziej przyjaznych programiście metod. Artykuł ten i poprzedni stanowią jednak tylko wstęp do poznania tematu, który jest znacznie bardziej rozbudowany i zawiera wiele ciekawych niuansów.

Dodatkowe informacje dostępne w dokumentacji: https://nozbe.github.io/WatermelonDB/

React Native podstawowe animacje
Testowanie aplikacji React Native

Chcesz pracować przy realizacji ciekawych projektów razem z gronem specjalistów z różnych dziedzin? Lepiej nie mogłeś trafić 🙂

Sprawdź nasze aktualne oferty pracy w zakładce Kariera!

Adam Gałęcki

Internet Marketing Specialist | Na co dzień tworzę content i prowadzę kampanie SEO.

Zostaw komentarz:

Witryna jest chroniona przez reCAPTCHA i Google Politykę Prywatności oraz obowiązują Warunki Korzystania z Usługi.