fix(recommend): 首屏不再兜出重试空态

This commit is contained in:
Zhou Weidong committed 2026-10-27 11:24:21 +08:00
1 parent 9117bf58b8
commit d206465ee1
3 files changed
+302 -30

No files matched your search

@@ -33,6 +33,7 @@ class HomeFeedController extends ChangeNotifier {
String? get cursor => _state(_channel).cursor; String? get cursor => _state(_channel).cursor;
bool get hasMore => _state(_channel).hasMore; bool get hasMore => _state(_channel).hasMore;
bool get loaded => _state(_channel).loaded; bool get loaded => _state(_channel).loaded;
bool get isLoading => _state(_channel).loading;
bool get loadingMore => _state(_channel).loadingMore; bool get loadingMore => _state(_channel).loadingMore;
bool get loadingFirstPage => !loaded && _state(_channel).loading; bool get loadingFirstPage => !loaded && _state(_channel).loading;
+124 -30
View File
@@ -49,10 +49,16 @@ class RecommendPage extends StatefulWidget {
class _RecommendPageState extends State<RecommendPage> class _RecommendPageState extends State<RecommendPage>
with AutomaticKeepAliveClientMixin<RecommendPage> { with AutomaticKeepAliveClientMixin<RecommendPage> {
static const Duration _recommendRetryDelay = Duration(milliseconds: 1500);
static const int _maxRecommendRetries = 3;
RecommendInnerTab _tab = RecommendInnerTab.feed; RecommendInnerTab _tab = RecommendInnerTab.feed;
final PageController _pageController = PageController(); final PageController _pageController = PageController();
int _scrubCancelRequest = 0; int _scrubCancelRequest = 0;
bool _recommendDataRefreshScheduled = false; bool _recommendDataRefreshScheduled = false;
bool _recommendRefreshInFlight = false;
int _recommendRetryAttempts = 0;
Timer? _recommendRetryTimer;
HomeFeedChannel? _observedFeedChannel; HomeFeedChannel? _observedFeedChannel;
@override @override
@@ -71,9 +77,13 @@ class _RecommendPageState extends State<RecommendPage>
super.didUpdateWidget(oldWidget); super.didUpdateWidget(oldWidget);
if (oldWidget.feedController != widget.feedController) { if (oldWidget.feedController != widget.feedController) {
oldWidget.feedController.removeListener(_handleFeedControllerChanged); oldWidget.feedController.removeListener(_handleFeedControllerChanged);
_cancelRecommendRetries();
_observedFeedChannel = widget.feedController.channel; _observedFeedChannel = widget.feedController.channel;
widget.feedController.addListener(_handleFeedControllerChanged); widget.feedController.addListener(_handleFeedControllerChanged);
} }
if (oldWidget.active && !widget.active) {
_cancelRecommendRetries();
}
if (_requiresRecommendDataRefresh(oldWidget)) { if (_requiresRecommendDataRefresh(oldWidget)) {
_scheduleRecommendData(); _scheduleRecommendData();
} }
@@ -88,9 +98,32 @@ class _RecommendPageState extends State<RecommendPage>
void _handleFeedControllerChanged() { void _handleFeedControllerChanged() {
final channel = widget.feedController.channel; final channel = widget.feedController.channel;
if (channel == _observedFeedChannel) return; if (channel != _observedFeedChannel) {
_observedFeedChannel = channel; _observedFeedChannel = channel;
if (widget.active) _scheduleRecommendData(); _cancelRecommendRetries();
if (widget.active) _scheduleRecommendData();
return;
}
_handleRecommendFeedState();
}
void _handleRecommendFeedState() {
if (!mounted || !widget.active) return;
final feed = widget.feedController;
if (feed.dramas.isNotEmpty) {
_cancelRecommendRetries();
return;
}
if (feed.loaded &&
feed.lastError == null &&
!feed.isLoading &&
!_recommendRefreshInFlight) {
_cancelRecommendRetries();
return;
}
if (feed.loaded && feed.lastError != null && !_recommendRefreshInFlight) {
_scheduleRecommendRetry();
}
} }
void _scheduleRecommendData() { void _scheduleRecommendData() {
@@ -100,8 +133,15 @@ class _RecommendPageState extends State<RecommendPage>
_recommendDataRefreshScheduled = false; _recommendDataRefreshScheduled = false;
if (!mounted || !widget.active) return; if (!mounted || !widget.active) return;
final feed = widget.feedController; final feed = widget.feedController;
if (!feed.loaded && !feed.loadingFirstPage) { if (feed.dramas.isEmpty || feed.lastError != null) {
unawaited(feed.refreshCurrent()); _recommendRetryAttempts = 0;
if (!feed.isLoading && !_recommendRefreshInFlight) {
if (feed.loaded) {
_scheduleRecommendRetry();
} else {
unawaited(_refreshRecommendData());
}
}
} }
final playlists = _readPlaylists(); final playlists = _readPlaylists();
if (playlists != null && !playlists.loaded && !playlists.loading) { if (playlists != null && !playlists.loaded && !playlists.loading) {
@@ -110,9 +150,51 @@ class _RecommendPageState extends State<RecommendPage>
}); });
} }
void _scheduleRecommendRetry() {
if (!mounted ||
!widget.active ||
_recommendRefreshInFlight ||
_recommendRetryTimer != null ||
_recommendRetryAttempts >= _maxRecommendRetries) {
return;
}
setState(() {
_recommendRetryTimer = Timer(_recommendRetryDelay, () {
_recommendRetryTimer = null;
if (!mounted || !widget.active) return;
_recommendRetryAttempts += 1;
unawaited(_refreshRecommendData());
});
});
}
Future<void> _refreshRecommendData() async {
if (!mounted || !widget.active || _recommendRefreshInFlight) return;
setState(() => _recommendRefreshInFlight = true);
final succeeded = await widget.feedController.refreshCurrent();
if (!mounted) return;
_recommendRefreshInFlight = false;
if (!widget.active) return;
final feed = widget.feedController;
if (succeeded || (feed.loaded && feed.lastError == null)) {
_cancelRecommendRetries();
setState(() {});
return;
}
setState(() {});
_scheduleRecommendRetry();
}
void _cancelRecommendRetries() {
_recommendRetryTimer?.cancel();
_recommendRetryTimer = null;
_recommendRetryAttempts = 0;
}
@override @override
void dispose() { void dispose() {
widget.feedController.removeListener(_handleFeedControllerChanged); widget.feedController.removeListener(_handleFeedControllerChanged);
_cancelRecommendRetries();
_pageController.dispose(); _pageController.dispose();
super.dispose(); super.dispose();
} }
@@ -199,39 +281,51 @@ class _RecommendPageState extends State<RecommendPage>
} }
Widget _recommendFeed(BuildContext context) { Widget _recommendFeed(BuildContext context) {
final feed = widget.feedController;
final dramas = widget.feedController.dramas; final dramas = widget.feedController.dramas;
if (dramas.isEmpty) { if (dramas.isNotEmpty) {
final l10n = AppLocalizations.of(context)!; return PlayerPage(
if (widget.feedController.loadingFirstPage) { key: const Key('recommend-feed-player'),
return const _RecommendFeedSkeleton(); homeMode: true,
} embedded: true,
active: widget.active && _tab == RecommendInnerTab.feed,
showHomeFeedHeader: false,
drama: dramas.first,
initialIndex: 0,
feedDramas: dramas,
feedChannel: widget.feedController.channel,
onOpenFullPlayer: (drama, episode) =>
_openDrama(drama, episodeId: episode.id),
onRefreshFeed: widget.feedController.refreshCurrent,
onLoadMore: widget.feedController.loadMore,
onImmersiveChanged: widget.onImmersiveChanged,
scrubCancelRequest: _scrubCancelRequest,
);
}
if (!feed.loaded ||
feed.isLoading ||
_recommendRefreshInFlight ||
_recommendRetryTimer != null) {
return const _RecommendFeedSkeleton();
}
final l10n = AppLocalizations.of(context)!;
if (feed.lastError != null) {
return ColoredBox( return ColoredBox(
color: AppColors.mediaBlack, color: AppColors.mediaBlack,
child: EmptyState( child: EmptyState(
key: const Key('recommend-feed-empty'), key: const Key('recommend-feed-empty'),
icon: Icons.movie_outlined, icon: Icons.wifi_off_rounded,
title: l10n.noStories, title: l10n.networkError,
actionLabel: l10n.retry,
onAction: () => unawaited(widget.feedController.refreshCurrent()),
), ),
); );
} }
return PlayerPage( return ColoredBox(
key: const Key('recommend-feed-player'), color: AppColors.mediaBlack,
homeMode: true, child: EmptyState(
embedded: true, key: const Key('recommend-feed-empty'),
active: widget.active && _tab == RecommendInnerTab.feed, icon: Icons.movie_outlined,
showHomeFeedHeader: false, title: l10n.noStories,
drama: dramas.first, ),
initialIndex: 0,
feedDramas: dramas,
feedChannel: widget.feedController.channel,
onOpenFullPlayer: (drama, episode) =>
_openDrama(drama, episodeId: episode.id),
onRefreshFeed: widget.feedController.refreshCurrent,
onLoadMore: widget.feedController.loadMore,
onImmersiveChanged: widget.onImmersiveChanged,
scrubCancelRequest: _scrubCancelRequest,
); );
} }
@@ -11,6 +11,14 @@ import '../support/fake_catalog.dart';
import '../support/fake_category.dart'; import '../support/fake_category.dart';
void main() { void main() {
testWidgets('should_show_skeleton_before_feed_is_loaded', (tester) async {
await _pumpRecommend(tester, active: false);
expect(find.byKey(const Key('recommend-feed-skeleton')), findsOneWidget);
expect(find.byKey(const Key('recommend-feed-empty')), findsNothing);
expect(find.text('Retry'), findsNothing);
});
testWidgets('should_refresh_once_when_parent_rebuilds_repeatedly', ( testWidgets('should_refresh_once_when_parent_rebuilds_repeatedly', (
tester, tester,
) async { ) async {
@@ -52,6 +60,64 @@ void main() {
expect(fixture.controller.refreshCalls, 2); expect(fixture.controller.refreshCalls, 2);
}); });
testWidgets('should_auto_retry_three_times_then_show_network_error', (
tester,
) async {
final fixture = await _pumpFailingRecommend(tester);
expect(fixture.controller.refreshCalls, 1);
expect(find.byKey(const Key('recommend-feed-skeleton')), findsOneWidget);
expect(find.byKey(const Key('recommend-feed-empty')), findsNothing);
for (var attempt = 0; attempt < 3; attempt++) {
await tester.pump(const Duration(milliseconds: 1500));
await tester.pump();
expect(fixture.controller.refreshCalls, attempt + 2);
if (attempt < 2) {
expect(
find.byKey(const Key('recommend-feed-skeleton')),
findsOneWidget,
);
expect(find.byKey(const Key('recommend-feed-empty')), findsNothing);
}
}
final l10n = AppLocalizations.of(
tester.element(find.byType(RecommendPage)),
)!;
expect(fixture.controller.refreshCalls, 4);
expect(find.byKey(const Key('recommend-feed-skeleton')), findsNothing);
expect(find.byKey(const Key('recommend-feed-empty')), findsOneWidget);
expect(find.text(l10n.networkError), findsOneWidget);
expect(find.text(l10n.retry), findsNothing);
});
testWidgets('should_cancel_pending_auto_retry_when_disposed', (tester) async {
final fixture = await _pumpFailingRecommend(tester);
expect(fixture.controller.refreshCalls, 1);
await tester.pumpWidget(const MaterialApp(home: SizedBox.shrink()));
await tester.pump(const Duration(seconds: 2));
expect(fixture.controller.refreshCalls, 1);
});
testWidgets(
'should_show_no_stories_without_retry_when_loaded_feed_is_empty',
(tester) async {
final fixture = await _pumpEmptyRecommend(tester);
final l10n = AppLocalizations.of(
tester.element(find.byType(RecommendPage)),
)!;
expect(fixture.controller.refreshCalls, 1);
expect(find.byKey(const Key('recommend-feed-skeleton')), findsNothing);
expect(find.byKey(const Key('recommend-feed-empty')), findsOneWidget);
expect(find.text(l10n.noStories), findsOneWidget);
expect(find.text(l10n.retry), findsNothing);
},
);
testWidgets('should_not_refresh_after_dispose_when_channel_changes', ( testWidgets('should_not_refresh_after_dispose_when_channel_changes', (
tester, tester,
) async { ) async {
@@ -96,6 +162,58 @@ Future<_RecommendFixture> _pumpRecommend(
return fixture; return fixture;
} }
Future<_StatefulRecommendFixture> _pumpFailingRecommend(
WidgetTester tester,
) async {
tester.view.physicalSize = const Size(390, 844);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
final catalog = FakeCatalogRepository();
final controller = _StatefulHomeFeedController(catalog);
final categories = CategoryController(
FakeCategoryRepository(catalog: catalog),
);
addTearDown(controller.dispose);
addTearDown(categories.dispose);
final fixture = _StatefulRecommendFixture(
controller: controller,
categoryController: categories,
);
await tester.pumpWidget(fixture.app());
await tester.pump();
await tester.pump(const Duration(milliseconds: 1));
return fixture;
}
Future<_StatefulRecommendFixture> _pumpEmptyRecommend(
WidgetTester tester,
) async {
tester.view.physicalSize = const Size(390, 844);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
final catalog = FakeCatalogRepository();
final controller = _StatefulHomeFeedController(catalog, fail: false);
final categories = CategoryController(
FakeCategoryRepository(catalog: catalog),
);
addTearDown(controller.dispose);
addTearDown(categories.dispose);
final fixture = _StatefulRecommendFixture(
controller: controller,
categoryController: categories,
);
await tester.pumpWidget(fixture.app());
await tester.pump();
await tester.pump(const Duration(milliseconds: 1));
return fixture;
}
class _RecommendFixture { class _RecommendFixture {
const _RecommendFixture({ const _RecommendFixture({
required this.controller, required this.controller,
@@ -123,6 +241,32 @@ class _RecommendFixture {
} }
} }
class _StatefulRecommendFixture {
const _StatefulRecommendFixture({
required this.controller,
required this.categoryController,
});
final _StatefulHomeFeedController controller;
final CategoryController categoryController;
Widget app() {
return MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: ChangeNotifierProvider<CategoryController>.value(
value: categoryController,
child: RecommendPage(
feedController: controller,
active: true,
videoOnly: true,
onSearch: () {},
),
),
);
}
}
class _RecommendHarness extends StatefulWidget { class _RecommendHarness extends StatefulWidget {
const _RecommendHarness({ const _RecommendHarness({
super.key, super.key,
@@ -181,3 +325,36 @@ class _RecordingHomeFeedController extends HomeFeedController {
return true; return true;
} }
} }
class _StatefulHomeFeedController extends HomeFeedController {
_StatefulHomeFeedController(super.repository, {this.fail = true});
final bool fail;
int refreshCalls = 0;
bool _loaded = false;
bool _isLoading = false;
String? _lastError;
@override
bool get loaded => _loaded;
@override
bool get isLoading => _isLoading;
@override
String? get lastError => _lastError;
@override
Future<bool> refreshCurrent() async {
refreshCalls++;
_isLoading = true;
_lastError = null;
notifyListeners();
await Future<void>.delayed(Duration.zero);
_isLoading = false;
_loaded = true;
if (fail) _lastError = 'network error';
notifyListeners();
return !fail;
}
}