避免 Widget build(BuildContext context)带来的副作用
Flutter 是一个响应式 UI 框架,当需要展示界面的时候,框架通过 build 方法生成一帧的画面,当画面频繁变化时,flutter 会重复调用 build 方法来生成每一帧。所以不合适的函数调用或者当 build 和一些 Widget(例如:FutureBuilder
,StreamBuilder
)合用时,可能会产生一些副作用。
以下是会产生副作用的示例:
import 'random_color.dart' as color;
class MyHomePage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Container(
color: Colors.white,
child: FutureBuilder(
future: color.randomColors(),
builder: (context, snapshot) {
if (snapshot.hasData) {
final colors = snapshot.data;
return Column(
children: <Widget>[
Expanded(flex: 4, child: Container(color: colors[0])),
Expanded(flex: 3, child: Container(color: colors[1])),
Expanded(flex: 2, child: Container(color: colors[2])),
Expanded(flex: 2, child: Container(color: colors[3])),
],
);
} else {
return Container();
}
}),
);
}
}
其中randomColor
实现如下:
Future<List<Color>> randomColors() async {
await loadData();
return randomColorsSync();
}
var functionCallCount = 0;
List<Color> randomColorsSync() {
assert(data.isNotEmpty);
final code = data[Random().nextInt(data.length)];
functionCallCount++;
print("Function call count: $functionCallCount");
return [
code.substring(0, 6),
code.substring(6, 12),
code.substring(12, 18),
code.substring(18, 24),
].map((e) => hexColor("#$e")).toList();
}
为了便于分析,我在randomColorSync
函数内部添加了统计函数调用计数的代码,用于将调用次数显示在控制台上。
除此之外,我们创建了一个辅助 Widget:HomeWrapWidget
来手动触发 Widget 重建:
class HomeWrapWidget extends StatefulWidget {
@override
_HomeWrapWidgetState createState() => _HomeWrapWidgetState();
}
class _HomeWrapWidgetState extends State<HomeWrapWidget> {
@override
Widget build(BuildContext context) {
return Scaffold(
body: MyHomePage(),
floatingActionButton: Builder(
builder: (context) => FloatingActionButton(
onPressed: () => _rebuild(context),
child: Icon(Icons.tag_faces),
),
),
);
}
void _rebuild(BuildContext context) {
setState(() {});
}
}
最终的界面如下:
这是启动后的控制台日志:
I/flutter ( 4259): Function call count: 1
当我们重复点击悬浮按钮时,这个时候的控制台日志为:
I/flutter ( 4259): Function call count: 2
I/flutter ( 4259): Function call count: 3
I/flutter ( 4259): Function call count: 4
I/flutter ( 4259): Function call count: 5
I/flutter ( 4259): Function call count: 6
···
每次按下按钮后,界面的配色都发生了变化,重新获取了新的配色方案。
但是这并不是我们想要的效果,使用 FutureBuilder 的初衷时为了方便将 Future 数据映射为 UI,但是当我们更新界面其他部分时,却导致 FutureBuilder 再次获取了一遍数据,这种不符合直觉的结果在某些时候可能会产生一些错误。在示例部分我们使用了随机配色配合 FutureBuilder 构建 UI,所以当错误发生的时候,我们很明显可以观察到,但是如果将randomColor
函数替换为一个 http 请求函数,当你请求同一个网址,返回相同的资源的时候,错误发生后,仅从界面是观察不到相应的变化的。
当我们更新界面其他部分的时候,却导致了对返回 Future 数据的资源的反复请求,尤其在发生路由页面动画的时候,会触发整个页面的 Widget 树重建,按照流畅应用 60FPS 的要求计算,对于 http 请求每秒会发送 60 次,算上动画时长,多设备多用户,对用户流量,对服务器都是极大的负担,同时每次请求返回不同的 Future 对象,还会导致 FutureBuilder 重复调用 builder 方法,这就是我在标题里提到的副作用。
对于这些副作用,解决方案有以下 3 种:
在 initState 方法里获取 Future 对象并缓存
由于
initState
方法在整个 StatefulWidget 的生命周期中只会调用一次,所以对于 Future、Stream,可以在该方法中将需要用到的返回结果缓存下来,供后续使用。如果调用的方法需要传递BuildContext
对象,也可以在didChangeDependencies
方法中缓存调用结果。didChangeDependencies
方法会在 Element 依赖发生改变的时候被调用。code:
class _MyHomePage2State extends State<MyHomePage2> { Future<List<Color>> colorsFuture; @override void initState() { colorsFuture = color.randomColors(); super.initState(); } @override Widget build(BuildContext context) { return Container( color: Colors.white, child: FutureBuilder( future: colorsFuture, builder: (context, snapshot) { if (snapshot.hasData) { final colors = snapshot.data; return Column( children: <Widget>[ Expanded(flex: 4, child: Container(color: colors[0])), Expanded(flex: 3, child: Container(color: colors[1])), Expanded(flex: 2, child: Container(color: colors[2])), Expanded(flex: 2, child: Container(color: colors[3])), ], ); } else { return Container(); } }), ); } }
使用 AsyncMemoizer
正如它的类名,
AsyncMemoizer
实质上就是一个内存缓存,runOnce
方法保证只运行一次函数,并在之后使用缓存的异步结果。code:
import 'package:async/async.dart' show AsyncMemoizer; class MyHomePage3 extends StatefulWidget { @override _MyHomePage3State createState() => _MyHomePage3State(); } class _MyHomePage3State extends State<MyHomePage3> { final _memoizer = AsyncMemoizer<List<Color>>(); @override Widget build(BuildContext context) { return Container( color: Colors.white, child: FutureBuilder( future: _memoizer.runOnce(color.randomColors), builder: (context, snapshot) { if (snapshot.hasData) { final colors = snapshot.data; return Column( children: <Widget>[ Expanded(flex: 4, child: Container(color: colors[0])), Expanded(flex: 3, child: Container(color: colors[1])), Expanded(flex: 2, child: Container(color: colors[2])), Expanded(flex: 2, child: Container(color: colors[3])), ], ); } else { return Container(); } }), ); } }
由外部管理 Future
这次不在 Widget 内部缓存 Future 结果,而是由外部管理,保证只获取一次,返回相同的 Future 对象
code:
var myColors = color.randomColors(); class MyHomePage4 extends StatelessWidget { @override Widget build(BuildContext context) { return FutureBuilder( future: myColors, builder: (context, snapshot) { if (snapshot.hasData) { return ColorListWidget(colors: snapshot.data); } else { return Container(); } }); } } class ColorListWidget extends StatelessWidget { final List<Color> colors; const ColorListWidget({Key key, @required this.colors}) : super(key: key); @override Widget build(BuildContext context) { return Column( children: <Widget>[ buildColorTile(flex: 4, color: colors[0]), buildColorTile(flex: 3, color: colors[1]), buildColorTile(flex: 2, color: colors[2]), buildColorTile(flex: 2, color: colors[3]), ], ); } Widget buildColorTile({int flex = 1, Color color}) { return Expanded( flex: flex, child: Container( color: color, child: Stack( children: <Widget>[ Positioned( left: 0, bottom: 0, child: Text( "#${color.value.toRadixString(16).padLeft(8, '0').substring(2).toUpperCase()}", style: TextStyle( color: ThemeData.estimateBrightnessForColor(color) == Brightness.light ? Colors.black.withOpacity(0.9) : Colors.white.withOpacity(0.9), ), ), ), ], ), ), ); } }
最终效果: