In mobile application development, local storage is a critical feature used to save user preferences, offline data, login status, and other information. Flutter provides various local storage solutions suitable for different scenario requirements. This lesson will introduce commonly used local storage methods in Flutter, including lightweight key-value storage and more complex database storage, with practical examples demonstrating their real-world applications.
I. Overview of Local Storage
Local storage plays an important role in mobile applications, with main application scenarios including:
- Saving user login status to avoid repeated logins
- Storing application configurations and user preferences
- Caching network request data to implement offline functionality
- Storing structured data such as chat records and task lists
Common local storage solutions in Flutter include:
- shared_preferences: Lightweight key-value storage, suitable for simple data
- sqflite: SQLite database wrapper, suitable for structured data
- hive: High-performance NoSQL database, pure Dart implementation
- flutter_secure_storage: Secure storage, suitable for sensitive information like tokens
This lesson focuses on the first two most commonly used storage solutions.
II. Lightweight Storage: shared_preferences
shared_preferences is a plugin provided by the Flutter community for storing simple key-value pair data. It uses NSUserDefaults on iOS and SharedPreferences on Android, providing a consistent cross-platform API.
1. Installation and Configuration
Add the dependency to pubspec.yaml:
dependencies:
flutter:
sdk: flutter
shared_preferences: ^2.5.3 # Use the latest version
Run flutter pub get to install the dependency.
2. Basic Usage
shared_preferences supports storing data types including: String, int, double, bool, and List.
Basic operation steps:
- Obtain the SharedPreferences instance
- Use corresponding methods for data reading and writing
- No need to manually close the instance
import 'package:shared_preferences/shared_preferences.dart';
// Save data
Future<void> saveData() async {
// Get instance
final prefs = await SharedPreferences.getInstance();
// Store different types of data
await prefs.setString('username', 'john_doe');
await prefs.setInt('age', 30);
await prefs.setDouble('height', 1.75);
await prefs.setBool('isPremium', false);
await prefs.setStringList('hobbies', ['reading', 'sports', 'coding']);
}
// Read data
Future<void> readData() async {
final prefs = await SharedPreferences.getInstance();
// Read data, providing default values
String username = prefs.getString('username') ?? 'Guest';
int age = prefs.getInt('age') ?? 0;
double height = prefs.getDouble('height') ?? 0.0;
bool isPremium = prefs.getBool('isPremium') ?? false;
List<String> hobbies = prefs.getStringList('hobbies') ?? [];
print('Username: $username');
print('Age: $age');
print('Height: $height');
print('Is Premium: $isPremium');
print('Hobbies: $hobbies');
}
// Update data
Future<void> updateData() async {
final prefs = await SharedPreferences.getInstance();
// Update values for existing keys
await prefs.setInt('age', 31);
await prefs.setBool('isPremium', true);
}
// Delete data
Future<void> deleteData() async {
final prefs = await SharedPreferences.getInstance();
// Delete a specific key
await prefs.remove('height');
// Clear all data
// await prefs.clear();
}
3. shared_preferences Encapsulation
To simplify usage and avoid code duplication, you can encapsulate a utility class:
import 'package:shared_preferences/shared_preferences.dart';
class PrefsService {
// Singleton pattern
static final PrefsService _instance = PrefsService._internal();
factory PrefsService() => _instance;
PrefsService._internal();
late SharedPreferences _prefs;
// Initialization
Future<void> init() async {
_prefs = await SharedPreferences.getInstance();
}
// Storage methods
Future<void> setString(String key, String value) async {
await _prefs.setString(key, value);
}
Future<void> setInt(String key, int value) async {
await _prefs.setInt(key, value);
}
Future<void> setDouble(String key, double value) async {
await _prefs.setDouble(key, value);
}
Future<void> setBool(String key, bool value) async {
await _prefs.setBool(key, value);
}
Future<void> setStringList(String key, List<String> value) async {
await _prefs.setStringList(key, value);
}
// Reading methods
String getString(String key, {String defaultValue = ''}) {
return _prefs.getString(key) ?? defaultValue;
}
int getInt(String key, {int defaultValue = 0}) {
return _prefs.getInt(key) ?? defaultValue;
}
double getDouble(String key, {double defaultValue = 0.0}) {
return _prefs.getDouble(key) ?? defaultValue;
}
bool getBool(String key, {bool defaultValue = false}) {
return _prefs.getBool(key) ?? defaultValue;
}
List<String> getStringList(String key, {List<String> defaultValue = const []}) {
return _prefs.getStringList(key) ?? defaultValue;
}
// Deletion methods
Future<void> remove(String key) async {
await _prefs.remove(key);
}
Future<void> clear() async {
await _prefs.clear();
}
// Check if key exists
bool containsKey(String key) {
return _prefs.containsKey(key);
}
}
Using the encapsulated class:
// Initialization (usually at app startup)
await PrefsService().init();
// Store data
await PrefsService().setString('username', 'jane_smith');
await PrefsService().setInt('score', 100);
// Read data
String username = PrefsService().getString('username');
int score = PrefsService().getInt('score');
4. shared_preferences Use Cases and Limitations
Suitable scenarios:
- Storing user preferences (e.g., theme mode, language selection)
- Saving simple user states (e.g., login status flag)
- Storing small amounts of configuration information
Limitations:
- Not suitable for storing large amounts of data
- Does not support complex data structures
- Data is stored in plain text files, not suitable for sensitive information
- No query functionality, can only get values by key
III. Database Storage: sqflite Basics
For scenarios requiring storage of large amounts of structured data, sqflite is a better choice. sqflite is a wrapper for SQLite databases in Flutter, providing full SQL operation capabilities.
1. Installation and Configuration
Add dependencies to pubspec.yaml:
dependencies:
flutter:
sdk: flutter
sqflite: ^2.4.2 # SQLite database
path: ^1.9.1 # For handling file paths
Run flutter pub get to install dependencies.
2. Basic Database Operations
SQLite database operations mainly include: creating databases and tables, inserting data, querying data, updating data, and deleting data.
Creating Databases and Tables
import 'package:sqflite/sqflite.dart';
import 'package:path/path.dart';
class DatabaseHelper {
// Database name
static const _databaseName = "MyDatabase.db";
// Database version
static const _databaseVersion = 1;
// Table name
static const table = 'notes';
// Column names
static const columnId = '_id';
static const columnTitle = 'title';
static const columnContent = 'content';
static const columnCreatedAt = 'created_at';
// Singleton pattern
DatabaseHelper._privateConstructor();
static final DatabaseHelper instance = DatabaseHelper._privateConstructor();
// Database instance
static Database? _database;
Future<Database> get database async {
if (_database != null) return _database!;
_database = await _initDatabase();
return _database!;
}
// Initialize database
_initDatabase() async {
String path = join(await getDatabasesPath(), _databaseName);
return await openDatabase(path,
version: _databaseVersion,
onCreate: _onCreate);
}
// Create table
Future _onCreate(Database db, int version) async {
await db.execute('''
CREATE TABLE $table (
$columnId INTEGER PRIMARY KEY AUTOINCREMENT,
$columnTitle TEXT NOT NULL,
$columnContent TEXT,
$columnCreatedAt TEXT NOT NULL
)
''');
}
}
Inserting Data
// Insert data
Future<int> insertNote(Map<String, dynamic> note) async {
Database db = await DatabaseHelper.instance.database;
// Insert data and return ID of new record
return await db.insert(DatabaseHelper.table, note);
}
// Usage example
void addNote() async {
Map<String, dynamic> newNote = {
DatabaseHelper.columnTitle: 'First Note',
DatabaseHelper.columnContent: 'This is my first note',
DatabaseHelper.columnCreatedAt: DateTime.now().toIso8601String()
};
int id = await insertNote(newNote);
print('Inserted note with id: $id');
}
Querying Data
// Query all data
Future<List<Map<String, dynamic>>> queryAllNotes() async {
Database db = await DatabaseHelper.instance.database;
return await db.query(DatabaseHelper.table);
}
// Query by ID
Future<List<Map<String, dynamic>>> queryNote(int id) async {
Database db = await DatabaseHelper.instance.database;
return await db.query(DatabaseHelper.table,
where: '${DatabaseHelper.columnId} = ?',
whereArgs: [id]);
}
// Usage example
void getNotes() async {
List<Map<String, dynamic>> notes = await queryAllNotes();
print('All notes: $notes');
if (notes.isNotEmpty) {
List<Map<String, dynamic>> singleNote = await queryNote(notes[0][DatabaseHelper.columnId]);
print('First note: $singleNote');
}
}
Updating Data
// Update data
Future<int> updateNote(Map<String, dynamic> note) async {
Database db = await DatabaseHelper.instance.database;
int id = note[DatabaseHelper.columnId];
// Return number of affected rows
return await db.update(DatabaseHelper.table, note,
where: '${DatabaseHelper.columnId} = ?',
whereArgs: [id]);
}
// Usage example
void modifyNote(int noteId) async {
Map<String, dynamic> updatedNote = {
DatabaseHelper.columnId: noteId,
DatabaseHelper.columnTitle: 'Updated Note',
DatabaseHelper.columnContent: 'This note has been updated',
DatabaseHelper.columnCreatedAt: DateTime.now().toIso8601String()
};
int rowsAffected = await updateNote(updatedNote);
print('Updated $rowsAffected rows');
}
Deleting Data
// Delete data
Future<int> deleteNote(int id) async {
Database db = await DatabaseHelper.instance.database;
// Return number of affected rows
return await db.delete(DatabaseHelper.table,
where: '${DatabaseHelper.columnId} = ?',
whereArgs: [id]);
}
// Usage example
void removeNote(int noteId) async {
int rowsDeleted = await deleteNote(noteId);
print('Deleted $rowsDeleted rows');
}
3. Database Version Migration
When application upgrades require database structure modifications, database migration is necessary:
// Modify _initDatabase method in DatabaseHelper class
_initDatabase() async {
String path = join(await getDatabasesPath(), _databaseName);
return await openDatabase(path,
version: _databaseVersion,
onCreate: _onCreate,
onUpgrade: _onUpgrade, // Add upgrade callback
onDowngrade: onDatabaseDowngradeDelete // Delete database on downgrade
);
}
// Database upgrade
Future _onUpgrade(Database db, int oldVersion, int newVersion) async {
if (oldVersion < 2) {
// Version 2 adds a 'category' column
await db.execute('''
ALTER TABLE ${DatabaseHelper.table}
ADD COLUMN category TEXT
''');
}
if (oldVersion < 3) {
// Version 3 adds a 'priority' column
await db.execute('''
ALTER TABLE ${DatabaseHelper.table}
ADD COLUMN priority INTEGER DEFAULT 0
''');
}
}
4. Model Classes and Database Operation Encapsulation
To better manage data, you typically create model classes and encapsulate database operations:
// Note model class
class Note {
final int? id;
final String title;
final String? content;
final String createdAt;
final String? category;
final int priority;
Note({
this.id,
required this.title,
this.content,
required this.createdAt,
this.category,
this.priority = 0,
});
// Convert from Map to Note object
factory Note.fromMap(Map<String, dynamic> map) {
return Note(
id: map[DatabaseHelper.columnId],
title: map[DatabaseHelper.columnTitle],
content: map[DatabaseHelper.columnContent],
createdAt: map[DatabaseHelper.columnCreatedAt],
category: map['category'],
priority: map['priority'] ?? 0,
);
}
// Convert to Map
Map<String, dynamic> toMap() {
return {
DatabaseHelper.columnId: id,
DatabaseHelper.columnTitle: title,
DatabaseHelper.columnContent: content,
DatabaseHelper.columnCreatedAt: createdAt,
'category': category,
'priority': priority,
};
}
}
// Encapsulate database operations
class NoteService {
// Insert note
Future<int> insertNote(Note note) async {
Database db = await DatabaseHelper.instance.database;
return await db.insert(DatabaseHelper.table, note.toMap());
}
// Get all notes
Future<List<Note>> getAllNotes() async {
Database db = await DatabaseHelper.instance.database;
List<Map<String, dynamic>> maps = await db.query(DatabaseHelper.table);
return List.generate(maps.length, (i) => Note.fromMap(maps[i]));
}
// Get note by ID
Future<Note?> getNoteById(int id) async {
Database db = await DatabaseHelper.instance.database;
List<Map<String, dynamic>> maps = await db.query(
DatabaseHelper.table,
where: '${DatabaseHelper.columnId} = ?',
whereArgs: [id],
);
if (maps.isNotEmpty) {
return Note.fromMap(maps.first);
}
return null;
}
// Update note
Future<int> updateNote(Note note) async {
Database db = await DatabaseHelper.instance.database;
return await db.update(
DatabaseHelper.table,
note.toMap(),
where: '${DatabaseHelper.columnId} = ?',
whereArgs: [note.id],
);
}
// Delete note
Future<int> deleteNote(int id) async {
Database db = await DatabaseHelper.instance.database;
return await db.delete(
DatabaseHelper.table,
where: '${DatabaseHelper.columnId} = ?',
whereArgs: [id],
);
}
// Query notes by category
Future<List<Note>> getNotesByCategory(String category) async {
Database db = await DatabaseHelper.instance.database;
List<Map<String, dynamic>> maps = await db.query(
DatabaseHelper.table,
where: 'category = ?',
whereArgs: [category],
);
return List.generate(maps.length, (i) => Note.fromMap(maps[i]));
}
// Query sorted by priority
Future<List<Note>> getNotesSortedByPriority() async {
Database db = await DatabaseHelper.instance.database;
List<Map<String, dynamic>> maps = await db.query(
DatabaseHelper.table,
orderBy: 'priority DESC',
);
return List.generate(maps.length, (i) => Note.fromMap(maps[i]));
}
}
IV. Example: Saving User Login State
The following implements a function to save user login state using shared_preferences to store login information:
import 'package:shared_preferences/shared_preferences.dart';
class AuthService {
static const _tokenKey = 'auth_token';
static const _userIdKey = 'user_id';
static const _usernameKey = 'username';
// Save login state
Future<void> saveLoginState({
required String token,
required String userId,
required String username,
}) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_tokenKey, token);
await prefs.setString(_userIdKey, userId);
await prefs.setString(_usernameKey, username);
}
// Get current logged-in user information
Future<Map<String, String>?> getCurrentUser() async {
final prefs = await SharedPreferences.getInstance();
final token = prefs.getString(_tokenKey);
final userId = prefs.getString(_userIdKey);
final username = prefs.getString(_usernameKey);
if (token != null && userId != null && username != null) {
return {
'token': token,
'userId': userId,
'username': username,
};
}
return null;
}
// Check if user is logged in
Future<bool> isLoggedIn() async {
final user = await getCurrentUser();
return user != null;
}
// Logout, clear login state
Future<void> logout() async {
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_tokenKey);
await prefs.remove(_userIdKey);
await prefs.remove(_usernameKey);
}
// Get authentication token
Future<String?> getAuthToken() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getString(_tokenKey);
}
}
Checking login state when the application starts:
void main() async {
WidgetsFlutterBinding.ensureInitialized();
// Check login state
AuthService authService = AuthService();
bool isLoggedIn = await authService.isLoggedIn();
runApp(MyApp(isLoggedIn: isLoggedIn));
}
class MyApp extends StatelessWidget {
final bool isLoggedIn;
const MyApp({super.key, required this.isLoggedIn});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Local Storage Demo',
home: isLoggedIn ? const HomeScreen() : const LoginScreen(),
);
}
}
Login page implementation:
class LoginScreen extends StatefulWidget {
const LoginScreen({super.key});
@override
State<LoginScreen> createState() => _LoginScreenState();
}
class _LoginScreenState extends State<LoginScreen> {
final _usernameController = TextEditingController();
final _passwordController = TextEditingController();
final _authService = AuthService();
bool _isLoading = false;
Future<void> _login(BuildContext context) async {
setState(() {
_isLoading = true;
});
// Simulate API login request
await Future.delayed(const Duration(seconds: 2));
// In a real application, you should validate username and password
if (_usernameController.text.isNotEmpty && _passwordController.text.isNotEmpty) {
// Save login state
await _authService.saveLoginState(
token: 'fake_auth_token_123456',
userId: 'user_123',
username: _usernameController.text,
);
// Navigate to home page
if (mounted) {
Navigator.pushReplacement(
context,
MaterialPageRoute(builder: (context) => const HomeScreen()),
);
}
} else {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Please enter username and password')),
);
}
}
setState(() {
_isLoading = false;
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Login')),
body: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
children: [
TextField(
controller: _usernameController,
decoration: const InputDecoration(labelText: 'Username'),
),
TextField(
controller: _passwordController,
decoration: const InputDecoration(labelText: 'Password'),
obscureText: true,
),
const SizedBox(height: 20),
_isLoading
? const CircularProgressIndicator()
: ElevatedButton(
onPressed: () => _login(context),
child: const Text('Login'),
),
],
),
),
);
}
}
Home page implementation:
class HomeScreen extends StatefulWidget {
const HomeScreen({super.key});
@override
State<HomeScreen> createState() => _HomeScreenState();
}
class _HomeScreenState extends State<HomeScreen> {
final _authService = AuthService();
String? _username;
@override
void initState() {
super.initState();
_loadUserInfo();
}
Future<void> _loadUserInfo() async {
final user = await _authService.getCurrentUser();
setState(() {
_username = user?['username'];
});
}
Future<void> _logout(BuildContext context) async {
await _authService.logout();
if (mounted) {
Navigator.pushReplacement(
context,
MaterialPageRoute(builder: (context) => const LoginScreen()),
);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Home'),
actions: [
IconButton(
icon: const Icon(Icons.logout),
onPressed: () => _logout(context),
),
],
),
body: Center(
child: _username != null
? Text('Welcome, $_username! You are logged in.')
: const Text('Loading user info...'),
),
);
}
}
V. Example: Offline Data Caching
Using sqflite to implement local caching of network data for offline viewing functionality:
// News model class
class Article {
final String id;
final String title;
final String? description;
final String url;
final String? urlToImage;
final String publishedAt;
final String? content;
Article({
required this.id,
required this.title,
this.description,
required this.url,
this.urlToImage,
required this.publishedAt,
this.content,
});
// Convert from Map
factory Article.fromMap(Map<String, dynamic> map) {
return Article(
id: map['id'],
title: map['title'],
description: map['description'],
url: map['url'],
urlToImage: map['urlToImage'],
publishedAt: map['publishedAt'],
content: map['content'],
);
}
// Convert to Map
Map<String, dynamic> toMap() {
return {
'id': id,
'title': title,
'description': description,
'url': url,
'urlToImage': urlToImage,
'publishedAt': publishedAt,
'content': content,
'cachedAt': DateTime.now().toIso8601String(), // Cache time
};
}
}
// News database helper class
class NewsDatabaseHelper {
static const _databaseName = "NewsDatabase.db";
static const _databaseVersion = 1;
static const table = 'articles';
NewsDatabaseHelper._privateConstructor();
static final NewsDatabaseHelper instance = NewsDatabaseHelper._privateConstructor();
static Database? _database;
Future<Database> get database async {
if (_database != null) return _database!;
_database = await _initDatabase();
return _database!;
}
_initDatabase() async {
String path = join(await getDatabasesPath(), _databaseName);
return await openDatabase(path,
version: _databaseVersion,
onCreate: _onCreate);
}
Future _onCreate(Database db, int version) async {
await db.execute('''
CREATE TABLE $table (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
description TEXT,
url TEXT NOT NULL,
urlToImage TEXT,
publishedAt TEXT NOT NULL,
content TEXT,
cachedAt TEXT NOT NULL
)
''');
}
// Batch insert or update articles
Future<void> batchInsertOrUpdateArticles(List<Article> articles) async {
Database db = await instance.database;
Batch batch = db.batch();
for (var article in articles) {
batch.insert(
table,
article.toMap(),
conflictAlgorithm: ConflictAlgorithm.replace, // Replace if exists
);
}
await batch.commit();
}
// Get all cached articles
Future<List<Article>> getCachedArticles() async {
Database db = await instance.database;
List<Map<String, dynamic>> maps = await db.query(
table,
orderBy: 'publishedAt DESC',
);
return List.generate(maps.length, (i) => Article.fromMap(maps[i]));
}
// Get single article
Future<Article?> getArticleById(String id) async {
Database db = await instance.database;
List<Map<String, dynamic>> maps = await db.query(
table,
where: 'id = ?',
whereArgs: [id],
);
if (maps.isNotEmpty) {
return Article.fromMap(maps.first);
}
return null;
}
// Clear expired cache (e.g., older than 7 days)
Future<void> clearExpiredCache() async {
Database db = await instance.database;
final sevenDaysAgo = DateTime.now().subtract(const Duration(days: 7)).toIso8601String();
await db.delete(
table,
where: 'cachedAt < ?',
whereArgs: [sevenDaysAgo],
);
}
// Clear all cache
Future<void> clearAllCache() async {
Database db = await instance.database;
await db.delete(table);
}
}
News repository class, integrating network requests and local caching:
class NewsRepository {
final Dio _dio = Dio();
final NewsDatabaseHelper _dbHelper = NewsDatabaseHelper.instance;
final String _apiKey = 'your_news_api_key';
// Get top headlines, prioritize network, use cache on failure
Future<List<Article>> getTopHeadlines({bool forceRefresh = false}) async {
try {
// If not force refreshing, check cache first
if (!forceRefresh) {
final cachedArticles = await _dbHelper.getCachedArticles();
if (cachedArticles.isNotEmpty) {
return cachedArticles;
}
}
// Network request
Response response = await _dio.get(
'https://newsapi.org/v2/top-headlines',
queryParameters: {
'country': 'us',
'apiKey': _apiKey,
},
);
// Parse data
List<Article> articles = (response.data['articles'] as List)
.map((item) => Article(
id: item['url'], // Use url as unique identifier
title: item['title'],
description: item['description'],
url: item['url'],
urlToImage: item['urlToImage'],
publishedAt: item['publishedAt'],
content: item['content'],
))
.toList();
// Cache to local database
await _dbHelper.batchInsertOrUpdateArticles(articles);
// Clear expired cache
await _dbHelper.clearExpiredCache();
return articles;
} catch (e) {
// Network request failed, try to return cache
final cachedArticles = await _dbHelper.getCachedArticles();
if (cachedArticles.isNotEmpty) {
return cachedArticles;
}
// No cache available, throw error
throw Exception('Failed to fetch news and no cache available');
}
}
}
VI. Introduction to Other Storage Solutions
1. flutter_secure_storage
Used for storing sensitive information such as tokens and passwords, data is encrypted:
dependencies:
flutter_secure_storage: ^9.2.4
Usage example:
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
final storage = FlutterSecureStorage();
// Store sensitive data
await storage.write(key: 'auth_token', value: 'sensitive_token_123');
// Read data
String? token = await storage.read(key: 'auth_token');
// Delete data
await storage.delete(key: 'auth_token');
2. Hive
Hive is a high-performance, lightweight NoSQL database, pure Dart implementation, fast and easy to use:
dependencies:
hive: ^2.2.3
hive_flutter: ^1.1.0
Usage example:
// Initialization
await Hive.initFlutter();
Hive.registerAdapter(NoteAdapter()); // Register adapter
await Hive.openBox<Note>('notes');
// Get box
var box = Hive.box<Note>('notes');
// Add data
var note = Note(id: 1, title: 'Hive Demo', content: 'Hello Hive');
await box.put(note.id, note);
// Get data
Note? savedNote = box.get(1);
// Query all data
List<Note> allNotes = box.values.toList();
// Update data
note.title = 'Updated Title';
await box.put(note.id, note);
// Delete data
await box.delete(1);
VII. Local Storage Best Practices
- Choose the right storage solution:
- Use shared_preferences for simple key-value data
- Use flutter_secure_storage for sensitive information
- Use sqflite or hive for structured data
- Data layering management:
- Encapsulate storage operations, separate from business logic
- Use Repository Pattern to integrate network and local storage
- Performance optimization:
- Batch database operations to reduce IO times
- Clean up expired cache in time to avoid excessive storage space
- Perform database operations on background threads to avoid blocking UI
- Error handling:
- Add exception catching for all storage operations
- Provide a fallback strategy using cached data when network requests fail
- Data migration:
- Plan database versions properly and prepare upgrade migration plans
- Consider data backup and recovery mechanisms for major updates
- Security considerations:
- Sensitive data must be stored encrypted
- Avoid storing unnecessary user privacy data
Top comments (0)