perf(player): 限制雪碧图与海报预缓存内存并给播放位置缓存加容量上限
雪碧图解码从 2048 降到 1024、缓存从 8 张降到 3 张,避免擦洗预览驻留十几 MB 位图; 相邻集海报预缓存改用 ResizeImage 按 360/720 宽解码,不再缓存整张原图; 播放位置缓存按写入时间淘汰,最多保留 100 条,长会话不再只增不减; 观看记录详情失败日志加 kDebugMode 保护,release 下不再逐条打堆栈。
This commit is contained in:
1 parent
fb9001a02d
commit
0f777b19f2
5 files changed
+82
-14
No files matched your search
@@ -33,10 +33,15 @@ class PlaybackPositionCache {
|
|||||||
PlaybackPositionCache({
|
PlaybackPositionCache({
|
||||||
DateTime Function()? now,
|
DateTime Function()? now,
|
||||||
this.maxAge = _defaultMaxAge,
|
this.maxAge = _defaultMaxAge,
|
||||||
|
this.maxEntries = defaultMaxEntries,
|
||||||
}) : _now = now ?? DateTime.now;
|
}) : _now = now ?? DateTime.now;
|
||||||
|
|
||||||
|
/// 最多保留的集数:长会话连看几十部剧也不至于让 Map 无限增长。
|
||||||
|
static const int defaultMaxEntries = 100;
|
||||||
|
|
||||||
final DateTime Function() _now;
|
final DateTime Function() _now;
|
||||||
final Duration maxAge;
|
final Duration maxAge;
|
||||||
|
final int maxEntries;
|
||||||
final Map<String, CachedPlaybackPosition> _positions = {};
|
final Map<String, CachedPlaybackPosition> _positions = {};
|
||||||
|
|
||||||
/// 保存最近有效播放位置;零位置不会覆盖已有记录。
|
/// 保存最近有效播放位置;零位置不会覆盖已有记录。
|
||||||
@@ -57,6 +62,24 @@ class PlaybackPositionCache {
|
|||||||
duration: duration,
|
duration: duration,
|
||||||
updatedAt: _now(),
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 读取同剧同集且未过期的最近位置。
|
/// 读取同剧同集且未过期的最近位置。
|
||||||
|
|||||||
@@ -8,7 +8,8 @@ import 'package:dio/dio.dart';
|
|||||||
|
|
||||||
import '../../config/app_config.dart';
|
import '../../config/app_config.dart';
|
||||||
|
|
||||||
const int spriteThumbnailDecodeMaxDimension = 2048;
|
/// 雪碧图解码上限:擦洗预览只占很小一块区域,2048 会让单张位图膨胀到 16MB。
|
||||||
|
const int spriteThumbnailDecodeMaxDimension = 1024;
|
||||||
|
|
||||||
/// VTT 资源拒绝访问,服务层据此清缓存并重试一次。
|
/// VTT 资源拒绝访问,服务层据此清缓存并重试一次。
|
||||||
class SpriteThumbnailForbiddenException implements Exception {
|
class SpriteThumbnailForbiddenException implements Exception {
|
||||||
|
|||||||
@@ -227,14 +227,17 @@ class WatchRecordsController extends PagedListController<WatchRecord> {
|
|||||||
);
|
);
|
||||||
return detail;
|
return detail;
|
||||||
} catch (failure, stackTrace) {
|
} catch (failure, stackTrace) {
|
||||||
debugPrint(
|
// 弱网时详情会成批失败,release 下不需要逐条打堆栈。
|
||||||
'[WatchRecordsController] detail failed dramaId=$dramaId '
|
if (kDebugMode) {
|
||||||
'${failure.runtimeType}: $failure',
|
debugPrint(
|
||||||
);
|
'[WatchRecordsController] detail failed dramaId=$dramaId '
|
||||||
debugPrintStack(
|
'${failure.runtimeType}: $failure',
|
||||||
stackTrace: stackTrace,
|
);
|
||||||
label: '[WatchRecordsController] detail failed dramaId=$dramaId',
|
debugPrintStack(
|
||||||
);
|
stackTrace: stackTrace,
|
||||||
|
label: '[WatchRecordsController] detail failed dramaId=$dramaId',
|
||||||
|
);
|
||||||
|
}
|
||||||
if (isDisposed) return null;
|
if (isDisposed) return null;
|
||||||
final current = _recordIndex(dramaId);
|
final current = _recordIndex(dramaId);
|
||||||
if (current < 0) return null;
|
if (current < 0) return null;
|
||||||
|
|||||||
@@ -61,6 +61,17 @@ const double _playerLoadingSize = 24;
|
|||||||
const double _playerLoadingStrokeWidth = 2;
|
const double _playerLoadingStrokeWidth = 2;
|
||||||
const double _feedRefreshTopGap = 56;
|
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 {
|
class PlayerPageHandoff {
|
||||||
PlayerPageHandoff({required this.sourceUserWantsPlay});
|
PlayerPageHandoff({required this.sourceUserWantsPlay});
|
||||||
|
|
||||||
@@ -178,7 +189,9 @@ class _PlayerPageState extends State<PlayerPage>
|
|||||||
_thumbnailService = PlaybackThumbnailService(
|
_thumbnailService = PlaybackThumbnailService(
|
||||||
sourceResolver: _resolveThumbnailSource,
|
sourceResolver: _resolveThumbnailSource,
|
||||||
);
|
);
|
||||||
_spriteAxisService = SpriteThumbnailAxisService(spriteCacheCapacity: 8)
|
// 雪碧图每张解码后是整块位图,保留 3 部剧足够擦洗回退,8 部在低端机
|
||||||
|
// 容易把图片缓存顶满甚至触发 OOM。
|
||||||
|
_spriteAxisService = SpriteThumbnailAxisService(spriteCacheCapacity: 3)
|
||||||
..addListener(_handleSpriteFrameChanged);
|
..addListener(_handleSpriteFrameChanged);
|
||||||
WidgetsBinding.instance.addObserver(this);
|
WidgetsBinding.instance.addObserver(this);
|
||||||
logic.start(
|
logic.start(
|
||||||
@@ -507,9 +520,10 @@ class _PlayerPageState extends State<PlayerPage>
|
|||||||
final imageUrl = AppConfig.mediaUrl(posterUrl);
|
final imageUrl = AppConfig.mediaUrl(posterUrl);
|
||||||
if (_precachedPosterUrls.add(imageUrl)) {
|
if (_precachedPosterUrls.add(imageUrl)) {
|
||||||
unawaited(
|
unawaited(
|
||||||
precacheImage(NetworkImage(imageUrl), context).catchError((
|
precacheImage(
|
||||||
Object _,
|
_posterProvider(imageUrl, width: _posterPreviewWidth),
|
||||||
) {
|
context,
|
||||||
|
).catchError((Object _) {
|
||||||
_precachedPosterUrls.remove(imageUrl);
|
_precachedPosterUrls.remove(imageUrl);
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -728,7 +742,15 @@ class _PlayerPageState extends State<PlayerPage>
|
|||||||
if (logic.pip) {
|
if (logic.pip) {
|
||||||
final poster = logic.trackedEpisode?.posterUrl?.trim();
|
final poster = logic.trackedEpisode?.posterUrl?.trim();
|
||||||
if (poster != null && poster.isNotEmpty) {
|
if (poster != null && poster.isNotEmpty) {
|
||||||
unawaited(precacheImage(NetworkImage(poster), context));
|
unawaited(
|
||||||
|
precacheImage(
|
||||||
|
_posterProvider(
|
||||||
|
AppConfig.mediaUrl(poster),
|
||||||
|
width: _posterPipWidth,
|
||||||
|
),
|
||||||
|
context,
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return Stack(
|
return Stack(
|
||||||
|
|||||||
@@ -89,6 +89,25 @@ void main() {
|
|||||||
expect(cache.latestForDrama('drama-a')?.episodeId, 'episode-2');
|
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', () {
|
test('should_reject_expired_cached_position', () {
|
||||||
var now = DateTime(2026, 10, 22, 12);
|
var now = DateTime(2026, 10, 22, 12);
|
||||||
final cache = PlaybackPositionCache(now: () => now);
|
final cache = PlaybackPositionCache(now: () => now);
|
||||||
|
|||||||
Reference in new issue
Block a user