fix(player): 收紧图片与快速切集资源占用

This commit is contained in:
Zhou Weidong committed 2026-10-26 15:10:02 +08:00
1 parent 2fef7470e0
commit 80c962a95c
10 files changed
+281 -30

No files matched your search

@@ -81,6 +81,8 @@ class PlaybackSession extends ChangeNotifier {
// Includes initialization and asynchronous disposal, until native release ends.
int get livePlayerCount => _live.length;
int? get preloadedIndex => _next?.prepared == true ? _next?.index : null;
int? get previousPreloadedIndex =>
_previous?.prepared == true ? _previous?.index : null;
Future<void> get playbackSettled => _operations;
Future<void> get preloadSettled => _preloadTask;
@@ -9,7 +9,7 @@ import '../../config/app_config.dart';
import 'sprite_thumbnail_asset_loader.dart';
const Duration spriteVttCacheTtl = Duration(minutes: 10);
const int spriteImageCacheCapacity = 2;
const int spriteImageCacheCapacity = 1;
/// 单个 VTT cue 对应的 sprite 裁剪区域。
class SpriteThumbnailCue {
@@ -148,7 +148,6 @@ class SpriteThumbnailAxisService extends ChangeNotifier
final Set<String> _failedSprites = {};
bool _disposed = false;
@visibleForTesting
int get spriteCacheSize => _spriteCache.length;
@visibleForTesting
@@ -156,7 +155,7 @@ class SpriteThumbnailAxisService extends ChangeNotifier
.map((url) => Uri.tryParse(url)?.pathSegments.lastOrNull ?? url)
.toList();
/// 播放器就绪时预取当前 cue 与相邻 sprite,拖动帧不触发网络或解码。
/// 播放器就绪时预取当前 cue;容量大于 1 时顺带预取相邻 sprite。
@override
Future<void> prepareAt(String? thumbnailsUrl, Duration position) async {
final url = _normalizeUrl(thumbnailsUrl);
@@ -269,6 +268,7 @@ class SpriteThumbnailAxisService extends ChangeNotifier
if (current == null) return const [];
final index = axis.cues.indexOf(current);
final candidates = <SpriteThumbnailCue>[current];
if (spriteCacheCapacity <= 1) return candidates;
for (final direction in const [1, -1]) {
for (var step = 1; step < axis.cues.length; step++) {
final neighborIndex = index + direction * step;
+65 -10
View File
@@ -5,6 +5,24 @@ import 'package:flutter/material.dart';
import '../config/app_config.dart';
import '../theme/app_colors.dart';
const int _minImageDecodePixels = 96;
const int _maxImageDecodePixels = 2048;
/// 将布局逻辑尺寸换算成有限像素尺寸,兼顾小头像和全屏首帧。
int resolveImageDecodePixels(double? logicalExtent, double devicePixelRatio) {
final ratio = devicePixelRatio.isFinite && devicePixelRatio > 0
? devicePixelRatio
: 1.0;
final extent =
logicalExtent != null && logicalExtent.isFinite && logicalExtent > 0
? logicalExtent
: 120.0;
return (extent * ratio)
.ceil()
.clamp(_minImageDecodePixels, _maxImageDecodePixels)
.toInt();
}
/// 封面图统一入口:后端给的是相对路径,这里补服务地址并处理加载失败。
class RemoteImage extends StatelessWidget {
const RemoteImage(
@@ -17,6 +35,8 @@ class RemoteImage extends StatelessWidget {
this.fallbackIconKey,
this.fallbackAspectRatio,
this.fallbackColor = AppColors.raised,
this.decodeWidth,
this.decodeHeight,
});
final String path;
@@ -27,21 +47,56 @@ class RemoteImage extends StatelessWidget {
final Key? fallbackIconKey;
final double? fallbackAspectRatio;
final Color fallbackColor;
final int? decodeWidth;
final int? decodeHeight;
@override
Widget build(BuildContext context) {
final url = AppConfig.mediaUrl(path);
if (url.isEmpty) return _fallback(brand: showFallbackIcon);
return Image.network(
url,
width: width,
height: height,
fit: fit,
gaplessPlayback: true,
errorBuilder: (_, _, _) => _fallback(brand: showFallbackIcon),
frameBuilder: (context, child, frame, wasSynchronouslyLoaded) {
if (wasSynchronouslyLoaded || frame != null) return child;
return _fallback(brand: showFallbackIcon);
return LayoutBuilder(
builder: (context, constraints) {
final pixelRatio = MediaQuery.devicePixelRatioOf(context);
final layoutWidth = constraints.hasBoundedWidth
? constraints.maxWidth
: width;
final aspectRatio =
fallbackAspectRatio != null &&
fallbackAspectRatio!.isFinite &&
fallbackAspectRatio! > 0
? fallbackAspectRatio
: null;
final inferredHeight = constraints.hasBoundedHeight
? constraints.maxHeight
: height ??
(layoutWidth != null && aspectRatio != null
? layoutWidth / aspectRatio
: null);
final cacheWidth =
decodeWidth ?? resolveImageDecodePixels(layoutWidth, pixelRatio);
final cacheHeight =
decodeHeight ??
(inferredHeight == null
? null
: resolveImageDecodePixels(inferredHeight, pixelRatio));
return Image.network(
url,
width: width,
height: height,
fit: fit,
cacheWidth: cacheWidth
.clamp(_minImageDecodePixels, _maxImageDecodePixels)
.toInt(),
cacheHeight: cacheHeight
?.clamp(_minImageDecodePixels, _maxImageDecodePixels)
.toInt(),
gaplessPlayback: true,
errorBuilder: (_, _, _) => _fallback(brand: showFallbackIcon),
frameBuilder: (context, child, frame, wasSynchronouslyLoaded) {
if (wasSynchronouslyLoaded || frame != null) return child;
return _fallback(brand: showFallbackIcon);
},
);
},
);
}
+34
View File
@@ -93,6 +93,9 @@ class PlayerLogic extends ChangeNotifier {
String selectedQuality = 'AUTO';
Duration? _qualitySeek;
int pageRequest = 0;
int? _pendingSelectionIndex;
int? _pendingSelectionRevision;
Future<void>? _selectionDrain;
bool muted = false;
double playbackSpeed = 1.0;
bool stoppedAtFeedEnd = false;
@@ -957,6 +960,37 @@ class PlayerLogic extends ChangeNotifier {
);
}
/// 翻页入口使用 single-flight:执行中的切集不被重复排队,只保留最新目标。
Future<void> requestEpisodeSelection(int index, {int? revision}) {
if (_disposed || index < 0 || index >= sessionEpisodes.length) {
return Future<void>.value();
}
_pendingSelectionIndex = index;
_pendingSelectionRevision = revision;
return _selectionDrain ??= _drainEpisodeSelections();
}
Future<void> _drainEpisodeSelections() async {
try {
while (!_disposed) {
final index = _pendingSelectionIndex;
if (index == null) break;
final revision = _pendingSelectionRevision;
_pendingSelectionIndex = null;
_pendingSelectionRevision = null;
await selectEpisode(index, revision: revision);
}
} finally {
_selectionDrain = null;
final index = _pendingSelectionIndex;
if (!_disposed && index != null) {
unawaited(
requestEpisodeSelection(index, revision: _pendingSelectionRevision),
);
}
}
}
Future<void> selectEpisode(int index, {int? revision}) async {
final feedRevisionAtStart = revision ?? feedRevision;
final request = ++pageRequest;
+23 -1
View File
@@ -353,6 +353,28 @@ class _PlayerPageState extends State<PlayerPage>
);
}
Future<void> _selectPage(int index) async {
await logic.requestEpisodeSelection(index);
if (!mounted || logic.session.currentIndex != index) return;
await logic.session.preloadSettled;
if (!mounted || logic.session.currentIndex != index) return;
_logResourceWatermark();
}
void _logResourceWatermark() {
if (!kDebugMode) return;
final imageCache = PaintingBinding.instance.imageCache;
debugPrint(
'[player-resources] '
'slots=${logic.session.livePlayerCount} '
'warmNext=${logic.session.preloadedIndex ?? '-'} '
'warmPrevious=${logic.session.previousPreloadedIndex ?? '-'} '
'sprites=${_spriteAxisService.spriteCacheSize} '
'imageCacheBytes=${imageCache.currentSizeBytes} '
'imageCacheCount=${imageCache.currentSize}',
);
}
void _setScrubbing(bool value) {
if (!mounted) return;
if (_scrubbing == value) return;
@@ -507,7 +529,7 @@ class _PlayerPageState extends State<PlayerPage>
onPageChanged: (position) {
final index = indices[position];
widget.onDramaChanged?.call(logic.queue.entryAt(index).drama);
unawaited(logic.selectEpisode(index));
unawaited(_selectPage(index));
if (position >= indices.length - 3) {
final loadMore = widget.onLoadMore;
if (loadMore != null) unawaited(loadMore());
@@ -7,6 +7,7 @@ import 'package:flutter/material.dart';
import '../../../core/config/app_config.dart';
import '../../../core/data/services/sprite_thumbnail_axis.dart';
import '../../../core/theme/app_colors.dart';
import '../../../core/widgets/remote_image.dart';
import 'scrub_preview_card.dart';
enum PlayerOpeningFallback { handoff, poster, thumbnails, black }
@@ -149,12 +150,25 @@ class _PlayerOpeningFrameState extends State<PlayerOpeningFrame> {
children: [
const ColoredBox(color: AppColors.mediaBlack),
if (_hasPoster)
Image.network(
AppConfig.mediaUrl(widget.posterUrl!.trim()),
key: const Key('player-opening-poster'),
fit: widget.fit,
gaplessPlayback: true,
errorBuilder: (_, _, _) => _handlePosterError(),
LayoutBuilder(
builder: (context, constraints) {
final pixelRatio = MediaQuery.devicePixelRatioOf(context);
return Image.network(
AppConfig.mediaUrl(widget.posterUrl!.trim()),
key: const Key('player-opening-poster'),
fit: widget.fit,
cacheWidth: resolveImageDecodePixels(
constraints.maxWidth,
pixelRatio,
),
cacheHeight: resolveImageDecodePixels(
constraints.maxHeight,
pixelRatio,
),
gaplessPlayback: true,
errorBuilder: (_, _, _) => _handlePosterError(),
);
},
)
else
_spriteOrBlack(),
+31 -10
View File
@@ -75,7 +75,9 @@ void main() {
expect(axis.cues, isEmpty);
});
testWidgets('should_keep_only_two_decoded_sprites_in_lru', (tester) async {
testWidgets('should_keep_one_sprite_and_reload_after_eviction', (
tester,
) async {
final loader = _FakeSpriteLoader(vtt: _spriteVtt);
final service = SpriteThumbnailAxisService(
loader: loader,
@@ -84,20 +86,37 @@ void main() {
addTearDown(service.dispose);
await service.prepareAt('https://cdn.test/thumbnails.vtt', Duration.zero);
expect(service.spriteCacheSize, 2);
expect(
service.spriteCacheKeys,
containsAll(['sprite_001.jpg', 'sprite_002.jpg']),
);
await service.prepareAt(
'https://cdn.test/thumbnails.vtt',
const Duration(milliseconds: 2500),
);
await service.prepareAt(
'https://cdn.test/thumbnails.vtt',
const Duration(milliseconds: 3500),
);
expect(service.spriteCacheSize, 2);
expect(service.spriteCacheKeys, contains('sprite_002.jpg'));
expect(service.spriteCacheKeys, isNot(contains('sprite_001.jpg')));
expect(service.spriteCacheSize, 1);
expect(service.spriteCacheKeys, ['sprite_003.jpg']);
expect(loader.images[0].debugDisposed, isTrue);
expect(loader.images[1].debugDisposed, isTrue);
expect(loader.images[2].debugDisposed, isFalse);
expect(
service.cachedFrameAt(
'https://cdn.test/thumbnails.vtt',
const Duration(milliseconds: 3500),
),
isNotNull,
);
await service.prepareAt('https://cdn.test/thumbnails.vtt', Duration.zero);
expect(service.spriteCacheSize, 1);
expect(service.spriteCacheKeys, ['sprite_001.jpg']);
expect(loader.images[2].debugDisposed, isTrue);
expect(loader.images[3].debugDisposed, isFalse);
expect(
service.cachedFrameAt('https://cdn.test/thumbnails.vtt', Duration.zero),
isNotNull,
);
});
testWidgets('should_cache_vtt_and_retry_once_after_403', (tester) async {
@@ -229,6 +248,7 @@ class _FakeSpriteLoader implements SpriteThumbnailAssetLoader {
final List<Object>? vttResponses;
int vttCalls = 0;
final List<String> spriteCalls = [];
final List<ui.Image> images = [];
@override
Future<String?> loadVtt(Uri uri) async {
@@ -244,6 +264,7 @@ class _FakeSpriteLoader implements SpriteThumbnailAssetLoader {
Future<SpriteThumbnailDecodedImage?> loadSprite(Uri uri) async {
spriteCalls.add(uri.pathSegments.last);
final image = await _createImage(320, 180);
images.add(image);
return SpriteThumbnailDecodedImage(
image: image,
originalWidth: 320,
+36
View File
@@ -422,6 +422,42 @@ void main() {
await _drain(logic);
});
test('should_coalesce_rapid_page_selection_and_keep_pool_bounded', () async {
final factory = _RecordingFactory();
final playback = FakePlaybackRepository();
final logic = _logic(FakeWatchHistory(), playback: playback);
logic.attach(
PlayerPage(
drama: _drama('rapid', numbers: const [1, 2, 3, 4, 5, 6]),
initialIndex: 0,
),
isMounted: () => true,
);
logic.start(factory: factory.create, coordinator: PlaybackCoordinator());
await _drain(logic);
await Future.wait([
for (var index = 1; index <= 5; index++)
logic.requestEpisodeSelection(index),
]);
await _drain(logic);
expect(logic.session.currentIndex, 5);
expect(playback.requests, containsAll(['rapid-1', 'rapid-2', 'rapid-6']));
expect(playback.requests, isNot(contains('rapid-3')));
expect(playback.requests, isNot(contains('rapid-4')));
expect(playback.requests, isNot(contains('rapid-5')));
expect(logic.session.livePlayerCount, lessThanOrEqualTo(3));
expect(factory.live, lessThanOrEqualTo(3));
await logic.requestEpisodeSelection(0);
await _drain(logic);
expect(logic.session.currentIndex, 0);
logic.dispose();
await _drain(logic);
});
group('surface flags', () {
test('should_set_immersive_when_picture_in_picture_callback_fires', () async {
final factory = _RecordingFactory();
@@ -64,6 +64,38 @@ void main() {
expect(loader.vttCalls, 0);
});
testWidgets('should_decode_fullscreen_poster_at_screen_width', (
tester,
) async {
final service = SpriteThumbnailAxisService(loader: _FakeSpriteLoader());
addTearDown(service.dispose);
await tester.pumpWidget(
MaterialApp(
home: MediaQuery(
data: const MediaQueryData(devicePixelRatio: 3),
child: Center(
child: SizedBox(
width: 390,
height: 844,
child: PlayerOpeningFrame(
posterUrl: 'https://cdn.test/poster.jpg',
thumbnailsUrl: '',
spriteSource: service,
),
),
),
),
),
);
final image = tester.widget<Image>(
find.byKey(const Key('player-opening-poster')),
);
final provider = image.image as ResizeImage;
expect(provider.width, 1170);
});
testWidgets('should_return_no_opening_frame_when_thumbnails_is_empty', (
tester,
) async {
+35
View File
@@ -0,0 +1,35 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:fruitshorts/core/widgets/remote_image.dart';
void main() {
test('should_bound_layout_pixels_for_small_and_large_extents', () {
expect(resolveImageDecodePixels(12, 3), 96);
expect(resolveImageDecodePixels(120, 3), 360);
expect(resolveImageDecodePixels(4000, 3), 2048);
});
testWidgets('should_decode_grid_cover_at_layout_width_times_dpr', (
tester,
) async {
await tester.pumpWidget(
MaterialApp(
home: MediaQuery(
data: const MediaQueryData(devicePixelRatio: 3),
child: Center(
child: SizedBox(
width: 120,
height: 160,
child: RemoteImage('/files/grid-cover.jpg'),
),
),
),
),
);
final image = tester.widget<Image>(find.byType(Image));
final provider = image.image as ResizeImage;
expect(provider.width, 360);
expect(provider.height, 480);
});
}