现象

最近在学习flutter,目前做了一个练手的小项目,项目包括一个登录逻辑,用户没有登录的话,会进入到登录界面,登录成功后,会把用户信息存储在本地,下次打开App的时候会自动读取,登录成功后回来到一个列表页,然后列表页点击其中一项就可以进入到详情页,差不多就是这么个小项目

项目结构

main.dart

final userMobx = UserMobx();

void main() {
  runApp(MyApp());
}

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

  @override
  Widget build(BuildContext context) {
    return MultiProvider(
        providers: [
          Provider<UserMobx>.value(value: userMobx),
          Provider<ApiService>.value(value: ApiService.create())
        ],
        child: MaterialApp(
            title: 'Flutter Demo',
            theme: ThemeData(
              primarySwatch: Colors.blue,
            ),
            
            //onGenerateRoute: RouteGenerator.generateRoute,
            home: Root()));
  }
}

root.dart

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

  @override
  Widget build(BuildContext context) {
    userMobx.readUserFromLocal(); // 罪魁祸首
    return Container(
      child: Observer(
        builder: (_) {
          if (userMobx.status == Status.Pending) {
            return _loading(context);
          } else if (userMobx.status == Status.LoggedOut) {
            return LoginPage();
          } else {
            return ExamIndex();
          }
        },
      ),
    );
  }

  Widget _loading(BuildContext context) {
    return Center(
        child: SizedBox(
            width: 24.0,
            height: 24.0,
            child: CircularProgressIndicator(strokeWidth: 2.0)));
  }
}

列表页exam_list.dart

class ExamIndex extends StatefulWidget {
  ExamIndex({Key key}) : super(key: key);

  _ExamIndexState createState() => _ExamIndexState();
}

class _ExamIndexState extends State<ExamIndex>
     {
  BuiltList<Exam> exams = ExamList().exams;
  int page = 1;
  bool loading = true;
  bool lastPage = false;

  // @override
  // bool get wantKeepAlive => true;

  @override
  void initState() {
    super.initState();
    print("examlist initState");
    _fetchData();
  }

  Future _fetchData() async {
    final client = ApiService.create();
    loading = true;
    Response<ExamList> _examListResponse = await client.getExams(page);
    ExamList _examList = _examListResponse.body;
    exams = exams.rebuild((b) => b..addAll(_examList.exams));
    page += 1;
    lastPage = _examList.lastPage;
    loading = false;
    if (mounted) {
      setState(() {});
    }
  }

  Future _refresh() async {}

  @override
  Widget build(BuildContext context) {
    //super.build(context);
    print("examlist building");
    return Scaffold(
        appBar: AppBar(title: Text("题目列表")), body: _listView(context));
  }

  RefreshIndicator _listView(BuildContext context) {
    return RefreshIndicator(
      onRefresh: _refresh,
      child: ListView.builder(
          itemCount: exams.length,
          padding: EdgeInsets.all(8),
          itemBuilder: (context, index) {
            if (index == exams.length - 1) {
              if (lastPage) {
                //Fluttertoast.showToast(msg: "已经到最后一页了");
              } else {
                _fetchData();
                return Center(
                    child: CircularProgressIndicator(strokeWidth: 2.0));
              }
            }

            Exam exam = exams[index];
            return GestureDetector(
                onTap: () =>
                    _navigateToPost(context, exam.id),
                child: Card(
                    child: Padding(
                  padding: EdgeInsets.all(5.0),
                  child: Column(
                    children: <Widget>[
                      Row(
                        children: <Widget>[
                          _label("出题人:"),
                          _value(exam.creatorName),
                          _label("考生:"),
                          _value(exam.userName)
                        ],
                      ),
                      Row(
                        children: <Widget>[
                          _label("元素个数:"),
                          _value(exam.factorNum),
                          _label("考题类型:"),
                          _value(exam.examType)
                        ],
                      ),
                      Row(
                        children: <Widget>[
                          _label("难度等级:"),
                          _value(exam.levelType),
                          _label("题目数:"),
                          _value(exam.itemLimit.toString())
                        ],
                      ),
                      Row(
                        children: <Widget>[
                          _label("开始时间:"),
                          _value(exam.startAt),
                          _label("结束时间:"),
                          _value(exam.endAt)
                        ],
                      ),
                      Row(
                        children: <Widget>[
                          _label("持续时间:"),
                          _value(exam.duration),
                          _label("分数:"),
                          _value(exam.score)
                        ],
                      )
                    ],
                  ),
                )));
          }),
    );
  }

  Widget _label(String name) {
    return Expanded(
        child: Text(name,
            style: TextStyle(fontWeight: FontWeight.bold, fontSize: 15)));
  }

  Widget _value(String name) {
    return Expanded(
        child: Text(name ?? '暂无',
            style: TextStyle(fontSize: 15, color: Colors.red)));
  }

  void _navigateToPost(BuildContext context, int id) {
    Navigator.push(
      context,
      new MaterialPageRoute(
          builder: (context) => new ExamDetail(), maintainState: true),
    );
  }
}

当我把登录逻辑和列表做完之后,无意间发现一个问题,当我从列表页跳转到详情页的时候(Navigator.push),列表页竟然重新build了,并且重新访问了接口,列表页的滚动位置也丢失了,更要命的是,当我返回的时候,同样的事情又发生了一次

原因

在各种网站,找了各种文章,还搜到了一个github的issue(https://github.com/flutter/flutter/issues/11655),最后发现原因是我在build函数里面调用了可能会使页面产生变化的函数,比如我root页build的第一行,我调用了readUserFromLocal,之前我列表页的拉数据也放在了build里面,如果你用了futureBuilder,那么也会发生同样的事情

解决方法

把root页改成statefullWidget,然后在initState里面去做这些登录逻辑,代码如下

class Root extends StatefulWidget {
  Root({Key key}) : super(key: key);

  _RootState createState() => _RootState();
}

class _RootState extends State<Root> {

  @override
  void initState() {
    super.initState();
    userMobx.readUserFromLocal();
    print("root initState");
  }

  @override
  didChangeDependencies() {
    super.didChangeDependencies();
    print("root didChangeDependencies");
  }

  @override
  Widget build(BuildContext context) {
    //super.build(context);
    return Container(
      child: Observer(
        builder: (_) {
          if (userMobx.status == Status.Pending) {
            return _loading(context);
          } else if (userMobx.status == Status.LoggedOut) {
            return LoginPage();
          } else {
            print("Observer");
            return ExamIndex();
          }
        },
      ),
    );
  }

  Widget _loading(BuildContext context) {
    return Center(
        child: SizedBox(
            width: 24.0,
            height: 24.0,
            child: CircularProgressIndicator(strokeWidth: 2.0)));
  }
}

列表页也是,拉数据的操作应该放在initState,而不是build函数里面

总结

  1. Navigator.push/pop确实会触发之前所有组件的build,但是这个不是问题,官方表示这不会耗费性能,相反对性能是有好处的
  2. build函数要保证里面不能有可能会使页面产生变化的函数,这会使你的组件百分百重绘而耗费大量的性能,万万不可把访问API,读写文件等操作放在build里面!