MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

Flutter减少重绘的列表优化:提升滚动性能

2024-03-104.4k 阅读

一、Flutter 列表重绘问题概述

在 Flutter 应用开发中,列表是一种极为常见的 UI 组件。无论是简单的新闻列表,还是复杂的商品展示列表,其性能表现直接影响用户体验。重绘(Repainting)作为影响列表性能的关键因素之一,常常是开发者需要重点关注和优化的对象。

当列表中的某些元素发生变化,比如数据更新、状态改变或者尺寸调整时,Flutter 可能会对整个列表或者部分列表进行重绘。不必要的重绘不仅消耗 CPU 和 GPU 资源,还可能导致滚动卡顿、帧率下降等问题,严重影响用户的操作流畅感。

例如,一个简单的消息列表,当新消息不断涌入时,如果每次消息更新都引发整个列表的重绘,那么随着列表长度的增加,性能问题将愈发明显。用户可能会感觉到滚动变得迟缓,新消息的加载也不再流畅。

二、深入理解 Flutter 列表的绘制机制

(一)Widget、Element 和 RenderObject 关系

要理解列表重绘的本质,首先得了解 Flutter 中 Widget、Element 和 RenderObject 这三个核心概念及其关系。

  1. Widget:Widget 是 Flutter 中描述 UI 的不可变配置对象。它就像是一个蓝图,定义了 UI 的外观和行为。例如,Text Widget 定义了文本的样式、内容等属性。Widget 本身是不可变的,一旦创建,其属性就不能更改。当需要更新 UI 时,会创建一个新的 Widget 来替换旧的 Widget。
Text('Hello, Flutter!', style: TextStyle(fontSize: 18));
  1. Element:Element 是 Widget 的实例,它在 Widget 和 RenderObject 之间起到桥梁作用。每个 Widget 都会对应一个 Element,Element 负责管理 Widget 的生命周期,包括创建、更新和销毁。Element 会根据 Widget 的配置来创建和更新相应的 RenderObject。

  2. RenderObject:RenderObject 负责实际的布局和绘制。它处理 UI 元素的大小、位置以及绘制到屏幕上的具体操作。例如,RenderParagraph 负责文本的布局和绘制,RenderBox 负责盒子模型的布局等。

(二)列表绘制过程

ListView 为例,当 ListView Widget 被创建时,会生成对应的 ListViewElement,进而创建 RenderSliverListListView 在渲染层对应的对象)。

  1. 初始绘制:在初始绘制阶段,RenderSliverList 会根据当前视口(viewport)的大小和列表项的数量,决定渲染哪些列表项。它会为每个需要渲染的列表项创建相应的 RenderObject,并进行布局计算,确定每个列表项在屏幕上的位置和大小,然后将这些列表项绘制到屏幕上。

  2. 滚动时绘制:当用户滚动列表时,RenderSliverList 会根据视口的变化,决定哪些列表项需要进入视口并渲染,哪些列表项需要移出视口并销毁其 RenderObject。这个过程涉及到不断地创建和销毁 RenderObject,如果处理不当,就容易引发重绘问题。

三、常见导致列表重绘的原因

(一)不必要的 Widget 重建

  1. 父 Widget 状态变化:如果 ListView 的父 Widget 的状态发生变化,即使 ListView 本身的数据没有改变,ListView 也可能会被重建。例如,父 Widget 中有一个切换主题的按钮,每次点击按钮,父 Widget 的状态改变,导致整个 ListView 及其子 Widget 被重建,进而引发重绘。
class ParentWidget extends StatefulWidget {
  @override
  _ParentWidgetState createState() => _ParentWidgetState();
}

class _ParentWidgetState extends State<ParentWidget> {
  bool isDarkTheme = false;

  void toggleTheme() {
    setState(() {
      isDarkTheme =!isDarkTheme;
    });
  }

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        RaisedButton(
          child: Text('Toggle Theme'),
          onPressed: toggleTheme,
        ),
        ListView(
          children: [
            ListTile(title: Text('Item 1')),
            ListTile(title: Text('Item 2')),
            // 更多列表项
          ],
        )
      ],
    );
  }
}

在上述代码中,点击“Toggle Theme”按钮会导致 ParentWidget 的状态改变,进而 ListView 被重建。

  1. 传递给列表的属性变化:如果传递给 ListView 的属性频繁变化,也会导致 ListView 重建。比如,传递一个动态计算的 itemCount 属性,每次该属性值变化时,ListView 就会重建。
class ListWidget extends StatefulWidget {
  @override
  _ListWidgetState createState() => _ListWidgetState();
}

class _ListWidgetState extends State<ListWidget> {
  int itemCount = 10;

  void updateItemCount() {
    setState(() {
      itemCount = itemCount + 1;
    });
  }

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        RaisedButton(
          child: Text('Add Item'),
          onPressed: updateItemCount,
        ),
        ListView.builder(
          itemCount: itemCount,
          itemBuilder: (context, index) {
            return ListTile(title: Text('Item $index'));
          },
        )
      ],
    );
  }
}

每次点击“Add Item”按钮,itemCount 属性变化,ListView.builder 会重建。

(二)列表项内部状态变化

  1. 单个列表项状态管理不当:如果列表项自身包含状态,并且在状态变化时没有正确处理,可能会导致整个列表重绘。例如,一个包含开关按钮的列表项,当开关状态改变时,如果没有局部更新列表项,而是导致整个列表的重建,就会引发不必要的重绘。
class ListItem extends StatefulWidget {
  @override
  _ListItemState createState() => _ListItemState();
}

class _ListItemState extends State<ListItem> {
  bool isSwitched = false;

  void toggleSwitch() {
    setState(() {
      isSwitched =!isSwitched;
    });
  }

  @override
  Widget build(BuildContext context) {
    return ListTile(
      title: Text('Item'),
      trailing: Switch(
        value: isSwitched,
        onChanged: (value) {
          toggleSwitch();
        },
      ),
    );
  }
}

class ListPage extends StatefulWidget {
  @override
  _ListPageState createState() => _ListPageState();
}

class _ListPageState extends State<ListPage> {
  @override
  Widget build(BuildContext context) {
    return ListView(
      children: [
        ListItem(),
        ListItem(),
        // 更多列表项
      ],
    );
  }
}

在上述代码中,每个 ListItem 中的开关状态改变会导致 ListItem 的重建。如果没有进行优化,可能会影响整个列表的性能。

  1. 列表项数据更新未优化:当列表项的数据更新时,如果直接修改数据并导致整个列表重建,而不是只更新发生变化的列表项,也会引发重绘。比如,一个显示用户信息的列表,当用户修改了部分信息后,整个列表被重建来显示更新后的信息,而不是仅更新对应的列表项。

四、Flutter 减少重绘的列表优化策略

(一)使用 const Widget

  1. 原理:如果列表项的内容在整个生命周期内不会发生变化,那么可以将其定义为 const Widgetconst Widget 在编译时就会被确定,Flutter 会复用相同的 const Widget,减少不必要的重建。

  2. 示例

class MyListItem extends StatelessWidget {
  const MyListItem({Key key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return const ListTile(
      title: Text('Fixed Item'),
      trailing: Icon(Icons.check),
    );
  }
}

class MyListView extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return ListView(
      children: const [
        MyListItem(),
        MyListItem(),
        MyListItem(),
      ],
    );
  }
}

在上述代码中,MyListItem 被定义为 const WidgetListView 中的这些列表项在运行时不会被重建,除非 ListView 本身因为其他原因被重建。

(二)局部更新列表项

  1. 使用 AnimatedListAnimatedList 提供了一种高效的方式来插入、删除和移动列表项,同时可以执行动画过渡。它通过 AnimatedListState 来管理列表的变化,避免了整个列表的重绘。
class AnimatedListPage extends StatefulWidget {
  @override
  _AnimatedListPageState createState() => _AnimatedListPageState();
}

class _AnimatedListPageState extends State<AnimatedListPage> {
  final List<String> items = List.generate(5, (index) => 'Item $index');
  final GlobalKey<AnimatedListState> _listKey = GlobalKey<AnimatedListState>();

  void _insertItem(int index) {
    setState(() {
      items.insert(index, 'New Item');
    });
    _listKey.currentState.insertItem(index);
  }

  void _removeItem(int index) {
    setState(() {
      items.removeAt(index);
    });
    _listKey.currentState.removeItem(index, (context, animation) {
      return SizeTransition(
        sizeFactor: animation,
        child: ListTile(title: Text(items[index])),
      );
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('Animated List')),
      body: AnimatedList(
        key: _listKey,
        initialItemCount: items.length,
        itemBuilder: (context, index, animation) {
          return SizeTransition(
            sizeFactor: animation,
            child: ListTile(title: Text(items[index])),
          );
        },
      ),
      floatingActionButton: Row(
        mainAxisAlignment: MainAxisAlignment.end,
        children: [
          FloatingActionButton(
            child: Icon(Icons.add),
            onPressed: () => _insertItem(0),
          ),
          SizedBox(width: 16),
          FloatingActionButton(
            child: Icon(Icons.remove),
            onPressed: () => _removeItem(0),
          ),
        ],
      ),
    );
  }
}

在上述代码中,通过 AnimatedListinsertItemremoveItem 方法,可以局部插入和删除列表项,并执行动画,而不会导致整个列表的重绘。

  1. 使用 ValueNotifierValueListenableBuilder:对于列表项内部状态的管理,可以使用 ValueNotifierValueListenableBuilder 来实现局部更新。ValueNotifier 用于监听值的变化,ValueListenableBuilder 会在值变化时重建其内部的 Widget,而不会影响其他部分。
class ListItemWithValueNotifier extends StatefulWidget {
  @override
  _ListItemWithValueNotifierState createState() => _ListItemWithValueNotifierState();
}

class _ListItemWithValueNotifierState extends State<ListItemWithValueNotifier> {
  final ValueNotifier<bool> isSwitchedNotifier = ValueNotifier(false);

  void toggleSwitch() {
    isSwitchedNotifier.value =!isSwitchedNotifier.value;
  }

  @override
  Widget build(BuildContext context) {
    return ValueListenableBuilder<bool>(
      valueListenable: isSwitchedNotifier,
      builder: (context, isSwitched, _) {
        return ListTile(
          title: Text('Item'),
          trailing: Switch(
            value: isSwitched,
            onChanged: (value) {
              toggleSwitch();
            },
          ),
        );
      },
    );
  }
}

class ListPageWithValueNotifier extends StatefulWidget {
  @override
  _ListPageWithValueNotifierState createState() => _ListPageWithValueNotifierState();
}

class _ListPageWithValueNotifierState extends State<ListPageWithValueNotifier> {
  @override
  Widget build(BuildContext context) {
    return ListView(
      children: [
        ListItemWithValueNotifier(),
        ListItemWithValueNotifier(),
        // 更多列表项
      ],
    );
  }
}

在上述代码中,每个 ListItemWithValueNotifier 中的开关状态变化只会导致 ValueListenableBuilder 内部的 Widget 重建,而不会影响整个列表。

(三)优化列表项的构建函数

  1. 避免在构建函数中进行复杂计算:列表项的构建函数应该尽量简单,避免在其中进行复杂的计算,如网络请求、大量数据处理等。这些操作应该在其他地方提前完成,然后将结果传递给列表项。
class ComplexListItem extends StatelessWidget {
  final String data;

  ComplexListItem({this.data});

  // 错误示例:在构建函数中进行复杂计算
  // String processedData() {
  //   // 模拟复杂计算,如数据转换、过滤等
  //   return data.toUpperCase();
  // }

  // 正确示例:提前计算好数据
  final String processedData;

  ComplexListItem.precomputed({this.processedData});

  @override
  Widget build(BuildContext context) {
    return ListTile(
      title: Text(processedData),
    );
  }
}

在上述代码中,错误示例在构建函数中进行复杂计算,每次列表项构建时都会执行。而正确示例提前计算好数据,避免了在构建函数中的重复计算。

  1. 缓存列表项的属性:如果列表项的某些属性不会经常变化,可以将其缓存起来,避免每次构建时重新计算。
class CachedListItem extends StatelessWidget {
  final String text;
  final TextStyle cachedTextStyle;

  CachedListItem({this.text}) : cachedTextStyle = TextStyle(fontSize: 18, color: Colors.blue);

  @override
  Widget build(BuildContext context) {
    return ListTile(
      title: Text(text, style: cachedTextStyle),
    );
  }
}

在上述代码中,cachedTextStyle 被缓存起来,每次构建 CachedListItem 时不需要重新创建 TextStyle

(四)使用 AutomaticKeepAliveClientMixin

  1. 原理:对于长列表,当列表项移出视口时,Flutter 会默认销毁其 RenderObject 以节省资源。但是,有些列表项可能包含需要保持状态的内容,比如正在播放的视频、正在加载的图片等。AutomaticKeepAliveClientMixin 可以让列表项在移出视口时不被销毁,从而避免下次进入视口时的重新构建和重绘。

  2. 示例

class KeepAliveListItem extends StatefulWidget {
  @override
  _KeepAliveListItemState createState() => _KeepAliveListItemState();
}

class _KeepAliveListItemState extends State<KeepAliveListItem> with AutomaticKeepAliveClientMixin {
  @override
  bool get wantKeepAlive => true;

  @override
  Widget build(BuildContext context) {
    super.build(context);
    return ListTile(title: Text('Keep Alive Item'));
  }
}

class KeepAliveListView extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return ListView(
      children: [
        KeepAliveListItem(),
        KeepAliveListItem(),
        // 更多列表项
      ],
    );
  }
}

在上述代码中,KeepAliveListItem 使用了 AutomaticKeepAliveClientMixin 并将 wantKeepAlive 设置为 true,这样列表项在移出视口时不会被销毁,下次进入视口时也不需要重新构建,减少了重绘。

(五)使用 IndexedStack 结合 PageView

  1. 原理IndexedStack 可以根据索引显示其子 Widget 中的一个,而 PageView 可以实现页面滑动效果。通过结合这两个组件,可以只渲染当前视口内的列表项,减少不必要的重绘。

  2. 示例

class IndexedStackPage extends StatefulWidget {
  @override
  _IndexedStackPageState createState() => _IndexedStackPageState();
}

class _IndexedStackPageState extends State<IndexedStackPage> {
  int currentIndex = 0;

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        PageView(
          onPageChanged: (index) {
            setState(() {
              currentIndex = index;
            });
          },
          children: [
            ListTile(title: Text('Item 1')),
            ListTile(title: Text('Item 2')),
            ListTile(title: Text('Item 3')),
          ],
        ),
        IndexedStack(
          index: currentIndex,
          children: [
            Container(color: Colors.red),
            Container(color: Colors.green),
            Container(color: Colors.blue),
          ],
        )
      ],
    );
  }
}

在上述代码中,PageView 用于页面切换,IndexedStack 根据 PageView 的当前索引显示对应的子 Widget,这样只有当前显示的列表项及其相关的 Widget 会被渲染,减少了其他列表项的重绘。

五、性能监测与验证

(一)使用 Flutter DevTools

  1. 帧率监测:Flutter DevTools 提供了帧率监测功能。在应用运行时,可以打开 DevTools,选择“Performance”标签页,然后开始滚动列表。如果帧率波动较大,低于 60fps,说明可能存在重绘问题导致性能下降。

  2. Widget 重建监测:DevTools 还可以监测 Widget 的重建情况。在“Performance”标签页中,通过分析性能数据,可以查看哪些 Widget 被频繁重建,从而定位到可能引发重绘的代码部分。

(二)自定义性能监测

  1. 添加日志输出:在列表项的构建函数或者状态更新函数中添加日志输出,记录每次构建或者更新的时间和相关信息。通过分析日志,可以了解列表项的重建频率和触发原因。
class LoggingListItem extends StatefulWidget {
  @override
  _LoggingListItemState createState() => _LoggingListItemState();
}

class _LoggingListItemState extends State<LoggingListItem> {
  @override
  Widget build(BuildContext context) {
    print('Building LoggingListItem');
    return ListTile(title: Text('Logging Item'));
  }
}
  1. 使用 Stopwatch 测量时间:可以使用 Stopwatch 来测量列表更新或者重绘的时间。例如,在列表数据更新前后启动和停止 Stopwatch,获取更新所花费的时间,以此来评估优化效果。
class StopwatchListPage extends StatefulWidget {
  @override
  _StopwatchListPageState createState() => _StopwatchListPageState();
}

class _StopwatchListPageState extends State<StopwatchListPage> {
  final List<String> items = List.generate(10, (index) => 'Item $index');

  void updateList() {
    final stopwatch = Stopwatch()..start();
    setState(() {
      items.add('New Item');
    });
    stopwatch.stop();
    print('List update time: ${stopwatch.elapsedMilliseconds} ms');
  }

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        RaisedButton(
          child: Text('Update List'),
          onPressed: updateList,
        ),
        ListView.builder(
          itemCount: items.length,
          itemBuilder: (context, index) {
            return ListTile(title: Text(items[index]));
          },
        )
      ],
    );
  }
}

通过上述性能监测方法,可以准确地发现列表重绘问题,并验证优化措施是否有效,从而不断提升 Flutter 列表的滚动性能。

通过对 Flutter 列表重绘问题的深入分析和采取相应的优化策略,开发者可以显著提升列表的滚动性能,为用户带来更加流畅的使用体验。在实际开发中,需要根据具体的应用场景和需求,灵活运用这些优化方法,并结合性能监测工具,确保列表的高性能表现。