From a99973fb96efd8ab1cecf19918983b7a917bf33c Mon Sep 17 00:00:00 2001 From: qzl Date: Fri, 20 Mar 2026 15:29:06 +0800 Subject: [PATCH] refactor: merge profile cache into unified cache repository --- apps/lib/core/di/injection.dart | 11 ++- .../data/services/settings_user_cache.dart | 45 +++------ .../user_profile_cache_repository.dart | 83 ++++++++++++++++ .../settings/ui/screens/settings_screen.dart | 4 +- .../services/settings_user_cache_test.dart | 97 +++++++++---------- .../user_profile_cache_repository_test.dart | 47 +++++++++ 6 files changed, 202 insertions(+), 85 deletions(-) create mode 100644 apps/lib/features/settings/data/services/user_profile_cache_repository.dart create mode 100644 apps/test/features/settings/data/services/user_profile_cache_repository_test.dart diff --git a/apps/lib/core/di/injection.dart b/apps/lib/core/di/injection.dart index a7557fc..597a752 100644 --- a/apps/lib/core/di/injection.dart +++ b/apps/lib/core/di/injection.dart @@ -25,6 +25,7 @@ import '../../features/friends/data/friends_api.dart'; import '../../features/messages/data/inbox_api.dart'; import '../../features/settings/data/settings_api.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/todo/data/todo_api.dart'; @@ -76,6 +77,12 @@ Future configureDependencies() async { final usersApi = UsersApi(apiClient); sl.registerSingleton(usersApi); + final userProfileCacheRepository = UserProfileCacheRepository( + store: hybridCacheStore, + remoteLoader: usersApi.getMe, + ); + sl.registerSingleton(userProfileCacheRepository); + final calendarApi = CalendarApi(apiClient); sl.registerSingleton(calendarApi); @@ -100,7 +107,9 @@ Future configureDependencies() async { final settingsApi = SettingsApi(apiClient); sl.registerSingleton(settingsApi); - sl.registerSingleton(SettingsUserCache()); + sl.registerSingleton( + SettingsUserCache(userProfileCacheRepository), + ); final inboxApi = InboxApi(apiClient); sl.registerSingleton(inboxApi); diff --git a/apps/lib/features/settings/data/services/settings_user_cache.dart b/apps/lib/features/settings/data/services/settings_user_cache.dart index 4cc1e90..6fbceb4 100644 --- a/apps/lib/features/settings/data/services/settings_user_cache.dart +++ b/apps/lib/features/settings/data/services/settings_user_cache.dart @@ -1,49 +1,30 @@ +import 'dart:async'; + import '../../../users/data/models/user_response.dart'; +import 'user_profile_cache_repository.dart'; class SettingsUserCache { + final UserProfileCacheRepository _repository; + + SettingsUserCache(this._repository); + UserResponse? _cachedUser; - Future? _inflight; - int _generation = 0; UserResponse? get cachedUser => _cachedUser; - Future getOrLoad(Future Function() loader) { - final cached = _cachedUser; - if (cached != null) { - return Future.value(cached); - } - - final inflight = _inflight; - if (inflight != null) { - return inflight; - } - - final generation = _generation; - late final Future request; - request = loader() - .then((user) { - if (generation == _generation) { - _cachedUser = user; - } - return user; - }) - .whenComplete(() { - if (identical(_inflight, request)) { - _inflight = null; - } - }); - - _inflight = request; - return request; + Future getProfile({bool forceRefresh = false}) async { + final user = await _repository.getProfile(forceRefresh: forceRefresh); + _cachedUser = user; + return user; } void set(UserResponse user) { _cachedUser = user; + unawaited(_repository.setCached(user)); } void invalidate() { - _generation += 1; _cachedUser = null; - _inflight = null; + unawaited(_repository.invalidate()); } } diff --git a/apps/lib/features/settings/data/services/user_profile_cache_repository.dart b/apps/lib/features/settings/data/services/user_profile_cache_repository.dart new file mode 100644 index 0000000..578c9e4 --- /dev/null +++ b/apps/lib/features/settings/data/services/user_profile_cache_repository.dart @@ -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 Function() remoteLoader; + + Future? _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 getProfile({bool forceRefresh = false}) async { + if (forceRefresh) { + return _refreshAndRead(); + } + + final cached = await store.read>(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 setCached(UserResponse user) { + return store.write>( + cacheKey, + CacheEntry(value: user, fetchedAt: now()), + ); + } + + Future invalidate() => store.remove(cacheKey); + + void _refreshInBackground() { + final running = _refreshInFlight; + if (running != null) { + return; + } + final task = _refreshAndWrite().whenComplete(() { + _refreshInFlight = null; + }); + _refreshInFlight = task; + unawaited(task); + } + + Future _refreshAndRead() async { + await _refreshAndWrite(); + final cached = await store.read>(cacheKey); + return cached!.value; + } + + Future _refreshAndWrite() async { + final remote = await remoteLoader(); + await setCached(remote); + } +} diff --git a/apps/lib/features/settings/ui/screens/settings_screen.dart b/apps/lib/features/settings/ui/screens/settings_screen.dart index febb69e..7f9274c 100644 --- a/apps/lib/features/settings/ui/screens/settings_screen.dart +++ b/apps/lib/features/settings/ui/screens/settings_screen.dart @@ -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/services/settings_user_cache.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 '../widgets/settings_page_scaffold.dart'; @@ -34,7 +33,6 @@ class SettingsScreen extends StatefulWidget { } class _SettingsScreenState extends State { - final UsersApi _usersApi = sl(); final FriendsApi _friendsApi = sl(); final SettingsUserCache _userCache = sl(); @@ -56,7 +54,7 @@ class _SettingsScreenState extends State { Future _loadData() async { try { - final user = await _userCache.getOrLoad(_usersApi.getMe); + final user = await _userCache.getProfile(); if (mounted) { setState(() { _user = user; diff --git a/apps/test/features/settings/data/services/settings_user_cache_test.dart b/apps/test/features/settings/data/services/settings_user_cache_test.dart index 3e37475..c9467af 100644 --- a/apps/test/features/settings/data/services/settings_user_cache_test.dart +++ b/apps/test/features/settings/data/services/settings_user_cache_test.dart @@ -1,70 +1,69 @@ -import 'dart:async'; - 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/user_profile_cache_repository.dart'; import 'package:social_app/features/users/data/models/user_response.dart'; void main() { - test('getOrLoad calls loader only once when cache exists', () async { - final cache = SettingsUserCache(); + test('getProfile caches latest user in memory field', () async { 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 loader() async { - loadCalls += 1; - return const UserResponse(id: 'u1', username: 'first'); - } - - final first = await cache.getOrLoad(loader); - final second = await cache.getOrLoad(loader); + final first = await cache.getProfile(); + final second = await cache.getProfile(); expect(first.username, 'first'); expect(second.username, 'first'); + expect(cache.cachedUser?.id, 'u1'); expect(loadCalls, 1); }); - test('invalidate forces next load', () async { - final cache = SettingsUserCache(); - var loadCalls = 0; + test('invalidate clears memory cache', () { + final repository = UserProfileCacheRepository( + store: HybridCacheStore( + memory: MemoryCacheStore(), + persistent: PersistentCacheStore(), + ), + remoteLoader: () async => const UserResponse(id: 'u1', username: 'first'), + ); + final cache = SettingsUserCache(repository); - Future loader() async { - loadCalls += 1; - return UserResponse(id: 'u$loadCalls', username: 'user$loadCalls'); - } - - final first = await cache.getOrLoad(loader); + cache.set(const UserResponse(id: 'u1', username: 'first')); cache.invalidate(); - final second = await cache.getOrLoad(loader); - expect(first.id, 'u1'); - expect(second.id, 'u2'); - expect(loadCalls, 2); + expect(cache.cachedUser, isNull); }); - test( - 'invalidate blocks stale inflight response from repopulating cache', - () async { - final cache = SettingsUserCache(); - final completer = Completer(); - var loadCalls = 0; + test('set should update cached user immediately', () { + final repository = UserProfileCacheRepository( + store: HybridCacheStore( + memory: MemoryCacheStore(), + persistent: PersistentCacheStore(), + ), + remoteLoader: () async => const UserResponse(id: 'u1', username: 'first'), + ); + final cache = SettingsUserCache(repository); - Future slowLoader() { - loadCalls += 1; - return completer.future; - } + cache.set(const UserResponse(id: 'u2', username: 'next')); - final pending = cache.getOrLoad(slowLoader); - 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); - }, - ); + expect(cache.cachedUser?.id, 'u2'); + }); } diff --git a/apps/test/features/settings/data/services/user_profile_cache_repository_test.dart b/apps/test/features/settings/data/services/user_profile_cache_repository_test.dart new file mode 100644 index 0000000..97a082e --- /dev/null +++ b/apps/test/features/settings/data/services/user_profile_cache_repository_test.dart @@ -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( + value: const UserResponse(id: 'u1', username: 'cached'), + fetchedAt: DateTime(2026, 3, 20, 11, 0), + ); + await store.persistent.write>(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.delayed(const Duration(milliseconds: 10)); + + expect(result.username, 'cached'); + expect(refreshCalls, 1); + }, + ); +}