From fe49527dd8ddbafe3ad77f694f2ca07c6571d8f9 Mon Sep 17 00:00:00 2001 From: Vicky <10212746+haifeng-jiang@user.noreply.gitee.com> Date: Thu, 29 Oct 2026 11:38:21 +0800 Subject: [PATCH] =?UTF-8?q?perf(discover):=20=E5=89=A7=E5=9C=BA=E4=B8=8E?= =?UTF-8?q?=E6=96=B0=E7=89=87=E9=A1=B5=E7=BD=91=E6=A0=BC=E6=94=B9=E4=B8=BA?= =?UTF-8?q?=E6=8C=89=E8=A1=8C=E6=87=92=E5=8A=A0=E8=BD=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 双列网格原来是两个 Column 一次性建出所有卡片,翻几页后上百张卡连同封面都留在树上; 现在改成按行的 SliverList,只构建可见行,剧场页/新片页/搜索结果的滚动容器一并换成 sliver。 搜索结果、加载失败等状态同步改成 sliver,滚动位置与下拉刷新行为保持不变。 --- lib/pages/discover/discover_view.dart | 84 ++++++++----- .../widgets/discover_search_results.dart | 33 +++-- lib/pages/discover/widgets/drama_grid.dart | 59 +++++---- lib/pages/new_releases/new_releases_page.dart | 116 +++++++++++------- test/discover/home_all_dramas_grid_test.dart | 5 +- test/discover/theater_pages_test.dart | 41 +++---- 6 files changed, 193 insertions(+), 145 deletions(-) diff --git a/lib/pages/discover/discover_view.dart b/lib/pages/discover/discover_view.dart index c32bf0b..fff3617 100644 --- a/lib/pages/discover/discover_view.dart +++ b/lib/pages/discover/discover_view.dart @@ -153,14 +153,19 @@ class _DiscoverPageState extends State { edgeOffset: _stickyHeaderHeight + 8, onRefresh: () => Future.wait([categories.load(), theater.refreshCurrent()]), - child: ListView( + child: CustomScrollView( controller: logic.scroll, physics: AppPullToRefresh.physics, - padding: EdgeInsets.only( - top: showHeader ? _stickyHeaderHeight : 0, - bottom: AppDimens.spaceXl, - ), - children: _content(l10n, categories), + slivers: [ + if (showHeader) + const SliverToBoxAdapter( + child: SizedBox(height: _stickyHeaderHeight), + ), + ..._content(l10n, categories), + const SliverToBoxAdapter( + child: SizedBox(height: AppDimens.spaceXl), + ), + ], ), ), ), @@ -228,12 +233,12 @@ class _DiscoverPageState extends State { ]; } if (categories.loading && categories.categories.isEmpty) { - return [_block(const CircularProgressIndicator(strokeWidth: 2))]; + return [_sliverBlock(const CircularProgressIndicator(strokeWidth: 2))]; } final error = categories.error; if (error != null) { return [ - _block( + _sliverBlock( EmptyState( key: const Key('home-categories-error'), icon: Icons.cloud_off_rounded, @@ -253,12 +258,12 @@ class _DiscoverPageState extends State { ]; } if (theater.loadingFirstPage) { - return [_block(const CircularProgressIndicator(strokeWidth: 2))]; + return [_sliverBlock(const CircularProgressIndicator(strokeWidth: 2))]; } final theaterError = theater.lastError; if (theaterError != null && theater.dramas.isEmpty) { return [ - _block( + _sliverBlock( EmptyState( key: const Key('home-categories-error'), icon: Icons.cloud_off_rounded, @@ -282,38 +287,49 @@ class _DiscoverPageState extends State { List _theaterBlocks(AppLocalizations l10n) { final dramas = theater.dramas; return [ - _toolbar(), + SliverToBoxAdapter(child: _toolbar()), if (dramas.isEmpty) - Padding( - padding: const EdgeInsets.symmetric(vertical: 40), - child: EmptyState( - key: const Key('discover-empty'), - icon: Icons.movie_filter_outlined, - title: l10n.noStories, - subtitle: l10n.emptyHint, + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 40), + child: EmptyState( + key: const Key('discover-empty'), + icon: Icons.movie_filter_outlined, + title: l10n.noStories, + subtitle: l10n.emptyHint, + ), ), ) else - DramaGrid(l10n: l10n, dramas: dramas, onOpen: _openDrama), + SliverDramaGrid( + key: const Key('home-all-dramas-grid'), + l10n: l10n, + dramas: dramas, + onOpen: _openDrama, + ), if (theater.loadingMore) - const Padding( - padding: EdgeInsets.symmetric(vertical: 20), - child: Center( - child: SizedBox.square( - key: Key('theater-load-more'), - dimension: 22, - child: CircularProgressIndicator(strokeWidth: 2), + const SliverToBoxAdapter( + child: Padding( + padding: EdgeInsets.symmetric(vertical: 20), + child: Center( + child: SizedBox.square( + key: Key('theater-load-more'), + dimension: 22, + child: CircularProgressIndicator(strokeWidth: 2), + ), ), ), ) else if (dramas.isNotEmpty && !theater.hasMore) - Padding( - key: const Key('paged-list-no-more'), - padding: const EdgeInsets.symmetric(vertical: 16), - child: Text( - l10n.noMoreItems, - textAlign: TextAlign.center, - style: const TextStyle(color: AppColors.textHint, fontSize: 12), + SliverToBoxAdapter( + child: Padding( + key: const Key('paged-list-no-more'), + padding: const EdgeInsets.symmetric(vertical: 16), + child: Text( + l10n.noMoreItems, + textAlign: TextAlign.center, + style: const TextStyle(color: AppColors.textHint, fontSize: 12), + ), ), ), ]; @@ -401,6 +417,8 @@ class _DiscoverPageState extends State { Navigator.of(context).push(MaterialPageRoute(builder: (_) => page)); } + Widget _sliverBlock(Widget child) => SliverToBoxAdapter(child: _block(child)); + Widget _block(Widget child) => ConstrainedBox( constraints: const BoxConstraints(minHeight: 280), child: Center(child: child), diff --git a/lib/pages/discover/widgets/discover_search_results.dart b/lib/pages/discover/widgets/discover_search_results.dart index 51b9281..076c427 100644 --- a/lib/pages/discover/widgets/discover_search_results.dart +++ b/lib/pages/discover/widgets/discover_search_results.dart @@ -10,6 +10,8 @@ import '../../../l10n/app_localizations.dart'; import 'drama_grid.dart'; /// 剧场搜索结果:加载、失败与空结果都留在同一页内。 +/// +/// 自身是 sliver,好让结果网格跟着剧场页一起懒加载。 class DiscoverSearchResults extends StatelessWidget { const DiscoverSearchResults({ super.key, @@ -26,16 +28,24 @@ class DiscoverSearchResults extends StatelessWidget { final VoidCallback onRetry; final ValueChanged onOpen; + static const EdgeInsets _topPadding = EdgeInsets.only(top: 12); + @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context)!; - return Padding( - padding: const EdgeInsets.only(top: 12), - child: _content(l10n), + if (!loading && error == null && results.isNotEmpty) { + return SliverPadding( + padding: _topPadding, + sliver: SliverDramaGrid(l10n: l10n, dramas: results, onOpen: onOpen), + ); + } + return SliverPadding( + padding: _topPadding, + sliver: SliverToBoxAdapter(child: _state(l10n)), ); } - Widget _content(AppLocalizations l10n) { + Widget _state(AppLocalizations l10n) { if (loading) { return const Padding( padding: EdgeInsets.symmetric(vertical: 80), @@ -61,14 +71,11 @@ class DiscoverSearchResults extends StatelessWidget { onAction: onRetry, ); } - if (results.isEmpty) { - return EmptyState( - key: const Key('discover-search-empty'), - icon: Icons.search_off_rounded, - title: l10n.noSearchResults, - subtitle: l10n.emptyHint, - ); - } - return DramaGrid(l10n: l10n, dramas: results, onOpen: onOpen); + return EmptyState( + key: const Key('discover-search-empty'), + icon: Icons.search_off_rounded, + title: l10n.noSearchResults, + subtitle: l10n.emptyHint, + ); } } diff --git a/lib/pages/discover/widgets/drama_grid.dart b/lib/pages/discover/widgets/drama_grid.dart index 9e52536..213a1cb 100644 --- a/lib/pages/discover/widgets/drama_grid.dart +++ b/lib/pages/discover/widgets/drama_grid.dart @@ -10,7 +10,6 @@ import '../../../core/theme/app_dimens.dart'; import '../../../core/widgets/remote_image.dart'; import '../../../l10n/app_localizations.dart'; -const int _columns = 2; const double _columnGap = 8; const double _cardGap = 14; const double _gridHorizontalPadding = 12; @@ -29,8 +28,10 @@ const Color _tagChipBackground = AppColors.raised; final RegExp _tagSplitter = RegExp(r'[,/、·]'); /// 剧场/分类页共用的双列剧目网格。 -class DramaGrid extends StatelessWidget { - const DramaGrid({ +/// +/// 按行懒加载:翻到第 N 页时只构建当前可见的几张卡,长列表不会把整本目录都留在树上。 +class SliverDramaGrid extends StatelessWidget { + const SliverDramaGrid({ super.key, required this.l10n, required this.dramas, @@ -43,33 +44,41 @@ class DramaGrid extends StatelessWidget { @override Widget build(BuildContext context) { - return Padding( - key: const Key('home-all-dramas-section'), + final rowCount = (dramas.length + 1) ~/ 2; + return SliverPadding( padding: const EdgeInsets.symmetric(horizontal: _gridHorizontalPadding), - child: Row( - key: const Key('home-all-dramas-grid'), - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded(child: _column(0)), - const SizedBox(width: _columnGap), - Expanded(child: _column(1)), - ], + sliver: SliverList( + delegate: SliverChildBuilderDelegate((context, row) { + final left = dramas[row * 2]; + final rightIndex = row * 2 + 1; + final right = rightIndex < dramas.length ? dramas[rightIndex] : null; + return Padding( + // 行间距沿用卡片间距,首行不需要。 + padding: EdgeInsets.only(top: row == 0 ? 0 : _cardGap), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded(child: _card(left)), + const SizedBox(width: _columnGap), + Expanded( + child: right == null + ? const SizedBox.shrink() + : _card(right), + ), + ], + ), + ); + }, childCount: rowCount), ), ); } - Widget _column(int start) { - final children = []; - for (var index = start; index < dramas.length; index += _columns) { - final drama = dramas[index]; - children.add( - DramaGridCard(l10n: l10n, drama: drama, onTap: () => onOpen(drama)), - ); - if (index + _columns < dramas.length) { - children.add(const SizedBox(height: _cardGap)); - } - } - return Column(children: children); + Widget _card(Drama drama) { + return DramaGridCard( + l10n: l10n, + drama: drama, + onTap: () => onOpen(drama), + ); } } diff --git a/lib/pages/new_releases/new_releases_page.dart b/lib/pages/new_releases/new_releases_page.dart index 7c0e565..021105b 100644 --- a/lib/pages/new_releases/new_releases_page.dart +++ b/lib/pages/new_releases/new_releases_page.dart @@ -135,64 +135,81 @@ class _NewReleasesPageState extends State { child: AppPullToRefresh( key: const Key('new-releases-refresh'), onRefresh: _refresh, - child: ListView( + child: CustomScrollView( key: const Key('new-releases-list'), controller: _scroll, physics: AppPullToRefresh.physics, - padding: const EdgeInsets.only(bottom: 24), - children: hero == null + slivers: hero == null ? [ - SizedBox( - height: 420, - child: Center( - child: Text( - l10n.noStories, - key: const Key('new-releases-empty'), - style: const TextStyle( - color: AppColors.muted, + SliverToBoxAdapter( + child: SizedBox( + height: 420, + child: Center( + child: Text( + l10n.noStories, + key: const Key('new-releases-empty'), + style: const TextStyle( + color: AppColors.muted, + ), ), ), ), ), ] : [ - Padding( - padding: const EdgeInsets.symmetric( - horizontal: 16, + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16, + ), + child: _heroCard(context, l10n, hero), ), - child: _heroCard(context, l10n, hero), ), - const SizedBox(height: 14), - const _FilmStrip(), - const SizedBox(height: 14), + const SliverToBoxAdapter( + child: SizedBox(height: 14), + ), + const SliverToBoxAdapter(child: _FilmStrip()), + const SliverToBoxAdapter( + child: SizedBox(height: 14), + ), if (more.isNotEmpty) ...[ - _moreHeader(l10n), - const SizedBox(height: 10), - SizedBox( - height: _rowHeight, - child: ListView.separated( - key: const Key('new-releases-row'), - scrollDirection: Axis.horizontal, - padding: const EdgeInsets.symmetric( - horizontal: 16, + SliverToBoxAdapter( + child: _moreHeader(l10n), + ), + const SliverToBoxAdapter( + child: SizedBox(height: 10), + ), + SliverToBoxAdapter( + child: SizedBox( + height: _rowHeight, + child: ListView.separated( + key: const Key('new-releases-row'), + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric( + horizontal: 16, + ), + itemCount: more.length, + separatorBuilder: (_, _) => + const SizedBox(width: 10), + itemBuilder: (context, index) => + _moreCard( + context, + l10n, + more[index], + index, + ), ), - itemCount: more.length, - separatorBuilder: (_, _) => - const SizedBox(width: 10), - itemBuilder: (context, index) => - _moreCard( - context, - l10n, - more[index], - index, - ), ), ), ], - const SizedBox(height: 20), - _allHeader(l10n), - const SizedBox(height: 10), - DramaGrid( + const SliverToBoxAdapter( + child: SizedBox(height: 20), + ), + SliverToBoxAdapter(child: _allHeader(l10n)), + const SliverToBoxAdapter( + child: SizedBox(height: 10), + ), + SliverDramaGrid( key: const Key('new-releases-all-grid'), l10n: l10n, dramas: visible, @@ -200,12 +217,17 @@ class _NewReleasesPageState extends State { playDramaFromCatalog(context, drama), ), ), - PagedLoadFooter( - loading: pager.loading, - hasMore: pager.hasMore, - hasItems: visible.isNotEmpty, - loadMoreKey: const Key( - 'new-releases-load-more', + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.only(bottom: 24), + child: PagedLoadFooter( + loading: pager.loading, + hasMore: pager.hasMore, + hasItems: visible.isNotEmpty, + loadMoreKey: const Key( + 'new-releases-load-more', + ), + ), ), ), ], diff --git a/test/discover/home_all_dramas_grid_test.dart b/test/discover/home_all_dramas_grid_test.dart index c4843c7..ce37f02 100644 --- a/test/discover/home_all_dramas_grid_test.dart +++ b/test/discover/home_all_dramas_grid_test.dart @@ -384,7 +384,7 @@ void main() { expect(refresh.edgeOffset, 52); expect( - tester.widget(_verticalFeed).physics, + tester.widget(_verticalFeed).physics, AppPullToRefresh.physics, ); await tester.drag(_verticalFeed, const Offset(0, 240)); @@ -396,7 +396,8 @@ void main() { } final Finder _verticalFeed = find.byWidgetPredicate( - (widget) => widget is ListView && widget.scrollDirection == Axis.vertical, + (widget) => + widget is CustomScrollView && widget.scrollDirection == Axis.vertical, ); Future _pumpTheater( diff --git a/test/discover/theater_pages_test.dart b/test/discover/theater_pages_test.dart index 05d03e7..9c57d07 100644 --- a/test/discover/theater_pages_test.dart +++ b/test/discover/theater_pages_test.dart @@ -8,6 +8,7 @@ import 'package:fruitshorts/core/data/models/home_feed.dart'; import 'package:fruitshorts/core/theme/app_colors.dart'; import 'package:fruitshorts/core/widgets/app_pull_to_refresh.dart'; import 'package:fruitshorts/pages/discover/discover_view.dart'; +import 'package:fruitshorts/pages/discover/widgets/drama_grid.dart'; import 'package:fruitshorts/core/data/repositories/favorite_repository.dart'; import 'package:fruitshorts/core/data/repositories/playback_repository.dart'; import 'package:fruitshorts/core/data/repositories/watch_history_repository.dart'; @@ -234,25 +235,26 @@ void main() { await tester.pumpAndSettle(); final list = find.byKey(const Key('new-releases-list')); - expect(tester.widget(list).physics, AppPullToRefresh.physics); - await tester.drag(list, const Offset(0, -400)); - await tester.pump(); - expect(_countKeys('home-drama-card-'), 20); + expect( + tester.widget(list).physics, + AppPullToRefresh.physics, + ); + // 网格懒加载后只保留可见行,页数直接看传给网格的剧目数。 + List gridDramas() => tester + .widget(find.byKey(const Key('new-releases-all-grid'))) + .dramas; + expect(gridDramas(), hasLength(20)); + + // jumpTo 不发滚动通知,滑到底后再拖一下才会触发加载。 final position = _scrollPosition(tester, const Key('new-releases-list')); position.jumpTo(position.maxScrollExtent); await tester.pump(); - expect(find.byKey(const Key('new-releases-load-more')), findsOneWidget); - await tester.pump(const Duration(milliseconds: 299)); - expect(_countKeys('home-drama-card-'), 20); - await tester.pump(const Duration(milliseconds: 1)); + await tester.drag(list, const Offset(0, -60)); await tester.pump(); - expect(_countKeys('home-drama-card-'), 40); - + expect(find.byKey(const Key('new-releases-load-more')), findsOneWidget); await tester.pumpAndSettle(); - position.jumpTo(position.maxScrollExtent); - await tester.pumpAndSettle(); - expect(find.byKey(const Key('new-releases-no-more')), findsNothing); + expect(gridDramas(), hasLength(40)); position.jumpTo(0); await tester.pump(); @@ -262,10 +264,7 @@ void main() { await tester.pumpAndSettle(); expect(repository.fetchCalls, beforeRefresh + 1); expect(position.pixels, 0); - - await tester.drag(list, const Offset(0, -400)); - await tester.pump(); - expect(_countKeys('home-drama-card-'), 20); + expect(gridDramas(), hasLength(20)); }); testWidgets('should_refresh_reserve_without_load_more', (tester) async { @@ -327,14 +326,6 @@ ScrollPosition _scrollPosition(WidgetTester tester, Key listKey) { return tester.state(scrollable.first).position; } -int _countKeys(String prefix) => find - .byWidgetPredicate((widget) { - final key = widget.key; - return key is ValueKey && key.value.startsWith(prefix); - }) - .evaluate() - .length; - class _CountingCatalogRepository extends FakeCatalogRepository { _CountingCatalogRepository({super.dramas});