卷 VI · 交付CH 22深度 22/24

网络、JSON 与本地存储:数据层怎么搭

直觉过境 「JSON 加个注解就能自动序列化。」 改一下

思路对,机制不同。Dart 没有运行时反射,所以 Moshi/Gson 那种「运行时读注解自动转」在这里行不通——它靠编译期代码生成。那个本该让这件事变优雅的 macro 特性,在 2025 年 1 月被官方取消了,所以 2026 年的答案依然是 build_runner + json_serializable。这一章把第 17 章那个 Repository 真正填满。

dio代码生成freezed本地存储选型

发请求: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(代码生成,更强):连 ==hashCodecopyWith、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

它就生成了 CoursefromJson/toJson==/hashCode/copyWith/toString。你调 Course.fromJson(resp.data) 就拿到对象,course.copyWith(credits: 3) 就能改。

⚠ 那个「等 macro」的旧建议,已经作废

如果你看过 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_preferencesSharedPreferences
敏感数据(token、密码)flutter_secure_storageEncryptedSharedPreferences / Keystore
结构化数据、要查询(缓存课程列表、离线成绩)SQLite(drift / sqfliteRoom
大量对象、要快、不太需要复杂查询isar / hive(NoSQL 本地库)
文件(下载的 PDF、图片缓存)path_provider + dart:io内部/外部存储
✎ 学校 App 的存储建议

别过度设计。多数学校 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」的典型。

⇄ Compose 对照 · 几乎一一对应,只多一个生成步骤
AndroidFlutter
Retrofit + OkHttpdio
Moshi / Gson / kotlinx.serializationjson_serializable / freezed(要跑生成
data class(免费)freezed(要跑生成
Roomdrift
SharedPreferencesshared_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),带一台真的视口回收演示,让你看清「一进页面就卡半秒」是怎么来的、又怎么修。