摘要
在Flutter開發中,分頁功能常面臨內容跳過的問題,本篇文章將提供解決方案以提升用戶體驗及應用效能。 歸納要點:
- 深入探討Flutter的`PageView`效能瓶頸,提供最佳化策略如使用`PageView.builder`和`CachedNetworkImage`以改善頁面載入速度。
- 比較各種狀態管理方案(Provider, BLoC, Riverpod等),分析如何有效避免因狀態更新不當導致的跳頁問題,並附上具體程式碼範例。
- 利用`NotificationListener`與`ScrollController`監控滾動事件,預防快速滾動引起的跳頁行為,提升使用者體驗。
分頁是大型資料集合視覺化中最重要的功能之一。例如,假設我們有一個包含一千件商品的清單,你不應該一次性載入所有專案。因此,我們需要使用分頁來部分載入它們。最初,我建立了一個產品資料類如下:
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` 自行調整大小。在這種情況下,如果有可用的空間來顯示列表專案,則會再次呼叫載入更多功能。現在,我們的程式碼運作正常!
它也適用於不同的解析度!
我們做到了!感謝您閱讀我的文章。如果您喜歡這篇文章,別忘了點贊喔!
相關討論