Flutter技术与实战(5)

Flutter技术与实战(5)

八归少年 3,757 2020-09-13

Flutter进阶

如何构造炫酷的动画效果

  • 动画就是提升用户体验的一个重要方式,一个恰当的组件动画或者页面切换动画,不仅能够缓解用户因为等待而带来的情绪问题,还会增加好感。Flutter 既然完全接管了渲染层,除了静态的页面布局之外,对组件动画的支持自然也不在话下。
Animation、AnimationController与Listener
  • 动画就是动起来的画面,是静态的画面根据事先定义好的规律,在一定时间内不断微调,产生变化效果。而动画实现由静止到动态,主要是靠人眼的视觉残留效应。所以,对 动画系统而言,为了实现动画,它需要做三件事儿:
    • 确定画面变化的规律;
    • 根据这个规律,设定动画周期,启动动画;
    • 定期获取当前动画的值,不断地微调、重绘画面。
  • Animation 是 Flutter 动画库中的核心类,会根据预定规则,在单位时间内持续输出动画的当前状态。Animation 知道当前动画的状态(比如,动画是否开始、停止、前进或者后退,以及动画的当前值),但却不知道这些状态究竟应用在哪个组件对象上。换句话说,Animation 仅仅是用来提供动画数据,而不负责动画的渲染。
  • AnimationController 用于管理 Animation,可以用来设置动画的时长、启动动画、暂停动画、反转动画等。
  • Listener 是 Animation 的回调函数,用来监听动画的进度变化,我们需要在这个回调函数中,根据动画的当前值重新渲染组件,实现动画的渲染。
  • 初始化了一个动画周期为 1 秒的、用于管理动画的 AnimationController 对象,并用线性变化的 Tween 创建了一个变化范围从 50 到 200 的 Animaiton 对象。
  • 然后,给这个 Animaiton 对象设置了一个进度监听器,并在进度监听器中强制界面重绘,刷新动画状态。
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
异步任务
  • 事实上,上图的Event Loop 示意图只是一个简化版。在 Dart 中,实际上有两个队列,一个事件队列(Event Queue),另一个则是微任务队列(Microtask Queue)。在每一次事件循环中,Dart 总是先去第一个微任务队列中查询是否有可执行的任务,如果没有,才会处理后续的事件队列的流程。

  • 所以,Event Loop 完整版的流程图,应该如下所示。
    Microtask Queue与Event Queue

  • 分别看一下这两个队列的特点和使用场景。

  • 微任务队列。微任务顾名思义,表示一个短时间内就会完成的异步任务。从上面的流程图可以看到,微任务队列在事件循环中的优先级是最高的,只要队列中还有任务,就可以一直霸占着事件循环。

  • 微任务是由 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
Event Queue与Microtask Queue变化示例
  • 依次分析一下它们的执行顺序。
    • 因为其他语句都是异步任务,所以先打印 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,以回调的方式进行相应的事件处理。
http
  • 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}");
  }
}
dio
  • 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 平台间的常见数据类型转换。
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 中,演示如何使用平台视图。这部分内容主要包括两部分。

    • 作为调用发起方的 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');
    }
  }
}
如何在原生系统实现接口

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;
@end

@implementation SampleViewFactory{
  NSObject<FlutterBinaryMessenger>*_messenger;
}

- (instancetype)initWithMessenger:(NSObject<FlutterBinaryMessenger> *)messager{
  self = [super init];
  if (self) {
    _messenger = messager;
  }
  return self;
}

-(NSObject<FlutterMessageCodec> *)createArgsCodec{
  return [FlutterStandardMessageCodec sharedInstance];
}

//创建原生视图封装实例
-(NSObject<FlutterPlatformView> *)createWithFrame:(CGRect)frame viewIdentifier:(int64_t)viewId arguments:(id)args{
  SampleViewControl *activity = [[SampleViewControl alloc] initWithWithFrame:frame viewIdentifier:viewId arguments:args binaryMessenger:_messenger];
  return activity;
}
@end

//平台视图封装类
@interface SampleViewControl : NSObject<FlutterPlatformView>
- (instancetype)initWithWithFrame:(CGRect)frame viewIdentifier:(int64_t)viewId arguments:(id _Nullable)args binaryMessenger:(NSObject<FlutterBinaryMessenger>*)messenger;
@end

@implementation SampleViewControl{
    UIView * _templcateView;
}
//创建原生视图
- (instancetype)initWithWithFrame:(CGRect)frame viewIdentifier:(int64_t)viewId arguments:(id)args binaryMessenger:(NSObject<FlutterBinaryMessenger> *)messenger{
  if ([super init]) {
    _templcateView = [[UIView alloc] init];
    _templcateView.backgroundColor = [UIColor redColor];
  }
  return self;
}

-(UIView *)view{
  return _templcateView;
}

@end
  • 然后,我们同样需要把原生视图的创建与 Flutter 侧的调用关联起来,才可以在 Flutter 侧找到原生视图的实现。
- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
  NSObject<FlutterPluginRegistrar>* registrar = [self registrarForPlugin:@"samples.chenhang/native_views"];//生成注册类
  SampleViewFactory* viewFactory = [[SampleViewFactory alloc] initWithMessenger:registrar.messenger];//生成视图工厂
    [registrar registerViewFactory:viewFactory withId:@"sampleView"];//注册视图工厂
 ...
}
  • 需要注意的是,在 iOS 平台上,Flutter 内嵌 UIKitView 目前还处于技术预览状态,因此我们还需要在 Info.plist 文件中增加一项配置,把内嵌原生视图的功能开关设置为 true,才能打开这个隐藏功能。
<dict>
   ...
  <key>io.flutter.embedded_views_preview</key>
  <true/>
  ....
</dict>
  • 经过上面的封装与绑定,Android 端与 iOS 端的平台视图功能都已经实现了。接下来,我们就可以在 Flutter 应用里,像使用普通 Widget 一样,去内嵌原生视图了。
Scaffold(
        backgroundColor: Colors.yellowAccent,
        body:  Container(width: 200, height:200,
            child: SampleView(controller: controller)
        ));
  • 在上面的例子中,我们将原生视图封装在一个 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);
    }
}
 ...
@end
  • 通过注册方法通道,以及暴露的 changeBackgroundColor 接口,Android 端与 iOS 端修改平台视图背景颜色的功能都已经实现了。接下来,我们就可以在 Flutter 应用运行期间,修改原生视图展示样式了。
class DefaultState extends State<DefaultPage> {
  NativeViewController controller;
  @override
  void initState() {
    controller = NativeViewController();//初始化原生View控制器
  super.initState();
  }
  
  @override
  Widget build(BuildContext context) {
    return Scaffold(
          ...
          //内嵌原生View
          body:  Container(width: 200, height:200,
              child: SampleView(controller: controller)
          ),
         //设置点击行为:改变视图颜色 
         floatingActionButton: FloatingActionButton(onPressed: ()=>controller.changeBackgroundColor())
    );
  }
}
  • 需要注意的是,由于 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
end
  • 然后,在命令行输入 pod install 后,会自动生成一个 iOSDemo.xcworkspace 文件,这时我们就完成了 iOS 工程改造。
Flutter混编方案介绍
  • 在已有的原生 App 里嵌入一些 Flutter 页面,有两个办法。
    • 将原生工程作为 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 工程相比,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.
                       DESC
  s.homepage         = 'https://github.com/xx/FlutterEngine'
  s.license          = { :type => 'MIT', :file => 'LICENSE' }
  s.author           = { 'chenhang' => 'hangisnice@gmail.com' }
  s.source       = { :git => "", :tag => "#{s.version}" }
  s.ios.deployment_target = '8.0'
  s.ios.vendored_frameworks = 'App.framework', 'Flutter.framework'
end
  • pod lib lint 一下,Flutter 模块组件就已经做好了。趁热打铁,我们再修改 Podfile 文件把它集成到 iOSDemo 工程中。
...
target 'iOSDemo' do
    pod 'FlutterEngine', :path => './'
end
  • pod install 一下,Flutter 模块就集成进 iOS 原生工程中了。
  • 再次,我们试着修改一下 AppDelegate.m 的代码,把 window 的 rootViewController 改成 FlutterViewController。
- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions

{
    self.window = [[UIWindow alloc] initWithFrame:[UIScreen mainScreen].bounds];
    FlutterViewController *vc = [[FlutterViewController alloc]init];
    [vc setInitialRoute:@"defaultRoute"]; //路由标识符
    self.window.rootViewController = vc;
    [self.window makeKeyAndVisible];
    return YES;
}
  • 最后点击运行,一个写着“Hello from Flutter”的全屏红色的 Flutter Widget 也展示出来了。至此,iOS 工程的接入我们也顺利搞定了。
总结
  • 通过分离 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 页面。
Flutter页面跳转至原生页面示意图 * 打开原生页面 openNativePage,与关闭 Flutter 页面 closeFlutterPage,在 Android 和 iOS 平台上分别如何实现。 * 注册方法通道最合适的地方,是 Flutter 应用的入口,即在 FlutterViewController(iOS 端)和 Activity 中的 FlutterView(Android 端)这两个容器内部初始化 Flutter 页面前。为了将 Flutter 相关的行为封装到容器内部,我们需要分别继承 FlutterViewController 和 Activity,在其 viewDidLoad 和 onCreate 初始化容器时,注册 openNativePage 和 closeFlutterPage 这两个方法。

iOS端代码实现

@interface FlutterHomeViewController : FlutterViewController
@end

@implementation FlutterHomeViewController
- (void)viewDidLoad {
    [super viewDidLoad];
    //声明方法通道
    FlutterMethodChannel* channel = [FlutterMethodChannel methodChannelWithName:@"samples.chenhang/navigation" binaryMessenger:self];
    //注册方法回调
    [channel setMethodCallHandler:^(FlutterMethodCall* call, FlutterResult result) {
        //如果方法名为打开新页面
        if([call.method isEqualToString:@"openNativePage"]) {
            //初始化原生页面并打开
            SomeOtherNativeViewController *vc = [[SomeOtherNativeViewController alloc] init];
            [self.navigationController pushViewController:vc animated:YES];
            result(@0);
        }
        //如果方法名为关闭Flutter页面
        else if([call.method isEqualToString:@"closeFlutterPage"]) {
            //关闭自身(FlutterHomeViewController)
            [self.navigationController popViewControllerAnimated:YES];
            result(@0);
        }
        else {
            result(FlutterMethodNotImplemented);//其他方法未实现
        }
    }];
}
@end

Android端代码实现

//继承AppCompatActivity来作为Flutter的容器
public class FlutterHomeActivity extends AppCompatActivity {
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        //初始化Flutter容器       
        FlutterView flutterView = Flutter.createView(this, getLifecycle(), "defaultPage"); //传入路由标识符
        //注册方法通道
        new MethodChannel(flutterView, "samples.chenhang/navigation").setMethodCallHandler(
            new MethodCallHandler() {
                @Override
                public void onMethodCall(MethodCall call, Result result) {
                    //如果方法名为打开新页面
                    if(call.method.equals("openNativePage")) {
                        //新建Intent,打开原生页面
                        Intent intent = new Intent(FlutterHomeActivity.this, SomeNativePageActivity.class);
                        startActivity(intent);
                        result.success(0);
                    }
                    //如果方法名为关闭Flutter页面
                    else if(call.method.equals("closeFlutterPage")) {
                        //销毁自身(Flutter容器)
                        finish();
                        result.success(0);
                    }
                    else {
                        //方法未实现
                        result.notImplemented();
                    }
                }
            });
        //将flutterView替换成Activity的contentView
        setContentView(flutterView);
    }
}
  • 经过上面的方法注册,我们就可以在 Flutter 层分别通过 openNativePage 和 closeFlutterPage 方法,来实现 Flutter 页面与原生页面之间的切换了。
  • 举一个例子,Flutter 容器的根视图 DefaultPage 包含有两个按钮。
    • 点击左上角的按钮后,可以通过 closeFlutterPage 返回原生页面;
    • 点击中间的按钮后,会打开一个新的 Flutter 页面 PageA。PageA 中也有一个按钮,点击这个按钮之后会调用 openNativePage 来打开一个新的原生页面。
void main() => runApp(_widgetForRoute(window.defaultRouteName));
//获取方法通道
const platform = MethodChannel('samples.chenhang/navigation');

//根据路由标识符返回应用入口视图
Widget _widgetForRoute(String route) {
  switch (route) {
    default://返回默认视图
      return MaterialApp(home:DefaultPage());
  }
}

class PageA extends StatelessWidget {
  ...
  @override
  Widget build(BuildContext context) {
    return Scaffold(
            body: RaisedButton(
                    child: Text("Go PageB"),
                    onPressed: ()=>platform.invokeMethod('openNativePage')//打开原生页面
            ));
  }
}

class DefaultPage extends StatelessWidget {
  ...
  @override
  Widget build(BuildContext context) {
    return Scaffold(
        appBar: AppBar(
            title: Text("DefaultPage Page"),
            leading: IconButton(icon:Icon(Icons.arrow_back), onPressed:() => platform.invokeMethod('closeFlutterPage')//关闭Flutter页面
        )),
        body: RaisedButton(
                  child: Text("Go PageA"),
                  onPressed: ()=>Navigator.push(context, MaterialPageRoute(builder: (context) => PageA())),//打开Flutter页面 PageA
        ));
  }
}
混合导航栈示例
  • 混合应用中,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 的初始化工作。可以看到,对于一个应用而言,接入推送的过程可以拆解为以下三步。
    1. 初始化极光 SDK;
    2. 获取地址 id;
    3. 注册消息通知。
  • 这三步对应着在 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'
end
  • 然后,在原生接口 FlutterPushPlugin 类中,同样依次为 setup、getRegistrationID 与 onOpenNotification,提供极光 iOS SDK 的实现版本。
  • 需要注意的是,APNs 的推送消息是在 ApplicationDelegate 中回调的,所以我们需要在注册插件时,为插件提供同名的回调函数,让极光 SDK 把推送消息转发到插件的回调函数中。
  • 与 Android 类似,在极光 SDK 收到推送消息时,我们的应用可能处于后台,因此在用户点击了推送消息,把 Flutter 应用唤醒时,我们应该在确保 Flutter 已经完全初始化后,才能通知 Flutter 有新的推送消息。
  • 因此在下面的代码中,我们在用户点击了推送消息后也等待了 1 秒,才执行相应的 Flutter 回调通知。
@implementation FlutterPushPlugin
//注册插件
+ (void)registerWithRegistrar:(NSObject<FlutterPluginRegistrar>*)registrar {
    //注册方法通道
    FlutterMethodChannel* channel = [FlutterMethodChannel methodChannelWithName:@"flutter_push_plugin" binaryMessenger:[registrar messenger]];
    //初始化插件实例,绑定方法通道 
    FlutterPushPlugin* instance = [[FlutterPushPlugin alloc] init];
    instance.channel = channel;
    //为插件提供ApplicationDelegate回调方法 
    [registrar addApplicationDelegate:instance];
    //注册方法通道回调函数
    [registrar addMethodCallDelegate:instance channel:channel];
}
//处理方法调用
- (void)handleMethodCall:(FlutterMethodCall*)call result:(FlutterResult)result {
    if([@"setup" isEqualToString:call.method]) {
        //极光SDK初始化方法
        [JPUSHService setupWithOption:self.launchOptions appKey:call.arguments channel:@"App Store" apsForProduction:YES advertisingIdentifier:nil];
    } else if ([@"getRegistrationID" isEqualToString:call.method]) {
        //获取极光推送地址标识符
        [JPUSHService registrationIDCompletionHandler:^(int resCode, NSString *registrationID) {
            result(registrationID);
        }];
    } else {
        //方法未实现
        result(FlutterMethodNotImplemented);
  }
}
//应用程序启动回调
-(BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
    //初始化极光推送服务
    JPUSHRegisterEntity * entity = [[JPUSHRegisterEntity alloc] init];
    //设置推送权限
    entity.types = JPAuthorizationOptionAlert|JPAuthorizationOptionBadge|JPAuthorizationOptionSound;
    //请求推送服务
    [JPUSHService registerForRemoteNotificationConfig:entity delegate:self];
    //存储App启动状态,用于后续初始化调用
    self.launchOptions = launchOptions;
    return YES;
}
//推送token回调
- (void)application:(UIApplication *)application didRegisterForRemoteNotificationsWithDeviceToken:(NSData *)deviceToken {
    ///注册DeviceToken,换取极光推送地址标识符
    [JPUSHService registerDeviceToken:deviceToken];
}
//推送被点击回调
- (void)jpushNotificationCenter:(UNUserNotificationCenter *)center didReceiveNotificationResponse:(UNNotificationResponse *)response withCompletionHandler:(void(^)(void))completionHandler {
    //获取推送消息
    NSDictionary * userInfo = response.notification.request.content.userInfo;
    NSString *content = userInfo[@"aps"][@"alert"];
    if ([content isKindOfClass:[NSDictionary class]]) {
        content = userInfo[@"aps"][@"alert"][@"body"];
    }
    //延迟1秒通知Flutter,确保Flutter应用已完成初始化
    dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(1 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
        [self.channel invokeMethod:@"onOpenNotification" arguments:content];
    });
    //清除应用的小红点
    UIApplication.sharedApplication.applicationIconBadgeNumber = 0;
    //通知系统,推送回调处理完毕
    completionHandler(); 
}
//前台应用收到了推送消息
- (void)jpushNotificationCenter:(UNUserNotificationCenter *)center willPresentNotification:(UNNotification *)notification withCompletionHandler:(void (^)(NSInteger options))completionHandler {
    //通知系统展示推送消息提示
    completionHandler(UNNotificationPresentationOptionAlert); 
}
@end
  • 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 选项,启动应用的推送能力支持,如下图所示
    example iOS推送配置
    • 然后,我们需要切换到 Application Target 的 Info 面板,手动配置 NSAppTransportSecurity 键值对,以支持极光 SDK 非 https 域名服务。
    example iOS支持Http配置
    • 最后,在 Info tab 下的 Bundle identifier 项,把我们刚刚在极光官网注册的 Bundle ID 显式地更新进去。
    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 中进行国际化的整体思路,即语言差异配置抽取 + 国际化代码生成。而在语言差异配置抽取的过程中,文案、货币单位,以及背景图资源的处理,其实并没有本质区别。
Intl
  • 默认情况下,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 文件。
arb文件格式 * 试着修改一下 strings_zh.arb 文件,可以看到,Flutter i18n 插件为我们自动生成了 generated/i18n.dart。这个类中不仅以资源标识符属性的方式提供了静态文案的翻译映射,对于通过参数来实现动态文案的 message_tip 标识符,也自动生成了一个同名内联函数。 ![Flutter i18n插件自动生成代码](https://img-blog.csdnimg.cn/20200818151939602.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3lhbmdfc3R1ZHlfZmlyc3Q=,size_16,color_FFFFFF,t_70#pic_center) * 我们把 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工程中文配置 * 完成 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 文件。这里由于默认文件是英文的,所以我们只需要为中文创建一个文件即可。字符串资源的文件目录结构,如下图所示。

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 属性声明的,所以我们也需要将其修改为字符串资源中的一个引用,使其能够根据语言地区自动选择文案。
iOS工程应用名称配置 * 由于应用名称默认是不可配置的,所以工程并没有提供英文或者中文的可配置项,这些都需要通过新建与字符串引用对应的资源文件去搞定的。 * 我们右键单击 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,对这两个配置进行封装。
class AppConfig extends InheritedWidget {
  AppConfig({
    @required this.appName,
    @required this.apiBaseUrl,
    @required Widget child,
  }) : super(child: child);

  final String appName;//主页标题
  final String apiBaseUrl;//接口域名

  //方便其子Widget在Widget树中找到它
  static AppConfig of(BuildContext context) {
    return context.inheritFromWidgetOfExactType(AppConfig);
  }
  
  //判断是否需要子Widget更新。由于是应用入口,无需更新
  @override
  bool updateShouldNotify(InheritedWidget oldWidget) => false;
}
  • 为不同的环境创建不同的应用入口。由于 AppConfig 是整个应用程序的根节点,因此我可以通过调用 AppConfig.of 方法,来获取到相关的数据配置。
  • 在这个例子中,由于只有两个环境,即开发环境与生产环境,因此我们将文件分别命名为 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);//启动应用入口
}
  • 在应用内获取配置数据。

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 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 进入编辑应用程序启动选项。
    Config Selector新增入口

  • 然后,点击位于工具栏面板左侧顶部的“+”按钮,在弹出的菜单中选择 Flutter 选项,为应用程序新增一项启动入口。
    选择新增类型

  • 最后,在入口的编辑面板中,为 main_dev 选择程序的 Dart 入口,点击 OK 后,就完成了入口的新增工作。

编辑启动入口 * 接下来,我们就可以在 Config Selector 中切换不同的启动入口,从而直接在 Android Studio 中注入不同的配置环境了。 * 而如果我们想要打包构建出适用于 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

Hot Reload是怎么做到的

  • Flutter的Debug 模式支持 JIT,并且为开发期的运行和调试提供了大量优化,因此代码修改后,我们可以通过亚秒级的热重载(Hot Reload)进行增量代码的快速刷新,而无需经过全量的代码编译,从而大大缩短了从代码修改到看到修改产生的变化之间所需要的时间。
热重载
  • 热重载是指,在不中断 App 正常运行的情况下,动态注入修改后的代码片段。而这一切的背后,离不开 Flutter 所提供的运行时编译能力。
JIT编译模式示意图 * AOT(Ahead Of Time),指的是提前编译或运行前编译,在 Release 模式中使用,可以为特定的平台生成稳定的二进制代码,执行性能好、运行速度快,但每次执行均需提前编译,开发调试效率低。 AOT编译模式示意图
  • 可以看到,Flutter 提供的两种编译模式中,AOT 是静态编译,即编译成设备可直接执行的二进制码;而 JIT 则是动态编译,即将 Dart 代码编译成中间代码(Script Snapshot),在运行时设备需要 Dart VM 解释执行。
  • 而热重载之所以只能在 Debug 模式下使用,是因为 Debug 模式下,Flutter 采用的是 JIT 动态编译(而 Release 模式下采用的是 AOT 静态编译)。JIT 编译器将 Dart 代码编译成可以运行在 Dart VM 上的 Dart Kernel,而 Dart Kernel 是可以动态更新的,这就实现了代码的实时更新功能。
热重载流程 * 总体来说,热重载的流程可以分为扫描工程改动、增量编译、推送更新、代码合并、Widget 重建 5 个步骤。 1、工程改动。热重载模块会逐一扫描工程中的文件,检查是否有新增、删除或者改动,直到找到在上次编译之后,发生变化的 Dart 代码。 2、增量编译。热重载模块会将发生变化的 Dart 代码,通过编译转化为增量的 Dart Kernel 文件。 3、推送更新。热重载模块将增量的 Dart Kernel 文件通过 HTTP 端口,发送给正在移动设备上运行的 Dart VM。 4、代码合并。Dart VM 会将收到的增量 Dart Kernel 文件,与原有的 Dart Kernel 文件进行合并,然后重新加载新的 Dart Kernel 文件。 5、Widget 重建。在确认 Dart VM 资源加载成功后,Flutter 会将其 UI 线程重置,通知 Flutter Framework 重建 Widget。 * 可以看到,Flutter 提供的热重载在收到代码变更后,并不会让 App 重新启动执行,而只会触发 Widget 树的重新绘制,因此可以保持改动前的状态,这就大大节省了调试复杂交互界面的时间。 ##### 不支持热重载的情景 * Flutter 提供的亚秒级热重载一直是开发者的调试利器。通过热重载,我们可以快速修改 UI、修复 Bug,无需重启应用即可看到改动效果,从而大大提升了 UI 调试效率。 * 不过,Flutter 的热重载也有一定的局限性。因为涉及到状态保存与恢复,所以并不是所有的代码改动都可以通过热重载来更新。 ###### 典型场景 * . 代码编译出现错误。 * 当代码更改导致编译错误时,热重载会提示编译错误信息。在这种情况下,只需更正上述代码中的错误,就可以继续使用热重载。 * Widget状态无法兼容。 * 当代码更改会影响 Widget 的状态时,会使得热重载前后 Widget 所使用的数据不一致,即应用程序保留的状态与新的更改不兼容。这时,热重载也是无法使用的。比如将某个类的定义从 StatelessWidget 改为 StatefulWidget 时,热重载就会直接报错,当遇到这种情况时,我们需要重启应用,才能看到更新后的程序。 * 全局变量和静态属性的修改。 * 在 Flutter 中,全局变量和静态属性都被视为状态,在第一次运行应用程序时,会将它们的值设为初始化语句的执行结果,因此在热重载期间不会重新初始化。如果我们需要更改全局变量和静态属性的初始化语句,重启应用才能查看更改效果。 * main方法里面的修改。 * 在 Flutter 中,由于热重载之后只会根据原来的根节点重新创建控件树,因此 main 函数的任何改动并不会在热重载后重新执行。所以,如果我们改动了 main 函数体内的代码,是无法通过热重载看到更新效果的。 * initState方法里面的更改。 * 在热重载时,Flutter 会保存 Widget 的状态,然后重建 Widget。而 initState 方法是 Widget 状态的初始化方法,这个方法里的更改会与状态保存发生冲突,因此热重载后不会产生效果。我们需要重启应用,才能看到更改效果。 * 枚举和泛类型更改。 * 在 Flutter 中,枚举和泛型也被视为状态,因此对它们的修改也不支持热重载。

如何通过工具链优化开发调试效率

  • 软件开发通常是一个不断迭代、螺旋式上升的过程。在迭代的过程中,我们不可避免地会经常与 Bug 打交道,特别是在多人协作的项目中,我们不仅要修复自己的 Bug,有时还需要帮别人解决 Bug。
  • 而修复 Bug 的过程,不仅能帮我们排除代码中的隐患,也能帮助我们更快地上手项目。因此,掌握好调试这门技能,就显得尤为重要了。
  • 在 Flutter 中,调试代码主要分为输出日志、断点调试和布局调试 3 类。
输出日志
  • 为了便于跟踪和记录应用的运行情况,我们在开发时通常会在一些关键步骤输出日志(Log),即使用 print 函数在控制台打印出相关的上下文信息。通过这些信息,我们可以定位代码中可能出现的问题。
  • 不过,由于涉及 I/O 操作,使用 print 来打印信息会消耗较多的系统资源。同时,这些输出数据很可能会暴露 App 的执行细节,所以我们需要在发布正式版时屏蔽掉这些输出。
  • 为了根据不同的运行环境来开启日志调试功能,我们可以使用 Flutter 提供的 debugPrint 来代替 print。debugPrint 函数同样会将消息打印至控制台,但与 print 不同的是,它提供了定制打印的能力。也就是说,我们可以向 debugPrint 函数,赋值一个函数声明来自定义打印行为。
debugPrint = (String message, {int wrapWidth}) {};//空实现
  • 分别用 main.dart 与 main-dev.dart 实现了生产环境与开发环境的分离。同样,我们可以通过 main.dart 与 main-dev.dart,去分别定义生产环境与开发环境不同的打印日志行为。
//main.dart
void main() {
  // 将debugPrint指定为空的执行体, 所以它什么也不做
  debugPrint = (String message, {int wrapWidth}) {};
  runApp(MyApp()); 
}

//main-dev.dart
void main() async {
  // 将debugPrint指定为同步打印数据
  debugPrint = (String message, {int wrapWidth}) => debugPrintSynchronously(message, wrapWidth: wrapWidth);
  runApp(MyApp());
}
  • 可以看到,在代码实现上,我们只要将应用内所有的 print 都替换成 debugPrint,就可以满足开发环境下打日志的需求,也可以保证生产环境下应用的执行信息不会被意外打印。
断点调试
  • Android Studio 提供了断点调试的功能,调试 Flutter 应用与调试原生 Android 代码的方法完全一样,具体可以分为三步,即标记断点、调试应用、查看信息。
标记断点

标记断点

  • 添加断点后,对应的行号将会出现圆形的断点标记,并高亮显示整行代码。到此,断点就添加好了。当然,我们还可以同时添加多个断点,以便更好地观察代码的执行过程。
调试应用
  • Debug 视图模式划分为 4 个区域,即 A 区控制调试工具、B 区步进调试工具、C 区帧调试窗口、D 区变量查看窗口。
A区按钮 * B 区的按钮,主要用来控制断点的步进情况。 ![B区按钮](https://img-blog.csdnimg.cn/20200827143338362.png#pic_center) * 表达式计算按钮来通过赋值或表达式方式修改任意变量的值。 ![Evaluate计算表达式](https://img-blog.csdnimg.cn/20200913180447469.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3lhbmdfc3R1ZHlfZmlyc3Q=,size_16,color_FFFFFF,t_70#pic_center)
  • C 区用来指示当前断点所包含的函数执行堆栈,D 区则是其堆栈中的函数帧所对应的变量。
  • 我们的断点是在 _MyHomePageState 类中的 build 方法设置的,因此 D 区显示的也是 build 方法上下文所包含的变量信息(比如 _counter、_widget、this、_element 等)。如果我们想切换到 _MyHomePageState 的 build 方法执行堆栈中的其他函数(比如 StatefulElement.build),查看相关上下文的变量信息时,只需要在 C 区中点击对应的方法名即可。
切换函数执行堆栈 ###### 布局调试 * 如果想要更快地发现界面中更为细小的问题,比如对齐、边距等,则需要使用 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());
}

Debug Painting运行效果

  • 辅助线提供了基本的 Widget 可视化能力。通过辅助线,我们能够感知界面中是否存在对齐或边距的问题,但却没有办法获取到布局信息,比如 Widget 距离父视图的边距信息、Widget 宽高尺寸信息等。

  • 如果我们想要获取到 Widget 的可视化信息(比如布局信息、渲染信息等)去解决渲染问题,就需要使用更强大的 Flutter Inspector 了。Flutter Inspector 对控件布局详细数据提供了一种强大的可视化手段,来帮助我们诊断布局问题。

  • 为了使用 Flutter Inspector,我们需要回到 Android Studio,通过工具栏上的“Open DevTools”按钮启动 Flutter Inspector。
    Flutter inspector启动按钮

  • 随后,Android Studio 会打开浏览器,将计数器示例中的 Widget 树结构展示在面板中。可以看到,Flutter Inspector 所展示的 Widget 树结构,与代码中实现的 Widget 层次是一一对应的。
    Flutter inspector示意图

  • 我们的 App 运行在 iPhone X 之上,其分辨率为 375*812。接下来,我们以 Column 组件的布局信息为例,通过确认其水平方向为居中布局、垂直方向为充满父 Widget 剩余空间的过程,来说明 Flutter Inspector 的具体用法。

  • 为了确认 Column 在垂直方向是充满其父 Widget 剩余空间的,我们首先需要确定其父 Widget 在垂直方向上的另一个子 Widget,即 AppBar 的信息。我们点击 Flutter Inspector 面板左侧中的 AppBar 控件,右侧对应显示了它的具体视觉信息。

  • 可以看到 AppBar 控件距离左边距为 0,上边距也为 0;宽为 375,高为 100。

Flutter inspector之AppBar
  • 然后,我们将 Flutter Inspector 面板左侧选择的控件更新为 Column,右侧也更新了它的具体视觉信息,比如排版方向、对齐模式、渲染信息,以及它的两个子 Widget-Text。
  • 可以看到,Column 控件的距离左边距为 38.5,上边距为 0;宽为 298,高为 712。
Flutter inspector之Column * 通过上面的数据我们可以得出: * 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 的布局行为是完全符合预期的。 * 写代码不可避免会出现 Bug,出现时就需要 Debug(调试)。调试代码本质上就是一个不断收敛问题发生范围的过程,因此排查问题的一个最基本思路,就是二分法。 * 所谓二分调试法,是指通过某种稳定复现的特征(比如 Crash、某个变量的值、是否出现某个现象等任何明显的迹象),加上一个能把问题出现的范围划分为两半的手段(比如断点、assert、日志等),两者结合反复迭代不断将问题可能出现的范围一分为二(比如能判断出引发问题的代码出现在断点之前等)。通过二分法,我们可以快速缩小问题范围,这样一来调试的效率也就上去了。

如何检测并优化FlutterApp的整体性能表现

  • 除了代码逻辑 Bug 和视觉异常这些功能层面的问题之外,移动应用另一类常见的问题是性能问题,比如滑动操作不流畅、页面出现卡顿丢帧现象等。这些问题虽然不至于让移动应用完全不可用,但也很容易引起用户反感,从而对应用质量产生质疑,甚至失去耐心。
  • 那么,如果应用渲染并不流畅,出现了性能问题,我们该如何检测,又该从哪里着手处理呢?
  • 在 Flutter 中,性能问题可以分为 GPU 线程问题和 UI 线程(CPU)问题两类。这些问题的确认都需要先通过性能图层进行初步分析,而一旦确认问题存在,接下来就需要利用 Flutter 提供的各类分析工具来定位问题了。
如何使用性能图层
  • 为了使用性能图层,我们首先需要以分析(Profile)模式启动应用。与调试代码可以通过模拟器在调试模式下找到代码逻辑 Bug 不同,性能问题需要在发布模式下使用真机进行检测。
  • 这是因为,相比发布模式而言,调试模式增加了很多额外的检查(比如断言),这些检查可能会耗费很多资源;更重要的是,调试模式使用 JIT 模式运行应用,代码执行效率较低。这就使得调试模式运行的应用,无法真实反映出它的性能问题。
  • 而另一方面,模拟器使用的指令集为 x86,而真机使用的指令集是 ARM。这两种方式的二进制代码执行行为完全不同,因此模拟器与真机的性能差异较大:一些 x86 指令集擅长的操作模拟器会比真机快,而另一些操作则会比真机慢。这也使得我们无法使用模拟器来评估真机才能出现的性能问题。
  • 为了调试性能问题,我们需要在发布模式的基础之上,为分析工具提供少量必要的应用追踪信息,这就是分析模式。除了一些调试性能问题必须的追踪方法之外,Flutter 应用的分析模式和发布模式的编译和运行是类似的,只是启动参数变成了 profile 而已:我们既可以在 Android Studio 中通过菜单栏点击 Run->Profile ‘main.dart’ 选项启动应用,也可以通过命令行参数 flutter run --profile 运行 Flutter 应用。
分析渲染问题
  • 在完成了应用启动之后,接下来我们就可以利用 Flutter 提供的渲染问题分析工具,即性能图层(Performance Overlay),来分析渲染问题了。
  • 性能图层会在当前应用的最上层,以 Flutter 引擎自绘的方式展示 GPU 与 UI 线程的执行图表,而其中每一张图表都代表当前线程最近 300 帧的表现,如果 UI 产生了卡顿(跳帧),这些图表可以帮助我们分析并找到原因。
  • 下图演示了性能图层的展现样式。其中,GPU 线程的性能情况在上面,UI 线程的情况显示在下面,蓝色垂直的线条表示已执行的正常帧,绿色的线条代表的是当前帧。
性能图层 * 为了保持 60Hz 的刷新频率,GPU 线程与 UI 线程中执行每一帧耗费的时间都应该小于 16ms(1/60 秒)。在这其中有一帧处理时间过长,就会导致界面卡顿,图表中就会展示出一个红色竖条。下图演示了应用出现渲染和绘制耗时的情况下,性能图层的展示样式。 渲染和绘制耗时异常 * 如果红色竖条出现在 GPU 线程图表,意味着渲染的图形太复杂,导致无法快速渲染;而如果是出现在了 UI 线程图表,则表示 Dart 代码消耗了大量资源,需要优化代码执行时间。 ##### GPU问题定位 * GPU 问题主要集中在底层渲染耗时上。有时候 Widget 树虽然构造起来容易,但在 GPU 线程下的渲染却很耗时。涉及 Widget 裁剪、蒙层这类多视图叠加渲染,或是由于缺少缓存导致静态图像的反复绘制,都会明显拖慢 GPU 的渲染速度。 * 我们可以使用性能图层提供的两项参数,即检查多视图叠加的视图渲染开关 checkerboardOffscreenLayers,和检查缓存的图像开关 checkerboardRasterCacheImages,来检查这两种情况。 ###### checkerboardOffScreenLayers * 多视图叠加通常会用到 Canvas 里的 savaLayer 方法,这个方法在实现一些特定的效果(比如半透明)时非常有用,但由于其底层实现会在 GPU 渲染上涉及多图层的反复绘制,因此会带来较大的性能问题。 * 对于 saveLayer 方法使用情况的检查,我们只要在 MaterialApp 的初始化方法中,将 checkerboardOffscreenLayers 开关设置为 true,分析工具就会自动帮我们检测多视图叠加的情况了:使用了 saveLayer 的 Widget 会自动显示为棋盘格式,并随着页面刷新而闪烁。 * 不过,saveLayer 是一个较为底层的绘制方法,因此我们一般不会直接使用它,而是会通过一些功能性 Widget,在涉及需要剪切或半透明蒙层的场景中间接地使用。所以一旦遇到这种情况,我们需要思考一下是否一定要这么做,能不能通过其他方式来实现呢。 * 下面的例子中,我们使用 CupertinoPageScaffold 与 CupertinoNavigationBar 实现了一个动态模糊的效果。
CupertinoPageScaffold(
  navigationBar: CupertinoNavigationBar(),//动态模糊导航栏
    child: ListView.builder(
      itemCount: 100,
      //为列表创建100个不同颜色的RowItem
      itemBuilder: (context, index)=>TabRowItem(
            index: index,
            lastItem: index == 100 - 1,
            color: colorItems[index],//设置不同的颜色
            colorName: colorNameItems[index],
          )
    )
);
动态模糊效果
  • 由于视图滚动过程中频繁涉及视图蒙层效果的更新,因此 checkerboardOffscreenLayers 检测图层也感受到了对 GPU 的渲染压力,频繁的刷新闪烁。
检测saveLayer使用 * 如果我们没有对动态模糊效果的特殊需求,则可以使用不带模糊效果的 Scaffold 和白色的 AppBar 实现同样的产品功能,来解决这个性能问题。
Scaffold(
  //使用普通的白色AppBar
  appBar: AppBar(title: Text('Home', style: TextStyle(color:Colors.black),),backgroundColor: Colors.white),
  body: ListView.builder(
      itemCount: 100,
      //为列表创建100个不同颜色的RowItem
      itemBuilder: (context, index)=>TabRowItem(
        index: index,
        lastItem: index == 100 - 1,
        color: colorItems[index],//设置不同的颜色
        colorName: colorNameItems[index],
      )
  ),
);
  • 运行一下代码,可以看到,在去掉了动态模糊效果之后,GPU 的渲染压力得到了缓解,checkerboardOffscreenLayers 检测图层也不再频繁闪烁了。
去掉动态模糊效果 ###### checkboardRasterCacheImages * 从资源的角度看,另一类非常消耗性能的操作是,渲染图像。这是因为图像的渲染涉及 I/O、GPU 存储,以及不同通道的数据格式转换,因此渲染过程的构建需要消耗大量资源。为了缓解 GPU 的压力,Flutter 提供了多层次的缓存快照,这样 Widget 重建时就无需重新绘制静态图像了。 * 与检查多视图叠加渲染的 checkerboardOffscreenLayers 参数类似,Flutter 也提供了检查缓存图像的开关 checkerboardRasterCacheImages,来检测在界面重绘时频繁闪烁的图像(即没有静态缓存)。 * 我们可以把需要静态缓存的图像加到 RepaintBoundary 中,RepaintBoundary 可以确定 Widget 树的重绘边界,如果图像足够复杂,Flutter 引擎会自动将其缓存,避免重复刷新。当然,因为缓存资源有限,如果引擎认为图像不够复杂,也可能会忽略 RepaintBoundary。 * 如下代码展示了通过 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 后,我们就可以开始分析代码中的性能问题了。

打开Performance工具

  • 通过一个 ListView 中计算 MD5 的例子,来演示 Performance 的具体分析过程。
  • 考虑到在 build 函数中进行渲染信息的组装是一个常见的操作,为了演示这个知识点,我们故意放大了计算 MD5 的耗时,循环迭代计算了 1 万次。
class MyHomePage extends StatelessWidget {
  MyHomePage({Key key}) : super(key: key);

  String generateMd5(String data) {
    //MD5固定算法
    var content = new Utf8Encoder().convert(data);
    var digest = md5.convert(content);
    return hex.encode(digest.bytes);
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('demo')),
      body: ListView.builder(
          itemCount: 30,// 列表元素个数
          itemBuilder: (context, index) {
            //反复迭代计算MD5
            String str = '1234567890abcdefghijklmnopqrstuvwxyz';
            for(int i = 0;i<10000;i++) {
              str = generateMd5(str);
            }
            return ListTile(title: Text("Index : $index"), subtitle: Text(str));
          }// 列表项创建方法
      ),
    );
  }
}
  • 与性能图层能够自动记录应用执行情况不同,使用 Performance 来分析代码执行轨迹,我们需要手动点击“Record”按钮去主动触发,在完成信息的抽样采集后,点击“Stop”按钮结束录制。这时,我们就可以得到在这期间应用的执行情况了。
  • Performance 记录的应用执行情况叫做 CPU 帧图,又被称为火焰图。火焰图是基于记录代码执行结果所产生的图片,用来展示 CPU 的调用栈,表示的是 CPU 的繁忙程度。
  • 其中,y 轴表示调用栈,其每一层都是一个函数。调用栈越深,火焰就越高,底部就是正在执行的函数,上方都是它的父函数;x 轴表示单位时间,一个函数在 x 轴占据的宽度越宽,就表示它被采样到的次数越多,即执行时间越长。
  • 所以,我们要检测 CPU 耗时问题,皆可以查看火焰图底部的哪个函数占据的宽度最大。只要有“平顶”,就表示该函数可能存在性能问题。比如,我们这个案例的火焰图如下所示:
CPU帧图/火焰图
  • 可以看到,_MyHomePage.generateMd5 函数的执行时间最长,几乎占满了整个火焰图的宽,而这也与代码中存在的问题是一致的。
  • 在找到了问题之后,我们就可以使用 Isolate(或 compute)将这些耗时的操作挪到并发主 Isolate 之外去完成了。
  • 通常来说,由于 Flutter 采用基于声明式的 UI 设计理念,以数据驱动渲染,并采用 Widget->Element->RenderObject 三层结构,屏蔽了无谓的界面刷新,能够保证绝大多数情况下我们构建的应用都是高性能的,所以在使用分析工具检测出性能问题之后,通常我们并不需要做太多的细节优化工作,只需要在改造过程中避开一些常见的坑,就可以获得优异的性能。比如:
    • 控制 build 方法耗时,将 Widget 拆小,避免直接返回一个巨大的 Widget,这样 Widget 会享有更细粒度的重建和复用;
    • 尽量不要为 Widget 设置半透明效果,而是考虑用图片的形式代替,这样被遮挡的 Widget 部分区域就不需要绘制了;
    • 对列表采用懒加载而不是直接一次性创建所有的子 Widget,这样视图的初始化时间就减少了。

如何通过自动化测试提高交付质量

  • 在完成了应用的开发工作,并解决了代码中的逻辑问题和性能问题之后,接下来我们就需要测试验收应用的各项功能表现了。移动应用的测试工作量通常很大,这是因为为了验证真实用户的使用体验,测试往往需要跨越多个平台(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 中,测试用例的声明包含定义、执行和验证三个部分:定义和执行决定了被测试对象提供的、需要验证的最小可测单元;而验证则需要使用 expect 函数,将最小可测单元的执行结果与预期进行比较。
  • 所以,在 Flutter 中编写一个测试用例,通常包含以下两大步骤。
    1、实现一个包含定义、执行和验证步骤的测试用例;
    2、将其包装在 test 内部,test 是 Flutter 提供的测试用例封装类。
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’”,就可以启动测试用例了。
  • 如果测试用例的执行结果是不通过,Flutter 会给我们怎样的提示呢?我们试着修改一下第一个计数器递增的用例,将它的期望结果改为 2。
  • 运行测试用例,可以看到,Flutter 在执行完计数器的递增方法后,发现其结果 1 与预期的 2 不匹配,于是报错。
    单元测试失败示意图
  • 如果有多个测试用例,它们之间是存在关联关系的,我们可以在最外层使用 group 将它们组合在一起。
import 'package:test/test.dart';
import 'package:counter_app/counter.dart';
void main() {
  //组合测试用例,判断Counter对象调用increase方法后是否等于1,并且判断Counter对象调用decrease方法后是否等于-1
  group('Counter', () {
    test('Increase a counter value should be 1', () {
      final counter = Counter();
      counter.increase();
      expect(counter.value, 1);
    });

    test('Decrease a counter value should be -1', () {
      final counter = Counter();
      counter.decrease();
      expect(counter.value, -1);
    });
  });
}
  • 在对程序的内部功能进行单元测试时,我们还可能需要从外部依赖(比如 Web 服务)获取需要测试的数据。比如下面的例子,Todo 对象的初始化就是通过 Web 服务返回的 JSON 实现的。考虑到调用 Web 服务的过程中可能会出错,所以我们还处理了请求码不等于 200 的其他异常情况:
import 'package:http/http.dart' as http;

class Todo {
  final String title;
  Todo({this.title});
  //工厂类构造方法,将JSON转换为对象
  factory Todo.fromJson(Map<String, dynamic> json) {
    return Todo(     
      title: json['title'],
    );
  }
}
Future<Todo> fetchTodo(http.Client client) async {
  final response =
  await client.get('https://xxx.com/todos/1');

  if (response.statusCode == 200) {
    //请求成功,解析JSON
    return Todo.fromJson(json.decode(response.body));
  } else {
    //请求失败,抛出异常
    throw Exception('Failed to load post');
  }
}
  • 考虑到这些外部依赖并不是我们的程序所能控制的,因此很难覆盖所有可能的成功或失败方案。比如,对于一个正常运行的 Web 服务来说,我们基本不可能测试出 fetchTodo 这个接口是如何应对 403 或 502 状态码的。因此,更好的一个办法是,在测试用例中“模拟”这些外部依赖(对应本例即为 http.client),让这些外部依赖可以返回特定结果。
  • 在单元测试用例中模拟外部依赖,我们需要在 pubspec.yaml 文件中使用 mockito 包,以接口实现的方式定义外部依赖的接口。
dev_dependencies:
  test: 
  mockito:
  • 要使用 mockito 包来模拟 fetchTodo 的依赖 http.client,我们首先需要定义一个继承自 Mock(这个类可以模拟任何外部依赖),并以接口定义的方式实现了 http.client 的模拟类;然后,在测试用例的声明中,为其制定任意的接口返回。
  • 在下面的例子中,我们定义了一个模拟类 MockClient,这个类以接口声明的方式获取到了 http.Client 的外部接口。随后,我们就可以使用 when 语句,在其调用 Web 服务时,为其注入相应的数据返回了。在第一个用例中,我们为其注入了 JSON 结果;而在第二个用例中,我们为其注入了一个 403 的异常。
import 'package:mockito/mockito.dart';
import 'package:http/http.dart' as http;

class MockClient extends Mock implements http.Client {}

void main() {
  group('fetchTodo', () {
  test('returns a Todo if successful', () async {
    final client = MockClient();

    //使用Mockito注入请求成功的JSON字段
    when(client.get('https://xxx.com/todos/1'))
        .thenAnswer((_) async => http.Response('{"title": "Test"}', 200));
    //验证请求结果是否为Todo实例
    expect(await fetchTodo(client), isInstanceOf<Todo>());
  });

  test('throws an exception if error', () {
    final client = MockClient();

    //使用Mockito注入请求失败的Error
    when(client.get('https://xxx.com/todos/1'))
        .thenAnswer((_) async => http.Response('Forbidden', 403));
    //验证请求结果是否抛出异常
    expect(fetchTodo(client), throwsException);
  });
});
}
  • 运行这段测试用例,可以看到,我们在没有调用真实 Web 服务的情况下,成功模拟出了正常和异常两种结果,同样也是顺利通过验证了。
UI测试
  • UI 测试的目的是模仿真实用户的行为,即以真实用户的身份对应用程序执行 UI 交互操作,并涵盖各种用户流程。相比于单元测试,UI 测试的覆盖范围更广、更关注流程和交互,可以找到单元测试期间无法找到的错误。在 Flutter 中编写 UI 测试用例,我们需要在 pubspec.yaml 中使用 flutter_test 包,来提供编写 UI 测试的核心框架,即定义、执行和验证。
    • 定义,即通过指定规则,找到 UI 测试用例需要验证的、特定的子 Widget 对象;
    • 执行,意味着我们要在找到的子 Widget 对象中,施加用户交互事件;
    • 验证,表示在施加了交互事件后,判断待验证的 Widget 对象的整体表现是否符合预期。
dev_dependencies:
  flutter_test:
    sdk: flutter
  • 与单元测试使用 test 对用例进行包装类似,UI 测试使用 testWidgets 对用例进行包装。testWidgets 提供了 tester 参数,我们可以使用这个实例来操作需要测试的 Widget 对象。
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);
});
}
  • 除了点击事件之外,tester 还支持其他的交互行为,比如文字输入 enterText、拖动 drag、长按 longPress 等。
思考
  • 在下面的代码中,我们定义了 SharedPreferences 的更新和递增方法。请你使用 mockito 模拟 SharedPreferences 的方式,来为这两个方法实现对应的单元测试用例。
Future<bool>updateSP(SharedPreferences prefs, int counter) async {
  bool result = await prefs.setInt('counter', counter);
  return result;
}

Future<int>increaseSPCounter(SharedPreferences prefs) async {
  int counter = (prefs.getInt('counter') ?? 0) + 1;
  await updateSP(prefs, counter);
  return counter;
}
Future<Todo> fetchTodo(http.Client client) async {
  final response =
  await client.get('https://xxx.com/todos/1');

  if (response.statusCode == 200) {
    //请求成功,解析JSON
    return Todo.fromJson(json.decode(response.body));
  } else {
    //请求失败,抛出异常
    throw Exception('Failed to load post');
  }
}
  • 考虑到这些外部依赖并不是我们的程序所能控制的,因此很难覆盖所有可能的成功或失败方案。比如,对于一个正常运行的 Web 服务来说,我们基本不可能测试出 fetchTodo 这个接口是如何应对 403 或 502 状态码的。因此,更好的一个办法是,在测试用例中“模拟”这些外部依赖(对应本例即为 http.client),让这些外部依赖可以返回特定结果。
  • 在单元测试用例中模拟外部依赖,我们需要在 pubspec.yaml 文件中使用 mockito 包,以接口实现的方式定义外部依赖的接口。
dev_dependencies:
  test: 
  mockito:
  • 要使用 mockito 包来模拟 fetchTodo 的依赖 http.client,我们首先需要定义一个继承自 Mock(这个类可以模拟任何外部依赖),并以接口定义的方式实现了 http.client 的模拟类;然后,在测试用例的声明中,为其制定任意的接口返回。
  • 在下面的例子中,我们定义了一个模拟类 MockClient,这个类以接口声明的方式获取到了 http.Client 的外部接口。随后,我们就可以使用 when 语句,在其调用 Web 服务时,为其注入相应的数据返回了。在第一个用例中,我们为其注入了 JSON 结果;而在第二个用例中,我们为其注入了一个 403 的异常。
import 'package:mockito/mockito.dart';
import 'package:http/http.dart' as http;

class MockClient extends Mock implements http.Client {}

void main() {
  group('fetchTodo', () {
  test('returns a Todo if successful', () async {
    final client = MockClient();

    //使用Mockito注入请求成功的JSON字段
    when(client.get('https://xxx.com/todos/1'))
        .thenAnswer((_) async => http.Response('{"title": "Test"}', 200));
    //验证请求结果是否为Todo实例
    expect(await fetchTodo(client), isInstanceOf<Todo>());
  });

  test('throws an exception if error', () {
    final client = MockClient();

    //使用Mockito注入请求失败的Error
    when(client.get('https://xxx.com/todos/1'))
        .thenAnswer((_) async => http.Response('Forbidden', 403));
    //验证请求结果是否抛出异常
    expect(fetchTodo(client), throwsException);
  });
});
}
  • 运行这段测试用例,可以看到,我们在没有调用真实 Web 服务的情况下,成功模拟出了正常和异常两种结果,同样也是顺利通过验证了。
UI测试
  • UI 测试的目的是模仿真实用户的行为,即以真实用户的身份对应用程序执行 UI 交互操作,并涵盖各种用户流程。相比于单元测试,UI 测试的覆盖范围更广、更关注流程和交互,可以找到单元测试期间无法找到的错误。在 Flutter 中编写 UI 测试用例,我们需要在 pubspec.yaml 中使用 flutter_test 包,来提供编写 UI 测试的核心框架,即定义、执行和验证。
    • 定义,即通过指定规则,找到 UI 测试用例需要验证的、特定的子 Widget 对象;
    • 执行,意味着我们要在找到的子 Widget 对象中,施加用户交互事件;
    • 验证,表示在施加了交互事件后,判断待验证的 Widget 对象的整体表现是否符合预期。
dev_dependencies:
  flutter_test:
    sdk: flutter
  • 与单元测试使用 test 对用例进行包装类似,UI 测试使用 testWidgets 对用例进行包装。testWidgets 提供了 tester 参数,我们可以使用这个实例来操作需要测试的 Widget 对象。
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);
});
}
  • 除了点击事件之外,tester 还支持其他的交互行为,比如文字输入 enterText、拖动 drag、长按 longPress 等。
思考
  • 在下面的代码中,我们定义了 SharedPreferences 的更新和递增方法。请你使用 mockito 模拟 SharedPreferences 的方式,来为这两个方法实现对应的单元测试用例。
Future<bool>updateSP(SharedPreferences prefs, int counter) async {
  bool result = await prefs.setInt('counter', counter);
  return result;
}

Future<int>increaseSPCounter(SharedPreferences prefs) async {
  int counter = (prefs.getInt('counter') ?? 0) + 1;
  await updateSP(prefs, counter);
  return counter;
}

Copyright: 采用 知识共享署名4.0 国际许可协议进行许可

Links: https://www.yanghujun.com/archives/fluttertechnologycombat5