MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

Flutter SQLite数据库的迁移:处理数据结构的变更

2023-02-151.7k 阅读

Flutter SQLite 数据库迁移基础

在 Flutter 应用开发中,SQLite 数据库是一个常用的本地数据存储方案。随着应用的不断发展和迭代,数据库结构可能需要变更,这就涉及到数据库迁移的操作。数据库迁移主要目的是在不丢失原有数据的前提下,对数据库的表结构、字段等进行修改。

在 Flutter 中,我们通常使用 sqflite 插件来操作 SQLite 数据库。sqflite 提供了一系列方法来创建、查询、插入、更新和删除数据库中的数据。对于数据库迁移,sqflite 提供了 onUpgrade 回调函数来处理数据库版本升级时的操作。

数据库版本管理

在 SQLite 中,数据库有一个版本号。当应用首次创建数据库时,会指定一个初始版本号。例如:

import 'package:sqflite/sqflite.dart';
import 'package:path/path.dart';

Future<Database> openDatabase() async {
  final databasesPath = await getDatabasesPath();
  final path = join(databasesPath, 'my_database.db');

  return openDatabase(
    path,
    version: 1,
    onCreate: (Database db, int version) async {
      await db.execute('''
        CREATE TABLE users (
          id INTEGER PRIMARY KEY,
          name TEXT,
          age INTEGER
        )
      ''');
    },
  );
}

这里通过 version: 1 指定了数据库的初始版本为 1。当需要对数据库结构进行变更时,我们会增加版本号。

简单的数据库迁移示例

假设我们的应用最初只有一个 users 表,现在我们要在这个表中添加一个 email 字段。

增加版本号并实现 onUpgrade

首先,我们将数据库版本号从 1 增加到 2,并实现 onUpgrade 回调函数:

Future<Database> openDatabase() async {
  final databasesPath = await getDatabasesPath();
  final path = join(databasesPath, 'my_database.db');

  return openDatabase(
    path,
    version: 2,
    onCreate: (Database db, int version) async {
      await db.execute('''
        CREATE TABLE users (
          id INTEGER PRIMARY KEY,
          name TEXT,
          age INTEGER
        )
      ''');
    },
    onUpgrade: (Database db, int oldVersion, int newVersion) async {
      if (oldVersion == 1 && newVersion == 2) {
        await db.execute('''
          ALTER TABLE users ADD COLUMN email TEXT
        ''');
      }
    },
  );
}

onUpgrade 回调中,我们通过 oldVersionnewVersion 来判断当前是从哪个版本升级到哪个版本。如果是从版本 1 升级到版本 2,我们执行 ALTER TABLE 语句来添加 email 字段。

复杂的数据结构变更迁移

新增表并关联已有表

假设我们的应用发展到需要新增一个 orders 表,并且这个表要与 users 表通过 user_id 进行关联。

首先,修改数据库版本号为 3:

Future<Database> openDatabase() async {
  final databasesPath = await getDatabasesPath();
  final path = join(databasesPath, 'my_database.db');

  return openDatabase(
    path,
    version: 3,
    onCreate: (Database db, int version) async {
      await db.execute('''
        CREATE TABLE users (
          id INTEGER PRIMARY KEY,
          name TEXT,
          age INTEGER
        )
      ''');
    },
    onUpgrade: (Database db, int oldVersion, int newVersion) async {
      if (oldVersion == 1 && newVersion == 2) {
        await db.execute('''
          ALTER TABLE users ADD COLUMN email TEXT
        ''');
      } else if (oldVersion == 2 && newVersion == 3) {
        await db.execute('''
          CREATE TABLE orders (
            id INTEGER PRIMARY KEY,
            user_id INTEGER,
            order_date TEXT,
            FOREIGN KEY (user_id) REFERENCES users(id)
          )
        ''');
      }
    },
  );
}

在这个例子中,当从版本 2 升级到版本 3 时,我们创建了 orders 表,并通过 FOREIGN KEY 约束与 users 表建立了关联。

数据迁移与转换

有时候,不仅数据库结构会发生变化,数据也可能需要进行迁移或转换。例如,我们可能要将 users 表中的 age 字段从存储实际年龄改为存储出生年份。

先修改数据库版本号为 4:

Future<Database> openDatabase() async {
  final databasesPath = await getDatabasesPath();
  final path = join(databasesPath, 'my_database.db');

  return openDatabase(
    path,
    version: 4,
    onCreate: (Database db, int version) async {
      await db.execute('''
        CREATE TABLE users (
          id INTEGER PRIMARY KEY,
          name TEXT,
          age INTEGER
        )
      ''');
    },
    onUpgrade: (Database db, int oldVersion, int newVersion) async {
      if (oldVersion == 1 && newVersion == 2) {
        await db.execute('''
          ALTER TABLE users ADD COLUMN email TEXT
        ''');
      } else if (oldVersion == 2 && newVersion == 3) {
        await db.execute('''
          CREATE TABLE orders (
            id INTEGER PRIMARY KEY,
            user_id INTEGER,
            order_date TEXT,
            FOREIGN KEY (user_id) REFERENCES users(id)
          )
        ''');
      } else if (oldVersion == 3 && newVersion == 4) {
        // 1. 创建临时表
        await db.execute('''
          CREATE TEMPORARY TABLE users_temp AS
          SELECT id, name, age, email
          FROM users
        ''');
        // 2. 删除原表
        await db.execute('DROP TABLE users');
        // 3. 创建新结构的表
        await db.execute('''
          CREATE TABLE users (
            id INTEGER PRIMARY KEY,
            name TEXT,
            birth_year INTEGER,
            email TEXT
          )
        ''');
        // 4. 迁移数据并转换
        List<Map<String, dynamic>> tempUsers = await db.query('users_temp');
        for (var user in tempUsers) {
          int birthYear = DateTime.now().year - user['age'];
          await db.insert('users', {
            'id': user['id'],
            'name': user['name'],
            'birth_year': birthYear,
            'email': user['email']
          });
        }
        // 5. 删除临时表
        await db.execute('DROP TABLE users_temp');
      }
    },
  );
}

在这个例子中,当从版本 3 升级到版本 4 时,我们通过创建临时表、删除原表、创建新表并迁移转换数据的步骤,完成了 age 字段到 birth_year 字段的变更。

处理多步迁移和版本跳跃

多步迁移

在实际应用中,数据库迁移可能不是一步完成的。例如,我们可能需要对 orders 表进行多次修改。假设我们要在 orders 表中先添加一个 total_amount 字段,然后再添加一个 status 字段。

将数据库版本号依次增加到 5 和 6:

Future<Database> openDatabase() async {
  final databasesPath = await getDatabasesPath();
  final path = join(databasesPath, 'my_database.db');

  return openDatabase(
    path,
    version: 6,
    onCreate: (Database db, int version) async {
      await db.execute('''
        CREATE TABLE users (
          id INTEGER PRIMARY KEY,
          name TEXT,
          age INTEGER
        )
      ''');
    },
    onUpgrade: (Database db, int oldVersion, int newVersion) async {
      if (oldVersion == 1 && newVersion == 2) {
        await db.execute('''
          ALTER TABLE users ADD COLUMN email TEXT
        ''');
      } else if (oldVersion == 2 && newVersion == 3) {
        await db.execute('''
          CREATE TABLE orders (
            id INTEGER PRIMARY KEY,
            user_id INTEGER,
            order_date TEXT,
            FOREIGN KEY (user_id) REFERENCES users(id)
          )
        ''');
      } else if (oldVersion == 3 && newVersion == 4) {
        // 数据迁移与转换
        await db.execute('''
          CREATE TEMPORARY TABLE users_temp AS
          SELECT id, name, age, email
          FROM users
        ''');
        await db.execute('DROP TABLE users');
        await db.execute('''
          CREATE TABLE users (
            id INTEGER PRIMARY KEY,
            name TEXT,
            birth_year INTEGER,
            email TEXT
          )
        ''');
        List<Map<String, dynamic>> tempUsers = await db.query('users_temp');
        for (var user in tempUsers) {
          int birthYear = DateTime.now().year - user['age'];
          await db.insert('users', {
            'id': user['id'],
            'name': user['name'],
            'birth_year': birthYear,
            'email': user['email']
          });
        }
        await db.execute('DROP TABLE users_temp');
      } else if (oldVersion == 4 && newVersion == 5) {
        await db.execute('''
          ALTER TABLE orders ADD COLUMN total_amount REAL
        ''');
      } else if (oldVersion == 5 && newVersion == 6) {
        await db.execute('''
          ALTER TABLE orders ADD COLUMN status TEXT
        ''');
      }
    },
  );
}

通过这种方式,我们可以逐步对数据库进行多步迁移。

版本跳跃

有时候,我们可能会遇到版本跳跃的情况。例如,应用发布后发现某个版本的迁移有问题,需要直接从版本 2 跳跃到版本 4 进行修复。在 onUpgrade 回调中,我们需要处理这种情况:

Future<Database> openDatabase() async {
  final databasesPath = await getDatabasesPath();
  final path = join(databasesPath, 'my_database.db');

  return openDatabase(
    path,
    version: 4,
    onCreate: (Database db, int version) async {
      await db.execute('''
        CREATE TABLE users (
          id INTEGER PRIMARY KEY,
          name TEXT,
          age INTEGER
        )
      ''');
    },
    onUpgrade: (Database db, int oldVersion, int newVersion) async {
      if (oldVersion == 1 && newVersion == 2) {
        await db.execute('''
          ALTER TABLE users ADD COLUMN email TEXT
        ''');
      } else if (oldVersion == 2 && newVersion == 4) {
        // 假设跳过版本 3 的操作,直接进行版本 4 的操作
        await db.execute('''
          CREATE TEMPORARY TABLE users_temp AS
          SELECT id, name, age, email
          FROM users
        ''');
        await db.execute('DROP TABLE users');
        await db.execute('''
          CREATE TABLE users (
            id INTEGER PRIMARY KEY,
            name TEXT,
            birth_year INTEGER,
            email TEXT
          )
        ''');
        List<Map<String, dynamic>> tempUsers = await db.query('users_temp');
        for (var user in tempUsers) {
          int birthYear = DateTime.now().year - user['age'];
          await db.insert('users', {
            'id': user['id'],
            'name': user['name'],
            'birth_year': birthYear,
            'email': user['email']
          });
        }
        await db.execute('DROP TABLE users_temp');
      }
    },
  );
}

在这个例子中,当从版本 2 直接升级到版本 4 时,我们执行了版本 4 的迁移操作,跳过了版本 3 的相关操作(这里假设版本 3 的操作与修复无关)。

数据备份与恢复在迁移中的应用

数据备份

在进行复杂的数据库迁移之前,为了防止数据丢失,我们可以先对数据库进行备份。在 Flutter 中,可以使用 dart:io 库来实现文件复制,从而备份 SQLite 数据库文件。

import 'dart:io';

Future<void> backupDatabase() async {
  final databasesPath = await getDatabasesPath();
  final sourcePath = join(databasesPath,'my_database.db');
  final backupPath = join(databasesPath,'my_database_backup.db');

  File sourceFile = File(sourcePath);
  if (sourceFile.existsSync()) {
    await sourceFile.copy(backupPath);
  }
}

这个函数会将当前的数据库文件 my_database.db 复制一份到 my_database_backup.db,作为备份。

数据恢复

如果在迁移过程中出现问题,可以利用备份文件恢复数据。

Future<void> restoreDatabase() async {
  final databasesPath = await getDatabasesPath();
  final sourcePath = join(databasesPath,'my_database_backup.db');
  final targetPath = join(databasesPath,'my_database.db');

  File sourceFile = File(sourcePath);
  if (sourceFile.existsSync()) {
    File targetFile = File(targetPath);
    if (targetFile.existsSync()) {
      await targetFile.delete();
    }
    await sourceFile.copy(targetPath);
  }
}

这个函数会删除当前的数据库文件,并将备份文件复制到原数据库文件位置,实现数据恢复。

在实际的迁移操作中,可以在迁移开始前调用 backupDatabase 进行备份,在迁移出现异常时调用 restoreDatabase 恢复数据。例如:

Future<Database> openDatabase() async {
  final databasesPath = await getDatabasesPath();
  final path = join(databasesPath,'my_database.db');

  try {
    await backupDatabase();
    return openDatabase(
      path,
      version: 4,
      onCreate: (Database db, int version) async {
        await db.execute('''
          CREATE TABLE users (
            id INTEGER PRIMARY KEY,
            name TEXT,
            age INTEGER
          )
        ''');
      },
      onUpgrade: (Database db, int oldVersion, int newVersion) async {
        if (oldVersion == 1 && newVersion == 2) {
          await db.execute('''
            ALTER TABLE users ADD COLUMN email TEXT
          ''');
        } else if (oldVersion == 2 && newVersion == 4) {
          await db.execute('''
            CREATE TEMPORARY TABLE users_temp AS
            SELECT id, name, age, email
            FROM users
          ''');
          await db.execute('DROP TABLE users');
          await db.execute('''
            CREATE TABLE users (
              id INTEGER PRIMARY KEY,
              name TEXT,
              birth_year INTEGER,
              email TEXT
            )
          ''');
          List<Map<String, dynamic>> tempUsers = await db.query('users_temp');
          for (var user in tempUsers) {
            int birthYear = DateTime.now().year - user['age'];
            await db.insert('users', {
              'id': user['id'],
              'name': user['name'],
              'birth_year': birthYear,
              'email': user['email']
            });
          }
          await db.execute('DROP TABLE users_temp');
        }
      },
    );
  } catch (e) {
    await restoreDatabase();
    rethrow;
  }
}

这样在迁移过程中如果出现异常,会先恢复数据库,然后重新抛出异常,便于开发者定位问题。

测试数据库迁移

单元测试

对于数据库迁移,进行单元测试是非常重要的。可以使用 test 包来编写单元测试。例如,测试添加 email 字段的迁移:

import 'package:sqflite/sqflite.dart';
import 'package:test/test.dart';
import 'package:path/path.dart';
import 'package:sqflite_common_ffi/sqflite_ffi.dart';

void main() {
  setUp(() {
    sqfliteFfiInit();
    databaseFactory = databaseFactoryFfi;
  });

  test('Test email field addition in migration', () async {
    final databasesPath = await getDatabasesPath();
    final path = join(databasesPath, 'test_database.db');

    // 以版本 1 创建数据库
    Database db = await openDatabase(
      path,
      version: 1,
      onCreate: (Database db, int version) async {
        await db.execute('''
          CREATE TABLE users (
            id INTEGER PRIMARY KEY,
            name TEXT,
            age INTEGER
          )
        ''');
      },
    );
    await db.close();

    // 升级到版本 2
    db = await openDatabase(
      path,
      version: 2,
      onCreate: (Database db, int version) async {
        await db.execute('''
          CREATE TABLE users (
            id INTEGER PRIMARY KEY,
            name TEXT,
            age INTEGER
          )
        ''');
      },
      onUpgrade: (Database db, int oldVersion, int newVersion) async {
        if (oldVersion == 1 && newVersion == 2) {
          await db.execute('''
            ALTER TABLE users ADD COLUMN email TEXT
          ''');
        }
      },
    );

    List<Map<String, dynamic>> columns = await db.rawQuery('PRAGMA table_info(users)');
    bool hasEmailColumn = columns.any((column) => column['name'] == 'email');
    expect(hasEmailColumn, true);

    await db.close();
  });
}

在这个单元测试中,我们先以版本 1 创建数据库,然后升级到版本 2 并检查 email 字段是否成功添加。

集成测试

除了单元测试,集成测试可以更全面地测试数据库迁移在整个应用环境中的表现。可以使用 flutter_test 包来编写集成测试。例如,测试整个应用从初始版本到多个版本升级后的功能:

import 'package:flutter_test/flutter_test.dart';
import 'package:your_app/database_helper.dart';

void main() {
  group('Database Migration Integration Test', () {
    testWidgets('Test full migration flow', (WidgetTester tester) async {
      // 启动应用
      await tester.pumpWidget(MyApp());

      // 检查初始数据库状态
      Database db = await DatabaseHelper().database;
      List<Map<String, dynamic>> users = await db.query('users');
      expect(users.length, 0);

      // 模拟版本升级
      await DatabaseHelper().upgradeDatabaseToVersion(4);

      // 检查升级后的数据库状态
      users = await db.query('users');
      List<Map<String, dynamic>> columns = await db.rawQuery('PRAGMA table_info(users)');
      bool hasBirthYearColumn = columns.any((column) => column['name'] == 'birth_year');
      expect(hasBirthYearColumn, true);

      await db.close();
    });
  });
}

在这个集成测试中,我们启动应用,检查初始数据库状态,然后模拟数据库升级到版本 4,并检查升级后的数据库结构是否符合预期。

通过单元测试和集成测试,可以有效地保证数据库迁移的正确性和稳定性,避免在应用发布后出现数据丢失或结构错误等问题。

总结

在 Flutter 应用开发中,处理 SQLite 数据库的数据结构变更需要谨慎操作。通过合理使用 sqflite 插件的 onUpgrade 回调函数,结合数据备份恢复、测试等手段,可以确保数据库迁移过程的顺利进行,保证应用数据的完整性和一致性。无论是简单的字段添加,还是复杂的数据结构调整和数据转换,都需要遵循一定的规范和流程,以提供稳定可靠的应用体验。在实际项目中,根据应用的业务需求和发展规划,灵活运用这些数据库迁移技巧,能够更好地管理和维护应用的本地数据存储。