From 84b68fbb4ae04907a8a152f3874cde03ec05a1f1 Mon Sep 17 00:00:00 2001 From: zhx Date: Sat, 16 May 2026 00:01:07 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E7=81=AB=E7=AE=AD=E5=A5=96?= =?UTF-8?q?=E5=8A=B1=E5=B1=95=E7=A4=BA=E5=92=8C=E7=B4=A0=E6=9D=90=E9=A2=84?= =?UTF-8?q?=E5=8A=A0=E8=BD=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/services/audio/rtc_manager.dart | 36 ++ .../repositories/room_repository.dart | 2 + .../repositories/sc_room_repository_imp.dart | 5 + .../rocket/room_rocket_asset_preloader.dart | 426 ++++++++++++++++++ .../room_rocket_reward_dialog_loader.dart | 25 +- test/room_rocket_asset_preloader_test.dart | 108 +++++ 6 files changed, 599 insertions(+), 3 deletions(-) create mode 100644 lib/ui_kit/widgets/room/rocket/room_rocket_asset_preloader.dart create mode 100644 test/room_rocket_asset_preloader_test.dart diff --git a/lib/services/audio/rtc_manager.dart b/lib/services/audio/rtc_manager.dart index 11c90f0..ec6ee84 100644 --- a/lib/services/audio/rtc_manager.dart +++ b/lib/services/audio/rtc_manager.dart @@ -56,6 +56,7 @@ import '../../shared/business_logic/models/res/sc_room_theme_list_res.dart'; import '../../shared/business_logic/models/res/sc_vip_res.dart'; import '../../shared/tools/sc_room_profile_cache.dart'; import '../../ui_kit/components/sc_float_ichart.dart'; +import '../../ui_kit/widgets/room/rocket/room_rocket_asset_preloader.dart'; import '../../ui_kit/widgets/room/rocket/room_rocket_pag_effect_overlay.dart'; import '../../ui_kit/widgets/room/rocket/room_rocket_api_mapper.dart'; import '../../ui_kit/widgets/room/rocket/room_rocket_reward_dialog_loader.dart'; @@ -2765,6 +2766,7 @@ class RealTimeCommunicationManager extends ChangeNotifier { .then((res) { roomRocketStatus = res; notifyListeners(); + _scheduleRoomRocketAssetPreload(roomId, res, afterRoomEntry: true); }) .catchError((e) {}); _loadRoomRocketRewardPopups(roomId); @@ -2825,6 +2827,10 @@ class RealTimeCommunicationManager extends ChangeNotifier { roomRocketStatus = res; notifyListeners(); _publishRoomRocketLaunchNoticeIfNeeded(previousStatus, res); + _scheduleRoomRocketAssetPreload( + (res.roomId ?? currenRoom?.roomProfile?.roomProfile?.id ?? "").trim(), + res, + ); } ///刷新房间火箭信息 @@ -2843,6 +2849,35 @@ class RealTimeCommunicationManager extends ChangeNotifier { roomRocketStatus = res; notifyListeners(); _publishRoomRocketLaunchNoticeIfNeeded(previousStatus, res); + _scheduleRoomRocketAssetPreload(resolvedRoomId, res); + } + + void _scheduleRoomRocketAssetPreload( + String roomId, + SCRoomRocketStatusRes status, { + bool afterRoomEntry = false, + }) { + final resolvedRoomId = roomId.trim(); + if (resolvedRoomId.isEmpty) { + return; + } + if (afterRoomEntry) { + RoomRocketAssetPreloader.preloadAfterRoomEntry( + roomId: resolvedRoomId, + status: status, + ); + } else { + RoomRocketAssetPreloader.preloadForStatus( + roomId: resolvedRoomId, + status: status, + ); + } + if (RoomRocketAssetPreloader.shouldForceCurrentLaunchPreload(status)) { + RoomRocketAssetPreloader.forcePreloadCurrentLaunchAssets( + roomId: resolvedRoomId, + status: status, + ); + } } void scheduleRoomRocketStatusRefreshForGift({String? roomId}) { @@ -3646,6 +3681,7 @@ class RealTimeCommunicationManager extends ChangeNotifier { _roomRocketGiftRefreshTimer = null; _cancelRoomRocketPostLaunchStatusRefresh(); _lastRoomRocketLaunchNoticeKey = null; + RoomRocketAssetPreloader.cancelCurrentRoom(); _stopRoomRedPacketPresenceHeartbeat(); _previewRoomSeatCount = null; _finishRoomStartupSeatLoading(executePendingAction: false); diff --git a/lib/shared/business_logic/repositories/room_repository.dart b/lib/shared/business_logic/repositories/room_repository.dart index 101eb22..35b077c 100644 --- a/lib/shared/business_logic/repositories/room_repository.dart +++ b/lib/shared/business_logic/repositories/room_repository.dart @@ -231,6 +231,8 @@ abstract class SocialChatRoomRepository { String roomId, { int cursor = 1, int limit = 10, + String? launchNo, + bool roomScope = false, }); ///查询火箭规则说明 diff --git a/lib/shared/data_sources/sources/repositories/sc_room_repository_imp.dart b/lib/shared/data_sources/sources/repositories/sc_room_repository_imp.dart index 6ba11a7..3ec6d09 100644 --- a/lib/shared/data_sources/sources/repositories/sc_room_repository_imp.dart +++ b/lib/shared/data_sources/sources/repositories/sc_room_repository_imp.dart @@ -1133,11 +1133,16 @@ class SCChatRoomRepository implements SocialChatRoomRepository { String roomId, { int cursor = 1, int limit = 10, + String? launchNo, + bool roomScope = false, }) async { + final resolvedLaunchNo = launchNo?.trim() ?? ''; final queryParams = { if (roomId.trim().isNotEmpty) "roomId": roomId, "cursor": cursor, "limit": limit, + if (resolvedLaunchNo.isNotEmpty) "launchNo": resolvedLaunchNo, + if (roomScope || resolvedLaunchNo.isNotEmpty) "scope": "room", }; const path = "/go/app/voice-room/rocket/reward-records"; _rocketApiDebug('request GET $path', query: queryParams); diff --git a/lib/ui_kit/widgets/room/rocket/room_rocket_asset_preloader.dart b/lib/ui_kit/widgets/room/rocket/room_rocket_asset_preloader.dart new file mode 100644 index 0000000..94a60d7 --- /dev/null +++ b/lib/ui_kit/widgets/room/rocket/room_rocket_asset_preloader.dart @@ -0,0 +1,426 @@ +import 'dart:async'; +import 'dart:collection'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_cache_manager/flutter_cache_manager.dart'; +import 'package:yumi/shared/business_logic/models/res/sc_room_rocket_api_res.dart'; +import 'package:yumi/shared/business_logic/models/res/sc_room_rocket_status_res.dart'; +import 'package:yumi/shared/data_sources/sources/local/file_cache_manager.dart'; +import 'package:yumi/shared/tools/sc_network_image_utils.dart'; +import 'package:yumi/ui_kit/widgets/room/rocket/room_rocket_pag_effect_overlay.dart'; + +const int _roomRocketAssetPreloadMaxConcurrent = 2; +const Duration _roomRocketAssetPreloadDelay = Duration(milliseconds: 350); +const Duration _roomRocketAssetPreloadTimeout = Duration(seconds: 12); +const double _roomRocketAssetPreloadFallbackThreshold = 95; + +enum _RoomRocketPreloadKind { image, pag } + +class _RoomRocketPreloadAsset { + const _RoomRocketPreloadAsset({ + required this.resource, + required this.kind, + required this.roomId, + required this.generation, + }); + + final String resource; + final _RoomRocketPreloadKind kind; + final String roomId; + final int generation; + + String get key { + final uri = _networkUri(resource); + return '${kind.name}:${uri?.toString() ?? resource.trim()}'; + } +} + +class RoomRocketAssetPreloader { + const RoomRocketAssetPreloader._(); + + static final Queue<_RoomRocketPreloadAsset> _queue = + Queue<_RoomRocketPreloadAsset>(); + static final Set _scheduledKeys = {}; + static int _generation = 0; + static int _running = 0; + static String _activeRoomId = ''; + + static void preloadAfterRoomEntry({ + required String roomId, + required SCRoomRocketStatusRes status, + }) { + final normalizedRoomId = roomId.trim(); + if (normalizedRoomId.isEmpty) { + return; + } + final generation = _syncRoom(normalizedRoomId); + WidgetsBinding.instance.addPostFrameCallback((_) { + unawaited( + Future.delayed(_roomRocketAssetPreloadDelay, () { + if (!_isCurrentRoom(normalizedRoomId, generation)) { + return; + } + _enqueueAssets( + _assetsForStatus( + status, + roomId: normalizedRoomId, + generation: generation, + includeNextLevel: true, + includeCurrentRewardCovers: true, + ), + ); + }), + ); + }); + } + + static void preloadForStatus({ + required String roomId, + required SCRoomRocketStatusRes status, + }) { + final normalizedRoomId = roomId.trim(); + if (normalizedRoomId.isEmpty) { + return; + } + final generation = _syncRoom(normalizedRoomId); + _enqueueAssets( + _assetsForStatus( + status, + roomId: normalizedRoomId, + generation: generation, + includeNextLevel: true, + includeCurrentRewardCovers: true, + ), + ); + } + + static void forcePreloadCurrentLaunchAssets({ + required String roomId, + required SCRoomRocketStatusRes status, + }) { + final normalizedRoomId = roomId.trim(); + if (normalizedRoomId.isEmpty) { + return; + } + final generation = _syncRoom(normalizedRoomId); + _enqueueAssets( + _assetsForStatus( + status, + roomId: normalizedRoomId, + generation: generation, + includeNextLevel: false, + includeCurrentRewardCovers: true, + onlyCurrentLaunchAssets: true, + ), + priority: true, + forceRetry: true, + ); + } + + static void cancelCurrentRoom() { + _generation += 1; + _activeRoomId = ''; + _queue.clear(); + _scheduledKeys.clear(); + } + + static bool shouldForceCurrentLaunchPreload(SCRoomRocketStatusRes status) { + return _statusPercent(status) >= _forceThreshold(status); + } + + static int _syncRoom(String roomId) { + if (_activeRoomId != roomId) { + _activeRoomId = roomId; + _generation += 1; + _queue.clear(); + _scheduledKeys.clear(); + } + return _generation; + } + + static bool _isCurrentRoom(String roomId, int generation) { + return _activeRoomId == roomId && + _generation == generation && + _isAppResumed(); + } + + static bool _isAppResumed() { + final state = WidgetsBinding.instance.lifecycleState; + return state == null || state == AppLifecycleState.resumed; + } + + static void _enqueueAssets( + List<_RoomRocketPreloadAsset> assets, { + bool priority = false, + bool forceRetry = false, + }) { + if (assets.isEmpty) { + return; + } + final normalizedAssets = priority ? assets.reversed : assets; + for (final asset in normalizedAssets) { + if (_networkUri(asset.resource) == null) { + continue; + } + if (forceRetry) { + _queue.removeWhere((item) => item.key == asset.key); + _scheduledKeys.remove(asset.key); + } + if (!_scheduledKeys.add(asset.key)) { + continue; + } + if (priority) { + _queue.addFirst(asset); + } else { + _queue.addLast(asset); + } + } + _pump(); + } + + static void _pump() { + if (!_isAppResumed()) { + return; + } + while (_running < _roomRocketAssetPreloadMaxConcurrent && + _queue.isNotEmpty) { + final asset = _queue.removeFirst(); + if (!_isCurrentRoom(asset.roomId, asset.generation)) { + _scheduledKeys.remove(asset.key); + continue; + } + _running += 1; + unawaited( + _preloadAsset(asset) + .then((ok) { + if (!ok) { + _scheduledKeys.remove(asset.key); + } + }) + .whenComplete(() { + _running = (_running - 1).clamp(0, 999).toInt(); + _pump(); + }), + ); + } + } + + static Future _preloadAsset(_RoomRocketPreloadAsset asset) async { + if (!_isCurrentRoom(asset.roomId, asset.generation)) { + return false; + } + final uri = _networkUri(asset.resource); + if (uri == null) { + return true; + } + final url = uri.toString(); + try { + if (asset.kind == _RoomRocketPreloadKind.pag) { + await FileCacheManager.getInstance() + .getFile(url: url, headers: buildNetworkImageHeaders(url)) + .timeout(_roomRocketAssetPreloadTimeout); + } else { + await DefaultCacheManager() + .getSingleFile(url, headers: buildNetworkImageHeaders(url)) + .timeout(_roomRocketAssetPreloadTimeout); + } + _debug('preloaded ${asset.kind.name} ${_shortResource(url)}'); + return true; + } catch (error) { + _debug( + 'preload failed ${asset.kind.name} ${_shortResource(url)}: $error', + ); + return false; + } + } +} + +List<_RoomRocketPreloadAsset> _assetsForStatus( + SCRoomRocketStatusRes status, { + required String roomId, + required int generation, + required bool includeNextLevel, + required bool includeCurrentRewardCovers, + bool onlyCurrentLaunchAssets = false, +}) { + final currentLevel = _currentLevel(status); + final result = <_RoomRocketPreloadAsset>[]; + final current = _levelFor(status, currentLevel); + if (current != null) { + if (!onlyCurrentLaunchAssets) { + _addImage(result, current.rocketIconUrl, roomId, generation); + } + _addPag(result, current.rocketAnimationUrl, roomId, generation); + } + if (!onlyCurrentLaunchAssets && includeNextLevel) { + final next = _levelFor(status, currentLevel + 1); + if (next != null) { + _addImage(result, next.rocketIconUrl, roomId, generation); + _addPag(result, next.rocketAnimationUrl, roomId, generation); + } + } + if (includeCurrentRewardCovers) { + final preview = status.rewardPreviewForLevel(currentLevel); + for (final reward in preview?.all ?? const []) { + _addImage(result, reward.rewardCover, roomId, generation); + } + } + return _dedupeAssets(result); +} + +void _addImage( + List<_RoomRocketPreloadAsset> result, + String resource, + String roomId, + int generation, +) { + final value = resource.trim(); + if (value.isEmpty || RoomRocketPagEffectOverlay.isPag(value)) { + return; + } + result.add( + _RoomRocketPreloadAsset( + resource: value, + kind: _RoomRocketPreloadKind.image, + roomId: roomId, + generation: generation, + ), + ); +} + +void _addPag( + List<_RoomRocketPreloadAsset> result, + String resource, + String roomId, + int generation, +) { + final value = resource.trim(); + if (!RoomRocketPagEffectOverlay.isPag(value)) { + return; + } + result.add( + _RoomRocketPreloadAsset( + resource: value, + kind: _RoomRocketPreloadKind.pag, + roomId: roomId, + generation: generation, + ), + ); +} + +List<_RoomRocketPreloadAsset> _dedupeAssets( + List<_RoomRocketPreloadAsset> assets, +) { + final seen = {}; + final result = <_RoomRocketPreloadAsset>[]; + for (final asset in assets) { + final key = asset.key; + if (!seen.add(key)) { + continue; + } + result.add(asset); + } + return result; +} + +int _currentLevel(SCRoomRocketStatusRes status) { + return (status.currentLevel ?? status.level?.toInt() ?? 1) + .clamp(1, 99) + .toInt(); +} + +SCRoomRocketLevelRes? _levelFor(SCRoomRocketStatusRes status, int level) { + final normalizedLevel = level.clamp(1, 99).toInt(); + for (final item in status.levels ?? const []) { + if (item.level == normalizedLevel) { + return item; + } + } + return null; +} + +double _statusPercent(SCRoomRocketStatusRes status) { + final value = status.displayPercent ?? status.energyPercent; + if (value != null) { + final raw = value.toDouble(); + final percent = raw > 0 && raw <= 1 ? raw * 100 : raw; + return percent.clamp(0, 100).toDouble(); + } + final currentEnergy = status.currentEnergy ?? 0; + final maxEnergy = status.maxEnergy ?? status.needEnergy ?? 0; + if (maxEnergy <= 0) { + return 0; + } + return ((currentEnergy / maxEnergy) * 100).clamp(0, 100).toDouble(); +} + +double _forceThreshold(SCRoomRocketStatusRes status) { + final level = _levelFor(status, _currentLevel(status)); + final threshold = level?.shakeThresholdPercent.toDouble(); + if (threshold == null || threshold <= 0 || threshold > 100) { + return _roomRocketAssetPreloadFallbackThreshold; + } + return threshold.clamp(80, 99.5).toDouble(); +} + +Uri? _networkUri(String resource) { + final normalized = normalizeImageResourceUrl(resource.trim()); + if (normalized.isEmpty) { + return null; + } + final direct = Uri.tryParse(normalized); + if (_isHttpUri(direct)) { + return direct; + } + final encoded = Uri.tryParse(Uri.encodeFull(normalized)); + if (_isHttpUri(encoded)) { + return encoded; + } + return null; +} + +bool _isHttpUri(Uri? uri) { + if (uri == null || !uri.hasScheme || uri.host.isEmpty) { + return false; + } + final scheme = uri.scheme.toLowerCase(); + return scheme == 'http' || scheme == 'https'; +} + +String _shortResource(String value) { + final text = value.replaceAll(RegExp(r'\s+'), ' ').trim(); + if (text.length <= 120) { + return text; + } + return '${text.substring(0, 117)}...'; +} + +void _debug(String message) { + if (!kDebugMode) { + return; + } + debugPrint('[RoomRocketAssetPreloader] $message'); +} + +@visibleForTesting +List debugRoomRocketAssetPreloadResources( + SCRoomRocketStatusRes status, { + bool includeNextLevel = true, + bool includeCurrentRewardCovers = true, + bool onlyCurrentLaunchAssets = false, +}) { + return _assetsForStatus( + status, + roomId: 'debug-room', + generation: 1, + includeNextLevel: includeNextLevel, + includeCurrentRewardCovers: includeCurrentRewardCovers, + onlyCurrentLaunchAssets: onlyCurrentLaunchAssets, + ).map((item) => item.resource).toList(growable: false); +} + +@visibleForTesting +bool debugShouldForceRoomRocketAssetPreload(SCRoomRocketStatusRes status) { + return RoomRocketAssetPreloader.shouldForceCurrentLaunchPreload(status); +} diff --git a/lib/ui_kit/widgets/room/rocket/room_rocket_reward_dialog_loader.dart b/lib/ui_kit/widgets/room/rocket/room_rocket_reward_dialog_loader.dart index 3f4c603..6d2aefc 100644 --- a/lib/ui_kit/widgets/room/rocket/room_rocket_reward_dialog_loader.dart +++ b/lib/ui_kit/widgets/room/rocket/room_rocket_reward_dialog_loader.dart @@ -88,7 +88,13 @@ class _RoomRocketRewardDialogLoaderState if (useInitialPopupRecords) { _usedInitialPopupRecords = true; } - final nextRoomRecords = await _loadRoomRewardRecords(requestCursor); + final launchNo = _launchNoFromRecords( + nextPopupRecords.isNotEmpty ? nextPopupRecords : _popupRecords, + ); + final nextRoomRecords = await _loadRoomRewardRecords( + requestCursor, + launchNo: launchNo, + ); if (!mounted) { return; } @@ -152,8 +158,9 @@ class _RoomRocketRewardDialogLoaderState } Future> _loadRoomRewardRecords( - int cursor, - ) async { + int cursor, { + String launchNo = '', + }) async { if (widget.roomId.trim().isEmpty) { return const []; } @@ -166,6 +173,8 @@ class _RoomRocketRewardDialogLoaderState widget.roomId, cursor: cursor, limit: _rewardDialogPageSize, + launchNo: launchNo, + roomScope: true, ); return res.records; } catch (error) { @@ -208,6 +217,16 @@ class _RoomRocketRewardDialogLoaderState } } +String _launchNoFromRecords(List records) { + for (final record in records) { + final launchNo = record.launchNo.trim(); + if (launchNo.isNotEmpty) { + return launchNo; + } + } + return ''; +} + void _replaceUniqueRewardRecords( List target, List records, diff --git a/test/room_rocket_asset_preloader_test.dart b/test/room_rocket_asset_preloader_test.dart new file mode 100644 index 0000000..cea87b6 --- /dev/null +++ b/test/room_rocket_asset_preloader_test.dart @@ -0,0 +1,108 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:yumi/shared/business_logic/models/res/sc_room_rocket_api_res.dart'; +import 'package:yumi/shared/business_logic/models/res/sc_room_rocket_status_res.dart'; +import 'package:yumi/ui_kit/widgets/room/rocket/room_rocket_asset_preloader.dart'; + +void main() { + group('room rocket asset preloader', () { + test('collects current and next level disk preload resources', () { + final resources = debugRoomRocketAssetPreloadResources(_status()); + + expect(resources, [ + 'https://cdn.example.com/rocket-l2.png', + 'https://cdn.example.com/rocket-l2.pag', + 'https://cdn.example.com/rocket-l3.png', + 'https://cdn.example.com/rocket-l3.pag', + 'https://cdn.example.com/reward-top1.png', + 'https://cdn.example.com/reward-in-room.png', + ]); + }); + + test('force mode only collects current launch pag and reward covers', () { + final resources = debugRoomRocketAssetPreloadResources( + _status(), + includeNextLevel: false, + onlyCurrentLaunchAssets: true, + ); + + expect(resources, [ + 'https://cdn.example.com/rocket-l2.pag', + 'https://cdn.example.com/reward-top1.png', + 'https://cdn.example.com/reward-in-room.png', + ]); + }); + + test('uses shake threshold to decide force preload', () { + expect( + debugShouldForceRoomRocketAssetPreload(_status(percent: 94)), + false, + ); + expect( + debugShouldForceRoomRocketAssetPreload(_status(percent: 95)), + true, + ); + }); + }); +} + +SCRoomRocketStatusRes _status({num percent = 95}) { + return SCRoomRocketStatusRes( + roomId: 'room-1', + currentLevel: 2, + displayPercent: percent, + levels: [ + _level( + level: 2, + icon: 'https://cdn.example.com/rocket-l2.png', + animation: 'https://cdn.example.com/rocket-l2.pag', + rewardPreview: SCRoomRocketRewardPreviewRes( + top1: [ + _reward('https://cdn.example.com/reward-top1.png'), + _reward('https://cdn.example.com/reward-top1.png'), + ], + ignite: const [], + inRoom: [_reward('https://cdn.example.com/reward-in-room.png')], + ), + ), + _level( + level: 3, + icon: 'https://cdn.example.com/rocket-l3.png', + animation: 'https://cdn.example.com/rocket-l3.pag', + ), + ], + ); +} + +SCRoomRocketLevelRes _level({ + required int level, + required String icon, + required String animation, + SCRoomRocketRewardPreviewRes? rewardPreview, +}) { + return SCRoomRocketLevelRes( + id: level.toString(), + level: level, + needEnergy: level * 100, + rocketIconUrl: icon, + rocketPreviewUrl: '', + rocketAnimationUrl: animation, + progressBarUrl: '', + shakeThresholdPercent: 95, + enabled: true, + sort: level, + rewardPreview: rewardPreview, + ); +} + +SCRoomRocketRewardRes _reward(String cover) { + return SCRoomRocketRewardRes( + rewardScene: 'TOP1', + rewardType: 'GOLD', + rewardItemId: '', + rewardName: 'Yumi', + rewardCover: cover, + rocketIconUrl: '', + rewardAmount: 1000, + expireDays: 0, + ); +}