Skip to content

网络、模型与持久化 ​

把数据访问从页面移出去 ​

一个可维护的起点是:页面展示状态,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 未必指向开发电脑。

持续记录,持续理解。