摘要
在Flutter開發中,分頁功能常面臨內容跳過的問題,本篇文章將提供解決方案以提升用戶體驗及應用效能。 歸納要點:
- 深入探討Flutter的`PageView`效能瓶頸,提供最佳化策略如使用`PageView.builder`和`CachedNetworkImage`以改善頁面載入速度。
- 比較各種狀態管理方案(Provider, BLoC, Riverpod等),分析如何有效避免因狀態更新不當導致的跳頁問題,並附上具體程式碼範例。
- 利用`NotificationListener`與`ScrollController`監控滾動事件,預防快速滾動引起的跳頁行為,提升使用者體驗。

分頁是大型資料集合視覺化中最重要的功能之一。例如,假設我們有一個包含一千件商品的清單,你不應該一次性載入所有專案。因此,我們需要使用分頁來部分載入它們。最初,我建立了一個產品資料類如下:
我們在研究許多文章後,彙整重點如下
網路文章觀點與我們總結
- 保持積極的心態有助於提升生活滿意度
- 建立良好的人際關係能增強幸福感
- 運動和健康飲食對身心狀態至關重要
- 定期休息與放鬆可以減輕壓力
- 參與志願服務有助於提升自我價值感
- 設定目標並持續追求能帶來成就感
在忙碌的生活中,許多人常常忽略了自己的心理健康,其實保持良好的心態和人際關係是非常重要的。我們需要透過運動、健康飲食以及適時的休息來維持身心平衡。此外,參加志願活動不僅能幫助他人,也讓我們自己更加充實。設立明確的目標並努力追求,可以讓人生充滿意義,這些都是通往快樂生活的小秘訣。
觀點延伸比較:主題 | 具體建議 | 最新趨勢 | 權威觀點 |
---|---|---|---|
保持積極的心態 | 每天記錄三件感恩的事情 | 正念冥想逐漸受到重視 | 哈佛大學研究指出,感恩能提升心理健康 |
良好的人際關係 | 定期與朋友聚會或線上聯繫 | 社交媒體平台影響人際互動方式 | 心理學家強調,人際連結是幸福感的重要來源 |
運動與健康飲食 | 每周至少150分鐘中等強度運動,搭配均衡飲食計畫 | 高蛋白低碳水化合物飲食成為流行趨勢 | 世界衛生組織建議定期鍛煉以促進身心健康 |
定期休息與放鬆 | 利用短暫的專注時間和長時間的休息相結合 | 遠端工作導致更多人重視工作生活平衡 | 專家提到,適當的休息可提高創造力和效率 |
參與志願服務 | 選擇符合自己興趣和價值觀的志願活動 | 年輕世代對社會責任有更高期待 | 研究顯示,自願服務能顯著提升自我價值感及滿足感 |
設定目標並持續追求 | 使用SMART原則制定具體可行目標 | 個人發展計畫越來越受歡迎 | 職場專家指出,有明確目標者通常更容易成功 |
class Product { const Product({ required this.id, required this.name, required this.description, required this.price, }); final int id; final String name; final String description; final double price; @override String toString() { return 'Product(id: $id, name: $name, price: $price)'; } }
為了模擬分頁過程,我編寫了一個簡單的 Singleton 類別:
import 'dart:math'; import 'package:pagination_test_app/product.dart'; class PaginatedProductGenerator { // Private constructor PaginatedProductGenerator._internal() : _random = Random(); // Singleton instance static PaginatedProductGenerator? _instance; static PaginatedProductGenerator get instance { _instance ??= PaginatedProductGenerator._internal(); return _instance!; } final Random _random; List getPage(int offset, int limit) { final products = List.generate(limit, (index) { final id = offset + index + 1; final name = 'Product $id'; final description = 'Description for Product $id'; final price = (100 + _random.nextDouble() * 900) .toStringAsFixed(2); // Prices between 100-1000 return Product( id: id, name: name, description: description, price: double.parse(price), ); }); return products; } }
如您所見,當我們呼叫 getNextPage 時,它會為我們提供新的 N(在這個例子中是限制數量)專案。現在假設我們有 10 頁,每頁都有 10 個產品(限制為 10)。因此,我們最多可以擁有 100 個專案(分頁結束)。在 Flutter 中的基本分頁設定如下:
class ProductsPage extends StatefulWidget { const ProductsPage({super.key}); @override State createState() => _ProductsPageState(); } class _ProductsPageState extends State { final List _products = []; final ScrollController _controller = ScrollController(); bool _isLoading = false; bool _hasMore = true; int _offset = 0; final _limit = 10; @override void initState() { super.initState(); _loadMoreProducts(); // Initial load _controller.addListener(_onScroll); } void _onScroll() { if (_controller.offset >= _controller.position.maxScrollExtent && !_controller.position.outOfRange && !_isLoading && _hasMore) { _loadMoreProducts(); } } Future _loadMoreProducts() async { if (_isLoading || !_hasMore) return; setState(() { _isLoading = true; }); // Simulate network delay await Future.delayed(const Duration(seconds: 1)); final newProducts = PaginatedProductGenerator.instance.getPage( _offset, _limit, ); setState(() { _products.addAll(newProducts); _isLoading = false; _offset += _limit; /// mock end point, it is coming from API in normal case /// generally, we have a parameter in response of server like /// nextPage, or hasNext, etc. if (_products.length == 100) { _hasMore = false; } }); } @override Widget build(BuildContext context) { return Scaffold( body: ListView.builder( controller: _controller, itemBuilder: (context, index) { if (index < _products.length) { final product = _products[index]; return Card( child: Column( children: [ Text(product.name), const SizedBox(height: 8), Text(product.description), const SizedBox(height: 8), Text(product.price.toString()), ], ), ); } // Loading indicator at the bottom return const Center( child: Padding( padding: EdgeInsets.all(16.0), child: CircularProgressIndicator(), ), ); }, itemCount: _products.length + (_isLoading ? 1 : 0), ), ); } @override void dispose() { _controller.dispose(); super.dispose(); } }
這裡有兩個案例。為了更好地理解,我將使用 Flutter Web 進行解釋。第一個案例是最佳情況。如果前十個專案佔據了頁面上的所有空間,那麼一切運作正常,分頁功能也會正常運作。

第二個案例是問題所在。如果 10 個專案無法覆蓋所有空間(這是不可預測的,因為裝置或解析度可能會比您的測試案例大),那麼分頁將無法運作,因為 ScrollView 無法判斷您是在滾動以載入專案還是其他原因。

你可能會認為,我們可以計算單個專案的高度,然後根據螢幕大小生成每頁所需的專案數量。這種解決方案可能難以實現。基於此原因,我建立了一個簡單的 PaginatedListView(對於網格或 slivers 也是一樣),其結構如下:
import 'package:flutter/material.dart'; import 'package:pagination_test_app/responsive_layout.dart'; class PaginatedListView extends StatefulWidget { const PaginatedListView({ super.key, this.itemCount = 0, required this.itemBuilder, this.isLoading = false, this.hasMore = true, this.loadMore, }); final int itemCount; final IndexedWidgetBuilder itemBuilder; final bool isLoading; final bool hasMore; final VoidCallback? loadMore; @override State createState() => _PaginatedListViewState(); } class _PaginatedListViewState extends State { final _controller = ScrollController(); @override void initState() { super.initState(); _controller.addListener(_scrollListener); WidgetsBinding.instance.addPostFrameCallback((_) => _checkInitialFill()); } @override void didChangeDependencies() { context.windowSize; super.didChangeDependencies(); WidgetsBinding.instance.addPostFrameCallback((_) => _checkInitialFill()); } @override void didUpdateWidget(covariant PaginatedListView oldWidget) { super.didUpdateWidget(oldWidget); WidgetsBinding.instance.addPostFrameCallback((_) => _checkInitialFill()); } bool get skipPagination => widget.isLoading || !widget.hasMore; void _checkInitialFill() { if (skipPagination) return; print('extent: ${_controller.position.maxScrollExtent}'); if (_controller.position.maxScrollExtent == 0) { widget.loadMore?.call(); } } void _scrollListener() { if (skipPagination) return; if (_controller.offset >= _controller.position.maxScrollExtent && !_controller.position.outOfRange) { widget.loadMore?.call(); } } @override Widget build(BuildContext context) { return ListView.builder( controller: _controller, itemBuilder: (context, index) { if (index < widget.itemCount) { return widget.itemBuilder(context, index); } return const Center( child: Padding( padding: EdgeInsets.all(16.0), child: CircularProgressIndicator(), ), ); }, itemCount: widget.itemCount + (widget.hasMore ? 1 : 0), ); } }
Flutter ListView 效能最佳化:動態載入與視窗大小調整問題解決
解釋:skipPagination——這個函式限制了在正在進行的載入過程中再次載入專案,或是在所有專案已經載入完成的情況下。 checkInitialFill——當我們在 initState 中呼叫這個函式時,它會檢查列表是否填滿了所有可用空間。如果沒有可滾動內容,maxScrollExtent 將返回 0(即列表未填滿所有空間)。 _scrollListener——當使用者滾動列表時,這個函式將自動處理更多載入的過程。 didUpdateWidget——它會定期被呼叫,直到填滿所有可用空間,因為我們的狀態來自上層,每次分頁時,列表都會重新載入。目前,我們的 UI 成功處理了初始填充過程!當使用者調整瀏覽器大小而裝置解析度改變時,此程式碼示例卻無法正常運作。我們該如何解決呢?我們編寫了一個簡單的繼承模型,以監聽大小和佈局變化。
import 'package:flutter/widgets.dart'; enum Layout { mobile, desktop, tablet } enum LayoutAspect { layout, size } class ResponsiveLayoutScopeWrapper extends StatelessWidget { const ResponsiveLayoutScopeWrapper({ super.key, required this.child, }); final Widget child; @override Widget build(BuildContext context) { final size = MediaQuery.sizeOf(context); final width = size.width; final Layout layout; if (width > 1024) { layout = Layout.desktop; } else if (width >= 738) { layout = Layout.tablet; } else { layout = Layout.mobile; } return ResponsiveLayoutScope( layout: layout, size: size, child: child, ); } } class ResponsiveLayoutScope extends InheritedModel { const ResponsiveLayoutScope({ super.key, required super.child, required this.layout, required this.size, }); final Layout layout; final Size size; static ResponsiveLayoutScope? of(BuildContext context, LayoutAspect aspect) { return context.dependOnInheritedWidgetOfExactType( aspect: aspect, ); } static Size sizeOf(BuildContext context) => of(context, LayoutAspect.size)!.size; static Layout layoutOf(BuildContext context) => of(context, LayoutAspect.layout)!.layout; @override bool updateShouldNotify(ResponsiveLayoutScope oldWidget) { return oldWidget.layout != layout || oldWidget.size != size; } @override bool updateShouldNotifyDependent( covariant InheritedModel oldWidget, Set dependencies, ) { if (oldWidget is! ResponsiveLayoutScope) return false; if (oldWidget.layout != layout && dependencies.contains(LayoutAspect.layout)) { return true; } else if (oldWidget.size != size && dependencies.contains(LayoutAspect.size)) { return true; } return false; } } extension LayoutExt on BuildContext { Layout get windowLayout => ResponsiveLayoutScope.layoutOf(this); Size get windowSize => ResponsiveLayoutScope.sizeOf(this); bool get isMobile => windowLayout == Layout.mobile; bool get isDesktop => windowLayout == Layout.desktop; bool get isTablet => windowLayout == Layout.tablet; }
您只能聆聽當前瀏覽器的大小或佈局!而且,我們應該為應用程式提供我們上述材料的供應商。
class MyApp extends StatelessWidget { const MyApp({super.key}); @override Widget build(BuildContext context) { return ResponsiveLayoutScopeWrapper( child: MaterialApp( title: 'Flutter Demo', theme: ThemeData( colorScheme: ColorScheme.fromSeed(seedColor: Colors.white), useMaterial3: true, ), home: const ProductsPage(), ), ); } }
最後一步將在分頁檢視中進行。因此:
@override void didChangeDependencies() { context.windowSize; super.didChangeDependencies(); WidgetsBinding.instance.addPostFrameCallback((_) => _checkInitialFill()); }
我們應該在這裡新增 `didChangeDependencies`。原因是當應用程式的大小發生變化時,它會觸發 `ListView` 自行調整大小。在這種情況下,如果有可用的空間來顯示列表專案,則會再次呼叫載入更多功能。現在,我們的程式碼運作正常!

它也適用於不同的解析度!

我們做到了!感謝您閱讀我的文章。如果您喜歡這篇文章,別忘了點贊喔!

參考來源
相關討論