Skip to content

前端转 Flutter 笔记 (Day 13):存储抽象层与 Offline First 架构 🧱

🏷️ 系列:前端转 Flutter 笔记 | 作者:moliang

📅 2026-02-24 | 🕐 预计阅读 12 分钟


前言

在 Day 12 中,我们学会了用 SharedPreferences 存简单配置、用 Isar 存复杂结构化数据。但如果直接在业务代码里到处写 isar.users.put(),那和前端到处写 localStorage.setItem() 有什么区别?

今天我们来解决一个架构级别的问题:

👉 如何让底层存储随意切换,而上层业务代码一行不改?

这就是存储抽象层的威力。


📌 本文大纲

1. 🧱 存储抽象层 — 让底层实现随意切换的秘密
2. 🏛️ Repository + 抽象层 + Riverpod — Offline First 终极形态
3. 📊 方案速查对比表

1. 🧱 存储抽象层:让底层实现随意切换的秘密

在前端开发中,我们可能用过 axios 的拦截器来统一处理请求,或者用 adapter 模式封装不同的请求库。在 Flutter 的数据持久化中,同样的思路至关重要——

永远不要让业务代码直接依赖具体的存储库

🎯 核心思想 (对标前端)

前端可能今天用 localStorage,明天切到 IndexedDB,后天换成 localForage。如果你的代码到处都是 localStorage.setItem(),迁移成本将是灾难性的

解法:定义一层抽象接口(Abstract Class),业务只面向接口编程,具体用什么存储在"注入"时决定。

1.1 架构全景图

 ┌───────────────────────────────────────────┐
 │               🖥️ UI 层                    │
 │          (Widget / Page)                  │
 └─────────────────┬─────────────────────────┘
                   │ 调用

 ┌───────────────────────────────────────────┐
 │            📦 Repository 层               │
 │    (UserRepository / TodoRepository)      │
 │    负责协调:网络 + 本地缓存的策略         │
 └─────────────────┬─────────────────────────┘
                   │ 依赖注入

 ┌───────────────────────────────────────────┐
 │         🔌 存储抽象层 (Interface)          │
 │    abstract class LocalStorage            │
 │    (getAll / put / delete / watch)        │
 └────┬───────────┬───────────┬──────────────┘
      │           │           │
      ▼           ▼           ▼
 ┌─────────┐ ┌─────────┐ ┌──────────────┐
 │  Isar   │ │  Hive   │ │  InMemory    │
 │  实现   │ │  实现    │ │  (测试用)    │
 └─────────┘ └─────────┘ └──────────────┘

💡 一句话总结:UI 不关心数据从哪来,Repository 不关心数据存在哪,存储层随时可以替换。

1.2 定义存储抽象接口

这一步最关键——定义一个与任何具体存储都无关的"契约":

dart
// 📄 file: lib/data/local/local_storage.dart

/// 本地存储的抽象接口
/// 泛型 T 代表存储的数据模型类型
abstract class LocalStorage<T> {

  /// 获取所有数据
  Future<List<T>> getAll();

  /// 根据 ID 获取单条数据
  Future<T?> getById(int id);

  /// 新增或更新 (upsert 语义)
  Future<void> put(T item);

  /// 批量写入
  Future<void> putAll(List<T> items);

  /// 根据 ID 删除
  Future<void> delete(int id);

  /// 清空所有数据
  Future<void> clear();

  /// 监听数据变化 (响应式核心!)
  Stream<List<T>> watchAll();
}

💡 前端类比:这就像你在 TypeScript 里定义一个 interface StorageAdapter,然后让 LocalStorageAdapterIndexedDBAdapter 分别去 implements 它。


1.3 Isar 实现

把抽象接口"落地"到具体的存储引擎——Isar:

dart
// 📄 file: lib/data/local/isar_user_storage.dart
import 'package:isar/isar.dart';
import 'local_storage.dart';
import 'user_model.dart';

class IsarUserStorage implements LocalStorage<User> {
  final Isar isar;

  IsarUserStorage(this.isar);

  @override
  Future<List<User>> getAll() => isar.users.where().findAll();

  @override
  Future<User?> getById(int id) => isar.users.get(id);

  @override
  Future<void> put(User item) async {
    await isar.writeTxn(() => isar.users.put(item));
  }

  @override
  Future<void> putAll(List<User> items) async {
    await isar.writeTxn(() => isar.users.putAll(items));
  }

  @override
  Future<void> delete(int id) async {
    await isar.writeTxn(() => isar.users.delete(id));
  }

  @override
  Future<void> clear() async {
    await isar.writeTxn(() => isar.users.clear());
  }

  @override
  Stream<List<User>> watchAll() {
    // ⚡ Isar 的杀手锏!数据变化时自动推送
    return isar.users.where().watch(fireImmediately: true);
  }
}

1.4 内存实现 (用于单元测试)

🧪 不装任何数据库依赖,就能跑通全部业务逻辑测试!

dart
// 📄 file: lib/data/local/in_memory_user_storage.dart
import 'dart:async';
import 'local_storage.dart';
import 'user_model.dart';

class InMemoryUserStorage implements LocalStorage<User> {
  final List<User> _store = [];
  final _controller = StreamController<List<User>>.broadcast();

  @override
  Future<List<User>> getAll() async => List.from(_store);

  @override
  Future<User?> getById(int id) async {
    return _store.where((u) => u.id == id).firstOrNull;
  }

  @override
  Future<void> put(User item) async {
    _store.removeWhere((u) => u.id == item.id);
    _store.add(item);
    _controller.add(List.from(_store));
  }

  @override
  Future<void> putAll(List<User> items) async {
    for (final item in items) {
      _store.removeWhere((u) => u.id == item.id);
      _store.add(item);
    }
    _controller.add(List.from(_store));
  }

  @override
  Future<void> delete(int id) async {
    _store.removeWhere((u) => u.id == id);
    _controller.add(List.from(_store));
  }

  @override
  Future<void> clear() async {
    _store.clear();
    _controller.add([]);
  }

  @override
  Stream<List<User>> watchAll() => _controller.stream;
}

🔥 为什么这个很重要?

在前端中你可能用过 jest.mock() 去模拟 localStorage。但在 Dart/Flutter 中,接口 + 依赖注入的方式更加优雅——你的测试根本不知道底层是 Isar 还是内存,完全解耦。


2. 🏛️ 把它们串起来:Repository + 抽象层 + Riverpod

🎯 目标:实现真正的 Offline First 架构

还记得我们在 Day 10 学的 Repository 模式吗?现在我们把 网络层 (Dio) + 存储抽象层 + Riverpod 全部串起来。

2.1 Repository 完整实现

dart
// 📄 file: lib/data/repository/user_repository.dart
import 'package:dio/dio.dart';
import '../local/local_storage.dart';
import '../user_model.dart';

class UserRepository {
  final Dio dio;
  final LocalStorage<User> localStorage; // 👈 只依赖抽象!

  UserRepository({required this.dio, required this.localStorage});

  /// 🌐 Offline First 核心逻辑
  /// 返回一个 Stream,UI 监听它就够了
  Stream<List<User>> watchUsers() {
    // 1️⃣ 立即返回本地缓存的 Stream(秒开体验)
    // 2️⃣ 同时在后台悄悄请求网络
    _syncFromRemote();
    return localStorage.watchAll();
  }

  /// 后台同步:从网络拉取最新数据 -> 写入本地
  Future<void> _syncFromRemote() async {
    try {
      final response = await dio.get('/api/users');
      final List<dynamic> jsonList = response.data;
      final users = jsonList
          .map((json) => User.fromJson(json))
          .toList();

      // 拿到新数据后,更新本地缓存
      // 因为 localStorage.watchAll() 是响应式的,
      // UI 会自动刷新!✨
      await localStorage.putAll(users);
    } catch (e) {
      // 🛡️ 网络失败?没关系!
      // 用户看到的依然是本地缓存数据
      // 这就是 Offline First 的精髓
      print('网络同步失败,使用本地缓存: $e');
    }
  }

  /// 单独获取(先本地,后网络)
  Future<User?> getUserById(int id) async {
    // 先尝试本地
    final local = await localStorage.getById(id);
    if (local != null) return local;

    // 本地没有,再请求网络
    try {
      final response = await dio.get('/api/users/$id');
      final user = User.fromJson(response.data);
      await localStorage.put(user); // 缓存到本地
      return user;
    } catch (e) {
      return null;
    }
  }
}

2.2 用 Riverpod 连接一切

dart
// 📄 file: lib/providers/storage_providers.dart
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:dio/dio.dart';
import '../data/local/local_storage.dart';
import '../data/local/isar_user_storage.dart';
import '../data/repository/user_repository.dart';
import '../data/user_model.dart';

// 1️⃣ 存储层 Provider(在 main.dart 中覆盖为具体实现)
final userStorageProvider = Provider<LocalStorage<User>>((ref) {
  throw UnimplementedError('必须在 ProviderScope 中覆盖');
});

// 2️⃣ Repository Provider(自动组装 Dio + Storage)
final userRepositoryProvider = Provider<UserRepository>((ref) {
  return UserRepository(
    dio: Dio(BaseOptions(baseUrl: 'https://your-api.com')),
    localStorage: ref.watch(userStorageProvider), // 注入抽象!
  );
});

// 3️⃣ UI 直接用的 StreamProvider
final usersStreamProvider = StreamProvider<List<User>>((ref) {
  final repo = ref.watch(userRepositoryProvider);
  return repo.watchUsers();
});
dart
// 📄 main.dart — 一行代码决定存储引擎
Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();

  final dir = await getApplicationDocumentsDirectory();
  final isar = await Isar.open(
    [UserSchema],
    directory: dir.path,
  );

  runApp(
    ProviderScope(
      overrides: [
        // ✨ 魔法在这里:想换存储?只改这一行!
        userStorageProvider
            .overrideWithValue(IsarUserStorage(isar)),

        // 想用 Hive? ↓
        // userStorageProvider
        //     .overrideWithValue(HiveUserStorage())

        // 跑测试? ↓
        // userStorageProvider
        //     .overrideWithValue(InMemoryUserStorage())
      ],
      child: const MyApp(),
    ),
  );
}

2.3 UI 层:优雅消费数据

dart
class UserListPage extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final usersAsync = ref.watch(usersStreamProvider);

    return usersAsync.when(
      loading: () => const CircularProgressIndicator(),
      error: (err, stack) => Text('出错了: $err'),
      data: (users) => ListView.builder(
        itemCount: users.length,
        itemBuilder: (context, index) => ListTile(
          title: Text(users[index].name),
          subtitle: Text('年龄: ${users[index].age}'),
        ),
      ),
    );
  }
}

🎉 数据流全景

UI 订阅 Stream

Repository 立刻推送本地缓存

UI 秒开显示 ⚡

Repository 后台请求 API

API 数据写入 LocalStorage

Stream 自动推送新数据

UI 无感刷新 ✨

用户感知:打开 App 的瞬间就看到了数据,而且过一会儿自动变成了最新的。这就是微信、抖音的体验秘密 🥷


3. 📊 方案速查对比表

特性SharedPreferencesIsarsqfliteHive
数据类型简单 KV强类型对象SQL 表KV / 对象
查询能力强 (代码生成)SQL 语句
性能一般⚡ 极快
类型安全部分
响应式 (Stream)
加密支持
Web 支持
适用场景用户设置/开关主业务数据遗留项目迁移轻量缓存

💡 选型建议:不管最终选什么,都套上我们第 1 节的抽象层。这样即使 Isar 哪天停止维护了,你也能在一天之内平滑迁移到 drift(SQLite 的强类型封装)或其他方案,业务代码一行不改


📝 Day 13 总结

要点关键词
存储抽象层是护城河abstract class LocalStorage<T> — 所有业务代码只面向接口
Repository 是协调员不关心数据从哪来,只负责制定策略 — 先本地、后网络、自动同步
Riverpod 是粘合剂overrideWithValue — 一行代码决定整个 App 的存储引擎
Offline First 四字真言先本地、后网络 — 本地存储是网络请求的一层防弹衣
永远为测试留后路InMemoryStorage — 单元测试脱离真实数据库,跑得飞快

🏁 这就是 Day 13 的内容!有了存储抽象层 + Repository + Riverpod 三件套,你的 Flutter 数据层架构已经和大厂没什么区别了。

前端在涉及 IndexDB 时往往需要写复杂的游标回调,而我们用 Dart 的抽象层设计,做起复杂查询来如同用后端 ORM 一样酣畅淋漓——而且随时可以换引擎 🏎️


📚 系列导航

⬅️ 上一篇:Day 12 — 数据持久化:SharedPreferences 与 Isar

➡️ 下一篇:Day 14 — 敬请期待...

🌟 觉得有帮助?点赞 + 收藏 + 在看,三连是最大的鼓励!