从设计稿到数据库:Flutter背单词应用的数据层设计与AI协作实践
1. 项目概述与核心思路
最近在折腾一个背单词的Flutter应用,这是系列的第二篇。上一篇主要聊了想法和基础框架,这次想和大家分享一下从设计稿到数据落地的完整过程。很多开发者,尤其是独立开发者或者小团队,经常会卡在“想法很丰满,实现很骨感”的阶段——UI画得挺漂亮,但一到数据库设计就头大,表结构怎么定?字段类型选什么?关系怎么关联?一堆问题扑面而来。
我的做法是,先用手头的工具(比如Figma或Penpot)把核心页面的设计稿画出来,把用户的操作流程和数据展示需求可视化。然后,我直接拿着这些设计稿去和AI“聊了会天”,让它帮我推导出合理的数据库结构。这个过程不仅仅是得到一个SQL文件,更是一个梳理业务逻辑、明确实体关系的绝佳机会。你会发现,当AI开始问你“这个‘生词本’是用户私有的还是可以公开共享?”、“单词的‘熟练度’是离散的等级还是连续的数值?”这类问题时,你自己对项目的理解也会瞬间清晰很多。
这篇文章,我就来详细拆解这个流程:如何从一份视觉设计稿,一步步推导出健壮、可扩展的数据库模型,并最终在Flutter项目中落地。无论你是Flutter新手,还是正在为某个应用的数据层设计犯愁,相信这个“设计稿 -> AI分析 -> 数据库实现”的思路都能给你带来一些启发。
2. 从设计稿到数据需求:梳理核心实体与关系
设计稿不是美术作品,它是业务逻辑和数据的可视化呈现。在动笔写第一行数据库代码之前,我们必须从设计稿中提炼出关键的数据实体(Entity)和它们之间的关系(Relationship)。
2.1 拆解核心页面与数据流
我画的设计稿主要包含了以下几个核心页面:
- 首页/单词学习页:展示今日待学习的单词卡片,包含单词、音标、释义、例句。
- 单词详情页:点击卡片后进入,展示更详细的解释、同义词、词根词缀等。
- 生词本/收藏夹页:用户手动收藏的单词列表。
- 学习统计页:展示学习天数、已掌握单词数、记忆曲线等图表。
- 用户设置页:包括每日学习目标、提醒时间、发音偏好等。
仅仅看页面,我们就能初步抽象出几个核心实体:用户(User)、单词(Word)、单词学习记录(LearningRecord)、生词本(WordBook)。其中,单词这个实体是相对静态的,可以看作我们的“基础数据池”;而学习记录则是连接用户和单词的动态核心,它记录了“谁在什么时间以什么结果学习了哪个单词”这一关键事件。
2.2 定义实体属性与关系
接下来,我们需要为每个实体定义具体的属性。这时,设计稿上的每一个UI元素都可能对应一个或多个字段。
以单词(Word)实体为例:
- 基础信息:
word(单词拼写,主键)、phonetic(音标)、definition(释义,考虑到一词多义,可能需要用JSON存储或拆分成子表)。 - 扩展信息:
example_sentence(例句)、synonyms(同义词,列表)、roots(词根)。 - 元信息:
difficulty_level(根据词频设定的初始难度)、part_of_speech(词性)。
而学习记录(LearningRecord)则更为关键,它体现了业务的核心逻辑:
- 关联信息:
user_id(外键,关联用户)、word_id(外键,关联单词)。 - 学习状态:
familiarity(熟悉度,一个0-1的浮点数或1-5的整数等级)、last_reviewed_at(上次复习时间)、next_review_at(基于艾宾浩斯曲线计算的下次复习时间)。 - 学习历史:
review_count(复习次数)、correct_count(回答正确次数)。
注意:关于
familiarity(熟悉度)的设计,这里有一个重要的取舍。如果定义为离散等级(如1-5星),UI展示(比如用星星图标)和用户交互(点选评分)会很直观。但如果后续想引入更复杂的记忆算法(如基于SM-2的间隔重复算法),连续数值(如0.0-1.0)或包含easiness_factor(难度系数)、interval(间隔天数)等更多参数的模型会更有弹性。我建议在初期采用一个简单的整数等级(1-5),但数据库字段可以预留为FLOAT,为未来升级留出空间。
实体之间的关系是数据库设计的灵魂。在我们的背单词APP中:
- 一个用户拥有多条学习记录。(一对多)
- 一个单词可以被多条学习记录关联。(一对多)
- 一个用户可以创建多个生词本。(一对多)
- 一个生词本可以包含多个单词,一个单词也可以被加入多个生词本。(多对多,这意味着需要一张额外的关联表
word_book_relation)
理清这些关系,ER图(实体关系图)的雏形就在脑中形成了。带着这个初步构思,我们就可以去和AI进行更有针对性的“讨论”了。
3. 与AI协作进行数据库设计:提问、验证与优化
直接让AI“给我设计一个背单词的数据库”得到的结果往往泛泛而谈。高效的做法是,带着你梳理好的实体、属性和关系去提问,让AI扮演一个经验丰富的数据库架构师角色,对你的设计进行审查、提问和优化。
3.1 第一轮:提供上下文与初步设计
我给AI的提示(Prompt)大致是这样的: “我正在开发一个Flutter背单词APP。核心实体有用户(User)、单词(Word)、学习记录(LearningRecord)、生词本(WordBook)。我已经梳理了它们的基本属性和关系(如上所述)。请基于以下具体需求,帮我设计出详细的SQLite数据库表结构:
- 支持用户登录(后期可能扩展第三方登录)。
- 单词数据量可能很大(数万级),需要高效查询。
- 学习记录需要支持基于‘下次复习时间’的智能查询,以驱动每日学习任务。
- 生词本功能需要灵活,允许用户自定义创建。 请给出完整的
CREATE TABLE语句,并说明索引策略。”
AI的回复通常会直接给出SQL语句,但更重要的是,它往往会附带一系列追问和建议,这正是我们需要的:
- 关于用户表:AI可能会问,“
User表是否只需要本地存储?是否需要created_at字段用于数据分析?” 这提醒我们,即使用户系统暂时简单,保留基础的时间戳字段也是好习惯。 - 关于单词表:AI可能会建议,“
definition(释义)字段如果存储长文本或结构化数据(如JSON数组),需注意查询效率。对于synonyms(同义词)这类列表数据,建议拆分成单独的表word_synonyms,以支持更灵活的查询(如查找所有包含某个同义词的单词)。” 这是一个在“存储便捷性”和“查询灵活性”之间的经典权衡。初期为了简单,我可能会用JSON存储,但AI的建议为我标出了一个未来可能的重构点。 - 关于学习记录表:AI的核心建议通常会落在索引上。它会强调,
(user_id, next_review_at)这个联合索引对于快速获取用户今日需复习的单词至关重要。同时,(user_id, word_id)上应该建立唯一索引,防止同一个用户对同一个单词产生重复的学习记录。
3.2 第二轮:深入业务细节与性能考量
基于AI的第一轮反馈,我们可以进行更深入的讨论。例如,我提出了一个具体场景: “如果我想实现一个‘单词熟练度随时间变化’的折线图,在LearningRecord表中应该如何记录历史数据才最高效?是每次复习都新增一条记录,还是更新原记录并另开一张review_history表记录每次复习的详情?”
AI的分析通常会很中肯:
- 方案A(新增记录):优点是可以完整追溯每一次学习事件,便于做详细的数据分析。缺点是数据量会快速增长,查询用户对某个单词的当前状态时需要找
latest记录,稍显复杂。 - 方案B(主记录+历史表):
LearningRecord表只保存当前状态(如最新熟悉度、下次复习时间)。另建一张ReviewHistory表,记录每次复习的时间、结果(正确/错误)、所用时间等。这样平衡了查询性能和历史追踪的需求。
对于背单词应用,方案B通常是更优的选择。因为核心业务查询(获取今日需复习单词)非常频繁,需要LearningRecord表尽可能轻量。而详细的历史记录主要用于统计分析,访问频率较低。这个讨论过程让我明确了“当前状态”与“历史轨迹”分离的设计原则。
3.3 最终确定的数据库方案
经过几轮与AI的“对话”,我最终确定的SQLite核心表结构如下。这里不仅包含SQL,也包含了设计理由:
-- 用户表:核心是id和用于个性化设置的字段 CREATE TABLE users ( id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT UNIQUE, -- 可用于本地标识或未来同步 daily_goal INTEGER DEFAULT 20, -- 每日学习目标单词数 created_at INTEGER DEFAULT (strftime('%s', 'now')) -- 使用时间戳 ); CREATE INDEX idx_users_username ON users(username); -- 单词表:作为应用的“基础数据池” CREATE TABLE words ( id INTEGER PRIMARY KEY AUTOINCREMENT, word TEXT UNIQUE NOT NULL, -- 单词本身作为业务键,并建立唯一约束 phonetic TEXT, definition TEXT, -- 初期使用JSON字符串存储多个释义,如'[{"pos":"n.", "def":"定义1"}, {"pos":"v.", "def":"定义2"}]' example TEXT, difficulty INTEGER DEFAULT 1, -- 1-5级难度 created_at INTEGER DEFAULT (strftime('%s', 'now')) ); CREATE INDEX idx_words_word ON words(word); CREATE INDEX idx_words_difficulty ON words(difficulty); -- 学习记录表:整个应用最核心、最活跃的表 CREATE TABLE learning_records ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, word_id INTEGER NOT NULL, familiarity INTEGER DEFAULT 1, -- 熟悉度等级 1-5 last_reviewed_at INTEGER, -- 上次复习时间戳 next_review_at INTEGER, -- 下次复习时间戳,核心字段 review_count INTEGER DEFAULT 0, correct_count INTEGER DEFAULT 0, created_at INTEGER DEFAULT (strftime('%s', 'now')), updated_at INTEGER DEFAULT (strftime('%s', 'now')), UNIQUE(user_id, word_id), -- 防止重复记录 FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE, FOREIGN KEY (word_id) REFERENCES words (id) ON DELETE CASCADE ); -- 核心性能索引:快速获取某个用户今日需要复习的单词 CREATE INDEX idx_learning_records_review ON learning_records(user_id, next_review_at); CREATE INDEX idx_learning_records_user_word ON learning_records(user_id, word_id); -- 复习历史表:用于详细统计和图表生成 CREATE TABLE review_histories ( id INTEGER PRIMARY KEY AUTOINCREMENT, learning_record_id INTEGER NOT NULL, review_result INTEGER NOT NULL, -- 0错误,1正确 reviewed_at INTEGER DEFAULT (strftime('%s', 'now')), FOREIGN KEY (learning_record_id) REFERENCES learning_records (id) ON DELETE CASCADE ); CREATE INDEX idx_review_histories_record ON review_histories(learning_record_id); CREATE INDEX idx_review_histories_time ON review_histories(reviewed_at); -- 生词本表 CREATE TABLE word_books ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, name TEXT NOT NULL, is_public INTEGER DEFAULT 0, -- 0私有,1公开 created_at INTEGER DEFAULT (strftime('%s', 'now')), FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE ); -- 生词本与单词的关联表(多对多关系) CREATE TABLE word_book_relations ( word_book_id INTEGER NOT NULL, word_id INTEGER NOT NULL, added_at INTEGER DEFAULT (strftime('%s', 'now')), PRIMARY KEY (word_book_id, word_id), -- 联合主键 FOREIGN KEY (word_book_id) REFERENCES word_books (id) ON DELETE CASCADE, FOREIGN KEY (word_id) REFERENCES words (id) ON DELETE CASCADE );实操心得:在与AI讨论时,不要只满足于得到SQL语句。一定要追问“为什么这么设计?”和“有没有潜在的瓶颈?”。比如,AI建议在
learning_records上建立(user_id, next_review_at)索引,你要理解这是因为我们最频繁的查询是SELECT * FROM learning_records WHERE user_id = ? AND next_review_at <= ?。同时,时间戳字段统一使用INTEGER存储Unix时间戳,比TEXT或DATETIME类型在比较和计算时性能更好,时区处理也更简单。
4. 在Flutter项目中集成与操作数据库
数据库设计好了,接下来就是在Flutter中让它运转起来。我选择了sqflite+drift(原moor)的组合。sqflite提供了可靠的SQLite底层支持,而drift作为一个类型安全、编译时生成代码的ORM库,能极大提升开发效率和代码可维护性。
4.1 项目配置与模型定义
首先,在pubspec.yaml中添加依赖:
dependencies: drift: ^2.19.0 sqlite3_flutter_libs: ^0.5.0 path_provider: ^2.1.0 path: ^1.9.0 dev_dependencies: drift_dev: ^2.19.0 build_runner: ^2.4.0然后,根据我们设计的数据表,使用drift的语法定义数据模型。这里以Word和LearningRecord为例:
// database.dart import 'package:drift/drift.dart'; import 'package:drift/native.dart'; import 'package:path_provider/path_provider.dart'; import 'package:path/path.dart' as p; import 'dart:io'; part 'database.g.dart'; // 这将由drift自动生成 class Words extends Table { IntColumn get id => integer().autoIncrement()(); TextColumn get word => text().unique()(); TextColumn get phonetic => text().nullable()(); TextColumn get definition => text()(); // 存储JSON字符串 TextColumn get example => text().nullable()(); IntColumn get difficulty => integer().withDefault(const Constant(1))(); DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)(); } class LearningRecords extends Table { IntColumn get id => integer().autoIncrement()(); IntColumn get userId => integer().customConstraint('NOT NULL REFERENCES users(id) ON DELETE CASCADE')(); IntColumn get wordId => integer().customConstraint('NOT NULL REFERENCES words(id) ON DELETE CASCADE')(); IntColumn get familiarity => integer().withDefault(const Constant(1))(); DateTimeColumn get lastReviewedAt => dateTime().nullable()(); DateTimeColumn get nextReviewAt => dateTime().nullable()(); IntColumn get reviewCount => integer().withDefault(const Constant(0))(); IntColumn get correctCount => integer().withDefault(const Constant(0))(); DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)(); DateTimeColumn get updatedAt => dateTime().withDefault(currentDateAndTime)(); @override List<Set<Column>> get uniqueKeys => [ {userId, wordId} ]; } @DriftDatabase(tables: [Words, LearningRecords, Users, ReviewHistories, WordBooks, WordBookRelations]) class AppDatabase extends _$AppDatabase { AppDatabase() : super(_openConnection()); @override int get schemaVersion => 1; // 数据库版本,用于升级 // 可在此处定义一些复杂的查询方法 Future<List<WordWithRecord>> getTodayReviewWords(int userId) async { final now = DateTime.now(); final query = select(words).join([ innerJoin(learningRecords, learningRecords.wordId.equalsExp(words.id)), ]) ..where(learningRecords.userId.equals(userId) & learningRecords.nextReviewAt.isSmallerOrEqualValue(now)) ..orderBy([OrderingTerm(expression: learningRecords.nextReviewAt)]); return await query.map((row) => WordWithRecord(row.readTable(words), row.readTable(learningRecords))).get(); } } LazyDatabase _openConnection() { return LazyDatabase(() async { final dbFolder = await getApplicationDocumentsDirectory(); final file = File(p.join(dbFolder.path, 'vocabulary.db')); return NativeDatabase(file); }); }定义完成后,在项目根目录运行flutter pub run build_runner build命令,drift会自动生成database.g.dart文件,里面包含了所有表的Data Class和查询方法的实现。
4.2 核心数据操作与业务逻辑封装
有了生成的代码,我们就可以在Repository或Service层封装业务逻辑了。这里的关键是将数据库操作与UI逻辑解耦。
// word_repository.dart class WordRepository { final AppDatabase _db; WordRepository(this._db); // 添加一个单词到学习记录(首次学习) Future<void> learnWord(int userId, int wordId) async { final existing = await (_db.select(_db.learningRecords) ..where((tbl) => tbl.userId.equals(userId) & tbl.wordId.equals(wordId))) .getSingleOrNull(); if (existing == null) { // 首次学习,创建记录,下次复习时间设为明天 final record = LearningRecordsCompanion.insert( userId: userId, wordId: wordId, familiarity: const Value(1), nextReviewAt: Value(DateTime.now().add(const Duration(days: 1))), ); await _db.into(_db.learningRecords).insert(record); } // 如果已存在,可能是从生词本直接学习,这里不做操作 } // 复习一个单词,更新学习记录并添加历史 Future<void> reviewWord(int recordId, bool isCorrect) async { await _db.transaction(() async { final record = await (_db.select(_db.learningRecords)..where((tbl) => tbl.id.equals(recordId))).getSingle(); final newFamiliarity = _calculateNewFamiliarity(record.familiarity, isCorrect); final nextInterval = _calculateNextInterval(newFamiliarity, record.reviewCount); // 更新学习记录 await (_db.update(_db.learningRecords)..where((tbl) => tbl.id.equals(recordId))).write( LearningRecordsCompanion( familiarity: Value(newFamiliarity), lastReviewedAt: Value(DateTime.now()), nextReviewAt: Value(DateTime.now().add(Duration(days: nextInterval))), reviewCount: Value(record.reviewCount + 1), correctCount: Value(record.correctCount + (isCorrect ? 1 : 0)), updatedAt: Value(DateTime.now()), ), ); // 插入复习历史 await _db.into(_db.reviewHistories).insert( ReviewHistoriesCompanion.insert( learningRecordId: recordId, reviewResult: isCorrect ? 1 : 0, ), ); }); } // 简单的记忆算法示例:根据本次结果调整熟悉度 int _calculateNewFamiliarity(int current, bool isCorrect) { if (isCorrect) { return (current < 5) ? current + 1 : 5; } else { return (current > 1) ? current - 1 : 1; } } // 简单的间隔计算示例:熟悉度越高,间隔越长 int _calculateNextInterval(int familiarity, int reviewCount) { const baseIntervals = [1, 2, 4, 7, 14]; // 对应熟悉度1-5的间隔天数 return baseIntervals[familiarity - 1]; } }注意事项:在
reviewWord方法中,我使用了_db.transaction来确保更新学习记录和插入历史记录这两个操作是原子的,要么都成功,要么都失败,避免数据不一致。这是处理关联数据更新时的最佳实践。另外,这里的记忆算法(_calculateNewFamiliarity和_calculateNextInterval)是非常简化的版本。在实际项目中,你可能需要实现更科学的算法,如SM-2(Anki使用的算法),它会考虑难度系数、重复次数等多个因素来计算下次复习间隔。
4.3 数据库迁移与版本管理
应用迭代中,数据库结构变更是不可避免的。drift提供了良好的迁移支持。当我们需要新增字段或表时,首先更新Table的定义,然后增加schemaVersion,并在onUpgrade回调中编写迁移逻辑。
@DriftDatabase(tables: [Words, LearningRecords, Users, ReviewHistories, WordBooks, WordBookRelations]) class AppDatabase extends _$AppDatabase { AppDatabase() : super(_openConnection()); @override int get schemaVersion => 2; // 从1升级到2 @override MigrationStrategy get migration { return MigrationStrategy( onUpgrade: (migrator, from, to) async { if (from == 1) { // 从版本1升级到2:为words表添加“词性”字段 await migrator.addColumn(words, words.partOfSpeech); // 或者创建新表 // await migrator.createTable(someNewTable); } }, beforeOpen: (details) async { // 数据库打开前的操作,例如启用外键约束(SQLite默认关闭) await customStatement('PRAGMA foreign_keys = ON'); }, ); } }踩坑提醒:SQLite的
ALTER TABLE能力有限,只能添加列、重命名表,不能删除列或修改列类型。如果要做复杂的结构变更(如删除字段、修改字段类型),通常需要创建新表、迁移数据、删除旧表、重命名新表。drift的MigrationStrategy提供了migrator对象来简化这些操作,但规划好初始设计以减少后期复杂变更仍是上策。
5. 数据层与UI层的连接:状态管理与数据流
数据库和业务逻辑准备好了,最后一步是如何将数据流畅地展示在Flutter UI上。我推荐使用Provider、Riverpod或GetX这类状态管理工具,配合FutureBuilder或StreamBuilder来实现响应式UI。
5.1 使用Provider封装与提供数据
我们创建一个WordReviewModel来管理今日需复习的单词列表状态。
// word_review_model.dart class WordReviewModel extends ChangeNotifier { final WordRepository _repository; List<WordWithRecord> _todayWords = []; bool _isLoading = false; List<WordWithRecord> get todayWords => _todayWords; bool get isLoading => _isLoading; WordReviewModel(this._repository); Future<void> loadTodayReviewWords(int userId) async { if (_isLoading) return; _isLoading = true; notifyListeners(); try { _todayWords = await _repository.getTodayReviewWords(userId); } catch (e) { // 处理错误,例如显示Snackbar print('加载复习单词失败: $e'); _todayWords = []; } finally { _isLoading = false; notifyListeners(); } } Future<void> reviewWord(int recordId, bool isCorrect) async { try { await _repository.reviewWord(recordId, isCorrect); // 复习后,从当前列表中移除该单词 _todayWords.removeWhere((item) => item.learningRecord.id == recordId); notifyListeners(); } catch (e) { print('复习单词操作失败: $e'); // 处理错误 } } }5.2 在UI中消费数据
在UI页面中,我们可以监听WordReviewModel的变化,并更新界面。
// review_page.dart class ReviewPage extends StatelessWidget { const ReviewPage({Key? key}) : super(key: key); @override Widget build(BuildContext context) { final model = context.watch<WordReviewModel>(); final userId = context.read<AuthModel>().currentUserId; // 假设从AuthModel获取用户ID if (model.isLoading) { return const Center(child: CircularProgressIndicator()); } if (model.todayWords.isEmpty) { return Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Text('恭喜!今日复习任务已完成。'), ElevatedButton( onPressed: () => model.loadTodayReviewWords(userId), child: Text('重新加载'), ), ], ), ); } return PageView.builder( itemCount: model.todayWords.length, itemBuilder: (context, index) { final item = model.todayWords[index]; return WordCard( word: item.word, record: item.learningRecord, onKnown: () => model.reviewWord(item.learningRecord.id, true), onUnknown: () => model.reviewWord(item.learningRecord.id, false), ); }, ); } }实操心得:在
WordCard组件中,onKnown和onUnknown回调会触发model.reviewWord,该操作会更新数据库并修改model.todayWords列表,随后notifyListeners()会触发UI重建。由于PageView基于新的列表构建,当前复习完的单词卡片就会自然消失,切换到下一个单词,用户体验非常流畅。这种“操作 -> 更新状态 -> UI响应”的闭环是Flutter状态管理的精髓。
6. 常见问题、调试与性能优化
在实际开发中,从数据库设计到集成一定会遇到各种问题。下面记录了一些典型场景和解决思路。
6.1 数据库连接与初始化失败
问题描述:APP启动时闪退,日志提示无法打开数据库文件或表不存在。排查步骤:
- 检查路径权限:确保
getApplicationDocumentsDirectory()返回的路径是可写的。在Android和iOS上,这通常是安全目录。 - 检查数据库创建与升级逻辑:确认
schemaVersion设置正确,onCreate和onUpgrade回调被正确执行。可以在beforeOpen回调中打印日志确认。 - 查看生成的SQL:
drift会在运行build_runner时生成.g.dart文件。可以检查其中的createTable语句是否与你的预期一致。 - 使用数据库查看工具:将真机或模拟器中的数据库文件导出,用DB Browser for SQLite等工具直接打开,检查表结构和数据。这是最直接的调试手段。
6.2 查询性能慢,列表滚动卡顿
问题描述:生词本单词列表过多时,滚动不流畅。原因分析:很可能是在ListView.builder的itemBuilder中同步执行了复杂的数据库查询或计算。解决方案:
- 预加载与分页:不要在UI构建函数中直接查询全部数据。改为在Model中一次性加载适量数据(如50条),结合
ScrollController监听滚动到底部事件,再加载下一页。// 在Model中实现分页逻辑 Future<void> loadWordBooks(int userId, {int limit = 50, int offset = 0}) async { final query = select(wordBooks)..where((tbl) => tbl.userId.equals(userId))..limit(limit, offset: offset); _wordBooks.addAll(await query.get()); notifyListeners(); } - 使用
IndexedWidgetBuilder:确保ListView.builder被正确使用,它只会构建可见区域的子项。 - 优化查询语句:检查慢查询是否缺少索引。使用
EXPLAIN QUERY PLAN命令(在数据库工具中或通过customStatement执行)分析查询计划。确保WHERE、ORDER BY、JOIN涉及的字段都已建立索引。 - 将计算移出UI线程:对于熟悉度计算、下次复习时间计算等,确保在Repository层完成,不要放在
build方法里。
6.3 数据同步与冲突处理(为未来做准备)
问题描述:未来如果加入多设备同步功能,同一个学习记录在手机A和手机B上被几乎同时修改,会产生冲突。设计思路:
- 增加同步元字段:在核心表(如
learning_records)中添加updated_at(更新时间戳)和is_dirty(本地脏数据标记)字段。 - 采用“最后写入获胜”或更复杂的合并策略:比较
updated_at,取最新的记录。但对于“复习次数”、“正确次数”这种累加字段,简单的覆盖会导致数据丢失。更优的策略是将其设计为可合并的,例如,服务器端存储基础值,客户端上传增量(review_count_delta),由服务器进行合并。 - 使用
drift的DataClass与Companion:drift生成的Companion类用于插入/更新,它支持Value.absent()来表示“此字段不更新”,这在部分字段更新时非常有用,可以避免覆盖未修改的字段。
性能优化小技巧:对于
words这类基础且变动少的表,可以考虑在APP首次启动时,将预制的单词库(一个大的SQL文件或JSON文件)一次性导入数据库,避免在运行时进行大量INSERT。对于learning_records这种增长快的表,定期归档或清理非常久远(如一年前)且熟悉度已达最高等级的数据,可以控制表的大小,提升查询效率。
