theme: cyanosis
本文为稀土掘金技术社区首发签约文章,14天内禁止转载,14天后未获授权禁止转载,侵权必究!
张风捷特烈 - 出品
一、 用户交互与界面的反馈
经过前三篇,我们已经对异步概念有了基本的认知,也认识了 Future
和 Stream
两个对象的基本使用。Flutter
作为 UI
框架,最重要的工作是通过 构建组件
来决定呈现内容。异步任务是比较特殊的,它在执行期间具有 不确定性
:
在任务分发时,不知道任务完成
具体时刻
、不知道任务是否能够成功完成
。
而在实际编程中,我们需要将异步任务中的 不确定
,具体到界面表现中。这是应用交互过程中非常重要的环节。
1. 根据任务状态展示不同界面
比如 下拉刷新
时,从网络接口中获取最新数据进行展示。由于异步任务需要等待一些时间,在构建界面时一般会呈现 加载中
视图进行示意,从而让应用对用户的交互给出合适的 反馈
,缓解等待焦虑。
根据任务的完成情况,我们也需要给出不同的界面呈现。如下中图,在下拉刷新失败时,需要给出提示信息,让用户知道此次任务没有完成,再恢复到任务分发前的状态。如下右图,如果首次加载数据失败,在界面上应该呈现 重新加载
视图,给用户可操作性的空间。
| 下拉刷新 | 任务失败提示 | 加载失败 | --- | --- | --- | | |
|
另外,在一些任务执行期间,需要屏蔽用户的操作,从而避免多次点击。比如发送验证码之后,需要在一定时间后才能 重新获取
,这时需要给出左图示意的 不可操作
等待视图倒计时。完成后,再展示 可操作
的重新获取按钮。这样可以避免用户的误操作,多次点击发送验证码。
| 等待状态 | 重新获取 | | --- | --- | | |
|
像一些网络接口的访问也是如此,比如登录、修改信息、上传文件等,都要在任务执行过程中屏蔽掉可操作性,避免多次分发任务,导致应用行为的异常。通过任务不同状态展示不同界面,就可以很好的解决这个问题。
2. 不合理的交互体验
如果任务失败,未给出明确的示意,或重新操作的机会,这在应用的设计上是有缺陷的。比如掘金 App 中,当断网后进入小册的条目中,只会提示网络不可用,并未给出重新加载的按钮,或任何刷新界面的机会。
| 小册刷新失败 | 进入条目 | 文章加载失败 | --- | --- |--- | | |
|
另外,还有一些交互不合理的场景,比如右图中的 重新加载
,如果 点击后
到 任务失败
的间隔非常短,界面的表现就是在极短的时间内 任务失败
->任务失败
。也许这确实是程序运行中的 事实
,但在用户眼中界面没有任何改变,这就导致用户的交互没有任何反馈。特别是在没有水波纹按钮的情况下,这是很不符合交互预期的。而且如果一个按钮如果点着没反应,就会让人疑惑是不是没点好,从而点击多次。
也许点击后应用确实触发了请求接口,但立刻因为网络判定为失败。在这种异常情况下,可以给出更人性化的反馈:比如,可以显示 1s
的加载中界面,再给出错误界面。这样可以给用户交互的反馈,这 1s
可以看作 "善意的谎言"
。
3.超时处理 - 避免长时间等待
用户的交互反馈是非常重要的,关于异常的处理,很多应用处理的都不是很好。比如断网时,微信中的钱包的金额会一直转圈,朋友圈的 loading
指示器会在 20 s
内一直显示刷新状态,然后悄无声息地消失,没有任何信息。
| 微信钱包 | 微信朋友圈 | | --- | --- | | |
|
长时间的等待,是一个很不好的交互体验。对于可能长时间处于等待状态的任务,如果可以得到任务完成进度的,最好在界面上进行体现,比如大文件上传时。对于无法知晓进度的任务,最好给出超时处理,避免界面一直处于等待状态。 网络访问一般可以指定请求超时的时间,如果不是网络服务,可以使用 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
的公开仓库:
可以看出这是一个列表结构:
单体如下所示,可以看出字段非常多(已省略一些无用字段)。但很多字段是界面展示中不需要的,在解析时可以忽略。
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 的状态构建组件。
如下所示, FutureBuilder
继承自 StatefulWidget
,可以指定一个泛型。在构造时必须传入 builder
参数,该参数是自定义的函数类型 AsyncWidgetBuilder<T>
。也就是说,builder
是一个回调函数,返回值是 Widget, 表示其用于构建组件。通过这种回调的方式可以在任务状态变化时 局部重新构建
,避免大范围的更新。
另外,将任务的执行情况封装为 AsyncSnapshot<T>
对象,通过回调参数暴露给使用者,可以更方便的对任务状态进行感知,从而进行界面的构建工作。
``` final AsyncWidgetBuilder builder; final Future ? future;
typedef AsyncWidgetBuilder = Widget Function(BuildContext context, AsyncSnapshot snapshot); ```
如下,分别是任务分发后, 加载中
和 加载完成
的界面:
| 加载中 | 加载完成 | | --- | --- | | |
|
在 _HomePageState
中使用 FutureBuilder
构建主体内容,异步任务 getRepositoryByUser
在 initState
中初始化进行分发,返回 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
,并根据状态返回对应的界面。如下所示,当 ConnectionState
为 waiting
时,展示加载中界面 GithubRepositoryLoadingPanel
; done
时表示任务完成,可以通过 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
任务处理过程中,在相关的异常实际下抛出对应的异常即可:
这样在 FutureBuilder
组件的 builder
回调中,就可以监听到异常的信息。根据异常信息进行界面构建即可,比如这里使用 GithubRepositoryErrorPanel
组件:
在 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 | 异常到正常 | --- | --- |--- | | |
|
另外关于刷新有个小细节,因为 FutureBuilder
组件本质上是监听 Future
对象来触发局部更新的。而前面我们知道有个 Future
只能响应一次,FutureBuilder
在 initState
方法中对 Future
对象进行订阅。
如果点击按钮时, _HomePageState#refresh
方法中只是重新分发 getRepositoryByUser
任务,为 task
赋值。 FutureBuilder
的状态类是无法再接收到信息的。
dart ---->[_HomePageState#refresh]---- void refresh(){ task = api.getRepositoryByUser(username: 'toly1994328'); }
这就是 FutureBuilder
源码中所说的这种情况,可以通过 _HomePageState#setState
让 FutureBuilder
的状态类重新订阅新的 Future
对象。
dart ---->[_HomePageState#refresh]---- void refresh(){ task = api.getRepositoryByUser(username: 'toly1994328'); setState(() { }); }
其原理是,父级组件状态类触发 setState
时,会触发子级组件状态的 didUpdateWidget
方法,此时会对 Future
对象重新订阅:
三、Stream 与界面构建 - StreamBuilder 的使用
上一篇介绍过,Stream
流就是一系列的状态元素,每次 发布者
产出元素,监听者可以监听到对应事件,每次触发监听称为一次 激活 (active)
。
比如网络状态在时间维度上是一系列元素,可能在任意时刻发生任意变化,这就是很标准的 Stream
。通过监听网络状态流,我们就可以在每次激活时获得最新的网络状态。如果界面中需要对网络状态进行响应,使用 StreamBuilder
监听流,来局部构建组件是一个不错的选择。
1. StreamBuilder 构建网络状态响应视图
其实 StreamBuilder
和 FutureBuilder
是很类似的,都是对异步任务进行监听,在任务状态变化时,触发回调来局部更新。只不过 FutureBuilder
是 Future
异步任务,完成即止;而 StreamBuilder
是 Stream
异步任务,可以持续监听。两者都是通过 AsyncWidgetBuilder
进行组件构建的:
接下来实现如下效果,在网络状态每次变化时,界面上的显示视图随之变换:
| 手机网络切换 | WiFi 网络切换 | | --- | --- | | |
|
首先,可以使用 connectivity_plus
插件获取网络状态的流:
dependencies: connectivity_plus: ^2.3.7
在 _MyStatefulWidgetState#initState
时,使用 Connectivity().onConnectivityChanged
为 netStream
成员进行赋值。该流的泛型为 ConnectivityResult
,也就表示流中的元素类型为 ConnectivityResult
。通过 StreamBuilder
对 netStream
进行监听,并通过 _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
监听流,根据状态进行组件构建。
现在定义 RepositoryState
基类,派生出 RepositoryLoadingState
、RepositoryLoadedState
、 RepositoryErrorState
分别表示如下的三种状态:
| 加载中状态 | 加载完成状态 | 异常状态 | | --- | --- | --- | | |
|
``` 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
流获取方式。
然后 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 | 异常到正常 | --- | --- |--- | | |
|
3. Stream 处理的灵活性
使用 StreamController
可以灵活地添加元素,产出状态。拿之前的场景介绍一下,如下左图:在刷新时,如果异常状态太快出现,就会导致加载界面一闪而过,体验上不是太好。我们可以处理一下,如右图所示:校验任务发送到异常的时间,如果过短,稍作等待,再产出异常状态。
| 刷新后异常太快 | 优化处理 | | --- | --- | | |
|
代码处理如下,在 getRepositoryByUser
方法中记录任务分发的时刻,发生异常时校验 cost
是否大于 1 ms
,如果小于 1000 ms
, 等待 1000 - cost
毫秒后,再添加异常状态。正是由于 StreamController
可以让使用者主动添加元素,才可以让状态产出的流程更加灵活。
通过本文的介绍,大家应该对 异步任务状态
与 组件构建
的关系有了一定的认识。 知道如何通过 FutureBuilder
组件监听 Future
对象进行组件构建;以及通过 StreamBuilder
组件监听 Stream
对象进行组件构建。其中蕴含的思想,希望大家可以好好参悟一下。下一篇我们将进一步认识 Future
对象,揭开它的表象,看一些更本质一点的东西。那本文就到这里,谢谢观看 ~