【Flutter 异步编程 - 贰】 | 详细分析 Future 类的使用

·  阅读 2267
【Flutter 异步编程 - 贰】 |  详细分析 Future 类的使用

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

一、分析 Future 对象

对于 Dart 语言来说,异步使用的过程中,绝大多数场景和 Future 对象有关。C++Java 语言中也有 Future 的概念,对于 JavaScript/Typescript 来说就是 Promise 对象。它们是 异步任务结果 的封装,对 暂未完成 任务的一种 预期(Future) 或 允诺 (Promise)。


1. 认识 Future 对象

前面说过,异步任务有三种状态。在任务分发之后,任务处于未完成状态,而其返回值便是 Future 对象。比如上一篇中使用的文件异步写入方法 writeAsString ,其返回值是 Future<File> 类型。Future 可以指定一个泛型,该类型就是所 期待的结果

如下所示,在触发 writeAsString 方法之后,返回值是 一个 对未来的期待。这里期待返回的类型是 File ,也就是写入后的文件对象。

void saveToFile(TaskResult result) {
  String filePath = path.join(Directory.current.path, "out.json");
  File file = File(filePath);
  String content = json.encode(result);
  Future<File> futureFile = file.writeAsString(content);
}
复制代码

Future 对象是在任务开始时生成的,认识这一点非常重要。而未来该任务会有什么结果,在 对象诞生时刻 是无法确定的。可能烧水会成功完成,获得 热水 结果;也可能烧水壶爆炸,任务失败,获得一个 异常 结果。

image.png

所以 ,对于 Future 对象而言,需要对其进行监听来 感知 任务回调的结果。Future 类中提供了进行监听的,两个非常重要的方法: thencatchError 分别监听 成功完成异常结束 的场景。


2. Future 对任务回调的监听

如下 tag1 处,提供 Future#then 方法,可以监听到任务完成的时机,并且回调方法中的参数,就是期望的结果数据。

void saveToFile(TaskResult result) {
  String filePath = path.join(Directory.current.path, "out.json");
  File file = File(filePath);
  String content = json.encode(result);
  Future<File> futureFile = file.writeAsString(content);
  futureFile.then((File file) { // tag1
    print('写入成功:${file.path}');
  });
}
复制代码

下面来看异常情况,比如下面的 saveToErrorFile 方法,没有对应的文件夹,在写入文件时就会发生异常。通过 catchError 方法可以监听任务异常结束的时机,对异常结果进行处理。

image.png

void saveToErrorFile(TaskResult result) {
  String filePath = path.join(Directory.current.path, "error","out.json");
  File file = File(filePath);
  String content = json.encode(result);
  Future<File> futureFile = file.writeAsString(content);
  
  // 监听任务成功完成
  futureFile.then((File file) {
    print('写入成功:${file.path}');
  });
  
  // 监听任务异常结束
  futureFile.catchError((err) { 
    print("catchError:$err");
  });
}
复制代码

如果有些逻辑 无论任务成败 都需要执行,在 thencatchError 都有要进行书写,比较麻烦。这样的场景下,可以通过 whenComplete 来监听任务完成时机。如下日志可以看出,即使任务异常结束,也可以触发 whenComplete 回调逻辑。

image.png

futureFile.whenComplete((){
  print("=======Complete=======");
});
复制代码

3. 思考异步任务的异常抓取

由于这三个方法都会返回当前 Future 对象,在形式上可以连写。不知道你有没有发现这有点像啥?

futureFile.then((File file) {
  print('写入成功:${file.path}');
}).catchError((err) {
  print("catchError:$err");
}).whenComplete((){
  print("=======Complete=======");
});
复制代码

没错,这和代码的异常抓取非常像,whenComplete 用于无论如何都会执行的逻辑块,和 finally 代码块异曲同工;catchError 就是抓取异常信息,和 catch 代码块作用相同;then 用于任务执行成功的逻辑处理,和 try 代码块非常神似。

try...catch...finally

其实仔细想想,无论是同步还是异步,任何任务都有出错的可能。对于同步任务而言,比如类型转换异常、数值解析异常、分母为 0 异常等。如果预判当前逻辑中可能存在异常情况,需要通过 try...catch 来抓取处理。

同理,对于异步任务而言,本机体只是进行 任务分发, 任务真正的执行过程在其他机体中。其他机体只能通过 回调 和本机体通信,所以对于异步任务而言自然需要回调来处理异常。

另外,对于异步任务而言,出现异常的可能性更大,因为其他机体的处理流程是不可控的。比如网络请求获取数据,需要 通过网络发送请求服务器处理请求服务器发送响应 ,其中每个环节都可能出错,所以对异常的抓取是非常有必要的。


二、深入认识异步异常抓取

1. catchError 中 onError 的细节

Future#catchError 方法源码注释中有相关说明:这里的第一参 onErrorFunction 类型,可以是红框中的两类函数:支持 一参两参

image.png

如下日志所示,第二参是 _StackTrace 对象,可以根据它定位到出错代码的位置:

futureFile.catchError((err,stack) {
  print("catchError::[${err.runtimeType}]::$err");
  print("stack at ::[${stack.runtimeType}]::$stack");
});
复制代码

2. 认识 FutureOr 对象

从上面可以看出 onError 方法需要返回一个 FutureOr 对象。如下,通不通过 then 处理,直接使用 futureFile 对象监听异常,如果不返回,会抛一个 ArgumentError 的异常,可谓 抓一送一

image.png

futureFile.catchError((err){
  print("catchError::[${err.runtimeType}]::$err");
});
复制代码

既然出异常,自然要解决,所以我们需要在异常回调方法中,返回一个 FutureOr 对象。


FutureOr 是一个比较特别的对象,在 Dart 源码中它只是一个私有构造的抽象类,它不可以被实例化。另外 vm:entry-point 表示它是和虚拟机打交道的。在日常开发中,我们只需要知道,该类型代表 Future<T>T 类型。也就是说,它是 一类两型 的特殊存在。

@pragma("vm:entry-point")
abstract class FutureOr<T> {
  // Private generative constructor, so that it is not subclassable, mixable, or
  // instantiable.
  FutureOr._() {
    throw new UnsupportedError("FutureOr can't be instantiated");
  }
}
复制代码

比如 FileFuture<File> 都可以作为 FutureOr 来看待,在 catchError 中可以返回一个 File 对象,或者 Future<File> 异步对象。

image.png

futureFile.catchError((err){
  print("catchError::[${err.runtimeType}]::$err");
  return File('this is error file');
});
复制代码

3. catchError 方法的返回值

通过 catchError 方法的定义可以看出,它可以返回一个 Future<T> 对象。

Future<T> catchError(Function onError, {bool test(Object error)?});
复制代码

如下,发生异常时,catchError 返回的 futureFile 对象,通过 then 监听时,会打印出 this is error file 这正是 onError 返回的文件对象。

image.png

futureFile = futureFile.catchError((err){
  print("catchError::[${err.runtimeType}]::$err");
  return File('this is error file');
});

futureFile.then((value){
  print(value.path);
});
复制代码

当没有发生异常时 catchError 返回值仍是原先任务对象:

image.png


4. catchError 第二参: test

另外 catchError 还有可选的第二参 test,也是一个函数,返回一个 bool 值,入参是 error 对象。test 指定的函数会先于 onError 函数触发,返回值可以控制是否触发 onError

比如下面 test 中如果 error 不是 FileSystemException 才抓取,所以这时第一参 onError 就不会触发。简单来说 test 用于根据 error 信息,判断是否需要对异常进行抓取,一般来说很少使用,了解一下即可。

futureFile.catchError(
  (err,stack){
    print("catchError::[${err.runtimeType}]::$err");
    print("stack at ::[${stack.runtimeType}]::$stack");
    return File('this is error file');
  },
  test: (error)=> error is! FileSystemException
);
复制代码

三、 异步成功回调的使用细节

1. then 方法的返回值

我们一般只是在 then 中监听异步任务完成的结果,从下面的 then 方法的定义可以看出:第一个入参是函数对象,其中函数参数是 T 类中值,也就是 Future 的泛型类型;该函数还有一个返回值,类型为 FutureOr<R> 。其中 R 泛型是方法指定的泛型,then 方法的返回值是 R 泛型的 Future 对象。

Future<R> then<R>(FutureOr<R> onValue(T value), {Function? onError});
复制代码

也就是说,通过 then 方法,可以返回一个其他类型的 Future 对象。如下所示,为 then 方法泛型指定为 String,这样在第一参函数中返回 FutureOr<String> 对象,then 的返回值就是 Future<String>

我们知道 File#readAsString 方法返回的是 Future<String>,可以作为第一参的返回值,这样 then 会返回了一个异步任务 thenResult 。同样可以对这个异步任务使用 then 监听:

Future<String> thenResult = futureFile.then<String>((File file) {
  print('写入成功:${file.path}');
  return file.readAsString();
});

thenResult.then((String value){
  print('读取成功:${value}');
});
复制代码

上面拆开只是为了方便理解,如下可以通过连续的 then 调用。如果现在一个异步任务完成后,执行另一个异步任务,这种写法要方便一些。不过一般很少使用,了解一下即可:then 拥有返回另一异步任务的能力。

futureFile.then<String>((File file) {
  print('写入成功:${file.path}');
  return file.readAsString();
}).then((String value){
  print('读取成功:${value}');
});
复制代码

2. then 方法中的 onError

then 方法有两个入参,第一个是必传的回调函数,会将 T 类型的数据回调出来。除此之外,还有一个 onError 的可选回调,该错误回调参数也是异常和堆栈信息。

需要注意一点,如果在 then 中提供 onError 回调后,对 then 的返回值再监听 catchError 就会没有效果,如下所示:

image.png

futureFile.then((File file) {
  print('写入成功:${file.path}');
},onError:(err,stack){
  print("onError::[${err.runtimeType}]::$err");
  print("onError stack at ::[${stack.runtimeType}]::$stack");
}).catchError((err){
  print("catchError:$err");
});
复制代码

3. whenComplete 方法

最后看一下 whenComplete 方法,该方法的回调中没有任何参数,更像一个 时机 的监听。表示任务结束时候需要执行的动作,和 finally 代码块是很类似的。

Future<T> whenComplete(FutureOr<void> action());
复制代码

注意一下,whenComplete 方法会返回 Future<T> 对象,也就是该异步任务本身,所以你可以连续监听多个 whenComplete 事件。这样,一次异步任务完成后,三个成功事件的监听都会触发,感觉挺有意思的,虽然没太大卵用。

image.png

print("====start Task==${DateTime.now()}===");
Future future = Future.delayed(Duration(seconds: 3));

future.whenComplete((){
  print("====whenComplete1==${DateTime.now()}===");
}).whenComplete((){
  print("====whenComplete2==${DateTime.now()}===");
}).whenComplete((){
  print("====whenComplete3==${DateTime.now()}===");
});
复制代码

4、 async/await 关键字的使用

通过 then 进行回调,在写法上看起来比较臃肿,特别是当个多个异步任务需要按顺序执行时。比如,先读配置文件 config.json;根据配置文件中的信息,读取一个资源文件 a.json ;在读取 a.json 成功之后,将文件中的 read_count 字段 +1

--->[config.json]---
{
  "assets_file_path": "/Users/mac/Coder/Projects/juejin/async_task/config/a.json"
}

--->[a.json]---
{"ip":"198.164.88.001","port":"9090","read_count":11}
复制代码

当三个异步任务需要依次执行,如果仅通过 then 方法来监听,就会导致回调中嵌套另一个异步任务的回调,让代码看起来很闹心。代码如下:

void readFile(TaskResult result) {
  String filePath = path.join(Directory.current.path,"config","config.json");
  File file = File(filePath);
  Future<String> futureFile = file.readAsString();
  futureFile.then((String value){
    String assetsPath = json.decode(value)['assets_file_path'];
    File file = File(assetsPath);
    file.readAsString().then((String value){
      print(value);
      dynamic map = json.decode(value);
      int count = map['read_count'];
      map['read_count'] = count+1;
      file.writeAsString(json.encode(map)).then((File file){
        print("写入成功:${file.path}");
      });
    });
  });
}
复制代码

async/await 两个关键字的组合就是为了简化这种场景下的语法书写形式而存在的。语法规定, await 关键字只能在 async 修饰的方法中使用。如下所示,使用 await 关键字修饰 Future 对象后,返回值是结果类型。

代码中 tag1 处使用 await 修饰 Future 对象,表示必须等待做个异步对象完成后,才可以执行下一行代码。这样代码就可以用同步的方式书写,就像 冲水 需要等待 烧水 任务的结束,它们在逻辑上是同步的。 但这并不影响 扫地烧水 在逻辑上是异步的。

void readFile(TaskResult result) async{
  String filePath = path.join(Directory.current.path,"config","config.json");
  File configFile = File(filePath);
  String configContent = await configFile.readAsString(); // tag1
  String assetsPath = json.decode(configContent)['assets_file_path'];
  
  File assetsFile = File(assetsPath);
  String assetsContent = await assetsFile.readAsString();
  print(assetsContent);
  dynamic map = json.decode(assetsContent);
  int count = map['read_count'];
  map['read_count'] = count+1;
  
  File resultFile = await assetsFile.writeAsString(json.encode(map));
  print("写入成功:${resultFile.path}");
}
复制代码

await 的价值是简化异步任务完成监听,让依赖于任务结果的后续任务摆脱回调监听,从而以一种同步方式更简单地书写。对于某些任务,需要依赖异步任务结果的场景中,使用这两个关键字可以保证功能正确的条件下,让代码的可读性增加。其实这本质上就是个语法糖而已,认清到任务之间的关系,就很容易理解。


5、 async/await 使用的注意点

可能很多人(包括曾经的我)一看到异步方法,就下意识地选择 await 来获取结果,这是非常片面的。await 修饰的异步任务结束后才会继续向下执行,所以之后的逻辑和任务结果相关时,才有使用的价值。

如果两个异步任务没有关系的话,前一个任务使用 await 修饰,那么后一个任务只能等待前者结束才能分发。 比如 烧水煮饭 两个异步任务没有什么关系,如果在处理 烧水 时使用 await 等待结果,将 煮饭 任务在下面代码中分发,这显然是对任务的不合理分配。

所以,在使用 async/await 处理时,要留个心眼:想一下其后的代码是否真的必须在该任务完成之后才处理;这个 await 的修饰,是否会阻塞到后面不相干的异步任务分发。而不是一味的使用 await 进行处理,这样在某些场景,任务分配不合理,就无法发挥出异步的最大功效。


四、结合应用场景介绍 Future 的使用

Future 作为 Dart 对单个异步任务的封装类,在使用上是非常方便的。掌握了上面的几点知识,能解决日常开发中 90% 对 Future 对象的使用场景。如下是 Future 类的结构图,其中有 6 个构造方法,4 个静态方法,5 个成员方法。一些不常用的功能,这里暂不介绍,在后期的文章中会做统一介绍。接下来,我们将结合 Flutter 应用开发,通过 Future 对象进一步地理解异步。

image.png


1. 场景应用介绍

现在将脱离 Dart 控制台打印,通过 FutureFlutter 应用中的表现对其深入了解。如下所示:在计数器初始项目基础上进行拓展,点击右下角按钮时,会执行一个异步任务。在异步任务执行的过程中,按钮显示 加载中 效果,且呈灰色不可点击。当任务执行完毕后恢复原样,每次异步任务完成之后,会让界面中的数字 +1

在开始,先定义一个 TaskState 的枚举,用于标识任务的状态,如下所示: initial 标识初始状态,loading 标识加载中,error 标识任务异常结束。

enum TaskState {
  initial,
  loading,
  error,
}
复制代码

这样,我们可以根据任务运行的状态 TaskState ,对按钮的构建逻辑通过方法进行封装。代码如下所示:逻辑很简单,就是不同的情况下,为 FloatingActionButton 组件提供不同的参数而已:现在重点就是看初始状态下 _doIncrementTask 方法如何执行异步任务。

Widget buildButtonByState(TaskState state) {
  VoidCallback? onPressed;
  Color color;
  Widget child;
  
  switch (state) {
    case TaskState.initial:
      child = const Icon(Icons.add);
      onPressed = _doIncrementTask;
      color = Theme.of(context).primaryColor;
      break;
    case TaskState.loading:
      child = const CupertinoActivityIndicator(color: Colors.white);
      color = Colors.grey;
      onPressed = null;
      break;
    case TaskState.error:
      child = const Icon(Icons.refresh);
      color = Colors.red;
      onPressed = renderLoaded;
      break;
  }
  return FloatingActionButton(
    backgroundColor: color,
    onPressed: onPressed,
    child: child,
  );
}
复制代码

2. Future.delayed 创建延时异步任务

Future.delayed 构造可以创建一个延迟的异步任务。由于第一入参可以指定任务消耗的时长,这经常被用于一些异步场景的模拟。其中 renderLoading 方法应用更新界面,显示 加载中 的效果,也就是将 _state 置为 TaskState.loading 再触发更新。renderLoaded 方法将界面置为初始状态。

从上面可以看出加载中的动画依然在运行中,所以异步的等待并不会阻塞界面线程。通过 await 关键字可以等待异步任务结束,再执行接下来的代码,使 _counter 自加,更新界面。从这三个任务,大家可以自己品味一下,异步的意义。

int _counter = 0;
TaskState _state = TaskState.initial;

void _doIncrementTask() async {
  renderLoading();
  // 模拟异步任务
  await Future.delayed(const Duration(seconds: 2));
  _counter++;
  renderLoaded();
}

void renderLoading() {
  setState(() {
    _state = TaskState.loading;
  });
}

void renderLoaded() {
  setState(() {
    _state = TaskState.initial;
  });
}
复制代码

3. 直观感受同步和异步任务的区别

可能有人还是体会不出,下面改一下代码,让你更直观的感受两者之间的差距。与 异步等待 相对应的是 同步等待,使用 sleep 方法可以模拟同步等待的耗时任务。如下,将代码改为同步等待两秒,可以看出线程被阻塞,在此期间程序就无法做出任何反应,俗称 "卡死"

void _doIncrementTask() async {
  renderLoading();
  // 模拟同步等待任务耗时
  sleep(const Duration(seconds: 2));
  _counter++;
  renderLoaded();
}
复制代码

通过同步和异步等待的对比,想必你应该明白两者的差距。上面的 sleep 方法,在日常开发中就对应一下需要在 Dart 代码中处理的计算密集型的任务,比如下面的 loopAdd 方法,执行十亿次的累加计算,就是在同步执行一个耗时任务,卡住是必然的。当然也有解决的方案,在后期文章中会进行探讨。

void _doIncrementTask() async {
  renderLoading();
  loopAdd(1000000000);
  _counter++;
  renderLoaded();
}
int loopAdd(int count) {
  int sum = 0;
  for (int i = 0; i <= count; i++) {
    sum+=i;
  }
  return sum;
}
复制代码

3. Future.delayed 的第二参使用

很多人可能只知道 Future.delayed 只是作为异步延时一下,并没有在意它的第二参。如下所示,第二参数是提个函数,无回调参数,返回 FutureOr<T> 对象。也就是说 Future.delayed 也可以有异步任务的结果值。

Future.delayed(Duration duration, [FutureOr<T> computation()?])
复制代码

Future.delayed 也可以模拟异步任务的返回结果、任务异常的情况。如下所示:当 counter 自加之后是 3 的倍数时,抛出异常。通过 computation 函数就可以实现:

13.gif

代码如下:定义一个 computation 函数作为第二入参(函数名可任意指定),在其中对 counter % 3 == 0 时,通过 throw 抛出异常,来模拟异步任务的异常情况。如果没有异常,则返回 counter 值,该值将作为延时异步任务的结果值。

由于这里使用 await 关键字,之后的逻辑可以看作同步执行的,可以使用 try...catch 来抓取异常。

void _doIncrementTask() async {
  renderLoading();
  // 模拟异步任务
  try {
    _counter = await Future.delayed(const Duration(seconds: 1), computation);
    renderLoaded();
  } catch (e) {
    renderError();
  }
}

FutureOr<int> computation(){
  int counter = _counter + 1;
  if( counter % 3 == 0 ){
    throw 'error';
  }
  return counter;
}

void renderError() {
  setState(() {
    _state = TaskState.error;
  });
}
复制代码

本文详细介绍了 Future 对象的使用,需要额外注意三个回调方法的使用方式,以及 async/await 关键字的使用场景。另外结合 Flutter 中的一个小应用案例,体会了一些异步在实际开发中的使用方式。不过 Future 仅是异步编程的一部分, 在某些场景下 Future 有其局限性,我们需要一种更高级的手段来处理,下一篇我们将进入流 Stream 的认知,敬请期待。那本就到这里,谢谢观看 ~

分类:
Android
收藏成功!
已添加到「」, 点击更改