Flutter 分享整理总结
Flutter 是一个由 Google 创建,面向 Android 和 iOS 应用开发的跨平台开源框架,并于 2018 年 12 月 4 日发布了 1.0 版本。不同于 React Native 等跨平台框架,Flutter 不生成原生组件,而是自己控制引擎将组件渲染出来。因此 Flutter 的渲染速度很快,而且不需要考虑不同平台出现的渲染差异。
Dart
说到 Flutter 就不得不提 Dart 语言。Dart 同样是由 Google 创建的一门开源语言,Google 的 Adwords,Flutter 以及 Fuchsia 等项目在开发时都使用了 Dart。那么 Dart 有什么样的特点呢?
优点
入门成本较低
很多程序员都会有一门或几门比较拿手的编程语言,比如 Java,JavaScript 或者 Swift 等。但就目前的形式来看,擅长使用 Dart 的程序员比例非常低,有很多人甚至没有使用过(甚至没有听说过)Dart。但比较幸运的一点是 Dart 学习起来并不困难,与上述几门语言相比它没有提出什么崭新的概念,使用的语法也与很多编程语言类似,因此有其它编程语言基础的程序员可以比较快速地入门 Dart。
支持预编译与即时编译
一些编程语言在运行之前会提前转换为为目标代码,然后在对应的平台上执行,这种行为被称为预编译(AOT)。相对应地,还有一些编程语言不会在运行前进行处理,或只进行一部分预处理,在代码执行的时候才转换为目标平台的代码,这种行为被称为即时编译(JIT)。一般来说,由于经过预先的编译,AOT 编译的产物一般都有速度较快,runtime 较小的优点,而 JIT 能够在运行时获取较详细的代码上下文,适合调试与快速开发。
Dart 同时支持这两种编译方式,只要在开发时启用 JIT 编译,在发布包时使用 AOT 编译,就可以同时拥有这两者的长处。
缺点
每个语言都是为了解决一些其它语言难以应对问题而被创造出来的,因此大部分语言都有它的适用场景。但是一般地,一门好的语言应该在减少开发者工作量的同时保证程序的可靠性。Dart 在这方面仍然有许多值得改进的地方。
所有类型都是 nullable
int a = null;
double div(double a, double b) {
if (b == 0) {
return; // 这里没有返回值,但仍然是合法的表达
}
return a / b;
}
null
被称为是一个「十亿代价的错误」,在面向对象的语言中,有无数的系统错误是因为空引用导致的,因为 null
的存在导致很多程序员在编写的时候没有或者忘了检查这个值。在一些语言(例如 TypeScript)中,一个类的引用不能为 null
,并且在使用可能为 null
的引用时需要被强制检查:
class A {
f: number = 0;
}
// Error: Type 'null' is not assignable to type 'A'.ts(2322)
const a: A = null;
// OK
const b: A | null = null;
// Error: Object is possibly 'null'.ts(2531)
b.f;
这样就可以在代码运行之前就检查出很多潜在的空引用错误。而 Dart 非但没有吸取这个教训,反而将 null
的使用扩大化,这样就无法在语言层面保证程序的健壮性。
上图摘自 Flutter SDK 中的某一段源码,可以发现为了保证程序的健壮性,程序员需要在每个函数的前面断言参数不为 null
,并且这里的错误只能在运行期间才能发现,增加了开发难度。
类型推断不够完善
很多时候,一段程序的逻辑与它所要处理的数据类型无关,所以编程语言需要有一种机制来避免为了不同的类型写重复的代码。像 Python,JavaScript 之类的语言使用鸭子类型,在使用数据的时候不关心它的具体类型,而只关注是否实现了某些特性。而 C++,Java 等语言的类型比较严格,必须在编写方法时确定它可以接受的参数类型,因此给出了模板和泛型等解决方案。Dart 与后者的解决方案更加相似,它提供了泛型来解决这样的问题。但是它的类型推断仍然有一些不完善的地方。
Type id<Type>(Type a) => a;
typedef FunctionType<T> = T Function(T);
class FunctionWrapper<T> {
FunctionWrapper({
this.func = id,
});
FunctionType<T> func;
}
main() {
final f = FunctionWrapper<int>();
print(f.func(0));
}
上述代码会在运行时给出如下错误:
Unhandled exception:
type '(T) => T' is not a subtype of type '(int) => int'
#0 main (file:///Users/means88/code/test/function.dart)
#1 _startIsolate.<anonymous closure> (dart:isolate/runtime/libisolate_patch.dart:279:19)
#2 _RawReceivePortImpl._handleMessage (dart:isolate/runtime/libisolate_patch.dart:165:12)
可以看到 id
函数的类型没有正确地被推导出来,这就给语言的使用增加了一些限制。另外,在语言不支持的情况下,也没有给出编译时的错误,提高了开发的成本。
dynamic 类型
上述的几项虽然可以有更大的改进空间,但并不是最致命的问题。很多语言都或多或少带有一些问题,这是不可避免的,但 dynamic
绝对称得上是这个语言最大的败笔。那么 dynamic
类型做了些什么呢。
将一个引用声明为 dynamic
类型并不改变运行时的结果,但是它关闭了类型检查。语言特性的缺失可以用很多方法来补救,但是 Dart 选择了掩耳盗铃。它提供一个关键字关闭了编译器的检查,让开发者自己在运行时确认引用对象的类型。这个问题比 null
严重好几倍,现在我们不仅需要判断一个引用是否为 null
,还需要判断它所指的对象究竟是什么类型。另外,所有的代码提示也会一起被 dynamic
带走,这使得程序代码非常不可控。
Dart 完全可以提供一个更完善的类型系统,使用严格的代数数据类型取代 dynamic
,这样就可以在保证语言灵活性的同时保证程序的稳定性。而非像现在一样只能通过开发者自己不停地判断保证类型的匹配。
代码生成
Dart 的类型是静态的,所以没有办法获取到太多的运行期间的信息进行下一步操作。虽然 Dart 中也有反射机制,但是在 Flutter 中这个特性是被关闭的,因此不可避免地会产生一些冗余的代码。例如将一段数据发送给服务器,我们希望调用的地方有代码提示,而发送的时候最终需要转换成 Map
,最后代码可能会变成这样:
Future<Response> sendSomeData({
@require String name,
@require int age,
String description,
}) {
final params = {
"name": name,
"age": age,
"description": description,
};
return request(params);
}
或者可以把函数参数放在一个类中,但是这个类仍然需要提供构造函数和转换为 Map
的方法,如:
class RequestParams {
String name;
int age;
String description;
RequestParams({
@required this.name,
@required this.age,
this.description,
});
Map<String, dynamic> toMap() {
return {
"name": name,
"age": age,
"description": description,
};
}
}
许多框架都会借助代码生成来减轻开发者的工作,如客户端的布局文件或资源文件都会生成代码供开发者引用。同样地,为了解决这个问题,Dart 提供了 build 这个包,以及 source_gen 等更高层次的实现。基于这些包可以开发出一些自动生成代码的工具来减少冗余的代码。如上面的情况,可以使用 json_serializable 自动生成转为 Map
的代码:
import 'package:json_annotation/json_annotation.dart';
part 'request_params.g.dart';
@JsonSerializable()
class RequestParams {
String name;
int age;
String description;
RequestParams({
@required this.name,
@required this.age,
this.description,
});
factory RequestParam.fromJson(Map<String, dynamic> json) => _$RequestParamsFromJson(json);
Map<String, dynamic> toJson() => _$RequestParamsToJson(this);
}
其中 _$RequestParamsFromJson
与 _$RequestParamsToJson
都是自动生成在 request_params.g.dart
中的函数,它代替我们做了与 Map
相互转换的工作。以上的写法是 Dart 的官方代码生成库以及社区主流的操作方式,但这样还是有一些问题。首先上面的代码中,成员变量和构造函数中仍然有很多重复的部分,另外还需要在代码生成完毕后在原来的类中引用生成出来的函数,逻辑上产生了循环依赖,比较混乱。因此我推荐的方式是,在源文件中定义类的接口,然后在生成的文件中实现。上面的文件就可以改写为:
// request_params.dart
@JsonSerializable()
abstract class IRequestParams {
String name;
int age;
String description;
}
// request_params.g.dart
class RequestParams implements IRequestParams {
String name;
int age;
String description;
RequestParams({
@required this.name,
@required this.age,
this.description,
});
factory RequestParam.fromJson(Map<String, dynamic> json) => RequestParms({
name: json['name'],
age: json['age'],
description: json['description'],
});
Map<String, dynamic> toJson() => {
"name": name,
"age": age,
"description": description,
};
}
这样可以减少需要编写的代码数量,同时逻辑上比较自然,使用时不容易产生疑惑。但总而言之,Dart 的代码生成并不是一个好的解决方案。观察其它语言可以发现,代码生成一般是在构建过程中的一个步骤,对终端开发者是透明的,而不像 Dart 一样需要关心生成的代码中暴露了哪些内容。Dart 的代码生成可能只是缓解语言缺陷的一个不太精妙的解决方案。
Flutter 布局
因为 Dart 是一门比较普通的编程语言,所以在开发时大部分逻辑和其它客户端领域没有本质区别。但是在界面布局上,每个平台可以说是各有千秋,并且是一项纯粹依赖于经验的技能。对于大部分界面框架来说,一个元素如何布局取决于它自身的属性(以及和它相关的元素的属性),而 Flutter 将布局效果拆分到各个 Widget 中,一个 Widget 如何展示主要取决于它嵌套在哪些 Widget 中。
盒限制模型
Flutter 的元素在底层都使用 RenderBox
进行渲染,一般来说它的尺寸会是下面三种情况之一:
- 尽可能地大
- 恰好容纳子元素
- 有一个固定的尺寸
这一点和其它平台上的布局都比较相似,所以在这些情况下一般开发者都可以很轻松地完成界面布局,只需要将原先写在元素属性内的值拿出来用别的元素表示就可以了。
未限制大小
但在另外一些情况下,布局的尺寸没有给出限制。比如在 Flex 布局元素的交叉轴方向,以及滚动元素的滚动方向,它的最大值是无穷(然而还是会根据内部的元素尺寸拥有一个确定的实际值)。这一点比较好理解,因为在以上的布局的尺寸可能比它外部的元素还要大,不能按照前面的情况给它设定最大值。但是,当我们试图在这样的元素里面放置一个布局规则为「尽可能大」的元素时,糟糕的情况就出现了。
由于外面的 Row
没有限制最大的宽度,而里面的 TextField
又尝试得到最大的宽度,因此里面的 TextField
的实际宽度会变为无穷大,此时 Flutter 就会抛出一个错误。
I/flutter ( 7674): BoxConstraints forces an infinite width.
I/flutter ( 7674): These invalid constraints were provided to RenderStack's layout() function by the following
I/flutter ( 7674): function, which probably computed the invalid constraints in question:
...
类似的错误可能是其它平台的开发者最容易遇到也是最摸不着头脑的一点了。比如在前端开发中,在 Flex 布局中放置一个固定尺寸的图标和一个尽可能长的输入框是很常见的行为,但是相同的做法在 Flutter 中却行不通。其中一种解决方法是在 TextField
外面套一层 Flexible
,通过它来限制最大宽度,这样就可以避免上面的问题。
借此也可以想象一下,有多个 Flex 元素嵌套的布局会有多么难写(Row(Colomn(Row(...)))
)。Flutter 的布局在习惯上与其它平台还有很多差异,这一点虽然并不困难但是只能靠开发经验慢慢补足。
容器嵌套
Flutter 将布局的样式以很细的粒度拆分到了各个 Widget 中,而一些比较高级的组件默认都是 Material Design 的,不可能在实际的开发过程中直接使用。因此在目前的开发中,经常会出现很深层次的组件嵌套。这种嵌套使得代码非常难看,很难将组件和它的属性一眼分离开来,深层的缩进使得代码几乎「居中显示」。对此 Google Flutter 团队的解释是「既然布局使用 Dart 编写的,我们可以用编程语言的方法让它看起来更清楚」。但是并没有人给出一个更加具体的描述,说明怎样写可以让它看起来更清楚。也许 Flutter 需要拥有一个更加具有普适性的 UI 组件库,在提供一些基础功能的同时方便开发者对一些样式进行定义。这样或许就可以将常用的布局属性组合到一起,减少容器的嵌套了。
状态管理
Flutter 的框架结构在很大程度上借鉴了 React 的模式,由状态来控制组件的渲染,所以整个应用的状态管理就成了一个重要的部分。Flutter 的官方文档中对一些状态管理的方法举例进行了说明,但是其中的示例场景都比较简单,不能正确反应真实开发场景,因此在选择的时候还是需要从更深的角度去思考。
setState
React 中也有 setState
方法,然而两者之间有比较大的差别。React 的 setState
有两种模式,一种是直接传递要修改的状态属性,另一种是传递一个接收当前状态的函数,用于生成新的状态。
state = {
count: 0,
data: 0,
};
this.setState({
count: 1,
});
// or
this.setState((prevState) => ({
count: prevState.count + 1,
}));
// state: { count: 1, data: 0 }
无论是哪种方法,都是一个纯粹的函数,它只能改变状态的值,在改变以后由框架触发界面的重新渲染。而 Flutter 的 setState
接收的是一个普通的函数,在里面可以做任何操作。
this.setState(() {
this.count = 1;
});
这主要是两个框架之间的理念仍然有些区别。React 希望 state
是 immutable 的,开发者不能直接去修改它的值。然而 Flutter 的 state
只是 Dart 中是一个普通的对象,不是一个特殊的角色。并且受 Dart 语言的限制,Flutter 很难实现一个通用的 patch 方法修改 state
,因此只能让开发者自己进行操作。
虽然两者之间有这样的区别,但是数据的结构上还是相通的,Flutter 也会有类似于 React 的状态传递过深的问题。因此同样地,Flutter 也有 context 的机制,从框架内部提供最基础的解决方案。
Redux
Redux 是 React 生态中最常使用的状态管理工具,Dart 和 Flutter 中也有对应的实现(redux.dart 和 flutter_redux)。Redux 在 React 的很多场景下使用也并不是特别适合,到了 Flutter 中,缺点就变得更加明显了。
全局单一的 store
因为 Redux 的 store 只有一个,因此状态难以复用。状态本身的复制是比较简单的,我们可以将状态放在一个类中,多创建一些即可,但是其它部分很复杂:如果不希望每个部分相互影响,需要有不同的 action,最后「将每个部分组合到一起」这个步骤是怎么也无法跳过的。
在 Dart 中,这个情况更加严重。受语法上的限制,redux.dart 中的 combineReducer
只能将 reducer 组合到一起,而不能生成组合后的 state。Dart 中的 store 构造函数如下:
Store(
this.reducer, {
State initialState,
List<Middleware<State>> middleware = const [],
bool syncStream: false,
);
与 JavaScript 版不同,这里的 initialState
是创建 Store
对象时传入的,而非定义在 reducer 的默认参数中。这意味着在创建时需要一个完整的 State
类。怎么写才能创造这么一个 State
,并且保证模块之间相对的独立性呢?在实际使用时可以发现,无论是拆分部分分离到其它类还是使用 mixin 都会产生问题。
Immutable
不同于 React,Flutter 这个框架本身并不包含任何需要 immutable 的场景,而 Redux 在状态修改的时候需要是 immutable 的,Dart 的语言特性又决定了它并不能像 JavaScript 一样方便地对一个对象进行复制,所以在 Flutter 中使用 Redux 是不贴合框架本身的风格的,还会带来更多麻烦。
BloC/Rx
相比之下,Dart 对 Stream 提供了很好的支持,如果有一种方法可以使用流的形式管理状态,那么它应该是比较适合 Flutter 的。利用 Flutter 中提供的 StreamBuilder
,将流中的每一帧数据映射到一个 Widget,这样在 Stream 中有数据产生时,Flutter 会根据新生成的数据对界面进行更新。
StreamBuilder<Entity>(
stream: this.entityStream,
builder: (context, snapshot) {
return Widget(
...,
onTap: () {
controller.add(someEvent);
}
);
},
)
这样做有个显然的好处,就是可以把不同的数据自然地分离开,避免了在这样的语言中处理复杂类型的麻烦。通过数据流的方式,可以将多个来源的数据分别用不同的流表示,并且可以在流之间也建立订阅关系,产生更加灵活的数据流。ReactiveX 就是一个流模型的库,有多种语言的实现,并且生态系统比较丰富,在 Dart 中也有 RxDart 的实现。
MobX
MobX 是一个利用 getter/setter(以及 Proxy)语言特性的响应式状态管理工具。它自动记录了对象之间的依赖关系,并在依赖发生变化的触发更新,为开发者提供透明式的响应式编程。MobX 的 Dart 版本 mobx.dart 仍然在开发中。
总结
Flutter 实现了一个高性能的跨平台框架,并且发布了 1.0 版本。但是这个情况和 React 发布 15.0 版本时有很大区别:React 发布第一个正式版时已经比较成熟,有许多项目已经在使用,而 Flutter 的 1.0 版本只是一个起点,具体表现为:
- Dart 语言不够完善,包括上面提到的问题以及 4000 多个 issue
- Flutter 本身也有许多问题,同样拥有 4000 多个 issue
- 生态环境不够丰富,Dart Pub 上的包不足 5000 个(而 npm 超过 88 万)
- 生产环境使用较少,即使是官网的提名公司也只是很小一部分在使用
(以上相关统计数据截至 Thu, 10 Jan 2019 13:16:19 GMT)
因此,现在并不是一个使用 Flutter 的最好时机,换用 Flutter 的收益很难超过投入的成本。但对于贡献 Flutter 来说,也许正是一个百废待兴的绝佳机会。