Создание надежной локальной службы хранения данных во Flutter
Современным мобильным приложениям часто требуется хранить различные типы данных локально — от пользовательских настроек до токенов аутентификации. Хотя Flutter SharedPreferences
обеспечивает базовое хранилище и FlutterSecureStorage
зашифрованное хранилище, эффективное управление ими в большом приложении требует тщательного архитектурного планирования.
В этой статье мы рассмотрим, как создать надежную систему хранения данных «ключ-значение», которая разделяет задачи, обеспечивает безопасность типов и делает операции хранения удобными и безопасными.
Двухслойная архитектура
Наша реализация использует двухуровневую архитектуру:
KeyValueStorageBase
: базовый класс низкого уровня, который напрямую взаимодействует с плагинами хранения.KeyValueStorageService
: высокоуровневая служба, которая обеспечивает типизированные, специфичные для домена операции хранения.
Почему два слоя?
Такое разделение служит нескольким важным целям:
1. Разделение интересов
- Базовый класс более универсален и обрабатывает необработанные операции хранения
- Класс обслуживания обрабатывает бизнес-логику и преобразование данных в соответствии с доменом приложения
- Четкая граница между механизмом хранения и бизнес-логикой
2. Единая ответственность
- Базовый класс: как хранить и извлекать данные
- Класс обслуживания: что и когда хранить, а также управление ключами
3. Изоляция зависимости
- Только базовый класс знает о
SharedPreferences
иFlutterSecureStorage
. - Код приложения взаимодействует только с сервисным уровнем.
Базовый уровень: KeyValueStorageBase
Давайте рассмотрим реализацию key_value_storage_base.dart
нашего базового класса:
import 'dart:async';
import 'package:flutter/services.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import '../../config/logging/logging.dart';
/// Base class containing a unified API for key-value pairs' storage.
/// This class provides low level methods for storing:
/// - Sensitive keys using [FlutterSecureStorage]
/// - Insensitive keys using [SharedPreferences]
class KeyValueStorageBase {
/// Instance of shared preferences
static SharedPreferences? _sharedPrefs;
/// Instance of flutter secure storage
static FlutterSecureStorage? _secureStorage;
/// Singleton instance of KeyValueStorage Helper
static const instance = KeyValueStorageBase._();
/// Private constructor
const KeyValueStorageBase._();
/// Initializer for shared prefs and flutter secure storage
/// Should be called in main before runApp and
/// after WidgetsBinding.FlutterInitialized(), to allow for synchronous tasks
/// when possible.
static Future<void> init() async {
_sharedPrefs ??= await SharedPreferences.getInstance();
const androidOptions = AndroidOptions(
encryptedSharedPreferences: true,
);
_secureStorage ??= const FlutterSecureStorage(aOptions: androidOptions);
}
/// Reads the value for the key from common preferences storage
T? getCommon<T>(String key) {
try {
return switch (T) {
const (String) => _sharedPrefs!.getString(key) as T?,
const (List<String>) => _sharedPrefs!.getStringList(key) as T?,
const (int) => _sharedPrefs!.getInt(key) as T?,
const (bool) => _sharedPrefs!.getBool(key) as T?,
const (double) => _sharedPrefs!.getDouble(key) as T?,
_ => _sharedPrefs!.get(key) as T?
};
} on PlatformException catch (ex) {
appLogger.debug('$ex');
return null;
}
}
/// Reads the decrypted value for the key from secure storage
Future<String?> getEncrypted(String key) {
try {
return _secureStorage!.read(key: key);
} on PlatformException catch (ex) {
appLogger.debug('$ex');
return Future<String?>.value();
}
}
/// Sets the value for the key to common preferences storage
Future<bool> setCommon<T>(String key, T value) {
return switch (T) {
const (String) => _sharedPrefs!.setString(key, value as String),
const (List<String>) =>
_sharedPrefs!.setStringList(key, value as List<String>),
const (int) => _sharedPrefs!.setInt(key, value as int),
const (bool) => _sharedPrefs!.setBool(key, value as bool),
const (double) => _sharedPrefs!.setDouble(key, value as double),
_ => _sharedPrefs!.setString(key, value as String)
};
}
/// Sets the encrypted value for the key to secure storage
Future<bool> setEncrypted(String key, String value) {
try {
_secureStorage!.write(key: key, value: value);
return Future.value(true);
} on PlatformException catch (ex) {
appLogger.debug('$ex');
return Future.value(false);
}
}
Future<bool> removeCommon(String key) => _sharedPrefs!.remove(key);
Future<void> removeEncrypted(String key) => _secureStorage!.delete(key: key);
/// Erases common preferences keys
Future<bool> clearCommon() => _sharedPrefs!.clear();
/// Erases encrypted keys
Future<bool> clearEncrypted() async {
try {
await _secureStorage!.deleteAll();
return true;
} on PlatformException catch (ex) {
appLogger.debug('$ex');
return false;
}
}
}
Инициализация
WidgetsBinding.ensureInitialized();
...
// For preparing the key-value mem cache
await KeyValueStorageBase.init();
...
runApp();
Этот метод init()
является важнейшей частью нашего дизайна базового слоя. Инициализируя обе системы хранения (SharedPreferences
и FlutterSecureStorage
) при запуске приложения, мы гарантируем, что экземпляры хранилища будут готовы до того, как какая-либо часть приложения попытается получить к ним доступ. Это позволяет нам выполнять синхронные чтения SharedPreferences
на протяжении всего жизненного цикла нашего приложения.
Без этого шаблона инициализации нам пришлось бы обрабатывать асинхронные операции для каждой операции чтения, что значительно усложнило бы наш API хранилища и навязало бы ненужную сложность остальной части кода нашего приложения. Этот шаблон «инициализируйте один раз, используйте синхронно» особенно ценен для доступа к критически важным данным, таким как состояние аутентификации пользователя или конфигурация приложения во время запуска приложения.
Основные характеристики базового слоя
1. Унифицированный интерфейс хранения
T? getCommon<T>(String key)
Future<String?> getEncrypted(String key)
Future<bool> setCommon<T>(String key, T value)
Future<bool> setEncrypted(String key, String value)
2. Типобезопасные операции
T? getCommon<T>(String key) {
return switch (T) {
const (String) => _sharedPrefs!.getString(key) as T?,
const (List<String>) => _sharedPrefs!.getStringList(key) as T?,
const (int) => _sharedPrefs!.getInt(key) as T?,
const (bool) => _sharedPrefs!.getBool(key) as T?,
const (double) => _sharedPrefs!.getDouble(key) as T?,
_ => _sharedPrefs!.get(key) as T?
};
}
3. Обработка ошибок
try {
return _secureStorage!.read(key: key);
} on PlatformException catch (ex) {
appLogger.debug('$ex');
return Future<String?>.value();
}
Уровень обслуживания: KeyValueStorageService
Уровень обслуживания обеспечивает операции хранения, специфичные для домена:
// ignore_for_file: avoid_positional_boolean_parameters
import 'dart:convert';
import 'package:hooks_riverpod/hooks_riverpod.dart';
// Services
import '../../features/notifications/notifications.dart';
import '../networking/models/token_model.codegen.dart';
import 'key_value_storage_base.dart';
// Helpers
import '../../helpers/helpers.dart';
// Features
import '../../features/map/map.dart';
import '../../features/profile/profile.dart';
/// A provider used to access instance of this service
final keyValueStorageServiceProvider = Provider((ref) {
return KeyValueStorageService();
});
/// A service class for providing methods to store and retrieve key-value data
/// from common or secure storage.
class KeyValueStorageService {
/// The name of auth token key
static const _authTokenKey = 'authToken';
/// The name of user model key
static const _authUserKey = 'authUserKey';
/// The last map data key
static const _mapDataKey = 'mapDataKey';
/// The name of notifications key
static const _notificationsKey = 'notificationsKey';
/// The name of the first map load key
static const _firstMapLoadKey = 'firstMapLoadKey';
/// The name of the stored locale key
static const _localeKey = 'localeKey';
/// The name of the stored job alerts area key
static const _jobAlertsAreaKey = 'jobAlertsAreaKey';
/// Instance of key-value storage base class
static const _keyValueStorage = KeyValueStorageBase.instance;
/// Returns last authenticated user
UserModel? getAuthUser() {
final user = _keyValueStorage.getCommon<String>(_authUserKey);
if (user == null) return null;
return UserModel.fromJson(jsonDecode(user) as JSON);
}
/// Returns last authentication token
Future<TokenModel?> getAuthToken() async {
final token = await _keyValueStorage.getEncrypted(_authTokenKey);
if (token == null) return null;
return TokenModel.fromJson(jsonDecode(token) as JSON);
}
/// Returns whether this is the first time the map is being loaded
bool isFirstMapLoad() {
final firstLoad = _keyValueStorage.getCommon<bool>(_firstMapLoadKey);
if (firstLoad == null) return true;
return firstLoad;
}
/// Sets the first map load to this value. Even though this method is
/// asynchronous, we don't care about it's completion which is why we don't
/// use `await` and let it execute in the background.
void setFirstMapLoad(bool isFirstLoad) {
_keyValueStorage.setCommon<bool>(_firstMapLoadKey, isFirstLoad);
}
/// Returns the stored locale
String? getLocale() {
final locale = _keyValueStorage.getCommon<String>(_localeKey);
return locale;
}
/// Sets the stored locale to this value. Even though this method is
/// asynchronous, we don't care about it's completion which is why we don't
/// use `await` and let it execute in the background.
void setLocale(String locale) {
_keyValueStorage.setCommon<String>(_localeKey, locale);
}
/// Sets the authenticated user to this value. Even though this method is
/// asynchronous, we don't care about it's completion which is why we don't
/// use `await` and let it execute in the background.
void setAuthUser(UserModel user) {
_keyValueStorage.setCommon<String>(_authUserKey, jsonEncode(user.toJson()));
}
/// Sets the authentication token to this value. Even though this method is
/// asynchronous, we don't care about it's completion which is why we don't
/// use `await` and let it execute in the background.
void setAuthToken(TokenModel token) {
_keyValueStorage.setEncrypted(_authTokenKey, jsonEncode(token.toJson()));
}
void clearUserData() {
_keyValueStorage
..removeCommon(_authUserKey)
..removeCommon(_firstMapLoadKey)
..removeCommon(_mapDataKey)
..removeCommon(_notificationsKey)
..removeCommon(_jobAlertsAreaKey)
..removeEncrypted(_authTokenKey);
}
/// Returns the last map data
MapDataModel? getMapData() {
final json = _keyValueStorage.getCommon<String>(_mapDataKey);
if (json == null) return null;
return MapDataModel.fromJson(jsonDecode(json));
}
/// Sets the last map data. Even though this method is asynchronous,
/// we don't care about it's completion which is why we don't use `await` and
/// let it execute in the background.
void setMapData(MapDataModel mapData) {
_keyValueStorage.setCommon(_mapDataKey, jsonEncode(mapData.toJson()));
}
/// Returns the last job alerts area
MapDataModel? getJobAlertsArea() {
final json = _keyValueStorage.getCommon<String>(_jobAlertsAreaKey);
if (json == null) return null;
return MapDataModel.fromJson(jsonDecode(json));
}
/// Sets the last job alerts area. Even though this method is asynchronous,
/// we don't care about it's completion which is why we don't use `await` and
/// let it execute in the background.
void setJobAlertsArea(MapDataModel mapData) {
_keyValueStorage.setCommon(_jobAlertsAreaKey, jsonEncode(mapData.toJson()));
}
void clearJobAlertsArea() {
_keyValueStorage.removeCommon(_jobAlertsAreaKey);
}
/// Returns the last notifications
List<NotificationModel> getNotifications() {
final list = _keyValueStorage.getCommon<List<String>>(_notificationsKey);
if (list == null) return [];
return list.map((e) => NotificationModel.fromJson(jsonDecode(e))).toList();
}
/// Sets the notifications. Even though this method is asynchronous, we don't
/// care about it's completion which is why we don't use `await` and let it
/// execute in the background.
Future<bool> addNotification(NotificationModel notification) async {
final json = jsonEncode(notification.toJson());
final jsonList =
_keyValueStorage.getCommon<List<String>>(_notificationsKey);
return _keyValueStorage.setCommon(_notificationsKey, [...?jsonList, json]);
}
/// Resets the authentication. Even though these methods are asynchronous, we
/// don't care about their completion which is why we don't use `await` and
/// let them execute in the background.
void resetKeys() {
_keyValueStorage
..clearCommon()
..clearEncrypted();
}
}
Ключевые характеристики уровня обслуживания
1. Операции, специфичные для домена
- Методы напрямую соответствуют потребностям бизнеса
- Управляет сериализацией/десериализацией
- Обеспечивает безопасность типов на уровне домена
2. Централизованное управление ключами
static const _authTokenKey = 'authToken';
static const _authUserKey = 'authUserKey';
3. Интеллектуальные решения по хранению данных
- Конфиденциальные данные используют зашифрованное хранилище
- Регулярные данные используют общие предпочтения
4. Массовые операции
void clearUserData() {
_keyValueStorage
..removeCommon(_authUserKey)
..removeCommon(_firstMapLoadKey)
..removeEncrypted(_authTokenKey);
}
Преимущества этой архитектуры
1. Безопасность типов
- Проверка типов во время компиляции для сохраненных значений
- Модели доменов правильно сериализованы/десериализованы
2. Защищенность:
- Четкое разделение между безопасным и незащищенным хранилищем
- Зашифрованное хранилище для конфиденциальных данных
- Централизованные решения по безопасности
3. Удобное управление
- Единый источник достоверной информации для операций по хранению
- Легко добавлять новые операции хранения
- Последовательная обработка ошибок
4. Тестируемость
- Базовый класс может быть имитирован для тестирования
- Уровень обслуживания можно тестировать независимо
- Четкие границы для модульных тестов
5. Производительность
- Асинхронные операции при необходимости
- Синхронные операции, где это возможно
- Фоновое выполнение некритических записей
Заключение
Эта двухслойная архитектура обеспечивает надежную основу для хранения ключей и значений в приложениях Flutter. Следуя этому шаблону, вы создаете чистую, поддерживаемую и безопасную систему хранения, которая может расти вместе с вашим приложением.