数据持久化存储方案 - Hive Flutter

Hive Flutter

Hive 是一个纯 Dart 编写的、基于文件存储的、轻量且功能强大的 Key-Value 型数据库。适用于 Flutter 生态的各端(本文以 Flutter 移动端为例分享)。

Hive 官方文档 https://docs.hivedb.dev/#/

一、为什么用 Hive ?🧐

Flutter 端实现持久化存储的方案很多,比如 shared_preferences(以下简称 SP),SP 也是 Key-Value 格式的数据存储方案,但它更像是一个原子型的存储方案,很多常用的功能需要自己去实现;再比如 sqflite,它是一个轻巧的数据库,支持原生数据库的绝大多数功能,但需要使用者熟悉 SQL 操作,上手曲线很陡峭。当然,还有很多其他的数据存储方案,我暂时还没了解到,不再举例。

Bloc 是状态管理方案,状态,意味着 APP 一旦关闭,其状态就会丢失。但应用使用期间,其状态是可以实时更新、跨页面、跨组件同步更新的。

那为什么用 Hive 呢?正如上面提到的三个 package,Hive 正是集成了三者的优点,一站式解决了数据持久存储和实时响应的问题。它完全没有 SP 的简陋、sqflite 的陡峭曲线,同时还兼具了 Bloc 的数据同步。

如果你的应用不需要后端支持、需要存储一定数量的数据,又不想项目过于复杂,Hive 绝对值得试试。

️ 注意:总归总,Hive 还是文件型数据存储方案,内存压力和 CPU 性能是绕不开的话题。所以,Hive 不适合存储过多的数据,Hive 的作者在 issue 中建议 1000 ~ 5000;超过这个值,性能会逐渐降低。更有建设性的方案,建议仔细阅读 isuse,其中的几个大佬还给出了其他合理方案。

二、例外

Hive 虽然可以解决部分数据存储的问题以及一些状态同步问题,但并不意味着它可以完全替代 SP、sqflite 和 Bloc;

  • 多个设备同步数据:这种情况考虑使用 后端 + Bloc 的方案解决。以【音乐】应用为例,如果你是做一个播放器,Hive 很值得推荐,如果你是做云音乐,建议还是后端存储吧。
  • 大数据读写:考虑使用索引处理以提高性能,参考:issue-170
  • 图片存储:Hive 支持二进制格式的图片存储,但建议图片体积不要过大(建议 2M 以下),我觉得还是使用 OSS 存储比较合理。
  • 分页查询:Hive 通常会一次性加载所有数据到内存中,不支持类似 SQL 的分页查询,如果需要实现,可以使用 List 的 Api。

三、举个例子 🌰

本文以 2 个小例子演示如何上手 Hive。

3.1 新建项目并安装依赖

使用 flutter create hive_demo 创建一个 App。

打开项目,在 pubspec.yaml 安装以下依赖:

dependencies:
  flutter:
    sdk: flutter

  # The following adds the Cupertino Icons font to your application.
  # Use with the CupertinoIcons class for iOS style icons.
  cupertino_icons: ^1.0.0

  # 目录操作,Hive 初始化时,需要指定一个存储位置
  # https://pub.flutter-io.cn/packages/path_provider
  path_provider: ^1.6.24

  # Hive 相关依赖
  # https://pub.flutter-io.cn/packages/hive
  hive: ^1.4.4+1

  # Hive Flutter 支持,扩展了 Flutter 组件
  # https://pub.flutter-io.cn/packages/hive
  hive_flutter: ^0.3.1

  # Hive 自定义 Object 支持
  # https://pub.flutter-io.cn/packages/hive_generator
  hive_generator: ^0.8.2

为了演示代码,我们把新工程的 main.dart 文件拆分一下,其中的 MyHomePage 被我拆分到了一个独立的文件(./lib/pages/root_page.dart)中,名字也被替换成了 RootPage

RootPage

3.2 明确概念

Hive 中有三个概念需要了解,分别是:Box、Object、Adapter。

  • Box:数据通常都存放在 Box 中,看上去很像数据库中的 Table;但是,Hive 中,我们可以直接使用 Box 操作数据,比如:Box.addBox.delete 等,所以,Box 更像是一个 Module
  • Object:Object 就像数据库中的 Entity(实体)。 Hive 可以存储绝大多数的数据类型,例如:Box.add('小米')Box.put('platform', '安卓'),如果需要存储复杂的数据,就需要自定义一个对象,通常对象需要继承自 HiveObject,如:class MyObj extends HiveObject
  • Adapter:是自定义对象的适配器,需要实现 typeIdreadwrite。这里官方的文档比较简单,因为,现实中我们的对象不可能只有一个字段,多个字段如何使用,官方没有在文档中演示,另外,write 的用法也没有完善,其实我们可以在 write 的时候对数据进行 默认值 处理。

注意:自定义的对象,必须要使用 Adapter 注册,参考:https://docs.hivedb.dev/#/custom-objects/type_adapters

有了基本的概念,我们就可以尝试敲一下代码了。

3.3 挂载

在 Hive 中,如果需要存储数据,就需要使用到 Box,比如:

// 伪代码
Box box = await Hive.box('users');

但跨组件或页面使用时,新页面中如果不定义 Box,则会出现变量未定义的错误,如果定义了,就会报 Box 已经打开的错误,也就是说,一个 Box 如果已经打开,就不能再次打开。

如果上次调用完成后再调用 box.close(),数据又会无法同步。

所以,我们需要建立一个单例类,以确保应用初始化时就已经实例化好需要的 Box,接下来,我们只需要调用这个类的实例,就可以拿到需要的 Box。代码如下:

/// ./lin/utils/db_util.dart
import 'dart:io';
import 'package:hive/hive.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'package:path_provider/path_provider.dart';

/// Hive 数据操作
class DBUtil {
  /// 实例
  static DBUtil instance;

  /// 初始化,需要在 main.dart 调用
  /// <https://docs.hivedb.dev/>
  static Future<void> install() async {
    /// 初始化数据库地址
    Directory document = await getApplicationDocumentsDirectory();
    Hive.init(document.path);

    /// 注册自定义对象(实体)
    /// <https://docs.hivedb.dev/#/custom-objects/type_adapters>
    /// Hive.registerAdapter(SettingsAdapter());
  }

  /// 初始化 Box
  static Future<DBUtil> getInstance() async {
    if (instance == null) {
      instance = DBUtil();
      await Hive.initFlutter();
    }
    return instance;
  }
}

该单例提供了 2 个静态(异步)方法:

  • DBUtil.install():该方法会在应用启动时调用,用于初始化 Hive 的状态;
  • DBUtil.getInstance():在组件使用时调用,用于获取该类的实例,拿到实例我们就可以获取其中的 Box;

首先,我们需要在 main.dart 中调用 DBUtil.install 方法:

/// ./lib/main.dart
import 'package:flutter/material.dart';
import 'package:hive_demo/pages/root_page.dart';
import 'package:hive_demo/utils/db_util.dart';

void main() async {
  /// 注意:需要添加下面的一行,才可以使用 异步方法
  WidgetsFlutterBinding.ensureInitialized();

  /// 初始化 Hive
  await DBUtil.install();
  await DBUtil.getInstance();

  runApp(MyApp());
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Hive Demo',
      theme: ThemeData(
        platform: TargetPlatform.iOS,
        primaryColor: Colors.blueAccent,
        appBarTheme: AppBarTheme(elevation: 0),
      ),
      home: RootPage(),
    );
  }
}

重启 App,当 App 启动时,Hive 会被初始化,我们还没有定义 Box 实例,所以,现在没有任何的效果。

3.4 简单数据存取

首先,我们尝试一下简单的 Box 数据存储,做一个新增标签的功能。修改我们的 root_page 页面,代码如下:

/// ./lib/pages/root_page.dart
import 'package:flutter/material.dart';

class RootPage extends StatefulWidget {
  @override
  _RootPageState createState() => _RootPageState();
}

class _RootPageState extends State<RootPage> {
  TextEditingController _tagEditingController;

  @override
  void initState() {
    _tagEditingController = TextEditingController();
    super.initState();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('Hive Demo'),
      ),
      body: ListView(
        children: [
          tagsHeader,
          Container(child: tags, padding: EdgeInsets.all(10)),
          tagsCreator,
        ],
      ),
    );
  }

  /// 标签列表
  Widget get tags {
    /// 标签集合
    List<String> tags = ['设计', '开发', '运维', '测试', '产品'];

    return Wrap(
      spacing: 10,
      alignment: WrapAlignment.center,
      children: List.generate(
        tags.length,
        (int index) {
          final String text = tags[index];
          return Chip(
            label: Text(text),
            onDeleted: () {
              // 删除操作
            },
          );
        },
      ),
    );
  }

  /// 新增标签
  Widget get tagsCreator {
    /// 输入表单
    Widget input = TextField(
      controller: _tagEditingController,
      decoration: InputDecoration(
        hintText: '标签',
        border: InputBorder.none,
        contentPadding: EdgeInsets.symmetric(horizontal: 10),
      ),
    );

    /// 新增按钮
    Widget submit = RaisedButton(
      child: Text('新增'),
      elevation: 0,
      padding: EdgeInsets.all(14),
      onPressed: () {
        // 新增标签
      },
    );

    return Container(
      decoration: BoxDecoration(
        border: Border.all(color: Colors.blueGrey.withAlpha(60)),
        borderRadius: BorderRadius.circular(8),
      ),
      margin: EdgeInsets.symmetric(horizontal: 20, vertical: 10),
      padding: EdgeInsets.all(6),
      child: Row(
        children: [
          Expanded(child: input),
          SizedBox(width: 10),
          submit,
        ],
      ),
    );
  }

  /// 标签操作
  Widget get tagsHeader {
    /// 清空按钮
    Widget clearBtn = FlatButton(
      child: Text(
        '清空',
        style: TextStyle(color: Colors.red),
      ),
      padding: EdgeInsets.zero,
      onPressed: () {
        /// 清空标签
      },
    );

    return Container(
      padding: EdgeInsets.symmetric(horizontal: 20, vertical: 10),
      child: Row(
        children: [
          Expanded(child: Text('标签管理')),
          clearBtn,
        ],
      ),
    );
  }
}

效果如下:

root_page

当然,现在的数据都是静态的,接下来我们一步步实现动态数据展示。

首先,我们实例化一个 Box,为了统一管理,我们在单例类中新建,修改单例类,新增 tagsBox Box 实例,并实例化它。

/// ./lib/utils/db_util.dart
import 'dart:io';
import 'package:hive/hive.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'package:path_provider/path_provider.dart';

/// Hive 数据操作
class DBUtil {
  /// 实例
  static DBUtil instance;

  /// 标签
  Box tagsBox;

  /// 初始化,需要在 main.dart 调用
  /// <https://docs.hivedb.dev/>
  static Future<void> install() async {
    /// 初始化数据库地址
    Directory document = await getApplicationDocumentsDirectory();
    Hive.init(document.path);

    /// 注册自定义对象(实体)
    /// <https://docs.hivedb.dev/#/custom-objects/type_adapters>
    /// Hive.registerAdapter(SettingsAdapter());
  }

  /// 初始化 Box
  static Future<DBUtil> getInstance() async {
    if (instance == null) {
      instance = DBUtil();
      await Hive.initFlutter();

      /// 标签
      instance.tagsBox = await Hive.openBox('tags');
    }

    return instance;
  }
}

同时,修改我们的 root_page 代码,在其中建立一个 dbUtil 实例。

/// ./lib/pages/root_page.dart

class _RootPageState extends State<RootPage> {
  TextEditingController _tagEditingController;

  DBUtil dbUtil;

  @override
  void initState() {
    init();
    _tagEditingController = TextEditingController();
    super.initState();
  }

  Future<void> init() async {
    dbUtil = await DBUtil.getInstance();
    if (!mounted) return;
    setState(() {});
  }

  /// 其他代码略
}

重新运行 App,确保 tagsBox 创建成功。

修改标签列表渲染组件,使其可以动态渲染列表。ValueListenableBuilder 组件不需要 setState,可以实时渲染数据。

/// ./lib/pages/root_page.dart

/// 注意,需要引入下面的两个 package
/// 我在使用的时候,listenable 方法需要 hive_flutter,但它不会自动引入,每次都需要手动引入。
import 'package:hive/hive.dart';
import 'package:hive_flutter/hive_flutter.dart';

渲染标签列表的代码如下:

/// ./lib/pages/root_page.dart

/// 标签列表
Widget get tags {
  /// 先判断 dbUtil 是否初始化成功
  if (dbUtil == null || dbUtil.tagsBox == null)
    return Container(
      child: Text('Loading'),
      alignment: Alignment.center,
    );

  return ValueListenableBuilder(
    valueListenable: dbUtil.tagsBox.listenable(),
    builder: (BuildContext context, Box tags, Widget _) {
      /// 数据为空
      if (tags.keys.length == 0)
        return Container(
          child: Text('暂无数据'),
          alignment: Alignment.center,
        );

      return Wrap(
        spacing: 10,
        alignment: WrapAlignment.center,
        children: List.generate(tags.keys.length, (int index) {
          final String text = tags.getAt(index);
          return Chip(
            label: Text(text),
            onDeleted: () {
              // 删除操作
            },
          );
        }),
      );
    },
  );
}

完善输入表单,使其可以正常添加数据。

/// ./lib/pages/root_page.dart

/// 新增按钮
Widget submit = RaisedButton(
  child: Text('新增'),
  elevation: 0,
  padding: EdgeInsets.all(14),
  onPressed: () async {
    // 新增标签
    final tag = _tagEditingController.text;
    if (tag == null || tag.isEmpty) return;
    await dbUtil.tagsBox.add(tag);
    _tagEditingController.clear();
    FocusScope.of(context).unfocus();
  },
);

输入文本,标签已经可以正常添加、刷新列表了。

新增标签

我们打印一下数据,看下每个数据长什么样子!在遍历 tags 前,添加一行 print(tags.toMap());,打开控制台,可以看到数据格式:

flutter: {0: abc, 1: asd, 2: abcd, 3: eee, 4: fff, 5: ggg, 6: hihi}

可以看出,Box 存储的数据是一个 Map,其中的 key 可以理解为数据库中的自增 ID。接下来,我们实现删除,就需要使用到这个 key 值。

修改标签组件,添加删除逻辑。

/// ./lib/pages/root_page.dart

/// 标签列表
Widget get tags {
  /// 先判断 dbUtil 是否初始化成功
  if (dbUtil == null || dbUtil.tagsBox == null)
    return Container(
      child: Text('Loading'),
      alignment: Alignment.center,
    );

  return ValueListenableBuilder(
    valueListenable: dbUtil.tagsBox.listenable(),
    builder: (BuildContext context, Box tags, Widget _) {
      /// 数据为空
      if (tags.keys.length == 0)
        return Container(
          child: Text('暂无数据'),
          alignment: Alignment.center,
        );

      return Wrap(
        spacing: 10,
        alignment: WrapAlignment.center,
        children: List.generate(tags.keys.length, (int index) {
          final int key = tags.keyAt(index);
          final String text = tags.getAt(index);
          return Chip(
            label: Text(text),
            onDeleted: () async {
              // 删除操作
              await dbUtil.tagsBox.delete(key);
            },
          );
        }),
      );
    },
  );
}

最后,实现清空操作!

/// ./lib/pages/root_page.dart

/// 标签操作
Widget get tagsHeader {
  /// 清空按钮
  Widget clearBtn = FlatButton(
    child: Text(
      '清空',
      style: TextStyle(color: Colors.red),
    ),
    padding: EdgeInsets.zero,
    onPressed: () async {
      /// 清空标签
      await dbUtil.tagsBox.clear();
    },
  );

  return Container(
    padding: EdgeInsets.symmetric(horizontal: 20, vertical: 10),
    child: Row(
      children: [
        Expanded(child: Text('标签管理')),
        clearBtn,
      ],
    ),
  );
}

至此,我们已经简单体验了 Hive 的基本玩法。

标签管理

3.5 自定义对象数据存取

很明显,上面的例子还是很单一的,现实中,我们存储的数据可能比这复杂的多。接下来,我们创建一个简单的 TODO 待办。

我们需要建立一个待办条目对象,每个条目都包含 内容(String)、创建日期(DateTime)、完成日期(DateTime)、优先级(int) 等几个属性。

首先,在 ./lib/db/ 目录下建立我们的 Object。

/// ./lib/db/todo_item_db.dart
import 'package:hive/hive.dart';

@HiveType()
class TodoItem extends HiveObject {
  /// 内容
  String content;

  /// 优先级
  int level;

  /// 创建日期
  String createAt;

  /// 完成日期
  String completionAt;

  TodoItem({
    this.content,
    this.level,
    this.createAt,
    this.completionAt,
  });
}

class TodoItemAdapter extends TypeAdapter<TodoItem> {
  @override
  final int typeId = 0;

  @override
  TodoItem read(BinaryReader reader) {
    return TodoItem(
      content: reader.read(),
      level: reader.read(),
      createAt: reader.read(),
      completionAt: reader.read(),
    );
  }

  @override
  void write(BinaryWriter writer, obj) {
    writer.write(obj.content);
    writer.write(obj.level ?? 0);
    writer.write(obj.createAt ?? DateTime.now().toString());
    writer.write(obj.completionAt);
  }
}

然后,在 DBUtil 单例中注册 TodoItemAdapter。修改 db_util.dart 中的 install 方法,增加 Hive.registerAdapter(TodoItemAdapter());,同时,我们还需要修改其中的 getInstance 方法,新增一个 todoBox,最终如下:

import 'dart:io';
import 'package:hive/hive.dart';
import 'package:hive_demo/db/todo_item_db.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'package:path_provider/path_provider.dart';

/// Hive 数据操作
class DBUtil {
  /// 实例
  static DBUtil instance;

  /// 标签
  Box tagsBox;

  /// 待办
  Box todoBox;

  /// 初始化,需要在 main.dart 调用
  /// <https://docs.hivedb.dev/>
  static Future<void> install() async {
    /// 初始化数据库地址
    Directory document = await getApplicationDocumentsDirectory();
    Hive.init(document.path);

    /// 注册自定义对象(实体)
    /// <https://docs.hivedb.dev/#/custom-objects/type_adapters>
    Hive.registerAdapter(TodoItemAdapter());
  }

  /// 初始化 Box
  static Future<DBUtil> getInstance() async {
    if (instance == null) {
      instance = DBUtil();
      await Hive.initFlutter();

      /// 标签
      instance.tagsBox = await Hive.openBox('tags');

      /// 待办
      instance.todoBox = await Hive.openBox('todo');
    }

    return instance;
  }
}

修改完成,重新运行我们的 App。

新建一个 TodoPage(./lib/pages/todo_page.dart),并在 main.dart 中替换我们的页面。

Hive 的 api 比较好理解,增删改的逻辑代码量通常只有几行。这里我们不在赘述,直接布局好 UI,简单调用就可以了。TodoPage 的代码如下:

import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:hive/hive.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'package:hive_demo/db/todo_item_db.dart';
import 'package:hive_demo/utils/db_util.dart';

class TodoPage extends StatefulWidget {
  @override
  _TodoPageState createState() => _TodoPageState();
}

class _TodoPageState extends State<TodoPage> {
  DBUtil dbUtil;

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

  Future<void> init() async {
    dbUtil = await DBUtil.getInstance();
    if (!mounted) return;
    setState(() {});
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('Hive Todo'),
        actions: [
          IconButton(
            icon: Icon(Icons.clear_all),
            onPressed: () async {
              bool confirm = await confirmAlert('确定清空所有待办?');
              if (confirm != true) return;
              await dbUtil.todoBox.clear();
            },
          ),
        ],
      ),
      body: content,
      floatingActionButton: createBtn,
    );
  }

  Widget get content {
    if (dbUtil == null || dbUtil.todoBox == null)
      return Container(
        child: Text('Loading'),
        alignment: Alignment.center,
      );

    return ValueListenableBuilder(
      valueListenable: dbUtil.todoBox.listenable(),
      builder: (BuildContext context, Box todos, Widget _) {
        if (todos.keys.length == 0) return empty;
        return lists(todos);
      },
    );
  }

  Widget lists(Box todos) {
    int total = todos.keys.length;

    /// 获取未完成待办
    List<TodoItem> defaults = [];

    /// 获取已完成待办
    List<TodoItem> completions = [];

    for (int i = 0; i < total; i++) {
      TodoItem item = todos.getAt(i);

      if (item.completionAt != null) {
        completions.add(item);
      } else {
        defaults.add(item);
      }
    }

    /// 创建待处理列表
    Widget defaultsList = ListView.builder(
      itemCount: defaults.length,
      shrinkWrap: true,
      physics: NeverScrollableScrollPhysics(),
      itemBuilder: (BuildContext contenx, int index) => row(defaults[index]),
    );

    /// 创建已完成列表
    Widget completionsList = ListView.builder(
      itemCount: completions.length,
      shrinkWrap: true,
      physics: NeverScrollableScrollPhysics(),
      itemBuilder: (BuildContext contenx, int index) => row(completions[index]),
    );

    return ListView(
      children: [
        SizedBox(height: 10),
        defaultsList,
        if (completions.length > 0) completionsList,
        if (total > 0)
          Container(
            padding: EdgeInsets.all(20),
            alignment: Alignment.center,
            child: Text(
              '共 $total 条待办',
              style: TextStyle(
                color: Colors.blueGrey,
                fontSize: 12,
              ),
            ),
          ),
        SizedBox(height: 10),
      ],
    );
  }

  /// 待办条目
  Widget row(TodoItem item) {
    /// 是否存在优先级
    bool inLevel = item.level != null && item.level > 0;

    /// 是否已完成
    bool isCompletion = item.completionAt != null;

    /// 优先级图标
    Widget levelPrefix = Text(
      '!' * item.level,
      style: TextStyle(color: Colors.red),
    );

    /// 文本内容
    Widget content = Expanded(
      child: Text(
        item.content ?? '未输入内容',
        maxLines: 2,
        overflow: TextOverflow.ellipsis,
        style: TextStyle(
          fontSize: 15,
          fontWeight: FontWeight.bold,
          decoration:
              isCompletion ? TextDecoration.lineThrough : TextDecoration.none,
        ),
      ),
    );

    /// 副标题
    Widget subtitle = Text(
      (isCompletion ? item.completionAt : item.createAt) ?? '-',
    );

    /// 操作
    Widget actions = Row(
      mainAxisSize: MainAxisSize.min,
      children: [
        if (!isCompletion)
          IconButton(
            icon: Icon(Icons.edit, size: 20, color: Colors.green),
            onPressed: () {
              showDialog(
                context: context,
                child: TodoCreateDialog(
                  dbUtil: dbUtil,
                  item: item,
                ),
              );
            },
          ),
        IconButton(
          icon: Icon(Icons.clear, size: 20, color: Colors.red),
          onPressed: () async {
            bool confirm = await confirmAlert('确定删除本条待办?');
            if (confirm != true) return;
            await dbUtil.todoBox.delete(item.key);
          },
        ),
      ],
    );

    return Container(
      margin: EdgeInsets.symmetric(horizontal: 20, vertical: 10),
      padding: EdgeInsets.all(10),
      decoration: BoxDecoration(
        color: Colors.white,
        borderRadius: BorderRadius.circular(8),
      ),
      child: Row(
        children: [
          isCompletion
              ? Container(width: 24)
              : IconButton(
                  icon: Icon(Icons.check_circle,
                      size: 20, color: Colors.blueAccent),
                  onPressed: () async {
                    /// 已完成
                    item.completionAt = DateTime.now().toString();
                    await dbUtil.todoBox.put(item.key, item);
                  },
                ),
          SizedBox(width: 10),
          Expanded(
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: [
                Row(
                  children: [
                    if (inLevel) levelPrefix,
                    if (inLevel) SizedBox(width: 10),
                    content,
                  ],
                ),
                SizedBox(height: 8),
                subtitle
              ],
            ),
          ),
          SizedBox(width: 10),
          actions,
        ],
      ),
    );
  }

  /// 确认弹窗
  Future<bool> confirmAlert(String content, {String title = '操作提示'}) async {
    return await showDialog(
      context: context,
      child: AlertDialog(
        title: Text(title),
        content: Text(content),
        actions: [
          FlatButton(
            onPressed: () {
              Navigator.of(context).pop(false);
            },
            child: Text('取消'),
          ),
          FlatButton(
            onPressed: () {
              Navigator.of(context).pop(true);
            },
            child: Text('确定'),
          ),
        ],
      ),
    );
  }

  /// 无数据
  Widget get empty {
    return Container(
      child: Text('暂无数据'),
      alignment: Alignment.center,
    );
  }

  /// 新增按钮
  Widget get createBtn {
    return FloatingActionButton(
      child: Icon(Icons.add),
      onPressed: () {
        showDialog(
          context: context,
          child: TodoCreateDialog(dbUtil: dbUtil),
        );
      },
    );
  }
}

/// 弹窗
class TodoCreateDialog extends StatefulWidget {
  /// 从上下文传入 DBUtil,避免再次获取实例
  final DBUtil dbUtil;

  /// 如果传入了一个条目,则视为编辑
  final TodoItem item;

  const TodoCreateDialog({
    Key key,
    @required this.dbUtil,
    this.item,
  }) : super(key: key);

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

class _TodoCreateDialogState extends State<TodoCreateDialog> {
  TextEditingController _contentEditingController;

  String content;

  int level;

  @override
  void initState() {
    level = 0;

    _contentEditingController = TextEditingController();

    if (widget.item != null) {
      content = widget.item?.content;
      _contentEditingController.text = content;
      level = widget.item?.level ?? 0;
      setState(() {});
    }

    super.initState();
  }

  @override
  Widget build(BuildContext context) {
    return Dialog(
      shape: RoundedRectangleBorder(
        borderRadius: BorderRadius.circular(6),
      ),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        mainAxisSize: MainAxisSize.min,
        children: [
          title,
          input,
          levelPicker,
          SizedBox(height: 20),
          Divider(),
          actions,
        ],
      ),
    );
  }

  /// 标题
  Widget get title {
    return Container(
      padding: EdgeInsets.only(left: 20, right: 20, top: 20),
      width: double.infinity,
      child: Text(
        widget.item != null ? '编辑待办' : '新建待办',
        textAlign: TextAlign.center,
        style: TextStyle(
          fontSize: 18,
          fontWeight: FontWeight.bold,
        ),
      ),
    );
  }

  /// 输入框
  Widget get input {
    return Container(
      margin: EdgeInsets.all(20),
      decoration: BoxDecoration(
        border: Border.all(color: Colors.blueGrey.withAlpha(70)),
        borderRadius: BorderRadius.circular(6),
      ),
      child: Column(
        children: [
          TextField(
            minLines: 2,
            maxLines: 8,
            controller: _contentEditingController,
            decoration: InputDecoration(
              hintText: '请填写待办事项',
              border: InputBorder.none,
              contentPadding: EdgeInsets.symmetric(
                horizontal: 16,
                vertical: 14,
              ),
            ),
            onChanged: (String value) {
              setState(() {
                content = value;
              });
            },
          ),
        ],
      ),
    );
  }

  /// 优先级
  Widget get levelPicker {
    return Row(
      children: [
        SizedBox(width: 20),
        Expanded(
          child: Text(
            '优先级',
            style: TextStyle(
              fontSize: 12,
              color: Colors.blueGrey,
            ),
          ),
        ),
        CupertinoSegmentedControl(
          groupValue: level,
          borderColor: Colors.green,
          selectedColor: Colors.green,
          padding: EdgeInsets.zero,
          children: {
            0: Padding(
              child: Text('正常'),
              padding: EdgeInsets.symmetric(horizontal: 16, vertical: 8),
            ),
            1: Text('高'),
            2: Text('紧急'),
          },
          onValueChanged: (int index) {
            setState(() {
              level = index;
            });
          },
        ),
        SizedBox(width: 20),
      ],
    );
  }

  Widget get actions {
    return Container(
      padding: EdgeInsets.only(
        right: 20,
        left: 20,
        bottom: 10,
      ),
      child: Row(
        mainAxisAlignment: MainAxisAlignment.spaceAround,
        children: [
          Expanded(child: cancelBtn),
          Expanded(child: confirmBtn),
        ],
      ),
    );
  }

  /// 取消按钮
  Widget get cancelBtn {
    return FlatButton(
      minWidth: double.infinity,
      onPressed: () {
        Navigator.of(context).pop();
      },
      child: Text(
        '取消',
        style: TextStyle(
          fontSize: 16,
          color: Colors.blueGrey,
        ),
      ),
    );
  }

  /// 创建按钮
  Widget get confirmBtn {
    return FlatButton(
      minWidth: double.infinity,
      onPressed: () async {
        if (widget.item != null) {
          /// 更新
          await widget.dbUtil.todoBox.put(
            widget.item.key,
            TodoItem(
              content: content,
              level: level ?? 0,
              createAt: widget.item.createAt,
              completionAt: widget.item.completionAt,
            ),
          );
        } else {
          /// 新增
          await widget.dbUtil.todoBox.add(TodoItem(
            content: content,
            level: level ?? 0,
            createAt: DateTime.now().toString(),
          ));
        }

        Navigator.of(context).pop();
      },
      child: Text(
        widget.item != null ? '保存' : '创建',
        style: TextStyle(
          fontSize: 16,
          fontWeight: FontWeight.bold,
        ),
      ),
    );
  }
}

整体代码比较多,但拆分组件后,逻辑并没有变得太复杂。效果如下:

todo 待办 10.0MB
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,839评论 6 482
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,543评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 153,116评论 0 344
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,371评论 1 279
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,384评论 5 374
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,111评论 1 285
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,416评论 3 400
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,053评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,558评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,007评论 2 325
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,117评论 1 334
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,756评论 4 324
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,324评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,315评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,539评论 1 262
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,578评论 2 355
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,877评论 2 345

推荐阅读更多精彩内容