网络、JSON 与本地存储:数据层怎么搭
思路对,机制不同。Dart 没有运行时反射,所以 Moshi/Gson 那种「运行时读注解自动转」在这里行不通——它靠编译期代码生成。那个本该让这件事变优雅的 macro 特性,在 2025 年 1 月被官方取消了,所以 2026 年的答案依然是 build_runner + json_serializable。这一章把第 17 章那个 Repository 真正填满。
发请求:http 还是 dio
官方的 http 包够做简单请求。但真实 App 有拦截器(自动加 token)、超时、重试、统一错误处理的需求,用 dio——它是 Flutter 生态的事实标准,相当于 Android 的 Retrofit + OkHttp:
final dio = Dio(BaseOptions(
baseUrl: 'https://api.school.edu',
connectTimeout: const Duration(seconds: 10),
));
// 拦截器:每个请求自动带上 token(≈ OkHttp Interceptor)
dio.interceptors.add(InterceptorsWrapper(
onRequest: (options, handler) {
options.headers['Authorization'] = 'Bearer ${authStore.token}';
handler.next(options);
},
onError: (e, handler) {
if (e.response?.statusCode == 401) authStore.logout(); // 统一处理登录过期
handler.next(e);
},
));
// 用
final resp = await dio.get('/courses'); // resp.data 已经是解析好的 Map/List
dio 还自带 CancelToken——正好补上第 19 章说的「Future 不能取消」:给请求传一个 token,页面销毁时 cancel() 掉。
JSON → 对象:为什么必须代码生成
这是从 Android 过来必须调整的一处。你在 Android 用 Moshi/Gson,一个 @Serializable 或 @JsonClass 注解,运行时反射就把 JSON 转成对象了。Dart 不行,因为它默认没有运行时反射(反射会让 tree-shaking 失效、包体变大,Flutter 权衡后砍掉了它)。
1. 手写 fromJson/toJson:字段少时可以,但字段一多就是体力活加出错源。
2. json_serializable(代码生成):加注解,跑一个命令,它生成那些 fromJson 代码。主流选择。
3. freezed(代码生成,更强):连 ==、hashCode、copyWith、sealed union 一起生成——相当于给 Dart 补回了 data class 和 sealed class。第 2、3 章欠的债,freezed 一次还清。
这里值得停下来理解「为什么 Dart 甘愿放弃反射、换来这份麻烦」。反射(运行时读取类的结构)很方便,但它有两个 Dart 无法接受的代价:一是它让 tree-shaking 失效——编译器没法确定哪些类的哪些字段会被反射用到,就只能全部保留,包体积会失控;二是它有运行时开销,每次转换都要动态查类型信息。Flutter 要在手机上做到小包体和快启动,就必须砍掉反射。砍掉之后,「把 JSON 转对象」这件本来靠反射做的事,只能挪到编译期——由一个工具提前把转换代码生成出来。所以 build_runner 不是 Dart 生态不成熟的临时方案,而是「无反射」这个根本取舍的必然结果。想通这一点,你就不会再觉得它是负担,而会把它当成和 Android 的 KSP 一样理所当然的一环。
推荐 freezed——它顺带解决了第 2 章说的「Dart 没有 data class」的痛。一个模型长这样:
import 'package:freezed_annotation/freezed_annotation.dart';
part 'course.freezed.dart';
part 'course.g.dart';
@freezed
class Course with _$Course {
const factory Course({
required String id,
required String name,
@JsonKey(name: 'teacher_name') required String teacher, // 字段名映射
@Default(0) int credits, // 默认值
}) = _Course;
factory Course.fromJson(Map<String, dynamic> json) => _$CourseFromJson(json);
}
你写上面这些,然后跑:
$ dart run build_runner build --delete-conflicting-outputs # 开发时用 watch 模式,改完自动重新生成 $ dart run build_runner watch
它就生成了 Course 的 fromJson/toJson、==/hashCode/copyWith/toString。你调 Course.fromJson(resp.data) 就拿到对象,course.copyWith(credits: 3) 就能改。
如果你看过 2024 年的教程,可能听说「Dart 马上要出 macro,到时候就不用 build_runner 了」。这件事黄了——Dart 团队在 2025 年 1 月正式取消了 macro 特性并归档了仓库。原因是它和 Dart 的快速编译模型、以及 IDE 的实时补全根本冲突(编译器要先跑宏生成代码再编译,IDE 要在宏运行前就知道它会生成什么,这几乎是停机问题)。
所以 2026 年的现实是:代码生成(build_runner)依然是标准做法,而且短期内不会变。别等 macro 了。好在生成物会迁移到「增强(augmentation)」这个新机制上,对你的使用方式没影响——命令还是那个命令。把 build_runner 当成你项目的常驻工具接受它,就像 Android 项目里的 kapt/KSP 一样。
本地存储:按数据形态选
不是所有东西都塞一个地方。按「存什么」选:
| 存什么 | 用什么 | Android 对应 |
|---|---|---|
| 几个简单键值(主题、是否首次启动、上次选的 Tab) | shared_preferences | SharedPreferences |
| 敏感数据(token、密码) | flutter_secure_storage | EncryptedSharedPreferences / Keystore |
| 结构化数据、要查询(缓存课程列表、离线成绩) | SQLite(drift / sqflite) | Room |
| 大量对象、要快、不太需要复杂查询 | isar / hive | (NoSQL 本地库) |
| 文件(下载的 PDF、图片缓存) | path_provider + dart:io | 内部/外部存储 |
别过度设计。多数学校 App:token 用 flutter_secure_storage,用户偏好用 shared_preferences,需要离线看的结构化数据(课表、成绩)用 drift(它是 Room 的等价物——类型安全、编译期检查 SQL、也走 build_runner 生成)。三样就够。真需要复杂离线同步再考虑 Isar。
拼进 Repository:数据层完整长这样
把这一章的东西塞进第 17 章那个 Repository 骨架,一个真实的数据层就成型了:
class CourseRepository {
CourseRepository(this._dio, this._db);
final Dio _dio;
final CourseDb _db; // drift 生成的数据库
Future<List<Course>> getCourses({bool refresh = false}) async {
if (!refresh) {
final cached = await _db.allCourses();
if (cached.isNotEmpty) return cached; // 先给缓存
}
try {
final resp = await _dio.get('/courses'); // 打网络
final courses = (resp.data as List)
.map((j) => Course.fromJson(j)) // 生成的解析
.toList();
await _db.upsertCourses(courses); // 回写缓存
return courses;
} on DioException catch (e) {
final cached = await _db.allCourses();
if (cached.isNotEmpty) return cached; // 网络失败,退化到缓存
rethrow; // 缓存也没有,往上抛
}
}
}
这就是「离线优先 / 缓存优先」的雏形:能给缓存先给缓存,网络成功就更新,网络失败退回缓存。UI 层(第 19 章的 AsyncValue)只管订阅结果,完全不知道数据经历了这一路。这就是分层的回报。
1. 大 JSON 在主 isolate 解析卡界面。如果一次拉几千条、解析很重,把 map((j) => Course.fromJson(j)) 那步放进 Isolate.run(第 20 章)。小数据量不用管。
2. 忘了 --delete-conflicting-outputs。改了模型后重新生成报冲突,加这个 flag。改模型是常事,把它写进你的常用命令。
3. 生成文件要不要提交?团队约定即可,但推荐提交 .g.dart/.freezed.dart(省得每个人 clone 后都要先跑生成,CI 也快)。记得配 build.yaml 或 .gitignore 保持一致。
错误处理:把网络异常变成 UI 能显示的东西
数据层最容易被新手忽略的一半是失败路径。网络会超时、会 401、会返回 500、会断网。如果你让 DioException 一路裸奔到 UI,用户看到的是一片空白或一个红屏。正确做法是在数据层把底层异常翻译成业务能理解的失败类型:
sealed class AppFailure {}
class NetworkFailure extends AppFailure {} // 断网、超时
class AuthFailure extends AppFailure {} // 401,该重新登录
class ServerFailure extends AppFailure { final int code; ServerFailure(this.code); }
// Repository 里翻译
Future<List<Course>> getCourses() async {
try {
final resp = await _dio.get('/courses');
return (resp.data as List).map(Course.fromJson).toList();
} on DioException catch (e) {
throw switch (e.type) { // 第 3 章的 switch 表达式
DioExceptionType.connectionTimeout ||
DioExceptionType.connectionError => NetworkFailure(),
_ when e.response?.statusCode == 401 => AuthFailure(),
_ => ServerFailure(e.response?.statusCode ?? 0),
};
}
}
这样 UI 层拿到的是 AsyncError(:final error)(第 19 章),里面是一个 AppFailure,你就能用第 3 章的模式匹配分别显示「网络不给力,点击重试」「登录过期了」「服务器开小差」——而不是一句冷冰冰的 Exception: ...。好的错误文案是学校 App 口碑的一半,家长和老师对报错很敏感。
分页:列表数据不可能一次全拉
学生名单、历史通知、成绩记录——这些会越来越长,一次拉全会慢会费流量。分页是数据层的标配。最常见的是「翻页令牌 / 页码」模式,配合第 23 章的列表滚动触发:
Future<PageResult<Notice>> getNotices({int page = 1, int size = 20}) async {
final resp = await _dio.get('/notices', queryParameters: {'page': page, 'size': size});
return PageResult(
items: (resp.data['list'] as List).map(Notice.fromJson).toList(),
hasMore: resp.data['has_more'] as bool,
);
}
UI 侧监听滚动,快到底时加载下一页(第 23 章会给触发代码)。Riverpod 有专门管分页的模式(把「已加载的页」累积在 Notifier 的 state 里)。别自己在 Widget 里手搓分页状态——「当前第几页、有没有更多、正在加载吗、加载失败了吗」这四个状态搅在一起,塞进 Widget 的 State 会很快失控,这正是第 17 章说的「该提升到 Notifier」的典型。
| Android | Flutter |
|---|---|
| Retrofit + OkHttp | dio |
| Moshi / Gson / kotlinx.serialization | json_serializable / freezed(要跑生成) |
| data class(免费) | freezed(要跑生成) |
| Room | drift |
| SharedPreferences | shared_preferences |
| Repository 模式 | 一模一样 |
唯一的体感差别:Android 有 KSP/kapt 在后台悄悄生成,你几乎无感;Flutter 里 build_runner 是你要显式跑(或开 watch)的一步。习惯之后就像多了个 ./gradlew 步骤,不是负担。整个数据层的架构思想(Repository、缓存优先、DTO 与领域模型分离)你全都有,直接用。
这一章的一句话
数据层 = dio 发请求 + 代码生成解析 JSON(Dart 无反射,且等了很久的 macro 已在 2025 年取消,就用 build_runner + freezed,顺带补回 data class)+ 按形态选本地存储;把它们拼进 Repository 就得到「缓存优先、网络更新、失败退化」的完整数据层,UI 只管订阅结果。
页面能跳、数据能取了。还剩一个每个 App 都逃不掉、又最容易写卡的东西——长列表。学校 App 里全是列表:课程、通知、学生、成绩。下一章讲 ListView.builder 的回收机制(你熟的 RecyclerView),带一台真的视口回收演示,让你看清「一进页面就卡半秒」是怎么来的、又怎么修。