![]() |
强健的豆浆 · 《神偷奶爸2》-搜狐娱乐· 昨天 · |
![]() |
愤怒的伤疤 · 【Blender毛发教程】使用Blender ...· 昨天 · |
![]() |
唠叨的领结 · 风靡全球的经典动画片《巴巴爸爸》1-3季,英 ...· 昨天 · |
![]() |
唠叨的领结 · 【巴巴爸爸全英文150集】第一季第一集__b ...· 昨天 · |
![]() |
乖乖的生姜 · 人外日常动画《半兽人的烦恼》7月份开播,主视 ...· 昨天 · |
class _AnimateAppState extends State<AnimateApp> with SingleTickerProviderStateMixin {
AnimationController controller;
Animation<double> animation;
@override
void initState() {
super.initState();
//创建动画周期为1秒的AnimationController对象
controller = AnimationController(
vsync: this, duration: const Duration(milliseconds: 1000));
// 创建从50到200线性变化的Animation对象
animation = Tween(begin: 50.0, end: 200.0).animate(controller)
..addListener(() {
setState(() {}); //刷新界面
controller.forward(); //启动动画
需要注意的是,在创建 AnimationController 的时候,设置了一个 vsync 属性。这个属性是用来防止出现不可见动画的。vsync 对象会把动画绑定到一个 Widget,当 Widget 不显示时,动画将会暂停,当 Widget 再次显示时,动画会重新恢复执行,这样就可以避免动画的组件不在当前屏幕时白白消耗资源。
Animation 只是用于提供动画数据,并不负责动画渲染,所以我们还需要在 Widget 的 build 方法中,把当前动画状态的值读出来,用于设置 Flutter Logo 容器的宽和高,才能最终实现动画效果。
@override
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();
在上面用到的 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)
这些动画只能执行一次。如果想让它像心跳一样执行,有两个办法。
在启动动画时,使用 repeat(reverse: true),让动画来回重复执行。
监听动画状态。在动画结束时,反向执行;在动画反向执行完毕时,重新启动执行。
//以下两段语句等价
//第一段
controller.repeat(reverse: true);//让动画重复执行
//第二段
animation.addStatusListener((status) {
if (status == AnimationStatus.completed) {
controller.reverse();//动画结束时反向执行
} else if (status == AnimationStatus.dismissed) {
controller.forward();//动画反向执行完毕时,重新执行
controller.forward();//启动动画
AnimationWidget与AnimationBuilder
在为 Widget 添加动画效果的过程中我们不难发现,Animation 仅提供动画的数据,因此我们还需要监听动画执行进度,并在回调中使用 setState 强制刷新界面才能看到动画效果。考虑到这些步骤都是固定的,Flutter 提供了两个类来帮我们简化这一步骤,即 AnimatedWidget 与 AnimatedBuilder。
在构建 Widget 时,AnimatedWidget 会将 Animation 的状态与其子 Widget 的视觉样式绑定。要使用 AnimatedWidget,我们需要一个继承自它的新类,并接收 Animation 对象作为其初始化参数。然后,在 build 方法中,读取出 Animation 对象的当前值,用作初始化 Widget 的样式。
class AnimatedLogo extends AnimatedWidget {
//AnimatedWidget需要在初始化时传入animation对象
AnimatedLogo({Key key, Animation<double> animation})
: super(key: key, listenable: animation);
Widget build(BuildContext context) {
//取出动画对象
final Animation<double> animation = listenable;
return Center(
child: Container(
height: animation.value,//根据动画对象的当前状态更新宽高
width: animation.value,
child: FlutterLogo(),
在使用时,我们只需把 Animation 对象传入 AnimatedLogo 即可,再也不用监听动画的执行进度刷新 UI 了。
MaterialApp(
home: Scaffold(
body: AnimatedLogo(animation: animation)//初始化AnimatedWidget时传入animation对象
在 AnimatedLogo 的 build 方法中,我们使用 Animation 的 value 作为 logo 的宽和高。这样做对于简单组件的动画没有任何问题,但如果动画的组件比较复杂,一个更好的解决方案是,将动画和渲染职责分离:logo 作为外部参数传入,只做显示;而尺寸的变化动画则由另一个类去管理。
这个分离工作,我们可以借助 AnimatedBuilder 来完成。
与 AnimatedWidget 类似,AnimatedBuilder 也会自动监听 Animation 对象的变化,并根据需要将该控件树标记为 dirty 以自动刷新 UI。事实上,翻看源码就会发现 AnimatedBuilder 其实也是继承自 AnimatedWidget。
MaterialApp(
home: Scaffold(
body: Center(
child: AnimatedBuilder(
animation: animation,//传入动画对象
child:FlutterLogo(),
//动画构建回调
builder: (context, child) => Container(
width: animation.value,//使用动画的当前状态更新UI
height: animation.value,
child: child, //child参数即FlutterLogo()
hero动画
如何实现在两个页面之间切换的过渡动画呢?比如在社交类 App,在 Feed 流中点击小图进入查看大图页面的场景中,我们希望能够实现小图到大图页面逐步放大的动画切换效果,而当用户关闭大图时,也实现原路返回的动画。
这样的跨页面共享的控件动画效果有一个专门的名词,即“共享元素变换”(Shared Element Transition)。
对于 Android 开发者来说,这个概念并不陌生。Android 原生提供了对这种动画效果的支持,通过几行代码,就可以实现在两个 Activity 共享的组件之间做出流畅的转场动画。
又比如,Keynote 提供了的“神奇移动”(Magic Move)功能,可以实现两个 Keynote 页面之间的流畅过渡。
Flutter 也有类似的概念,即 Hero 控件。通过 Hero,我们可以在两个页面的共享元素之间,做出流畅的页面切换效果。
定义了两个页面,其中 page1 有一个位于底部的小 Flutter Logo,page2 有一个位于中部的大 Flutter Logo。在点击了 page1 的小 logo 后,会使用 hero 效果过渡到 page2。
为了实现共享元素变换,我们需要将这两个组件分别用 Hero 包裹,并同时为它们设置相同的 tag “hero”。然后,为 page1 添加点击手势响应,在用户点击 logo 时,跳转到 page2。
class Page1 extends StatelessWidget {
Widget build(BuildContext context) {
return Scaffold(
body: GestureDetector(//手势监听点击
child: Hero(
tag: 'hero',//设置共享tag
child: Container(
width: 100, height: 100,
child: FlutterLogo())),
onTap: () {
Navigator.of(context).push(MaterialPageRoute(builder: (_)=>Page2()));//点击后打开第二个页面
class Page2 extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
body: Hero(
tag: 'hero',//设置共享tag
child: Container(
width: 300, height: 300,
child: FlutterLogo()
对于实际应用而言,由于动画过程涉及到页面的频繁刷新,因此我强烈建议你尽量使用 AnimatedWidget 或 AnimatedBuilder 来缩小受动画影响的组件范围,只重绘需要做动画的组件即可,要避免使用进度监听器直接刷新整个页面,让不需要做动画的组件也跟着一起销毁重建。
单线程模型怎么保证UI运行流畅
Event Loop机制
Dart 是单线程的。作为支持 Flutter 这个 UI 框架的关键技术,Dart 当然也支持异步。需要注意的是,单线程和异步并不冲突。
那为什么单线程也可以异步?
这里有一个大前提,那就是我们的 App 绝大多数时间都在等待。比如,等用户点击、等网络请求返回、等文件 IO 结果,等等。而这些等待行为并不是阻塞的。比如说,网络请求,Socket 本身提供了 select 模型可以异步查询;而文件 IO,操作系统也提供了基于事件的回调机制。
所以,基于这些特点,单线程模型可以在等待的过程中做别的事情,等真正需要响应结果了,再去做对应的处理。因为等待过程并不是阻塞的,所以给我们的感觉就像是同时在做多件事情一样。但其实始终只有一个线程在处理你的事情。
等待这个行为是通过 Event Loop 驱动的。事件队列 Event Queue 会把其他平行世界(比如 Socket)完成的,需要主线程响应的事件放入其中。像其他语言一样,Dart 也有一个巨大的事件循环,在不断的轮询事件队列,取出事件(比如,键盘事件、I\O 事件、网络事件等),在主线程同步执行其回调函数,如下图所示。
事实上,上图的Event Loop 示意图只是一个简化版。在 Dart 中,实际上有两个队列,一个事件队列(Event Queue),另一个则是微任务队列(Microtask Queue)。在每一次事件循环中,Dart 总是先去第一个微任务队列中查询是否有可执行的任务,如果没有,才会处理后续的事件队列的流程。
所以,Event Loop 完整版的流程图,应该如下所示。
分别看一下这两个队列的特点和使用场景。
微任务队列。微任务顾名思义,表示一个短时间内就会完成的异步任务。从上面的流程图可以看到,微任务队列在事件循环中的优先级是最高的,只要队列中还有任务,就可以一直霸占着事件循环。
微任务是由 scheduleMicroTask 建立的。如下所示,这段代码会在下一个事件循环中输出一段字符串。
scheduleMicrotask(() => print('This is a microtask'));
不过,一般的异步任务通常也很少必须要在事件队列前完成,所以也不需要太高的优先级,因此我们通常很少会直接用到微任务队列,就连 Flutter 内部,也只有 7 处用到了而已(比如,手势识别、文本输入、滚动视图、保存页面效果等需要高优执行任务的场景)。
异步任务我们用的最多的还是优先级更低的 Event Queue。比如,I/O、绘制、定时器这些异步事件,都是通过事件队列驱动主线程执行的。
Dart 为 Event Queue 的任务建立提供了一层封装,叫作 Future。从名字上也很容易理解,它表示一个在未来时间才会完成的任务。
把一个函数体放入 Future,就完成了从同步任务到异步任务的包装。Future 还提供了链式调用的能力,可以在异步任务执行完毕后依次执行链路上的其他函数体。
Future(() => print('Running in Future 1'));//下一个事件循环输出字符串
Future(() => print(‘Running in Future 2'))
.then((_) => print('and then 1'))
.then((_) => print('and then 2’));//上一个事件循环结束后,连续输出三段字符串
当然,这两个 Future 异步任务的执行优先级比微任务的优先级要低。
正常情况下,一个 Future 异步任务的执行是相对简单的:在我们声明一个 Future 时,Dart 会将异步任务的函数执行体放入事件队列,然后立即返回,后续的代码继续同步执行。而当同步执行的代码执行完毕后,事件队列会按照加入事件队列的顺序(即声明顺序),依次取出事件,最后同步执行 Future 的函数体及后续的 then。
这意味着,then 与 Future 函数体共用一个事件循环。而如果 Future 有多个 then,它们也会按照链式调用的先后顺序同步执行,同样也会共用一个事件循环。
如果 Future 执行体已经执行完毕了,但你又拿着这个 Future 的引用,往里面加了一个 then 方法体,这时 Dart 会如何处理呢?面对这种情况,Dart 会将后续加入的 then 方法体放入微任务队列,尽快执行。
//f1比f2先执行
Future(() => print('f1'));
Future(() => print('f2'));
//f3执行后会立刻同步执行then 3
Future(() => print('f3')).then((_) => print('then 3'));
//then 4会加入微任务队列,尽快执行
Future(() => null).then((_) => print('then 4'));
Future(() => print('f1'));//声明一个匿名Future
Future fx = Future(() => null);//声明Future fx,其执行体为null
//声明一个匿名Future,并注册了两个then。在第一个then回调里启动了一个微任务
Future(() => print('f2')).then((_) {
print('f3');
scheduleMicrotask(() => print('f4'));
}).then((_) => print('f5'));
//声明了一个匿名Future,并注册了两个then。第一个then是一个Future
Future(() => print('f6'))
.then((_) => Future(() => print('f7')))
.then((_) => print('f8'));
//声明了一个匿名Future
Future(() => print('f9'));
//往执行体为null的fx注册了了一个then
fx.then((_) => print('f10'));
//启动一个微任务
scheduleMicrotask(() => print('f11'));
print('f12');
------------------------------------------------------------
打印结果:
f12 f11 f1 f10 f2 f3 f5 f4 f6 f9 f7 f8
依次分析一下它们的执行顺序。
因为其他语句都是异步任务,所以先打印 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。
记住一点:then 会在 Future 函数体执行完毕后立刻执行,无论是共用同一个事件循环还是进入下一个微任务。
对于一个异步函数来说,其返回时内部执行动作并未结束,因此需要返回一个 Future 对象,供调用者使用。调用者根据 Future 对象,来决定:是在这个 Future 对象上注册一个 then,等 Future 的执行体结束了以后再进行异步处理;还是一直同步等待 Future 执行体结束。
对于异步函数返回的 Future 对象,如果调用者决定同步等待,则需要在调用处使用 await 关键字,并且在调用处的函数体使用 async 关键字。
//声明了一个延迟3秒返回Hello的Future,并注册了一个then返回拼接后的Hello 2019
Future<String> fetchContent() =>
Future<String>.delayed(Duration(seconds:3), () => "Hello")
.then((x) => "$x 2019");
main() async{
print(await fetchContent());//等待Hello 2019的返回
在使用 await 进行等待的时候,在等待语句的调用上下文函数 main 加上了 async 关键字。为什么要加这个关键字呢?
因为 Dart 中的 await 并不是阻塞等待,而是异步等待。Dart 会将调用体的函数也视作异步函数,将等待语句的上下文放入 Event Queue 中,一旦有了结果,Event Loop 就会把它从 Event Queue 中取出,等待代码继续执行。
Future(() => print('f1'))
.then((_) async => await Future(() => print('f2')))
.then((_) => print('f3'));
Future(() => print('f4'));
------------------------------------------------------
打印结果:f1 f4 f2 f3
分析一下代码的执行顺序
按照任务的声明顺序,f1 和 f4 被先后加入事件队列。
f1 被取出并打印;然后到了 then。then 的执行体是个 future f2,于是放入 Event Queue。然后把 await 也放到 Event Queue 里。
Event Queue 里面还有一个 f4,我们的 await 并不能阻塞 f4 的执行。因此,Event Loop 先取出 f4,打印 f4;然后才能取出并打印 f2,最后把等待的 await 取出,开始执行后面的 f3。
由于 await 是采用事件队列的机制实现等待行为的,所以比它先在事件队列中的 f4 并不会被它阻塞。
//声明了一个延迟2秒返回Hello的Future,并注册了一个then返回拼接后的Hello 2019
Future<String> fetchContent() =>
Future<String>.delayed(Duration(seconds:2), () => "Hello")
.then((x) => "$x 2019");
//异步函数会同步等待Hello 2019的返回,并打印
func() async => print(await fetchContent());
main() {
print("func before");
func();
//await fun();
print("func after");
-------------------------------------------------------------------------
打印结果:func before func after Hello 2019
分析一下代码的执行顺序
首先,第一句代码是同步的,因此先打印“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。
Isolate
尽管 Dart 是基于单线程模型的,但为了进一步利用多核 CPU,将 CPU 密集型运算进行隔离,Dart 也提供了多线程机制,即 Isolate。在 Isolate 中,资源隔离做得非常好,每个 Isolate 都有自己的 Event Loop 与 Queue,Isolate 之间不共享任何资源,只能依靠消息机制通信,因此也就没有资源抢占问题。
Isolate 的创建非常简单,我们只要给定一个函数入口,创建时再传入一个参数,就可以启动 Isolate 了。
doSth(msg) => print(msg);
main() {
Isolate.spawn(doSth, "Hi");
但更多情况下,我们的需求并不会这么简单,不仅希望能并发,还希望 Isolate 在并发执行的时候告知主 Isolate 当前的执行结果。
对于执行结果的告知,Isolate 通过发送管道(SendPort)实现消息通信机制。我们可以在启动并发 Isolate 时将主 Isolate 的发送管道作为参数传给它,这样并发 Isolate 就可以在任务执行完毕后利用这个发送管道给我们发消息了。
//在主 Isolate 里,我们创建了一个并发 Isolate,在函数入口传入了主 Isolate 的发送管道,然后等待并发 Isolate 的回传消息。在并发 Isolate 中,我们用这个管道给主 Isolate 发了一个 Hello 字符串。
Isolate isolate;
start() async {
ReceivePort receivePort= ReceivePort();//创建管道
//创建并发Isolate,并传入发送管道
isolate = await Isolate.spawn(getMsg, receivePort.sendPort);
//监听管道消息
receivePort.listen((data) {
print('Data:$data');
receivePort.close();//关闭管道
isolate?.kill(priority: Isolate.immediate);//杀死并发Isolate
isolate = null;
//并发Isolate往管道发送一个字符串
getMsg(sendPort) => sendPort.send("Hello");
需要注意的是,在 Isolate 中,发送管道是单向的:我们启动了一个 Isolate 执行某项任务,Isolate 执行完毕后,发送消息告知我们。如果 Isolate 执行任务时,需要依赖主 Isolate 给它发送参数,执行完毕后再发送执行结果给主 Isolate,这样双向通信的场景我们如何实现呢?答案也很简单,让并发 Isolate 也回传一个发送管道即可。
以一个并发计算阶乘的例子来说明如何实现双向通信。
创建了一个异步函数计算阶乘。在这个异步函数内,创建了一个并发 Isolate,传入主 Isolate 的发送管道;并发 Isolate 也回传一个发送管道;主 Isolate 收到回传管道后,发送参数 N 给并发 Isolate,然后立即返回一个 Future;并发 Isolate 用参数 N,调用同步计算阶乘的函数,返回执行结果;最后,主 Isolate 打印了返回结果:
//并发计算阶乘
Future<dynamic> asyncFactoriali(n) async{
final response = ReceivePort();//创建管道
//创建并发Isolate,并传入管道
await Isolate.spawn(_isolate,response.sendPort);
//等待Isolate回传管道
final sendPort = await response.first as SendPort;
//创建了另一个管道answer
final answer = ReceivePort();
//往Isolate回传的管道中发送参数,同时传入answer管道
sendPort.send([n,answer.sendPort]);
return answer.first;//等待Isolate通过answer管道回传执行结果
//Isolate函数体,参数是主Isolate传入的管道
_isolate(initialReplyTo) async {
final port = ReceivePort();//创建管道
initialReplyTo.send(port.sendPort);//往主Isolate回传管道
final message = await port.first as List;//等待主Isolate发送消息(参数和回传结果的管道)
final data = message[0] as int;//参数
final send = message[1] as SendPort;//回传结果的管道
send.send(syncFactorial(data));//调用同步计算阶乘的函数回传结果
//同步计算阶乘
int syncFactorial(n) => n < 2 ? n : n * syncFactorial(n-1);
main() async => print(await asyncFactoriali(4));//等待并发计算阶乘结果
在 Flutter 中,像这样执行并发计算任务我们可以采用更简单的方式。Flutter 提供了支持并发计算的 compute 函数,其内部对 Isolate 的创建和双向通信进行了封装抽象,屏蔽了很多底层细节,我们在调用时只需要传入函数入口和函数参数,就能够实现并发计算和消息通知。
用 compute 函数改造一下并发计算阶乘的代码。
//同步计算阶乘
int syncFactorial(n) => n < 2 ? n : n * syncFactorial(n-1);
//使用compute函数封装Isolate的创建和结果的返回
main() async => print(await compute(syncFactorial, 4));
HTTP网络编程与JSON解析
异步与并发的一个典型应用场景,就是网络编程。一个好的移动应用,不仅需要有良好的界面和易用的交互体验,也需要具备和外界进行信息交互的能力。而通过网络,信息隔离的客户端与服务端间可以建立一个双向的通信通道,从而实现资源访问、接口数据请求和提交、上传下载文件等操作。
为了便于我们快速实现基于网络通道的信息交换实时更新 App 数据,Flutter 也提供了一系列的网络编程类库和工具。
HTTP网络编程
在通过网络与服务端数据交互时,不可避免地需要用到三个概念:定位、传输与应用。
定位,定义了如何准确地找到网络上的一台或者多台主机(即 IP 地址);传输,则主要负责在找到主机后如何高效且可靠地进行数据通信(即 TCP、UDP 协议);而应用,则负责识别双方通信的内容(即 HTTP 协议)。
在进行数据通信时,可以只使用传输层协议。但传输层传递的数据是二进制流,如果没有应用层,我们无法识别数据内容。如果想要使传输的数据有意义,则必须要用到应用层协议。移动应用通常使用 HTTP 协议作应用层协议,来封装 HTTP 信息。
在编程框架中,一次 HTTP 网络调用通常可以拆解为以下步骤:
创建网络调用实例 client,设置通用请求行为(如超时时间);
构造 URI,设置请求 header、body;
发起请求, 等待响应;
解码响应的内容。
当然,Flutter 也不例外。在 Flutter 中,Http 网络编程的实现方式主要分为三种:dart:io 里的 HttpClient 实现、Dart 原生 http 请求库实现、第三方库 dio 实现。
HttpClient
HttpClient 是 dart:io 库中提供的网络请求类,实现了基本的网络编程功能。
get() async {
//创建网络调用示例,设置通用请求行为(超时时间)
var httpClient = HttpClient();
httpClient.idleTimeout = Duration(seconds: 5);
//构造URI,设置user-agent为"Custom-UA"
var uri = Uri.parse("https://flutter.dev");
var request = await httpClient.getUrl(uri);
request.headers.add("user-agent", "Custom-UA");
//发起请求,等待响应
var response = await request.close();
//收到响应,打印结果
if (response.statusCode == HttpStatus.ok) {
print(await response.transform(utf8.decoder).join());
} else {
print('Error: \nHttp status ${response.statusCode}');
需要注意的是,由于网络请求是异步行为,因此在 Flutter 中,所有网络编程框架都是以 Future 作为异步请求的包装,所以我们需要使用 await 与 async 进行非阻塞的等待。当然,你也可以注册 then,以回调的方式进行相应的事件处理。
HttpClient 使用方式虽然简单,但其接口却暴露了不少内部实现细节。比如,异步调用拆分得过细,链接需要调用方主动关闭,请求结果是字符串但却需要手动解码等。
http 是 Dart 官方提供的另一个网络请求类,相比于 HttpClient,易用性提升了不少。
首先,我们需要将 http 加入到 pubspec 中的依赖里。
dependencies:
http: '>=0.11.3+12'
httpGet() async {
//创建网络调用示例
var client = http.Client();
//构造URI
var uri = Uri.parse("https://flutter.dev");
//设置user-agent为"Custom-UA",随后立即发出请求
http.Response response = await client.get(uri, headers : {"user-agent" : "Custom-UA"});
//打印请求结果
if(response.statusCode == HttpStatus.ok) {
print(response.body);
} else {
print("Error: ${response.statusCode}");
HttpClient 和 http 使用方式虽然简单,但其暴露的定制化能力都相对较弱,很多常用的功能都不支持(或者实现异常繁琐),比如取消请求、定制拦截器、Cookie 管理等。因此对于复杂的网络请求行为,我推荐使用目前在 Dart 社区人气较高的第三方 dio 来发起网络请求。
首先需要把 dio 加到 pubspec 中的依赖里。
GitHub地址:https://github.com/flutterchina/dio/blob/master/README-ZH.md
dependencies:
dio: '>2.1.3'
void getRequest() async {
//创建网络调用示例
Dio dio = new Dio();
//设置URI及请求user-agent后发起请求
var response = await dio.get("https://flutter.dev", options:Options(headers: {"user-agent" : "Custom-UA"}));
//打印请求结果
if(response.statusCode == HttpStatus.ok) {
print(response.data.toString());
} else {
print("Error: ${response.statusCode}");
需要注意的是,创建 URI、设置 Header 及发出请求的行为,都是通过 dio.get 方法实现的。这个方法的 options 参数提供了精细化控制网络请求的能力,可以支持设置 Header、超时时间、Cookie、请求方法等。
对于常见的上传及下载文件需求,dio 也提供了良好的支持:文件上传可以通过构建表单 FormData 实现,而文件下载则可以使用 download 方法搞定。
我们通过 FormData 创建了两个待上传的文件,通过 post 方法发送至服务端。download 的使用方法则更为简单,我们直接在请求参数中,把待下载的文件地址和本地文件名提供给 dio 即可。如果我们需要感知下载进度,可以增加 onReceiveProgress 回调函数。
//使用FormData表单构建待上传文件
FormData formData = FormData.from({
"file1": UploadFileInfo(File("./file1.txt"), "file1.txt"),
"file2": UploadFileInfo(File("./file2.txt"), "file1.txt"),
//通过post方法发送至服务端
var responseY = await dio.post("https://xxx.com/upload", data: formData);
print(responseY.toString());
//使用download方法下载文件
dio.download("https://xxx.com/file1", "xx1.zip");
//增加下载进度回调函数
dio.download("https://xxx.com/file1", "xx2.zip", onReceiveProgress: (count, total) {
//do something
有时,我们的页面由多个并行的请求响应结果构成,这就需要等待这些请求都返回后才能刷新界面。在 dio 中,我们可以结合 Future.wait 方法轻松实现。
与 Android 的 okHttp 一样,dio 还提供了请求拦截器,通过拦截器,我们可以在请求之前,或响应之后做一些特殊的操作。比如可以为请求 option 统一增加一个 header,或是返回缓存数据,或是增加本地校验处理等等。
为 dio 增加了一个拦截器。在请求发送之前,不仅为每个请求头都加上了自定义的 user-agent,还实现了基本的 token 认证信息检查功能。而对于本地已经缓存了请求 uri 资源的场景,我们可以直接返回缓存数据,避免再次下载。
//增加拦截器
dio.interceptors.add(InterceptorsWrapper(
onRequest: (RequestOptions options){
//为每个请求头都增加user-agent
options.headers["user-agent"] = "Custom-UA";
//检查是否有token,没有则直接报错
if(options.headers['token'] == null) {
return dio.reject("Error:请先登录");
//检查缓存是否有数据
if(options.uri == Uri.parse('http://xxx.com/file1')) {
return dio.resolve("返回缓存数据");
//放行请求
return options;
//增加try catch,防止请求报错
try {
var response = await dio.get("https://xxx.com/xxx.zip");
print(response.data.toString());
}catch(e) {
print(e);
需要注意的是,由于网络通信期间有可能会出现异常(比如,域名无法解析、超时等),因此我们需要使用 try-catch 来捕获这些未知错误,防止程序出现异常。除了这些基本的用法,dio 还支持请求取消、设置代理,证书校验等功能。
JSON解析
移动应用与 Web 服务器建立好了连接之后,接下来的两个重要工作分别是:服务器如何结构化地去描述返回的通信信息,以及移动应用如何解析这些格式化的信息。
在如何结构化地去表达信息上,我们需要用到 JSON。JSON 是一种轻量级的、用于表达由属性值和字面量组成对象的数据交换语言。一个简单的表示学生成绩的 JSON 结构,如下所示。
String jsonString = '''
"id":"123",
"name":"张三",
"score" : 95
需要注意的是,由于 Flutter 不支持运行时反射,因此并没有提供像 Gson、Mantle 这样自动解析 JSON 的库来降低解析成本。在 Flutter 中,JSON 解析完全是手动的,开发者要做的事情多了一些,但使用起来倒也相对灵活。
所谓手动解析,是指使用 dart:convert 库中内置的 JSON 解码器,将 JSON 字符串解析成自定义对象的过程。使用这种方式,我们需要先将 JSON 字符串传递给 JSON.decode 方法解析成一个 Map,然后把这个 Map 传给自定义的类,进行相关属性的赋值。
以上面表示学生成绩的 JSON 结构为例,演示手动解析的使用方法。首先,根据 JSON 结构定义 Student 类,并创建一个工厂类,来处理 Student 类属性成员与 JSON 字典对象的值之间的映射关系。
class Student{
//属性id,名字与成绩
String id;
String name;
int score;
//构造方法
Student({
this.id,
this.name,
this.score
//JSON解析工厂类,使用字典数据为对象初始化赋值
factory Student.fromJson(Map<String, dynamic> parsedJson){
return Student(
id: parsedJson['id'],
name : parsedJson['name'],
score : parsedJson ['score']
数据解析类创建好了,剩下的事情就相对简单了,我们只需要把 JSON 文本通过 JSON.decode 方法转换成 Map,然后把它交给 Student 的工厂类 fromJson 方法,即可完成 Student 对象的解析。
loadStudent() {
//jsonString为JSON文本
final jsonResponse = json.decode(jsonString);
Student student = Student.fromJson(jsonResponse);
print(student.name);
在上面的例子中,JSON 文本所有的属性都是基本类型,因此我们直接从 JSON 字典取出相应的元素为对象赋值即可。而如果 JSON 下面还有嵌套对象属性,比如下面的例子中,Student 还有一个 teacher 的属性,我们又该如何解析呢。
String jsonString = '''
"id":"123",
"name":"张三",
"score" : 95,
"teacher": {
"name": "李四",
"age" : 40
这里,teacher 不再是一个基本类型,而是一个对象。面对这种情况,我们需要为每一个非基本类型属性创建一个解析类。与 Student 类似,我们也需要为它的属性 teacher 创建一个解析类 Teacher。
class Teacher {
//Teacher的名字与年龄
String name;
int age;
//构造方法
Teacher({this.name,this.age});
//JSON解析工厂类,使用字典数据为对象初始化赋值
factory Teacher.fromJson(Map<String, dynamic> parsedJson){
return Teacher(
name : parsedJson['name'],
age : parsedJson ['age']
然后,我们只需要在 Student 类中,增加 teacher 属性及对应的 JSON 映射规则即可。
class Student{
//增加teacher属性
Teacher teacher;
//构造函数增加teacher
Student({
this.teacher
factory Student.fromJson(Map<String, dynamic> parsedJson){
return Student(
//增加映射规则
teacher: Teacher.fromJson(parsedJson ['teacher'])
完成了 teacher 属性的映射规则添加之后,我们就可以继续使用 Student 来解析上述的 JSON 文本了。
final jsonResponse = json.decode(jsonString);//将字符串解码成Map对象
Student student = Student.fromJson(jsonResponse);//手动解析
print(student.teacher.name);
可以看到,通过这种方法,无论对象有多复杂的非基本类型属性,我们都可以创建对应的解析类进行处理。不过到现在为止,我们的 JSON 数据解析还是在主 Isolate 中完成。如果 JSON 的数据格式比较复杂,数据量又大,这种解析方式可能会造成短期 UI 无法响应。对于这类 CPU 密集型的操作,我们可以使用上一篇文章中提到的 compute 函数,将解析工作放到新的 Isolate 中完成。
static Student parseStudent(String content) {
final jsonResponse = json.decode(content);
Student student = Student.fromJson(jsonResponse);
return student;
doSth() {
//用compute函数将json解析放到新Isolate
compute(parseStudent,jsonString).then((student)=>print(student.teacher.name));
通过 compute 的改造,我们就不用担心 JSON 解析时间过长阻塞 UI 响应了。
在 Flutter 中,没有像原生开发那样提供了 Gson 或 Mantle 等库,用于将 JSON 字符串直接转换为对应的实体类。而这些能力无一例外都需要用到运行时反射,这是 Flutter 从设计之初就不支持的,理由如下。
运行时反射破坏了类的封装性和安全性,会带来安全风险。就在前段时间,Fastjson 框架就爆出了一个巨大的安全漏洞。这个漏洞使得精心构造的字符串文本,可以在反序列化时让服务器执行任意代码,直接导致业务机器被远程控制、内网渗透、窃取敏感信息等操作。
运行时反射会增加二进制文件大小。因为搞不清楚哪些代码可能会在运行时用到,因此使用反射后,会默认使用所有代码构建应用程序,这就导致编译器无法优化编译期间未使用的代码,应用安装包体积无法进一步压缩,这对于自带 Dart 虚拟机的 Flutter 应用程序是难以接受的。
反射给开发者编程带来了方便,但也带来了很多难以解决的新问题,因此 Flutter 并不支持反射。
本地存储与数据库的使用与优化
我们需要把这些更新后的数据以一定的形式,通过一定的载体保存起来,这样应用下次运行时,就可以把数据从存储的载体中读出来,也就实现了数据的持久化。
数据持久化的应用场景有很多。比如,用户的账号登录信息需要保存,用于每次与 Web 服务验证身份;又比如,下载后的图片需要缓存,避免每次都要重新加载,浪费用户流量。
由于 Flutter 仅接管了渲染层,真正涉及到存储等操作系统底层行为时,还需要依托于原生 Android、iOS,因此与原生开发类似的,根据需要持久化数据的大小和方式不同,Flutter 提供了三种数据持久化方法,即文件、SharedPreferences 与数据库。
文件是存储在某种介质(比如磁盘)上指定路径的、具有文件名的一组有序信息的集合。从其定义看,要想以文件的方式实现数据持久化,我们首先需要确定一件事儿:数据放在哪儿?这,就意味着要定义文件的存储路径。
Flutter 提供了两种文件存储的目录,即临时(Temporary)目录与文档(Documents)目录。
临时目录是操作系统可以随时清除的目录,通常被用来存放一些不重要的临时缓存数据。这个目录在 iOS 上对应着 NSTemporaryDirectory 返回的值,而在 Android 上则对应着 getCacheDir 返回的值。
文档目录则是只有在删除应用程序时才会被清除的目录,通常被用来存放应用产生的重要数据文件。在 iOS 上,这个目录对应着 NSDocumentDirectory,而在 Android 上则对应着 AppData 目录。
通过一个例子与你演示如何在 Flutter 中实现文件读写。
//创建文件目录
Future<File> get _localFile async {
final directory = await getApplicationDocumentsDirectory();
final path = directory.path;
return File('$path/content.txt');
//将字符串写入文件
Future<File> writeContent(String content) async {
final file = await _localFile;
return file.writeAsString(content);
//从文件读出字符串
Future<String> readContent() async {
try {
final file = await _localFile;
String contents = await file.readAsString();
return contents;
} catch (e) {
return "";
有了文件读写函数,我们就可以在代码中对 content.txt 这个文件进行读写操作了。在下面的代码中,我们往这个文件写入了一段字符串后,隔了一会又把它读了出来。
writeContent("Hello World!");
readContent().then((value)=>print(value));
除了字符串读写之外,Flutter 还提供了二进制流的读写能力,可以支持图片、压缩包等二进制文件的读写。
SharedPreference
文件比较适合大量的、有序的数据持久化,如果我们只是需要缓存少量的键值对信息(比如记录用户是否阅读了公告,或是简单的计数),则可以使用 SharedPreferences。
SharedPreferences 会以原生平台相关的机制,为简单的键值对数据提供持久化存储,即在 iOS 上使用 NSUserDefaults,在 Android 使用 SharedPreferences。
通过一个例子来演示在 Flutter 中如何通过 SharedPreferences 实现数据的读写。在下面的代码中,我们将计数器持久化到了 SharedPreferences 中,并为它分别提供了读方法和递增写入的方法。
需要注意的是,setter(setInt)方法会同步更新内存中的键值对,然后将数据保存至磁盘,因此我们无需再调用更新方法强制刷新缓存。同样地,由于涉及到耗时的文件读写,因此我们必须以异步的方式对这些操作进行包装。
//读取SharedPreferences中key为counter的值
Future<int>_loadCounter() async {
SharedPreferences prefs = await SharedPreferences.getInstance();
int counter = (prefs.getInt('counter') ?? 0);
return counter;
//递增写入SharedPreferences中key为counter的值
Future<void>_incrementCounter() async {
SharedPreferences prefs = await SharedPreferences.getInstance();
int counter = (prefs.getInt('counter') ?? 0) + 1;
prefs.setInt('counter', counter);
在完成了计数器存取方法的封装后,我们就可以在代码中随时更新并持久化计数器数据了。在下面的代码中,我们先是读取并打印了计数器数据,随后将其递增,并再次把它读取打印。
//读出counter数据并打印
_loadCounter().then((value)=>print("before:$value"));
//递增counter数据后,再次读出并打印
_incrementCounter().then((_) {
_loadCounter().then((value)=>print("after:$value"));
可以看到,SharedPreferences 的使用方式非常简单方便。不过需要注意的是,以键值对的方式只能存储基本类型的数据,比如 int、double、bool 和 string。
SharedPrefernces 的使用固然方便,但这种方式只适用于持久化少量数据的场景,我们并不能用它来存储大量数据,比如文件内容(文件路径是可以的)。
如果我们需要持久化大量格式化后的数据,并且这些数据还会以较高的频率更新,为了考虑进一步的扩展性,我们通常会选用 sqlite 数据库来应对这样的场景。与文件和 SharedPreferences 相比,数据库在数据读写上可以提供更快、更灵活的解决方案。
sqlite插件文档:https://pub.dev/documentation/sqflite/latest/
sqlite文档:https://www.sqlitetutorial.net/
以一个例子介绍数据库的使用方法。
class Student{
String id;
String name;
int score;
//构造方法
Student({this.id, this.name, this.score,});
//用于将JSON字典转换成类对象的工厂类方法
factory Student.fromJson(Map<String, dynamic> parsedJson){
return Student(
id: parsedJson['id'],
name : parsedJson['name'],
score : parsedJson ['score'],
JSON 类拥有一个可以将 JSON 字典转换成类对象的工厂类方法,我们也可以提供将类对象反过来转换成 JSON 字典的实例方法。因为最终存入数据库的并不是实体类对象,而是字符串、整型等基本类型组成的字典,所以我们可以通过这两个方法,实现数据库的读写。同时,我们还分别定义了 3 个 Student 对象,用于后续插入数据库。
class Student{
//将类对象转换成JSON字典,方便插入数据库
Map<String, dynamic> toJson() {
return {'id': id, 'name': name, 'score': score,};
var student1 = Student(id: '123', name: '张三', score: 90);
var student2 = Student(id: '456', name: '李四', score: 80);
var student3 = Student(id: '789', name: '王五', score: 85);
有了实体类作为数据库存储的对象,接下来就需要创建数据库了。在下面的代码中,我们通过 openDatabase 函数,给定了一个数据库存储地址,并通过数据库表初始化语句,创建了一个用于存放 Student 对象的 students 表。
final Future<Database> database = openDatabase(
join(await getDatabasesPath(), 'students_database.db'),
onCreate: (db, version)=>db.execute("CREATE TABLE students(id TEXT PRIMARY KEY, name TEXT, score INTEGER)"),
onUpgrade: (db, oldVersion, newVersion){
//dosth for migration
version: 1,
以上代码属于通用的数据库创建模板,有三个地方需要注意。
在设定数据库存储地址时,使用 join 方法对两段地址进行拼接。join 方法在拼接时会使用操作系统的路径分隔符,这样我们就无需关心路径分隔符究竟是“/”还是“\”了。
创建数据库时,传入了一个 version 1,在 onCreate 方法的回调里面也有一个 version。这两个 version 是相等的。
数据库只会创建一次,也就意味着 onCreate 方法在应用从安装到卸载的生命周期中只会执行一次。如果我们在版本升级过程中,想对数据库的存储字段进行改动又该如何处理呢?
sqlite 提供了 onUpgrade 方法,我们可以根据这个方法传入的 oldVersion 和 newVersion 确定升级策略。其中,前者代表用户手机上的数据库版本,而后者代表当前版本的数据库版本。比如,我们的应用有 1.0、1.1 和 1.2 三个版本,在 1.1 把数据库 version 升级到了 2。考虑到用户的升级顺序并不总是连续的,可能会直接从 1.0 升级到 1.2,因此我们可以在 onUpgrade 函数中,对数据库当前版本和用户手机上的数据库版本进行比较,制定数据库升级方案。
数据库创建好了之后,接下来我们就可以把之前创建的 3 个 Student 对象插入到数据库中了。数据库的插入需要调用 insert 方法,在下面的代码中,我们将 Student 对象转换成了 JSON,在指定了插入冲突策略(如果同样的对象被插入两次,则后者替换前者)和目标数据库表后,完成了 Student 对象的插入。
Future<void> insertStudent(Student std) async {
final Database db = await database;
await db.insert(
'students',
std.toJson(),
//插入冲突策略,新的替换旧的
conflictAlgorithm: ConflictAlgorithm.replace,
//插入3个Student对象
await insertStudent(student1);
await insertStudent(student2);
await insertStudent(student3);
数据完成插入之后,接下来我们就可以调用 query 方法把它们取出来了。需要注意的是,写入的时候我们是一个接一个地有序插入,读的时候我们则采用批量读的方式(当然也可以指定查询规则读特定对象)。读出来的数据是一个 JSON 字典数组,因此我们还需要把它转换成 Student 数组。最后,别忘了把数据库资源释放掉。
Future<List<Student>> students() async {
final Database db = await database;
final List<Map<String, dynamic>> maps = await db.query('students');
return List.generate(maps.length, (i)=>Student.fromJson(maps[i]));
//读取出数据库中插入的Student对象集合
students().then((list)=>list.forEach((s)=>print(s.name)));
//释放数据库资源
final Database db = await database;
db.close();
可以看到,在面对大量格式化的数据模型读取时,数据库提供了更快、更灵活的持久化解决方案。
除了基础的数据库读写操作之外,sqlite 还提供了更新、删除以及事务等高级特性,这与原生 Android、iOS 上的 SQLite 或是 MySQL 并无不同。
如何在Dart层兼容Android/IOS平台特定实现(一)
依托于与 Skia 的深度定制及优化,Flutter 给我们提供了很多关于渲染的控制和支持,能够实现绝对的跨平台应用层渲染一致性。但对于一个应用而言,除了应用层视觉显示和对应的交互逻辑处理之外,有时还需要原生操作系统(Android、iOS)提供的底层能力支持。比如,我们前面提到的数据持久化,以及推送、摄像头硬件调用等。
由于 Flutter 只接管了应用渲染层,因此这些系统底层能力是无法在 Flutter 框架内提供支持的;而另一方面,Flutter 还是一个相对年轻的生态,因此原生开发中一些相对成熟的 Java、C++ 或 Objective-C 代码库,比如图片处理、音视频编解码等,可能在 Flutter 中还没有相关实现。
因此,为了解决调用原生系统底层能力以及相关代码库复用问题,Flutter 为开发者提供了一个轻量级的解决方案,即逻辑层的方法通道(Method Channel)机制。基于方法通道,我们可以将原生代码所拥有的能力,以接口形式暴露给 Dart,从而实现 Dart 代码与原生代码的交互,就像调用了一个普通的 Dart API 一样。
Flutter 作为一个跨平台框架,提供了一套标准化的解决方案,为开发者屏蔽了操作系统的差异。但,Flutter 毕竟不是操作系统,因此在某些特定场景下(比如推送、蓝牙、摄像头硬件调用时),也需要具备直接访问系统底层原生代码的能力。为此,Flutter 提供了一套灵活而轻量级的机制来实现 Dart 和原生代码之间的通信,即方法调用的消息传递机制,而方法通道则是用来传递通信消息的信道。
一次典型的方法调用过程类似网络调用,由作为客户端的 Flutter,通过方法通道向作为服务端的原生代码宿主发送方法调用请求,原生代码宿主在监听到方法调用的消息后,调用平台相关的 API 来处理 Flutter 发起的请求,最后将处理完毕的结果通过方法通道回发至 Flutter。调用过程如下图所示。
从上图中可以看到,方法调用请求的处理和响应,在 Android 中是通过 FlutterView,而在 iOS 中则是通过 FlutterViewController 进行注册的。FlutterView 与 FlutterViewController 为 Flutter 应用提供了一个画板,使得构建于 Skia 之上的 Flutter 通过绘制即可实现整个应用所需的视觉效果。因此,它们不仅是 Flutter 应用的容器,同时也是 Flutter 应用的入口,自然也是注册方法调用请求最合适的地方。
方法通道使用示例
在实际业务中,提示用户跳转到应用市场(iOS 为 App Store、Android 则为各类手机应用市场)去评分是一个高频需求,考虑到 Flutter 并未提供这样的接口,而跳转方式在 Android 和 iOS 上各不相同,因此我们需要分别在 Android 和 iOS 上实现这样的功能,并暴露给 Dart 相关的接口。
Flutter 如何实现一次方法调用请求
首先,我们需要确定一个唯一的字符串标识符,来构造一个命名通道;然后,在这个通道之上,Flutter 通过指定方法名“openAppMarket”来发起一次方法调用请求。
可以看到,这和我们平时调用一个 Dart 对象的方法完全一样。因为方法调用过程是异步的,所以我们需要使用非阻塞(或者注册回调)来等待原生代码给予响应。
//声明MethodChannel
//注意:通道名称没要求,Android/IOS通道入口做判断即可
const platform = MethodChannel('samples.chenhang/utils');
//处理按钮点击
handleButtonClick() async{
int result;
//异常捕获
try {
//异步等待方法通道的调用结果
result = await platform.invokeMethod('openAppMarket');
catch (e) {
result = -1;
print("Result:$result");
需要注意的是,与网络调用类似,方法调用请求有可能会失败(比如,Flutter 发起了原生代码不支持的 API 调用,或是调用过程出错等),因此我们需要把发起方法调用请求的语句用 try-catch 包装起来。
调用方的实现搞定了,接下来就需要在原生代码宿主中完成方法调用的响应实现了。由于我们需要适配 Android 和 iOS 两个平台,所以我们分别需要在两个平台上完成对应的接口实现。
在原生代码中完成方法调用的响应
在 Android 平台,方法调用的处理和响应是在 Flutter 应用的入口,也就是在 MainActivity 中的 FlutterView 里实现的,因此我们需要打开 Flutter 的 Android 宿主 App,找到 MainActivity.java 文件,并在其中添加相关的逻辑。
调用方与响应方都是通过命名通道进行信息交互的,所以我们需要在 onCreate 方法中,创建一个与调用方 Flutter 所使用的通道名称一样的 MethodChannel,并在其中设置方法处理回调,响应 openAppMarket 方法,打开应用市场的 Intent。同样地,考虑到打开应用市场的过程可能会出错,我们也需要增加 try-catch 来捕获可能的异常。
protected void onCreate(Bundle savedInstanceState) {
//创建与调用方标识符一样的方法通道
new MethodChannel(getFlutterView(), "samples.chenhang/utils").setMethodCallHandler(
//设置方法处理回调
new MethodCallHandler() {
//响应方法请求
@Override
public void onMethodCall(MethodCall call, Result result) {
//判断方法名是否支持
if(call.method.equals("openAppMarket")) {
try {
//应用市场URI
Uri uri = Uri.parse("market://details?id=com.hangchen.example.flutter_module_page.host");
Intent intent = new Intent(Intent.ACTION_VIEW, uri);
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
//打开应用市场
activity.startActivity(intent);
//返回处理结果
result.success(0);
} catch (Exception e) {
//打开应用市场出现异常
result.error("UNAVAILABLE", "没有安装应用市场", null);
}else {
//方法名暂不支持
result.notImplemented();
在 iOS 平台,方法调用的处理和响应是在 Flutter 应用的入口,也就是在 Applegate 中的 rootViewController(即 FlutterViewController)里实现的,因此我们需要打开 Flutter 的 iOS 宿主 App,找到 AppDelegate.m 文件,并添加相关逻辑。
与 Android 注册方法调用响应类似,我们需要在 didFinishLaunchingWithOptions: 方法中,创建一个与调用方 Flutter 所使用的通道名称一样的 MethodChannel,并在其中设置方法处理回调,响应 openAppMarket 方法,通过 URL 打开应用市场。
- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
//创建命名方法通道
FlutterMethodChannel* channel = [FlutterMethodChannel methodChannelWithName:@"samples.chenhang/utils" binaryMessenger:(FlutterViewController *)self.window.rootViewController];
//往方法通道注册方法调用处理回调
[channel setMethodCallHandler:^(FlutterMethodCall* call, FlutterResult result) {
//方法名称一致
if ([@"openAppMarket" isEqualToString:call.method]) {
//打开App Store(本例打开微信的URL)
[[UIApplication sharedApplication] openURL:[NSURL URLWithString:@"itms-apps://itunes.apple.com/xy/app/foo/id414478124"]];
//返回方法处理结果
result(@0);
} else {
//找不到被调用的方法
result(FlutterMethodNotImplemented);
在 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 平台间的常见数据类型转换。
方法通道解决了逻辑层的原生能力复用问题,使得 Flutter 能够通过轻量级的异步方法调用,实现与原生代码的交互。一次典型的调用过程由 Flutter 发起方法调用请求开始,请求经由唯一标识符指定的方法通道到达原生代码宿主,而原生代码宿主则通过注册对应方法实现、响应并处理调用请求,最后将执行结果通过消息通道,回传至 Flutter。
需要注意的是,方法通道是非线程安全的。这意味着原生代码与 Flutter 之间所有接口调用必须发生在主线程。Flutter 是单线程模型,因此自然可以确保方法调用请求是发生在主线程(Isolate)的;而原生代码在处理方法调用请求时,如果涉及到异步或非主线程切换,需要确保回调过程是在原生系统的 UI 线程(也就是 Android 和 iOS 的主线程)中执行的,否则应用可能会出现奇怪的 Bug,甚至是 Crash。
扩展方法通道示例,让 openAppMarket 支持传入 AppID 和包名,使得我们可以跳转到任意一个 App 的应用市场。
//Flutter端
// 处理按钮点击
handleButtonClick() async {
int result;
// 异常捕获
try {
// 异步等待方法通道的调用结果
result = await platform.invokeMethod('openAppMarket', <String, dynamic>{
'appId': "com.xxx.xxx",
'packageName': "xxx.com.xxx",
} catch (e) {
result = -1;
print("Result:$result");
//Android端
if (call.method == "openAppMarket") {
if (call.hasArgument("appId")) {
//获取 appId
call.argument<String>("appId")
if (call.hasArgument("packageName")) {
//获取包名
call.argument<String>("packageName")
如何在Dart层兼容Android/IOS平台特定实现(二)
构造一个复杂App需要什么
构建一个 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 那样,使用原生视图了。整个流程,如下图所示。
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');
如何在原生系统实现接口
Android端的实现。
//视图工厂类
class SampleViewFactory extends PlatformViewFactory {
private final BinaryMessenger messenger;
//初始化方法
public SampleViewFactory(BinaryMessenger msger) {
super(StandardMessageCodec.INSTANCE);
messenger = msger;
//创建原生视图封装类,完成关联
@Override
public PlatformView create(Context context, int id, Object obj) {
return new SimpleViewControl(context, id, messenger);
//原生视图封装类
class SimpleViewControl implements PlatformView {
private final View view;//缓存原生视图
//初始化方法,提前创建好视图
public SimpleViewControl(Context context, int id, BinaryMessenger messenger) {
view = new View(context);
view.setBackgroundColor(Color.rgb(255, 0, 0));
//返回原生视图
@Override
public View getView() {
return view;
//原生视图销毁回调
@Override
public void dispose() {
将原生视图封装类与原生视图工厂完成关联后,接下来就需要将 Flutter 侧的调用与视图工厂绑定起来了。与上一篇文章讲述的方法通道类似,我们仍然需要在 MainActivity 中进行绑定操作。
protected void onCreate(Bundle savedInstanceState) {
Registrar registrar = registrarFor("samples.chenhang/native_views");//生成注册类
SampleViewFactory playerViewFactory = new SampleViewFactory(registrar.messenger());//生成视图工厂
registrar.platformViewRegistry().registerViewFactory("sampleView", playerViewFactory);//注册视图工厂
iOS 端的实现。
与 Android 类似,我们同样需要分别创建平台视图工厂和原生视图封装类,并通过视图工厂的 create 方法,将它们关联起来。
//平台视图工厂
@interface SampleViewFactory : NSObject<FlutterPlatformViewFactory>
- (instancetype)initWithMessenger:(NSObject<FlutterBinaryMessenger>*)messager;
@implementation SampleViewFactory{
NSObject<FlutterBinaryMessenger>*_messenger;
- (instancetype)initWithMessenger:(NSObject<FlutterBinaryMessenger> *)messager{
self = [super init];
if (self) {
_messenger = messager;
return self;
-(NSObject<FlutterMessageCodec> *)createArgsCodec{
return [FlutterStandardMessageCodec sharedInstance];
//创建原生视图封装实例
-(NSObject<FlutterPlatformView> *)createWithFrame:(CGRect)frame viewIdentifier:(int64_t)viewId arguments:(id)args{
SampleViewControl *activity = [[SampleViewControl alloc] initWithWithFrame:frame viewIdentifier:viewId arguments:args binaryMessenger:_messenger];
return activity;
//平台视图封装类
@interface SampleViewControl : NSObject<FlutterPlatformView>
- (instancetype)initWithWithFrame:(CGRect)frame viewIdentifier:(int64_t)viewId arguments:(id _Nullable)args binaryMessenger:(NSObject<FlutterBinaryMessenger>*)messenger;
@implementation SampleViewControl{
UIView * _templcateView;
//创建原生视图
- (instancetype)initWithWithFrame:(CGRect)frame viewIdentifier:(int64_t)viewId arguments:(id)args binaryMessenger:(NSObject<FlutterBinaryMessenger> *)messenger{
if ([super init]) {
_templcateView = [[UIView alloc] init];
_templcateView.backgroundColor = [UIColor redColor];
return self;
-(UIView *)view{
return _templcateView;
然后,我们同样需要把原生视图的创建与 Flutter 侧的调用关联起来,才可以在 Flutter 侧找到原生视图的实现。
- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
NSObject<FlutterPluginRegistrar>* registrar = [self registrarForPlugin:@"samples.chenhang/native_views"];//生成注册类
SampleViewFactory* viewFactory = [[SampleViewFactory alloc] initWithMessenger:registrar.messenger];//生成视图工厂
[registrar registerViewFactory:viewFactory withId:@"sampleView"];//注册视图工厂
需要注意的是,在 iOS 平台上,Flutter 内嵌 UIKitView 目前还处于技术预览状态,因此我们还需要在 Info.plist 文件中增加一项配置,把内嵌原生视图的功能开关设置为 true,才能打开这个隐藏功能。
<key>io.flutter.embedded_views_preview</key>
<true/>
</dict>
经过上面的封装与绑定,Android 端与 iOS 端的平台视图功能都已经实现了。接下来,我们就可以在 Flutter 应用里,像使用普通 Widget 一样,去内嵌原生视图了。
Scaffold(
backgroundColor: Colors.yellowAccent,
body: Container(width: 200, height:200,
child: SampleView(controller: controller)
在上面的例子中,我们将原生视图封装在一个 StatelessWidget 中,可以有效应对静态展示的场景。如果我们需要在程序运行时动态调整原生视图的样式,又该如何处理呢?
如何在程序运行时,动态地调整原生视图的样式
与基于声明式的 Flutter Widget,每次变化只能以数据驱动其视图销毁重建不同,原生视图是基于命令式的,可以精确地控制视图展示样式。因此,我们可以在原生视图的封装类中,将其持有的修改视图实例相关的接口,以方法通道的方式暴露给 Flutter,让 Flutter 也可以拥有动态调整视图视觉样式的能力。
以一个具体的案例来演示如何在程序运行时动态调整内嵌原生视图的背景颜色。
在这个案例中,我们会用到原生视图的一个初始化属性,即 onPlatformViewCreated:原生视图会在其创建完成后,以回调的形式通知视图 id,因此我们可以在这个时候注册方法通道,让后续的视图修改请求通过这条通道传递给原生视图。
由于我们在底层直接持有了原生视图的实例,因此理论上可以直接在这个原生视图的 Flutter 封装类上提供视图修改方法,而不管它到底是 StatelessWidget 还是 StatefulWidget。但为了遵照 Flutter 的 Widget 设计理念,我们还是决定将视图展示与视图控制分离,即:将原生视图封装为一个 StatefulWidget 专门用于展示,通过其 controller 初始化参数,在运行期修改原生视图的展示效果。如下所示。
//原生视图控制器
class NativeViewController {
MethodChannel _channel;
//原生视图完成创建后,通过id生成唯一方法通道
onCreate(int id) {
_channel = MethodChannel('samples.chenhang/native_views_$id');
//调用原生视图方法,改变背景颜色
Future<void> changeBackgroundColor() async {
return _channel.invokeMethod('changeBackgroundColor');
//原生视图Flutter侧封装,继承自StatefulWidget
class SampleView extends StatefulWidget {
const SampleView({
Key key,
this.controller,
}) : super(key: key);
//持有视图控制器
final NativeViewController controller;
@override
State<StatefulWidget> createState() => _SampleViewState();
class _SampleViewState extends State<SampleView> {
//根据平台确定返回何种平台视图
@override
Widget build(BuildContext context) {
if (defaultTargetPlatform == TargetPlatform.android) {
return AndroidView(
viewType: 'sampleView',
//原生视图创建完成后,通过onPlatformViewCreated产生回调
onPlatformViewCreated: _onPlatformViewCreated,
} else {
return UiKitView(viewType: 'sampleView',
//原生视图创建完成后,通过onPlatformViewCreated产生回调
onPlatformViewCreated: _onPlatformViewCreated
//原生视图创建完成后,调用control的onCreate方法,传入view id
_onPlatformViewCreated(int id) {
if (widget.controller == null) {
return;
widget.controller.onCreate(id);
在进行原生视图初始化时,我们需要完成方法通道的注册和相关事件的处理;在响应方法调用消息时,我们需要判断方法名,如果完全匹配,则修改视图背景,否则返回异常。
Android端接口实现代码
class SimpleViewControl implements PlatformView, MethodCallHandler {
private final MethodChannel methodChannel;
public SimpleViewControl(Context context, int id, BinaryMessenger messenger) {
//用view id注册方法通道
methodChannel = new MethodChannel(messenger, "samples.chenhang/native_views_" + id);
//设置方法通道回调
methodChannel.setMethodCallHandler(this);
//处理方法调用消息
@Override
public void onMethodCall(MethodCall methodCall, MethodChannel.Result result) {
//如果方法名完全匹配
if (methodCall.method.equals("changeBackgroundColor")) {
//修改视图背景,返回成功
view.setBackgroundColor(Color.rgb(0, 0, 255));
result.success(0);
}else {
//调用方发起了一个不支持的API调用
result.notImplemented();
iOS 端接口实现代码
@implementation SampleViewControl{
FlutterMethodChannel* _channel;
- (instancetype)initWithWithFrame:(CGRect)frame viewIdentifier:(int64_t)viewId arguments:(id)args binaryMessenger:(NSObject<FlutterBinaryMessenger> *)messenger{
if ([super init]) {
//使用view id完成方法通道的创建
_channel = [FlutterMethodChannel methodChannelWithName:[NSString stringWithFormat:@"samples.chenhang/native_views_%lld", viewId] binaryMessenger:messenger];
//设置方法通道的处理回调
__weak __typeof__(self) weakSelf = self;
[_channel setMethodCallHandler:^(FlutterMethodCall* call, FlutterResult result) {
[weakSelf onMethodCall:call result:result];
return self;
//响应方法调用消息
- (void)onMethodCall:(FlutterMethodCall*)call result:(FlutterResult)result {
//如果方法名完全匹配
if ([[call method] isEqualToString:@"changeBackgroundColor"]) {
//修改视图背景色,返回成功
_templcateView.backgroundColor = [UIColor blueColor];
result(@0);
} else {
//调用方发起了一个不支持的API调用
result(FlutterMethodNotImplemented);
通过注册方法通道,以及暴露的 changeBackgroundColor 接口,Android 端与 iOS 端修改平台视图背景颜色的功能都已经实现了。接下来,我们就可以在 Flutter 应用运行期间,修改原生视图展示样式了。
class DefaultState extends State<DefaultPage> {
NativeViewController controller;
@override
void initState() {
controller = NativeViewController();//初始化原生View控制器
super.initState();
@override
Widget build(BuildContext context) {
return Scaffold(
//内嵌原生View
body: Container(width: 200, height:200,
child: SampleView(controller: controller)
//设置点击行为:改变视图颜色
floatingActionButton: FloatingActionButton(onPressed: ()=>controller.changeBackgroundColor())
需要注意的是,由于 Flutter 与原生渲染方式完全不同,因此转换不同的渲染数据会有较大的性能开销。如果在一个界面上同时实例化多个原生控件,就会对性能造成非常大的影响,所以我们要避免在使用 Flutter 控件也能实现的情况下去使用内嵌平台视图。
因为这样做,一方面需要分别在 Android 和 iOS 端写大量的适配桥接代码,违背了跨平台技术的本意,也增加了后续的维护成本;另一方面毕竟除去地图、WebView、相机等涉及底层方案的特殊情况外,大部分原生代码能够实现的 UI 效果,完全可以用 Flutter 实现。
如何在原生应用中混编Flutter工程
使用 Flutter 从头开始写一个 App,是一件轻松惬意的事情。但,对于成熟产品来说,完全摒弃原有 App 的历史沉淀,而全面转向 Flutter 并不现实。用 Flutter 去统一 iOS/Android 技术栈,把它作为已有原生 App 的扩展能力,通过逐步试验有序推进从而提升终端开发效率,可能才是现阶段 Flutter 最具吸引力的地方。
那么,Flutter 工程与原生工程该如何组织管理?不同平台的 Flutter 工程打包构建产物该如何抽取封装?封装后的产物该如何引入原生工程?原生工程又该如何使用封装后的 Flutter 能力?
首先,我们分别用 Xcode 与 Android Studio 快速建立一个只有首页的基本工程,工程名分别为 iOSDemo 与 AndroidDemo。
这时,Android 工程就已经准备好了;而对于 iOS 工程来说,由于基本工程并不支持以组件化的方式管理项目,因此我们还需要多做一步,将其改造成使用 CocoaPods 管理的工程,也就是要在 iOSDemo 根目录下创建一个只有基本信息的 Podfile 文件.
use_frameworks!
platform :ios, '8.0'
target 'iOSDemo' do
#todo
然后,在命令行输入 pod install 后,会自动生成一个 iOSDemo.xcworkspace 文件,这时我们就完成了 iOS 工程改造。
Flutter混编方案介绍
在已有的原生 App 里嵌入一些 Flutter 页面,有两个办法。
将原生工程作为 Flutter 工程的子工程,由 Flutter 统一管理。这种模式,就是统一管理模式。
将 Flutter 工程作为原生工程共用的子模块,维持原有的原生工程管理方式不变。这种模式,就是三端分离模式。
由于 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 工程和原生工程的目录(即 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 打开它,其目录如下图所示。
可以看到,和传统的 Flutter 工程相比,Flutter 模块工程也有内嵌的 Android 工程与 iOS 工程,因此我们可以像普通工程一样使用 Android Studio 进行开发调试。
仔细查看可以发现,Flutter 模块有一个细微的变化:Android 工程下多了一个 Flutter 目录,这个目录下的 build.gradle 配置就是我们构建 aar 的打包配置。这就是模块工程既能像 Flutter 传统工程一样使用 Android Studio 开发调试,又能打包构建 aar 与 pod 的秘密。
然后,打开 main.dart 文件,将其逻辑更新为以下代码逻辑,即一个写着“Hello from Flutter”的全屏红色的 Flutter Widget。
import 'package:flutter/material.dart';
import 'dart:ui';
void main() => runApp(_widgetForRoute(window.defaultRouteName));//独立运行传入默认路由
Widget _widgetForRoute(String route) {
switch (route) {
default:
return MaterialApp(
home: Scaffold(
backgroundColor: const Color(0xFFD63031),//ARGB红色
body: Center(
child: Text(
'Hello from Flutter', //显示的文字
textDirection: TextDirection.ltr,
style: TextStyle(
fontSize: 20.0,
color: Colors.blue,
注意:我们创建的 Widget 实际上是包在一个 switch-case 语句中的。这是因为封装的 Flutter 模块一般会有多个页面级 Widget,原生 App 代码则会通过传入路由标识字符串,告诉 Flutter 究竟应该返回何种 Widget。为了简化案例,在这里我们忽略标识字符串,统一返回一个 MaterialApp。
把这段代码编译打包,构建出对应的 Android 和 iOS 依赖库,实现原生工程的接入。
Android模块集成
最新集成方式:https://flutter.cn/docs/development/add-to-app/android/project-setup
在自己新建的Android原生工程中新建一个flutter module。然后add FlutterActivity到AndroidManifest.xml中。
<activity
android:name="io.flutter.embedding.android.FlutterActivity"
android:theme="@style/AppTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize"
然后在Activity中实现跳转。
myButton.setOnClickListener(new OnClickListener() {
@Override
public void onClick(View v) {
startActivity(
FlutterActivity.createDefaultIntent(MainActivity.this)
上面的示例默认入口为Main.dart,下面演示如何自定义入口。
myButton.addOnClickListener(new OnClickListener() {
@Override
public void onClick(View v) {
startActivity(
FlutterActivity
.withNewEngine()
.initialRoute("/my_route")
.build(MainActivity.this)
上面两种方式实现跳转FlutterActivity会有短暂的延迟,为了最大程度地减少这种延迟,您可以在到达FlutterActivity之前预热FlutterEngine,然后可以使用预热的FlutterEngine。要预热FlutterEngine,请在您的应用中找到一个合理的位置以实例化FlutterEngine。以下示例在Application类中任意预热FlutterEngine。
public class MyApplication extends Application {
@Override
public void onCreate() {
super.onCreate();
// Instantiate a FlutterEngine.
flutterEngine = new FlutterEngine(this);
// Start executing Dart code to pre-warm the FlutterEngine.
flutterEngine.getDartExecutor().executeDartEntrypoint(
DartEntrypoint.createDefault()
// Cache the FlutterEngine to be used by FlutterActivity.
FlutterEngineCache
.getInstance()
.put("my_engine_id", flutterEngine);
在Activity中跳转到FlutterActivity中。
myButton.addOnClickListener(new OnClickListener() {
@Override
public void onClick(View v) {
startActivity(
FlutterActivity
.withCachedEngine("my_engine_id")
.build(MainActivity.this)
如何将初始路由与缓存引擎一起使用?
public class MyApplication extends Application {
@Override
public void onCreate() {
super.onCreate();
// Instantiate a FlutterEngine.
flutterEngine = new FlutterEngine(this);
// Configure an initial route.
flutterEngine.getNavigationChannel().setInitialRoute("your/route/here");
// Start executing Dart code to pre-warm the FlutterEngine.
flutterEngine.getDartExecutor().executeDartEntrypoint(
DartEntrypoint.createDefault()
// Cache the FlutterEngine to be used by FlutterActivity or FlutterFragment.
FlutterEngineCache
.getInstance()
.put("my_engine_id", flutterEngine);
到原生工程对 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);
//import io.flutter.facade.Flutter;找不到???//TODO
View FlutterView = Flutter.createView(this, getLifecycle(), "defaultRoute"); //传入路由标识符
setContentView(FlutterView);//用FlutterView替代Activity的ContentView
最后点击运行,可以看到一个写着“Hello from Flutter”的全屏红色的 Flutter Widget 就展示出来了。至此,我们完成了 Android 工程的接入。
iOS模块集成
iOS 工程接入的情况要稍微复杂一些。在 iOS 平台,原生工程对 Flutter 的依赖分别是。
Flutter 库和引擎,即 Flutter.framework;
Flutter 工程的产物,即 App.framework。
iOS 平台的 Flutter 模块抽取,实际上就是通过打包命令生成这两个产物,并将它们封装成一个 pod 供原生工程引用。类似地,首先我们在 Flutter_library 的根目录下,执行 iOS 打包构建命令。
Flutter build ios --debug
这条命令的作用是编译 Flutter 工程生成两个产物:Flutter.framework 和 App.framework。同样,把 debug 换成 release 就可以构建 release 产物(当然,你还需要处理一下签名问题)。
其次,在 iOSDemo 的根目录下创建一个名为 FlutterEngine 的目录,并把这两个 framework 文件拷贝进去。iOS 的模块化产物工作要比 Android 多一个步骤,因为我们需要把这两个产物手动封装成 pod。因此,我们还需要在该目录下创建 FlutterEngine.podspec,即 Flutter 模块的组件定义。
Pod::Spec.new do |s|
s.name = 'FlutterEngine'
s.version = '0.1.0'
s.summary = 'XXXXXXX'
s.description = <<-DESC
TODO: Add long description of the pod here.
s.homepage = 'https://github.com/xx/FlutterEngine'
s.license = { :type => 'MIT', :file => 'LICENSE' }
s.author = { 'chenhang' => '[email protected]' }
s.source = { :git => "", :tag => "#{s.version}" }
s.ios.deployment_target = '8.0'
s.ios.vendored_frameworks = 'App.framework', 'Flutter.framework'
pod lib lint 一下,Flutter 模块组件就已经做好了。趁热打铁,我们再修改 Podfile 文件把它集成到 iOSDemo 工程中。
target 'iOSDemo' do
pod 'FlutterEngine', :path => './'
pod install 一下,Flutter 模块就集成进 iOS 原生工程中了。
再次,我们试着修改一下 AppDelegate.m 的代码,把 window 的 rootViewController 改成 FlutterViewController。
- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions
self.window = [[UIWindow alloc] initWithFrame:[UIScreen mainScreen].bounds];
FlutterViewController *vc = [[FlutterViewController alloc]init];
[vc setInitialRoute:@"defaultRoute"]; //路由标识符
self.window.rootViewController = vc;
[self.window makeKeyAndVisible];
return YES;
最后点击运行,一个写着“Hello from Flutter”的全屏红色的 Flutter Widget 也展示出来了。至此,iOS 工程的接入我们也顺利搞定了。
通过分离 Android、iOS 和 Flutter 三端工程,抽离 Flutter 库和引擎及工程代码为组件库,以 Android 和 iOS 平台最常见的 aar 和 pod 形式接入原生工程,我们就可以低成本地接入 Flutter 模块,愉快地使用 Flutter 扩展原生 App 的边界了。
但,我们还可以做得更好。如果每次通过构建 Flutter 模块工程,都是手动搬运 Flutter 编译产物,那很容易就会因为工程管理混乱导致 Flutter 组件库被覆盖,从而引发难以排查的 Bug。而要解决此类问题的话,我们可以引入 CI 自动构建框架,把 Flutter 编译产物构建自动化,原生工程通过接入不同版本的构建产物,实现更优雅的三端分离模式。
混合开发,该用何种方案管理导航栈
对于混合开发的应用而言,通常我们只会将应用的部分模块修改成 Flutter 开发,其他模块继续保留原生开发,因此应用内除了 Flutter 的页面之外,还会有原生 Android、iOS 的页面。在这种情况下,Flutter 页面有可能会需要跳转到原生页面,而原生页面也可能会需要跳转到 Flutter 页面。这就涉及到了一个新的问题:如何统一管理原生页面和 Flutter 页面跳转交互的混合导航栈。
混合导航栈
混合导航栈,指的是原生页面和 Flutter 页面相互掺杂,存在于用户视角的页面导航栈视图中。
Flutter 与原生 Android、iOS 各自实现了一套互不相同的页面映射机制,即原生采用单容器单页面(一个 ViewController/Activity 对应一个原生页面)、Flutter 采用单容器多页面(一个 ViewController/Activity 对应多个 Flutter 页面)的机制。Flutter 在原生的导航栈之上又自建了一套 Flutter 导航栈,这使得 Flutter 页面与原生页面之间涉及页面切换时,我们需要处理跨引擎的页面切换。
##### 从原生页面跳转至Flutter页面
* Flutter 本身依托于原生提供的容器(iOS 为 FlutterViewController,Android 为 Activity 中的 FlutterView),所以我们通过初始化 Flutter 容器,为其设置初始路由页面之后,就可以以原生的方式跳转至 Flutter 页面了。
//iOS 跳转至Flutter页面
FlutterViewController *vc = [[FlutterViewController alloc] init];
[vc setInitialRoute:@"defaultPage"];//设置Flutter初始化路由页面
[self.navigationController pushViewController:vc animated:YES];//完成页面跳转
//Android 跳转至Flutter页面
//创建一个作为Flutter页面容器的Activity
public class FlutterHomeActivity extends AppCompatActivity {
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
//设置Flutter初始化路由页面
//flutter类包找不到???
View FlutterView = Flutter.createView(this, getLifecycle(), "defaultRoute"); //传入路由标识符
setContentView(FlutterView);//用FlutterView替代Activity的ContentView
//用FlutterPageActivity完成页面跳转
Intent intent = new Intent(MainActivity.this, FlutterHomeActivity.class);
startActivity(intent);
从Flutter页面跳转至原生页面
从 Flutter 页面跳转至原生页面,则会相对麻烦些,我们需要考虑以下两种场景。
从 Flutter 页面打开新的原生页面;
从 Flutter 页面回退到旧的原生页面。
Flutter 并没有提供对原生页面操作的方法,所以不可以直接调用。我们需要通过方法通道,在 Flutter 和原生两端各自初始化时,提供 Flutter 操作原生页面的方法,并注册方法通道,在原生端收到 Flutter 的方法调用时,打开新的原生页面。
Flutter 容器本身属于原生导航栈的一部分,所以当 Flutter 容器内的根页面(即初始化路由页面)需要返回时,我们需要关闭 Flutter 容器,从而实现 Flutter 根页面的关闭。同样,Flutter 并没有提供操作 Flutter 容器的方法,因此我们依然需要通过方法通道,在原生代码宿主为 Flutter 提供操作 Flutter 容器的方法,在页面返回时,关闭 Flutter 页面。
* 打开原生页面 openNativePage,与关闭 Flutter 页面 closeFlutterPage,在 Android 和 iOS 平台上分别如何实现。
* 注册方法通道最合适的地方,是 Flutter 应用的入口,即在 FlutterViewController(iOS 端)和 Activity 中的 FlutterView(Android 端)这两个容器内部初始化 Flutter 页面前。为了将 Flutter 相关的行为封装到容器内部,我们需要分别继承 FlutterViewController 和 Activity,在其 viewDidLoad 和 onCreate 初始化容器时,注册 openNativePage 和 closeFlutterPage 这两个方法。
iOS端代码实现
@interface FlutterHomeViewController : FlutterViewController
@implementation FlutterHomeViewController
- (void)viewDidLoad {
[super viewDidLoad];
//声明方法通道
FlutterMethodChannel* channel = [FlutterMethodChannel methodChannelWithName:@"samples.chenhang/navigation" binaryMessenger:self];
//注册方法回调
[channel setMethodCallHandler:^(FlutterMethodCall* call, FlutterResult result) {
//如果方法名为打开新页面
if([call.method isEqualToString:@"openNativePage"]) {
//初始化原生页面并打开
SomeOtherNativeViewController *vc = [[SomeOtherNativeViewController alloc] init];
[self.navigationController pushViewController:vc animated:YES];
result(@0);
//如果方法名为关闭Flutter页面
else if([call.method isEqualToString:@"closeFlutterPage"]) {
//关闭自身(FlutterHomeViewController)
[self.navigationController popViewControllerAnimated:YES];
result(@0);
else {
result(FlutterMethodNotImplemented);//其他方法未实现
Android端代码实现
//继承AppCompatActivity来作为Flutter的容器
public class FlutterHomeActivity extends AppCompatActivity {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
//初始化Flutter容器
FlutterView flutterView = Flutter.createView(this, getLifecycle(), "defaultPage"); //传入路由标识符
//注册方法通道
new MethodChannel(flutterView, "samples.chenhang/navigation").setMethodCallHandler(
new MethodCallHandler() {
@Override
public void onMethodCall(MethodCall call, Result result) {
//如果方法名为打开新页面
if(call.method.equals("openNativePage")) {
//新建Intent,打开原生页面
Intent intent = new Intent(FlutterHomeActivity.this, SomeNativePageActivity.class);
startActivity(intent);
result.success(0);
//如果方法名为关闭Flutter页面
else if(call.method.equals("closeFlutterPage")) {
//销毁自身(Flutter容器)
finish();
result.success(0);
else {
//方法未实现
result.notImplemented();
//将flutterView替换成Activity的contentView
setContentView(flutterView);
经过上面的方法注册,我们就可以在 Flutter 层分别通过 openNativePage 和 closeFlutterPage 方法,来实现 Flutter 页面与原生页面之间的切换了。
举一个例子,Flutter 容器的根视图 DefaultPage 包含有两个按钮。
点击左上角的按钮后,可以通过 closeFlutterPage 返回原生页面;
点击中间的按钮后,会打开一个新的 Flutter 页面 PageA。PageA 中也有一个按钮,点击这个按钮之后会调用 openNativePage 来打开一个新的原生页面。
void main() => runApp(_widgetForRoute(window.defaultRouteName));
//获取方法通道
const platform = MethodChannel('samples.chenhang/navigation');
//根据路由标识符返回应用入口视图
Widget _widgetForRoute(String route) {
switch (route) {
default://返回默认视图
return MaterialApp(home:DefaultPage());
class PageA extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
body: RaisedButton(
child: Text("Go PageB"),
onPressed: ()=>platform.invokeMethod('openNativePage')//打开原生页面
class DefaultPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text("DefaultPage Page"),
leading: IconButton(icon:Icon(Icons.arrow_back), onPressed:() => platform.invokeMethod('closeFlutterPage')//关闭Flutter页面
body: RaisedButton(
child: Text("Go PageA"),
onPressed: ()=>Navigator.push(context, MaterialPageRoute(builder: (context) => PageA())),//打开Flutter页面 PageA
混合应用中,RootViewController 与 MainActivity 分别是 iOS 和 Android 应用的原生页面入口,可以初始化为 Flutter 容器的 FlutterHomeViewController(iOS 端)与 FlutterHomeActivity(Android 端)。
在为其设置初始路由页面 DefaultPage 之后,就可以以原生的方式跳转至 Flutter 页面。但是,Flutter 并未提供接口,来支持从 Flutter 的 DefaultPage 页面返回到原生页面,因此我们需要利用方法通道来注册关闭 Flutter 容器的方法,即 closeFlutterPage,让 Flutter 容器接收到这个方法调用时关闭自身。
在 Flutter 容器内部,我们可以使用 Flutter 内部的页面路由机制,通过 Navigator.push 方法,完成从 DefaultPage 到 PageA 的页面跳转;而当我们想从 Flutter 的 PageA 页面跳转到原生页面时,因为涉及到跨引擎的页面路由,所以我们仍然需要利用方法通道来注册打开原生页面的方法,即 openNativePage,让 Flutter 容器接收到这个方法调用时,在原生代码宿主完成原生页面 SomeOtherNativeViewController(iOS 端)与 SomeNativePageActivity(Android 端)的初始化,并最终完成页面跳转。
需要注意的是,与纯 Flutter 应用不同,原生应用混编 Flutter 由于涉及到原生页面与 Flutter 页面之间切换,因此导航栈内可能会出现多个 Flutter 容器的情况,即多个 Flutter 实例。
Flutter 实例的初始化成本非常高昂,每启动一个 Flutter 实例,就会创建一套新的渲染机制,即 Flutter Engine,以及底层的 Isolate。而这些实例之间的内存是不互相共享的,会带来较大的系统资源消耗。
因此我们在实际业务开发中,应该尽量用 Flutter 去开发闭环的业务模块,原生只需要能够跳转到 Flutter 模块,剩下的业务都应该在 Flutter 内部完成,而尽量避免 Flutter 页面又跳回到原生页面,原生页面又启动新的 Flutter 实例的情况。
为了解决混编工程中 Flutter 多实例的问题,业界有两种解决方案。
以今日头条为代表的修改 Flutter Engine 源码,使多 FlutterView 实例对应的多 Flutter Engine 能够在底层共享 Isolate;
以闲鱼为代表的共享 FlutterView,即由原生层驱动 Flutter 层渲染内容的方案。
坦白说,这两种方案各有不足。
前者涉及到修改 Flutter 源码,不仅开发维护成本高,而且增加了线程模型和内存回收出现异常的概率,稳定性不可控。
后者涉及到跨渲染引擎的 hack,包括 Flutter 页面的新建、缓存和内存回收等机制,因此在一些低端机或是处理页面切换动画时,容易出现渲染 Bug。
除此之外,这两种方式均与 Flutter 的内部实现绑定较紧,因此在处理 Flutter SDK 版本升级时往往需要耗费较大的适配成本。
综合来说,目前这两种解决方案都不够完美。所以,在 Flutter 官方支持多实例单引擎之前,我们还是尽量在产品模块层面,保证应用内不要出现多个 Flutter 容器实例吧。
为什么需要做状态管理,怎么做
如果我们的应用足够简单,数据流动的方向和顺序是清晰的,我们只需要将数据映射成视图就可以了。作为声明式的框架,Flutter 可以自动处理数据到渲染的全过程,通常并不需要状态管理。
但,随着产品需求迭代节奏加快,项目逐渐变得庞大时,我们往往就需要管理不同组件、不同页面之间共享的数据关系。当需要共享的数据关系达到几十上百个的时候,我们就很难保持清晰的数据流动方向和顺序了,导致应用内各种数据传递嵌套和回调满天飞。在这个时候,我们迫切需要一个解决方案,来帮助我们理清楚这些共享数据的关系,于是状态管理框架便应运而生。
源自 Flutter 官方的状态管理框架 Provider 则相对简单得多,不仅容易理解,而且框架的入侵性小,还可以方便地组合和控制 UI 刷新粒度。因此,在 Google I/O 2019 大会一经面世,Provider 就成为了官方推荐的状态管理方式之一。
Provider
从名字就可以看出,Provider 是一个用来提供数据的框架。它是 InheritedWidget 的语法糖,提供了依赖注入的功能,允许在 Widget 树中更加灵活地处理和传递数据。
那么,什么是依赖注入呢?通俗地说,依赖注入是一种可以让我们在需要时提取到所需资源的机制,即:预先将某种“资源”放到程序中某个我们都可以访问的位置,当需要使用这种“资源”时,直接去这个位置拿即可,而无需关心“资源”是谁放进去的。
为了使用 Provider,我们需要解决以下 3 个问题。
资源(即数据状态)如何封装?
资源放在哪儿,才都能访问得到?
具体使用时,如何取出资源?
在使用 Provider 之前,我们首先需要在 pubspec.yaml 文件中添加 Provider 的依赖.
dependencies:
flutter:
sdk: flutter
provider: 3.0.0+1 #provider依赖
添加好 Provider 的依赖后,我们就可以进行数据状态的封装了。这里,我们只有一个状态需要共享,即 count。由于第二个页面还需要修改状态,因此我们还需要在数据状态的封装上包含更改数据的方法。
//定义需要共享的数据模型,通过混入ChangeNotifier管理听众
class CounterModel with ChangeNotifier {
int _count = 0;
//读方法
int get counter => _count;
//写方法
void increment() {
_count++;
notifyListeners();//通知听众刷新
可以看到,我们在资源封装类中使用 mixin 混入了 ChangeNotifier。这个类能够帮助我们管理所有依赖资源封装类的听众。当资源封装类调用 notifyListeners 时,它会通知所有听众进行刷新。
Provider 实际上是 InheritedWidget 的语法糖,所以通过 Provider 传递的数据从数据流动方向来看,是由父到子(或者反过来)。这时我们就明白了,原来需要把资源放到 FirstPage 和 SecondPage 的父 Widget,也就是应用程序的实例 MyApp 中(当然,把资源放到更高的层级也是可以的,比如放到 main 函数中)。
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
//通过Provider组件封装数据资源
return ChangeNotifierProvider.value(
value: CounterModel(),//需要共享的数据资源
child: MaterialApp(
home: FirstPage(),
Provider 是 InheritedWidget 的语法糖,因此它也是一个 Widget。所以,我们直接在 MaterialApp 的外层使用 Provider 进行包装,就可以把数据资源依赖注入到应用中。
这里需要注意的是,由于封装的数据资源不仅需要为子 Widget 提供读的能力,还要提供写的能力,因此我们需要使用 Provider 的升级版 ChangeNotifierProvider。而如果只需要为子 Widget 提供读能力,直接使用 Provider 即可。
完成数据读写操作
关于读数据,与 InheritedWidget 一样,我们可以通过 Provider.of 方法来获取资源数据。而如果我们想写数据,则需要通过获取到的资源数据,调用其暴露的更新数据方法(本例中对应的是 increment),代码如下所示。
//第一个页面,负责读数据
class FirstPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
//取出资源
final _counter = Provider.of<CounterModel>(context);
return Scaffold(
//展示资源中的数据
body: Text('Counter: ${_counter.counter}'),
//跳转到SecondPage
floatingActionButton: FloatingActionButton(
onPressed: () => Navigator.of(context).push(MaterialPageRoute(builder: (context) => SecondPage()))
//第二个页面,负责读写数据
class SecondPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
//取出资源
final _counter = Provider.of<CounterModel>(context);
return Scaffold(
//展示资源中的数据
body: Text('Counter: ${_counter.counter}'),
//用资源更新方法来设置按钮点击回调
floatingActionButton:FloatingActionButton(
onPressed: _counter.increment,
child: Icon(Icons.add),
Consumer
通过上面的示例可以看到,使用 Provider.of 获取资源,可以得到资源暴露的数据的读写接口,在实现数据的共享和同步上还是比较简单的。但是,滥用 Provider.of 方法也有副作用,那就是当数据更新时,页面中其他的子 Widget 也会跟着一起刷新,如何解决呢?
Provider 可以精确地控制 UI 刷新粒度,而这一切是基于 Consumer 实现的。Consumer 使用了 Builder 模式创建 UI,收到更新通知就会通过 builder 重新构建 Widget。
class SecondPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
//使用Consumer来封装counter的读取
body: Consumer<CounterModel>(
//builder函数可以直接获取到counter参数
builder: (context, CounterModel counter, _) => Text('Value: ${counter.counter}')),
//使用Consumer来封装increment的读取
floatingActionButton: Consumer<CounterModel>(
//builder函数可以直接获取到increment参数
builder: (context, CounterModel counter, child) => FloatingActionButton(
onPressed: counter.increment,
child: child,
child: TestIcon(),
可以看到,Consumer 中的 builder 实际上就是真正刷新 UI 的函数,它接收 3 个参数,即 context、model 和 child。其中:context 是 Widget 的 build 方法传进来的 BuildContext,model 是我们需要的数据资源,而 child 则用来构建那些与数据资源无关的部分。在数据资源发生变更时,builder 会多次执行,但 child 不会重建。
多状态的资源封装
如果有多个数据状态需要共享,我们又该如何处理呢?
按照封装、注入和读写这 3 个步骤,实现多个数据状态的共享。
扩展上面的例子,让两个页面之间展示计数器数据的 Text 能够共享 App 传递的字体大小。
多个数据状态与单个数据的封装并无不同,如果需要支持数据的读写,我们需要一个接一个地为每一个数据状态都封装一个单独的资源封装类;而如果数据是只读的,则可以直接传入原始的数据对象,从而省去资源封装的过程。
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 的升级版 ChangeNotifierProvider 实现了可读写资源的注入,而如果我们想注入多个资源,则可以使用 Provider 的另一个升级版 MultiProvider,来实现多个 Provider 的组合注入。
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MultiProvider(providers: [
Provider.value(value: 30.0),//注入字体大小
ChangeNotifierProvider.value(value: CounterModel())//注入计数器实例
child: MaterialApp(
home: FirstPage(),
还是使用 Provider.of 方式来获取资源。相较于单状态资源的获取来说,获取多个资源时,我们只需要依次读取每一个资源即可。
final _counter = Provider.of<CounterModel>(context);//获取计时器实例
final textSize = Provider.of<double>(context);//获取字体大小
如果以 Consumer 的方式来获取资源的话,我们只要使用 Consumer2 对象(这个对象提供了读取两个数据资源的能力),就可以一次性地获取字体大小与计数器实例这两个数据资源。
//使用Consumer2获取两个数据资源
Consumer2<CounterModel,double>(
//builder函数以参数的形式提供了数据资源
builder: (context, CounterModel counter, double textSize, _) => Text(
'Value: ${counter.counter}',
style: TextStyle(fontSize: textSize))
可以看到,Consumer2 与 Consumer 的使用方式基本一致,只不过是在 builder 方法中多了一个数据资源参数。事实上,如果你希望在子 Widget 中共享更多的数据,我们最多可以使用到 Consumer6,即共享 6 个数据资源。
使用 Provider 可以实现 2 个同样类型的对象共享,应该如何实现吗?
答:可以封装一个大对象,将两个同样类型的对象封装为其内部属性。
如何实现原生推送能力
数据共享不仅存在于客户端内部,同样也存在于服务端与客户端之间。比如,有新的微博评论,或者是发生了重大新闻,我们都需要在服务端把这些状态变更的消息实时推送到客户端,提醒用户有新的内容。有时,我们还会针对特定的用户画像,通过推送实现精准的营销信息触达。
可以说,消息推送是增强用户黏性,促进用户量增长的重要手段。那么,消息推送的流程是什么样的呢?
消息推送流程
消息推送是一个横跨业务服务器、第三方推送服务托管厂商、操作系统长连接推送服务、用户终端、手机应用五方的复杂业务应用场景。
在 iOS 上,苹果推送服务(APNs)接管了系统所有应用的消息通知需求;而 Android 原生,则提供了类似 Firebase 的云消息传递机制(FCM),可以实现统一的推送托管服务。
当某应用需要发送消息通知时,这则消息会由应用的服务器先发给苹果或 Google,经由 APNs 或 FCM 被发送到设备,设备操作系统在完成解析后,最终把消息转给所属应用。这个流程的示意图,如下所示。
不过,Google 服务在大陆地区使用并不稳定,因此国行 Android 手机通常会把 Google 服务换成自己的服务,定制一套推送标准。而这对开发者来说,无疑是增大了适配负担。所以针对 Android 端,我们通常会使用第三方推送服务,比如极光推送、友盟推送等。
虽然这些第三方推送服务使用自建的长连接,无法享受操作系统底层的优化,但它们会对所有使用推送服务的 App 共享推送通道,只要有一个使用第三方推送服务的应用没被系统杀死,就可以让消息及时送达。
而另一方面,这些第三方服务简化了业务服务器与手机推送服务建立连接的操作,使得我们的业务服务器通过简单的 API 调用就可以完成消息推送。而为了保持 Android/iOS 方案的统一,在 iOS 上我们也会使用封装了 APNs 通信的第三方推送服务。
* 这些第三方推送服务厂商提供的能力和接入流程大都一致,考虑到极光的社区和生态相对活跃,以极光推送为例,在Flutter应用中引用原生推送的能力。
##### 原生推送接入流程
* 要想在 Flutter 中接收推送消息,我们需要把原生的推送能力暴露给 Flutter 应用,即在原生代码宿主实现推送能力(极光 SDK)的接入,并通过方法通道提供给 Dart 层感知推送消息的机制。
* 集成极光推送插件,可参考我的这篇博客
【Flutter 第三方SDK集成(友盟统计,极光推送,百度地图)】
在之前学习了如何在原生工程中的 Flutter 应用入口注册原生代码宿主回调,从而实现 Dart 层调用原生接口的方案。这种方案简单直接,适用于 Dart 层与原生接口之间交互代码量少、数据流动清晰的场景。
但对于推送这种涉及 Dart 与原生多方数据流转、代码量大的模块,这种与工程耦合的方案就不利于独立开发维护了。这时,我们需要使用 Flutter 提供的插件工程对其进行单独封装。
Flutter 的插件工程与普通的应用工程类似,都有 android 和 ios 目录,这也是我们完成平台相关逻辑代码的地方,而 Flutter 工程插件的注册,则仍会在应用的入口完成。除此之外,插件工程还内嵌了一个 example 工程,这是一个引用了插件代码的普通 Flutter 应用工程。我们通过 example 工程,可以直接调试插件功能。
在了解了整体工程的目录结构之后,接下来我们需要去 Dart 插件代码所在的 flutter_push_plugin.dart 文件,实现 Dart 层的推送接口封装。
Dart接口实现
为了实现消息的准确触达,我们需要提供一个可以标识手机上 App 的地址,即 token 或 id。一旦完成地址的上报,我们就可以等待业务服务器给我们发消息了。
因为我们需要使用极光这样的第三方推送服务,所以还得进行一些前置的应用信息关联绑定,以及 SDK 的初始化工作。可以看到,对于一个应用而言,接入推送的过程可以拆解为以下三步。
初始化极光 SDK;
获取地址 id;
注册消息通知。
这三步对应着在 Dart 层需要封装的 3 个原生接口调用:setup、registrationID 和 setOpenNotificationHandler。
前两个接口是在方法通道上调用原生代码宿主提供的方法,而注册消息通知的回调函数 setOpenNotificationHandler 则相反,是原生代码宿主在方法通道上调用 Dart 层所提供的事件回调,因此我们需要在方法通道上为原生代码宿主注册反向回调方法,让原生代码宿主收到消息后可以直接通知它。
另外,考虑到推送是整个应用共享的能力,因此我们将 FlutterPushPlugin 这个类封装成了单例。
//Flutter Push插件
class FlutterPushPlugin {
static final FlutterPushPlugin _instance = new FlutterPushPlugin.private(const MethodChannel('flutter_push_plugin'));
//方法通道
final MethodChannel _channel;
//消息回调
EventHandler _onOpenNotification;
//构造方法
FlutterPushPlugin.private(MethodChannel channel) : _channel = channel {
//注册原生反向回调方法,让原生代码宿主可以执行onOpenNotification方法
_channel.setMethodCallHandler(_handleMethod);
//初始化极光SDK
setupWithAppID(String appID) {
_channel.invokeMethod("setup", appID);
//注册消息通知
setOpenNotificationHandler(EventHandler onOpenNotification) {
_onOpenNotification = onOpenNotification;
//注册原生反向回调方法,让原生代码宿主可以执行onOpenNotification方法
Future<Null> _handleMethod(MethodCall call) {
switch (call.method) {
case "onOpenNotification":
return _onOpenNotification(call.arguments);
default:
throw new UnsupportedError("Unrecognized Event");
//获取地址id
Future<String> get registrationID async {
final String regID = await _channel.invokeMethod('getRegistrationID');
return regID;
Dart 层是原生代码宿主的代理,可以看到这一层的接口设计算是简单。接下来,我们分别去接管推送的 Android 和 iOS 平台上完成相应的实现。
Android接口实现
考虑到 Android 平台的推送配置工作相对较少,因此我们先用 Android Studio 打开 example 下的 android 工程进行插件开发工作。需要注意的是,由于 android 子工程的运行依赖于 Flutter 工程编译构建产物,所以在打开 android 工程进行开发前,你需要确保整个工程代码至少 build 过一次,否则 IDE 会报错。
操作步骤参考极光Android SDK集成指南。
首先,我们需要在插件工程下的 build.gradle 引入极光 SDK,即 jpush 与 jcore。
dependencies {
implementation 'cn.jiguang.sdk:jpush:3.3.4'
implementation 'cn.jiguang.sdk:jcore:2.1.2'
然后,在原生接口 FlutterPushPlugin 类中,依次把 Dart 层封装的 3 个接口调用,即 setup、getRegistrationID 与 onOpenNotification,提供极光 Android SDK 的实现版本。
需要注意的是,由于极光 Android SDK 的信息绑定是在应用的打包配置里设置,并不需要通过代码完成(iOS 才需要),因此 setup 方法的 Android 版本是一个空实现。
public class FlutterPushPlugin implements MethodCallHandler {
//注册器,通常为MainActivity
public final Registrar registrar;
//方法通道
private final MethodChannel channel;
//插件实例
public static FlutterPushPlugin instance;
//注册插件
public static void registerWith(Registrar registrar) {
//注册方法通道
final MethodChannel channel = new MethodChannel(registrar.messenger(), "flutter_push_plugin");
instance = new FlutterPushPlugin(registrar, channel);
channel.setMethodCallHandler(instance);
//把初始化极光SDK提前至插件注册时
JPushInterface.setDebugMode(true);
JPushInterface.init(registrar.activity().getApplicationContext());
//私有构造方法
private FlutterPushPlugin(Registrar registrar, MethodChannel channel) {
this.registrar = registrar;
this.channel = channel;
//方法回调
@Override
public void onMethodCall(MethodCall call, Result result) {
if (call.method.equals("setup")) {
//极光Android SDK的初始化工作需要在App工程中配置,因此不需要代码实现
result.success(0);
else if (call.method.equals("getRegistrationID")) {
//获取极光推送地址标识符
result.success(JPushInterface.getRegistrationID(registrar.context()));
} else {
result.notImplemented();
public void callbackNotificationOpened(NotificationMessage message) {
//将推送消息回调给Dart层
channel.invokeMethod("onOpenNotification",message.notificationContent);
iOS接口实现
与 Android 类似,我们需要使用 Xcode 打开 example 下的 ios 工程进行插件开发工作。同样,在打开 ios 工程前,你需要确保整个工程代码至少 build 过一次,否则 IDE 会报错。
操作步骤参考极光 iOS SDK 集成指南
首先,我们需要在插件工程下的 flutter_push_plugin.podspec 文件中引入极光 SDK,即 jpush。这里,我们选用了不使用广告 id 的版本。
Pod::Spec.new do |s|
s.dependency 'JPush', '3.2.2-noidfa'
然后,在原生接口 FlutterPushPlugin 类中,同样依次为 setup、getRegistrationID 与 onOpenNotification,提供极光 iOS SDK 的实现版本。
需要注意的是,APNs 的推送消息是在 ApplicationDelegate 中回调的,所以我们需要在注册插件时,为插件提供同名的回调函数,让极光 SDK 把推送消息转发到插件的回调函数中。
与 Android 类似,在极光 SDK 收到推送消息时,我们的应用可能处于后台,因此在用户点击了推送消息,把 Flutter 应用唤醒时,我们应该在确保 Flutter 已经完全初始化后,才能通知 Flutter 有新的推送消息。
因此在下面的代码中,我们在用户点击了推送消息后也等待了 1 秒,才执行相应的 Flutter 回调通知。
@implementation FlutterPushPlugin
//注册插件
+ (void)registerWithRegistrar:(NSObject<FlutterPluginRegistrar>*)registrar {
//注册方法通道
FlutterMethodChannel* channel = [FlutterMethodChannel methodChannelWithName:@"flutter_push_plugin" binaryMessenger:[registrar messenger]];
//初始化插件实例,绑定方法通道
FlutterPushPlugin* instance = [[FlutterPushPlugin alloc] init];
instance.channel = channel;
//为插件提供ApplicationDelegate回调方法
[registrar addApplicationDelegate:instance];
//注册方法通道回调函数
[registrar addMethodCallDelegate:instance channel:channel];
//处理方法调用
- (void)handleMethodCall:(FlutterMethodCall*)call result:(FlutterResult)result {
if([@"setup" isEqualToString:call.method]) {
//极光SDK初始化方法
[JPUSHService setupWithOption:self.launchOptions appKey:call.arguments channel:@"App Store" apsForProduction:YES advertisingIdentifier:nil];
} else if ([@"getRegistrationID" isEqualToString:call.method]) {
//获取极光推送地址标识符
[JPUSHService registrationIDCompletionHandler:^(int resCode, NSString *registrationID) {
result(registrationID);
} else {
//方法未实现
result(FlutterMethodNotImplemented);
//应用程序启动回调
-(BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
//初始化极光推送服务
JPUSHRegisterEntity * entity = [[JPUSHRegisterEntity alloc] init];
//设置推送权限
entity.types = JPAuthorizationOptionAlert|JPAuthorizationOptionBadge|JPAuthorizationOptionSound;
//请求推送服务
[JPUSHService registerForRemoteNotificationConfig:entity delegate:self];
//存储App启动状态,用于后续初始化调用
self.launchOptions = launchOptions;
return YES;
//推送token回调
- (void)application:(UIApplication *)application didRegisterForRemoteNotificationsWithDeviceToken:(NSData *)deviceToken {
///注册DeviceToken,换取极光推送地址标识符
[JPUSHService registerDeviceToken:deviceToken];
//推送被点击回调
- (void)jpushNotificationCenter:(UNUserNotificationCenter *)center didReceiveNotificationResponse:(UNNotificationResponse *)response withCompletionHandler:(void(^)(void))completionHandler {
//获取推送消息
NSDictionary * userInfo = response.notification.request.content.userInfo;
NSString *content = userInfo[@"aps"][@"alert"];
if ([content isKindOfClass:[NSDictionary class]]) {
content = userInfo[@"aps"][@"alert"][@"body"];
//延迟1秒通知Flutter,确保Flutter应用已完成初始化
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(1 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
[self.channel invokeMethod:@"onOpenNotification" arguments:content];
//清除应用的小红点
UIApplication.sharedApplication.applicationIconBadgeNumber = 0;
//通知系统,推送回调处理完毕
completionHandler();
//前台应用收到了推送消息
- (void)jpushNotificationCenter:(UNUserNotificationCenter *)center willPresentNotification:(UNNotification *)notification withCompletionHandler:(void (^)(NSInteger options))completionHandler {
//通知系统展示推送消息提示
completionHandler(UNNotificationPresentationOptionAlert);
FlutterPushPlugin 插件为 Flutter 应用提供了原生推送的封装,不过要想 example 工程能够真正地接收到推送消息,我们还需要对 exmaple 工程进行最后的配置,即:为它提供应用推送证书,并关联极光应用配置。
应用工程配置
在单独为 Android/iOS 应用进行推送配置之前,我们首先需要去极光的官方网站,为 example 应用注册一个唯一标识符(即 AppKey)。
在得到了 AppKey 之后,我们需要依次进行 Android 与 iOS 的配置工作。
Android 的配置工作相对简单,整个配置过程完全是应用与极光 SDK 的关联工作。
首先,根据 example 的 Android 工程包名,完成 Android 工程的推送注册。
然后,通过 AppKey,在 app 的 build.gradle 文件中实现极光信息的绑定。
defaultConfig {
//ndk支持架构
ndk {
abiFilters 'armeabi', 'armeabi-v7a', 'arm64-v8a'
manifestPlaceholders = [
JPUSH_PKGNAME : applicationId, //包名
JPUSH_APPKEY : "f861910af12a509b34e266c2", //JPush 上注册的包名对应的Appkey
JPUSH_CHANNEL : "developer-default", //填写默认值即可
iOS 的应用配置相对 Android 会繁琐一些,因为整个配置过程涉及应用、苹果 APNs 服务、极光三方之间的信息关联。
除了需要在应用内绑定极光信息之外(即 handleMethodCall 中的 setup 方法),还需要在苹果的开发者官网提前申请苹果的推送证书。关于申请证书,苹果提供了.p12 证书和 APNs Auth Key 两种鉴权方式。
这里,我推荐使用更为简单的 Auth Key 方式。申请推送证书的过程,极光官网提供了详细的注册步骤,这里我就不再赘述了。需要注意的是,申请 iOS 的推送证书时,你只能使用付费的苹果开发者账号。
在拿到了 APNs Auth Key 之后,我们同样需要去极光官网,根据 Bundle ID 进行推送设置,并把 Auth Key 上传至极光进行托管,由它完成与苹果的鉴权工作。
接下来,我们回到 Xcode 打开的 example 工程,进行最后的配置工作。
首先,我们需要为 example 工程开启 Application Target 的 Capabilities->Push Notifications 选项,启动应用的推送能力支持,如下图所示
然后,我们需要切换到 Application Target 的 Info 面板,手动配置 NSAppTransportSecurity 键值对,以支持极光 SDK 非 https 域名服务。
最后,在 Info tab 下的 Bundle identifier 项,把我们刚刚在极光官网注册的 Bundle ID 显式地更新进去。
接下来,我们就可以在 example 工程中的 main.dart 文件中,使用 FlutterPushPlugin 插件来实现原生推送能力了。
在下面的代码中,我们在 main 函数的入口,使用插件单例注册了极光推送服务,随后在应用 State 初始化时,获取了极光推送地址,并设置了消息推送回调。
//获取推送插件单例
FlutterPushPlugin fpush = FlutterPushPlugin();
void main() {
//使用AppID注册极光推送服务(仅针对iOS平台)
fpush.setupWithAppID("f861910af12a509b34e266c2");
runApp(MyApp());
class MyApp extends StatefulWidget {
@override
_MyAppState createState() => _MyAppState();
class _MyAppState extends State<MyApp> {
//极光推送地址regID
String _regID = 'Unknown';
//接收到的推送消息
String _notification = "";
@override
initState() {
super.initState();
//注册推送消息回调
fpush.setOpenNotificationHandler((String message) async {
//刷新界面状态,展示推送消息
setState(() {
_notification = message;
//获取推送地址regID
initPlatformState();
initPlatformState() async {
//调用插件封装的regID
String regID = await fpush.registrationID;
//刷新界面状态,展示regID
setState(() {
_regID = regID;
@override
Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(
body: Center(
child: Column(
children: <Widget>[
//展示regID,以及收到的消息
Text('Running on: $_regID\n'),
Text('Notification Received: $_notification')
接下来,我们再去极光开发者服务后台发一条真实的推送消息。在服务后台选择我们的 App,随后进入极光推送控制台。这时,我们就可以进行消息推送测试了。
需要注意的是,我们今天的实际工程演示是通过内嵌的 example 工程示例所完成的,如果你有一个独立的 Flutter 工程(比如Flutter_Push_Demo)需要接入 Flutter_Push_Plugin,其配置方式与 example 工程并无不同,唯一的区别是,需要在 pubspec.yaml 文件中将对插件的依赖显示地声明出来而已:
适配国际化,除了多语言我们还需要注意什么
借助于 App Store 与 Google Play,我们能够把应用发布到全世界的任何一个应用商店里。应用的(潜在)使用者可能来自于不同国家、说着不同的语言。如果我们想为全世界的使用者提供统一而标准的体验,那么首先就需要让 App 能够支持多种语言。而这一过程,一般被称为“国际化”。
提起国际化,你可能会认为这等同于翻译 App 内所有用户可见的文本。其实,这个观点不够精确。更为准确地描述国际化的工作职责,应该是“涉及语言及地区差异的适配改造过程”。
比如,如果我们要显示金额,同样的面值,在中国会显示为¥100,而在美国则会显示为 $100;又比如,App 的引导图,在中国我们可能会选用长城作为背景,而在美国我们则可能会选择金门大桥作为背景。
因此,对一款 App 做国际化的具体过程,除了翻译文案之外,还需要将货币单位和背景图等资源也设计成可根据不同地区自适应的变量。这也就意味着,我们在设计 App 架构时,需要提前将语言与地区的差异部分独立出来。
其实,这也是在 Flutter 中进行国际化的整体思路,即语言差异配置抽取 + 国际化代码生成。而在语言差异配置抽取的过程中,文案、货币单位,以及背景图资源的处理,其实并没有本质区别。
默认情况下,Flutter仅支持美国英语本地化,如果想要添加其它语言支持,需要指定其它MaterialApp属性,并引入flutter_localizations包。
在pubspec.yaml文件中添加包依赖,代码如下。
flutter_localizations:
sdk: flutter
然后引入Flutter_localizations并为MaterialApp指定localizationsDelegates和supported-Locales,代码如下。
MaterialApp(
//生成本地化值的集合;
localizationsDelegates: [
//Material组件库提供本地化的字符和其他值
GlobalMaterialLocalizations.delegate,
//定义Widget默认文本方向,即从左到右或从右到左
GlobalWidgetsLocalizations.delegate,
//为Cupertino库(iOS风格)提高本地化的字符串和其他值
GlobalCupertinoLocalizations.delegate,
//支持本地化区域的集合
supportedLocales: [
//Locale标识用户的语言环境
Locale('zh'),
Locale('en'),
将手机语言切换,内容也将切换。通过如下方法获取当前区域设置。
Locale myLocale=Localizations.localeOf(context);
监听系统语言切换
当我们更改系统语言设置时,Localizations组件将会重新构建,用户只看到了语言的切换。监听语言切换通过localeResolutionCallback或localeListResolutionCallback。localeListResolutionCallback返回一个Locale,此Locale表示最终使用的Locale。一般情况下,当App不支持当前语言时会返回一个默认值。用法如下。
MaterialApp(
supportedLocales: [
Locale('zh'),
Locale('en'),
localeListResolutionCallback: (List<Locale> locales,Iterable<Locale> supportLocales){
print('$locales');
print('$supportLocales');
开发的UI支持国际化
需要实现两个类:Localizations和Delegate。Localizations类的实现如下。
class SimpleLocalizations {
SimpleLocalizations(this._locale);
final Locale _locale;
static SimpleLocalizations of(BuildContext context) {
return Localizations.of<SimpleLocalizations>(context, SimpleLocalizations);
Map<String, Map<String, String>> _localizedValues = {
"zh": valuesZHCN,
"en": valuesEN
Map<String, String> get values {
if (_locale == null) {
return _localizedValues['zh'];
return _localizedValues[_locale.languageCode];
static const LocalizationsDelegate<SimpleLocalizations> delegate =
_SimpleLocalizationsDelegate();
static Future<SimpleLocalizations> load(Locale locale) {
return SynchronousFuture<SimpleLocalizations>(SimpleLocalizations(locale));
其中,valuesZHCN和valuesEN分别是中文文案和英文文案,代码如下。
var valuesZHCN = {
LocalizationsKey.appName: "应用名称",
LocalizationsKey.title: "标题"
var valuesEN = {
LocalizationsKey.appName: "App Name",
LocalizationsKey.title: "Title"
valuesZHCN和valuesEN是Map,定义了相应的key和value。为了更好的管理及方便使用,将key统一定义,代码如下。
class LocalizationsKey {
static const appName = "app_name";
static const title = "title";
Delegate的代码实现如下。
class _SimpleLocalizationsDelegate
extends LocalizationsDelegate<SimpleLocalizations> {
const _SimpleLocalizationsDelegate();
//是否支持某个Locale,正常情况返回true。
@override
bool isSupported(Locale locale) => true;
//加载相应的Locale资源类
@override
Future<SimpleLocalizations> load(Locale locale) =>
SimpleLocalizations.load(locale);
//返回值决定当Localizations组件重新构建是,是否调用load方法重新加载Locale资源,
//通常情况不需要,返回false即可。
@override
bool shouldReload(LocalizationsDelegate<SimpleLocalizations> old) => false;
实现了Localizations和Delegate后,在MaterialApp中注册Delegate,代码如下。
localizationsDelegates: [
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
SimpleLocalizations.delegate,
在组件中使用国际化的值,代码如下。
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(widget.title),
body: Center(
child: Text(
"当前App_Name的国际化属性值:${ SimpleLocalizations.of(context).values[LocalizationsKey.appName]}"
中文显示"应用名称",英文显示”App Name“。
上面实现了应用程序国际化,但是有一个缺陷,开发者需要知道不同国家/地区的语言码和地域码,世界上有那么多国家,开发者很难记住这些语言码和地域码,使用Intl可以简化此流程。
Intl包可以让开发者轻松的实现国际化,并将文本分离为单独的文件,方便开发人员开发,在pubspec.yaml中添加如下包依赖。
dependencies:
intl: ^0.16.1
dev_dependencies:
flutter_test:
sdk: flutter
intl_translation: ^0.17.3
在lib下创建locations/intl_messages目录,存放Intl相关文件,实现Localizations和Delegate类。Localizations的实现如下。
class IntlLocalizations {
IntlLocalizations();
static IntlLocalizations of(BuildContext context) {
return Localizations.of<IntlLocalizations>(context, IntlLocalizations);
String get appName {
return Intl.message('app_name');
static const LocalizationsDelegate<IntlLocalizations> delegate =_IntlLocalizationsDelegate();
static Future<IntlLocalizations> load(Locale locale) async{
final String localeName=Intl.canonicalizedLocale(locale.toString());
//代码会报错,在使用intl_trnaslation工具生成arb文件再转换成dart文件就不会了。
await initializeMessages(localeName);
Intl.defaultLocale=localeName;
return IntlLocalizations();
这里使用Intl.message获取文本值,Delegate的实现如下。
class _IntlLocalizationsDelegate
extends LocalizationsDelegate<IntlLocalizations> {
const _IntlLocalizationsDelegate();
@override
bool isSupported(Locale locale)=>true;
@override
Future<IntlLocalizations> load(Locale locale)=>IntlLocalizations.load(locale);
@override
bool shouldReload(LocalizationsDelegate<IntlLocalizations> old)=>false;
通过intl_translation生成arb文件,命令如下。打开Android Studio的Terminal。
flutter pub run intl_translation:extract_to_arb --output-dir=lib/locations/intl_messages lib/locations/intl_messages/intl_localizations.dart
其中,lib/locations/intl_messages 是创建的目录,ntl_localization.dart是Localizations的实现文件。成功后会在lib/locations/intl_messages下生成intl_messages.arb文件,内容如下。
"@@last_modified": "2020-09-11T16:20:39.991382",
"app_name": "app_name",
"@app_name": {
"type": "text",
"placeholders": {}
如果想添加英文支持,复制当前文件并修改名称为intl_en_US.arb,内容如下。支持其它语言类似。
"@@last_modified": "2020-09-11T16:20:39.991382",
"app_name": "app_name",
"@app_name": {
"type": "en_US",
"placeholders": {}
通过intl_translation工具将arb文件生成dart文件,命令如下。
flutter pub run intl_translation:generate_from_arb --output-dir=lib/locations/intl_messages --no-use-deferred-loading lib/locations/intl_messages/intl_localizations.dart lib/locations/intl_messages/intl_*.arb
此时,会在intl_message目录下生成多个以message开头的文件,上面IntlLocalizations报错的代码导入message_all包即可。
每一个arb文件对于一个dart文件,在MaterialApp添加当前Localizations的支持,代码如下。
localizationsDelegates: [
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
SimpleLocalizations.delegate,
IntlLocalizations.delegate
使用方式和正常的Localizations一样,代码如下。
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(widget.title),
body: Center(
child: Text(
"当前App_Name的国际化属性值:${IntlLocalizations.of(context).appName}"
~~ Flutter i18n ~~
注:Flutter i8n已经停止维护。
在 Flutter 中,国际化的语言和地区的差异性配置,是应用程序代码的一部分。如果要在 Flutter 中实现文本的国际化,我们需要执行以下几步。
首先,实现一个 LocalizationsDelegate(即翻译代理),并将所有需要翻译的文案全部声明为它的属性;
然后,依次为需要支持的语言地区进行手动翻译适配;
最后,在应用程序 MaterialApp 初始化时,将这个代理类设置为应用程序的翻译回调。
使用官方提供的国际化方案来设计 App 架构,不仅工作量大、繁琐,而且极易出错。所以,要开始 Flutter 应用的国际化道路,我们不如把官方的解决方案扔到一边,直接从 Android Studio 中的 Flutter i18n 插件开始学习。这个插件在其内部提供了不同语言地区的配置封装,能够帮助我们自动地从翻译稿生成 Dart 代码。
在Android Studio的Settings里,找到Plugins,搜索Flutter i18n下载即可。
lutter 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 文件。
* 试着修改一下 strings_zh.arb 文件,可以看到,Flutter i18n 插件为我们自动生成了 generated/i18n.dart。这个类中不仅以资源标识符属性的方式提供了静态文案的翻译映射,对于通过参数来实现动态文案的 message_tip 标识符,也自动生成了一个同名内联函数。

* 我们把 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),
由于 iOS 应用程序有一套自建的语言环境管理机制,默认是英文。为了让 iOS 应用正确地支持国际化,我们还需要在原生的 iOS 工程中进行额外的配置。我们打开 iOS 原生工程,切换到工程面板。在 Localization 这一项配置中,我们看到 iOS 工程已经默认支持了英文,所以还需要点击“+”按钮,新增中文。
* 完成 iOS 的工程配置后,我们回到 Flutter 工程,选择 iOS 手机运行程序。可以看到,计数器的 iOS 版本也可以正确地支持国际化了。
##### 原生工程配置
* 上面介绍的国际化方案,其实都是在 Flutter 应用内实现的。而在 Flutter 框架运行之前,我们是无法访问这些国际化文案的。
* Flutter 需要原生环境才能运行,但有些文案,比如应用的名称,我们需要在 Flutter 框架运行之前就为它提供多个语言版本(比如英文版本为 computer,中文版本为计数器),这时就需要在对应的原生工程中完成相应的国际化配置了。
###### Android 工程下进行应用名称的配置
* 首先,在 Android 工程中,应用名称是在 AndroidManifest.xml 文件中 application 的 android:label 属性声明的,所以我们需要将其修改为字符串资源中的一个引用,让其能够根据语言地区自动选择合适的文案。
<manifest ... >
<!-- 设置应用名称 -->
<application
android:label="@string/title"
</application>
</manifest>
然后,我们还需要在 android/app/src/main/res 文件夹中,为要支持的语言创建字符串 strings.xml 文件。这里由于默认文件是英文的,所以我们只需要为中文创建一个文件即可。字符串资源的文件目录结构,如下图所示。
values 与 values-zh 文件夹下的 strings.xml 内容如下所示。
<!--英文(默认)字符串资源-->
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="title">Computer</string>
</resources>
<!--中文字符串资源-->
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="title">计数器</string>
</resources>
iOS工程实现应用名称的配置
与 Android 工程类似,iOS 工程中的应用名称是在 Info.list 文件的 Bundle name 属性声明的,所以我们也需要将其修改为字符串资源中的一个引用,使其能够根据语言地区自动选择文案。
* 由于应用名称默认是不可配置的,所以工程并没有提供英文或者中文的可配置项,这些都需要通过新建与字符串引用对应的资源文件去搞定的。
* 我们右键单击 Runner 文件夹,然后选择 New File 来添加名为 InfoPlist.strings 的字符串资源文件,并在工程面板的最右侧文件检查器中的 Localization 选项中,添加英文和中文两种语言。InfoPlist.strings 的英文版和中文版内容如下所示。
//英文版
"CFBundleName" = "Computer";
//中文版
"CFBundleName" = "计数器";
如何适配不同分辨率的手机屏幕
在移动应用的世界中,页面是由控件组成的。如果我们支持的设备只有普通手机,可以确保同一个页面、同一个控件,在不同的手机屏幕上的显示效果是基本一致的。
但,随着平板电脑和类平板电脑等超大屏手机越来越普及,很多原本只在普通手机上运行的应用也逐渐跑在了平板上。但,由于平板电脑的屏幕非常大,展示适配普通手机的界面和控件时,可能会出现 UI 异常的情况。比如,对于新闻类手机应用来说,通常会有新闻列表和新闻详情两个页面,如果我们把这两个页面原封不动地搬到平板电脑上,就会出现控件被拉伸、文字过小过密、图片清晰度不够、屏幕空间被浪费的异常体验。
而另一方面,即使对于同一台手机或平板电脑来说,屏幕的宽高配置也不是一成不变的。因为加速度传感器的存在,所以当我们旋转屏幕时,屏幕宽高配置会发生逆转,即垂直方向与水平方向的布局行为会互相交换,从而导致控件被拉伸等 UI 异常问题。
因此,为了让用户在不同的屏幕宽高配置下获得最佳的体验,我们不仅需要对平板进行屏幕适配,充分利用额外可用的屏幕空间,也需要在屏幕方向改变时重新排列控件。即,我们需要优化应用程序的界面布局,为用户提供新功能、展示新内容,以将拉伸变形的界面和控件替换为更自然的布局,将单一的视图合并为复合视图。
在 Flutter 中,屏幕适配的原理也非常类似,只不过 Flutter 并没有布局文件的概念,我们需要准备多个布局来实现。
适配屏幕旋转
为了适配竖屏模式与横屏模式,我们需要准备两个布局方案,一个用于纵向,一个用于横向。当设备改变方向时,Flutter 会通知我们重建布局:Flutter 提供的 OrientationBuilder 控件,可以在设备改变方向时,通过 builder 函数回调告知其状态。这样,我们就可以根据回调函数提供的 orientation 参数,来识别当前设备究竟是处于横屏(landscape)还是竖屏(portrait)状态,从而刷新界面。
@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 的实用性和可读性。而,我们也可以通过独立的区块,在不同尺寸的手机屏幕上快速复用视觉功能。
* 首先,我们需要分别为新闻列表与新闻详情创建两个可重用的独立区块。
* 新闻列表,可以在元素被点击时通过回调函数告诉父 Widget 元素索引;
* 而新闻详情,则用于展示新闻列表中被点击的元素索引。
* 对于手机来说,由于空间小,所以新闻列表区块和新闻详情区块都是独立的页面,可以通过点击新闻元素进行新闻详情页面的切换;而对于平板电脑(和手机横屏布局)来说,由于空间足够大,所以我们把这两个区块放置在同一个页面,可以通过点击新闻元素去刷新同一页面的新闻详情。
* 页面的实现和区块的实现是互相独立的,通过区块复用就可以减少编写两个独立布局的工作。
//列表Widget
class ListWidget extends StatefulWidget {
final ItemSelectedCallback onItemSelected;
ListWidget(
this.onItemSelected,//列表被点击的回调函数
@override
_ListWidgetState createState() => _ListWidgetState();
class _ListWidgetState extends State<ListWidget> {
@override
Widget build(BuildContext context) {
//创建一个20项元素的列表
return ListView.builder(
itemCount: 20,
itemBuilder: (context, position) {
return ListTile(
title: Text(position.toString()),//标题为index
onTap:()=>widget.onItemSelected(position),//点击后回调函数
//详情Widget
class DetailWidget extends StatefulWidget {
final int data; //新闻列表被点击元素索引
DetailWidget(this.data);
@override
_DetailWidgetState createState() => _DetailWidgetState();
class _DetailWidgetState extends State<DetailWidget> {
@override
Widget build(BuildContext context) {
return Container(
color: Colors.red,//容器背景色
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Text(widget.data.toString()),//居中展示列表被点击元素索引
然后,我们只需要检查设备屏幕是否有足够的宽度来同时展示列表与详情部分。为了获取屏幕宽度,我们可以使用 MediaQueryData 提供的 size 方法。
在这里,我们将平板电脑的判断条件设置为宽度大于 480。这样,屏幕中就有足够的空间可以切换到多窗格的复合布局了。
if(MediaQuery.of(context).size.width > 480) {
//tablet
} else {
//phone
最后,如果宽度够大,我们就会使用 Row 控件将列表与详情包装在同一个页面中,用户可以点击左侧的列表刷新右侧的详情;如果宽度比较小,那我们就只展示列表,用户可以点击列表,导航到新的页面展示详情。
class _MasterDetailPageState extends State<MasterDetailPage> {
var selectedValue = 0;
@override
Widget build(BuildContext context) {
return Scaffold(
body: OrientationBuilder(builder: (context, orientation) {
//平板或横屏手机,页面内嵌列表ListWidget与详情DetailWidget
if (MediaQuery.of(context).size.width > 480) {
return Row(children: <Widget>[
Expanded(
child: ListWidget((value) {//在列表点击回调方法中刷新右侧详情页
setState(() {selectedValue = value;});
Expanded(child: DetailWidget(selectedValue)),
} else {//普通手机,页面内嵌列表ListWidget
return ListWidget((value) {//在列表点击回调方法中打开详情页DetailWidget
Navigator.push(context, MaterialPageRoute(
builder: (context) {
return Scaffold(
body: DetailWidget(value),
如何理解Flutter的编译模式
Flutter的编译模式
Flutter 支持 3 种运行模式,包括 Debug、Release 和 Profile。在编译时,这三种模式是完全独立的。
Debug 模式对应 Dart 的 JIT 模式,可以在真机和模拟器上同时运行。该模式会打开所有的断言(assert),以及所有的调试信息、服务扩展和调试辅助(比如 Observatory)。此外,该模式为快速开发和运行做了优化,支持亚秒级有状态的 Hot reload(热重载),但并没有优化代码执行速度、二进制包大小和部署。flutter run --debug 命令,就是以这种模式运行的。
Release 模式对应 Dart 的 AOT 模式,只能在真机上运行,不能在模拟器上运行,其编译目标为最终的线上发布,给最终的用户使用。该模式会关闭所有的断言,以及尽可能多的调试信息、服务扩展和调试辅助。此外,该模式优化了应用快速启动、代码快速执行,以及二级制包大小,因此编译时间较长。flutter run --release 命令,就是以这种模式运行的。
Profile 模式,基本与 Release 模式一致,只是多了对 Profile 模式的服务扩展的支持,包括支持跟踪,以及一些为了最低限度支持所需要的依赖(比如,可以连接 Observatory 到进程)。该模式用于分析真实设备实际运行性能。flutter run --profile 命令,就是以这种模式运行的。
在开发应用时,为了便于快速发现问题,我们通常会在运行时识别当前的编译模式,去改变代码的部分执行行为:在 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 个步骤。
抽象出应用程序的可配置部分,并使用 InheritedWidget 对其进行封装;
将不同的配置环境拆解为多个应用程序入口(比如,开发环境为 main-dev.dart、生产环境为 main.dart),把应用程序的可配置部分固化在各个入口处;
在运行期,通过 InheritedWidget 提供的数据共享机制,将配置部分应用到其子 Widget 对应的功能中;
使用 Flutter 提供的编译打包选项,构建出不同配置环境的安装包。
在下面的示例中,把应用程序调用的接口和标题进行区分实现,即开发环境使用 dev.example.com 域名,应用主页标题为 dev;而生产环境使用 api.example.com 域名,主页标题为 example。
首先是配置抽象。根据需求可以看出,应用程序中有两个需要配置的部分,即接口 apiBaseUrl 和标题 appName,因此我定义了一个继承自 InheritedWidget 的类 AppConfig,对这两个配置进行封装。