From 0f777b19f2f414d7fe9506bba5886d5f5d1e78b3 Mon Sep 17 00:00:00 2001 From: Vicky <10212746+haifeng-jiang@user.noreply.gitee.com> Date: Thu, 29 Oct 2026 11:03:14 +0800 Subject: [PATCH] =?UTF-8?q?perf(player):=20=E9=99=90=E5=88=B6=E9=9B=AA?= =?UTF-8?q?=E7=A2=A7=E5=9B=BE=E4=B8=8E=E6=B5=B7=E6=8A=A5=E9=A2=84=E7=BC=93?= =?UTF-8?q?=E5=AD=98=E5=86=85=E5=AD=98=E5=B9=B6=E7=BB=99=E6=92=AD=E6=94=BE?= =?UTF-8?q?=E4=BD=8D=E7=BD=AE=E7=BC=93=E5=AD=98=E5=8A=A0=E5=AE=B9=E9=87=8F?= =?UTF-8?q?=E4=B8=8A=E9=99=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 雪碧图解码从 2048 降到 1024、缓存从 8 张降到 3 张,避免擦洗预览驻留十几 MB 位图; 相邻集海报预缓存改用 ResizeImage 按 360/720 宽解码,不再缓存整张原图; 播放位置缓存按写入时间淘汰,最多保留 100 条,长会话不再只增不减; 观看记录详情失败日志加 kDebugMode 保护,release 下不再逐条打堆栈。 --- .../services/playback_position_cache.dart | 23 +++++++++++++ .../sprite_thumbnail_asset_loader.dart | 3 +- .../services/watch_records_controller.dart | 19 ++++++----- lib/pages/player/player_view.dart | 32 ++++++++++++++++--- .../playback_position_cache_test.dart | 19 +++++++++++ 5 files changed, 82 insertions(+), 14 deletions(-) diff --git a/lib/core/data/services/playback_position_cache.dart b/lib/core/data/services/playback_position_cache.dart index 3825f81..7913b21 100644 --- a/lib/core/data/services/playback_position_cache.dart +++ b/lib/core/data/services/playback_position_cache.dart @@ -33,10 +33,15 @@ class PlaybackPositionCache { PlaybackPositionCache({ DateTime Function()? now, this.maxAge = _defaultMaxAge, + this.maxEntries = defaultMaxEntries, }) : _now = now ?? DateTime.now; + /// 最多保留的集数:长会话连看几十部剧也不至于让 Map 无限增长。 + static const int defaultMaxEntries = 100; + final DateTime Function() _now; final Duration maxAge; + final int maxEntries; final Map _positions = {}; /// 保存最近有效播放位置;零位置不会覆盖已有记录。 @@ -57,6 +62,24 @@ class PlaybackPositionCache { duration: duration, updatedAt: _now(), ); + _evictOverflow(); + } + + /// 超出上限时按写入时间淘汰最旧的一条,避免长会话内存只增不减。 + void _evictOverflow() { + while (maxEntries > 0 && _positions.length > maxEntries) { + String? oldestKey; + DateTime? oldestAt; + for (final entry in _positions.entries) { + final at = entry.value.updatedAt; + if (oldestAt == null || at.isBefore(oldestAt)) { + oldestAt = at; + oldestKey = entry.key; + } + } + if (oldestKey == null) return; + _positions.remove(oldestKey); + } } /// 读取同剧同集且未过期的最近位置。 diff --git a/lib/core/data/services/sprite_thumbnail_asset_loader.dart b/lib/core/data/services/sprite_thumbnail_asset_loader.dart index c82648e..e47715e 100644 --- a/lib/core/data/services/sprite_thumbnail_asset_loader.dart +++ b/lib/core/data/services/sprite_thumbnail_asset_loader.dart @@ -8,7 +8,8 @@ import 'package:dio/dio.dart'; import '../../config/app_config.dart'; -const int spriteThumbnailDecodeMaxDimension = 2048; +/// 雪碧图解码上限:擦洗预览只占很小一块区域,2048 会让单张位图膨胀到 16MB。 +const int spriteThumbnailDecodeMaxDimension = 1024; /// VTT 资源拒绝访问,服务层据此清缓存并重试一次。 class SpriteThumbnailForbiddenException implements Exception { diff --git a/lib/core/data/services/watch_records_controller.dart b/lib/core/data/services/watch_records_controller.dart index ad3f0c8..52dacf5 100644 --- a/lib/core/data/services/watch_records_controller.dart +++ b/lib/core/data/services/watch_records_controller.dart @@ -227,14 +227,17 @@ class WatchRecordsController extends PagedListController { ); return detail; } catch (failure, stackTrace) { - debugPrint( - '[WatchRecordsController] detail failed dramaId=$dramaId ' - '${failure.runtimeType}: $failure', - ); - debugPrintStack( - stackTrace: stackTrace, - label: '[WatchRecordsController] detail failed dramaId=$dramaId', - ); + // 弱网时详情会成批失败,release 下不需要逐条打堆栈。 + if (kDebugMode) { + debugPrint( + '[WatchRecordsController] detail failed dramaId=$dramaId ' + '${failure.runtimeType}: $failure', + ); + debugPrintStack( + stackTrace: stackTrace, + label: '[WatchRecordsController] detail failed dramaId=$dramaId', + ); + } if (isDisposed) return null; final current = _recordIndex(dramaId); if (current < 0) return null; diff --git a/lib/pages/player/player_view.dart b/lib/pages/player/player_view.dart index 8267568..d0ccad2 100644 --- a/lib/pages/player/player_view.dart +++ b/lib/pages/player/player_view.dart @@ -61,6 +61,17 @@ const double _playerLoadingSize = 24; const double _playerLoadingStrokeWidth = 2; const double _feedRefreshTopGap = 56; +/// 相邻集海报预缓存宽度:列表卡片只有百来 dp,按原图解码会占数 MB。 +const int _posterPreviewWidth = 360; + +/// 画中画还原封面接近全屏,保留更高分辨率但不必按原图。 +const int _posterPipWidth = 720; + +/// 预缓存用低分辨率 provider;与 RemoteImage 一样限制解码尺寸,避免整张原图进缓存。 +ImageProvider _posterProvider(String url, {required int width}) { + return ResizeImage(NetworkImage(url), width: width, allowUpscaling: false); +} + class PlayerPageHandoff { PlayerPageHandoff({required this.sourceUserWantsPlay}); @@ -178,7 +189,9 @@ class _PlayerPageState extends State _thumbnailService = PlaybackThumbnailService( sourceResolver: _resolveThumbnailSource, ); - _spriteAxisService = SpriteThumbnailAxisService(spriteCacheCapacity: 8) + // 雪碧图每张解码后是整块位图,保留 3 部剧足够擦洗回退,8 部在低端机 + // 容易把图片缓存顶满甚至触发 OOM。 + _spriteAxisService = SpriteThumbnailAxisService(spriteCacheCapacity: 3) ..addListener(_handleSpriteFrameChanged); WidgetsBinding.instance.addObserver(this); logic.start( @@ -507,9 +520,10 @@ class _PlayerPageState extends State final imageUrl = AppConfig.mediaUrl(posterUrl); if (_precachedPosterUrls.add(imageUrl)) { unawaited( - precacheImage(NetworkImage(imageUrl), context).catchError(( - Object _, - ) { + precacheImage( + _posterProvider(imageUrl, width: _posterPreviewWidth), + context, + ).catchError((Object _) { _precachedPosterUrls.remove(imageUrl); }), ); @@ -728,7 +742,15 @@ class _PlayerPageState extends State if (logic.pip) { final poster = logic.trackedEpisode?.posterUrl?.trim(); if (poster != null && poster.isNotEmpty) { - unawaited(precacheImage(NetworkImage(poster), context)); + unawaited( + precacheImage( + _posterProvider( + AppConfig.mediaUrl(poster), + width: _posterPipWidth, + ), + context, + ), + ); } } return Stack( diff --git a/test/playback/playback_position_cache_test.dart b/test/playback/playback_position_cache_test.dart index 447a013..66b4bdc 100644 --- a/test/playback/playback_position_cache_test.dart +++ b/test/playback/playback_position_cache_test.dart @@ -89,6 +89,25 @@ void main() { expect(cache.latestForDrama('drama-a')?.episodeId, 'episode-2'); }); + test('should_evict_oldest_entry_when_capacity_exceeded', () { + var now = DateTime(2026, 10, 22, 12); + final cache = PlaybackPositionCache(now: () => now, maxEntries: 2); + + for (final episodeId in ['episode-1', 'episode-2', 'episode-3']) { + cache.save( + episodeId: episodeId, + dramaId: 'drama-a', + position: const Duration(seconds: 30), + duration: const Duration(minutes: 3), + ); + now = now.add(const Duration(minutes: 1)); + } + + expect(cache.read('episode-1', dramaId: 'drama-a'), isNull); + expect(cache.read('episode-2', dramaId: 'drama-a'), isNotNull); + expect(cache.read('episode-3', dramaId: 'drama-a'), isNotNull); + }); + test('should_reject_expired_cached_position', () { var now = DateTime(2026, 10, 22, 12); final cache = PlaybackPositionCache(now: () => now);