你好,我是陈航。专栏上线以来,我在评论区看到了很多同学写的心得、经验和建议,当然更多的还是大家提的问题。

为了能够让大家更好地理解我们专栏的核心知识点,我今天特意整理了每篇文章的课后思考题,并结合大家在留言区的回答情况做一次分析与扩展。

当然 ,我也希望你能把这篇答疑文章作为对整个专栏所讲知识点的一次复习,如果你在学习或者使用Flutter的过程中,遇到哪些问题,欢迎继续给我留言。我们一起交流,共同进步!

需要注意的是,这些课后题并不存在标准答案。就算是同一个功能、同一个界面,不同人也会有完全不一样的实现方案,只要你的解决方案的输入和输出满足题目要求,在我看来你就已经掌握了相应的知识点。因此,在这篇文章中,我会更侧重于介绍方案、实现思路、原理和关键细节,而不是讲具体实操的方方面面。

接下来,我们就具体看看这些思考题的答案吧。

问题1:直接在build函数里以内联的方式实现Scaffold页面元素的构建,好处是什么?

这个问题选自第5篇文章“从标准模板入手,体会Flutter代码是如何运行在原生系统上的”,你可以先回顾下这篇文章的相关知识点。

然后,我来说说这样做的最大好处是,各个组件之间可以直接共享页面的状态和方法,页面和组件间不再需要把状态数据传来传去、多级回调了。

不过这种方式也有缺点,一旦数据发生变更,Flutter会重建整个大Widget(而不是变化的那部分),所以会对性能产生些影响。

问题2:对于集合类型List和Map,如何让其内部元素支持多种类型?

这个问题来自第6篇文章“基础语法与类型变量:Dart是如何表示信息的?”,你可以先回顾下这篇文章的相关知识点。

如果集合中多个类型之间存在共同的父类(比如double和int),可以使用父类进行容器类型声明,从而增加类型的安全校验,在取出对象时根据runtimeType转换成实际类型即可。如果容器中的类型比较多,想省掉类型转换的步骤,也可以使用动态类型dynamic为元素添加不同类型的元素。

而在判断元素真实类型时,我们可以使用is关键字或runtimeType。

问题3:继承、接口与混入的相关问题。

这个问题来自第7篇文章“函数、类与运算符:Dart是如何处理信息的?”,你可以先回顾下这篇文章的相关知识点。

第一,你是怎样理解父类继承、接口实现和混入的?我们应该在什么场景下使用它们?

父类继承、接口实现和混入都是实现代码复用的手段,我们在代码中应该根据不同的需求去使用。其中:

第二,在父类继承的场景中,父类子类之间的构造函数执行顺序是怎样的?如果父类有多个构造函数,子类也有多个构造函数,如何从代码层面确保父类子类之间构造函数的正确调用?

默认情况下,子类的构造函数会自动调用父类的默认构造函数,如果需要显式地调用父类的构造函数,则需要在子类构造函数的函数体开头位置调用。但,如果子类提供了初始化参数列表,则初始化参数列表会在父类构造函数之前执行。

构造函数之间,有时候会有一些相同的逻辑。如果把这些逻辑分别写在各个构造函数中,会有些累赘,所以构造函数之间是可以传递的,相当于填充了某个构造函数的参数,从而实现类的初始化。因此可以传递的构造函数是没有方法体的,它们只会在初始化列表中,去调用另一个构造函数。

如果子类与父类存在多个构造函数,通常是为了简化类的初始化代码,将部分不需要的属性设置为默认值。因此,我们只要能确保每条构造函数的初始化路径都不会有属性被遗漏即可。一个好的做法是,依照构造函数的参数个数,将参数少的构造函数转发至参数多的构造函数中,由参数最多的构造函数统一调用父类参数最多的那个构造函数。

问题4:扩展购物车案例的程序,使其支持商品数量属性,并输出商品列表信息(包括商品名称、数量及单价)。

这个问题来自第8篇文章“综合案例:掌握Dart核心特性”,你可以先回顾下这篇文章的相关知识点。

要实现这个扩展功能,如我所说,每个人都可能有完全不一样的解决方案。在这里,我给你的提示是,在Item类中增加数量属性,在做小票打印时,循环购物车内的商品信息即可实现。

需要注意的是,增加数量属性后,商品在做合并计算价格时,count需要置为1,而不能做累加。比如,五斤苹果和三盒巧克力做合并,结果是一份巧克力苹果套餐,而不是八份巧克力苹果套餐。

问题5:Widget、Element 和 RenderObject之间是什么关系?你能否在Android/iOS/Web中找到对应的概念呢?

这个问题来自第9篇文章“Widget,构建Flutter界面的基石”。

Widget是数据配置,RenderObject负责渲染,而Element是一个介于它们之间的中间类,用于渲染资源复用。

Widget和Element是一一对应的,但RenderObject不是,只有实际需要布局和绘制的控件才会有RenderObject。

这三个概念在iOS、Android和Web开发中,对应的概念分别是:

问题6:State构造函数和initState的差异是什么?

这个问题来自第11篇文章“提到生命周期,我们是在说什么?”。

State构造函数调用时,Widget还未完成初始化,因此仅适用于一些与UI无关的数据初始化,比如父类传入的参数加工。

而initState函数调用时,StatefulWidget已经完成了Widget树的插入工作,因此与Widget相关的一些初始化工作,比如设置滚动监听器则必须放在initState。

问题7:Text、Image以及按钮控件,真正承载其视觉功能的控件分别是什么?

这个问题来自第12篇文章“经典控件(一):文本、图片和按钮在Flutter中怎么用?”。

Text是封装了RichText的StatelessWidget,Image是封装了RawImage的StatefulWidget,而按钮则是封装了RawMaterialButton的StatelessWidget。

可以看到,StatelessWidget和StatefulWidget只是封装了控件的容器,并不参与实际绘制,真正负责渲染的是继承自RenderObject的视觉功能组件们,比如RichText与RawImage。

问题8:在ListView中,如何提前缓存子元素?

这个问题来自第13篇文章“经典控件(二):UITableView/ListView在Flutter中是什么?”。

ListView构造函数中有一个cacheExtent参数,即预渲染区域长度。ListView会在其可视区域的两边留一个cacheExtent长度的区域作为预渲染区域,相当于提前缓存些元素,这样当滑动时就可以迅速呈现了。

问题9:Row与Column自身的大小是如何决定的?当它们嵌套时,又会出现怎样的情况呢?

这个问题来自第14篇文章“经典布局:如何定义子控件在父容器中排版的位置?”。

Row与Column自身的大小由父Widget的大小、子Widget的大小,以及mainSize共同决定。

Row和Column只会在主轴方向占用尽可能大的空间(max:屏幕方向主轴大小或父Widget主轴方向大小;min:所有子Widget组合在一起的主轴方向大小),而纵轴的长度则取决于它们最大子元素的长度。

如果Row里面嵌套Row,或者Column里面嵌套Column,只有最外层的Row或Colum才会占用尽可能大的空间,里层Row或Column占用的空间为实际大小。

问题10:在 UpdatedItem 控件的基础上,增加切换夜间模式的功能。

这个问题来自第16篇文章“从夜间模式说起,如何定制不同风格的App主题?”。

这是一道实践题。同样地,我在这里也只提示你实现思路:你可以在ThemeData中,通过增加变量来判断当前使用何种主题,然后在State中驱动变量更新即可。

问题11:像素密度为3.0及1.0设备,如何根据资源图片像素进行处理?

这个问题来自第17篇文章“依赖管理(一):图片、配置和字体在Flutter中怎么用?”。

设备根据资源图片像素进行适配的原则是:调整为使用最合适的分辨率资源,即像素密度为3.0的设备会选择2.0而不是1.0的资源图片;而像素密度为1.0的设备,对于像素密度大于1.0的资源图片会进行压缩。

问题12:.packages 与 pubspec.lock 是否需要做代码版本管理?

这个问题来自第18篇文章“依赖管理(二):第三方组件库在Flutter中要如何管理?”。

pubspec.lock需要做版本管理,因为lock文件记录了Dart在计算项目依赖时,当前工程所有显式和隐私的依赖关系。我们可以直接使用这个结果去统一工程开发环境。

而.packages不需要版本管理,因为这个文件记录了Dart在计算项目依赖时,当前工程所有依赖的本地缓存文件。与本地环境有关,无需统一。

问题13:GestureDetector内嵌FlatButton后,事件是如何响应的?

这个问题来自第19篇文章“用户交互事件该如何响应?”。

对于一个父容器中存在按钮FlatButton的界面,在父容器使用GestureDetector监听了onTap事件的情况下,我们点击按钮是不会被父Widget响应的。因为,手势竞技场只会同时响应一个(子Widget)。

如果监听的是onDoubleTap事件,在按钮上双击后,父容器的双击事件会被识别。因为,子Widget没有处理双击事件,不需要经历手势竞技场的PK过程。

问题14:请分别概括属性传值、InheritedWidget、Notification 与 EventBus的特点。

这个问题来自第20篇文章“关于跨组件传递数据,你只需要记住这三招”。

属性传值适合在同一个视图树中使用,传递方向由父及子,通过构造方法将值以属性的方式传递过去,简单高效。其缺点是,涉及跨层传递时,属性可能需要跨越很多层才能传递给子组件,导致中间很多并不需要这个属性的组件,也得接收其子Widget的数据,繁琐且冗余。

InheritedWidget适用于子Widget主动向上层拿数据的场景,传递方向由父及子,可以实现跨层的数据读共享。InheritedWidget也可以实现写共享,需要在上层封装写数据的方法供下层调用。其优点是,数据传输方便,无代码侵入即可达到逻辑和视图解耦的效果;而其缺点是,如果层次较深,刷新范围过大会影响性能。

Notification适用于子Widget向父Widget推送数据的场景,传递方向由子及父,可以实现跨层的数据变更共享。其优点是,多个子元素的同一事件可由父元素统一处理,多对一简单;而其缺点是,Notification的自定义过程略繁琐。

EventBus适用于无需存在父子关系的实体之间通信,订阅者需要显式地订阅和取消。其优点是,能够支持任意对象的传递,一对多的方式实现简单;而其缺点是,订阅管理略显繁琐。

问题15:实现一个计算页面,这个页面可以对前一个页面传入的 2 个数值参数进行求和,并在该页面关闭时告知上一页面计算的结果。

这个问题来自第21篇文章“路由与导航,Flutter是这样实现页面切换的”。

这是一个实践题,还需要你动手去实现。这里,我给你的提示是:基本路由可以通过构造函数属性传值的方式,或是在MaterialPageRoute中加入参数setting,来传递页面参数。

打开页面时,我们可以使用上述机制为基本路由传递参数(2个数值),并注册then回调监听页面的关闭事件;而页面需要关闭时,我们将2个数值参数取出,求和后调用pop函数即可。

问题16:AnimatedBuilder中,外层的child参数与内层builder函数中的child参数的作用分别是什么?

AnimatedBuilder(
  animation: animation,
  child:FlutterLogo(),
  builder: (context, child) => Container(
    width: animation.value,
    height: animation.value,
    child: child
  )
)

这个问题来自第22篇文章“如何构造炫酷的动画效果?”。

外层的child参数定义渲染,内层builder中的child参数定义动画,实现了动画和渲染的分离。通过builder函数,限定了重建rebuild的范围,做动画时不必每次重新构建整个Widget。

问题17:并发 Isolate 计算阶乘例子里给并发Isolate两个SendPort的原因?

//并发计算阶乘
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));//等待并发计算阶乘结果

这个问题来自第23篇文章“单线程模型怎么保证UI运行流畅?”。

SendPort/ReceivePort是一个单向管道,帮助我们实现并发Isolate往主Isolate回传执行结果:并发Isolate负责用SendPort发,而主Isolate负责用ReceivePort收。对于回传执行结果这个过程而言,主Isolate除了被动等待没有别的办法。

在这个例子中,并发Isolate用SendPort发了两次数据,意味着主Isolate也需要用SendPort对应的ReceivePort等待两次。如果并发Isolate用SenderPort发了三次数据,那主Isolate也需要用ReceivePort等待三次。

那么,主Isolate怎么知道自己需要等待几次呢,总不能一直等着吧?

所以更好的办法是,只使用SendPort/ReceivePort一次,发/收完了就不用了。但,如果下次还要发/收怎么办?

这时,我们就可以参考这个计算阶乘案例的做法,在发数据的时候把下一次用到的SendPort也当做参数传过去。

问题18:自定义dio拦截器,检查并刷新token。

这个问题来自第24篇文章“HTTP网络编程与JSON解析”。

这也是一个实践题,我同样只提示你关键思路:在拦截器的onRequest方法中,检查header中是否存在token,如果没有,则发起一个新的请求去获取token,更新header。考虑到可能会有多个request同时发出,token会请求多次,我们可以通过调用拦截器的 lock/unlock 方法来锁定/解锁拦截器。

一旦请求/响应拦截器被锁定,接下来的请求/响应将会在进入请求/响应拦截器之前排队等待,直到解锁后,这些入队的请求才会继续执行(进入拦截器)。

问题19:持久化存储的相关问题。

这个问题来自来第25篇文章“本地存储与数据库的使用和优化”。

首先,我们先看看文件、SharedPreferences 和数据库,这三种持久化数据存储方式的适用场景。

接下来,我们看看如何做数据库跨版本升级?

数据库升级,实际上就是改表结构。如果升级过程是连续的,我们只需要在每个版本执行修改表结构的语句就可以了。如果升级过程不是连续的,比如直接从1.0升到5.0,中间2.0、3.0和4.0都直接跳过的:

1.0->2.0:执行表结构修改语句A
2.0->3.0:执行表结构修改语句B
3.0->4.0:执行表结构修改语句C
4.0->5.0:执行表结构修改语句D

因此,我们在5.0的数据库迁移中,不能只考虑5.0的表结构,单独执行4.0的升级逻辑D,还需要考虑2.0、3.0、4.0的表结构,把1.0升级到4.0之间的所有升级逻辑执行一遍:

switch(oldVersion) {
 case '1.0': do A;
 case '2.0': do B;
 case '3.0': do C;
 case '4.0': do D;
 default: print('done');
}

这样就万无一失了。

不过需要注意的是,在Dart的switch里,条件判断break语句是不能省的。关于如何在Dart中写出类似C++的fallthrough switch,你可以再思考一下。

问题20:扩展openAppMarket的实现,使得我们可以跳转到任意一个App的应用市场。

这个问题来自第26篇文章“如何在Dart层兼容Android/iOS平台特定实现?(一)”。

对于这个问题,我给你的提示是:Dart调用invokeMethod方法时,可传入Map类型的键值对参数(包含iOS的bundleID和Android包名),然后在原生代码宿主将参数取出即可。

问题21:扩展内嵌原生视图的实现,实现动态变更原生视图颜色的需求。

这个问题来自第27篇文章“如何在Dart层兼容Android/iOS平台特定实现?(二)”。

对于这个问题,我给你提示与上一问题类似:Dart调用invokeMethod方法时,可传入Map类型的键值对参数(颜色的RGB信息),然后在原生代码宿主将参数取出即可。

问题22:对于有资源依赖的Flutter模块工程,其打包构建的产物,以及抽离组件库的过程是否有不同?

这个问题来自第28篇文章“如何在原生应用中混编Flutter工程?”。

答案是没什么不同。因为Flutter模块的文件本身就包含了资源文件。

如果模块工程有原生插件依赖,则其抽离过程还需要借助记录了插件本地依赖缓存地址的.flutter-plugins文件,来实现组件依赖的原生部分的封装。具体细节,你可以参考第44篇文章

问题23:如何确保混合工程中两种页面过渡动画在应用整体的效果一致?

这个问题来自第29篇文章“混合开发,该用何种方案管理导航栈?

首先,这两种页面过渡动画分别是:原生页面之间的切换动画和Flutter页面之间的切换动画。

保证整体效果一致,有两种方案:

问题24:如何使用Provider实现2个同样类型的对象共享?

这个问题来自第30篇文章“为什么需要做状态管理,怎么做?

答案很简单,你可以封装1个大对象,将2个同样类型的对象封装为其内部属性。

问题25:如何让Flutter代码能够更快地收到推送消息?

这个问题来自第31篇文章“如何实现原生推送能力?”。

我们需要先判断当前应用是处于前台还是后台,然后再用对应的方式去处理:

问题26:如何实现图片资源的国际化?

这个问题来自第32篇文章“适配国际化,除了多语言我们还需要注意什么?”。

其实,图片资源国际化与文本资源,本质上并无区别,只需要在arb文件中对不同的图片进行单独声明即可。具体的实现细节,你可以再回顾下这篇文章的相关内容。

问题27:相邻页面的横竖屏切换如何实现?

这个问题来自第33篇文章“如何适配不同分辨率的手机屏幕?”。

这个实现方式很简单。你可以在initState中设置屏幕支持方向,在dispose时将屏幕方向还原即可。

问题28:在保持生产环境代码不变的情况下,如何支持不同配置的切换?

这个问题来自第34篇文章“如何理解Flutter的编译模式?”。

与配置夜间模式类似,我们可以通过增加状态开关来判断当前使用何种配置,设置入口,然后在State中驱动变量更新即可。关于夜间模式的配置,你可以再回顾下第16篇文章“从夜间模式说起,如何定制不同风格的App主题?”中的相关内容。

问题29:将debugPrint改为循环写日志。

这个问题来自第36篇文章“如何通过工具链优化开发调试效率?

关于这个问题,我给你的提示是,用不同的main文件定义debugPrint行为:main-dev.dart定义为日志输出至控制台,而main.dart定义为输出至文件。当前操作的文件名默认为0,写满后文件名按5取模递增,同步更新至SharedPreferences中,并将文件清空,重新写入。

问题30:使用并发Isolate完成MD5的计算。

这个问题来自第37篇文章“如何检测并优化Flutter App的整体性能表现?”。

关于这个问题,我给你的提示是:将界面改造为StatefulWidget,把MD5的计算启动放在StatefulWidget的初始化中,使用compute去启动计算。在build函数中,判断是否存在MD5数据,如果没有,展示CircularProgressIndicator,如果有,则展示ListView。

问题31:如何使用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;
}

这个问题来自第38篇文章“如何通过自动化测试提高交付质量?”。

待测函数updateSP与increaseSPCounter,其内部依赖了SharedPreferences的setInt方法与getInt方法,其中前者是异步函数,后者是同步函数。

因此,我们只需要为setInt与getInt模拟对应的数据返回即可。对于setInt,我们只需要在参数为1的时候返回true:

when(prefs.setInt('counter', 1)).thenAnswer((_) async => true);

对于getInt,我们只需要返回2:

when(prefs.getInt('counter')).thenAnswer((_) => 2);

其他部分与普通的单元测试并无不同。

问题32:并发Isolate的异常如何采集?

这个问题来自第39篇文章“线上出现问题,该如何做好异常捕获与信息采集?”。

并发Isolate的异常是无法通过try-catch来捕获的。并发Isolate与主Isolate通信是采用SendPort的消息机制,而异常本质上也可以视作一种消息传递机制。所以,如果主Isolate想要捕获并发Isolate中的异常消息,可以给并发Isolate传入SendPort。

而创建Isolate的函数spawn中就恰好有一个类型为SendPort的onError参数,因此并发Isolate可以通过往这个参数里发送消息,实现异常通知。

问题33:依赖单个或多个网络接口数据的页面加载时长应该如何统计?

这个问题来自第40篇文章“衡量Flutter App线上质量,我们需要关注这三个指标”。

页面加载时长=页面完成渲染的时间-页面初始化的时间。所以,我们只需要在进入页面时记录启动页面初始化时间,在接口返回数据刷新界面的同时,开启单次帧绘制回调,检测到页面完成渲染后记录页面渲染完成时间,两者相减即可。如果页面的渲染涉及到多个接口也类似。

问题34:如何设置Travis的Flutter版本?

这个问题来自第42篇文章“如何构建高效的Flutter App打包发布环境?”。

设置方式很简单。在before_install字段里,克隆Flutter SDK时,直接指定特定的分支即可:

git clone -b 'v1.5.4-hotfix.2' --depth 1 https://github.com/flutter/flutter.git

问题35:如何通过反射快速实现插件定义的标准化?

这个问题来自第44篇文章“如何构建自己的Flutter混合开发框架(二)?”。

在Dart层调用不存在的接口(或未实现的接口),可以通过noSuchMethod方法进行统一处理。这个方法会携带一个类型为Invocation的参数invocation,我们可以通过它得到调用的函数名及参数:

//获取方法名
String methodName = invocation.memberName.toString().substring(8, string.length - 2);
//获取参数
dynamic args = invocation.positionalArguments;

其中,参数args是一个List类型的变量,我们可以在原生代码宿主把相关的参数依次解析出来。有了函数名和参数,我们在插件类实例上,就可以利用反射去动态地调用原生方法了。

与传统的方法调用相比,以反射的方式执行方法调用,其步骤相对繁琐一些,我们需要依次找到并初始化反射调用过程的类示例对象、方法对象、参数列表对象,然后执行反射调用,并根据方法声明获取执行结果。不过这些步骤都是固定的,我们依葫芦画瓢就好。

Android端的调用方式:

public void onMethodCall(MethodCall call, Result result) {
  ...
  String method = call.argument("method"); //获取函数名
  ArrayList params = call.argument("params"); //获取参数列表
  Class<?> c = FlutterPluginDemoPlugin.class; //反射施加对象
  Method m = c.getMethod(method, ArrayList.class); //获取方法对象
  Object ret = m.invoke(this,params); //在插件实例上调用反射方法,获取返回值
  result.success(ret); //返回执行结果
  ...
}

iOS端的调用方式:

- (void)handleMethodCall:(FlutterMethodCall*)call result:(FlutterResult)result {
 ...
  NSArray *arguments = call.arguments[@"params"]; //获取函数名
  NSString *methodName = call.arguments[@"method"]; //获取参数列表
  SEL selector = NSSelectorFromString([NSString stringWithFormat:@"%@:",methodName]); //获取函数对应的Slector
  NSMethodSignature *signature = [[self class] instanceMethodSignatureForSelector:selector]; //在插件实例上获取方法签名
  NSInvocation *invocation = [NSInvocation invocationWithMethodSignature:signature]; //通过方法签名生成反射的invocation对象
        
  invocation.target = self; //设置invocation的执行对象
  invocation.selector = selector; //设置invocation的selector     
  [invocation setArgument:&arguments atIndex:2]; //设置invocation的参数

  [invocation invoke]; //执行反射
        
  NSObject *ret = nil;
  if (signature.methodReturnLength) {
      void *returnValue = nil;
      [invocation getReturnValue:&returnValue];
      ret = (__bridge NSObject *)returnValue; //获取反射调用结果
  }    
              
  result(ret); //返回执行结果
  ...      
}

以上,就是“Flutter核心技术与实战”专栏,全部思考题的答案了。你如果还有其他问题的话,欢迎给我留言,我们一起讨论。