Flutter中如何优化列表类应用卡顿

图片列表类网络应用非常普遍,例如 B 站、小红书等。这类应用的主要特点是:

  • 多标签页切换
  • 标签页中包含列表 GridView 或者 ListView
  • 列表中包含大量网络图片资源加载

下图是 B 站的切换效果,其中热门页中包含 ListView,追番页中包含 GridView,它们都包含大量网络图片资源。

在多标签页切换过程中,如果应用处理不当,会造成严重的卡顿问题。

下面是一些减少列表卡顿问题的策略和示例代码。

一. 减少刷新次数

在标签页中,经常存在频繁调用 setState 的情况。这里列举了两个常见场景和优化方法。

1. 不相关多请求合并

通常,标签页中包含不同结构,需在多个不同类型请求情况下更新界面数据。

例如,标签页结构包含三个部分:头部轮播、中部分类和底部的列表。如下图所示,这三部分内容涉及到三个不同的协议请求。

如果每个协议请求完毕后都使用 setState 来更新当前界面数据,势必会增加刷新次数。此时可使用Future.wait处理,Future.wait 可以带来两个好处(详细原因见打赌你不知道的两个 await 使用细节):

  1. 请求合并减少耗时。
  2. 请求结果合并提高数据可维护性。

如下代码中,fun1 和 fun2 函数是两个不相干的耗时任务,通过 wait 合并将结果汇总在 rArr 数组中。示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
main(List<String> args) async {
var t1 = DateTime.now().millisecondsSinceEpoch;
await callFuns2();
var t2 = DateTime.now().millisecondsSinceEpoch;
print('cost ${t2 - t1}');
}

Future callFuns2() async {
var rArr = await Future.wait([fun1(), fun2()]);
var r = rArr[0];
var r2 = rArr[1];
print('r = $r');
print('r2 = $r2');
}

Future<String> fun1() {
return Future.delayed(Duration(seconds: 2), () {
return "future 1";
});
}

Future<String> fun2() {
return Future.delayed(Duration(seconds: 3), () {
return "future 2";
});
}

2. 擅用 FutureBuilder

常见的逻辑处理流程:进入标签页,在 initState 中发起异步请求,之后 setState 刷新界面。如果 initState 中有多个异步请求,setState 的数目也会随之增加。加入多个标签页后,setState 的数目则系数级增长。

如何解决此问题呢?可使用 FutureBuilder。

FutureBuilder 依赖一个 Future,它会根据所依赖的 Future 的状态来动态构建自身。代码如下:

initialData 参数可以加载缓存数据作为默认值。future 参数中请求实时网络数据,整个过程中完全不再需要使用 setState,可达到同样的效果。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
Widget build(BuildContext context) {
return Center(
child: FutureBuilder<String>(
future: mockNetworkData(),
//initialData:此处可加载缓存数据作为默认值。
builder: (BuildContext context, AsyncSnapshot snapshot) {
// 请求已结束
if (snapshot.connectionState == ConnectionState.done) {
if (snapshot.hasError) {
// 请求失败,显示错误
return Text("Error: ${snapshot.error}");
} else {
// 请求成功,显示数据
return Text("Contents: ${snapshot.data}");
}
} else {
// 请求未结束,显示loading
return CircularProgressIndicator();
}
},
),
);
}

Future<String> mockNetworkData() async {
return Future.delayed(Duration(seconds: 2), () => "我是从互联网上获取的数据");
}

注意:FutureBuilder 存在耗时操作,如果确定每次 build 过程中需要实时数据可以采用 FutureBuilder,若该耗时任务只请求一次的场景,需要放到 instate 中来处理。FutureBuilder 需擅用而非乱用,需视场景决定。

二. 网络数据按需请求

示例场景图片如下:

1. 适当拆分请求

非场景强相关网络请求可按需前移或后移。

例如图二中,底部标签栏与首页顶部子标签栏数据如果来自后端且由单独标签分类协议获取,则可前移至图一的欢迎页。

这样,用户不会察觉到标签分类数据的拉取过程,进入应用后首页可直接呈现标签协议数据,提高用户体验。

2. 分页加载策略及请求 size 优化

  • 请求 Size 优化

如上图二中,红色实现列表可见区域仅显示了 4 个数据。因此,列表请求协议的默认初始 size 可设置为 4。选择一个过大的 size 是没有意义的,因为用户是否会下滑列表存在不确定性。如果 size 设置过大,就会浪费不必要的系统资源,降低显示效率。

  • 分页加载策略

若用户在滑动列表时可以采用分页加载策略,每次请求 4 个 item 即可。推荐一个分页加载开源项目flutter_pagewise | Flutter Package

PAGE_SIZE:设置要显示的项目数,这里可以设置为 4。

pageFuture:下拉时根据 pageIndex * PAGE_SIZE 确定偏移分页请求的实时数据。

itemBuilder:构建要显示的项目。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@override
Widget build(BuildContext context) {
return PagewiseGridView<ImageModel>.count(
pageSize: PAGE_SIZE,
crossAxisCount: 3,
mainAxisSpacing: 8.0,
crossAxisSpacing: 8.0,
childAspectRatio: 0.555,
padding: EdgeInsets.all(15.0),
itemBuilder: this._itemBuilder,
pageFuture: (pageIndex) =>
BackendService.getImages(pageIndex! * PAGE_SIZE, PAGE_SIZE),
);
}

三. 子线程加载图片

此处拿一个复杂图片加载流程说明,如:后端下发一个 base64 的图片地址,需要显示该 base64 图片。

整个过程伪代码表示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
Future<Uint8List?> imageToUint8List(String base64ImgUrl) async{
//① 通过url下载成文件
File file = downloader.download(base64ImageUrl);
//② 从文件中读取原始加密过的base64image流
String encodeBase64ImageStr = file.readAsStringSync();
//③ 将加密base64image流进行解密
String decodeBase64ImageStr = decode(encodeBase64ImageStr, decodeKey);
//④ 将base64 string转换成Uint8List
Uint8List strBase64 = base64Decode(decodeBase64ImageStr);
return strBase64;
}

Uint8List imgBytes = await imageToUint8List("https://base64/url");
Image.memory(imgBytes,...);

与直接下发的 imageUrl 通过 Image.network(imageUrl)相比,上述流程复杂得多。

其中第 ①②③ 步均属于比较耗时的操作。如果每张图片都经历这些步骤,每个标签页中包含大量图片时,切换过程势必会非常卡顿。

Flutter 中提供了 isolate 机制,可以将耗时任务放入单独的线程中。使用系统提供的 compute 封装,示例如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
Future<Uint8List?> imageToUint8List(String base64ImgUrl) async{
return compute(downloadAndDecode,base64ImageUrl);
}

Future<Uint8List?> downloadAndDecode(String base64ImageUrl) async{
// 实现同原imageToUint8List
//① 通过url下载成文件
//② 从文件中读取原始加密过的base64image流
//③ 将加密base64image流进行解密
//④ 将base64 string转换成Uint8List
return strBase64;
}

Uint8List imgBytes = await imageToUint8List("https://base64/url");
Image.memory(imgBytes,...);

在转移到 compute 中存在使用通道问题。如果在 downloadAndDecode 中使用通道,则会报错。在 Flutter 3.7 之前,只能使用 Dart 来实现其中的所有功能。在 3.7 之后,支持子进程使用通道,具体实现方法请参考Flutter 3.7 新特性:介绍后台 isolate 通道

四. 提供加载优先策略

为什么要提供下载优先策略呢?原因很简单,提高用户体验。

试想如下场景:多个标签页间快速切换,而每个标签页中都包含了大量的图片。当你切换到最后一个标签页时停下来,之前标签页中是不是有一些图片加载任务还在执行呢?此时我们就应该优先完成停留页面的图片显示任务,以提高用户的体验感。

以上述优化建议三中场景为例:后端下发一个 base64 的图片地址,需要显示该 base64 图片。

此时可通过调整 downloadAndDecode 中步骤 ① 下载优先策略来控制整个图片显示过程:将最后停留页面的图片下载任务优先执行,切换过程中图片任务后执行。笔者定制了flutter_download_manager项目,使其支持 LIFO 模式,保证后停留页面的 base64 图片先加入到下载队列中优先下载并显示。

1
2
3
4
enum DownloadStrategy{
FIFO,
LIFO
}

设置方法如下:

1
2
IDownloader downloadManager =
IDownloader(strategy: DownloadStrategy.LIFO, maxConcurrentTasks: 5);

五. 提供缓存策略

缓存策略可以延伸到很多方面,此处从如下两方面说明:

1. 图片缓存

其中包括图片显示过程中缓存和图片本身缓存。

  • 图片显示过程中缓存

图片显示过程若比较耗时,分多个步骤获取图片。可以将各步骤产物进行缓存。

如下载产物,base64 解码后产物,甚至 Uint8List。不过在子线程中缓存存在内存不共享和通道 flutter3.7 之前不支持访问问题,需要考虑好技术可行性和实施方案。

如下代码中缓存了 url 对应的 bodyBytes 数据,下次访问相同 url 则可直接返回使用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
class CachedData extends StatelessWidget {
final String url;

const CachedData({Key? key, required this.url}) : super(key: key);

@override
Widget build(BuildContext context) {
return FutureBuilder(
future: _getData(),
builder: (context, snapshot) {
if (snapshot.hasData) {
return Text(snapshot.data);
} else if (snapshot.hasError) {
return Text(‘Error: ${snapshot.error}’);
} else {
return CircularProgressIndicator();
}
},
);
}

Future<String> _getData() async {
final cache = await CacheManager.getInstance();
final data = await cache.get(url);
if (data != null) {
return utf8.decode(data.buffer.asUint8List());
} else {
final response = await http.get(Uri.parse(url));
if (response.statusCode == 200) {
await cache.put(url, response.bodyBytes);
return response.body;
} else {
throw Exception(‘Failed to load data’);
}
}
}
}

class CacheManager {
static CacheManager? _instance;

static Future<CacheManager> getInstance() async {
if (_instance == null) {
_instance = CacheManager._();
}
return _instance!;
}
  • 图片本身缓存

这个理解起来比较简单,说的是图片显示框架是否提供了图片缓存。

例如,ExtendedImage 比 Image 更好。而 PowerImage 支持原生和 Flutter 共享图片缓存,通过原生程序生成位图,然后共享给 Flutter。这样在性能上更为优化。

2. 标签页缓存

为保证已加载过的标签页中图片切回过程中不会重新加载,可使用 AutomaticKeepAliveClientMixin 来实现,不清楚推荐看下可滚动组件子项缓存

1
2
3
4
5
6
7
8
9
10
11
class _PageState extends State<Page> with AutomaticKeepAliveClientMixin {

@override
Widget build(BuildContext context) {
super.build(context); // 必须调用
return Center(child: Text("${widget.text}", textScaleFactor: 5));
}

@override
bool get wantKeepAlive => true; // 是否需要缓存
}

请求取消

标签页切换后,最后停留。应先加载停留页面列表的图片。在中间切换标签页时,此时处于不可见状态,因此可取消其间图片的加载请求。

如何取消呢?

可以通过 Future.any 来实现,在 Future.any 中依次插入取消任务与真正执行任务。若真正任务还未执行,取消任务就有机会取消执行真正任务。具体原理可查看Flutter 中如何取消任务。关键代码及说明如下:

总结

本文介绍了 Flutter 中优化列表类应用卡顿的几种方法。其中包括分页加载策略及请求 size 优化、子线程加载图片、提供加载优先策略、提供缓存策略和请求取消等。这些方法可提高应用的性能和用户体验。具体采用那种方式需要视具体场景自行选取,切莫乱用。

参考链接

7.6 异步 UI 更新(FutureBuilder、StreamBuilder) | 《Flutter 实战·第二版》

Loading a flutter app efficiently on slow internet connections | by Santhosh Adiga U | Apr, 2023 | Medium