Flutter 开发入门教程(基于 MyNote 移动端项目)
下面这份教程会用你提供的 MyNote 项目作为活教材,从零讲清 Flutter 开发的核心概念。每一节都对应项目里的真实代码,方便你对照学习。
一、Flutter 是什么,项目结构长什么样
Flutter 是 Google 的跨平台 UI 框架,用 Dart 语言写一份代码,编译到 Android / iOS / Windows / Linux / macOS。
一个典型 Flutter 项目结构:
my_note/
├── pubspec.yaml # 依赖声明(类似 package.json)
├── lib/ # 所有 Dart 源码
│ ├── main.dart # 入口
│ ├── *_actions.dart # 业务动作(part 文件)
│ ├── *_view.dart # 界面
│ └── *.dart # 工具/模型
├── android/ ios/ ... # 各平台原生壳
└── .dart_tool/ # 构建产物(自动生成,别手改)
你文件开头那个 dart_plugin_registrant.dart 就在 .dart_tool/ 里,是 Flutter 自动生成的插件注册文件。它把 path_provider、url_launcher、flutter_secure_storage 这些插件的原生实现按平台注册进去。永远不要手改它——注释里也写了 "Generated file. Do not edit."
二、入口:main() 与根 Widget
lib/main.dart:
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized(); // ① 初始化绑定
if (Platform.isAndroid) {
await SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
} else {
await setLandscapeFullscreen(false);
}
if (Platform.isAndroid) PathProviderAndroid.registerWith(); // ② 手动注册插件
runApp(const MyNoteApp()); // ③ 启动根 Widget
}
三个关键点:
-
WidgetsFlutterBinding.ensureInitialized()
在runApp之前如果要调用平台通道(比如读写文件、设置状态栏),必须先初始化绑定,否则报错。 -
手动注册插件
正常情况下插件由.dart_tool生成的文件注册。这里 Android 手动调PathProviderAndroid.registerWith(),是为了在某些自定义构建下保证path_provider可用。 -
runApp()把根 Widget 挂到屏幕上。
根 Widget:
class MyNoteApp extends StatelessWidget {
@override
Widget build(BuildContext context) => MaterialApp(
title: 'MyNote',
theme: ThemeData(colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo)),
home: const HomePage(),
);
}
StatelessWidget:无状态,build方法只依赖传入参数。MaterialApp:Material Design 应用外壳,提供主题、导航、本地化。home:指定首屏。
三、Flutter 的核心:一切都是 Widget
Flutter 里 UI 由一棵 Widget 树组成。两种基本类型:
| 类型 | 用途 | 例子 |
|---|---|---|
StatelessWidget |
不可变,参数变了才重建 | MyNoteApp、_DiffRow |
StatefulWidget |
自己持有可变状态,setState 触发重建 |
HomePage、FilePicker |
StatefulWidget 的经典写法
class HomePage extends StatefulWidget {
const HomePage({super.key});
@override
State<HomePage> createState() => _HomePageState();
}
class _HomePageState extends State<HomePage> with WidgetsBindingObserver {
bool _loading = true;
// ...
@override
void initState() {
super.initState();
_initialize(); // 只调用一次
}
@override
Widget build(BuildContext context) {
// 每次 setState 都会重新执行
return _loading ? const CircularProgressIndicator() : _libraryView();
}
}
生命周期要点:
initState():初始化,只跑一次。项目里用它启动_initialize()异步加载数据。build():描述 UI,可能被调用很多次,不要在里面做耗时操作。dispose():释放资源。项目里释放了所有TextEditingController、ScrollController、Timer。
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
_autoSyncTimer?.cancel();
_endpoint.dispose();
for (final tab in _tabs) tab.dispose();
super.dispose();
}
记住原则:凡是你 new 出来的 controller / timer / listener,都要在 dispose 里释放。
四、状态管理:setState + ValueNotifier
项目没用 Provider / Riverpod / Bloc 这些库,而是原生方式:
1. setState —— 页面级状态
void _updateUi(VoidCallback action) => setState(action);
_updateUi(() {
tab.difference = false;
tab.preview = false;
});
2. ValueNotifier + ValueListenableBuilder —— 细粒度更新
用于缩放、旋转图标这类只影响一小块 UI 的状态:
class _PinchZoomState extends State<_PinchZoom> {
final pointers = <int, Offset>{};
double? startDistance;
@override
Widget build(BuildContext context) => Listener(
onPointerMove: (event) {
pointers[event.pointer] = event.position;
if (pointers.length == 2) {
final positions = pointers.values.toList();
widget.zoom.value = (startZoom * (positions[0] - positions[1]).distance / startDistance!)
.clamp(0.4, 2.0);
}
},
child: ValueListenableBuilder<double>(
valueListenable: widget.zoom,
builder: (context, zoom, _) => widget.builder(context, zoom),
),
);
}
ValueNotifier 变化时只重建 ValueListenableBuilder 里面的子树,性能更好。
3. Listenable.merge —— 监听多个
ListenableBuilder(
listenable: Listenable.merge([_editor, _previewScroll]),
builder: (context, _) { /* 编辑内容或滚动时都重建 */ },
)
五、布局:像搭积木一样组合
Flutter 没有 XML 布局,全靠 Widget 嵌套。项目里常见组合:
竖排 / 横排
Column(children: [ ... ]) // 垂直
Row(children: [ ... ]) // 水平
撑满剩余空间
Expanded(child: ListView(...))
限定尺寸
ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 420),
child: ListView(...),
)
SizedBox(width: 12) // 固定间隔
AspectRatio(aspectRatio: 16/9, child: ...)
滚动
// 整页滚动
SingleChildScrollView(controller: _previewScroll, child: ...)
// 列表滚动(懒加载,性能好)
ListView.builder(
controller: _treeScroll,
itemExtent: 34, // 固定高度可大幅提升性能
itemCount: nodes.length,
itemBuilder: (context, index) => ListTile(title: Text(nodes[index].path)),
)
布局小技巧
- 横向滚动条 + 换行:
SingleChildScrollView(scrollDirection: Axis.horizontal, child: Row(...)) - 自适应宽度:
LayoutBuilder(builder: (context, constraints) => ...) - 溢出省略:
Text('...', overflow: TextOverflow.ellipsis, maxLines: 1)
六、导航:页面跳转
Flutter 用 Navigator 管理页面栈。
打开新页面
await Navigator.of(context).push<void>(
MaterialPageRoute(
builder: (pageContext) => FileTreeDifferencePage(
session: treeSession,
load: (time, onProgress) => _loadTreeDifference(library, time, onProgress),
onOpen: (path) { Navigator.of(context).pop(); _selectFile(path); },
),
),
);
返回
Navigator.pop(context, result); // result 是返回值
弹窗 / 底部抽屉
// 对话框
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('删除笔记'),
content: Text('确定删除“$file”?'),
actions: [
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('取消')),
FilledButton(onPressed: () => Navigator.pop(context, true), child: const Text('删除')),
],
),
);
// 底部抽屉(常用于文件选择器)
showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
builder: (sheetContext) => FilePicker(...),
);
拦截系统返回键
PopScope<Object?>(
canPop: false,
onPopInvokedWithResult: (didPop, _) {
if (_navigationHistory.canGoBack) _navigateHistory(-1);
},
child: page,
)
七、异步:Future / async / await
Dart 的异步模型和其他语言类似:
// 声明
Future<String> _readMarkdown(LocalLibrary library, String relative) =>
_markdownFile(library, relative).readAsString();
// 使用
final content = await _store!.readMarkdown(_selected!, file);
常用模式
① 顺序执行
final files = await _store!.markdownFiles(library);
final favorites = await _favoriteStore!.active(library.remoteId ?? library.id);
② 并行执行
final results = await Future.wait([
for (var index = start; index < end; index++) _processFile(index),
]);
③ 不阻塞 UI(fire and forget)
unawaited(_refreshEditDifference()); // 明确表示不等它
④ 错误捕获
try {
await sync.sync();
} catch (error) {
_showToast(error);
} finally {
setState(() => _syncing = false);
}
⑤ 防止重入
Future<void> _run(Future<void> Function() action) async {
if (_busy) return; // 已经有一个在跑,直接忽略
setState(() => _busy = true);
try {
await action();
} catch (error) {
_showToast(error);
} finally {
if (mounted) setState(() => _busy = false);
}
}
⑥ 检查 mounted
异步返回时页面可能已被销毁,setState 会报错:
if (!mounted) return;
setState(() { ... });
八、文件读写
项目用 path_provider 找应用目录,用 dart:io 读写。
// 获取应用文档目录
final documents = await getApplicationDocumentsDirectory();
// 拼路径(跨平台安全,用 path 包)
import 'package:path/path.dart' as p;
final file = File(p.join(documents.path, 'mobile-auto-sync-v1.json'));
// 读
if (await file.exists()) {
final data = jsonDecode(await file.readAsString());
}
// 写:先写临时文件再 rename,保证原子性
final temporary = File('${file.path}.tmp');
try {
await temporary.writeAsString(jsonEncode(value), flush: true);
await temporary.rename(file.path);
} finally {
if (await temporary.exists()) await temporary.delete();
}
原子写是很重要的工程实践:崩溃时最多丢临时文件,不会把原文件写坏。
九、Markdown 渲染
项目用 flutter_markdown_plus。它接收 Markdown 字符串,返回 Widget:
MarkdownBody(
data: '# 标题\n正文',
onTapLink: (text, href, title) => _openLink(href),
imageBuilder: _localImage,
styleSheet: MarkdownStyleSheet.fromTheme(Theme.of(context)),
builders: {
'pre': CodeBlockBuilder(), // 自定义代码块
'h1': headingBuilder!, // 自定义标题
},
)
自定义元素:MarkdownElementBuilder
class CodeBlockBuilder extends MarkdownElementBuilder {
@override
Widget? visitElementAfterWithContext(
BuildContext context,
md.Element element,
TextStyle? preferredStyle,
TextStyle? parentStyle,
) {
final language = element.attributes['class']?.replaceFirst('language-', '');
final content = element.textContent;
return Column(children: [
Text(language ?? '纯文本'),
TextButton(
onPressed: () => Clipboard.setData(ClipboardData(text: content)),
child: const Text('复制'),
),
SelectableText(content, style: const TextStyle(fontFamily: 'monospace')),
]);
}
}
要点: MarkdownElementBuilder 在解析到对应 Markdown 语法时被调用,返回的 Widget 替换默认渲染。
十、自定义绘制:文本差异高亮
DifferenceTextController 是个很好的高级例子——继承 TextEditingController 重写 buildTextSpan,给变化的行加背景色。
class DifferenceTextController extends TextEditingController {
String? differenceBaseline;
@override
TextSpan buildTextSpan({
required BuildContext context,
TextStyle? style,
required bool withComposing,
}) {
final baseline = differenceBaseline;
if (baseline == null) {
return super.buildTextSpan(context: context, style: style, withComposing: withComposing);
}
final changed = changedAfterLineIndexes(baseline, text); // 算哪些行变了
final children = <InlineSpan>[];
var line = 0;
for (final rawLine in text.split('\n')) {
children.add(TextSpan(
text: rawLine,
style: changed.contains(line)
? const TextStyle(backgroundColor: Color(0x295DC47D))
: null,
));
line++;
}
return TextSpan(style: style, children: children);
}
}
关键点:
- 缓存计算结果(
_cachedBaseline/_cachedText),避免每帧重算。 - 用
TextSpan的树形结构做局部样式。
十一、手势:点击、双击、缩放
简单点击
InkWell(
onTap: () => _selectFile(path),
onDoubleTap: () => widget.onJump!(line),
child: Padding(...),
)
底层指针事件(做缩放需要)
Listener(
onPointerDown: (event) { pointers[event.pointer] = event.position; },
onPointerMove: (event) {
if (pointers.length == 2) {
// 两根手指 → 计算距离变化 → 更新缩放
}
},
onPointerUp: (event) => pointers.remove(event.pointer),
child: child,
)
双击检测(不干扰选择文本)
项目里 _PreviewDoubleTap 演示了如何手动实现双击:
onPointerUp: (event) {
if (firstTap != null && (event.position - firstTap!).distance <= kDoubleTapSlop) {
widget.onDoubleTap(event.position);
} else {
firstTap = event.position;
timeout = Timer(kDoubleTapTimeout, () => firstTap = null);
}
}
十二、主题与颜色
MaterialApp(
theme: ThemeData(colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo)),
)
在任意 Widget 里取主题:
final colors = Theme.of(context).colorScheme;
final dark = Theme.of(context).brightness == Brightness.dark;
Container(
color: dark ? const Color(0x3D238636) : const Color(0xFFDFF5E3),
child: Text('...', style: TextStyle(color: colors.onSurface)),
)
原则: 别硬编码颜色,用 Theme.of(context).colorScheme 里的语义色(primary / surface / onSurface / error),自动适配深色模式。
十三、持久化:JSON + 原子写 + 版本号
项目里所有配置文件都长这样:
class AutoSyncSettings {
bool enabled = false;
int intervalSeconds = 60;
final Set<String> backgroundLibraryIds = {};
Future<void> load() async {
if (!await _file.exists()) return;
final data = jsonDecode(await _file.readAsString());
if (data is! Map) return;
enabled = data['enabled'] == true;
final interval = data['intervalSeconds'];
intervalSeconds = interval is int && interval >= 1 && interval <= 86400 ? interval : 60;
}
Future<void> save() async {
final temporary = File('${_file.path}.tmp');
try {
await temporary.writeAsString(jsonEncode({
'enabled': enabled,
'intervalSeconds': intervalSeconds,
'backgroundLibraryIds': backgroundLibraryIds.toList(),
}), flush: true);
await temporary.rename(_file.path);
} finally {
if (await temporary.exists()) await temporary.delete();
}
}
}
三个工程习惯:
- 文件名带版本:
mobile-auto-sync-v1.json,将来迁移格式有依据。 - 读时校验:类型不对就用默认值,绝不 crash。
- 写时原子:临时文件 + rename。
十四、代码组织:part / part of
项目把 HomePage 的动作拆到多个文件:
// main.dart
part 'link_actions.dart';
part 'navigation_actions.dart';
part 'note_actions.dart';
part 'sync_actions.dart';
part 'difference_actions.dart';
part 'favorite_actions.dart';
// link_actions.dart
part of 'main.dart';
extension _LinkActions on _HomePageState {
Future<void> _openLink(String href) => _run(() async { ... });
}
为什么这样?
_HomePageState 的方法如果全塞在 main.dart 会几千行。用 part + extension,所有方法仍然能访问私有字段(_selected、_tabs 等),但文件拆开了。
注意:
part机制适用于一个库拆多个文件。如果只是普通模块复用,直接import就好。
十五、实用小工具代码
防抖 Timer
Timer? _tabSessionTimer;
void _scheduleTabSession() {
_tabSessionTimer?.cancel();
_tabSessionTimer = Timer(
const Duration(milliseconds: 300),
() => unawaited(_persistTabSession()),
);
}
提示 Toast
void _showToast(Object message) {
if (!mounted) return;
final messenger = ScaffoldMessenger.of(context);
messenger.hideCurrentSnackBar();
messenger.showSnackBar(SnackBar(
content: Text(message.toString()),
behavior: SnackBarBehavior.floating,
));
}
检查并安全删除
Future<void> deleteNote(LocalLibrary library, String relative) async {
final file = await _checkedNoteFile(library, relative); // 校验路径、符号链接
await file.delete();
}
安全相对路径(防止路径穿越)
String safeRelativePath(String value) {
final parts = value.split('/');
if (parts.any((part) => part.isEmpty || part == '.' || part == '..' || part.contains('\\'))) {
throw const FormatException('远端文件路径不安全。');
}
return value;
}
任何来自外部的路径都要校验,否则可能写到系统目录。
十六、调试与日志
// 开发日志(release 构建会被剔除)
debugPrint('[mynote.tree_diff] start library=${library.id}');
// 打印堆栈
debugPrintStack(label: '[mynote.tree_diff] failure stack', stackTrace: stackTrace);
跑项目:
flutter pub get # 拉依赖
flutter run # 调试运行
flutter analyze # 静态检查
flutter test # 跑测试
flutter build apk --release # 打包
热重载:r(保存状态) / R(重启)。
十七、学习路线建议
按这个顺序读 MyNote 代码:
main.dart的main()+MyNoteApp→ 理解入口_libraryView()→ 看一个复杂界面怎么用Column/Row/Expanded搭出来FilePicker→ 学StatefulWidget+ListView.builder+TextFieldFileFilterDialog→ 学showDialog+ 表单AutoSyncSettings→ 学 JSON 持久化EditorHistory→ 学ChangeNotifier之外的监听模式_loadTreeDifference→ 学异步并发 + 进度回调SyncHistoryCache→ 学复杂缓存 + 原子写 + 并发去重
十八、核心心法(背下来)
- Widget 是配置,不是控件。
build可以跑很多次,别在里面做副作用。 - 状态向上提,回调向下传。 子 Widget 用
ValueChanged<T>把事件交给父级。 - 凡是 new 出来的都要 dispose。
- 异步返回先检查
mounted。 - 写文件用临时文件 + rename。
- 外部输入必校验(路径、JSON、网络响应)。
- 能缓存的就缓存(
_loadedmap、_cachedChanged)。 - UI 状态粒度要细:能用一个
ValueNotifier就别setState整个页面。
十九、进一步练习
想练手可以给 MyNote 加功能:
- 加一个"最近修改文件"列表:练习
ListView+ 排序。 - 给代码块加行号:在
CodeBlockBuilder里做。 - 支持文件夹拖拽移动:练
Draggable+DragTarget。 - 用
flutter_test给mergeText写单测:练纯函数测试。
需要我针对某个具体部分(比如 part/extension 机制、Future.wait 并发、自定义 TextEditingController)再深入展开,直接说。
