Flutter 的快取策略

會煮咖啡的貓發表於2022-11-22

Flutter 的快取策略

原文 https://medium.com/@romaingre...

前言

在移動應用程式中,快取管理是一件非常重要的事情。

在本文中,我將告訴您如何在我的公司 Beapp 中設定策略快取。

正文

W 怎麼了?

如果你讀了這篇文章,我想你知道快取是什麼,但是以防萬一..。

快取基本上是將資料儲存在裝置的儲存器中。

W 為什麼使用快取?

  • 如果使用者連線不好或者沒有網際網路
  • 限制 API 呼叫,特別是對於不需要經常重新整理的資料
  • 儲存敏感資料(我們稍後討論)

一張圖片勝過千言萬語:

Cache Strategy Scheme

快取策略計劃

如您所見,快取的主要用途是始終嘗試向使用者顯示資料。

關於敏感資料,出於以下原因,我將使用者快取與網路快取分離:

  • 網路快取比使用者快取更短暫。
  • 相反,使用者快取儲存敏感資料,如訪問令牌、重新整理令牌,這些資料必須是安全的,使用者不能訪問。
  • 更具體地說,重新整理令牌的有效期可能很長(長達幾個月) ,而經典資料可能在一小時後重新整理,這將導致不必要的 API 呼叫。

因此,將這些策略分離開來是一種很好的做法,即使它們可以被合併。

現在我們瞭解了什麼是快取,讓我們深入研究程式碼吧!

H 如何建立這些策略?

檔案樹如下所示:

-- lib

----- core

------- cache

--------- storage

--------- strategy

在子資料夾儲存中,我們建立了一個檔案 Storage.dart,其中包含一個抽象類 Storage

這個類是一個“契約 contrac”,我們在其中宣告運算元據的方法。

abstract class Storage {
  Future<void> write(String key, String value);

  Future<String?> read(String key);

  Future<void> delete(String key);

  Future<int> count({String? prefix});

  Future<void> clear({String? prefix});
}

正如我所說,我們將透過我們的應用程式操縱它們,但為此,我們需要在裝置中儲存它們的方法。

我們使用 Hive 包,它是一個基於鍵/值的儲存解決方案。

總而言之,Hive 在裝置的儲存中建立了一個資料夾,您可以在其中儲存一個 hiveBox,其中包含 key: value 資料。

我們可以很容易地透過它的名字進入這個盒子。

現在我們可以從 Storage 抽象類中實現這些方法。

class CacheStorage implements Storage {
  static const _hiveBoxName = "cache";

  CacheStorage()  {
    Hive.initFlutter() ;
  }

  @override
  Future<void> clear({String? prefix}) async {
    final box = await Hive.openBox(_hiveBoxName);
    if (prefix == null) {
      await box.clear() ;
    } else {
      for (var key in box.keys) {
        if (key is String && key.startsWith(prefix)) {
          await box.delete(key);
        }
      }
    }
  }

  @override
  Future<void> delete(String key) async {
    final box = await Hive.openBox(_hiveBoxName);
    return box.delete(key);
  }

  @override
  Future<String?> read(String key) async {
    final box = await Hive.openBox(_hiveBoxName);
    return box.get(key);
  }

  @override
  Future<void> write(String key, String value) async {
    final box = await Hive.openBox(_hiveBoxName);
    return box.put(key, value);
  }

  @override
  Future<int> count({String? prefix}) async {
    final box = await Hive.openBox(_hiveBoxName);
    if (prefix == null) {
      return box.length;
    } else {
      var count = 0;
      for (var key in box.keys) {
        if (key is String && key.startsWith(prefix)) {
          count++;
        }
      }
      return count;
    }
  }
}

原則很簡單:

  • 我們在建立 CacheStorage 時建立一個 hive 例項。
  • 每次我們運算元據時,我們將開啟我們的 Hive 框(使用它的名稱)並執行觸發的方法(獲取、寫入、刪除...)。
  • 我們可以很容易地透過它的鍵來訪問資料值。

現在我們已經有了運算元據的方法,我們可以設定不同的策略,使用統一的呼叫語法來適應應用程式中的不同用例。

我們開始建立一個契約快取\_策略。快取根中的 Dart 。該合同允許我們應用其中一種策略並對其進行配置。

import 'dart:convert';

import 'package:flutter/foundation.dart';

import 'cache_manager.dart';
import 'cache_wrapper.dart';
import 'storage/storage.dart';

abstract class CacheStrategy {
  static const defaultTTLValue = 60 * 60 * 1000;

  Future _storeCacheData<T>(String key, T value, Storage storage) async {
    final cacheWrapper = CacheWrapper<T>(value, DateTime.now() .millisecondsSinceEpoch);
    await storage.write(key, jsonEncode(cacheWrapper.toJsonObject() ));
  }

  _isValid<T>(CacheWrapper<T> cacheWrapper, bool keepExpiredCache, int ttlValue) => keepExpiredCache || DateTime.now() .millisecondsSinceEpoch < cacheWrapper.cachedDate + ttlValue;

  Future<T> invokeAsync<T>(AsyncBloc<T> asyncBloc, String key, Storage storage) async {
    final asyncData = await asyncBloc() ;
    _storeCacheData(key, asyncData, storage);
    return asyncData;
  }

  Future<T?> fetchCacheData<T>(String key, SerializerBloc serializerBloc, Storage storage, {bool keepExpiredCache = false, int ttlValue = defaultTTLValue}) async {
    final value = await storage.read(key);
    if (value != null) {
      final cacheWrapper = CacheWrapper.fromJson(jsonDecode(value));
      if (_isValid(cacheWrapper, keepExpiredCache, ttlValue)) {
        if (kDebugMode) print("Fetch cache data for key $key: ${cacheWrapper.data}");
        return serializerBloc(cacheWrapper.data);
      }
    }
    return null;
  }

  Future<T?> applyStrategy<T>(AsyncBloc<T> asyncBloc, String key, SerializerBloc serializerBloc, int ttlValue, Storage storage);
}
  • DefaultTTLValue 是儲存在快取中的資料的實時值。換句話說: 在這段時間之後,資料被認為是無效的。
    -\_storeCacheData() 透過 CacheWrapper 允許儲存資料,我們將在後面看到它。
    -\_isValid() 與 defaultTTLValue 相比,檢查快取獲取是否仍然有效
  • InvkeAsync() 將使用作為引數傳遞的 syncBloc 方法從遠端位置(通常來自 Web 服務)獲取資料,並儲存和返回檢索到的資料。
  • FetchCacheData() 將透過 key 引數從快取中獲取資料,轉換 Cache Wrapper 接收到的 JSON 來檢查它是否仍然有效,如果有效,則返回具有相應型別的 Dart 物件中的序列化資料,這要感謝 seralizerBloc。
  • ApplicyStrategy() 將執行要選擇的策略,其中包含所需的所有引數。

透過這些解釋,我們可以看到任何戰略的實施路徑:

  • 我們呼叫 applicyStrategy() 來指出我們想要應用哪個策略,以及所需的引數。
  • 要檢查快取的資料 fetchCacheData() ,該方法使用\_isValid() 檢查有效性並返回資料或 null。
  • 為了從 WS 獲取資料,我們觸發了 invekAsync() ,一旦接收到資料,就將它們與\_storeCacheData() 一起放到 cache 中。
class CacheWrapper<T> {
  final T data;
  final int cachedDate;

  CacheWrapper(this.data, this.cachedDate);

  CacheWrapper.fromJson(json)
      : cachedDate = json['cachedDate'],
        data = json['data'];

  Map toJson()  => {'cachedDate': cachedDate, 'data': data};

  @override
  String toString()  => "CacheWrapper{cachedDate=$cachedDate, data=$data}";
}

關於 CacheWrapper,您可以在根快取資料夾中建立一個檔案 cache_wrapper. dart。

正如其名稱所示,CacheWrapper 是一個允許包裝接收資料的類。它有兩個引數,一個是允許包裝任何型別資料的通用型別資料,另一個是在資料儲存在快取中的日期和時間自動設定的 cachedDate。

From JSON() 和 toJson() 方法將接收到的資料轉換為用於快取的 JSON 或者在程式碼中使用它的 Map。

因此,可以將 CacheWrapper 解釋為包含快取資料並允許對這些資料進行編碼/解碼的“包裝器”。

在本文的這個步驟中,我們的結構資料夾如下所示:

-- lib

----- core

------- cache

--------- storage

----------- storage.dart

----------- cache_storage.dart

--------- cache_strategy.dart

現在我們已經看到了我們的策略可以做什麼的定義,讓我們深入研究它們的實現。

在快取根目錄中的新策略資料夾中,我們將建立所有策略的檔案。

每個策略都是單例的,所以應用程式中每個策略只有一個例項。

我們可以使用 get_it 來注入我們的策略,但是這增加了對包的依賴以及我們所知道的第三方的所有缺點,所以我們自己建立了它們。

每個策略都將繼承自抽象的 CacheStrategy 類,它們將分別使用 applicyStrategy() 方法實現各自的策略。

AsyncOrCache

這個策略將首先呼叫端點來檢索資料。如果丟擲錯誤(出於各種原因: 錯誤 401,403,500...) ,我們將檢索儲存在裝置快取中的最後資料。如果快取中沒有任何內容或無效資料,我們將返回先前引發的錯誤,以便在狀態管理器中處理它(稍後將看到它)。

class AsyncOrCacheStrategy extends CacheStrategy {
  static final AsyncOrCacheStrategy _instance = AsyncOrCacheStrategy._internal() ;

  factory AsyncOrCacheStrategy()  {
    return _instance;
  }

  AsyncOrCacheStrategy._internal() ;

  @override
  Future<T?> applyStrategy<T>(AsyncBloc<T> asyncBloc, String key, SerializerBloc<T> serializerBloc, int ttlValue, Storage storage) async => await invokeAsync(asyncBloc, key, storage).onError(
        (RestException restError, stackTrace) async {
          if (restError.code == 403 || restError.code == 404) {
            storage.clear(prefix: key);
            return Future.error(restError);
          } else {
            return await fetchCacheData(key, serializerBloc, storage, ttlValue: ttlValue) ?? Future.error(restError);
          }
        },
      );
}
CacheOrAsync

最後一個策略和前一個一樣,只是反過來而已。首先,我們檢查資料是否儲存在快取中,如果結果為 null,則觸發 WS 呼叫。如果丟擲錯誤,我們在狀態管理器中處理它。

class CacheOrAsyncStrategy extends CacheStrategy {
  static final CacheOrAsyncStrategy _instance = CacheOrAsyncStrategy._internal() ;

  factory CacheOrAsyncStrategy()  {
    return _instance;
  }

  CacheOrAsyncStrategy._internal() ;

  @override
  Future<T?> applyStrategy<T>(AsyncBloc<T> asyncBloc, String key, SerializerBloc<T> serializerBloc, int ttlValue, Storage storage) async =>
      await fetchCacheData(key, serializerBloc, storage, ttlValue: ttlValue) ?? await invokeAsync(asyncBloc, key, storage);
}
只是同步

此策略呼叫 Web 服務來獲取資料。

class JustAsyncStrategy extends CacheStrategy {
  static final JustAsyncStrategy _instance = JustAsyncStrategy._internal() ;

  factory JustAsyncStrategy()  {
    return _instance;
  }

  JustAsyncStrategy._internal() ;

  @override
  Future<T?> applyStrategy<T>(AsyncBloc<T> asyncBloc, String key, SerializerBloc<T> serializerBloc, int ttlValue, Storage storage) async => await invokeAsync(asyncBloc, key, storage);
}
JustCache
class JustCacheStrategy extends CacheStrategy {
  static final JustCacheStrategy _instance = JustCacheStrategy._internal() ;

  factory JustCacheStrategy()  {
    return _instance;
  }

  JustCacheStrategy._internal() ;
  @override
  Future<T?> applyStrategy<T>(AsyncBloc<T> asyncBloc, String key, SerializerBloc<T> serializerBloc, int ttlValue, Storage storage) async =>
      await fetchCacheData(key, serializerBloc, storage, ttlValue: ttlValue);
}

此策略僅使用儲存在裝置快取中的資料。缺點是如果應用程式找不到資料,則返回 null。

對於最後兩種策略,它們可以直接由對快取或網路的直接呼叫來替代,但是這裡我們保留了一種統一的呼叫方式。

現在我們已經看到了不同的策略,讓我們使用它們!

在根快取資料夾中,我們建立一個 cache_manager.dart 檔案。

這個檔案將包含構建快取策略的所有邏輯。它將被直接注入到我們的程式碼中(稍後我將回到這一點)。

import 'cache_strategy.dart';
import 'storage/cache_storage.dart';

typedef AsyncBloc<T> = Function;
typedef SerializerBloc<T> = Function(dynamic);

class CacheManager {
  final CacheStorage cacheStorage;

  CacheManager(
    this.cacheStorage,
  );

  String? defaultSessionName;

  StrategyBuilder from<T>(String key) => StrategyBuilder<T>(key, cacheStorage).withSession(defaultSessionName);

  Future clear({String? prefix}) async {
    if (defaultSessionName != null && prefix != null) {
      await cacheStorage.clear(prefix: "${defaultSessionName}_$prefix");
    } else if (prefix != null) {
      await cacheStorage.clear(prefix: prefix);
    } else if (defaultSessionName != null) {
      await cacheStorage.clear(prefix: defaultSessionName);
    } else {
      await cacheStorage.clear() ;
    }
  }
}

class StrategyBuilder<T> {
  final String _key;
  final CacheStorage _cacheStorage;

  StrategyBuilder(this._key, this._cacheStorage);

  late AsyncBloc<T> _asyncBloc;
  late SerializerBloc<T> _serializerBloc;
  late CacheStrategy _strategy;
  int _ttlValue = CacheStrategy.defaultTTLValue;
  String? _sessionName;

  StrategyBuilder withAsync(AsyncBloc<T> asyncBloc) {
    _asyncBloc = asyncBloc;
    return this;
  }

  StrategyBuilder withStrategy(CacheStrategy strategyType) {
    _strategy = strategyType;
    return this;
  }

  StrategyBuilder withTtl(int ttlValue) {
    _ttlValue = ttlValue;
    return this;
  }

  StrategyBuilder withSession(String? sessionName) {
    _sessionName = sessionName;
    return this;
  }

  StrategyBuilder withSerializer(SerializerBloc serializerBloc) {
    _serializerBloc = serializerBloc;
    return this;
  }

  String buildSessionKey(String key) => _sessionName != null ? "${_sessionName}_$key" : key;

  Future<T?> execute()  async {
    try {
      return await _strategy.applyStrategy<T?>(_asyncBloc, buildSessionKey(_key), _serializerBloc, _ttlValue, _cacheStorage);
    } catch (exception) {
      rethrow;
    }
  }
}

讓我解釋一下這個檔案:

→ 它分為兩個類: CacheManager 和 Strategies yBuilder

→ CacheManager 使用 from() 方法儲存入口點。Strategies yBuilder 擁有其他一些方法,這些方法允許我們透過一些引數(如非同步函式、序列化器等)來構建快取會話。.

  • DefaultSessionName 允許我們將一個全域性名稱放到將要開啟的快取會話中。例如,如果我們為每個登入的使用者建立一個快取會話,我們可以將使用者的 firstName + lastName + id 設定為 defaultSessionName,這樣我們就可以使用這個名稱輕鬆地操作整個快取會話。
  • From() : 該方法建立一個通用型別 < T > 的 Strategies yBuilder 例項,該例項允許返回任何型別: List、 String、 Object... 一個鍵引數被傳遞,它將在 buildSessionKey() 方法中用於 hive 框的名稱。AcheStorage 例項也作為引數傳遞,以便 Strategies yBuilder 可以使用它並將其傳遞給 CacheStrategy。最後,Strategies yBuilder 的 withSession() 方法用於命名當前快取會話。
  • Clear() : 允許以不同方式清除快取。我們可以使用 Strategy Builder 的 defaultSessionName 或字首引數清理快取會話,或者清理建立的所有快取。

一旦呼叫 from() 方法,就輪到呼叫 Strategies yBuilder 方法了:

  • With Async() : 我們為構建器提供 AsyncBloc < T > 函式,構建器將從遠端源(比如 API)獲取資料。
  • WithSerializer() : 我們為構建器提供序列化器/反序列化器,它負責將接收到的 JSON 資料轉換為 dart 物件,反之亦然,使用 SerializerBloc < T > 函式。
由於 Dart 中的預設序列化/反序列化沒有針對複雜物件進行最佳化,因此 Flutter 建議使用一個包(json_seralizable)。它將為每個 DTO 自動生成方法,然後將這些方法直接注入 seralizerBloc,用於序列化從快取接收的資料。
  • WithTtl() : 為快取提供生存時間,預設情況下我們將其設定為 1 小時。
  • WithStrategy() : 接收所選擇的策略單例。直接注入一個單例模式允許定製/新增不同的策略,例如,它比列舉更靈活。
  • Execute() : 後一個方法觸發 applicyStrategy() 方法來執行快取策略。

H 如何使用這種策略?

現在我們已經瞭解了這個理論,讓我們來看看在應用程式中實現快取策略的實際情況。

我向你保證,這是最簡單的部分。

首先,我們需要注入我們建立的 CacheManager。為了做到這一點,我們使用 get_it 包,它將使用依賴注入來建立一個可以在整個程式碼庫中使用的單例模式。

我建議您在應用程式的核心資料夾中建立一個 service_locator. dart 檔案。

final getIt = GetIt.instance;

void setupGetIt()  {
  // Cache
  getIt.registerSingleton<CacheManager>(CacheManager(CacheStorage() ));

}

因此,我們使用 CacheManager 來管理策略並儲存 CacheStorage 例項用於儲存。

這個 setupGetIt() 方法將在 app root starter 中觸發,以注入 CacheManager 單例項。

當我們嘗試在 簡潔專案架構 clean architecture 中工作時,我們的故障看起來是這樣的:

-- data

----- datasource

----- domain

----- dto

----- repository

我們最感興趣的是儲存庫資料夾,因為它在從資料來源接收輸入 dto 時充當閘道器,將其轉換為來自域的實體。

讓我們以一個應用程式為例,它將顯示學生要完成的工作。 我們需要一個方法來檢索分配。

class HomeworkAssignmentRepository {

  final apiProvider = getIt<HomeworkDataSource>() ;
  final _cacheManager = getIt<CacheManager>() ;

  Future<List<HomeworkEntity>?> getHomeworkAssignment(String courseId, String studentId) async {
    final List<HomeworkDto>? result = await _cacheManager
        .from<List<HomeworkDto>>("homework-assignment-$courseId-$studentId")
        .withSerializer((result) => HomeworkDto.fromJson(result))
        .withAsync(()  => apiProvider.fetchHomeworkAssignment(courseId, studentId))
        .withStrategy(AsyncOrCache() )
        .execute() ;

    if (result != null) {
      return List<HomeworkEntity>.from(result.map((dto) => dto.toEntity() ));
    }
    return null;

  }
}

首先,我們將我們的 HomeworkDataSource 和 CacheManager 注入 get_it。

資料來源將用於呼叫端點,管理器用於配置策略。

在將來的 getHomeworkAsmission 中,我們希望得到一個 HomeworkD 的列表,它將在 HomeworkEntity 中被轉換。我們看到我們的戰略得到了應用,我們解釋道:

  • From() 設定將使用哪個 dto 並給出快取的金鑰。
  • WithSerializer() 注入將反序列化資料的方法。
  • WithAsync() 注入帶有必要引數的 API 呼叫。
  • WithStrategy() 允許定義要選擇的策略。
  • Execute() 將透過將定義的引數傳送給 Strategies yBuilder 來觸發我們的策略。

現在,有了這個配置,我們的策略將首先觸發一個 API 呼叫來從伺服器檢索資料。如果呼叫引發錯誤,則該策略將嘗試從快取中檢索資料,最後,它將向 UI 返回資料(無論是否為新鮮資料)或 null。

結束語

如果本文對你有幫助,請轉發讓更多的朋友閱讀。

也許這個操作只要你 3 秒鐘,對我來說是一個激勵,感謝。

祝你有一個美好的一天~


© 貓哥

本文由mdnice多平臺釋出

相關文章