From d206465ee1aa44ae91496bff8ed5fefb80406beb Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Tue, 27 Oct 2026 11:24:21 +0800 Subject: [PATCH] =?UTF-8?q?fix(recommend):=20=E9=A6=96=E5=B1=8F=E4=B8=8D?= =?UTF-8?q?=E5=86=8D=E5=85=9C=E5=87=BA=E9=87=8D=E8=AF=95=E7=A9=BA=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../data/services/home_feed_controller.dart | 1 + lib/pages/recommend/recommend_page.dart | 154 ++++++++++++--- .../recommend_refresh_scheduling_test.dart | 177 ++++++++++++++++++ 3 files changed, 302 insertions(+), 30 deletions(-) diff --git a/lib/core/data/services/home_feed_controller.dart b/lib/core/data/services/home_feed_controller.dart index 7326983..7d31048 100644 --- a/lib/core/data/services/home_feed_controller.dart +++ b/lib/core/data/services/home_feed_controller.dart @@ -33,6 +33,7 @@ class HomeFeedController extends ChangeNotifier { String? get cursor => _state(_channel).cursor; bool get hasMore => _state(_channel).hasMore; bool get loaded => _state(_channel).loaded; + bool get isLoading => _state(_channel).loading; bool get loadingMore => _state(_channel).loadingMore; bool get loadingFirstPage => !loaded && _state(_channel).loading; diff --git a/lib/pages/recommend/recommend_page.dart b/lib/pages/recommend/recommend_page.dart index ed3005f..8508219 100644 --- a/lib/pages/recommend/recommend_page.dart +++ b/lib/pages/recommend/recommend_page.dart @@ -49,10 +49,16 @@ class RecommendPage extends StatefulWidget { class _RecommendPageState extends State with AutomaticKeepAliveClientMixin { + static const Duration _recommendRetryDelay = Duration(milliseconds: 1500); + static const int _maxRecommendRetries = 3; + RecommendInnerTab _tab = RecommendInnerTab.feed; final PageController _pageController = PageController(); int _scrubCancelRequest = 0; bool _recommendDataRefreshScheduled = false; + bool _recommendRefreshInFlight = false; + int _recommendRetryAttempts = 0; + Timer? _recommendRetryTimer; HomeFeedChannel? _observedFeedChannel; @override @@ -71,9 +77,13 @@ class _RecommendPageState extends State super.didUpdateWidget(oldWidget); if (oldWidget.feedController != widget.feedController) { oldWidget.feedController.removeListener(_handleFeedControllerChanged); + _cancelRecommendRetries(); _observedFeedChannel = widget.feedController.channel; widget.feedController.addListener(_handleFeedControllerChanged); } + if (oldWidget.active && !widget.active) { + _cancelRecommendRetries(); + } if (_requiresRecommendDataRefresh(oldWidget)) { _scheduleRecommendData(); } @@ -88,9 +98,32 @@ class _RecommendPageState extends State void _handleFeedControllerChanged() { final channel = widget.feedController.channel; - if (channel == _observedFeedChannel) return; - _observedFeedChannel = channel; - if (widget.active) _scheduleRecommendData(); + if (channel != _observedFeedChannel) { + _observedFeedChannel = channel; + _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() { @@ -100,8 +133,15 @@ class _RecommendPageState extends State _recommendDataRefreshScheduled = false; if (!mounted || !widget.active) return; final feed = widget.feedController; - if (!feed.loaded && !feed.loadingFirstPage) { - unawaited(feed.refreshCurrent()); + if (feed.dramas.isEmpty || feed.lastError != null) { + _recommendRetryAttempts = 0; + if (!feed.isLoading && !_recommendRefreshInFlight) { + if (feed.loaded) { + _scheduleRecommendRetry(); + } else { + unawaited(_refreshRecommendData()); + } + } } final playlists = _readPlaylists(); if (playlists != null && !playlists.loaded && !playlists.loading) { @@ -110,9 +150,51 @@ class _RecommendPageState extends State }); } + 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 _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 void dispose() { widget.feedController.removeListener(_handleFeedControllerChanged); + _cancelRecommendRetries(); _pageController.dispose(); super.dispose(); } @@ -199,39 +281,51 @@ class _RecommendPageState extends State } Widget _recommendFeed(BuildContext context) { + final feed = widget.feedController; final dramas = widget.feedController.dramas; - if (dramas.isEmpty) { - final l10n = AppLocalizations.of(context)!; - if (widget.feedController.loadingFirstPage) { - return const _RecommendFeedSkeleton(); - } + if (dramas.isNotEmpty) { + return PlayerPage( + key: const Key('recommend-feed-player'), + 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( color: AppColors.mediaBlack, child: EmptyState( key: const Key('recommend-feed-empty'), - icon: Icons.movie_outlined, - title: l10n.noStories, - actionLabel: l10n.retry, - onAction: () => unawaited(widget.feedController.refreshCurrent()), + icon: Icons.wifi_off_rounded, + title: l10n.networkError, ), ); } - return PlayerPage( - key: const Key('recommend-feed-player'), - 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, + return ColoredBox( + color: AppColors.mediaBlack, + child: EmptyState( + key: const Key('recommend-feed-empty'), + icon: Icons.movie_outlined, + title: l10n.noStories, + ), ); } diff --git a/test/recommend/recommend_refresh_scheduling_test.dart b/test/recommend/recommend_refresh_scheduling_test.dart index 523adcc..cb9dbfc 100644 --- a/test/recommend/recommend_refresh_scheduling_test.dart +++ b/test/recommend/recommend_refresh_scheduling_test.dart @@ -11,6 +11,14 @@ import '../support/fake_catalog.dart'; import '../support/fake_category.dart'; 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', ( tester, ) async { @@ -52,6 +60,64 @@ void main() { 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', ( tester, ) async { @@ -96,6 +162,58 @@ Future<_RecommendFixture> _pumpRecommend( 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 { const _RecommendFixture({ 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.value( + value: categoryController, + child: RecommendPage( + feedController: controller, + active: true, + videoOnly: true, + onSearch: () {}, + ), + ), + ); + } +} + class _RecommendHarness extends StatefulWidget { const _RecommendHarness({ super.key, @@ -181,3 +325,36 @@ class _RecordingHomeFeedController extends HomeFeedController { 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 refreshCurrent() async { + refreshCalls++; + _isLoading = true; + _lastError = null; + notifyListeners(); + await Future.delayed(Duration.zero); + _isLoading = false; + _loaded = true; + if (fail) _lastError = 'network error'; + notifyListeners(); + return !fail; + } +}