ObjectBox - 用于Flutter的快速本地数据库,可在不同设备间进行同步!

1,778 阅读10分钟

当你选择一个你要在下一个项目中使用的数据库时,做出明智和明智的选择是好事。ObjectBox是一个非常快速的NoSQL本地数据库,适用于Flutter和原生的Android/iOS,具有直观的API,对查询和关系有丰富的支持,另外你可以选择 在多个设备上 保持数据库同步 ,而不需要你的任何麻烦。

我们将建立一个 "商店订单应用程序",显示各个客户的订单。你将会学到很多东西--实体的设置、关系、订购和反应式查询。首先,我们将只关注本地数据库的功能,然后我将向你展示用ObjectBox Sync跨设备同步你的数据是多么简单。

完成的应用程序

该应用程序的外观和行为将与下面的视频一样。它有一个DataTable widget,以行的形式显示所有的订单。一行显示订单ID客户名称(通过数据库关系获得),价格,最后是一个删除按钮

为了保持简单,我们不会有任何表格来输入关于订单和客户的数据。相反,我们添加新的订单,并通过点击AppBar 中的按钮来切换与订单相关的客户。我们将使用一个叫做Faker的包来获得假的客户名称等东西。

我们可以通过点击DataTable 中的列名对显示的订单按其ID或价格进行升序或降序排序。

最后,点击客户名称将打开一个模式底单,只显示相应客户的订单--同样,订单和客户实体之间的关系将在这方面派上用场。

开始工作

为了快速启动和运行,从下面抓取启动项目。它包含了在屏幕上显示东西所需的所有部件,然而,这些部件没有任何类和数据可以使用--这将是我们现在要实现的工作。因为我们想专注于ObjectBox数据库,我们将使用一个简单的StatefulWidget 来进行状态管理。

初始项目

完成的项目

包含AppBar 按钮的HomePage widget已经准备好了一切,所以我们可以立即开始编写非boilerplate代码。我们也要依靠启动项目中的faker 包。

home_page.dart

import 'package:faker/faker.dart';
import 'package:flutter/material.dart';

import 'order_data_table.dart';

class HomePage extends StatefulWidget {
  @override
  _HomePageState createState() => _HomePageState();
}

class _HomePageState extends State<HomePage> {
  final faker = Faker();

  @override
  void initState() {
    super.initState();
    setNewCustomer();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('Orders App'),
        actions: [
          IconButton(
            icon: Icon(Icons.person_add_alt),
            onPressed: setNewCustomer,
          ),
          IconButton(
            icon: Icon(Icons.attach_money),
            onPressed: addFakeOrderForCurrentCustomer,
          ),
        ],
      ),
      body: OrderDataTable(
        // TODO: Pass in the orders
        onSort: (columnIndex, ascending) {
          // TODO: Query the database and sort the data
        },
      ),
    );
  }

  void setNewCustomer() {
    // TODO: Implement properly
    print('Name: ${faker.person.name()}');
  }

  void addFakeOrderForCurrentCustomer() {
    // TODO: Implement properly
    print('Price: ${faker.randomGenerator.integer(500, min: 10)}');
  }
}

OrderDataTable widget也已经为你写好了所有的boilerplate。我们正在使用DataTable widget,它指定了它的columns ,最重要的是rows --这是从数据库来的数据将被显示。由于DataTable 是Flutter的一个核心部件,我已经希望你能理解它的工作原理。如果你不知道,我建议你去看看Flutter的官方文档

同样,所有的东西(包括排序回调函数)都已经实现了,我们所需要的就是添加一些实际的数据来显示。

order_data_table.dart

import 'package:flutter/material.dart';

class OrderDataTable extends StatefulWidget {
  final void Function(int columnIndex, bool ascending) onSort;

  const OrderDataTable({
    Key? key,
    required this.onSort,
  }) : super(key: key);

  @override
  _OrderDataTableState createState() => _OrderDataTableState();
}

class _OrderDataTableState extends State<OrderDataTable> {
  bool _sortAscending = true;
  int _sortColumnIndex = 0;

  @override
  Widget build(BuildContext context) {
    return SingleChildScrollView(
      scrollDirection: Axis.horizontal,
      child: SingleChildScrollView(
        child: DataTable(
          sortColumnIndex: _sortColumnIndex,
          sortAscending: _sortAscending,
          columns: [
            DataColumn(
              label: Text('Number'),
              onSort: _onDataColumnSort,
            ),
            DataColumn(
              label: Text('Customer'),
            ),
            DataColumn(
              label: Text('Price'),
              numeric: true,
              onSort: _onDataColumnSort,
            ),
            DataColumn(
              label: Container(),
            ),
          ],
          rows: [
            DataRow(
              cells: [
                DataCell(
                  Text('ID'),
                ),
                DataCell(
                  Text('CUSTOMER NAME'),
                  onTap: () {
                    // TODO: Show only tapped customer's orders in a modal bottom sheet
                  },
                ),
                DataCell(
                  Text(
                    '\$PRICE',
                  ),
                ),
                DataCell(
                  Icon(Icons.delete),
                  onTap: () {
                    // TODO: Delete the order from the database
                  },
                ),
              ],
            ),
          ],
        ),
      ),
    );
  }

  void _onDataColumnSort(int columnIndex, bool ascending) {
    setState(() {
      _sortColumnIndex = columnIndex;
      _sortAscending = ascending;
    });
    widget.onSort(columnIndex, ascending);
  }
}

添加依赖性

我们需要在pubspec中添加一系列的依赖,以便在我们的Flutter应用中使用ObjectBox--有objectbox,然后是一个插件objectbox_flutter_libs,最后是一个开发依赖objectbox_generator。除了这三个,我们还要依赖path_provider来获得我们可以在Android和iOS上存储本地数据库文件的目录。

pubspec.yaml

environment:
  sdk: '>=2.12.0 <3.0.0'

dependencies:
  flutter:
    sdk: flutter
  # Version 1.0.0 is coming soon
  # It will be fully compatible with 0.15.0
  objectbox: ^0.15.0
  objectbox_flutter_libs: any
  path_provider: ^2.0.1
  faker: ^2.0.0

dev_dependencies:
  flutter_test:
    sdk: flutter
  build_runner: any
  objectbox_generator: any

官方博文中了解更多关于1.0版本的信息。

特定平台的设置

ObjectBox只支持iOS上的64位架构,过去几年所有的设备都已经在这个架构上运行。尽管如此,你还是需要在XCode中说明这一点。如果你是在Mac上,右击Flutter项目中的ios 文件夹,选择**"在XCode中打开**"。一旦你到了那里,按照下图的步骤,将架构设置为$ARCHS_STANDARD_64_BIT

此外,只支持iOS 11及以上版本,所以我们要确保将其设置为最低的iOS版本。

如果你只使用本地的ObjectBox数据库,Android不需要对本地的Android项目进行任何这样的更新。然而,在本教程的最后,我将向你展示添加跨设备ObjectBox Sync是多么容易,为此我还需要将最小的安卓版本提升到SDK 21。如果你不使用Sync,不需要这样做。

android/app/build.gradle

...
android {
    ...
    defaultConfig {
        applicationId "com.resocoder.objectbox_prep"
        minSdkVersion 21 // 👈 bump this
        targetSdkVersion 30
        versionCode flutterVersionCode.toInteger()
        versionName flutterVersionName
    }
    ...
}
...

定义实体

ObjectBox与实体一起工作。在Dart中,这些是用@Entity() 注解的简单类。每个实体都需要有一个int id; 字段,它将由ObjectBox包自动填充,所以它不应该是required 构造函数参数。除此以外,实体可以有任何支持的类型的字段。让我们创建ShopOrderCustomer 类,现在,它们之间没有任何关系。

实体.dart

import 'package:objectbox/objectbox.dart';

@Entity()
class ShopOrder {
  int id;
  int price;

  ShopOrder({
    this.id = 0,
    required this.price,
  });
}

@Entity()
class Customer {
  int id;
  String name;

  Customer({
    this.id = 0,
    required this.name,
  });
}

正如你所看到的,实体类很简单--它们真的只包含我们感兴趣的数据,没有任何模板。相反,所有的模板将被生成到objectbox.g.dart文件中,该文件将始终位于lib 文件夹的根部。让我们运行 build_runner!

🧑💻终端

flutter pub run build_runner watch --delete-conflicting-outputs 

尽管如此,我们还是想在ShopOrderCustomer 实体之间定义一个关系,例如,轻松地获得一个特定客户的所有订单。幸运的是,ObjectBox支持各种关系的开箱即用!🙃

当你考虑到这一点,一个订单总是只属于一个客户。另一方面,客户可以做多个订单。因此,我们在这里寻找的关系是**"一对多"**。一个客户对许多订单,反过来说,许多订单对一个客户。

也支持其他类型的关系,如一对一和多对多。请查看文档

关系的标志是创建一个字段,并将其立即设置为等于ToOne<NameOfOtherEntity>()ToMany<NameOfOtherEntity>() 。这些辅助类允许我们在Dart代码中的ShopOrder 对象中持有一个Customer 实例,尽管在实际的数据库中,这些数据将被完全分开存储。

entities.dart

@Entity()
class ShopOrder {
  int id;
  int price;
  final customer = ToOne<Customer>();

  ShopOrder({
    this.id = 0,
    required this.price,
  });
}

@Entity()
class Customer {
  int id;
  String name;
  @Backlink()
  final orders = ToMany<ShopOrder>();

  Customer({
    this.id = 0,
    required this.name,
  });
}

注意到orders 字段上的@Backlink() 注解了吗?这告诉实体,客户的订单将通过查看ShopOrder 类中的ToOne 关系来获得(反向链接)。

创建一个商店

你使用一个Store 来访问所有ObjectBox的数据。首先,你需要初始化它并指定数据库文件的存储位置。我们将从initState 我们的HomePage 状态中进行。

为了得到在用户的移动设备上安全存储数据库文件的目录,我们调用getApplicationDocumentsDirectory() ,然后继续使用这个异步调用里面的值then ,因为initState 不能被标记为异步的。

我们将把Store 放到一个late 字段中,然后用一个单独的布尔值表明它是否已经被初始化,这样我们就不必在整个代码库中处理空值了。

home_page.dart

class _HomePageState extends State<HomePage> {
  final faker = Faker();

  late Store _store;
  bool hasBeenInitialized = false;

  @override
  void initState() {
    super.initState();
    getApplicationDocumentsDirectory().then((dir) {
      _store = Store(
        // This method is from the generated file
        getObjectBoxModel(),
        directory: join(dir.path, 'objectbox'),
      );

      setState(() {
        hasBeenInitialized = true;
      });
    });
  }

  @override
  void dispose() {
    _store.close();
    super.dispose();
  }

  ...
}

设置客户和添加订单

应用程序的用户可以通过按下AppBar 上的*"人物图标"*,将 "当前客户 "设置为一个具有新ID和新名称的不同Customer 对象。

随后,当按下美元符号按钮时,这个Customer 对象将成为 "下订单 "的人。现在让我们在home_page.dart中实现setNewCustomeraddFakeOrderForCurrentCustomer 方法。

为了使当前客户在整个类中都能被访问,我们将为它创建一个late 字段。

home_page.dart

class _HomePageState extends State<HomePage> {
  ...

  late Customer _customer;

  @override
  void initState() {
    super.initState();
    // We want to have a customer populated right when the app starts
    setNewCustomer();
    getApplicationDocumentsDirectory().then((dir) {
      ...
    });
  }

  ...

  void setNewCustomer() {
    _customer = Customer(name: faker.person.name());
  }

  void addFakeOrderForCurrentCustomer() {
    final order = ShopOrder(
      price: faker.randomGenerator.integer(500, min: 10),
    );
    order.customer.target = _customer;
    _store.box<ShopOrder>().put(order);
  }
}

现在让我们对addFakeOrderForCurrentCustomer 进行一下剖析。创建ShopOrder 对象是很简单的。然后,我们在_customer 字段内用当前的Customer 实体来填充ToOne<Customer> 关系。最后,我们将订单持久化地放入数据库中。

一个ObjectBoxStore 可以有许多Boxes。在这种情况下,会有一个用于ShopOrder 实体,另一个用于Customers。

target 虽然我们只是把数据放到ShopOrder 盒子里,但是数据库很聪明,会自动把指定为关系的Customer 实体放到它自己的Box<Customer> 。但这并不总是如此,请在官方文档中阅读更多信息。

观察数据

我们现在已经处理了将数据放入数据库的问题。但如果我们不能在用户界面上看到它,这就没有用了。为此,我们首先需要从数据库中读取数据。

用ObjectBox进行一次性读取是可能的,但在Flutter这样的反应式框架中,使用一个Stream 来持续观察数据几乎总是更好的,每当数据库中的数据被更新时(例如,我们添加了一个新的订单),就会产生一个新的事件。

我们想在应用程序启动时立即开始观察Box<ShopOrder> 内的数据,所以我们将创建一个简单的查询并在initState 内观察它。我们还将把返回的Stream 到一个状态字段中--我们要从build 方法中使用它。

home_page.dart

class _HomePageState extends State<HomePage> {
  ...

  // 👇 ADD THIS
  late Stream<List<ShopOrder>> _stream;

  @override
  void initState() {
    super.initState();
    setNewCustomer();
    getApplicationDocumentsDirectory().then((dir) {
      _store = Store(
        getObjectBoxModel(),
        directory: join(dir.path, 'objectbox'),
      );

      setState(() {
        // 👇 ADD THIS
        _stream = _store
            .box<ShopOrder>()
            // The simplest possible query that just gets ALL the data out of the Box
            .query()
            .watch(triggerImmediately: true)
            // Watching the query produces a Stream<Query<ShopOrder>>
            // To get the actual data inside a List<ShopOrder>, we need to call find() on the query
            .map((query) => query.find());

        hasBeenInitialized = true;
      });
    });
  }

  ...
}

显示数据

我们有了ShopOrders的Stream ,现在我们需要在OrderDataTable 里面显示它。启动项目已经包含了所有的准备代码,所以我们就来让它可以传入一个List<ShopOrder> 对象。首先,我们当然需要为订单创建一个字段。

order_data_table.dart

class OrderDataTable extends StatefulWidget {
  final List<ShopOrder> orders;
  final void Function(int columnIndex, bool ascending) onSort;

  const OrderDataTable({
    Key? key,
    required this.orders,
    required this.onSort,
  }) : super(key: key);

  @override
  _OrderDataTableState createState() => _OrderDataTableState();
}

一旦我们有了List,我们就可以在DataTable 里面显示它的数据。为此,我们只对DataTable widget的构造函数的rows 参数感兴趣。对于orders List里面的每个ShopOrder 对象,我们要把它映射到一个DataRow ,以便很好地把数据显示给用户。

order_data_table.dart

class _OrderDataTableState extends State<OrderDataTable> {
  ...

  @override
  Widget build(BuildContext context) {
    return SingleChildScrollView(
      scrollDirection: Axis.horizontal,
      child: SingleChildScrollView(
        child: DataTable(
          ...
          // 👇 This is important
          rows: widget.orders.map((order) {
            return DataRow(
              cells: [
                DataCell(
                  Text(order.id.toString()),
                ),
                DataCell(
                  Text(order.customer.target?.name ?? 'NONE'),
                  onTap: () {
                    // TODO: Show only tapped customer's orders in a modal bottom sheet
                  },
                ),
                DataCell(
                  Text(
                    '\$${order.price}',
                  ),
                ),
                DataCell(
                  Icon(Icons.delete),
                  onTap: () {
                    // TODO: Delete the order from the database
                  },
                ),
              ],
            );
          }).toList(),
        ),
      ),
    );
  }

  ...
}

现在让我们把实际的List<ShopOrder> 传递到OrderDataTable!由于我们在HomePage 状态下有一个Stream ,我们将使用一个StreamBuilder widget来重建OrderDataTable ,每当有新的ShopOrder 被添加到数据库和流发出一个新的事件。

主页_page.dart

class _HomePageState extends State<HomePage> {
  ...

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      ...
      body: !hasBeenInitialized
          ? Center(
              child: CircularProgressIndicator(),
            )
          : StreamBuilder<List<ShopOrder>>(
              stream: _stream,
              builder: (context, snapshot) {
                if (!snapshot.hasData) {
                  return Center(
                    child: CircularProgressIndicator(),
                  );
                } else {
                  return OrderDataTable(
                    orders: snapshot.data!,
                    onSort: (columnIndex, ascending) {
                      // TODO: Query the database and sort the data
                    },
                  );
                }
              },
            ),
    );
  }

  ...
}

应用程序现在可以工作了!算是吧...虽然我们可以显示和添加新的订单,但我们也希望能够根据订单的ID或价格进行排序删除一个订单,最后,在一个模态的底单中只显示一个特定客户的订单。让我们逐一来看看。

对订单进行排序

排序是非常容易实现的。我们已经在OrderDataTable widget上有一个onSort callback。让我们简单地在这个回调函数中创建一个新的数据库查询,它将被适当地排序,然后重置StreamBuilder 所用的Stream ,以包含排序后的数据。

每个实体,如我们的ShopOrder ,都有一个生成的 "伴侣类",它的名字后面有一个下划线,在我们的例子中是一个ShopOrder_ 。这个类是用来指定字段的,我们希望根据这个字段对查询进行排序(或排序--但排序ShopOrders ,听起来很傻......)。

onSort 回调接收被用户点过的columnIndex 。我们应该根据这个索引对数据进行排序--索引0是指ID列,而索引2是指价格列

我们是升序排序还是降序排序,由flags 参数处理。默认(0)意味着升序排序,否则我们使用Order (不是我们的ShopOrder !!)类中的一个值来指定降序排序。

home_page.dart

return OrderDataTable(
  orders: snapshot.data!,
  onSort: (columnIndex, ascending) {
    final newQueryBuilder = _store.box<ShopOrder>().query();
    final sortField =
        columnIndex == 0 ? ShopOrder_.id : ShopOrder_.price;
    newQueryBuilder.order(sortField,
        flags: ascending ? 0 : Order.descending);

    setState(() {
      _stream = newQueryBuilder
          .watch(triggerImmediately: true)
          .map((query) => query.find());
    });
  },
);

ObjectBox中的排序是相当强大的,请在官方文档中阅读更多关于它的信息。

删除订单

在我们的OrderDataTable 中按下DataCell 里面的垃圾桶按钮,应该可以从数据库中删除订单。为此,我们需要访问Store ,甚至从OrderDataTable ,所以让我们确保我们可以传入它。

order_data_table.dart

class OrderDataTable extends StatefulWidget {
  ...
  final Store store;

  const OrderDataTable({
    Key? key,
    ...
    required this.store,
  }) : super(key: key);

  @override
  _OrderDataTableState createState() => _OrderDataTableState();
}

现在在最后一个DataCell ,在映射的DataRow

orders_data_table.dart

class _OrderDataTableState extends State<OrderDataTable> {
  ...

  @override
  Widget build(BuildContext context) {
    return SingleChildScrollView(
      scrollDirection: Axis.horizontal,
      child: SingleChildScrollView(
        child: DataTable(
          ...
          rows: widget.orders.map((order) {
            return DataRow(
              cells: [
                ...
                // 👇 Edit this last DataCell's code
                DataCell(
                  Icon(Icons.delete),
                  onTap: () {
                    widget.store.box<ShopOrder>().remove(order.id);
                  },
                ),
              ],
            );
          }).toList(),
        ),
      ),
    );
  }

  ...
}

从数据库中删除实体就像把它们放进去一样简单。现在,我们当然需要将StoreHomePage 状态传入widget。

home_page.dart

return OrderDataTable(
  orders: snapshot.data!,
  onSort: (columnIndex, ascending) {
    ...
  },
  store: _store,
);

显示一个特定客户的订单

你还记得一个Customer 是如何与许多ShopOrders 有关的吗?我的意思是,它实际上有一个字段初始化为ToMany<ShopOrder>!这正是我们现在要使用的,以显示一个特定客户的所有订单,而不必担心自己以某种方式找到这些与客户相关的订单。ObjectBox支持的一对多关系将为我们处理所有的复杂性,我们可以像以前一样使用漂亮的Dart对象。

在显示客户姓名的DataCell ,我们将显示一个模态的底层页面,它将简单地包含一个ListView ,显示所有被点击的客户的订单。或者,我应该说,DataCell's order's customer's orders。

是的,前面的句子肯定让你感到困惑,但这正是我们在ListView 。每个订单与客户有一个To-One关系,然后每个客户与该客户的订单有一个To-Many关系。因此,我们实际上是利用两个关系来获得我们需要的数据。

order_data_table.dart

DataCell(
  Text(order.customer.target?.name ?? 'NONE'),
  onTap: () {
    showModalBottomSheet(
      context: context,
      builder: (context) {
        return Material(
          child: ListView(
            children: order.customer.target!.orders
                .map(
                  (_) => ListTile(
                    title: Text(
                      '${_.id}    ${_.customer.target?.name}    \$${_.price}',
                    ),
                  ),
                )
                .toList(),
          ),
        );
      },
    );
  },
),

跨设备同步

如果你已经使用ObjectBox作为你的本地数据库,在多个设备间同步数据是非常容易的。ObjectBox Sync是一项付费服务,它是一个完整的开箱即用的数据同步解决方案,可以始终保持你的数据是最新的。

如果你对使用Sync感兴趣,只需联系ObjectBox的人,他们将为你的项目设置最好的解决方案。一旦你有了ObjectBox Sync的可执行程序,你就可以在你的服务器上运行它,并马上开始同步数据。

你也可以免费试用Sync,看看它是否适合你的应用程序。

Sync有一个非常好的文档,一旦你有了自己的可执行文件或Docker镜像,可以在几分钟内让你运行。

如果你想看一下Sync的演示,那么一定要看看本文所附的视频教程,从这个时间戳开始。

总结

ObjectBox是一个快速的本地数据库,具有令人愉快的API,丰富的查询,关系支持,你也可以通过使用ObjectBox Sync轻松地在多个设备上同步你的数据。

它与其他的数据库相比如何?说到性能,它把大多数其他Flutter数据库吹得天花乱坠。与其他NoSQL数据库如Sembast甚至Shared Preferences相比,它因支持关系和高级排序能力而大放异彩。当你加入轻松同步数据的选项时,ObjectBox绝对应该是你为下一个Flutter应用选择数据库时考虑的一个选项。