【Flutter 异步编程 - 肆】 | 异步任务状态与组件构建

4,002 阅读9分钟

本文为稀土掘金技术社区首发签约文章,14天内禁止转载,14天后未获授权禁止转载,侵权必究!
张风捷特烈 - 出品


一、 用户交互与界面的反馈

经过前三篇,我们已经对异步概念有了基本的认知,也认识了 FutureStream 两个对象的基本使用。Flutter 作为 UI 框架,最重要的工作是通过 构建组件 来决定呈现内容。异步任务是比较特殊的,它在执行期间具有 不确定性

在任务分发时,不知道任务完成 具体时刻 、不知道任务是否能够 成功完成

而在实际编程中,我们需要将异步任务中的 不确定 ,具体到界面表现中。这是应用交互过程中非常重要的环节。


1. 根据任务状态展示不同界面

比如 下拉刷新 时,从网络接口中获取最新数据进行展示。由于异步任务需要等待一些时间,在构建界面时一般会呈现 加载中 视图进行示意,从而让应用对用户的交互给出合适的 反馈,缓解等待焦虑。

根据任务的完成情况,我们也需要给出不同的界面呈现。如下中图,在下拉刷新失败时,需要给出提示信息,让用户知道此次任务没有完成,再恢复到任务分发前的状态。如下右图,如果首次加载数据失败,在界面上应该呈现 重新加载 视图,给用户可操作性的空间。

下拉刷新任务失败提示加载失败

另外,在一些任务执行期间,需要屏蔽用户的操作,从而避免多次点击。比如发送验证码之后,需要在一定时间后才能 重新获取 ,这时需要给出左图示意的 不可操作 等待视图倒计时。完成后,再展示 可操作 的重新获取按钮。这样可以避免用户的误操作,多次点击发送验证码。

等待状态重新获取

像一些网络接口的访问也是如此,比如登录、修改信息、上传文件等,都要在任务执行过程中屏蔽掉可操作性,避免多次分发任务,导致应用行为的异常。通过任务不同状态展示不同界面,就可以很好的解决这个问题。


2. 不合理的交互体验

如果任务失败,未给出明确的示意,或重新操作的机会,这在应用的设计上是有缺陷的。比如掘金 App 中,当断网后进入小册的条目中,只会提示网络不可用,并未给出重新加载的按钮,或任何刷新界面的机会。

小册刷新失败进入条目文章加载失败

另外,还有一些交互不合理的场景,比如右图中的 重新加载 ,如果 点击后任务失败 的间隔非常短,界面的表现就是在极短的时间内 任务失败 ->任务失败。也许这确实是程序运行中的 事实,但在用户眼中界面没有任何改变,这就导致用户的交互没有任何反馈。特别是在没有水波纹按钮的情况下,这是很不符合交互预期的。而且如果一个按钮如果点着没反应,就会让人疑惑是不是没点好,从而点击多次。

也许点击后应用确实触发了请求接口,但立刻因为网络判定为失败。在这种异常情况下,可以给出更人性化的反馈:比如,可以显示 1s 的加载中界面,再给出错误界面。这样可以给用户交互的反馈,这 1s 可以看作 "善意的谎言"


3.超时处理 - 避免长时间等待

用户的交互反馈是非常重要的,关于异常的处理,很多应用处理的都不是很好。比如断网时,微信中的钱包的金额会一直转圈,朋友圈的 loading 指示器会在 20 s 内一直显示刷新状态,然后悄无声息地消失,没有任何信息。

微信钱包微信朋友圈
1664714801801.png1664714928870.png

长时间的等待,是一个很不好的交互体验。对于可能长时间处于等待状态的任务,如果可以得到任务完成进度的,最好在界面上进行体现,比如大文件上传时。对于无法知晓进度的任务,最好给出超时处理,避免界面一直处于等待状态。 网络访问一般可以指定请求超时的时间,如果不是网络服务,可以使用 Timer 开启超时验证。

Timer? timeout;
int timeoutMS = 3000;

void sendFile() {
  startTimeoutCheck();
  // TODO 分发任务
}

void startTimeoutCheck() {
  if (timeout != null) {
    timeout!.cancel();
    timeout = null;
  }
  timeout = Timer(Duration(milliseconds: timeoutMS), _onTimeout);
}

void _onTimeout() {
  // TODO 超时处理 如取消 loading 状态,给出超时提示等
  if (timeout != null) {
    timeout!.cancel();
    timeout = null;
  }
}

应用开发中,非常关注正常情况下的界面表现,但对于异常时的小细节处理的并不好。很多应用在异常时只会弹出错误信息,或者给出统一的错误界面,更好一些的会给根据 错误类型 呈现不同的界面、解决方案。如下,是三大电商应用在 断网 情况下商品详情页的表现,大家可以品鉴一下:

京东淘宝拼多多

二、代码实践 - 网络请求与界面处理

接下来,通过一个具体的案例,结合代码说明一下如何根据 异步任务状态构建组件 。这里通过 Github 的开放 API 接口进行网络请求,获取数据。场景是: 展示用户的公开仓库 。接口文档见 list-repositories-for-a-user

【api】: api.github.com/users/{user…
username 为用户名


1. 接口响应数据结构分析

首先,我们可以使用 Postman 等接口访问软件进行请求测试,查看响应数据的结构。如下,获取用户名为 toly1994328 的公开仓库:

image.png


可以看出这是一个列表结构:

image.png

单体如下所示,可以看出字段非常多(已省略一些无用字段)。但很多字段是界面展示中不需要的,在解析时可以忽略。

image.png

{
        "id": 248628088,
        "node_id": "MDEwOlJlcG9zaXRvcnkyNDg2MjgwODg=",
        "name": "FlutterUnit",
        "full_name": "toly1994328/FlutterUnit",
        "private": false,
        "owner": {
            "login": "toly1994328",
            "id": 26687012,
            "node_id": "MDQ6VXNlcjI2Njg3MDEy",
            "avatar_url": "https://avatars.githubusercontent.com/u/26687012?v=4",
        },
        "html_url": "https://github.com/toly1994328/FlutterUnit",
        "description": "【Flutter 集录指南 App】The unity of flutter, The unity of coder.",
        "fork": false,
        "url": "https://api.github.com/repos/toly1994328/FlutterUnit",
        "created_at": "2020-03-19T23:47:07Z",
        "updated_at": "2022-10-01T15:18:35Z",
        "pushed_at": "2022-09-30T10:37:14Z",
        "homepage": "",
        "size": 46177,
        "stargazers_count": 5369,
        "watchers_count": 5369,
        "language": "Dart",
        "has_issues": true,
        "has_projects": true,
        "has_downloads": true,
        "has_wiki": true,
        "has_pages": false,
        "forks_count": 880,
        "visibility": "public",
        "forks": 880,
        "open_issues": 44,
        "watchers": 5369,
        "default_branch": "master"
    },

2. 定义实体类及解析

如下,定义 GithubRepository 类来承载界面中需要的数据,并通过 GithubRepository.fromJson 构造根据解析的 json 对象为成员初始化:

class GithubRepository {
  final String username;
  final String name;
  final String userAvatarUrl;
  final String description;
  final String url;
  final String language;
  final bool private;
  final int stargazersCount;
  final int forksCount;
  final String updateAt;

  const GithubRepository({
    required this.username,
    required this.name,
    required this.userAvatarUrl,
    required this.description,
    required this.url,
    required this.language,
    required this.private,
    required this.stargazersCount,
    required this.forksCount,
    required this.updateAt,
  });

  factory GithubRepository.fromJson(dynamic map) {
    return GithubRepository(
      username: map['owner']['login'] ?? '',
      userAvatarUrl: map['owner']['avatar_url'] ?? '',
      description: map['description'] ?? '',
      url: map['html_url'] ?? '',
      name: map['name'] ?? '',
      language: map['language'] ?? '',
      private: map['private'] ?? '',
      stargazersCount: map['stargazers_count'] ?? 0,
      forksCount: map['stargazers_count'] ?? 0,
      updateAt: map['updated_at'] ?? '',
    );
  }
}

在解析时,可以先把接口数据拷贝出来,将这些字符定义为 data ,进行解析。代码如下:

String data = '接口数据';

void main(){
  dynamic result = json.decode(data);
  List<GithubRepository> reps = result.map<GithubRepository>(GithubRepository.fromJson).toList();
  print(reps);
}


解析没问题后,可以通过 dio 库进行网络请求。首先在 pubspec.yaml 中添加依赖:

dependencies:
  dio: ^4.0.6

然后定义 GithubApi 类用于网络请求处理,提供 getRepositoryByUser 异步方法,返回 List<GithubRepository> 泛型的 Future 对象。通过 get 方法,请求接口,获取数据并解析,暂时未处理异常。

class GithubApi {
  static const String host = 'https://api.github.com';

  late Dio dio;

  GithubApi() {
    dio = Dio(BaseOptions(baseUrl: host));
  }

  Future<List<GithubRepository>> getRepositoryByUser({
    required String username,
  }) async {
    String url = '/users/$username/repos';
    Response<String> rep = await dio.get<String>(url);
    List<GithubRepository> resultReps = [];
    if (rep.statusCode == 200 && rep.data != null) {
      dynamic result = json.decode(rep.data!);
      resultReps = result.map<GithubRepository>(GithubRepository.fromJson).toList();
    }
    return resultReps;
  }
}

这时,可以在测试文件中对接口操作进行测试。如下所示,这样就完成 getRepositoryByUser 异步方法,获取 GithubRepository 数据。


3. 使用 FutureBuilder 处理异步任务中的界面构建

可以回想一下,之前我们对 Future 异步任务 的处理。在分发异步任务之后,根据不同的任务的状态,通过 setState 进行界面的更新。 Flutter 框架中封装了 FutureBuilder 组件,来方便让使用者根据 Future 的状态构建组件。

13.gif

如下所示, FutureBuilder 继承自 StatefulWidget ,可以指定一个泛型。在构造时必须传入 builder 参数,该参数是自定义的函数类型 AsyncWidgetBuilder<T> 。也就是说,builder 是一个回调函数,返回值是 Widget, 表示其用于构建组件。通过这种回调的方式可以在任务状态变化时 局部重新构建 ,避免大范围的更新。

image.png

另外,将任务的执行情况封装为 AsyncSnapshot<T> 对象,通过回调参数暴露给使用者,可以更方便的对任务状态进行感知,从而进行界面的构建工作。

final AsyncWidgetBuilder<T> builder;
final Future<T>? future;

typedef AsyncWidgetBuilder<T> = Widget Function(BuildContext context, AsyncSnapshot<T> snapshot);

如下,分别是任务分发后, 加载中加载完成 的界面:

加载中加载完成
image.pngimage.png

_HomePageState 中使用 FutureBuilder 构建主体内容,异步任务 getRepositoryByUserinitState 中初始化进行分发,返回 Future<List<GithubRepository>> 对象为 task 成员进行赋值。 FutureBuilder 中传入 task后,FutureBuilder 内部状态将会监听任务的执行情况,并通过 builder 回调函数通知使用者。这里通过 buildByState 进行处理:

class HomePage extends StatefulWidget {
  const HomePage({Key? key}) : super(key: key);

  @override
  State<HomePage> createState() => _HomePageState();
}

class _HomePageState extends State<HomePage> {
  late Future<List<GithubRepository>> task;
  final GithubApi api = GithubApi();

  @override
  void initState() {
    super.initState();
    task = api.getRepositoryByUser(username: 'toly1994328');
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('toly1994328 的 github 仓库'),
      ),
      body: FutureBuilder<List<GithubRepository>>(
        future: task,
        builder: buildByState,
      ),
    );
  }

在回调中,使用者可以通过 snapshot 对象感知任务状态 ConnectionState ,并根据状态返回对应的界面。如下所示,当 ConnectionStatewaiting 时,展示加载中界面 GithubRepositoryLoadingPaneldone 时表示任务完成,可以通过 hasData 查看是否存在数据,如果有数据,展示加载完成界面 GithubRepositoryPanel

其中 GithubRepositoryLoadingPanel 组件构建时,加载中动画使用了 flutter_spinkit 三方包,代码详见 【github_repository_loading_panel】GithubRepositoryPanel 是根据数据展示列表,这里不贴代码了,可详见源码 【github_repository_panel】

Widget buildByState(BuildContext context, AsyncSnapshot<List<GithubRepository>> snapshot) {
  switch (snapshot.connectionState) {
    case ConnectionState.none:
    case ConnectionState.active:
      break;
    case ConnectionState.waiting:
      return const GithubRepositoryLoadingPanel();
    case ConnectionState.done:
      if (snapshot.hasData) {
        if (snapshot.data != null) {
          return GithubRepositoryPanel(githubRepositories: snapshot.data!);
        }
      }
      break;
  }
  return const SizedBox.shrink();
}

这就是最基本的根据异步任务状态,决定视图显示。其实 FutureBuilder 只是一个简单的封装类而已,并不是异步任务只能通过 FutureBuilder 处理。不用它,我们手动维护状态变化,除了麻烦一点,也没有什么本质的区别;或者有些比较特殊的场景,也可以自己封装异步处理的逻辑。所以,不要让工具限制了你的思维。


4. 异常处理

下面来看一下异常处理,对于一个任务而言,出现异常的原因非常多。比如网络请求中,可能是本地网络连接异常,也可能是服务器处理异常,也可能是程序在解析数据时出现异常。

在开发中,我们最好能够区分是哪种异常,并表现在界面上。这样,在发生错误时可以更快地定位问题,对于用户来说体验上也会更好一些。当然,这样就意味着需要多写一些代码进行界面构建逻辑处理。

断网时数据解析异常服务器响应异常

这里简单定义一个 ErrorType 的枚举类用于记录异常状态,实际开发中,如果想在界面上显示更详细的异常信息,可以通过定义实体类记录需要的字段。

enum ErrorType{
  netConnectError,
  serverError,
  dataParserError
}

接下来在 getRepositoryByUser 任务处理过程中,在相关的异常实际下抛出对应的异常即可:

image.png

这样在 FutureBuilder 组件的 builder 回调中,就可以监听到异常的信息。根据异常信息进行界面构建即可,比如这里使用 GithubRepositoryErrorPanel 组件:

image.png

GithubRepositoryErrorPanel 组件中,根据不同的 ErrorType ,构建不同的组件进行展示。具体代码可详见 【github_repository_error_panel】

class GithubRepositoryErrorPanel extends StatelessWidget {
  final VoidCallback onRefresh;
  final ErrorType errorType;

  const GithubRepositoryErrorPanel({
    Key? key,
    required this.onRefresh,
    required this.errorType,
  }) : super(key: key);

  @override
  Widget build(BuildContext context) {
    switch (errorType) {
      case ErrorType.netConnectError:
        return _NetErrorPanel(onRefresh: onRefresh);
      case ErrorType.dataParserError:
        return _DataParserErrorPanel(onRefresh: onRefresh);
      case ErrorType.serverError:
        return _ServerErrorPanel(onRefresh: onRefresh);
    }
  }
}

有些异常是很难复现的,比如一些服务器的异常,不过开发中我们可以故意抛出异常进行模拟,来进行测试界面的表现:

刷新1刷新2异常到正常
25.gif26.gif28.gif

另外关于刷新有个小细节,因为 FutureBuilder 组件本质上是监听 Future 对象来触发局部更新的。而前面我们知道有个 Future 只能响应一次,FutureBuilderinitState 方法中对 Future 对象进行订阅。

image.png

如果点击按钮时, _HomePageState#refresh 方法中只是重新分发 getRepositoryByUser 任务,为 task 赋值。 FutureBuilder 的状态类是无法再接收到信息的。

---->[_HomePageState#refresh]----
void refresh(){
  task = api.getRepositoryByUser(username: 'toly1994328');
}

这就是 FutureBuilder 源码中所说的这种情况,可以通过 _HomePageState#setStateFutureBuilder 的状态类重新订阅新的 Future 对象。

image.png

---->[_HomePageState#refresh]----
void refresh(){
  task = api.getRepositoryByUser(username: 'toly1994328');
  setState(() {
  });
}

其原理是,父级组件状态类触发 setState 时,会触发子级组件状态的 didUpdateWidget 方法,此时会对 Future 对象重新订阅:

image.png


三、Stream 与界面构建 - StreamBuilder 的使用

上一篇介绍过,Stream 流就是一系列的状态元素,每次 发布者 产出元素,监听者可以监听到对应事件,每次触发监听称为一次 激活 (active)

比如网络状态在时间维度上是一系列元素,可能在任意时刻发生任意变化,这就是很标准的 Stream 。通过监听网络状态流,我们就可以在每次激活时获得最新的网络状态。如果界面中需要对网络状态进行响应,使用 StreamBuilder 监听流,来局部构建组件是一个不错的选择。


1. StreamBuilder 构建网络状态响应视图

其实 StreamBuilderFutureBuilder 是很类似的,都是对异步任务进行监听,在任务状态变化时,触发回调来局部更新。只不过 FutureBuilderFuture 异步任务,完成即止;而 StreamBuilderStream 异步任务,可以持续监听。两者都是通过 AsyncWidgetBuilder 进行组件构建的:

image.png

接下来实现如下效果,在网络状态每次变化时,界面上的显示视图随之变换:

手机网络切换WiFi 网络切换
29.gif30.gif

首先,可以使用 connectivity_plus 插件获取网络状态的流:

dependencies:
  connectivity_plus: ^2.3.7

_MyStatefulWidgetState#initState 时,使用 Connectivity().onConnectivityChangednetStream 成员进行赋值。该流的泛型为 ConnectivityResult ,也就表示流中的元素类型为 ConnectivityResult 。通过 StreamBuildernetStream 进行监听,并通过 _buildByStreamState 方法根据状态构建组件:

class MyStatefulWidget extends StatefulWidget {
  const MyStatefulWidget({super.key});

  @override
  State<MyStatefulWidget> createState() => _MyStatefulWidgetState();
}

class _MyStatefulWidgetState extends State<MyStatefulWidget> {
  late Stream<ConnectivityResult> netStream;

  @override
  void initState() {
    super.initState();
    netStream = Connectivity().onConnectivityChanged;
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('网络状态监听'),),
      body: Center(
        child: StreamBuilder<ConnectivityResult>(
          stream: netStream,
          builder: _buildByStreamState,
        ),
      ),
    );
  }

_buildByStreamState 回调方法也是 AsyncWidgetBuilder<T> 类型的,所以和 FutureBuilder 类似,也可以通过 AsyncSnapshot 状态进行构建不同的组件。需要注意一点:每次 Stream 监听到事件时,会触发回调,此时 snapshot 的状态为 ConnectionState.active 。 由于 Future 任务完成即止,所以使用FutureBuilder 时不会有 active 状态。

另外,其中的 NetStatePanel 组件就是根据 ConnectivityResult 枚举构建组件。逻辑非常简单,没什么好说的,这里就不贴了。详见源码 【net_state/view】

Widget _buildByStreamState(
    BuildContext context, AsyncSnapshot<ConnectivityResult> snapshot) {
  if (snapshot.hasError) {
    return const NetErrorPanel();
  }
  switch (snapshot.connectionState) {
    case ConnectionState.none:
    case ConnectionState.done:
      break;
    case ConnectionState.waiting:
      return const CupertinoActivityIndicator();
    case ConnectionState.active:
      return NetStatePanel(state: snapshot.data);
  }
  return const SizedBox.shrink();
}

2. StreamBuilder 与状态转换的思想

从广义上来看,界面的状态在时间轴上会有若干次变化,而且变化的时机、变化的结果都是不定的。这其实也是一种 Stream ,其中界面的状态是流中的元素,在合适的时机产出对应的状态元素。然后通过 StreamBuilder 监听流,根据状态进行组件构建。

fa5e1d89eeb4cd63114f255ec13b076.png


现在定义 RepositoryState 基类,派生出 RepositoryLoadingStateRepositoryLoadedStateRepositoryErrorState 分别表示如下的三种状态:

加载中状态加载完成状态异常状态
class RepositoryState {
  const RepositoryState();
}

class RepositoryLoadingState extends RepositoryState {
  const RepositoryLoadingState();
}

class RepositoryLoadedState extends RepositoryState {
  final List<GithubRepository> data;

  const RepositoryLoadedState({required this.data});
}

class RepositoryErrorState extends RepositoryState {
  final ErrorType type;

  const RepositoryErrorState({required this.type});
}

然后在 GithubApi 中定义 StreamController<RepositoryState> 作为发布者产出元素;给出 repositoryStream 流获取方式。

image.png

然后 getRepositoryByUser 方法不再返回结果,而是在不同情况下通过 _reposCtrl 添加不同的状态对象。比如网络请求之前,产出 RepositoryLoadingState ,这样界面监听到该状态时显示加载中视图,其他同理。

void getRepositoryByUser({required String username}) async {
  String url = '/users/$username/repos';
  List<GithubRepository> resultReps = [];
  _reposCtrl.add(const RepositoryLoadingState()); // 产出等待中状态
  try {
    Response<String> rep = await dio.get<String>(url);
    rep = await dio.get<String>(url);
    if (rep.statusCode == 200 && rep.data != null) {
      dynamic result = json.decode(rep.data!);
      resultReps = result.map<GithubRepository>(GithubRepository.fromJson).toList();
      _reposCtrl.add(RepositoryLoadedState(data: resultReps));  // 产出加载完成状态
    } else {
      _reposCtrl.add(const RepositoryErrorState(type: ErrorType.serverError)); // 产出加载异常状态
    }
  } catch (e) {
    ErrorType type = ErrorType.netConnectError;
    if (e is DioError) {
      if (e.type == DioErrorType.other) {
        type = ErrorType.netConnectError;
      } else {
        type = ErrorType.serverError;
      }
    } else {
      type = ErrorType.dataParserError;
    }
    _reposCtrl.add(RepositoryErrorState(type: type)); // 产出加载异常状态
  }
}

然后通过 StreamBuilder 监听 api.repositoryStream 流,通过 buildByState 回调方法构建界面。其中只要根据 snapshot 中的状态构建界面即可。

这样也能完成同样的功能,通过 Stream 的好处在于我们可以持续监听状态的变化,而不像 Future 那样完成即止,刷新时还需要重新订阅。另外,使用 Stream 可以更方便地产出自定义的状态,灵活性非常好。最后,还有一个优势,在后面会介绍 Stream 流的转换操作,可以实现防抖、节流的功能。

了解 flutter_bloc 的朋友可能看这里比较亲切,其实 bloc 本质上就是对 Stream 的封装,核心思想并没有太大的不同,本质都不会脱离对流元素的监听,并根据产出状态构建界面。

刷新1刷新2异常到正常
25.gif26.gif28.gif

3. Stream 处理的灵活性

使用 StreamController 可以灵活地添加元素,产出状态。拿之前的场景介绍一下,如下左图:在刷新时,如果异常状态太快出现,就会导致加载界面一闪而过,体验上不是太好。我们可以处理一下,如右图所示:校验任务发送到异常的时间,如果过短,稍作等待,再产出异常状态。

刷新后异常太快优化处理

代码处理如下,在 getRepositoryByUser 方法中记录任务分发的时刻,发生异常时校验 cost 是否大于 1 ms ,如果小于 1000 ms , 等待 1000 - cost 毫秒后,再添加异常状态。正是由于 StreamController 可以让使用者主动添加元素,才可以让状态产出的流程更加灵活。

image.png


通过本文的介绍,大家应该对 异步任务状态组件构建 的关系有了一定的认识。 知道如何通过 FutureBuilder 组件监听 Future 对象进行组件构建;以及通过 StreamBuilder 组件监听 Stream 对象进行组件构建。其中蕴含的思想,希望大家可以好好参悟一下。下一篇我们将进一步认识 Future 对象,揭开它的表象,看一些更本质一点的东西。那本文就到这里,谢谢观看 ~