Uninote
Uninote
用户根目录
brdr
common
programming
docs
tests
后端试题
问题讨论

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
}

三个关键点:

  1. WidgetsFlutterBinding.ensureInitialized()
    在 runApp 之前如果要调用平台通道(比如读写文件、设置状态栏),必须先初始化绑定,否则报错。

  2. 手动注册插件
    正常情况下插件由 .dart_tool 生成的文件注册。这里 Android 手动调 PathProviderAndroid.registerWith(),是为了在某些自定义构建下保证 path_provider 可用。

  3. 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();
    }
  }
}

三个工程习惯:

  1. 文件名带版本:mobile-auto-sync-v1.json,将来迁移格式有依据。
  2. 读时校验:类型不对就用默认值,绝不 crash。
  3. 写时原子:临时文件 + 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 代码:

  1. main.dart 的 main() + MyNoteApp → 理解入口
  2. _libraryView() → 看一个复杂界面怎么用 Column / Row / Expanded 搭出来
  3. FilePicker → 学 StatefulWidget + ListView.builder + TextField
  4. FileFilterDialog → 学 showDialog + 表单
  5. AutoSyncSettings → 学 JSON 持久化
  6. EditorHistory → 学 ChangeNotifier 之外的监听模式
  7. _loadTreeDifference → 学异步并发 + 进度回调
  8. SyncHistoryCache → 学复杂缓存 + 原子写 + 并发去重

十八、核心心法(背下来)

  1. Widget 是配置,不是控件。 build 可以跑很多次,别在里面做副作用。
  2. 状态向上提,回调向下传。 子 Widget 用 ValueChanged<T> 把事件交给父级。
  3. 凡是 new 出来的都要 dispose。
  4. 异步返回先检查 mounted。
  5. 写文件用临时文件 + rename。
  6. 外部输入必校验(路径、JSON、网络响应)。
  7. 能缓存的就缓存(_loaded map、_cachedChanged)。
  8. UI 状态粒度要细:能用一个 ValueNotifier 就别 setState 整个页面。

十九、进一步练习

想练手可以给 MyNote 加功能:

  • 加一个"最近修改文件"列表:练习 ListView + 排序。
  • 给代码块加行号:在 CodeBlockBuilder 里做。
  • 支持文件夹拖拽移动:练 Draggable + DragTarget。
  • 用 flutter_test 给 mergeText 写单测:练纯函数测试。

需要我针对某个具体部分(比如 part/extension 机制、Future.wait 并发、自定义 TextEditingController)再深入展开,直接说。

flutter

ios 环境

点赞(0) 阅读(13) 举报
目录
标题