MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

Flutter异步加载的缓存策略:减少重复数据请求

2024-11-134.8k 阅读

Flutter异步加载概述

在Flutter应用开发中,异步加载是常见的操作,比如从网络获取数据、读取本地文件等。异步操作能避免阻塞主线程,确保应用的流畅性。Flutter提供了Futureasync/await语法来处理异步任务。

例如,通过http库从网络获取数据:

import 'package:http/http.dart' as http;

Future<String> fetchData() async {
  var response = await http.get(Uri.parse('https://example.com/api/data'));
  if (response.statusCode == 200) {
    return response.body;
  } else {
    throw Exception('Failed to load data');
  }
}

这里fetchData函数使用async关键字声明为异步函数,await关键字暂停函数执行,直到http.get操作完成。

重复数据请求问题

在实际应用中,如果频繁地进行异步加载,尤其是对相同数据的请求,会带来不必要的性能开销和资源浪费。比如在一个新闻应用中,用户每次下拉刷新都重新从网络获取最新新闻列表。但如果新闻数据在短时间内没有更新,重复请求网络就显得多余。

缓存策略的重要性

缓存策略可以有效解决重复数据请求的问题。通过在本地存储已经获取的数据,当再次需要相同数据时,优先从缓存中读取,只有在缓存数据过期或不存在时才发起新的请求。这样可以显著减少网络请求次数,提高应用响应速度,降低用户流量消耗。

缓存策略的实现方式

内存缓存

简单的内存缓存实现

内存缓存将数据存储在应用的内存中,访问速度快。可以使用Map来简单实现一个内存缓存。

class InMemoryCache {
  final Map<String, dynamic> _cache = {};

  dynamic get(String key) => _cache[key];

  void put(String key, dynamic value) {
    _cache[key] = value;
  }
}

在异步加载数据时,可以结合这个缓存:

final cache = InMemoryCache();

Future<String> fetchDataWithCache() async {
  var cachedData = cache.get('data_key');
  if (cachedData != null) {
    return cachedData;
  }
  var response = await http.get(Uri.parse('https://example.com/api/data'));
  if (response.statusCode == 200) {
    var data = response.body;
    cache.put('data_key', data);
    return data;
  } else {
    throw Exception('Failed to load data');
  }
}

这里先检查内存缓存中是否有数据,如果有则直接返回,否则发起网络请求,并将新获取的数据存入缓存。

考虑缓存有效期

简单的内存缓存没有考虑数据的时效性。为了实现带有有效期的内存缓存,可以在缓存数据时记录时间戳。

class ExpirableInMemoryCache {
  final Map<String, CacheEntry> _cache = {};

  dynamic get(String key) {
    var entry = _cache[key];
    if (entry != null && DateTime.now().isBefore(entry.expiry)) {
      return entry.value;
    }
    return null;
  }

  void put(String key, dynamic value, {Duration duration = const Duration(minutes: 5)}) {
    var expiry = DateTime.now().add(duration);
    _cache[key] = CacheEntry(value, expiry);
  }
}

class CacheEntry {
  final dynamic value;
  final DateTime expiry;

  CacheEntry(this.value, this.expiry);
}

使用这个带有有效期的缓存:

final expirableCache = ExpirableInMemoryCache();

Future<String> fetchDataWithExpirableCache() async {
  var cachedData = expirableCache.get('data_key');
  if (cachedData != null) {
    return cachedData;
  }
  var response = await http.get(Uri.parse('https://example.com/api/data'));
  if (response.statusCode == 200) {
    var data = response.body;
    expirableCache.put('data_key', data);
    return data;
  } else {
    throw Exception('Failed to load data');
  }
}

这样,缓存的数据在超过设定的有效期后会被视为无效,从而触发新的异步加载。

本地存储缓存

使用shared_preferences进行简单本地缓存

shared_preferences是Flutter中用于持久化简单数据的插件。虽然它主要用于存储简单数据类型,但也可以用来缓存一些较小的文本数据。 首先,添加依赖:

dependencies:
  shared_preferences: ^2.0.15

然后实现缓存逻辑:

import 'package:shared_preferences/shared_preferences.dart';

class SharedPreferencesCache {
  static const _key = 'cached_data';

  Future<String?> get() async {
    var prefs = await SharedPreferences.getInstance();
    return prefs.getString(_key);
  }

  Future<void> put(String value) async {
    var prefs = await SharedPreferences.getInstance();
    prefs.setString(_key, value);
  }
}

在异步加载函数中使用:

final sharedPrefsCache = SharedPreferencesCache();

Future<String> fetchDataWithSharedPrefsCache() async {
  var cachedData = await sharedPrefsCache.get();
  if (cachedData != null) {
    return cachedData;
  }
  var response = await http.get(Uri.parse('https://example.com/api/data'));
  if (response.statusCode == 200) {
    var data = response.body;
    await sharedPrefsCache.put(data);
    return data;
  } else {
    throw Exception('Failed to load data');
  }
}

使用sqflite进行复杂数据本地缓存

对于更复杂的数据结构,如JSON数组或对象,sqflite数据库插件更为合适。 添加依赖:

dependencies:
  sqflite: ^2.2.0

创建数据库表并实现缓存操作:

import 'package:sqflite/sqflite.dart';
import 'package:path/path.dart';

class DatabaseCache {
  static const _tableName = 'cache';
  static const _columnKey = 'key';
  static const _columnValue = 'value';
  static const _columnExpiry = 'expiry';

  late Database _database;

  Future<void> init() async {
    var databasesPath = await getDatabasesPath();
    String path = join(databasesPath, 'cache.db');
    _database = await openDatabase(
      path,
      version: 1,
      onCreate: (Database db, int version) async {
        await db.execute('''
          CREATE TABLE $_tableName (
            $_columnKey TEXT PRIMARY KEY,
            $_columnValue TEXT,
            $_columnExpiry INTEGER
          )
        ''');
      },
    );
  }

  Future<String?> get(String key) async {
    var now = DateTime.now().millisecondsSinceEpoch;
    var results = await _database.query(
      _tableName,
      columns: [_columnValue],
      where: '$_columnKey = ? AND $_columnExpiry > ?',
      whereArgs: [key, now],
    );
    if (results.isNotEmpty) {
      return results.first[_columnValue];
    }
    return null;
  }

  Future<void> put(String key, String value, {Duration duration = const Duration(minutes: 5)}) async {
    var expiry = DateTime.now().add(duration).millisecondsSinceEpoch;
    try {
      await _database.insert(
        _tableName,
        {
          _columnKey: key,
          _columnValue: value,
          _columnExpiry: expiry,
        },
        conflictAlgorithm: ConflictAlgorithm.replace,
      );
    } catch (e) {
      print('Error inserting into cache: $e');
    }
  }
}

在异步加载中使用数据库缓存:

final databaseCache = DatabaseCache();
await databaseCache.init();

Future<String> fetchDataWithDatabaseCache() async {
  var cachedData = await databaseCache.get('data_key');
  if (cachedData != null) {
    return cachedData;
  }
  var response = await http.get(Uri.parse('https://example.com/api/data'));
  if (response.statusCode == 200) {
    var data = response.body;
    await databaseCache.put('data_key', data);
    return data;
  } else {
    throw Exception('Failed to load data');
  }
}

缓存策略的选择与权衡

内存缓存的优缺点

优点:

  1. 速度快:数据存储在内存中,访问速度极快,能够快速响应数据请求,提升应用的即时性体验。例如,在频繁需要获取同一数据的场景下,内存缓存可以在瞬间返回数据,无需等待网络请求或磁盘读取。
  2. 简单易用:通过简单的Map数据结构就能实现基本的内存缓存功能,代码实现简洁明了,易于理解和维护。如前面简单的InMemoryCache示例,几行代码就能完成缓存的基本读写操作。

缺点:

  1. 数据易丢失:应用关闭或内存不足时,内存中的缓存数据会被清除。这意味着一旦出现这些情况,再次需要数据时就必须重新进行异步加载。比如在手机系统内存紧张,后台清理应用进程后,内存缓存的数据就丢失了。
  2. 容量有限:应用可用的内存是有限的,如果缓存大量数据,可能会导致应用内存占用过高,引发性能问题甚至应用崩溃。例如,在缓存大量图片数据时,可能很快就会耗尽可用内存。

本地存储缓存的优缺点

优点:

  1. 数据持久化:即使应用关闭或设备重启,数据依然存在。像使用shared_preferencessqflite进行本地存储缓存,数据会保存在设备的存储介质上,下次打开应用时可以继续使用缓存数据。例如,新闻应用在用户关闭应用后,再次打开时仍能快速显示之前缓存的新闻内容。
  2. 大容量存储:相比于内存缓存,本地存储的容量限制较小,可以存储大量的数据。例如,使用sqflite数据库可以存储复杂的结构化数据,满足应用对大量数据缓存的需求。

缺点:

  1. 读写速度相对较慢:与内存缓存相比,从本地存储读取和写入数据的速度较慢。因为涉及到磁盘I/O操作,尤其是在读取大量数据时,这种速度差异会更加明显。比如从sqflite数据库读取复杂数据结构时,会比从内存缓存中读取花费更多时间。
  2. 实现相对复杂:使用shared_preferences存储简单数据还算简单,但如果要使用sqflite进行复杂数据缓存,需要创建数据库、表结构,编写SQL语句等,实现过程相对复杂,对开发者的技术要求较高。

根据应用场景选择合适的缓存策略

  1. 实时性要求高且数据量小的场景:如应用的一些配置信息,适合使用内存缓存。因为这些数据通常较小,且应用需要快速获取,内存缓存的快速响应特性能够满足需求。例如,应用的主题设置、用户偏好等数据,每次打开应用时都可能需要读取,内存缓存可以快速提供这些数据。
  2. 数据需要持久化且对实时性要求不是极高的场景:像新闻、文章等内容数据,适合使用本地存储缓存。这些数据更新频率相对较低,且用户希望在离线或应用重启后仍能查看,本地存储缓存的数据持久化特性能够满足需求。例如,用户在地铁等无网络环境下,仍然可以查看之前缓存的新闻文章。
  3. 混合使用场景:在一些复杂应用中,可以结合内存缓存和本地存储缓存。首先尝试从内存缓存中获取数据,如果内存缓存中没有或者数据已过期,再从本地存储缓存中读取。如果本地存储缓存也没有,最后发起异步加载请求,并将新获取的数据同时存入内存缓存和本地存储缓存。例如,在一个电商应用中,商品列表数据可以先从内存缓存中获取,若没有则从本地数据库缓存中读取,若都没有则从网络获取,获取后同时更新内存和本地数据库缓存。

缓存策略与异步加载的结合优化

缓存与Future的结合

使用FutureBuilder实现缓存感知的UI加载

在Flutter中,FutureBuilder是一个常用的组件,用于根据Future的状态来构建UI。结合缓存策略,可以实现缓存感知的UI加载。

class CacheAwareFutureBuilder extends StatefulWidget {
  final Future<String> Function() futureBuilder;

  const CacheAwareFutureBuilder({required this.futureBuilder, super.key});

  @override
  State<CacheAwareFutureBuilder> createState() => _CacheAwareFutureBuilderState();
}

class _CacheAwareFutureBuilderState extends State<CacheAwareFutureBuilder> {
  late Future<String> _future;

  @override
  void initState() {
    super.initState();
    _future = widget.futureBuilder();
  }

  @override
  Widget build(BuildContext context) {
    return FutureBuilder<String>(
      future: _future,
      builder: (context, snapshot) {
        if (snapshot.connectionState == ConnectionState.waiting) {
          return const CircularProgressIndicator();
        } else if (snapshot.hasError) {
          return Text('Error: ${snapshot.error}');
        } else if (snapshot.hasData) {
          return Text(snapshot.data!);
        }
        return Container();
      },
    );
  }
}

使用时,将带有缓存逻辑的异步加载函数传入:

@override
Widget build(BuildContext context) {
  return CacheAwareFutureBuilder(
    futureBuilder: fetchDataWithDatabaseCache,
  );
}

这样,FutureBuilder会根据缓存策略来决定是直接显示缓存数据还是等待新的异步加载完成。

处理Future的并发与缓存一致性

在多任务并发的情况下,可能会出现多个异步任务同时请求相同数据的情况。如果处理不当,可能会导致缓存一致性问题。例如,一个任务正在更新缓存数据,而另一个任务同时从缓存中读取到了旧数据。 为了解决这个问题,可以使用Future.sync方法确保异步任务顺序执行。

Future<String> fetchDataWithConcurrentCache() async {
  return Future.sync(() async {
    var cachedData = await databaseCache.get('data_key');
    if (cachedData != null) {
      return cachedData;
    }
    var response = await http.get(Uri.parse('https://example.com/api/data'));
    if (response.statusCode == 200) {
      var data = response.body;
      await databaseCache.put('data_key', data);
      return data;
    } else {
      throw Exception('Failed to load data');
    }
  });
}

这里Future.sync确保了整个异步加载和缓存操作是顺序执行的,避免了并发情况下的缓存一致性问题。

缓存策略在复杂业务场景中的应用

分页数据的缓存策略

在分页数据加载的场景中,缓存策略需要考虑更多因素。例如,不同页的数据需要分别缓存,并且要处理缓存的更新和过期。 可以为每页数据设置单独的缓存键。

Future<List<String>> fetchPageData(int page) async {
  var cacheKey = 'page_$page';
  var cachedData = await databaseCache.get(cacheKey);
  if (cachedData != null) {
    return (cachedData as List).cast<String>();
  }
  var response = await http.get(Uri.parse('https://example.com/api/data?page=$page'));
  if (response.statusCode == 200) {
    var data = (response.body as List).cast<String>();
    await databaseCache.put(cacheKey, data.toString());
    return data;
  } else {
    throw Exception('Failed to load data');
  }
}

当数据更新时,需要同时更新相应页的缓存数据。例如,当有新数据添加到第一页时:

void updatePage1Data(List<String> newData) async {
  var cacheKey = 'page_1';
  await databaseCache.put(cacheKey, newData.toString());
}

关联数据的缓存策略

在一些业务场景中,数据之间存在关联关系。例如,一个博客应用中,文章数据和评论数据相关联。在缓存时,需要考虑这种关联关系,确保缓存数据的一致性。 可以为文章和评论分别设置缓存,但在更新文章缓存时,同时更新相关评论的缓存状态。

class BlogCache {
  static const _articleCacheKeyPrefix = 'article_';
  static const _commentCacheKeyPrefix = 'comment_';

  Future<String?> getArticle(int articleId) async {
    var cacheKey = '$_articleCacheKeyPrefix$articleId';
    return await databaseCache.get(cacheKey);
  }

  Future<void> putArticle(int articleId, String articleData) async {
    var cacheKey = '$_articleCacheKeyPrefix$articleId';
    await databaseCache.put(cacheKey, articleData);
    // 同时更新评论缓存状态,例如设置为过期
    var commentCacheKey = '$_commentCacheKeyPrefix$articleId';
    await databaseCache.put(commentCacheKey, null, duration: Duration.zero);
  }

  Future<String?> getComments(int articleId) async {
    var cacheKey = '$_commentCacheKeyPrefix$articleId';
    return await databaseCache.get(cacheKey);
  }

  Future<void> putComments(int articleId, String commentData) async {
    var cacheKey = '$_commentCacheKeyPrefix$articleId';
    await databaseCache.put(cacheKey, commentData);
  }
}

这样,当文章数据更新时,相关评论缓存会被标记为过期,下次获取评论时会重新加载。

缓存策略的测试与优化

缓存策略的单元测试

测试内存缓存

对于内存缓存,可以编写单元测试来验证缓存的读写功能和有效期逻辑。 使用test库进行测试:

import 'package:test/test.dart';

void main() {
  group('InMemoryCache', () {
    late ExpirableInMemoryCache cache;

    setUp(() {
      cache = ExpirableInMemoryCache();
    });

    test('should put and get data', () {
      cache.put('key', 'value');
      expect(cache.get('key'), 'value');
    });

    test('should respect expiry', () async {
      cache.put('key', 'value', duration: const Duration(seconds: 1));
      await Future.delayed(const Duration(seconds: 2));
      expect(cache.get('key'), null);
    });
  });
}

测试本地存储缓存

对于shared_preferencessqflite缓存,测试相对复杂一些,因为涉及到异步操作和模拟设备存储环境。 以sqflite缓存测试为例:

import 'package:test/test.dart';
import 'package:sqflite_common_ffi/sqflite_ffi.dart';

void main() {
  group('DatabaseCache', () {
    late DatabaseCache cache;

    setUp(() async {
      sqfliteFfiInit();
      databaseFactory = databaseFactoryFfi;
      cache = DatabaseCache();
      await cache.init();
    });

    test('should put and get data', () async {
      await cache.put('key', 'value');
      var result = await cache.get('key');
      expect(result, 'value');
    });

    test('should respect expiry', () async {
      await cache.put('key', 'value', duration: const Duration(seconds: 1));
      await Future.delayed(const Duration(seconds: 2));
      var result = await cache.get('key');
      expect(result, null);
    });
  });
}

缓存策略的性能优化

缓存清理策略

为了避免缓存占用过多资源,需要制定合理的缓存清理策略。例如,定期清理过期的缓存数据。 对于内存缓存,可以在应用空闲时检查并清理过期数据:

class ExpirableInMemoryCache {
  // ...

  void cleanExpired() {
    var now = DateTime.now();
    _cache.removeWhere((key, entry) => now.isAfter(entry.expiry));
  }
}

对于本地存储缓存,如sqflite数据库缓存,可以在每次启动应用时清理过期数据:

class DatabaseCache {
  // ...

  Future<void> cleanExpired() async {
    var now = DateTime.now().millisecondsSinceEpoch;
    await _database.delete(
      _tableName,
      where: '$_columnExpiry <= ?',
      whereArgs: [now],
    );
  }
}

缓存命中率优化

提高缓存命中率可以有效减少重复数据请求。可以通过合理设置缓存键,确保不同数据有唯一的缓存标识。同时,根据数据的访问频率调整缓存策略。例如,对于访问频率高的数据,可以适当延长缓存有效期。 另外,预加载也是提高缓存命中率的一种方法。在应用启动或空闲时,预先加载一些常用数据到缓存中。比如,在新闻应用启动时,预先加载热门新闻分类的数据到缓存,这样用户打开应用后能更快看到内容。

缓存策略的安全性考虑

数据加密

当缓存敏感数据时,如用户登录信息、支付信息等,需要对缓存数据进行加密。在Flutter中,可以使用encrypt库进行数据加密。 添加依赖:

dependencies:
  encrypt: ^6.0.0

加密和解密缓存数据示例:

import 'package:encrypt/encrypt.dart';

class EncryptedCache {
  static final _key = Key.fromUtf8('my 32 length key................');
  static final _iv = IV.fromLength(16);
  static final _encrypter = Encrypter(AES(_key));

  Future<String?> get(String key) async {
    var encryptedData = await databaseCache.get(key);
    if (encryptedData != null) {
      var decrypted = _encrypter.decrypt64(encryptedData, iv: _iv);
      return decrypted;
    }
    return null;
  }

  Future<void> put(String key, String value) async {
    var encrypted = _encrypter.encrypt(value, iv: _iv).base64;
    await databaseCache.put(key, encrypted);
  }
}

防止缓存穿透

缓存穿透是指查询一个不存在的数据,每次都绕过缓存直接请求数据库,导致数据库压力增大。可以通过在缓存中设置一个特殊标识来防止缓存穿透。例如,当查询一个不存在的数据时,在缓存中设置一个空值,并设置较短的有效期。

Future<String> fetchDataWithAntiPenetration() async {
  var cachedData = await databaseCache.get('data_key');
  if (cachedData == 'null_value') {
    throw Exception('Data does not exist');
  }
  if (cachedData != null) {
    return cachedData;
  }
  var response = await http.get(Uri.parse('https://example.com/api/data'));
  if (response.statusCode == 200) {
    var data = response.body;
    await databaseCache.put('data_key', data);
    return data;
  } else if (response.statusCode == 404) {
    await databaseCache.put('data_key', 'null_value', duration: const Duration(minutes: 1));
    throw Exception('Data does not exist');
  } else {
    throw Exception('Failed to load data');
  }
}

这样,下次再查询相同不存在的数据时,直接从缓存中获取空值,避免了对数据库的无效请求。

通过以上全面的缓存策略实现、结合优化、测试以及安全性考虑,可以有效地减少Flutter异步加载中的重复数据请求,提升应用的性能和用户体验。无论是简单的应用还是复杂的业务场景,合理的缓存策略都能在优化资源利用和提升应用响应速度方面发挥重要作用。