一个简洁的英汉词典应用,提供快速检索、释义查看与复制等能力,界面风格参考 Google 搜索。
- 离线词库:启动时从
assets/data/EnWords.csv解析 10w+ 词条,并做内存缓存。 - 智能搜索:输入框实时筛选(含缩写、中文释义),自动节流避免卡顿。
- 结果展示:卡片式列表支持点击展开详情、复制释义、下拉刷新。
- 多端支持:基于 Flutter,可运行在 Android、iOS、Web、Windows。
flutter pub get # 安装依赖
flutter run -d chrome # Web 端热重载调试
flutter analyze # 静态检查
flutter test --coverage # 执行测试并生成覆盖率Windows 安装的 Flutter SDK 可能带有 CRLF 换行,若在 WSL 中运行上述命令失败,请先对
flutter/bin/*.sh执行dos2unix。
assets/
data/EnWords.csv # 词典数据
images/google.svg # 顶部 Logo
lib/
data/ # 数据层(CSV 解析、缓存)
features/ # 业务控制器
models/ # 实体定义
main.dart # UI 入口
test/ # 单元与组件测试
-
启动与数据加载
main.dart中创建DictionaryRepository与DictionaryController,将控制器注入到 UI。- 首次进入页面时调用
DictionaryController.load():- 通过
DictionaryRepository.loadEntries()从assets/data/EnWords.csv读取完整 CSV 内容。 - 使用
CsvToListConverter将文本行解析为二维数组,再逐行构造WordEntry:word:英文单词。translation:中文释义。
- 得到的
List<WordEntry>缓存在_entries与仓库_cache中,避免重复 IO。 - 调用
_buildAvlIndex()基于_entries构建 AVL 索引。 - 最后
_applyFilter()初始化_visibleEntries,并通过notifyListeners()通知 UI 刷新。
- 通过
-
用户输入与节流
- 搜索框变化时调用
DictionaryController.updateQuery(value):- 更新
_query,并重置一个 120ms 的Timer充当节流。 - 在用户快速输入期间不会立即触发筛选,只有停止输入超过 120ms 才真正调用
_applyFilter()。
- 更新
- 清空按钮会调用
clearQuery(),将_query置空并重新应用筛选逻辑。
- 搜索框变化时调用
-
搜索策略切换
- 通过底部弹窗(
main.dart中的_showStrategyPicker())在两种策略间切换:SearchStrategy.linear:顺序查找。SearchStrategy.avl:基于 AVL 树的前缀索引查找。
- 选择后调用
setStrategy()更新_strategy并再次执行_applyFilter()。
- 通过底部弹窗(
-
结果展示与交互
- UI 监听
DictionaryController:isLoading控制骨架屏 / 进度指示。visibleEntries用于渲染列表结果,默认仅展示前 50 条;搜索时最多 150 条。lastSearchDuration用于显示最近一次搜索耗时,方便对比不同数据结构性能。
- 列表项支持点击、复制等操作,但不影响搜索核心逻辑。
- UI 监听
AVL 相关代码位于 lib/data_structures/avl.dart,核心由两部分组成:
-
AvlNode<K, V>:key:用于排序的键,这里是小写后的英文单词String。values:同一键对应的多个值列表List<V>,在本项目中是多个WordEntry(处理同形词)。left/right:左右子树指针。height:当前节点高度,用于计算平衡因子。
-
AvlMap<K, V>:- 内部持有根节点
_root和一个比较函数compare(K a, K b),这里使用a.compareTo(b)做字典序比较。 - 提供:
insert(K key, V value):插入键值对并保持树平衡。get(K key):按键精确查找对应的List<V>。forEachInRange(K low, K high, void Function(K, List<V>) f):在闭区间[low, high]内按中序遍历所有键值对,这是前缀搜索的关键。
- 内部持有根节点
insert 会调用内部递归函数 _insert(node, key, value):
-
标准二叉查找树插入:
- 若当前子树根
node为空,则新建AvlNode(key, [value])作为叶子返回。 - 否则用
compare(key, node.key)比较:- 若结果为
0,表示键相等,将value追加到node.values,支持一个单词对应多个词条。 - 若结果
< 0,递归插入到左子树node.left. - 若结果
> 0,递归插入到右子树node.right.
- 若结果为
- 若当前子树根
-
回溯阶段更新高度:
- 每次从子树返回时调用
_updateHeight(node):- 取左右子树高度中较大值 + 1 作为当前节点高度.
- 每次从子树返回时调用
-
根据平衡因子进行旋转:
- 通过
_balanceFactor(node) = height(left) - height(right)判断是否失衡:> 1:左侧过高,属于 LL 或 LR 型:- 若左子树平衡因子 < 0,先左旋左子树(LR),再右旋当前节点.
- 否则直接右旋当前节点(LL).
< -1:右侧过高,属于 RR 或 RL 型:- 若右子树平衡因子 > 0,先右旋右子树(RL),再左旋当前节点.
- 否则直接左旋当前节点(RR).
- 旋转通过
_rotateLeft/_rotateRight实现,调整指针并更新高度,保证整棵树始终满足 AVL 条件(任一节点左右子树高度差不超过 1)。
- 通过
通过上述过程,当所有 WordEntry 插入完成后,就得到一棵按 小写英文单词排序的自平衡二叉查找树,其高度约为 O(log N),后续查询和区间遍历都能维持较优复杂度。
在 DictionaryController 中,_buildAvlIndex() 专门负责从 _entries 生成 AVL 索引 _avl:
- 创建
AvlMap<String, WordEntry>,比较函数为(a, b) => a.compareTo(b)。 - 遍历
_entries中的每个WordEntry e:- 提取键
key = e.word.toLowerCase(),统一转小写,实现不区分大小写的英文搜索. - 调用
map.insert(key, e)将词条插入 AVL 树.
- 提取键
- 最终将构建好的
map赋值给_avl,供后续搜索使用.
因为所有数据只在加载或刷新时构建一次索引,平时用户输入只需要在这棵已有的树上做查询,避免了每次搜索都全表扫描.
当 _strategy == SearchStrategy.avl 时,_applyFilter() 的核心逻辑是:
-
若
_avl为空(尚未构建索引),直接返回空结果. -
将用户输入统一为小写:
lowerQuery = _query.toLowerCase(). -
构造一个闭区间上界:
high = '$lowerQuery\uffff':- 利用 Unicode 中
\uffff是一个“很大”的码位. - 所有以
lowerQuery为前缀的单词,其字典序都落在[lowerQuery, lowerQuery + \uffff]之间.
- 利用 Unicode 中
-
调用
_avl.forEachInRange(lowerQuery, high, (k, vs) { ... }):- 该方法会在 AVL 树上递归中序遍历,只访问键在
[low, high]范围内的节点. - 对于每个命中键
k和其对应的List<WordEntry> vs:- 额外通过
k.startsWith(lowerQuery)再次确认是真正前缀匹配(防御性编程). - 将所有
vs追加到结果列表results中.
- 额外通过
- 该方法会在 AVL 树上递归中序遍历,只访问键在
-
对
results排序:- 优先级:完全相等 > 仅前缀匹配 > 其它情况(这里主要是前两种).
- 其次按单词长度、再按字典序稳定排序,使结果更符合直觉.
-
取前 150 条赋值给
_visibleEntries,并记录本次搜索耗时lastSearchDuration.
由于 AVL 树高度为 O(log N),forEachInRange 只会遍历与区间相关的子树,因此前缀搜索的时间复杂度约为 O(log N + M)(M 为命中结果数),相比顺序查找在大词库上更有优势.
当 _strategy == SearchStrategy.linear 时,_applyFilter() 会走顺序查找路径:
-
将查询词转为小写:
lowerQuery = _query.toLowerCase(). -
使用
_entries.where((e) => e.matches(lowerQuery))对所有词条做一次线性扫描:WordEntry.matches(query)会同时在word与translation上做不区分大小写的contains检查:word.toLowerCase().contains(query):英文中包含查询子串.translation.toLowerCase().contains(query):中文释义中包含查询子串.
- 因此顺序查找支持:
- 英文部分任意子串匹配(不仅限前缀).
- 中文释义任意子串匹配(AVL 模式仅针对英文单词前缀)。
-
对筛选后的
filtered列表进行排序,使用内部的rank(e)函数定义“命中优先级”:- 计算
w = e.word.toLowerCase()与t = e.translation.toLowerCase(). - 返回值越小优先级越高:
0:w == lowerQuery,英文完全匹配.1:w.startsWith(lowerQuery),英文前缀匹配.2:w.contains(lowerQuery),英文子串匹配.3:t == lowerQuery,中文完全匹配.4:t.startsWith(lowerQuery),中文前缀匹配.5:其他情况.
- 排序规则:先比
rank,再比单词长度,最后按单词字典序,确保结果顺序稳定且符合直觉(更“精确”的匹配排在前面)。
- 计算
-
同样只保留前 150 条结果,并记录耗时.
顺序查找的特点:
- 优点:
- 逻辑简单,直接扫描
List<WordEntry>,实现和调试成本低. - 支持英文和中文的任意子串匹配,灵活度高.
- 逻辑简单,直接扫描
- 缺点:
- 时间复杂度为
O(N)(再加上排序),在 10w+ 级别词库上,频繁搜索可能带来更高的 CPU 开销.
- 时间复杂度为
本项目通过 SearchStrategy 将两种实现并存,并允许在 UI 中切换,方便你在真实数据规模下对比:
- 偏性能 / 前缀匹配:
- 适合使用 AVL:
- 主要按英文单词前缀搜索.
- 更关心在大词库上的响应速度.
- 适合使用 AVL:
- 偏灵活 / 支持中文和子串搜索:
- 适合使用顺序查找:
- 既要按英文,也要按中文释义搜索.
- 希望支持任意子串而不是仅限前缀.
- 适合使用顺序查找:
你可以在运行应用时切换策略,并观察 lastSearchDuration 的变化,直观感受两种数据结构在真实词典规模下的表现差异.
遵循 Conventional Commits(例如 feat(search): add accent matching)。提交前执行 flutter analyze 与 flutter test 并在 PR 中附上测试结果/截图。更多细节参见 AGENTS.md.