用 Flutter 做一个本地优先的待办应用

一、要做什么:本地优先,验收先定死

三句话讲完这个项目:一个 Flutter 待办应用,不登录、不联网,数据只活在本机;增删改、勾选完成,杀掉进程再开一切还在;亮暗两套主题,切到暗色重启仍是暗色。功能一分钟说完,但“本地优先”(offline-first)这个约束会让每个看起来很小的决策都变重——没有后端兜底,状态丢了就是丢了,持久化写坏了就是坏了,全部正确性责任压在客户端一侧。所以本文重心不在 UI 怎么画,而在三条决策链:状态管理、本地持久化、主题与暗色。

环境与工程起步不是本文内容(看系列的《Flutter 环境搭建与工具链》与《第一个 Flutter 应用》),默认 flutter doctor 已经全绿。开工前先把验收指标定死,每条都带”怎么测”:

指标 目标 怎么测
离线可用 无网状态功能完整 真机/模拟器开飞行模式,冷启动后完成一轮增删改
数据持久 杀进程重开不丢 从系统任务切换器划掉应用再进;注意开发期热重载不算重启(见第六节)
主题记忆 重启后保持所选模式 切暗色→杀进程→重开,确认仍为暗色
列表流畅 百到千条量级无感知卡顿(定性目标) flutter run --profile,DevTools Performance 面板看帧时间线有无持续超时帧
迁移安全 升级表结构老数据保留 把 version 加一,在 onUpgrade 写迁移,带着旧数据跑一遍

二、整体架构:三层就够,先不上全家桶

分层一条线,从上到下:

UI(Widget 树)
 └─ 状态层(setState → ChangeNotifier)   // 谁持有数据、谁来通知重建
     └─ 仓库层(TodoRepository)           // 全应用唯一摸数据库的类
         └─ 持久层(sqflite 主数据 + shared_preferences 偏好)

纪律只有一条:UI 不直接碰数据库,仓库层不 import 任何 Flutter 的 Widget。这条线画清楚之后,状态方案怎么换、持久化怎么换,各层互不连坐。

克制建议也放这里:不要第一个项目就上 Riverpod + drift + freezed + json_serializable 全家桶。待办应用没有复杂异步、没有多端同步,全家桶带来的第一周不是生产力而是编译配置和生成代码。瓶颈从来不在框架能力上限,在你对问题本身的理解。

三、决策链一:状态管理,从 setState 演进到需要为止

先把手写 setState 的版本写对,再谈框架。列表页最小实现:

class _TodoListPageState extends State<TodoListPage> {
  final List<Todo> _todos = [];
  final _controller = TextEditingController();

  @override
  void initState() {
    super.initState();
    _load(); // 初始加载放 initState,不能写成 async initState——生命周期函数不容你改签名
  }

  Future<void> _load() async {
    _todos = await TodoRepository.instance.listTodos();
    setState(() {}); // setState 的语义:本 State 的 build 需要重跑,不是"刷新页面"
  }

  Future<void> _add(String title) async {
    await TodoRepository.instance.addTodo(title);
    await _load(); // 持久化成功后再重读,UI 与数据库之间不存在不一致的窗口
  }

  Future<void> _toggle(Todo todo) async {
    await TodoRepository.instance.toggleDone(todo);
    await _load();
  }
}

这一版完全够用,而且它不是”将就”,是起点。什么时候 setState 开始痛?两个信号:

  • 第二个页面要读同一份数据(比如统计页要算未完成数)——数据只能提升到两个页面的公共祖先,改一处全树重建,改到后来没人说得清谁依赖谁;
  • 列表项组件变复杂,整页一起 build 的代价开始可感——setState 的重建粒度就是它所在 State 的 build 范围,粒度过粗只能靠拆 Widget 硬扛。

两个信号出现之前,引入任何状态管理框架都是在为想象中的问题付费。三方案对比摆开:

方案 优点 缺点 适用
setState 零依赖零样板,重建范围一目了然 跨页面共享靠手工提升,深层传递全是样板 单页面内部状态,任何规模的 App 都有它的份额
Provider(ChangeNotifier) InheritedWidget 的官方封装,学习成本低,渐进引入无阵痛 样板比 setState 多;大应用的粒度要靠手工拆 model 中小应用的跨页共享状态
Riverpod 编译期安全、不依赖 BuildContext、天然可测,2.x 是当前稳定系列 概念多(Provider/Notifier/AsyncValue),小应用里威力发挥不出来 复杂异步、多数据源、中大型应用

一行事实注脚:撰写本文时检索确认,Riverpod 当前稳定系列是 2.x(pub.dev 上最新为 2.5),Provider 的维护者也早已在文档里把新项目指向 Riverpod。但”生态推荐什么”和”你的应用需要什么”是两回事——待办应用的状态就是一份跨两三个页面的列表,Provider 的 ChangeNotifier 足以收尾。演进版改两处:

// 状态提升:列表搬进 ChangeNotifier,两个页面共享同一实例
class TodoListModel extends ChangeNotifier {
  final _repo = TodoRepository.instance;
  List<Todo> todos = [];

  Future<void> load() async {
    todos = await _repo.listTodos();
    notifyListeners(); // 通知所有 watch 到它的子树重建,页面自己不再持状态
  }

  Future<void> toggle(Todo todo) async {
    await _repo.toggleDone(todo);
    await load();
  }
}

// main 里挂一次:
ChangeNotifierProvider(
  create: (_) => TodoListModel()..load(),
  child: const MyApp(),
)
// 页面里:context.watch<TodoListModel>().todos —— 重建范围收窄到真正读它的那棵子树

什么时候才轮到 Riverpod:异步数据源多到要编排、想要编译期检查 provider 之间的依赖、或者团队本来就会。到那天迁移成本也可控——仓库层一行不用动,这正是分层买的保险。

反模式一:全局可变单例当状态管理。写一个 GlobalState.todos 谁都能改,改完手动调各页面刷新——短期能跑,但”谁改的数据、谁该刷新”从此无据可查,最后每个 bug 都要全工程搜引用。ChangeNotifier 的价值不在代码少,在”改数据”和”通知重建”被绑在同一个动作里。

反模式二:第一个页面就上全家桶。为了一个列表页引入 Riverpod + freezed + 代码生成链,学习曲线吃掉两周,产出和一个 setState 页面没有差别。框架解决的是规模问题,规模没到之前它是纯成本。

四、决策链二:本地持久化,三个候选各有边界

方案 是什么 优点 缺点 适用
shared_preferences 平台原生 KV 存储(Android 的 SharedPreferences / iOS 的 NSUserDefaults) API 极简,官方维护 只有字符串/数字平面键值;无查询、无迁移、无索引 设置项、主题选择、上次打开时间这类”几十个键以内”的数据
sqflite SQLite 的 Dart 封装,手写 SQL 与迁移 SQL 全功能(查询/索引/事务),官方 Cookbook 常客,社区资料最多 SQL 是字符串,无编译期检查;迁移手写;样板多 结构化本地数据、想要 SQL 查询能力、依赖想保持最少
drift SQLite 之上的类型安全层(声明表 → 代码生成 → 流式查询),底层可用 drift_sqflite 适配 编译期检查 SQL、查询可返回 Stream 做响应式 UI、活跃维护 引入 build_runner 代码生成链,概念与学习成本最高 多表关系、需要响应式查询、数据模型会持续演进的应用

本项目的选择:待办主数据进 sqflite,主题偏好进 shared_preferences。理由不是”sqflite 最好”,而是待办的数据形状就是一张单表,sqflite 的依赖最干净、迁移路径最透明;哪天模型长出多表关系,再迁 drift,仓库层是现成的隔离带。先装包:

# 一律不钉版本,pub.dev 上当前稳定版是什么就用什么
flutter pub add sqflite path shared_preferences provider

仓库层全文(含迁移演示):

import 'package:path/path.dart' as p;
import 'package:sqflite/sqflite.dart';

// 全应用唯一摸数据库的类:换掉实现只动这一个文件
class TodoRepository {
  TodoRepository._();
  static final TodoRepository instance = TodoRepository._();
  Database? _db;

  Future<Database> get db async => _db ??= await _open(); // 惰性单连接,避免并发重复开库

  Future<Database> _open() async {
    return openDatabase(
      p.join(await getDatabasesPath(), 'todos.db'),
      version: 2, // 动过表结构就 +1;version 回退会直接抛错,SQLite 只向前迁移
      onCreate: (db, _) => db.execute(
        'CREATE TABLE todos ('
        'id INTEGER PRIMARY KEY AUTOINCREMENT, '
        'title TEXT NOT NULL, '
        'done INTEGER NOT NULL DEFAULT 0, ' // SQLite 没有布尔,0/1 是惯例
        'createdAt INTEGER NOT NULL)',
      ),
      onUpgrade: (db, oldV, newV) async {
        if (oldV < 2) {
          // 迁移只允许增量 ALTER,不允许"删库重装"——用户数据不是开发数据
          await db.execute('ALTER TABLE todos ADD COLUMN note TEXT');
        }
      },
    );
  }

  Future<List<Todo>> listTodos() async {
    final rows = await (await db).query('todos', orderBy: 'id DESC');
    // 全表读进内存:待办的量级下这是正确取舍,上万条再谈分页与索引
    return rows.map(Todo.fromRow).toList();
  }

  Future<void> addTodo(String title) async {
    await (await db).insert('todos', {
      'title': title,
      'done': 0,
      'createdAt': DateTime.now().millisecondsSinceEpoch,
    });
  }

  Future<void> toggleDone(Todo todo) async {
    await (await db).update(
      'todos',
      {'done': todo.done == 1 ? 0 : 1},
      where: 'id = ?',        // 参数化不拼字符串:本地库也保持注入免疫的习惯
      whereArgs: [todo.id],
    );
  }
}

反模式三:把整个待办列表序列化成 JSON 塞进 shared_preferences。每次改动全量读写一个键,列表过千后每次启动都要反序列化全表;没有索引没有迁移,加一个字段就是一次全体用户的数据事故。KV 存储的边界就是”取出来是一个值”,一个值装不下的数据就该进表。

反模式四:在 Widget 里直接写 SQL。页面直接 openDatabase,短期省一个文件,长期把 UI 与存储焊死——本文后面所有”换 drift / 换状态方案成本可控”的承诺全部作废。

五、决策链三:主题与暗色,一次做对

主题的正确心智:MaterialApp 吃三样东西——theme(亮色)、darkTheme(暗色)、themeMode(system/light/dark 三态开关),切换暗色不需要自己写两套颜色。当前稳定版里 Material 3 已是新工程默认,配色用 ColorScheme.fromSeed 从一个种子色生成,别手抄十六进制。偏好记忆还是 shared_preferences,模型照第二节的样子写:

class ThemeModeModel extends ChangeNotifier {
  ThemeMode mode = ThemeMode.system; // 默认跟随系统,别替用户做主
  static const _key = 'theme_mode';

  Future<void> load() async {
    final prefs = await SharedPreferences.getInstance();
    // 读到脏数据不崩:对不上任何枚举名就退回跟随系统,本地存储的基本礼节
    final saved = prefs.getString(_key);
    mode = ThemeMode.values.where((m) => m.name == saved).isEmpty
        ? ThemeMode.system
        : ThemeMode.values.firstWhere((m) => m.name == saved);
    notifyListeners();
  }

  Future<void> set(ThemeMode m) async {
    mode = m;
    notifyListeners();
    (await SharedPreferences.getInstance()).setString(_key, m.name);
  }
}

// MaterialApp 处:
MaterialApp(
  theme: ThemeData(colorScheme: ColorScheme.fromSeed(seedColor: Colors.teal)),
  darkTheme: ThemeData(
    colorScheme: ColorScheme.fromSeed(
        seedColor: Colors.teal, brightness: Brightness.dark),
  ),
  themeMode: context.watch<ThemeModeModel>().mode, // 拨一次开关,根组件重建一次——主题场景这是可接受的重建量
  home: const TodoListPage(),
)

常见偷懒做法是只写 darkTheme 不给三态开关,用户”跟随系统”的意愿就被吞了;另一个极端是手写 Brightness 判断散落各处。三态开关 + 枚举持久化,十行代码一次做对。

六、性能:本站未实测,给定性结论与自查口径

先声明口径:本文没有本站实测的帧率或耗时数字,以下是定性结论加自查方法。清单场景的性能来源很清楚:

  • 列表用 ListView.builder,按需构建可见项(和 React Native 的 FlatList 同思想),千条数据不等于千个 Widget 同时存在。反着来(Column + SingleChildScrollView 装 children:)才是列表卡顿第一成因;
  • 控制 setState 重建范围:列表项的勾选状态变化只重建该项的子 Widget,不是整页——setState 写在哪个 State,哪个 build 重跑,粒度问题在写之前就想;
  • 重活不进 build:排序、过滤放仓库层或模型层,build 里只做映射;
  • 全表读的量级判断:待办场景(百到千条)全量进内存不构成瓶颈,真到上万条,先加分页再谈优化,方向反了会把时间花在不存在的瓶颈上。

自查口径:flutter run --profile(不是 debug 模式,debug 的帧时间不可信),DevTools 的 Performance 面板看 UI 与光栅两线的帧耗时,出现成片超时帧再回来对照上面四条。另有一个开发期专属坑:热重载(hot reload)会保留 State,热重启(hot restart)才会重跑 initState——验证持久化和主题记忆必须用热重启甚至杀进程,否则你在测的是”热重载保留了内存状态”,不是你写的持久化代码。

七、总结:这套方案的边界

这篇教会了三件事:状态方案按痛点演进,setState 是起点不是将就;持久化按数据形状选,KV 是 KV、表是表;仓库层隔离让前面两个决策全部可逆。

什么时候不该用这套:需要多端同步、协作编辑或云端检索,本地优先只是半个方案,同步层(冲突解决、增量上传)才是主战场;数据要复杂 SQL 查询加响应式更新,直接上 drift,别拿 sqflite 过渡。1.8G 内存小服务器上跑相关后端的话题,与本篇无关,但如果你将来想给待办配个自托管同步服务,量力而行的架构思路可以参考本站的热榜聚合系列。

下一步:整个 Flutter 系列的串线路文是《Flutter 学习路线》;遇坑先查《Flutter 基础知识参考字典》;Dart 语法卡壳回系列里的语法篇翻对应小节。

版本核实(撰写时检索,2024-05-13):当前 Flutter 稳定版为 3.19 系列(官方 release notes),3.22 预计 5 月中发布;Riverpod 当前稳定系列为 2.x(pub.dev 最新 2.5);sqflite、shared_preferences、drift(含 drift_sqflite 适配)均为活跃/维护中状态,Material 3 为新工程默认。安装命令一律不钉版本号,具体 API 以 pub.dev 与 docs.flutter.dev 为准。本文无基准测试,性能描述全部为定性。

暂无评论

发送评论 编辑评论


				
|´・ω・)ノ
ヾ(≧∇≦*)ゝ
(☆ω☆)
(╯‵□′)╯︵┴─┴
 ̄﹃ ̄
(/ω\)
∠( ᐛ 」∠)_
(๑•̀ㅁ•́ฅ)
→_→
୧(๑•̀⌄•́๑)૭
٩(ˊᗜˋ*)و
(ノ°ο°)ノ
(´இ皿இ`)
⌇●﹏●⌇
(ฅ´ω`ฅ)
(╯°A°)╯︵○○○
φ( ̄∇ ̄o)
ヾ(´・ ・`。)ノ"
( ง ᵒ̌皿ᵒ̌)ง⁼³₌₃
(ó﹏ò。)
Σ(っ °Д °;)っ
( ,,´・ω・)ノ"(´っω・`。)
╮(╯▽╰)╭
o(*////▽////*)q
>﹏<
( ๑´•ω•) "(ㆆᴗㆆ)
😂
😀
😅
😊
🙂
🙃
😌
😍
😘
😜
😝
😏
😒
🙄
😳
😡
😔
😫
😱
😭
💩
👻
🙌
🖕
👍
👫
👬
👭
🌚
🌝
🙈
💊
😶
🙏
🍦
🍉
😣
Source: github.com/k4yt3x/flowerhd
颜文字
Emoji
小恐龙
花!
上一篇
下一篇