Flutter 工程師如何統一管理本地儲存架構?乾淨分層解決多元需求

Published on: | Last updated:

有些 Flutter 專案一開始本地儲存(local storage)很單純:存個 user preferences、做點 cache、放 application configurations。結果需求一多,就開始同時碰 SharedPreferences、Secure Storage、甚至 Realm / Hive 這種資料庫型的東西。這時候如果沒有一個集中管理的 StorageManager,presentation layer 很快會出現 code duplication、tight coupling,整體 complexity 也會跟著上去。

核心結論:為什麼 Flutter 需要 StorageManager(集中式 storage manager)

Flutter 專案導入 StorageManager 的目的,是把 SharedPreferences、Secure Storage、Realm 等不同 local storage 的初始化(configure)與 CRUD 存取收斂到同一個入口,避免 presentation layer 重複寫存取邏輯、降低 tight coupling,並用 Clients 提供受限且 type-safe 的資料介面,讓模組不會直接碰整個資料庫。

老實說,這類問題不是「會不會發生」,而是「你什麼時候開始覺得煩」。一開始你可能只存暗黑模式(isDarkTheme / isDarkMode 之類),但下一個 sprint 你就會想塞登入狀態、token、一些快取資料;再下一個 sprint 你開始有物件資料要存,SharedPreferences 立刻就不太像話了。然後你就開始在不同頁面、不同 service 重複寫初始化、重複寫讀寫,最後回頭看:啊,原來 presentation layer 已經被存儲細節佔滿。

沒有 storage manager 時,常見的 3 個後果(原文因果保留)

在沒有 Storage Manager 的情境下,專案通常會逐步走向三種狀況:code duplication、tight coupling、increased complexity。這些不是抽象詞,它們會直接反映在你改一個 storage 實作時,需要動到多少地方、以及你能不能好好測試與除錯。

  1. Code duplication:初始化與存取邏輯會在多處重複出現。你今天在 A 模組做一次 configure,明天在 B 模組又做一次;讀寫 key-value 的方式也可能散落各處。

  2. Tight coupling:presentation layer 直接依賴特定儲存方案的細節。當底層 storage 換了(例如原文舉例把 Realm 換成 Hive),你會發現改動不只在 storage 層,而是一路往上炸。

  3. Increased complexity:存儲相關邏輯混進 presentation layer,維護成本與測試成本都上升。尤其當你要追 bug 時,會很痛苦:到底是 UI 流程錯,還是 storage 初始化時序錯?

對,這三點聽起來像教科書。但你真的遇到時,就不太像教科書了,會比較像「怎麼每個地方都在存東西,而且存法還不一樣」。很現實。

導入 Storage Manager 能得到什麼:Plug and Play、Centralised Source、Reduce Boilerplate

原文把 Storage Manager 的好處整理成 3 點:Plug and Play(可替換服務)、Centralised Source(集中式入口)、Reduce Boilerplate(減少重複初始化與樣板碼)。重點是:它們都指向同一件事——把 storage 的變動面集中,讓其他層(尤其 presentation layer)不用跟著抖。

  1. Plug and Play:能在不影響其他 components / layers 的前提下替換 storage 服務。原文例子是「adding or replacing Realm to Hive」這種替換情境。

  2. Centralised Source:整個 storage layer 有單一入口點(single source of point)。測試、除錯、追初始化順序,都比較可控。

  3. Reduce Boilerplate:初始化不需要散在各處。你不必每個模組都寫一次 configure / open / init 類似的流程。

講到「集中式」,我腦中會直接浮現另一個詞:責任邊界。你把 storage 的責任收進 StorageManager,其他地方就比較不會「順手」做一些不該做的事。這其實是乾淨架構(clean architecture)那套精神在這裡的落地方式之一。

概念總覽:StorageManager、Handlers、Clients 的關係
概念總覽:StorageManager、Handlers、Clients 的關係

先分清楚 2 類儲存:Preference Handlers vs Database Handlers

原文在進入程式碼前,先把 storage handlers 分成 2 類,這個分類很關鍵,因為後面 Clients 也會跟著分成兩類(2 (handlers/clients分成兩類))。

  • Preference Handlers:用 key-value pairs 存簡單資料。原文舉例包含 SharedPreferences、Secure Storage、Get Storage 等。

  • Database Handlers:存更複雜的資料(例如 custom objects),並支援比較完整的 CRUD 操作。原文舉例包含 Hive、Realm 等。

這裡不要硬把它想成「大小之分」,比較像「存取模型不同」:key-value 走 key,database 走 primaryKey / 型別 / 物件集合。你混在一起寫,後面一定亂。

Handlers 是什麼:用共同介面封裝 storage 實作,並統一 configure

Handlers 的定位是「封裝特定 storage 服務的實作」,讓 StorageManager 能用一致方式操作不同 storage 機制。因為每個 handler 的初始化方式不同,所以原文先抽出 StorageConfigurable,統一 configure 這件事。

/// This class configures the storage mechanism.
abstract class StorageConfigurable {
  /// This method should be called before using the storage mechanism.
  Future<void> configure();
}

這段其實很直白:任何 storage 機制在使用前都要 configure,但 configure 的內容怎麼做,由各 handler 自己決定。你只要求它「必須提供 configure」。就這樣。很乾脆。

接著是 database 類型的 handler 介面:DatabaseHandleable。它繼承 StorageConfigurable,並提供 read / readAll / write / delete / deleteAll 這套 CRUD API。原文也把 generic type、primaryKey、以及 shouldUpdate 的預設行為描述在註解裡。

///
/// Abstract class representing a local database storage mechanism.
///
/// Provides a common interface for interacting with local storage.
/// [T] is type of objects stored in the database.
///
///
abstract class DatabaseHandleable<T> implements StorageConfigurable {
  /// This method reads a object of specific type.
  /// Requires  a [primaryKey] that will be used to find an exact data that
  /// user needs.
  Future<S?> read<S extends T>(Object primaryKey);

/// This method returns all the data of specific type [S] from the database.
  /// If nothing is found, it will return an empty [Iterable]
  Future<List<S>> readAll<S extends T>();

/// This method add/update a data of specific type.
  /// [value] is the data that we need to add in database
  /// It is required to add [S] type in order to add data to the
  /// particular field.
  Future<void> write<S extends T>(
    S value, {
    bool shouldUpdate = true,
  });

/// This method removes an entry of data from the database.
  /// Needs [primaryKey] in order to find a particular data
  Future<void> delete<S extends T>(Object primaryKey);

/// This method removes all data of a given type [S] from the database.
  /// If not found, it will throw an error.
  Future<void> deleteAll<S extends T>();
}

另一邊是 PreferenceHandleable:針對 key-value pairs 的介面。它同樣 implements StorageConfigurable,但 CRUD 形狀不同:read / write 用 key(String),delete / deleteAll 也圍繞 key-value 的模型。

/// Abstract class representing a key-value storage mechanism.
///
/// Provides a common interface for interacting with various
/// preference database.
///
/// This is for key-value pair storage database.
///
abstract class PreferenceHandleable implements StorageConfigurable {
  /// This method reads a object of specific type.
  /// Requires a [key] that will be used to find an exact data that
  /// user needs.
  Future<S?> read<S extends Object?>(String key);

/// This method removes an entry of data from the preference database.
  /// Requires [key] in order to find a particular data
  Future<void> delete(String key);

/// This method removes all the key-value pairs stored in
  /// preference storage.
  Future<void> deleteAll();

/// This method add/update data.
  /// [value] is the data that we need to add in preference database.
  /// particular key.
  Future<void> write(
    String key,
    Object value,
  );
}

講到這裡,你其實已經把「規則」寫出來了:任何 SharedPreferences / Secure Storage / Get Storage 這種偏 preference 的實作,都得符合 PreferenceHandleable;任何 Realm / Hive 這種偏 database 的實作,都得符合 DatabaseHandleable。StorageManager 不需要知道你底下到底用誰,它只需要知道你有沒有守規矩。

Clients 的角色:提供受限介面,避免模組直接碰整個資料庫

Handlers 解決的是「怎麼跟某個 storage 互動」;Clients 解決的是「每個模組可以摸到多少」。原文的立場很清楚:如果沒有 clients,各模組可能存取與修改整個資料庫,帶來 security 和 integrity 的 potential risks,例如資料毀損或非預期修改。它不是說一定會發生,但風險是存在的。

原文也給了具體情境:login module 應該只碰 user authentication data,不該能隨便讀寫整個 database。這種「只拿你該拿的」的想法,就是 clients 在做的事。

同樣地,clients 也分成兩類,對應兩類 handlers:

  • KeyValuePreferenceClient:對 PreferenceHandleable 提供「綁定單一 key」的受限存取。

  • LocalDatabaseClient:對 DatabaseHandleable 提供以 primaryKey / 型別為核心的 CRUD。

核心機制詳解:Handler 與 Client 的分工與資料邊界
核心機制詳解:Handler 與 Client 的分工與資料邊界

KeyValuePreferenceClient:把 key 綁死,讓模組少一點自由發揮

KeyValuePreferenceClient 的結構很固定:建構子拿一個 PreferenceHandleable handler,再拿一個 String key。之後 read / write / delete 都圍繞這個 key 進行。也就是說,模組拿到 client 後,不需要(也不應該)再到處傳 key 字串。

/// Provides a restricted interface for accessing and modifying specific data
/// within a preference storage mechanism.
///
/// This class encapsulates the logic for reading, writing, and deleting
/// data associated with a given key.

class KeyValuePreferenceClient {
  /// Takes [PreferenceHandleable] handler instance and a [String] type key.
  KeyValuePreferenceClient(
    this._handler,
    this._key,
  );

late final PreferenceHandleable _handler;
  final String _key;

/// This method reads a object of specific type.
  /// user needs.

Future<S?> read<S extends Object>() async {
    return _handler.read<S?>(_key);
  }

/// This method removes an entry of data from the preference database.
  Future<void> delete() async {
    return _handler.delete(_key);
  }

/// This method removes all the key-value pairs stored in
  /// preference storage.

/// This method add/update data.
  /// [value] is the data that we need to add in preference database.
  /// particular key.
  Future<void> write(Object value) async {
    return _handler.write(_key, value);
  }

/// This method removes All entries from the preference database.
  Future<void> deleteAll() async {
    return _handler.deleteAll();
  }
}

你會注意到 deleteAll 也被放進來了,代表 client 仍然能觸發清空整個 preference storage。這個是否符合你的邊界設計,取決於你怎麼分配 client 的使用位置;原文在這裡只是示範 API 形狀,沒有進一步限制「哪些模組拿得到 deleteAll」。這點要自己小心。真的。

LocalDatabaseClient:針對物件資料的 CRUD 包裝

LocalDatabaseClient 對應 DatabaseHandleable。它提供 read(primaryKey)、readAll()、write(value)、delete(primaryKey)、deleteAll()。也就是你對 database 型 storage 的基本操作都在這裡。

/// Provides a restricted interface for accessing and modifying specific data
/// within a local storage mechanism.
///
/// This class encapsulates the logic for reading, writing, and deleting
/// data associated with a given primary key.

class LocalDatabaseClient<T extends Object> {
  /// This class takes instance of [DatabaseHandleable],
  /// and a primaryKey of type [Object]
  LocalDatabaseClient(
    this._handler,
  );

late final DatabaseHandleable<Object> _handler;

/// This method returns a value of type [T] if it is found
  /// Primary key is used to find the exact result.
  /// If not found, it will return a [Null] value.

Future<T?> read(Object primaryKey) async {
    return _handler.read<T>(primaryKey);
  }

/// This method returns all the data of specific type [T] from the database.
  /// If nothing is found, it will return an empty [Iterable]

Future<List<T>> readAll() async {
    return _handler.readAll<T>();
  }

/// This method add/update a data of specific type.
  /// [value] is the data that we need to add in database
  /// It is required to add [T] type in order to add data to the
  /// particular field.

Future<void> write(
    T value, {
    bool shouldUpdate = true,
  }) async {
    return _handler.write<T>(value);
  }

/// This method removes an entry of data from the database.

Future<void> delete(Object primaryKey) async {
    return _handler.delete<T>(primaryKey);
  }

/// This method removes all the entry of a specific data type in database.
  Future<void> deleteAll() async {
    return _handler.deleteAll<T>();
  }
}

原文也點到一個可能讓讀者卡住的地方:LocalDatabaseClient 的 _handler 被宣告成 DatabaseHandleable。這在泛型意圖上看起來有點繞,但就原文來看,它是一種設計選擇/示例寫法;你可以把重點放在「client 透過泛型 T 來限定 read/write 的型別」這件事,而不是先陷進型別宣告到底是不是最漂亮的那種討論。

StorageManager:集中式入口(factory),負責 configure handlers 並發 client

StorageManager 在原文的定位非常明確:它是一個 factory,也是一個 single source,讓 presentation layer 用同一個入口取得不同 storage clients。它會整合多個 handlers,並在使用前呼叫 configure 來完成初始化。

/// This class act as a factory to provide different storage clients
/// instances.
/// Act as a single source that will be used in Presentation Layer to interact
/// with different storage handlers

class StorageManager {
  /// Once the object is created, it will configure the instances of differnet
  /// clients.
  /// [PreferenceHandleable] and [DatabaseHandleable] is configured.
  /// These are not required, It not passed, handlers will be
  /// configured internally.
  StorageManager({
    required DatabaseHandleable<Object> realmHandler,
    required PreferenceHandleable sharedPreferenceHandler,
    required PreferenceHandleable secureStoreHandler,
  })  : _secureStoreHandler = secureStoreHandler,
        _sharedPreferenceHandler = sharedPreferenceHandler,
        _realmHandler = realmHandler;

/// Instance of [RealmDatabaseHandler]
  final DatabaseHandleable<Object> _realmHandler;

/// Instance of [SharedPreferenceHandler]
  final PreferenceHandleable _sharedPreferenceHandler;

/// Instance of [SecureStorageHandler]
  final PreferenceHandleable _secureStoreHandler;

/// This method configures all the dependency related to storage handlers.
  Future<void> configure() async {
    try {
      await Future.wait(
        [
          _configureHandler(_sharedPreferenceHandler),
          _configureHandler(_realmHandler),
          _configureHandler(_secureStoreHandler),
        ],
        eagerError: true,
      );
    } catch (e) {
      // rethrow;
      // Logs the first error that occurs in future.
      // log error here as well if want to .
    }
  }

Future<void> _configureHandler(StorageConfigurable handler) async {
    try {
      await handler.configure();
    } catch (e) {
      rethrow;
    }
  }

/// A single soruce where all the `Secure Storage Handlers` can access
  /// the database.
  KeyValuePreferenceClient getSecureStorageClient(
    SecureStorageKey key,
  ) =>
      KeyValuePreferenceClient(_secureStoreHandler, key.name);

/// A single soruce where all the `Shared Prefernce Storage Handlers`
  /// can access the database.
  KeyValuePreferenceClient getPreferenceStorageClient(
    PreferenceStorageKey key,
  ) =>
      KeyValuePreferenceClient(_sharedPreferenceHandler, key.name);

/// A single soruce where all the `Local Storage Handlers`
  /// can access the database.
  LocalDatabaseClient<T> getLocalStorageClient<T extends Object>() =>
      LocalDatabaseClient<T>(_realmHandler);
}

這段有幾個點,原文其實都有講到(只是散在不同段落):

  • configure 是必要步驟:StorageManager.configure() 會對 handlers 做 configure,使用 Future.wait 並設定 eagerError: true。也就是多個初始化一起跑,第一個錯就提早報錯。

  • 錯誤處理意圖留在註解:catch 裡面留了 rethrow / log 的註解,表示這裡預期你可以接上日誌或調整錯誤策略,但原文沒有宣稱已完成日誌實作。

  • 提供三種 client getter:getSecureStorageClient(SecureStorageKey)、getPreferenceStorageClient(PreferenceStorageKey)、getLocalStorageClient()。presentation layer 不直接碰 handler,只拿 client。

有個小細節:原文註解提到「These are not required」以及「If not passed, handlers will be configured internally」,但同一段建構子參數是 required。就文章呈現而言,這是一種描述上的不一致;重點仍然是「StorageManager 需要拿到 handlers,並在使用前 configure」。如果你真的要做內部配置,那是另一套設計取捨,但原文沒有展開。

用 GetIt 在 runApp 前初始化:registerSingletonAsync 與使用方式

原文建議在 runApp 前初始化並註冊 StorageManager,讓各模組可以立刻做 CRUD,並避免每個模組重複 configure。這裡的範例使用 GetIt,並透過 registerSingletonAsync 以非同步方式建立 StorageManager、呼叫 configure、再回傳實例。

另外,原文程式碼區塊的語言標示寫成 php,但內容實際上是 Dart / GetIt 的用法;重寫時只更正描述,不改動程式碼本身。

 GetIt.instance.registerSingletonAsync<StorageManager>(() async {
      final storageManager = StorageManager(
        sharedPreferenceHandler: SharedPreferenceHandler(),
        realmHandler: RealmDatabaseHandler(),
        secureStoreHandler: SecureStorageHandler(),
      );
      await storageManager.configure();
      return storageManager;
    })

註冊完成後,就可以透過 GetIt 取回 StorageManager,再拿到你要的 client:

final realmData = GetIt.instance<StorageManager>().getLocalStorageClient<RealmDataObject>();
final preferenceString = GetIt.instance<StorageManager>().getPreferenceStorageClient(PreferenceStorageKey.isDarkMode);
final secureString =  GetIt.instance<StorageManager>().getSecureStorageClient(SecureStorageKey.secretKey1);

看到這裡其實就夠了:模組拿 client 做 read / write / delete / readAll / deleteAll,底下到底是 SharedPreferences 還是 Secure Storage,或者 Realm / Hive(原文把 Hive 放在可替換例子裡),模組本身不需要知道。它只需要知道 client 的介面。

可選增強(optional enhancements):StorageFailure 與 Type-Safe Keys

原文最後提了兩個 optional enhancements:Storage Failure(針對 storage 失敗建立失敗模型)以及 Type-Safe Keys(用 enum 或自訂 key 型別,避免硬編字串 key)。這些不是「一定要做」,但它們通常會讓系統更穩。

StorageFailure:當 storage client 出問題時,你需要能描述「發生什麼事」,並讓上層可以處理。原文用 sealed class StorageFailure implements Failure 來定義失敗藍圖,包含 title、message、errorCode(可選)。

/// Blueprint for Storage Failures
sealed class StorageFailure implements Failure {
  const StorageFailure({
    required this.title,
    required this.message,
    this.errorCode,
  });

/// [title] for [StorageFailure]
  @override
  final String title;

/// [message] for [StorageFailure]
  @override
  final String message;

/// [errorCode] for [StorageFailure]
  @override
  final int? errorCode;
}

Type-Safe Keys:原文的想法是,不要到處傳 String keys,改用 enum(或 custom key type)集中管理。這能降低 key 拼錯、命名不一致的風險,也讓整個 key 的來源更清楚。

/// This provide a centrailised point where all the keys for key-valuse based
/// clients can be accessed.
enum PreferenceStorageKey {
  isDarkTheme,
  keyNameExample,
}

/// This enum provides a centralized location for storing secure storage keys.
///
/// By using an enum, we ensure consistency and avoid hardcoding keys
/// throughout the application.
enum SecureStorageKey {
  secretStringData1,
  userData,
}

你會發現 StorageManager.getPreferenceStorageClient(PreferenceStorageKey key) 與 getSecureStorageClient(SecureStorageKey key) 正好把這件事接起來:你在上層只傳 enum,底下用 key.name 轉成 String。這樣 key 的集合就被「鎖」在 enum 裡,不會散落各處。

收束前補充:Type-safe keys 與 failure 模型在架構中的位置
收束前補充:Type-safe keys 與 failure 模型在架構中的位置

結語:把 storage 變動面收斂到一個入口,其他層才會乾淨

StorageManager 的核心價值,是把不同 local storage(SharedPreferences、Secure Storage、Realm 等)的 configure 與存取方式抽象化,並用 Handlers + Clients 的分層讓模組只拿到需要的資料介面,降低 code duplication、tight coupling 與 complexity,讓 Flutter 專案在離線(offline-capable)、快取(cache)、偏好設定(user preferences)等需求成長時仍能維持可維護性。

至於你要不要把它做得更完整——例如把錯誤處理從註解變成一致策略、把 keys 管得更嚴——那就是下一步了。原文把方向點出來了,剩下就是你專案的取捨。就這樣。

Related to this topic:

Comments

撥打專線 LINE免費通話