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


theme: cyanosis

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


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

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

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

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


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

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

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

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


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

| 等待状态 | 重新获取 | | --- | --- | | | |

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


2. 不合理的交互体验

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

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

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

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


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

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

| 微信钱包 | 微信朋友圈 | | --- | --- | | 1664714801801.png | 1664714928870.png |

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

```dart 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】: https://api.github.com/users/{username}/repos
username 为用户名


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

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

image.png


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

image.png

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

image.png

dart { "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']['avatarurl'] ?? '', description: map['description'] ?? '', url: map['htmlurl'] ?? '', name: map['name'] ?? '', language: map['language'] ?? '', private: map['private'] ?? '', stargazersCount: map['stargazerscount'] ?? 0, forksCount: map['stargazerscount'] ?? 0, updateAt: map['updated_at'] ?? '', ); } } ```


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

```dart String data = '接口数据';

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


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

dependencies: dio: ^4.0.6

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

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

late Dio dio;

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

Future > getRepositoryByUser({ required String username, }) async { String url = '/users/$username/repos'; Response rep = await dio.get (url); List resultReps = []; if (rep.statusCode == 200 && rep.data != null) { dynamic result = json.decode(rep.data!); resultReps = result.map (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 builder; final Future ? future;

typedef AsyncWidgetBuilder = Widget Function(BuildContext context, AsyncSnapshot snapshot); ```


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

| 加载中 | 加载完成 | | --- | --- | | image.png | image.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 createState() => _HomePageState(); }

class _HomePageState extends State { late Future > 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 >( future: task, builder: buildByState, ), ); } ```


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

其中 GithubRepositoryLoadingPanel 组件构建时,加载中动画使用了 flutter_spinkit 三方包,代码详见 【githubrepositoryloadingpanel】GithubRepositoryPanel 是根据数据展示列表,这里不贴代码了,可详见源码 【githubrepository_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 ,构建不同的组件进行展示。具体代码可详见 【githubrepositoryerror_panel】

```dart 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.gif | 26.gif | 28.gif

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

image.png

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

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

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

image.png

dart ---->[_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.gif | 30.gif |


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

dependencies: connectivity_plus: ^2.3.7

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

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

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

class _MyStatefulWidgetState extends State { late Stream 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 ( 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 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 ,这样界面监听到该状态时显示加载中视图,其他同理。

dart 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.gif | 26.gif | 28.gif


3. Stream 处理的灵活性

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

| 刷新后异常太快 | 优化处理 | | --- | --- | | | |

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

image.png


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

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值