持久化儲存架構:鍵值資料
大多數 Flutter 應用程式,無論規模大小,都會在某個時刻需要將資料儲存到使用者的裝置上,例如 API 金鑰、使用者偏好設定,或是需要離線可用的資料。
在本教學中,你將學習如何在採用推薦 Flutter 架構設計 的 Flutter 應用程式中,整合鍵值資料的持久化儲存。如果你對於如何將資料儲存到磁碟還不熟悉,可以先閱讀 將鍵值資料儲存到磁碟 這篇教學。
鍵值儲存區常用於儲存簡單資料,例如應用程式設定。在本教學中,你將用它來儲存深色模式(Dark Mode)偏好設定。如果你想學習如何在裝置上儲存複雜資料,建議使用 SQL。此時可參考本教學後續的 持久化儲存架構:SQL。
範例應用程式:可選主題的應用
#這個範例應用程式包含一個單一螢幕,上方有應用程式列(app bar)、中間是項目清單,底部有一個文字欄位(text field)輸入區。

在 AppBar 中,Switch 讓使用者可以在深色與淺色主題模式間切換。這個設定會立即套用,並透過鍵值資料儲存服務儲存在裝置上。當使用者再次啟動應用程式時,設定會自動還原。

儲存主題選擇的鍵值資料
#此功能遵循推薦的 Flutter 架構設計模式,分為展示層(presentation layer)與資料層(data layer)。
- 展示層包含
ThemeSwitch元件(Widget)與ThemeSwitchViewModel。 - 資料層包含
ThemeRepository與SharedPreferencesService。
主題選擇展示層
#ThemeSwitch 是一個 StatelessWidget,其中包含 Switch 元件(Widget)。開關的狀態由 ThemeSwitchViewModel 中的公開欄位 isDarkMode 表示。當使用者點擊開關時,程式會在 view model 中執行 toggle 指令。
class ThemeSwitch extends StatelessWidget {
const ThemeSwitch({super.key, required this.viewmodel});
final ThemeSwitchViewModel viewmodel;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Row(
children: [
const Text('Dark Mode'),
ListenableBuilder(
listenable: viewmodel,
builder: (context, _) {
return Switch(
value: viewmodel.isDarkMode,
onChanged: (_) {
viewmodel.toggle.execute();
},
);
},
),
],
),
);
}
}ThemeSwitchViewModel 實作了一個 view model(檢視模型), 如 MVVM(Model-View-ViewModel)模式中所描述。 這個 view model 包含了 ThemeSwitch 元件(Widget)的狀態, 並以布林變數 _isDarkMode 來表示。
這個 view model 使用 ThemeRepository 來儲存與載入深色模式(dark mode)設定。
它包含兩個不同的指令操作(command actions): load,會從儲存庫(repository)載入深色模式設定, 以及 toggle,用來在深色模式與淺色模式之間切換狀態。 它透過 isDarkMode getter 對外公開狀態。
_load 方法實作了 load 指令。 此方法會呼叫 ThemeRepository.isDarkMode 以取得已儲存的設定,並呼叫 notifyListeners() 來刷新 UI。
_toggle 方法則實作了 toggle 指令。 此方法會呼叫 ThemeRepository.setDarkMode 來儲存新的深色模式設定。 同時,它也會變更本地狀態 _isDarkMode, 然後呼叫 notifyListeners() 來更新 UI。
class ThemeSwitchViewModel extends ChangeNotifier {
ThemeSwitchViewModel(this._themeRepository) {
load = Command0(_load)..execute();
toggle = Command0(_toggle);
}
final ThemeRepository _themeRepository;
bool _isDarkMode = false;
/// If true show dark mode
bool get isDarkMode => _isDarkMode;
late final Command0<void> load;
late final Command0<void> toggle;
/// Load the current theme setting from the repository
Future<Result<void>> _load() async {
final result = await _themeRepository.isDarkMode();
if (result is Ok<bool>) {
_isDarkMode = result.value;
}
notifyListeners();
return result;
}
/// Toggle the theme setting
Future<Result<void>> _toggle() async {
_isDarkMode = !_isDarkMode;
final result = await _themeRepository.setDarkMode(_isDarkMode);
notifyListeners();
return result;
}
}主題選擇資料層
#依照架構指引,
資料層被拆分為兩個部分:ThemeRepository 和 SharedPreferencesService。
ThemeRepository 是所有主題化(theming)設定的唯一真實來源(single source of truth),
並且負責處理來自服務層的任何可能錯誤。
在這個範例中,
ThemeRepository 也透過可觀察的 Stream 對外提供深色模式(dark mode)設定。
這讓應用程式的其他部分
可以訂閱深色模式設定的變化。
ThemeRepository 依賴於 SharedPreferencesService。
Repository 會從 service 取得儲存的值,
並在值改變時進行儲存。
setDarkMode() 方法會將新值傳遞給 StreamController,
這樣任何監聽 observeDarkMode stream 的元件
class ThemeRepository {
ThemeRepository(this._service);
final _darkModeController = StreamController<bool>.broadcast();
final SharedPreferencesService _service;
/// Get if dark mode is enabled
Future<Result<bool>> isDarkMode() async {
try {
final value = await _service.isDarkMode();
return Result.ok(value);
} on Exception catch (e) {
return Result.error(e);
}
}
/// Set dark mode
Future<Result<void>> setDarkMode(bool value) async {
try {
await _service.setDarkMode(value);
_darkModeController.add(value);
return Result.ok(null);
} on Exception catch (e) {
return Result.error(e);
}
}
/// Stream that emits theme config changes.
/// ViewModels should call [isDarkMode] to get the current theme setting.
Stream<bool> observeDarkMode() => _darkModeController.stream;
}SharedPreferencesService 封裝了 SharedPreferences 外掛的功能,並呼叫 setBool() 和 getBool() 方法來儲存深色模式設定,將這個第三方相依性隱藏在應用程式的其他部分之外。
class SharedPreferencesService {
static const String _kDarkMode = 'darkMode';
Future<void> setDarkMode(bool value) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_kDarkMode, value);
}
Future<bool> isDarkMode() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getBool(_kDarkMode) ?? false;
}
}整合應用
#在這個範例中, ThemeRepository 和 SharedPreferencesService 會在 main() 方法中建立, 並作為建構子參數依賴傳遞給 MainApp。
void main() {
// ···
runApp(
MainApp(
themeRepository: ThemeRepository(SharedPreferencesService()),
// ···
),
);
}接著,當建立ThemeSwitch時, 同時建立ThemeSwitchViewModel, 並將ThemeRepository作為相依性傳遞進去。
ThemeSwitch(
viewmodel: ThemeSwitchViewModel(widget.themeRepository),
),範例應用程式中也包含 MainAppViewModel 類別, 它會監聽 ThemeRepository 的變化, 並將深色模式(dark mode)設定暴露給 MaterialApp 元件(Widget)。
class MainAppViewModel extends ChangeNotifier {
MainAppViewModel(this._themeRepository) {
_subscription = _themeRepository.observeDarkMode().listen((isDarkMode) {
_isDarkMode = isDarkMode;
notifyListeners();
});
_load();
}
final ThemeRepository _themeRepository;
StreamSubscription<bool>? _subscription;
bool _isDarkMode = false;
bool get isDarkMode => _isDarkMode;
Future<void> _load() async {
final result = await _themeRepository.isDarkMode();
if (result is Ok<bool>) {
_isDarkMode = result.value;
}
notifyListeners();
}
@override
void dispose() {
_subscription?.cancel();
super.dispose();
}
}ListenableBuilder(
listenable: _viewModel,
builder: (context, child) {
return MaterialApp(
theme: _viewModel.isDarkMode ? ThemeData.dark() : ThemeData.light(),
home: child,
);
},
child: //...
)