perf(discover): 剧场与新片页网格改为按行懒加载
flutter-ci / test (push) Failing after 19m39s

双列网格原来是两个 Column 一次性建出所有卡片,翻几页后上百张卡连同封面都留在树上;
现在改成按行的 SliverList,只构建可见行,剧场页/新片页/搜索结果的滚动容器一并换成 sliver。
搜索结果、加载失败等状态同步改成 sliver,滚动位置与下拉刷新行为保持不变。
This commit is contained in:
Vicky authored and trae-solo-agent committed 2026-10-29 11:38:21 +08:00
1 parent 4aaa5bbd60
commit fe49527dd8
6 files changed
+193 -145

No files matched your search

+51 -33
View File
@@ -153,14 +153,19 @@ class _DiscoverPageState extends State<DiscoverPage> {
edgeOffset: _stickyHeaderHeight + 8, edgeOffset: _stickyHeaderHeight + 8,
onRefresh: () => onRefresh: () =>
Future.wait([categories.load(), theater.refreshCurrent()]), Future.wait([categories.load(), theater.refreshCurrent()]),
child: ListView( child: CustomScrollView(
controller: logic.scroll, controller: logic.scroll,
physics: AppPullToRefresh.physics, physics: AppPullToRefresh.physics,
padding: EdgeInsets.only( slivers: [
top: showHeader ? _stickyHeaderHeight : 0, if (showHeader)
bottom: AppDimens.spaceXl, const SliverToBoxAdapter(
), child: SizedBox(height: _stickyHeaderHeight),
children: _content(l10n, categories), ),
..._content(l10n, categories),
const SliverToBoxAdapter(
child: SizedBox(height: AppDimens.spaceXl),
),
],
), ),
), ),
), ),
@@ -228,12 +233,12 @@ class _DiscoverPageState extends State<DiscoverPage> {
]; ];
} }
if (categories.loading && categories.categories.isEmpty) { if (categories.loading && categories.categories.isEmpty) {
return [_block(const CircularProgressIndicator(strokeWidth: 2))]; return [_sliverBlock(const CircularProgressIndicator(strokeWidth: 2))];
} }
final error = categories.error; final error = categories.error;
if (error != null) { if (error != null) {
return [ return [
_block( _sliverBlock(
EmptyState( EmptyState(
key: const Key('home-categories-error'), key: const Key('home-categories-error'),
icon: Icons.cloud_off_rounded, icon: Icons.cloud_off_rounded,
@@ -253,12 +258,12 @@ class _DiscoverPageState extends State<DiscoverPage> {
]; ];
} }
if (theater.loadingFirstPage) { if (theater.loadingFirstPage) {
return [_block(const CircularProgressIndicator(strokeWidth: 2))]; return [_sliverBlock(const CircularProgressIndicator(strokeWidth: 2))];
} }
final theaterError = theater.lastError; final theaterError = theater.lastError;
if (theaterError != null && theater.dramas.isEmpty) { if (theaterError != null && theater.dramas.isEmpty) {
return [ return [
_block( _sliverBlock(
EmptyState( EmptyState(
key: const Key('home-categories-error'), key: const Key('home-categories-error'),
icon: Icons.cloud_off_rounded, icon: Icons.cloud_off_rounded,
@@ -282,38 +287,49 @@ class _DiscoverPageState extends State<DiscoverPage> {
List<Widget> _theaterBlocks(AppLocalizations l10n) { List<Widget> _theaterBlocks(AppLocalizations l10n) {
final dramas = theater.dramas; final dramas = theater.dramas;
return [ return [
_toolbar(), SliverToBoxAdapter(child: _toolbar()),
if (dramas.isEmpty) if (dramas.isEmpty)
Padding( SliverToBoxAdapter(
padding: const EdgeInsets.symmetric(vertical: 40), child: Padding(
child: EmptyState( padding: const EdgeInsets.symmetric(vertical: 40),
key: const Key('discover-empty'), child: EmptyState(
icon: Icons.movie_filter_outlined, key: const Key('discover-empty'),
title: l10n.noStories, icon: Icons.movie_filter_outlined,
subtitle: l10n.emptyHint, title: l10n.noStories,
subtitle: l10n.emptyHint,
),
), ),
) )
else 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) if (theater.loadingMore)
const Padding( const SliverToBoxAdapter(
padding: EdgeInsets.symmetric(vertical: 20), child: Padding(
child: Center( padding: EdgeInsets.symmetric(vertical: 20),
child: SizedBox.square( child: Center(
key: Key('theater-load-more'), child: SizedBox.square(
dimension: 22, key: Key('theater-load-more'),
child: CircularProgressIndicator(strokeWidth: 2), dimension: 22,
child: CircularProgressIndicator(strokeWidth: 2),
),
), ),
), ),
) )
else if (dramas.isNotEmpty && !theater.hasMore) else if (dramas.isNotEmpty && !theater.hasMore)
Padding( SliverToBoxAdapter(
key: const Key('paged-list-no-more'), child: Padding(
padding: const EdgeInsets.symmetric(vertical: 16), key: const Key('paged-list-no-more'),
child: Text( padding: const EdgeInsets.symmetric(vertical: 16),
l10n.noMoreItems, child: Text(
textAlign: TextAlign.center, l10n.noMoreItems,
style: const TextStyle(color: AppColors.textHint, fontSize: 12), textAlign: TextAlign.center,
style: const TextStyle(color: AppColors.textHint, fontSize: 12),
),
), ),
), ),
]; ];
@@ -401,6 +417,8 @@ class _DiscoverPageState extends State<DiscoverPage> {
Navigator.of(context).push<void>(MaterialPageRoute(builder: (_) => page)); Navigator.of(context).push<void>(MaterialPageRoute(builder: (_) => page));
} }
Widget _sliverBlock(Widget child) => SliverToBoxAdapter(child: _block(child));
Widget _block(Widget child) => ConstrainedBox( Widget _block(Widget child) => ConstrainedBox(
constraints: const BoxConstraints(minHeight: 280), constraints: const BoxConstraints(minHeight: 280),
child: Center(child: child), child: Center(child: child),
@@ -10,6 +10,8 @@ import '../../../l10n/app_localizations.dart';
import 'drama_grid.dart'; import 'drama_grid.dart';
/// 剧场搜索结果:加载、失败与空结果都留在同一页内。 /// 剧场搜索结果:加载、失败与空结果都留在同一页内。
///
/// 自身是 sliver,好让结果网格跟着剧场页一起懒加载。
class DiscoverSearchResults extends StatelessWidget { class DiscoverSearchResults extends StatelessWidget {
const DiscoverSearchResults({ const DiscoverSearchResults({
super.key, super.key,
@@ -26,16 +28,24 @@ class DiscoverSearchResults extends StatelessWidget {
final VoidCallback onRetry; final VoidCallback onRetry;
final ValueChanged<Drama> onOpen; final ValueChanged<Drama> onOpen;
static const EdgeInsets _topPadding = EdgeInsets.only(top: 12);
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!; final l10n = AppLocalizations.of(context)!;
return Padding( if (!loading && error == null && results.isNotEmpty) {
padding: const EdgeInsets.only(top: 12), return SliverPadding(
child: _content(l10n), 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) { if (loading) {
return const Padding( return const Padding(
padding: EdgeInsets.symmetric(vertical: 80), padding: EdgeInsets.symmetric(vertical: 80),
@@ -61,14 +71,11 @@ class DiscoverSearchResults extends StatelessWidget {
onAction: onRetry, onAction: onRetry,
); );
} }
if (results.isEmpty) { return EmptyState(
return EmptyState( key: const Key('discover-search-empty'),
key: const Key('discover-search-empty'), icon: Icons.search_off_rounded,
icon: Icons.search_off_rounded, title: l10n.noSearchResults,
title: l10n.noSearchResults, subtitle: l10n.emptyHint,
subtitle: l10n.emptyHint, );
);
}
return DramaGrid(l10n: l10n, dramas: results, onOpen: onOpen);
} }
} }
+34 -25
View File
@@ -10,7 +10,6 @@ import '../../../core/theme/app_dimens.dart';
import '../../../core/widgets/remote_image.dart'; import '../../../core/widgets/remote_image.dart';
import '../../../l10n/app_localizations.dart'; import '../../../l10n/app_localizations.dart';
const int _columns = 2;
const double _columnGap = 8; const double _columnGap = 8;
const double _cardGap = 14; const double _cardGap = 14;
const double _gridHorizontalPadding = 12; const double _gridHorizontalPadding = 12;
@@ -29,8 +28,10 @@ const Color _tagChipBackground = AppColors.raised;
final RegExp _tagSplitter = RegExp(r'[,/、·]'); final RegExp _tagSplitter = RegExp(r'[,/、·]');
/// 剧场/分类页共用的双列剧目网格。 /// 剧场/分类页共用的双列剧目网格。
class DramaGrid extends StatelessWidget { ///
const DramaGrid({ /// 按行懒加载:翻到第 N 页时只构建当前可见的几张卡,长列表不会把整本目录都留在树上。
class SliverDramaGrid extends StatelessWidget {
const SliverDramaGrid({
super.key, super.key,
required this.l10n, required this.l10n,
required this.dramas, required this.dramas,
@@ -43,33 +44,41 @@ class DramaGrid extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Padding( final rowCount = (dramas.length + 1) ~/ 2;
key: const Key('home-all-dramas-section'), return SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: _gridHorizontalPadding), padding: const EdgeInsets.symmetric(horizontal: _gridHorizontalPadding),
child: Row( sliver: SliverList(
key: const Key('home-all-dramas-grid'), delegate: SliverChildBuilderDelegate((context, row) {
crossAxisAlignment: CrossAxisAlignment.start, final left = dramas[row * 2];
children: [ final rightIndex = row * 2 + 1;
Expanded(child: _column(0)), final right = rightIndex < dramas.length ? dramas[rightIndex] : null;
const SizedBox(width: _columnGap), return Padding(
Expanded(child: _column(1)), // 行间距沿用卡片间距,首行不需要。
], 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) { Widget _card(Drama drama) {
final children = <Widget>[]; return DramaGridCard(
for (var index = start; index < dramas.length; index += _columns) { l10n: l10n,
final drama = dramas[index]; drama: drama,
children.add( onTap: () => onOpen(drama),
DramaGridCard(l10n: l10n, drama: drama, onTap: () => onOpen(drama)), );
);
if (index + _columns < dramas.length) {
children.add(const SizedBox(height: _cardGap));
}
}
return Column(children: children);
} }
} }
+69 -47
View File
@@ -135,64 +135,81 @@ class _NewReleasesPageState extends State<NewReleasesPage> {
child: AppPullToRefresh( child: AppPullToRefresh(
key: const Key('new-releases-refresh'), key: const Key('new-releases-refresh'),
onRefresh: _refresh, onRefresh: _refresh,
child: ListView( child: CustomScrollView(
key: const Key('new-releases-list'), key: const Key('new-releases-list'),
controller: _scroll, controller: _scroll,
physics: AppPullToRefresh.physics, physics: AppPullToRefresh.physics,
padding: const EdgeInsets.only(bottom: 24), slivers: hero == null
children: hero == null
? [ ? [
SizedBox( SliverToBoxAdapter(
height: 420, child: SizedBox(
child: Center( height: 420,
child: Text( child: Center(
l10n.noStories, child: Text(
key: const Key('new-releases-empty'), l10n.noStories,
style: const TextStyle( key: const Key('new-releases-empty'),
color: AppColors.muted, style: const TextStyle(
color: AppColors.muted,
),
), ),
), ),
), ),
), ),
] ]
: [ : [
Padding( SliverToBoxAdapter(
padding: const EdgeInsets.symmetric( child: Padding(
horizontal: 16, padding: const EdgeInsets.symmetric(
horizontal: 16,
),
child: _heroCard(context, l10n, hero),
), ),
child: _heroCard(context, l10n, hero),
), ),
const SizedBox(height: 14), const SliverToBoxAdapter(
const _FilmStrip(), child: SizedBox(height: 14),
const SizedBox(height: 14), ),
const SliverToBoxAdapter(child: _FilmStrip()),
const SliverToBoxAdapter(
child: SizedBox(height: 14),
),
if (more.isNotEmpty) ...[ if (more.isNotEmpty) ...[
_moreHeader(l10n), SliverToBoxAdapter(
const SizedBox(height: 10), child: _moreHeader(l10n),
SizedBox( ),
height: _rowHeight, const SliverToBoxAdapter(
child: ListView.separated( child: SizedBox(height: 10),
key: const Key('new-releases-row'), ),
scrollDirection: Axis.horizontal, SliverToBoxAdapter(
padding: const EdgeInsets.symmetric( child: SizedBox(
horizontal: 16, 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), const SliverToBoxAdapter(
_allHeader(l10n), child: SizedBox(height: 20),
const SizedBox(height: 10), ),
DramaGrid( SliverToBoxAdapter(child: _allHeader(l10n)),
const SliverToBoxAdapter(
child: SizedBox(height: 10),
),
SliverDramaGrid(
key: const Key('new-releases-all-grid'), key: const Key('new-releases-all-grid'),
l10n: l10n, l10n: l10n,
dramas: visible, dramas: visible,
@@ -200,12 +217,17 @@ class _NewReleasesPageState extends State<NewReleasesPage> {
playDramaFromCatalog(context, drama), playDramaFromCatalog(context, drama),
), ),
), ),
PagedLoadFooter( SliverToBoxAdapter(
loading: pager.loading, child: Padding(
hasMore: pager.hasMore, padding: const EdgeInsets.only(bottom: 24),
hasItems: visible.isNotEmpty, child: PagedLoadFooter(
loadMoreKey: const Key( loading: pager.loading,
'new-releases-load-more', hasMore: pager.hasMore,
hasItems: visible.isNotEmpty,
loadMoreKey: const Key(
'new-releases-load-more',
),
),
), ),
), ),
], ],
+3 -2
View File
@@ -384,7 +384,7 @@ void main() {
expect(refresh.edgeOffset, 52); expect(refresh.edgeOffset, 52);
expect( expect(
tester.widget<ListView>(_verticalFeed).physics, tester.widget<CustomScrollView>(_verticalFeed).physics,
AppPullToRefresh.physics, AppPullToRefresh.physics,
); );
await tester.drag(_verticalFeed, const Offset(0, 240)); await tester.drag(_verticalFeed, const Offset(0, 240));
@@ -396,7 +396,8 @@ void main() {
} }
final Finder _verticalFeed = find.byWidgetPredicate( final Finder _verticalFeed = find.byWidgetPredicate(
(widget) => widget is ListView && widget.scrollDirection == Axis.vertical, (widget) =>
widget is CustomScrollView && widget.scrollDirection == Axis.vertical,
); );
Future<void> _pumpTheater( Future<void> _pumpTheater(
+16 -25
View File
@@ -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/theme/app_colors.dart';
import 'package:fruitshorts/core/widgets/app_pull_to_refresh.dart'; import 'package:fruitshorts/core/widgets/app_pull_to_refresh.dart';
import 'package:fruitshorts/pages/discover/discover_view.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/favorite_repository.dart';
import 'package:fruitshorts/core/data/repositories/playback_repository.dart'; import 'package:fruitshorts/core/data/repositories/playback_repository.dart';
import 'package:fruitshorts/core/data/repositories/watch_history_repository.dart'; import 'package:fruitshorts/core/data/repositories/watch_history_repository.dart';
@@ -234,25 +235,26 @@ void main() {
await tester.pumpAndSettle(); await tester.pumpAndSettle();
final list = find.byKey(const Key('new-releases-list')); final list = find.byKey(const Key('new-releases-list'));
expect(tester.widget<ListView>(list).physics, AppPullToRefresh.physics); expect(
await tester.drag(list, const Offset(0, -400)); tester.widget<CustomScrollView>(list).physics,
await tester.pump(); AppPullToRefresh.physics,
expect(_countKeys('home-drama-card-'), 20); );
// 网格懒加载后只保留可见行,页数直接看传给网格的剧目数。
List<Drama> gridDramas() => tester
.widget<SliverDramaGrid>(find.byKey(const Key('new-releases-all-grid')))
.dramas;
expect(gridDramas(), hasLength(20));
// jumpTo 不发滚动通知,滑到底后再拖一下才会触发加载。
final position = _scrollPosition(tester, const Key('new-releases-list')); final position = _scrollPosition(tester, const Key('new-releases-list'));
position.jumpTo(position.maxScrollExtent); position.jumpTo(position.maxScrollExtent);
await tester.pump(); await tester.pump();
expect(find.byKey(const Key('new-releases-load-more')), findsOneWidget); await tester.drag(list, const Offset(0, -60));
await tester.pump(const Duration(milliseconds: 299));
expect(_countKeys('home-drama-card-'), 20);
await tester.pump(const Duration(milliseconds: 1));
await tester.pump(); await tester.pump();
expect(_countKeys('home-drama-card-'), 40); expect(find.byKey(const Key('new-releases-load-more')), findsOneWidget);
await tester.pumpAndSettle(); await tester.pumpAndSettle();
position.jumpTo(position.maxScrollExtent); expect(gridDramas(), hasLength(40));
await tester.pumpAndSettle();
expect(find.byKey(const Key('new-releases-no-more')), findsNothing);
position.jumpTo(0); position.jumpTo(0);
await tester.pump(); await tester.pump();
@@ -262,10 +264,7 @@ void main() {
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(repository.fetchCalls, beforeRefresh + 1); expect(repository.fetchCalls, beforeRefresh + 1);
expect(position.pixels, 0); expect(position.pixels, 0);
expect(gridDramas(), hasLength(20));
await tester.drag(list, const Offset(0, -400));
await tester.pump();
expect(_countKeys('home-drama-card-'), 20);
}); });
testWidgets('should_refresh_reserve_without_load_more', (tester) async { testWidgets('should_refresh_reserve_without_load_more', (tester) async {
@@ -327,14 +326,6 @@ ScrollPosition _scrollPosition(WidgetTester tester, Key listKey) {
return tester.state<ScrollableState>(scrollable.first).position; return tester.state<ScrollableState>(scrollable.first).position;
} }
int _countKeys(String prefix) => find
.byWidgetPredicate((widget) {
final key = widget.key;
return key is ValueKey<String> && key.value.startsWith(prefix);
})
.evaluate()
.length;
class _CountingCatalogRepository extends FakeCatalogRepository { class _CountingCatalogRepository extends FakeCatalogRepository {
_CountingCatalogRepository({super.dramas}); _CountingCatalogRepository({super.dramas});