Flutter SQLite数据库的迁移:处理数据结构的变更
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
回调中,我们通过 oldVersion
和 newVersion
来判断当前是从哪个版本升级到哪个版本。如果是从版本 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
回调函数,结合数据备份恢复、测试等手段,可以确保数据库迁移过程的顺利进行,保证应用数据的完整性和一致性。无论是简单的字段添加,还是复杂的数据结构调整和数据转换,都需要遵循一定的规范和流程,以提供稳定可靠的应用体验。在实际项目中,根据应用的业务需求和发展规划,灵活运用这些数据库迁移技巧,能够更好地管理和维护应用的本地数据存储。