1. 项目缘起与核心挑战:为什么选择Flutter与两周极限挑战
去年年底,我给自己定了个目标:想做一个真正能解决自己背单词痛点的App。市面上背单词软件很多,但要么功能太臃肿,要么复习算法不符合我的记忆习惯,要么就是广告太多。作为一个有开发能力的用户,我决定自己动手。但问题来了,我只有一个人,并且希望能在春节假期这两周的空闲时间里,完成一个从零到一、能上架到应用商店的MVP(最小可行产品)。时间紧,任务重,技术选型就成了第一个关键决策。
为什么最终选择了Flutter?这背后有几个非常现实的考量。首先,“一个人”意味着我必须最大化开发效率,避免在Android和iOS两端重复劳动。Flutter的“一次编写,多端运行”特性,对于个人开发者来说,吸引力是致命的。其次,“两周”这个时间限制,要求技术栈必须上手快、生态成熟、能快速产出UI。Dart语言对于有Java或JavaScript背景的开发者来说非常友好,而Flutter丰富的预制组件和热重载功能,能让我在开发UI时获得即时反馈,极大地加快了界面构建和调试的速度。最后,背单词App的核心——数据管理和状态同步——对于Flutter而言,有provider、riverpod、bloc等成熟的状态管理方案,社区也有大量关于本地数据库(如sqflite、hive)的实践,技术风险可控。
当然,挑战也非常明确。两周时间,我不可能做一个功能完备的“墨墨背单词”或“不背单词”。我必须做极致的减法,聚焦最核心的“学习-复习”闭环。我的核心需求清单被压缩到了极致:1. 词库的导入与管理(支持自定义);2. 基于艾宾浩斯遗忘曲线的复习计划生成;3. 简洁的学习与复习界面;4. 学习数据的本地持久化存储。任何锦上添花的功能,如社交、云同步、复杂统计图表,全部砍掉。这个决策过程很痛苦,但它是项目能按时完成的前提。
2. 技术架构与核心包选型:一个人的高效作战方案
确定了Flutter和技术范围后,下一步就是搭建项目骨架和选择核心依赖。我的原则是:选用最流行、文档最全、社区问题解答最多的包,最大限度降低踩坑成本和搜索解决方案的时间。
2.1 项目初始化与基础架构
我使用flutter create word_cracker创建项目后,第一件事不是写代码,而是规划目录结构。一个清晰的结构能让自己在高速开发中不迷失。我采用了基于功能模块的目录组织方式:
lib/ ├── main.dart ├── core/ # 核心逻辑 │ ├── models/ # 数据模型 (Word, ReviewRecord等) │ ├── services/ # 业务服务 (词库解析、复习算法等) │ └── constants/ # 常量定义 ├── data/ # 数据层 │ ├── local/ # 本地数据库相关 │ └── repositories/ # 数据仓库 ├── presentation/ # UI层 │ ├── pages/ # 页面 (home, learn, review等) │ ├── widgets/ # 可复用组件 │ └── providers/ # 状态管理 (我选择了riverpod) └── utils/ # 工具类 (扩展函数、格式转换等)2.2 核心依赖包(pubspec.yaml关键项)
以下是我在pubspec.yaml中精挑细选的依赖,每一个都肩负重任:
dependencies: flutter: sdk: flutter # 状态管理:Riverpod,比Provider更现代,编译安全,依赖注入清晰 flutter_riverpod: ^2.3.6 # 本地数据库:Hive,性能远超sqflite,纯Dart实现,API简洁 hive: ^2.2.3 hive_flutter: ^1.1.0 # 词库解析:csv,用于解析从网上下载的CSV格式词库文件 csv: ^5.0.2 # 路由管理:go_router,声明式路由,支持深链接和嵌套路由,比Navigator 2.0原生API友好 go_router: ^12.1.1 # UI增强:懒加载列表,优化长列表性能 flutter_staggered_animations: ^1.1.0 # 日期时间处理 intl: ^0.18.1 dev_dependencies: # Hive模型生成器,通过注解自动生成TypeAdapter,极大简化模型序列化 hive_generator: ^1.1.3 build_runner: ^2.4.6选型理由深度剖析:
- 状态管理为什么是Riverpod?在两周的极限开发中,状态管理的清晰度和可维护性至关重要。
Provider的依赖关系在大型项目中容易变得隐晦。Riverpod的“Provider”是全局可访问的单例,但依赖关系是声明式且显式的,所有依赖在编译期就能检查出来,避免了运行时“Provider not found”的错误。这对于快速迭代、频繁修改数据流的情景来说,能节省大量调试时间。 - 数据库为什么是Hive而不是sqflite?背单词App的数据结构相对简单(单词、复习记录),但读写频繁。
sqflite是SQLite的包装,功能强大,但需要写SQL或使用ORM,有一定学习成本。Hive是一个轻量级、基于键值的数据库,它的性能在纯Dart环境下非常出色,特别是对于大量小型对象的存储和查询。配合hive_generator,我只需要用@HiveType()注解我的数据模型类,运行一条命令,就能自动生成序列化代码,开发体验极其流畅。这对于追求速度的个人项目是决定性优势。 - 路由为什么是go_router?Flutter原生的Navigator 2.0 API过于底层和复杂。
go_router通过一个简洁的声明式路由表,统一管理了路由栈、路径解析、参数传递和过渡动画。它完美支持了我在App中需要的底部导航栏(BottomNavigationBar)与独立页面栈共存的场景,代码非常清晰。
这个技术选型组合,确保了我能在大部分时间里,专注于业务逻辑(背单词算法和UI交互),而不是和底层框架搏斗。
3. 核心功能实现拆解:从词库到复习算法的落地
有了架构和工具,接下来就是实现核心功能。我将其拆解为三个环环相扣的模块:数据模型与存储、词库导入、复习调度引擎。
3.1 数据模型设计与Hive集成
一切从数据开始。我定义了最核心的两个模型:Word(单词)和ReviewRecord(复习记录)。
// lib/core/models/word.dart import 'package:hive/hive.dart'; part 'word.g.dart'; // 这将由hive_generator生成 @HiveType(typeId: 0) // 每个模型需要唯一的typeId class Word { @HiveField(0) final String spell; // 拼写 @HiveField(1) final String pronunciation; // 音标 @HiveField(2) final String definition; // 中文释义 @HiveField(3) final String example; // 例句 @HiveField(4) final DateTime addedDate; // 添加日期 @HiveField(5) int familiarity; // 熟悉度 (0-5),用于调整复习间隔 Word({ required this.spell, required this.pronunciation, required this.definition, required this.example, required this.addedDate, this.familiarity = 0, }); }定义好后,在终端运行flutter packages pub run build_runner build,就会在相同目录下生成word.g.dart文件,里面包含了WordAdapter类,Hive就知道如何保存和读取Word对象了。ReviewRecord模型同理,它会关联一个Word的ID,并记录本次复习的时间和结果(是否记住)。
这里有个关键细节:Hive在初始化时,需要注册这些Adapter。我通常在main()函数里做这件事:
void main() async { WidgetsFlutterBinding.ensureInitialized(); // 确保Flutter引擎初始化 await Hive.initFlutter(); // 初始化Hive Hive.registerAdapter(WordAdapter()); // 注册Adapter Hive.registerAdapter(ReviewRecordAdapter()); await Hive.openBox<Word>('words_box'); // 打开(或创建)存储单词的Box await Hive.openBox<ReviewRecord>('reviews_box'); runApp(const ProviderScope(child: MyApp())); // Riverpod的ProviderScope是根Widget }3.2 词库导入:从CSV文件到本地数据库
词库来源,我选择了从一些开源项目或词典网站下载的CSV文件,格式简单(例如:abandon,əˈbændən,抛弃,He abandoned his car.)。在lib/core/services/word_import_service.dart中,我创建了一个导入服务。
class WordImportService { Future<void> importFromCsv(String csvString) async { final wordsBox = Hive.box<Word>('words_box'); final now = DateTime.now(); // 使用csv包解析 final rows = const CsvToListConverter().convert(csvString, eol: '\n'); for (var row in rows) { if (row.length >= 4) { final word = Word( spell: row[0].toString().trim(), pronunciation: row[1].toString().trim(), definition: row[2].toString().trim(), example: row[3].toString().trim(), addedDate: now, ); await wordsBox.add(word); // 将Word对象存入Hive Box } } } }在UI层,我使用file_picker包让用户选择CSV文件,读取内容后调用这个服务。这里的一个坑是:文件读取是异步操作,且可能在UI线程中执行较长时间(如果词库很大)。为了避免界面卡顿,一定要用Isolate或在加载时显示一个明确的进度指示器。我为了简单,第一次只导入了考研核心3000词,所以直接在主线程处理了,但在正式上架前,这是必须优化的点。
3.3 复习算法引擎:简化的艾宾浩斯遗忘曲线
这是App的“大脑”。我并没有实现完整的SM-2(SuperMemo-2)算法,而是基于艾宾浩斯遗忘曲线的精神,设计了一个简化版的自适应复习间隔算法。
算法的核心逻辑在lib/core/services/review_scheduler.dart:
class ReviewScheduler { // 根据单词的当前熟悉度和历史复习情况,计算下一次应该复习的时间 static DateTime calculateNextReview(Word word, List<ReviewRecord> pastReviews) { // 基础间隔天数,根据熟悉度递增 const baseIntervals = [1, 3, 7, 16, 30]; // 对应熟悉度 0,1,2,3,4 int baseInterval = baseIntervals[word.familiarity.clamp(0, 4)]; // 简单衰减因子:如果最近一次复习错了,缩短间隔;连续对了,延长间隔(有上限) double factor = 1.0; if (pastReviews.isNotEmpty) { final lastReview = pastReviews.last; if (!lastReview.remembered) { factor = 0.5; // 没记住,下次复习提前 } else if (word.familiarity > 2) { // 比较熟悉了,如果最近几次都对了,可以适当拉长间隔 final recentCorrect = pastReviews.take(3).where((r) => r.remembered).length; if (recentCorrect == 3) { factor = 1.5; } } } // 计算下一次复习日期 final nextIntervalDays = (baseInterval * factor).round(); return DateTime.now().add(Duration(days: nextIntervalDays)); } // 获取今天需要复习的所有单词 static Future<List<Word>> getTodaysReviewWords() async { final wordsBox = Hive.box<Word>('words_box'); final reviewsBox = Hive.box<ReviewRecord>('reviews_box'); List<Word> dueWords = []; for (var word in wordsBox.values) { final wordReviews = reviewsBox.values .where((record) => record.wordId == word.key) .toList(); DateTime nextReviewDate; if (wordReviews.isEmpty) { // 从未复习过,按添加日期算第一次复习 nextReviewDate = word.addedDate.add(const Duration(days: 1)); } else { // 取最近一次复习记录中计算的下次复习时间 final lastReview = wordReviews.last; nextReviewDate = lastReview.scheduledDate; } // 如果下次复习日期是今天或之前,则加入待复习列表 if (nextReviewDate.isBefore(DateTime.now()) || _isSameDay(nextReviewDate, DateTime.now())) { dueWords.add(word); } } return dueWords; } static bool _isSameDay(DateTime a, DateTime b) { return a.year == b.year && a.month == b.month && a.day == b.day; } }这个算法虽然简单,但已经具备了“根据记忆效果动态调整”的核心特性。每次用户复习一个单词,选择“记住”或“没记住”后,App会调用calculateNextReview生成新的复习计划,并创建一条ReviewRecord存入数据库。getTodaysReviewWords则负责在首页展示今天需要完成的任务。
一个重要的心得:时间的处理一定要小心。我最初直接比较DateTime.now()和scheduledDate,忽略了时区、以及DateTime包含时分秒的问题。这导致“今天”需要复习的单词可能因为时间差而没被正确筛选出来。使用_isSameDay这种只比较年月日的工具函数,或者使用date_utils包,可以避免这个坑。
4. UI层构建与状态管理实战
UI层我采用了经典的“首页-学习-复习-设置”底部导航结构。使用go_router管理路由,riverpod管理状态。
4.1 使用Riverpod管理全局状态
我创建了几个关键的Provider:
// lib/presentation/providers/word_list_provider.dart final wordListProvider = StateNotifierProvider<WordListNotifier, List<Word>>((ref) { return WordListNotifier(); }); class WordListNotifier extends StateNotifier<List<Word>> { WordListNotifier() : super([]) { _loadWords(); } Future<void> _loadWords() async { final box = await Hive.openBox<Word>('words_box'); state = box.values.toList(); } Future<void> addWord(Word word) async { final box = await Hive.openBox<Word>('words_box'); await box.add(word); _loadWords(); // 更新状态 } // ... 其他增删改查方法 }在UI中,使用ConsumerWidget或Consumer来监听状态变化:
class HomePage extends ConsumerWidget { const HomePage({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { final words = ref.watch(wordListProvider); final todaysReviews = ref.watch(todaysReviewProvider); // 另一个Provider,封装了ReviewScheduler.getTodaysReviewWords return Scaffold( appBar: AppBar(title: const Text('我的词库')), body: ListView.builder( itemCount: words.length, itemBuilder: (ctx, index) => WordListItem(word: words[index]), ), floatingActionButton: FloatingActionButton( onPressed: () => _importCsv(ref), child: const Icon(Icons.add), ), ); } }Riverpod的精妙之处在于:ref.watch会自动建立依赖关系。当WordListNotifier中的state发生变化(比如新增了一个单词),所有watch了这个provider的Widget都会自动重建,UI得以更新。这比手动调用setState要清晰和高效得多。
4.2 学习与复习页面交互设计
学习页面很简单,就是展示单词、音标、释义和例句。复习页面则是核心交互界面。
class ReviewPage extends ConsumerStatefulWidget { const ReviewPage({super.key}); @override ConsumerState<ReviewPage> createState() => _ReviewPageState(); } class _ReviewPageState extends ConsumerState<ReviewPage> { Word? _currentWord; bool _showAnswer = false; @override void initState() { super.initState(); _loadNextWord(); } Future<void> _loadNextWord() async { final words = await ReviewScheduler.getTodaysReviewWords(); if (words.isNotEmpty) { setState(() { _currentWord = words.first; _showAnswer = false; }); } else { // 提示今日复习完成 } } Future<void> _submitReview(bool remembered) async { if (_currentWord == null) return; final reviewService = ref.read(reviewServiceProvider); await reviewService.recordReview(_currentWord!, remembered); _loadNextWord(); // 加载下一个单词 } @override Widget build(BuildContext context) { return Scaffold( body: Center( child: _currentWord == null ? const Text('恭喜!今日复习已完成!') : Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Text(_currentWord!.spell, style: Theme.of(context).textTheme.headlineMedium), if (_showAnswer) ...[ const SizedBox(height: 20), Text(_currentWord!.pronunciation), Text(_currentWord!.definition), Text(_currentWord!.example, style: const TextStyle(fontStyle: FontStyle.italic)), ], const SizedBox(height: 40), if (!_showAnswer) ElevatedButton( onPressed: () => setState(() => _showAnswer = true), child: const Text('显示释义'), ) else Row( mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ ElevatedButton( onPressed: () => _submitReview(false), child: const Text('没记住'), ), ElevatedButton( onPressed: () => _submitReview(true), child: const Text('记住了'), ), ], ), ], ), ), ); } }这个交互流程是许多记忆类App的经典设计:先回忆,再确认,最后根据回忆结果反馈给算法。这里的一个优化点是:在实际操作中,我后来加入了“模糊”选项,形成了“记住/模糊/忘记”三级反馈,能让算法更精细地调整间隔。但在MVP阶段,“记住/没记住”二分法已经足够。
5. 打包发布与两周开发复盘
开发完成后,最后一步是打包并发布到应用商店。对于Flutter来说,这个过程已经相当标准化。
5.1 Android打包 (APK/AAB)
- 配置应用图标和启动图:在
android/app/src/main/res/目录下替换对应的mipmap资源。我使用了一个在线工具一次性生成所有尺寸的图标。 - 配置应用信息:修改
android/app/src/main/AndroidManifest.xml中的label(应用名)和icon,以及权限(我这个App不需要特殊权限)。 - 生成签名密钥库(jks文件):这是上架Google Play的必需步骤。使用命令行生成:
务必妥善保管这个jks文件和密码。keytool -genkey -v -keystore ~/upload-keystore.jks -keyalg RSA -keysize 2048 -validity 10000 -alias upload - 在
android/app/build.gradle中配置签名:android { ... signingConfigs { release { storeFile file("/path/to/your/upload-keystore.jks") storePassword "your-store-password" keyAlias "upload" keyPassword "your-key-password" } } buildTypes { release { signingConfig signingConfigs.release ... } } } - 执行打包命令:
flutter build appbundle # 生成用于Google Play的.aab文件 # 或 flutter build apk --split-per-abi # 生成多个APK(用于其他渠道)
5.2 iOS打包(模拟器与真机)
iOS打包稍微复杂,需要Apple开发者账号(每年99美元)。
- 在Xcode中配置:打开
ios/Runner.xcworkspace,在Runnertarget的Signing & Capabilities中,选择你的Team,并确保Bundle Identifier是唯一的。 - 配置应用图标和启动图:在
Assets.xcassets中替换AppIcon和LaunchImage。 - 连接真机运行测试:用数据线连接iPhone,在Xcode顶部选择你的设备,然后点击运行。这会在真机上安装开发版本,用于最终测试。
- 归档(Archive)并上传:在Xcode的菜单栏选择
Product->Archive。归档成功后,会打开Organizer窗口,点击Distribute App,选择App Store Connect,然后按照向导上传。之后就可以在App Store Connect中提交审核了。
5.3 两周极限开发复盘:经验、教训与取舍
回顾这两周,与其说是“开发”,不如说是一场与时间和复杂度的赛跑。几点最深的体会:
- MVP的边界要卡得极其死。“再花半天加个功能吧”这种想法是最大的敌人。我中途曾想加入“单词发音”功能,调研了
audioplayers包和音源获取,发现这至少需要一天。果断放弃,用显示音标代替。核心是“复习”,能响应的复习比能发音的复习更重要。 - 工具链的顺畅程度决定下限。Flutter的热重载、Dart的强类型、Riverpod的编译时安全、Hive的简洁API,这些工具组合在一起,让我几乎没在调试环境配置、空指针异常、数据序列化这些“脏活”上浪费时间。选择成熟稳定的生态,就是给项目买了保险。
- 数据结构和算法先行。在写第一行UI代码之前,我花了一天时间设计
Word和ReviewRecord模型,并写好了Hive的集成代码和复习算法的骨架。当UI开始构建时,它只需要调用这些已经测试过的服务。这种前后端分离的思维,即使在个人全栈项目中也非常有用。 - UI可以丑,但交互不能卡。我使用了最基础的Material Design组件,没有自定义复杂的动画和样式。但在列表滚动、按钮点击、页面跳转时,必须保证60fps的流畅度。任何轻微的卡顿都会让学习体验大打折扣。性能优化意识要从一开始就有。
- 测试要贯穿始终,但形式可以灵活。我没有写完整的单元测试(时间不够),但我为
ReviewScheduler这个核心类写了大量的print调试和简单的assert语句,并在开发过程中不断用一些边界用例(如空列表、极端熟悉度)去验证它。对于个人快速项目,这种“手动测试+关键逻辑断言”的方式是性价比最高的。
最终,在第14天的晚上,我成功将APK文件安装到自己的手机上,完成了第一次从导入词库到复习完成的完整闭环。虽然它界面简陋,功能单一,但它是完全按照我自己的学习习惯打造的、没有多余干扰的“背单词工具”。这个过程中对Flutter技术栈的深度实践,以及对一个完整App生命周期的把控,其价值远超这个App本身。对于想用Flutter快速验证想法的个人开发者来说,这条路是完全可以走通的,关键在于极致的聚焦和高效的开发工具链。