有趣的Flutter:从0到1构建跨平台App
上QQ阅读APP看书,第一时间看更新

第2章 Dart语言概述

本章中,我们主要讲解一些在 Flutter 开发过程中常用的 Dart 基本类型,同时介绍在 Dart 语言中占据举足轻重地位的方法。除此之外,还会讲解控制语句、异常处理。通过这些,我们可以了解在 Dart 语言中如何利用基本类型实现一些基本的逻辑。再接下来,我们会讲解类以及泛型,它们有助于我们了解如何在 Dart 中基于面向对象的方式来编写代码。除此之外,异步、引入外部代码这些进阶知识,能够帮助我们编写更多的高阶代码。

2.1 基本数据类型

在 Dart 语言中,我们常用的基本数据类型主要包括数字、字符串、布尔、列表、集合以及映射,本节我们首先简单了解一下这些基本类型的用法。

2.1.1 数字

在 C 语言中,对数字的描述可以分为整数型和浮点型。Dart 同样如此,其数字分为 intdouble 两种类型,它们都是 num 的子类。

Dart 语言中的 int 类型通常为 64 位有符号整型,double 类型为 64 位有符号浮点数类型,符合 IEEE 754 标准。需要注意的是,当把 Dart 代码编译成 JavaScript 代码时,如果 JavaScript 中的 number 类型对精度有要求,则最大只能使用 54 位有符号整型数字。在有精度要求的情况下,int 类型的数字取值范围最好在 -253 和 253-1 之间。

下面我们尝试声明两个数字,它们分别为 int 类型和 double 类型:

int fisrtInt = 1;
double firstDouble = 1.001;

当然,也可以直接通过十六进制或者小数的科学计数法对数字进行表示,分别如下:

int hexInt = 0x10ABCD;
double exponentDouble = 1.002e5;

2.1.2 字符串

可以说,字符串是日常使用中最重要的数据类型之一。在 Dart 中,我们可以使用单引号(')、双引号(")来声明字符串类型的数据。下面举几个例子:

String firstString = 'This\'s is an string';
String secondString = "This's is another string";
String stringWithEmoji = "This string contains an emoji ☺"; // 最后一个字符为 emoji 表情

除此之外,我们也可以利用加号(+)将多个字符串类型的数据拼接在一起。如果我们拼接的目标里包含其他类型的对象,Dart 会先自动调用 toString 方法将此对象转换成字符串对象,再进行拼接。如果需要在文字中嵌入多个变量或者表达式,那么直接使用 + 进行拼接并不是很方便,这时可以使用 ${} 语法。如果 {} 内仅包含一个变量标识符,我们还可以省略 {}。下面我们尝试通过不同的方式声明和拼接字符串类型的数据:

String funny = 'Funny';
String concat = 'It is' + funny + 'Flutter';
String expression = 'It is ${funny.toLowerCase()} Flutter';
String identifier = 'It is $funny Flutter';

2.1.3 布尔

Dart 专门为布尔变量创建了一个布尔类型。这个类型只有两个对象实例——truefalse。需要注意的是,这两个对象都是编译时常量。

在 C 语言的判断语句中,我们可以将非 0 值传递给 if 表达式,编译器会自动将其转换为布尔值进行逻辑判断,但这在 Dart 中是非法的。在 Dart 中,类似 ifassert 这样的表达式,仅支持传入布尔值进行逻辑判断。示例代码如下:

int intValue = 1;
assert(intValue); // 编译时错误
bool boolValue = true;
assert(boolValue); // true
int doubleValue = 1.0;
if (doubleValue == 1.0) {
  print('equal'); // 输出 equal
}

2.1.4 列表

前三节讲了最常用的几种基本数据类型,它们完全可以满足日常最基本的一些编程需求。但是,为了更好地进行 Flutter 编程,多了解一些数据类型也是极有必要的。首先需要了解的是列表,支持随机访问,这点类似于其他语言中的数组或者向量。可以使用 [] 来声明列表对象,下面做一些简单的测试:

List<String> list = ['f', 'l', 'u', 't', 't', 'e', 'r'];
assert(list[0] == 'f'); // 结果为 true
list[0] = 'm'
assert(list[0] == 'm'); // 结果为 true

2.1.5 集合

相对于列表,集合中的元素不重复且无序,不支持随机访问,具有更好的查找性能。在 Dart 中,可以使用 {} 来声明一个集合。当声明的集合中包含内容时,编译器会自动对这个内容进行类型推导。当声明的集合为空时,编译器就无法自动推导类型了,因此建议在声明集合时使用泛型显式声明内容类型。如果使用 var 关键字来声明一个空集合,由于声明映射时已经占用了 {},因此要显式地写明泛型,例如 <String>{},否则编译器会报错。下面声明两个集合对象,并做一些简单的测试:

Set<String> set = {'dart', 'flutter'};
assert(set.length == 2); // 结果为 true
var happiness = <String>{}; // 或 Set<String> happiness = {}
happiness.add('funny');
happiness.addAll(set);
assert(happiness.length == 3); // 结果为 true

2.1.6 映射

在日常的开发工作中,有许多场景离不开映射,例如将英文单词映射成中文,将 ID 映射成用户名等。我们可以对任意两个 Dart 对象进行映射,需要注意一个键名只能有一个与之对应的键值。在 Dart 代码 中声明映射对象时,可以使用跟声明集合对象时相同的符号——{}。区别在于在映射中使用时,要用 : 符号分隔键名和键值。声明好后,Dart 编译器会自动进行类型推导。需要注意的是,如果使用 var 关键字来声明一个空映射,那么默认会把 {} 推导为映射类型,如果想用 {} 声明集合,则需要显式地写明泛型,这一点在 2.1.5节也有提到。示例代码如下:

Map<String, String> map = {
  'dictionary': '字典',
  'map': '映射',
};
assert(map.length == 2); // true
var nameBook = {};
assert(nameBook.length == 0); // true
nameBook[1] = 'Jony Wang';
nameBook[66] = 'Linda Zhang';
assert(nameBook[66] == 'Linda Zhang'); // true
assert(nameBook[2] == null); // true

2.2 函数

了解了用来承载代码中数据的基本数据类型后,我们还需要了解在 Dart 中如何使用函数来封装和调用代码中的逻辑。本节中,我们主要讲解函数的声明方式、函数的参数以及函数与闭包的关系。

2.2.1 声明

几乎所有的编程语言中都有函数相关的概念,我们可以简单地把它理解为一个包含输入和输出的代码集合。在 Dart 中,函数和其他数据类型一样是一等公民,意味着我们可以像传递值一样传递函数。Dart 中所有的函数都是 Function 的实例,这也是可以将函数作为值和参数来传递的原因之一。函数既能以 fun(){} 的方式声明,也能用 => 声明。函数返回值的类型是可以省略的,如果省略了返回值的类型,那么编译器会自动根据返回值做类型推导。不过,Dart 官方不推荐这样的做法。简单的示例代码如下:

String funny() {
  return "funny flutter";
}
lonelyFunny() {
  return "lonely funny flutter";
}
String shortFunny() => "short funny flutter";

需要注意的是,使用 => 声明函数是类似于 { return expression; } 的简化方式。

2.2.2 参数

在上面的示例中,我们声明的都是不带参数的函数,下面我们一起学习带参数的函数。和其他语言类似,Dart 中函数的参数也分为必选参数和可选参数。其中必选参数可以省略,即采取默认值,如果不省略就必须放在前面,可选参数则放在靠后的位置。根据是否命名,参数可以分为位置参数和命名参数,下面我们介绍一下各类参数的声明和使用方式。

(1) 必选位置参数

跟 C 语言和 Java 语言类似,Dart 支持使用位置来区分参数。在调用函数时,需要完全按照声明函数时的参数顺序传入各个参数。下面是一个示例:

void sayHello(String name, String greeting) {
  print("Hello $name, $greeting.");
}
sayHello("Jony", "nice to meet you"); // 输出 Hello Jony, nice to meet you.

这里的 namegreeting 都是必选参数,如果没有传入对应的参数,那么 Dart 编译器会报错。

(2) 可选位置参数

可选位置参数需要使用 [] 声明。如果有必选位置参数,那么需要放在可选位置参数的前面。示例声明如下:

void positionBasedOptionalParamter(String a, [String b, String c])

调用这个函数时,要按顺序传入各个参数。如果没有给可选位置参数传值,那么 Dart 会默认传入 null 作为这个可选参数的值。示例调用如下:

positionBasedOptionalParamter("test",  "b String"); // 参数 c 为 null

(3) 命名参数

命名参数需要使用 {} 声明。虽然这个声明形式跟声明可选位置参数时差不多,但在调用的时候,需要以“参数名:参数值”的方式传入参数。还是用 sayHello 函数作为示例:

void sayHello({@required String name, @required String greeting}) {
  print("Hello $name, $greeting.");
}
sayHello(name: "Jony", greetings: "nice to meet you"); // 输出 Hello Jony, nice to meet you.

我们可以看到这里出现了一个新的关键字 @required,这是 Flutter 框架提供的一个注解,能帮助我们标识哪些命名参数是必须要传入的。

2.2.3 闭包

在 Dart 中,我们可以像声明变量一样在任意地方声明函数。如果我们在一个函数的内部再声明另一个函数,那这个内部函数是可以访问其函数外部的变量的,外部的变量将会自动被内部函数捕获,形成闭包。所以,我们可以简单地把闭包理解成函数与上下文捕获机制的结合。下面,我们来举一个函数闭包的例子:

void outterFunction() {
  String hello = 'hello';
  void innerFunction() {
    String world = 'innerFunction';
    print('$hello $world'); // 输出 "hello world"
  }
}

这里我们首先在 outterFunction 的函数体中声明了一个 innerFunction 函数。在 innerFunction 函数中,可以访问和调用 outterFunction 函数中的变量。

2.2.4 main 函数

和 C 语言一样,Dart 中也有一个名为 main 的入口函数,程序会以 main 函数为入口开始执行。Dart 中的 main 函数没有返回值,void 关键字可以省略,声明成 main 或者 void main 都可以。下面我们举一个实际的例子:

void main() { // 或 main()
  print('Hello world.');
}

另外可以声明 List<String> 来接收外部传入的参数,这通常在编写命名行工具时使用,用于获取控制台传入的参数。下面我们来编写一个带有参数的 main 函数:

void main(List<String> args) {
  print('Hello $args');
}

2.2.5 匿名函数

大部分函数是有名字的,我们把没有名字的函数称为匿名函数。在 Dart 中,函数名字并不是必须的,如果有需要那么名字可以省略。例如,在遍历容器时,我们希望传入一个函数对容器内的每个成员都执行一遍这个函数定义的操作,这时函数的命名就显得不那么重要。举一个例子:

List<String> words = ['funny', 'flutter'];
words.forEach((word) {
  print('$word');
});

就像这个例子,如果匿名函数内只有一行代码,那么在声明该函数时还可以省略 {},直接使用 => 声明返回值。代码如下:

List<String> words = ['funny', 'flutter'];
words.forEach((word) => print('$word') ); // 输出 funny\nflutter

2.3 流程控制

一个编程语言最基本的部分莫过于由条件、循环等组成的流程控制语句。大多数编程语言中的流程控制语句写法大同小异,Dart 也不例外,本节中我们就来简单了解一些 Dart 语言中的基本控制语句。

2.3.1 利用 if 来判断

利用 if 语句,可以改变程序的执行流程。在 Dart 中,ifelse if 语句都是接收一个布尔类型的条件结果为参数,当判断此结果为真时,就执行控制流中的语句。示例代码如下:

if (isRaining()) {
  you.bringRainCoat();
} else if (isSnowing()) {
  you.wearJacket();
} else {
  car.putTopDown();
}

2.3.2 利用 for/while 来循环

当我们需要对一个容器进行遍历或者需要执行一系列重复性操作时,就要用到 forwhile 语句。在 Dart 中,for 循环有两种形式。一种和 C 语言中的 for 循环类似,包含三部分:初始语句、条件语句、操作语句。另一种和 JavaScript 语言中的 for in 类似,用于遍历一个可迭代的对象。以下示例展示了两种 for 循环:

for (int i = 0; i < 5; ++i) {
  print('$i ')
} // 0 1 2 3 4
List<int> array = [1, 2, 3, 4, 5]
for (int item in array) {
  print('$item ')
} // 1 2 3 4 5

while 语句同样可以实现循环,它接收一个条件值,当条件为真时执行循环体中的语句。while 循环也有两种形式,一种就是 while,另一种是 do while。两者的区别在于判断语句的执行时机不同,while 会在执行循环体之前对条件进行判断,而 do while 会在执行完循环体后才对条件进行判断,也因此 do while 一定会执行一次循环体,以下示例展示了两种 while 循环:

Int count = 1;
while(count > 0) {
  print('$count ');
  --count;
} // 输出 1
Int index = 0;
do {
  print('$index ');
  --index;
} while(index > 0); // 输出 1

使用 breakcontinue 可以分别结束整个循环和跳过当次循环。以下示例展示了 breakcontinue 的作用:

List<int> array = [1, 2, 3, 4, 5];
for (int item in array) {
  if (item == 1) {
    continue;
  }
  if (item == 4) {
    break;
  }
  print('$item ')
} // 输出 2 3

2.3.3 利用 switch 来选择

相较 C 语言,Dart 对 switch 语句做了加强。Dart 中的 switch 不仅可以比较整数,还可以比较字符串以及其他编译期常量。需要注意的是,利用 switch 进行比较的变量必须和 case 语句中的变量类型相同,并且不能重写比较函数 ==。除了空 case 语句,其他 case 语句必须以 breakcontinuethrow 或者 return 为结尾,否则编译器将抛出错误,其中 continue 需要和 label 结合使用。如果所有 case 语句都未命中,switch 将自动执行 default 语句。示例代码如下:

switch (state) {
  case 'RETRY':
    continue label;
label:
  case 'CLOSED':
  case 'NOW_CLOSED':
    executeNowClosed();
    break;
  default:
    throw UnsupportedStateExcetpion('Unsuported state: $state')
}

2.4 异常处理

在计算机科学中,异常是一个极其重要的概念。当底层遇到无法处理的问题时,就会向上层抛出异常,由上层决定程序接下来的状态。在操作系统中,也可以用信号的形式抛出异常。有些异常可以被程序接管处理,但也有些异常(如内存溢出异常)可能导致整个程序直接被终止执行。

2.4.1 抛出异常

Dart 提供了 Exception 和 Error 两种形式的基础异常类型。一般来说,Dart 建议上层的用户代码应该保护抛出的 Exception,而不应该保护抛出的 Error,因为 Error 表示一个运行时的错误。例如当 List 对象为空时,List.first 方法会抛出 StateError 以表示错误。当然,Dart 也可以将任意非空对象当作异常抛出,但这并不是 Dart 官方建议的做法。示例代码如下:

throw Exception('This is first dart exception');
throw 'Network connection closed';

2.4.2 捕获异常

底层抛出异常后,上层会立即停止执行异常后面的代码,转而去执行异常控制程序。在 Dart 中,底层的异常能够被上层捕获和处理。不仅如此,Dart 还支持捕获和处理特定类型的异常。未被捕获的异常则继续向上层传递,直到没有任何代码捕获它,如果一直没有代码捕获并处理这个异常,那么 Dart 会执行默认的异常处理逻辑,退出有异常的程序。示例代码如下:

try {
  ... // 其他代码
  throw FormatException('Format is wrong');
} on FormatException {
  print('Do not worry about it');
} catch(e) {
  print('Other exception $e');
  rethrow;
}

除了异常对象之外,Dart 还提供了异常堆栈参数以便用户排查问题。对于某些无法处理的异常,Dart 允许使用 rethrow 语句将此异常重新抛给上层,由上层处理它。示例代码如下:

try {
  someExceptionalFunction();
} on Exception catch(e) {
  print('Exception catched $e');
} on FileNotExsitException catch(e) {
  print('Record exception $e');
  rethrow;
} catch(e, s) {
  print('Unexpected exception $e catched, call stack: $s');
}

2.4.3 使用 finally 保证代码一定被执行

前面我们提到,一旦遇到异常,程序将立即跳转到异常控制程序,而不执行异常后面的代码。但有时我们需要在处理完异常后,执行一些代码逻辑(如清理工作),此时就需要用到 finally 代码块。finally 代码块紧跟在 try catch 代码块后面,等异常处理结束后,Dart 能够保证 finally 代码块中的逻辑得到执行。示例代码如下:

try {
  someExceptionalFunction();
} finally {
  doSomeCleanUp();
}
try {
  someExceptionalFunction();
} catch(e) {
  print('Exception catched $e');
} finally {
  doSomeCleanUp();
}

2.5 类

Dart 是一种面向对象的语言,其每一个对象都是一个类的实例。类是面向对象设计程序时实现信息封装的基础,我们可以将一些数据和方法封装在类中,对外暴露接口,屏蔽内部的具体实现。相对于 C++ 这种面向对象的语言,Dart 采用单继承的机制,即一个类只能有一个父类。如果想让一个类继承多个父类,可以使用 mixin(混入)机制。mixin 和 Swift 中的 Extension 类似,可以往一个类中混入其他类已实现的一些方法,而不需要继承其他类。

2.5.1 类的成员变量

一个类往往拥有很多成员变量和方法,在实例化一个类对象时,Dart 会为此对象申请内存空间以保存其成员变量和方法。任何一个 Dart 对象都是一个类的实例,利用这个对象可以访问类的成员变量和方法。下面就实例化一个 Point 类对象,请注意这里并未定义 Point 类,我们会在 2.5.2节定义。代码如下:

// 示例代码,未定义 Point 类
Point p = Point(2, 2);
p.y = 3;
assert(p.y == 3);
num distance = p.distanceTo(Point(4, 4));

注意,对一个 null 对象使用 . 语法会导致 Dart 程序抛出异常,因此在使用 . 语法时应该确保变量值非 null。我们也可以使用 ?. 来保证不访问取值为 null 的变量或者方法,达到省去判空操作的目的。?. 的具体语义是当前对象如果不为空,就返回对应的成员变量;如果为空,则返回 null。示例代码如下:

var p = null
p.x; // 抛出异常
if (p != null) {
  print(p.x); // 若 p 非空,则打印 p.x 的值
}
print(p?.x);// 若 p 为空,则打印 null

2.5.2 类的构造方法

一般而言,要使用一个类的成员变量,先要创建一个类对象。在面向对象的语言中,能够创建对象实例的方法被称为构造方法,我们可以给构造方法传入具体的参数来构造一个特定的对象。在 Dart 中,构造方法只能跟类名相同,或者是一个类方法。这里会定义一个 Point,代码如下:

class Point {
  num x = -1;
  num y = -1;
  Point(num x, num y) {
    this.x = x;
    this.y = y;
  }
 // 或直接用 Point(this.x, this.y) 声明,与上面的函数等同
  Point.origin() {
    this.x = 0;
    this.y = 0;
  }
}
Point p1 = Point(1, 2);
Point p2 = Point.origin();

对于一些在实例中数据不会改变的类,Dart 可以利用常量构造方法构造一个常量对象,编译器会在编译期构造此对象。需要注意的是,只有所有成员变量都被标注为 final 的类才可以使用常量构造方法。使用 const 关键字可以声明常量对象,const 关键字可以在等号的左边或者右边,也可以同时出现。示例代码如下:

class ImmutablePoint {
  final num x, y;
  const ImmutablePoint(this.x, this.y);
}
ImmutablePoint originPoint = const ImmutablePoint(0, 0);
const ImmutablePoint otherPoint = ImmutablePoint(0, 0);
const ImmutablePoint doubleConstPoint = const ImmutablePoint(0, 0);
// identical 函数用于检查两个变量是否引用同一个对象
assert(identical(originPoint , otherPoint)); // true
assert(identical(originPoint , doubleConstPoint )); // true

2.5.3 使用 gettersetter

我们通常不会直接利用对象访问类的成员变量。更常见的做法是利用 getter 方法和 setter 方法来访问和设置成员变量。在 Dart 中,编译器会为我们自动生成 getter 方法和 setter 方法,但有时需要我们自行实现,例如需要对传入的值进行计算时。对于这种情况,Dart 提供了 get 关键字和 set 关键字。自定义实现 getter 方法和 setter 方法之后,调用方并不需要更改原来的调用方式。以下示例展示了 get 关键字和 set 关键字的用法:

class Rectangle {
  num left, top, width, height;
  Rectangle(this.left, this.top, this.width, this.height);
  num get right => left + width;
  set right(num value) => left = value - width;
}
Rectangle rectangle = Rectangle(1, 2, 3, 4);
rectangle.right = 1;// setter
print(rectangle.right); // getter

2.5.4 继承

继承是面向对象设计中的一个基本概念,可以使子类具有父类的属性和方法。我们也可以给子类增加属性,重新实现和追加实现一些方法等。

有了继承机制,我们可以更好地利用面向对象的设计思路实现抽象。例如动物(Animal)类中已经实现了一个走路(walk)方法,当一个子类继承该类时,我们可以在子类的走路方法中实现特定的功能。在下面的代码中,人(Human)类和猫(Cat)类都继承自动物父类,我们分别在两个类中对走路姿态做了额外定义:

class Animal {
  void walk() {
    print('animal is getting away from here');
  }
}
class Cat extends Animal {
  @override
  void walk() {
    super.walk();
    print('cat is wagging tail');
  }
}
class Human extends Animal {
  @override
  void walk() {
    super.walk();
    print('human is waving hand');
  }
}
Cat cat = Cat();
Human human = Human();
cat.walk(); // 输出 animal is getting away from here\ncat is wagging tail
human.walk(); // 输出 animal is getting away from here\nhuman is waving hand

在实际的工程实践中,为了很好地区分开重写方法和其他方法,一般会在重写方法前加上 @override 注解。

2.5.5 抽象机制与抽象类

利用继承机制可以重新实现父类的方法,如果一个类想预留一些没有实现的方法给子类实现,那么可以使用抽象机制。在 Dart 中,实例方法、setter 方法和 getter 方法都可以是抽象的。要想在一个类中使用抽象方法,必须先利用 abstract 关键字声明此类为抽象类。抽象类中含有未被实现的抽象方法,因此不能被直接实例化。抽象类中也可以包含部分方法的实现,当某个子类继承抽象类时,它需要先重写抽象类中的所有抽象方法,之后子类才可以被实例化。下面的示例构造了一个抽象类——动物(Animal)类,人(Human)类继承自此类:

abstract class Animal {
  void play(); // 定义一个没有实现的抽象方法 play
}
class Human extends Animal {
  @override
  void play() {
    print('human playing video game');
  }
}
Human human = Human();
human.play(); // 打印 human playing video game

2.5.6 隐式接口

Dart 没有为接口提供一个专用的关键字,但是在 Dart 的定义中,每个类都是一个隐式的接口。利用 implements 关键字,一个类可以实现另一个类的所有实例变量和实例方法。如下示例代码中的 Impostor 类就实现了 Person 类中的 _name 变量和 greet 方法:

class Person {
  final _name;
  Person(this._name);
  String greet(String who) => 'Hello, $who. I am $_name.';
}
class Impostor implements Person {
  get _name => '';
  String greet(String who) => 'Hi $who. Do you know who I am?';
}
String greetBob(Person person) => person.greet('Bob');
void main() {
  print(greetBob(Person('Kathy')));
  print(greetBob(Impostor()));
}

2.5.7 继承之外的另一种选择:mixin

Dart 和大多数面向对象的语言一样,也是采用的单继承机制。也就是说,一个类只能有一个父类。但很多时候,我们需要的是一种能力的组合而非简单的继承,借助 mixin 机制可以轻松实现这个功能。当然,我们也可以抽象出层级结构更多的父类以涵盖所有能力,但这并不十分灵活。

使用 mixin 关键字可以声明一个 mixin 实例,使用 with 关键字可以将 mixin 实例赋值给一个特定的类。下面我们举个例子,来感受一下 mixin 机制和 mixinwith 关键字的用法:

mixin Driver {
  driverLicence = true
  void driveCar() {
    print('I can driving')
  }
}
mixin Cooker {
  void makeFood() {
    print('I am cooking')
  }
}
class Person with Driver, Cooker {
}
Person a = new Person()
a.driveCar(); // 输出 I can driving

在上面的示例中,我们通过把实例 DriverCooker 赋值给 Person 类,让人具有了开车和做饭的能力。简单来讲,mixin 机制就是将特性或者能力赋予某个类。在 Dart 中,即使一个类没有使用 mixin 关键字声明,使用 with 关键字也可以把它作为 mixin 实例赋值给另一个类。示例代码如下:

class Listenable {
  void listen() {
    print('I am listening')
  }
}
class Channel with Listenable {
}

我们也可以使用 on 关键字限定某个 mixin 实例只能由特定的类使用。在这样的 mixin 实例中,可以调用原类中的函数。下面的示例就限定了实例 Flyable 只能由 Bird 类使用,并在 Flyable 中调用了 Bird 类的函数 saying

class Bird() {
  void saying() {
    print('tweet tweet tweet');
  }
}
mixin Flyable on Bird {
  void fly() {
    print('I am flying');
    saying();
  }
}

2.6 泛型

2.6.1 泛型与类型安全

Dart 中变量的类型是可选的,所以在类型约束方面比较松散。但在某些场合中,我们仍然需要对类型进行约束,这时就要用到泛型了。举个例子,Dart 中的 List 数据类型就支持泛型,因此默认 List 实例中可以放入任意类型的对象。示例代码如下:

List list = new List();
list.add(123);
list.add(true);
list.add({'123': 123});
print(list); // 输出:[123, true, {123: 123}]

在这个例子中,list 里包含整数类型、布尔类型和字典类型的数据。但这样对这个 list 的使用者并不友好,因为使用者没有办法安全舒适地对 list 进行遍历。更最重要的是,不限制可以放入容器中的数据类型可能会给维护工作带来巨大的灾难。因此,泛型是保证类型安全的重要途径。

2.6.2 在定义中使用泛型

当类中某个成员是类型不确定的数据时,可以在定义这个类的时候使用泛型代替这个成员的类型。下面我们举一个例子:

class TypeList<T>{
  List list=new List();
  add(T value){
    list.add(value);
  }
  T operator [](int i){
    return list[i];
  }
  forEach(f) => list.forEach(f);
}
void main() {
  TypeList<String>  books = new TypeList<String>();
  books.add('《有趣的 Flutter》');
  books.add('《Flutter 入门经典》');
  books.forEach((s) => print(s));
}

这个例子中,我们使用 T 来表示泛型。在调用 TypeList 的时候,需要为其指定泛型的具体类型。这里我们使用 TypeList<String> 将泛型 T 指定为 String 类型。于是在后面的操作中,我们只能向 books 中插入 String 类型的对象,否则编译器将报错。

2.6.3 在函数中使用泛型

我们不仅可以在类中使用泛型,也可以在函数中使用。下面举一个例子:

T first<T>(List<T> ts) {
  T tmp = ts[0];
  return tmp;
}

这个例子中的函数返回值、List 中的泛型,以及临时变量 tmp 的类型都是 T。在调用 first 函数时,我们需要在其后使用 <> 指定泛型的类型。示例代码如下,这里同样指定泛型 TString 类型:

String str = first<String>(["123", "456"]);
print(str); // 123

2.6.4 限定泛型的类型

泛型并不意味着任意类型。在某些情况下,我们只希望泛型中的类型是某些指定的类型,此时可以限制泛型的类型。举个例子,我们希望泛型 T 只能是 BaseClass 或其子类,此时可以用以下方式声明:

class TypeList<T extends BaseClass> {
  // 在这里写其他的实现
  String toString() => "Instance of 'Foo<$T>'";
}
class Extender extends SomeBaseClass { }

此时若使用其他的类型,编译器将会报错:

Foo<BaseClass> someBaseClassFoo = Foo<BaseClass>();
Foo<Extender> extenderFoo = Foo<Extender>();
Foo<Object> foo = Foo<Object>(); // 编译错误

2.7 异步

异步操作也是现代编程语言一定会重点实现的一个能力,其语法的简洁度会直接影响语言开发者的开发体验。这一点 Dart 做得相当出色,使用简单的语法便能直接完成异步操作。

2.7.1 什么是异步

在日常的代码编写中,有许多耗时的场景,如网络请求、磁盘 IO 等。在这些场景中,往往需要等待耗时操作完成后才能进行下一步操作。对于非主线程,可以进行同步等待。但对于主线程,同步等待可能会影响帧率,这样的代价可能是巨大的,因此这种场景下,需要让耗时操作异步执行。通常需要利用函数实现异步操作,示例代码如下:

readJSON(String filename, Function callback) {
  ... // 异步读取文件中的 JSON 内容,等读取完成后再调用回调函数 callback
  callback(content)
}

2.7.2 Future 对象与 async/await 关键字

嵌套层级过深的回调函数会使代码不易读,这个问题被称为回调地狱。ES7 引入了 asyncawait 关键字来解决这一问题,这个语言特性在 Dart 中同样可以使用,并且更加强大。首先,对于函数来讲,加上 async 声明表示这是一个异步函数,而 await 关键字仅能在异步函数中执行。ES7 中的 async 函数会返回一个 Promise 对象,与之对应,Dart 中的 async 函数会返回一个 Future 对象,Future 对象仅在遇到 await 关键字时才能执行。下面就来声明一个 async 函数:

Future readJSON(String filename) async {
  var content = await readFile(filename);
  return JSON.decode(content);
}
Future main() async {
  try {
    var jsonContent = await readJSON('/path/to/jsonFile');
    print('Reading JSON success with content: $jsonContent');
  } catch(e) {
    print('Reading JSON failed with error: $e');
  }
}

当然,也可以使用简化声明格式来声明一个 async 函数。async 关键字已经能表明函数的返回值是 Future 对象,因此只需要为函数添加 async 关键字,编译器就能自动进行类型转化(即便在声明函数时没有将返回值类型写成 Future,编译器也能将当前的函数返回值类型修改为 Future),示例代码如下:

String funnyString() => 'Funny flutter';
String asyncFunnyString() async => 'Async funny flutter';
print(funnyString()); // 'Funny flutter'
print(await asyncFunnyString()); // 'Async funny flutter'

2.7.3 使用 async for 处理 Stream 对象

Stream 对象是 Dart 中一个重要的组成部分,一般可以用于磁盘 IO 或者事件传递。以文件读取为例:

walkFiles(String directory, Function callback) {
  Stream<FileSystemEntity> listener = Directory(directory).list();
  listener.listen((entity) {
    if (entity is File) {
      callback(entity);
    }
  });
}

本例中,我们使用 Stream API 读取文件,返回了一个 Stream 对象。所有 Stream 对象都有一个 listen 方法,我们可以利用这个方法在异步产生返回值时执行对应的操作。例如,这里就将从磁盘中读取的文件实体(entity)通过回调方法传递了出去。

在 Dart 中,我们也可以使用 async for 来循环遍历 Stream 对象。async for 兼具 async 关键字和 for 循环的特性,只有当 Stream 中有值时才会调用 {} 内的语句并执行,可以使用 return 或者 break 来结束对 Stream 的监听。当 Stream 被上游关闭后,async for 循环将自动被打破,并继续执行后面的语句。还是以上面的文件读取为例,这里使用 async for 将其改写成如下代码:

walkFiles(String directory, Function callback) {
  async for (var entity in Directory(directory).list().listen()) {
    if (entity is File) {
      callback(entity);
    }
  });
}

2.8 引入外部代码

语法仅仅是编程语言的一部分,如果不支持代码引入,那么编程语言将无法高效地被投入实际生产。Dart 提供了一个外部代码引入的能力,利用这个能力,我们可以节省大量开发时间。上文多次用到的 print 函数就是一个 Dart 核心库 dart:core 中的函数,我们可以使用 import 关键字来引入需要的代码:

import 'dart:core';
print('Then, I can print something.');

2.8.1 利用 import 关键字引入其他框架中的代码

对于 dart:core 这样的核心库,其实并不需要显式地主动用 import 关键字引入它,Dart 会默认把它引入当前的上下文中。对于非核心库,如要使用随机数类 Random 时,才需要使用 import 关键字将包含此类的 dart:math 库导入当前的执行环境中。下面列举一些经常使用的库,方便大家查阅:

import 'dart:async';     // 异步 API,例如,对 Future 和 Stream 对象的支持
import 'dart:math';      // 数学计算相关的 API,如随机数生成函数
import 'dart:convert';   // 数据(如 JSON、UTF-8)相关的类型转换
import 'dart:io';        // 磁盘 IO 相关的 API

2.8.2 利用 as 关键字防止外部框架冲突

import 关键字会默认将代码包中的代码展开在当前上下文中,这样会引发命名冲突问题。例如,a 库和 b 库中都有函数 func,如果使用 import 关键字同时引用这两个库,就会导致编译器无法确定具体使用哪个库中的函数而报错。因此,需要在导入的时候对 a、b 库进行区分。在 Dart 中,我们可以使用 as 关键字对代码库进行重命名,利用 alias.func 的方式,就可以区分开两个库的函数,进而正确地找到函数并调用。示例代码如下:

import 'a' as a
import 'b' as b
a.func()
b.func()

2.9 小结

本章我们对 Flutter 框架使用的 Dart 语言做了基本介绍,这些基本的语法知识有助于更好地理解之后的示例代码。如果在第二部分的开发实战中遇到不会的语法,可以返回本章来查询。