文章参考了Flutter中国开源项目发起人杜文(网名wendux)创作的一本系统介绍Flutter技术的中文书籍《Flutter实战·第二版》,网址:第二版序 | 《Flutter实战·第二版》 https://book.flutterchina.club/#第二版变化
文章目录
一、状态管理
响应式的编程框架中都会有一个永恒的主题——“状态(State)管理”,以下是管理状态的最常见的方法:
- Widget 管理自己的状态。
- Widget 管理子 Widget 状态。
- 混合管理(父 Widget 和子 Widget 都管理状态)。
官方原则:
- 如果状态是用户数据,如复选框的选中状态、滑块的位置,则该状态最好由父 Widget 管理。
- 如果状态是有关界面外观效果的,例如颜色、动画,那么状态最好由 Widget 本身来管理。
- 如果某一个状态是不同 Widget 共享的则最好由它们共同的父 Widget 管理。
在 Widget 内部管理状态封装性会好一些,而在父 Widget 中管理会比较灵活。有些时候,如果不确定到底该怎么管理状态,那么推荐的首选是在父 Widget 中管理(灵活会显得更重要一些)。
创建三个盒子TapboxA、TapboxB和TapboxC——每创建一个盒子,当点击它时,盒子背景会在绿色与灰色间切换。
状态 _active
确定颜色:绿色为true
,灰色为false
,如图所示:
下面的例子将使用GestureDetector
来识别点击事件
1.自身管理
实现一个TapboxA,在它对应的_TapboxAState 类:
- 管理TapboxA的状态。
- 定义
_active
:确定盒子的当前颜色的布尔值。 - 定义
_handleTap()
函数,该函数在点击该盒子时更新_active
,并调用setState()
更新UI。 - 实现widget的所有交互式行为。
import 'package:flutter/material.dart';
void main() => runApp(const TapboxA());
// TapboxA 管理自身状态.
//------------------------- TapboxA ----------------------------------
class TapboxA extends StatefulWidget {
const TapboxA({Key? key}) : super(key: key);
_TapboxAState createState() => _TapboxAState();
}
class _TapboxAState extends State<TapboxA> {
bool _active = false;
void _handleTap() {
setState(() {
_active = !_active;
});
}
Widget build(BuildContext context) {
return MaterialApp(
home: GestureDetector(
onTap: _handleTap,
child: Container(
width: 200.0,
height: 200.0,
decoration: BoxDecoration(
color: _active ? Colors.lightGreen[700] : Colors.grey[600],
),
child: Center(
child: Text(
_active ? 'Active' : 'Inactive',
style: const TextStyle(fontSize: 32.0, color: Colors.white),
),
),
),
),
);
}
}
2.父管理子
父 Widget 管理状态并告诉其子 Widget 何时更新较好。 如IconButton
是一个图标按钮,但它是一个无状态的Widget,因为父Widget需要知道该按钮是否被点击来采取相应的处理。
以下示例 TapboxB 通过回调将其状态导出到其父组件,状态由父组件管理,因此它的父组件为StatefulWidget
。但 TapboxB 不管理任何状态,所以TapboxB
为StatelessWidget
。
创建ParentWidgetState
类:
- 为TapboxB 管理
_active
状态。 - 实现
_handleTapboxChanged()
,当盒子被点击时调用的方法。 - 当状态改变时,调用
setState()
更新UI。
创建TapboxB
类:
- 继承
StatelessWidget
类,因为所有状态都由其父组件处理。 - 当检测到点击时,它会通知父组件。
import 'package:flutter/material.dart';
void main() => runApp(const ParentWidget());
// ParentWidget 为 TapboxB 管理状态.
//------------------------ ParentWidget --------------------------------
class ParentWidget extends StatefulWidget {
const ParentWidget({Key? key}) : super(key: key);
_ParentWidgetState createState() => _ParentWidgetState();
}
class _ParentWidgetState extends State<ParentWidget> {
bool _active = false;
void _handleTapboxChanged(bool newValue) {
setState(() {
_active = newValue;
});
}
Widget build(BuildContext context) {
return MaterialApp(
home:Container(
child: TapboxB(
active: _active,
onChanged: _handleTapboxChanged,
),
),);
}
}
//------------------------- TapboxB ----------------------------------
class TapboxB extends StatelessWidget {
const TapboxB({Key? key, this.active = false, required this.onChanged})
: super(key: key);
final bool active;
final ValueChanged<bool> onChanged;
void _handleTap() {
onChanged(!active);
}
Widget build(BuildContext context) {
return GestureDetector(
onTap: _handleTap,
child: Container(
width: 200.0,
height: 200.0,
decoration: BoxDecoration(
color: active ? Colors.lightGreen[700] : Colors.grey[600],
),
child: Center(
child: Text(
active ? 'Active' : 'Inactive',
style: const TextStyle(fontSize: 32.0, color: Colors.white),
),
),
),
);
}
}
3.混合管理
对于一些组件混合管理的方式非常有用。这种情况下组件自身管理一些内部状态,而父组件管理一些其他外部状态。
下面 TapboxC 示例中手指按下时盒子周围出现一个深绿色的边框,抬起时边框消失。点击完成后盒子颜色改变。 TapboxC 将其_active
状态导出到其父组件中,但在内部管理其_highlight
状态。这个例子有两个状态对象_ParentWidgetState
和_TapboxCState
。
_ParentWidgetStateC
类:
- 管理
_active
状态。 - 实现
_handleTapboxChanged()
,当盒子被点击时调用。 - 当点击盒子并且
_active
状态改变时调用setState()
更新UI。
_TapboxCState
对象:
- 管理
_highlight
状态。 GestureDetector
监听所有tap事件。当用户点下时,它添加高亮(深绿色边框);当用户释放时,会移除高亮。- 当按下、抬起、或者取消点击时更新
_highlight
状态,调用setState()
更新UI。 - 当点击时,将状态的改变传递给父组件。
import 'package:flutter/material.dart';
void main() => runApp(const ParentWidgetC());
//---------------------------- ParentWidget ----------------------------
class ParentWidgetC extends StatefulWidget {
const ParentWidgetC({Key? key}) : super(key: key);
_ParentWidgetCState createState() => _ParentWidgetCState();
}
class _ParentWidgetCState extends State<ParentWidgetC> {
bool _active = false;
void _handleTapboxChanged(bool newValue) {
setState(() {
_active = newValue;
});
}
Widget build(BuildContext context) {
return MaterialApp(
home: Container(
child: TapboxC(
active: _active,
onChanged: _handleTapboxChanged,
),
),
);
}
}
//----------------------------- TapboxC ------------------------------
class TapboxC extends StatefulWidget {
const TapboxC({Key? key, this.active = false, required this.onChanged})
: super(key: key);
final bool active;
final ValueChanged<bool> onChanged;
_TapboxCState createState() => _TapboxCState();
}
class _TapboxCState extends State<TapboxC> {
bool _highlight = false;
void _handleTapDown(TapDownDetails details) {
setState(() {
_highlight = true;
});
}
void _handleTapUp(TapUpDetails details) {
setState(() {
_highlight = false;
});
}
void _handleTapCancel() {
setState(() {
_highlight = false;
});
}
void _handleTap() {
widget.onChanged(!widget.active);
}
Widget build(BuildContext context) {
// 在按下时添加绿色边框,当抬起时,取消高亮
return GestureDetector(
onTapDown: _handleTapDown,
// 处理按下事件
onTapUp: _handleTapUp,
// 处理抬起事件
onTap: _handleTap,
onTapCancel: _handleTapCancel,
child: Container(
width: 200.0,
height: 200.0,
decoration: BoxDecoration(
color: widget.active ? Colors.lightGreen[700] : Colors.grey[600],
border: _highlight
? Border.all(
color: const Color(0xFF800400),
width: 10.0,
)
: null,
),
child: Center(
child: Text(
widget.active ? 'Active' : 'Inactive',
style: const TextStyle(fontSize: 32.0, color: Colors.deepOrange),
),
),
),
);
}
}
在Flutter中,TapDownDetails
和TapUpDetails
是用于处理触摸事件的类:
TapDownDetails
:- 当用户按下屏幕时,Flutter会生成一个
TapDown
事件。 TapDownDetails
包含有关此按下事件的信息,例如触摸点的位置、时间戳等。- 你可以通过
onTapDown
回调来处理这些信息,例如在按下时添加高亮效果。
- 当用户按下屏幕时,Flutter会生成一个
TapUpDetails
:- 当用户释放屏幕时,Flutter会生成一个
TapUp
事件。 TapUpDetails
包含有关此释放事件的信息,例如触摸点的位置、时间戳等。- 你可以通过
onTapUp
回调来处理这些信息,例如在抬起时取消高亮效果。
- 当用户释放屏幕时,Flutter会生成一个
_handleTapDown
和_handleTapUp
函数分别处理了按下和抬起事件,使用了这些信息来控制高亮效果和状态变化。这是一种常见的方式,用于在用户与应用程序交互时提供视觉反馈。
另一种实现可能会将高亮状态导出到父组件,但同时保持_active
状态为内部状态,但如果你要将该TapBox 给其他人使用,可能没有什么意义。 开发人员只会关心该框是否处于 Active 状态,而不在乎高亮显示是如何管理的,所以应该让 TapBox 内部处理这些细节。
4.全局管理
- 当应用中需要一些跨组件(包括跨路由)的状态需要同步时上面方法很难胜任。
- 如有一个设置页可设置应用语言,为让设置实时生效期望在语言状态发生改变时,App中依赖应用语言的组件能够重新 build 一下,但这些依赖应用语言的组件和设置页并不在一起,所以这种情况用上面的方法很难管理。
- 正确做法是通过一个全局状态管理器来处理这种相距较远的组件之间的通信。目前主要有两种办法:
- 实现一个全局的事件总线,将语言状态改变对应为一个事件,然后在APP中依赖应用语言的组件的
initState
方法中订阅语言改变的事件。当用户在设置页切换语言后,我们发布语言改变事件,而订阅了此事件的组件就会收到通知,收到通知后调用setState(...)
方法重新build
一下自身即可。 - 使用一些专门用于状态管理的包,如 Provider、Redux,读者可以在 pub 上查看其详细信息。
- 实现一个全局的事件总线,将语言状态改变对应为一个事件,然后在APP中依赖应用语言的组件的
二、路由管理
- 路由(Route)在移动开发中通常指页面(Page)。
- 所谓路由管理,就是管理页面间如何跳转,通常也可被称为导航管理。
- Flutter 中的路由管理和原生开发类似,无论是 Android 还是 iOS,导航管理都会维护一个路由栈,路由入栈(push)操作对应打开一个新页面,路由出栈(pop)操作对应页面关闭操作,而路由管理主要是指如何来管理路由栈。
简单的页面跳转:
import 'package:flutter/material.dart';
void main() => runApp(const MyApp());
class MyApp extends StatelessWidget {
const MyApp({Key? key}) : super(key: key);
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
theme: ThemeData(
primarySwatch: Colors.blue,
),
home: const MyHomePage(title: 'Flutter Demo Home Page'),
);
}
}
class MyHomePage extends StatefulWidget {
const MyHomePage({Key? key, required this.title}) : super(key: key);
final String title;
_MyHomePageState createState() => _MyHomePageState();
}
class _MyHomePageState extends State<MyHomePage> {
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(widget.title),
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
// 在_MyHomePageState.build方法中的Column的子widget中添加一个按钮(TextButton)
TextButton(
child: const Text("open new route"),
onPressed: () {
//导航到新路由
Navigator.push(
context,
MaterialPageRoute(builder: (context) {
return const NewRoute();
}),
);
},
),
],
),
),
);
}
}
// 创建一个新路由,命名“NewRoute”
class NewRoute extends StatelessWidget {
const NewRoute({Key? key}) : super(key: key);
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text("New route"),
),
body: const Center(
child: Text("This is new route"),
),
);
}
}
1.MaterialPageRoute
PageRoute
类是一个抽象类,表示占有整个屏幕空间的一个模态路由页面,它还定义了路由构建及切换时过渡动画的相关接口及属性。MaterialPageRoute
继承自PageRoute
类,MaterialPageRoute
是 Material组件库提供的组件,可针对不同平台,实现与平台页面切换动画风格一致的路由切换动画:- 对于 Android,
- 当打开新页面时,新的页面会从屏幕底部滑动到屏幕顶部;
- 当关闭页面时,当前页面会从屏幕顶部滑动到屏幕底部后消失,同时上一个页面会显示到屏幕上。
- 对于 iOS,
- 当打开页面时,新的页面会从屏幕右侧边缘一直滑动到屏幕左边,直到新页面全部显示到屏幕上,而上一个页面则会从当前屏幕滑动到屏幕左侧而消失;
- 当关闭页面时,正好相反,当前页面会从屏幕右侧滑出,同时上一个页面会从屏幕左侧滑入。
- 对于 Android,
MaterialPageRoute
构造函数的各个参数的意义:
MaterialPageRoute({
WidgetBuilder builder,
RouteSettings settings,
bool maintainState = true,
bool fullscreenDialog = false,
})
builder
是一个WidgetBuilder类型的回调函数,作用是构建路由页面的具体内容,返回值是一个widget。通常要实现此回调返回新路由的实例。settings
包含路由的配置信息,如路由名称、是否初始路由(首页)。maintainState
:默认情况下,当入栈一个新路由时,原来的路由仍然会被保存在内存中,如果想在路由没用时释放其所占用的所有资源,可设置maintainState
为false
。fullscreenDialog
表示新的路由页面是否是一个全屏的模态对话框,在 iOS 中如果fullscreenDialog
为true
,新页面将会从屏幕底部滑入(而不是水平方向)。- 如果想自定义路由切换动画,可继承 PageRoute 来实现。
3.Navigator
Navigator
是一个路由管理的组件,提供了打开和退出路由页方法。Navigator
通过一个栈来管理活动路由集合。通常当前屏幕显示的页面就是栈顶的路由。Navigator
提供了一系列方法来管理路由栈,在此只介绍其最常用的两个方法:
1)push()
- 将给定的路由入栈(即打开新的页面),返回值是一个
Future
对象,用以接收新路由出栈(即关闭)时的返回数据。
Future push(BuildContext context, Route route)
2)pop()
- 将栈顶路由出栈,
result
为页面关闭时返回给上一个页面的数据。
bool pop(BuildContext context, [ result ])
Navigator
还有很多其他方法,如Navigator.replace
、Navigator.popUntil
等,详情请参考API文档或SDK 源码注释。
Navigator类中第一个参数为context的静态方法都对应一个Navigator的实例方法, 比如Navigator.push(BuildContext context, Route route)
等价于Navigator.of(context).push(Route route)
,下面命名路由相关的方法也是一样的。
// 路由跳转:传入一个路由对象(返回值是一个Future对象,用以接收新路由出栈(即关闭)时的返回数据。)
Future<T> push<T extends Object>(Route<T> route)
// 路由跳转:传入一个名称(命名路由)
Future<T> pushNamed<T extends Object>(
String routeName, {
Object arguments,
})
// 路由返回:可以传入一个参数,result为页面关闭时返回给上一个页面的数据。
bool pop<T extends Object>([ T result ])
4.路由传值
在路由跳转时带参数如打开商品详情页时带一个商品id,商品详情页才知道展示哪个商品信息;又如填写订单时选择收货地址,将用户选择的地址返回到订单页等等。
1)TipRoute
新旧路由传参:创建一个TipRoute
路由,接受一个提示文本参数显示在页面上,另外TipRoute
中添加一个“返回”按钮,点击后在返回上一个路由的同时会带上一个返回参数:
class TipRoute extends StatelessWidget {
const TipRoute({
Key? key,
required this.text, // 接收一个text参数
}) : super(key: key);
final String text;
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text("提示"),
),
body: Padding(
padding: const EdgeInsets.all(18),
child: Center(
child: Column(
children: <Widget>[
Text(text),
ElevatedButton(
onPressed: () => Navigator.pop(context, "我是返回值"),
child: const Text("返回"),
)
],
),
),
),
);
}
}
完整代码如下
import 'package:flutter/material.dart';
void main() => runApp(const MyApp());
class MyApp extends StatelessWidget {
const MyApp({Key? key}) : super(key: key);
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
theme: ThemeData(
primarySwatch: Colors.blue,
),
home: const MyHomePage(title: 'Flutter Demo Home Page'),
);
}
}
class MyHomePage extends StatefulWidget {
const MyHomePage({Key? key, required this.title}) : super(key: key);
final String title;
_MyHomePageState createState() => _MyHomePageState();
}
class _MyHomePageState extends State<MyHomePage> {
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(widget.title),
),
body: Center(
child: ElevatedButton(
onPressed: () async {
// 打开`TipRoute`,并等待返回结果
var result = await Navigator.push(
context,
MaterialPageRoute(
builder: (context) {
return const TipRoute(
// 路由参数
text: "我是提示xxxx",
);
},
),
);
//输出`TipRoute`路由返回结果
print("路由返回值: $result");
},
child: const Text("打开提示页"),
),
),
);
}
}
class TipRoute extends StatelessWidget {
const TipRoute({
Key? key,
required this.text, // 接收一个text参数
}) : super(key: key);
final String text;
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text("提示"),
),
body: Padding(
padding: const EdgeInsets.all(18),
child: Center(
child: Column(
children: <Widget>[
Text(text),
ElevatedButton(
onPressed: () => Navigator.pop(context, "我是返回值"),
child: const Text("返回"),
)
],
),
),
),
);
}
}
- 运行代码点击
RouterTestRoute
页的“打开提示页”按钮会打开TipRoute
页,提示文案“我是提示xxxx”是通过TipRoute
的text
参数传递给新路由页。可通过等待Navigator.push(…)
返回的Future
来获取新路由的返回数据。 TipRoute
页中有两种方式返回到上一页:- 第一种直接点击导航栏返回箭头,
- 第二种点击页面中的“返回”按钮。
- 两种返回方式区别是前者不会返回数据给上一个路由,而后者会。下面是分别点击页面中的返回按钮和导航栏返回箭头后,
RouterTestRoute
页中print
方法在控制台输出的内容:
I/flutter (27896): 路由返回值: 我是返回值
I/flutter (27896): 路由返回值: null
上面介绍的是非命名路由的传值方式,命名路由传值方式会有所不同。
5.命名路由
“命名路由”(Named Route)即有名字的路由,可先给路由起个名字,然后通过路由名字直接打开新路由,路由管理直观、简单。
建议统一使用命名路由管理方式,这将会带来如下好处:
- 语义化更明确。
- 代码更好维护;如果使用匿名路由,则必须在调用
Navigator.push
的地方创建新路由页,这样不仅需要import新路由页的dart文件,而且代码将会非常分散。 - 可通过
onGenerateRoute
做一些全局的路由跳转前置处理逻辑。
1)路由表
使用命名路由须先提供注册一个路由表(routing table)使名字与路由组件相对应。注册路由表就是给路由起名字,路由表定义如下:
Map<String, WidgetBuilder> routes;
- Map 的 key 为路由名字,是个字符串;
- Map 的 value 是一个
builder
回调函数,用于生成相应的路由 widget。 - 通过路由名字打开新路由时,应用会根据路由名字在路由表中查找到对应的
WidgetBuilder
回调函数,然后调用该回调函数生成路由widget并返回。
2)注册路由表
路由表的注册只需在MyApp
类的build
方法中找到MaterialApp
,添加routes
属性:
MaterialApp(
title: 'Flutter Demo',
theme: ThemeData(
primarySwatch: Colors.blue,
),
//注册路由表
routes:{
"new_page":(context) => NewRoute(),
... // 省略其他路由注册信息
} ,
home: MyHomePage(title: 'Flutter Demo Home Page'),
);
将home
注册为命名路由:
MaterialApp(
title: 'Flutter Demo',
initialRoute:"/", //名为"/"的路由作为应用的home(首页)
theme: ThemeData(
primarySwatch: Colors.blue,
),
//注册路由表
routes:{
"new_page":(context) => NewRoute(),
"/":(context) => MyHomePage(title: 'Flutter Demo Home Page'), //注册首页路由
}
);
只需在路由表中注册一下MyHomePage
路由,然后将其名字作为MaterialApp
的initialRoute
属性值即可,该属性决定应用的初始路由页是哪一个命名路由。
3)路由页跳转
通过路由名称来打开新路由可使用Navigator
的pushNamed
方法:
Future pushNamed(BuildContext context, String routeName,{Object arguments})
修改TextButton
的onPressed
回调代码,改为:
onPressed: () {
Navigator.pushNamed(context, "new_page");
//Navigator.push(context,
// MaterialPageRoute(builder: (context) {
// return NewRoute();
//}));
},
热重载应用,再次点击“open new route”按钮,依然可以打开新的路由页。
4)参数传递
命名路由传递并获取路由参数,先注册一个路由:
routes:{
"new_page":(context) => EchoRoute(),
} ,
在路由页通过RouteSetting
对象获取路由参数:
class EchoRoute extends StatelessWidget {
Widget build(BuildContext context) {
//获取路由参数
var args=ModalRoute.of(context).settings.arguments;
//...省略无关代码
}
}
在打开路由时传递参数
Navigator.of(context).pushNamed("new_page", arguments: "首页传过来的参数");
完整代码如下
import 'package:flutter/material.dart';
void main() => runApp(const MyApp());
class MyApp extends StatelessWidget {
const MyApp({Key? key}) : super(key: key);
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
theme: ThemeData(
primarySwatch: Colors.blue,
),
routes: {
"new_page": (context) => const EchoRoute(),
"/": (context) => const MyHomePage(title: 'Flutter Demo Home Page'),
//注册首页路由
});
}
}
class MyHomePage extends StatefulWidget {
const MyHomePage({Key? key, required this.title}) : super(key: key);
final String title;
_MyHomePageState createState() => _MyHomePageState();
}
class _MyHomePageState extends State<MyHomePage> {
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(widget.title),
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
// 在_MyHomePageState.build方法中的Column的子widget中添加一个按钮(TextButton)
TextButton(
child: const Text("open new route"),
onPressed: () {
// 在打开路由时传递参数
Navigator.of(context).pushNamed("new_page", arguments: "首页传过来的参数");
},
),
],
),
),
);
}
}
// 创建一个新路由,命名“NewRoute”
class EchoRoute extends StatelessWidget {
const EchoRoute({Key? key}) : super(key: key);
Widget build(BuildContext context) {
//获取路由参数
var args = ModalRoute.of(context)?.settings.arguments;
return Scaffold(
appBar: AppBar(
title: const Text("New route"),
),
body: Center(
child: Text("$args"),
),
);
}
}
5)适配TipRoute
路由页如果需要接收参数,如4.中的首页传过来的参数,不改变TipRoute
源码适配:
MaterialApp(
... //省略无关代码
routes: {
"tip2": (context){
return TipRoute(text: ModalRoute.of(context)!.settings.arguments);
},
},
);
完整代码
import 'package:flutter/material.dart';
void main() => runApp(const MyApp());
class MyApp extends StatelessWidget {
const MyApp({Key? key}) : super(key: key);
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
theme: ThemeData(
primarySwatch: Colors.blue,
),
routes: {
"new_page": (context) => const EchoRoute(),
"/": (context) => const MyHomePage(title: 'Flutter Demo Home Page'),
//注册首页路由
"tip2": (context){
return TipRoute(text: "${ModalRoute.of(context)!.settings.arguments}");
},
});
}
}
class MyHomePage extends StatefulWidget {
const MyHomePage({Key? key, required this.title}) : super(key: key);
final String title;
_MyHomePageState createState() => _MyHomePageState();
}
class _MyHomePageState extends State<MyHomePage> {
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(widget.title),
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
// 在_MyHomePageState.build方法中的Column的子widget中添加一个按钮(TextButton)
TextButton(
child: const Text("open new route"),
onPressed: () {
// 在打开路由时传递参数
Navigator.of(context).pushNamed("new_page", arguments: "首页传过来的参数");
},
),
TextButton(
child: const Text("open TipRoute"),
onPressed: () {
// 在打开路由时传递参数
Navigator.of(context).pushNamed("tip2", arguments: "首页传过来的参数2");
},
),
],
),
),
);
}
}
// 创建一个新路由,命名“NewRoute”
class EchoRoute extends StatelessWidget {
const EchoRoute({Key? key}) : super(key: key);
Widget build(BuildContext context) {
//获取路由参数
var args = ModalRoute.of(context)?.settings.arguments;
return Scaffold(
appBar: AppBar(
title: const Text("New route"),
),
body: Center(
child: Text("$args"),
),
);
}
}
class TipRoute extends StatelessWidget {
const TipRoute({
Key? key,
required this.text, // 接收一个text参数
}) : super(key: key);
final String text;
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text("提示"),
),
body: Padding(
padding: const EdgeInsets.all(18),
child: Center(
child: Column(
children: <Widget>[
Text(text),
ElevatedButton(
onPressed: () => Navigator.pop(context, "我是返回值"),
child: const Text("返回"),
)
],
),
),
),
);
}
}
在这段代码中的context
:
- 在
build
方法中,context
表示当前TipRoute
部件的上下文环境。 - 可以使用
context
来访问父级部件、主题、局部化信息等。 - 在
onPressed
回调中,Navigator.pop(context, "我是返回值")
使用了context
来关闭当前页面并返回一个值给上一个页面。
6.页面跳转总结
在Flutter中,页面跳转被称为“路由”,主要通过Navigator
组件来管理。以下是两种常见的页面跳转方式:
1)基本路由(静态路由)
(1)不传值跳转
Navigator.of(context).push(
MaterialPageRoute(builder: (context) => PageA()),
);
(2)传值跳转
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => PageB(para: '你好'),
),
);
在目标页面PageB
中接收参数:
class PageB extends StatelessWidget {
final String para;
PageB({this.para = '没有接收到数据'});
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('PageB')),
body: Center(child: Text(para)),
);
}
}
2)命名路由(动态路由)
(1)不传值跳转
在MaterialApp
中配置路由:
return MaterialApp(
home: Scaffold(
appBar: AppBar(title: Text('Flutter 页面跳转')),
body: MyBody(),
),
routes: {
'/pageC': (context) => PageC(),
'/pageD': (context) => PageD(),
},
);
跳转到/pageC
:
Navigator.pushNamed(context, '/pageC');
(2)传值跳转
配置路由页面参数和监听:
final routes = {
'/pageC': (context) => PageC(),
'/pageD': (context, {arguments}) => PageD(arguments: arguments),
};
onGenerateRoute: (RouteSettings settings) {
final String name = settings.name;
final Function pageContentBuilder = this.routes[name];
if (pageContentBuilder != null) {
if (settings.arguments != null) {
final Route route = MaterialPageRoute(
builder: (context) => pageContentBuilder(context, arguments: settings.arguments),
);
return route;
} else {
final Route route = MaterialPageRoute(
builder: (context) => pageContentBuilder(context),
);
return route;
}
}
};
传值跳转到/pageD
:
Navigator.pushNamed(context, '/pageD', arguments: {"id": 123456});
在目标页面PageD
中接收参数:
class PageD extends StatelessWidget {
final Map arguments;
PageD({this.arguments});
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('PageD')),
body: Center(child: Text('ID: ${arguments['id']}')),
);
}
}
希望这些示例能帮助你更好地理解Flutter中的页面跳转。如果你有其他问题,随时告诉我!123
6.路由生成钩子
- 假设开发一个电商App,当用户没有登录时可以看店铺、商品等信息,但交易记录、购物车、用户个人信息等页面需要登录后才能看。
- 为实现上述功能需要在打开每一个路由页前判断用户登录状态,
MaterialApp
有一个onGenerateRoute
属性,当用户导航到一个未在路由表中显式定义的路由时,onGenerateRoute
会被调用,此时根据路由名称动态地创建并返回一个Route
对象。 - 当调用
Navigator.pushNamed(...)
打开命名路由时:- 如果指定的路由名在路由表中已注册,则会调用路由表中的
builder
函数来生成路由组件; - 如果路由表中没有注册才会调用
onGenerateRoute
来生成路由。
- 如果指定的路由名在路由表中已注册,则会调用路由表中的
onGenerateRoute
回调签名如下:
Route<dynamic> Function(RouteSettings settings)
使用onGenerateRoute
:
- 首先在
MaterialApp
或CupertinoApp
中设置onGenerateRoute
属性。 - 创建一个函数,该函数接收一个
RouteSettings
参数,并返回一个Route
对象。 - 在这个函数中根据
settings.name
来判断需要创建哪个路由。
onGenerateRoute
回调对于实现控制页面权限的功能非常容易:路由表替换成onGenerateRoute
回调,在该回调中进行统一权限控制,如:
MaterialApp(
... //省略无关代码
onGenerateRoute:(RouteSettings settings){
return MaterialPageRoute(builder: (context){
String routeName = settings.name;
// 如果访问的路由页需要登录,但当前未登录,则直接返回登录页路由,
// 引导用户登录;其他情况则正常打开路由。
}
);
}
);
注意,
onGenerateRoute
只会对命名路由生效。
MaterialApp(
onGenerateRoute: (settings) {
if (settings.name == '/details') {
// 根据需要传递的参数创建一个Route
return MaterialPageRoute(
builder: (context) {
// 从settings.arguments中获取参数
final args = settings.arguments as ScreenArguments;
return DetailsScreen(title: args.title, message: args.message);
},
);
}
// 如果路由名称不匹配,返回一个默认的Route
return null;
},
// 其他属性...
)
导航到使用onGenerateRoute
的路由:
- 使用
Navigator.pushNamed(context, '/details', arguments: args)
来导航到路由。 - 在
arguments
属性中传递需要的参数。
另外如路由MaterialApp中还有navigatorObservers
和onUnknownRoute
两个回调属性,前者可以监听所有路由跳转动作,后者在打开一个不存在的命名路由时会被调用
三、包管理
软件开发中一些公共的库或 SDK 会被很多项目用到,将这些代码单独抽到一个独立模块,然后需要使用时再直接集成这个模块可大大提高开发效率。如 Java 语言中这种独立模块会被打成一个 jar 包,Android 中的 aar 包,Web开发中的 npm 包等。这种可共享的独立模块统一称为“包”( Package)。
1.配置文件
一个 App 实际开发中会依赖很多包,这些包通常都有交叉依赖关系、版本依赖等,各种开发生态或编程语言官方通常都会提供一些包管理工具,如在 Android 提供了 Gradle 来管理依赖,iOS 用 Cocoapods 或 Carthage 来管理依赖,Node 中通过 npm 等。
1)YAML
- 在 Flutter 中也有自己的包管理工具: Flutter 使用配置文件
pubspec.yaml
(位于项目根目录)来管理第三方依赖包。
YAML 是一种直观、可读性高并易被阅读的文件格式,和 xml 或 Json 相比它语法简单并非常容易解析,所以 YAML 常用于配置文件,Flutter 也是用 yaml 文件作为其配置文件。
2)pubspec.yaml
Flutter 项目默认的配置文件是pubspec.yaml
name: flutter_in_action
description: First Flutter Application.
version: 1.0.0+1
dependencies:
flutter:
sdk: flutter
cupertino_icons: ^0.1.2
dev_dependencies:
flutter_test:
sdk: flutter
flutter:
uses-material-design: true
各个字段意义:
name
:应用或包名称。description
: 应用或包的描述、简介。version
:应用或包的版本号。dependencies
:应用或包依赖的其他包或插件。如果Flutter应用本身依赖某个包,需要将所依赖的包添加到dependencies
下dev_dependencies
:开发环境依赖的工具包(而不是flutter应用本身依赖的包)。flutter
:flutter相关的配置选项。
注意dependencies
和dev_dependencies
的区别:
dependencies
的依赖包将作为App的源码的一部分参与编译,生成最终的安装包。- 而
dev_dependencies
的依赖包只是作为开发阶段的一些工具包,主要是用于提高开发、测试效率,如 flutter 的自动化测试包等。
2.Pub仓库
Pub(https://pub.dev/ )是 Google 官方的 Dart Packages 仓库,类似于 node 中的 npm仓库、Android中的 jcenter。我们可以在 Pub 上面查找我们需要的包和插件,也可以向 Pub 发布我们的包和插件。包仓库搜索地址为 https://pub.dartlang.org
3.Pub包使用
实现一个显示随机字符串的 widget。有一个名为 “english_words” 的开源软件包,其中包含数千个常用的英文单词以及一些实用功能。
-
首先在 pub 上找到 english_words 这个包,确定其最新的版本号和是否支持 Flutter。“english_words”包最新的版本是4.0.0,并且支持flutter。
-
打开 pubspec .yaml 文件,在 dependencies 下将“english_words” 添加到依赖项列表
dependencies: flutter: sdk: flutter # 新添加的依赖 english_words: ^4.0.0
-
下载包。在Android Studio的编辑器视图中查看pubspec.yaml时(图2-13),单击右上角的 Pub get 。
- 也可在控制台定位到当前工程目录,然后手动运行
flutter packages get
命令来下载依赖包。
- 也可在控制台定位到当前工程目录,然后手动运行
-
引入
english_words
包import 'package:english_words/english_words.dart';
-
使用
english_words
包来生成随机字符串。class RandomWordsWidget extends StatelessWidget { Widget build(BuildContext context) { // 生成随机字符串 final wordPair = WordPair.random(); return Padding( padding: const EdgeInsets.all(8.0), child: Text(wordPair.toString()), ); } }
将
RandomWordsWidget
添加到_MyHomePageState.build
的Column
的子widget中。Column( mainAxisAlignment: MainAxisAlignment.center, children: <Widget>[ ... //省略无关代码 RandomWordsWidget(), ], )
4.其他依赖
除依赖Pub仓库还可依赖本地包和git仓库。
-
依赖本地包
如果正在本地开发一个包,包名为pkg1,可通过下面方式依赖:
dependencies: pkg1: path: ../../code/pkg1
路径可以是相对的,也可以是绝对的。
-
依赖Git:你也可以依赖存储在Git仓库中的包。如果软件包位于仓库的根目录中,请使用以下语法
dependencies: pkg1: git: url: git://github.com/xxx/pkg1.git
上面假定包位于Git存储库的根目录中。如果不是这种情况,可以使用path参数指定相对位置,例如:
dependencies: package1: git: url: git://github.com/flutter/packages.git path: packages/package1
上面介绍的这些依赖方式是Flutter开发中常用的,但还有一些其他依赖方式,自行查看:https://www.dartlang.org/tools/pub/dependencies
5.插件
通过包可以复用模块化代码,一个最小的Package包括:
- 一个
pubspec.yaml
文件:声明了Package的名称、版本、作者等的元数据文件。 - 一个
lib
文件夹:包括包中公开的(public)代码,最少应有一个<package-name>.dart
文件
Flutter 包分为两类:
- Dart包:其中一些可能包含Flutter的特定功能,因此对Flutter框架具有依赖性,这种包仅用于Flutter,例如
fluro
(opens new window)包。 - 插件包:一种专用的Dart包,其中包含用Dart代码编写的API,以及针对Android(使用Java或Kotlin)和针对iOS(使用OC或Swift)平台的特定实现,也就是说插件包括原生代码,一个具体的例子是
battery
(opens new window)插件包。
Flutter 本质上只是一个 UI 框架,运行在宿主平台之上,Flutter 本身是无法提供一些系统能力,比如使用蓝牙、相机、GPS等,因此要在 Flutter 中调用这些能力就必须和原生平台进行通信。目前Flutter 已经支持 iOS、Android、Web、macOS、Windows、Linux等众多平台,要调用特定平台 API 就需要写插件。插件是一种特殊的包,和纯 dart 包主要区别是插件中除了dart代码,还包括特定平台的代码,比如 image_picker 插件可以在 iOS 和 Android 设备上访问相册和摄像头。
1)实现原理
我们知道一个完整的Flutter应用程序实际上包括原生代码和Flutter代码两部分。Flutter 中提供了平台通道(platform channel)用于Flutter和原生平台的通信,平台通道正是Flutter和原生之间通信的桥梁,它也是Flutter插件的底层基础设施。
Flutter与原生之间的通信本质上是一个远程调用(RPC),通过消息传递实现:
- 应用的Flutter部分通过平台通道(platform channel)将调用消息发送到宿主应用。
- 宿主监听平台通道,并接收该消息。然后它会调用该平台的API,并将响应发送回Flutter。
由于插件编写涉及具体平台的开发知识,比如 image_picker 插件需要开发者在 iOS 和 Android 平台上分别实现图片选取和拍摄的功能,因此需要开发者熟悉原生开发,而本书主要聚焦 Flutter ,因此不做过多介绍,不过插件的开发也并不复杂,感兴趣的读者可以查看官方的插件开发示例 (opens new window)。
2)获取平台信息
有时,在 Flutter 中我们想根据宿主平台添加一些差异化的功能,因此 Flutter 中提供了一个全局变量 defaultTargetPlatform
来获取当前应用的平台信息,defaultTargetPlatform
定义在"platform.dart"中,它的类型是TargetPlatform
,这是一个枚举类,定义如下:
enum TargetPlatform {
android,
fuchsia,
iOS,
...
}
可以看到目前Flutter只支持这三个平台。我们可以通过如下代码判断平台:
if(defaultTargetPlatform == TargetPlatform.android){
// 是安卓系统,do something
...
}
...
由于不同平台有它们各自的交互规范,Flutter Material 库中的一些组件都针对相应的平台做了一些适配,比如路由组件MaterialPageRoute
,它在 android 和 ios 中会应用各自平台规范的切换动画。那如果我们想让我们的 APP 在所有平台都表现一致,比如希望在所有平台路由切换动画都按照ios平台一致的左右滑动切换风格该怎么做?Flutter中提供了一种覆盖默认平台的机制,我们可以通过显式指定debugDefaultTargetPlatformOverride
全局变量的值来指定应用平台。比如:
debugDefaultTargetPlatformOverride=TargetPlatform.iOS;
print(defaultTargetPlatform); // 会输出TargetPlatform.iOS
上面代码即使在Android中运行后,Flutter APP 也会认为是当前系统是iOS,Material组件库中所有组件交互方式都会和iOS平台对齐,defaultTargetPlatform
的值也会变为TargetPlatform.iOS
。
3)常用插件
Flutter 官方提供了一系列常用的插件,如访问相机/相册、本地存储、播放视频等,完整列表见:https://github.com/flutter/plugins/tree/master/packages 读者可以自行查看。除了官方维护的插件,Flutter 社区也有不少现成插件,具体读者可以在 https://pub.dev/ 上查找。
四、资源管理
Flutter APP 安装包中会包含代码和 assets(资源)两部分。Assets 会打包到程序安装包中,可在运行时访问。常见类型的 assets 包括静态数据(例如JSON文件)、配置文件、图标和图片等。
1.assets
1)asset 管理
和包管理一样 Flutter 也使用pubspec.yaml
文件来管理应用程序所需的资源,举个例子:
flutter:
assets:
- assets/my_icon.png
- assets/background.png
assets
指定应包含在应用程序中的文件,每个 asset 都通过相对于pubspec.yaml
文件所在的文件系统路径来标识自身的路径。asset
的声明顺序无关紧要,asset的实际目录可以是任意文件夹(在示例中是 assets 文件夹)。
在构建期间,Flutter 将 asset 放置到称为 asset bundle 的特殊存档中,应用程序可以在运行时读取它们(但不能修改)。
2)asset变体
不同版本的 asset 可能会显示在不同上下文中。 在pubspec.yaml
的 assets 部分中指定 asset 路径时,构建过程中会在相邻子目录中查找具有相同名称的任何文件。这些文件随后会与指定的 asset 一起被包含在 asset bundle 中。
如应用程序目录中有以下文件:
- …/pubspec.yaml
- …/graphics/my_icon.png
- …/graphics/background.png
- …/graphics/dark/background.png
- …
然后pubspec.yaml
文件中只需包含:
flutter:
assets:
- graphics/background.png
那么这两个graphics/background.png
和graphics/dark/background.png
都将包含在 asset bundle 中。前者被认为是 _main asset_
(主资源),后者被认为是一种变体(variant)。
在选择匹配当前设备分辨率的图片时,Flutter会使用到 asset 变体
2.加载
应用可以通过AssetBundle
对象访问其 asset 。有两种主要方法允许从 Asset bundle 中加载字符串或图片(二进制)文件。
1)加载文本
- 通过
rootBundle
对象加载:每个Flutter应用程序都有一个rootBundle
对象, 通过它可轻松访问主资源包,直接使用package:flutter/services.dart
中全局静态的rootBundle
对象来加载asset即可。 - 通过
DefaultAssetBundle
加载:建议使用DefaultAssetBundle
来获取当前 BuildContext 的AssetBundle。 这种方法不是使用应用程序构建的默认 asset bundle,而是使父级 widget 在运行时动态替换的不同的 AssetBundle,这对于本地化或测试场景很有用。
通常可使用DefaultAssetBundle.of()
在应用运行时来间接加载 asset(例如JSON文件),而在widget 上下文之外,或其他AssetBundle
句柄不可用时,可以使用rootBundle
直接加载这些 asset,例如:
import 'dart:async' show Future;
import 'package:flutter/services.dart' show rootBundle;
Future<String> loadAsset() async {
return await rootBundle.loadString('assets/config.json');
}
2)加载图片
Flutter也可为当前设备加载适合其分辨率的图像。
声明分辨率
声明分辨率相关的图片 assets
AssetImage
可将 asset 的请求逻辑映射到最接近当前设备像素比例(dpi)的asset。前提必须根据特定目录结构来保存asset:
…/image.png
…/Mx/image.png
…/Nx/image.png
…
其中 M 和 N 是数字标识符,对应其中包含的图像的分辨率,它们指定不同设备像素比例的图片。如下主资源默认对应于1.0倍的分辨率图片:
…/my_icon.png
…/2.0x/my_icon.png
…/3.0x/my_icon.png
在设备像素比率为1.8的设备上,.../2.0x/my_icon.png
将被选择。对于2.7的设备像素比率,.../3.0x/my_icon.png
将被选择。
如果未在Image
widget上指定渲染图像的宽度和高度,那么Image
widget将占用与主资源相同的屏幕空间大小。 既若.../my_icon.png
是72px乘72px,那么.../3.0x/my_icon.png
应该是216px乘216px;但如果未指定宽度和高度,它们都将渲染为72像素×72像素(以逻辑像素为单位)。
pubspec.yaml
中asset部分中的每一项都应与实际文件相对应,但主资源项除外。当主资源缺少某个资源时,会按分辨率从低到高的顺序去选择 ,也就是说1x中没有的话会在2x中找,2x中还没有的话就在3x中找。
AssetImage
要加载图片,可使用 AssetImage
类。如可从上面的asset声明中加载背景图片:
Widget build(BuildContext context) {
return DecoratedBox(
decoration: BoxDecoration(
image: DecorationImage(
image: AssetImage('graphics/background.png'),
),
),
);
}
AssetImage
不是一个 widget 而是是一个ImageProvider
,要直接得到一个显示图片的widget,可使用Image.asset()
方法:
Widget build(BuildContext context) {
return Image.asset('graphics/background.png');
}
使用默认的 asset bundle 加载资源时内部会自动处理分辨率等,这些处理对开发者来说是无感知的。 (如果使用一些更低级别的类,如 ImageStream
或 ImageCache
时你会注意到有与缩放相关的参数)
依赖包图片
依赖包中的资源图片
要加载依赖包中的图像必须给AssetImage
提供package
参数。
如假设依赖于一个名为“my_icons”的包,它具有如下目录结构:
- …/pubspec.yaml
- …/icons/heart.png
- …/icons/1.5x/heart.png
- …/icons/2.0x/heart.png
- …
然后加载图像,使用:
AssetImage('icons/heart.png', package: 'my_icons')
// 或
Image.asset('icons/heart.png', package: 'my_icons')
注意:包在使用本身的资源时也应该加上
package
参数来获取。
打包包assets
如果在pubspec.yaml
文件中声明了期望的资源,它将会打包到相应的package中。特别是,包本身使用的资源必须在pubspec.yaml
中指定。
包也可选择在其lib/
文件夹中包含未在其pubspec.yaml
文件中声明的资源。在这种情况下,对于要打包的图片,应用程序必须在pubspec.yaml
中指定包含哪些图像。 例如,一个名为“fancy_backgrounds”的包,可能包含以下文件:
- …/lib/backgrounds/background1.png
- …/lib/backgrounds/background2.png
- …/lib/backgrounds/background3.png
要包含第一张图像,必须在pubspec.yaml
的assets部分中声明它:
flutter:
assets:
- packages/fancy_backgrounds/backgrounds/background1.png
lib/
是隐含的,所以它不应该包含在路径中。
3)特定平台assets
要给应用设置APP图标或添加启动图必须使用特定平台的assets。
设置APP图标
更新Flutter应用程序启动图标的方式与在本机Android或iOS应用程序中更新启动图标的方式相同。
-
Android
在 Flutter 项目的根目录中,导航到
.../android/app/src/main/res
目录,里面包含了各种资源文件夹(如mipmap-hdpi
已包含占位符图像 “ic_launcher.png”,见图2-15)。 只需按照Android开发人员指南中的说明, 将其替换为所需的资源,并遵守每种屏幕密度(dpi)的建议图标大小标准。注意: 如果重命名.png文件,还须在您
AndroidManifest.xml
的<application>
标签的android:icon
属性中更新名称。 -
iOS
在Flutter项目的根目录中,导航到
.../ios/Runner
。该目录中Assets.xcassets/AppIcon.appiconset
已经包含占位符图片(见图2-16), 只需将它们替换为适当大小的图片,保留原始文件名称。
更新启动页
在 Flutter 框架加载时,Flutter 会使用本地平台机制绘制启动页。此启动页将持续到Flutter渲染应用程序的第一帧时。
注意: 这意味着如果您不在应用程序的
main()
方法中调用runApp函数 (或者更具体地说,如果您不调用window.render
去响应window.onDrawFrame
的话, 启动屏幕将永远持续显示。
- Android
要将启动屏幕(splash screen)添加到您的Flutter应用程序, 请导航至.../android/app/src/main
。在res/drawable/launch_background.xml
,通过自定义drawable来实现自定义启动界面(你也可以直接换一张图片)。
- iOS
要将图片添加到启动屏幕(splash screen)的中心,请导航至.../ios/Runner
。在Assets.xcassets/LaunchImage.imageset
, 拖入图片,并命名为LaunchImage.png
、LaunchImage@2x.png
、LaunchImage@3x.png
。 如果你使用不同的文件名,那您还必须更新同一目录中的Contents.json
文件,图片的具体尺寸可以查看苹果官方的标准。
您也可以通过打开Xcode完全自定义storyboard。在Project Navigator中导航到Runner/Runner
然后通过打开Assets.xcassets
拖入图片,或者通过在LaunchScreen.storyboard中使用Interface Builder进行自定义,如图2-18所示。
3.平台共享assets
如果我们采用的是Flutter+原生的开发模式,那么可能会存Flutter和原生需要共享资源的情况,比如Flutter项目中已经有了一张图片A,如果原生代码中也要使用A,我们可以将A拷贝一份到原生项目的特定目录,这样的话虽然功能可以实现,但是最终的应用程序包会变大,因为包含了重复的资源,为了解决这个问题,Flutter 提供了一种Flutter和原生之间共享资源的方式,由于实现上需要涉及平台相关的原生代码,故本书不做展开,读者有需要可以自行查阅官方文档。
五、Flutter Web
Flutter 目前已经支持macOS、Windows、Linux、Android、iOS、Web等多个平台这些平台中除了Web平台会比较特殊一些,因为除了它其余的“平台”都是操作系统,而 Web 并不是操作系统,Web应用程序是运行在浏览器中的,而浏览器是运行在操作系统之上,因此 “平台”一词,指的是某种“运行环境”,并不等同于“操作系统”,浏览器和操作系统都是应用程序运行的环境而已。
传统的 Web 应用都是基于 Javascript+html+css 开发的,运行在浏览器之上,因此天然具备跨平台优势,而 Flutter 的目标是高性能的跨端 UI 框架,所以支持 Web 平台将有助于 Flutter 技术扩大应用场景,实现 write once, run anywhere(一次编码,到处运行)。为此,Flutter 团队从 1.0 开始一直在尝试让 Flutter 支持 Web 平台,第一个支持 Web 平台的稳定版是 2.0 ,在 2.0 之后 Flutter 对 Web 平台的支持也一直在优化,现在也有一些公司将Flutter应用应用到生产环境。
1.Web 应用的特殊性
因为 Web 应用是在浏览器中运行的,而浏览器是运行在操作系统之上,因此Web应用不能直接调用操作系统 API, Web 应用能调用哪些操作系统能力取决于它的宿主-浏览器是否暴露相关的操作系统 API。而浏览器出于安全考虑,会提供一个沙箱环境——开放一些安全、可控的系统能力,同时限制一部分敏感的操作,具体表现在:
- 浏览器允许Web应用访问网络,但有严格的“同源策略”限制。
- 浏览器允许 JavaScript 读取用户手动选择本地文件(文件上传场景),但不允许 JavaScript 主动访问本地文件系统,同时在任何情况下,浏览器都不允许 JavaScript 直接往本地文件系统写文件,因此
dart:io
包在 Web 应用是不能用的。 - 浏览器对Web应用访问系统硬件权限有自身策略,比如访问 wifi、gps、摄像头等。
因此,如果用 Flutter 开发 Web 应用,以上这些限制将会生效,所以会出现和其他平台不一致的情况,常见的两个场景是:不能在 Web 应用中发起非同源请求、不能在Web应用中直接读取文件。
“同源策略” 是浏览器处于安全考虑对 Web 应用访问网络的一套限制策略, “同源”表示一个网页中 JavaScript 发起网络请求的地址和当前网页地址中协议、域名、端口全部相同,如果有其中之一不同,则为“非同源”,如果不进行特殊处理,浏览器会禁止非同源请求。关于“同源策略”的详细内容以及如何访问非同源请求读者可以自己上网搜索,这在 Web 开发中是一个非常基础的知识点,网上资料很多,不再赘述。
2.Web 渲染器
Flutter 中提供了两种不同的渲染器来运行和构建 Web 应用,分别是 html 渲染器和 CanvasKit 渲染器。
1)Html渲染器
由于浏览器有一套自身的布局标准( html+css ),Flutter在生成Web应用时可以编译为符合浏览器标准的文件,包括使用 HTML,CSS,Canvas 和 SVG 元素来渲染。
使用Html渲染器的优点是应用体积相对较小,缺点是使用Html渲染器时大多数 UI 并不是 Flutter 引擎绘制的,所以可能会存在跨浏览器跨时UI出现不一致的情况。
2)CanvasKit 渲染器
我们知道 Flutter 的优势是提供一套自绘的UI框架,可以保证多端UI的一致性。Flutter 在支持其他平台时,都是将引擎的C++代码编译为相应平台的代码来实现移植的(运行在操作系统之上)。但是在 Web 平台,Web 应用是运行在浏览器之上,而现代浏览器都实现了对 WebAssembly 的支持,简单来讲,在之前W3C规范中只要求浏览器能够支持 JavaScript 语言,这样的话很多其他语言的代码想在浏览器中运行就必须改写为 JavaScript,而 WebAssembly 是一种标准的、可移植的二进制文件格式规范,文件扩展名为 .wasm,现在浏览器都支持 WebAssembly ,这也就意味着其他语言按照 WebAssembly 规范编译的应用可以在浏览器中运行!因此,Flutter 将引擎编译成 WebAssembly 格式,并使用 WebGL 渲染,这种渲染方式的渲染器官方称为 CanvasKit 渲染器。
CanvasKit 渲染器的优点是可以保证跨端UI绘制的一致性,有更好的性能,以及降低不同浏览器渲染效果不一致的风险。但缺点是应用的大小会增加大约 2MB。
3.在浏览器中运行
1)命令行参数
--web-renderer
可选参数值为 auto
、html
或 canvaskit
。
auto
(默认)- 自动选择渲染器。移动端浏览器选择 HTML,桌面端浏览器选择 CanvasKit。html
- 强制使用 HTML 渲染器。canvaskit
- 强制使用 CanvasKit 渲染器。
此选项适用于 run
和 build
命令。例如:
flutter run -d chrome --web-renderer html
flutter build web --web-renderer canvaskit
如果运行/构建目标是非浏览器设备(即移动设备或桌面设备),这个选项会被忽略。
4.Flutter Web 使用场景
Web 开发已有完整且强大的开发及生态体系,Flutter Web并不适用Web开发的所有场景,目前Flutter Web 主要关注以下三个应用场景:
- 渐进式 Web 应用 (Progressive web apps, PWA)。
- 单页应用 (Single page apps, SPA),• 一般一个应用只有一个html文件,只需一次加载,后续与服务端动态互传数据。
- 将现有 Flutter 移动应用拓展到 Web,在两个平台共享代码。
注意:PWA 和 SPA 应用在 Web开发中是两种基本的应用类型,Web开发者会比较熟悉,如果读者不了解可以自行百度,不再赘述。
现在阶段,Flutter 对于富文本和瀑布流类型的 Web 页面并不是很适合,例如博客,它是典型的“以文档为中心”的模式,而不是像 Flutter 这样的 UI 框架可以提供的“以应用为中心”的服务。以文档为中心的应用通常各个页面之间相互独立,很少有关联,也就不需要跨页面的状态共享,而以应用为中心的服务,通常各个页面之间是有状态关联,不同页面组成一个完整的功能。
最后,有关如何在 Web 上使用 Flutter 的更多信息请参考 Flutter官方文档 (opens new window)。
存在跨浏览器跨时UI出现不一致的情况。
2)CanvasKit 渲染器
我们知道 Flutter 的优势是提供一套自绘的UI框架,可以保证多端UI的一致性。Flutter 在支持其他平台时,都是将引擎的C++代码编译为相应平台的代码来实现移植的(运行在操作系统之上)。但是在 Web 平台,Web 应用是运行在浏览器之上,而现代浏览器都实现了对 WebAssembly 的支持,简单来讲,在之前W3C规范中只要求浏览器能够支持 JavaScript 语言,这样的话很多其他语言的代码想在浏览器中运行就必须改写为 JavaScript,而 WebAssembly 是一种标准的、可移植的二进制文件格式规范,文件扩展名为 .wasm,现在浏览器都支持 WebAssembly ,这也就意味着其他语言按照 WebAssembly 规范编译的应用可以在浏览器中运行!因此,Flutter 将引擎编译成 WebAssembly 格式,并使用 WebGL 渲染,这种渲染方式的渲染器官方称为 CanvasKit 渲染器。
CanvasKit 渲染器的优点是可以保证跨端UI绘制的一致性,有更好的性能,以及降低不同浏览器渲染效果不一致的风险。但缺点是应用的大小会增加大约 2MB。
3.在浏览器中运行
1)命令行参数
--web-renderer
可选参数值为 auto
、html
或 canvaskit
。
auto
(默认)- 自动选择渲染器。移动端浏览器选择 HTML,桌面端浏览器选择 CanvasKit。html
- 强制使用 HTML 渲染器。canvaskit
- 强制使用 CanvasKit 渲染器。
此选项适用于 run
和 build
命令。例如:
flutter run -d chrome --web-renderer html
flutter build web --web-renderer canvaskit
如果运行/构建目标是非浏览器设备(即移动设备或桌面设备),这个选项会被忽略。
4.Flutter Web 使用场景
Web 开发已有完整且强大的开发及生态体系,Flutter Web并不适用Web开发的所有场景,目前Flutter Web 主要关注以下三个应用场景:
- 渐进式 Web 应用 (Progressive web apps, PWA)。
- 单页应用 (Single page apps, SPA),• 一般一个应用只有一个html文件,只需一次加载,后续与服务端动态互传数据。
- 将现有 Flutter 移动应用拓展到 Web,在两个平台共享代码。
注意:PWA 和 SPA 应用在 Web开发中是两种基本的应用类型,Web开发者会比较熟悉,如果读者不了解可以自行百度,不再赘述。
现在阶段,Flutter 对于富文本和瀑布流类型的 Web 页面并不是很适合,例如博客,它是典型的“以文档为中心”的模式,而不是像 Flutter 这样的 UI 框架可以提供的“以应用为中心”的服务。以文档为中心的应用通常各个页面之间相互独立,很少有关联,也就不需要跨页面的状态共享,而以应用为中心的服务,通常各个页面之间是有状态关联,不同页面组成一个完整的功能。
最后,有关如何在 Web 上使用 Flutter 的更多信息请参考 Flutter官方文档 (opens new window)。