From 8936c3d6eb969ba20a737d98588542cf1a7d397f Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Thu, 22 Oct 2026 19:14:35 +0800 Subject: [PATCH] =?UTF-8?q?fix(client):=20=E4=BF=AE=E6=AD=A3=20ChangeNotif?= =?UTF-8?q?ier=20=E7=9B=91=E5=90=AC=E7=94=9F=E5=91=BD=E5=91=A8=E6=9C=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/plans/PROGRESS.md | 7 + lib/pages/home/widgets/home_feed_grid.dart | 190 +++++++++--------- lib/pages/player/player_logic.dart | 83 +++++--- .../widgets/comment_composer_sheet.dart | 9 + lib/pages/recommend/recommend_page.dart | 41 ++-- .../widgets/all_dramas_filter_grid.dart | 111 +++++----- .../widgets/recommend_sections_view.dart | 114 +++++------ 7 files changed, 286 insertions(+), 269 deletions(-) diff --git a/docs/plans/PROGRESS.md b/docs/plans/PROGRESS.md index cd8c8a2..ceec043 100644 --- a/docs/plans/PROGRESS.md +++ b/docs/plans/PROGRESS.md @@ -1131,3 +1131,10 @@ $ flutter test - 首页移除父级 feed 监听,网格、剧单和全部短剧改为局部监听,刷新时保留旧数据及原滚动位置。 - 剧单、全部短剧、追剧沿用相同刷新策略;首帧加载与刷新指示器分离。 - 按当前流程未执行 analyze/test。 + +## 2026-10-22 ChangeNotifier 监听生命周期排查 + +- 全仓核对手动 add/remove;首页、推荐和追剧改为内容区单实例 ListenableBuilder,避免重复订阅。 +- 播放器进度监听改为具名回调,在切换、重建会话和 dispose 时解绑;评论输入弹窗补 controller 更换迁移。 +- `_onFeedChanged` 已无源码调用链,报错来源为热重载前注册的旧 tear-off;需完全关闭 App 后冷启动。 +- 按当前流程未执行 analyze/test。 diff --git a/lib/pages/home/widgets/home_feed_grid.dart b/lib/pages/home/widgets/home_feed_grid.dart index 50d3f60..97f07a1 100644 --- a/lib/pages/home/widgets/home_feed_grid.dart +++ b/lib/pages/home/widgets/home_feed_grid.dart @@ -105,22 +105,25 @@ class _HomeFeedGridState extends State { walletBadge: walletBadge, ), Expanded( - child: PageView.builder( - key: const Key('home-primary-page-view'), - controller: _pageController, - itemCount: HomePrimaryTab.values.length, - onPageChanged: (index) => setState( - () => _selectedPrimary = HomePrimaryTab.values[index], - ), - itemBuilder: (context, index) => _KeepAlivePage( - key: PageStorageKey( - 'home-primary-page-${HomePrimaryTab.values[index].name}', + child: ListenableBuilder( + listenable: widget.feedController, + builder: (context, _) => PageView.builder( + key: const Key('home-primary-page-view'), + controller: _pageController, + itemCount: HomePrimaryTab.values.length, + onPageChanged: (index) => setState( + () => _selectedPrimary = HomePrimaryTab.values[index], ), - child: _tabGrid( - context, - l10n, - tab: HomePrimaryTab.values[index], - shortcuts: shortcuts, + itemBuilder: (context, index) => _KeepAlivePage( + key: PageStorageKey( + 'home-primary-page-${HomePrimaryTab.values[index].name}', + ), + child: _tabGrid( + context, + l10n, + tab: HomePrimaryTab.values[index], + shortcuts: shortcuts, + ), ), ), ), @@ -152,90 +155,81 @@ class _HomeFeedGridState extends State { required HomePrimaryTab tab, required List shortcuts, }) { - return ListenableBuilder( - listenable: widget.feedController, - builder: (context, _) { - if (widget.feedController.loadingFirstPage) { - return const Center( - child: SizedBox.square( - dimension: 24, - child: CircularProgressIndicator(strokeWidth: 2), + if (widget.feedController.loadingFirstPage) { + return const Center( + child: SizedBox.square( + dimension: 24, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ); + } + final tag = _selectedTags[tab]; + final dramas = _dramasFor(tab, tag, shortcuts); + return LayoutBuilder( + builder: (context, constraints) { + final itemWidth = + (constraints.maxWidth - + AppDimens.feedPageHorizontal * 2 - + AppDimens.homeGridGap * 2) / + 3; + final itemExtent = + itemWidth / AppDimens.cardCoverAspectRatio + + AppDimens.homeCardBodyExtent; + return NotificationListener( + onNotification: _onScroll, + child: AppPullToRefresh( + key: const Key('home-grid-refresh'), + onRefresh: _refresh, + child: CustomScrollView( + key: const Key('home-grid-scroll'), + controller: _scrollControllers[tab], + physics: AppPullToRefresh.physics, + slivers: [ + if (dramas.isEmpty) + SliverFillRemaining( + hasScrollBody: false, + child: _emptyState(l10n, tab, tag), + ) + else + SliverPadding( + padding: const EdgeInsets.symmetric( + horizontal: AppDimens.feedPageHorizontal, + ), + sliver: SliverGrid( + key: const Key('home-grid-cards'), + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 3, + crossAxisSpacing: AppDimens.homeGridGap, + mainAxisSpacing: AppDimens.homeGridRowGap, + mainAxisExtent: itemExtent, + ), + delegate: SliverChildBuilderDelegate( + (context, index) => HomeFeedCard( + drama: dramas[index], + onTap: () => widget.onOpenDrama(dramas[index]), + ), + childCount: dramas.length, + ), + ), + ), + if (dramas.isNotEmpty) + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.only(bottom: AppDimens.spaceXl), + child: PagedListFooter( + loading: widget.feedController.loadingMore, + hasMore: widget.feedController.hasMore, + hasItems: dramas.isNotEmpty, + errorMessage: widget.feedController.lastError == null + ? null + : l10n.catalogError, + onRetry: widget.feedController.loadMore, + ), + ), + ), + ], ), - ); - } - final tag = _selectedTags[tab]; - final dramas = _dramasFor(tab, tag, shortcuts); - return LayoutBuilder( - builder: (context, constraints) { - final itemWidth = - (constraints.maxWidth - - AppDimens.feedPageHorizontal * 2 - - AppDimens.homeGridGap * 2) / - 3; - final itemExtent = - itemWidth / AppDimens.cardCoverAspectRatio + - AppDimens.homeCardBodyExtent; - return NotificationListener( - onNotification: _onScroll, - child: AppPullToRefresh( - key: const Key('home-grid-refresh'), - onRefresh: _refresh, - child: CustomScrollView( - key: const Key('home-grid-scroll'), - controller: _scrollControllers[tab], - physics: AppPullToRefresh.physics, - slivers: [ - if (dramas.isEmpty) - SliverFillRemaining( - hasScrollBody: false, - child: _emptyState(l10n, tab, tag), - ) - else - SliverPadding( - padding: const EdgeInsets.symmetric( - horizontal: AppDimens.feedPageHorizontal, - ), - sliver: SliverGrid( - key: const Key('home-grid-cards'), - gridDelegate: - SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 3, - crossAxisSpacing: AppDimens.homeGridGap, - mainAxisSpacing: AppDimens.homeGridRowGap, - mainAxisExtent: itemExtent, - ), - delegate: SliverChildBuilderDelegate( - (context, index) => HomeFeedCard( - drama: dramas[index], - onTap: () => widget.onOpenDrama(dramas[index]), - ), - childCount: dramas.length, - ), - ), - ), - if (dramas.isNotEmpty) - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.only( - bottom: AppDimens.spaceXl, - ), - child: PagedListFooter( - loading: widget.feedController.loadingMore, - hasMore: widget.feedController.hasMore, - hasItems: dramas.isNotEmpty, - errorMessage: - widget.feedController.lastError == null - ? null - : l10n.catalogError, - onRetry: widget.feedController.loadMore, - ), - ), - ), - ], - ), - ), - ); - }, + ), ); }, ); diff --git a/lib/pages/player/player_logic.dart b/lib/pages/player/player_logic.dart index a9c44e8..3ad74ec 100644 --- a/lib/pages/player/player_logic.dart +++ b/lib/pages/player/player_logic.dart @@ -69,6 +69,9 @@ class PlayerLogic extends ChangeNotifier { VideoPlayerPort? trackedPlayer; Episode? trackedEpisode; Drama? trackedDrama; + int? _trackedPlayerIndex; + bool _completionHandled = false; + bool _completionProgressSaved = false; DateTime lastProgressWrite = DateTime.fromMillisecondsSinceEpoch(0); /// 进度上报节流:契约建议每 10 秒一次 @@ -317,6 +320,7 @@ class PlayerLogic extends ChangeNotifier { unawaited(saveCurrentProgress()); final previous = session; sessionEpisodes = List.of(nextEpisodes); + _detachTrackedPlayerListener(); trackedPlayer = null; trackedEpisode = null; trackedDrama = null; @@ -601,6 +605,7 @@ class PlayerLogic extends ChangeNotifier { _disposed = true; pictureInPicture.setStateListener(null); unawaited(saveCurrentProgress()); + _detachTrackedPlayerListener(); if (_started) { session.setRouteVisible(false); cancelScrub(); @@ -696,46 +701,66 @@ class PlayerLogic extends ChangeNotifier { await player.controller.setVolume(muted ? 0 : 1); await player.controller.setPlaybackSpeed(playbackSpeed); if (session.active != player) return; + _detachTrackedPlayerListener(); trackedPlayer = player; trackedEpisode = episode; trackedDrama = drama; + _trackedPlayerIndex = index; + _completionHandled = false; + _completionProgressSaved = false; lastProgressWrite = DateTime.now(); - var completionHandled = false; - var completionProgressSaved = false; - player.controller.addListener(() { - if (session.active != player) return; - final snapshot = _readPlaybackSnapshot(player); - if (snapshot == null) return; - _cachePlaybackPosition(drama, episode, snapshot); - if (snapshot.completed && !completionProgressSaved) { - completionProgressSaved = true; - unawaited( - queueProgress( - drama, - episode, - snapshot.duration, - duration: snapshot.duration, - finished: true, - ), - ); - } - if (!completionHandled && - snapshot.completed && - session.canAutoAdvance && - session.currentIndex == index) { - completionHandled = true; - unawaited(advanceAfterEnd(player, index)); - } - if (!shouldReportProgress(DateTime.now())) return; + player.controller.addListener(_handleTrackedPlayerChanged); + } + + void _detachTrackedPlayerListener() { + trackedPlayer?.controller.removeListener(_handleTrackedPlayerChanged); + _trackedPlayerIndex = null; + } + + void _handleTrackedPlayerChanged() { + if (_disposed) return; + final player = trackedPlayer; + final episode = trackedEpisode; + final drama = trackedDrama; + final index = _trackedPlayerIndex; + if (player == null || + episode == null || + drama == null || + index == null || + session.active != player) { + return; + } + final snapshot = _readPlaybackSnapshot(player); + if (snapshot == null) return; + _cachePlaybackPosition(drama, episode, snapshot); + if (snapshot.completed && !_completionProgressSaved) { + _completionProgressSaved = true; unawaited( queueProgress( drama, episode, - snapshot.position, + snapshot.duration, duration: snapshot.duration, + finished: true, ), ); - }); + } + if (!_completionHandled && + snapshot.completed && + session.canAutoAdvance && + session.currentIndex == index) { + _completionHandled = true; + unawaited(advanceAfterEnd(player, index)); + } + if (!shouldReportProgress(DateTime.now())) return; + unawaited( + queueProgress( + drama, + episode, + snapshot.position, + duration: snapshot.duration, + ), + ); } Future advanceAfterEnd(VideoPlayerPort player, int index) async { diff --git a/lib/pages/player/widgets/comment_composer_sheet.dart b/lib/pages/player/widgets/comment_composer_sheet.dart index 8e7ea04..9f9ce82 100644 --- a/lib/pages/player/widgets/comment_composer_sheet.dart +++ b/lib/pages/player/widgets/comment_composer_sheet.dart @@ -79,6 +79,15 @@ class _CommentComposerSheetState extends State { _trackKeyboardInset(_keyboardHeight(context)); } + @override + void didUpdateWidget(covariant CommentComposerSheet oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.controller != widget.controller) { + oldWidget.controller.removeListener(_onControllerChanged); + widget.controller.addListener(_onControllerChanged); + } + } + Future _prepareInput() async { if (_isAndroid) { await _keyboardInset.start(); diff --git a/lib/pages/recommend/recommend_page.dart b/lib/pages/recommend/recommend_page.dart index fb723a5..803e7f7 100644 --- a/lib/pages/recommend/recommend_page.dart +++ b/lib/pages/recommend/recommend_page.dart @@ -56,25 +56,28 @@ class _RecommendPageState extends State { children: [ _header(l10n), Expanded( - child: IndexedStack( - index: _tab.index, - children: [ - ListenableBuilder( - listenable: widget.feedController, - builder: (context, _) => _recommendFeed(context), - ), - RecommendSectionsView( - feedController: widget.feedController, - categories: categories, - onOpenDrama: _openDrama, - onMore: _handleMore, - ), - AllDramasFilterGrid( - feedController: widget.feedController, - onOpenDrama: _openDrama, - onOpenFilter: widget.onOpenFilter, - ), - ], + child: ListenableBuilder( + listenable: Listenable.merge([ + widget.feedController, + categories, + ]), + builder: (context, _) => IndexedStack( + index: _tab.index, + children: [ + _recommendFeed(context), + RecommendSectionsView( + feedController: widget.feedController, + categories: categories, + onOpenDrama: _openDrama, + onMore: _handleMore, + ), + AllDramasFilterGrid( + feedController: widget.feedController, + onOpenDrama: _openDrama, + onOpenFilter: widget.onOpenFilter, + ), + ], + ), ), ), ], diff --git a/lib/pages/recommend/widgets/all_dramas_filter_grid.dart b/lib/pages/recommend/widgets/all_dramas_filter_grid.dart index 10d2d33..059e47b 100644 --- a/lib/pages/recommend/widgets/all_dramas_filter_grid.dart +++ b/lib/pages/recommend/widgets/all_dramas_filter_grid.dart @@ -44,6 +44,7 @@ class _AllDramasFilterGridState extends State { @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context)!; + final dramas = _filtered(); return LayoutBuilder( builder: (context, constraints) { final itemWidth = @@ -65,69 +66,55 @@ class _AllDramasFilterGridState extends State { key: const Key('all-dramas-scroll'), physics: AppPullToRefresh.physics, slivers: [ - SliverToBoxAdapter( - child: ListenableBuilder( - listenable: widget.feedController, - builder: (context, _) => _filters(l10n), + SliverToBoxAdapter(child: _filters(l10n)), + if (dramas.isEmpty) + SliverFillRemaining( + hasScrollBody: false, + child: EmptyState( + key: const Key('all-dramas-empty'), + icon: Icons.movie_filter_outlined, + title: l10n.homeGridFilterEmpty, + actionLabel: l10n.homeGridClearFilter, + onAction: () => setState(() { + _region = 'all'; + _audio = 'all'; + _genre = null; + }), + ), + ) + else + SliverPadding( + padding: const EdgeInsets.symmetric( + horizontal: AppDimens.feedPageHorizontal, + ), + sliver: SliverGrid( + key: const Key('all-dramas-grid'), + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 3, + crossAxisSpacing: AppDimens.homeGridGap, + mainAxisSpacing: AppDimens.homeGridRowGap, + mainAxisExtent: itemExtent, + ), + delegate: SliverChildBuilderDelegate( + (context, index) => HomeFeedCard( + drama: dramas[index], + onTap: () => widget.onOpenDrama(dramas[index]), + ), + childCount: dramas.length, + ), + ), ), - ), - ListenableBuilder( - listenable: widget.feedController, - builder: (context, _) { - final dramas = _filtered(); - if (dramas.isEmpty) { - return SliverFillRemaining( - hasScrollBody: false, - child: EmptyState( - key: const Key('all-dramas-empty'), - icon: Icons.movie_filter_outlined, - title: l10n.homeGridFilterEmpty, - actionLabel: l10n.homeGridClearFilter, - onAction: () => setState(() { - _region = 'all'; - _audio = 'all'; - _genre = null; - }), - ), - ); - } - return SliverPadding( - padding: const EdgeInsets.symmetric( - horizontal: AppDimens.feedPageHorizontal, - ), - sliver: SliverGrid( - key: const Key('all-dramas-grid'), - gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 3, - crossAxisSpacing: AppDimens.homeGridGap, - mainAxisSpacing: AppDimens.homeGridRowGap, - mainAxisExtent: itemExtent, - ), - delegate: SliverChildBuilderDelegate( - (context, index) => HomeFeedCard( - drama: dramas[index], - onTap: () => widget.onOpenDrama(dramas[index]), - ), - childCount: dramas.length, - ), - ), - ); - }, - ), - ListenableBuilder( - listenable: widget.feedController, - builder: (context, _) => SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.only(bottom: AppDimens.spaceXl), - child: PagedListFooter( - loading: widget.feedController.loadingMore, - hasMore: widget.feedController.hasMore, - hasItems: _filtered().isNotEmpty, - errorMessage: widget.feedController.lastError == null - ? null - : l10n.catalogError, - onRetry: widget.feedController.loadMore, - ), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.only(bottom: AppDimens.spaceXl), + child: PagedListFooter( + loading: widget.feedController.loadingMore, + hasMore: widget.feedController.hasMore, + hasItems: dramas.isNotEmpty, + errorMessage: widget.feedController.lastError == null + ? null + : l10n.catalogError, + onRetry: widget.feedController.loadMore, ), ), ), diff --git a/lib/pages/recommend/widgets/recommend_sections_view.dart b/lib/pages/recommend/widgets/recommend_sections_view.dart index 4560103..139c48d 100644 --- a/lib/pages/recommend/widgets/recommend_sections_view.dart +++ b/lib/pages/recommend/widgets/recommend_sections_view.dart @@ -33,68 +33,60 @@ class RecommendSectionsView extends StatelessWidget { @override Widget build(BuildContext context) { - return ListenableBuilder( - listenable: Listenable.merge([feedController, categories]), - builder: (context, _) { - final l10n = AppLocalizations.of(context)!; - final source = feedController.dramas; - final hits = [...source] - ..sort((a, b) => b.viewCount.compareTo(a.viewCount)); - final newest = [...source] - ..sort((a, b) { - final left = publishedAtForDrama(a); - final right = publishedAtForDrama(b); - if (left == null && right == null) return b.id.compareTo(a.id); - if (left == null) return 1; - if (right == null) return -1; - return right.compareTo(left); - }); - return AppPullToRefresh( - key: const Key('recommend-sections-refresh'), - onRefresh: () async { - await Future.wait([ - feedController.refreshCurrent(), - categories.load(), - ]); - }, - child: ListView( - key: const Key('recommend-sections-scroll'), - physics: AppPullToRefresh.physics, - padding: const EdgeInsets.only(bottom: AppDimens.spaceXl), - children: [ - _section( - context, - l10n, - key: 'hits', - title: l10n.weeklyHitsTitle, - subtitle: l10n.weeklyHitsSubtitle, - dramas: hits.take(10).toList(), - onMore: () => onMore(RecommendSectionKind.hits), - ), - const SizedBox(height: AppDimens.spaceXl), - _section( - context, - l10n, - key: 'new', - title: l10n.weeklyNewTitle, - subtitle: l10n.weeklyNewSubtitle, - dramas: newest.take(10).toList(), - onMore: () => onMore(RecommendSectionKind.newReleases), - ), - const SizedBox(height: AppDimens.spaceXl), - _section( - context, - l10n, - key: 'costume', - title: l10n.costumeAwardsTitle, - subtitle: l10n.costumeAwardsSubtitle, - dramas: _costumeDramas(source), - onMore: () => onMore(RecommendSectionKind.costume), - ), - ], - ), - ); + final l10n = AppLocalizations.of(context)!; + final source = feedController.dramas; + final hits = [...source] + ..sort((a, b) => b.viewCount.compareTo(a.viewCount)); + final newest = [...source] + ..sort((a, b) { + final left = publishedAtForDrama(a); + final right = publishedAtForDrama(b); + if (left == null && right == null) return b.id.compareTo(a.id); + if (left == null) return 1; + if (right == null) return -1; + return right.compareTo(left); + }); + return AppPullToRefresh( + key: const Key('recommend-sections-refresh'), + onRefresh: () async { + await Future.wait([feedController.refreshCurrent(), categories.load()]); }, + child: ListView( + key: const Key('recommend-sections-scroll'), + physics: AppPullToRefresh.physics, + padding: const EdgeInsets.only(bottom: AppDimens.spaceXl), + children: [ + _section( + context, + l10n, + key: 'hits', + title: l10n.weeklyHitsTitle, + subtitle: l10n.weeklyHitsSubtitle, + dramas: hits.take(10).toList(), + onMore: () => onMore(RecommendSectionKind.hits), + ), + const SizedBox(height: AppDimens.spaceXl), + _section( + context, + l10n, + key: 'new', + title: l10n.weeklyNewTitle, + subtitle: l10n.weeklyNewSubtitle, + dramas: newest.take(10).toList(), + onMore: () => onMore(RecommendSectionKind.newReleases), + ), + const SizedBox(height: AppDimens.spaceXl), + _section( + context, + l10n, + key: 'costume', + title: l10n.costumeAwardsTitle, + subtitle: l10n.costumeAwardsSubtitle, + dramas: _costumeDramas(source), + onMore: () => onMore(RecommendSectionKind.costume), + ), + ], + ), ); }