This commit is contained in:
louzefeng
2024-07-11 05:50:32 +00:00
parent bf99793fd0
commit d3828a7aee
6071 changed files with 0 additions and 0 deletions

View 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&lt;AnimateApp&gt; with SingleTickerProviderStateMixin {
AnimationController controller;
Animation&lt;double&gt; 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&lt;double&gt; animation})
: super(key: key, listenable: animation);
Widget build(BuildContext context) {
//取出动画对象
final Animation&lt;double&gt; 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) =&gt; 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 Logopage2有一个位于中部的大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: (_)=&gt;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) =&gt; Container(
width: animation.value,
height: animation.value,
child: child
)
)
```
1. 在AnimatedBuilder的例子中child似乎被指定了两遍第3行的child与第7行的child你可以解释下这么做的原因吗
1. 如果我把第3行的child删掉把Flutter Logo放到第7行动画是否能正常执行这会有什么问题吗
欢迎你在评论区给我留言分享你的观点,我会在下一篇文章中等待你!感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。

View 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(() =&gt; print('This is a microtask'));
```
不过一般的异步任务通常也很少必须要在事件队列前完成所以也不需要太高的优先级因此我们通常很少会直接用到微任务队列就连Flutter内部也只有7处用到了而已比如手势识别、文本输入、滚动视图、保存页面效果等需要高优执行任务的场景
异步任务我们用的最多的还是优先级更低的Event Queue。比如I/O、绘制、定时器这些异步事件都是通过事件队列驱动主线程执行的。
**Dart为Event Queue的任务建立提供了一层封装叫作Future**。从名字上也很容易理解,它表示一个在未来时间才会完成的任务。
把一个函数体放入Future就完成了从同步任务到异步任务的包装。Future还提供了链式调用的能力可以在异步任务执行完毕后依次执行链路上的其他函数体。
接下来,我们看一个具体的代码示例:分别声明两个异步任务,在下一个事件循环中输出一段字符串。其中第二个任务执行完毕之后,还会继续输出另外两段字符串:
```
Future(() =&gt; print('Running in Future 1'));//下一个事件循环输出字符串
Future(() =&gt; print(Running in Future 2'))
.then((_) =&gt; print('and then 1'))
.then((_) =&gt; 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(() =&gt; print('f1'));
Future(() =&gt; print('f2'));
//f3执行后会立刻同步执行then 3
Future(() =&gt; print('f3')).then((_) =&gt; print('then 3'));
//then 4会加入微任务队列尽快执行
Future(() =&gt; null).then((_) =&gt; print('then 4'));
```
说了这么多规则,可能大家并没有完全记住。那我们通过一个综合案例,来把之前介绍的各个执行规则都串起来,再集中学习一下。
在下面的例子中我们依次声明了若干个异步任务Future以及微任务。在其中的一些Future内部我们又内嵌了Future与microtask的声明
```
Future(() =&gt; print('f1'));//声明一个匿名Future
Future fx = Future(() =&gt; null);//声明Future fx其执行体为null
//声明一个匿名Future并注册了两个then。在第一个then回调里启动了一个微任务
Future(() =&gt; print('f2')).then((_) {
print('f3');
scheduleMicrotask(() =&gt; print('f4'));
}).then((_) =&gt; print('f5'));
//声明了一个匿名Future并注册了两个then。第一个then是一个Future
Future(() =&gt; print('f6'))
.then((_) =&gt; Future(() =&gt; print('f7')))
.then((_) =&gt; print('f8'));
//声明了一个匿名Future
Future(() =&gt; print('f9'));
//往执行体为null的fx注册了了一个then
fx.then((_) =&gt; print('f10'));
//启动一个微任务
scheduleMicrotask(() =&gt; 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&lt;String&gt; fetchContent() =&gt;
Future&lt;String&gt;.delayed(Duration(seconds:3), () =&gt; &quot;Hello&quot;)
.then((x) =&gt; &quot;$x 2019&quot;);
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(() =&gt; print('f1'))
.then((_) async =&gt; await Future(() =&gt; print('f2')))
.then((_) =&gt; print('f3'));
Future(() =&gt; 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&lt;String&gt; fetchContent() =&gt;
Future&lt;String&gt;.delayed(Duration(seconds:2), () =&gt; &quot;Hello&quot;)
.then((x) =&gt; &quot;$x 2019&quot;);
//异步函数会同步等待Hello 2019的返回并打印
func() async =&gt; print(await fetchContent());
main() {
print(&quot;func before&quot;);
func();
print(&quot;func after&quot;);
}
```
运行这段代码我们发现最终输出的顺序其实是“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) =&gt; print(msg);
main() {
Isolate.spawn(doSth, &quot;Hi&quot;);
...
}
```
但更多情况下我们的需求并不会这么简单不仅希望能并发还希望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) =&gt; sendPort.send(&quot;Hello&quot;);
```
这里需要注意的是在Isolate中发送管道是单向的我们启动了一个Isolate执行某项任务Isolate执行完毕后发送消息告知我们。如果Isolate执行任务时需要依赖主Isolate给它发送参数执行完毕后再发送执行结果给主Isolate这样**双向通信的场景我们如何实现呢**答案也很简单让并发Isolate也回传一个发送管道即可。
接下来,我们以一个**并发计算阶乘**的例子来说明如何实现双向通信。
在下面的例子中我们创建了一个异步函数计算阶乘。在这个异步函数内创建了一个并发Isolate传入主Isolate的发送管道并发Isolate也回传一个发送管道主Isolate收到回传管道后发送参数N给并发Isolate然后立即返回一个Future并发Isolate用参数N调用同步计算阶乘的函数返回执行结果最后主Isolate打印了返回结果
```
//并发计算阶乘
Future&lt;dynamic&gt; 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) =&gt; n &lt; 2 ? n : n * syncFactorial(n-1);
main() async =&gt; print(await asyncFactoriali(4));//等待并发计算阶乘结果
```
看完这段代码你是什么感觉呢?我们只是为了并发计算一个阶乘,这样是不是太繁琐了?
没错确实太繁琐了。在Flutter中像这样执行并发计算任务我们可以采用更简单的方式。Flutter提供了支持并发计算的compute函数其内部对Isolate的创建和双向通信进行了封装抽象屏蔽了很多底层细节我们在调用时只需要传入函数入口和函数参数就能够实现并发计算和消息通知。
我们试着用compute函数改造一下并发计算阶乘的代码
```
//同步计算阶乘
int syncFactorial(n) =&gt; n &lt; 2 ? n : n * syncFactorial(n-1);
//使用compute函数封装Isolate的创建和结果的返回
main() async =&gt; 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(() =&gt; print('f1'))
.then((_) async =&gt; await Future(() =&gt; print('f2')))
.then((_) =&gt; print('f3'));
Future(() =&gt; print('f4'));
```
欢迎你在评论区给我留言分享你的观点,我会在下一篇文章中等待你!感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。

View 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为&quot;Custom-UA&quot;
var uri = Uri.parse(&quot;https://flutter.dev&quot;);
var request = await httpClient.getUrl(uri);
request.headers.add(&quot;user-agent&quot;, &quot;Custom-UA&quot;);
//发起请求,等待响应
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: '&gt;=0.11.3+12'
```
在下面的代码中与HttpClient的例子类似的我们也是先后构造了http网络调用实例和Flutter官网URI在设置user-agent为Custom-UA后发出请求最后打印请求结果
```
httpGet() async {
//创建网络调用示例
var client = http.Client();
//构造URI
var uri = Uri.parse(&quot;https://flutter.dev&quot;);
//设置user-agent为&quot;Custom-UA&quot;,随后立即发出请求
http.Response response = await client.get(uri, headers : {&quot;user-agent&quot; : &quot;Custom-UA&quot;});
//打印请求结果
if(response.statusCode == HttpStatus.ok) {
print(response.body);
} else {
print(&quot;Error: ${response.statusCode}&quot;);
}
}
```
可以看到相比于HttpClienthttp的使用方式更加简单仅需一次异步调用就可以实现基本的网络通信。
### dio
HttpClient和http使用方式虽然简单但其暴露的定制化能力都相对较弱很多常用的功能都不支持或者实现异常繁琐比如取消请求、定制拦截器、Cookie管理等。因此对于复杂的网络请求行为我推荐使用目前在Dart社区人气较高的第三方dio来发起网络请求。
接下来我通过几个例子来和你介绍dio的使用方法。与http类似的我们首先需要把dio加到pubspec中的依赖里
```
dependencies:
dio: '&gt;2.1.3'
```
在下面的代码中与前面HttpClient与http例子类似的我们也是先后创建了dio网络调用实例、创建URI、设置Header、发出请求最后等待请求结果
```
void getRequest() async {
//创建网络调用示例
Dio dio = new Dio();
//设置URI及请求user-agent后发起请求
var response = await dio.get(&quot;https://flutter.dev&quot;, options:Options(headers: {&quot;user-agent&quot; : &quot;Custom-UA&quot;}));
//打印请求结果
if(response.statusCode == HttpStatus.ok) {
print(response.data.toString());
} else {
print(&quot;Error: ${response.statusCode}&quot;);
}
}
```
>
这里需要注意的是创建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({
&quot;file1&quot;: UploadFileInfo(File(&quot;./file1.txt&quot;), &quot;file1.txt&quot;),
&quot;file2&quot;: UploadFileInfo(File(&quot;./file2.txt&quot;), &quot;file1.txt&quot;),
});
//通过post方法发送至服务端
var responseY = await dio.post(&quot;https://xxx.com/upload&quot;, data: formData);
print(responseY.toString());
//使用download方法下载文件
dio.download(&quot;https://xxx.com/file1&quot;, &quot;xx1.zip&quot;);
//增加下载进度回调函数
dio.download(&quot;https://xxx.com/file1&quot;, &quot;xx2.zip&quot;, onReceiveProgress: (count, total) {
//do something
});
```
有时我们的页面由多个并行的请求响应结果构成这就需要等待这些请求都返回后才能刷新界面。在dio中我们可以结合Future.wait方法轻松实现
```
//同时发起两个并行请求
List&lt;Response&gt; responseX= await Future.wait([dio.get(&quot;https://flutter.dev&quot;),dio.get(&quot;https://pub.dev/packages/dio&quot;)]);
//打印请求1响应结果
print(&quot;Response1: ${responseX[0].toString()}&quot;);
//打印请求2响应结果
print(&quot;Response2: ${responseX[1].toString()}&quot;);
```
此外与Android的okHttp一样dio还提供了请求拦截器通过拦截器我们可以在请求之前或响应之后做一些特殊的操作。比如可以为请求option统一增加一个header或是返回缓存数据或是增加本地校验处理等等。
在下面的例子中我们为dio增加了一个拦截器。在请求发送之前不仅为每个请求头都加上了自定义的user-agent还实现了基本的token认证信息检查功能。而对于本地已经缓存了请求uri资源的场景我们可以直接返回缓存数据避免再次下载
```
//增加拦截器
dio.interceptors.add(InterceptorsWrapper(
onRequest: (RequestOptions options){
//为每个请求头都增加user-agent
options.headers[&quot;user-agent&quot;] = &quot;Custom-UA&quot;;
//检查是否有token没有则直接报错
if(options.headers['token'] == null) {
return dio.reject(&quot;Error:请先登录&quot;);
}
//检查缓存是否有数据
if(options.uri == Uri.parse('http://xxx.com/file1')) {
return dio.resolve(&quot;返回缓存数据&quot;);
}
//放行请求
return options;
}
));
//增加try catch防止请求报错
try {
var response = await dio.get(&quot;https://xxx.com/xxx.zip&quot;);
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 = '''
{
&quot;id&quot;:&quot;123&quot;,
&quot;name&quot;:&quot;张三&quot;,
&quot;score&quot; : 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&lt;String, dynamic&gt; 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 = '''
{
&quot;id&quot;:&quot;123&quot;,
&quot;name&quot;:&quot;张三&quot;,
&quot;score&quot; : 95,
&quot;teacher&quot;: {
&quot;name&quot;: &quot;李四&quot;,
&quot;age&quot; : 40
}
}
''';
```
这里teacher不再是一个基本类型而是一个对象。面对这种情况我们需要为每一个非基本类型属性创建一个解析类。与Student类似我们也需要为它的属性teacher创建一个解析类Teacher
```
class Teacher {
//Teacher的名字与年龄
String name;
int age;
//构造方法
Teacher({this.name,this.age});
//JSON解析工厂类使用字典数据为对象初始化赋值
factory Teacher.fromJson(Map&lt;String, dynamic&gt; 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&lt;String, dynamic&gt; 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)=&gt;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 = '''
{
&quot;id&quot;:&quot;123&quot;,
&quot;name&quot;:&quot;张三&quot;,
&quot;score&quot; : 95,
&quot;teachers&quot;: [
{
&quot;name&quot;: &quot;李四&quot;,
&quot;age&quot; : 40
},
{
&quot;name&quot;: &quot;王五&quot;,
&quot;age&quot; : 45
}
]
}
''';
```
欢迎你在评论区给我留言分享你的观点,我会在下一篇文章中等待你!感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。

View 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&lt;File&gt; get _localFile async {
final directory = await getApplicationDocumentsDirectory();
final path = directory.path;
return File('$path/content.txt');
}
//将字符串写入文件
Future&lt;File&gt; writeContent(String content) async {
final file = await _localFile;
return file.writeAsString(content);
}
//从文件读出字符串
Future&lt;String&gt; readContent() async {
try {
final file = await _localFile;
String contents = await file.readAsString();
return contents;
} catch (e) {
return &quot;&quot;;
}
}
```
有了文件读写函数我们就可以在代码中对content.txt这个文件进行读写操作了。在下面的代码中我们往这个文件写入了一段字符串后隔了一会又把它读了出来
```
writeContent(&quot;Hello World!&quot;);
...
readContent().then((value)=&gt;print(value));
```
除了字符串读写之外Flutter还提供了二进制流的读写能力可以支持图片、压缩包等二进制文件的读写。这些内容不是本次分享的重点如果你想要深入研究的话可以查阅[官方文档](https://api.flutter.dev/flutter/dart-io/File-class.html)。
## SharedPreferences
文件比较适合大量的、有序的数据持久化如果我们只是需要缓存少量的键值对信息比如记录用户是否阅读了公告或是简单的计数则可以使用SharedPreferences。
SharedPreferences会以原生平台相关的机制为简单的键值对数据提供持久化存储即在iOS上使用NSUserDefaults在Android使用SharedPreferences。
接下来我通过一个例子来演示在Flutter中如何通过SharedPreferences实现数据的读写。在下面的代码中我们将计数器持久化到了SharedPreferences中并为它分别提供了读方法和递增写入的方法。
这里需要注意的是settersetInt方法会同步更新内存中的键值对然后将数据保存至磁盘因此我们无需再调用更新方法强制刷新缓存。同样地由于涉及到耗时的文件读写因此我们必须以异步的方式对这些操作进行包装
```
//读取SharedPreferences中key为counter的值
Future&lt;int&gt;_loadCounter() async {
SharedPreferences prefs = await SharedPreferences.getInstance();
int counter = (prefs.getInt('counter') ?? 0);
return counter;
}
//递增写入SharedPreferences中key为counter的值
Future&lt;void&gt;_incrementCounter() async {
SharedPreferences prefs = await SharedPreferences.getInstance();
int counter = (prefs.getInt('counter') ?? 0) + 1;
prefs.setInt('counter', counter);
}
```
在完成了计数器存取方法的封装后,我们就可以在代码中随时更新并持久化计数器数据了。在下面的代码中,我们先是读取并打印了计数器数据,随后将其递增,并再次把它读取打印:
```
//读出counter数据并打印
_loadCounter().then((value)=&gt;print(&quot;before:$value&quot;));
//递增counter数据后再次读出并打印
_incrementCounter().then((_) {
_loadCounter().then((value)=&gt;print(&quot;after:$value&quot;));
});
```
可以看到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&lt;String, dynamic&gt; parsedJson){
return Student(
id: parsedJson['id'],
name : parsedJson['name'],
score : parsedJson ['score'],
);
}
}
```
JSON类拥有一个可以将JSON字典转换成类对象的工厂类方法我们也可以提供将类对象反过来转换成JSON字典的实例方法。因为最终存入数据库的并不是实体类对象而是字符串、整型等基本类型组成的字典所以我们可以通过这两个方法实现数据库的读写。同时我们还分别定义了3个Student对象用于后续插入数据库
```
class Student{
...
//将类对象转换成JSON字典方便插入数据库
Map&lt;String, dynamic&gt; 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&lt;Database&gt; database = openDatabase(
join(await getDatabasesPath(), 'students_database.db'),
onCreate: (db, version)=&gt;db.execute(&quot;CREATE TABLE students(id TEXT PRIMARY KEY, name TEXT, score INTEGER)&quot;),
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&lt;void&gt; 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&lt;List&lt;Student&gt;&gt; students() async {
final Database db = await database;
final List&lt;Map&lt;String, dynamic&gt;&gt; maps = await db.query('students');
return List.generate(maps.length, (i)=&gt;Student.fromJson(maps[i]));
}
//读取出数据库中插入的Student对象集合
students().then((list)=&gt;list.forEach((s)=&gt;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表增加了一个字段ageALTER TABLE students ADD age INTEGER。请你写出1.1版本及1.2版本的数据库升级代码。
```
//1.0版本数据库创建代码
final Future&lt;Database&gt; database = openDatabase(
join(await getDatabasesPath(), 'students_database.db'),
onCreate: (db, version)=&gt;db.execute(&quot;CREATE TABLE students(id TEXT PRIMARY KEY, name TEXT, score INTEGER)&quot;),
onUpgrade: (db, oldVersion, newVersion){
//dosth for migration
},
version: 1,
);
```
欢迎你在评论区给我留言分享你的观点,我会在下一篇文章中等待你!感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。

View File

@@ -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(&quot;Result$result&quot;);
}
```
需要注意的是与网络调用类似方法调用请求有可能会失败比如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(), &quot;samples.chenhang/utils&quot;).setMethodCallHandler(
//设置方法处理回调
new MethodCallHandler() {
//响应方法请求
@Override
public void onMethodCall(MethodCall call, Result result) {
//判断方法名是否支持
if(call.method.equals(&quot;openAppMarket&quot;)) {
try {
//应用市场URI
Uri uri = Uri.parse(&quot;market://details?id=com.hangchen.example.flutter_module_page.host&quot;);
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(&quot;UNAVAILABLE&quot;, &quot;没有安装应用市场&quot;, 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:@&quot;samples.chenhang/utils&quot; binaryMessenger:(FlutterViewController *)self.window.rootViewController];
//往方法通道注册方法调用处理回调
[channel setMethodCallHandler:^(FlutterMethodCall* call, FlutterResult result) {
//方法名称一致
if ([@&quot;openAppMarket&quot; isEqualToString:call.method]) {
//打开App Store(本例打开微信的URL)
[[UIApplication sharedApplication] openURL:[NSURL URLWithString:@&quot;itms-apps://itunes.apple.com/xy/app/foo/id414478124&quot;]];
//返回方法处理结果
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的应用市场。
欢迎你在评论区给我留言分享你的观点,我会在下一篇文章中等待你!感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。

View File

@@ -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(&quot;samples.chenhang/native_views&quot;);//生成注册类
SampleViewFactory playerViewFactory = new SampleViewFactory(registrar.messenger());//生成视图工厂
registrar.platformViewRegistry().registerViewFactory(&quot;sampleView&quot;, playerViewFactory);//注册视图工厂
}
```
完成绑定之后平台视图调用响应的Android部分就搞定了。
接下来,我们再来看看**iOS端的实现**。
与Android类似我们同样需要分别创建平台视图工厂和原生视图封装类并通过视图工厂的create方法将它们关联起来
```
//平台视图工厂
@interface SampleViewFactory : NSObject&lt;FlutterPlatformViewFactory&gt;
- (instancetype)initWithMessenger:(NSObject&lt;FlutterBinaryMessenger&gt;*)messager;
@end
@implementation SampleViewFactory{
NSObject&lt;FlutterBinaryMessenger&gt;*_messenger;
}
- (instancetype)initWithMessenger:(NSObject&lt;FlutterBinaryMessenger&gt; *)messager{
self = [super init];
if (self) {
_messenger = messager;
}
return self;
}
-(NSObject&lt;FlutterMessageCodec&gt; *)createArgsCodec{
return [FlutterStandardMessageCodec sharedInstance];
}
//创建原生视图封装实例
-(NSObject&lt;FlutterPlatformView&gt; *)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&lt;FlutterPlatformView&gt;
- (instancetype)initWithWithFrame:(CGRect)frame viewIdentifier:(int64_t)viewId arguments:(id _Nullable)args binaryMessenger:(NSObject&lt;FlutterBinaryMessenger&gt;*)messenger;
@end
@implementation SampleViewControl{
UIView * _templcateView;
}
//创建原生视图
- (instancetype)initWithWithFrame:(CGRect)frame viewIdentifier:(int64_t)viewId arguments:(id)args binaryMessenger:(NSObject&lt;FlutterBinaryMessenger&gt; *)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&lt;FlutterPluginRegistrar&gt;* registrar = [self registrarForPlugin:@&quot;samples.chenhang/native_views&quot;];//生成注册类
SampleViewFactory* viewFactory = [[SampleViewFactory alloc] initWithMessenger:registrar.messenger];//生成视图工厂
[registrar registerViewFactory:viewFactory withId:@&quot;sampleView&quot;];//注册视图工厂
...
}
```
需要注意的是在iOS平台上Flutter内嵌UIKitView目前还处于技术预览状态因此我们还需要在Info.plist文件中增加一项配置把内嵌原生视图的功能开关设置为true才能打开这个隐藏功能
```
&lt;dict&gt;
...
&lt;key&gt;io.flutter.embedded_views_preview&lt;/key&gt;
&lt;true/&gt;
....
&lt;/dict&gt;
```
经过上面的封装与绑定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&lt;void&gt; 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&lt;StatefulWidget&gt; createState() =&gt; _SampleViewState();
}
class _SampleViewState extends State&lt;SampleView&gt; {
//根据平台确定返回何种平台视图
@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, &quot;samples.chenhang/native_views_&quot; + id);
//设置方法通道回调
methodChannel.setMethodCallHandler(this);
}
//处理方法调用消息
@Override
public void onMethodCall(MethodCall methodCall, MethodChannel.Result result) {
//如果方法名完全匹配
if (methodCall.method.equals(&quot;changeBackgroundColor&quot;)) {
//修改视图背景,返回成功
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&lt;FlutterBinaryMessenger&gt; *)messenger{
if ([super init]) {
...
//使用view id完成方法通道的创建
_channel = [FlutterMethodChannel methodChannelWithName:[NSString stringWithFormat:@&quot;samples.chenhang/native_views_%lld&quot;, 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:@&quot;changeBackgroundColor&quot;]) {
//修改视图背景色,返回成功
_templcateView.backgroundColor = [UIColor blueColor];
result(@0);
} else {
//调用方发起了一个不支持的API调用
result(FlutterMethodNotImplemented);
}
}
...
@end
```
通过注册方法通道以及暴露的changeBackgroundColor接口Android端与iOS端修改平台视图背景颜色的功能都已经实现了。接下来我们就可以在Flutter应用运行期间修改原生视图展示样式了
```
class DefaultState extends State&lt;DefaultPage&gt; {
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: ()=&gt;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)中,你可以下载下来,反复运行几次,加深理解。
## 思考题
最后,我给你留下一道思考题吧。
请你在动态调整原生视图样式的代码基础上,增加颜色参数,以实现动态变更原生视图颜色的需求。
欢迎你在评论区给我留言分享你的观点,我会在下一篇文章中等待你!感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。

View 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() =&gt; 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(), &quot;defaultRoute&quot;); //传入路由标识符
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 = &lt;&lt;-DESC
TODO: Add long description of the pod here.
DESC
s.homepage = 'https://github.com/xx/FlutterEngine'
s.license = { :type =&gt; 'MIT', :file =&gt; 'LICENSE' }
s.author = { 'chenhang' =&gt; 'hangisnice@gmail.com' }
s.source = { :git =&gt; &quot;&quot;, :tag =&gt; &quot;#{s.version}&quot; }
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 =&gt; './'
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:@&quot;defaultRoute&quot;]; //路由标识符
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组件库的过程会有什么不同吗
欢迎你在评论区给我留言分享你的观点,我会在下一篇文章中等待你!感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。

View 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工程中通过FlutterViewiOS工程中通过FlutterViewController为Flutter搭建应用入口实现Flutter与原生的混合开发方式。
我在[第26篇](https://time.geekbang.org/column/article/127601)文章中提到FlutterView与FlutterViewController是初始化Flutter的地方也是应用的入口。可以看到以混合开发方式接入Flutter与开发一个纯Flutter应用在运行机制上并无任何区别只需要原生工程为它提供一个画板容器Android为FlutterViewiOS为FlutterViewControllerFlutter就可以自己管理页面导航栈从而实现多个复杂页面的渲染和切换。
关于纯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为FlutterViewControllerAndroid为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:@&quot;defaultPage&quot;];//设置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(), &quot;defaultRoute&quot;); //传入路由标识符
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应用的入口即在FlutterViewControlleriOS端和Activity中的FlutterViewAndroid端这两个容器内部初始化Flutter页面前。为了将Flutter相关的行为封装到容器内部我们需要分别继承FlutterViewController和Activity在其viewDidLoad和onCreate初始化容器时注册openNativePage和closeFlutterPage这两个方法。
iOS端的实现代码如下所示
```
@interface FlutterHomeViewController : FlutterViewController
@end
@implementation FlutterHomeViewController
- (void)viewDidLoad {
[super viewDidLoad];
//声明方法通道
FlutterMethodChannel* channel = [FlutterMethodChannel methodChannelWithName:@&quot;samples.chenhang/navigation&quot; binaryMessenger:self];
//注册方法回调
[channel setMethodCallHandler:^(FlutterMethodCall* call, FlutterResult result) {
//如果方法名为打开新页面
if([call.method isEqualToString:@&quot;openNativePage&quot;]) {
//初始化原生页面并打开
SomeOtherNativeViewController *vc = [[SomeOtherNativeViewController alloc] init];
[self.navigationController pushViewController:vc animated:YES];
result(@0);
}
//如果方法名为关闭Flutter页面
else if([call.method isEqualToString:@&quot;closeFlutterPage&quot;]) {
//关闭自身(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(), &quot;defaultPage&quot;); //传入路由标识符
//注册方法通道
new MethodChannel(flutterView, &quot;samples.chenhang/navigation&quot;).setMethodCallHandler(
new MethodCallHandler() {
@Override
public void onMethodCall(MethodCall call, Result result) {
//如果方法名为打开新页面
if(call.method.equals(&quot;openNativePage&quot;)) {
//新建Intent打开原生页面
Intent intent = new Intent(FlutterHomeActivity.this, SomeNativePageActivity.class);
startActivity(intent);
result.success(0);
}
//如果方法名为关闭Flutter页面
else if(call.method.equals(&quot;closeFlutterPage&quot;)) {
//销毁自身(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() =&gt; 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(&quot;Go PageB&quot;),
onPressed: ()=&gt;platform.invokeMethod('openNativePage')//打开原生页面
));
}
}
class DefaultPage extends StatelessWidget {
...
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(&quot;DefaultPage Page&quot;),
leading: IconButton(icon:Icon(Icons.arrow_back), onPressed:() =&gt; platform.invokeMethod('closeFlutterPage')//关闭Flutter页面
)),
body: RaisedButton(
child: Text(&quot;Go PageA&quot;),
onPressed: ()=&gt;Navigator.push(context, MaterialPageRoute(builder: (context) =&gt; PageA())),//打开Flutter页面 PageA
));
}
}
```
整个混合导航栈示例的代码流程,如下图所示。通过这张图,你就可以把这个示例的整个代码流程串起来了。
<img src="https://static001.geekbang.org/resource/image/93/53/932efcc59bcc0ee590e644e67288ba53.png" alt="">
在我们的混合应用中RootViewController与MainActivity分别是iOS和Android应用的原生页面入口可以初始化为Flutter容器的FlutterHomeViewControlleriOS端与FlutterHomeActivityAndroid端
在为其设置初始路由页面DefaultPage之后就可以以原生的方式跳转至Flutter页面。但是Flutter并未提供接口来支持从Flutter的DefaultPage页面返回到原生页面因此我们需要利用方法通道来注册关闭Flutter容器的方法即closeFlutterPage让Flutter容器接收到这个方法调用时关闭自身。
在Flutter容器内部我们可以使用Flutter内部的页面路由机制通过Navigator.push方法完成从DefaultPage到PageA的页面跳转而当我们想从Flutter的PageA页面跳转到原生页面时因为涉及到跨引擎的页面路由所以我们仍然需要利用方法通道来注册打开原生页面的方法即openNativePage让 Flutter容器接收到这个方法调用时在原生代码宿主完成原生页面SomeOtherNativeViewControlleriOS端与SomeNativePageActivityAndroid端的初始化并最终完成页面跳转。
## 总结
好了,今天的分享就到这里。我们一起总结下今天的主要内容吧。
对于原生Android、iOS工程混编Flutter开发由于应用中会同时存在Android、iOS和Flutter页面所以我们需要妥善处理跨渲染引擎的页面跳转解决原生页面如何切换Flutter页面以及Flutter页面如何切换到原生页面的问题。
在原生页面切换到Flutter页面时我们通常会将Flutter容器封装成一个独立的ViewControlleriOS端或ActivityAndroid端在为其设置好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页面之间的切换动画。请你思考下如何能够确保这两种页面过渡动画在应用整体的效果是一致的。
欢迎你在评论区给我留言分享你的观点,我会在下一篇文章中等待你!感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。

View 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 =&gt; _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&lt;CounterModel&gt;(context);
return Scaffold(
//展示资源中的数据
body: Text('Counter: ${_counter.counter}'),
//跳转到SecondPage
floatingActionButton: FloatingActionButton(
onPressed: () =&gt; Navigator.of(context).push(MaterialPageRoute(builder: (context) =&gt; SecondPage()))
));
}
}
//第二个页面,负责读写数据
class SecondPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
//取出资源
final _counter = Provider.of&lt;CounterModel&gt;(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(&quot;TestIcon build&quot;);
return Icon(Icons.add);//返回Icon实例
}
}
```
然后我们用TestIcon控件替换掉SecondPage中FloatingActionButton的Icon子Widget
```
class SecondPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
//取出共享的数据资源
final _counter = Provider.of&lt;CounterModel&gt;(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&lt;CounterModel&gt;(
//builder函数可以直接获取到counter参数
builder: (context, CounterModel counter, _) =&gt; Text('Value: ${counter.counter}')),
//使用Consumer来封装increment的读取
floatingActionButton: Consumer&lt;CounterModel&gt;(
//builder函数可以直接获取到increment参数
builder: (context, CounterModel counter, child) =&gt; FloatingActionButton(
onPressed: counter.increment,
child: child,
),
child: TestIcon(),
),
);
}
}
```
可以看到Consumer中的builder实际上就是真正刷新UI的函数它接收3个参数即context、model和child。其中context是Widget的build方法传进来的BuildContextmodel是我们需要的数据资源而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&lt;CounterModel&gt;(context);//获取计时器实例
final textSize = Provider.of&lt;double&gt;(context);//获取字体大小
```
而如果以Consumer的方式来获取资源的话我们只要使用Consumer2&lt;A,B&gt;对象(这个对象提供了读取两个数据资源的能力),就可以一次性地获取字体大小与计数器实例这两个数据资源:
```
//使用Consumer2获取两个数据资源
Consumer2&lt;CounterModel,double&gt;(
//builder函数以参数的形式提供了数据资源
builder: (context, CounterModel counter, double textSize, _) =&gt; 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个同样类型的对象共享你知道应该如何实现吗
欢迎你在评论区给我留言分享你的观点,我会在下一篇文章中等待你!感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。

View 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(&quot;setup&quot;, appID);
}
//注册消息通知
setOpenNotificationHandler(EventHandler onOpenNotification) {
_onOpenNotification = onOpenNotification;
}
//注册原生反向回调方法让原生代码宿主可以执行onOpenNotification方法
Future&lt;Null&gt; _handleMethod(MethodCall call) {
switch (call.method) {
case &quot;onOpenNotification&quot;:
return _onOpenNotification(call.arguments);
default:
throw new UnsupportedError(&quot;Unrecognized Event&quot;);
}
}
//获取地址id
Future&lt;String&gt; 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(), &quot;flutter_push_plugin&quot;);
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(&quot;setup&quot;)) {
//极光Android SDK的初始化工作需要在App工程中配置因此不需要代码实现
result.success(0);
}
else if (call.method.equals(&quot;getRegistrationID&quot;)) {
//获取极光推送地址标识符
result.success(JPushInterface.getRegistrationID(registrar.context()));
} else {
result.notImplemented();
}
}
public void callbackNotificationOpened(NotificationMessage message) {
//将推送消息回调给Dart层
channel.invokeMethod(&quot;onOpenNotification&quot;,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() + &quot;.MainActivity&quot;;
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(&quot;tag&quot;,&quot;找不到MainActivity&quot;);
}
new Timer().schedule(new TimerTask() {
@Override
public void run() {
FlutterPushPlugin.instance.callbackNotificationOpened(message);
}
},1000); // 延迟1秒通知Dart
}
}
```
最后我们还需要在插件工程的AndroidManifest.xml中分别声明receiver JPushXMessageReceiver和service JPushXCustomService完成对系统的注册
```
...
&lt;application&gt;
&lt;!--注册推送消息接收类 --&gt;
&lt;receiver android:name=&quot;.JPushXMessageReceiver&quot;&gt;
&lt;intent-filter&gt;
&lt;action android:name=&quot;cn.jpush.android.intent.RECEIVE_MESSAGE&quot; /&gt;
&lt;category android:name=&quot;${applicationId}&quot; /&gt;
&lt;/intent-filter&gt;
&lt;/receiver&gt;
&lt;!--注册长连通道Service --&gt;
&lt;service android:name=&quot;.JPushXCustomService&quot;
android:enabled=&quot;true&quot;
android:exported=&quot;false&quot;
android:process=&quot;:pushcore&quot;&gt;
&lt;intent-filter&gt;
&lt;action android:name=&quot;cn.jiguang.user.service.action&quot; /&gt;
&lt;/intent-filter&gt;
&lt;/service&gt;
&lt;/application&gt;
...
```
接收消息和回调消息的功能完成后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&lt;FlutterPluginRegistrar&gt;*)registrar {
//注册方法通道
FlutterMethodChannel* channel = [FlutterMethodChannel methodChannelWithName:@&quot;flutter_push_plugin&quot; 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([@&quot;setup&quot; isEqualToString:call.method]) {
//极光SDK初始化方法
[JPUSHService setupWithOption:self.launchOptions appKey:call.arguments channel:@&quot;App Store&quot; apsForProduction:YES advertisingIdentifier:nil];
} else if ([@&quot;getRegistrationID&quot; 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[@&quot;aps&quot;][@&quot;alert&quot;];
if ([content isKindOfClass:[NSDictionary class]]) {
content = userInfo[@&quot;aps&quot;][@&quot;alert&quot;][@&quot;body&quot;];
}
//延迟1秒通知Flutter确保Flutter应用已完成初始化
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(1 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
[self.channel invokeMethod:@&quot;onOpenNotification&quot; 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 : &quot;f861910af12a509b34e266c2&quot;, //JPush 上注册的包名对应的Appkey
JPUSH_CHANNEL : &quot;developer-default&quot;, //填写默认值即可
]
}
```
至此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-&gt;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(&quot;f861910af12a509b34e266c2&quot;);
runApp(MyApp());
}
class MyApp extends StatefulWidget {
@override
_MyAppState createState() =&gt; _MyAppState();
}
class _MyAppState extends State&lt;MyApp&gt; {
//极光推送地址regID
String _regID = 'Unknown';
//接收到的推送消息
String _notification = &quot;&quot;;
@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: &lt;Widget&gt;[
//展示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代码能够更快地收到推送消息你会如何优化呢
欢迎你在评论区给我留言分享你的观点,我会在下一篇文章中等待你!感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。

View 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属性声明的所以我们需要将其修改为字符串资源中的一个引用让其能够根据语言地区自动选择合适的文案
```
&lt;manifest ... &gt;
...
&lt;!-- 设置应用名称 --&gt;
&lt;application
...
android:label=&quot;@string/title&quot;
...
&gt;
&lt;/application&gt;
&lt;/manifest&gt;
```
然后我们还需要在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内容如下所示
```
&lt;!--英文(默认)字符串资源--&gt;
&lt;?xml version=&quot;1.0&quot; encoding=&quot;utf-8&quot;?&gt;
&lt;resources&gt;
&lt;string name=&quot;title&quot;&gt;Computer&lt;/string&gt;
&lt;/resources&gt;
&lt;!--中文字符串资源--&gt;
&lt;?xml version=&quot;1.0&quot; encoding=&quot;utf-8&quot;?&gt;
&lt;resources&gt;
&lt;string name=&quot;title&quot;&gt;计数器&lt;/string&gt;
&lt;/resources&gt;
```
完成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的英文版和中文版内容如下所示
```
//英文版
&quot;CFBundleName&quot; = &quot;Computer&quot;;
//中文版
&quot;CFBundleName&quot; = &quot;计数器&quot;;
```
至此我们也完成了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中如何实现图片类资源的国际化呢
欢迎你在评论区给我留言分享你的观点,我会在下一篇文章中等待你!感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。

View 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() =&gt; _ListWidgetState();
}
class _ListWidgetState extends State&lt;ListWidget&gt; {
@override
Widget build(BuildContext context) {
//创建一个20项元素的列表
return ListView.builder(
itemCount: 20,
itemBuilder: (context, position) {
return ListTile(
title: Text(position.toString()),//标题为index
onTap:()=&gt;widget.onItemSelected(position),//点击后回调函数
);
},
);
}
}
//详情Widget
class DetailWidget extends StatefulWidget {
final int data; //新闻列表被点击元素索引
DetailWidget(this.data);
@override
_DetailWidgetState createState() =&gt; _DetailWidgetState();
}
class _DetailWidgetState extends State&lt;DetailWidget&gt; {
@override
Widget build(BuildContext context) {
return Container(
color: Colors.red,//容器背景色
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: &lt;Widget&gt;[
Text(widget.data.toString()),//居中展示列表被点击元素索引
],
),
),
);
}
}
```
然后我们只需要检查设备屏幕是否有足够的宽度来同时展示列表与详情部分。为了获取屏幕宽度我们可以使用MediaQueryData提供的size方法。
在这里我们将平板电脑的判断条件设置为宽度大于480。这样屏幕中就有足够的空间可以切换到多窗格的复合布局了
```
if(MediaQuery.of(context).size.width &gt; 480) {
//tablet
} else {
//phone
}
```
最后如果宽度够大我们就会使用Row控件将列表与详情包装在同一个页面中用户可以点击左侧的列表刷新右侧的详情如果宽度比较小那我们就只展示列表用户可以点击列表导航到新的页面展示详情
```
class _MasterDetailPageState extends State&lt;MasterDetailPage&gt; {
var selectedValue = 0;
@override
Widget build(BuildContext context) {
return Scaffold(
body: OrientationBuilder(builder: (context, orientation) {
//平板或横屏手机页面内嵌列表ListWidget与详情DetailWidget
if (MediaQuery.of(context).size.width &gt; 480) {
return Row(children: &lt;Widget&gt;[
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同时支持竖屏和横屏你会如何实现呢
欢迎你在评论区给我留言分享你的观点,我会在下一篇文章中等待你!感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。

View 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) =&gt; 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)中,你可以下载下来,反复运行几次,加深理解与记忆。
## 思考题
最后,我给你留一道思考题吧。
在保持生产环境代码不变的情况下,如果想在开发环境中支持不同配置的切换,我们应该如何实现?
欢迎你在评论区给我留言分享你的观点,我会在下一篇文章中等待你!感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。

View 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编译模式背后的技术吧。
- JITJust In Time指的是即时编译或运行时编译在Debug模式中使用可以动态下发和执行代码启动速度快但执行性能受运行时编译影响
<img src="https://static001.geekbang.org/resource/image/ab/a3/ab692d1e072df378bc78fef6245205a3.png" alt="">
- AOTAhead 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: () =&gt; print('T'));
}
}
//改动后
class MyWidget extends StatefulWidget {
@override
State&lt;MyWidget&gt; createState() =&gt; MyWidgetState();
}
class MyWidgetState extends State&lt;MyWidget&gt; { /*...*/ }
```
当遇到这种情况时,我们需要重启应用,才能看到更新后的程序。
## 全局变量和静态属性的更改
在Flutter中全局变量和静态属性都被视为状态在第一次运行应用程序时会将它们的值设为初始化语句的执行结果因此在热重载期间不会重新初始化。
比如下面的代码中我们修改了一个静态Text数组的初始化元素。虽然热重载并不会报错但由于静态变量并不会在热重载之后初始化因此这个改变并不会产生效果
```
//改动前
final sampleText = [
Text(&quot;T1&quot;),
Text(&quot;T2&quot;),
Text(&quot;T3&quot;),
Text(&quot;T4&quot;),
];
//改动后
final sampleText = [
Text(&quot;T1&quot;),
Text(&quot;T2&quot;),
Text(&quot;T3&quot;),
Text(&quot;T10&quot;), //改动点
];
```
如果我们需要更改全局变量和静态属性的初始化语句,重启应用才能查看更改效果。
## 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() =&gt; runApp(new MyAPP());
//更新后
void main() =&gt; runApp(const Center(child: Text('Hello, 2019', textDirection: TextDirection.ltr)));
```
由于main函数并不会在热重载后重新执行因此以上改动是无法通过热重载查看更新的。
## initState方法里的更改
在热重载时Flutter会保存Widget的状态然后重建Widget。而initState方法是Widget状态的初始化方法这个方法里的更改会与状态保存发生冲突因此热重载后不会产生效果。
在下面的例子中我们将计数器的初始值由10改为100
```
//更改前
class _MyHomePageState extends State&lt;MyHomePage&gt; {
int _counter;
@override
void initState() {
_counter = 10;
super.initState();
}
...
}
//更改后
class _MyHomePageState extends State&lt;MyHomePage&gt; {
int _counter;
@override
void initState() {
_counter = 100;
super.initState();
}
...
}
```
由于这样的改动发生在initState方法中因此无法通过热重载查看更新我们需要重启应用才能看到更改效果。
## 枚举和泛型类型更改
在Flutter中枚举和泛型也被视为状态因此对它们的修改也不支持热重载。比如在下面的代码中我们将一个枚举类型改为普通类并为其增加了一个泛型参数
```
//更改前
enum Color {
red,
green,
blue
}
class C&lt;U&gt; {
U u;
}
//更改后
class Color {
Color(this.r, this.g, this.b);
final int r;
final int g;
final int b;
}
class C&lt;U, V&gt; {
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有何区别
欢迎你在评论区给我留言分享你的观点,我会在下一篇文章中等待你!感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。

View 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}) =&gt; 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但可以循环写。如果日志文件已满则循环至下一个日志文件清空后重新写入。
欢迎你在评论区给我留言分享你的观点,我会在下一篇文章中等待你!感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。

View File

@@ -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函数我们可以实现生产环境与开发环境不同的日志输出行为从而保证在开发期打印的调试信息不会被发布至线上借助于IDEAndroid 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-&gt;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线程中执行每一帧耗费的时间都应该小于16ms1/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)=&gt;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)=&gt;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&lt;10000;i++) {
str = generateMd5(str);
}
return ListTile(title: Text(&quot;Index : $index&quot;), 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-&gt;Element-&gt;RenderObject三层结构屏蔽了无谓的界面刷新能够保证绝大多数情况下我们构建的应用都是高性能的所以在使用分析工具检测出性能问题之后通常我们并不需要做太多的细节优化工作只需要在改造过程中避开一些常见的坑就可以获得优异的性能。比如
- 控制build方法耗时将Widget拆小避免直接返回一个巨大的Widget这样Widget会享有更细粒度的重建和复用
- 尽量不要为Widget设置半透明效果而是考虑用图片的形式代替这样被遮挡的Widget部分区域就不需要绘制了
- 对列表采用懒加载而不是直接一次性创建所有的子Widget这样视图的初始化时间就减少了。
## 思考题
最后,我给你留下一道思考题吧。
请你改造ListView计算MD5的示例在保证原有功能的情况下使用并发Isolate或compute完成MD5的计算。提示计算过程可以使用CircularProgressIndicator来展示加载动画。
欢迎你在评论区给我留言分享你的观点,我会在下一篇文章中等待你!感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。

View 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() =&gt; count++;
void decrease() =&gt; 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&lt;String, dynamic&gt; json) {
return Todo(
title: json['title'],
);
}
}
Future&lt;Todo&gt; 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 =&gt; http.Response('{&quot;title&quot;: &quot;Test&quot;}', 200));
//验证请求结果是否为Todo实例
expect(await fetchTodo(client), isInstanceOf&lt;Todo&gt;());
});
test('throws an exception if error', () {
final client = MockClient();
//使用Mockito注入请求失败的Error
when(client.get('https://xxx.com/todos/1'))
.thenAnswer((_) async =&gt; 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&lt;bool&gt;updateSP(SharedPreferences prefs, int counter) async {
bool result = await prefs.setInt('counter', counter);
return result;
}
Future&lt;int&gt;increaseSPCounter(SharedPreferences prefs) async {
int counter = (prefs.getInt('counter') ?? 0) + 1;
await updateSP(prefs, counter);
return counter;
}
```
欢迎你在评论区给我留言分享你的观点,我会在下一篇文章中等待你!感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。