refactor: merge profile cache into unified cache repository

This commit is contained in:
qzl
2026-03-20 15:29:06 +08:00
parent 1cea877bf1
commit a99973fb96
6 changed files with 202 additions and 85 deletions
+10 -1
View File
@@ -25,6 +25,7 @@ import '../../features/friends/data/friends_api.dart';
import '../../features/messages/data/inbox_api.dart'; import '../../features/messages/data/inbox_api.dart';
import '../../features/settings/data/settings_api.dart'; import '../../features/settings/data/settings_api.dart';
import '../../features/settings/data/services/settings_user_cache.dart'; import '../../features/settings/data/services/settings_user_cache.dart';
import '../../features/settings/data/services/user_profile_cache_repository.dart';
import '../../features/users/data/users_api.dart'; import '../../features/users/data/users_api.dart';
import '../../features/todo/data/todo_api.dart'; import '../../features/todo/data/todo_api.dart';
@@ -76,6 +77,12 @@ Future<void> configureDependencies() async {
final usersApi = UsersApi(apiClient); final usersApi = UsersApi(apiClient);
sl.registerSingleton<UsersApi>(usersApi); sl.registerSingleton<UsersApi>(usersApi);
final userProfileCacheRepository = UserProfileCacheRepository(
store: hybridCacheStore,
remoteLoader: usersApi.getMe,
);
sl.registerSingleton<UserProfileCacheRepository>(userProfileCacheRepository);
final calendarApi = CalendarApi(apiClient); final calendarApi = CalendarApi(apiClient);
sl.registerSingleton<CalendarApi>(calendarApi); sl.registerSingleton<CalendarApi>(calendarApi);
@@ -100,7 +107,9 @@ Future<void> configureDependencies() async {
final settingsApi = SettingsApi(apiClient); final settingsApi = SettingsApi(apiClient);
sl.registerSingleton<SettingsApi>(settingsApi); sl.registerSingleton<SettingsApi>(settingsApi);
sl.registerSingleton<SettingsUserCache>(SettingsUserCache()); sl.registerSingleton<SettingsUserCache>(
SettingsUserCache(userProfileCacheRepository),
);
final inboxApi = InboxApi(apiClient); final inboxApi = InboxApi(apiClient);
sl.registerSingleton<InboxApi>(inboxApi); sl.registerSingleton<InboxApi>(inboxApi);
@@ -1,49 +1,30 @@
import 'dart:async';
import '../../../users/data/models/user_response.dart'; import '../../../users/data/models/user_response.dart';
import 'user_profile_cache_repository.dart';
class SettingsUserCache { class SettingsUserCache {
final UserProfileCacheRepository _repository;
SettingsUserCache(this._repository);
UserResponse? _cachedUser; UserResponse? _cachedUser;
Future<UserResponse>? _inflight;
int _generation = 0;
UserResponse? get cachedUser => _cachedUser; UserResponse? get cachedUser => _cachedUser;
Future<UserResponse> getOrLoad(Future<UserResponse> Function() loader) { Future<UserResponse> getProfile({bool forceRefresh = false}) async {
final cached = _cachedUser; final user = await _repository.getProfile(forceRefresh: forceRefresh);
if (cached != null) { _cachedUser = user;
return Future<UserResponse>.value(cached); return user;
}
final inflight = _inflight;
if (inflight != null) {
return inflight;
}
final generation = _generation;
late final Future<UserResponse> request;
request = loader()
.then((user) {
if (generation == _generation) {
_cachedUser = user;
}
return user;
})
.whenComplete(() {
if (identical(_inflight, request)) {
_inflight = null;
}
});
_inflight = request;
return request;
} }
void set(UserResponse user) { void set(UserResponse user) {
_cachedUser = user; _cachedUser = user;
unawaited(_repository.setCached(user));
} }
void invalidate() { void invalidate() {
_generation += 1;
_cachedUser = null; _cachedUser = null;
_inflight = null; unawaited(_repository.invalidate());
} }
} }
@@ -0,0 +1,83 @@
import 'dart:async';
import '../../../../core/cache/cache_entry.dart';
import '../../../../core/cache/cache_policy.dart';
import '../../../../core/cache/hybrid_cache_store.dart';
import '../../../users/data/models/user_response.dart';
class UserProfileCacheRepository {
static const String cacheKey = 'settings:user_profile';
final HybridCacheStore store;
final CachePolicy policy;
final DateTime Function() now;
final Future<UserResponse> Function() remoteLoader;
Future<void>? _refreshInFlight;
UserProfileCacheRepository({
required this.store,
required this.remoteLoader,
CachePolicy? policy,
DateTime Function()? now,
}) : policy =
policy ??
const CachePolicy(
softTtl: Duration(minutes: 2),
hardTtl: Duration(minutes: 30),
minRefreshInterval: Duration(minutes: 1),
),
now = now ?? DateTime.now;
Future<UserResponse> getProfile({bool forceRefresh = false}) async {
if (forceRefresh) {
return _refreshAndRead();
}
final cached = await store.read<CacheEntry<UserResponse>>(cacheKey);
if (cached == null) {
return _refreshAndRead();
}
final decision = policy.evaluate(now: now(), fetchedAt: cached.fetchedAt);
if (decision.shouldRefreshInBackground) {
_refreshInBackground();
}
if (decision.mustBlockForNetwork || !decision.canUseCached) {
return _refreshAndRead();
}
return cached.value;
}
Future<void> setCached(UserResponse user) {
return store.write<CacheEntry<UserResponse>>(
cacheKey,
CacheEntry<UserResponse>(value: user, fetchedAt: now()),
);
}
Future<void> invalidate() => store.remove(cacheKey);
void _refreshInBackground() {
final running = _refreshInFlight;
if (running != null) {
return;
}
final task = _refreshAndWrite().whenComplete(() {
_refreshInFlight = null;
});
_refreshInFlight = task;
unawaited(task);
}
Future<UserResponse> _refreshAndRead() async {
await _refreshAndWrite();
final cached = await store.read<CacheEntry<UserResponse>>(cacheKey);
return cached!.value;
}
Future<void> _refreshAndWrite() async {
final remote = await remoteLoader();
await setCached(remote);
}
}
@@ -19,7 +19,6 @@ import 'package:social_app/features/friends/data/friends_api.dart';
import 'package:social_app/features/settings/data/settings_api.dart'; import 'package:social_app/features/settings/data/settings_api.dart';
import 'package:social_app/features/settings/data/services/settings_user_cache.dart'; import 'package:social_app/features/settings/data/services/settings_user_cache.dart';
import 'package:social_app/features/users/data/models/user_response.dart'; import 'package:social_app/features/users/data/models/user_response.dart';
import 'package:social_app/features/users/data/users_api.dart';
import 'package:social_app/features/home/ui/navigation/home_return_policy.dart'; import 'package:social_app/features/home/ui/navigation/home_return_policy.dart';
import '../widgets/settings_page_scaffold.dart'; import '../widgets/settings_page_scaffold.dart';
@@ -34,7 +33,6 @@ class SettingsScreen extends StatefulWidget {
} }
class _SettingsScreenState extends State<SettingsScreen> { class _SettingsScreenState extends State<SettingsScreen> {
final UsersApi _usersApi = sl<UsersApi>();
final FriendsApi _friendsApi = sl<FriendsApi>(); final FriendsApi _friendsApi = sl<FriendsApi>();
final SettingsUserCache _userCache = sl<SettingsUserCache>(); final SettingsUserCache _userCache = sl<SettingsUserCache>();
@@ -56,7 +54,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
Future<void> _loadData() async { Future<void> _loadData() async {
try { try {
final user = await _userCache.getOrLoad(_usersApi.getMe); final user = await _userCache.getProfile();
if (mounted) { if (mounted) {
setState(() { setState(() {
_user = user; _user = user;
@@ -1,70 +1,69 @@
import 'dart:async';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:social_app/core/cache/cache_policy.dart';
import 'package:social_app/core/cache/hybrid_cache_store.dart';
import 'package:social_app/core/cache/memory_cache_store.dart';
import 'package:social_app/core/cache/persistent_cache_store.dart';
import 'package:social_app/features/settings/data/services/settings_user_cache.dart'; import 'package:social_app/features/settings/data/services/settings_user_cache.dart';
import 'package:social_app/features/settings/data/services/user_profile_cache_repository.dart';
import 'package:social_app/features/users/data/models/user_response.dart'; import 'package:social_app/features/users/data/models/user_response.dart';
void main() { void main() {
test('getOrLoad calls loader only once when cache exists', () async { test('getProfile caches latest user in memory field', () async {
final cache = SettingsUserCache();
var loadCalls = 0; var loadCalls = 0;
final repository = UserProfileCacheRepository(
store: HybridCacheStore(
memory: MemoryCacheStore(),
persistent: PersistentCacheStore(),
),
policy: const CachePolicy(
softTtl: Duration(minutes: 2),
hardTtl: Duration(minutes: 30),
minRefreshInterval: Duration(minutes: 1),
),
remoteLoader: () async {
loadCalls += 1;
return const UserResponse(id: 'u1', username: 'first');
},
);
final cache = SettingsUserCache(repository);
Future<UserResponse> loader() async { final first = await cache.getProfile();
loadCalls += 1; final second = await cache.getProfile();
return const UserResponse(id: 'u1', username: 'first');
}
final first = await cache.getOrLoad(loader);
final second = await cache.getOrLoad(loader);
expect(first.username, 'first'); expect(first.username, 'first');
expect(second.username, 'first'); expect(second.username, 'first');
expect(cache.cachedUser?.id, 'u1');
expect(loadCalls, 1); expect(loadCalls, 1);
}); });
test('invalidate forces next load', () async { test('invalidate clears memory cache', () {
final cache = SettingsUserCache(); final repository = UserProfileCacheRepository(
var loadCalls = 0; store: HybridCacheStore(
memory: MemoryCacheStore(),
persistent: PersistentCacheStore(),
),
remoteLoader: () async => const UserResponse(id: 'u1', username: 'first'),
);
final cache = SettingsUserCache(repository);
Future<UserResponse> loader() async { cache.set(const UserResponse(id: 'u1', username: 'first'));
loadCalls += 1;
return UserResponse(id: 'u$loadCalls', username: 'user$loadCalls');
}
final first = await cache.getOrLoad(loader);
cache.invalidate(); cache.invalidate();
final second = await cache.getOrLoad(loader);
expect(first.id, 'u1'); expect(cache.cachedUser, isNull);
expect(second.id, 'u2');
expect(loadCalls, 2);
}); });
test( test('set should update cached user immediately', () {
'invalidate blocks stale inflight response from repopulating cache', final repository = UserProfileCacheRepository(
() async { store: HybridCacheStore(
final cache = SettingsUserCache(); memory: MemoryCacheStore(),
final completer = Completer<UserResponse>(); persistent: PersistentCacheStore(),
var loadCalls = 0; ),
remoteLoader: () async => const UserResponse(id: 'u1', username: 'first'),
);
final cache = SettingsUserCache(repository);
Future<UserResponse> slowLoader() { cache.set(const UserResponse(id: 'u2', username: 'next'));
loadCalls += 1;
return completer.future;
}
final pending = cache.getOrLoad(slowLoader); expect(cache.cachedUser?.id, 'u2');
cache.invalidate(); });
completer.complete(const UserResponse(id: 'u1', username: 'stale'));
await pending;
final fresh = await cache.getOrLoad(() async {
loadCalls += 1;
return const UserResponse(id: 'u2', username: 'fresh');
});
expect(fresh.id, 'u2');
expect(cache.cachedUser?.id, 'u2');
expect(loadCalls, 2);
},
);
} }
@@ -0,0 +1,47 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:social_app/core/cache/cache_entry.dart';
import 'package:social_app/core/cache/cache_policy.dart';
import 'package:social_app/core/cache/hybrid_cache_store.dart';
import 'package:social_app/core/cache/memory_cache_store.dart';
import 'package:social_app/core/cache/persistent_cache_store.dart';
import 'package:social_app/features/settings/data/services/user_profile_cache_repository.dart';
import 'package:social_app/features/users/data/models/user_response.dart';
void main() {
test(
'repository should return persistent cache first then refresh in background',
() async {
final store = HybridCacheStore(
memory: MemoryCacheStore(),
persistent: PersistentCacheStore(),
);
const key = UserProfileCacheRepository.cacheKey;
final stale = CacheEntry<UserResponse>(
value: const UserResponse(id: 'u1', username: 'cached'),
fetchedAt: DateTime(2026, 3, 20, 11, 0),
);
await store.persistent.write<CacheEntry<UserResponse>>(key, stale);
var refreshCalls = 0;
final repository = UserProfileCacheRepository(
store: store,
now: () => DateTime(2026, 3, 20, 11, 5),
policy: const CachePolicy(
softTtl: Duration(minutes: 2),
hardTtl: Duration(minutes: 30),
minRefreshInterval: Duration(minutes: 1),
),
remoteLoader: () async {
refreshCalls += 1;
return const UserResponse(id: 'u1', username: 'remote');
},
);
final result = await repository.getProfile();
await Future<void>.delayed(const Duration(milliseconds: 10));
expect(result.username, 'cached');
expect(refreshCalls, 1);
},
);
}