核心原理与源码导读
不要从最大、最复杂的编辑器文件开始。先理解 Dart 对象、Flutter 重建、共享会话和异步保存四件事,再回到真实源码。
1. Dart 和 Flutter 各负责什么
Dart 是语言,Flutter 是使用这门语言的界面框架。 就像你用中文写说明,而 Markdown 决定标题、列表等表示形式;二者不是同一个层次。
// 教学代码:只演示 Dart 语法,不是应用的完整文档模型。
class Note {
Note({required this.title, this.path});
String title;
String? path;
bool get hasFile => path != null;
}
void main() {
final note = Note(title: '我的第一篇笔记');
print(note.title);
print(note.hasFile);
}| 语法 | 含义 |
|---|---|
class Note |
定义一类对象的结构与行为 |
Note(...) |
创建对象时的构造方法 |
required |
调用者必须提供这个命名参数 |
String? |
允许为空的字符串;String 不允许空值 |
final note |
变量只赋值一次;不代表对象内部永远不可改变 |
bool get hasFile |
用属性形式调用的计算结果 |
=> |
简写的返回表达式 |
真实 WorkspaceDocument.path 也可空:新建草稿尚未选保存位置,不该用一个虚假的文件名代替磁盘路径。
2. Widget 是界面描述,不是保存位置
下面也是教学组件,不是当前应用的界面复制:
import 'package:flutter/material.dart';
class NoteTitle extends StatelessWidget {
const NoteTitle({super.key, required this.title});
final String title;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(12),
child: Text(title),
);
}
}build 根据当前输入描述界面。Flutter 可以多次调用它,所以不能在里面随手“打开文件、写设置、发网络请求”。否则滚动、主题变化都可能触发重复副作用。
StatelessWidget 自己不保存可变内部状态,但可以因父组件传来新标题而重新构建。StatefulWidget 搭配 State 保存组件自身的短期状态,比如一个面板是否展开。有状态不等于所有业务状态都应该塞在这个 State 里。
三棵树,先有个印象就够了
- Widget:这次想要什么界面。
- Element:框架怎样把前后两次界面描述对应起来。
- RenderObject:最终测量尺寸、布局和绘制。
因此“重新 build”不等于“把整扇窗口删掉重画所有像素”。真正是否耗时还要看布局、绘制、文本和派生数据计算。
3. 状态怎样让屏幕变化
Vex 的工作区 ViewModel 继承 ChangeNotifier,应用通过 provider 提供它:
用户点开关
↓
ViewModel 更新状态
↓
notifyListeners 通知监听者
↓
Selector / Consumer 读取关心的状态
↓
Flutter 更新相关界面Selector 可限制某个界面只关心主题、语言等指定值。它不能自动阻止你在其他地方做昂贵的全文解析,也不能代替正确的状态设计。
控制器解决的是“组件怎么被操作”:例如获取编辑选区、撤销、定位某个章节。ViewModel 解决的是“用户意图怎么串起来”:例如保存哪个文档、用哪个服务、失败怎样提示。
4. 从 main 开始读一次真实启动
阅读顺序:apps/vex/lib/main.dart → app.dart → workspace_screen.dart。
WidgetsFlutterBinding.ensureInitialized():先准备 Flutter 与平台通信基础。- 注册字体许可,让应用许可信息包含所用字体。
- 检查是否为内置只读文档的独立进程入口;如果是,打开文档后返回。
- 初始化桌面窗口,macOS 等平台采用相应标题栏策略。
- 创建真实文件服务、设置仓库和恢复仓库,注入
WorkspaceViewModel。 - 等待
vm.initialize()加载设置、恢复状态等。 - 尝试打开命令行传入的文件路径。
runApp(VexApp(...))建立 Flutter 应用树。
app.dart 再负责主题、语言、本地化委托与工作区根界面。不要误以为 main.dart 必须包含所有菜单和编辑逻辑。
5. 为什么原文只能有一份权威状态
假设源码视图保存字符串 A,阅读视图保存字符串 B,导图又保存字符串 C。用户改了 A、切到 C、再撤销时,很难判断到底以哪份为准。
Vex 让 MarkdownSession 保存权威原文:
// 可在 vex_markdown_core 包环境中理解和练习。
import 'package:vex_markdown_core/vex_markdown_core.dart';
void main() {
final session = MarkdownSession('# 学习 Flutter\n');
session.replace('# 学习 Flutter\n\n今天先认识目录。\n');
print(session.isDirty); // true
session.undo();
print(session.isDirty); // false:回到了最初保存基线
}isDirty 不是“按过任何键就永远为真”,而是当前内容是否不同于保存基线。撤销回到基线可以变干净;再次编辑则又变脏。
“脏”在编程里只表示未保存差异,不是文件损坏。
6. 解析与源码范围有什么用
Markdown 原文:
# 学习计划
## Flutter
先认识 Widget。核心解析后知道哪里是一级标题、哪里是二级标题,也知道它们在原始字符串中的位置。导图改名时,用标题文字范围替换,不用把全篇重新生成成某种“标准格式”。
当前替换后仍会重新解析文档,不是完全增量解析。保留源范围的意义是安全定位和尽量保留原文,不等于所有操作都是零成本。
UTF-16 为什么会影响选区
Dart 的字符串长度和 Flutter 文本选区偏移以 UTF-16 代码单元计数。大部分汉字长度为 1,某些补充字符长度为 2;用户眼里的一个字符还可能由多个码点组成。
void main() {
print('维'.length); // 1
print('\u{1F680}'.length); // 2
print('\u{1F680}'.runes.length); // 1 个 Unicode 码点
}源码范围、选区、换行和可见文本投影必须保持同一坐标约定。否则“删除一个字符”可能删错范围。CRLF 由两个代码单元组成,也不能只在部分代码里当成一个计数。
7. Future、async、await 到底是什么
保存文件通常不是立刻完成。Future<T> 表示“将来得到一个 T 或一个错误”;await 等待结果,但允许事件循环处理其他输入。
Future<String> explainAsync() async {
await Future<void>.delayed(const Duration(milliseconds: 100));
return '等待结束';
}async 不自动等于开了另一个线程。 在异步函数里同步做很重的计算,仍然可能阻塞界面。要区分等待 I/O 与 CPU 计算。
等待期间,世界仍在变化
用户点保存时文本是 A;等待磁盘写入时,用户输入成 B。最后成功只能说明 A 已保存,不能把 B 也标记成功。
这个简化示例说明原则,真实代码还处理队列、冲突与路径:
Future<void> saveSnapshot(
MarkdownSession session,
Future<void> Function(String) write,
) async {
final snapshot = session.text;
await write(snapshot);
session.markSaved(savedText: snapshot);
}配合 revision、目标对象检查和串行队列,才能避免旧操作回来覆盖新状态。仅仅“所有地方都加 await”并不能自动解决竞态。
8. 防抖和队列不是一回事
| 机制 | 解决的问题 | 类比 |
|---|---|---|
| 防抖 debounce | 连续输入不必每键写一次恢复文件 | 等你短暂停笔再记录一次 |
| 串行队列 | 多次异步写入不能乱序 | 前一个写完,后一个再开始 |
| 快照 snapshot | 明确这次操作处理哪一份内容 | 给当时的文本拍一个内容副本 |
| 版本 revision | 等待结束后判断目标是否变了 | 看内容编号是否已经更新 |
这些机制互补,不能拿一个替代另一个。
9. 打字机和专注为什么分开
打字机模式主要控制滚动:当前光标行靠近编辑器可用视口的垂直中线,文档随着输入和移动滚动,需要额外留白支持首尾行居中。不是把屏幕上画一个固定光标,也不是点哪里都强行移动鼠标。
专注模式主要控制呈现:当前段落清楚,其余内容变淡。二者可以共存,但开启专注不应擅自更改打字机开关。
动画还应尊重减少动态效果设置,避免每次输入引发剧烈位移。不同输入法、软键盘和视口高度需要真实环境检验。
10. 源码导读路线
| 阶段 | 读哪些文件 | 只回答一个问题 |
|---|---|---|
| 第一遍 | main.dart、app.dart |
程序怎么启动? |
| 第二遍 | domain/workspace.dart |
当前文档由什么组成? |
| 第三遍 | vex_markdown_core/lib/src/session.dart |
一次修改怎样撤销? |
| 第四遍 | file_service.dart、保存相关 VM 方法 |
保存失败和继续输入怎样处理? |
| 第五遍 | settings_repository.dart、recovery_repository.dart |
正式文件、偏好、恢复有什么区别? |
| 第六遍 | workspace_panes.dart、workspace_menu.dart |
状态怎样变成布局和动作? |
| 后续 | 编辑器选区、即时映射、导图布局、导出 | 逐个专题深入,不要一次读完 |
不要求一开始理解每个私有方法。能画出“用户动作 → 状态 → 会话 → 服务 → 结果”的路径,就是有效进展。
下一篇:开发调试与测试入门。
有疑问或建议,欢迎到 GitHub 提交 Issue 参与讨论。