网络与本地存储
REST、GraphQL 与 SQLite。
网络与本地存储
这一课你将能做到
- 用
http 或 dio 发 GET/POST/PUT/DELETE 请求,并处理响应状态码与 JSON
- 用
shared_preferences 存简单的键值对(token、开关),用 sqflite 做本地结构化数据库
- 用
FutureBuilder / StreamBuilder 优雅地把异步数据绑定到 UI,写完整的 CRUD 本地持久化流程
为什么要学
App 不只是静态 UI——要从服务器拿数据、缓存 token、存用户偏好。不会 IO 操作,Flutter 就是纯玩具。
人话:HTTP 是「打电话要外卖」,本地存储是「记在备忘录上」。两者配合,App 才能在线离线都可用。
先建立图像
UI 层
├── FutureBuilder / StreamBuilder ← 异步数据绑定
└── onPressed → 调用 Repository
Repository 层
├── Remote: dio.get('/api/todos') → JSON → Model
└── Local: sqflite.query() / shared_preferences.getString()
跟着做
步骤 1:添加依赖
pubspec.yaml:
dependencies:
http: ^1.2.0
dio: ^5.4.0
shared_preferences: ^2.2.0
sqflite: ^2.3.0
path_provider: ^2.1.0
path: ^1.8.3
步骤 2:JSON 序列化(手动 + 生成)
class Todo {
final int id;
final String title;
final bool done;
Todo({required this.id, required this.title, this.done = false});
// 手动方式
factory Todo.fromJson(Map<String, dynamic> json) => Todo(
id: json['id'] as int,
title: json['title'] as String,
done: json['done'] as bool? ?? false,
);
Map<String, dynamic> toJson() => {'id': id, 'title': title, 'done': done};
}
生产环境加 json_serializable 自动生成 fromJson/toJson。先理解手动方式再迁移。
步骤 3:用 http 发请求
import 'package:http/http.dart' as http;
Future<List<Todo>> fetchTodos() async {
final resp = await http.get(Uri.parse('https://jsonplaceholder.typicode.com/todos?_limit=5'));
if (resp.statusCode == 200) {
final list = jsonDecode(resp.body) as List;
return list.map((e) => Todo.fromJson(e as Map<String, dynamic>)).toList();
}
throw Exception('请求失败: ${resp.statusCode}');
}
步骤 4:用 dio(更强大)
import 'package:dio/dio.dart';
class ApiClient {
late final Dio _dio;
ApiClient() {
_dio = Dio(BaseOptions(
baseUrl: 'https://jsonplaceholder.typicode.com',
connectTimeout: const Duration(seconds: 10),
receiveTimeout: const Duration(seconds: 10),
));
_dio.interceptors.add(InterceptorsWrapper(
onRequest: (opts, handler) {
opts.headers['Authorization'] = 'Bearer $token';
handler.next(opts);
},
onError: (err, handler) {
if (err.response?.statusCode == 401) {
// 统一跳登录
}
handler.next(err);
},
));
}
Future<List<Todo>> getTodos() async {
final resp = await _dio.get('/todos', queryParameters: {'_limit': 5});
return (resp.data as List).map((e) => Todo.fromJson(e)).toList();
}
}
dio 优势:拦截器、全局 baseUrl、自动 JSON、FormData 上传、下载进度、并发取消。
步骤 5:FutureBuilder 绑定 UI
class TodoPage extends StatefulWidget {
const TodoPage({super.key});
@override
State<TodoPage> createState() => _TodoPageState();
}
class _TodoPageState extends State<TodoPage> {
late Future<List<Todo>> _future;
@override
void initState() {
super.initState();
_future = fetchTodos();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('待办')),
body: FutureBuilder<List<Todo>>(
future: _future,
builder: (ctx, snap) {
if (snap.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}
if (snap.hasError) {
return Center(child: Text('出错了: ${snap.error}'));
}
final items = snap.data ?? [];
return ListView.builder(
itemCount: items.length,
itemBuilder: (ctx, i) => ListTile(title: Text(items[i].title)),
);
},
),
);
}
}
经验:用 FutureBuilder 时,future 字段不要每次 build 都重新创建(否则一直 loading),放 initState 或 Riverpod provider 里。
步骤 6:SharedPreferences 存简单值
import 'package:shared_preferences/shared_preferences.dart';
class Prefs {
static const _keyToken = 'token';
static Future<String?> getToken() async =>
(await SharedPreferences.getInstance()).getString(_keyToken);
static Future<void> setToken(String token) async =>
(await SharedPreferences.getInstance()).setString(_keyToken, token);
static Future<void> clearToken() async =>
(await SharedPreferences.getInstance()).remove(_keyToken);
}
注意:只存 String/bool/int/double/StringList。不要存大对象或列表。JSON 小对象可以 stringify 存,多的用 sqflite。
步骤 7:sqflite 本地数据库
import 'package:sqflite/sqflite.dart';
import 'package:path/path.dart';
class LocalDb {
static Database? _db;
static Future<Database> get instance async {
_db ??= await _init();
return _db!;
}
static Future<Database> _init() async {
final dbPath = join(await getDatabasesPath(), 'app.db');
return openDatabase(dbPath, version: 1, onCreate: (db, ver) async {
await db.execute('''
CREATE TABLE todos (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
done INTEGER NOT NULL DEFAULT 0
)
''');
});
}
static Future<int> insertTodo(Map<String, dynamic> row) async {
final db = await instance;
return db.insert('todos', row);
}
static Future<List<Map<String, dynamic>>> getTodos() async {
final db = await instance;
return db.query('todos', orderBy: 'id DESC');
}
static Future<int> updateTodo(int id, Map<String, dynamic> row) async {
final db = await instance;
return db.update('todos', row, where: 'id = ?', whereArgs: [id]);
}
static Future<int> deleteTodo(int id) async {
final db = await instance;
return db.delete('todos', where: 'id = ?', whereArgs: [id]);
}
}
步骤 8:文件读写(缓存图片、保存日志)
import 'package:path_provider/path_provider.dart';
import 'dart:io';
Future<String> localFilePath(String name) async {
final dir = await getApplicationDocumentsDirectory();
return '${dir.path}/$name';
}
Future<void> writeLog(String text) async {
final path = await localFilePath('log.txt');
await File(path).writeAsString('$text\n', mode: FileMode.append);
}
Future<String> readLog() async {
final path = await localFilePath('log.txt');
if (!await File(path).exists()) return '';
return File(path).readAsString();
}
getApplicationDocumentsDirectory():文档目录,备份到 iCloud/Google
getTemporaryDirectory():缓存目录,系统会清
getApplicationSupportDirectory():应用数据,不备份
讲清楚要点
- 所有网络请求都是异步:必须在
initState / 按钮回调 / provider 中发起,build 里不直接 await。
- SharedPreferences 是异步单例:
SharedPreferences.getInstance() 很快但仍是 async,不要想当然同步用。
- sqflite 表结构用
onCreate/onUpgrade:schema 变更要升级 version 并写 migration。
- dio 拦截器 = AOP:统一加 token、统一处理 401、统一打日志,避免每个请求写样板。
- 文件操作要权限:Android 高版本
AndroidManifest.xml 加 INTERNET 和存储权限声明。
你可能会懵的地方
-
http vs dio 选哪个?
快速原型/小包用 http;生产环境/复杂网络用 dio(拦截器、全局配置、FormData、取消请求)。
-
FutureBuilder 一直 loading?
future 字段在 build 里创建,每次 rebuild 都新建一个 Future。放 initState / provider 里。
-
JSON 字段是 int,但服务器偶尔给 null?
as int? ?? 0,先转可空再加默认值。建议每个字段都防御性处理。
-
sqflite 要自己做 ORM 吗?
可以用 drift 或 floor,但先理解 sqflite 原生 API,迁移到 ORM 更容易。
-
文件在 iOS 沙盒哪里?
iOS 有严格的沙盒:Documents/、Library/、tmp/,Flutter 的 path_provider 帮你封装了这些路径。
错法对照
| 错法 | 问题 | 正解 |
|---|
| build 方法里直接 await 网络请求 | 编译错:build 不能 async | 用 FutureBuilder / Riverpod FutureProvider |
| SharedPreferences 存复杂对象 | 不支持,读取崩溃 | JSON stringify 后再存,或换 sqflite |
| 忘记处理 401 统一跳转 | 网络挂了用户不知道 | dio 拦截器 onError 里统一处理 |
jsonDecode 后不加 as Map<String, dynamic> | dynamic 导致后续报错 | 显式转换,或用 json_serializable |
| sqflite 的 onCreate 写多表 | 重建时只执行一次,改 schema 要改 version | version++ 并写 onUpgrade |
文件路径写死 /data/data/... | iOS 不兼容 | 用 path_provider 获取跨平台路径 |
卡点急救
- 网络请求报
Connection refused:检查模拟器 IP(Android 模拟器 10.0.2.2 指向宿主机);真机确保同一局域网。
- iOS 报
Insecure HTTP:iOS 9+ 默认禁止 HTTP,Info.plist 里加 NSAppTransportSecurity → NSAllowsArbitraryLoads true(仅开发)。
- SharedPreferences 写进去读不出来:Key 大小写不一致;或者是在写完后立刻读但 await 顺序不对。检查 log。
- sqflite 报
no such table:onCreate 没执行,删了 App 重装或调 version++。真机调试时用 flutter run --debug 看 adb 日志。
- FutureBuilder 刷新不及时:传一个能变的 future 引用,或者改用 Riverpod
FutureProvider / AsyncNotifier。
面试常问
-
FutureBuilder 的原理?
StatefulWidget + initState 订阅 Future;future 完成后通过 setState 触发重建;builder 里按 snapshot 的 connectionState / hasData / hasError 决定 UI。
-
dio 的拦截器有什么场景?
统一加 token / 签名;统一处理 401/403 跳转登录;统一日志 / 埋点;请求重试 / 节流。
-
本地存储方案对比?
SharedPreferences:简单键值对;sqflite:结构化数据/查询;file:大文件/日志;hive:高性能键值(免 SQL);drift:ORM 层。
-
JSON 反序列化怎么保证安全?
字段级防御:先转可空类型再加默认值;生成代码(json_serializable)处理 null;用 freezed 做不可变数据类。
自测三问
- 为什么 FutureBuilder 的
future 字段不能在 build 方法里重新创建?
- 用 dio 写一段代码:发 GET 带 query parameter,并在拦截器里统一加 Header。
- sqflite 表结构改了怎么办?(
onCreate 已经不会再跑)
小结一句话
Flutter 的数据层 = http/dio 请求网络 + json 转换 + FutureBuilder/StreamBuilder 绑定 UI + SharedPreferences/sqflite/文件持久化;分层清晰,App 才能在线离线都稳定。
延伸