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,
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<DiscoverPage> {
];
}
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<DiscoverPage> {
];
}
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<DiscoverPage> {
List<Widget> _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<DiscoverPage> {
Navigator.of(context).push<void>(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),
@@ -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<Drama> 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,
);
}
}
+34 -25
View File
@@ -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 = <Widget>[];
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),
);
}
}
+69 -47
View File
@@ -135,64 +135,81 @@ class _NewReleasesPageState extends State<NewReleasesPage> {
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<NewReleasesPage> {
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',
),
),
),
),
],