外观
网络、模型与持久化
把数据访问从页面移出去
一个可维护的起点是:页面展示状态,Repository 提供业务数据,Service 或 Client 对接网络与平台接口。是否进一步拆分,取决于实际复杂度。
引入第三方包前,先检查它支持的平台、SDK 约束、维护情况与许可证。使用 flutter pub add 包名 添加依赖,提交应用的锁文件,并阅读升级说明。
请求与模型转换
在练习项目执行 flutter pub add http,新建 lib/note_repository.dart:
dart
import 'dart:convert';
import 'package:http/http.dart' as http;
class Note {
const Note({required this.id, required this.title});
final int id;
final String title;
factory Note.fromJson(Map<String, dynamic> json) {
final id = json['id'];
final title = json['title'];
if (id is! int || title is! String) {
throw const FormatException('笔记字段不符合约定');
}
return Note(id: id, title: title);
}
}
class NoteRepository {
NoteRepository(this.client, this.endpoint);
final http.Client client;
final Uri endpoint;
Future<List<Note>> fetchNotes() async {
final response = await client
.get(endpoint)
.timeout(const Duration(seconds: 10));
if (response.statusCode != 200) {
throw Exception('读取笔记失败:HTTP ${response.statusCode}');
}
final payload = jsonDecode(utf8.decode(response.bodyBytes));
if (payload is! List) {
throw const FormatException('笔记接口应返回数组');
}
return payload.map((item) {
if (item is! Map<String, dynamic>) {
throw const FormatException('笔记条目应为对象');
}
return Note.fromJson(item);
}).toList();
}
}接口约定是 [{"id": 1, "title": "布局约束"}]。调用方必须提供实际可用的 HTTPS 地址和 http.Client;谁创建 Client,谁负责在不再使用时 close()。该示例没有包含服务端。
timeout 限制等待时间,不保证底层请求立即取消。鉴权、分页与取消策略应按真实接口补充。网络示例可对照 官方请求指南;Android 正式包需要检查主 Manifest 中的网络权限,macOS 沙箱应用要检查网络授权。
把请求生命周期交给 State
下面的页面与上面的 Repository 配合使用。由父级创建并传入 Repository;import 中的本地文件名对应前一节。
dart
import 'package:flutter/material.dart';
import 'note_repository.dart';
class RemoteNotesPage extends StatefulWidget {
const RemoteNotesPage({super.key, required this.repository});
final NoteRepository repository;
@override
State<RemoteNotesPage> createState() => _RemoteNotesPageState();
}
class _RemoteNotesPageState extends State<RemoteNotesPage> {
late Future<List<Note>> _notes;
@override
void initState() {
super.initState();
_notes = widget.repository.fetchNotes();
}
@override
void didUpdateWidget(covariant RemoteNotesPage oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.repository != widget.repository) {
_notes = widget.repository.fetchNotes();
}
}
void _retry() {
setState(() => _notes = widget.repository.fetchNotes());
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('远程笔记')),
body: FutureBuilder<List<Note>>(
future: _notes,
builder: (context, snapshot) {
if (snapshot.connectionState != ConnectionState.done) {
return const Center(child: CircularProgressIndicator());
}
if (snapshot.hasError) {
return Center(
child: FilledButton(
onPressed: _retry,
child: const Text('加载失败,点击重试'),
),
);
}
final notes = snapshot.data ?? const <Note>[];
if (notes.isEmpty) {
return const Center(child: Text('还没有笔记'));
}
return ListView.builder(
itemCount: notes.length,
itemBuilder: (context, index) => ListTile(
key: ValueKey(notes[index].id),
title: Text(notes[index].title),
),
);
},
),
);
}
}Future 保存在 State 中,普通重建复用同一个请求;重新加载时显式替换。不要写成 future: repository.fetchNotes() 放在 build 里,否则无关重建可能不断启动新请求。
搜索接口还有竞态问题:用户先输入 A、再输入 B,A 的旧请求可能更晚返回。手动回写状态时,可用递增请求编号判断是否仍为最新请求;支持取消的客户端也应取消旧请求。防抖只能减少调用次数,不能独立保证结果顺序。
选择合适的本地存储
| 数据 | 存储方向 | 需要考虑 |
|---|---|---|
| 主题、排序偏好 | 键值存储,例如 shared_preferences | 默认值、类型、异步读取与缓存一致性 |
| 图片、导出文件、离线附件 | 文件系统与平台目录能力 | 沙箱路径、容量、清理和 Web 替代方案 |
| 可查询的结构化数据 | 支持目标平台的数据库方案 | 事务、索引、迁移与备份 |
| 令牌或密钥 | 基于平台安全存储的方案 | 平台差异、访问策略与退出清理 |
先添加 shared_preferences,下面的独立函数演示保存和读取主题偏好:
dart
import 'package:shared_preferences/shared_preferences.dart';
final preferences = SharedPreferencesAsync();
Future<void> saveDarkMode(bool enabled) async {
await preferences.setBool('darkMode', enabled);
}
Future<bool> readDarkMode() async {
return await preferences.getBool('darkMode') ?? false;
}这里使用包提供的异步 API,具体支持版本和平台请核对 包文档。偏好存储适合少量简单设置,不能把它当作重要业务数据的可靠数据库;入门用法见 官方持久化指南。
练习与验收
分别让接口返回正常数组、空数组、错误状态码、错误字段和超时,检查页面对应状态及重试。切换主题后彻底重启应用,确认偏好恢复;测试前明确服务端地址,模拟器的 localhost 未必指向开发电脑。