自动释放资源

This commit is contained in:
zhx 2026-05-15 21:56:31 +08:00
parent a122935747
commit 539edffcfe
8 changed files with 261 additions and 11 deletions

View File

@ -10,6 +10,7 @@ class SCRoomRocketLevelRes {
required this.shakeThresholdPercent,
required this.enabled,
required this.sort,
this.rewards = const <SCRoomRocketRewardRes>[],
this.rewardPreview,
this.rocketKings = const <SCRoomRocketKingUserRes>[],
});
@ -194,6 +195,7 @@ class SCRoomRocketLevelRes {
95,
enabled: _boolValue(data['enabled'], fallback: true),
sort: _intValue(data['sort']),
rewards: SCRoomRocketRewardPreviewRes._rewardList(data['rewards']),
rewardPreview: SCRoomRocketRewardPreviewRes.tryFromJson(
_firstPayload([
data['rewardPreview'],
@ -241,6 +243,7 @@ class SCRoomRocketLevelRes {
final num shakeThresholdPercent;
final bool enabled;
final int sort;
final List<SCRoomRocketRewardRes> rewards;
final SCRoomRocketRewardPreviewRes? rewardPreview;
final List<SCRoomRocketKingUserRes> rocketKings;
@ -256,6 +259,7 @@ class SCRoomRocketLevelRes {
'shakeThresholdPercent': shakeThresholdPercent,
'enabled': enabled,
'sort': sort,
'rewards': rewards.map((item) => item.toJson()).toList(),
'rewardPreview':
rewardPreview == null
? null
@ -270,6 +274,71 @@ class SCRoomRocketLevelRes {
'rocketKings': rocketKings.map((item) => item.toJson()).toList(),
};
}
static List<SCRoomRocketLevelRes> listFromJson(dynamic value) {
if (value is! Iterable || value is String) {
return const <SCRoomRocketLevelRes>[];
}
return value.map(SCRoomRocketLevelRes.fromJson).toList(growable: false);
}
}
class SCRoomRocketLevelContentsRes {
SCRoomRocketLevelContentsRes({
required this.configured,
required this.sysOrigin,
required this.enabled,
required this.timezone,
required this.maxLevel,
required this.levels,
required this.updateTime,
});
factory SCRoomRocketLevelContentsRes.fromJson(dynamic json) {
final data = _asMap(json);
return SCRoomRocketLevelContentsRes(
configured: _boolValue(data['configured']),
sysOrigin: _stringValue(data['sysOrigin']),
enabled: _boolValue(data['enabled']),
timezone: _stringValue(data['timezone'] ?? data['timeZone']),
maxLevel: _intValue(data['maxLevel']),
levels: SCRoomRocketLevelRes.listFromJson(data['levels']),
updateTime: _stringValue(data['updateTime']),
);
}
final bool configured;
final String sysOrigin;
final bool enabled;
final String timezone;
final int maxLevel;
final List<SCRoomRocketLevelRes> levels;
final String updateTime;
SCRoomRocketLevelRes? levelFor(int level) {
for (final item in levels) {
if (item.level == level) {
return item;
}
}
return null;
}
SCRoomRocketRewardPreviewRes? rewardPreviewForLevel(int level) {
return levelFor(level)?.rewardPreview;
}
Map<String, dynamic> toJson() {
return {
'configured': configured,
'sysOrigin': sysOrigin,
'enabled': enabled,
'timezone': timezone,
'maxLevel': maxLevel,
'levels': levels.map((item) => item.toJson()).toList(),
'updateTime': updateTime,
};
}
}
class SCRoomRocketRewardRes {

View File

@ -202,7 +202,10 @@ abstract class SocialChatRoomRepository {
});
///
Future<SCRoomRocketStatusRes> rocketStatus(String roomId, {int? level});
Future<SCRoomRocketStatusRes> rocketStatus(String roomId, {int? level});
///
Future<SCRoomRocketLevelContentsRes> roomRocketLevelContents();
///
Future<SCRoomRocketKingRes> roomRocketKing(

View File

@ -1027,6 +1027,23 @@ class SCChatRoomRepository implements SocialChatRoomRepository {
return result;
}
///go/app/voice-room/rocket/level-contents
@override
Future<SCRoomRocketLevelContentsRes> roomRocketLevelContents() async {
const path = "/go/app/voice-room/rocket/level-contents";
_rocketApiDebug('request GET $path');
final result = await http.get<SCRoomRocketLevelContentsRes>(
path,
extra: {BaseNetworkClient.silentErrorToastKey: true},
fromJson: (json) {
_rocketApiDebug('GET $path', raw: json);
return SCRoomRocketLevelContentsRes.fromJson(json);
},
);
_rocketApiDebug('parsed $path', result: result);
return result;
}
///go/app/voice-room/rocket/king
@override
Future<SCRoomRocketKingRes> roomRocketKing(

View File

@ -13,7 +13,18 @@ class RoomRocketApiMapper {
static List<RoomRocketLevelItem>? levelsFromStatus(
SCRoomRocketStatusRes? status,
) {
final levels = status?.levels;
return levelsFromApiLevels(status?.levels);
}
static List<RoomRocketLevelItem>? levelsFromLevelContents(
SCRoomRocketLevelContentsRes? contents,
) {
return levelsFromApiLevels(contents?.levels);
}
static List<RoomRocketLevelItem>? levelsFromApiLevels(
List<SCRoomRocketLevelRes>? levels,
) {
if (levels == null || levels.isEmpty) {
return null;
}
@ -65,6 +76,26 @@ class RoomRocketApiMapper {
return rewardGroupsFromPreview(preview);
}
static RoomRocketRewardGroups? rewardGroupsFromLevelContents(
SCRoomRocketLevelContentsRes? contents, {
required int level,
}) {
return rewardGroupsFromPreview(contents?.rewardPreviewForLevel(level));
}
static Map<int, RoomRocketRewardGroups> rewardGroupsByLevelFromLevelContents(
SCRoomRocketLevelContentsRes? contents,
) {
final result = <int, RoomRocketRewardGroups>{};
for (final item in contents?.levels ?? const <SCRoomRocketLevelRes>[]) {
final groups = rewardGroupsFromPreview(item.rewardPreview);
if (groups != null) {
result[item.level] = groups;
}
}
return result;
}
static RoomRocketRewardGroups? rewardGroupsFromPreview(
SCRoomRocketRewardPreviewRes? preview,
) {

View File

@ -350,6 +350,7 @@ class _RoomRocketDialogLoader extends StatefulWidget {
class _RoomRocketDialogLoaderState extends State<_RoomRocketDialogLoader> {
SCRoomRocketStatusRes? _status;
SCRoomRocketLevelContentsRes? _levelContents;
List<RoomRocketNoteSection>? _noteSections;
Map<int, RoomRocketRewardGroups> _configRewardGroupsByLevel = {};
final Map<int, RoomRocketRewardGroups> _statusRewardGroupsByLevel = {};
@ -421,7 +422,31 @@ class _RoomRocketDialogLoaderState extends State<_RoomRocketDialogLoader> {
Future<void> _loadConfigs() async {
try {
_rocketUiDebug('request enabled rocket configs');
_rocketUiDebug('request rocket level contents');
final contents = await SCChatRoomRepository().roomRocketLevelContents();
if (!mounted) {
return;
}
final groups = RoomRocketApiMapper.rewardGroupsByLevelFromLevelContents(
contents,
);
setState(() {
_levelContents = contents;
_configRewardGroupsByLevel = groups;
});
_rocketUiDebug(
'rocket level contents loaded levels=${contents.levels.length} '
'rewardLevels=${groups.keys.toList()}',
);
} catch (error) {
_rocketUiDebug('rocket level contents request failed: $error');
unawaited(_loadLegacyConfigs());
}
}
Future<void> _loadLegacyConfigs() async {
try {
_rocketUiDebug('request legacy enabled rocket configs');
final configs = await SCChatRoomRepository().rocketConfigEnabled();
if (!mounted) {
return;
@ -433,10 +458,10 @@ class _RoomRocketDialogLoaderState extends State<_RoomRocketDialogLoader> {
_configRewardGroupsByLevel = groups;
});
_rocketUiDebug(
'rocket configs loaded rewardLevels=${groups.keys.toList()}',
'legacy rocket configs loaded rewardLevels=${groups.keys.toList()}',
);
} catch (error) {
_rocketUiDebug('rocket configs request failed: $error');
_rocketUiDebug('legacy rocket configs request failed: $error');
}
}
@ -456,10 +481,15 @@ class _RoomRocketDialogLoaderState extends State<_RoomRocketDialogLoader> {
final rewardGroups =
selectedLevelNumber == currentLevelNumber
? _mergeRewardGroups(rewardGroupsFromStatus, configRewardGroups)
: rewardGroupsFromStatus ?? const RoomRocketRewardGroups();
: rewardGroupsFromStatus ??
configRewardGroups ??
const RoomRocketRewardGroups();
final levelItems =
RoomRocketApiMapper.levelsFromLevelContents(_levelContents) ??
RoomRocketApiMapper.levelsFromStatus(status);
return RoomRocketDialog(
status: status,
levels: RoomRocketApiMapper.levelsFromStatus(status),
levels: levelItems,
selectedLevel: selectedLevel,
onLevelSelected: _onLevelSelected,
rewards:
@ -555,6 +585,7 @@ class _RoomRocketDialogLoaderState extends State<_RoomRocketDialogLoader> {
Future<void> _loadRewardForLevel(int level) async {
final normalizedLevel = level.clamp(1, 99).toInt();
if (_statusRewardGroupsByLevel.containsKey(normalizedLevel) ||
_configRewardGroupsByLevel.containsKey(normalizedLevel) ||
_loadingRewardLevels.contains(normalizedLevel)) {
return;
}

View File

@ -493,10 +493,7 @@ class _RoomRocketPagViewState extends State<_RoomRocketPagView> {
void _scheduleMemoryRelease(String path) {
_memoryReleaseTimer?.cancel();
if (widget.memoryTtl <= Duration.zero) {
return;
}
_memoryReleaseTimer = Timer(widget.memoryTtl, () {
_memoryReleaseTimer = _schedulePagMemoryReleaseTimer(widget.memoryTtl, () {
if (!mounted || widget.resource.trim() != path || _pagBytes == null) {
return;
}
@ -572,6 +569,24 @@ Duration debugRoomRocketPagMemoryTtl() {
return _pagMemoryTtl;
}
@visibleForTesting
Timer? debugScheduleRoomRocketPagMemoryRelease({
Duration memoryTtl = _pagMemoryTtl,
required VoidCallback onRelease,
}) {
return _schedulePagMemoryReleaseTimer(memoryTtl, onRelease);
}
Timer? _schedulePagMemoryReleaseTimer(
Duration memoryTtl,
VoidCallback onRelease,
) {
if (memoryTtl <= Duration.zero) {
return null;
}
return Timer(memoryTtl, onRelease);
}
@visibleForTesting
String debugNormalizeRoomRocketPagResource(String resource) {
return _normalizePagResource(resource);

View File

@ -45,4 +45,17 @@ void main() {
test('keeps pag bytes in memory for five minutes by default', () {
expect(debugRoomRocketPagMemoryTtl(), const Duration(minutes: 5));
});
test('releases pag bytes when the configured ttl expires', () async {
var released = false;
final timer = debugScheduleRoomRocketPagMemoryRelease(
memoryTtl: const Duration(milliseconds: 10),
onRelease: () => released = true,
);
expect(released, isFalse);
await Future<void>.delayed(const Duration(milliseconds: 30));
expect(released, isTrue);
timer?.cancel();
});
}

View File

@ -1,4 +1,5 @@
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_config_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_api_mapper.dart';
@ -137,4 +138,74 @@ void main() {
expect(groups[2]?.inRoom.single.imageUrl, 'cover-2');
expect(groups[2]?.inRoom.single.expireDays, 7);
});
test('parses rocket level contents response from go api', () {
final contents = SCRoomRocketLevelContentsRes.fromJson({
'configured': true,
'sysOrigin': 'LIKEI',
'enabled': true,
'timezone': 'Asia/Riyadh',
'maxLevel': 6,
'levels': [
{
'level': 1,
'needEnergy': 100,
'rocketIconUrl': 'icon-1',
'previewRocketUrl': 'preview-1',
'rocketAnimationUrl': 'animation-1',
'rewardPreview': {
'top1': [
{
'rewardScene': 'TOP1',
'rewardType': 'GOLD',
'rewardAmount': 100,
'rewardName': 'Lv1 top1',
},
],
},
},
{
'level': 2,
'needEnergy': 200,
'rocketIconUrl': 'icon-2',
'previewRocketUrl': 'preview-2',
'rocketAnimationUrl': 'animation-2',
'rewards': [
{
'rewardScene': 'IGNITE',
'rewardType': 'GOLD',
'rewardAmount': 200,
'rewardName': 'Lv2 ignite',
},
{
'rewardScene': 'IN_ROOM',
'rewardType': 'NONE',
'rewardName': '轮空',
},
],
},
],
});
expect(contents.enabled, isTrue);
expect(contents.levels, hasLength(2));
expect(contents.levelFor(1)?.rocketIconUrl, 'icon-1');
expect(contents.rewardPreviewForLevel(1)?.top1.single.rewardAmount, 100);
expect(contents.rewardPreviewForLevel(2)?.ignite.single.rewardAmount, 200);
expect(contents.levelFor(2)?.rewards, hasLength(2));
final levels = RoomRocketApiMapper.levelsFromLevelContents(contents);
final allGroups = RoomRocketApiMapper.rewardGroupsByLevelFromLevelContents(
contents,
);
final groups = RoomRocketApiMapper.rewardGroupsFromLevelContents(
contents,
level: 2,
);
expect(levels?.first.imageUrl, 'icon-1');
expect(allGroups[1]?.top1.single.amount, '100');
expect(allGroups[2]?.setOff.single.amount, '200');
expect(groups?.setOff.single.amount, '200');
});
}