mirror of
https://github.com/cheetahlou/CategoryResourceRepost.git
synced 2025-11-16 14:13:46 +08:00
mod
This commit is contained in:
297
极客时间专栏/Flutter核心技术与实战/Flutter进阶/22 | 如何构造炫酷的动画效果?.md
Normal file
297
极客时间专栏/Flutter核心技术与实战/Flutter进阶/22 | 如何构造炫酷的动画效果?.md
Normal file
@@ -0,0 +1,297 @@
|
||||
<audio id="audio" title="22 | 如何构造炫酷的动画效果?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/e8/24/e8e8df71e3c47c21a9ad31ef2a5f3b24.mp3"></audio>
|
||||
|
||||
你好,我是陈航。
|
||||
|
||||
在上一篇文章中,我带你一起学习了Flutter中实现页面路由的两种方式:基本路由与命名路由,即手动创建页面进行切换,和通过前置路由注册后提供标识符进行跳转。除此之外,Flutter还在这两种路由方式的基础上,支持页面打开和页面关闭传递参数,可以更精确地控制路由切换。
|
||||
|
||||
通过前面第[12](https://time.geekbang.org/column/article/110292)、[13](https://time.geekbang.org/column/article/110859)、[14](https://time.geekbang.org/column/article/110848)和[15](https://time.geekbang.org/column/article/111673)篇文章的学习,我们已经掌握了开发一款样式精美的小型App的基本技能。但当下,用户对于终端页面的要求已经不再满足于只能实现产品功能,除了样式美观之外,还希望交互良好、有趣、自然。
|
||||
|
||||
动画就是提升用户体验的一个重要方式,一个恰当的组件动画或者页面切换动画,不仅能够缓解用户因为等待而带来的情绪问题,还会增加好感。Flutter既然完全接管了渲染层,除了静态的页面布局之外,对组件动画的支持自然也不在话下。
|
||||
|
||||
因此在今天的这篇文章中,我会向你介绍Flutter中动画的实现方法,看看如何让我们的页面动起来。
|
||||
|
||||
## Animation、AnimationController与Listener
|
||||
|
||||
动画就是动起来的画面,是静态的画面根据事先定义好的规律,在一定时间内不断微调,产生变化效果。而动画实现由静止到动态,主要是靠人眼的视觉残留效应。所以,对动画系统而言,为了实现动画,它需要做三件事儿:
|
||||
|
||||
1. 确定画面变化的规律;
|
||||
1. 根据这个规律,设定动画周期,启动动画;
|
||||
1. 定期获取当前动画的值,不断地微调、重绘画面。
|
||||
|
||||
这三件事情对应到Flutter中,就是Animation、AnimationController与Listener:
|
||||
|
||||
1. Animation是Flutter动画库中的核心类,会根据预定规则,在单位时间内持续输出动画的当前状态。Animation知道当前动画的状态(比如,动画是否开始、停止、前进或者后退,以及动画的当前值),但却不知道这些状态究竟应用在哪个组件对象上。换句话说,Animation仅仅是用来提供动画数据,而不负责动画的渲染。
|
||||
1. AnimationController用于管理Animation,可以用来设置动画的时长、启动动画、暂停动画、反转动画等。
|
||||
1. Listener是Animation的回调函数,用来监听动画的进度变化,我们需要在这个回调函数中,根据动画的当前值重新渲染组件,实现动画的渲染。
|
||||
|
||||
接下来,我们看一个具体的案例:让大屏幕中间的Flutter Logo由小变大。
|
||||
|
||||
首先,我们初始化了一个动画周期为1秒的、用于管理动画的AnimationController对象,并用线性变化的Tween创建了一个变化范围从50到200的Animaiton对象。
|
||||
|
||||
然后,我们给这个Animaiton对象设置了一个进度监听器,并在进度监听器中强制界面重绘,刷新动画状态。
|
||||
|
||||
接下来,我们调用AnimationController对象的forward方法,启动动画:
|
||||
|
||||
```
|
||||
class _AnimateAppState extends State<AnimateApp> with SingleTickerProviderStateMixin {
|
||||
AnimationController controller;
|
||||
Animation<double> animation;
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
//创建动画周期为1秒的AnimationController对象
|
||||
controller = AnimationController(
|
||||
vsync: this, duration: const Duration(milliseconds: 1000));
|
||||
// 创建从50到200线性变化的Animation对象
|
||||
animation = Tween(begin: 50.0, end: 200.0).animate(controller)
|
||||
..addListener(() {
|
||||
setState(() {}); //刷新界面
|
||||
});
|
||||
controller.forward(); //启动动画
|
||||
}
|
||||
...
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
需要注意的是,我们在创建AnimationController的时候,设置了一个vsync属性。这个属性是用来防止出现不可见动画的。vsync对象会把动画绑定到一个Widget,当Widget不显示时,动画将会暂停,当Widget再次显示时,动画会重新恢复执行,这样就可以避免动画的组件不在当前屏幕时白白消耗资源。
|
||||
|
||||
我们在一开始提到,Animation只是用于提供动画数据,并不负责动画渲染,所以我们还需要在Widget的build方法中,把当前动画状态的值读出来,用于设置Flutter Logo容器的宽和高,才能最终实现动画效果:
|
||||
|
||||
```
|
||||
@override
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MaterialApp(
|
||||
home: Center(
|
||||
child: Container(
|
||||
width: animation.value, // 将动画的值赋给widget的宽高
|
||||
height: animation.value,
|
||||
child: FlutterLogo()
|
||||
)));
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
最后,别忘了在页面销毁时,要释放动画资源:
|
||||
|
||||
```
|
||||
@override
|
||||
void dispose() {
|
||||
controller.dispose(); // 释放资源
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
我们试着运行一下,可以看到,Flutter Logo动起来了:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/c7/db/c73f5a245ecea87be428a83634ec12db.gif" alt="">
|
||||
|
||||
我们在上面用到的Tween默认是线性变化的,但可以创建CurvedAnimation来实现非线性曲线动画。CurvedAnimation提供了很多常用的曲线,比如震荡曲线elasticOut:
|
||||
|
||||
```
|
||||
//创建动画周期为1秒的AnimationController对象
|
||||
controller = AnimationController(
|
||||
vsync: this, duration: const Duration(milliseconds: 1000));
|
||||
|
||||
//创建一条震荡曲线
|
||||
final CurvedAnimation curve = CurvedAnimation(
|
||||
parent: controller, curve: Curves.elasticOut);
|
||||
// 创建从50到200跟随振荡曲线变化的Animation对象
|
||||
animation = Tween(begin: 50.0, end: 200.0).animate(curve)
|
||||
|
||||
```
|
||||
|
||||
运行一下,可以看到Flutter Logo有了一个弹性动画:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/ce/05/ce0f1ce6380329e3d9194518e2be2d05.gif" alt="">
|
||||
|
||||
现在的问题是,这些动画只能执行一次。如果想让它像心跳一样执行,有两个办法:
|
||||
|
||||
1. 在启动动画时,使用repeat(reverse: true),让动画来回重复执行。
|
||||
1. 监听动画状态。在动画结束时,反向执行;在动画反向执行完毕时,重新启动执行。
|
||||
|
||||
具体的实现代码,如下所示:
|
||||
|
||||
```
|
||||
//以下两段语句等价
|
||||
//第一段
|
||||
controller.repeat(reverse: true);//让动画重复执行
|
||||
|
||||
//第二段
|
||||
animation.addStatusListener((status) {
|
||||
if (status == AnimationStatus.completed) {
|
||||
controller.reverse();//动画结束时反向执行
|
||||
} else if (status == AnimationStatus.dismissed) {
|
||||
controller.forward();//动画反向执行完毕时,重新执行
|
||||
}
|
||||
});
|
||||
controller.forward();//启动动画
|
||||
|
||||
```
|
||||
|
||||
运行一下,可以看到,我们实现了Flutter Logo的心跳效果。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/a7/48/a7e5b1fd635a557cb4289273bd299e48.gif" alt="">
|
||||
|
||||
## AnimatedWidget与AnimatedBuilder
|
||||
|
||||
在为Widget添加动画效果的过程中我们不难发现,Animation仅提供动画的数据,因此我们还需要监听动画执行进度,并在回调中使用setState强制刷新界面才能看到动画效果。考虑到这些步骤都是固定的,Flutter提供了两个类来帮我们简化这一步骤,即AnimatedWidget与AnimatedBuilder。
|
||||
|
||||
接下来,我们分别看看这两个类如何使用。
|
||||
|
||||
在构建Widget时,AnimatedWidget会将Animation的状态与其子Widget的视觉样式绑定。要使用AnimatedWidget,我们需要一个继承自它的新类,并接收Animation对象作为其初始化参数。然后,在build方法中,读取出Animation对象的当前值,用作初始化Widget的样式。
|
||||
|
||||
下面的案例演示了Flutter Logo的AnimatedWidget版本:用AnimatedLogo继承了AnimatedWidget,并在build方法中,把动画的值与容器的宽高做了绑定:
|
||||
|
||||
```
|
||||
class AnimatedLogo extends AnimatedWidget {
|
||||
//AnimatedWidget需要在初始化时传入animation对象
|
||||
AnimatedLogo({Key key, Animation<double> animation})
|
||||
: super(key: key, listenable: animation);
|
||||
|
||||
Widget build(BuildContext context) {
|
||||
//取出动画对象
|
||||
final Animation<double> animation = listenable;
|
||||
return Center(
|
||||
child: Container(
|
||||
height: animation.value,//根据动画对象的当前状态更新宽高
|
||||
width: animation.value,
|
||||
child: FlutterLogo(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在使用时,我们只需把Animation对象传入AnimatedLogo即可,再也不用监听动画的执行进度刷新UI了:
|
||||
|
||||
```
|
||||
MaterialApp(
|
||||
home: Scaffold(
|
||||
body: AnimatedLogo(animation: animation)//初始化AnimatedWidget时传入animation对象
|
||||
));
|
||||
|
||||
```
|
||||
|
||||
在上面的例子中,在AnimatedLogo的build方法中,我们使用Animation的value作为logo的宽和高。这样做对于简单组件的动画没有任何问题,但如果动画的组件比较复杂,一个更好的解决方案是,**将动画和渲染职责分离**:logo作为外部参数传入,只做显示;而尺寸的变化动画则由另一个类去管理。
|
||||
|
||||
这个分离工作,我们可以借助AnimatedBuilder来完成。
|
||||
|
||||
与AnimatedWidget类似,AnimatedBuilder也会自动监听Animation对象的变化,并根据需要将该控件树标记为dirty以自动刷新UI。事实上,如果你翻看[源码](https://github.com/flutter/flutter/blob/ca5411e3aa99d571ddd80b75b814718c4a94c839/packages/flutter/lib/src/widgets/transitions.dart#L920),就会发现AnimatedBuilder其实也是继承自AnimatedWidget。
|
||||
|
||||
我们以一个例子来演示如何使用AnimatedBuilder。在这个例子中,AnimatedBuilder的尺寸变化动画由builder函数管理,渲染则由外部传入child参数负责:
|
||||
|
||||
```
|
||||
MaterialApp(
|
||||
home: Scaffold(
|
||||
body: Center(
|
||||
child: AnimatedBuilder(
|
||||
animation: animation,//传入动画对象
|
||||
child:FlutterLogo(),
|
||||
//动画构建回调
|
||||
builder: (context, child) => Container(
|
||||
width: animation.value,//使用动画的当前状态更新UI
|
||||
height: animation.value,
|
||||
child: child, //child参数即FlutterLogo()
|
||||
)
|
||||
)
|
||||
)
|
||||
));
|
||||
|
||||
```
|
||||
|
||||
可以看到,通过使用AnimatedWidget和AnimatedBuilder,动画的生成和最终的渲染被分离开了,构建动画的工作也被大大简化了。
|
||||
|
||||
## hero动画
|
||||
|
||||
现在我们已经知道了如何在一个页面上实现动画效果,那么如何实现在两个页面之间切换的过渡动画呢?比如在社交类App,在Feed流中点击小图进入查看大图页面的场景中,我们希望能够实现小图到大图页面逐步放大的动画切换效果,而当用户关闭大图时,也实现原路返回的动画。
|
||||
|
||||
这样的跨页面共享的控件动画效果有一个专门的名词,即“共享元素变换”(Shared Element Transition)。
|
||||
|
||||
对于Android开发者来说,这个概念并不陌生。Android原生提供了对这种动画效果的支持,通过几行代码,就可以实现在两个Activity共享的组件之间做出流畅的转场动画。
|
||||
|
||||
又比如,Keynote提供了的“神奇移动”(Magic Move)功能,可以实现两个Keynote页面之间的流畅过渡。
|
||||
|
||||
Flutter也有类似的概念,即Hero控件。**通过Hero,我们可以在两个页面的共享元素之间,做出流畅的页面切换效果。**
|
||||
|
||||
接下来,我们通过一个案例来看看Hero组件具体如何使用。
|
||||
|
||||
在下面的例子中,我定义了两个页面,其中page1有一个位于底部的小Flutter Logo,page2有一个位于中部的大Flutter Logo。在点击了page1的小logo后,会使用hero效果过渡到page2。
|
||||
|
||||
为了实现共享元素变换,我们需要将这两个组件分别用Hero包裹,并同时为它们设置相同的tag “hero”。然后,为page1添加点击手势响应,在用户点击logo时,跳转到page2:
|
||||
|
||||
```
|
||||
class Page1 extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: GestureDetector(//手势监听点击
|
||||
child: Hero(
|
||||
tag: 'hero',//设置共享tag
|
||||
child: Container(
|
||||
width: 100, height: 100,
|
||||
child: FlutterLogo())),
|
||||
onTap: () {
|
||||
Navigator.of(context).push(MaterialPageRoute(builder: (_)=>Page2()));//点击后打开第二个页面
|
||||
},
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class Page2 extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: Hero(
|
||||
tag: 'hero',//设置共享tag
|
||||
child: Container(
|
||||
width: 300, height: 300,
|
||||
child: FlutterLogo()
|
||||
))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
运行一下,可以看到,我们通过简单的两步,就可以实现元素跨页面飞行的复杂动画效果了!
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/c5/d6/c5fe68b6e627d8285ed6aadf932abcd6.gif" alt="">
|
||||
|
||||
## 总结
|
||||
|
||||
好了,今天的分享就到这里。我们简单回顾一下今天的主要内容吧。
|
||||
|
||||
在Flutter中,动画的状态与渲染是分离的。我们通过Animation生成动画曲线,使用AnimationController控制动画时间、启动动画。而动画的渲染,则需要设置监听器获取动画进度后,重新触发组件用新的动画状态刷新后才能实现动画的更新。
|
||||
|
||||
为了简化这一步骤,Flutter提供了AnimatedWidget和AnimatedBuilder这两个组件,省去了状态监听和UI刷新的工作。而对于跨页面动画,Flutter提供了Hero组件,只要两个相同(相似)的组件有同样的tag,就能实现元素跨页面过渡的转场效果。
|
||||
|
||||
可以看到,Flutter对于动画的分层设计还是非常简单清晰的,但造成的副作用就是使用起来稍微麻烦一些。对于实际应用而言,由于动画过程涉及到页面的频繁刷新,因此我强烈建议你尽量使用AnimatedWidget或AnimatedBuilder来缩小受动画影响的组件范围,只重绘需要做动画的组件即可,要避免使用进度监听器直接刷新整个页面,让不需要做动画的组件也跟着一起销毁重建。
|
||||
|
||||
我把今天分享中所涉及的针对控件的普通动画,AnimatedBuilder和AnimatedWidget,以及针对页面的过渡动画Hero打包到了[GitHub](https://github.com/cyndibaby905/22_app_animation)上,你可以把工程下载下来,多运行几次,体会这几种动画的具体使用方法。
|
||||
|
||||
## 思考题
|
||||
|
||||
最后,我给你留下两个小作业吧。
|
||||
|
||||
```
|
||||
AnimatedBuilder(
|
||||
animation: animation,
|
||||
child:FlutterLogo(),
|
||||
builder: (context, child) => Container(
|
||||
width: animation.value,
|
||||
height: animation.value,
|
||||
child: child
|
||||
)
|
||||
)
|
||||
|
||||
```
|
||||
|
||||
1. 在AnimatedBuilder的例子中,child似乎被指定了两遍(第3行的child与第7行的child),你可以解释下这么做的原因吗?
|
||||
1. 如果我把第3行的child删掉,把Flutter Logo放到第7行,动画是否能正常执行?这会有什么问题吗?
|
||||
|
||||
欢迎你在评论区给我留言分享你的观点,我会在下一篇文章中等待你!感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。
|
||||
|
||||
|
||||
358
极客时间专栏/Flutter核心技术与实战/Flutter进阶/23 | 单线程模型怎么保证UI运行流畅?.md
Normal file
358
极客时间专栏/Flutter核心技术与实战/Flutter进阶/23 | 单线程模型怎么保证UI运行流畅?.md
Normal file
@@ -0,0 +1,358 @@
|
||||
<audio id="audio" title="23 | 单线程模型怎么保证UI运行流畅?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/50/a7/506cba0649ff21059bb1bb58243e59a7.mp3"></audio>
|
||||
|
||||
你好,我是陈航。
|
||||
|
||||
在上一篇文章中,我带你一起学习了如何在Flutter中实现动画。对于组件动画,Flutter将动画的状态与渲染进行了分离,因此我们需要使用动画曲线生成器Animation、动画状态控制器AnimationController与动画进度监听器一起配合完成动画更新;而对于跨页面动画,Flutter提供了Hero组件,可以实现共享元素变换的页面切换效果。
|
||||
|
||||
在之前的章节里,我们介绍了很多Flutter框架出色的渲染和交互能力。支撑起这些复杂的能力背后,实际上是基于单线程模型的Dart。那么,与原生Android和iOS的多线程机制相比,单线程的Dart如何从语言设计层面和代码运行机制上保证Flutter UI的流畅性呢?
|
||||
|
||||
因此今天,我会通过几个小例子,循序渐进地向你介绍Dart语言的Event Loop处理机制、异步处理和并发编程的原理和使用方法,从语言设计和实践层面理解Dart单线程模型下的代码运行本质,从而懂得后续如何在工作中使用Future与Isolate,优化我们的项目。
|
||||
|
||||
## Event Loop机制
|
||||
|
||||
首先,我们需要建立这样一个概念,那就是**Dart是单线程的**。那单线程意味着什么呢?这意味着Dart代码是有序的,按照在main函数出现的次序一个接一个地执行,不会被其他代码中断。另外,作为支持Flutter这个UI框架的关键技术,Dart当然也支持异步。需要注意的是,**单线程和异步并不冲突。**
|
||||
|
||||
那为什么单线程也可以异步?
|
||||
|
||||
这里有一个大前提,那就是我们的App绝大多数时间都在等待。比如,等用户点击、等网络请求返回、等文件IO结果,等等。而这些等待行为并不是阻塞的。比如说,网络请求,Socket本身提供了select模型可以异步查询;而文件IO,操作系统也提供了基于事件的回调机制。
|
||||
|
||||
所以,基于这些特点,单线程模型可以在等待的过程中做别的事情,等真正需要响应结果了,再去做对应的处理。因为等待过程并不是阻塞的,所以给我们的感觉就像是同时在做多件事情一样。但其实始终只有一个线程在处理你的事情。
|
||||
|
||||
等待这个行为是通过Event Loop驱动的。事件队列Event Queue会把其他平行世界(比如Socket)完成的,需要主线程响应的事件放入其中。像其他语言一样,Dart也有一个巨大的事件循环,在不断的轮询事件队列,取出事件(比如,键盘事件、I\O事件、网络事件等),在主线程同步执行其回调函数,如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/0c/ec/0cb6e6d34295cef460e48d139bc944ec.png" alt="">
|
||||
|
||||
## 异步任务
|
||||
|
||||
事实上,图1的Event Loop示意图只是一个简化版。在Dart中,实际上有两个队列,一个事件队列(Event Queue),另一个则是微任务队列(Microtask Queue)。在每一次事件循环中,Dart总是先去第一个微任务队列中查询是否有可执行的任务,如果没有,才会处理后续的事件队列的流程。
|
||||
|
||||
所以,Event Loop完整版的流程图,应该如下所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/70/bc/70dc4e1c222ddfaee8aa06df85c22bbc.png" alt="">
|
||||
|
||||
接下来,我们分别看一下这两个队列的特点和使用场景吧。
|
||||
|
||||
首先,我们看看微任务队列。微任务顾名思义,表示一个短时间内就会完成的异步任务。从上面的流程图可以看到,微任务队列在事件循环中的优先级是最高的,只要队列中还有任务,就可以一直霸占着事件循环。
|
||||
|
||||
微任务是由scheduleMicroTask建立的。如下所示,这段代码会在下一个事件循环中输出一段字符串:
|
||||
|
||||
```
|
||||
scheduleMicrotask(() => print('This is a microtask'));
|
||||
|
||||
```
|
||||
|
||||
不过,一般的异步任务通常也很少必须要在事件队列前完成,所以也不需要太高的优先级,因此我们通常很少会直接用到微任务队列,就连Flutter内部,也只有7处用到了而已(比如,手势识别、文本输入、滚动视图、保存页面效果等需要高优执行任务的场景)。
|
||||
|
||||
异步任务我们用的最多的还是优先级更低的Event Queue。比如,I/O、绘制、定时器这些异步事件,都是通过事件队列驱动主线程执行的。
|
||||
|
||||
**Dart为Event Queue的任务建立提供了一层封装,叫作Future**。从名字上也很容易理解,它表示一个在未来时间才会完成的任务。
|
||||
|
||||
把一个函数体放入Future,就完成了从同步任务到异步任务的包装。Future还提供了链式调用的能力,可以在异步任务执行完毕后依次执行链路上的其他函数体。
|
||||
|
||||
接下来,我们看一个具体的代码示例:分别声明两个异步任务,在下一个事件循环中输出一段字符串。其中第二个任务执行完毕之后,还会继续输出另外两段字符串:
|
||||
|
||||
```
|
||||
Future(() => print('Running in Future 1'));//下一个事件循环输出字符串
|
||||
|
||||
Future(() => print(‘Running in Future 2'))
|
||||
.then((_) => print('and then 1'))
|
||||
.then((_) => print('and then 2’));//上一个事件循环结束后,连续输出三段字符串
|
||||
|
||||
```
|
||||
|
||||
当然,这两个Future异步任务的执行优先级比微任务的优先级要低。
|
||||
|
||||
正常情况下,一个Future异步任务的执行是相对简单的:在我们声明一个Future时,Dart会将异步任务的函数执行体放入事件队列,然后立即返回,后续的代码继续同步执行。而当同步执行的代码执行完毕后,事件队列会按照加入事件队列的顺序(即声明顺序),依次取出事件,最后同步执行Future的函数体及后续的then。
|
||||
|
||||
这意味着,**then与Future函数体共用一个事件循环**。而如果Future有多个then,它们也会按照链式调用的先后顺序同步执行,同样也会共用一个事件循环。
|
||||
|
||||
如果Future执行体已经执行完毕了,但你又拿着这个Future的引用,往里面加了一个then方法体,这时Dart会如何处理呢?面对这种情况,Dart会将后续加入的then方法体放入微任务队列,尽快执行。
|
||||
|
||||
下面的代码演示了Future的执行规则,即,先加入事件队列,或者先声明的任务先执行;then在Future结束后立即执行。
|
||||
|
||||
- 在第一个例子中,由于f1比f2先声明,因此会被先加入事件队列,所以f1比f2先执行;
|
||||
- 在第二个例子中,由于Future函数体与then共用一个事件循环,因此f3执行后会立刻同步执行then 3;
|
||||
- 最后一个例子中,Future函数体是null,这意味着它不需要也没有事件循环,因此后续的then也无法与它共享。在这种场景下,Dart会把后续的then放入微任务队列,在下一次事件循环中执行。
|
||||
|
||||
```
|
||||
//f1比f2先执行
|
||||
Future(() => print('f1'));
|
||||
Future(() => print('f2'));
|
||||
|
||||
//f3执行后会立刻同步执行then 3
|
||||
Future(() => print('f3')).then((_) => print('then 3'));
|
||||
|
||||
//then 4会加入微任务队列,尽快执行
|
||||
Future(() => null).then((_) => print('then 4'));
|
||||
|
||||
```
|
||||
|
||||
说了这么多规则,可能大家并没有完全记住。那我们通过一个综合案例,来把之前介绍的各个执行规则都串起来,再集中学习一下。
|
||||
|
||||
在下面的例子中,我们依次声明了若干个异步任务Future,以及微任务。在其中的一些Future内部,我们又内嵌了Future与microtask的声明:
|
||||
|
||||
```
|
||||
Future(() => print('f1'));//声明一个匿名Future
|
||||
Future fx = Future(() => null);//声明Future fx,其执行体为null
|
||||
|
||||
//声明一个匿名Future,并注册了两个then。在第一个then回调里启动了一个微任务
|
||||
Future(() => print('f2')).then((_) {
|
||||
print('f3');
|
||||
scheduleMicrotask(() => print('f4'));
|
||||
}).then((_) => print('f5'));
|
||||
|
||||
//声明了一个匿名Future,并注册了两个then。第一个then是一个Future
|
||||
Future(() => print('f6'))
|
||||
.then((_) => Future(() => print('f7')))
|
||||
.then((_) => print('f8'));
|
||||
|
||||
//声明了一个匿名Future
|
||||
Future(() => print('f9'));
|
||||
|
||||
//往执行体为null的fx注册了了一个then
|
||||
fx.then((_) => print('f10'));
|
||||
|
||||
//启动一个微任务
|
||||
scheduleMicrotask(() => print('f11'));
|
||||
print('f12');
|
||||
|
||||
```
|
||||
|
||||
运行一下,上述各个异步任务会依次打印其内部执行结果:
|
||||
|
||||
```
|
||||
f12
|
||||
f11
|
||||
f1
|
||||
f10
|
||||
f2
|
||||
f3
|
||||
f5
|
||||
f4
|
||||
f6
|
||||
f9
|
||||
f7
|
||||
f8
|
||||
|
||||
```
|
||||
|
||||
看到这儿,你可能已经懵了。别急,我们先来看一下这段代码执行过程中,Event Queue与Microtask Queue中的变化情况,依次分析一下它们的执行顺序为什么会是这样的:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/8a/8b/8a1106a01613fa999a35911fc5922e8b.gif" alt="">
|
||||
|
||||
- 因为其他语句都是异步任务,所以先打印f12。
|
||||
- 剩下的异步任务中,微任务队列优先级最高,因此随后打印f11;然后按照Future声明的先后顺序,打印f1。
|
||||
- 随后到了fx,由于fx的执行体是null,相当于执行完毕了,Dart将fx的then放入微任务队列,由于微任务队列的优先级最高,因此fx的then还是会最先执行,打印f10。
|
||||
- 然后到了fx下面的f2,打印f2,然后执行then,打印f3。f4是一个微任务,要到下一个事件循环才执行,因此后续的then继续同步执行,打印f5。本次事件循环结束,下一个事件循环取出f4这个微任务,打印f4。
|
||||
- 然后到了f2下面的f6,打印f6,然后执行then。这里需要注意的是,这个then是一个Future异步任务,因此这个then,以及后续的then都被放入到事件队列中了。
|
||||
- f6下面还有f9,打印f9。
|
||||
- 最后一个事件循环,打印f7,以及后续的f8。
|
||||
|
||||
上面的代码很是烧脑,万幸我们平时开发Flutter时一般不会遇到这样奇葩的写法,所以你大可放心。你只需要记住一点:**then会在Future函数体执行完毕后立刻执行,无论是共用同一个事件循环还是进入下一个微任务。**
|
||||
|
||||
在深入理解Future异步任务的执行规则之后,我们再来看看怎么封装一个异步函数。
|
||||
|
||||
## 异步函数
|
||||
|
||||
对于一个异步函数来说,其返回时内部执行动作并未结束,因此需要返回一个Future对象,供调用者使用。调用者根据Future对象,来决定:是在这个Future对象上注册一个then,等Future的执行体结束了以后再进行异步处理;还是一直同步等待Future执行体结束。
|
||||
|
||||
对于异步函数返回的Future对象,如果调用者决定同步等待,则需要在调用处使用await关键字,并且在调用处的函数体使用async关键字。
|
||||
|
||||
在下面的例子中,异步方法延迟3秒返回了一个Hello 2019,在调用处我们使用await进行持续等待,等它返回了再打印:
|
||||
|
||||
```
|
||||
//声明了一个延迟3秒返回Hello的Future,并注册了一个then返回拼接后的Hello 2019
|
||||
Future<String> fetchContent() =>
|
||||
Future<String>.delayed(Duration(seconds:3), () => "Hello")
|
||||
.then((x) => "$x 2019");
|
||||
|
||||
main() async{
|
||||
print(await fetchContent());//等待Hello 2019的返回
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
也许你已经注意到了,我们在使用await进行等待的时候,在等待语句的调用上下文函数main加上了async关键字。为什么要加这个关键字呢?
|
||||
|
||||
因为**Dart中的await并不是阻塞等待,而是异步等待**。Dart会将调用体的函数也视作异步函数,将等待语句的上下文放入Event Queue中,一旦有了结果,Event Loop就会把它从Event Queue中取出,等待代码继续执行。
|
||||
|
||||
接下来,为了帮助你加深印象,我准备了两个具体的案例。
|
||||
|
||||
我们先来看下这段代码。第二行的then执行体f2是一个Future,为了等它完成再进行下一步操作,我们使用了await,期望打印结果为f1、f2、f3、f4:
|
||||
|
||||
```
|
||||
Future(() => print('f1'))
|
||||
.then((_) async => await Future(() => print('f2')))
|
||||
.then((_) => print('f3'));
|
||||
Future(() => print('f4'));
|
||||
|
||||
```
|
||||
|
||||
实际上,当你运行这段代码时就会发现,打印出来的结果其实是f1、f4、f2、f3!
|
||||
|
||||
我来给你分析一下这段代码的执行顺序:
|
||||
|
||||
- 按照任务的声明顺序,f1和f4被先后加入事件队列。
|
||||
- f1被取出并打印;然后到了then。then的执行体是个future f2,于是放入Event Queue。然后把await也放到Event Queue里。
|
||||
- 这个时候要注意了,Event Queue里面还有一个f4,我们的await并不能阻塞f4的执行。因此,Event Loop先取出f4,打印f4;然后才能取出并打印f2,最后把等待的await取出,开始执行后面的f3。
|
||||
|
||||
由于await是采用事件队列的机制实现等待行为的,所以比它先在事件队列中的f4并不会被它阻塞。
|
||||
|
||||
接下来,我们再看另一个例子:在主函数调用一个异步函数去打印一段话,而在这个异步函数中,我们使用await与async同步等待了另一个异步函数返回字符串:
|
||||
|
||||
```
|
||||
//声明了一个延迟2秒返回Hello的Future,并注册了一个then返回拼接后的Hello 2019
|
||||
Future<String> fetchContent() =>
|
||||
Future<String>.delayed(Duration(seconds:2), () => "Hello")
|
||||
.then((x) => "$x 2019");
|
||||
//异步函数会同步等待Hello 2019的返回,并打印
|
||||
func() async => print(await fetchContent());
|
||||
|
||||
main() {
|
||||
print("func before");
|
||||
func();
|
||||
print("func after");
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
运行这段代码,我们发现最终输出的顺序其实是“func before”“func after”“Hello 2019”。func函数中的等待语句似乎没起作用。这是为什么呢?
|
||||
|
||||
同样,我来给你分析一下这段代码的执行顺序:
|
||||
|
||||
- 首先,第一句代码是同步的,因此先打印“func before”。
|
||||
- 然后,进入func函数,func函数调用了异步函数fetchContent,并使用await进行等待,因此我们把fetchContent、await语句的上下文函数func先后放入事件队列。
|
||||
- await的上下文函数并不包含调用栈,因此func后续代码继续执行,打印“func after”。
|
||||
- 2秒后,fetchContent异步任务返回“Hello 2019”,于是func的await也被取出,打印“Hello 2019”。
|
||||
|
||||
通过上述分析,你发现了什么现象?那就是await与async只对调用上下文的函数有效,并不向上传递。因此对于这个案例而言,func是在异步等待。如果我们想在main函数中也同步等待,需要在调用异步函数时也加上await,在main函数也加上async。
|
||||
|
||||
经过上面两个例子的分析,你应该已经明白await与async是如何配合,完成等待工作的了吧。
|
||||
|
||||
介绍完了异步,我们再来看在Dart中,如何通过多线程实现并发。
|
||||
|
||||
## Isolate
|
||||
|
||||
尽管Dart是基于单线程模型的,但为了进一步利用多核CPU,将CPU密集型运算进行隔离,Dart也提供了多线程机制,即Isolate。在Isolate中,资源隔离做得非常好,每个Isolate都有自己的Event Loop与Queue,**Isolate之间不共享任何资源,只能依靠消息机制通信,因此也就没有资源抢占问题**。
|
||||
|
||||
和其他语言一样,Isolate的创建非常简单,我们只要给定一个函数入口,创建时再传入一个参数,就可以启动Isolate了。如下所示,我们声明了一个Isolate的入口函数,然后在main函数中启动它,并传入了一个字符串参数:
|
||||
|
||||
```
|
||||
doSth(msg) => print(msg);
|
||||
|
||||
main() {
|
||||
Isolate.spawn(doSth, "Hi");
|
||||
...
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
但更多情况下,我们的需求并不会这么简单,不仅希望能并发,还希望Isolate在并发执行的时候告知主Isolate当前的执行结果。
|
||||
|
||||
对于执行结果的告知,Isolate通过发送管道(SendPort)实现消息通信机制。我们可以在启动并发Isolate时将主Isolate的发送管道作为参数传给它,这样并发Isolate就可以在任务执行完毕后利用这个发送管道给我们发消息了。
|
||||
|
||||
下面我们通过一个例子来说明:在主Isolate里,我们创建了一个并发Isolate,在函数入口传入了主Isolate的发送管道,然后等待并发Isolate的回传消息。在并发Isolate中,我们用这个管道给主Isolate发了一个Hello字符串:
|
||||
|
||||
```
|
||||
Isolate isolate;
|
||||
|
||||
start() async {
|
||||
ReceivePort receivePort= ReceivePort();//创建管道
|
||||
//创建并发Isolate,并传入发送管道
|
||||
isolate = await Isolate.spawn(getMsg, receivePort.sendPort);
|
||||
//监听管道消息
|
||||
receivePort.listen((data) {
|
||||
print('Data:$data');
|
||||
receivePort.close();//关闭管道
|
||||
isolate?.kill(priority: Isolate.immediate);//杀死并发Isolate
|
||||
isolate = null;
|
||||
});
|
||||
}
|
||||
//并发Isolate往管道发送一个字符串
|
||||
getMsg(sendPort) => sendPort.send("Hello");
|
||||
|
||||
```
|
||||
|
||||
这里需要注意的是,在Isolate中,发送管道是单向的:我们启动了一个Isolate执行某项任务,Isolate执行完毕后,发送消息告知我们。如果Isolate执行任务时,需要依赖主Isolate给它发送参数,执行完毕后再发送执行结果给主Isolate,这样**双向通信的场景我们如何实现呢**?答案也很简单,让并发Isolate也回传一个发送管道即可。
|
||||
|
||||
接下来,我们以一个**并发计算阶乘**的例子来说明如何实现双向通信。
|
||||
|
||||
在下面的例子中,我们创建了一个异步函数计算阶乘。在这个异步函数内,创建了一个并发Isolate,传入主Isolate的发送管道;并发Isolate也回传一个发送管道;主Isolate收到回传管道后,发送参数N给并发Isolate,然后立即返回一个Future;并发Isolate用参数N,调用同步计算阶乘的函数,返回执行结果;最后,主Isolate打印了返回结果:
|
||||
|
||||
```
|
||||
//并发计算阶乘
|
||||
Future<dynamic> asyncFactoriali(n) async{
|
||||
final response = ReceivePort();//创建管道
|
||||
//创建并发Isolate,并传入管道
|
||||
await Isolate.spawn(_isolate,response.sendPort);
|
||||
//等待Isolate回传管道
|
||||
final sendPort = await response.first as SendPort;
|
||||
//创建了另一个管道answer
|
||||
final answer = ReceivePort();
|
||||
//往Isolate回传的管道中发送参数,同时传入answer管道
|
||||
sendPort.send([n,answer.sendPort]);
|
||||
return answer.first;//等待Isolate通过answer管道回传执行结果
|
||||
}
|
||||
|
||||
//Isolate函数体,参数是主Isolate传入的管道
|
||||
_isolate(initialReplyTo) async {
|
||||
final port = ReceivePort();//创建管道
|
||||
initialReplyTo.send(port.sendPort);//往主Isolate回传管道
|
||||
final message = await port.first as List;//等待主Isolate发送消息(参数和回传结果的管道)
|
||||
final data = message[0] as int;//参数
|
||||
final send = message[1] as SendPort;//回传结果的管道
|
||||
send.send(syncFactorial(data));//调用同步计算阶乘的函数回传结果
|
||||
}
|
||||
|
||||
//同步计算阶乘
|
||||
int syncFactorial(n) => n < 2 ? n : n * syncFactorial(n-1);
|
||||
main() async => print(await asyncFactoriali(4));//等待并发计算阶乘结果
|
||||
|
||||
```
|
||||
|
||||
看完这段代码你是什么感觉呢?我们只是为了并发计算一个阶乘,这样是不是太繁琐了?
|
||||
|
||||
没错,确实太繁琐了。在Flutter中,像这样执行并发计算任务我们可以采用更简单的方式。Flutter提供了支持并发计算的compute函数,其内部对Isolate的创建和双向通信进行了封装抽象,屏蔽了很多底层细节,我们在调用时只需要传入函数入口和函数参数,就能够实现并发计算和消息通知。
|
||||
|
||||
我们试着用compute函数改造一下并发计算阶乘的代码:
|
||||
|
||||
```
|
||||
//同步计算阶乘
|
||||
int syncFactorial(n) => n < 2 ? n : n * syncFactorial(n-1);
|
||||
//使用compute函数封装Isolate的创建和结果的返回
|
||||
main() async => print(await compute(syncFactorial, 4));
|
||||
|
||||
```
|
||||
|
||||
可以看到,用compute函数改造以后,整个代码就变成了两行,现在并发计算阶乘的代码看起来就清爽多了。
|
||||
|
||||
## 总结
|
||||
|
||||
好了,今天关于Dart的异步与并发机制、实现原理的分享就到这里了,我们来简单回顾一下主要内容。
|
||||
|
||||
Dart是单线程的,但通过事件循环可以实现异步。而Future是异步任务的封装,借助于await与async,我们可以通过事件循环实现非阻塞的同步等待;Isolate是Dart中的多线程,可以实现并发,有自己的事件循环与Queue,独占资源。Isolate之间可以通过消息机制进行单向通信,这些传递的消息通过对方的事件循环驱动对方进行异步处理。
|
||||
|
||||
在UI编程过程中,异步和多线程是两个相伴相生的名词,也是很容易混淆的概念。对于异步方法调用而言,代码不需要等待结果的返回,而是通过其他手段(比如通知、回调、事件循环或多线程)在后续的某个时刻主动(或被动)地接收执行结果。
|
||||
|
||||
因此,从辩证关系上来看,异步与多线程并不是一个同等关系:异步是目的,多线程只是我们实现异步的一个手段之一。而在Flutter中,借助于UI框架提供的事件循环,我们可以不用阻塞的同时等待多个异步任务,因此并不需要开多线程。我们一定要记住这一点。
|
||||
|
||||
我把今天分享所涉及到的知识点打包到了[GitHub](https://github.com/cyndibaby905/23_dart_async)中,你可以下载下来,反复运行几次,加深理解。
|
||||
|
||||
## 思考题
|
||||
|
||||
最后,我给你留下两道思考题吧。
|
||||
|
||||
1. 在通过并发Isolate计算阶乘的例子中,我在asyncFactoriali方法里先后发给了并发Isolate两个SendPort。你能否解释下这么做的原因?可以只发一个SendPort吗?
|
||||
1. 请改造以下代码,在不改变整体异步结构的情况下,实现输出结果为f1、f2、f3、f4。
|
||||
|
||||
```
|
||||
Future(() => print('f1'))
|
||||
.then((_) async => await Future(() => print('f2')))
|
||||
.then((_) => print('f3'));
|
||||
Future(() => print('f4'));
|
||||
|
||||
```
|
||||
|
||||
欢迎你在评论区给我留言分享你的观点,我会在下一篇文章中等待你!感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。
|
||||
|
||||
|
||||
419
极客时间专栏/Flutter核心技术与实战/Flutter进阶/24 | HTTP网络编程与JSON解析.md
Normal file
419
极客时间专栏/Flutter核心技术与实战/Flutter进阶/24 | HTTP网络编程与JSON解析.md
Normal file
@@ -0,0 +1,419 @@
|
||||
<audio id="audio" title="24 | HTTP网络编程与JSON解析" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/b8/a1/b840f321655f1d0c3eb1b6ef06e7eca1.mp3"></audio>
|
||||
|
||||
你好,我是陈航。
|
||||
|
||||
在上一篇文章中,我带你一起学习了Dart中异步与并发的机制及实现原理。与其他语言类似,Dart的异步是通过事件循环与队列实现的,我们可以使用Future来封装异步任务。而另一方面,尽管Dart是基于单线程模型的,但也提供了Isolate这样的“多线程”能力,这使得我们可以充分利用系统资源,在并发Isolate中搞定CPU密集型的任务,并通过消息机制通知主Isolate运行结果。
|
||||
|
||||
异步与并发的一个典型应用场景,就是网络编程。一个好的移动应用,不仅需要有良好的界面和易用的交互体验,也需要具备和外界进行信息交互的能力。而通过网络,信息隔离的客户端与服务端间可以建立一个双向的通信通道,从而实现资源访问、接口数据请求和提交、上传下载文件等操作。
|
||||
|
||||
为了便于我们快速实现基于网络通道的信息交换实时更新App数据,Flutter也提供了一系列的网络编程类库和工具。因此在今天的分享中,我会通过一些小例子与你讲述在Flutter应用中,如何实现与服务端的数据交互,以及如何将交互响应的数据格式化。
|
||||
|
||||
## Http网络编程
|
||||
|
||||
我们在通过网络与服务端数据交互时,不可避免地需要用到三个概念:定位、传输与应用。
|
||||
|
||||
其中,**定位**,定义了如何准确地找到网络上的一台或者多台主机(即IP地址);**传输**,则主要负责在找到主机后如何高效且可靠地进行数据通信(即TCP、UDP协议);而**应用**,则负责识别双方通信的内容(即HTTP协议)。
|
||||
|
||||
我们在进行数据通信时,可以只使用传输层协议。但传输层传递的数据是二进制流,如果没有应用层,我们无法识别数据内容。如果想要使传输的数据有意义,则必须要用到应用层协议。移动应用通常使用HTTP协议作应用层协议,来封装HTTP信息。
|
||||
|
||||
在编程框架中,一次HTTP网络调用通常可以拆解为以下步骤:
|
||||
|
||||
1. 创建网络调用实例client,设置通用请求行为(如超时时间);
|
||||
1. 构造URI,设置请求header、body;
|
||||
1. 发起请求, 等待响应;
|
||||
1. 解码响应的内容。
|
||||
|
||||
当然,Flutter也不例外。在Flutter中,Http网络编程的实现方式主要分为三种:dart:io里的HttpClient实现、Dart原生http请求库实现、第三方库dio实现。接下来,我依次为你讲解这三种方式。
|
||||
|
||||
### HttpClient
|
||||
|
||||
HttpClient是dart:io库中提供的网络请求类,实现了基本的网络编程功能。
|
||||
|
||||
接下来,我将和你分享一个实例,对照着上面提到的网络调用步骤,来演示HttpClient如何使用。
|
||||
|
||||
在下面的代码中,我们创建了一个HttpClien网络调用实例,设置了其超时时间为5秒。随后构造了Flutter官网的URI,并设置了请求Header的user-agent为Custom-UA。然后发起请求,等待Flutter官网响应。最后在收到响应后,打印出返回结果:
|
||||
|
||||
```
|
||||
get() async {
|
||||
//创建网络调用示例,设置通用请求行为(超时时间)
|
||||
var httpClient = HttpClient();
|
||||
httpClient.idleTimeout = Duration(seconds: 5);
|
||||
|
||||
//构造URI,设置user-agent为"Custom-UA"
|
||||
var uri = Uri.parse("https://flutter.dev");
|
||||
var request = await httpClient.getUrl(uri);
|
||||
request.headers.add("user-agent", "Custom-UA");
|
||||
|
||||
//发起请求,等待响应
|
||||
var response = await request.close();
|
||||
|
||||
//收到响应,打印结果
|
||||
if (response.statusCode == HttpStatus.ok) {
|
||||
print(await response.transform(utf8.decoder).join());
|
||||
} else {
|
||||
print('Error: \nHttp status ${response.statusCode}');
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
可以看到,使用HttpClient来发起网络调用还是相对比较简单的。
|
||||
|
||||
这里需要注意的是,由于网络请求是异步行为,因此**在Flutter中,所有网络编程框架都是以Future作为异步请求的包装**,所以我们需要使用await与async进行非阻塞的等待。当然,你也可以注册then,以回调的方式进行相应的事件处理。
|
||||
|
||||
### http
|
||||
|
||||
HttpClient使用方式虽然简单,但其接口却暴露了不少内部实现细节。比如,异步调用拆分得过细,链接需要调用方主动关闭,请求结果是字符串但却需要手动解码等。
|
||||
|
||||
http是Dart官方提供的另一个网络请求类,相比于HttpClient,易用性提升了不少。同样,我们以一个例子来介绍http的使用方法。
|
||||
|
||||
首先,我们需要将http加入到pubspec中的依赖里:
|
||||
|
||||
```
|
||||
dependencies:
|
||||
http: '>=0.11.3+12'
|
||||
|
||||
```
|
||||
|
||||
在下面的代码中,与HttpClient的例子类似的,我们也是先后构造了http网络调用实例和Flutter官网URI,在设置user-agent为Custom-UA后,发出请求,最后打印请求结果:
|
||||
|
||||
```
|
||||
httpGet() async {
|
||||
//创建网络调用示例
|
||||
var client = http.Client();
|
||||
|
||||
//构造URI
|
||||
var uri = Uri.parse("https://flutter.dev");
|
||||
|
||||
//设置user-agent为"Custom-UA",随后立即发出请求
|
||||
http.Response response = await client.get(uri, headers : {"user-agent" : "Custom-UA"});
|
||||
|
||||
//打印请求结果
|
||||
if(response.statusCode == HttpStatus.ok) {
|
||||
print(response.body);
|
||||
} else {
|
||||
print("Error: ${response.statusCode}");
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
可以看到,相比于HttpClient,http的使用方式更加简单,仅需一次异步调用就可以实现基本的网络通信。
|
||||
|
||||
### dio
|
||||
|
||||
HttpClient和http使用方式虽然简单,但其暴露的定制化能力都相对较弱,很多常用的功能都不支持(或者实现异常繁琐),比如取消请求、定制拦截器、Cookie管理等。因此对于复杂的网络请求行为,我推荐使用目前在Dart社区人气较高的第三方dio来发起网络请求。
|
||||
|
||||
接下来,我通过几个例子来和你介绍dio的使用方法。与http类似的,我们首先需要把dio加到pubspec中的依赖里:
|
||||
|
||||
```
|
||||
dependencies:
|
||||
dio: '>2.1.3'
|
||||
|
||||
```
|
||||
|
||||
在下面的代码中,与前面HttpClient与http例子类似的,我们也是先后创建了dio网络调用实例、创建URI、设置Header、发出请求,最后等待请求结果:
|
||||
|
||||
```
|
||||
void getRequest() async {
|
||||
//创建网络调用示例
|
||||
Dio dio = new Dio();
|
||||
|
||||
//设置URI及请求user-agent后发起请求
|
||||
var response = await dio.get("https://flutter.dev", options:Options(headers: {"user-agent" : "Custom-UA"}));
|
||||
|
||||
//打印请求结果
|
||||
if(response.statusCode == HttpStatus.ok) {
|
||||
print(response.data.toString());
|
||||
} else {
|
||||
print("Error: ${response.statusCode}");
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
>
|
||||
这里需要注意的是,创建URI、设置Header及发出请求的行为,都是通过dio.get方法实现的。这个方法的options参数提供了精细化控制网络请求的能力,可以支持设置Header、超时时间、Cookie、请求方法等。这部分内容不是今天分享的重点,如果你想深入理解的话,可以访问其[API文档](https://github.com/flutterchina/dio#dio-apis)学习具体使用方法。
|
||||
|
||||
|
||||
对于常见的上传及下载文件需求,dio也提供了良好的支持:文件上传可以通过构建表单FormData实现,而文件下载则可以使用download方法搞定。
|
||||
|
||||
在下面的代码中,我们通过FormData创建了两个待上传的文件,通过post方法发送至服务端。download的使用方法则更为简单,我们直接在请求参数中,把待下载的文件地址和本地文件名提供给dio即可。如果我们需要感知下载进度,可以增加onReceiveProgress回调函数:
|
||||
|
||||
```
|
||||
//使用FormData表单构建待上传文件
|
||||
FormData formData = FormData.from({
|
||||
"file1": UploadFileInfo(File("./file1.txt"), "file1.txt"),
|
||||
"file2": UploadFileInfo(File("./file2.txt"), "file1.txt"),
|
||||
|
||||
});
|
||||
//通过post方法发送至服务端
|
||||
var responseY = await dio.post("https://xxx.com/upload", data: formData);
|
||||
print(responseY.toString());
|
||||
|
||||
//使用download方法下载文件
|
||||
dio.download("https://xxx.com/file1", "xx1.zip");
|
||||
|
||||
//增加下载进度回调函数
|
||||
dio.download("https://xxx.com/file1", "xx2.zip", onReceiveProgress: (count, total) {
|
||||
//do something
|
||||
});
|
||||
|
||||
```
|
||||
|
||||
有时,我们的页面由多个并行的请求响应结果构成,这就需要等待这些请求都返回后才能刷新界面。在dio中,我们可以结合Future.wait方法轻松实现:
|
||||
|
||||
```
|
||||
//同时发起两个并行请求
|
||||
List<Response> responseX= await Future.wait([dio.get("https://flutter.dev"),dio.get("https://pub.dev/packages/dio")]);
|
||||
|
||||
//打印请求1响应结果
|
||||
print("Response1: ${responseX[0].toString()}");
|
||||
//打印请求2响应结果
|
||||
print("Response2: ${responseX[1].toString()}");
|
||||
|
||||
```
|
||||
|
||||
此外,与Android的okHttp一样,dio还提供了请求拦截器,通过拦截器,我们可以在请求之前,或响应之后做一些特殊的操作。比如可以为请求option统一增加一个header,或是返回缓存数据,或是增加本地校验处理等等。
|
||||
|
||||
在下面的例子中,我们为dio增加了一个拦截器。在请求发送之前,不仅为每个请求头都加上了自定义的user-agent,还实现了基本的token认证信息检查功能。而对于本地已经缓存了请求uri资源的场景,我们可以直接返回缓存数据,避免再次下载:
|
||||
|
||||
```
|
||||
//增加拦截器
|
||||
dio.interceptors.add(InterceptorsWrapper(
|
||||
onRequest: (RequestOptions options){
|
||||
//为每个请求头都增加user-agent
|
||||
options.headers["user-agent"] = "Custom-UA";
|
||||
//检查是否有token,没有则直接报错
|
||||
if(options.headers['token'] == null) {
|
||||
return dio.reject("Error:请先登录");
|
||||
}
|
||||
//检查缓存是否有数据
|
||||
if(options.uri == Uri.parse('http://xxx.com/file1')) {
|
||||
return dio.resolve("返回缓存数据");
|
||||
}
|
||||
//放行请求
|
||||
return options;
|
||||
}
|
||||
));
|
||||
|
||||
//增加try catch,防止请求报错
|
||||
try {
|
||||
var response = await dio.get("https://xxx.com/xxx.zip");
|
||||
print(response.data.toString());
|
||||
}catch(e) {
|
||||
print(e);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
需要注意的是,由于网络通信期间有可能会出现异常(比如,域名无法解析、超时等),因此我们需要使用try-catch来捕获这些未知错误,防止程序出现异常。
|
||||
|
||||
除了这些基本的用法,dio还支持请求取消、设置代理,证书校验等功能。不过,这些高级特性不属于本次分享的重点,故不再赘述,详情可以参考dio的[GitHub主页](https://github.com/flutterchina/dio/blob/master/README-ZH.md)了解具体用法。
|
||||
|
||||
## JSON解析
|
||||
|
||||
移动应用与Web服务器建立好了连接之后,接下来的两个重要工作分别是:服务器如何结构化地去描述返回的通信信息,以及移动应用如何解析这些格式化的信息。
|
||||
|
||||
### 如何结构化地描述返回的通信信息?
|
||||
|
||||
在如何结构化地去表达信息上,我们需要用到JSON。JSON是一种轻量级的、用于表达由属性值和字面量组成对象的数据交换语言。
|
||||
|
||||
一个简单的表示学生成绩的JSON结构,如下所示:
|
||||
|
||||
```
|
||||
String jsonString = '''
|
||||
{
|
||||
"id":"123",
|
||||
"name":"张三",
|
||||
"score" : 95
|
||||
}
|
||||
''';
|
||||
|
||||
```
|
||||
|
||||
需要注意的是,由于Flutter不支持运行时反射,因此并没有提供像Gson、Mantle这样自动解析JSON的库来降低解析成本。在Flutter中,JSON解析完全是手动的,开发者要做的事情多了一些,但使用起来倒也相对灵活。
|
||||
|
||||
接下来,我们就看看Flutter应用是如何解析这些格式化的信息。
|
||||
|
||||
### 如何解析格式化的信息?
|
||||
|
||||
所谓手动解析,是指使用dart:convert库中内置的JSON解码器,将JSON字符串解析成自定义对象的过程。使用这种方式,我们需要先将JSON字符串传递给JSON.decode方法解析成一个Map,然后把这个Map传给自定义的类,进行相关属性的赋值。
|
||||
|
||||
以上面表示学生成绩的JSON结构为例,我来和你演示手动解析的使用方法。
|
||||
|
||||
首先,我们根据JSON结构定义Student类,并创建一个工厂类,来处理Student类属性成员与JSON字典对象的值之间的映射关系:
|
||||
|
||||
```
|
||||
class Student{
|
||||
//属性id,名字与成绩
|
||||
String id;
|
||||
String name;
|
||||
int score;
|
||||
//构造方法
|
||||
Student({
|
||||
this.id,
|
||||
this.name,
|
||||
this.score
|
||||
});
|
||||
//JSON解析工厂类,使用字典数据为对象初始化赋值
|
||||
factory Student.fromJson(Map<String, dynamic> parsedJson){
|
||||
return Student(
|
||||
id: parsedJson['id'],
|
||||
name : parsedJson['name'],
|
||||
score : parsedJson ['score']
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
数据解析类创建好了,剩下的事情就相对简单了,我们只需要把JSON文本通过JSON.decode方法转换成Map,然后把它交给Student的工厂类fromJson方法,即可完成Student对象的解析:
|
||||
|
||||
```
|
||||
loadStudent() {
|
||||
//jsonString为JSON文本
|
||||
final jsonResponse = json.decode(jsonString);
|
||||
Student student = Student.fromJson(jsonResponse);
|
||||
print(student.name);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在上面的例子中,JSON文本所有的属性都是基本类型,因此我们直接从JSON字典取出相应的元素为对象赋值即可。而如果JSON下面还有嵌套对象属性,比如下面的例子中,Student还有一个teacher的属性,我们又该如何解析呢?
|
||||
|
||||
```
|
||||
String jsonString = '''
|
||||
{
|
||||
"id":"123",
|
||||
"name":"张三",
|
||||
"score" : 95,
|
||||
"teacher": {
|
||||
"name": "李四",
|
||||
"age" : 40
|
||||
}
|
||||
}
|
||||
''';
|
||||
|
||||
```
|
||||
|
||||
这里,teacher不再是一个基本类型,而是一个对象。面对这种情况,我们需要为每一个非基本类型属性创建一个解析类。与Student类似,我们也需要为它的属性teacher创建一个解析类Teacher:
|
||||
|
||||
```
|
||||
class Teacher {
|
||||
//Teacher的名字与年龄
|
||||
String name;
|
||||
int age;
|
||||
//构造方法
|
||||
Teacher({this.name,this.age});
|
||||
//JSON解析工厂类,使用字典数据为对象初始化赋值
|
||||
factory Teacher.fromJson(Map<String, dynamic> parsedJson){
|
||||
return Teacher(
|
||||
name : parsedJson['name'],
|
||||
age : parsedJson ['age']
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
然后,我们只需要在Student类中,增加teacher属性及对应的JSON映射规则即可:
|
||||
|
||||
```
|
||||
class Student{
|
||||
...
|
||||
//增加teacher属性
|
||||
Teacher teacher;
|
||||
//构造函数增加teacher
|
||||
Student({
|
||||
...
|
||||
this.teacher
|
||||
});
|
||||
factory Student.fromJson(Map<String, dynamic> parsedJson){
|
||||
return Student(
|
||||
...
|
||||
//增加映射规则
|
||||
teacher: Teacher.fromJson(parsedJson ['teacher'])
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
完成了teacher属性的映射规则添加之后,我们就可以继续使用Student来解析上述的JSON文本了:
|
||||
|
||||
```
|
||||
final jsonResponse = json.decode(jsonString);//将字符串解码成Map对象
|
||||
Student student = Student.fromJson(jsonResponse);//手动解析
|
||||
print(student.teacher.name);
|
||||
|
||||
```
|
||||
|
||||
可以看到,通过这种方法,无论对象有多复杂的非基本类型属性,我们都可以创建对应的解析类进行处理。
|
||||
|
||||
不过到现在为止,我们的JSON数据解析还是在主Isolate中完成。如果JSON的数据格式比较复杂,数据量又大,这种解析方式可能会造成短期UI无法响应。对于这类CPU密集型的操作,我们可以使用上一篇文章中提到的compute函数,将解析工作放到新的Isolate中完成:
|
||||
|
||||
```
|
||||
static Student parseStudent(String content) {
|
||||
final jsonResponse = json.decode(content);
|
||||
Student student = Student.fromJson(jsonResponse);
|
||||
return student;
|
||||
}
|
||||
doSth() {
|
||||
...
|
||||
//用compute函数将json解析放到新Isolate
|
||||
compute(parseStudent,jsonString).then((student)=>print(student.teacher.name));
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
通过compute的改造,我们就不用担心JSON解析时间过长阻塞UI响应了。
|
||||
|
||||
## 总结
|
||||
|
||||
好了,今天的分享就到这里了,我们简单回顾一下主要内容。
|
||||
|
||||
首先,我带你学习了实现Flutter应用与服务端通信的三种方式,即HttpClient、http与dio。其中dio提供的功能更为强大,可以支持请求拦截、文件上传下载、请求合并等高级能力。因此,我推荐你在实际项目中使用dio的方式。
|
||||
|
||||
然后,我和你分享了JSON解析的相关内容。JSON解析在Flutter中相对比较简单,但由于不支持反射,所以我们只能手动解析,即:先将JSON字符串转换成Map,然后再把这个Map给到自定义类,进行相关属性的赋值。
|
||||
|
||||
如果你有原生Android、iOS开发经验的话,可能会觉得Flutter提供的JSON手动解析方案并不好用。在Flutter中,没有像原生开发那样提供了Gson或Mantle等库,用于将JSON字符串直接转换为对应的实体类。而这些能力无一例外都需要用到运行时反射,这是Flutter从设计之初就不支持的,理由如下:
|
||||
|
||||
1. 运行时反射破坏了类的封装性和安全性,会带来安全风险。就在前段时间,Fastjson框架就爆出了一个巨大的安全漏洞。这个漏洞使得精心构造的字符串文本,可以在反序列化时让服务器执行任意代码,直接导致业务机器被远程控制、内网渗透、窃取敏感信息等操作。
|
||||
1. 运行时反射会增加二进制文件大小。因为搞不清楚哪些代码可能会在运行时用到,因此使用反射后,会默认使用所有代码构建应用程序,这就导致编译器无法优化编译期间未使用的代码,应用安装包体积无法进一步压缩,这对于自带Dart虚拟机的Flutter应用程序是难以接受的。
|
||||
|
||||
反射给开发者编程带来了方便,但也带来了很多难以解决的新问题,因此Flutter并不支持反射。而我们要做的就是,老老实实地手动解析JSON吧。
|
||||
|
||||
我把今天分享所涉及到的知识点打包到了[GitHub](https://github.com/cyndibaby905/24_network_demo)中,你可以下载下来,反复运行几次,加深理解与记忆。
|
||||
|
||||
## 思考题
|
||||
|
||||
最后,我给你留两道思考题吧。
|
||||
|
||||
1. 请使用dio实现一个自定义拦截器,拦截器内检查header中的token:如果没有token,需要暂停本次请求,同时访问"[http://xxxx.com/token](http://xxxx.com/token)",在获取新token后继续本次请求。
|
||||
1. 为以下Student JSON写相应的解析类:
|
||||
|
||||
```
|
||||
String jsonString = '''
|
||||
{
|
||||
"id":"123",
|
||||
"name":"张三",
|
||||
"score" : 95,
|
||||
"teachers": [
|
||||
{
|
||||
"name": "李四",
|
||||
"age" : 40
|
||||
},
|
||||
{
|
||||
"name": "王五",
|
||||
"age" : 45
|
||||
}
|
||||
]
|
||||
}
|
||||
''';
|
||||
|
||||
```
|
||||
|
||||
欢迎你在评论区给我留言分享你的观点,我会在下一篇文章中等待你!感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。
|
||||
|
||||
|
||||
253
极客时间专栏/Flutter核心技术与实战/Flutter进阶/25 | 本地存储与数据库的使用和优化.md
Normal file
253
极客时间专栏/Flutter核心技术与实战/Flutter进阶/25 | 本地存储与数据库的使用和优化.md
Normal file
@@ -0,0 +1,253 @@
|
||||
<audio id="audio" title="25 | 本地存储与数据库的使用和优化" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/19/ff/19192dd42e8278eb4db4a46ec15d82ff.mp3"></audio>
|
||||
|
||||
你好,我是陈航。
|
||||
|
||||
在上一篇文章中,我带你一起学习了Flutter的网络编程,即如何建立与Web服务器的通信连接,以实现数据交换,以及如何解析结构化后的通信信息。
|
||||
|
||||
其中,建立通信连接在Flutter中有三种基本方案,包括HttpClient、http与dio。考虑到HttpClient与http并不支持复杂的网络请求行为,因此我重点介绍了如何使用dio实现资源访问、接口数据请求与提交、上传及下载文件、网络拦截等高级操作。
|
||||
|
||||
而关于如何解析信息,由于Flutter并不支持反射,因此只提供了手动解析JSON的方式:把JSON转换成字典,然后给自定义的类属性赋值即可。
|
||||
|
||||
正因为有了网络,我们的App拥有了与外界进行信息交换的通道,也因此具备了更新数据的能力。不过,经过交换后的数据通常都保存在内存中,而应用一旦运行结束,内存就会被释放,这些数据也就随之消失了。
|
||||
|
||||
因此,我们需要把这些更新后的数据以一定的形式,通过一定的载体保存起来,这样应用下次运行时,就可以把数据从存储的载体中读出来,也就实现了**数据的持久化**。
|
||||
|
||||
数据持久化的应用场景有很多。比如,用户的账号登录信息需要保存,用于每次与Web服务验证身份;又比如,下载后的图片需要缓存,避免每次都要重新加载,浪费用户流量。
|
||||
|
||||
由于Flutter仅接管了渲染层,真正涉及到存储等操作系统底层行为时,还需要依托于原生Android、iOS,因此与原生开发类似的,根据需要持久化数据的大小和方式不同,Flutter提供了三种数据持久化方法,即文件、SharedPreferences与数据库。接下来,我将与你详细讲述这三种方式。
|
||||
|
||||
## 文件
|
||||
|
||||
文件是存储在某种介质(比如磁盘)上指定路径的、具有文件名的一组有序信息的集合。从其定义看,要想以文件的方式实现数据持久化,我们首先需要确定一件事儿:数据放在哪儿?这,就意味着要定义文件的存储路径。
|
||||
|
||||
Flutter提供了两种文件存储的目录,即**临时(Temporary)目录与文档(Documents)目录**:
|
||||
|
||||
- 临时目录是操作系统可以随时清除的目录,通常被用来存放一些不重要的临时缓存数据。这个目录在iOS上对应着NSTemporaryDirectory返回的值,而在Android上则对应着getCacheDir返回的值。
|
||||
- 文档目录则是只有在删除应用程序时才会被清除的目录,通常被用来存放应用产生的重要数据文件。在iOS上,这个目录对应着NSDocumentDirectory,而在Android上则对应着AppData目录。
|
||||
|
||||
接下来,我通过一个例子与你演示如何在Flutter中实现文件读写。
|
||||
|
||||
在下面的代码中,我分别声明了三个函数,即创建文件目录函数、写文件函数与读文件函数。这里需要注意的是,由于文件读写是非常耗时的操作,所以这些操作都需要在异步环境下进行。另外,为了防止文件读取过程中出现异常,我们也需要在外层包上try-catch:
|
||||
|
||||
```
|
||||
//创建文件目录
|
||||
Future<File> get _localFile async {
|
||||
final directory = await getApplicationDocumentsDirectory();
|
||||
final path = directory.path;
|
||||
return File('$path/content.txt');
|
||||
}
|
||||
//将字符串写入文件
|
||||
Future<File> writeContent(String content) async {
|
||||
final file = await _localFile;
|
||||
return file.writeAsString(content);
|
||||
}
|
||||
//从文件读出字符串
|
||||
Future<String> readContent() async {
|
||||
try {
|
||||
final file = await _localFile;
|
||||
String contents = await file.readAsString();
|
||||
return contents;
|
||||
} catch (e) {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
有了文件读写函数,我们就可以在代码中对content.txt这个文件进行读写操作了。在下面的代码中,我们往这个文件写入了一段字符串后,隔了一会又把它读了出来:
|
||||
|
||||
```
|
||||
writeContent("Hello World!");
|
||||
...
|
||||
readContent().then((value)=>print(value));
|
||||
|
||||
```
|
||||
|
||||
除了字符串读写之外,Flutter还提供了二进制流的读写能力,可以支持图片、压缩包等二进制文件的读写。这些内容不是本次分享的重点,如果你想要深入研究的话,可以查阅[官方文档](https://api.flutter.dev/flutter/dart-io/File-class.html)。
|
||||
|
||||
## SharedPreferences
|
||||
|
||||
文件比较适合大量的、有序的数据持久化,如果我们只是需要缓存少量的键值对信息(比如记录用户是否阅读了公告,或是简单的计数),则可以使用SharedPreferences。
|
||||
|
||||
SharedPreferences会以原生平台相关的机制,为简单的键值对数据提供持久化存储,即在iOS上使用NSUserDefaults,在Android使用SharedPreferences。
|
||||
|
||||
接下来,我通过一个例子来演示在Flutter中如何通过SharedPreferences实现数据的读写。在下面的代码中,我们将计数器持久化到了SharedPreferences中,并为它分别提供了读方法和递增写入的方法。
|
||||
|
||||
这里需要注意的是,setter(setInt)方法会同步更新内存中的键值对,然后将数据保存至磁盘,因此我们无需再调用更新方法强制刷新缓存。同样地,由于涉及到耗时的文件读写,因此我们必须以异步的方式对这些操作进行包装:
|
||||
|
||||
```
|
||||
//读取SharedPreferences中key为counter的值
|
||||
Future<int>_loadCounter() async {
|
||||
SharedPreferences prefs = await SharedPreferences.getInstance();
|
||||
int counter = (prefs.getInt('counter') ?? 0);
|
||||
return counter;
|
||||
}
|
||||
|
||||
//递增写入SharedPreferences中key为counter的值
|
||||
Future<void>_incrementCounter() async {
|
||||
SharedPreferences prefs = await SharedPreferences.getInstance();
|
||||
int counter = (prefs.getInt('counter') ?? 0) + 1;
|
||||
prefs.setInt('counter', counter);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在完成了计数器存取方法的封装后,我们就可以在代码中随时更新并持久化计数器数据了。在下面的代码中,我们先是读取并打印了计数器数据,随后将其递增,并再次把它读取打印:
|
||||
|
||||
```
|
||||
//读出counter数据并打印
|
||||
_loadCounter().then((value)=>print("before:$value"));
|
||||
|
||||
//递增counter数据后,再次读出并打印
|
||||
_incrementCounter().then((_) {
|
||||
_loadCounter().then((value)=>print("after:$value"));
|
||||
});
|
||||
|
||||
```
|
||||
|
||||
可以看到,SharedPreferences的使用方式非常简单方便。不过需要注意的是,以键值对的方式只能存储基本类型的数据,比如int、double、bool和string。
|
||||
|
||||
## 数据库
|
||||
|
||||
SharedPrefernces的使用固然方便,但这种方式只适用于持久化少量数据的场景,我们并不能用它来存储大量数据,比如文件内容(文件路径是可以的)。
|
||||
|
||||
如果我们需要持久化大量格式化后的数据,并且这些数据还会以较高的频率更新,为了考虑进一步的扩展性,我们通常会选用sqlite数据库来应对这样的场景。与文件和SharedPreferences相比,数据库在数据读写上可以提供更快、更灵活的解决方案。
|
||||
|
||||
接下来,我就以一个例子分别与你介绍数据库的使用方法。
|
||||
|
||||
我们以上一篇文章中提到的Student类为例:
|
||||
|
||||
```
|
||||
class Student{
|
||||
String id;
|
||||
String name;
|
||||
int score;
|
||||
//构造方法
|
||||
Student({this.id, this.name, this.score,});
|
||||
//用于将JSON字典转换成类对象的工厂类方法
|
||||
factory Student.fromJson(Map<String, dynamic> parsedJson){
|
||||
return Student(
|
||||
id: parsedJson['id'],
|
||||
name : parsedJson['name'],
|
||||
score : parsedJson ['score'],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
JSON类拥有一个可以将JSON字典转换成类对象的工厂类方法,我们也可以提供将类对象反过来转换成JSON字典的实例方法。因为最终存入数据库的并不是实体类对象,而是字符串、整型等基本类型组成的字典,所以我们可以通过这两个方法,实现数据库的读写。同时,我们还分别定义了3个Student对象,用于后续插入数据库:
|
||||
|
||||
```
|
||||
class Student{
|
||||
...
|
||||
//将类对象转换成JSON字典,方便插入数据库
|
||||
Map<String, dynamic> toJson() {
|
||||
return {'id': id, 'name': name, 'score': score,};
|
||||
}
|
||||
}
|
||||
|
||||
var student1 = Student(id: '123', name: '张三', score: 90);
|
||||
var student2 = Student(id: '456', name: '李四', score: 80);
|
||||
var student3 = Student(id: '789', name: '王五', score: 85);
|
||||
|
||||
```
|
||||
|
||||
有了实体类作为数据库存储的对象,接下来就需要创建数据库了。在下面的代码中,我们通过openDatabase函数,给定了一个数据库存储地址,并通过数据库表初始化语句,创建了一个用于存放Student对象的students表:
|
||||
|
||||
```
|
||||
final Future<Database> database = openDatabase(
|
||||
join(await getDatabasesPath(), 'students_database.db'),
|
||||
onCreate: (db, version)=>db.execute("CREATE TABLE students(id TEXT PRIMARY KEY, name TEXT, score INTEGER)"),
|
||||
onUpgrade: (db, oldVersion, newVersion){
|
||||
//dosth for migration
|
||||
},
|
||||
version: 1,
|
||||
);
|
||||
|
||||
```
|
||||
|
||||
以上代码属于通用的数据库创建模板,有三个地方需要注意:
|
||||
|
||||
1. 在设定数据库存储地址时,使用join方法对两段地址进行拼接。join方法在拼接时会使用操作系统的路径分隔符,这样我们就无需关心路径分隔符究竟是“/”还是“\”了。
|
||||
1. 创建数据库时,传入了一个version 1,在onCreate方法的回调里面也有一个version。这两个version是相等的。
|
||||
<li>数据库只会创建一次,也就意味着onCreate方法在应用从安装到卸载的生命周期中只会执行一次。如果我们在版本升级过程中,想对数据库的存储字段进行改动又该如何处理呢?<br>
|
||||
sqlite提供了onUpgrade方法,我们可以根据这个方法传入的oldVersion和newVersion确定升级策略。其中,前者代表用户手机上的数据库版本,而后者代表当前版本的数据库版本。比如,我们的应用有1.0、1.1和1.2三个版本,在1.1把数据库version升级到了2。考虑到用户的升级顺序并不总是连续的,可能会直接从1.0升级到1.2,因此我们可以在onUpgrade函数中,对数据库当前版本和用户手机上的数据库版本进行比较,制定数据库升级方案。</li>
|
||||
|
||||
数据库创建好了之后,接下来我们就可以把之前创建的3个Student对象插入到数据库中了。数据库的插入需要调用insert方法,在下面的代码中,我们将Student对象转换成了JSON,在指定了插入冲突策略(如果同样的对象被插入两次,则后者替换前者)和目标数据库表后,完成了Student对象的插入:
|
||||
|
||||
```
|
||||
Future<void> insertStudent(Student std) async {
|
||||
final Database db = await database;
|
||||
await db.insert(
|
||||
'students',
|
||||
std.toJson(),
|
||||
//插入冲突策略,新的替换旧的
|
||||
conflictAlgorithm: ConflictAlgorithm.replace,
|
||||
);
|
||||
}
|
||||
//插入3个Student对象
|
||||
await insertStudent(student1);
|
||||
await insertStudent(student2);
|
||||
await insertStudent(student3);
|
||||
|
||||
```
|
||||
|
||||
数据完成插入之后,接下来我们就可以调用query方法把它们取出来了。需要注意的是,写入的时候我们是一个接一个地有序插入,读的时候我们则采用批量读的方式(当然也可以指定查询规则读特定对象)。读出来的数据是一个JSON字典数组,因此我们还需要把它转换成Student数组。最后,别忘了把数据库资源释放掉:
|
||||
|
||||
```
|
||||
Future<List<Student>> students() async {
|
||||
final Database db = await database;
|
||||
final List<Map<String, dynamic>> maps = await db.query('students');
|
||||
return List.generate(maps.length, (i)=>Student.fromJson(maps[i]));
|
||||
}
|
||||
|
||||
//读取出数据库中插入的Student对象集合
|
||||
students().then((list)=>list.forEach((s)=>print(s.name)));
|
||||
//释放数据库资源
|
||||
final Database db = await database;
|
||||
db.close();
|
||||
|
||||
```
|
||||
|
||||
可以看到,在面对大量格式化的数据模型读取时,数据库提供了更快、更灵活的持久化解决方案。
|
||||
|
||||
除了基础的数据库读写操作之外,sqlite还提供了更新、删除以及事务等高级特性,这与原生Android、iOS上的SQLite或是MySQL并无不同,因此这里就不再赘述了。你可以参考sqflite插件的[API文档](https://pub.dev/documentation/sqflite/latest/),或是查阅[SQLite教程](http://www.sqlitetutorial.net/)了解具体的使用方法。
|
||||
|
||||
## 总结
|
||||
|
||||
好了,今天的分享就这里。我们简单回顾下今天学习的内容吧。
|
||||
|
||||
首先,我带你学习了文件,这种最常见的数据持久化方式。Flutter提供了两类目录,即临时目录与文档目录。我们可以根据实际需求,通过写入字符串或二进制流,实现数据的持久化。
|
||||
|
||||
然后,我通过一个小例子和你讲述了SharedPreferences,这种适用于持久化小型键值对的存储方案。
|
||||
|
||||
最后,我们一起学习了数据库。围绕如何将一个对象持久化到数据库,我与你介绍了数据库的创建、写入和读取方法。可以看到,使用数据库的方式虽然前期准备工作多了不少,但面对持续变更的需求,适配能力和灵活性都更强了。
|
||||
|
||||
数据持久化是CPU密集型运算,因此数据存取均会大量涉及到异步操作,所以请务必使用异步等待或注册then回调,正确处理读写操作的时序关系。
|
||||
|
||||
我把今天分享所涉及到的知识点打包到了[GitHub](https://github.com/cyndibaby905/25_data_persistence)中,你可以下载下来,反复运行几次,加深理解与记忆。
|
||||
|
||||
## 思考题
|
||||
|
||||
最后,我给你留下两道思考题吧。
|
||||
|
||||
1. 请你分别介绍一下文件、SharedPreferences和数据库,这三种持久化数据存储方式的适用场景。
|
||||
1. 我们的应用经历了1.0、1.1和1.2三个版本。其中,1.0版本新建了数据库并创建了Student表,1.1版本将Student表增加了一个字段age(ALTER TABLE students ADD age INTEGER)。请你写出1.1版本及1.2版本的数据库升级代码。
|
||||
|
||||
```
|
||||
//1.0版本数据库创建代码
|
||||
final Future<Database> database = openDatabase(
|
||||
join(await getDatabasesPath(), 'students_database.db'),
|
||||
onCreate: (db, version)=>db.execute("CREATE TABLE students(id TEXT PRIMARY KEY, name TEXT, score INTEGER)"),
|
||||
onUpgrade: (db, oldVersion, newVersion){
|
||||
//dosth for migration
|
||||
},
|
||||
version: 1,
|
||||
);
|
||||
|
||||
```
|
||||
|
||||
欢迎你在评论区给我留言分享你的观点,我会在下一篇文章中等待你!感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。
|
||||
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
<audio id="audio" title="26 | 如何在Dart层兼容Android/iOS平台特定实现?(一)" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/df/da/df801d06e3f23c2f9744004134f37bda.mp3"></audio>
|
||||
|
||||
你好,我是陈航。
|
||||
|
||||
在上一篇文章中,我与你介绍了在Flutter中实现数据持久化的三种方式,即文件、SharedPreferences与数据库。
|
||||
|
||||
其中,文件适用于字符串或者二进制流的数据持久化,我们可以根据访问频次,决定将它存在临时目录或是文档目录。而SharedPreferences则适用于存储小型键值对信息,可以应对一些轻量配置缓存的场景。数据库则适用于频繁变化的、结构化的对象存取,可以轻松应对数据的增删改查。
|
||||
|
||||
依托于与Skia的深度定制及优化,Flutter给我们提供了很多关于渲染的控制和支持,能够实现绝对的跨平台应用层渲染一致性。但对于一个应用而言,除了应用层视觉显示和对应的交互逻辑处理之外,有时还需要原生操作系统(Android、iOS)提供的底层能力支持。比如,我们前面提到的数据持久化,以及推送、摄像头硬件调用等。
|
||||
|
||||
由于Flutter只接管了应用渲染层,因此这些系统底层能力是无法在Flutter框架内提供支持的;而另一方面,Flutter还是一个相对年轻的生态,因此原生开发中一些相对成熟的Java、C++或Objective-C代码库,比如图片处理、音视频编解码等,可能在Flutter中还没有相关实现。
|
||||
|
||||
因此,为了解决调用原生系统底层能力以及相关代码库复用问题,Flutter为开发者提供了一个轻量级的解决方案,即逻辑层的方法通道(Method Channel)机制。基于方法通道,我们可以将原生代码所拥有的能力,以接口形式暴露给Dart,从而实现Dart代码与原生代码的交互,就像调用了一个普通的Dart API一样。
|
||||
|
||||
接下来,我就与你详细讲述Flutter的方法通道机制吧。
|
||||
|
||||
## 方法通道
|
||||
|
||||
Flutter作为一个跨平台框架,提供了一套标准化的解决方案,为开发者屏蔽了操作系统的差异。但,Flutter毕竟不是操作系统,因此在某些特定场景下(比如推送、蓝牙、摄像头硬件调用时),也需要具备直接访问系统底层原生代码的能力。为此,Flutter提供了一套灵活而轻量级的机制来实现Dart和原生代码之间的通信,即方法调用的消息传递机制,而方法通道则是用来传递通信消息的信道。
|
||||
|
||||
一次典型的方法调用过程类似网络调用,由作为客户端的Flutter,通过方法通道向作为服务端的原生代码宿主发送方法调用请求,原生代码宿主在监听到方法调用的消息后,调用平台相关的API来处理Flutter发起的请求,最后将处理完毕的结果通过方法通道回发至Flutter。调用过程如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/a8/9c/a8a5cec456e66323e045318d7c5f4d9c.png" alt="">
|
||||
|
||||
从上图中可以看到,方法调用请求的处理和响应,在Android中是通过FlutterView,而在iOS中则是通过FlutterViewController进行注册的。FlutterView与FlutterViewController为Flutter应用提供了一个画板,使得构建于Skia之上的Flutter通过绘制即可实现整个应用所需的视觉效果。因此,它们不仅是Flutter应用的容器,同时也是Flutter应用的入口,自然也是注册方法调用请求最合适的地方。
|
||||
|
||||
接下来,我通过一个例子来演示如何使用方法通道实现与原生代码的交互。
|
||||
|
||||
## 方法通道使用示例
|
||||
|
||||
在实际业务中,提示用户跳转到应用市场(iOS为App Store、Android则为各类手机应用市场)去评分是一个高频需求,考虑到Flutter并未提供这样的接口,而跳转方式在Android和iOS上各不相同,因此我们需要分别在Android和iOS上实现这样的功能,并暴露给Dart相关的接口。
|
||||
|
||||
我们先来看看作为客户端的Flutter,怎样实现一次方法调用请求。
|
||||
|
||||
### Flutter如何实现一次方法调用请求?
|
||||
|
||||
首先,我们需要确定一个唯一的字符串标识符,来构造一个命名通道;然后,在这个通道之上,Flutter通过指定方法名“openAppMarket”来发起一次方法调用请求。
|
||||
|
||||
可以看到,这和我们平时调用一个Dart对象的方法完全一样。因为方法调用过程是异步的,所以我们需要使用非阻塞(或者注册回调)来等待原生代码给予响应。
|
||||
|
||||
```
|
||||
//声明MethodChannel
|
||||
const platform = MethodChannel('samples.chenhang/utils');
|
||||
|
||||
//处理按钮点击
|
||||
handleButtonClick() async{
|
||||
int result;
|
||||
//异常捕获
|
||||
try {
|
||||
//异步等待方法通道的调用结果
|
||||
result = await platform.invokeMethod('openAppMarket');
|
||||
}
|
||||
catch (e) {
|
||||
result = -1;
|
||||
}
|
||||
print("Result:$result");
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
需要注意的是,与网络调用类似,方法调用请求有可能会失败(比如,Flutter发起了原生代码不支持的API调用,或是调用过程出错等),因此我们需要把发起方法调用请求的语句用try-catch包装起来。
|
||||
|
||||
调用方的实现搞定了,接下来就需要在原生代码宿主中完成方法调用的响应实现了。由于我们需要适配Android和iOS两个平台,所以我们分别需要在两个平台上完成对应的接口实现。
|
||||
|
||||
### 在原生代码中完成方法调用的响应
|
||||
|
||||
首先,**我们来看看Android端的实现方式**。在上一小结最后我提到,在Android平台,方法调用的处理和响应是在Flutter应用的入口,也就是在MainActivity中的FlutterView里实现的,因此我们需要打开Flutter的Android宿主App,找到MainActivity.java文件,并在其中添加相关的逻辑。
|
||||
|
||||
调用方与响应方都是通过命名通道进行信息交互的,所以我们需要在onCreate方法中,创建一个与调用方Flutter所使用的通道名称一样的MethodChannel,并在其中设置方法处理回调,响应openAppMarket方法,打开应用市场的Intent。同样地,考虑到打开应用市场的过程可能会出错,我们也需要增加try-catch来捕获可能的异常:
|
||||
|
||||
```
|
||||
protected void onCreate(Bundle savedInstanceState) {
|
||||
...
|
||||
//创建与调用方标识符一样的方法通道
|
||||
new MethodChannel(getFlutterView(), "samples.chenhang/utils").setMethodCallHandler(
|
||||
//设置方法处理回调
|
||||
new MethodCallHandler() {
|
||||
//响应方法请求
|
||||
@Override
|
||||
public void onMethodCall(MethodCall call, Result result) {
|
||||
//判断方法名是否支持
|
||||
if(call.method.equals("openAppMarket")) {
|
||||
try {
|
||||
//应用市场URI
|
||||
Uri uri = Uri.parse("market://details?id=com.hangchen.example.flutter_module_page.host");
|
||||
Intent intent = new Intent(Intent.ACTION_VIEW, uri);
|
||||
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
|
||||
//打开应用市场
|
||||
activity.startActivity(intent);
|
||||
//返回处理结果
|
||||
result.success(0);
|
||||
} catch (Exception e) {
|
||||
//打开应用市场出现异常
|
||||
result.error("UNAVAILABLE", "没有安装应用市场", null);
|
||||
}
|
||||
}else {
|
||||
//方法名暂不支持
|
||||
result.notImplemented();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
现在,方法调用响应的Android部分已经搞定,接下来我们来看一下**iOS端的方法调用响应如何实现。**
|
||||
|
||||
在iOS平台,方法调用的处理和响应是在Flutter应用的入口,也就是在Applegate中的rootViewController(即FlutterViewController)里实现的,因此我们需要打开Flutter的iOS宿主App,找到AppDelegate.m文件,并添加相关逻辑。
|
||||
|
||||
与Android注册方法调用响应类似,我们需要在didFinishLaunchingWithOptions:方法中,创建一个与调用方Flutter所使用的通道名称一样的MethodChannel,并在其中设置方法处理回调,响应openAppMarket方法,通过URL打开应用市场:
|
||||
|
||||
```
|
||||
- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
|
||||
//创建命名方法通道
|
||||
FlutterMethodChannel* channel = [FlutterMethodChannel methodChannelWithName:@"samples.chenhang/utils" binaryMessenger:(FlutterViewController *)self.window.rootViewController];
|
||||
//往方法通道注册方法调用处理回调
|
||||
[channel setMethodCallHandler:^(FlutterMethodCall* call, FlutterResult result) {
|
||||
//方法名称一致
|
||||
if ([@"openAppMarket" isEqualToString:call.method]) {
|
||||
//打开App Store(本例打开微信的URL)
|
||||
[[UIApplication sharedApplication] openURL:[NSURL URLWithString:@"itms-apps://itunes.apple.com/xy/app/foo/id414478124"]];
|
||||
//返回方法处理结果
|
||||
result(@0);
|
||||
} else {
|
||||
//找不到被调用的方法
|
||||
result(FlutterMethodNotImplemented);
|
||||
}
|
||||
}];
|
||||
...
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这样,iOS端的方法调用响应也已经实现了。
|
||||
|
||||
接下来,我们就可以在Flutter应用里,通过调用openAppMarket方法,实现打开不同操作系统提供的应用市场功能了。
|
||||
|
||||
需要注意的是,在原生代码处理完毕后将处理结果返回给Flutter时,**我们在Dart、Android和iOS分别用了三种数据类型**:Android端返回的是java.lang.Integer、iOS端返回的是NSNumber、Dart端接收到返回结果时又变成了int类型。这是为什么呢?
|
||||
|
||||
这是因为在使用方法通道进行方法调用时,由于涉及到跨系统数据交互,Flutter会使用StandardMessageCodec对通道中传输的信息进行类似JSON的二进制序列化,以标准化数据传输行为。这样在我们发送或者接收数据时,这些数据就会根据各自系统预定的规则自动进行序列化和反序列化。看到这里,你是不是对这样类似网络调用的方法通道技术有了更深刻的印象呢。
|
||||
|
||||
对于上面提到的例子,类型为java.lang.Integer或NSNumber的返回值,先是被序列化成了一段二进制格式的数据在通道中传输,然后当该数据传递到Flutter后,又被反序列化成了Dart语言中的int类型的数据。
|
||||
|
||||
关于Android、iOS和Dart平台间的常见数据类型转换,我总结成了下面一张表格,帮助你理解与记忆。你只要记住,像null、布尔、整型、字符串、数组和字典这些基本类型,是可以在各个平台之间以平台定义的规则去混用的,就可以了。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/c6/e7/c6f1148978fabe62e4089d7877ecb1e7.png" alt="">
|
||||
|
||||
## 总结
|
||||
|
||||
好了,今天的分享就到这里,我们来总结一下主要内容吧。
|
||||
|
||||
方法通道解决了逻辑层的原生能力复用问题,使得Flutter能够通过轻量级的异步方法调用,实现与原生代码的交互。一次典型的调用过程由Flutter发起方法调用请求开始,请求经由唯一标识符指定的方法通道到达原生代码宿主,而原生代码宿主则通过注册对应方法实现、响应并处理调用请求,最后将执行结果通过消息通道,回传至Flutter。
|
||||
|
||||
需要注意的是,方法通道是非线程安全的。这意味着原生代码与Flutter之间所有接口调用必须发生在主线程。Flutter是单线程模型,因此自然可以确保方法调用请求是发生在主线程(Isolate)的;而原生代码在处理方法调用请求时,如果涉及到异步或非主线程切换,需要确保回调过程是在原生系统的UI线程(也就是Android和iOS的主线程)中执行的,否则应用可能会出现奇怪的Bug,甚至是Crash。
|
||||
|
||||
我把今天分享所涉及到的知识点打包到了[GitHub](https://github.com/cyndibaby905/26_native_method)中,你可以下载下来,反复运行几次,加深理解。
|
||||
|
||||
## 思考题
|
||||
|
||||
最后,我给你留下一道思考题吧。
|
||||
|
||||
请扩展方法通道示例,让openAppMarket支持传入AppID和包名,使得我们可以跳转到任意一个App的应用市场。
|
||||
|
||||
欢迎你在评论区给我留言分享你的观点,我会在下一篇文章中等待你!感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。
|
||||
|
||||
|
||||
@@ -0,0 +1,423 @@
|
||||
<audio id="audio" title="27 | 如何在Dart层兼容Android/iOS平台特定实现?(二)" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/6b/63/6bafa784e653f95a94058ade8fdcea63.mp3"></audio>
|
||||
|
||||
你好,我是陈航。
|
||||
|
||||
在上一篇文章中,我与你介绍了方法通道,这种在Flutter中实现调用原生Android、iOS代码的轻量级解决方案。使用方法通道,我们可以把原生代码所拥有的能力,以接口形式提供给Dart。
|
||||
|
||||
这样,当发起方法调用时,Flutter应用会以类似网络异步调用的方式,将请求数据通过一个唯一标识符指定的方法通道传输至原生代码宿主;而原生代码处理完毕后,会将响应结果通过方法通道回传至Flutter,从而实现Dart代码与原生Android、iOS代码的交互。这,与调用一个本地的Dart 异步API并无太多区别。
|
||||
|
||||
通过方法通道,我们可以把原生操作系统提供的底层能力,以及现有原生开发中一些相对成熟的解决方案,以接口封装的形式在Dart层快速搞定,从而解决原生代码在Flutter上的复用问题。然后,我们可以利用Flutter本身提供的丰富控件,做好UI渲染。
|
||||
|
||||
底层能力+应用层渲染,看似我们已经搞定了搭建一个复杂App的所有内容。但,真的是这样吗?
|
||||
|
||||
## 构建一个复杂App都需要什么?
|
||||
|
||||
别急,在下结论之前,我们先按照四象限分析法,把能力和渲染分解成四个维度,分析构建一个复杂App都需要什么。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/d1/cc/d1826dfb3a8b688db04cbf5beb04f2cc.png" alt="">
|
||||
|
||||
经过分析,我们终于发现,原来构建一个App需要覆盖那么多的知识点,通过Flutter和方法通道只能搞定应用层渲染、应用层能力和底层能力,对于那些涉及到底层渲染,比如浏览器、相机、地图,以及原生自定义视图的场景,自己在Flutter上重新开发一套显然不太现实。
|
||||
|
||||
在这种情况下,使用混合视图看起来是一个不错的选择。我们可以在Flutter的Widget树中提前预留一块空白区域,在Flutter的画板中(即FlutterView与FlutterViewController)嵌入一个与空白区域完全匹配的原生视图,就可以实现想要的视觉效果了。
|
||||
|
||||
但是,采用这种方案极其不优雅,因为嵌入的原生视图并不在Flutter的渲染层级中,需要同时在Flutter侧与原生侧做大量的适配工作,才能实现正常的用户交互体验。
|
||||
|
||||
幸运的是,Flutter提供了一个平台视图(Platform View)的概念。它提供了一种方法,允许开发者在Flutter里面嵌入原生系统(Android和iOS)的视图,并加入到Flutter的渲染树中,实现与Flutter一致的交互体验。
|
||||
|
||||
这样一来,通过平台视图,我们就可以将一个原生控件包装成Flutter控件,嵌入到Flutter页面中,就像使用一个普通的Widget一样。
|
||||
|
||||
接下来,我就与你详细讲述如何使用平台视图。
|
||||
|
||||
## 平台视图
|
||||
|
||||
如果说方法通道解决的是原生能力逻辑复用问题,那么平台视图解决的就是原生视图复用问题。Flutter提供了一种轻量级的方法,让我们可以创建原生(Android和iOS)的视图,通过一些简单的Dart层接口封装之后,就可以将它插入Widget树中,实现原生视图与Flutter视图的混用。
|
||||
|
||||
一次典型的平台视图使用过程与方法通道类似:
|
||||
|
||||
- 首先,由作为客户端的Flutter,通过向原生视图的Flutter封装类(在iOS和Android平台分别是UIKitView和AndroidView)传入视图标识符,用于发起原生视图的创建请求;
|
||||
- 然后,原生代码侧将对应原生视图的创建交给平台视图工厂(PlatformViewFactory)实现;
|
||||
- 最后,在原生代码侧将视图标识符与平台视图工厂进行关联注册,让Flutter发起的视图创建请求可以直接找到对应的视图创建工厂。
|
||||
|
||||
至此,我们就可以像使用Widget那样,使用原生视图了。整个流程,如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/2b/e8/2b3afbb05585c474e4dc2d18bf6066e8.png" alt="">
|
||||
|
||||
接下来,我以一个具体的案例,也就是将一个红色的原生视图内嵌到Flutter中,与你演示如何使用平台视图。这部分内容主要包括两部分:
|
||||
|
||||
- 作为调用发起方的Flutter,如何实现原生视图的接口调用?
|
||||
- 如何在原生(Android和iOS)系统实现接口?
|
||||
|
||||
接下来,我将分别与你讲述这两个问题。
|
||||
|
||||
### Flutter如何实现原生视图的接口调用?
|
||||
|
||||
在下面的代码中,我们在SampleView的内部,分别使用了原生Android、iOS视图的封装类AndroidView和UIkitView,并传入了一个唯一标识符,用于和原生视图建立关联:
|
||||
|
||||
```
|
||||
class SampleView extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
//使用Android平台的AndroidView,传入唯一标识符sampleView
|
||||
if (defaultTargetPlatform == TargetPlatform.android) {
|
||||
return AndroidView(viewType: 'sampleView');
|
||||
} else {
|
||||
//使用iOS平台的UIKitView,传入唯一标识符sampleView
|
||||
return UiKitView(viewType: 'sampleView');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
可以看到,平台视图在Flutter侧的使用方式比较简单,与普通Widget并无明显区别。而关于普通Widget的使用方式,你可以参考第[12](https://time.geekbang.org/column/article/110292)、[13](https://time.geekbang.org/column/article/110859)篇的相关内容进行复习。
|
||||
|
||||
调用方的实现搞定了。接下来,我们需要在原生代码中完成视图创建的封装,建立相关的绑定关系。同样的,由于需要同时适配Android和iOS平台,我们需要分别在两个系统上完成对应的接口实现。
|
||||
|
||||
### 如何在原生系统实现接口?
|
||||
|
||||
首先,我们来看看**Android端的实现**。在下面的代码中,我们分别创建了平台视图工厂和原生视图封装类,并通过视图工厂的create方法,将它们关联起来:
|
||||
|
||||
```
|
||||
//视图工厂类
|
||||
class SampleViewFactory extends PlatformViewFactory {
|
||||
private final BinaryMessenger messenger;
|
||||
//初始化方法
|
||||
public SampleViewFactory(BinaryMessenger msger) {
|
||||
super(StandardMessageCodec.INSTANCE);
|
||||
messenger = msger;
|
||||
}
|
||||
//创建原生视图封装类,完成关联
|
||||
@Override
|
||||
public PlatformView create(Context context, int id, Object obj) {
|
||||
return new SimpleViewControl(context, id, messenger);
|
||||
}
|
||||
}
|
||||
//原生视图封装类
|
||||
class SimpleViewControl implements PlatformView {
|
||||
private final View view;//缓存原生视图
|
||||
//初始化方法,提前创建好视图
|
||||
public SimpleViewControl(Context context, int id, BinaryMessenger messenger) {
|
||||
view = new View(context);
|
||||
view.setBackgroundColor(Color.rgb(255, 0, 0));
|
||||
}
|
||||
|
||||
//返回原生视图
|
||||
@Override
|
||||
public View getView() {
|
||||
return view;
|
||||
}
|
||||
//原生视图销毁回调
|
||||
@Override
|
||||
public void dispose() {
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
将原生视图封装类与原生视图工厂完成关联后,接下来就需要将Flutter侧的调用与视图工厂绑定起来了。与上一篇文章讲述的方法通道类似,我们仍然需要在MainActivity中进行绑定操作:
|
||||
|
||||
```
|
||||
protected void onCreate(Bundle savedInstanceState) {
|
||||
...
|
||||
Registrar registrar = registrarFor("samples.chenhang/native_views");//生成注册类
|
||||
SampleViewFactory playerViewFactory = new SampleViewFactory(registrar.messenger());//生成视图工厂
|
||||
|
||||
registrar.platformViewRegistry().registerViewFactory("sampleView", playerViewFactory);//注册视图工厂
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
完成绑定之后,平台视图调用响应的Android部分就搞定了。
|
||||
|
||||
接下来,我们再来看看**iOS端的实现**。
|
||||
|
||||
与Android类似,我们同样需要分别创建平台视图工厂和原生视图封装类,并通过视图工厂的create方法,将它们关联起来:
|
||||
|
||||
```
|
||||
//平台视图工厂
|
||||
@interface SampleViewFactory : NSObject<FlutterPlatformViewFactory>
|
||||
- (instancetype)initWithMessenger:(NSObject<FlutterBinaryMessenger>*)messager;
|
||||
@end
|
||||
|
||||
@implementation SampleViewFactory{
|
||||
NSObject<FlutterBinaryMessenger>*_messenger;
|
||||
}
|
||||
|
||||
- (instancetype)initWithMessenger:(NSObject<FlutterBinaryMessenger> *)messager{
|
||||
self = [super init];
|
||||
if (self) {
|
||||
_messenger = messager;
|
||||
}
|
||||
return self;
|
||||
}
|
||||
|
||||
-(NSObject<FlutterMessageCodec> *)createArgsCodec{
|
||||
return [FlutterStandardMessageCodec sharedInstance];
|
||||
}
|
||||
|
||||
//创建原生视图封装实例
|
||||
-(NSObject<FlutterPlatformView> *)createWithFrame:(CGRect)frame viewIdentifier:(int64_t)viewId arguments:(id)args{
|
||||
SampleViewControl *activity = [[SampleViewControl alloc] initWithWithFrame:frame viewIdentifier:viewId arguments:args binaryMessenger:_messenger];
|
||||
return activity;
|
||||
}
|
||||
@end
|
||||
|
||||
//平台视图封装类
|
||||
@interface SampleViewControl : NSObject<FlutterPlatformView>
|
||||
- (instancetype)initWithWithFrame:(CGRect)frame viewIdentifier:(int64_t)viewId arguments:(id _Nullable)args binaryMessenger:(NSObject<FlutterBinaryMessenger>*)messenger;
|
||||
@end
|
||||
|
||||
@implementation SampleViewControl{
|
||||
UIView * _templcateView;
|
||||
}
|
||||
//创建原生视图
|
||||
- (instancetype)initWithWithFrame:(CGRect)frame viewIdentifier:(int64_t)viewId arguments:(id)args binaryMessenger:(NSObject<FlutterBinaryMessenger> *)messenger{
|
||||
if ([super init]) {
|
||||
_templcateView = [[UIView alloc] init];
|
||||
_templcateView.backgroundColor = [UIColor redColor];
|
||||
}
|
||||
return self;
|
||||
}
|
||||
|
||||
-(UIView *)view{
|
||||
return _templcateView;
|
||||
}
|
||||
|
||||
@end
|
||||
|
||||
```
|
||||
|
||||
然后,我们同样需要把原生视图的创建与Flutter侧的调用关联起来,才可以在Flutter侧找到原生视图的实现:
|
||||
|
||||
```
|
||||
- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
|
||||
NSObject<FlutterPluginRegistrar>* registrar = [self registrarForPlugin:@"samples.chenhang/native_views"];//生成注册类
|
||||
SampleViewFactory* viewFactory = [[SampleViewFactory alloc] initWithMessenger:registrar.messenger];//生成视图工厂
|
||||
[registrar registerViewFactory:viewFactory withId:@"sampleView"];//注册视图工厂
|
||||
...
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
需要注意的是,在iOS平台上,Flutter内嵌UIKitView目前还处于技术预览状态,因此我们还需要在Info.plist文件中增加一项配置,把内嵌原生视图的功能开关设置为true,才能打开这个隐藏功能:
|
||||
|
||||
```
|
||||
<dict>
|
||||
...
|
||||
<key>io.flutter.embedded_views_preview</key>
|
||||
<true/>
|
||||
....
|
||||
</dict>
|
||||
|
||||
```
|
||||
|
||||
经过上面的封装与绑定,Android端与iOS端的平台视图功能都已经实现了。接下来,我们就可以在Flutter应用里,像使用普通Widget一样,去内嵌原生视图了:
|
||||
|
||||
```
|
||||
Scaffold(
|
||||
backgroundColor: Colors.yellowAccent,
|
||||
body: Container(width: 200, height:200,
|
||||
child: SampleView(controller: controller)
|
||||
));
|
||||
|
||||
```
|
||||
|
||||
如下所示,我们分别在iOS和Android平台的Flutter应用上,内嵌了一个红色的原生视图:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/09/3f/095093cea18f8e18b6de2c94e447d03f.png" alt="">
|
||||
|
||||
在上面的例子中,我们将原生视图封装在一个StatelessWidget中,可以有效应对静态展示的场景。如果我们需要在程序运行时动态调整原生视图的样式,又该如何处理呢?
|
||||
|
||||
## 如何在程序运行时,动态地调整原生视图的样式?
|
||||
|
||||
与基于声明式的Flutter Widget,每次变化只能以数据驱动其视图销毁重建不同,原生视图是基于命令式的,可以精确地控制视图展示样式。因此,我们可以在原生视图的封装类中,将其持有的修改视图实例相关的接口,以方法通道的方式暴露给Flutter,让Flutter也可以拥有动态调整视图视觉样式的能力。
|
||||
|
||||
接下来,我以一个具体的案例来演示如何在程序运行时动态调整内嵌原生视图的背景颜色。
|
||||
|
||||
在这个案例中,我们会用到原生视图的一个初始化属性,即onPlatformViewCreated:原生视图会在其创建完成后,以回调的形式通知视图id,因此我们可以在这个时候注册方法通道,让后续的视图修改请求通过这条通道传递给原生视图。
|
||||
|
||||
由于我们在底层直接持有了原生视图的实例,因此理论上可以直接在这个原生视图的Flutter封装类上提供视图修改方法,而不管它到底是StatelessWidget还是StatefulWidget。但为了遵照Flutter的Widget设计理念,我们还是决定将视图展示与视图控制分离,即:将原生视图封装为一个StatefulWidget专门用于展示,通过其controller初始化参数,在运行期修改原生视图的展示效果。如下所示:
|
||||
|
||||
```
|
||||
//原生视图控制器
|
||||
class NativeViewController {
|
||||
MethodChannel _channel;
|
||||
//原生视图完成创建后,通过id生成唯一方法通道
|
||||
onCreate(int id) {
|
||||
_channel = MethodChannel('samples.chenhang/native_views_$id');
|
||||
}
|
||||
//调用原生视图方法,改变背景颜色
|
||||
Future<void> changeBackgroundColor() async {
|
||||
return _channel.invokeMethod('changeBackgroundColor');
|
||||
}
|
||||
}
|
||||
|
||||
//原生视图Flutter侧封装,继承自StatefulWidget
|
||||
class SampleView extends StatefulWidget {
|
||||
const SampleView({
|
||||
Key key,
|
||||
this.controller,
|
||||
}) : super(key: key);
|
||||
|
||||
//持有视图控制器
|
||||
final NativeViewController controller;
|
||||
@override
|
||||
State<StatefulWidget> createState() => _SampleViewState();
|
||||
}
|
||||
|
||||
class _SampleViewState extends State<SampleView> {
|
||||
//根据平台确定返回何种平台视图
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (defaultTargetPlatform == TargetPlatform.android) {
|
||||
return AndroidView(
|
||||
viewType: 'sampleView',
|
||||
//原生视图创建完成后,通过onPlatformViewCreated产生回调
|
||||
onPlatformViewCreated: _onPlatformViewCreated,
|
||||
);
|
||||
} else {
|
||||
return UiKitView(viewType: 'sampleView',
|
||||
//原生视图创建完成后,通过onPlatformViewCreated产生回调
|
||||
onPlatformViewCreated: _onPlatformViewCreated
|
||||
);
|
||||
}
|
||||
}
|
||||
//原生视图创建完成后,调用control的onCreate方法,传入view id
|
||||
_onPlatformViewCreated(int id) {
|
||||
if (widget.controller == null) {
|
||||
return;
|
||||
}
|
||||
widget.controller.onCreate(id);
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
Flutter的调用方实现搞定了,接下来我们分别看看Android和iOS端的实现。
|
||||
|
||||
程序的整体结构与之前并无不同,只是在进行原生视图初始化时,我们需要完成方法通道的注册和相关事件的处理;在响应方法调用消息时,我们需要判断方法名,如果完全匹配,则修改视图背景,否则返回异常。
|
||||
|
||||
Android端接口实现代码如下所示:
|
||||
|
||||
```
|
||||
class SimpleViewControl implements PlatformView, MethodCallHandler {
|
||||
private final MethodChannel methodChannel;
|
||||
...
|
||||
public SimpleViewControl(Context context, int id, BinaryMessenger messenger) {
|
||||
...
|
||||
//用view id注册方法通道
|
||||
methodChannel = new MethodChannel(messenger, "samples.chenhang/native_views_" + id);
|
||||
//设置方法通道回调
|
||||
methodChannel.setMethodCallHandler(this);
|
||||
}
|
||||
//处理方法调用消息
|
||||
@Override
|
||||
public void onMethodCall(MethodCall methodCall, MethodChannel.Result result) {
|
||||
//如果方法名完全匹配
|
||||
if (methodCall.method.equals("changeBackgroundColor")) {
|
||||
//修改视图背景,返回成功
|
||||
view.setBackgroundColor(Color.rgb(0, 0, 255));
|
||||
result.success(0);
|
||||
}else {
|
||||
//调用方发起了一个不支持的API调用
|
||||
result.notImplemented();
|
||||
}
|
||||
}
|
||||
...
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
iOS端接口实现代码:
|
||||
|
||||
```
|
||||
@implementation SampleViewControl{
|
||||
...
|
||||
FlutterMethodChannel* _channel;
|
||||
}
|
||||
|
||||
- (instancetype)initWithWithFrame:(CGRect)frame viewIdentifier:(int64_t)viewId arguments:(id)args binaryMessenger:(NSObject<FlutterBinaryMessenger> *)messenger{
|
||||
if ([super init]) {
|
||||
...
|
||||
//使用view id完成方法通道的创建
|
||||
_channel = [FlutterMethodChannel methodChannelWithName:[NSString stringWithFormat:@"samples.chenhang/native_views_%lld", viewId] binaryMessenger:messenger];
|
||||
//设置方法通道的处理回调
|
||||
__weak __typeof__(self) weakSelf = self;
|
||||
[_channel setMethodCallHandler:^(FlutterMethodCall* call, FlutterResult result) {
|
||||
[weakSelf onMethodCall:call result:result];
|
||||
}];
|
||||
}
|
||||
return self;
|
||||
}
|
||||
|
||||
//响应方法调用消息
|
||||
- (void)onMethodCall:(FlutterMethodCall*)call result:(FlutterResult)result {
|
||||
//如果方法名完全匹配
|
||||
if ([[call method] isEqualToString:@"changeBackgroundColor"]) {
|
||||
//修改视图背景色,返回成功
|
||||
_templcateView.backgroundColor = [UIColor blueColor];
|
||||
result(@0);
|
||||
} else {
|
||||
//调用方发起了一个不支持的API调用
|
||||
result(FlutterMethodNotImplemented);
|
||||
}
|
||||
}
|
||||
...
|
||||
@end
|
||||
|
||||
```
|
||||
|
||||
通过注册方法通道,以及暴露的changeBackgroundColor接口,Android端与iOS端修改平台视图背景颜色的功能都已经实现了。接下来,我们就可以在Flutter应用运行期间,修改原生视图展示样式了:
|
||||
|
||||
```
|
||||
class DefaultState extends State<DefaultPage> {
|
||||
NativeViewController controller;
|
||||
@override
|
||||
void initState() {
|
||||
controller = NativeViewController();//初始化原生View控制器
|
||||
super.initState();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
...
|
||||
//内嵌原生View
|
||||
body: Container(width: 200, height:200,
|
||||
child: SampleView(controller: controller)
|
||||
),
|
||||
//设置点击行为:改变视图颜色
|
||||
floatingActionButton: FloatingActionButton(onPressed: ()=>controller.changeBackgroundColor())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
运行一下,效果如下所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/fd/ac/fd1f6d7280aaacb3294d7733706fc8ac.gif" alt="">
|
||||
|
||||
## 总结
|
||||
|
||||
好了,今天的分享就到这里。我们总结一下今天的主要内容吧。
|
||||
|
||||
平台视图解决了原生渲染能力的复用问题,使得Flutter能够通过轻量级的代码封装,把原生视图组装成一个Flutter控件。
|
||||
|
||||
Flutter提供了平台视图工厂和视图标识符两个概念,因此Dart层发起的视图创建请求可以通过标识符直接找到对应的视图创建工厂,从而实现原生视图与Flutter视图的融合复用。对于需要在运行期动态调用原生视图接口的需求,我们可以在原生视图的封装类中注册方法通道,实现精确控制原生视图展示的效果。
|
||||
|
||||
需要注意的是,由于Flutter与原生渲染方式完全不同,因此转换不同的渲染数据会有较大的性能开销。如果在一个界面上同时实例化多个原生控件,就会对性能造成非常大的影响,所以我们要避免在使用Flutter控件也能实现的情况下去使用内嵌平台视图。
|
||||
|
||||
因为这样做,一方面需要分别在Android和iOS端写大量的适配桥接代码,违背了跨平台技术的本意,也增加了后续的维护成本;另一方面毕竟除去地图、WebView、相机等涉及底层方案的特殊情况外,大部分原生代码能够实现的UI效果,完全可以用Flutter实现。
|
||||
|
||||
我把今天分享所涉及到的知识点打包到了[GitHub](https://github.com/cyndibaby905/27_native_view)中,你可以下载下来,反复运行几次,加深理解。
|
||||
|
||||
## 思考题
|
||||
|
||||
最后,我给你留下一道思考题吧。
|
||||
|
||||
请你在动态调整原生视图样式的代码基础上,增加颜色参数,以实现动态变更原生视图颜色的需求。
|
||||
|
||||
欢迎你在评论区给我留言分享你的观点,我会在下一篇文章中等待你!感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。
|
||||
|
||||
|
||||
271
极客时间专栏/Flutter核心技术与实战/Flutter进阶/28 | 如何在原生应用中混编Flutter工程?.md
Normal file
271
极客时间专栏/Flutter核心技术与实战/Flutter进阶/28 | 如何在原生应用中混编Flutter工程?.md
Normal file
@@ -0,0 +1,271 @@
|
||||
<audio id="audio" title="28 | 如何在原生应用中混编Flutter工程?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/8b/28/8b5212c17afc34708c24b9cc4a91d828.mp3"></audio>
|
||||
|
||||
你好,我是陈航。今天,我来和你聊聊如何在原生应用中接入Flutter。
|
||||
|
||||
在前面两篇文章中,我与你分享了如何在Dart层引入Android/iOS平台特定的能力,来提升App的功能体验。
|
||||
|
||||
使用Flutter从头开始写一个App,是一件轻松惬意的事情。但,对于成熟产品来说,完全摒弃原有App的历史沉淀,而全面转向Flutter并不现实。用Flutter去统一iOS/Android技术栈,把它作为已有原生App的扩展能力,通过逐步试验有序推进从而提升终端开发效率,可能才是现阶段Flutter最具吸引力的地方。
|
||||
|
||||
那么,Flutter工程与原生工程该如何组织管理?不同平台的Flutter工程打包构建产物该如何抽取封装?封装后的产物该如何引入原生工程?原生工程又该如何使用封装后的Flutter能力?
|
||||
|
||||
这些问题使得在已有原生App中接入Flutter看似并不是一件容易的事情。那接下来,我就和你介绍下如何在原生App中以最自然的方式接入Flutter。
|
||||
|
||||
## 准备工作
|
||||
|
||||
既然是要在原生应用中混编Flutter,相信你一定已经准备好原生应用工程来实施今天的改造了。如果你还没有准备好也没关系,我会以一个最小化的示例和你演示这个改造过程。
|
||||
|
||||
首先,我们分别用Xcode与Android Studio快速建立一个只有首页的基本工程,工程名分别为iOSDemo与AndroidDemo。
|
||||
|
||||
这时,Android工程就已经准备好了;而对于iOS工程来说,由于基本工程并不支持以组件化的方式管理项目,因此我们还需要多做一步,将其改造成使用CocoaPods管理的工程,也就是要在iOSDemo根目录下创建一个只有基本信息的Podfile文件:
|
||||
|
||||
```
|
||||
use_frameworks!
|
||||
platform :ios, '8.0'
|
||||
target 'iOSDemo' do
|
||||
#todo
|
||||
end
|
||||
|
||||
```
|
||||
|
||||
然后,在命令行输入pod install后,会自动生成一个iOSDemo.xcworkspace文件,这时我们就完成了iOS工程改造。
|
||||
|
||||
## Flutter混编方案介绍
|
||||
|
||||
如果你想要在已有的原生App里嵌入一些Flutter页面,有两个办法:
|
||||
|
||||
- 将原生工程作为Flutter工程的子工程,由Flutter统一管理。这种模式,就是统一管理模式。
|
||||
- 将Flutter工程作为原生工程共用的子模块,维持原有的原生工程管理方式不变。这种模式,就是三端分离模式。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/43/e3/43959076df5aadeb751dff0d7b1134e3.png" alt="">
|
||||
|
||||
由于Flutter早期提供的混编方式能力及相关资料有限,国内较早使用Flutter混合开发的团队大多使用的是统一管理模式。但是,随着功能迭代的深入,这种方案的弊端也随之显露,不仅三端(Android、iOS、Flutter)代码耦合严重,相关工具链耗时也随之大幅增长,导致开发效率降低。
|
||||
|
||||
所以,后续使用Flutter混合开发的团队陆续按照三端代码分离的模式来进行依赖治理,实现了Flutter工程的轻量级接入。
|
||||
|
||||
除了可以轻量级接入,三端代码分离模式把Flutter模块作为原生工程的子模块,还可以快速实现Flutter功能的“热插拔”,降低原生工程的改造成本。而Flutter工程通过Android Studio进行管理,无需打开原生工程,可直接进行Dart代码和原生代码的开发调试。
|
||||
|
||||
**三端工程分离模式的关键是抽离Flutter工程,将不同平台的构建产物依照标准组件化的形式进行管理**,即Android使用aar、iOS使用pod。换句话说,接下来介绍的混编方案会将Flutter模块打包成aar和pod,这样原生工程就可以像引用其他第三方原生组件库那样快速接入Flutter了。
|
||||
|
||||
听起来是不是很兴奋?接下来,我们就开始正式采用三端分离模式来接入Flutter模块吧。
|
||||
|
||||
## 集成Flutter
|
||||
|
||||
我曾在前面的文章中提到,Flutter的工程结构比较特殊,包括Flutter工程和原生工程的目录(即iOS和Android两个目录)。在这种情况下,原生工程就会依赖于Flutter相关的库和资源,从而无法脱离父目录进行独立构建和运行。
|
||||
|
||||
原生工程对Flutter的依赖主要分为两部分:
|
||||
|
||||
- Flutter库和引擎,也就是Flutter的Framework库和引擎库;
|
||||
- Flutter工程,也就是我们自己实现的Flutter模块功能,主要包括Flutter工程lib目录下的Dart代码实现的这部分功能。
|
||||
|
||||
在已经有原生工程的情况下,我们需要在同级目录创建Flutter模块,构建iOS和Android各自的Flutter依赖库。这也很好实现,Flutter就为我们提供了这样的命令。我们只需要在原生项目的同级目录下,执行Flutter命令创建名为flutter_library的模块即可:
|
||||
|
||||
```
|
||||
Flutter create -t module flutter_library
|
||||
|
||||
```
|
||||
|
||||
这里的Flutter模块,也是Flutter工程,我们用Android Studio打开它,其目录如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/61/89/61d3530bcf7a23e1708c536b53ced789.png" alt="">
|
||||
|
||||
可以看到,和传统的Flutter工程相比,Flutter模块工程也有内嵌的Android工程与iOS工程,因此我们可以像普通工程一样使用Android Studio进行开发调试。
|
||||
|
||||
仔细查看可以发现,**Flutter模块有一个细微的变化**:Android工程下多了一个Flutter目录,这个目录下的build.gradle配置就是我们构建aar的打包配置。这就是模块工程既能像Flutter传统工程一样使用Android Studio开发调试,又能打包构建aar与pod的秘密。
|
||||
|
||||
实际上,iOS工程的目录结构也有细微变化,但这个差异并不影响打包构建,因此我就不再展开了。
|
||||
|
||||
然后,我们打开main.dart文件,将其逻辑更新为以下代码逻辑,即一个写着“Hello from Flutter”的全屏红色的Flutter Widget:
|
||||
|
||||
```
|
||||
import 'package:flutter/material.dart';
|
||||
import 'dart:ui';
|
||||
|
||||
void main() => runApp(_widgetForRoute(window.defaultRouteName));//独立运行传入默认路由
|
||||
|
||||
Widget _widgetForRoute(String route) {
|
||||
switch (route) {
|
||||
default:
|
||||
return MaterialApp(
|
||||
home: Scaffold(
|
||||
backgroundColor: const Color(0xFFD63031),//ARGB红色
|
||||
body: Center(
|
||||
child: Text(
|
||||
'Hello from Flutter', //显示的文字
|
||||
textDirection: TextDirection.ltr,
|
||||
style: TextStyle(
|
||||
fontSize: 20.0,
|
||||
color: Colors.blue,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
注意:我们创建的Widget实际上是包在一个switch-case语句中的。这是因为封装的Flutter模块一般会有多个页面级Widget,原生App代码则会通过传入路由标识字符串,告诉Flutter究竟应该返回何种Widget。为了简化案例,在这里我们忽略标识字符串,统一返回一个MaterialApp。
|
||||
|
||||
接下来,我们要做的事情就是把这段代码编译打包,构建出对应的Android和iOS依赖库,实现原生工程的接入。
|
||||
|
||||
现在,我们首先来看看Android工程如何接入。
|
||||
|
||||
### Android模块集成
|
||||
|
||||
之前我们提到原生工程对Flutter的依赖主要分为两部分,对应到Android平台,这两部分分别是:
|
||||
|
||||
- Flutter库和引擎,也就是icudtl.dat、libFlutter.so,还有一些class文件。这些文件都封装在Flutter.jar中。
|
||||
- Flutter工程产物,主要包括应用程序数据段isolate_snapshot_data、应用程序指令段isolate_snapshot_instr、虚拟机数据段vm_snapshot_data、虚拟机指令段vm_snapshot_instr、资源文件Flutter_assets。
|
||||
|
||||
搞清楚Flutter工程的Android编译产物之后,我们对Android的Flutter依赖抽取步骤如下:
|
||||
|
||||
首先在Flutter_library的根目录下,执行aar打包构建命令:
|
||||
|
||||
```
|
||||
Flutter build apk --debug
|
||||
|
||||
```
|
||||
|
||||
这条命令的作用是编译工程产物,并将Flutter.jar和工程产物编译结果封装成一个aar。你很快就会想到,如果是构建release产物,只需要把debug换成release就可以了。
|
||||
|
||||
**其次**,打包构建的flutter-debug.aar位于.android/Flutter/build/outputs/aar/目录下,我们把它拷贝到原生Android工程AndroidDemo的app/libs目录下,并在App的打包配置build.gradle中添加对它的依赖:
|
||||
|
||||
```
|
||||
...
|
||||
repositories {
|
||||
flatDir {
|
||||
dirs 'libs' // aar目录
|
||||
}
|
||||
}
|
||||
android {
|
||||
...
|
||||
compileOptions {
|
||||
sourceCompatibility 1.8 //Java 1.8
|
||||
targetCompatibility 1.8 //Java 1.8
|
||||
}
|
||||
...
|
||||
}
|
||||
|
||||
dependencies {
|
||||
...
|
||||
implementation(name: 'flutter-debug', ext: 'aar')//Flutter模块aar
|
||||
...
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
Sync一下,Flutter模块就被添加到了Android项目中。
|
||||
|
||||
再次,我们试着改一下MainActivity.java的代码,把它的contentView改成Flutter的widget:
|
||||
|
||||
```
|
||||
protected void onCreate(Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
View FlutterView = Flutter.createView(this, getLifecycle(), "defaultRoute"); //传入路由标识符
|
||||
setContentView(FlutterView);//用FlutterView替代Activity的ContentView
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
最后点击运行,可以看到一个写着“Hello from Flutter”的全屏红色的Flutter Widget就展示出来了。至此,我们完成了Android工程的接入。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/36/5b/3648bb9b0ec126fe07963d5e4cbede5b.png" alt="">
|
||||
|
||||
### iOS模块集成
|
||||
|
||||
iOS工程接入的情况要稍微复杂一些。在iOS平台,原生工程对Flutter的依赖分别是:
|
||||
|
||||
- Flutter库和引擎,即Flutter.framework;
|
||||
- Flutter工程的产物,即App.framework。
|
||||
|
||||
iOS平台的Flutter模块抽取,实际上就是通过打包命令生成这两个产物,并将它们封装成一个pod供原生工程引用。
|
||||
|
||||
类似地,首先我们在Flutter_library的根目录下,执行iOS打包构建命令:
|
||||
|
||||
```
|
||||
Flutter build ios --debug
|
||||
|
||||
```
|
||||
|
||||
这条命令的作用是编译Flutter工程生成两个产物:Flutter.framework和App.framework。同样,把debug换成release就可以构建release产物(当然,你还需要处理一下签名问题)。
|
||||
|
||||
**其次**,在iOSDemo的根目录下创建一个名为FlutterEngine的目录,并把这两个framework文件拷贝进去。iOS的模块化产物工作要比Android多一个步骤,因为我们需要把这两个产物手动封装成pod。因此,我们还需要在该目录下创建FlutterEngine.podspec,即Flutter模块的组件定义:
|
||||
|
||||
```
|
||||
Pod::Spec.new do |s|
|
||||
s.name = 'FlutterEngine'
|
||||
s.version = '0.1.0'
|
||||
s.summary = 'XXXXXXX'
|
||||
s.description = <<-DESC
|
||||
TODO: Add long description of the pod here.
|
||||
DESC
|
||||
s.homepage = 'https://github.com/xx/FlutterEngine'
|
||||
s.license = { :type => 'MIT', :file => 'LICENSE' }
|
||||
s.author = { 'chenhang' => 'hangisnice@gmail.com' }
|
||||
s.source = { :git => "", :tag => "#{s.version}" }
|
||||
s.ios.deployment_target = '8.0'
|
||||
s.ios.vendored_frameworks = 'App.framework', 'Flutter.framework'
|
||||
end
|
||||
|
||||
```
|
||||
|
||||
pod lib lint一下,Flutter模块组件就已经做好了。趁热打铁,我们再修改Podfile文件把它集成到iOSDemo工程中:
|
||||
|
||||
```
|
||||
...
|
||||
target 'iOSDemo' do
|
||||
pod 'FlutterEngine', :path => './'
|
||||
end
|
||||
|
||||
```
|
||||
|
||||
pod install一下,Flutter模块就集成进iOS原生工程中了。
|
||||
|
||||
再次,我们试着修改一下AppDelegate.m的代码,把window的rootViewController改成FlutterViewController:
|
||||
|
||||
```
|
||||
- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions
|
||||
|
||||
{
|
||||
self.window = [[UIWindow alloc] initWithFrame:[UIScreen mainScreen].bounds];
|
||||
FlutterViewController *vc = [[FlutterViewController alloc]init];
|
||||
[vc setInitialRoute:@"defaultRoute"]; //路由标识符
|
||||
self.window.rootViewController = vc;
|
||||
[self.window makeKeyAndVisible];
|
||||
return YES;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
最后点击运行,一个写着“Hello from Flutter”的全屏红色的Flutter Widget也展示出来了。至此,iOS工程的接入我们也顺利搞定了。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/a5/42/a511d31edbbbf2949763af49453ac642.png" alt="">
|
||||
|
||||
## 总结
|
||||
|
||||
通过分离Android、iOS和Flutter三端工程,抽离Flutter库和引擎及工程代码为组件库,以Android和iOS平台最常见的aar和pod形式接入原生工程,我们就可以低成本地接入Flutter模块,愉快地使用Flutter扩展原生App的边界了。
|
||||
|
||||
但,我们还可以做得更好。
|
||||
|
||||
如果每次通过构建Flutter模块工程,都是手动搬运Flutter编译产物,那很容易就会因为工程管理混乱导致Flutter组件库被覆盖,从而引发难以排查的Bug。而要解决此类问题的话,我们可以引入CI自动构建框架,把Flutter编译产物构建自动化,原生工程通过接入不同版本的构建产物,实现更优雅的三端分离模式。
|
||||
|
||||
而关于自动化构建,我会在后面的文章中和你详细介绍,这里就不再赘述了。
|
||||
|
||||
接下来,我们简单回顾一下今天的内容。
|
||||
|
||||
原生工程混编Flutter的方式有两种。一种是,将Flutter工程内嵌Android和iOS工程,由Flutter统一管理的集中模式;另一种是,将Flutter工程作为原生工程共用的子模块,由原生工程各自管理的三端工程分离模式。目前,业界采用的基本都是第二种方式。
|
||||
|
||||
而对于三端工程分离模式最主要的则是抽离Flutter工程,将不同平台的构建产物依照标准组件化的形式进行管理,即:针对Android平台打包构建生成aar,通过build.gradle进行依赖管理;针对iOS平台打包构建生成framework,将其封装成独立的pod,并通过podfile进行依赖管理。
|
||||
|
||||
我把今天分享所涉及到的知识点打包到了GitHub([flutter_module_page](https://github.com/cyndibaby905/28_module_page)、[iOS_demo](https://github.com/cyndibaby905/28_iOSDemo)、[Android_Demo](https://github.com/cyndibaby905/28_AndroidDemo))中,你可以下载下来,反复运行几次,加深理解与记忆。
|
||||
|
||||
## 思考题
|
||||
|
||||
最后,我给你下留一个思考题吧。
|
||||
|
||||
对于有资源依赖的Flutter模块工程而言,其打包构建的产物,以及抽离Flutter组件库的过程会有什么不同吗?
|
||||
|
||||
欢迎你在评论区给我留言分享你的观点,我会在下一篇文章中等待你!感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。
|
||||
|
||||
|
||||
258
极客时间专栏/Flutter核心技术与实战/Flutter进阶/29 | 混合开发,该用何种方案管理导航栈?.md
Normal file
258
极客时间专栏/Flutter核心技术与实战/Flutter进阶/29 | 混合开发,该用何种方案管理导航栈?.md
Normal file
@@ -0,0 +1,258 @@
|
||||
<audio id="audio" title="29 | 混合开发,该用何种方案管理导航栈?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/05/9c/050dd24b30aa48f49d780ac0b61e929c.mp3"></audio>
|
||||
|
||||
你好,我是陈航。
|
||||
|
||||
为了把Flutter引入到原生工程,我们需要把Flutter工程改造为原生工程的一个组件依赖,并以组件化的方式管理不同平台的Flutter构建产物,即Android平台使用aar、iOS平台使用pod进行依赖管理。这样,我们就可以在Android工程中通过FlutterView,iOS工程中通过FlutterViewController,为Flutter搭建应用入口,实现Flutter与原生的混合开发方式。
|
||||
|
||||
我在[第26篇](https://time.geekbang.org/column/article/127601)文章中提到,FlutterView与FlutterViewController是初始化Flutter的地方,也是应用的入口。可以看到,以混合开发方式接入Flutter,与开发一个纯Flutter应用在运行机制上并无任何区别,只需要原生工程为它提供一个画板容器(Android为FlutterView,iOS为FlutterViewController),Flutter就可以自己管理页面导航栈,从而实现多个复杂页面的渲染和切换。
|
||||
|
||||
关于纯Flutter应用的页面路由与导航,我已经在[第21篇文章](https://time.geekbang.org/column/article/118421)中与你介绍过了。今天这篇文章,我会为你讲述在混合开发中,应该如何管理混合导航栈。
|
||||
|
||||
对于混合开发的应用而言,通常我们只会将应用的部分模块修改成Flutter开发,其他模块继续保留原生开发,因此应用内除了Flutter的页面之外,还会有原生Android、iOS的页面。在这种情况下,Flutter页面有可能会需要跳转到原生页面,而原生页面也可能会需要跳转到Flutter页面。这就涉及到了一个新的问题:如何统一管理原生页面和Flutter页面跳转交互的混合导航栈。
|
||||
|
||||
接下来,我们就从这个问题入手,开始今天的学习吧。
|
||||
|
||||
## 混合导航栈
|
||||
|
||||
混合导航栈,指的是原生页面和Flutter页面相互掺杂,存在于用户视角的页面导航栈视图中。
|
||||
|
||||
以下图为例,Flutter与原生Android、iOS各自实现了一套互不相同的页面映射机制,即原生采用单容器单页面(一个ViewController/Activity对应一个原生页面)、Flutter采用单容器多页面(一个ViewController/Activity对应多个Flutter页面)的机制。Flutter在原生的导航栈之上又自建了一套Flutter导航栈,这使得Flutter页面与原生页面之间涉及页面切换时,我们需要处理跨引擎的页面切换。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/60/dd/603d3f3777ef09a420b7b794efe0c9dd.png" alt="">
|
||||
|
||||
接下来,我们就分别看看从原生页面跳转至Flutter页面,以及从Flutter页面跳转至原生页面,应该如何处理吧。
|
||||
|
||||
### 从原生页面跳转至Flutter页面
|
||||
|
||||
从原生页面跳转至Flutter页面,实现起来比较简单。
|
||||
|
||||
因为Flutter本身依托于原生提供的容器(iOS为FlutterViewController,Android为Activity中的FlutterView),所以我们通过初始化Flutter容器,为其设置初始路由页面之后,就可以以原生的方式跳转至Flutter页面了。
|
||||
|
||||
如下代码所示。对于iOS,我们初始化一个FlutterViewController的实例,为其设置初始化页面路由后,将其加入原生的视图导航栈中完成跳转。
|
||||
|
||||
对于Android而言,则需要多加一步。因为Flutter页面的入口并不是原生视图导航栈的最小单位Activity,而是一个View(即FlutterView),所以我们还需要把这个View包装到Activity的contentView中。在Activity内部设置页面初始化路由之后,在外部就可以采用打开一个普通的原生视图的方式,打开Flutter页面了。
|
||||
|
||||
```
|
||||
//iOS 跳转至Flutter页面
|
||||
FlutterViewController *vc = [[FlutterViewController alloc] init];
|
||||
[vc setInitialRoute:@"defaultPage"];//设置Flutter初始化路由页面
|
||||
[self.navigationController pushViewController:vc animated:YES];//完成页面跳转
|
||||
|
||||
|
||||
//Android 跳转至Flutter页面
|
||||
|
||||
//创建一个作为Flutter页面容器的Activity
|
||||
public class FlutterHomeActivity extends AppCompatActivity {
|
||||
protected void onCreate(Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
//设置Flutter初始化路由页面
|
||||
View FlutterView = Flutter.createView(this, getLifecycle(), "defaultRoute"); //传入路由标识符
|
||||
setContentView(FlutterView);//用FlutterView替代Activity的ContentView
|
||||
}
|
||||
}
|
||||
//用FlutterPageActivity完成页面跳转
|
||||
Intent intent = new Intent(MainActivity.this, FlutterHomeActivity.class);
|
||||
startActivity(intent);
|
||||
|
||||
```
|
||||
|
||||
### 从Flutter页面跳转至原生页面
|
||||
|
||||
从Flutter页面跳转至原生页面,则会相对麻烦些,我们需要考虑以下两种场景:
|
||||
|
||||
- 从Flutter页面打开新的原生页面;
|
||||
- 从Flutter页面回退到旧的原生页面。
|
||||
|
||||
首先,我们来看看Flutter如何打开原生页面。
|
||||
|
||||
Flutter并没有提供对原生页面操作的方法,所以不可以直接调用。我们需要通过方法通道(你可以再回顾下[第26篇](https://time.geekbang.org/column/article/127601)文章的相关内容),在Flutter和原生两端各自初始化时,提供Flutter操作原生页面的方法,并注册方法通道,在原生端收到Flutter的方法调用时,打开新的原生页面。
|
||||
|
||||
接下来,我们再看看如何从Flutter页面回退到原生页面。
|
||||
|
||||
因为Flutter容器本身属于原生导航栈的一部分,所以当Flutter容器内的根页面(即初始化路由页面)需要返回时,我们需要关闭Flutter容器,从而实现Flutter根页面的关闭。同样,Flutter并没有提供操作Flutter容器的方法,因此我们依然需要通过方法通道,在原生代码宿主为Flutter提供操作Flutter容器的方法,在页面返回时,关闭Flutter页面。
|
||||
|
||||
Flutter跳转至原生页面的两种场景,如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/78/4b/78349cea1db3f8eb94ddb28af244494b.png" alt="">
|
||||
|
||||
**接下来,我们一起看看这两个需要通过方法通道实现的方法,即打开原生页面openNativePage,与关闭Flutter页面closeFlutterPage,在Android和iOS平台上分别如何实现。**
|
||||
|
||||
注册方法通道最合适的地方,是Flutter应用的入口,即在FlutterViewController(iOS端)和Activity中的FlutterView(Android端)这两个容器内部初始化Flutter页面前。为了将Flutter相关的行为封装到容器内部,我们需要分别继承FlutterViewController和Activity,在其viewDidLoad和onCreate初始化容器时,注册openNativePage和closeFlutterPage这两个方法。
|
||||
|
||||
iOS端的实现代码如下所示:
|
||||
|
||||
```
|
||||
@interface FlutterHomeViewController : FlutterViewController
|
||||
@end
|
||||
|
||||
@implementation FlutterHomeViewController
|
||||
- (void)viewDidLoad {
|
||||
[super viewDidLoad];
|
||||
//声明方法通道
|
||||
FlutterMethodChannel* channel = [FlutterMethodChannel methodChannelWithName:@"samples.chenhang/navigation" binaryMessenger:self];
|
||||
//注册方法回调
|
||||
[channel setMethodCallHandler:^(FlutterMethodCall* call, FlutterResult result) {
|
||||
//如果方法名为打开新页面
|
||||
if([call.method isEqualToString:@"openNativePage"]) {
|
||||
//初始化原生页面并打开
|
||||
SomeOtherNativeViewController *vc = [[SomeOtherNativeViewController alloc] init];
|
||||
[self.navigationController pushViewController:vc animated:YES];
|
||||
result(@0);
|
||||
}
|
||||
//如果方法名为关闭Flutter页面
|
||||
else if([call.method isEqualToString:@"closeFlutterPage"]) {
|
||||
//关闭自身(FlutterHomeViewController)
|
||||
[self.navigationController popViewControllerAnimated:YES];
|
||||
result(@0);
|
||||
}
|
||||
else {
|
||||
result(FlutterMethodNotImplemented);//其他方法未实现
|
||||
}
|
||||
}];
|
||||
}
|
||||
@end
|
||||
|
||||
```
|
||||
|
||||
Android端的实现代码如下所示:
|
||||
|
||||
```
|
||||
//继承AppCompatActivity来作为Flutter的容器
|
||||
public class FlutterHomeActivity extends AppCompatActivity {
|
||||
@Override
|
||||
protected void onCreate(Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
//初始化Flutter容器
|
||||
FlutterView flutterView = Flutter.createView(this, getLifecycle(), "defaultPage"); //传入路由标识符
|
||||
//注册方法通道
|
||||
new MethodChannel(flutterView, "samples.chenhang/navigation").setMethodCallHandler(
|
||||
new MethodCallHandler() {
|
||||
@Override
|
||||
public void onMethodCall(MethodCall call, Result result) {
|
||||
//如果方法名为打开新页面
|
||||
if(call.method.equals("openNativePage")) {
|
||||
//新建Intent,打开原生页面
|
||||
Intent intent = new Intent(FlutterHomeActivity.this, SomeNativePageActivity.class);
|
||||
startActivity(intent);
|
||||
result.success(0);
|
||||
}
|
||||
//如果方法名为关闭Flutter页面
|
||||
else if(call.method.equals("closeFlutterPage")) {
|
||||
//销毁自身(Flutter容器)
|
||||
finish();
|
||||
result.success(0);
|
||||
}
|
||||
else {
|
||||
//方法未实现
|
||||
result.notImplemented();
|
||||
}
|
||||
}
|
||||
});
|
||||
//将flutterView替换成Activity的contentView
|
||||
setContentView(flutterView);
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
经过上面的方法注册,我们就可以在Flutter层分别通过openNativePage和closeFlutterPage方法,来实现Flutter页面与原生页面之间的切换了。
|
||||
|
||||
在下面的例子中,Flutter容器的根视图DefaultPage包含有两个按钮:
|
||||
|
||||
- 点击左上角的按钮后,可以通过closeFlutterPage返回原生页面;
|
||||
- 点击中间的按钮后,会打开一个新的Flutter页面PageA。PageA中也有一个按钮,点击这个按钮之后会调用openNativePage来打开一个新的原生页面。
|
||||
|
||||
```
|
||||
void main() => runApp(_widgetForRoute(window.defaultRouteName));
|
||||
//获取方法通道
|
||||
const platform = MethodChannel('samples.chenhang/navigation');
|
||||
|
||||
//根据路由标识符返回应用入口视图
|
||||
Widget _widgetForRoute(String route) {
|
||||
switch (route) {
|
||||
default://返回默认视图
|
||||
return MaterialApp(home:DefaultPage());
|
||||
}
|
||||
}
|
||||
|
||||
class PageA extends StatelessWidget {
|
||||
...
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: RaisedButton(
|
||||
child: Text("Go PageB"),
|
||||
onPressed: ()=>platform.invokeMethod('openNativePage')//打开原生页面
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
class DefaultPage extends StatelessWidget {
|
||||
...
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text("DefaultPage Page"),
|
||||
leading: IconButton(icon:Icon(Icons.arrow_back), onPressed:() => platform.invokeMethod('closeFlutterPage')//关闭Flutter页面
|
||||
)),
|
||||
body: RaisedButton(
|
||||
child: Text("Go PageA"),
|
||||
onPressed: ()=>Navigator.push(context, MaterialPageRoute(builder: (context) => PageA())),//打开Flutter页面 PageA
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
整个混合导航栈示例的代码流程,如下图所示。通过这张图,你就可以把这个示例的整个代码流程串起来了。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/93/53/932efcc59bcc0ee590e644e67288ba53.png" alt="">
|
||||
|
||||
在我们的混合应用中,RootViewController与MainActivity分别是iOS和Android应用的原生页面入口,可以初始化为Flutter容器的FlutterHomeViewController(iOS端)与FlutterHomeActivity(Android端)。
|
||||
|
||||
在为其设置初始路由页面DefaultPage之后,就可以以原生的方式跳转至Flutter页面。但是,Flutter并未提供接口,来支持从Flutter的DefaultPage页面返回到原生页面,因此我们需要利用方法通道来注册关闭Flutter容器的方法,即closeFlutterPage,让Flutter容器接收到这个方法调用时关闭自身。
|
||||
|
||||
在Flutter容器内部,我们可以使用Flutter内部的页面路由机制,通过Navigator.push方法,完成从DefaultPage到PageA的页面跳转;而当我们想从Flutter的PageA页面跳转到原生页面时,因为涉及到跨引擎的页面路由,所以我们仍然需要利用方法通道来注册打开原生页面的方法,即openNativePage,让 Flutter容器接收到这个方法调用时,在原生代码宿主完成原生页面SomeOtherNativeViewController(iOS端)与SomeNativePageActivity(Android端)的初始化,并最终完成页面跳转。
|
||||
|
||||
## 总结
|
||||
|
||||
好了,今天的分享就到这里。我们一起总结下今天的主要内容吧。
|
||||
|
||||
对于原生Android、iOS工程混编Flutter开发,由于应用中会同时存在Android、iOS和Flutter页面,所以我们需要妥善处理跨渲染引擎的页面跳转,解决原生页面如何切换Flutter页面,以及Flutter页面如何切换到原生页面的问题。
|
||||
|
||||
在原生页面切换到Flutter页面时,我们通常会将Flutter容器封装成一个独立的ViewController(iOS端)或Activity(Android端),在为其设置好Flutter容器的页面初始化路由(即根视图)后,原生的代码就可以按照打开一个普通的原生页面的方式,来打开Flutter页面了。
|
||||
|
||||
而如果我们想在Flutter页面跳转到原生页面,则需要同时处理好打开新的原生页面,以及关闭自身回退到老的原生页面两种场景。在这两种场景下,我们都需要利用方法通道来注册相应的处理方法,从而在原生代码宿主实现新页面的打开和Flutter容器的关闭。
|
||||
|
||||
需要注意的是,与纯Flutter应用不同,原生应用混编Flutter由于涉及到原生页面与Flutter页面之间切换,因此导航栈内可能会出现多个Flutter容器的情况,即多个Flutter实例。
|
||||
|
||||
Flutter实例的初始化成本非常高昂,每启动一个Flutter实例,就会创建一套新的渲染机制,即Flutter Engine,以及底层的Isolate。而这些实例之间的内存是不互相共享的,会带来较大的系统资源消耗。
|
||||
|
||||
因此我们在实际业务开发中,应该尽量用Flutter去开发闭环的业务模块,原生只需要能够跳转到Flutter模块,剩下的业务都应该在Flutter内部完成,而**尽量避免Flutter页面又跳回到原生页面,原生页面又启动新的Flutter实例的情况**。
|
||||
|
||||
为了解决混编工程中Flutter多实例的问题,业界有两种解决方案:
|
||||
|
||||
- 以今日头条为代表的[修改Flutter Engine源码](https://mp.weixin.qq.com/s/-vyU1JQzdGLUmLGHRImIvg),使多FlutterView实例对应的多Flutter Engine能够在底层共享Isolate;
|
||||
- 以闲鱼为代表的[共享FlutterView](https://www.infoq.cn/article/VBqfCIuwdjtU_CmcKaEu),即由原生层驱动Flutter层渲染内容的方案。
|
||||
|
||||
坦白说,这两种方案各有不足:
|
||||
|
||||
- 前者涉及到修改Flutter源码,不仅开发维护成本高,而且增加了线程模型和内存回收出现异常的概率,稳定性不可控。
|
||||
- 后者涉及到跨渲染引擎的hack,包括Flutter页面的新建、缓存和内存回收等机制,因此在一些低端机或是处理页面切换动画时,容易出现渲染Bug。
|
||||
- 除此之外,这两种方式均与Flutter的内部实现绑定较紧,因此在处理Flutter SDK版本升级时往往需要耗费较大的适配成本。
|
||||
|
||||
综合来说,目前这两种解决方案都不够完美。所以,在Flutter官方支持多实例单引擎之前,我们还是尽量在产品模块层面,保证应用内不要出现多个Flutter容器实例吧。
|
||||
|
||||
我把今天分享所涉及到的知识点打包到了GitHub([flutter_module_page](https://github.com/cyndibaby905/29_flutter_module_page)、[android_demo](https://github.com/cyndibaby905/29_android_hybrid_demo)、[iOS_demo](https://github.com/cyndibaby905/29_ios_hybrid_demo))中,你可以下载下来,反复运行几次,加深理解与记忆。
|
||||
|
||||
## 思考题
|
||||
|
||||
最后,我给你留两道思考题吧。
|
||||
|
||||
1. 请在openNativePage方法的基础上,增加页面id的功能,可以支持在Flutter页面打开任意的原生页面。
|
||||
1. 混编工程中会出现两种页面过渡动画:原生页面之间的切换动画、Flutter页面之间的切换动画。请你思考下,如何能够确保这两种页面过渡动画在应用整体的效果是一致的。
|
||||
|
||||
欢迎你在评论区给我留言分享你的观点,我会在下一篇文章中等待你!感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。
|
||||
|
||||
|
||||
302
极客时间专栏/Flutter核心技术与实战/Flutter进阶/30 | 为什么需要做状态管理,怎么做?.md
Normal file
302
极客时间专栏/Flutter核心技术与实战/Flutter进阶/30 | 为什么需要做状态管理,怎么做?.md
Normal file
@@ -0,0 +1,302 @@
|
||||
<audio id="audio" title="30 | 为什么需要做状态管理,怎么做?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/2c/26/2c7fef7af30267adcb930e92ca364c26.mp3"></audio>
|
||||
|
||||
你好,我是陈航。
|
||||
|
||||
在上一篇文章中,我与你分享了如何在原生混编Flutter工程中管理混合导航栈,应对跨渲染引擎的页面跳转,即解决原生页面如何切换到Flutter页面,以及Flutter页面如何切换到原生页面的问题。
|
||||
|
||||
如果说跨渲染引擎页面切换的关键在于,如何确保页面跳转的渲染体验一致性,那么跨组件(页面)之间保持数据共享的关键就在于,如何清晰地维护组件共用的数据状态了。在第20篇文章“[关于跨组件传递数据,你只需要记住这三招](https://time.geekbang.org/column/article/116382)”中,我已经与你介绍了InheritedWidget、Notification和EventBus这3种数据传递机制,通过它们可以实现组件间的单向数据传递。
|
||||
|
||||
如果我们的应用足够简单,数据流动的方向和顺序是清晰的,我们只需要将数据映射成视图就可以了。作为声明式的框架,Flutter可以自动处理数据到渲染的全过程,通常并不需要状态管理。
|
||||
|
||||
但,随着产品需求迭代节奏加快,项目逐渐变得庞大时,我们往往就需要管理不同组件、不同页面之间共享的数据关系。当需要共享的数据关系达到几十上百个的时候,我们就很难保持清晰的数据流动方向和顺序了,导致应用内各种数据传递嵌套和回调满天飞。在这个时候,我们迫切需要一个解决方案,来帮助我们理清楚这些共享数据的关系,于是状态管理框架便应运而生。
|
||||
|
||||
Flutter在设计声明式UI上借鉴了不少React的设计思想,因此涌现了诸如flutter_redux、flutter_mobx 、fish_redux等基于前端设计理念的状态管理框架。但这些框架大都比较复杂,且需要对框架设计概念有一定理解,学习门槛相对较高。
|
||||
|
||||
而源自Flutter官方的状态管理框架Provider则相对简单得多,不仅容易理解,而且框架的入侵性小,还可以方便地组合和控制UI刷新粒度。因此,在Google I/O 2019大会一经面世,Provider就成为了官方推荐的状态管理方式之一。
|
||||
|
||||
那么今天,我们就来聊聊Provider到底怎么用吧。
|
||||
|
||||
## Provider
|
||||
|
||||
从名字就可以看出,Provider是一个用来提供数据的框架。它是InheritedWidget的语法糖,提供了依赖注入的功能,允许在Widget树中更加灵活地处理和传递数据。
|
||||
|
||||
那么,什么是依赖注入呢?通俗地说,依赖注入是一种可以让我们在需要时提取到所需资源的机制,即:预先将某种“资源”放到程序中某个我们都可以访问的位置,当需要使用这种“资源”时,直接去这个位置拿即可,而无需关心“资源”是谁放进去的。
|
||||
|
||||
所以,为了使用Provider,我们需要解决以下3个问题:
|
||||
|
||||
- 资源(即数据状态)如何封装?
|
||||
- 资源放在哪儿,才都能访问得到?
|
||||
- 具体使用时,如何取出资源?
|
||||
|
||||
接下来,我通过一个例子来与你演示如何使用Provider。
|
||||
|
||||
在下面的示例中,我们有两个独立的页面FirstPage和SecondPage,它们会共享计数器的状态:其中FirstPage负责读,SecondPage负责读和写。
|
||||
|
||||
在使用Provider之前,我们**首先需要在pubspec.yaml文件中添加Provider的依赖**:
|
||||
|
||||
```
|
||||
dependencies:
|
||||
flutter:
|
||||
sdk: flutter
|
||||
provider: 3.0.0+1 #provider依赖
|
||||
|
||||
```
|
||||
|
||||
添加好Provider的依赖后,我们就可以进行数据状态的封装了。这里,我们只有一个状态需要共享,即count。由于第二个页面还需要修改状态,因此我们还需要在数据状态的封装上包含更改数据的方法:
|
||||
|
||||
```
|
||||
//定义需要共享的数据模型,通过混入ChangeNotifier管理听众
|
||||
class CounterModel with ChangeNotifier {
|
||||
int _count = 0;
|
||||
//读方法
|
||||
int get counter => _count;
|
||||
//写方法
|
||||
void increment() {
|
||||
_count++;
|
||||
notifyListeners();//通知听众刷新
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
可以看到,我们在资源封装类中使用mixin混入了ChangeNotifier。这个类能够帮助我们管理所有依赖资源封装类的听众。当资源封装类调用notifyListeners时,它会通知所有听众进行刷新。
|
||||
|
||||
**资源已经封装完毕,接下来我们就需要考虑把它放到哪儿了。**
|
||||
|
||||
因为Provider实际上是InheritedWidget的语法糖,所以通过Provider传递的数据从数据流动方向来看,是由父到子(或者反过来)。这时我们就明白了,原来需要把资源放到FirstPage和SecondPage的父Widget,也就是应用程序的实例MyApp中(当然,把资源放到更高的层级也是可以的,比如放到main函数中):
|
||||
|
||||
```
|
||||
class MyApp extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
//通过Provider组件封装数据资源
|
||||
return ChangeNotifierProvider.value(
|
||||
value: CounterModel(),//需要共享的数据资源
|
||||
child: MaterialApp(
|
||||
home: FirstPage(),
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
可以看到,既然Provider是InheritedWidget的语法糖,因此它也是一个Widget。所以,我们直接在MaterialApp的外层使用Provider进行包装,就可以把数据资源依赖注入到应用中。
|
||||
|
||||
这里需要注意的是,由于封装的数据资源不仅需要为子Widget提供读的能力,还要提供写的能力,因此我们需要使用Provider的升级版ChangeNotifierProvider。而如果只需要为子Widget提供读能力,直接使用Provider即可。
|
||||
|
||||
**最后,在注入数据资源完成之后,我们就可以在FirstPage和SecondPage这两个子Widget完成数据的读写操作了。**
|
||||
|
||||
关于读数据,与InheritedWidget一样,我们可以通过Provider.of方法来获取资源数据。而如果我们想写数据,则需要通过获取到的资源数据,调用其暴露的更新数据方法(本例中对应的是increment),代码如下所示:
|
||||
|
||||
```
|
||||
//第一个页面,负责读数据
|
||||
class FirstPage extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
//取出资源
|
||||
final _counter = Provider.of<CounterModel>(context);
|
||||
return Scaffold(
|
||||
//展示资源中的数据
|
||||
body: Text('Counter: ${_counter.counter}'),
|
||||
//跳转到SecondPage
|
||||
floatingActionButton: FloatingActionButton(
|
||||
onPressed: () => Navigator.of(context).push(MaterialPageRoute(builder: (context) => SecondPage()))
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
//第二个页面,负责读写数据
|
||||
class SecondPage extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
//取出资源
|
||||
final _counter = Provider.of<CounterModel>(context);
|
||||
return Scaffold(
|
||||
//展示资源中的数据
|
||||
body: Text('Counter: ${_counter.counter}'),
|
||||
//用资源更新方法来设置按钮点击回调
|
||||
floatingActionButton:FloatingActionButton(
|
||||
onPressed: _counter.increment,
|
||||
child: Icon(Icons.add),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
运行代码,试着多点击几次第二个界面的“+”按钮,关闭第二个界面,可以看到第一个界面也同步到了按钮的点击数。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/8e/45/8e13ca8e62920a403b00122136a46245.gif" alt="">
|
||||
|
||||
## Consumer
|
||||
|
||||
通过上面的示例可以看到,使用Provider.of获取资源,可以得到资源暴露的数据的读写接口,在实现数据的共享和同步上还是比较简单的。但是,**滥用Provider.of方法也有副作用,那就是当数据更新时,页面中其他的子Widget也会跟着一起刷新。**
|
||||
|
||||
为验证这一点,我们以第二个界面右下角FloatingActionButton中的子Widget “+”Icon为例做个测试。
|
||||
|
||||
首先,为了打印出Icon控件每一次刷新的情况,我们需要自定义一个控件TestIcon,并在其build方法中返回Icon实例的同时,打印一句话:
|
||||
|
||||
```
|
||||
//用于打印build方法执行情况的自定义控件
|
||||
class TestIcon extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
print("TestIcon build");
|
||||
return Icon(Icons.add);//返回Icon实例
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
然后,我们用TestIcon控件,替换掉SecondPage中FloatingActionButton的Icon子Widget:
|
||||
|
||||
```
|
||||
class SecondPage extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
//取出共享的数据资源
|
||||
final _counter = Provider.of<CounterModel>(context);
|
||||
return Scaffold(
|
||||
...
|
||||
floatingActionButton:FloatingActionButton(
|
||||
onPressed: _counter.increment,
|
||||
child: TestIcon(),//替换掉原有的Icon(Icons.add)
|
||||
));
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
运行这段实例,然后在第二个页面多次点击“+”按钮,观察控制台输出:
|
||||
|
||||
```
|
||||
I/flutter (21595): TestIcon build
|
||||
I/flutter (21595): TestIcon build
|
||||
I/flutter (21595): TestIcon build
|
||||
I/flutter (21595): TestIcon build
|
||||
I/flutter (21595): TestIcon build
|
||||
|
||||
```
|
||||
|
||||
可以看到,TestIcon控件本来是一个不需要刷新的StatelessWidget,但却因为其父Widget FloatingActionButton所依赖的数据资源counter发生了变化,导致它也要跟着刷新。
|
||||
|
||||
那么,**有没有办法能够在数据资源发生变化时,只刷新对资源存在依赖关系的Widget,而其他Widget保持不变呢?**
|
||||
|
||||
答案当然是可以的。
|
||||
|
||||
在本次分享一开始时,我曾说Provider可以精确地控制UI刷新粒度,而这一切是基于Consumer实现的。Consumer使用了Builder模式创建UI,收到更新通知就会通过builder重新构建Widget。
|
||||
|
||||
接下来,我们就看看**如何使用Consumer来改造SecondPage**吧。
|
||||
|
||||
在下面的例子中,我们在SecondPage中去掉了Provider.of方法来获取counter的语句,在其真正需要这个数据资源的两个子Widget,即Text和FloatingActionButton中,使用Consumer来对它们进行了一层包装:
|
||||
|
||||
```
|
||||
class SecondPage extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
//使用Consumer来封装counter的读取
|
||||
body: Consumer<CounterModel>(
|
||||
//builder函数可以直接获取到counter参数
|
||||
builder: (context, CounterModel counter, _) => Text('Value: ${counter.counter}')),
|
||||
//使用Consumer来封装increment的读取
|
||||
floatingActionButton: Consumer<CounterModel>(
|
||||
//builder函数可以直接获取到increment参数
|
||||
builder: (context, CounterModel counter, child) => FloatingActionButton(
|
||||
onPressed: counter.increment,
|
||||
child: child,
|
||||
),
|
||||
child: TestIcon(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
可以看到,Consumer中的builder实际上就是真正刷新UI的函数,它接收3个参数,即context、model和child。其中:context是Widget的build方法传进来的BuildContext,model是我们需要的数据资源,而child则用来构建那些与数据资源无关的部分。在数据资源发生变更时,builder会多次执行,但child不会重建。
|
||||
|
||||
运行这段代码,可以发现,不管我们点击了多少次“+”按钮,TestIcon控件始终没有发生销毁重建。
|
||||
|
||||
## 多状态的资源封装
|
||||
|
||||
通过上面的例子,我们学习了Provider是如何共享一个数据状态的。那么,如果有多个数据状态需要共享,我们又该如何处理呢?
|
||||
|
||||
其实也不难。接下来,我就**按照封装、注入和读写这3个步骤,与你介绍多个数据状态的共享**。
|
||||
|
||||
在处理多个数据状态共享之前,我们需要先扩展一下上面计数器状态共享的例子,让两个页面之间展示计数器数据的Text能够共享App传递的字体大小。
|
||||
|
||||
**首先,我们来看看如何封装**。
|
||||
|
||||
多个数据状态与单个数据的封装并无不同,如果需要支持数据的读写,我们需要一个接一个地为每一个数据状态都封装一个单独的资源封装类;而如果数据是只读的,则可以直接传入原始的数据对象,从而省去资源封装的过程。
|
||||
|
||||
**接下来,我们再看看如何实现注入。**
|
||||
|
||||
在单状态的案例中,我们通过Provider的升级版ChangeNotifierProvider实现了可读写资源的注入,而如果我们想注入多个资源,则可以使用Provider的另一个升级版MultiProvider,来实现多个Provider的组合注入。
|
||||
|
||||
在下面的例子中,我们通过MultiProvider往App实例内注入了double和CounterModel这两个资源Provider:
|
||||
|
||||
```
|
||||
class MyApp extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MultiProvider(providers: [
|
||||
Provider.value(value: 30.0),//注入字体大小
|
||||
ChangeNotifierProvider.value(value: CounterModel())//注入计数器实例
|
||||
],
|
||||
child: MaterialApp(
|
||||
home: FirstPage(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在完成了多个资源的注入后,最后我们来看看**如何获取这些资源**。
|
||||
|
||||
这里,我们还是使用Provider.of方式来获取资源。相较于单状态资源的获取来说,获取多个资源时,我们只需要依次读取每一个资源即可:
|
||||
|
||||
```
|
||||
final _counter = Provider.of<CounterModel>(context);//获取计时器实例
|
||||
final textSize = Provider.of<double>(context);//获取字体大小
|
||||
|
||||
```
|
||||
|
||||
而如果以Consumer的方式来获取资源的话,我们只要使用Consumer2<A,B>对象(这个对象提供了读取两个数据资源的能力),就可以一次性地获取字体大小与计数器实例这两个数据资源:
|
||||
|
||||
```
|
||||
//使用Consumer2获取两个数据资源
|
||||
Consumer2<CounterModel,double>(
|
||||
//builder函数以参数的形式提供了数据资源
|
||||
builder: (context, CounterModel counter, double textSize, _) => Text(
|
||||
'Value: ${counter.counter}',
|
||||
style: TextStyle(fontSize: textSize))
|
||||
)
|
||||
|
||||
```
|
||||
|
||||
可以看到,Consumer2与Consumer的使用方式基本一致,只不过是在builder方法中多了一个数据资源参数。事实上,如果你希望在子Widget中共享更多的数据,我们最多可以使用到Consumer6,即共享6个数据资源。
|
||||
|
||||
## 总结
|
||||
|
||||
好了,今天的分享就到这里,我们总结一下今天的主要内容吧。
|
||||
|
||||
我与你介绍了在Flutter中通过Provider进行状态管理的方法,Provider以InheritedWidget语法糖的方式,通过数据资源封装、数据注入和数据读写这3个步骤,为我们实现了跨组件(跨页面)之间的数据共享。
|
||||
|
||||
我们既可以用Provider来实现静态的数据读传递,也可以使用ChangeNotifierProvider来实现动态的数据读写传递,还可以通过MultiProvider来实现多个数据资源的共享。
|
||||
|
||||
在具体使用数据时,Provider.of和Consumer都可以实现数据的读取,并且Consumer还可以控制UI刷新的粒度,避免与数据无关的组件的无谓刷新。
|
||||
|
||||
可以看到,通过Provider来实现数据传递,无论在单个页面内还是在整个App之间,我们都可以很方便地实现状态管理,搞定那些通过StatefulWidget无法实现的场景,进而开发出简单、层次清晰、可扩展性高的应用。事实上,当我们使用Provider后,我们就再也不需要使用StatefulWidget了。
|
||||
|
||||
我把今天分享所涉及到的知识点打包到了[GitHub](https://github.com/cyndibaby905/30_provider_demo)中,你可以下载下来,反复运行几次,加深理解与记忆。
|
||||
|
||||
## 思考题
|
||||
|
||||
最后,我给你留一道思考题吧。
|
||||
|
||||
使用Provider可以实现2个同样类型的对象共享,你知道应该如何实现吗?
|
||||
|
||||
欢迎你在评论区给我留言分享你的观点,我会在下一篇文章中等待你!感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。
|
||||
|
||||
|
||||
522
极客时间专栏/Flutter核心技术与实战/Flutter进阶/31 | 如何实现原生推送能力?.md
Normal file
522
极客时间专栏/Flutter核心技术与实战/Flutter进阶/31 | 如何实现原生推送能力?.md
Normal file
@@ -0,0 +1,522 @@
|
||||
<audio id="audio" title="31 | 如何实现原生推送能力?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/db/82/dbcc23a0abe7f62e1425513c3c267382.mp3"></audio>
|
||||
|
||||
你好,我是陈航。
|
||||
|
||||
在上一篇文章中,我与你分享了如何使用Provider去维护Flutter组件共用的数据状态。在Flutter中状态即数据,通过数据资源封装、注入和读写这三步,我们不仅可以实现跨组件之间的数据共享,还能精确控制UI刷新粒度,避免无关组件的刷新。
|
||||
|
||||
其实,数据共享不仅存在于客户端内部,同样也存在于服务端与客户端之间。比如,有新的微博评论,或者是发生了重大新闻,我们都需要在服务端把这些状态变更的消息实时推送到客户端,提醒用户有新的内容。有时,我们还会针对特定的用户画像,通过推送实现精准的营销信息触达。
|
||||
|
||||
可以说,消息推送是增强用户黏性,促进用户量增长的重要手段。那么,消息推送的流程是什么样的呢?
|
||||
|
||||
## 消息推送流程
|
||||
|
||||
手机推送每天那么多,导致在我们看来这很简单啊。但其实,消息推送是一个横跨业务服务器、第三方推送服务托管厂商、操作系统长连接推送服务、用户终端、手机应用五方的复杂业务应用场景。
|
||||
|
||||
在iOS上,苹果推送服务(APNs)接管了系统所有应用的消息通知需求;而Android原生,则提供了类似Firebase的云消息传递机制(FCM),可以实现统一的推送托管服务。
|
||||
|
||||
当某应用需要发送消息通知时,这则消息会由应用的服务器先发给苹果或Google,经由APNs或FCM被发送到设备,设备操作系统在完成解析后,最终把消息转给所属应用。这个流程的示意图,如下所示。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/91/ea/916f0ae684a20fbc87a15314cb8f2aea.png" alt="">
|
||||
|
||||
不过,Google服务在大陆地区使用并不稳定,因此国行Android手机通常会把Google服务换成自己的服务,定制一套推送标准。而这对开发者来说,无疑是增大了适配负担。所以针对Android端,我们通常会使用第三方推送服务,比如极光推送、友盟推送等。
|
||||
|
||||
虽然这些第三方推送服务使用自建的长连接,无法享受操作系统底层的优化,但它们会对所有使用推送服务的App共享推送通道,只要有一个使用第三方推送服务的应用没被系统杀死,就可以让消息及时送达。
|
||||
|
||||
而另一方面,这些第三方服务简化了业务服务器与手机推送服务建立连接的操作,使得我们的业务服务器通过简单的API调用就可以完成消息推送。
|
||||
|
||||
而为了保持Android/iOS方案的统一,在iOS上我们也会使用封装了APNs通信的第三方推送服务。
|
||||
|
||||
第三方推送的服务流程,如下图所示。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/10/23/10f46ae2b6ccb3753241dbf56f961223.png" alt="">
|
||||
|
||||
这些第三方推送服务厂商提供的能力和接入流程大都一致,考虑到极光的社区和生态相对活跃,所以今天我们就以极光为例,来看看在Flutter应用中如何才能引用原生的推送能力。
|
||||
|
||||
## 原生推送接入流程
|
||||
|
||||
要想在Flutter中接收推送消息,我们需要把原生的推送能力暴露给Flutter应用,即在原生代码宿主实现推送能力(极光SDK)的接入,并通过方法通道提供给Dart层感知推送消息的机制。
|
||||
|
||||
### 插件工程
|
||||
|
||||
在[第26篇](https://time.geekbang.org/column/article/127601)文章中,我们学习了如何在原生工程中的Flutter应用入口注册原生代码宿主回调,从而实现Dart层调用原生接口的方案。这种方案简单直接,适用于Dart层与原生接口之间交互代码量少、数据流动清晰的场景。
|
||||
|
||||
但对于推送这种涉及Dart与原生多方数据流转、代码量大的模块,这种与工程耦合的方案就不利于独立开发维护了。这时,我们需要使用Flutter提供的插件工程对其进行单独封装。
|
||||
|
||||
Flutter的插件工程与普通的应用工程类似,都有android和ios目录,这也是我们完成平台相关逻辑代码的地方,而Flutter工程插件的注册,则仍会在应用的入口完成。除此之外,插件工程还内嵌了一个example工程,这是一个引用了插件代码的普通Flutter应用工程。我们通过example工程,可以直接调试插件功能。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/d6/38/d608c42b507e27a7456f37255d8a5b38.png" alt="">
|
||||
|
||||
在了解了整体工程的目录结构之后,接下来我们需要去Dart插件代码所在的flutter_push_plugin.dart文件,实现Dart层的推送接口封装。
|
||||
|
||||
### Dart接口实现
|
||||
|
||||
为了实现消息的准确触达,我们需要提供一个可以标识手机上App的地址,即token或id。一旦完成地址的上报,我们就可以等待业务服务器给我们发消息了。
|
||||
|
||||
因为我们需要使用极光这样的第三方推送服务,所以还得进行一些前置的应用信息关联绑定,以及SDK的初始化工作。可以看到,对于一个应用而言,接入推送的过程可以拆解为以下三步:
|
||||
|
||||
1. 初始化极光SDK;
|
||||
1. 获取地址id;
|
||||
1. 注册消息通知。
|
||||
|
||||
这三步对应着在Dart层需要封装的3个原生接口调用:setup、registrationID和setOpenNotificationHandler。
|
||||
|
||||
前两个接口是在方法通道上调用原生代码宿主提供的方法,而注册消息通知的回调函数setOpenNotificationHandler则相反,是原生代码宿主在方法通道上调用Dart层所提供的事件回调,因此我们需要在方法通道上为原生代码宿主注册反向回调方法,让原生代码宿主收到消息后可以直接通知它。
|
||||
|
||||
另外,考虑到推送是整个应用共享的能力,因此我们将FlutterPushPlugin这个类封装成了单例:
|
||||
|
||||
```
|
||||
//Flutter Push插件
|
||||
class FlutterPushPlugin {
|
||||
//单例
|
||||
static final FlutterPushPlugin _instance = new FlutterPushPlugin.private(const MethodChannel('flutter_push_plugin'));
|
||||
//方法通道
|
||||
final MethodChannel _channel;
|
||||
//消息回调
|
||||
EventHandler _onOpenNotification;
|
||||
//构造方法
|
||||
FlutterPushPlugin.private(MethodChannel channel) : _channel = channel {
|
||||
//注册原生反向回调方法,让原生代码宿主可以执行onOpenNotification方法
|
||||
_channel.setMethodCallHandler(_handleMethod);
|
||||
}
|
||||
//初始化极光SDK
|
||||
setupWithAppID(String appID) {
|
||||
_channel.invokeMethod("setup", appID);
|
||||
}
|
||||
//注册消息通知
|
||||
setOpenNotificationHandler(EventHandler onOpenNotification) {
|
||||
_onOpenNotification = onOpenNotification;
|
||||
}
|
||||
|
||||
//注册原生反向回调方法,让原生代码宿主可以执行onOpenNotification方法
|
||||
Future<Null> _handleMethod(MethodCall call) {
|
||||
switch (call.method) {
|
||||
case "onOpenNotification":
|
||||
return _onOpenNotification(call.arguments);
|
||||
default:
|
||||
throw new UnsupportedError("Unrecognized Event");
|
||||
}
|
||||
}
|
||||
//获取地址id
|
||||
Future<String> get registrationID async {
|
||||
final String regID = await _channel.invokeMethod('getRegistrationID');
|
||||
return regID;
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
Dart层是原生代码宿主的代理,可以看到这一层的接口设计算是简单。接下来,我们分别去接管推送的Android和iOS平台上完成相应的实现。
|
||||
|
||||
### Android接口实现
|
||||
|
||||
考虑到Android平台的推送配置工作相对较少,因此我们先用Android Studio打开example下的android工程进行插件开发工作。需要注意的是,由于android子工程的运行依赖于Flutter工程编译构建产物,所以在打开android工程进行开发前,你需要确保整个工程代码至少build过一次,否则IDE会报错。
|
||||
|
||||
>
|
||||
备注:以下操作步骤参考[极光Android SDK集成指南](https://docs.jiguang.cn//jpush/client/Android/android_guide/)。
|
||||
|
||||
|
||||
首先,我们需要在插件工程下的build.gradle引入极光SDK,即jpush与jcore:
|
||||
|
||||
```
|
||||
dependencies {
|
||||
implementation 'cn.jiguang.sdk:jpush:3.3.4'
|
||||
implementation 'cn.jiguang.sdk:jcore:2.1.2'
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
然后,在原生接口FlutterPushPlugin类中,依次把Dart层封装的3个接口调用,即setup、getRegistrationID与onOpenNotification,提供极光Android SDK的实现版本。
|
||||
|
||||
需要注意的是,由于极光Android SDK的信息绑定是在应用的打包配置里设置,并不需要通过代码完成(iOS才需要),因此setup方法的Android版本是一个空实现:
|
||||
|
||||
```
|
||||
public class FlutterPushPlugin implements MethodCallHandler {
|
||||
//注册器,通常为MainActivity
|
||||
public final Registrar registrar;
|
||||
//方法通道
|
||||
private final MethodChannel channel;
|
||||
//插件实例
|
||||
public static FlutterPushPlugin instance;
|
||||
//注册插件
|
||||
public static void registerWith(Registrar registrar) {
|
||||
//注册方法通道
|
||||
final MethodChannel channel = new MethodChannel(registrar.messenger(), "flutter_push_plugin");
|
||||
instance = new FlutterPushPlugin(registrar, channel);
|
||||
channel.setMethodCallHandler(instance);
|
||||
//把初始化极光SDK提前至插件注册时
|
||||
JPushInterface.setDebugMode(true);
|
||||
JPushInterface.init(registrar.activity().getApplicationContext());
|
||||
}
|
||||
//私有构造方法
|
||||
private FlutterPushPlugin(Registrar registrar, MethodChannel channel) {
|
||||
this.registrar = registrar;
|
||||
this.channel = channel;
|
||||
}
|
||||
//方法回调
|
||||
@Override
|
||||
public void onMethodCall(MethodCall call, Result result) {
|
||||
if (call.method.equals("setup")) {
|
||||
//极光Android SDK的初始化工作需要在App工程中配置,因此不需要代码实现
|
||||
result.success(0);
|
||||
}
|
||||
else if (call.method.equals("getRegistrationID")) {
|
||||
//获取极光推送地址标识符
|
||||
result.success(JPushInterface.getRegistrationID(registrar.context()));
|
||||
} else {
|
||||
result.notImplemented();
|
||||
}
|
||||
}
|
||||
|
||||
public void callbackNotificationOpened(NotificationMessage message) {
|
||||
//将推送消息回调给Dart层
|
||||
channel.invokeMethod("onOpenNotification",message.notificationContent);
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
可以看到,我们的FlutterPushPlugin类中,仅提供了callbackNotificationOpened这个工具方法,用于推送消息参数回调给Dart,但这个类本身并没有去监听极光SDK的推送消息。
|
||||
|
||||
为了获取推送消息,我们分别需要继承极光SDK提供的两个类:JCommonService和JPushMessageReceiver。
|
||||
|
||||
- JCommonService是一个后台Service,实际上是极光共享长连通道的核心,可以在多手机平台上使得推送通道更稳定。
|
||||
- JPushMessageReceiver则是一个BroadcastReceiver,推送消息的获取都是通过它实现的。我们可以通过覆盖其onNotifyMessageOpened方法,从而在用户点击系统推送消息时获取到通知。
|
||||
|
||||
作为BroadcastReceiver的JPushMessageReceiver,可以长期在后台存活,监听远端推送消息,但Flutter可就不行了,操作系统会随时释放掉后台应用所占用的资源。因此,**在用户点击推送时,我们在收到相应的消息回调后,需要做的第一件事情不是立刻通知Flutter,而是应该启动应用的MainActivity。**在确保Flutter已经完全初始化后,才能通知Flutter有新的推送消息。
|
||||
|
||||
因此在下面的代码中,我们在打开MainActivity后,等待了1秒,才执行相应的Flutter回调通知:
|
||||
|
||||
```
|
||||
//JPushXCustomService.java
|
||||
//长连通道核心,可以使推送通道更稳定
|
||||
public class JPushXCustomService extends JCommonService {
|
||||
}
|
||||
|
||||
//JPushXMessageReceiver.java
|
||||
//获取推送消息的Receiver
|
||||
public class JPushXMessageReceiver extends JPushMessageReceiver {
|
||||
//用户点击推送消息回调
|
||||
@Override
|
||||
public void onNotifyMessageOpened(Context context, final NotificationMessage message) {
|
||||
try {
|
||||
//找到MainActivity
|
||||
String mainClassName = context.getApplicationContext().getPackageName() + ".MainActivity";
|
||||
Intent i = new Intent(context, Class.forName(mainClassName));
|
||||
i.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_SINGLE_TOP);
|
||||
//启动主Activity
|
||||
context.startActivity(i);
|
||||
} catch (Exception e) {
|
||||
Log.e("tag","找不到MainActivity");
|
||||
}
|
||||
new Timer().schedule(new TimerTask() {
|
||||
@Override
|
||||
public void run() {
|
||||
FlutterPushPlugin.instance.callbackNotificationOpened(message);
|
||||
}
|
||||
},1000); // 延迟1秒通知Dart
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
最后,我们还需要在插件工程的AndroidManifest.xml中,分别声明receiver JPushXMessageReceiver和service JPushXCustomService,完成对系统的注册:
|
||||
|
||||
```
|
||||
...
|
||||
<application>
|
||||
<!--注册推送消息接收类 -->
|
||||
<receiver android:name=".JPushXMessageReceiver">
|
||||
<intent-filter>
|
||||
<action android:name="cn.jpush.android.intent.RECEIVE_MESSAGE" />
|
||||
<category android:name="${applicationId}" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
<!--注册长连通道Service -->
|
||||
<service android:name=".JPushXCustomService"
|
||||
android:enabled="true"
|
||||
android:exported="false"
|
||||
android:process=":pushcore">
|
||||
<intent-filter>
|
||||
<action android:name="cn.jiguang.user.service.action" />
|
||||
</intent-filter>
|
||||
</service>
|
||||
</application>
|
||||
...
|
||||
|
||||
```
|
||||
|
||||
接收消息和回调消息的功能完成后,FlutterPushPlugin插件的Android部分就搞定了。接下来,我们去开发插件的iOS部分。
|
||||
|
||||
### iOS接口实现
|
||||
|
||||
与Android类似,我们需要使用Xcode打开example下的ios工程进行插件开发工作。同样,在打开ios工程前,你需要确保整个工程代码至少build过一次,否则IDE会报错。
|
||||
|
||||
>
|
||||
备注:以下操作步骤参考[极光iOS SDK集成指南](https://docs.jiguang.cn//jpush/client/iOS/ios_guide_new/)
|
||||
|
||||
|
||||
首先,我们需要在插件工程下的flutter_push_plugin.podspec文件中引入极光SDK,即jpush。这里,我们选用了不使用广告id的版本:
|
||||
|
||||
```
|
||||
Pod::Spec.new do |s|
|
||||
...
|
||||
s.dependency 'JPush', '3.2.2-noidfa'
|
||||
end
|
||||
|
||||
```
|
||||
|
||||
然后,在原生接口FlutterPushPlugin类中,同样依次为setup、getRegistrationID与onOpenNotification,提供极光 iOS SDK的实现版本。
|
||||
|
||||
需要注意的是,APNs的推送消息是在ApplicationDelegate中回调的,所以我们需要在注册插件时,为插件提供同名的回调函数,让极光SDK把推送消息转发到插件的回调函数中。
|
||||
|
||||
与Android类似,在极光SDK收到推送消息时,我们的应用可能处于后台,因此在用户点击了推送消息,把Flutter应用唤醒时,我们应该在确保Flutter已经完全初始化后,才能通知Flutter有新的推送消息。
|
||||
|
||||
因此在下面的代码中,我们在用户点击了推送消息后也等待了1秒,才执行相应的Flutter回调通知:
|
||||
|
||||
```
|
||||
@implementation FlutterPushPlugin
|
||||
//注册插件
|
||||
+ (void)registerWithRegistrar:(NSObject<FlutterPluginRegistrar>*)registrar {
|
||||
//注册方法通道
|
||||
FlutterMethodChannel* channel = [FlutterMethodChannel methodChannelWithName:@"flutter_push_plugin" binaryMessenger:[registrar messenger]];
|
||||
//初始化插件实例,绑定方法通道
|
||||
FlutterPushPlugin* instance = [[FlutterPushPlugin alloc] init];
|
||||
instance.channel = channel;
|
||||
//为插件提供ApplicationDelegate回调方法
|
||||
[registrar addApplicationDelegate:instance];
|
||||
//注册方法通道回调函数
|
||||
[registrar addMethodCallDelegate:instance channel:channel];
|
||||
}
|
||||
//处理方法调用
|
||||
- (void)handleMethodCall:(FlutterMethodCall*)call result:(FlutterResult)result {
|
||||
if([@"setup" isEqualToString:call.method]) {
|
||||
//极光SDK初始化方法
|
||||
[JPUSHService setupWithOption:self.launchOptions appKey:call.arguments channel:@"App Store" apsForProduction:YES advertisingIdentifier:nil];
|
||||
} else if ([@"getRegistrationID" isEqualToString:call.method]) {
|
||||
//获取极光推送地址标识符
|
||||
[JPUSHService registrationIDCompletionHandler:^(int resCode, NSString *registrationID) {
|
||||
result(registrationID);
|
||||
}];
|
||||
} else {
|
||||
//方法未实现
|
||||
result(FlutterMethodNotImplemented);
|
||||
}
|
||||
}
|
||||
//应用程序启动回调
|
||||
-(BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
|
||||
//初始化极光推送服务
|
||||
JPUSHRegisterEntity * entity = [[JPUSHRegisterEntity alloc] init];
|
||||
//设置推送权限
|
||||
entity.types = JPAuthorizationOptionAlert|JPAuthorizationOptionBadge|JPAuthorizationOptionSound;
|
||||
//请求推送服务
|
||||
[JPUSHService registerForRemoteNotificationConfig:entity delegate:self];
|
||||
//存储App启动状态,用于后续初始化调用
|
||||
self.launchOptions = launchOptions;
|
||||
return YES;
|
||||
}
|
||||
//推送token回调
|
||||
- (void)application:(UIApplication *)application didRegisterForRemoteNotificationsWithDeviceToken:(NSData *)deviceToken {
|
||||
///注册DeviceToken,换取极光推送地址标识符
|
||||
[JPUSHService registerDeviceToken:deviceToken];
|
||||
}
|
||||
//推送被点击回调
|
||||
- (void)jpushNotificationCenter:(UNUserNotificationCenter *)center didReceiveNotificationResponse:(UNNotificationResponse *)response withCompletionHandler:(void(^)(void))completionHandler {
|
||||
//获取推送消息
|
||||
NSDictionary * userInfo = response.notification.request.content.userInfo;
|
||||
NSString *content = userInfo[@"aps"][@"alert"];
|
||||
if ([content isKindOfClass:[NSDictionary class]]) {
|
||||
content = userInfo[@"aps"][@"alert"][@"body"];
|
||||
}
|
||||
//延迟1秒通知Flutter,确保Flutter应用已完成初始化
|
||||
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(1 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
|
||||
[self.channel invokeMethod:@"onOpenNotification" arguments:content];
|
||||
});
|
||||
//清除应用的小红点
|
||||
UIApplication.sharedApplication.applicationIconBadgeNumber = 0;
|
||||
//通知系统,推送回调处理完毕
|
||||
completionHandler();
|
||||
}
|
||||
//前台应用收到了推送消息
|
||||
- (void)jpushNotificationCenter:(UNUserNotificationCenter *)center willPresentNotification:(UNNotification *)notification withCompletionHandler:(void (^)(NSInteger options))completionHandler {
|
||||
//通知系统展示推送消息提示
|
||||
completionHandler(UNNotificationPresentationOptionAlert);
|
||||
}
|
||||
@end
|
||||
|
||||
```
|
||||
|
||||
至此,在完成了极光iOS SDK的接口封装之后,FlutterPushPlugin插件的iOS部分也搞定了。
|
||||
|
||||
FlutterPushPlugin插件为Flutter应用提供了原生推送的封装,不过要想example工程能够真正地接收到推送消息,我们还需要对exmaple工程进行最后的配置,即:为它提供应用推送证书,并关联极光应用配置。
|
||||
|
||||
### 应用工程配置
|
||||
|
||||
在单独为Android/iOS应用进行推送配置之前,我们首先需要去[极光的官方网站](https://www.jiguang.cn/dev2/#/overview/appCardList),为example应用注册一个唯一标识符(即AppKey):
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/14/6a/1474a599fc1c2bd34d2e12cd6dbfb46a.png" alt="">
|
||||
|
||||
在得到了AppKey之后,我们需要**依次进行Android与iOS的配置工作**。
|
||||
|
||||
Android的配置工作相对简单,整个配置过程完全是应用与极光SDK的关联工作。
|
||||
|
||||
首先,根据example的Android工程包名,完成Android工程的推送注册:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/3d/b9/3de9b139d409f669fa23a94cb2cdcdb9.png" alt="">
|
||||
|
||||
然后,通过AppKey,在app的build.gradle文件中实现极光信息的绑定:
|
||||
|
||||
```
|
||||
defaultConfig {
|
||||
...
|
||||
//ndk支持架构
|
||||
ndk {
|
||||
abiFilters 'armeabi', 'armeabi-v7a', 'arm64-v8a'
|
||||
}
|
||||
|
||||
manifestPlaceholders = [
|
||||
JPUSH_PKGNAME : applicationId, //包名
|
||||
JPUSH_APPKEY : "f861910af12a509b34e266c2", //JPush 上注册的包名对应的Appkey
|
||||
JPUSH_CHANNEL : "developer-default", //填写默认值即可
|
||||
]
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
至此,Android部分的所有配置工作和接口实现都已经搞定了。接下来,我们再来看看iOS的配置实现。
|
||||
|
||||
**iOS的应用配置相对Android会繁琐一些**,因为整个配置过程涉及应用、苹果APNs服务、极光三方之间的信息关联。
|
||||
|
||||
除了需要在应用内绑定极光信息之外(即handleMethodCall中的setup方法),还需要在[苹果的开发者官网](https://developer.apple.com/)提前申请苹果的推送证书。关于申请证书,苹果提供了.p12证书和APNs Auth Key两种鉴权方式。
|
||||
|
||||
这里,我推荐使用更为简单的Auth Key方式。申请推送证书的过程,极光官网提供了详细的[注册步骤](https://docs.jiguang.cn//jpush/client/iOS/ios_cer_guide/),这里我就不再赘述了。需要注意的是,申请iOS的推送证书时,你只能使用付费的苹果开发者账号。
|
||||
|
||||
在拿到了APNs Auth Key之后,我们同样需要去极光官网,根据Bundle ID进行推送设置,并把Auth Key上传至极光进行托管,由它完成与苹果的鉴权工作:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/31/57/31fd3119256c8d54f216b8922bf7da57.png" alt="">
|
||||
|
||||
通过上面的步骤,我们已经完成了将推送证书与极光信息绑定的操作,接下来,我们**回到Xcode打开的example工程,进行最后的配置工作**。
|
||||
|
||||
首先,我们需要为example工程开启Application Target的Capabilities->Push Notifications选项,启动应用的推送能力支持,如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/c8/f9/c866e1a8662b8220ad20f12560dc70f9.png" alt="">
|
||||
|
||||
然后,我们需要切换到Application Target的Info面板,手动配置NSAppTransportSecurity键值对,以支持极光SDK非https域名服务:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/64/a2/64d21293e30b316b1e88d79c954df4a2.png" alt="">
|
||||
|
||||
最后,在Info tab下的Bundle identifier项,把我们刚刚在极光官网注册的Bundle ID显式地更新进去:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/a3/06/a30ae94430ce022c1aec423c6c9ea606.png" alt="">
|
||||
|
||||
至此,example工程运行所需的所有原生配置工作和接口实现都已经搞定了。接下来,我们就可以在example工程中的main.dart文件中,使用FlutterPushPlugin插件来实现原生推送能力了。
|
||||
|
||||
在下面的代码中,我们在main函数的入口,使用插件单例注册了极光推送服务,随后在应用State初始化时,获取了极光推送地址,并设置了消息推送回调:
|
||||
|
||||
```
|
||||
//获取推送插件单例
|
||||
FlutterPushPlugin fpush = FlutterPushPlugin();
|
||||
void main() {
|
||||
//使用AppID注册极光推送服务(仅针对iOS平台)
|
||||
fpush.setupWithAppID("f861910af12a509b34e266c2");
|
||||
runApp(MyApp());
|
||||
}
|
||||
|
||||
class MyApp extends StatefulWidget {
|
||||
@override
|
||||
_MyAppState createState() => _MyAppState();
|
||||
}
|
||||
|
||||
class _MyAppState extends State<MyApp> {
|
||||
//极光推送地址regID
|
||||
String _regID = 'Unknown';
|
||||
//接收到的推送消息
|
||||
String _notification = "";
|
||||
|
||||
@override
|
||||
initState() {
|
||||
super.initState();
|
||||
//注册推送消息回调
|
||||
fpush.setOpenNotificationHandler((String message) async {
|
||||
//刷新界面状态,展示推送消息
|
||||
setState(() {
|
||||
_notification = message;
|
||||
});
|
||||
});
|
||||
//获取推送地址regID
|
||||
initPlatformState();
|
||||
}
|
||||
|
||||
initPlatformState() async {
|
||||
//调用插件封装的regID
|
||||
String regID = await fpush.registrationID;
|
||||
//刷新界面状态,展示regID
|
||||
setState(() {
|
||||
_regID = regID;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MaterialApp(
|
||||
home: Scaffold(
|
||||
body: Center(
|
||||
child: Column(
|
||||
children: <Widget>[
|
||||
//展示regID,以及收到的消息
|
||||
Text('Running on: $_regID\n'),
|
||||
Text('Notification Received: $_notification')
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
点击运行,可以看到,我们的应用已经可以获取到极光推送地址了:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/40/bf/40bacc76342c21ef43681a625709a4bf.png" alt="">
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/54/53/542d1eda3e40c50b3c4a3495bb682a53.jpeg" alt="">
|
||||
|
||||
接下来,我们再去[极光开发者服务后台](https://www.jiguang.cn/dev2/#/overview/appCardList)发一条真实的推送消息。在服务后台选择我们的App,随后进入极光推送控制台。这时,我们就可以进行消息推送测试了。
|
||||
|
||||
在发送通知一栏,我们把通知标题改为“测试”,通知内容设置为“极光推送测试”;在目标人群一栏,由于是测试账号,我们可以直接选择“广播所有人”,如果你希望精确定位到接收方,也可以提供在应用中获取到的极光推送地址(即Registration ID):
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/a5/b6/a5c3aaf126d59561ee078dc5987ca8b6.png" alt="">
|
||||
|
||||
点击发送预览并确认,可以看到,我们的应用不仅可以被来自极光的推送消息唤醒,还可以在Flutter应用内收到来自原生宿主转发的消息内容:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/6a/4e/6a90537391778ea75a28ad4b01641b4e.gif" alt="">
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/b5/d8/b59ff183027f50df9fed14c4abfe9fd8.gif" alt="">
|
||||
|
||||
## 总结
|
||||
|
||||
好了,今天的分享就到这里。我们一起来小结一下吧。
|
||||
|
||||
我们以Flutter插件工程的方式,为极光SDK提供了一个Dart层的封装。插件工程同时提供了iOS和Android目录,我们可以在这两个目录下完成原生代码宿主封装,不仅可以为Dart层提供接口正向回调(比如,初始化、获取极光推送地址),还可以通过方法通道以反向回调的方式将推送消息转发给Dart。
|
||||
|
||||
今天,我和你分享了很多原生代码宿主的配置、绑定、注册的逻辑。不难发现,推送过程链路长、涉众多、配置复杂,要想在Flutter完全实现原生推送能力,工作量主要集中在原生代码宿主,Dart层能做的事情并不多。
|
||||
|
||||
我把今天分享所改造的[Flutter_Push_Plugin](https://github.com/cyndibaby905/31_flutter_push_plugin)放到了GitHub中,你可以把插件工程下载下来,多运行几次,体会插件工程与普通Flutter工程的异同,并加深对消息推送全流程的认识。其中,Flutter_Push_Plugin提供了实现原生推送功能的最小集合,你可以根据实际需求完善这个插件。
|
||||
|
||||
需要注意的是,我们今天的实际工程演示是通过内嵌的example工程示例所完成的,如果你有一个独立的Flutter工程(比如[Flutter_Push_Demo](https://github.com/cyndibaby905/31_flutter_push_demo))需要接入Flutter_Push_Plugin,其配置方式与example工程并无不同,唯一的区别是,需要在pubspec.yaml文件中将对插件的依赖显示地声明出来而已:
|
||||
|
||||
```
|
||||
dependencies:
|
||||
flutter_push_plugin:
|
||||
git:
|
||||
url: https://github.com/cyndibaby905/31_flutter_push_plugin.git
|
||||
|
||||
```
|
||||
|
||||
## 思考题
|
||||
|
||||
在Flutter_Push_Plugin的原生实现中,用户点击了推送消息把Flutter应用唤醒时,为了确保Flutter完成初始化,我们等待了1秒才执行相应的Flutter回调通知。这段逻辑有需要优化的地方吗?为了让Flutter代码能够更快地收到推送消息,你会如何优化呢?
|
||||
|
||||
欢迎你在评论区给我留言分享你的观点,我会在下一篇文章中等待你!感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。
|
||||
|
||||
|
||||
221
极客时间专栏/Flutter核心技术与实战/Flutter进阶/32 | 适配国际化,除了多语言我们还需要注意什么?.md
Normal file
221
极客时间专栏/Flutter核心技术与实战/Flutter进阶/32 | 适配国际化,除了多语言我们还需要注意什么?.md
Normal file
@@ -0,0 +1,221 @@
|
||||
<audio id="audio" title="32 | 适配国际化,除了多语言我们还需要注意什么?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/c0/00/c0872212496ce1875136153f69f20300.mp3"></audio>
|
||||
|
||||
你好,我是陈航。今天,我们来聊聊Flutter应用的国际化。
|
||||
|
||||
借助于App Store与Google Play,我们能够把应用发布到全世界的任何一个应用商店里。应用的(潜在)使用者可能来自于不同国家、说着不同的语言。如果我们想为全世界的使用者提供统一而标准的体验,那么首先就需要让App能够支持多种语言。而这一过程,一般被称为“国际化”。
|
||||
|
||||
提起国际化,你可能会认为这等同于翻译App内所有用户可见的文本。其实,这个观点不够精确。**更为准确地描述国际化的工作职责,应该是“涉及语言及地区差异的适配改造过程”。**
|
||||
|
||||
比如,如果我们要显示金额,同样的面值,在中国会显示为¥100,而在美国则会显示为$100;又比如,App的引导图,在中国我们可能会选用长城作为背景,而在美国我们则可能会选择金门大桥作为背景。
|
||||
|
||||
因此,对一款App做国际化的具体过程,除了翻译文案之外,还需要将货币单位和背景图等资源也设计成可根据不同地区自适应的变量。这也就意味着,我们在设计App架构时,需要提前将语言与地区的差异部分独立出来。
|
||||
|
||||
其实,这也是在Flutter中进行国际化的整体思路,即语言差异配置抽取+国际化代码生成。而在语言差异配置抽取的过程中,文案、货币单位,以及背景图资源的处理,其实并没有本质区别。所以在今天的分享中,我会以多语言文案为主,为你讲述在Flutter中如何实现语言与地区差异的独立化,相信在学习完这部分的知识之后,对于其他类型的语言差异你也能够轻松搞定国际化了。
|
||||
|
||||
## Flutter i18n
|
||||
|
||||
在Flutter中,国际化的语言和地区的差异性配置,是应用程序代码的一部分。如果要在Flutter中实现文本的国际化,我们需要执行以下几步:
|
||||
|
||||
- 首先,实现一个LocalizationsDelegate(即翻译代理),并将所有需要翻译的文案全部声明为它的属性;
|
||||
- 然后,依次为需要支持的语言地区进行手动翻译适配;
|
||||
- 最后,在应用程序MaterialApp初始化时,将这个代理类设置为应用程序的翻译回调。
|
||||
|
||||
如果我们中途想要新增或者删除某个语系或者文案,都需要修改程序代码。
|
||||
|
||||
看到这里你会发现,如果我们想要使用官方提供的国际化方案来设计App架构,不仅工作量大、繁琐,而且极易出错。所以,要开始Flutter应用的国际化道路,我们不如把官方的解决方案扔到一边,直接**从Android Studio中的Flutter i18n插件开始学习**。这个插件在其内部提供了不同语言地区的配置封装,能够帮助我们自动地从翻译稿生成Dart代码。
|
||||
|
||||
为了安装Flutter i18n插件,我们需要打开Android Studio的Preference选项,在左边的tab中,切换到Plugins选项,搜索这个插件,点击install即可。安装完成之后再重启Android Studio,这个插件就可以使用了。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/59/03/59676dae6d5f710da6a428eb2d0fd603.png" alt="">
|
||||
|
||||
Flutter i18n依赖flutter_localizations插件包,所以我们还需要在pubspec.yaml文件里,声明对它的依赖,否则程序会报错:
|
||||
|
||||
```
|
||||
dependencies:
|
||||
flutter_localizations:
|
||||
sdk: flutter
|
||||
|
||||
```
|
||||
|
||||
这时,我们会发现在res文件夹下,多了一个values/strings_en.arb的文件。
|
||||
|
||||
arb文件是JSON格式的配置,用来存放文案标识符和文案翻译的键值对。所以,我们只要修改了res/values下的arb文件,i18n插件就会自动帮我们生成对应的代码。
|
||||
|
||||
strings_en文件,则是系统默认的英文资源配置。为了支持中文,我们还需要在values目录下再增加一个strings_zh.arb文件:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/d3/64/d3ac46709b5ca24cd193b06def732864.png" alt="">
|
||||
|
||||
试着修改一下strings_zh.arb文件,可以看到,Flutter i18n插件为我们自动生成了generated/i18n.dart。这个类中不仅以资源标识符属性的方式提供了静态文案的翻译映射,对于通过参数来实现动态文案的message_tip标识符,也自动生成了一个同名内联函数:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/21/c5/2178c10594f5759c8195b19a7c7e00c5.png" alt="">
|
||||
|
||||
我们把strings_en.arb继续补全,提供英文版的文案。需要注意的是,i18n.dart是由插件自动生成的,每次arb文件有新的变更都会自动更新,所以切忌手动编辑这个文件。
|
||||
|
||||
接下来,我们**以Flutter官方的工程模板,即计数器demo来演示如何在Flutter中实现国际化**。
|
||||
|
||||
在下面的代码中,我们在应用程序的入口,即MaterialApp初始化时,为其设置了支持国际化的两个重要参数,即localizationsDelegates与supportedLocales。前者为应用的翻译回调,而后者则为应用所支持的语言地区属性。
|
||||
|
||||
S.delegate是Flutter i18n插件自动生成的类,包含了所支持的语言地区属性,以及对应的文案翻译映射。理论上,通过这个类就可以完全实现应用的国际化,但为什么我们在配置应用程序的翻译回调时,除了它之外,还加入了GlobalMaterialLocalizations.delegate与GlobalWidgetsLocalizations.delegate这两个回调呢?
|
||||
|
||||
这是因为Flutter提供的Widget,其本身已经支持了国际化,所以我们没必要再翻译一遍,直接用官方的就可以了,而这两个类则就是官方所提供的翻译回调。事实上,我们刚才在pubspec.yaml文件中声明的flutter_localizations插件包,就是Flutter提供的翻译套装,而这两个类就是套装中的著名成员。
|
||||
|
||||
在完成了应用程序的国际化配置之后,我们就可以在程序中通过S.of(context),直接获取arb文件中翻译的文案了。
|
||||
|
||||
不过需要注意的是,**提取翻译文案的代码需要在能获取到翻译上下文的前提下才能生效,也就是说只能针对MaterialApp的子Widget生效。**因此,在这种配置方式下,我们是无法对MaterialApp的title属性进行国际化配置的。不过,好在MaterialApp提供了一个回调方法onGenerateTitle,来提供翻译上下文,因此我们可以通过它,实现title文案的国际化:
|
||||
|
||||
```
|
||||
//应用程序入口
|
||||
class MyApp extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MaterialApp(
|
||||
localizationsDelegates: const [
|
||||
S.delegate,//应用程序的翻译回调
|
||||
GlobalMaterialLocalizations.delegate,//Material组件的翻译回调
|
||||
GlobalWidgetsLocalizations.delegate,//普通Widget的翻译回调
|
||||
],
|
||||
supportedLocales: S.delegate.supportedLocales,//支持语系
|
||||
//title的国际化回调
|
||||
onGenerateTitle: (context){
|
||||
return S.of(context).app_title;
|
||||
},
|
||||
home: MyHomePage(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
应用的主界面文案的国际化,则相对简单得多了,直接通过S.of(context)方法就可以拿到arb声明的翻译文案了:
|
||||
|
||||
```
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
//获取appBar title的翻译文案
|
||||
appBar: AppBar(
|
||||
title: Text(S.of(context).main_title),
|
||||
),
|
||||
body: Center(
|
||||
//传入_counter参数,获取计数器动态文案
|
||||
child: Text(
|
||||
S.of(context).message_tip(_counter.toString())
|
||||
)
|
||||
),
|
||||
floatingActionButton: FloatingActionButton(
|
||||
onPressed: _incrementCounter,//点击回调
|
||||
tooltip: 'Increment',
|
||||
child: Icon(Icons.add),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在Android手机上,分别切换英文和中文系统,可以看到,计数器应用已经正确地处理了多语言的情况。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/5f/ab/5f352551211ae6e063b8561ce02254ab.gif" alt="">
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/f3/e0/f3b249143095cf4210183f995f71f6e0.gif" alt="">
|
||||
|
||||
由于iOS应用程序有一套自建的语言环境管理机制,默认是英文。为了让iOS应用正确地支持国际化,我们还需要在原生的iOS工程中进行额外的配置。我们打开iOS原生工程,切换到工程面板。在Localization这一项配置中,我们看到iOS工程已经默认支持了英文,所以还需要点击“+”按钮,新增中文:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/06/14/0688c4a91a4606eec5ca3d14f170cf14.png" alt="">
|
||||
|
||||
完成iOS的工程配置后,我们回到Flutter工程,选择iOS手机运行程序。可以看到,计数器的iOS版本也可以正确地支持国际化了。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/6c/11/6cbacb27d1b8009b982e6a2468f79111.gif" alt="">
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/83/d8/83c948476dabc20c23cf06d6220721d8.gif" alt="">
|
||||
|
||||
## 原生工程配置
|
||||
|
||||
上面介绍的国际化方案,其实都是在Flutter应用内实现的。而在Flutter框架运行之前,我们是无法访问这些国际化文案的。
|
||||
|
||||
Flutter需要原生环境才能运行,但有些文案,比如应用的名称,我们需要在Flutter框架运行之前就为它提供多个语言版本(比如英文版本为computer,中文版本为计数器),这时就需要在对应的原生工程中完成相应的国际化配置了。
|
||||
|
||||
**我们先去Android工程下进行应用名称的配置。**
|
||||
|
||||
首先,在Android工程中,应用名称是在AndroidManifest.xml文件中application的android:label属性声明的,所以我们需要将其修改为字符串资源中的一个引用,让其能够根据语言地区自动选择合适的文案:
|
||||
|
||||
```
|
||||
<manifest ... >
|
||||
...
|
||||
<!-- 设置应用名称 -->
|
||||
<application
|
||||
...
|
||||
android:label="@string/title"
|
||||
...
|
||||
>
|
||||
</application>
|
||||
</manifest>
|
||||
|
||||
```
|
||||
|
||||
然后,我们还需要在android/app/src/main/res文件夹中,为要支持的语言创建字符串strings.xml文件。这里由于默认文件是英文的,所以我们只需要为中文创建一个文件即可。字符串资源的文件目录结构,如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/95/ff/959ec47a7916cc6f1b44817bd5ddaaff.png" alt="">
|
||||
|
||||
values与values-zh文件夹下的strings.xml内容如下所示:
|
||||
|
||||
```
|
||||
<!--英文(默认)字符串资源-->
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="title">Computer</string>
|
||||
</resources>
|
||||
|
||||
|
||||
<!--中文字符串资源-->
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="title">计数器</string>
|
||||
</resources>
|
||||
|
||||
```
|
||||
|
||||
完成Android应用标题的工程配置后,我们回到Flutter工程,选择Android手机运行程序,可以看到,计数器的Android应用标题也可以正确地支持国际化了。
|
||||
|
||||
接下来,我们再看**iOS工程下如何实现应用名称的配置**。
|
||||
|
||||
与Android工程类似,iOS工程中的应用名称是在Info.list文件的Bundle name属性声明的,所以我们也需要将其修改为字符串资源中的一个引用,使其能够根据语言地区自动选择文案:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/1d/eb/1d4a885a83c2297d1c8d1fe6118e46eb.png" alt="">
|
||||
|
||||
由于应用名称默认是不可配置的,所以工程并没有提供英文或者中文的可配置项,这些都需要通过新建与字符串引用对应的资源文件去搞定的。
|
||||
|
||||
我们右键单击Runner文件夹,然后选择New File来添加名为InfoPlist.strings的字符串资源文件,并在工程面板的最右侧文件检查器中的Localization选项中,添加英文和中文两种语言。InfoPlist.strings的英文版和中文版内容如下所示:
|
||||
|
||||
```
|
||||
//英文版
|
||||
"CFBundleName" = "Computer";
|
||||
|
||||
//中文版
|
||||
"CFBundleName" = "计数器";
|
||||
|
||||
```
|
||||
|
||||
至此,我们也完成了iOS应用标题的工程配置。我们回到Flutter工程,选择iOS手机运行程序,发现计数器的iOS应用标题也支持国际化了。
|
||||
|
||||
## 总结
|
||||
|
||||
好了,今天的分享就到这里。我们来总结下核心知识点吧。
|
||||
|
||||
在今天的分享中,我与你介绍了Flutter应用国际化的解决方案,即在代码中实现一个LocalizationsDelegate,在这个类中将所有需要翻译的文案全部声明为它的属性,然后依次进行手动翻译适配,最后将这个代理类设置为应用程序的翻译回调。
|
||||
|
||||
而为了简化手动翻译到代码转换的过程,我们通常会使用多个arb文件存储文案在不同语言地区的映射关系,并使用Flutter i18n插件来实现代码的自动转换。
|
||||
|
||||
国际化的核心就是语言差异配置抽取。在原生Android和iOS系统中进行国际化适配,我们只需为需要国际化的资源(比如,字符串文本、图片、布局等)提供不同的文件夹目录,就可以在应用层代码访问国际化资源时,自动根据语言地区进行适配。
|
||||
|
||||
但,Flutter的国际化能力就相对原始很多,不同语言和地区的国际化资源既没有存放在单独的xml或者JSON上,也没有存放在不同的语言和地区文件夹中。幸好有Flutter i18n插件的帮助,否则为一个应用提供国际化的支持将会是件极其繁琐的事情。
|
||||
|
||||
我把今天分享所涉及到的知识点打包到了[GitHub](https://github.com/cyndibaby905/32_i18n_demo)中,你可以下载下来,反复运行几次,加深理解与记忆。
|
||||
|
||||
## 思考题
|
||||
|
||||
最后,我给你留下一道思考题吧。
|
||||
|
||||
在Flutter中,如何实现图片类资源的国际化呢?
|
||||
|
||||
欢迎你在评论区给我留言分享你的观点,我会在下一篇文章中等待你!感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。
|
||||
|
||||
|
||||
228
极客时间专栏/Flutter核心技术与实战/Flutter进阶/33 | 如何适配不同分辨率的手机屏幕?.md
Normal file
228
极客时间专栏/Flutter核心技术与实战/Flutter进阶/33 | 如何适配不同分辨率的手机屏幕?.md
Normal file
@@ -0,0 +1,228 @@
|
||||
<audio id="audio" title="33 | 如何适配不同分辨率的手机屏幕?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/8d/b7/8da3751838fed0d962e905b7255eceb7.mp3"></audio>
|
||||
|
||||
你好,我是陈航。
|
||||
|
||||
在上一篇文章中,我与你分享了在Flutter中实现国际化的基本原理。与原生Android和iOS只需为国际化资源提供不同的目录,就可以在运行时自动根据语言和地区进行适配不同,Flutter的国际化是完全在代码中实现的。
|
||||
|
||||
即通过代码声明的方式,将应用中所有需要翻译的文案都声明为LocalizationsDelegate的属性,然后针对不同的语言和地区进行手动翻译适配,最后在初始化应用程序时,将这个代理设置为国际化的翻译回调。而为了简化这个过程,也为了将国际化资源与代码实现分离,我们通常会使用arb文件存储不同语言地区的映射关系,并通过Flutter i18n插件来实现代码的自动生成。
|
||||
|
||||
可以说,国际化为全世界的用户提供了统一而标准的体验。那么,为不同尺寸、不同旋转方向的手机提供统一而标准的体验,就是屏幕适配需要解决的问题了。
|
||||
|
||||
在移动应用的世界中,页面是由控件组成的。如果我们支持的设备只有普通手机,可以确保同一个页面、同一个控件,在不同的手机屏幕上的显示效果是基本一致的。但,随着平板电脑和类平板电脑等超大屏手机越来越普及,很多原本只在普通手机上运行的应用也逐渐跑在了平板上。
|
||||
|
||||
但,由于平板电脑的屏幕非常大,展示适配普通手机的界面和控件时,可能会出现UI异常的情况。比如,对于新闻类手机应用来说,通常会有新闻列表和新闻详情两个页面,如果我们把这两个页面原封不动地搬到平板电脑上,就会出现控件被拉伸、文字过小过密、图片清晰度不够、屏幕空间被浪费的异常体验。
|
||||
|
||||
而另一方面,即使对于同一台手机或平板电脑来说,屏幕的宽高配置也不是一成不变的。因为加速度传感器的存在,所以当我们旋转屏幕时,屏幕宽高配置会发生逆转,即垂直方向与水平方向的布局行为会互相交换,从而导致控件被拉伸等UI异常问题。
|
||||
|
||||
因此,为了让用户在不同的屏幕宽高配置下获得最佳的体验,我们不仅需要对平板进行屏幕适配,充分利用额外可用的屏幕空间,也需要在屏幕方向改变时重新排列控件。即,我们需要优化应用程序的界面布局,为用户提供新功能、展示新内容,以将拉伸变形的界面和控件替换为更自然的布局,将单一的视图合并为复合视图。
|
||||
|
||||
在原生Android或iOS中,这种在同一页面实现不同布局的行为,我们通常会准备多个布局文件,通过判断当前屏幕分辨率来决定应该使用哪套布局方式。在Flutter中,屏幕适配的原理也非常类似,只不过Flutter并没有布局文件的概念,我们需要准备多个布局来实现。
|
||||
|
||||
那么今天,我们就来分别来看一下如何通过多个布局,实现适配屏幕旋转与平板电脑。
|
||||
|
||||
## 适配屏幕旋转
|
||||
|
||||
在屏幕方向改变时,屏幕宽高配置也会发生逆转:从竖屏模式变成横屏模式,原来的宽变成了高(垂直方向上的布局空间更短了),而高则变成了宽(水平方向上的布局空间更长了)。
|
||||
|
||||
通常情况下,由于ScrollView和ListView的存在,我们基本上不需要担心垂直方向上布局空间更短的问题,大不了一屏少显示几个控件元素,用户仍然可以使用与竖屏模式同样的交互滚动视图来查看其他控件元素;但水平方向上布局空间更长,界面和控件通常已被严重拉伸,原有的布局方式和交互方式都需要做较大调整。
|
||||
|
||||
从横屏模式切回竖屏模式,也是这个道理。
|
||||
|
||||
为了适配竖屏模式与横屏模式,我们需要准备两个布局方案,一个用于纵向,一个用于横向。当设备改变方向时,Flutter会通知我们重建布局:Flutter提供的OrientationBuilder控件,可以在设备改变方向时,通过builder函数回调告知其状态。这样,我们就可以根据回调函数提供的orientation参数,来识别当前设备究竟是处于横屏(landscape)还是竖屏(portrait)状态,从而刷新界面。
|
||||
|
||||
如下所示的代码演示了OrientationBuilder的具体用法。我们在其builder回调函数中,准确地识别出了设备方向,并对横屏和竖屏两种模型加载了不同的布局方式,而_buildVerticalLayout和_buildHorizontalLayout是用于创建相应布局的方法:
|
||||
|
||||
```
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
//使用OrientationBuilder的builder模式感知屏幕旋转
|
||||
body: OrientationBuilder(
|
||||
builder: (context, orientation) {
|
||||
//根据屏幕旋转方向返回不同布局行为
|
||||
return orientation == Orientation.portrait
|
||||
? _buildVerticalLayout()
|
||||
: _buildHorizontalLayout();
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
OrientationBuilder提供了orientation参数可以识别设备方向,而如果我们在OrientationBuilder之外,希望根据设备的旋转方向设置一些组件的初始化行为,也可以使用MediaQueryData提供的orientation方法:
|
||||
|
||||
```
|
||||
if(MediaQuery.of(context).orientation == Orientation.portrait) {
|
||||
//dosth
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
需要注意的是,Flutter应用默认支持竖屏和横屏两种模式。如果我们的应用程序不需要提供横屏模式,也可以直接调用SystemChrome提供的setPreferredOrientations方法告诉Flutter,这样Flutter就可以固定视图的布局方向了:
|
||||
|
||||
```
|
||||
SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]);
|
||||
|
||||
```
|
||||
|
||||
## 适配平板电脑
|
||||
|
||||
当适配更大的屏幕尺寸时,我们希望App上的内容可以适应屏幕上额外的可用空间。如果我们在平板中使用与手机相同的布局,就会浪费大量的可视空间。与适配屏幕旋转类似,最直接的方法是为手机和平板电脑创建两种不同的布局。然而,考虑到平板电脑和手机为用户提供的功能并无差别,因此这种实现方式将会新增许多不必要的重复代码。
|
||||
|
||||
为解决这个问题,我们可以采用另外一种方法:**将屏幕空间划分为多个窗格,即采用与原生Android、iOS类似的Fragment、ChildViewController概念,来抽象独立区块的视觉功能。**
|
||||
|
||||
多窗格布局可以在平板电脑和横屏模式上,实现更好的视觉平衡效果,增强App的实用性和可读性。而,我们也可以通过独立的区块,在不同尺寸的手机屏幕上快速复用视觉功能。
|
||||
|
||||
如下图所示,分别展示了普通手机、横屏手机与平板电脑,如何使用多窗格布局来改造新闻列表和新闻详情交互:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/44/4f/44854c927812081c32d119886b12904f.png" alt="">
|
||||
|
||||
首先,我们需要分别为新闻列表与新闻详情创建两个可重用的独立区块:
|
||||
|
||||
- 新闻列表,可以在元素被点击时通过回调函数告诉父Widget元素索引;
|
||||
- 而新闻详情,则用于展示新闻列表中被点击的元素索引。
|
||||
|
||||
对于手机来说,由于空间小,所以新闻列表区块和新闻详情区块都是独立的页面,可以通过点击新闻元素进行新闻详情页面的切换;而对于平板电脑(和手机横屏布局)来说,由于空间足够大,所以我们把这两个区块放置在同一个页面,可以通过点击新闻元素去刷新同一页面的新闻详情。
|
||||
|
||||
页面的实现和区块的实现是互相独立的,通过区块复用就可以减少编写两个独立布局的工作:
|
||||
|
||||
```
|
||||
//列表Widget
|
||||
class ListWidget extends StatefulWidget {
|
||||
final ItemSelectedCallback onItemSelected;
|
||||
ListWidget(
|
||||
this.onItemSelected,//列表被点击的回调函数
|
||||
);
|
||||
@override
|
||||
_ListWidgetState createState() => _ListWidgetState();
|
||||
}
|
||||
|
||||
class _ListWidgetState extends State<ListWidget> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
//创建一个20项元素的列表
|
||||
return ListView.builder(
|
||||
itemCount: 20,
|
||||
itemBuilder: (context, position) {
|
||||
return ListTile(
|
||||
title: Text(position.toString()),//标题为index
|
||||
onTap:()=>widget.onItemSelected(position),//点击后回调函数
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
//详情Widget
|
||||
class DetailWidget extends StatefulWidget {
|
||||
final int data; //新闻列表被点击元素索引
|
||||
DetailWidget(this.data);
|
||||
@override
|
||||
_DetailWidgetState createState() => _DetailWidgetState();
|
||||
}
|
||||
|
||||
class _DetailWidgetState extends State<DetailWidget> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
color: Colors.red,//容器背景色
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: <Widget>[
|
||||
Text(widget.data.toString()),//居中展示列表被点击元素索引
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
然后,我们只需要检查设备屏幕是否有足够的宽度来同时展示列表与详情部分。为了获取屏幕宽度,我们可以使用MediaQueryData提供的size方法。
|
||||
|
||||
在这里,我们将平板电脑的判断条件设置为宽度大于480。这样,屏幕中就有足够的空间可以切换到多窗格的复合布局了:
|
||||
|
||||
```
|
||||
if(MediaQuery.of(context).size.width > 480) {
|
||||
//tablet
|
||||
} else {
|
||||
//phone
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
最后,如果宽度够大,我们就会使用Row控件将列表与详情包装在同一个页面中,用户可以点击左侧的列表刷新右侧的详情;如果宽度比较小,那我们就只展示列表,用户可以点击列表,导航到新的页面展示详情:
|
||||
|
||||
```
|
||||
class _MasterDetailPageState extends State<MasterDetailPage> {
|
||||
var selectedValue = 0;
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: OrientationBuilder(builder: (context, orientation) {
|
||||
//平板或横屏手机,页面内嵌列表ListWidget与详情DetailWidget
|
||||
if (MediaQuery.of(context).size.width > 480) {
|
||||
return Row(children: <Widget>[
|
||||
Expanded(
|
||||
child: ListWidget((value) {//在列表点击回调方法中刷新右侧详情页
|
||||
setState(() {selectedValue = value;});
|
||||
}),
|
||||
),
|
||||
Expanded(child: DetailWidget(selectedValue)),
|
||||
]);
|
||||
|
||||
} else {//普通手机,页面内嵌列表ListWidget
|
||||
return ListWidget((value) {//在列表点击回调方法中打开详情页DetailWidget
|
||||
Navigator.push(context, MaterialPageRoute(
|
||||
builder: (context) {
|
||||
return Scaffold(
|
||||
body: DetailWidget(value),
|
||||
);
|
||||
},
|
||||
));
|
||||
|
||||
});
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
运行一下代码,可以看到,我们的应用已经完全适配不同尺寸、不同方向的设备屏幕了。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/01/46/01edeb35d0780b197d7d61d43afa7546.gif" alt="">
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/a6/79/a60c85860e1dea73a2369b18482c8c79.gif" alt="">
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/21/a8/217c8292073b5d0b13f86ea9c5b9e2a8.gif" alt="">
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/96/62/96a2ab0fea7ffec12a507f6ced657d62.gif" alt="">
|
||||
|
||||
## 总结
|
||||
|
||||
好了,今天的分享就到这里。我们总结一下今天的核心知识点吧。
|
||||
|
||||
在Flutter中,为了适配不同设备屏幕,我们需要提供不同的布局方式。而将独立的视觉区块进行封装,通过OrientationBuilder提供的orientation回调参数,以及MediaQueryData提供的屏幕尺寸,以多窗格布局的方式为它们提供不同的页面呈现形态,能够大大降低编写独立布局所带来的重复工作。如果你的应用不需要支持设备方向,也可以通过SystemChrome提供的setPreferredOrientations方法,强制竖屏。
|
||||
|
||||
做好应用开发,我们除了要保证产品功能正常,还需要兼容碎片化(包括设备碎片化、品牌碎片化、系统碎片化、屏幕碎片化等方面)可能带来的潜在问题,以确保良好的用户体验。
|
||||
|
||||
与其他维度碎片化可能带来功能缺失甚至Crash不同,屏幕碎片化不至于导致功能完全不可用,但控件显示尺寸却很容易在没有做好适配的情况下产生变形,让用户看到异形甚至不全的UI信息,影响产品形象,因此也需要重点关注。
|
||||
|
||||
在应用开发中,我们可以分别在不同屏幕尺寸的主流机型和模拟器上运行我们的程序,来观察UI样式和功能是否异常,从而写出更加健壮的布局代码。
|
||||
|
||||
我把今天分享所涉及到的知识点打包到了[GitHub](https://github.com/cyndibaby905/33_multi_screen_demo)中,你可以下载下来,反复运行几次,加深理解与记忆。
|
||||
|
||||
## 思考题
|
||||
|
||||
最后,我给你留下一道思考题吧
|
||||
|
||||
setPreferredOrientations方法是全局生效的,如果你的应用程序中有两个相邻的页面,页面A仅支持竖屏,页面B同时支持竖屏和横屏,你会如何实现呢?
|
||||
|
||||
欢迎你在评论区给我留言分享你的观点,我会在下一篇文章中等待你!感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。
|
||||
|
||||
|
||||
233
极客时间专栏/Flutter核心技术与实战/Flutter进阶/34 | 如何理解Flutter的编译模式?.md
Normal file
233
极客时间专栏/Flutter核心技术与实战/Flutter进阶/34 | 如何理解Flutter的编译模式?.md
Normal file
@@ -0,0 +1,233 @@
|
||||
<audio id="audio" title="34 | 如何理解Flutter的编译模式?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/df/46/dff197d69ffb6279544ed322acc23a46.mp3"></audio>
|
||||
|
||||
你好,我是陈航。今天,我们来聊聊Flutter的编译模式吧。
|
||||
|
||||
在开发移动应用程序时,一个App的完整生命周期包括开发、测试和上线3个阶段。在每个阶段,开发者的关注点都不一样。
|
||||
|
||||
比如,在开发阶段,我们希望调试尽可能方便、快速,尽可能多地提供错误上下文信息;在测试阶段,我们希望覆盖范围尽可能全面,能够具备不同配置切换能力,可以测试和验证还没有对外发布的新功能;而在发布阶段,我们则希望能够去除一切测试代码,精简调试信息,使运行速度尽可能快,代码足够安全。
|
||||
|
||||
这就要求开发者在构建移动应用时,不仅要在工程内提前准备多份配置环境,还要利用编译器提供的编译选项,打包出符合不同阶段优化需求的App。
|
||||
|
||||
对于Flutter来说,它既支持常见的Debug、Release等工程物理层面的编译模式,也支持在工程内提供多种配置环境入口。今天,我们就来学习一下Flutter提供的编译模式,以及如何在App中引用开发环境和生产环境,使得我们在不破坏任何生产环境代码的情况下,能够测试处于开发期的新功能。
|
||||
|
||||
## Flutter的编译模式
|
||||
|
||||
Flutter支持3种运行模式,包括Debug、Release和Profile。在编译时,这三种模式是完全独立的。首先,我们先来看看这3种模式的具体含义吧。
|
||||
|
||||
- Debug模式对应Dart的JIT模式,可以在真机和模拟器上同时运行。该模式会打开所有的断言(assert),以及所有的调试信息、服务扩展和调试辅助(比如Observatory)。此外,该模式为快速开发和运行做了优化,支持亚秒级有状态的Hot reload(热重载),但并没有优化代码执行速度、二进制包大小和部署。flutter run --debug命令,就是以这种模式运行的。
|
||||
- Release模式对应Dart的AOT模式,只能在真机上运行,不能在模拟器上运行,其编译目标为最终的线上发布,给最终的用户使用。该模式会关闭所有的断言,以及尽可能多的调试信息、服务扩展和调试辅助。此外,该模式优化了应用快速启动、代码快速执行,以及二级制包大小,因此编译时间较长。flutter run --release命令,就是以这种模式运行的。
|
||||
- Profile模式,基本与Release模式一致,只是多了对Profile模式的服务扩展的支持,包括支持跟踪,以及一些为了最低限度支持所需要的依赖(比如,可以连接Observatory到进程)。该模式用于分析真实设备实际运行性能。flutter run --profile命令,就是以这种模式运行的。
|
||||
|
||||
由于Profile与Release在编译过程上几乎无差异,因此我们今天只讨论Debug和Release模式。
|
||||
|
||||
在开发应用时,为了便于快速发现问题,我们通常会在运行时识别当前的编译模式,去改变代码的部分执行行为:在Debug模式下,我们会打印详细的日志,调用开发环境接口;而在Release模式下,我们会只记录极少的日志,调用生产环境接口。
|
||||
|
||||
在运行时识别应用的编译模式,有两种解决办法:
|
||||
|
||||
- 通过断言识别;
|
||||
- 通过Dart VM所提供的编译常数识别。
|
||||
|
||||
我们先来看看**如何通过断言识别应用的编译模式**。
|
||||
|
||||
通过Debug与Release模式的介绍,我们可以得出,Release与Debug模式的一个重要区别就是,Release模式关闭了所有的断言。因此,我们可以借助于断言,写出只在Debug模式下生效的代码。
|
||||
|
||||
如下所示,我们在断言里传入了一个始终返回true的匿名函数执行结果,这个匿名函数的函数体只会在Debug模式下生效:
|
||||
|
||||
```
|
||||
assert(() {
|
||||
//Do sth for debug
|
||||
return true;
|
||||
}());
|
||||
|
||||
```
|
||||
|
||||
需要注意的是,匿名函数声明调用结束时追加了小括号()。 这是因为断言只能检查布尔值,所以我们必须使用括号强制执行这个始终返回true的匿名函数,以确保匿名函数体的代码可以执行。
|
||||
|
||||
接下来,我们再看看**如何通过编译常数识别应用的编译模式**。
|
||||
|
||||
如果说通过断言只能写出在Debug模式下运行的代码,而通过Dart提供的编译常数,我们还可以写出只在Release模式下生效的代码。Dart提供了一个布尔型的常量kReleaseMode,用于反向指示当前App的编译模式。
|
||||
|
||||
如下所示,我们通过判断这个常量,可以准确地识别出当前的编译模式:
|
||||
|
||||
```
|
||||
if(kReleaseMode){
|
||||
//Do sth for release
|
||||
} else {
|
||||
//Do sth for debug
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
## 分离配置环境
|
||||
|
||||
通过断言和kReleaseMode常量,我们能够识别出当前App的编译环境,从而可以在运行时对某个代码功能进行局部微调。而如果我们想在整个应用层面,为不同的运行环境提供更为统一的配置(比如,对于同一个接口调用行为,开发环境会使用dev.example.com域名,而生产环境会使用api.example.com域名),则需要在应用启动入口提供可配置的初始化方式,根据特定需求为应用注入配置环境。
|
||||
|
||||
在Flutter构建App时,为应用程序提供不同的配置环境,总体可以分为抽象配置、配置多入口、读配置和编译打包4个步骤:
|
||||
|
||||
1. 抽象出应用程序的可配置部分,并使用InheritedWidget对其进行封装;
|
||||
1. 将不同的配置环境拆解为多个应用程序入口(比如,开发环境为main-dev.dart、生产环境为main.dart),把应用程序的可配置部分固化在各个入口处;
|
||||
1. 在运行期,通过InheritedWidget提供的数据共享机制,将配置部分应用到其子Widget对应的功能中;
|
||||
1. 使用Flutter提供的编译打包选项,构建出不同配置环境的安装包。
|
||||
|
||||
**接下来,我将依次为你介绍具体的实现步骤。**
|
||||
|
||||
在下面的示例中,我会把应用程序调用的接口和标题进行区分实现,即开发环境使用dev.example.com域名,应用主页标题为dev;而生产环境使用api.example.com域名,主页标题为example。
|
||||
|
||||
首先是**配置抽象**。根据需求可以看出,应用程序中有两个需要配置的部分,即接口apiBaseUrl和标题appName,因此我定义了一个继承自InheritedWidget的类AppConfig,对这两个配置进行封装:
|
||||
|
||||
```
|
||||
class AppConfig extends InheritedWidget {
|
||||
AppConfig({
|
||||
@required this.appName,
|
||||
@required this.apiBaseUrl,
|
||||
@required Widget child,
|
||||
}) : super(child: child);
|
||||
|
||||
final String appName;//主页标题
|
||||
final String apiBaseUrl;//接口域名
|
||||
|
||||
//方便其子Widget在Widget树中找到它
|
||||
static AppConfig of(BuildContext context) {
|
||||
return context.inheritFromWidgetOfExactType(AppConfig);
|
||||
}
|
||||
|
||||
//判断是否需要子Widget更新。由于是应用入口,无需更新
|
||||
@override
|
||||
bool updateShouldNotify(InheritedWidget oldWidget) => false;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
接下来,我们需要**为不同的环境创建不同的应用入口**。
|
||||
|
||||
在这个例子中,由于只有两个环境,即开发环境与生产环境,因此我们将文件分别命名为main_dev.dart和main.dart。在这两个文件中,我们会使用不同的配置数据来对AppConfig进行初始化,同时把应用程序实例MyApp作为其子Widget,这样整个应用内都可以获取到配置数据:
|
||||
|
||||
```
|
||||
//main_dev.dart
|
||||
void main() {
|
||||
var configuredApp = AppConfig(
|
||||
appName: 'dev',//主页标题
|
||||
apiBaseUrl: 'http://dev.example.com/',//接口域名
|
||||
child: MyApp(),
|
||||
);
|
||||
runApp(configuredApp);//启动应用入口
|
||||
}
|
||||
|
||||
//main.dart
|
||||
void main() {
|
||||
var configuredApp = AppConfig(
|
||||
appName: 'example',//主页标题
|
||||
apiBaseUrl: 'http://api.example.com/',//接口域名
|
||||
child: MyApp(),
|
||||
);
|
||||
runApp(configuredApp);//启动应用入口
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
完成配置环境的注入之后,接下来就可以**在应用内获取配置数据**,来实现定制化的功能了。由于AppConfig是整个应用程序的根节点,因此我可以通过调用AppConfig.of方法,来获取到相关的数据配置。
|
||||
|
||||
在下面的代码中,我分别获取到了应用主页的标题,以及接口域名,并显示了出来:
|
||||
|
||||
```
|
||||
class MyApp extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
var config = AppConfig.of(context);//获取应用配置
|
||||
return MaterialApp(
|
||||
title: config.appName,//应用主页标题
|
||||
home: MyHomePage(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class MyHomePage extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
var config = AppConfig.of(context);//获取应用配置
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(config.appName),//应用主页标题
|
||||
),
|
||||
body: Center(
|
||||
child: Text('API host: ${config.apiBaseUrl}'),//接口域名
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
现在,我们已经完成了分离配置环境的代码部分。最后,我们可以使用Flutter提供的编译选项,来**构建出不同配置的安装包**了。
|
||||
|
||||
如果想要在模拟器或真机上运行这段代码,我们可以在flutter run命令后面,追加–target或-t参数,来指定应用程序初始化入口:
|
||||
|
||||
```
|
||||
//运行开发环境应用程序
|
||||
flutter run -t lib/main_dev.dart
|
||||
|
||||
//运行生产环境应用程序
|
||||
flutter run -t lib/main.dart
|
||||
|
||||
```
|
||||
|
||||
如果我们想在Android Studio上为应用程序创建不同的启动配置,则可以**通过Flutter插件为main_dev.dart增加启动入口**。
|
||||
|
||||
首先,点击工具栏上的Config Selector,选择Edit Configurations进入编辑应用程序启动选项:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/ab/4e/ab9617b62b6fd66ac2a0cc949aeb874e.png" alt="">
|
||||
|
||||
然后,点击位于工具栏面板左侧顶部的“+”按钮,在弹出的菜单中选择Flutter选项,为应用程序新增一项启动入口:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/bf/64/bf7cd1ffc0fc58557672b4420d1f7364.png" alt="">
|
||||
|
||||
最后,在入口的编辑面板中,为main_dev选择程序的Dart入口,点击OK后,就完成了入口的新增工作:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/7a/78/7adea7407c99fbb1dfbbdfbb7b247278.png" alt="">
|
||||
|
||||
接下来,我们就可以**在Config Selector中切换不同的启动入口,从而直接在Android Studio中注入不同的配置环境了**:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/8f/3e/8f38c9d92eda9ab8d6038a7e7611323e.png" alt="">
|
||||
|
||||
我们试着在不同的入口中进行切换和运行,可以看到,App已经可以识别出不同的配置环境了:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/6a/26/6a43d7189d9d8a8cb0184cb424c3ef26.png" alt="">
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/6a/45/6afbe85bea6acfe86173085d34192145.png" alt="">
|
||||
|
||||
而如果我们想要打包构建出适用于Android的APK,或是iOS的IPA安装包,则可以在flutter build 命令后面,同样追加–target或-t参数,指定应用程序初始化入口:
|
||||
|
||||
```
|
||||
//打包开发环境应用程序
|
||||
flutter build apk -t lib/main_dev.dart
|
||||
flutter build ios -t lib/main_dev.dart
|
||||
|
||||
//打包生产环境应用程序
|
||||
flutter build apk -t lib/main.dart
|
||||
flutter build ios -t lib/main.dart
|
||||
|
||||
```
|
||||
|
||||
## 总结
|
||||
|
||||
好了,今天的分享就到这里。我们来总结一下今天的主要内容吧。
|
||||
|
||||
Flutter支持Debug与Release的编译模式,并且这两种模式在构建时是完全独立的。Debug模式下会打开所有的断言和调试信息,而Release模式下则会关闭这些信息,因此我们可以通过断言,写出只在Debug模式下生效的代码。而如果我们想更精准地识别出当前的编译模式,则可以利用Dart所提供的编译常数kReleaseMode,写出只在Release模式下生效的代码。
|
||||
|
||||
除此之外,Flutter对于常见的分环境配置能力也提供了支持,我们可以使用InheritedWidget为应用中可配置部分进行封装抽象,通过配置多入口的方式为应用的启动注入配置环境。
|
||||
|
||||
需要注意的是,虽然断言和kReleaseMode都能够识别出Debug编译模式,但它们对二进制包的打包构建影响是不同的。
|
||||
|
||||
采用断言的方式,其相关代码会在Release构建中被完全剔除;而如果使用kReleaseMode常量来识别Debug环境,虽然这段代码永远不会在Release环境中执行,但却会被打入到二进制包中,增大包体积。因此,如果没有特殊需求的话,一定要使用断言来实现Debug特有的逻辑,或是在发布期前将使用kReleaseMode判断的Debug逻辑完全删除。
|
||||
|
||||
我把今天分享所涉及到的知识点打包到了[GitHub](https://github.com/cyndibaby905/34_multi_env)中,你可以下载下来,反复运行几次,加深理解与记忆。
|
||||
|
||||
## 思考题
|
||||
|
||||
最后,我给你留一道思考题吧。
|
||||
|
||||
在保持生产环境代码不变的情况下,如果想在开发环境中支持不同配置的切换,我们应该如何实现?
|
||||
|
||||
欢迎你在评论区给我留言分享你的观点,我会在下一篇文章中等待你!感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。
|
||||
|
||||
|
||||
246
极客时间专栏/Flutter核心技术与实战/Flutter进阶/35 | Hot Reload是怎么做到的?.md
Normal file
246
极客时间专栏/Flutter核心技术与实战/Flutter进阶/35 | Hot Reload是怎么做到的?.md
Normal file
@@ -0,0 +1,246 @@
|
||||
<audio id="audio" title="35 | Hot Reload是怎么做到的?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/b3/fc/b36d386ebf9704449b576cbd79b17dfc.mp3"></audio>
|
||||
|
||||
你好,我是陈航。
|
||||
|
||||
在上一篇文章中,我与你分享了Flutter的Debug与Release编译模式,以及如何通过断言与编译常数来精准识别当前代码所运行的编译模式,从而写出只在Debug或Release模式下生效的代码。
|
||||
|
||||
另外,对于在开发期与发布期分别使用不同的配置环境,Flutter也提供了支持。我们可以将应用中可配置的部分进行封装抽象,使用配置多入口的方式,通过InheritedWidget来为应用的启动注入环境配置。
|
||||
|
||||
如果你有过原生应用的开发经历,那你一定知道在原生应用开发时,如果我们想要在硬件设备上看到调整后的运行效果,在完成了代码修改后,必须要经过漫长的重新编译,才能同步到设备上。
|
||||
|
||||
而Flutter则不然,由于Debug模式支持JIT,并且为开发期的运行和调试提供了大量优化,因此代码修改后,我们可以通过亚秒级的热重载(Hot Reload)进行增量代码的快速刷新,而无需经过全量的代码编译,从而大大缩短了从代码修改到看到修改产生的变化之间所需要的时间。
|
||||
|
||||
比如,在开发页面的过程中,当我们点击按钮出现一个弹窗的时候,发现弹窗标题没有对齐,这时候只要修改标题的对齐样式,然后保存,在代码并没有重新编译的情况下,标题样式就发生了改变,感觉就像是在UI编辑面板中直接修改元素样式一样,非常方便。
|
||||
|
||||
那么,Flutter的热重载到底是如何实现的呢?
|
||||
|
||||
## 热重载
|
||||
|
||||
热重载是指,在不中断App正常运行的情况下,动态注入修改后的代码片段。而这一切的背后,离不开Flutter所提供的运行时编译能力。为了更好地理解Flutter的热重载实现原理,我们先简单回顾一下Flutter编译模式背后的技术吧。
|
||||
|
||||
- JIT(Just In Time),指的是即时编译或运行时编译,在Debug模式中使用,可以动态下发和执行代码,启动速度快,但执行性能受运行时编译影响;
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/ab/a3/ab692d1e072df378bc78fef6245205a3.png" alt="">
|
||||
|
||||
- AOT(Ahead Of Time),指的是提前编译或运行前编译,在Release模式中使用,可以为特定的平台生成稳定的二进制代码,执行性能好、运行速度快,但每次执行均需提前编译,开发调试效率低。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/fe/a5/fe8712b8a36a032b0646ed85fec9b2a5.png" alt="">
|
||||
|
||||
可以看到,Flutter提供的两种编译模式中,AOT是静态编译,即编译成设备可直接执行的二进制码;而JIT则是动态编译,即将Dart代码编译成中间代码(Script Snapshot),在运行时设备需要Dart VM解释执行。
|
||||
|
||||
而热重载之所以只能在Debug模式下使用,是因为Debug模式下,Flutter采用的是JIT动态编译(而Release模式下采用的是AOT静态编译)。JIT编译器将Dart代码编译成可以运行在Dart VM上的Dart Kernel,而Dart Kernel是可以动态更新的,这就实现了代码的实时更新功能。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/2d/fa/2dfbedae7b95dd152a587070db4bb9fa.png" alt="">
|
||||
|
||||
总体来说,**热重载的流程可以分为扫描工程改动、增量编译、推送更新、代码合并、Widget重建5个步骤:**
|
||||
|
||||
1. 工程改动。热重载模块会逐一扫描工程中的文件,检查是否有新增、删除或者改动,直到找到在上次编译之后,发生变化的Dart代码。
|
||||
1. 增量编译。热重载模块会将发生变化的Dart代码,通过编译转化为增量的Dart Kernel文件。
|
||||
1. 推送更新。热重载模块将增量的Dart Kernel文件通过HTTP端口,发送给正在移动设备上运行的Dart VM。
|
||||
1. 代码合并。Dart VM会将收到的增量Dart Kernel文件,与原有的Dart Kernel文件进行合并,然后重新加载新的Dart Kernel文件。
|
||||
1. Widget重建。在确认Dart VM资源加载成功后,Flutter会将其UI线程重置,通知Flutter Framework重建Widget。
|
||||
|
||||
可以看到,Flutter提供的热重载在收到代码变更后,并不会让App重新启动执行,而只会触发Widget树的重新绘制,因此可以保持改动前的状态,这就大大节省了调试复杂交互界面的时间。
|
||||
|
||||
比如,我们需要为一个视图栈很深的页面调整UI样式,若采用重新编译的方式,不仅需要漫长的全量编译时间,而为了恢复视图栈,也需要重复之前的多次点击交互,才能重新进入到这个页面查看改动效果。但如果是采用热重载的方式,不仅没有编译时间,而且页面的视图栈状态也得以保留,完成热重载之后马上就可以预览UI效果了,相当于局部界面刷新。
|
||||
|
||||
## 不支持热重载的场景
|
||||
|
||||
Flutter提供的亚秒级热重载一直是开发者的调试利器。通过热重载,我们可以快速修改UI、修复Bug,无需重启应用即可看到改动效果,从而大大提升了UI调试效率。
|
||||
|
||||
不过,Flutter的热重载也有一定的局限性。因为涉及到状态保存与恢复,所以并不是所有的代码改动都可以通过热重载来更新。
|
||||
|
||||
接下来,我就与你介绍几个不支持热重载的典型场景:
|
||||
|
||||
- 代码出现编译错误;
|
||||
- Widget状态无法兼容;
|
||||
- 全局变量和静态属性的更改;
|
||||
- main方法里的更改;
|
||||
- initState方法里的更改;
|
||||
- 枚举和泛类型更改。
|
||||
|
||||
现在,我们就具体看看这几种场景的问题,应该如何解决吧。
|
||||
|
||||
## 代码出现编译错误
|
||||
|
||||
当代码更改导致编译错误时,热重载会提示编译错误信息。比如下面的例子中,代码中漏写了一个反括号,在使用热重载时,编译器直接报错:
|
||||
|
||||
```
|
||||
Initializing hot reload...
|
||||
Syncing files to device iPhone X...
|
||||
|
||||
Compiler message:
|
||||
lib/main.dart:84:23: Error: Can't find ')' to match '('.
|
||||
return MaterialApp(
|
||||
^
|
||||
Reloaded 1 of 462 libraries in 301ms.
|
||||
|
||||
```
|
||||
|
||||
在这种情况下,只需更正上述代码中的错误,就可以继续使用热重载。
|
||||
|
||||
## Widget状态无法兼容
|
||||
|
||||
当代码更改会影响Widget的状态时,会使得热重载前后Widget所使用的数据不一致,即应用程序保留的状态与新的更改不兼容。这时,热重载也是无法使用的。
|
||||
|
||||
比如下面的代码中,我们将某个类的定义从 StatelessWidget改为StatefulWidget时,热重载就会直接报错:
|
||||
|
||||
```
|
||||
//改动前
|
||||
class MyWidget extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(onTap: () => print('T'));
|
||||
}
|
||||
}
|
||||
|
||||
//改动后
|
||||
class MyWidget extends StatefulWidget {
|
||||
@override
|
||||
State<MyWidget> createState() => MyWidgetState();
|
||||
}
|
||||
class MyWidgetState extends State<MyWidget> { /*...*/ }
|
||||
|
||||
```
|
||||
|
||||
当遇到这种情况时,我们需要重启应用,才能看到更新后的程序。
|
||||
|
||||
## 全局变量和静态属性的更改
|
||||
|
||||
在Flutter中,全局变量和静态属性都被视为状态,在第一次运行应用程序时,会将它们的值设为初始化语句的执行结果,因此在热重载期间不会重新初始化。
|
||||
|
||||
比如下面的代码中,我们修改了一个静态Text数组的初始化元素。虽然热重载并不会报错,但由于静态变量并不会在热重载之后初始化,因此这个改变并不会产生效果:
|
||||
|
||||
```
|
||||
//改动前
|
||||
final sampleText = [
|
||||
Text("T1"),
|
||||
Text("T2"),
|
||||
Text("T3"),
|
||||
Text("T4"),
|
||||
];
|
||||
|
||||
//改动后
|
||||
final sampleText = [
|
||||
Text("T1"),
|
||||
Text("T2"),
|
||||
Text("T3"),
|
||||
Text("T10"), //改动点
|
||||
];
|
||||
|
||||
```
|
||||
|
||||
如果我们需要更改全局变量和静态属性的初始化语句,重启应用才能查看更改效果。
|
||||
|
||||
## main方法里的更改
|
||||
|
||||
在Flutter中,由于热重载之后只会根据原来的根节点重新创建控件树,因此main函数的任何改动并不会在热重载后重新执行。所以,如果我们改动了main函数体内的代码,是无法通过热重载看到更新效果的。
|
||||
|
||||
在第1篇文章“[预习篇 · 从零开始搭建Flutter开发环境](https://time.geekbang.org/column/article/104051)”中,我与你介绍了这种情况。在更新前,我们通过MyApp封装了一个展示“Hello World”的文本,在更新后,直接在main函数封装了一个展示“Hello 2019”的文本:
|
||||
|
||||
```
|
||||
//更新前
|
||||
class MyAPP extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return const Center(child: Text('Hello World', textDirection: TextDirection.ltr));
|
||||
}
|
||||
}
|
||||
|
||||
void main() => runApp(new MyAPP());
|
||||
|
||||
//更新后
|
||||
void main() => runApp(const Center(child: Text('Hello, 2019', textDirection: TextDirection.ltr)));
|
||||
|
||||
```
|
||||
|
||||
由于main函数并不会在热重载后重新执行,因此以上改动是无法通过热重载查看更新的。
|
||||
|
||||
## initState方法里的更改
|
||||
|
||||
在热重载时,Flutter会保存Widget的状态,然后重建Widget。而initState方法是Widget状态的初始化方法,这个方法里的更改会与状态保存发生冲突,因此热重载后不会产生效果。
|
||||
|
||||
在下面的例子中,我们将计数器的初始值由10改为100:
|
||||
|
||||
```
|
||||
//更改前
|
||||
class _MyHomePageState extends State<MyHomePage> {
|
||||
int _counter;
|
||||
@override
|
||||
void initState() {
|
||||
_counter = 10;
|
||||
super.initState();
|
||||
}
|
||||
...
|
||||
}
|
||||
|
||||
//更改后
|
||||
class _MyHomePageState extends State<MyHomePage> {
|
||||
int _counter;
|
||||
@override
|
||||
void initState() {
|
||||
_counter = 100;
|
||||
super.initState();
|
||||
}
|
||||
...
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
由于这样的改动发生在initState方法中,因此无法通过热重载查看更新,我们需要重启应用,才能看到更改效果。
|
||||
|
||||
## 枚举和泛型类型更改
|
||||
|
||||
在Flutter中,枚举和泛型也被视为状态,因此对它们的修改也不支持热重载。比如在下面的代码中,我们将一个枚举类型改为普通类,并为其增加了一个泛型参数:
|
||||
|
||||
```
|
||||
//更改前
|
||||
enum Color {
|
||||
red,
|
||||
green,
|
||||
blue
|
||||
}
|
||||
|
||||
class C<U> {
|
||||
U u;
|
||||
}
|
||||
|
||||
//更改后
|
||||
class Color {
|
||||
Color(this.r, this.g, this.b);
|
||||
final int r;
|
||||
final int g;
|
||||
final int b;
|
||||
}
|
||||
|
||||
class C<U, V> {
|
||||
U u;
|
||||
V v;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这两类更改都会导致热重载失败,并生成对应的提示消息。同样的,我们需要重启应用,才能查看到更改效果。
|
||||
|
||||
## 总结
|
||||
|
||||
好了,今天的分享就到这里,我们总结一下今天的主要内容吧。
|
||||
|
||||
Flutter的热重载是基于JIT编译模式的代码增量同步。由于JIT属于动态编译,能够将Dart代码编译成生成中间代码,让Dart VM在运行时解释执行,因此可以通过动态更新中间代码实现增量同步。
|
||||
|
||||
热重载的流程可以分为5步,包括:扫描工程改动、增量编译、推送更新、代码合并、Widget重建。Flutter在接收到代码变更后,并不会让App重新启动执行,而只会触发Widget树的重新绘制,因此可以保持改动前的状态,大大缩短了从代码修改到看到修改产生的变化之间所需要的时间。
|
||||
|
||||
而另一方面,由于涉及到状态保存与恢复,因此涉及状态兼容与状态初始化的场景,热重载是无法支持的,比如改动前后Widget状态无法兼容、全局变量与静态属性的更改、main方法里的更改、initState方法里的更改、枚举和泛型的更改等。
|
||||
|
||||
可以发现,热重载提高了调试UI的效率,非常适合写界面样式这样需要反复查看修改效果的场景。但由于其状态保存的机制所限,热重载本身也有一些无法支持的边界。
|
||||
|
||||
如果你在写业务逻辑的时候,不小心碰到了热重载无法支持的场景,也不需要进行漫长的重新编译加载等待,只要点击位于工程面板左下角的热重启(Hot Restart)按钮,就可以以秒级的速度进行代码重新编译以及程序重启了,同样也很快。
|
||||
|
||||
## 思考题
|
||||
|
||||
最后,我给你留下一道思考题吧。
|
||||
|
||||
你是否了解其他框架(比如React Native、Webpack)的热重载机制?它们的热重载机制与Flutter有何区别?
|
||||
|
||||
欢迎你在评论区给我留言分享你的观点,我会在下一篇文章中等待你!感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。
|
||||
|
||||
|
||||
183
极客时间专栏/Flutter核心技术与实战/Flutter进阶/36 | 如何通过工具链优化开发调试效率?.md
Normal file
183
极客时间专栏/Flutter核心技术与实战/Flutter进阶/36 | 如何通过工具链优化开发调试效率?.md
Normal file
@@ -0,0 +1,183 @@
|
||||
<audio id="audio" title="36 | 如何通过工具链优化开发调试效率?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/87/3e/87b1d3adb1c08e9e39ba64caca06ad3e.mp3"></audio>
|
||||
|
||||
你好,我是陈航。今天我们来聊聊如何调试Flutter App。
|
||||
|
||||
软件开发通常是一个不断迭代、螺旋式上升的过程。在迭代的过程中,我们不可避免地会经常与Bug打交道,特别是在多人协作的项目中,我们不仅要修复自己的Bug,有时还需要帮别人解决Bug。
|
||||
|
||||
而修复Bug的过程,不仅能帮我们排除代码中的隐患,也能帮助我们更快地上手项目。因此,掌握好调试这门技能,就显得尤为重要了。
|
||||
|
||||
在Flutter中,调试代码主要分为输出日志、断点调试和布局调试3类。所以,在今天这篇文章中,我将会围绕这3个主题为你详细介绍Flutter应用的代码调试。
|
||||
|
||||
我们先来看看,如何通过输出日志调试应用代码吧。
|
||||
|
||||
## 输出日志
|
||||
|
||||
为了便于跟踪和记录应用的运行情况,我们在开发时通常会在一些关键步骤输出日志(Log),即使用print函数在控制台打印出相关的上下文信息。通过这些信息,我们可以定位代码中可能出现的问题。
|
||||
|
||||
在前面的很多篇文章里,我们都大量使用了print函数来输出应用执行过程中的信息。不过,由于涉及I/O操作,使用print来打印信息会消耗较多的系统资源。同时,这些输出数据很可能会暴露App的执行细节,所以我们需要在发布正式版时屏蔽掉这些输出。
|
||||
|
||||
说到操作方法,你想到的可能是在发布版本前先注释掉所有的print语句,等以后需要调试时,再取消这些注释。但,这种方法无疑是非常无聊且耗时的。那么,Flutter给我们提供了什么更好的方式吗?
|
||||
|
||||
为了根据不同的运行环境来开启日志调试功能,我们可以使用Flutter提供的debugPrint来代替print。**debugPrint函数同样会将消息打印至控制台,但与print不同的是,它提供了定制打印的能力。**也就是说,我们可以向debugPrint函数,赋值一个函数声明来自定义打印行为。
|
||||
|
||||
比如在下面的代码中,我们将debugPrint函数定义为一个空函数体,这样就可以实现一键取消打印的功能了:
|
||||
|
||||
```
|
||||
debugPrint = (String message, {int wrapWidth}) {};//空实现
|
||||
|
||||
```
|
||||
|
||||
在Flutter 中,我们可以使用不同的main文件来表示不同环境下的入口。比如,在第34篇文章“[如何理解Flutter的编译模式?](https://time.geekbang.org/column/article/135865)”中,我们就分别用main.dart与main-dev.dart实现了生产环境与开发环境的分离。同样,我们可以通过main.dart与main-dev.dart,去分别定义生产环境与开发环境不同的打印日志行为。
|
||||
|
||||
在下面的例子中,我们将生产环境的debugPrint定义为空实现,将开发环境的debugPrint定义为同步打印数据:
|
||||
|
||||
```
|
||||
//main.dart
|
||||
void main() {
|
||||
// 将debugPrint指定为空的执行体, 所以它什么也不做
|
||||
debugPrint = (String message, {int wrapWidth}) {};
|
||||
runApp(MyApp());
|
||||
}
|
||||
|
||||
//main-dev.dart
|
||||
void main() async {
|
||||
// 将debugPrint指定为同步打印数据
|
||||
debugPrint = (String message, {int wrapWidth}) => debugPrintSynchronously(message, wrapWidth: wrapWidth);
|
||||
runApp(MyApp());
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
可以看到,在代码实现上,我们只要将应用内所有的print都替换成debugPrint,就可以满足开发环境下打日志的需求,也可以保证生产环境下应用的执行信息不会被意外打印。
|
||||
|
||||
## 断点调试
|
||||
|
||||
输出日志固然方便,但如果要想获取更为详细,或是粒度更细的上下文信息,静态调试的方式非常不方便。这时,我们需要更为灵活的动态调试方法,即断点调试。断点调试可以让代码在目标语句上暂停,让程序逐条执行后续的代码语句,来帮助我们实时关注代码执行上下文中所有变量值的详细变化过程。
|
||||
|
||||
Android Studio提供了断点调试的功能,调试Flutter应用与调试原生Android代码的方法完全一样,具体可以分为三步,即**标记断点、调试应用、查看信息**。
|
||||
|
||||
接下来,我们以Flutter默认的计数器应用模板为例,观察代码中_counter值的变化,体会断点调试的全过程。
|
||||
|
||||
**首先是标记断点。**既然我们要观察_counter值的变化,因此在界面上展示最新的_counter值时添加断点,去观察其数值变化是最理想的。因此,我们在行号右侧点击鼠标,可以把断点加载到初始化Text控件所示的位置。
|
||||
|
||||
在下图的例子中,我们为了观察_counter在等于20的时候是否正常,还特意设置了一个条件断点_counter==20,这样调试器就只会在第20次点击计数器按钮时暂停下来:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/dc/4f/dcccfaa6fcfb3bd2dc5be627129a244f.png" alt="">
|
||||
|
||||
添加断点后,对应的行号将会出现圆形的断点标记,并高亮显示整行代码。到此,断点就添加好了。当然,我们还可以同时添加多个断点,以便更好地观察代码的执行过程。
|
||||
|
||||
**接下来则是调试应用了。**和之前通过点击run按钮的运行方式不同,这一次我们需要点击工具栏上的虫子图标,以调试模式启动App,如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/d9/9c/d944045bd22008a14e6f027015cd5c9c.png" alt="">
|
||||
|
||||
等调试器初始化好后,我们的程序就启动了。由于我们的断点设置在了_counter为20时,因此在第20次点击了“+”按钮后,代码运行到了断点位置,自动进入了Debug视图模式。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/95/09/959408a818e9e978c6ff830f2e400609.png" alt="">
|
||||
|
||||
如图所示,我把Debug视图模式划分为4个区域,即A区控制调试工具、B区步进调试工具、C区帧调试窗口、D区变量查看窗口。
|
||||
|
||||
**A区的按钮**,主要用来控制调试的执行情况:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/ce/4b/ceaed745cf0deceef2fd0dbcd680dc4b.png" alt="">
|
||||
|
||||
- 比如,我们可以点击继续执行按钮来让程序继续运行、点击终止执行按钮来让程序终止运行、点击重新执行按钮来让程序重新启动,或是在程序正常执行时,点击暂停执行按钮按钮来让程序暂停运行。
|
||||
- 又比如,我们可以点击编辑断点按钮来编辑断点信息,或是点击禁用断点按钮来取消断点。
|
||||
|
||||
**B区的按钮**,主要用来控制断点的步进情况:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/56/9c/56a8ba3a79d100e03a28001b3b5dad9c.png" alt="">
|
||||
|
||||
- 比如,我们可以点击单步跳过按钮来让程序单步执行(但不会进入方法体内部)、点击单步进入或强制单步进入按钮让程序逐条语句执行,甚至还可以点击运行到光标处按钮让程序执行到在光标处(相当于新建临时断点)。
|
||||
- 比如,当我们认为断点所在的方法体已经无需执行时,则可以点击单步跳出按钮让程序立刻执行完当前进入的方法,从而返回方法调用处的下一行。
|
||||
- 又比如,我们可以点击表达式计算按钮来通过赋值或表达式方式修改任意变量的值。如下图所示,我们通过输入表达式_counter+=100,将计数器更新为120:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/6e/2f/6ea7684f8ce9dabdd1d42e7f38b38a2f.png" alt="">
|
||||
|
||||
**C区**用来指示当前断点所包含的函数执行堆栈,**D区**则是其堆栈中的函数帧所对应的变量。
|
||||
|
||||
在这个例子中,我们的断点是在_MyHomePageState类中的build方法设置的,因此D区显示的也是build方法上下文所包含的变量信息(比如_counter、_widget、this、_element等)。如果我们想切换到_MyHomePageState的build方法执行堆栈中的其他函数(比如StatefulElement.build),查看相关上下文的变量信息时,只需要在C区中点击对应的方法名即可。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/55/8f/5552132471184515ef62d3493492368f.png" alt="">
|
||||
|
||||
可以看到,Android Studio提供的Flutter调试能力很丰富,我们可以通过这些基本步骤的组合,更为灵活地调整追踪步长,观察程序的执行情况,揪出代码中的Bug。
|
||||
|
||||
## 布局调试
|
||||
|
||||
通过断点调试,我们在Android Studio的调试面板中,可以随时查看执行上下文有关的变量的值,根据逻辑来做进一步的判断,确定跟踪执行的步骤。不过在更多时候,我们使用Flutter的目的是实现视觉功能,而视觉功能的调试是无法简单地通过Debug视图模式面板来搞定的。
|
||||
|
||||
在上一篇文章中,我们通过Flutter提供的热重载机制,已经极大地缩短了从代码编写到界面运行所耗费的时间,可以更快地发现代码与目标界面的明显问题,但**如果想要更快地发现界面中更为细小的问题,比如对齐、边距等,则需要使用Debug Painting这个界面调试工具**。
|
||||
|
||||
Debug Painting能够以辅助线的方式,清晰展示每个控件元素的布局边界,因此我们可以根据辅助线快速找出布局出问题的地方。而Debug Painting的开启也比较简单,只需要将debugPaintSizeEnabled变量置为true即可。如下所示,我们在main函数中,开启了Debug Painting调试开关:
|
||||
|
||||
```
|
||||
import 'package:flutter/rendering.dart';
|
||||
|
||||
void main() {
|
||||
debugPaintSizeEnabled = true; //打开Debug Painting调试开关
|
||||
runApp(new MyApp());
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
运行代码后,App在iPhone X中的执行效果如下:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/4a/74/4ae96d4e0bb7dc868ca92753c9bb1574.png" alt="">
|
||||
|
||||
可以看到,计数器示例中的每个控件元素都已经被标尺辅助线包围了。
|
||||
|
||||
辅助线提供了基本的Widget可视化能力。通过辅助线,我们能够感知界面中是否存在对齐或边距的问题,但却没有办法获取到布局信息,比如Widget距离父视图的边距信息、Widget宽高尺寸信息等。
|
||||
|
||||
**如果我们想要获取到Widget的可视化信息(比如布局信息、渲染信息等)去解决渲染问题,就需要使用更强大的Flutter Inspector了。**Flutter Inspector对控件布局详细数据提供了一种强大的可视化手段,来帮助我们诊断布局问题。
|
||||
|
||||
为了使用Flutter Inspector,我们需要回到Android Studio,通过工具栏上的“Open DevTools”按钮启动Flutter Inspector:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/ff/65/ff54c2a1883bb01f9db2b0f64bf75965.png" alt="">
|
||||
|
||||
随后,Android Studio会打开浏览器,将计数器示例中的Widget树结构展示在面板中。可以看到,Flutter Inspector所展示的Widget树结构,与代码中实现的Widget层次是一一对应的。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/49/56/4939857499b003b5018737c965c30f56.png" alt="">
|
||||
|
||||
我们的App运行在iPhone X之上,其分辨率为375*812。接下来,我们以Column组件的布局信息为例,通过确认其水平方向为居中布局、垂直方向为充满父Widget剩余空间的过程,来说明**Flutter Inspector的具体用法**。
|
||||
|
||||
为了确认Column在垂直方向是充满其父Widget剩余空间的,我们首先需要确定其父Widget在垂直方向上的另一个子Widget,即AppBar的信息。我们点击Flutter Inspector面板左侧中的AppBar控件,右侧对应显示了它的具体视觉信息。
|
||||
|
||||
可以看到AppBar控件距离左边距为0,上边距也为0;宽为375,高为100:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/10/d7/1000678ddbe58f74aa9c2da1ed20a1d7.png" alt="">
|
||||
|
||||
然后,我们将Flutter Inspector面板左侧选择的控件更新为Column,右侧也更新了它的具体视觉信息,比如排版方向、对齐模式、渲染信息,以及它的两个子Widget-Text。
|
||||
|
||||
可以看到,Column控件的距离左边距为38.5,上边距为0;宽为298,高为712:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/84/d9/84b689d4e0c98f07831810cb346278d9.png" alt="">
|
||||
|
||||
通过上面的数据我们可以得出:
|
||||
|
||||
- Column的右边距=父Widget宽度(即iPhone X宽度375)-Column左边距(即38.5)- Column宽(即298)=38.5,即左右边距相等,因此Column是水平方向居中的;
|
||||
- Column的高度=父Widget的高度(即iPhone X高度812)- AppBar上边距(即0)- AppBar高度(即100) - Column上边距(即0)= 712.0,即Column在垂直方向上完全填满了父Widget除去AppBar之后的剩余空间。
|
||||
|
||||
因此,Column的布局行为是完全符合预期的。
|
||||
|
||||
## 总结
|
||||
|
||||
好了,今天的分享就到这里,我们总结一下今天的主要内容吧。
|
||||
|
||||
首先,我带你学习了如何实现定制日志的输出能力。Flutter提供了debugPrint函数,这是一个可以被覆盖的打印函数。我们可以分别定义生产环境与开发环境的日志输出行为,来满足开发期打日志需求的同时,保证发布期日志执行信息不会被意外打印。
|
||||
|
||||
然后,我与你介绍了Android Studio提供的Flutter调试功能,并通过观察计数器工程的计数器变量为例,与你讲述了具体的调试方法。
|
||||
|
||||
最后,我们一起学习了Flutter的布局调试能力,即通过Debug Paiting来定义辅助线,以及通过Flutter Inspector这种可视化手段来更为准确地诊断布局问题。
|
||||
|
||||
写代码不可避免会出现Bug,出现时就需要Debug(调试)。调试代码本质上就是一个不断收敛问题发生范围的过程,因此排查问题的一个最基本思路,就是二分法。
|
||||
|
||||
所谓二分调试法,是指通过某种稳定复现的特征(比如Crash、某个变量的值、是否出现某个现象等任何明显的迹象),加上一个能把问题出现的范围划分为两半的手段(比如断点、assert、日志等),两者结合反复迭代不断将问题可能出现的范围一分为二(比如能判断出引发问题的代码出现在断点之前等)。通过二分法,我们可以快速缩小问题范围,这样一来调试的效率也就上去了。
|
||||
|
||||
## 思考题
|
||||
|
||||
最后,我给你留下一道思考题吧。
|
||||
|
||||
请将debugPrint在生产环境下的打印日志行为更改为写日志文件。其中,日志文件一共5个(0-4),每个日志文件不能超过2MB,但可以循环写。如果日志文件已满,则循环至下一个日志文件,清空后重新写入。
|
||||
|
||||
欢迎你在评论区给我留言分享你的观点,我会在下一篇文章中等待你!感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。
|
||||
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
<audio id="audio" title="37 | 如何检测并优化Flutter App的整体性能表现?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/0a/bc/0a3368f5c1858140b91706bcf745eabc.mp3"></audio>
|
||||
|
||||
你好,我是陈航。
|
||||
|
||||
在上一篇文章中,我与你分享了调试Flutter代码的3种基本方式,即输出日志、断点调试与布局调试。
|
||||
|
||||
通过可定制打印行为的debugPrint函数,我们可以实现生产环境与开发环境不同的日志输出行为,从而保证在开发期打印的调试信息不会被发布至线上;借助于IDE(Android Studio)所提供的断点调试选项,我们可以不断调整代码执行步长和代码暂停条件,收敛问题发生范围,直至找到问题根源;而如果我们想找出代码中的布局渲染类Bug,则可以通过Debug Painting和Flutter Inspector提供的辅助线和视图可视化信息,来更为精准地定位视觉问题。
|
||||
|
||||
除了代码逻辑Bug和视觉异常这些功能层面的问题之外,移动应用另一类常见的问题是性能问题,比如滑动操作不流畅、页面出现卡顿丢帧现象等。这些问题虽然不至于让移动应用完全不可用,但也很容易引起用户反感,从而对应用质量产生质疑,甚至失去耐心。
|
||||
|
||||
那么,如果应用渲染并不流畅,出现了性能问题,我们该如何检测,又该从哪里着手处理呢?
|
||||
|
||||
在Flutter中,性能问题可以分为GPU线程问题和UI线程(CPU)问题两类。这些问题的确认都需要先通过性能图层进行初步分析,而一旦确认问题存在,接下来就需要利用Flutter提供的各类分析工具来定位问题了。
|
||||
|
||||
所以在今天这篇文章中,我会与你一起学习分析Flutter应用性能问题的基本思路和工具,以及常见的优化办法。
|
||||
|
||||
## 如何使用性能图层?
|
||||
|
||||
要解决问题,我们首先得了解如何去度量问题,性能分析也不例外。Flutter提供了度量性能问题的工具和手段,来帮助我们快速定位代码中的性能问题,而性能图层就是帮助我们确认问题影响范围的利器。
|
||||
|
||||
**为了使用性能图层,我们首先需要以分析(Profile)模式启动应用。**与调试代码可以通过模拟器在调试模式下找到代码逻辑Bug不同,性能问题需要在发布模式下使用真机进行检测。
|
||||
|
||||
这是因为,相比发布模式而言,调试模式增加了很多额外的检查(比如断言),这些检查可能会耗费很多资源;更重要的是,调试模式使用JIT模式运行应用,代码执行效率较低。这就使得调试模式运行的应用,无法真实反映出它的性能问题。
|
||||
|
||||
而另一方面,模拟器使用的指令集为x86,而真机使用的指令集是ARM。这两种方式的二进制代码执行行为完全不同,因此模拟器与真机的性能差异较大:一些x86指令集擅长的操作模拟器会比真机快,而另一些操作则会比真机慢。这也使得我们无法使用模拟器来评估真机才能出现的性能问题。
|
||||
|
||||
**为了调试性能问题,我们需要在发布模式的基础之上,为分析工具提供少量必要的应用追踪信息,这就是分析模式**。除了一些调试性能问题必须的追踪方法之外,Flutter应用的分析模式和发布模式的编译和运行是类似的,只是启动参数变成了profile而已:我们既可以在Android Studio中通过菜单栏点击Run->Profile ‘main.dart’ 选项启动应用,也可以通过命令行参数flutter run --profile运行Flutter应用。
|
||||
|
||||
## 分析渲染问题
|
||||
|
||||
在完成了应用启动之后,接下来我们就可以利用Flutter提供的渲染问题分析工具,即性能图层(Performance Overlay),来分析渲染问题了。
|
||||
|
||||
性能图层会在当前应用的最上层,以Flutter引擎自绘的方式展示GPU与UI线程的执行图表,而其中每一张图表都代表当前线程最近 300帧的表现,如果UI产生了卡顿(跳帧),这些图表可以帮助我们分析并找到原因。
|
||||
|
||||
下图演示了性能图层的展现样式。其中,GPU线程的性能情况在上面,UI线程的情况显示在下面,蓝色垂直的线条表示已执行的正常帧,绿色的线条代表的是当前帧:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/91/8e/91eb7eff1c5c2326f2904044b950fe8e.png" alt="">
|
||||
|
||||
为了保持60Hz的刷新频率,GPU线程与UI线程中执行每一帧耗费的时间都应该小于16ms(1/60秒)。在这其中有一帧处理时间过长,就会导致界面卡顿,图表中就会展示出一个红色竖条。下图演示了应用出现渲染和绘制耗时的情况下,性能图层的展示样式:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/bf/c1/bfb5ec2b5dcf7a2c20b7ec3b946854c1.jpeg" alt="">
|
||||
|
||||
如果红色竖条出现在GPU线程图表,意味着渲染的图形太复杂,导致无法快速渲染;而如果是出现在了UI线程图表,则表示Dart代码消耗了大量资源,需要优化代码执行时间。
|
||||
|
||||
接下来,我们就先看看GPU问题定位吧。
|
||||
|
||||
## GPU问题定位
|
||||
|
||||
GPU问题主要集中在底层渲染耗时上。有时候Widget树虽然构造起来容易,但在GPU线程下的渲染却很耗时。涉及Widget裁剪、蒙层这类多视图叠加渲染,或是由于缺少缓存导致静态图像的反复绘制,都会明显拖慢GPU的渲染速度。
|
||||
|
||||
我们可以使用性能图层提供的两项参数,即检查多视图叠加的视图渲染开关checkerboardOffscreenLayers,和检查缓存的图像开关checkerboardRasterCacheImages,来检查这两种情况。
|
||||
|
||||
### checkerboardOffscreenLayers
|
||||
|
||||
多视图叠加通常会用到Canvas里的savaLayer方法,这个方法在实现一些特定的效果(比如半透明)时非常有用,但由于其底层实现会在GPU渲染上涉及多图层的反复绘制,因此会带来较大的性能问题。
|
||||
|
||||
对于saveLayer方法使用情况的检查,我们只要在MaterialApp的初始化方法中,将checkerboardOffscreenLayers开关设置为true,分析工具就会自动帮我们检测多视图叠加的情况了:使用了saveLayer的Widget会自动显示为棋盘格式,并随着页面刷新而闪烁。
|
||||
|
||||
不过,saveLayer是一个较为底层的绘制方法,因此我们一般不会直接使用它,而是会通过一些功能性Widget,在涉及需要剪切或半透明蒙层的场景中间接地使用。所以一旦遇到这种情况,我们需要思考一下是否一定要这么做,能不能通过其他方式来实现呢。
|
||||
|
||||
比如下面的例子中,我们使用CupertinoPageScaffold与CupertinoNavigationBar实现了一个动态模糊的效果。
|
||||
|
||||
```
|
||||
CupertinoPageScaffold(
|
||||
navigationBar: CupertinoNavigationBar(),//动态模糊导航栏
|
||||
child: ListView.builder(
|
||||
itemCount: 100,
|
||||
//为列表创建100个不同颜色的RowItem
|
||||
itemBuilder: (context, index)=>TabRowItem(
|
||||
index: index,
|
||||
lastItem: index == 100 - 1,
|
||||
color: colorItems[index],//设置不同的颜色
|
||||
colorName: colorNameItems[index],
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
```
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/19/11/1988216282ce60059462da7b9a2eda11.gif" alt="">
|
||||
|
||||
由于视图滚动过程中频繁涉及视图蒙层效果的更新,因此checkerboardOffscreenLayers检测图层也感受到了对GPU的渲染压力,频繁的刷新闪烁。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/55/db/5585780bfd5dcae6a3498b7119a558db.gif" alt="">
|
||||
|
||||
如果我们没有对动态模糊效果的特殊需求,则可以使用不带模糊效果的Scaffold和白色的AppBar实现同样的产品功能,来解决这个性能问题:
|
||||
|
||||
```
|
||||
Scaffold(
|
||||
//使用普通的白色AppBar
|
||||
appBar: AppBar(title: Text('Home', style: TextStyle(color:Colors.black),),backgroundColor: Colors.white),
|
||||
body: ListView.builder(
|
||||
itemCount: 100,
|
||||
//为列表创建100个不同颜色的RowItem
|
||||
itemBuilder: (context, index)=>TabRowItem(
|
||||
index: index,
|
||||
lastItem: index == 100 - 1,
|
||||
color: colorItems[index],//设置不同的颜色
|
||||
colorName: colorNameItems[index],
|
||||
)
|
||||
),
|
||||
);
|
||||
|
||||
```
|
||||
|
||||
运行一下代码,可以看到,在去掉了动态模糊效果之后,GPU的渲染压力得到了缓解,checkerboardOffscreenLayers检测图层也不再频繁闪烁了。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/8c/46/8c937383845cb306dade4ab9c374ed46.gif" alt="">
|
||||
|
||||
### checkerboardRasterCacheImages
|
||||
|
||||
从资源的角度看,另一类非常消耗性能的操作是,渲染图像。这是因为图像的渲染涉及I/O、GPU存储,以及不同通道的数据格式转换,因此渲染过程的构建需要消耗大量资源。为了缓解GPU的压力,Flutter提供了多层次的缓存快照,这样Widget重建时就无需重新绘制静态图像了。
|
||||
|
||||
与检查多视图叠加渲染的checkerboardOffscreenLayers参数类似,Flutter也提供了检查缓存图像的开关checkerboardRasterCacheImages,来检测在界面重绘时频繁闪烁的图像(即没有静态缓存)。
|
||||
|
||||
我们可以把需要静态缓存的图像加到RepaintBoundary中,RepaintBoundary可以确定Widget树的重绘边界,如果图像足够复杂,Flutter引擎会自动将其缓存,避免重复刷新。当然,因为缓存资源有限,如果引擎认为图像不够复杂,也可能会忽略RepaintBoundary。
|
||||
|
||||
如下代码展示了通过RepaintBoundary,将一个静态复合Widget加入缓存的具体用法。可以看到,RepaintBoundary在使用上与普通Widget并无区别:
|
||||
|
||||
```
|
||||
RepaintBoundary(//设置静态缓存图像
|
||||
child: Center(
|
||||
child: Container(
|
||||
color: Colors.black,
|
||||
height: 10.0,
|
||||
width: 10.0,
|
||||
),
|
||||
));
|
||||
|
||||
```
|
||||
|
||||
## UI线程问题定位
|
||||
|
||||
如果说GPU线程问题定位的是渲染引擎底层渲染异常,那么UI线程问题发现的则是应用的性能瓶颈。比如在视图构建时,在build方法中使用了一些复杂的运算,或是在主Isolate中进行了同步的I/O操作。这些问题,都会明显增加CPU的处理时间,拖慢应用的响应速度。
|
||||
|
||||
这时,我们可以使用Flutter提供的Performance工具,来记录应用的执行轨迹。Performance是一个强大的性能分析工具,能够以时间轴的方式展示CPU的调用栈和执行时间,去检查代码中可疑的方法调用。
|
||||
|
||||
在点击了Android Studio底部工具栏中的“Open DevTools”按钮之后,系统会自动打开Dart DevTools的网页,将顶部的tab切换到Performance后,我们就可以开始分析代码中的性能问题了。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/11/3a/11d8392713ed0ce8615eeb360662653a.png" alt="">
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/86/87/867cbb2e87f5f18df0e1ac1a114bf687.png" alt="">
|
||||
|
||||
接下来,我们通过一个ListView中计算MD5的例子,来演示Performance的具体分析过程。
|
||||
|
||||
考虑到在build函数中进行渲染信息的组装是一个常见的操作,为了演示这个知识点,我们故意放大了计算MD5的耗时,循环迭代计算了1万次:
|
||||
|
||||
```
|
||||
class MyHomePage extends StatelessWidget {
|
||||
MyHomePage({Key key}) : super(key: key);
|
||||
|
||||
String generateMd5(String data) {
|
||||
//MD5固定算法
|
||||
var content = new Utf8Encoder().convert(data);
|
||||
var digest = md5.convert(content);
|
||||
return hex.encode(digest.bytes);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: Text('demo')),
|
||||
body: ListView.builder(
|
||||
itemCount: 30,// 列表元素个数
|
||||
itemBuilder: (context, index) {
|
||||
//反复迭代计算MD5
|
||||
String str = '1234567890abcdefghijklmnopqrstuvwxyz';
|
||||
for(int i = 0;i<10000;i++) {
|
||||
str = generateMd5(str);
|
||||
}
|
||||
return ListTile(title: Text("Index : $index"), subtitle: Text(str));
|
||||
}// 列表项创建方法
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
与性能图层能够自动记录应用执行情况不同,使用Performance来分析代码执行轨迹,我们需要手动点击“Record”按钮去主动触发,在完成信息的抽样采集后,点击“Stop”按钮结束录制。这时,我们就可以得到在这期间应用的执行情况了。
|
||||
|
||||
Performance记录的应用执行情况叫做CPU帧图,又被称为火焰图。火焰图是基于记录代码执行结果所产生的图片,用来展示CPU的调用栈,表示的是CPU 的繁忙程度。
|
||||
|
||||
其中,y轴表示调用栈,其每一层都是一个函数。调用栈越深,火焰就越高,底部就是正在执行的函数,上方都是它的父函数;x轴表示单位时间,一个函数在x轴占据的宽度越宽,就表示它被采样到的次数越多,即执行时间越长。
|
||||
|
||||
所以,我们要检测CPU耗时问题,皆可以查看火焰图底部的哪个函数占据的宽度最大。只要有“平顶”,就表示该函数可能存在性能问题。比如,我们这个案例的火焰图如下所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/12/02/1262ed91986a5e09646d56e5a2db3302.png" alt="">
|
||||
|
||||
可以看到,_MyHomePage.generateMd5函数的执行时间最长,几乎占满了整个火焰图的宽,而这也与代码中存在的问题是一致的。
|
||||
|
||||
在找到了问题之后,我们就可以使用Isolate(或compute)将这些耗时的操作挪到并发主Isolate之外去完成了。
|
||||
|
||||
## 总结
|
||||
|
||||
好了,今天的分享就到这里。我们总结一下今天的主要内容吧。
|
||||
|
||||
在Flutter中,性能分析过程可以分为GPU线程问题定位和UI线程(CPU)问题定位,而它们都需要在真机上以分析模式(Profile)启动应用,并通过性能图层分析大致的渲染问题范围。一旦确认问题存在,接下来就需要利用Flutter所提供的分析工具来定位问题原因了。
|
||||
|
||||
关于GPU线程渲染问题,我们可以重点检查应用中是否存在多视图叠加渲染,或是静态图像反复刷新的现象。而UI线程渲染问题,我们则是通过Performance工具记录的火焰图(CPU帧图),分析代码耗时,找出应用执行瓶颈。
|
||||
|
||||
通常来说,由于Flutter采用基于声明式的UI设计理念,以数据驱动渲染,并采用Widget->Element->RenderObject三层结构,屏蔽了无谓的界面刷新,能够保证绝大多数情况下我们构建的应用都是高性能的,所以在使用分析工具检测出性能问题之后,通常我们并不需要做太多的细节优化工作,只需要在改造过程中避开一些常见的坑,就可以获得优异的性能。比如:
|
||||
|
||||
- 控制build方法耗时,将Widget拆小,避免直接返回一个巨大的Widget,这样Widget会享有更细粒度的重建和复用;
|
||||
- 尽量不要为Widget设置半透明效果,而是考虑用图片的形式代替,这样被遮挡的Widget部分区域就不需要绘制了;
|
||||
- 对列表采用懒加载而不是直接一次性创建所有的子Widget,这样视图的初始化时间就减少了。
|
||||
|
||||
## 思考题
|
||||
|
||||
最后,我给你留下一道思考题吧。
|
||||
|
||||
请你改造ListView计算MD5的示例,在保证原有功能的情况下,使用并发Isolate(或compute)完成MD5的计算。提示:计算过程可以使用CircularProgressIndicator来展示加载动画。
|
||||
|
||||
欢迎你在评论区给我留言分享你的观点,我会在下一篇文章中等待你!感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。
|
||||
|
||||
|
||||
328
极客时间专栏/Flutter核心技术与实战/Flutter进阶/38 | 如何通过自动化测试提高交付质量?.md
Normal file
328
极客时间专栏/Flutter核心技术与实战/Flutter进阶/38 | 如何通过自动化测试提高交付质量?.md
Normal file
@@ -0,0 +1,328 @@
|
||||
<audio id="audio" title="38 | 如何通过自动化测试提高交付质量?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/9a/8e/9a5c2d4cc971ae10841ca25b2096f88e.mp3"></audio>
|
||||
|
||||
你好,我是陈航。
|
||||
|
||||
在上一篇文章中,我与你分享了如何分析并优化Flutter应用的性能问题。通过在真机上以分析模式运行应用,我们可以借助于性能图层的帮助,找到引起性能瓶颈的两类问题,即GPU渲染问题和CPU执行耗时问题。然后,我们就可以使用Flutter提供的渲染开关和CPU帧图(火焰图),来检查应用中是否存在过度渲染或是代码执行耗时长的情况,从而去定位并着手解决应用的性能问题了。
|
||||
|
||||
在完成了应用的开发工作,并解决了代码中的逻辑问题和性能问题之后,接下来我们就需要测试验收应用的各项功能表现了。移动应用的测试工作量通常很大,这是因为为了验证真实用户的使用体验,测试往往需要跨越多个平台(Android/iOS)及不同的物理设备手动完成。
|
||||
|
||||
随着产品功能不断迭代累积,测试工作量和复杂度也随之大幅增长,手动测试变得越来越困难。那么,在为产品添加新功能,或者修改已有功能时,如何才能确保应用可以继续正常工作呢?
|
||||
|
||||
答案是,通过编写自动化测试用例。
|
||||
|
||||
所谓自动化测试,是把由人驱动的测试行为改为由机器执行。具体来说就是,通过精心设计的测试用例,由机器按照执行步骤对应用进行自动测试,并输出执行结果,最后根据测试用例定义的规则确定结果是否符合预期。
|
||||
|
||||
也就是说,自动化测试将重复的、机械的人工操作变为自动化的验证步骤,极大的节省人力、时间和硬件资源,从而提高了测试效率。
|
||||
|
||||
在自动化测试用例的编写上,Flutter提供了包括单元测试和UI测试的能力。其中,单元测试可以方便地验证单个函数、方法或类的行为,而UI测试则提供了与Widget进行交互的能力,确认其功能是否符合预期。
|
||||
|
||||
接下来,我们就具体看看这两种自动化测试用例的用法吧。
|
||||
|
||||
## 单元测试
|
||||
|
||||
单元测试是指,对软件中的最小可测试单元进行验证的方式,并通过验证结果来确定最小单元的行为是否与预期一致。所谓最小可测试单元,一般来说,就是人为规定的、最小的被测功能模块,比如语句、函数、方法或类。
|
||||
|
||||
在Flutter中编写单元测试用例,我们可以在pubspec.yaml文件中使用test包来完成。其中,test包提供了编写单元测试用例的核心框架,即定义、执行和验证。如下代码所示,就是test包的用法:
|
||||
|
||||
```
|
||||
dev_dependencies:
|
||||
test:
|
||||
|
||||
```
|
||||
|
||||
>
|
||||
备注:test包的声明需要在dev_dependencies下完成,在这个标签下面定义的包只会在开发模式生效。
|
||||
|
||||
|
||||
与Flutter应用通过main函数定义程序入口相同,Flutter单元测试用例也是通过main函数来定义测试入口的。不过,**这两个程序入口的目录位置有些区别**:应用程序的入口位于工程中的lib目录下,而测试用例的入口位于工程中的test目录下。
|
||||
|
||||
一个有着单元测试用例的Flutter工程目录结构,如下所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/43/f7/436f402015d289528725e69f42ec9ef7.png" alt="">
|
||||
|
||||
接下来,我们就可以在main.dart中声明一个用来测试的类了。在下面的例子中,我们声明了一个计数器类Counter,这个类可以支持以递增或递减的方式修改计数值count:
|
||||
|
||||
```
|
||||
class Counter {
|
||||
int count = 0;
|
||||
void increase() => count++;
|
||||
void decrease() => count--;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
实现完待测试的类,我们就可以为它编写测试用例了。**在Flutter中,测试用例的声明包含定义、执行和验证三个部分:**定义和执行决定了被测试对象提供的、需要验证的最小可测单元;而验证则需要使用expect函数,将最小可测单元的执行结果与预期进行比较。
|
||||
|
||||
所以,在Flutter中编写一个测试用例,通常包含以下两大步骤:
|
||||
|
||||
1. 实现一个包含定义、执行和验证步骤的测试用例;
|
||||
1. 将其包装在test内部,test是Flutter提供的测试用例封装类。
|
||||
|
||||
在下面的例子中,我们定义了两个测试用例,其中第一个用例用来验证调用increase函数后的计数器值是否为1,而第二个用例则用来判断1+1是否等于2:
|
||||
|
||||
```
|
||||
import 'package:test/test.dart';
|
||||
import 'package:flutter_app/main.dart';
|
||||
|
||||
void main() {
|
||||
//第一个用例,判断Counter对象调用increase方法后是否等于1
|
||||
test('Increase a counter value should be 1', () {
|
||||
final counter = Counter();
|
||||
counter.increase();
|
||||
expect(counter.value, 1);
|
||||
});
|
||||
//第二个用例,判断1+1是否等于2
|
||||
test('1+1 should be 2', () {
|
||||
expect(1+1, 2);
|
||||
});
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
选择widget_test.dart文件,在右键弹出的菜单中选择“Run ‘tests in widget_test’”,就可以启动测试用例了。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/c0/c2/c0f59369d9af26b3705daee16da352c2.png" alt="">
|
||||
|
||||
稍等片刻,控制台就会输出测试用例的执行结果了。当然,这两个用例都能通过测试:
|
||||
|
||||
```
|
||||
22:05 Tests passed: 2
|
||||
|
||||
```
|
||||
|
||||
**如果测试用例的执行结果是不通过,Flutter会给我们怎样的提示呢?**我们试着修改一下第一个计数器递增的用例,将它的期望结果改为2:
|
||||
|
||||
```
|
||||
test('Increase a counter value should be 1', () {
|
||||
final counter = Counter();
|
||||
counter.increase();
|
||||
expect(counter.value, 2);//判断Counter对象调用increase后是否等于2
|
||||
});
|
||||
|
||||
```
|
||||
|
||||
运行测试用例,可以看到,Flutter在执行完计数器的递增方法后,发现其结果1与预期的2不匹配,于是报错:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/fe/40/fe030c283a78c133787d03eca8a3f240.png" alt="">
|
||||
|
||||
上面的示例演示了单个测试用例的编写方法,而**如果有多个测试用例**,它们之间是存在关联关系的,我们可以在最外层使用group将它们组合在一起。
|
||||
|
||||
在下面的例子中,我们定义了计数器递增和计数器递减两个用例,验证递增的结果是否等于1的同时判断递减的结果是否等于-1,并把它们组合在了一起:
|
||||
|
||||
```
|
||||
import 'package:test/test.dart';
|
||||
import 'package:counter_app/counter.dart';
|
||||
void main() {
|
||||
//组合测试用例,判断Counter对象调用increase方法后是否等于1,并且判断Counter对象调用decrease方法后是否等于-1
|
||||
group('Counter', () {
|
||||
test('Increase a counter value should be 1', () {
|
||||
final counter = Counter();
|
||||
counter.increase();
|
||||
expect(counter.value, 1);
|
||||
});
|
||||
|
||||
test('Decrease a counter value should be -1', () {
|
||||
final counter = Counter();
|
||||
counter.decrease();
|
||||
expect(counter.value, -1);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
同样的,这两个测试用例的执行结果也是通过。
|
||||
|
||||
**在对程序的内部功能进行单元测试时,我们还可能需要从外部依赖(比如Web服务)获取需要测试的数据。**比如下面的例子,Todo对象的初始化就是通过Web服务返回的JSON实现的。考虑到调用Web服务的过程中可能会出错,所以我们还处理了请求码不等于200的其他异常情况:
|
||||
|
||||
```
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
class Todo {
|
||||
final String title;
|
||||
Todo({this.title});
|
||||
//工厂类构造方法,将JSON转换为对象
|
||||
factory Todo.fromJson(Map<String, dynamic> json) {
|
||||
return Todo(
|
||||
title: json['title'],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<Todo> fetchTodo(http.Client client) async {
|
||||
final response =
|
||||
await client.get('https://xxx.com/todos/1');
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
//请求成功,解析JSON
|
||||
return Todo.fromJson(json.decode(response.body));
|
||||
} else {
|
||||
//请求失败,抛出异常
|
||||
throw Exception('Failed to load post');
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
考虑到这些外部依赖并不是我们的程序所能控制的,因此很难覆盖所有可能的成功或失败方案。比如,对于一个正常运行的Web服务来说,我们基本不可能测试出fetchTodo这个接口是如何应对403或502状态码的。因此,更好的一个办法是,在测试用例中“模拟”这些外部依赖(对应本例即为http.client),让这些外部依赖可以返回特定结果。
|
||||
|
||||
在单元测试用例中模拟外部依赖,我们需要在pubspec.yaml文件中使用mockito包,以接口实现的方式定义外部依赖的接口:
|
||||
|
||||
```
|
||||
dev_dependencies:
|
||||
test:
|
||||
mockito:
|
||||
|
||||
```
|
||||
|
||||
要**使用mockito包来模拟fetchTodo的依赖http.client**,我们首先需要定义一个继承自Mock(这个类可以模拟任何外部依赖),并以接口定义的方式实现了http.client的模拟类;然后,在测试用例的声明中,为其制定任意的接口返回。
|
||||
|
||||
在下面的例子中,我们定义了一个模拟类MockClient,这个类以接口声明的方式获取到了http.Client的外部接口。随后,我们就可以使用when语句,在其调用Web服务时,为其注入相应的数据返回了。在第一个用例中,我们为其注入了JSON结果;而在第二个用例中,我们为其注入了一个403的异常。
|
||||
|
||||
```
|
||||
import 'package:mockito/mockito.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
class MockClient extends Mock implements http.Client {}
|
||||
|
||||
void main() {
|
||||
group('fetchTodo', () {
|
||||
test('returns a Todo if successful', () async {
|
||||
final client = MockClient();
|
||||
|
||||
//使用Mockito注入请求成功的JSON字段
|
||||
when(client.get('https://xxx.com/todos/1'))
|
||||
.thenAnswer((_) async => http.Response('{"title": "Test"}', 200));
|
||||
//验证请求结果是否为Todo实例
|
||||
expect(await fetchTodo(client), isInstanceOf<Todo>());
|
||||
});
|
||||
|
||||
test('throws an exception if error', () {
|
||||
final client = MockClient();
|
||||
|
||||
//使用Mockito注入请求失败的Error
|
||||
when(client.get('https://xxx.com/todos/1'))
|
||||
.thenAnswer((_) async => http.Response('Forbidden', 403));
|
||||
//验证请求结果是否抛出异常
|
||||
expect(fetchTodo(client), throwsException);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
运行这段测试用例,可以看到,我们在没有调用真实Web服务的情况下,成功模拟出了正常和异常两种结果,同样也是顺利通过验证了。
|
||||
|
||||
接下来,我们再看看UI测试吧。
|
||||
|
||||
## UI测试
|
||||
|
||||
UI测试的目的是模仿真实用户的行为,即以真实用户的身份对应用程序执行UI交互操作,并涵盖各种用户流程。相比于单元测试,UI测试的覆盖范围更广、更关注流程和交互,可以找到单元测试期间无法找到的错误。
|
||||
|
||||
在Flutter中编写UI测试用例,我们需要在pubspec.yaml中使用flutter_test包,来提供编写**UI测试的核心框架**,即定义、执行和验证:
|
||||
|
||||
<li>
|
||||
定义,即通过指定规则,找到UI测试用例需要验证的、特定的子Widget对象;
|
||||
</li>
|
||||
<li>
|
||||
执行,意味着我们要在找到的子Widget对象中,施加用户交互事件;
|
||||
</li>
|
||||
<li>
|
||||
验证,表示在施加了交互事件后,判断待验证的Widget对象的整体表现是否符合预期。
|
||||
</li>
|
||||
|
||||
如下代码所示,就是flutter_test包的用法:
|
||||
|
||||
```
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
|
||||
|
||||
```
|
||||
|
||||
接下来,我以Flutter默认的计时器应用模板为例,与你说明**UI测试用例的编写方法**。
|
||||
|
||||
在计数器应用中,有两处地方会响应外部交互事件,包括响应用户点击行为的按钮Icon,与响应渲染刷新事件的文本Text。按钮点击后,计数器会累加,文本也随之刷新。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/55/03/5510a86c0d7a25a3c1f3366e485f4c03.png" alt="">
|
||||
|
||||
为确保程序的功能正常,我们希望编写一个UI测试用例,来验证按钮的点击行为是否与文本的刷新行为完全匹配。
|
||||
|
||||
与单元测试使用test对用例进行包装类似,**UI测试使用testWidgets对用例进行包装**。testWidgets提供了tester参数,我们可以使用这个实例来操作需要测试的Widget对象。
|
||||
|
||||
在下面的代码中,我们**首先**声明了需要验证的MyApp对象。在通过pumpWidget触发其完成渲染后,使用find.text方法分别查找了字符串文本为0和1的Text控件,目的是验证响应刷新事件的文本Text的初始化状态是否为0。
|
||||
|
||||
**随后**,我们通过find.byIcon方法找到了按钮控件,并通过tester.tap方法对其施加了点击行为。在完成了点击后,我们使用tester.pump方法强制触发其完成渲染刷新。**最后**,我们使用了与验证Text初始化状态同样的语句,判断在响应了刷新事件后的文本Text其状态是否为1:
|
||||
|
||||
```
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:flutter_app_demox/main.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets('Counter increments UI test', (WidgetTester tester) async {
|
||||
//声明所需要验证的Widget对象(即MyApp),并触发其渲染
|
||||
await tester.pumpWidget(MyApp());
|
||||
|
||||
//查找字符串文本为'0'的Widget,验证查找成功
|
||||
expect(find.text('0'), findsOneWidget);
|
||||
//查找字符串文本为'1'的Widget,验证查找失败
|
||||
expect(find.text('1'), findsNothing);
|
||||
|
||||
//查找'+'按钮,施加点击行为
|
||||
await tester.tap(find.byIcon(Icons.add));
|
||||
//触发其渲染
|
||||
await tester.pump();
|
||||
|
||||
//查找字符串文本为'0'的Widget,验证查找失败
|
||||
expect(find.text('0'), findsNothing);
|
||||
//查找字符串文本为'1'的Widget,验证查找成功
|
||||
expect(find.text('1'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
运行这段UI测试用例代码,同样也顺利通过验证了。
|
||||
|
||||
除了点击事件之外,tester还支持其他的交互行为,比如文字输入enterText、拖动drag、长按longPress等,这里我就不再一一赘述了。如果你想深入理解这些内容,可以参考WidgetTester的[官方文档](https://api.flutter.dev/flutter/flutter_test/WidgetTester-class.html)进行学习。
|
||||
|
||||
## 总结
|
||||
|
||||
好了,今天的分享就到这里,我们总结一下今天的主要内容吧。
|
||||
|
||||
在Flutter中,自动化测试可以分为单元测试和UI测试。
|
||||
|
||||
单元测试的步骤,包括定义、执行和验证。通过单元测试用例,我们可以验证单个函数、方法或类,其行为表现是否与预期一致。而UI测试的步骤,同样是包括定义、执行和验证。我们可以通过模仿真实用户的行为,对应用进行交互操作,覆盖更广的流程。
|
||||
|
||||
如果测试对象存在像Web服务这样的外部依赖,为了让单元测试过程更为可控,我们可以使用mockito为其定制任意的数据返回,实现正常和异常两种测试用例。
|
||||
|
||||
需要注意的是,尽管UI测试扩大了应用的测试范围,可以找到单元测试期间无法找到的错误,不过相比于单元测试用例来说,UI测试用例的开发和维护代价非常高。因为一个移动应用最主要的功能其实就是UI,而UI的变化非常频繁,UI测试需要不断的维护才能保持稳定可用的状态。
|
||||
|
||||
“投入和回报”永远是考虑是否采用UI测试,以及采用何种级别的UI测试,需要最优先考虑的问题。我推荐的原则是,项目达到一定的规模,并且业务特征具有一定的延续规律性后,再考虑UI测试的必要性。
|
||||
|
||||
我把今天分享涉及的知识点打包到了[GitHub](https://github.com/cyndibaby905/38_test_app)中,你可以下载下来,反复运行几次,加深理解与记忆。
|
||||
|
||||
## 思考题
|
||||
|
||||
最后,我给你留下一道思考题吧。
|
||||
|
||||
在下面的代码中,我们定义了SharedPreferences的更新和递增方法。请你使用mockito模拟SharedPreferences的方式,来为这两个方法实现对应的单元测试用例。
|
||||
|
||||
```
|
||||
Future<bool>updateSP(SharedPreferences prefs, int counter) async {
|
||||
bool result = await prefs.setInt('counter', counter);
|
||||
return result;
|
||||
}
|
||||
|
||||
Future<int>increaseSPCounter(SharedPreferences prefs) async {
|
||||
int counter = (prefs.getInt('counter') ?? 0) + 1;
|
||||
await updateSP(prefs, counter);
|
||||
return counter;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
欢迎你在评论区给我留言分享你的观点,我会在下一篇文章中等待你!感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。
|
||||
|
||||
|
||||
Reference in New Issue
Block a user