merge: 修正 ChangeNotifier 监听生命周期

This commit is contained in:
Zhou Weidong committed 2026-10-22 19:14:41 +08:00
commit 2cfa655750
7 files changed
+286 -269

No files matched your search

+7
View File
@@ -1131,3 +1131,10 @@ $ flutter test
- 首页移除父级 feed 监听,网格、剧单和全部短剧改为局部监听,刷新时保留旧数据及原滚动位置。 - 首页移除父级 feed 监听,网格、剧单和全部短剧改为局部监听,刷新时保留旧数据及原滚动位置。
- 剧单、全部短剧、追剧沿用相同刷新策略;首帧加载与刷新指示器分离。 - 剧单、全部短剧、追剧沿用相同刷新策略;首帧加载与刷新指示器分离。
- 按当前流程未执行 analyze/test。 - 按当前流程未执行 analyze/test。
## 2026-10-22 ChangeNotifier 监听生命周期排查
- 全仓核对手动 add/remove;首页、推荐和追剧改为内容区单实例 ListenableBuilder,避免重复订阅。
- 播放器进度监听改为具名回调,在切换、重建会话和 dispose 时解绑;评论输入弹窗补 controller 更换迁移。
- `_onFeedChanged` 已无源码调用链,报错来源为热重载前注册的旧 tear-off;需完全关闭 App 后冷启动。
- 按当前流程未执行 analyze/test。
+92 -98
View File
@@ -105,22 +105,25 @@ class _HomeFeedGridState extends State<HomeFeedGrid> {
walletBadge: walletBadge, walletBadge: walletBadge,
), ),
Expanded( Expanded(
child: PageView.builder( child: ListenableBuilder(
key: const Key('home-primary-page-view'), listenable: widget.feedController,
controller: _pageController, builder: (context, _) => PageView.builder(
itemCount: HomePrimaryTab.values.length, key: const Key('home-primary-page-view'),
onPageChanged: (index) => setState( controller: _pageController,
() => _selectedPrimary = HomePrimaryTab.values[index], itemCount: HomePrimaryTab.values.length,
), onPageChanged: (index) => setState(
itemBuilder: (context, index) => _KeepAlivePage( () => _selectedPrimary = HomePrimaryTab.values[index],
key: PageStorageKey(
'home-primary-page-${HomePrimaryTab.values[index].name}',
), ),
child: _tabGrid( itemBuilder: (context, index) => _KeepAlivePage(
context, key: PageStorageKey(
l10n, 'home-primary-page-${HomePrimaryTab.values[index].name}',
tab: HomePrimaryTab.values[index], ),
shortcuts: shortcuts, child: _tabGrid(
context,
l10n,
tab: HomePrimaryTab.values[index],
shortcuts: shortcuts,
),
), ),
), ),
), ),
@@ -152,90 +155,81 @@ class _HomeFeedGridState extends State<HomeFeedGrid> {
required HomePrimaryTab tab, required HomePrimaryTab tab,
required List<HomeGridCategoryShortcut> shortcuts, required List<HomeGridCategoryShortcut> shortcuts,
}) { }) {
return ListenableBuilder( if (widget.feedController.loadingFirstPage) {
listenable: widget.feedController, return const Center(
builder: (context, _) { child: SizedBox.square(
if (widget.feedController.loadingFirstPage) { dimension: 24,
return const Center( child: CircularProgressIndicator(strokeWidth: 2),
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<ScrollNotification>(
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<ScrollNotification>(
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,
),
),
),
],
),
),
);
},
); );
}, },
); );
+54 -29
View File
@@ -69,6 +69,9 @@ class PlayerLogic extends ChangeNotifier {
VideoPlayerPort? trackedPlayer; VideoPlayerPort? trackedPlayer;
Episode? trackedEpisode; Episode? trackedEpisode;
Drama? trackedDrama; Drama? trackedDrama;
int? _trackedPlayerIndex;
bool _completionHandled = false;
bool _completionProgressSaved = false;
DateTime lastProgressWrite = DateTime.fromMillisecondsSinceEpoch(0); DateTime lastProgressWrite = DateTime.fromMillisecondsSinceEpoch(0);
/// 进度上报节流:契约建议每 10 秒一次 /// 进度上报节流:契约建议每 10 秒一次
@@ -317,6 +320,7 @@ class PlayerLogic extends ChangeNotifier {
unawaited(saveCurrentProgress()); unawaited(saveCurrentProgress());
final previous = session; final previous = session;
sessionEpisodes = List<Episode>.of(nextEpisodes); sessionEpisodes = List<Episode>.of(nextEpisodes);
_detachTrackedPlayerListener();
trackedPlayer = null; trackedPlayer = null;
trackedEpisode = null; trackedEpisode = null;
trackedDrama = null; trackedDrama = null;
@@ -601,6 +605,7 @@ class PlayerLogic extends ChangeNotifier {
_disposed = true; _disposed = true;
pictureInPicture.setStateListener(null); pictureInPicture.setStateListener(null);
unawaited(saveCurrentProgress()); unawaited(saveCurrentProgress());
_detachTrackedPlayerListener();
if (_started) { if (_started) {
session.setRouteVisible(false); session.setRouteVisible(false);
cancelScrub(); cancelScrub();
@@ -696,46 +701,66 @@ class PlayerLogic extends ChangeNotifier {
await player.controller.setVolume(muted ? 0 : 1); await player.controller.setVolume(muted ? 0 : 1);
await player.controller.setPlaybackSpeed(playbackSpeed); await player.controller.setPlaybackSpeed(playbackSpeed);
if (session.active != player) return; if (session.active != player) return;
_detachTrackedPlayerListener();
trackedPlayer = player; trackedPlayer = player;
trackedEpisode = episode; trackedEpisode = episode;
trackedDrama = drama; trackedDrama = drama;
_trackedPlayerIndex = index;
_completionHandled = false;
_completionProgressSaved = false;
lastProgressWrite = DateTime.now(); lastProgressWrite = DateTime.now();
var completionHandled = false; player.controller.addListener(_handleTrackedPlayerChanged);
var completionProgressSaved = false; }
player.controller.addListener(() {
if (session.active != player) return; void _detachTrackedPlayerListener() {
final snapshot = _readPlaybackSnapshot(player); trackedPlayer?.controller.removeListener(_handleTrackedPlayerChanged);
if (snapshot == null) return; _trackedPlayerIndex = null;
_cachePlaybackPosition(drama, episode, snapshot); }
if (snapshot.completed && !completionProgressSaved) {
completionProgressSaved = true; void _handleTrackedPlayerChanged() {
unawaited( if (_disposed) return;
queueProgress( final player = trackedPlayer;
drama, final episode = trackedEpisode;
episode, final drama = trackedDrama;
snapshot.duration, final index = _trackedPlayerIndex;
duration: snapshot.duration, if (player == null ||
finished: true, episode == null ||
), drama == null ||
); index == null ||
} session.active != player) {
if (!completionHandled && return;
snapshot.completed && }
session.canAutoAdvance && final snapshot = _readPlaybackSnapshot(player);
session.currentIndex == index) { if (snapshot == null) return;
completionHandled = true; _cachePlaybackPosition(drama, episode, snapshot);
unawaited(advanceAfterEnd(player, index)); if (snapshot.completed && !_completionProgressSaved) {
} _completionProgressSaved = true;
if (!shouldReportProgress(DateTime.now())) return;
unawaited( unawaited(
queueProgress( queueProgress(
drama, drama,
episode, episode,
snapshot.position, snapshot.duration,
duration: 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<void> advanceAfterEnd(VideoPlayerPort player, int index) async { Future<void> advanceAfterEnd(VideoPlayerPort player, int index) async {
@@ -79,6 +79,15 @@ class _CommentComposerSheetState extends State<CommentComposerSheet> {
_trackKeyboardInset(_keyboardHeight(context)); _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<void> _prepareInput() async { Future<void> _prepareInput() async {
if (_isAndroid) { if (_isAndroid) {
await _keyboardInset.start(); await _keyboardInset.start();
+22 -19
View File
@@ -56,25 +56,28 @@ class _RecommendPageState extends State<RecommendPage> {
children: [ children: [
_header(l10n), _header(l10n),
Expanded( Expanded(
child: IndexedStack( child: ListenableBuilder(
index: _tab.index, listenable: Listenable.merge([
children: [ widget.feedController,
ListenableBuilder( categories,
listenable: widget.feedController, ]),
builder: (context, _) => _recommendFeed(context), builder: (context, _) => IndexedStack(
), index: _tab.index,
RecommendSectionsView( children: [
feedController: widget.feedController, _recommendFeed(context),
categories: categories, RecommendSectionsView(
onOpenDrama: _openDrama, feedController: widget.feedController,
onMore: _handleMore, categories: categories,
), onOpenDrama: _openDrama,
AllDramasFilterGrid( onMore: _handleMore,
feedController: widget.feedController, ),
onOpenDrama: _openDrama, AllDramasFilterGrid(
onOpenFilter: widget.onOpenFilter, feedController: widget.feedController,
), onOpenDrama: _openDrama,
], onOpenFilter: widget.onOpenFilter,
),
],
),
), ),
), ),
], ],
@@ -44,6 +44,7 @@ class _AllDramasFilterGridState extends State<AllDramasFilterGrid> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!; final l10n = AppLocalizations.of(context)!;
final dramas = _filtered();
return LayoutBuilder( return LayoutBuilder(
builder: (context, constraints) { builder: (context, constraints) {
final itemWidth = final itemWidth =
@@ -65,69 +66,55 @@ class _AllDramasFilterGridState extends State<AllDramasFilterGrid> {
key: const Key('all-dramas-scroll'), key: const Key('all-dramas-scroll'),
physics: AppPullToRefresh.physics, physics: AppPullToRefresh.physics,
slivers: [ slivers: [
SliverToBoxAdapter( SliverToBoxAdapter(child: _filters(l10n)),
child: ListenableBuilder( if (dramas.isEmpty)
listenable: widget.feedController, SliverFillRemaining(
builder: (context, _) => _filters(l10n), 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,
),
),
), ),
), SliverToBoxAdapter(
ListenableBuilder( child: Padding(
listenable: widget.feedController, padding: const EdgeInsets.only(bottom: AppDimens.spaceXl),
builder: (context, _) { child: PagedListFooter(
final dramas = _filtered(); loading: widget.feedController.loadingMore,
if (dramas.isEmpty) { hasMore: widget.feedController.hasMore,
return SliverFillRemaining( hasItems: dramas.isNotEmpty,
hasScrollBody: false, errorMessage: widget.feedController.lastError == null
child: EmptyState( ? null
key: const Key('all-dramas-empty'), : l10n.catalogError,
icon: Icons.movie_filter_outlined, onRetry: widget.feedController.loadMore,
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,
),
), ),
), ),
), ),
@@ -33,68 +33,60 @@ class RecommendSectionsView extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return ListenableBuilder( final l10n = AppLocalizations.of(context)!;
listenable: Listenable.merge([feedController, categories]), final source = feedController.dramas;
builder: (context, _) { final hits = [...source]
final l10n = AppLocalizations.of(context)!; ..sort((a, b) => b.viewCount.compareTo(a.viewCount));
final source = feedController.dramas; final newest = [...source]
final hits = [...source] ..sort((a, b) {
..sort((a, b) => b.viewCount.compareTo(a.viewCount)); final left = publishedAtForDrama(a);
final newest = [...source] final right = publishedAtForDrama(b);
..sort((a, b) { if (left == null && right == null) return b.id.compareTo(a.id);
final left = publishedAtForDrama(a); if (left == null) return 1;
final right = publishedAtForDrama(b); if (right == null) return -1;
if (left == null && right == null) return b.id.compareTo(a.id); return right.compareTo(left);
if (left == null) return 1; });
if (right == null) return -1; return AppPullToRefresh(
return right.compareTo(left); key: const Key('recommend-sections-refresh'),
}); onRefresh: () async {
return AppPullToRefresh( await Future.wait([feedController.refreshCurrent(), categories.load()]);
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),
),
],
),
);
}, },
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),
),
],
),
); );
} }