2.1 计数器应用示例
用Android Studio和VS Code创建的Flutter应用模板默认是一个简单的计数器示例。本节先仔细讲解一下这个计数器Demo的源码,让读者对Flutter应用程序结构有个基本了解,然后在随后的小节中将会基于此示例,一步一步添加一些新的功能来介绍Flutter应用的其它概念与技术。
对于接下来的示例,希望读者可以跟着笔者一起亲自动手来写一下,这样不仅可以加深印象,而且也会对介绍的概念与技术有一个真切的体会。如果你还不是很熟悉Dart语言或者没有移动开发经验,不用担心,只要你熟悉面向对象和基本编程概念(如变量、循环和条件控制),则可以完成本示例。
2.1.1 创建Flutter应用模板
通过Android Studio或VS Code创建一个新的Flutter工程,命名为”first_flutter_app”。创建好后,就会得到一个计数器应用的Demo。
注意,默认Demo示例可能随着编辑器Flutter插件的版本变化而变化,本例中会介绍计数器示例的全部代码,所以不会对本示例产生影响。
我们先运行创建的工程,效果如图2-1所示:
该计数器示例中,每点击一次右下角带“+”号的悬浮按钮,屏幕中央的数字就会加1。
在这个示例中,主要Dart代码是在 lib/main.dart 文件中,下面是它的源码:
import 'package:flutter/material.dart';
void main() => runApp(new MyApp());
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return new MaterialApp(
title: 'Flutter Demo',
theme: new ThemeData(
primarySwatch: Colors.blue,
),
home: new MyHomePage(title: 'Flutter Demo Home Page'),
);
}
}
class MyHomePage extends StatefulWidget {
MyHomePage({Key key, this.title}) : super(key: key);
final String title;
@override
_MyHomePageState createState() => new _MyHomePageState();
}
class _MyHomePageState extends State<MyHomePage> {
int _counter = 0;
void _incrementCounter() {
setState(() {
_counter++;
});
}
@override
Widget build(BuildContext context) {
return new Scaffold(
appBar: new AppBar(
title: new Text(widget.title),
),
body: new Center(
child: new Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
new Text(
'You have pushed the button this many times:',
),
new Text(
'$_counter',
style: Theme.of(context).textTheme.headline4,
),
],
),
),
floatingActionButton: new FloatingActionButton(
onPressed: _incrementCounter,
tooltip: 'Increment',
child: new Icon(Icons.add),
), // This trailing comma makes auto-formatting nicer for build methods.
);
}
}
分析
导入包。
import 'package:flutter/material.dart';
此行代码作用是导入了Material UI组件库。Material是一种标准的移动端和web端的视觉设计语言, Flutter默认提供了一套丰富的Material风格的UI组件。
应用入口。
void main() => runApp(MyApp());
- 与C/C++、Java类似,Flutter 应用中
main
函数为应用程序的入口。main
函数中调用了runApp
方法,它的功能是启动Flutter应用。runApp
它接受一个Widget
参数,在本示例中它是一个MyApp
对象,MyApp()
是Flutter应用的根组件。 main
函数使用了(=>
)符号,这是Dart中单行函数或方法的简写。
应用结构。
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return new MaterialApp(
//应用名称
title: 'Flutter Demo',
theme: new ThemeData(
//蓝色主题
primarySwatch: Colors.blue,
),
//应用首页路由
home: new MyHomePage(title: 'Flutter Demo Home Page'),
);
}
}
MyApp
类代表Flutter应用,它继承了StatelessWidget
类,这也就意味着应用本身也是一个widget。- 在Flutter中,大多数东西都是widget(后同“组件”或“部件”),包括对齐(alignment)、填充(padding)和布局(layout)等,它们都是以widget的形式提供。
- Flutter在构建页面时,会调用组件的
build
方法,widget的主要工作是提供一个build()方法来描述如何构建UI界面(通常是通过组合、拼装其它基础widget)。 MaterialApp
是Material库中提供的Flutter APP框架,通过它可以设置应用的名称、主题、语言、首页及路由列表等。MaterialApp
也是一个widget。home
为Flutter应用的首页,它也是一个widget。
2.1.2 首页
class MyHomePage extends StatefulWidget {
MyHomePage({Key key, this.title}) : super(key: key);
final String title;
@override
_MyHomePageState createState() => new _MyHomePageState();
}
class _MyHomePageState extends State<MyHomePage> {
...
}
MyHomePage
是Flutter应用的首页,它继承自StatefulWidget
类,表示它是一个有状态的组件(Stateful widget)。关于Stateful widget我们将在第三章“Widget简介”一节仔细介绍,现在我们只需简单认为有状态的组件(Stateful widget) 和无状态的组件(Stateless widget)有两点不同:
Stateful widget可以拥有状态,这些状态在widget生命周期中是可以变的,而Stateless widget是不可变的。
- Stateful widget至少由两个类组成:
- 一个
StatefulWidget
类。 - 一个
State
类;StatefulWidget
类本身是不变的,但是State
类中持有的状态在widget生命周期中可能会发生变化。
- 一个
_MyHomePageState
类是MyHomePage
类对应的状态类。看到这里,读者可能已经发现:和MyApp
类不同,MyHomePage
类中并没有build
方法,取而代之的是,build
方法被挪到了_MyHomePageState
方法中,至于为什么这么做,先留个疑问,在分析完完整代码后再来解答。
State类
接下来,我们看看
_MyHomePageState
中都包含哪些东西:该组件的状态。由于我们只需要维护一个点击次数计数器,所以定义一个
_counter
状态:int _counter = 0; //用于记录按钮点击的总次数
_counter
为保存屏幕右下角带“+”号按钮点击次数的状态。设置状态的自增函数。
void _incrementCounter() {
setState(() {
_counter++;
});
}
当按钮点击时,会调用此函数,该函数的作用是先自增
_counter
,然后调用setState
方法。setState
方法的作用是通知Flutter框架,有状态发生了改变,Flutter框架收到通知后,会执行build
方法来根据新的状态重新构建界面, Flutter 对此方法做了优化,使重新执行变的很快,所以你可以重新构建任何需要更新的东西,而无需分别去修改各个widget。构建UI界面构建UI界面的逻辑在
build
方法中,当MyHomePage
第一次创建时,_MyHomePageState
类会被创建,当初始化完成后,Flutter框架会调用Widget的build
方法来构建widget树,最终将widget树渲染到设备屏幕上。所以,我们看看_MyHomePageState
的build
方法中都干了什么事:Widget build(BuildContext context) {
return new Scaffold(
appBar: new AppBar(
title: new Text(widget.title),
),
body: new Center(
child: new Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
new Text(
'You have pushed the button this many times:',
),
new Text(
'$_counter',
style: Theme.of(context).textTheme.headline4,
),
],
),
),
floatingActionButton: new FloatingActionButton(
onPressed: _incrementCounter,
tooltip: 'Increment',
child: new Icon(Icons.add),
),
);
}
Scaffold
是 Material 库中提供的页面脚手架,它提供了默认的导航栏、标题和包含主屏幕widget树(后同“组件树”或“部件树”)的body
属性,组件树可以很复杂。本书后面示例中,路由默认都是通过Scaffold
创建。body
的组件树中包含了一个Center
组件,Center
可以将其子组件树对齐到屏幕中心。此例中,Center
子组件是一个Column
组件,Column
的作用是将其所有子组件沿屏幕垂直方向依次排列; 此例中Column
子组件是两个Text
,第一个Text
显示固定文本 “You have pushed the button this many times:”,第二个Text
显示_counter
状态的数值。floatingActionButton
是页面右下角的带“+”的悬浮按钮,它的onPressed
属性接受一个回调函数,代表它被点击后的处理器,本例中直接将_incrementCounter
方法作为其处理函数。
现在,我们将整个计数器执行流程串起来:当右下角的floatingActionButton
按钮被点击之后,会调用_incrementCounter
方法。在_incrementCounter
方法中,首先会自增_counter
计数器(状态),然后setState
会通知Flutter框架状态发生变化,接着,Flutter框架会调用build
方法以新的状态重新构建UI,最终显示在设备屏幕上。
为什么要将build方法放在State中,而不是放在StatefulWidget中?
现在,我们回答之前提出的问题,为什么build()
方法放在State(而不是StatefulWidget
)中 ?这主要是为了提高开发的灵活性。如果将build()
方法放在StatefulWidget
中则会有两个问题:
状态访问不便
试想一下,如果我们的StatefulWidget
有很多状态,而每次状态改变都要调用build
方法,由于状态是保存在State中的,如果build
方法在StatefulWidget
中,那么build
方法和状态分别在两个类中,那么构建时读取状态将会很不方便!试想一下,如果真的将build
方法放在StatefulWidget中的话,由于构建用户界面过程需要依赖State,所以build
方法将必须加一个State
参数,大概是下面这样:Widget build(BuildContext context, State state){
//state.counter
...
}
这样的话就只能将State的所有状态声明为公开的状态,这样才能在State类外部访问状态!但是,将状态设置为公开后,状态将不再具有私密性,这就会导致对状态的修改将会变的不可控。但如果将
build()
方法放在State中的话,构建过程不仅可以直接访问状态,而且也无需公开私有状态,这会非常方便。继承
StatefulWidget
不便例如,Flutter中有一个动画widget的基类AnimatedWidget
,它继承自StatefulWidget
类。AnimatedWidget
中引入了一个抽象方法build(BuildContext context)
,继承自AnimatedWidget
的动画widget都要实现这个build
方法。现在设想一下,如果StatefulWidget
类中已经有了一个build
方法,正如上面所述,此时build
方法需要接收一个state对象,这就意味着AnimatedWidget
必须将自己的State对象(记为_animatedWidgetState)提供给其子类,因为子类需要在其build
方法中调用父类的build
方法,代码可能如下:class MyAnimationWidget extends AnimatedWidget{
@override
Widget build(BuildContext context, State state){
//由于子类要用到AnimatedWidget的状态对象_animatedWidgetState,
//所以AnimatedWidget必须通过某种方式将其状态对象_animatedWidgetState
//暴露给其子类
super.build(context, _animatedWidgetState)
}
}
这样很显然是不合理的,因为
AnimatedWidget
的状态对象是AnimatedWidget
内部实现细节,不应该暴露给外部。- 如果要将父类状态暴露给子类,那么必须得有一种传递机制,而做这一套传递机制是无意义的,因为父子类之间状态的传递和子类本身逻辑是无关的。
综上所述,可以发现,对于StatefulWidget
,将build
方法放在State中,可以给开发带来很大的灵活性。
2.2 路由管理
路由(Route)在移动开发中通常指页面(Page),这跟web开发中单页应用的Route概念意义是相同的,Route在Android中通常指一个Activity,在iOS中指一个ViewController。所谓路由管理,就是管理页面之间如何跳转,通常也可被称为导航管理。Flutter中的路由管理和原生开发类似,无论是Android还是iOS,导航管理都会维护一个路由栈,路由入栈(push)操作对应打开一个新页面,路由出栈(pop)操作对应页面关闭操作,而路由管理主要是指如何来管理路由栈。
2.2.1 一个简单示例
我们在上一节“计数器”示例的基础上,做如下修改:
创建一个新路由,命名“NewRoute”
class NewRoute extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text("New route"),
),
body: Center(
child: Text("This is new route"),
),
);
}
}
新路由继承自
StatelessWidget
,界面很简单,在页面中间显示一句”This is new route”。在
_MyHomePageState.build
方法中的Column
的子widget中添加一个按钮(FlatButton
) :Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
... //省略无关代码
FlatButton(
child: Text("open new route"),
textColor: Colors.blue,
onPressed: () {
//导航到新路由
Navigator.push( context,
MaterialPageRoute(builder: (context) {
return NewRoute();
}));
},
),
],
)
我们添加了一个打开新路由的按钮,并将按钮文字颜色设置为蓝色,点击该按钮后就会打开新的路由页面,效果如图2-2和2-3所示。
2.2.2 MaterialPageRoute
MaterialPageRoute
继承自PageRoute
类,PageRoute
类是一个抽象类,表示占有整个屏幕空间的一个模态路由页面,它还定义了路由构建及切换时过渡动画的相关接口及属性。MaterialPageRoute
是Material组件库提供的组件,它可以针对不同平台,实现与平台页面切换动画风格一致的路由切换动画:
- 对于Android,当打开新页面时,新的页面会从屏幕底部滑动到屏幕顶部;当关闭页面时,当前页面会从屏幕顶部滑动到屏幕底部后消失,同时上一个页面会显示到屏幕上。
- 对于iOS,当打开页面时,新的页面会从屏幕右侧边缘一致滑动到屏幕左边,直到新页面全部显示到屏幕上,而上一个页面则会从当前屏幕滑动到屏幕左侧而消失;当关闭页面时,正好相反,当前页面会从屏幕右侧滑出,同时上一个页面会从屏幕左侧滑入。
下面我们介绍一下MaterialPageRoute
构造函数的各个参数的意义:
MaterialPageRoute({
WidgetBuilder builder,
RouteSettings settings,
bool maintainState = true,
bool fullscreenDialog = false,
})
builder
是一个WidgetBuilder类型的回调函数,它的作用是构建路由页面的具体内容,返回值是一个widget。我们通常要实现此回调,返回新路由的实例。settings
包含路由的配置信息,如路由名称、是否初始路由(首页)。maintainState
:默认情况下,当入栈一个新路由时,原来的路由仍然会被保存在内存中,如果想在路由没用的时候释放其所占用的所有资源,可以设置maintainState
为false。fullscreenDialog
表示新的路由页面是否是一个全屏的模态对话框,在iOS中,如果fullscreenDialog
为true
,新页面将会从屏幕底部滑入(而不是水平方向)。如果想自定义路由切换动画,可以自己继承PageRoute来实现,我们将在后面介绍动画时,实现一个自定义的路由组件。
2.2.3 Navigator
Navigator
是一个路由管理的组件,它提供了打开和退出路由页方法。Navigator
通过一个栈来管理活动路由集合。通常当前屏幕显示的页面就是栈顶的路由。Navigator
提供了一系列方法来管理路由栈,在此我们只介绍其最常用的两个方法:
Future push(BuildContext context, Route route)
将给定的路由入栈(即打开新的页面),返回值是一个Future
对象,用以接收新路由出栈(即关闭)时的返回数据。
bool pop(BuildContext context, [ result ])
将栈顶路由出栈,result
为页面关闭时返回给上一个页面的数据。Navigator
还有很多其它方法,如Navigator.replace
、Navigator.popUntil
等,详情请参考API文档或SDK源码注释,在此不再赘述。下面我们还需要介绍一下路由相关的另一个概念“命名路由”。
实例方法
Navigator类中第一个参数为context的静态方法都对应一个Navigator的实例方法, 比如Navigator.push(BuildContext context, Route route)
等价于Navigator.of(context).push(Route route)
,下面命名路由相关的方法也是一样的。
2.2.4 路由传值
很多时候,在路由跳转时我们需要带一些参数,比如打开商品详情页时,我们需要带一个商品id,这样商品详情页才知道展示哪个商品信息;又比如我们在填写订单时需要选择收货地址,打开地址选择页并选择地址后,可以将用户选择的地址返回到订单页等等。下面我们通过一个简单的示例来演示新旧路由如何传参。
示例
我们创建一个TipRoute
路由,它接受一个提示文本参数,负责将传入它的文本显示在页面上,另外TipRoute
中我们添加一个“返回”按钮,点击后在返回上一个路由的同时会带上一个返回参数,下面我们看一下实现代码。TipRoute
实现代码:
class TipRoute extends StatelessWidget {
TipRoute({
Key key,
@required this.text, // 接收一个text参数
}) : super(key: key);
final String text;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text("提示"),
),
body: Padding(
padding: EdgeInsets.all(18),
child: Center(
child: Column(
children: <Widget>[
Text(text),
RaisedButton(
onPressed: () => Navigator.pop(context, "我是返回值"),
child: Text("返回"),
)
],
),
),
),
);
}
}
下面是打开新路由TipRoute
的代码:
class RouterTestRoute extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Center(
child: RaisedButton(
onPressed: () async {
// 打开`TipRoute`,并等待返回结果
var result = await Navigator.push(
context,
MaterialPageRoute(
builder: (context) {
return TipRoute(
// 路由参数
text: "我是提示xxxx",
);
},
),
);
//输出`TipRoute`路由返回结果
print("路由返回值: $result");
},
child: Text("打开提示页"),
),
);
}
}
运行上面代码,点击RouterTestRoute
页的“打开提示页”按钮,会打开TipRoute
页,运行效果如图2-4所示下:
需要说明:
- 提示文案“我是提示xxxx”是通过
TipRoute
的text
参数传递给新路由页的。我们可以通过等待Navigator.push(…)
返回的Future
来获取新路由的返回数据。 - 在
TipRoute
页中有两种方式可以返回到上一页;第一种方式时直接点击导航栏返回箭头,第二种方式是点击页面中的“返回”按钮。这两种返回方式的区别是前者不会返回数据给上一个路由,而后者会。下面是分别点击页面中的返回按钮和导航栏返回箭头后,RouterTestRoute
页中print
方法在控制台输出的内容:
上面介绍的是非命名路由的传值方式,命名路由的传值方式会有所不同,我们会在下面介绍命名路由时介绍。I/flutter (27896): 路由返回值: 我是返回值
I/flutter (27896): 路由返回值: null
2.2.5 命名路由
所谓“命名路由”(Named Route)即有名字的路由,我们可以先给路由起一个名字,然后就可以通过路由名字直接打开新的路由了,这为路由管理带来了一种直观、简单的方式。路由表
要想使用命名路由,我们必须先提供并注册一个路由表(routing table),这样应用程序才知道哪个名字与哪个路由组件相对应。其实注册路由表就是给路由起名字,路由表的定义如下:
它是一个Map<String, WidgetBuilder> routes;
Map
,key为路由的名字,是个字符串;value是个builder
回调函数,用于生成相应的路由widget。我们在通过路由名字打开新路由时,应用会根据路由名字在路由表中查找到对应的WidgetBuilder
回调函数,然后调用该回调函数生成路由widget并返回。注册路由表
路由表的注册方式很简单,我们回到之前“计数器”的示例,然后在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
路由并没有使用命名路由,如果我们也想将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
属性值即可,该属性决定应用的初始路由页是哪一个命名路由。通过路由名打开新路由页
要通过路由名称来打开新路由,可以使用Navigator
的pushNamed
方法:Future pushNamed(BuildContext context, String routeName,{Object arguments})
Navigator
除了pushNamed
方法,还有pushReplacementNamed
等其他管理命名路由的方法,读者可以自行查看API文档。接下来我们通过路由名来打开新的路由页,修改FlatButton
的onPressed
回调代码,改为:
热重载应用,再次点击“open new route”按钮,依然可以打开新的路由页。onPressed: () {
Navigator.pushNamed(context, "new_page");
//Navigator.push(context,
// MaterialPageRoute(builder: (context) {
// return NewRoute();
//}));
},
命名路由参数传递
在Flutter最初的版本中,命名路由是不能传递参数的,后来才支持了参数;下面展示命名路由如何传递并获取路由参数:
我们先注册一个路由:
在路由页通过routes:{
"new_page":(context) => EchoRoute(),
} ,
RouteSetting
对象获取路由参数:
在打开路由时传递参数class EchoRoute extends StatelessWidget {
@override
Widget build(BuildContext context) {
//获取路由参数
var args=ModalRoute.of(context).settings.arguments;
//...省略无关代码
}
}
Navigator.of(context).pushNamed("new_page", arguments: "hi");
适配
假设我们也想将上面路由传参示例中的TipRoute
路由页注册到路由表中,以便也可以通过路由名来打开它。但是,由于TipRoute
接受一个text
参数,我们如何在不改变TipRoute
源码的前提下适配这种情况?其实很简单:MaterialApp(
... //省略无关代码
routes: {
"tip2": (context){
return TipRoute(text: ModalRoute.of(context).settings.arguments);
},
},
);
2.2.6 路由生成钩子
假设我们要开发一个电商APP,当用户没有登录时可以看店铺、商品等信息,但交易记录、购物车、用户个人信息等页面需要登录后才能看。为了实现上述功能,我们需要在打开每一个路由页前判断用户登录状态!如果每次打开路由前我们都需要去判断一下将会非常麻烦,那有什么更好的办法吗?答案是有!MaterialApp
有一个onGenerateRoute
属性,它在打开命名路由时可能会被调用,之所以说可能,是因为当调用Navigator.pushNamed(...)
打开命名路由时,如果指定的路由名在路由表中已注册,则会调用路由表中的builder
函数来生成路由组件;如果路由表中没有注册,才会调用onGenerateRoute
来生成路由。onGenerateRoute
回调签名如下:
有了Route<dynamic> Function(RouteSettings settings)
onGenerateRoute
回调,要实现上面控制页面权限的功能就非常容易:我们放弃使用路由表,取而代之的是提供一个onGenerateRoute
回调,然后在该回调中进行统一的权限控制,如:MaterialApp(
... //省略无关代码
onGenerateRoute:(RouteSettings settings){
return MaterialPageRoute(builder: (context){
String routeName = settings.name;
// 如果访问的路由页需要登录,但当前未登录,则直接返回登录页路由,
// 引导用户登录;其它情况则正常打开路由。
}
);
}
);
注意,
onGenerateRoute
只会对命名路由生效。
2.2.7 总结
本章先介绍了Flutter中路由管理、传参的方式,然后又着重介绍了命名路由相关内容。在此需要说明一点,由于命名路由只是一种可选的路由管理方式,在实际开发中,读者可能心中会犹豫到底使用哪种路由管理方式。在此,根据笔者经验,建议读者最好统一使用命名路由的管理方式,这将会带来如下好处:
- 语义化更明确。
- 代码更好维护;如果使用匿名路由,则必须在调用
Navigator.push
的地方创建新路由页,这样不仅需要import新路由页的dart文件,而且这样的代码将会非常分散。 - 可以通过
onGenerateRoute
做一些全局的路由跳转前置处理逻辑。
综上所述,笔者比较建议使用命名路由,当然这并不是什么金科玉律,读者可以根据自己偏好或实际情况来决定。
另外,还有一些关于路由管理的内容我们没有介绍,比如路由MaterialApp中还有navigatorObservers
和onUnknownRoute
两个回调属性,前者可以监听所有路由跳转动作,后者在打开一个不存在的命名路由时会被调用,由于这些功能并不常用,而且也比较简单,我们便不再花费篇幅来介绍了,读者可以自行查看API文档。
2.3 包管理
在软件开发中,很多时候有一些公共的库或SDK可能会被很多项目用到,因此,将这些代码单独抽到一个独立模块,然后哪个项目需要使用时再直接集成这个模块,便可大大提高开发效率。很多编程语言或开发工具都支持这种“模块共享”机制,如Java语言中这种独立模块会被打成一个jar包,Android中的aar包,Web开发中的npm包等。为了方便表述,我们将这种可共享的独立模块统一称为“包”( Package)。
一个APP在实际开发中往往会依赖很多包,而这些包通常都有交叉依赖关系、版本依赖等,如果由开发者手动来管理应用中的依赖包将会非常麻烦。因此,各种开发生态或编程语言官方通常都会提供一些包管理工具,比如在Android提供了Gradle来管理依赖,iOS用Cocoapods或Carthage来管理依赖,Node中通过npm等。而在Flutter开发中也有自己的包管理工具。本节我们主要介绍一下flutter如何使用配置文件pubspec.yaml
(位于项目根目录)来管理第三方依赖包。
YAML是一种直观、可读性高并且容易被人类阅读的文件格式,它和xml或Json相比,它语法简单并非常容易解析,所以YAML常用于配置文件,Flutter也是用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
:应用或包依赖的其它包或插件。dev_dependencies
:开发环境依赖的工具包(而不是flutter应用本身依赖的包)。flutter
:flutter相关的配置选项。
如果我们的Flutter应用本身依赖某个包,我们需要将所依赖的包添加到dependencies
下,接下来我们通过一个例子来演示一下如何添加、下载并使用第三方包。
Pub仓库
Pub(https://pub.dev/ )是Google官方的Dart Packages仓库,类似于node中的npm仓库,android中的jcenter。我们可以在Pub上面查找我们需要的包和插件,也可以向Pub发布我们的包和插件。我们将在后面的章节中介绍如何向Pub发布我们的包和插件。
示例
接下来,我们实现一个显示随机字符串的widget。有一个名为“english_words”的开源软件包,其中包含数千个常用的英文单词以及一些实用功能。我们首先在pub上找到english_words这个包(如图2-5所示),确定其最新的版本号和是否支持Flutter。
我们看到“english_words”包最新的版本是3.1.3,并且支持flutter,接下来:
将“english_words”(3.1.3版本)添加到依赖项列表,如下:
dependencies:
flutter:
sdk: flutter
cupertino_icons: ^0.1.0
# 新添加的依赖
english_words: ^3.1.3
下载包。在Android Studio的编辑器视图中查看pubspec.yaml时(图2-6),单击右上角的 Packages get 。
这会将依赖包安装到您的项目。我们可以在控制台中看到以下内容:flutter packages get
Running "flutter packages get" in flutter_in_action...
Process finished with exit code 0
我们也可以在控制台,定位到当前工程目录,然后手动运行
flutter packages get
命令来下载依赖包。另外,需要注意dependencies
和dev_dependencies
的区别,前者的依赖包将作为APP的源码的一部分参与编译,生成最终的安装包。而后者的依赖包只是作为开发阶段的一些工具包,主要是用于帮助我们提高开发、测试效率,比如flutter的自动化测试包等。引入
english_words
包。import 'package:english_words/english_words.dart';
在输入时,Android Studio会自动提供有关库导入的建议选项。导入后该行代码将会显示为灰色,表示导入的库尚未使用。
使用
english_words
包来生成随机字符串。class RandomWordsWidget extends StatelessWidget {
@override
Widget build(BuildContext context) {
// 生成随机字符串
final wordPair = new WordPair.random();
return Padding(
padding: const EdgeInsets.all(8.0),
child: new Text(wordPair.toString()),
);
}
}
我们将
RandomWordsWidget
添加到_MyHomePageState.build
的Column
的子widget中。Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
... //省略无关代码
RandomWordsWidget(),
],
)
如果应用程序正在运行,请使用热重载按钮(⚡️图标) 更新正在运行的应用程序。每次单击热重载或保存项目时,都会在正在运行的应用程序中随机选择不同的单词对。 这是因为单词对是在
build
方法内部生成的。每次热更新时,build
方法都会被执行,运行效果如图2-7所示。
其它依赖方式
上文所述的依赖方式是依赖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 。
总结
本节介绍了Flutter中包管理、引用、下载的整体流程,我们将在后面的章节中介绍如何开发并发布我们自己的包。
2.4 资源管理
Flutter APP安装包中会包含代码和 assets(资源)两部分。Assets是会打包到程序安装包中的,可在运行时访问。常见类型的assets包括静态数据(例如JSON文件)、配置文件、图标和图片(JPEG,WebP,GIF,动画WebP / GIF,PNG,BMP和WBMP)等。
指定 assets
和包管理一样,Flutter也使用
pubspec.yaml
文件来管理应用程序所需的资源,举个例子:flutter:
assets:
- assets/my_icon.png
- assets/background.png
assets
指定应包含在应用程序中的文件, 每个asset都通过相对于pubspec.yaml
文件所在的文件系统路径来标识自身的路径。asset的声明顺序是无关紧要的,asset的实际目录可以是任意文件夹(在本示例中是assets文件夹)。
在构建期间,Flutter将asset放置到称为 asset bundle 的特殊存档中,应用程序可以在运行时读取它们(但不能修改)。Asset 变体(variant)
构建过程支持“asset变体”的概念:不同版本的asset可能会显示在不同的上下文中。 在
pubspec.yaml
的assets部分中指定asset路径时,构建过程中,会在相邻子目录中查找具有相同名称的任何文件。这些文件随后会与指定的asset一起被包含在asset bundle中。
例如,如果应用程序目录中有以下文件:…/pubspec.yaml
- …/graphics/my_icon.png
- …/graphics/background.png
- …/graphics/dark/background.png
- …etc.
然后pubspec.yaml
文件中只需包含:
flutter:
assets:
- graphics/background.png
那么这两个graphics/background.png
和graphics/dark/background.png
都将包含在您的asset bundle中。前者被认为是main asset (主资源),后者被认为是一种变体(variant)。
在选择匹配当前设备分辨率的图片时,Flutter会使用到asset变体(见下文),将来,Flutter可能会将这种机制扩展到本地化、阅读提示等方面。
加载 assets
您的应用可以通过AssetBundle
对象访问其asset 。有两种主要方法允许从Asset bundle中加载字符串或图片(二进制)文件。
加载文本assets
- 通过
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');
}
加载图片
类似于原生开发,Flutter也可以为当前设备加载适合其分辨率的图像。
声明分辨率相关的图片 assets
AssetImage
可以将asset的请求逻辑映射到最接近当前设备像素比例(dpi)的asset。为了使这种映射起作用,必须根据特定的目录结构来保存asset:
- …/image.png
- …/Mx/image.png
- …/Nx/image.png
- …etc.
其中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
类。例如,我们可以从上面的asset声明中加载背景图片:
Widget build(BuildContext context) {
return new DecoratedBox(
decoration: new BoxDecoration(
image: new DecorationImage(
image: new 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
- …etc.
然后加载图像,使用:
new AssetImage('icons/heart.png', package: 'my_icons')
或
new 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
特定平台 assets
上面的资源都是flutter应用中的,这些资源只有在Flutter框架运行之后才能使用,如果要给我们的应用设置APP图标或者添加启动图,那我们必须使用特定平台的assets。
设置APP图标
更新Flutter应用程序启动图标的方式与在本机Android或iOS应用程序中更新启动图标的方式相同。
Android
在Flutter项目的根目录中,导航到.../android/app/src/main/res
目录,里面包含了各种资源文件夹(如mipmap-hdpi
已包含占位符图像“ic_launcher.png”,见图2-8)。 只需按照Android开发人员指南中的说明, 将其替换为所需的资源,并遵守每种屏幕密度(dpi)的建议图标大小标准。注意: 如果您重命名.png文件,则还必须在您
AndroidManifest.xml
的<application>
标签的android:icon
属性中更新名称。iOS
在Flutter项目的根目录中,导航到.../ios/Runner
。该目录中Assets.xcassets/AppIcon.appiconset
已经包含占位符图片(见图2-9), 只需将它们替换为适当大小的图片,保留原始文件名称。
更新启动页
在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-11所示。
2.5 调试Flutter应用
Dart 分析器
在运行应用程序前,请运行flutter analyze
测试你的代码。这个工具是一个静态代码检查工具,它是dartanalyzer
工具的一个包装,主要用于分析代码并帮助开发者发现可能的错误,比如,Dart分析器大量使用了代码中的类型注释来帮助追踪问题,避免var
、无类型的参数、无类型的列表文字等。
如果你使用IntelliJ的Flutter插件,那么分析器在打开IDE时就已经自动启用了,如果读者使用的是其它IDE,强烈建议读者启用Dart 分析器,因为在大多数时候,Dart 分析器可以在代码运行前发现大多数问题。
Dart Observatory (语句级的单步调试和分析器)
如果我们使用flutter run
启动应用程序,那么当它运行时,我们可以打开Observatory工具的Web页面,例如Observatory默认监听http://127.0.0.1:8100/,可以在浏览器中直接打开该链接。直接使用语句级单步调试器连接到您的应用程序。如果您使用的是IntelliJ,则还可以使用其内置的调试器来调试您的应用程序。
Observatory 同时支持分析、检查堆等。有关Observatory的更多信息请参考Observatory 文档。
如果您使用Observatory进行分析,请确保通过--profile
选项来运行flutter run
命令来运行应用程序。 否则,配置文件中将出现的主要问题将是调试断言,以验证框架的各种不变量(请参阅下面的“调试模式断言”)。
debugger()
声明
当使用Dart Observatory(或另一个Dart调试器,例如IntelliJ IDE中的调试器)时,可以使用该debugger()
语句插入编程式断点。要使用这个,你必须添加import 'dart:developer';
到相关文件顶部。debugger()
语句采用一个可选when
参数,您可以指定该参数仅在特定条件为真时中断,如下所示:
void someFunction(double offset) {
debugger(when: offset > 30.0);
// ...
}
print
、debugPrint
、flutter logs
Dart print()
功能将输出到系统控制台,您可以使用flutter logs
来查看它(基本上是一个包装adb logcat
)。
如果你一次输出太多,那么Android有时会丢弃一些日志行。为了避免这种情况,您可以使用Flutter的foundation
库中的debugPrint()
。 这是一个封装print,它将输出限制在一个级别,避免被Android内核丢弃。
Flutter框架中的许多类都有toString
实现。按照惯例,这些输出通常包括对象的runtimeType
单行输出,通常在表单中ClassName(more information about this instance…)。 树中使用的一些类也具有toStringDeep
,从该点返回整个子树的多行描述。已一些具有详细信息toString
的类会实现一个toStringShort
,它只返回对象的类型或其他非常简短的(一个或两个单词)描述。
调试模式断言
在Flutter应用调试过程中,Dart assert
语句被启用,并且Flutter框架使用它来执行许多运行时检查来验证是否违反一些不可变的规则。
当一个不可变的规则被违反时,它被报告给控制台,并带有一些上下文信息来帮助追踪问题的根源。
要关闭调试模式并使用发布模式,请使用flutter run --release
运行您的应用程序。 这也关闭了Observatory调试器。一个中间模式可以关闭除Observatory之外所有调试辅助工具的,称为“profile mode”,用--profile
替代--release
即可。
调试应用程序层
Flutter框架的每一层都提供了将其当前状态或事件转储(dump)到控制台(使用debugPrint
)的功能。
Widget 树
要转储Widgets树的状态,请调用debugDumpApp()
。 只要应用程序已经构建了至少一次(即在调用build()
之后的任何时间),您可以在应用程序未处于构建阶段(即,不在build()
方法内调用 )的任何时间调用此方法(在调用runApp()
之后)。
如, 这个应用程序:
import 'package:flutter/material.dart';
void main() {
runApp(
new MaterialApp(
home: new AppHome(),
),
);
}
class AppHome extends StatelessWidget {
@override
Widget build(BuildContext context) {
return new Material(
child: new Center(
child: new FlatButton(
onPressed: () {
debugDumpApp();
},
child: new Text('Dump App'),
),
),
);
}
}
…会输出这样的内容(精确的细节会根据框架的版本、设备的大小等等而变化):
I/flutter ( 6559): WidgetsFlutterBinding - CHECKED MODE
I/flutter ( 6559): RenderObjectToWidgetAdapter<RenderBox>([GlobalObjectKey RenderView(497039273)]; renderObject: RenderView)
I/flutter ( 6559): └MaterialApp(state: _MaterialAppState(1009803148))
I/flutter ( 6559): └ScrollConfiguration()
I/flutter ( 6559): └AnimatedTheme(duration: 200ms; state: _AnimatedThemeState(543295893; ticker inactive; ThemeDataTween(ThemeData(Brightness.light Color(0xff2196f3) etc...) → null)))
I/flutter ( 6559): └Theme(ThemeData(Brightness.light Color(0xff2196f3) etc...))
I/flutter ( 6559): └WidgetsApp([GlobalObjectKey _MaterialAppState(1009803148)]; state: _WidgetsAppState(552902158))
I/flutter ( 6559): └CheckedModeBanner()
I/flutter ( 6559): └Banner()
I/flutter ( 6559): └CustomPaint(renderObject: RenderCustomPaint)
I/flutter ( 6559): └DefaultTextStyle(inherit: true; color: Color(0xd0ff0000); family: "monospace"; size: 48.0; weight: 900; decoration: double Color(0xffffff00) TextDecoration.underline)
I/flutter ( 6559): └MediaQuery(MediaQueryData(size: Size(411.4, 683.4), devicePixelRatio: 2.625, textScaleFactor: 1.0, padding: EdgeInsets(0.0, 24.0, 0.0, 0.0)))
I/flutter ( 6559): └LocaleQuery(null)
I/flutter ( 6559): └Title(color: Color(0xff2196f3))
... #省略剩余内容
这是一个“扁平化”的树,显示了通过各种构建函数投影的所有widget(如果你在widget树的根中调用toStringDeepwidget
,这是你获得的树)。 你会看到很多在你的应用源代码中没有出现的widget,因为它们是被框架中widget的build()
函数插入的。例如,InkFeature
是Material widget的一个实现细节 。
当按钮从被按下变为被释放时debugDumpApp()被调用,FlatButton对象同时调用setState()
,并将自己标记为”dirty”。 这就是为什么如果你看转储,你会看到特定的对象标记为“dirty”。您还可以查看已注册了哪些手势监听器; 在这种情况下,一个单一的GestureDetector被列出,并且监听“tap”手势(“tap”是TapGestureDetector
的toStringShort
函数输出的)
如果您编写自己的widget,则可以通过覆盖debugFillProperties()
来添加信息。 将DiagnosticsProperty对象作为方法参数,并调用父类方法。 该函数是该toString
方法用来填充小部件描述信息的。
渲染树
如果您尝试调试布局问题,那么Widget树可能不够详细。在这种情况下,您可以通过调用debugDumpRenderTree()
转储渲染树。 正如debugDumpApp()
,除布局或绘制阶段外,您可以随时调用此函数。作为一般规则,从frame 回调 或事件处理器中调用它是最佳解决方案。
要调用debugDumpRenderTree()
,您需要添加import'package:flutter/rendering.dart';
到您的源文件。
上面这个小例子的输出结果如下所示:
I/flutter ( 6559): RenderView
I/flutter ( 6559): │ debug mode enabled - android
I/flutter ( 6559): │ window size: Size(1080.0, 1794.0) (in physical pixels)
I/flutter ( 6559): │ device pixel ratio: 2.625 (physical pixels per logical pixel)
I/flutter ( 6559): │ configuration: Size(411.4, 683.4) at 2.625x (in logical pixels)
I/flutter ( 6559): │
I/flutter ( 6559): └─child: RenderCustomPaint
I/flutter ( 6559): │ creator: CustomPaint ← Banner ← CheckedModeBanner ←
I/flutter ( 6559): │ WidgetsApp-[GlobalObjectKey _MaterialAppState(1009803148)] ←
I/flutter ( 6559): │ Theme ← AnimatedTheme ← ScrollConfiguration ← MaterialApp ←
I/flutter ( 6559): │ [root]
I/flutter ( 6559): │ parentData: <none>
I/flutter ( 6559): │ constraints: BoxConstraints(w=411.4, h=683.4)
I/flutter ( 6559): │ size: Size(411.4, 683.4)
... # 省略
这是根RenderObject
对象的toStringDeep
函数的输出。
当调试布局问题时,关键要看的是size
和constraints
字段。约束沿着树向下传递,尺寸向上传递。
如果您编写自己的渲染对象,则可以通过覆盖debugFillProperties()
将信息添加到转储。 将DiagnosticsProperty对象作为方法的参数,并调用父类方法。
Layer树
读者可以理解为渲染树是可以分层的,而最终绘制需要将不同的层合成起来,而Layer则是绘制时需要合成的层,如果您尝试调试合成问题,则可以使用debugDumpLayerTree()
。对于上面的例子,它会输出:
I/flutter : TransformLayer
I/flutter : │ creator: [root]
I/flutter : │ offset: Offset(0.0, 0.0)
I/flutter : │ transform:
I/flutter : │ [0] 3.5,0.0,0.0,0.0
I/flutter : │ [1] 0.0,3.5,0.0,0.0
I/flutter : │ [2] 0.0,0.0,1.0,0.0
I/flutter : │ [3] 0.0,0.0,0.0,1.0
I/flutter : │
I/flutter : ├─child 1: OffsetLayer
I/flutter : │ │ creator: RepaintBoundary ← _FocusScope ← Semantics ← Focus-[GlobalObjectKey MaterialPageRoute(560156430)] ← _ModalScope-[GlobalKey 328026813] ← _OverlayEntry-[GlobalKey 388965355] ← Stack ← Overlay-[GlobalKey 625702218] ← Navigator-[GlobalObjectKey _MaterialAppState(859106034)] ← Title ← ⋯
I/flutter : │ │ offset: Offset(0.0, 0.0)
I/flutter : │ │
I/flutter : │ └─child 1: PictureLayer
I/flutter : │
I/flutter : └─child 2: PictureLayer
这是根Layer
的toStringDeep
输出的。
根部的变换是应用设备像素比的变换; 在这种情况下,每个逻辑像素代表3.5个设备像素。RepaintBoundary
widget在渲染树的层中创建了一个RenderRepaintBoundary
。这用于减少需要重绘的需求量。
语义
您还可以调用debugDumpSemanticsTree()
获取语义树(呈现给系统可访问性API的树)的转储。 要使用此功能,必须首先启用辅助功能,例如启用系统辅助工具或SemanticsDebugger
(下面讨论)。
对于上面的例子,它会输出:
I/flutter : SemanticsNode(0; Rect.fromLTRB(0.0, 0.0, 411.4, 683.4))
I/flutter : ├SemanticsNode(1; Rect.fromLTRB(0.0, 0.0, 411.4, 683.4))
I/flutter : │ └SemanticsNode(2; Rect.fromLTRB(0.0, 0.0, 411.4, 683.4); canBeTapped)
I/flutter : └SemanticsNode(3; Rect.fromLTRB(0.0, 0.0, 411.4, 683.4))
I/flutter : └SemanticsNode(4; Rect.fromLTRB(0.0, 0.0, 82.0, 36.0); canBeTapped; "Dump App")
调度
要找出相对于帧的开始/结束事件发生的位置,可以切换debugPrintBeginFrameBanner
和debugPrintEndFrameBanner
布尔值以将帧的开始和结束打印到控制台。
例如:
I/flutter : ▄▄▄▄▄▄▄▄ Frame 12 30s 437.086ms ▄▄▄▄▄▄▄▄
I/flutter : Debug print: Am I performing this work more than once per frame?
I/flutter : Debug print: Am I performing this work more than once per frame?
I/flutter : ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
debugPrintScheduleFrameStacks
还可以用来打印导致当前帧被调度的调用堆栈。
可视化调试
您也可以通过设置debugPaintSizeEnabled
为true
以可视方式调试布局问题。 这是来自rendering
库的布尔值。它可以在任何时候启用,并在为true时影响绘制。 设置它的最简单方法是在void main()
的顶部设置。
当它被启用时,所有的盒子都会得到一个明亮的深青色边框,padding(来自widget如Padding)显示为浅蓝色,子widget周围有一个深蓝色框, 对齐方式(来自widget如Center和Align)显示为黄色箭头. 空白(如没有任何子节点的Container)以灰色显示。debugPaintBaselinesEnabled
做了类似的事情,但对于具有基线的对象,文字基线以绿色显示,表意(ideographic)基线以橙色显示。debugPaintPointersEnabled
标志打开一个特殊模式,任何正在点击的对象都会以深青色突出显示。 这可以帮助您确定某个对象是否以某种不正确的方式进行hit测试(Flutter检测点击的位置是否有能响应用户操作的widget),例如,如果它实际上超出了其父项的范围,首先不会考虑通过hit测试。
如果您尝试调试合成图层,例如以确定是否以及在何处添加RepaintBoundary
widget,则可以使用debugPaintLayerBordersEnabled
标志, 该标志用橙色或轮廓线标出每个层的边界,或者使用debugRepaintRainbowEnabled
标志, 只要他们重绘时,这会使该层被一组旋转色所覆盖。
所有这些标志只能在调试模式下工作。通常,Flutter框架中以“debug...
” 开头的任何内容都只能在调试模式下工作。
调试动画
调试动画最简单的方法是减慢它们的速度。为此,请将timeDilation
变量(在scheduler库中)设置为大于1.0的数字,例如50.0。 最好在应用程序启动时只设置一次。如果您在运行中更改它,尤其是在动画运行时将其值改小,则在观察时可能会出现倒退,这可能会导致断言命中,并且这通常会干扰我们的开发工作。
调试性能问题
要了解您的应用程序导致重新布局或重新绘制的原因,您可以分别设置debugPrintMarkNeedsLayoutStacks
和 debugPrintMarkNeedsPaintStacks
标志。 每当渲染盒被要求重新布局和重新绘制时,这些都会将堆栈跟踪记录到控制台。如果这种方法对您有用,您可以使用services
库中的debugPrintStack()
方法按需打印堆栈痕迹。
统计应用启动时间
要收集有关Flutter应用程序启动所需时间的详细信息,可以在运行flutter run
时使用trace-startup
和profile
选项。
$ flutter run --trace-startup --profile
跟踪输出保存为start_up_info.json
,在Flutter工程目录在build目录下。输出列出了从应用程序启动到这些跟踪事件(以微秒捕获)所用的时间:
- 进入Flutter引擎时.
- 展示应用第一帧时.
- 初始化Flutter框架时.
- 完成Flutter框架初始化时.
如 :
{
"engineEnterTimestampMicros": 96025565262,
"timeToFirstFrameMicros": 2171978,
"timeToFrameworkInitMicros": 514585,
"timeAfterFrameworkInitMicros": 1657393
}
跟踪Dart代码性能
要执行自定义性能跟踪和测量Dart任意代码段的wall/CPU时间(类似于在Android上使用systrace)。 使用dart:developer
的Timeline工具来包含你想测试的代码块,例如:
Timeline.startSync('interesting function');
// iWonderHowLongThisTakes();
Timeline.finishSync();
然后打开你应用程序的Observatory timeline页面,在“Recorded Streams”中选择‘Dart’复选框,并执行你想测量的功能。
刷新页面将在Chrome的跟踪工具中显示应用按时间顺序排列的timeline记录。
请确保运行flutter run
时带有--profile
标志,以确保运行时性能特征与您的最终产品差异最小。
2.6 Flutter异常捕获
在介绍Flutter异常捕获之前必须先了解一下Dart单线程模型,只有了解了Dart的代码执行流程,我们才能知道该在什么地方去捕获异常。
2.6.1 Dart单线程模型
在Java和Objective-C(以下简称“OC”)中,如果程序发生异常且没有被捕获,那么程序将会终止,但是这在Dart或JavaScript中则不会!究其原因,这和它们的运行机制有关系。Java和OC都是多线程模型的编程语言,任意一个线程触发异常且该异常未被捕获时,就会导致整个进程退出。但Dart和JavaScript不会,它们都是单线程模型,运行机制很相似(但有区别),下面我们通过Dart官方提供的一张图来看看Dart大致运行原理:
Dart 在单线程中是以消息循环机制来运行的,其中包含两个任务队列,一个是“微任务队列” microtask queue,另一个叫做“事件队列” event queue。从图中可以发现,微任务队列的执行优先级高于事件队列。
现在我们来介绍一下Dart线程运行过程,如上图中所示,入口函数 main() 执行完后,消息循环机制便启动了。首先会按照先进先出的顺序逐个执行微任务队列中的任务,事件任务执行完毕后程序便会退出,但是,在事件任务执行的过程中也可以插入新的微任务和事件任务,在这种情况下,整个线程的执行过程便是一直在循环,不会退出,而Flutter中,主线程的执行过程正是如此,永不终止。
在Dart中,所有的外部事件任务都在事件队列中,如IO、计时器、点击、以及绘制事件等,而微任务通常来源于Dart内部,并且微任务非常少,之所以如此,是因为微任务队列优先级高,如果微任务太多,执行时间总和就越久,事件队列任务的延迟也就越久,对于GUI应用来说最直观的表现就是比较卡,所以必须得保证微任务队列不会太长。值得注意的是,我们可以通过Future.microtask(…)
方法向微任务队列插入一个任务。
在事件循环中,当某个任务发生异常并没有被捕获时,程序并不会退出,而直接导致的结果是当前任务的后续代码就不会被执行了,也就是说一个任务中的异常是不会影响其它任务执行的。
2.6.2 Flutter异常捕获
Dart中可以通过try/catch/finally
来捕获代码块异常,这个和其它编程语言类似,如果读者不清楚,可以查看Dart语言文档,不再赘述,下面我们看看Flutter中的异常捕获。
Flutter框架异常捕获
Flutter 框架为我们在很多关键的方法进行了异常捕获。这里举一个例子,当我们布局发生越界或不合规范时,Flutter就会自动弹出一个错误界面,这是因为Flutter已经在执行build方法时添加了异常捕获,最终的源码如下:
@override
void performRebuild() {
...
try {
//执行build方法
built = build();
} catch (e, stack) {
// 有异常时则弹出错误提示
built = ErrorWidget.builder(_debugReportException('building $this', e, stack));
}
...
}
可以看到,在发生异常时,Flutter默认的处理方式是弹一个ErrorWidget,但如果我们想自己捕获异常并上报到报警平台的话应该怎么做?我们进入_debugReportException()
方法看看:
FlutterErrorDetails _debugReportException(
String context,
dynamic exception,
StackTrace stack, {
InformationCollector informationCollector
}) {
//构建错误详情对象
final FlutterErrorDetails details = FlutterErrorDetails(
exception: exception,
stack: stack,
library: 'widgets library',
context: context,
informationCollector: informationCollector,
);
//报告错误
FlutterError.reportError(details);
return details;
}
我们发现,错误是通过FlutterError.reportError
方法上报的,继续跟踪:
static void reportError(FlutterErrorDetails details) {
...
if (onError != null)
onError(details); //调用了onError回调
}
我们发现onError
是FlutterError
的一个静态属性,它有一个默认的处理方法 dumpErrorToConsole
,到这里就清晰了,如果我们想自己上报异常,只需要提供一个自定义的错误处理回调即可,如:
void main() {
FlutterError.onError = (FlutterErrorDetails details) {
reportError(details);
};
...
}
这样我们就可以处理那些Flutter为我们捕获的异常了,接下来我们看看如何捕获其它异常。
其它异常捕获与日志收集
在Flutter中,还有一些Flutter没有为我们捕获的异常,如调用空对象方法异常、Future中的异常。在Dart中,异常分两类:同步异常和异步异常,同步异常可以通过try/catch
捕获,而异步异常则比较麻烦,如下面的代码是捕获不了Future
的异常的:
try{
Future.delayed(Duration(seconds: 1)).then((e) => Future.error("xxx"));
}catch (e){
print(e)
}
Dart中有一个runZoned(...)
方法,可以给执行对象指定一个Zone。Zone表示一个代码执行的环境范围,为了方便理解,读者可以将Zone类比为一个代码执行沙箱,不同沙箱的之间是隔离的,沙箱可以捕获、拦截或修改一些代码行为,如Zone中可以捕获日志输出、Timer创建、微任务调度的行为,同时Zone也可以捕获所有未处理的异常。下面我们看看runZoned(...)
方法定义:
R runZoned<R>(R body(), {
Map zoneValues,
ZoneSpecification zoneSpecification,
Function onError,
})
zoneValues
: Zone 的私有数据,可以通过实例zone[key]
获取,可以理解为每个“沙箱”的私有数据。zoneSpecification
:Zone的一些配置,可以自定义一些代码行为,比如拦截日志输出行为等,举个例子:
下面是拦截应用中所有调用print
输出日志的行为。main() {
runZoned(() => runApp(MyApp()), zoneSpecification: new ZoneSpecification(
print: (Zone self, ZoneDelegate parent, Zone zone, String line) {
parent.print(zone, "Intercepted: $line");
}),
);
}
这样一来,我们APP中所有调用
print
方法输出日志的行为都会被拦截,通过这种方式,我们也可以在应用中记录日志,等到应用触发未捕获的异常时,将异常信息和日志统一上报。ZoneSpecification还可以自定义一些其他行为,读者可以查看API文档。onError
:Zone中未捕获异常处理回调,如果开发者提供了onError回调或者通过ZoneSpecification.handleUncaughtError
指定了错误处理回调,那么这个zone将会变成一个error-zone,该error-zone中发生未捕获异常(无论同步还是异步)时都会调用开发者提供的回调,如:runZoned(() {
runApp(MyApp());
}, onError: (Object obj, StackTrace stack) {
var details=makeDetails(obj,stack);
reportError(details);
});
这样一来,结合上面的
FlutterError.onError
我们就可以捕获我们Flutter应用中全部错误了!需要注意的是,error-zone内部发生的错误是不会跨越当前error-zone的边界的,如果想跨越error-zone边界去捕获异常,可以通过共同的“源”zone来捕获,如:var future = new Future.value(499);
runZoned(() {
var future2 = future.then((_) { throw "error in first error-zone"; });
runZoned(() {
var future3 = future2.catchError((e) { print("Never reached!"); });
}, onError: (e) { print("unused error handler"); });
}, onError: (e) { print("catches error of first error-zone."); });
总结
我们最终的异常捕获和上报代码大致如下:
void collectLog(String line){
... //收集日志
}
void reportErrorAndLog(FlutterErrorDetails details){
... //上报错误和日志逻辑
}
FlutterErrorDetails makeDetails(Object obj, StackTrace stack){
...// 构建错误信息
}
void main() {
FlutterError.onError = (FlutterErrorDetails details) {
reportErrorAndLog(details);
};
runZoned(
() => runApp(MyApp()),
zoneSpecification: ZoneSpecification(
print: (Zone self, ZoneDelegate parent, Zone zone, String line) {
collectLog(line); // 收集日志
},
),
onError: (Object obj, StackTrace stack) {
var details = makeDetails(obj, stack);
reportErrorAndLog(details);
},
);
}