first commit

This commit is contained in:
张乾
2024-10-16 00:01:16 +08:00
parent ac7d1ed7bc
commit 84ae12296c
322 changed files with 104488 additions and 0 deletions

View File

@@ -0,0 +1,75 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
00 开篇词 为什么每一位大前端从业者都应该学习Flutter
你好我是陈航之前在美团外卖担任商家业务大前端团队技术负责人。在接下来三个月的时间里我将和你一起学习Flutter。
当下是移动互联网的时代也是大前端技术紧密整合的时代。而移动系统与终端设备的碎片化让我们一直头痛于在不同平台上开发和维护同一个产品的成本问题使用原生方式来开发App不仅要求分别针对iOS和Android平台使用不同的语言实现同样的产品功能还要对不同的终端设备和不同的操作系统进行功能适配并承担由此带来的测试维护升级工作。
这对中小型团队而言无疑是非常大的负担,也无形中拖慢了追求“小步快跑”,以快速应对市场变化的互联网产品交付节奏。
为解决这一问题各类打着“一套代码多端运行”口号的跨平台开发方案如雨后春笋般涌现React Native就是其中的典型代表。
React Native希望开发者能够在性能、展示、交互能力和迭代交付效率之间做到平衡。它在Web容器方案的基础上优化了加载、解析和渲染这三大过程以相对简单的方式支持了构建移动端页面必要的Web标准保证了便捷的前端开发体验并且在保留基本渲染能力的基础上用原生自带的UI组件实现代替了核心的渲染引擎从而保证了良好的渲染性能。
但是由于React Native的技术方案所限使用原生控件承载界面渲染在牺牲了部分Web标准灵活性的同时固然解决了不少性能问题但也引入了新的问题除开通过JavaScript虚拟机进行原生接口的调用而带来的通信低效不谈由于框架本身不负责渲染而是由原生代理因此我们还需要面对大量平台相关的逻辑。
而随着系统版本和API的变化我们还需要处理不同平台的原生控件渲染能力上的差异修复各类怪异的Bug甚至还需要在原生系统上打各类补丁。
这都使React Native的跨平台特性被大打折扣要用好React Native除了掌握这个框架外开发者还必须同时熟悉iOS和Android系统。这无疑给开发者提出了更多挑战也是很多开发者们对React Native又爱又恨的原因。在这其中也有一些团队决定放弃React Native回归原生开发Airbnb就是一个例子。
备注2018年Airbnb团队在Medium上发布的一系列文章React Native at Airbnb、React Native at Airbnb: The Technology、Building a Cross-Platform Mobile Team、Sunsetting React Native、Whats Next for Mobile at Airbnb详细描述了这个过程。
而我们本次课程的主角Flutter则完全不同于React Native。
它开辟了全新的思路提供了一整套从底层渲染逻辑到上层开发语言的完整解决方案视图渲染完全闭环在其框架内部不依赖于底层操作系统提供的任何组件从根本上保证了视图渲染在Android和iOS上的高度一致性Flutter的开发语言Dart是Google专门为前端开发量身打造的专属语言借助于先进的工具链和编译器成为了少数同时支持JIT和AOT的语言之一开发期调试效率高发布期运行速度快、执行性能好在代码执行效率上可以媲美原生App。而这与React Native所用的只能解释执行的JavaScript又拉开了性能差距。
正是因为这些革命性的特点Flutter在正式版发布半年多的时间里在GitHub上的Star就已经超过了68,000与已经发布4年多的、拥有78,000 Star的同行业领头羊React Native的差距非常小。同时阿里闲鱼、今日头条等知名商用案例的加持更使得Flutter的热度不断攀升。
现在看来在Google的强力带动下Flutter极有可能成为跨平台开发领域的终极解决方案。在过去的大半年时间里我曾面试了20多位初、中、高级候选人包括前端、Android、iOS的开发者。当问到最近想学习什么新技术时超过80%的候选人告诉我他会学习或正在学习Flutter。
不过坦白讲相比其他跨平台技术Flutter的学习成本相对较高。我听过很多前端开发者反馈Flutter从语言到开发框架都是全新的技术栈的积累也要从头开始学不动了。
学习成本高,这也是目前大多数开发者犹豫是否要跟进这个框架的最重要原因。对此,我感同身受。
但其实大前端各个方向的工作有很多相似、相通之处。面对业务侧日益增多的需求作为大前端团队的负责人我曾在不同时期带领团队分别探索并大规模落地了以React Native和Flutter为代表的跨平台方案也是美团最早落地Flutter线上大规模应用的发起者和推动者之一。
在探索并大规模落地Flutter的过程中我阅读过大量关于Flutter的教程和技术博客但我发现很多文章的学习门槛都比较高而且过于重视应用层API各个参数的介绍或实现细节导致很多从其他平台转来的开发者无从下手只能依葫芦画瓢却不知道为什么要“画瓢”无法与自身的经验串联进而形成知识体系。这无疑又增加了学习门槛加长了学习周期。
那么Flutter到底该怎么学真的要从头开始么
虽然Flutter是全新的跨平台技术但其背后的框架原理和底层设计思想无论是底层渲染机制与事件处理方式还是组件化解耦思路亦或是工程化整体方法等与原生Android/iOS开发并没有本质区别甚至还从React Native那里吸收了不少优秀的设计理念。就连Flutter所采用的Dart语言关于信息表达和处理的方式也有诸多其他优秀编程语言的影子。
因此从本质上看Flutter并没有开创新的概念。这也就意味着如果我们在学习Flutter时能够深入进去搞懂它的原理、设计思路和通用理念并与过往的开发经验相结合建立起属于自己的知识体系抽象层次而不是仅停留在应用层API的使用上就摆脱了经验与平台的强绑定。
这样的话,即使未来老框架不断更新,或者出现新的解决方案,我们仍旧可以立于不败之地。
那么Flutter框架底层有哪些关键技术它们是如何高效运转以支撑起可以媲美原生应用的跨平台方案的Flutter应用开发的最佳实践是怎样的企业需要什么样的终端技术人才终端技术未来有哪些发展方向
这些问题正是我要通过这个课程为你解答的。在这个课程里我不仅会帮助你快速上手能够使用Flutter开发一款企业级App更希望帮助你将其与过往的开发经验串联起来以建立起自己的知识体系同时希望你能透过现象明白Flutter框架的用法并看到其背后的原理和设计理念。
为了帮助你领悟到Flutter的核心思想和关键技术而不是陷入组件的API细节难以自拔我会在不影响学习、理解的情况下省去一些不影响核心功能的代码和参数讲解着重为你剖析框架的核心知识点和背后原理并与你分享一些常见问题的解决思路。
整体来说,专栏主要包括以下五大部分内容:
Flutter开发起步模块。我会从跨平台方案发展历史出发与你介绍Flutter的诞生背景、基本原理并带你体验一下Flutter代码是如何在原生系统上运行的。
Dart基础模块。我会从Dart与其他编程语言的设计思想对比出发与你讲述Dart设计的关键思路以及独有特性并通过一个综合案例带你去实践一下。
Flutter基础模块。我将通过Flutter与原生系统对应概念对比与你讲述Flutter独有的概念和框架设计思路。学完这个模块你就可以开发出一个简单的App了。
Flutter进阶模块。我会与你讲述Flutter开发中的一些疑难问题、高级特性及其背后原理帮助你在遇到问题时化被动为主动。
Flutter综合应用模块。我将和你聊聊在企业级应用迭代的生命周期中如何从效率和质量这两个维度出发构建自己的Flutter开发体系。
最后我希望通过这个课程能够帮助你快速上手Flutter开发应用掌握其精髓并引导你建立起属于自己的终端知识体系。
现在Flutter正处于快速发展中社区也非常活跃。站在未来看未来尽管Flutter全平台制霸的目标已经非常清晰但为期三个月的专栏分享未必能穷尽Flutter未来可能的技术发展方向。接下来我会持续关注Flutter包括移动端之外的最新变化持续更新这个专栏第一时间与你分享Flutter的那些事儿。
好了今天的内容就到这里了。如果可以的话还请你在留言区中做个自我介绍和我聊聊你目前的工作、学习情况以及你在学习或者使用Flutter时遇到的问题这样我们可以彼此了解也方便我在后面针对性地给你讲解。
加油,让我们突破自己的瓶颈,保持学习、保持冷静、保持成长。

View File

@@ -0,0 +1,224 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
01 预习篇 · 从0开始搭建Flutter工程环境
你好,我是陈航。
俗话说工欲善其事必先利其器。任何一门新技术、新语言的学习都需要从最基础的工程环境搭建开始学习Flutter也不例外。所以作为专栏的第一篇文章我会与你逐一介绍Flutter的开发环境配置并通过一个Demo为你演示Flutter项目是如何运行在Andorid和iOS的模拟器和真机上的。如果你已经掌握了这部分内容那可以跳过这篇预习文章直接开始后面内容的学习。
由于是跨平台开发所以为了方便调试你需要一个可以支持Android和iOS运行的操作系统也就是macOS因此后面的内容主要针对的是在macOS系统下如何配置Flutter开发环境。
如果你身边没有macOS系统的电脑也没关系在Windows或Linux系统上配置Flutter也是类似的方法一些关键的区别我也会重点说明。但这样的话你就只能在Android单平台上开发调试了。
准备工作
安装Android Studio
Android Studio是基于IntelliJ IDEA的、Google官方的Android应用集成开发环境(IDE)。
我们在官网上找到最新版截止至本文定稿最新版为3.4下载后启动安装文件剩下的就是按照系统提示进行SDK的安装和工程配置工作了。
配置完成后我们打开AVD Manager点击“Create Virtual Device”按钮创建一台Nexus 6P模拟器至此Android Studio的安装配置工作就完成了。
安装Xcode
Xcode是苹果公司官方的iOS和macOS应用集成开发环境(IDE)。它的安装方式非常简单直接在macOS系统的App Store搜索Xcode然后安装即可。
安装完成后我们会在Launchpad看到Xcode图标打开它按照提示接受Xcode许可协议以及安装配置组件就可以了。
配置完成后我们打开Terminal输入命令open -a Simulator打开iOS模拟器检查 Hardware>Device 菜单项中的设置,并试着在不同的模拟器之间做切换。
至此Xcode的安装配置工作也就顺利完成了。
安装Flutter
Flutter源站在国内可能不太稳定因此谷歌中国开发者社区GDG专门搭建了临时镜像使得我们的Flutter 命令行工具可以到该镜像站点下载所需资源。
接下来我们需要配置镜像站点的环境变量。对于macOS和Linux系统来说我们通过文本编辑器打开~/.bash_profile文件在文件最后添加以下代码来配置镜像站点的环境变量
export PUB_HOSTED_URL=https://pub.flutter-io.cn
export FLUTTER_STORAGE_BASE_URL=https://storage.flutter-io.cn
而对于Windows系统来说我们右键点击计算机图标依次选择属性>高级系统设置–>高级–>环境变量新建用户变量PUB_HOSTED_URL其值为https://pub.flutter-io.cn随后新建FLUTTER_STORAGE_BASE_URL其值为https://storage.flutter-io.cn重启电脑即可完成配置。
到这里,我们就完成了镜像的配置。
不过由于GDG并不是官方组织因此Flutter团队也无法保证此服务长期可用。但是你也不用担心可以关注Flutter社区 Using Flutter in China来获取其他可用的镜像资源随时更新环境变量即可。
随后我们再去Flutter官网选择并下载最新的稳定版截止至本文定稿最新稳定版为1.5)。
接下来,我们把下载的压缩包解压到你想安装的目录,比如~/Documents或C:\src\flutter。为了可以在命令行中执行flutter命令我们同样需要配置环境变量。
对于macOS与Linux系统我们编辑~/.bash_profile文件把以下代码添加至文件最后将flutter命令的执行路径追加到环境变量PATH中
export PATH=~/Documents/flutter/bin:$PATH
而对于Windows系统我们在当前用户变量下Path以; 为分隔符在其后追加flutter命令行的全路径C:\src\flutter\bin重启电脑即可完成配置。
到这里我们就完成了Flutter SDK的安装。
打开Flutter根目录我们可以发现有一个examples文件夹里面是一些基本的flutter示例。在今天这篇文章中我会以hello_world示例为例和你演示一下如何在模拟器和真机中运行Flutter项目。
首先我给你介绍的是通过Flutter命令行运行的模式。进入hello_world目录输入flutter emulators命令查看当前可用的模拟器
图1 查看可用的flutter模拟器
可以看到我们刚刚创建的两台模拟器也就是Nexus 6P和iOS模拟器都已经在列表中了。于是我们启动iOS模拟器运行Flutter项目
flutter emulators --launch apple_ios_simulator
flutter run
等待10秒左右一个熟悉的hello world大屏幕就出现在我们面前了
图2 Flutter demo
Android模拟器的启动和运行也与之类似我就不再赘述了。
不过使用命令行的方式来开发调试Flutter还是非常不方便更高效的方式是配置Android和iOS的集成开发环境。
Flutter 提供了一个命令flutter doctor协助我们安装 Flutter的工程依赖它会检查本地是否有Android和iOS的开发环境如果检测到依赖缺失就会给出对应依赖的安装方法。
接下来我们试着运行下flutter doctor这条命令得到了如下图所示的结果
图3 flutter doctor命令示意
可以看到flutter doctor检测出了iOS工具链、Android Studio工程这两项配置中的问题。此外由于我的电脑还安装了IDEA和VS Code而它们也是Flutter官方支持的IDE因此也一并检测出了问题。
接下来我们根据运行flutter doctor命令得到的提示来分别解决iOS工具链和Android Studio工程配置问题。
iOS工具链设置
现在我们已经可以在iOS模拟器上开发调试Flutter应用了。但要将Flutter应用部署到真实的iOS设备上我们还需要安装一些额外的连接控制命令工具就像通过电脑的iTunes给手机安装应用一样并申请一个iOS开发者账号进行Xcode签名配置。
依据提示我们首先安装libimobiledevice和ideviceinstaller这两项依赖
brew update
brew install --HEAD usbmuxd
brew link usbmuxd
brew install --HEAD libimobiledevice
brew install ideviceinstaller
其中usbmuxd是一个与iOS设备建立多路通信连接的socket守护进程通过它可以将USB通信抽象为TCP通信libimobiledevice是一个与iOS设备进行通信的跨平台协议库而ideviceinstaller则是一个使用它们在iOS设备上管理App的工具。
现在你不了解它们的具体作用也没关系只要知道安装了它们Flutter就可以进行iOS真机的开发调试就可以了。
然后进行Xcode签名配置。
打开hello_world项目中的ios/Runner.xcworkspace在Xcode中选择导航面板左侧最上方的Runner项目。
图4 Flutter Xcode签名配置
在General > Signing > Team 中我们需要配置一下开发团队也就是用你的Apple ID登录Xcode。当配置完成时Xcode会自动创建并下载开发证书。
任意Apple ID都支持开发和测试但如果想将应用发布到App Store则必须加入Apple开发者计划。开发者计划的详细信息你可以通过苹果官方的compare memberships了解这里我就不再展开了。
最后当我们第一次连接真机设备进行开发时Xcode会在你的帐户中自动注册这个设备随后自动创建和下载配置文件。我们只需要在真机设备上按照手机提示信任你的Mac和开发证书就可以了。
至此我们就可以在iOS真机上开发调试Flutter项目了。
Android 工具链配置
相对于iOS工具链的设置Android工具链配置就简单多了这是因为Google官方已经在Android Studio中提供了Flutter和Dart这两个插件。因此我们可以通过这两个工程插件进行Flutter项目的管理以及开发调试。又因为Flutter插件本身依赖于Dart插件所以我们只安装Flutter插件就可以了。
图5 Flutter插件安装
启动Android Studio打开菜单项 Preferences > Plugins搜索Flutter插件并点击 install进行安装。安装完毕后重启Android StudioFlutter插件就生效了。
由于Android Studio本身是基于IDEA开发的因此IDEA的环境配置与Android Studio并无不同这里就不再赘述了。
对于VS Code我们点击View->Command Palette输入”install”然后选择”ExtensionsInstall Extension”。在搜索框中输入flutter选择安装即可。
至此Android的工具链配置也完成了。
尽管Android Studio是Google官方的Android集成开发环境但借助于Flutter插件的支持Android Studio也因此具备了提供一整套Flutter开发、测试、集成打包等跨平台开发环境的能力而插件底层通过调用Xcode提供的命令行工具可以同时支持开发调试及部署iOS和Android应用。
因此我后续的分享都会以Android Studio作为讲解Flutter开发测试的IDE。
运行Flutter项目
用Android Studio打开hello_world工程Open an existing Android Studio Project然后定位到工具栏
图6 Flutter工具栏
在Target selector中我们可以选择一个运行该应用的设备。如果没有列出可用设备你可以采用下面的两种方式
参考我在前面讲到的方法也就是打开AVD Manager并创建一台Android模拟器或是通过open -a Simulator 命令在不同的iOS模拟器之间进行切换。
直接插入Android或iOS真机。
hello_world工程稍微有点特殊因为它提供了两个Dart启动入口一个英文版的hello world-main.dart和一个阿拉伯语版的hello world-arabic.dart。因此我们可以在Config selector中进行启动入口的选择也可以直接使用默认的main.dart。
在工具栏中点击 Run图标稍等10秒钟左右就可以在模拟器或真机上看到启动的应用程序了。
对于Flutter开发测试如果每次修改代码都需要重新编译加载的话那需要等待少则数十秒多则几分钟的时间才能查看样式效果无疑是非常低效的。
正是因为Flutter在开发阶段使用了JIT编译模式使得通过热重载Hot Reload这样的技术去进一步提升调试效率成为可能。简单来说热重载就是在无需重新编译代码、重启应用程序、丢失程序执行状态的情况下就能实时加载修改后的代码查看改动效果。
备注我会在“02 | 预习篇 · Dart语言概览”中与你分析Flutter使用Dart语言同时支持AOT和JIT。
就hello_world示例而言为了体验热重载我们还需要对代码做一些改造将其根节点修改为StatelessWidget
import 'package:flutter/widgets.dart';
class MyAPP extends StatelessWidget {
@override
Widget build(BuildContext context) {
return const Center(child: Text('Hello World', textDirection: TextDirection.ltr));
}
}
void main() => runApp(new MyAPP());
点击Run图标然后试着修改一下代码保存后仅需几百毫秒就可以看到最新的显示效果。
图7 热重载
是不是很Cool但是热重载也有一定的局限性并不是所有的代码改动都可以通过热重载来更新。
对hello_world示例而言由于Flutter并不会在热重载后重新执行main函数而只会根据原来的根节点重新创建控件树因此我们刚才做了一些改造之后才支持热重载。
关于Flutter热重载的原理以及限制原因我会在后面“34 | Hot Reload是怎么做到的”文章和你详细分析。现在你只需要知道如果热重载不起作用的时候我们也不需要进行漫长的重新编译加载等待只要点击位于工程面板左下角的热重启Hot Restart按钮就可以以秒级的速度进行代码重编译以及程序重启了而它与热重载的区别只是因为重启丢失了当前程序的运行状态而已对实际调试也没什么影响。
图8 热重启
总结
通过今天的内容相信你已经完成了Flutter开发测试环境的安装配置对如何在安装过程中随时检测工程依赖以及如何分别在Android和iOS真机及模拟器上运行Flutter程序有了一定的理解并对Flutter开发调试常用工具有了初步的认知。
善用这些集成工具能够帮助我们能提升Flutter开发效率而这些有关工程环境的基础知识则为Flutter的学习提供了支撑。这样如果你后续在开发测试中遇到了环境相关的问题也就知道应该如何去解决。
思考题
你在搭建Flutter工程环境的过程中遇到过哪些问题又是怎么解决的呢
欢迎留言告诉我,我们一起讨论。感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。

View File

@@ -0,0 +1,139 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
02 预习篇 · Dart语言概览
你好,我是陈航。
我们知道Flutter开发框架采用的开发语言是Dart所以要用好这个框架我们必须要搞清楚Dart语言。
关于新技术的学习一直以来我都非常认同一个观点千万不要直接陷入细节里你应该先鸟瞰其全貌这样才能从高维度理解问题。所以为了帮助你更高效地掌握Dart以最快的速度具备开发一款Flutter应用的能力今天这篇文章我会先从Flutter开发的角度和你介绍Dart语言出现的历史背景、特性以及未来。
然后我会在本专栏的“Dart基础”模块与你详细分享它的特性、基础语法、类型变量、函数等知识并和你分享一个使用Dart的综合案例帮你学懂、学会这门语言。
如果你已经对Dart有一个初步印象了也可以跳过这篇预习文章直接学习后面的内容。
Dart是什么
2011年10月在丹麦召开的GOTO大会上Google发布了一种新的编程语言Dart。如同Kotlin和Swift的出现分别是为了解决Java和Objective-C在编写应用程序的一些实际问题一样Dart的诞生正是要解决JavaScript存在的、在语言本质上无法改进的缺陷。
那么JavaScript到底有哪些问题和缺陷呢JavaScript之父布兰登 · 艾克Brendan Eich曾在一次采访中说JavaScript“几天就设计出来了”。
概括来说,他的设计思路是这样的:
借鉴C语言的基本语法
借鉴Java语言的数据类型和内存管理机制
借鉴Scheme语言将函数提升到“第一等公民”first class的地位
借鉴Self语言使用基于原型prototype的继承机制。
所以JavaScript实际上是两类编程语言风格的混合产物简化的函数式编程风格简化的面向对象编程风格。
由于设计时间太短一些细节考虑得不够严谨导致后来很长一段时间使用JavaScript开发的程序混乱不堪。出于对JavaScript的不满Google的程序员们决定自己写一个新语言来换掉它所以Dart的最初定位也是一种运行在浏览器中的脚本语言。
而为了推广DartGoogle甚至将自己的Chrome浏览器内置了Dart VM可以直接高效地运行Dart代码。而对于普通浏览器来说Google也提供了一套能够将Dart代码编译成JavaScript代码的转换工具。这样一来开发者们就可以毫无顾虑地使用Dart去开发了而不必担心兼容问题。再加上出身名门Dart在一开始就赢得了部分前端开发者的关注。
JavaScript的生命力似乎比预想的更强大。
原本JavaScript只能在浏览器中运行但Node.js的出现让它开始有能力运行在服务端很快手机应用与桌面应用也成为了JavaScript的宿主容器一些明星项目比如React、React Native、Vue、Electron、NWnode-webkit等框架如雨后春笋般崛起迅速扩展了它的边界。
于是JavaScript成为了前后端通吃的全栈语言前端的开发模式也因此而改变进入了一个新的世界。就如同Atwood定律描述的凡是能用JavaScript写出来的系统最终都会用JavaScript写出来Any application that can be written in JavaScript, will eventually be written in JavaScript.)。
JavaScript因为Node.js焕发了第二春而Dart就没有那么好的运气了。由于缺少顶级项目的使用Dart始终不温不火。2015年在听取了大量开发者的反馈后Google决定将内置的Dart VM引擎从Chrome移除这对Dart的发展来说是重大挫折替代JavaScript就更无从谈起了。
Dart也借此机会开始转型在Google内部孵化了移动开发框架Flutter弯道超车进入了移动开发的领域而在Google未来的操作系统Fuchsia中Dart更是被指定为官方的开发语言。
与此同时Dart的老本行浏览器前端的发展也并未停滞。著名的前端框架Angular除了常见的TS版本外也在持续迭代对应的Dart版本AngularDart。不过不得不说的是这个项目的star一直以来只有可怜的1,100出头
也正是因为使用者不多、历史包袱少所以在经历了这么多的故事后Dart可以彻底转变思路成为专注大前端与跨平台生态的语言。
接下来我们就从Flutter开发的视角聊聊Dart最重要的核心特性吧。
Dart的特性
每门语言都有各自的特点,适合自己的才是最好的。
作为移动端开发的后来者Dart语言可以说是集百家之长拥有其他优秀编程语言的诸多特性和影子所以对于其他语言的开发者而言学习成本无疑是非常低的。同时Dart拥有的特点则恰到好处在对Flutter的支持上做到了独一无二。所以Dart成了Flutter的选择。
下面,我就和你详细分享下它的核心特性。
JIT与AOT
借助于先进的工具链和编译器Dart是少数同时支持JITJust In Time即时编译和AOTAhead of Time运行前编译的语言之一。那到底什么是JIT和AOT呢
语言在运行之前通常都需要编译JIT和AOT则是最常见的两种编译模式。
JIT在运行时即时编译在开发周期中使用可以动态下发和执行代码开发测试效率高但运行速度和执行性能则会因为运行时即时编译受到影响。
AOT即提前编译可以生成被直接执行的二进制代码运行速度快、执行性能表现好但每次执行前都需要提前编译开发测试效率低。
总结来讲在开发期使用JIT编译可以缩短产品的开发周期。Flutter最受欢迎的功能之一热重载正是基于此特性。而在发布期使用AOT就不需要像React Native那样在跨平台JavaScript代码和原生Android、iOS代码之间建立低效的方法调用映射关系。所以说Dart具有运行速度快、执行性能好的特点。
那么如何区分一门语言究竟是AOT还是JIT呢通常来说看代码在执行前是否需要编译即可。如果需要编译通常属于AOT如果不需要则属于JIT。
AOT的典型代表是C/C++它们必须在执行前编译成机器码而JIT的代表则包括了如JavaScript、Python等几乎所有的脚本语言。
内存分配与垃圾回收
Dart VM的内存分配策略比较简单创建对象时只需要在堆上移动指针内存增长始终是线性的省去了查找可用内存的过程。
在Dart中并发是通过Isolate实现的。Isolate是类似于线程但不共享内存独立运行的worker。这样的机制就可以让Dart实现无锁的快速分配。
Dart的垃圾回收则是采用了多生代算法。新生代在回收内存时采用“半空间”机制触发垃圾回收时Dart会将当前半空间中的“活跃”对象拷贝到备用空间然后整体释放当前空间的所有内存。回收过程中Dart只需要操作少量的“活跃”对象没有引用的大量“死亡”对象则被忽略这样的回收机制很适合Flutter框架中大量Widget销毁重建的场景。
单线程模型
支持并发执行线程的高级语言比如C++、Java、Objective-C大都以抢占式的方式切换线程每个线程都会被分配一个固定的时间片来执行超过了时间片后线程上下文将被抢占后切换。如果这时正在更新线程间的共享资源抢占后就可能导致数据不同步的问题。
解决这一问题的典型方法是,使用锁来保护共享资源,但锁本身又可能会带来性能损耗,甚至出现死锁等更严重的问题。
这时Dart是单线程模型的优势就体现出来了因为它天然不存在资源竞争和状态同步的问题。这就意味着一旦某个函数开始执行就将执行到这个函数结束而不会被其他Dart代码打断。
所以Dart中并没有线程只有Isolate隔离区。Isolates之间不会共享内存就像几个运行在不同进程中的worker通过事件循环Event Looper在事件队列Event Queue上传递消息通信。
无需单独的声明式布局语言
在Flutter中界面布局直接通过Dart编码来定义。
Dart声明式编程布局易于阅读和可视化使得Flutter并不需要类似JSX或XML的声明式布局语言。所有的布局都使用同一种格式也使得Flutter很容易提供高级工具使布局更简单。
开发过程也不需要可视化界面构建器,因为热重载可以让我们立即在手机上看到运行效果。
Dart的未来
那么在这样的背景下诞生的Dart今后发展会怎样呢?
Dart是一个优秀而年轻的现代语言但一种编程语言并不是搞定了引擎和开发者接口就算完成了而是必须在这个语言得以立足的库、框架、 应用程序等“生态”都成熟起来之后,其价值才会真正开始体现。而要走到这一步,通常需要花上数年的时间。
目前基于Dart语言的第三方库还很少并且质量一般不过值得庆幸的是因为Flutter和Fuchsia的推动Dart SDK更新迭代的速度快了很多开发者的热情也急剧增长Dart生态增速很快。
毕竟在Dart社区目前最顶级的产品就是Flutter和Fuchsia了因此Dart开发者主要以Flutter开发者居多当然了也有用Dart开发浏览器前端的开发者但人数并不多。所以我觉得Dart是否能够成功目前来看主要取决于Flutter和Fuchsia能否成功。而Flutter是构建Fuchsia的UI开发框架因此这个问题也变成了Flutter能否成功。
正如我在开篇词中提到的Flutter正式版发布也就半年多的时间在GitHub上Star就已经超过了68,000仅落后React Native 10,000左右可见热度之高。
现在我们一起回到Flutter自身来看它的出现提供了一套彻底的跨平台方案也确实弥补了当今跨平台开发框架的短板解决了业界痛点极有可能成为跨平台开发领域的终极解决方案前途光明未来非常值得期待。
至此我们已经可以清晰地看到Google在遭受与Oracle的Java侵权案后痛定思痛后下定决心要发展自己的语言生态的布局愿景Dart凭借Flutter与Fuchsia的生态主攻前端和移动端而服务端则有借助于Docker的火热势头增长迅猛的Go语言。
所以说Google的布局不仅全面应用和影响也非常广泛前后端均有杀手级产品用来构建语言生态。相信随着Google新系统Fuchsia的发布Flutter和Dart会以更迅猛的速度释放它们的力量而Google统一前后端开发技能栈的愿望也会在一定程度上得以实现。
总结
今天我带你了解了Dart出现的历史背景从Flutter开发者的视角详细介绍了Dart语言的各种特性并分析了Dart的未来发展。
Dart是一门现代语言集合了各种优秀语言的优点。如果你不了解Dart也无需担心只要你有过其他编程语言尤其是Java、JavaScript、Swift或Objective-C编程经验的话可以很容易地在Dart身上找它们的影子以极低的成本快速上手。
希望通过这篇文章你可以先对Dart语言有个初步了解为我们接下来的学习打好基础。在本专栏的“Dart基础”模块中我会对照着其他编程语言的特性和你讲述Dart与它们相似的设计理念帮助你快速建立起构建Flutter程序的所需要的Dart知识体系。
思考题
对于学习Dart或是其他编程语言你有什么困扰或者心得吗
欢迎你在评论区给我留言分享你的经历和观点,我会在下一篇文章中等你!感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。

View File

@@ -0,0 +1,151 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
03 深入理解跨平台方案的历史发展逻辑
你好,我是陈航。
今天,我会从跨平台开发方案的诞生背景、原理和发展历史的角度,和你聊聊这些常见的开发方案,以及针对不同的场景我们应该如何选择对应的方案。
浅述跨平台开发的背景
我们当下所处的移动互联网时代,以它独有的变革方式,带给我们快捷、经济、安全和方便,改变着生活的方方面面。而对于企业来说,移动应用已然成为各类手机终端上一张必备的产品名片。
在移动互联网的浪潮下我们开发的应用要想取胜开发效率和使用体验可以说是同等重要。但是使用原生的方式来开发App就要求我们必须针对iOS和Android这两个平台分别开发这对于中小型团队来说就是隐患和额外的负担。
因为这样的话我们不仅需要在不同的项目间尝试用不同的语言去实现同样的功能还要承担由此带来的维护任务。如果还要继续向其他平台比如Web、Mac或Windows拓展的话我们需要付出的成本和时间将成倍增长。而这显然是难以接受的。于是跨平台开发的概念顺势走进了我们的视野。
所以从本质上讲,跨平台开发是为了增加业务代码的复用率,减少因为要适配多个平台带来的工作量,从而降低开发成本。在提高业务专注度的同时,能够为用户提供一致的用户体验。用一个词来概括这些好处的话,就是“多快好省”。
“一次编码到处运行”。二十多年前Java正是以跨平台特性的口号登场击败了众多竞争对手。这个口号意味着Java可以在任何平台上进行开发然后编译成一段标准的字节码后就可以运行在任何安装有Java虚拟机JVM的设备上。虽然现在跨平台已经不是Java的最大优势而是它繁荣的生态但不可否认它当年打着跨平台旗号横空出世确实势不可挡。
而对于移动端开发来讲,如果能实现“一套代码,多端运行”,这样的技术势必会引发新的生产力变革,在目前多终端时代的大环境下,可以为企业节省人力资源上,从而带来直接的经济效益。
伴随着移动端的诞生和繁荣为了满足人们对开发效率和用户体验的不懈追求各种跨平台的开发方案也如雨后春笋般涌现。除了React Native和Flutter之外这几年还出现过许多其他的解决方案接下来我将会为你一一分析这些方案。这样你在选择适合自己的移动开发框架时也就有章可循了。
在此我特地强调一下我在下文提到的跨平台开发方案如果没有特殊说明的话指的就是跨iOS和Android开发。
跨平台开发方案的三个时代
根据实现方式的不同,业内常见的观点是将主流的跨平台方案划分为三个时代。
Web容器时代基于Web相关技术通过浏览器组件来实现界面及功能典型的框架包括Cordova(PhoneGap)、Ionic和微信小程序。
泛Web容器时代采用类Web标准进行开发但在运行时把绘制和渲染交由原生系统接管的技术代表框架有React Native、Weex和快应用广义的还包括天猫的Virtual View等。
自绘引擎时代自带渲染引擎客户端仅提供一块画布即可获得从业务逻辑到功能呈现的多端高度一致的渲染体验。Flutter是为数不多的代表。
接下来我们先看一下目前使用最广泛的Web容器方案。
Web容器时代
Web时代的方案主要采用的是原生应用内嵌浏览器控件WebViewiOS为UIWebView或WKWebViewAndroid为WebView的方式进行HTML5页面渲染并定义HTML5与原生代码交互协议将部分原生系统能力暴露给HTML5从而扩展HTML5的边界。这类交互协议就是我们通常说的JS Bridge
这种开发模式既有原生应用代码又有Web应用代码因此又被称为Hybrid开发模式。由于HTML5代码只需要开发一次就能同时在多个系统运行因此大大降低了开发成本。
由于采用了Web开发技术社区和资源非常丰富开发效率也很高。但一个完整HTML5页面的展示要经历浏览器控件的加载、解析和渲染三大过程性能消耗要比原生开发增加N个数量级。
接下来,我以加载过程为例,和你说明这个过程的复杂性。
浏览器控件加载HTML5页面的HTML主文档
加载过程中遇到外部CSS文件浏览器另外发出一个请求来获取CSS文件
遇到图片资源浏览器也会另外发出一个请求来获取图片资源。这是异步请求并不会影响HTML文档的加载。
加载过程中遇到JavaScript文件由于JavaScript代码可能会修改DOM树因此HTML文档会挂起渲染加载解析渲染同步的线程直到JavaScript文件加载解析并执行完毕才可以恢复HTML文档的渲染线程。
JavaScript代码中有用到CSS文件中的属性样式于是阻塞等待CSS加载完毕才能恢复执行。
而这只是完成HTML5页面渲染的最基础的加载过程。加载、解析和渲染这三个过程在实际运行时又不是完全独立的还会有交叉。也就是说会存在一边加载一边解析一边渲染的现象。这就使得页面的展示并不像想象中那么容易。
通过上面的分析你可以看出一个HTML5页面的展示是多么得复杂这和原生开发通过简单直接的创建控件设置属性后即可完成页面渲染有非常大的差异。Web与原生在UI渲染与系统功能调用上各司其职因此这个时代的框架在Web与原生系统间还有比较明显的、甚至肉眼可见的边界。
图1 Hybrid开发框架
我也曾碰到过很多人觉得跨平台开发不靠谱。但其实Web容器方案是跨平台开发历史上最成功的例子。也正是因为它太成功了以至于很多人都忽略了它也是跨平台方案之一。
泛Web容器时代
虽然Web容器方案具有生态繁荣、开发体验友好、生产效率高、跨平台兼容性强等优势但它最大的问题在于承载着大量Web标准的Web容器过于笨重以至于性能和体验都达不到与原生同样的水准在复杂交互和动画上较难实现出优良的用户体验。
而在实际的产品功能研发中我们通常只会用到Web标准中很小的一部分。面对这样的现实我们很快就想到能否对笨重的Web容器进行功能裁剪在仅保留必要的Web标准和渲染能力的基础上使得友好的开发体验与稳定的渲染性能保持一个平衡
答案当然是可以。
泛Web容器时代的解决方案优化了Web容器时代的加载、解析和渲染这三大过程把影响它们独立运行的Web标准进行了裁剪以相对简单的方式支持了构建移动端页面必要的Web标准如Flexbox等也保证了便捷的前端开发体验同时这个时代的解决方案基本上完全放弃了浏览器控件渲染而是采用原生自带的UI组件实现代替了核心的渲染引擎仅保持必要的基本控件渲染能力从而使得渲染过程更加简化也保证了良好的渲染性能。
也就是说在泛Web容器时代我们仍然采用前端友好的JavaScript进行开发整体加载、渲染机制大大简化并且由原生接管绘制即将原生系统作为渲染的后端为依托于JavaScript虚拟机的JavaScript代码提供所需要的UI控件的实体。这也是现在绝大部分跨平台框架的思路而React Native和Weex就是其中的佼佼者。
图2 泛Web容器框架
为了追求性能体验的极致并进一步维持方案的简单可扩展性有些轻量级的跨平台方案甚至会完全抛弃Web标准、放弃JavaScript的动态执行能力而自创一套原生DSL如天猫的VirtualView框架。从广义上来说这些方案也是泛Web容器类方案。
自绘引擎时代
泛Web容器时代使用原生控件承载界面渲染固然解决了不少性能问题但同时也带来了新的问题。抛开框架本身需要处理大量平台相关的逻辑外随着系统版本变化和API的变化我们还需要处理不同平台的原生控件渲染能力差异修复各类奇奇怪怪的Bug。始终需要Follow Native的思维方式就使得泛Web容器框架的跨平台特性被大打折扣。
而这一时期的代表Flutter则开辟了一种全新的思路即从头到尾重写一套跨平台的UI框架包括渲染逻辑甚至是开发语言。
渲染引擎依靠跨平台的Skia图形库来实现Skia引擎会将使用Dart构建的抽象的视图结构数据加工成GPU数据交由OpenGL最终提供给GPU渲染至此完成渲染闭环因此可以在最大程度上保证一款应用在不同平台、不同设备上的体验一致性。
而开发语言选用的是同时支持JITJust-in-Time即时编译和AOTAhead-of-Time预编译的Dart不仅保证了开发效率更提升了执行效率比使用JavaScript开发的泛Web容器方案要高得多
图3 自绘引擎开发框架
通过这样的思路Flutter可以尽可能地减少不同平台之间的差异, 同时保持和原生开发一样的高性能。所以说Flutter成了三类跨平台移动开发方案中最灵活的那个也成了目前最受业界关注的框架。
现在,我们已经弄明白了三类跨平台方案,那么我在开发应用的时候,到底应该如何选择最适合自己的框架呢?
我该选择哪一类跨平台开发方案?
从不同的角度来看,三个时代的跨平台框架代表们在开发效率、渲染性能、维护成本和社区生态上各有优劣,如下图所示:
图 4 主流跨平台框架对比
我们在做技术选型时可以参考以上维度从开发效率、技术栈、性能表现、维护成本和社区生态来进行综合考虑。比如是否必须支持动态化是只解决Android、iOS的跨端问题还是要包括Web对性能要求如何对多端体验的绝对一致性和维护成本是否有强诉求
从各个维度综合考量React Native和Flutter无疑是最均衡的两种跨平台开发方案而其他的方案或多或少都“偏科严重”。
React Native依托于Facebook经过4年多的发展已经成长为跨平台开发方案的实际领导者并拥有较为丰富的第三方库和开发社区
Flutter以挑战者姿态出现在我们的面前可以提供更彻底的跨平台技术解决方案。虽然Flutter推出时间不长但也有了诸多商用案例加上清晰的产品路线图和Google的强大号召力Flutter未来的发展非常值得期待。
那么问题来了我究竟应该选择React Native还是Flutter呢
在这里,我和你说一下我的建议吧。
对于知识学习来说这两个应用层面的框架最好都学。学习的过程中最重要的是打好基础深入理解框架的原理和设计思想重点思考它们的API设计的取舍发现它们的共性和差异。
Flutter作为后来者也从React Native社区学习和借鉴了不少的优秀设计很多概念两边都有对应比如React Native的Component和Flutter的Widget、Flex布局思想、状态管理和函数式编程等等这类的知识都是两个框架通用的技术。未来也许还会出现新的解决方案老框架也会不断更新只有掌握核心原理才能真正立于不败之地。
对于实际项目来说这两个框架都已达到了大面积商业应用的标准。综合成熟度和生态目前俩看React Native略胜Flutter。因此如果是中短期项目的话我建议使用React Native。但作为技术选型我们要看得更远一些。Flutter的设计理念比较先进解决方案也相对彻底在渲染能力的一致性以及性能上和React Native相比优势非常明显。
此外Flutter的野心不仅仅是移动端。前段时间Google团队已经完成了Hummingbird即Flutter的Web的官方Demo在桌面操作系统的探索上也取得了进展未来大前端技术栈是否会由Flutter完成统一值得期待。
小结
这就是今天分享的全部内容了。
在不同平台开发和维护同一个产品所付出的成本一直以来一个令人头疼的问题于是各类跨平台开发方案顺应而生。从Web容器时代到以React Native、Weex为代表的泛Web容器时代最后再到以Flutter为代表的自绘引擎时代这些优秀的跨平台开发框架们慢慢抹平了各个平台的差异使得操作系统的边界变得越来越模糊。
与此同时这个时代对开发者的要求也到达了一个新的阶段拥抱大前端的时代已经向我们走来。在这个专栏里我会假设你有一定的前端Android、iOS或Web开发基础。比如你知道View是什么路由是什么如何实现一个基本页面布局等等。我会让希望迅速掌握Flutter开发的爱好者们通过一种比较熟悉和友好的路径去学习Flutter相关的代码和程序以及背后的原理和设计思想。
思考题
你有哪些跨平台开发框架的使用经历呢?
欢迎你在评论区给我留言分享你的经历和观点,我会在下一篇文章中等待你!感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。

View File

@@ -0,0 +1,195 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
04 Flutter区别于其他方案的关键技术是什么
你好,我是陈航。
Flutter是什么它出现的动机是什么解决了哪些痛点相比其他跨平台技术Flutter的优势在哪里……相信很多人在第一眼看到Flutter时都会有类似的疑问。
别急在今天的这篇文章中我会与你介绍Flutter的历史背景和运行机制并以界面渲染过程为例与你讲述其实现原理让你对Flutter能够有一个全方位的认知和感受。在对Flutter有了全面了解后这些疑问自然也就迎刃而解了。
接下来我们就从Flutter出现的历史背景开始谈起吧。
Flutter出现的历史背景
为不同的操作系统开发拥有相同功能的应用程序,开发人员只有两个选择:
使用原生开发语言即Java和Objective-C针对不同平台分别进行开发。
使用跨平台解决方案,对不同平台进行统一开发。
原生开发方式的体验最好但研发效率和研发成本相对较高而跨平台开发方式研发虽然效率高但为了抹平多端平台差异各类解决方案暴露的组件和API较原生开发相比少很多因此研发体验和产品功能并不完美。
所以最成功的跨平台开发方案其实是依托于浏览器控件的Web。浏览器保证了99%的概率下Web的需求都是可以实现的不需要业务将就“技术”。不过Web最大的问题在于它的性能和体验与原生开发存在肉眼可感知的差异因此并不适用于对体验要求较高的场景。
对于用户体验更接近于原生的React Native对业务的支持能力却还不到浏览器的5%仅适用于中低复杂度的低交互类页面。面对稍微复杂一点儿的交互和动画需求开发者都需要case by case地去review甚至还可能要通过原生代码去扩展才能实现。
这些因素,也就导致了虽然跨平台开发从移动端诞生之初就已经被多次提及,但到现在也没有被很好地解决。
带着这些问题我们终于迎来了本次专栏的主角——Flutter。
Flutter是构建Google物联网操作系统Fuchsia的SDK主打跨平台、高保真、高性能。开发者可以通过 Dart语言开发App一套代码可以同时运行在 iOS 和 Android平台。 Flutter使用Native引擎渲染视图并提供了丰富的组件和接口这无疑为开发者和用户都提供了良好的体验。
从2017年5月谷歌公司发布的了Alpha版本的Flutter到2018年底Flutter Live发布的1.0版本再到现在最新的1.5版本截止至2019年7月1日Flutter正在赢得越来越多的关注。
很多人开始感慨跨平台技术似乎终于迎来了最佳解决方案。那么接下来我们就从原理层面去看看Flutter是如何解决既有跨平台开发方案问题的。
Flutter是怎么运转的
与用于构建移动应用程序的其他大多数框架不同Flutter是重写了一整套包括底层渲染逻辑和上层开发语言的完整解决方案。这样不仅可以保证视图渲染在Android和iOS上的高度一致性即高保真在代码执行效率和渲染性能上也可以媲美原生App的体验即高性能
就是Flutter和其他跨平台方案的本质区别
React Native之类的框架只是通过JavaScript虚拟机扩展调用系统组件由Android和iOS系统进行组件的渲染
Flutter则是自己完成了组件渲染的闭环。
那么Flutter是怎么完成组件渲染的呢这需要从图像显示的基本原理说起。
在计算机系统中图像的显示需要CPU、GPU和显示器一起配合完成CPU负责图像数据计算GPU负责图像数据渲染而显示器则负责最终图像显示。
CPU把计算好的、需要显示的内容交给GPU由GPU完成渲染后放入帧缓冲区随后视频控制器根据垂直同步信号VSync以每秒60次的速度从帧缓冲区读取帧数据交由显示器完成图像显示。
操作系统在呈现图像时遵循了这种机制而Flutter作为跨平台开发框架也采用了这种底层方案。下面有一张更为详尽的示意图来解释Flutter的绘制原理。
图1 Flutter绘制原理
可以看到Flutter关注如何尽可能快地在两个硬件时钟的VSync信号之间计算并合成视图数据然后通过Skia交给GPU渲染UI线程使用Dart来构建视图结构数据这些数据会在GPU线程进行图层合成随后交给Skia引擎加工成GPU数据而这些数据会通过OpenGL最终提供给GPU渲染。
在进一步学习Flutter之前我们有必要了解下构建Flutter的关键技术即Skia和Dart。
Skia是什么
要想了解Flutter你必须先了解它的底层图像渲染引擎Skia。因为Flutter只关心如何向GPU提供视图数据而Skia就是它向GPU提供视图数据的好帮手。
Skia是一款用C++开发的、性能彪悍的2D图像绘制引擎其前身是一个向量绘图软件。2005年被Google公司收购后因为其出色的绘制表现被广泛应用在Chrome和Android等核心产品上。Skia在图形转换、文字渲染、位图渲染方面都表现卓越并提供了开发者友好的API。
因此架构于Skia之上的Flutter也因此拥有了彻底的跨平台渲染能力。通过与Skia的深度定制及优化Flutter可以最大限度地抹平平台差异提高渲染效率与性能。
底层渲染能力统一了上层开发接口和功能体验也就随即统一了开发者再也不用操心平台相关的渲染特性了。也就是说Skia保证了同一套代码调用在Android和iOS平台上的渲染效果是完全一致的。
为什么是Dart
除了我们在第2篇预习文章“预习篇 · Dart语言概览”中提到的Dart因为同时支持AOT和JIT所以具有运行速度快、执行性能好的特点外Flutter为什么选择了Dart而不是前端应用的准官方语言JavaScript呢这个问题很有意思但也很有争议。
很多人说选择Dart是Flutter推广的一大劣势毕竟多学一门新语言就多一层障碍。想想Java对AndroidJavaScript对NodeJS的推动如果换个语言可能就不一样了。
Google公司给出的原因很简单也很直接Dart语言开发组就在隔壁对于Flutter需要的一些语言新特性能够快速在语法层面落地实现而如果选择了JavaScript就必须经过各种委员会和浏览器提供商漫长的决议。
事实上Flutter的确得到了兄弟团队的紧密支持。2018年2月发布的Dart 2.02018年12月发布的Dart 2.12019年2月发布的Dart 2.22019年5月发布的Dart2.3每次发布都包含了为Flutter量身定制的诸多改造比如改进的AOT性能、更智能的类型隐式转换等
当然Google公司选择使用Dart作为Flutter的开发语言我想还有其他更有说服力的理由
Dart同时支持即时编译JIT和事前编译AOT。在开发期使用JIT开发周期异常短调试方式颠覆常规支持有状态的热重载而发布期使用AOT本地代码的执行更高效代码性能和用户体验也更卓越。
Dart作为一门现代化语言集百家之长拥有其他优秀编程语言的诸多特性比如完善的包管理机制。也正是这个原因Dart的学习成本并不高很容易上手。
Dart避免了抢占式调度和共享内存可以在没有锁的情况下进行对象分配和垃圾回收在性能方面表现相当不错。
Dart是一门优秀的现代语言最初设计也是为了取代JavaScript成为Web开发的官方语言。竞争对手如此强劲最后的结果可想而知。这也是为什么相比于其他热门语言Dart的生态要冷清不少的原因。
而随着Flutter的发布Dart开始转型其自身定位也发生了变化专注于改善构建客户端应用程序的体验因此越来越多的开发者开始慢慢了解、学习这门语言并共同完善它的生态。凭借着Flutter的火热势头辅以Google强大的商业运作能力相信转型后的Dart前景会非常光明。
Flutter的原理
在了解了Flutter的基本运作机制后我们再来深入了解一下Flutter的实现原理。
首先我们来看一下Flutter的架构图。我希望通过这张图以及对应的解读你能在开始学习的时候就建立起对Flutter的整体印象能够从框架设计和实现原理的高度去理解Flutter区别其他跨平台解决方案的关键所在为后面的学习打好基础而不是直接一上来就陷入语言和框架的功能细节“泥潭”而无法自拔。
图2 Flutter架构图
备注此图引自Flutter System Overview
Flutter架构采用分层设计从下到上分为三层依次为Embedder、Engine、Framework。
Embedder是操作系统适配层实现了渲染Surface设置线程设置以及平台插件等平台相关特性的适配。从这里我们可以看到Flutter平台相关特性并不多这就使得从框架层面保持跨端一致性的成本相对较低。
Engine层主要包含Skia、Dart和Text实现了Flutter的渲染引擎、文字排版、事件处理和Dart运行时等功能。Skia和Text为上层接口提供了调用底层渲染和排版的能力Dart则为Flutter提供了运行时调用Dart和渲染引擎的能力。而Engine层的作用则是将它们组合起来从它们生成的数据中实现视图渲染。
Framework层则是一个用Dart实现的UI SDK包含了动画、图形绘制和手势识别等功能。为了在绘制控件等固定样式的图形时提供更直观、更方便的接口Flutter还基于这些基础能力根据Material和Cupertino两种视觉设计风格封装了一套UI组件库。我们在开发Flutter的时候可以直接使用这些组件库。
接下来我以界面渲染过程为例和你介绍Flutter是如何工作的。
页面中的各界面元素Widget以树的形式组织即控件树。Flutter通过控件树中的每个控件创建不同类型的渲染对象组成渲染对象树。而渲染对象树在Flutter的展示过程分为四个阶段布局、绘制、合成和渲染。
布局
Flutter采用深度优先机制遍历渲染对象树决定渲染对象树中各渲染对象在屏幕上的位置和尺寸。在布局过程中渲染对象树中的每个渲染对象都会接收父对象的布局约束参数决定自己的大小然后父对象按照控件逻辑决定各个子对象的位置完成布局过程。
图3 Flutter布局过程
为了防止因子节点发生变化而导致整个控件树重新布局Flutter加入了一个机制——布局边界Relayout Boundary可以在某些节点自动或手动地设置布局边界当边界内的任何对象发生重新布局时不会影响边界外的对象反之亦然。
图4 Flutter布局边界
绘制
布局完成后渲染对象树中的每个节点都有了明确的尺寸和位置。Flutter会把所有的渲染对象绘制到不同的图层上。与布局过程一样绘制过程也是深度优先遍历而且总是先绘制自身再绘制子节点。
以下图为例节点1在绘制完自身后会再绘制节点2然后绘制它的子节点3、4和5最后绘制节点6。
图5 Flutter 绘制示例
可以看到由于一些其他原因比如视图手动合并导致2的子节点5与它的兄弟节点6处于了同一层这样会导致当节点2需要重绘的时候与其无关的节点6也会被重绘带来性能损耗。
为了解决这一问题Flutter提出了与布局边界对应的机制——重绘边界Repaint Boundary。在重绘边界内Flutter会强制切换新的图层这样就可以避免边界内外的互相影响避免无关内容置于同一图层引起不必要的重绘。
图6 Flutter重绘边界
重绘边界的一个典型场景是Scrollview。ScrollView滚动的时候需要刷新视图内容从而触发内容重绘。而当滚动内容重绘时一般情况下其他内容是不需要重绘的这时候重绘边界就派上用场了。
合成和渲染
终端设备的页面越来越复杂因此Flutter的渲染树层级通常很多直接交付给渲染引擎进行多图层渲染可能会出现大量渲染内容的重复绘制所以还需要先进行一次图层合成即将所有的图层根据大小、层级、透明度等规则计算出最终的显示效果将相同的图层归类合并简化渲染树提高渲染效率。
合并完成后Flutter会将几何图层数据交由Skia引擎加工成二维图像数据最终交由GPU进行渲染完成界面的展示。这部分内容我已经在前面的内容中介绍过这里就不再赘述了。
接下来我们再看看学习Flutter都需要学习哪些知识。
学习Flutter需要掌握哪些知识
终端设备越来越碎片化需要支持的操作系统越来越多从研发效率和维护成本综合考虑跨平台开发一定是未来大前端的趋势我们应该拥抱变化。而Flutter提供了一套彻底的移动跨平台方案也确实弥补了如今跨平台开发框架的短板解决了业界痛点极有可能成为跨平台开发领域的终极解决方案前途非常光明。
那么我们学习Flutter都需要掌握哪些知识呢
我按照App的开发流程开发、调试测试、发布与线上运维将Flutter的技术栈进行了划分里面几乎包含了Flutter开发需要的所有知识点。而这些所有知识点我会在专栏中为你一一讲解。掌握了这些知识点后你也就具备了企业级应用开发的必要技能。
这些知识点,如下图所示:
图7 Flutter知识体系
有了这张图你是否感觉到学习Flutter的路线变得更加清晰了呢
小结
今天我带你了解了Flutter的历史背景与运行机制并以界面渲染过程为例从布局、绘制、合成和渲染四个阶段讲述了Flutter的实现原理。此外我向你介绍了构建Flutter底层的关键技术Skia与Dart它们是Flutter有别于其他跨平台开发方案的核心所在。
最后我梳理了一张Flutter学习思维导图围绕一个应用的迭代周期介绍了Flutter相关的知识点。我希望通过这个专栏能和你把Flutter背后的设计原理和知识体系讲清楚让你能对Flutter有一个整体感知。这样在你学完这个专栏以后就能够具备企业级应用开发的理论基础与实践。
思考题
你是如何理解Flutter的三大特点跨平台、高保真、高性能的你又打算怎么学习这个专栏呢
欢迎你在评论区给我留言分享你的观点,我会在下一篇文章中等待你!感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。

View File

@@ -0,0 +1,161 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
05 从标准模板入手体会Flutter代码是如何运行在原生系统上的
你好,我是陈航。
在专栏的第一篇预习文章中我和你一起搭建了Flutter的开发环境并且通过自带的hello_world示例和你演示了Flutter项目是如何运行在Android和iOS模拟器以及真机上的。
今天我会通过Android Studio创建的Flutter应用模板带你去了解Flutter的项目结构分析Flutter工程与原生Android和iOS工程有哪些联系体验一个有着基本功能的Flutter应用是如何运转的从而加深你对构建Flutter应用的关键概念和技术的理解。
如果你现在还不熟悉Dart语言也不用担心只要能够理解基本的编程概念比如类型、变量、函数和面向对象并具备一定的前端基础比如了解View是什么、页面基本布局等基础知识就可以和我一起完成今天的学习。而关于Dart语言基础概念的讲述、案例分析我会在下一个模块和你展开。
计数器示例工程分析
首先我们打开Android Studio创建一个Flutter工程应用flutter_app。Flutter会根据自带的应用模板自动生成一个简单的计数器示例应用Demo。我们先运行此示例效果如下
图1 计数器示例运行效果
每点击一次右下角带“+”号的悬浮按钮,就可以看到屏幕中央的数字随之+1。
工程结构
在体会了示例工程的运行效果之后我们再来看看Flutter工程目录结构了解Flutter工程与原生Android和iOS工程之间的关系以及这些关系是如何确保一个Flutter程序可以最终运行在Android和iOS系统上的。
图2 Flutter工程目录结构
可以看到除了Flutter本身的代码、资源、依赖和配置之外Flutter工程还包含了Android和iOS的工程目录。
这也不难理解因为Flutter虽然是跨平台开发方案但却需要一个容器最终运行到Android和iOS平台上所以Flutter工程实际上就是一个同时内嵌了Android和iOS原生子工程的父工程我们在lib目录下进行Flutter代码的开发而某些特殊场景下的原生功能则在对应的Android和iOS工程中提供相应的代码实现供对应的Flutter代码引用。
Flutter会将相关的依赖和构建产物注入这两个子工程最终集成到各自的项目中。而我们开发的Flutter代码最终则会以原生工程的形式运行。
工程代码
在对Flutter的工程结构有了初步印象之后我们就可以开始学习Flutter的项目代码了。
Flutter自带的应用模板也就是这个计数器示例对初学者来说是一个极好的入门范例。在这个简单示例中从基础的组件、布局到手势的监听再到状态的改变Flutter最核心的思想在这60余行代码中展现得可谓淋漓尽致。
为了便于你学习理解领会构建Flutter程序的大体思路与关键技术而不是在一开始时就陷入组件的API细节中我删掉了与核心流程无关的组件配置代码及布局逻辑在不影响示例功能的情况下对代码进行了改写并将其分为两部分
第一部分是应用入口、应用结构以及页面结构可以帮助你理解构建Flutter程序的基本结构和套路
第二部分则是页面布局、交互逻辑及状态管理能够帮你理解Flutter页面是如何构建、如何响应交互以及如何更新的。
首先,我们来看看第一部分的代码,也就是应用的整体结构:
import 'package:flutter/material.dart';
void main() => runApp(MyApp());
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) => MaterialApp(home: MyHomePage(title: 'Flutter Demo Home Page'));
}
class MyHomePage extends StatefulWidget {
MyHomePage({Key key, this.title}) : super(key: key);
final String title;
@override
_MyHomePageState createState() => _MyHomePageState();
}
class _MyHomePageState extends State<MyHomePage> {
Widget build(BuildContext context) => {...};
}
在本例中Flutter应用为MyApp类的一个实例而MyApp类继承自StatelessWidget类这也就意味着应用本身也是一个Widget。事实上在Flutter中Widget是整个视图描述的基础在Flutter的世界里包括应用、视图、视图控制器、布局等在内的概念都建立在Widget之上Flutter的核心设计思想便是一切皆Widget。
Widget是组件视觉效果的封装是UI界面的载体因此我们还需要为它提供一个方法来告诉Flutter框架如何构建UI界面这个方法就是build。
在build方法中我们通常通过对基础Widget进行相应的UI配置或是组合各类基础Widget的方式进行UI的定制化。比如在MyApp中我通过MaterialApp这个Flutter App框架设置了应用首页即MyHomePage。当然MaterialApp也是一个Widget。
MaterialApp类是对构建material设计风格应用的组件封装框架里面还有很多可配置的属性比如应用主题、应用名称、语言标识符、组件路由等。但是这些配置属性并不是本次分享的重点如果你感兴趣的话可以参考Flutter官方的API文档来了解MaterialApp框架的其他配置能力。
MyHomePage是应用的首页继承自StatefulWidget类。这代表着它是一个有状态的WidgetStateful Widget而_MyHomePageState就是它的状态。
如果你足够细心的话就会发现虽然MyHomePage类也是Widget但与MyApp类不同的是它并没有一个build方法去返回Widget而是多了一个createState方法返回_MyHomePageState对象而build方法则包含在这个_MyHomePageState类当中。
那么StatefulWidget与StatelessWidget的接口设计为什么会有这样的区别呢
这是因为Widget需要依据数据才能完成构建而对于StatefulWidget来说其依赖的数据在Widget生命周期中可能会频繁地发生变化。由State创建Widget以数据驱动视图更新而不是直接操作UI更新视觉属性代码表达可以更精炼逻辑也可以更清晰。
在了解了计数器示例程序的整体结构以后,我们再来看看这个示例代码的第二部分,也就是页面布局及交互逻辑部分。
class _MyHomePageState extends State<MyHomePage> {
int _counter = 0;
void _incrementCounter() => setState(() {_counter++;});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text(Widget.title)),
body: Text('You have pushed the button this many times:$_counter')),
floatingActionButton: FloatingActionButton(onPressed: _incrementCounter)
);
}
_MyHomePageState中创建的Widget Scaffold是Material库中提供的页面布局结构它包含AppBar、Body以及FloatingActionButton。
AppBar是页面的导航栏我们直接将MyHomePage中的title属性作为标题使用。
body则是一个Text组件显示了一个根据_counter属性可变的文本You have pushed the button this many times:$_counter
floatingActionButton则是页面右下角的带“+”的悬浮按钮。我们将_incrementCounter作为其点击处理函数。
_incrementCounter的实现很简单使用setState方法去自增状态属性_counter。setState方法是Flutter以数据驱动视图更新的关键函数它会通知Flutter框架我这儿有状态发生了改变赶紧给我刷新界面吧。而Flutter框架收到通知后会执行Widget的build方法根据新的状态重新构建界面。
这里需要注意的是状态的更改一定要配合使用setState。通过这个方法的调用Flutter会在底层标记Widget的状态随后触发重建。于我们的示例而言即使你修改了_counter如果不调用setStateFlutter框架也不会感知到状态的变化因此界面上也不会有任何改变你可以动手验证一下
下面的图3就是整个计数器示例的代码流程示意图。通过这张图你就能够把这个实例的整个代码流程串起来了
图3 代码流程示意图
MyApp为Flutter应用的运行实例通过在main函数中调用runApp函数实现程序的入口。而应用的首页则为MyHomePage一个拥有_MyHomePageState状态的StatefulWidget。_MyHomePageState通过调用build方法以相应的数据配置完成了包括导航栏、文本及按钮的页面视图的创建。
而当按钮被点击之后其关联的控件函数_incrementCounter会触发调用。在这个函数中通过调用setState方法更新_counter属性的同时也会通知Flutter框架其状态发生变化。随后Flutter会重新调用build方法以新的数据配置重新构建_MyHomePageState的UI最终完成页面的重新渲染。
Widget只是视图的“配置信息”是数据的映射是“只读”的。对于StatefulWidget而言当数据改变的时候我们需要重新创建Widget去更新界面这也就意味着Widget的创建销毁会非常频繁。
为此Flutter对这个机制做了优化其框架内部会通过一个中间层去收敛上层UI配置对底层真实渲染的改动从而最大程度降低对真实渲染视图的修改提高渲染效率而不是上层UI配置变了就需要销毁整个渲染视图树重建。
这样一来Widget仅是一个轻量级的数据配置存储结构它的重新创建速度非常快所以我们可以放心地重新构建任何需要更新的视图而无需分别修改各个子Widget的特定样式。关于Widget具体的渲染过程细节我会在后续的第9篇文章“Widget构建Flutter界面的基石”中向你详细介绍在这里就不再展开了。
总结
今天的这次Flutter项目初体验我们就先进行到这里。接下来我们一起回顾下涉及到的知识点。
首先我们通过Flutter标准模板创建了计数器示例并分析了Flutter的项目结构以及Flutter工程与原生Android、iOS工程的联系知道了Flutter代码是怎么运行在原生系统上的。
然后我带你学习了示例项目代码了解了Flutter应用结构及页面结构并认识了构建Flutter的基础也就是Widget以及状态管理机制知道了Flutter页面是如何构建的StatelessWidget与StatefulWidget的区别以及如何通过State的成员函数setState以数据驱动的方式更新状态从而更新页面。
有原生Android和iOS框架开发经验的同学可能更习惯命令式的UI编程风格手动创建UI组件在需要更改UI时调用其方法修改视觉属性。而Flutter采用声明式UI设计我们只需要描述当前的UI状态即State即可不同UI状态的视觉变更由Flutter在底层完成。
虽然命令式的UI编程风格更直观但声明式UI编程方式的好处是可以让我们把复杂的视图操作细节交给框架去完成这样一来不仅可以提高我们的效率也可以让我们专注于整个应用和页面的结构和功能。
所以在这里我非常希望你能够适应这样的UI编程思维方式的转换。
思考题
最后,我给你留下一个思考题吧。
示例项目代码在_MyHomePageState类中直接在build函数里以内联的方式完成了Scaffold页面元素的构建这样做的好处是什么呢
在实现同样功能的情况下如果将Scaffold页面元素的构建封装成一个新Widget类我们该如何处理
欢迎你在评论区给我留言分享你的观点,我会在下一篇文章中等待你!感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。

View File

@@ -0,0 +1,192 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
06 基础语法与类型变量Dart是如何表示信息的
你好,我是陈航。
在专栏的第2篇预习文章“Dart语言概览”中我们简单地认识了Dart这门优秀的程序语言。那么Dart与其他语言究竟有什么不同呢在已有其他编程语言经验的基础上我又如何快速上手呢
今天我们就从编程语言中最重要的组成部分也就是基础语法与类型变量出发一起来学习Dart吧。
Dart初体验
为了简单地体验一下Dart我们打开浏览器直接在repl.it 新建一个main.dart文件就可以了当然你也可以在电脑安装Dart SDK体验最新的语法
下面是一个基本的hello world示例我声明了一个带int参数的函数并通过字符串内嵌表达式的方式把这个参数打印出来
printInteger(int a) {
print('Hello world, this is $a.');
}
main() {
var number = 2019;
printInteger(number);
}
然后在编辑器中点击“run”按钮命令行就会输出
Hello world, this is 2019.
和绝大多数编译型语言一样Dart要求以main函数作为执行的入口。
在知道了如何简单地运行Dart代码后我们再来看一下Dart的基本变量类型。
Dart的变量与类型
在Dart中我们可以用var或者具体的类型来声明一个变量。当使用var定义变量时表示类型是交由编译器推断决定的当然你也可以用静态类型去定义变量更清楚地跟编译器表达你的意图这样编辑器和编译器就能使用这些静态类型向你提供代码补全或编译警告的提示了。
在默认情况下未初始化的变量的值都是null因此我们不用担心无法判定一个传递过来的、未定义变量到底是undefined还是烫烫烫而写一堆冗长的判断语句了。
Dart是类型安全的语言并且所有类型都是对象类型都继承自顶层类型Object因此一切变量的值都是类的实例即对象甚至数字、布尔值、函数和null也都是继承自Object的对象。
Dart内置了一些基本类型如 num、bool、String、List和Map在不引入其他库的情况下可以使用它们去声明变量。下面我将逐一和你介绍。
num、bool与String
作为编程语言中最常用的类型num、bool、String这三种基本类型被我放到了一起来介绍。
Dart的数值类型num只有两种子类即64位int和符合IEEE 754标准的64位double。前者代表整数类型而后者则是浮点数的抽象。在正常情况下它们的精度与取值范围就足够满足我们的诉求了。
int x = 1;
int hex = 0xEEADBEEF;
double y = 1.1;
double exponents = 1.13e5;
int roundY = y.round();
除了常见的基本运算符,比如+、-、*、/以及位运算符外你还能使用继承自num的 abs()、round()等方法,来实现求绝对值、取整的功能。
实际上你打开官方文档或查看源码就会发现这些常见的运算符也是继承自num
图1 num中的运算符
如果还有其他高级运算方法的需求num无法满足你可以试用一下dart:math库。这个库提供了诸如三角函数、指数、对数、平方根等高级函数。
为了表示布尔值Dart使用了一种名为bool的类型。在Dart里只有两个对象具有bool类型true和false它们都是编译时常量。
Dart是类型安全的因此我们不能使用if(nonbooleanValue) 或assert(nonbooleanValue)之类的在JavaScript可以正常工作的代码而应该显式地检查值。
如下所示检查变量是否为0在Dart中需要显示地与0做比较
// 检查是否为0.
var number = 0;
assert(number == 0);
// assert(number); 错误
Dart的String由UTF-16的字符串组成。和JavaScript一样构造字符串字面量时既能使用单引号也能使用双引号还能在字符串中嵌入变量或表达式你可以使用 ${express} 把一个表达式的值放进字符串。而如果是一个标识符,你可以省略{}。
下面这段代码就是内嵌表达式的例子。我们把单词cat转成大写放入到变量s1的声明中
var s = 'cat';
var s1 = 'this is a uppercased string: ${s.toUpperCase()}';
为了获得内嵌对象的字符串Dart会调用对象的toString()方法。而常见字符串的拼接Dart则通过内置运算符“+”实现。比如下面这条语句会如你所愿声明一个值为Hello World!‘的字符串:
var s2 = 'Hello' + ' ' + 'World!' ;
对于多行字符串的构建你可以通过三个单引号或三个双引号的方式声明这与Python是一致的
var s3 = """This is a
multi-line string.""";
List与Map
其他编程语言中常见的数组和字典类型在Dart中的对应实现是List和Map统称为集合类型。它们的声明和使用很简单和JavaScript中的用法类似。
接下来,我们一起看一段代码示例。
在代码示例的前半部分我们声明并初始化了两个List变量在第二个变量中添加了一个新的元素后调用其迭代方法依次打印出其内部元素
在代码示例的后半部分我们声明并初始化了两个Map变量在第二个变量中添加了两个键值对后同样调用其迭代方法依次打印出其内部元素。
var arr1 = ["Tom", "Andy", "Jack"];
var arr2 = List.of([1,2,3]);
arr2.add(499);
arr2.forEach((v) => print('${v}'));
var map1 = {"name": "Tom", 'sex': 'male'};
var map2 = new Map();
map2['name'] = 'Tom';
map2['sex'] = 'male';
map2.forEach((k,v) => print('${k}: ${v}'));
容器里的元素也需要有类型比如上述代码中arr2的类型是Listmap2的类型则为Map。Dart会自动根据上下文进行类型推断所以你后续往容器内添加的元素也必须遵照这一类型。
如果编译器自动推断的类型不符合预期我们当然可以在声明时显式地把类型标记出来不仅可以让代码提示更友好一些更重要的是可以让静态分析器帮忙检查字面量中的错误解除类型不匹配带来的安全隐患或是Bug。
以上述代码为例如果往arr2集合中添加一个浮点数arr2.add(1.1),尽管语义上合法,但编译器会提示类型不匹配,从而导致编译失败。
和Java语言类似在初始化集合实例对象时你可以为它的类型添加约束也可以用于后续判断集合类型。
下面的这段代码,在增加了类型约束后,语义是不是更清晰了?
var arr1 = <String>['Tom', 'Andy', 'Jack'];
var arr2 = new List<int>.of([1,2,3]);
arr2.add(499);
arr2.forEach((v) => print('${v}'));
print(arr2 is List<int>); // true
var map1 = <String, String>{'name': 'Tom','sex': 'male',};
var map2 = new Map<String, String>();
map2['name'] = 'Tom';
map2['sex'] = 'male';
map2.forEach((k,v) => print('${k}: ${v}'));
print(map2 is Map<String, String>); // true
常量定义
如果你想定义不可变的变量则需要在定义变量前加上final或const关键字
const表示变量在编译期间即能确定的值
final则不太一样用它定义的变量可以在运行时确定值而一旦确定后就不可再变。
声明const常量与final常量的典型例子如下所示
final name = 'Andy';
const count = 3;
var x = 70;
var y = 30;
final z = x / y;
可以看到const适用于定义编译常量字面量固定值的场景而final适用于定义运行时常量的场景。
总结
通过上面的介绍相信你已经对Dart的基本语法和类型系统有了一个初步的印象。这些初步的印象有助于你理解Dart语言设计的基本思路在已有编程语言经验的基础上快速上手。
而对于流程控制语法如if-else、for、while、do-while、break/continue、switch-case、assert由于与其他编程语言类似在这里我就不做一一介绍了更多的Dart语言特性需要你在后续的使用过程中慢慢学习。在我们使用Dart的过程中官方文档是我们最重要的学习参考资料。
恭喜你你现在已经迈出了Dart语言学习的第一步。接下来我们简单回顾一下今天的内容以便加深记忆与理解
在Dart中所有类型都是对象类型都继承自顶层类型Object因此一切变量都是对象数字、布尔值、函数和null也概莫能外
未初始化变量的值都是null
为变量指定类型,这样编辑器和编译器都能更好地理解你的意图。
思考题
对于集合类型List和Map如何让其内部元素支持多种类型比如int、double又如何在遍历集合时判断究竟是何种类型呢
欢迎你在评论区给我留言分享你的观点,我会在下一篇文章中等待你!感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。

View File

@@ -0,0 +1,294 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
07 函数、类与运算符Dart是如何处理信息的
你好,我是陈航。
在上一篇文章中我通过一个基本hello word的示例带你体验了Dart的基础语法与类型变量并与其他编程语言的特性进行对比希望可以帮助你快速建立起对Dart的初步印象。
其实,编程语言虽然千差万别,但归根结底,它们的设计思想无非就是回答两个问题:
如何表示信息;
如何处理信息。
在上一篇文章中我们已经解决了Dart如何表示信息的问题今天这篇文章我就着重和你分享它是如何处理信息的。
作为一门真正面向对象的编程语言Dart将处理信息的过程抽象为了对象以结构化的方式将功能分解而函数、类与运算符就是抽象中最重要的手段。
接下来我就从函数、类与运算符的角度来进一步和你讲述Dart面向对象设计的基本思路。
函数
函数是一段用来独立地完成某个功能的代码。我在上一篇文章中和你提到在Dart中所有类型都是对象类型函数也是对象它的类型叫作Function。这意味着函数也可以被定义为变量甚至可以被定义为参数传递给另一个函数。
在下面这段代码示例中我定义了一个判断整数是否为0的isZero函数并把它传递了给另一个printInfo函数完成格式化打印出判断结果的功能。
bool isZero(int number) { //判断整数是否为0
return number == 0;
}
void printInfo(int number,Function check) { //用check函数来判断整数是否为0
print("$number is Zero: ${check(number)}");
}
Function f = isZero;
int x = 10;
int y = 0;
printInfo(x,f); // 输出 10 is Zero: false
printInfo(y,f); // 输出 0 is Zero: true
如果函数体只有一行表达式就比如上面示例中的isZero和printInfo函数我们还可以像JavaScript语言那样用箭头函数来简化这个函数
bool isZero(int number) => number == 0;
void printInfo(int number,Function check) => print("$number is Zero: ${check(number)}");
有时,一个函数中可能需要传递多个参数。那么,如何让这类函数的参数声明变得更加优雅、可维护,同时降低调用者的使用成本呢?
C++与Java的做法是提供函数的重载即提供同名但参数不同的函数。但Dart认为重载会导致混乱因此从设计之初就不支持重载而是提供了可选命名参数和可选参数。
具体方式是,在声明函数时:
给参数增加{}以paramName: value的方式指定调用参数也就是可选命名参数
给参数增加[],则意味着这些参数是可以忽略的,也就是可选参数。
在使用这两种方式定义函数时,我们还可以在参数未传递时设置默认值。我以一个只有两个参数的简单函数为例,来和你说明这两种方式的具体用法:
//要达到可选命名参数的用法,那就在定义函数的时候给参数加上 {}
void enable1Flags({bool bold, bool hidden}) => print("$bold , $hidden");
//定义可选命名参数时增加默认值
void enable2Flags({bool bold = true, bool hidden = false}) => print("$bold ,$hidden");
//可忽略的参数在函数定义时用[]符号指定
void enable3Flags(bool bold, [bool hidden]) => print("$bold ,$hidden");
//定义可忽略参数时增加默认值
void enable4Flags(bool bold, [bool hidden = false]) => print("$bold ,$hidden");
//可选命名参数函数调用
enable1Flags(bold: true, hidden: false); //true, false
enable1Flags(bold: true); //true, null
enable2Flags(bold: false); //false, false
//可忽略参数函数调用
enable3Flags(true, false); //true, false
enable3Flags(true,); //true, null
enable4Flags(true); //true, false
enable4Flags(true,true); // true, true
这里我要和你强调的是在Flutter中会大量用到可选命名参数的方式你一定要记住它的用法。
类是特定类型的数据和方法的集合也是创建对象的模板。与其他语言一样Dart为类概念提供了内置支持。
类的定义及初始化
Dart是面向对象的语言每个对象都是一个类的实例都继承自顶层类型Object。在Dart中实例变量与实例方法、类变量与类方法的声明与Java类似我就不再过多展开了。
值得一提的是Dart中并没有public、protected、private这些关键字我们只要在声明变量与方法时在前面加上“_”即可作为private方法使用。如果不加“_”则默认为public。不过“_”的限制范围并不是类访问级别的而是库访问级别。
接下来我们以一个具体的案例看看Dart是如何定义和使用类的。
我在Point类中定义了两个成员变量x和y通过构造函数语法糖进行初始化成员函数printInfo的作用是打印它们的信息而类变量factor则在声明时就已经赋好了默认值0类函数printZValue会打印出它的信息。
class Point {
num x, y;
static num factor = 0;
//语法糖等同于在函数体内this.x = x;this.y = y;
Point(this.x,this.y);
void printInfo() => print('($x, $y)');
static void printZValue() => print('$factor');
}
var p = new Point(100,200); // new 关键字可以省略
p.printInfo(); // 输出(100, 200);
Point.factor = 10;
Point.printZValue(); // 输出10
有时候类的实例化需要根据参数提供多种初始化方式。除了可选命名参数和可选参数之外Dart还提供了命名构造函数的方式使得类的实例化过程语义更清晰。
此外与C++类似Dart支持初始化列表。在构造函数的函数体真正执行之前你还有机会给实例变量赋值甚至重定向至另一个构造函数。
如下面实例所示Point类中有两个构造函数Point.bottom与Point其中Point.bottom将其成员变量的初始化重定向到了Point中而Point则在初始化列表中为z赋上了默认值0。
class Point {
num x, y, z;
Point(this.x, this.y) : z = 0; // 初始化变量z
Point.bottom(num x) : this(x, 0); // 重定向构造函数
void printInfo() => print('($x,$y,$z)');
}
var p = Point.bottom(100);
p.printInfo(); // 输出(100,0,0)
复用
在面向对象的编程语言中将其他类的变量与方法纳入本类中进行复用的方式一般有两种继承父类和接口实现。当然在Dart也不例外。
在Dart中你可以对同一个父类进行继承或接口实现
继承父类意味着,子类由父类派生,会自动获取父类的成员变量和方法实现,子类可以根据需要覆写构造函数及父类方法;
接口实现则意味着,子类获取到的仅仅是接口的成员变量符号和方法符号,需要重新实现成员变量,以及方法的声明和初始化,否则编译器会报错。
接下来我以一个例子和你说明在Dart中继承和接口的差别。
Vector通过继承Point的方式增加了成员变量并覆写了printInfo的实现而Coordinate则通过接口实现的方式覆写了Point的变量定义及函数实现
class Point {
num x = 0, y = 0;
void printInfo() => print('($x,$y)');
}
//Vector继承自Point
class Vector extends Point{
num z = 0;
@override
void printInfo() => print('($x,$y,$z)'); //覆写了printInfo实现
}
//Coordinate是对Point的接口实现
class Coordinate implements Point {
num x = 0, y = 0; //成员变量需要重新声明
void printInfo() => print('($x,$y)'); //成员函数需要重新声明实现
}
var xxx = Vector();
xxx
..x = 1
..y = 2
..z = 3; //级联运算符等同于xxx.x=1; xxx.y=2;xxx.z=3;
xxx.printInfo(); //输出(1,2,3)
var yyy = Coordinate();
yyy
..x = 1
..y = 2; //级联运算符等同于yyy.x=1; yyy.y=2;
yyy.printInfo(); //输出(1,2)
print (yyy is Point); //true
print(yyy is Coordinate); //true
可以看出子类Coordinate采用接口实现的方式仅仅是获取到了父类Point的一个“空壳子”只能从语义层面当成接口Point来用但并不能复用Point的原有实现。那么我们是否能够找到方法去复用Point的对应方法实现呢
也许你很快就想到了我可以让Coordinate继承Point来复用其对应的方法。但如果Coordinate还有其他的父类我们又该如何处理呢
其实除了继承和接口实现之外Dart还提供了另一种机制来实现类的复用即“混入”Mixin。混入鼓励代码重用可以被视为具有实现方法的接口。这样一来不仅可以解决Dart缺少对多重继承的支持问题还能够避免由于多重继承可能导致的歧义菱形问题
备注继承歧义也叫菱形问题是支持多继承的编程语言中一个相当棘手的问题。当B类和C类继承自A类而D类继承自B类和C类时会产生歧义。如果A中有一个方法在B和C中已经覆写而D没有覆写它那么D继承的方法的版本是B类还是C类的呢
要使用混入只需要with关键字即可。我们来试着改造Coordinate的实现把类中的变量声明和函数实现全部删掉
class Coordinate with Point {
}
var yyy = Coordinate();
print (yyy is Point); //true
print(yyy is Coordinate); //true
可以看到,通过混入,一个类里可以以非继承的方式使用其他类中的变量与方法,效果正如你想象的那样。
运算符
Dart和绝大部分编程语言的运算符一样所以你可以用熟悉的方式去执行程序代码运算。不过Dart多了几个额外的运算符用于简化处理变量实例缺失即null的情况。
?.运算符假设Point类有printInfo()方法p是Point的一个可能为null的实例。那么p调用成员方法的安全代码可以简化为p?.printInfo() 表示p为null的时候跳过避免抛出异常。
??= 运算符如果a为null则给a赋值value否则跳过。这种用默认值兜底的赋值语句在Dart中我们可以用a ??= value表示。
??运算符如果a不为null返回a的值否则返回b。在Java或者C++中,我们需要通过三元表达式(a != null)? a : b来实现这种情况。而在Dart中这类代码可以简化为a ?? b。
在Dart中一切都是对象就连运算符也是对象成员函数的一部分。
对于系统的运算符,一般情况下只支持基本数据类型和标准库中提供的类型。而对于用户自定义的类,如果想支持基本操作,比如比较大小、相加相减等,则需要用户自己来定义关于这个运算符的具体实现。
Dart提供了类似C++的运算符覆写机制,使得我们不仅可以覆写方法,还可以覆写或者自定义运算符。
接下来我们一起看一个Vector类中自定义“+”运算符和覆写”==“运算符的例子:
class Vector {
num x, y;
Vector(this.x, this.y);
// 自定义相加运算符,实现向量相加
Vector operator +(Vector v) => Vector(x + v.x, y + v.y);
// 覆写相等运算符,判断向量相等
bool operator == (dynamic v) => x == v.x && y == v.y;
}
final x = Vector(3, 3);
final y = Vector(2, 2);
final z = Vector(1, 1);
print(x == (y + z)); // 输出true
operator是Dart的关键字与运算符一起使用表示一个类成员运算符函数。在理解时我们应该把operator和运算符作为整体看作是一个成员函数名。
总结
函数、类与运算符是Dart处理信息的抽象手段。从今天的学习中你可以发现Dart面向对象的设计吸纳了其他编程语言的优点表达和处理信息的方式既简单又简洁但又不失强大。
通过这两篇文章的内容相信你已经了解了Dart的基本设计思路熟悉了在Flutter开发中常用的语法特性也已经具备了快速上手实践的能力。
接下来,我们简单回顾一下今天的内容,以便加深记忆与理解。
首先我们认识了函数。函数也是对象可以被定义为变量或者参数。Dart不支持函数重载但提供了可选命名参数和可选参数的方式从而解决了函数声明时需要传递多个参数的可维护性。
然后我带你学习了类。类提供了数据和函数的抽象复用能力可以通过继承父类继承接口实现和非继承Mixin方式实现复用。在类的内部关于成员变量Dart提供了包括命名构造函数和初始化列表在内的两种初始化方式。
最后,需要注意的是,运算符也是对象成员函数的一部分,可以覆写或者自定义。
思考题
最后,请你思考以下两个问题。
你是怎样理解父类继承,接口实现和混入的?我们应该在什么场景下使用它们?
在父类继承的场景中,父类子类之间的构造函数执行顺序是怎样的?如果父类有多个构造函数,子类也有多个构造函数,如何从代码层面确保父类子类之间构造函数的正确调用?
class Point {
num x, y;
Point() : this.make(0,0);
Point.left(x) : this.make(x,0);
Point.right(y) : this.make(0,y);
Point.make(this.x, this.y);
void printInfo() => print('($x,$y)');
}
class Vector extends Point{
num z = 0;
/*5个构造函数
Vector
Vector.left;
Vector.middle
Vector.right
Vector.make
*/
@override
void printInfo() => print('($x,$y,$z)'); //覆写了printInfo实现
}
欢迎将你的答案留言告诉我,我们一起讨论。感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。

View File

@@ -0,0 +1,374 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
08 综合案例掌握Dart核心特性
你好,我是陈航。
在前两篇文章中我首先与你一起学习了Dart程序的基本结构和语法认识了Dart语言世界的基本构成要素也就是类型系统以及它们是怎么表示信息的。然后我带你学习了Dart面向对象设计的基本思路知道了函数、类与运算符这些其他编程语言中常见的概念在Dart中的差异及典型用法理解了Dart是怎么处理信息的。
可以看到Dart吸纳了其他编程语言的优点在关于如何表达以及处理信息上既简单又简洁而且又不失强大。俗话说纸上得来终觉浅绝知此事要躬行。那么今天我就用一个综合案例把前面学习的关于Dart的零散知识串起来希望你可以动手试验一下这个案例借此掌握如何用Dart编程。
有了前面学习的知识点再加上今天的综合案例练习我认为你已经掌握了Dart最常用的80%的特性可以在基本没有语言障碍的情况下去使用Flutter了。至于剩下的那20%的特性,因为使用较少,所以我不会在本专栏做重点讲解。如果你对这部分内容感兴趣的话,可以访问官方文档去做进一步了解。
此外关于Dart中的异步和并发我会在后面的第23篇文章“单线程模型怎么保证UI运行流畅”中进行深入介绍。
案例介绍
今天我选择的案例是先用Dart写一段购物车程序但先不使用Dart独有的特性。然后我们再以这段程序为起点逐步加入Dart语言特性将其改造为一个符合Dart设计思想的程序。你可以在这个改造过程中进一步体会到Dart的魅力所在。
首先我们来看看在不使用任何Dart语法特性的情况下一个有着基本功能的购物车程序长什么样子。
//定义商品Item类
class Item {
double price;
String name;
Item(name, price) {
this.name = name;
this.price = price;
}
}
//定义购物车类
class ShoppingCart {
String name;
DateTime date;
String code;
List<Item> bookings;
price() {
double sum = 0.0;
for(var i in bookings) {
sum += i.price;
}
return sum;
}
ShoppingCart(name, code) {
this.name = name;
this.code = code;
this.date = DateTime.now();
}
getInfo() {
return '购物车信息:' +
'\n-----------------------------' +
'\n用户名: ' + name+
'\n优惠码: ' + code +
'\n总价: ' + price().toString() +
'\n日期: ' + date.toString() +
'\n-----------------------------';
}
}
void main() {
ShoppingCart sc = ShoppingCart('张三', '123456');
sc.bookings = [Item('苹果',10.0), Item('鸭梨',20.0)];
print(sc.getInfo());
}
在这段程序中我定义了商品Item类以及购物车ShoppingCart类。它们分别包含了一个初始化构造方法将main函数传入的参数信息赋值给对象内部属性。而购物车的基本信息则通过ShoppingCart类中的getInfo方法输出。在这个方法中我采用字符串拼接的方式将各类信息进行格式化组合后返回给调用者。
运行这段程序不出意外购物车对象sc包括的用户名、优惠码、总价与日期在内的基本信息都会被打印到命令行中。
购物车信息:
-----------------------------
用户名: 张三
优惠码: 123456
总价: 30.0
日期: 2019-06-01 17:17:57.004645
-----------------------------
这段程序的功能非常简单我们初始化了一个购物车对象然后给购物车对象进行加购操作最后打印出基本信息。可以看到在不使用Dart语法任何特性的情况下这段代码与Java、C++甚至JavaScript没有明显的语法差异。
在关于如何表达以及处理信息上Dart保持了既简单又简洁的风格。那接下来我们就先从表达信息入手看看Dart是如何优化这段代码的。
类抽象改造
我们先来看看Item类与ShoppingCart类的初始化部分。它们在构造函数中的初始化工作仅仅是将main函数传入的参数进行属性赋值。
在其他编程语言中在构造函数的函数体内将初始化参数赋值给实例变量的方式非常常见。而在Dart里我们可以利用语法糖以及初始化列表来简化这样的赋值过程从而直接省去构造函数的函数体
class Item {
double price;
String name;
Item(this.name, this.price);
}
class ShoppingCart {
String name;
DateTime date;
String code;
List<Item> bookings;
price() {...}
//删掉了构造函数函数体
ShoppingCart(this.name, this.code) : date = DateTime.now();
...
}
这一下就省去了7行代码通过这次改造我们有两个新的发现
首先Item类与ShoppingCart类中都有一个name属性在Item中表示商品名称在ShoppingCart中则表示用户名
然后Item类中有一个price属性ShoppingCart中有一个price方法它们都表示当前的价格。
考虑到name属性与price属性方法的名称与类型完全一致在信息表达上的作用也几乎一致因此我可以在这两个类的基础上再抽象出一个新的基类Meta用于存放price属性与name属性。
同时考虑到在ShoppingCart类中price属性仅用做计算购物车中商品的价格而不是像Item类那样用于数据存取因此在继承了Meta类后我改写了ShoppingCart类中price属性的get方法
class Meta {
double price;
String name;
Meta(this.name, this.price);
}
class Item extends Meta{
Item(name, price) : super(name, price);
}
class ShoppingCart extends Meta{
DateTime date;
String code;
List<Item> bookings;
double get price {...}
ShoppingCart(name, this.code) : date = DateTime.now(),super(name,0);
getInfo() {...}
}
通过这次类抽象改造程序中各个类的依赖关系变得更加清晰了。不过目前这段程序中还有两个冗长的方法显得格格不入即ShoppingCart类中计算价格的price属性get方法以及提供购物车基本信息的getInfo方法。接下来我们分别来改造这两个方法。
方法改造
我们先看看price属性的get方法
double get price {
double sum = 0.0;
for(var i in bookings) {
sum += i.price;
}
return sum;
}
在这个方法里我采用了其他语言常见的求和算法依次遍历bookings列表中的Item对象累积相加求和。
而在Dart中这样的求和运算我们只需重载Item类的“+”运算符,并通过对列表对象进行归纳合并操作即可实现(你可以想象成,把购物车中的所有商品都合并成了一个商品套餐对象)。
另外由于函数体只有一行所以我们可以使用Dart的箭头函数来进一步简化实现函数
class Item extends Meta{
...
//重载了+运算符,合并商品为套餐商品
Item operator+(Item item) => Item(name + item.name, price + item.price);
}
class ShoppingCart extends Meta{
...
//把迭代求和改写为归纳合并
double get price => bookings.reduce((value, element) => value + element).price;
...
getInfo() {...}
}
可以看到这段代码又简洁了很多接下来我们再看看getInfo方法如何优化。
在getInfo方法中我们将ShoppingCart类的基本信息通过字符串拼接的方式进行格式化组合这在其他编程语言中非常常见。而在Dart中我们可以通过对字符串插入变量或表达式并使用多行字符串声明的方式来完全抛弃不优雅的字符串拼接实现字符串格式化组合。
getInfo () => '''
购物车信息:
-----------------------------
用户名: $name
优惠码: $code
总价: $price
Date: $date
-----------------------------
''';
在去掉了多余的字符串转义和拼接代码后getInfo方法看着就清晰多了。
在优化完了ShoppingCart类与Item类的内部实现后我们再来看看main函数从调用方的角度去分析程序还能在哪些方面做优化。
对象初始化方式的优化
在main函数中我们使用
ShoppingCart sc = ShoppingCart('张三', '123456') ;
初始化了一个使用123456优惠码、名为张三的用户所使用的购物车对象。而这段初始化方法的调用我们可以从两个方面优化
首先在对ShoppingCart的构造函数进行了大量简写后我们希望能够提供给调用者更明确的初始化方法调用方式让调用者以“参数名:参数键值对”的方式指定调用参数让调用者明确传递的初始化参数的意义。在Dart中这样的需求我们在声明函数时可以通过给参数增加{}实现。
其次,对一个购物车对象来说,一定会有一个有用户名,但不一定有优惠码的用户。因此,对于购物车对象的初始化,我们还需要提供一个不含优惠码的初始化方法,并且需要确定多个初始化方法与父类的初始化方法之间的正确调用顺序。
按照这样的思路我们开始对ShoppingCart进行改造。
需要注意的是由于优惠码可以为空我们还需要对getInfo方法进行兼容处理。在这里我用到了a??b运算符这个运算符能够大量简化在其他语言中三元表达式(a != null)? a : b的写法
class ShoppingCart extends Meta{
...
//默认初始化方法转发到withCode里
ShoppingCart({name}) : this.withCode(name:name, code:null);
//withCode初始化方法使用语法糖和初始化列表进行赋值并调用父类初始化方法
ShoppingCart.withCode({name, this.code}) : date = DateTime.now(), super(name,0);
//??运算符表示为code不为null则用原值否则使用默认值"没有"
getInfo () => '''
购物车信息:
-----------------------------
用户名: $name
优惠码: ${code??"没有"}
总价: $price
Date: $date
-----------------------------
''';
}
void main() {
ShoppingCart sc = ShoppingCart.withCode(name:'张三', code:'123456');
sc.bookings = [Item('苹果',10.0), Item('鸭梨',20.0)];
print(sc.getInfo());
ShoppingCart sc2 = ShoppingCart(name:'李四');
sc2.bookings = [Item('香蕉',15.0), Item('西瓜',40.0)];
print(sc2.getInfo());
}
运行这段程序,张三和李四的购物车信息都会被打印到命令行中:
购物车信息:
-----------------------------
用户名: 张三
优惠码: 123456
总价: 30.0
Date: 2019-06-01 19:59:30.443817
-----------------------------
购物车信息:
-----------------------------
用户名: 李四
优惠码: 没有
总价: 55.0
Date: 2019-06-01 19:59:30.451747
-----------------------------
关于购物车信息的打印我们是通过在main函数中获取到购物车对象的信息后使用全局的print函数打印的我们希望把打印信息的行为封装到ShoppingCart类中。而对于打印信息的行为而言这是一个非常通用的功能不止ShoppingCart类需要Item对象也可能需要。
因此我们需要把打印信息的能力单独封装成一个单独的类PrintHelper。但ShoppingCart类本身已经继承自Meta类考虑到Dart并不支持多继承我们怎样才能实现PrintHelper类的复用呢
这就用到了我在上一篇文章中提到的“混入”Mixin相信你还记得只要在使用时加上with关键字即可。
我们来试着增加PrintHelper类并调整ShoppingCart的声明
abstract class PrintHelper {
printInfo() => print(getInfo());
getInfo();
}
class ShoppingCart extends Meta with PrintHelper{
...
}
经过Mixin的改造我们终于把所有购物车的行为都封装到ShoppingCart内部了。而对于调用方而言还可以使用级联运算符“..”,在同一个对象上连续调用多个函数以及访问成员变量。使用级联操作符可以避免创建临时变量,让代码看起来更流畅:
void main() {
ShoppingCart.withCode(name:'张三', code:'123456')
..bookings = [Item('苹果',10.0), Item('鸭梨',20.0)]
..printInfo();
ShoppingCart(name:'李四')
..bookings = [Item('香蕉',15.0), Item('西瓜',40.0)]
..printInfo();
}
很好通过Dart独有的语法特性我们终于把这段购物车代码改造成了简洁、直接而又强大的Dart风格程序。
总结
这就是今天分享的全部内容了。在今天我们以一个与Java、C++甚至JavaScript没有明显语法差异的购物车雏形为起步逐步将它改造成了一个符合Dart设计思想的程序。
首先,我们使用构造函数语法糖及初始化列表,简化了成员变量的赋值过程。然后,我们重载了“+”运算符并采用归纳合并的方式实现了价格计算并且使用多行字符串和内嵌表达式的方式省去了无谓的字符串拼接。最后我们重新梳理了类之间的继承关系通过mixin、多构造函数可选命名参数等手段优化了对象初始化调用方式。
下面是今天购物车综合案例的完整代码希望你在IDE中多多练习体会这次的改造过程从而对Dart那些使代码变得更简洁、直接而强大的关键语法特性产生更深刻的印象。同时改造前后的代码你也可以在GitHub的Dart_Sample中找到
class Meta {
double price;
String name;
//成员变量初始化语法糖
Meta(this.name, this.price);
}
class Item extends Meta{
Item(name, price) : super(name, price);
//重载+运算符,将商品对象合并为套餐商品
Item operator+(Item item) => Item(name + item.name, price + item.price);
}
abstract class PrintHelper {
printInfo() => print(getInfo());
getInfo();
}
//with表示以非继承的方式复用了另一个类的成员变量及函数
class ShoppingCart extends Meta with PrintHelper{
DateTime date;
String code;
List<Item> bookings;
//以归纳合并方式求和
double get price => bookings.reduce((value, element) => value + element).price;
//默认初始化函数转发至withCode函数
ShoppingCart({name}) : this.withCode(name:name, code:null);
//withCode初始化方法使用语法糖和初始化列表进行赋值并调用父类初始化方法
ShoppingCart.withCode({name, this.code}) : date = DateTime.now(), super(name,0);
//??运算符表示为code不为null则用原值否则使用默认值"没有"
@override
getInfo() => '''
购物车信息:
-----------------------------
用户名: $name
优惠码: ${code??"没有"}
总价: $price
Date: $date
-----------------------------
''';
}
void main() {
ShoppingCart.withCode(name:'张三', code:'123456')
..bookings = [Item('苹果',10.0), Item('鸭梨',20.0)]
..printInfo();
ShoppingCart(name:'李四')
..bookings = [Item('香蕉',15.0), Item('西瓜',40.0)]
..printInfo();
}
思考题
请你扩展购物车程序的实现,使得我们的购物车可以支持:
商品数量属性;
购物车信息增加商品列表信息(包括商品名称,数量及单价)输出,实现小票的基本功能。
欢迎你在评论区给我留言分享你的观点,我会在下一篇文章中等待你!感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。

View File

@@ -0,0 +1,175 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
09 Widget构建Flutter界面的基石
你好,我是陈航。
在前面的Flutter开发起步和Dart基础模块中我和你一起学习了Flutter框架的整体架构与基本原理分析了Flutter的项目结构和运行机制并从Flutter开发角度介绍了Dart语言的基本设计思路也通过和其他高级语言的类比深入认识了Dart的语法特性。
这些内容是我们接下来系统学习构建Flutter应用的基础可以帮助我们更好地掌握Flutter的核心概念和技术。
在第4篇文章“Flutter区别于其他方案的关键技术是什么”中我和你分享了一张来自Flutter官方的架构图不难看出Widget是整个视图描述的基础。这张架构图很重要所以我在这里又放了一次。
图1 Flutter架构图
备注此图引自Flutter System Overview
那么Widget到底是什么呢
Widget是Flutter功能的抽象描述是视图的配置信息同样也是数据的映射是Flutter开发框架中最基本的概念。前端框架中常见的名词比如视图View、视图控制器View Controller、活动Activity、应用Application、布局Layout在Flutter中都是Widget。
事实上Flutter的核心设计思想便是“一切皆Widget”。所以我们学习Flutter首先得从学会使用Widget开始。
那么在今天的这篇文章中我会带着你一起学习Widget在Flutter中的设计思路和基本原理以帮助你深入理解Flutter的视图构建过程。
Widget渲染过程
在进行App开发时我们往往会关注的一个问题是如何结构化地组织视图数据提供给渲染引擎最终完成界面显示。
通常情况下不同的UI框架中会以不同的方式去处理这一问题但无一例外地都会用到视图树View Tree的概念。而Flutter将视图树的概念进行了扩展把视图数据的组织和渲染抽象为三部分即WidgetElement和 RenderObject。
这三部分之间的关系,如下所示:
图2 WidgetElement与RenderObject
Widget
Widget是Flutter世界里对视图的一种结构化描述你可以把它看作是前端中的“控件”或“组件”。Widget是控件实现的基本逻辑单位里面存储的是有关视图渲染的配置信息包括布局、渲染属性、事件响应信息等。
在页面渲染上Flutter将“Simple is best”这一理念做到了极致。为什么这么说呢Flutter将Widget设计成不可变的所以当视图渲染的配置信息发生变化时Flutter会选择重建Widget树的方式进行数据更新以数据驱动UI构建的方式简单高效。
这样做的缺点是因为涉及到大量对象的销毁和重建所以会对垃圾回收造成压力。不过Widget本身并不涉及实际渲染位图所以它只是一份轻量级的数据结构重建的成本很低。
另外由于Widget的不可变性可以以较低成本进行渲染节点复用因此在一个真实的渲染树中可能存在不同的Widget对应同一个渲染节点的情况这无疑又降低了重建UI的成本。
Element
Element是Widget的一个实例化对象它承载了视图构建的上下文数据是连接结构化的配置信息到完成最终渲染的桥梁。
Flutter渲染过程可以分为这么三步
首先通过Widget树生成对应的Element树
然后创建相应的RenderObject并关联到Element.renderObject属性上
最后构建成RenderObject树以完成最终的渲染。
可以看到Element同时持有Widget和RenderObject。而无论是Widget还是Element其实都不负责最后的渲染只负责发号施令真正去干活儿的只有RenderObject。那你可能会问既然都是发号施令那为什么需要增加中间的这层Element树呢直接由Widget命令RenderObject去干活儿不好吗
答案是,可以,但这样做会极大地增加渲染带来的性能损耗。
因为Widget具有不可变性但Element却是可变的。实际上Element树这一层将Widget树的变化类似React 虚拟DOM diff做了抽象可以只将真正需要修改的部分同步到真实的RenderObject树中最大程度降低对真实渲染视图的修改提高渲染效率而不是销毁整个渲染视图树重建。
就是Element树存在的意义。
RenderObject
从其名字我们就可以很直观地知道RenderObject是主要负责实现视图渲染的对象。
在前面的第4篇文章“Flutter区别于其他方案的关键技术是什么”中我们提到Flutter通过控件树Widget树中的每个控件Widget创建不同类型的渲染对象组成渲染对象树。
而渲染对象树在Flutter的展示过程分为四个阶段即布局、绘制、合成和渲染。 其中布局和绘制在RenderObject中完成Flutter采用深度优先机制遍历渲染对象树确定树中各个对象的位置和尺寸并把它们绘制到不同的图层上。绘制完毕后合成和渲染的工作则交给Skia搞定。
Flutter通过引入Widget、Element与RenderObject这三个概念把原本从视图数据到视图渲染的复杂构建过程拆分得更简单、直接在易于集中治理的同时保证了较高的渲染效率。
RenderObjectWidget介绍
通过第5篇文章“从标准模板入手体会Flutter代码是如何运行在原生系统上的”的介绍你应该已经知道如何使用StatelessWidget和StatefulWidget了。
不过StatelessWidget和StatefulWidget只是用来组装控件的容器并不负责组件最后的布局和绘制。在Flutter中布局和绘制工作实际上是在Widget的另一个子类RenderObjectWidget内完成的。
所以在今天这篇文章的最后我们再来看一下RenderObjectWidget的源码来看看如何使用Element和RenderObject完成图形渲染工作。
abstract class RenderObjectWidget extends Widget {
@override
RenderObjectElement createElement();
@protected
RenderObject createRenderObject(BuildContext context);
@protected
void updateRenderObject(BuildContext context, covariant RenderObject renderObject) { }
...
}
RenderObjectWidget是一个抽象类。我们通过源码可以看到这个类中同时拥有创建Element、RenderObject以及更新RenderObject的方法。
但实际上RenderObjectWidget本身并不负责这些对象的创建与更新。
对于Element的创建Flutter会在遍历Widget树时调用createElement去同步Widget自身配置从而生成对应节点的Element对象。而对于RenderObject的创建与更新其实是在RenderObjectElement类中完成的。
abstract class RenderObjectElement extends Element {
RenderObject _renderObject;
@override
void mount(Element parent, dynamic newSlot) {
super.mount(parent, newSlot);
_renderObject = widget.createRenderObject(this);
attachRenderObject(newSlot);
_dirty = false;
}
@override
void update(covariant RenderObjectWidget newWidget) {
super.update(newWidget);
widget.updateRenderObject(this, renderObject);
_dirty = false;
}
...
}
在Element创建完毕后Flutter会调用Element的mount方法。在这个方法里会完成与之关联的RenderObject对象的创建以及与渲染树的插入工作插入到渲染树后的Element就可以显示到屏幕中了。
如果Widget的配置数据发生了改变那么持有该Widget的Element节点也会被标记为dirty。在下一个周期的绘制时Flutter就会触发Element树的更新并使用最新的Widget数据更新自身以及关联的RenderObject对象接下来便会进入Layout和Paint的流程。而真正的绘制和布局过程则完全交由RenderObject完成
abstract class RenderObject extends AbstractNode with DiagnosticableTreeMixin implements HitTestTarget {
...
void layout(Constraints constraints, { bool parentUsesSize = false }) {...}
void paint(PaintingContext context, Offset offset) { }
}
布局和绘制完成后接下来的事情就交给Skia了。在VSync信号同步时直接从渲染树合成Bitmap然后提交给GPU。这部分内容我已经在之前的“Flutter区别于其他方案的关键技术是什么”中与你介绍过了这里就不再赘述了。
接下来我以下面的界面示例为例与你说明Widget、Element与RenderObject在渲染过程中的关系。在下面的例子中一个Row容器放置了4个子Widget左边是Image而右边则是一个Column容器下排布的两个Text。
图3 界面示例
那么在Flutter遍历完Widget树创建了各个子Widget对应的Element的同时也创建了与之关联的、负责实际布局和绘制的RenderObject。
图4 示例界面生成的“三棵树”
总结
好了今天关于Widget的设计思路和基本原理的介绍我们就先进行到这里。接下来我们一起回顾下今天的主要内容吧。
首先我与你介绍了Widget渲染过程学习了在Flutter中视图数据的组织和渲染抽象的三个核心概念即Widget、 Element和RenderObject。
其中Widget是Flutter世界里对视图的一种结构化描述里面存储的是有关视图渲染的配置信息Element则是Widget的一个实例化对象将Widget树的变化做了抽象能够做到只将真正需要修改的部分同步到真实的Render Object树中最大程度地优化了从结构化的配置信息到完成最终渲染的过程而RenderObject则负责实现视图的最终呈现通过布局、绘制完成界面的展示。
最后在对Flutter Widget渲染过程有了一定认识后我带你阅读了RenderObjectWidget的代码理解Widget、Element与RenderObject这三个对象之间是如何互相配合实现图形渲染工作的。
熟悉了Widget、Element与RenderObject这三个概念相信你已经对组件的渲染过程有了一个清晰而完整的认识。这样我们后续再学习常用的组件和布局时就能够从不同的视角去思考框架设计的合理性了。
不过在日常开发学习中绝大多数情况下我们只需要了解各种Widget特性及使用方法而无需关心Element及RenderObject。因为Flutter已经帮我们做了大量优化工作因此我们只需要在上层代码完成各类Widget的组装配置其他的事情完全交给Flutter就可以了。
思考题
你是如何理解Widget、Element和RenderObject这三个概念的它们之间是一一对应的吗你能否在Android/iOS/Web中找到对应的概念呢
欢迎你在评论区给我留言分享你的观点,我会在下一篇文章中等待你!感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。

View File

@@ -0,0 +1,213 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
10 Widget中的State到底是什么
你好,我是陈航。
通过上一篇文章我们已经深入理解了Widget是Flutter构建界面的基石也认识了Widget、Element、RenderObject是如何互相配合实现图形渲染工作的。Flutter在底层做了大量的渲染优化工作使得我们只需要通过组合、嵌套不同类型的Widget就可以构建出任意功能、任意复杂度的界面。
同时我们通过前面的学习也已经了解到Widget有StatelessWidget和StatefulWidget两种类型。StatefulWidget应对有交互、需要动态变化视觉效果的场景而StatelessWidget则用于处理静态的、无状态的视图展示。StatefulWidget的场景已经完全覆盖了StatelessWidget因此我们在构建界面时往往会大量使用StatefulWidget来处理静态的视图展示需求看起来似乎也没什么问题。
那么StatelessWidget存在的必要性在哪里StatefulWidget是否是Flutter中的万金油在今天这篇文章中我将着重和你介绍这两种类型的区别从而帮你更好地理解Widget掌握不同类型Widget的正确使用时机。
UI编程范式
要想理解StatelessWidget与StatefulWidget的使用场景我们首先需要了解在Flutter中如何调整一个控件Widget的展示样式即UI编程范式。
如果你有过原生系统Android、iOS或原生JavaScript开发经验的话应该知道视图开发是命令式的需要精确地告诉操作系统或浏览器用何种方式去做事情。比如如果我们想要变更界面的某个文案则需要找到具体的文本控件并调用它的控件方法命令才能完成文字变更。
下述代码分别展示了在Android、iOS及原生Javascript中如何将一个文本控件的展示文案更改为Hello World
// Android设置某文本控件展示文案为Hello World
TextView textView = (TextView) findViewById(R.id.txt);
textView.setText("Hello World");
// iOS设置某文本控件展示文案为Hello World
UILabel *label = (UILabel *)[self.view viewWithTag:1234];
label.text = @"Hello World";
// 原生JavaScript设置某文本控件展示文案为Hello World
document.querySelector("#demo").innerHTML = "Hello World!";
与此不同的是Flutter的视图开发是声明式的其核心设计思想就是将视图和数据分离这与React的设计思路完全一致。
对我们来说如果要实现同样的需求则要稍微麻烦点除了设计好Widget布局方案之外还需要提前维护一套文案数据集并为需要变化的Widget绑定数据集中的数据使Widget根据这个数据集完成渲染。
但是当需要变更界面的文案时我们只要改变数据集中的文案数据并通知Flutter框架触发Widget的重新渲染即可。这样一来开发者将无需再精确关注UI编程中的各个过程细节只要维护好数据集即可。比起命令式的视图开发方式需要挨个设置不同组件Widget的视觉属性这种方式要便捷得多。
总结来说命令式编程强调精确控制过程细节而声明式编程强调通过意图输出结果整体。对应到Flutter中意图是绑定了组件状态的State结果则是重新渲染后的组件。在Widget的生命周期内应用到State中的任何更改都将强制Widget重新构建。
其中对于组件完成创建后就无需变更的场景状态的绑定是可选项。这里“可选”就区分出了Widget的两种类型StatelessWidget不带绑定状态而StatefulWidget带绑定状态。当你所要构建的用户界面不随任何状态信息的变化而变化时需要选择使用StatelessWidget反之则选用StatefulWidget。前者一般用于静态内容的展示而后者则用于存在交互反馈的内容呈现中。
接下来我分别和你介绍StatelessWidget和StatefulWidget从源码分析它们的区别并总结一些关于Widget选型的基本原则。
StatelessWidget
在Flutter中Widget采用由父到子、自顶向下的方式进行构建父Widget控制着子Widget的显示样式其样式配置由父Widget在构建时提供。
用这种方式构建出的Widget有些比如Text、Container、Row、Column等在创建时除了这些配置参数之外不依赖于任何其他信息换句话说它们一旦创建成功就不再关心、也不响应任何数据变化进行重绘。在Flutter中这样的Widget被称为StatelessWidget无状态组件
这里有一张StatelessWidget的示意图如下所示
图1 StatelessWidget 示意图
接下来我以Text的部分源码为例和你说明StatelessWidget的构建过程。
class Text extends StatelessWidget {
//构造方法及属性声明部分
const Text(this.data, {
Key key,
this.textAlign,
this.textDirection,
//其他参数
...
}) : assert(data != null),
textSpan = null,
super(key: key);
final String data;
final TextAlign textAlign;
final TextDirection textDirection;
//其他属性
...
@override
Widget build(BuildContext context) {
...
Widget result = RichText(
//初始化配置
...
)
);
...
return result;
}
}
可以看到在构造方法将其属性列表赋值后build方法随即将子组件RichText通过其属性列表如文本data、对齐方式textAlign、文本展示方向textDirection等初始化后返回之后Text内部不再响应外部数据的变化。
那么什么场景下应该使用StatelessWidget呢
这里我有一个简单的判断规则父Widget是否能通过初始化参数完全控制其UI展示效果如果能那么我们就可以使用StatelessWidget来设计构造函数接口了。
我准备了两个简单的小例子,来帮助你理解这个判断规则。
第一个小例子是我需要创建一个自定义的弹窗控件把使用App过程中出现的一些错误信息提示给用户。这个组件的父Widget能够完全在子Widget初始化时将组件所需要的样式信息和错误提示信息传递给它也就意味着父Widget通过初始化参数就能完全控制其展示效果。所以我可以采用继承StatelessWidget的方式来进行组件自定义。
第二个小例子是我需要定义一个计数器按钮用户每次点击按钮后按钮颜色都会随之加深。可以看到这个组件的父Widget只能控制子Widget初始的样式展示效果而无法控制在交互过程中发生的颜色变化。所以我无法通过继承StatelessWidget的方式来自定义组件。那么这个时候就轮到StatefulWidget出场了。
StatefulWidget
与StatelessWidget相对应的有一些Widget比如Image、Checkbox的展示除了父Widget初始化时传入的静态配置之外还需要处理用户的交互比如用户点击按钮或其内部数据的变化比如网络数据回包并体现在UI上。
换句话说这些Widget创建完成后还需要关心和响应数据变化来进行重绘。在Flutter中这一类Widget被称为StatefulWidget有状态组件。这里有一张StatefulWidget的示意图如下所示
图2 StatefulWidget 示意图
看到这里你可能有点困惑了。因为我在上一篇文章“Widget构建Flutter界面的基石”中和你分享到Widget是不可变的发生变化时需要销毁重建所以谈不上状态。那么这到底是怎么回事呢
其实StatefulWidget是以State类代理Widget构建的设计方式实现的。接下来我就以Image的部分源码为例和你说明StatefulWidget的构建过程来帮助你理解这个知识点。
和上面提到的Text一样Image类的构造函数会接收要被这个类使用的属性参数。然而不同的是Image类并没有build方法来创建视图而是通过createState方法创建了一个类型为_ImageState的state对象然后由这个对象负责视图的构建。
这个state对象持有并处理了Image类中的状态变化所以我就以_imageInfo属性为例来和你展开说明。
_imageInfo属性用来给Widget加载真实的图片一旦State对象通过_handleImageChanged方法监听到_imageInfo属性发生了变化就会立即调用_ImageState类的setState方法通知Flutter框架“我这儿的数据变啦请使用更新后的_imageInfo数据重新加载图片”。而Flutter框架则会标记视图状态更新UI。
class Image extends StatefulWidget {
//构造方法及属性声明部分
const Image({
Key key,
@required this.image,
//其他参数
}) : assert(image != null),
super(key: key);
final ImageProvider image;
//其他属性
...
@override
_ImageState createState() => _ImageState();
...
}
class _ImageState extends State<Image> {
ImageInfo _imageInfo;
//其他属性
...
void _handleImageChanged(ImageInfo imageInfo, bool synchronousCall) {
setState(() {
_imageInfo = imageInfo;
});
}
...
@override
Widget build(BuildContext context) {
final RawImage image = RawImage(
image: _imageInfo?.image,
//其他初始化配置
...
);
return image;
}
...
}
可以看到在这个例子中Image以一种动态的方式运行监听变化更新视图。与StatelessWidget通过父Widget完全控制UI展示不同StatefulWidget的父Widget仅定义了它的初始化状态而其自身视图运行的状态则需要自己处理并根据处理情况即时更新UI展示。
好了至此我们已经通过StatelessWidget与StatefulWidget的源码理解了这两种类型的Widget。这时你可能会问既然StatefulWidget不仅可以响应状态变化又能展示静态UI那么StatelessWidget这种只能展示静态UI的Widget还有存在的必要吗
StatefulWidget不是万金油要慎用
对于UI框架而言同样的展示效果一般可以通过多种控件实现。从定义来看StatefulWidget仿佛是万能的替代StatelessWidget看起来合情合理。于是StatefulWidget的滥用也容易因此变得顺理成章难以避免。
但事实是StatefulWidget的滥用会直接影响Flutter应用的渲染性能。
接下来在今天这篇文章的最后我就再带你回顾一下Widget的更新机制来帮你意识到完全使用StatefulWidget的代价
Widget是不可变的更新则意味着销毁+重建build。StatelessWidget是静态的一旦创建则无需更新而对于StatefulWidget来说在State类中调用setState方法更新数据会触发视图的销毁和重建也将间接地触发其每个子Widget的销毁和重建。
那么,这意味着什么呢?
如果我们的根布局是一个StatefulWidget在其State中每调用一次更新UI都将是一整个页面所有Widget的销毁和重建。
在上一篇文章中我们了解到虽然Flutter内部通过Element层可以最大程度地降低对真实渲染视图的修改提高渲染效率而不是销毁整个RenderObject树重建。但大量Widget对象的销毁重建是无法避免的。如果某个子Widget的重建涉及到一些耗时操作那页面的渲染性能将会急剧下降。
因此正确评估你的视图展示需求避免无谓的StatefulWidget使用是提高Flutter应用渲染性能最简单也是最直接的手段。
在接下来的第29篇文章“为什么需要做状态管理怎么做”中我会继续带你学习StatefulWidget常见的几种状态管理方法与你更为具体地介绍在不同场景中该选用何种Widget的基本原则。这些原则你都可以根据实际需要应用到后续工作中。
总结
好了今天关于StatelessWidget与StatefulWidget的介绍我们就到这里了。我们一起来回顾下今天的主要知识点。
首先我带你了解了Flutter基于声明式的UI编程范式并通过阅读两个典型WidgetText与Image源码的方式与你一起学习了StatelessWidget与StatefulWidget的基本设计思路。
由于Widget采用由父到子、自顶向下的方式进行构建因此在自定义组件时我们可以根据父Widget是否能通过初始化参数完全控制其UI展示效果的基本原则来判断究竟是继承StatelessWidget还是StatefulWidget。
然后针对StatefulWidget的“万金油”误区我带你重新回顾了Widget的UI更新机制。尽管Flutter会通过Element层去最大程度降低对真实渲染视图的修改但大量的Widget销毁重建无法避免因此避免StatefulWidget的滥用是最简单、直接地提升应用渲染性能的手段。
需要注意的是除了我们主动地通过State刷新UI之外在一些特殊场景下Widget的build方法有可能会执行多次。因此我们不应该在这个方法内部放置太多有耗时的操作。而关于这个build方法在哪些场景下会执行以及为什么会执行多次我会在下一篇文章“提到生命周期我们是在说什么”中与你一起详细分析。
思考题
Flutter工程应用模板是计数器示例应用Demo这个Demo的根节点是一个StatelessWidget。请在保持原有功能的情况下将这个Demo改造为根节点为StatefulWidget的App。你能通过数据打点得出这两种方式的性能差异吗
欢迎你在评论区给我留言分享你的观点,我会在下一篇文章中等待你!感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。

View File

@@ -0,0 +1,229 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
11 提到生命周期,我们是在说什么?
你好我是陈航。今天我要和你分享的主题是Flutter中的生命周期是什么。
在上一篇文章中我们从常见的StatefulWidget的“万金油”误区出发一起回顾了Widget的UI更新机制。
通过父Widget初始化时传入的静态配置StatelessWidget就能完全控制其静态展示。而StatefulWidget还需要借助于State对象在特定的阶段来处理用户的交互或其内部数据的变化并体现在UI上。这些特定的阶段就涵盖了一个组件从加载到卸载的全过程即生命周期。与iOS的ViewController、Android的Activity一样Flutter中的Widget也存在生命周期并且通过State来体现。
而App则是一个特殊的Widget。除了需要处理视图显示的各个阶段即视图的生命周期之外还需要应对应用从启动到退出所经历的各个状态App的生命周期
对于开发者来说无论是普通Widget的State还是App框架都给我们提供了生命周期的回调可以让我们选择恰当的时机做正确的事儿。所以在对生命周期有了深入理解之后我们可以写出更加连贯流畅、体验优良的程序。
那么今天我就分别从Widget的State和App这两个维度与你介绍它们的生命周期。
State生命周期
State的生命周期指的是在用户参与的情况下其关联的Widget所经历的从创建到显示再到更新最后到停止直至销毁等各个过程阶段。
这些不同的阶段涉及到特定的任务处理因此为了写出一个体验和性能良好的控件正确理解State的生命周期至关重要。
State的生命周期流程如图1所示
图1 State生命周期图
可以看到State的生命周期可以分为3个阶段创建插入视图树、更新在视图树中存在、销毁从视图树中移除。接下来我们一起看看每一个阶段的具体流程。
创建
State初始化时会依次执行 :构造方法 -> initState -> didChangeDependencies -> build随后完成页面渲染。
我们来看一下初始化过程中每个方法的意义。
构造方法是State生命周期的起点Flutter会通过调用StatefulWidget.createState() 来创建一个State。我们可以通过构造方法来接收父Widget传递的初始化UI配置数据。这些配置数据决定了Widget最初的呈现效果。
initState会在State对象被插入视图树的时候调用。这个函数在State的生命周期中只会被调用一次所以我们可以在这里做一些初始化工作比如为状态变量设定默认值。
didChangeDependencies则用来专门处理State对象依赖关系变化会在initState() 调用结束后被Flutter调用。
build作用是构建视图。经过以上步骤Framework认为State已经准备好了于是调用build。我们需要在这个函数中根据父Widget传递过来的初始化配置数据以及State的当前状态创建一个Widget然后返回。
更新
Widget的状态更新主要由3个方法触发setState、didchangeDependencies与didUpdateWidget。
接下来,我和你分析下这三个方法分别会在什么场景下调用。
setState我们最熟悉的方法之一。当状态数据发生变化时我们总是通过调用这个方法告诉Flutter“我这儿的数据变啦请使用更新后的数据重建UI
didChangeDependenciesState对象的依赖关系发生变化后Flutter会回调这个方法随后触发组件构建。哪些情况下State对象的依赖关系会发生变化呢典型的场景是系统语言Locale或应用主题改变时系统会通知State执行didChangeDependencies回调方法。
didUpdateWidget当Widget的配置发生变化时比如父Widget触发重建即父Widget的状态发生变化时热重载时系统会调用这个函数。
一旦这三个方法被调用Flutter随后就会销毁老Widget并调用build方法重建Widget。
销毁
组件销毁相对比较简单。比如组件被移除或是页面销毁的时候系统会调用deactivate和dispose这两个方法来移除或销毁组件。
接下来,我们一起看一下它们的具体调用机制:
当组件的可见状态发生变化时deactivate函数会被调用这时State会被暂时从视图树中移除。值得注意的是页面切换时由于State对象在视图树中的位置发生了变化需要先暂时移除后再重新添加重新触发组件构建因此这个函数也会被调用。
当State被永久地从视图树中移除时Flutter会调用dispose函数。而一旦到这个阶段组件就要被销毁了所以我们可以在这里进行最终的资源释放、移除监听、清理环境等等。
如图2所示左边部分展示了当父Widget状态发生变化时父子双方共同的生命周期而中间和右边部分则描述了页面切换时两个关联的Widget的生命周期函数是如何响应的。
图2 几种常见场景下State生命周期图
我准备了一张表格,从功能,调用时机和调用次数的维度总结了这些方法,帮助你去理解、记忆。
图3 State生命周期中的方法调用对比
另外我强烈建议你打开自己的IDE在应用模板中增加以上回调函数并添加打印代码多运行几次看看各个函数的执行顺序从而加深对State生命周期的印象。毕竟实践出真知。
App生命周期
视图的生命周期定义了视图的加载到构建的全过程其回调机制能够确保我们可以根据视图的状态选择合适的时机做恰当的事情。而App的生命周期则定义了App从启动到退出的全过程。
在原生Android、iOS开发中有时我们需要在对应的App生命周期事件中做相应处理比如App从后台进入前台、从前台退到后台或是在UI绘制完成后做一些处理。
这样的需求在原生开发中我们可以通过重写Activity、ViewController生命周期回调方法或是注册应用程序的相关通知来监听App的生命周期并做相应的处理。而在Flutter中我们可以利用WidgetsBindingObserver类来实现同样的需求。
接下来,我们就来看看具体如何实现这样的需求。
首先我们来看看WidgetsBindingObserver中具体有哪些回调函数
abstract class WidgetsBindingObserver {
//页面pop
Future<bool> didPopRoute() => Future<bool>.value(false);
//页面push
Future<bool> didPushRoute(String route) => Future<bool>.value(false);
//系统窗口相关改变回调,如旋转
void didChangeMetrics() { }
//文本缩放系数变化
void didChangeTextScaleFactor() { }
//系统亮度变化
void didChangePlatformBrightness() { }
//本地化语言变化
void didChangeLocales(List<Locale> locale) { }
//App生命周期变化
void didChangeAppLifecycleState(AppLifecycleState state) { }
//内存警告回调
void didHaveMemoryPressure() { }
//Accessibility相关特性回调
void didChangeAccessibilityFeatures() {}
}
可以看到WidgetsBindingObserver这个类提供的回调函数非常丰富常见的屏幕旋转、屏幕亮度、语言变化、内存警告都可以通过这个实现进行回调。我们通过给WidgetsBinding的单例对象设置监听器就可以监听对应的回调方法。
考虑到其他的回调相对简单你可以参考官方文档对照着进行练习。因此我今天主要和你分享App生命周期的回调didChangeAppLifecycleState和帧绘制回调addPostFrameCallback与addPersistentFrameCallback。
生命周期回调
didChangeAppLifecycleState回调函数中有一个参数类型为AppLifecycleState的枚举类这个枚举类是Flutter对App生命周期状态的封装。它的常用状态包括resumed、inactive、paused这三个。
resumed可见的并能响应用户的输入。
inactive处在不活动状态无法处理用户响应。
paused不可见并不能响应用户的输入但是在后台继续活动中。
这里,我来和你分享一个实际案例。
在下面的代码中我们在initState时注册了监听器在didChangeAppLifecycleState回调方法中打印了当前的App状态最后在dispose时把监听器移除
class _MyHomePageState extends State<MyHomePage> with WidgetsBindingObserver{//这里你可以再回顾下第7篇文章“函数、类与运算符Dart是如何处理信息的”中关于Mixin的内容
...
@override
@mustCallSuper
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);//注册监听器
}
@override
@mustCallSuper
void dispose(){
super.dispose();
WidgetsBinding.instance.removeObserver(this);//移除监听器
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) async {
print("$state");
if (state == AppLifecycleState.resumed) {
//do sth
}
}
}
我们试着切换一下前、后台观察控制台输出的App状态可以发现
从后台切入前台控制台打印的App生命周期变化如下: AppLifecycleState.paused->AppLifecycleState.inactive->AppLifecycleState.resumed
从前台退回后台控制台打印的App生命周期变化则变成了AppLifecycleState.resumed->AppLifecycleState.inactive->AppLifecycleState.paused。
可以看到App前后台切换过程中打印出的状态是完全符合预期的。
图4 App切换前后台状态变化示意
帧绘制回调
除了需要监听App的生命周期回调做相应的处理之外有时候我们还需要在组件渲染之后做一些与显示安全相关的操作。
在iOS开发中我们可以通过dispatch_async(dispatch_get_main_queue(),^{…})方法让操作在下一个RunLoop执行而在Android开发中我们可以通过View.post()插入消息队列,来保证在组件渲染后进行相关操作。
其实在Flutter中实现同样的需求会更简单依然使用万能的WidgetsBinding来实现。
WidgetsBinding提供了单次Frame绘制回调以及实时Frame绘制回调两种机制来分别满足不同的需求
单次Frame绘制回调通过addPostFrameCallback实现。它会在当前Frame绘制完成后进行进行回调并且只会回调一次如果要再次监听则需要再设置一次。
WidgetsBinding.instance.addPostFrameCallback((_){
print("单次Frame绘制回调");//只回调一次
});
实时Frame绘制回调则通过addPersistentFrameCallback实现。这个函数会在每次绘制Frame结束后进行回调可以用做FPS监测。
WidgetsBinding.instance.addPersistentFrameCallback((_){
print("实时Frame绘制回调");//每帧都回调
});
总结
在今天这篇文章中我和你介绍了State和App的生命周期这是Flutter给我们提供的感知Widget和应用在不同阶段状态变化的回调。
首先我带你重新认识了Widget生命周期的实际承载者State。我将State的生命周期划分为了创建插入视图树、更新在视图树中存在、销毁从视图树种移除这3个阶段并为你介绍了每个阶段中涉及的关键方法希望你能够深刻理解Flutter组件从加载到卸载的完整周期。
然后通过与原生Android、iOS平台能力的对比以及查看WidgetsBindingObserver源码的方式我与你讲述了Flutter常用的生命周期状态切换机制。希望你能掌握Flutter的App生命周期监听方法并理解Flutter常用的生命周期状态切换机制。
最后我和你一起学习了Flutter帧绘制回调机制理解了单次Frame绘制回调与实时Frame绘制回调的异同与使用场景。
为了能够精确地控制WidgetFlutter提供了很多状态回调所以今天这一篇文章涉及到的方法有些多。但只要你分别记住创建、更新与销毁这三条主线的调用规则就一定能把这些方法的调用顺序串起来并能在实际开发中运用正确的方法去感知状态变更写出合理的组件。
我把今天分享所涉及的全部知识点打包成了一个小项目,你可以下载后在工程中实际运行,并对照着今天的课程学习,体会在不同场景下这些函数的调用时机。
思考题
最后,请你思考下这两个问题:
构造方法与initState函数在State的生命周期中都只会被调用一次也大都用于完成一些初始化的工作。根据我们今天的学习你能否举出例子比如哪些操作适合放在构造方法哪些操作适合放在initState而哪些操作必须放在initState。
通过didChangeDependencies触发Widget重建时父子Widget之间的生命周期函数调用时序是怎样的
欢迎你在评论区给我留言分享你的观点,我会在下一篇文章中等待你!感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。

View File

@@ -0,0 +1,218 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
12 经典控件文本、图片和按钮在Flutter中怎么用
你好,我是陈航。
在上一篇文章中我与你介绍了Widget生命周期的实际承载者State并详细介绍了初始化、状态更新与控件销毁这3个不同阶段所涉及的关键方法调用顺序。深入理解视图从加载到构建再到销毁的过程可以帮助你理解如何根据视图的状态在合适的时机做恰当的事情。
前面几次分享我们讲了很多关于Flutter框架视图渲染的基础知识和原理。但有些同学可能会觉得这些基础知识和原理在实践中并不常用所以在学习时会选择忽视这些内容。
但其实像视图数据流转机制、底层渲染方案、视图更新策略等知识都是构成一个UI框架的根本看似枯燥却往往具有最长久的生命力。新框架每年层出不穷可是扒下那层炫酷的“外衣”里面其实还是那些最基础的知识和原理。
因此,只有把这些最基础的知识弄明白了,修炼好了内功,才能触类旁通,由点及面形成自己的知识体系,也能够在框架之上思考应用层构建视图实现的合理性。
在对视图的基础知识有了整体印象后我们再来学习Flutter视图系统所提供的UI控件就会事半功倍了。而作为一个UI框架与Android、iOS和React类似的Flutter自然也提供了很多UI控件。而文本、图片和按钮则是这些不同的UI框架中构建视图都要用到的三个最基本的控件。因此在今天这篇文章中我就与你一起学习在Flutter中该如何使用它们。
文本控件
文本是视图系统中的常见控件用来显示一段特定样式的字符串就比如Android里的TextView、iOS中的UILabel。而在Flutter中文本展示是通过Text控件实现的。
Text支持两种类型的文本展示一个是默认的展示单一样式的文本Text另一个是支持多种混合样式的富文本Text.rich。
我们先来看看如何使用单一样式的文本Text。
单一样式文本Text的初始化是要传入需要展示的字符串。而这个字符串的具体展示效果受构造函数中的其他参数控制。这些参数大致可以分为两类
控制整体文本布局的参数如文本对齐方式textAlign、文本排版方向textDirection文本显示最大行数maxLines、文本截断规则overflow等等这些都是构造函数中的参数
控制文本展示样式的参数如字体名称fontFamily、字体大小fontSize、文本颜色color、文本阴影shadows等等这些参数被统一封装到了构造函数中的参数style中。
接下来我们以一个具体的例子来看看Text控件的使用方法。如下所示我在代码中定义了一段居中布局、20号红色粗体展示样式的字符串
Text(
'文本是视图系统中的常见控件用来显示一段特定样式的字符串就比如Android里的TextView或是iOS中的UILabel。',
textAlign: TextAlign.center,//居中显示
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 20, color: Colors.red),//20号红色粗体展示
);
运行效果如下图所示:
图1 单一样式文本Text示例
理解了展示单一样式的文本Text的使用方法后我们再来看看如何在一段字符串中支持多种混合展示样式。
混合展示样式与单一样式的关键区别在于分片即如何把一段字符串分为几个片段来管理给每个片段单独设置样式。面对这样的需求在Android中我们使用SpannableString来实现在iOS中我们使用NSAttributedString来实现而在Flutter中也有类似的概念即TextSpan。
TextSpan定义了一个字符串片段该如何控制其展示样式而将这些有着独立展示样式的字符串组装在一起则可以支持混合样式的富文本展示。
如下方代码所示我们分别定义了黑色与红色两种展示样式随后把一段字符串分成了4个片段并设置了不同的展示样式
TextStyle blackStyle = TextStyle(fontWeight: FontWeight.normal, fontSize: 20, color: Colors.black); //黑色样式
TextStyle redStyle = TextStyle(fontWeight: FontWeight.bold, fontSize: 20, color: Colors.red); //红色样式
Text.rich(
TextSpan(
children: <TextSpan>[
TextSpan(text:'文本是视图系统中常见的控件,它用来显示一段特定样式的字符串,类似', style: redStyle), //第1个片段红色样式
TextSpan(text:'Android', style: blackStyle), //第1个片段黑色样式
TextSpan(text:'中的', style:redStyle), //第1个片段红色样式
TextSpan(text:'TextView', style: blackStyle) //第1个片段黑色样式
]),
textAlign: TextAlign.center,
);
运行效果,如下图所示:
图2 混合样式富文本Text.rich示例
接下来我们再看看Flutter中的图片控件Image。
图片
使用Image可以让我们向用户展示一张图片。图片的显示方式有很多比如资源图片、网络图片、文件图片等图片格式也各不相同因此在Flutter中也有多种方式用来加载不同形式、支持不同格式的图片
加载本地资源图片如Image.asset(images/logo.png)
加载本地File文件图片如Image.file(new File(/storage/xxx/xxx/test.jpg))
加载网络图片如Image.network('http://xxx/xxx/test.gif') 。
除了可以根据图片的显示方式设置不同的图片源之外图片的构造方法还提供了填充模式fit、拉伸模式centerSlice、重复模式repeat等属性可以针对图片与目标区域的宽高比差异制定排版模式。
和Android中ImageView、iOS里的UIImageView的属性都是类似的。因此我在这里就不再过多展开了。你可以参考官方文档中的Image的构造函数部分去查看Image控件的具体使用方法。
关于图片展示我还要和你分享下Flutter中的FadeInImage控件。在加载网络图片的时候为了提升用户的等待体验我们往往会加入占位图、加载动画等元素但是默认的Image.network构造方法并不支持这些高级功能这时候FadeInImage控件就派上用场了。
FadeInImage控件提供了图片占位的功能并且支持在图片加载完成时淡入淡出的视觉效果。此外由于Image支持gif格式我们甚至还可以将一些炫酷的加载动画作为占位图。
下述代码展示了这样的场景。我们在加载大图片时将一张loading的gif作为占位图展示给用户
FadeInImage.assetNetwork(
placeholder: 'assets/loading.gif', //gif占位
image: 'https://xxx/xxx/xxx.jpg',
fit: BoxFit.cover, //图片拉伸模式
width: 200,
height: 200,
)
图3 FadeInImage占位图
Image控件需要根据图片资源异步加载的情况决定自身的显示效果因此是一个StatefulWidget。图片加载过程由ImageProvider触发而ImageProvider表示异步获取图片数据的操作可以从资源、文件和网络等不同的渠道获取图片。
首先ImageProvider根据_ImageState中传递的图片配置生成对应的图片缓存key然后去ImageCache中查找是否有对应的图片缓存如果有则通知_ImageState刷新UI如果没有则启动ImageStream开始异步加载加载完毕后更新缓存最后通知_ImageState刷新UI。
图片展示的流程,可以用以下流程图表示:
图4 图片加载流程
值得注意的是ImageCache使用LRULeast Recently Used最近最少使用算法进行缓存更新策略并且默认最多存储 1000张图片最大缓存限制为100MB当限定的空间已存满数据时把最久没有被访问到的图片清除。图片缓存只会在运行期间生效也就是只缓存在内存中。如果想要支持缓存到文件系统可以使用第三方的CachedNetworkImage控件。
CachedNetworkImage的使用方法与Image类似除了支持图片缓存外还提供了比FadeInImage更为强大的加载过程占位与加载错误占位可以支持比用图片占位更灵活的自定义控件占位。
在下面的代码中我们在加载图片时不仅给用户展示了作为占位的转圈loading还提供了一个错误图兜底以备图片加载出错
CachedNetworkImage(
imageUrl: "http://xxx/xxx/jpg",
placeholder: (context, url) => CircularProgressIndicator(),
errorWidget: (context, url, error) => Icon(Icons.error),
)
最后我们再来看看Flutter中的按钮控件。
按钮
通过按钮我们可以响应用户的交互事件。Flutter提供了三个基本的按钮控件即FloatingActionButton、FlatButton和RaisedButton。
FloatingActionButton一个圆形的按钮一般出现在屏幕内容的前面用来处理界面中最常用、最基础的用户动作。在之前的第5篇文章“从标准模板入手体会Flutter代码是如何运行在原生系统上的”中计数器示例的“+”悬浮按钮就是一个FloatingActionButton。
RaisedButton凸起的按钮默认带有灰色背景被点击后灰色背景会加深。
FlatButton扁平化的按钮默认透明背景被点击后会呈现灰色背景。
这三个按钮控件的使用方法类似,唯一的区别只是默认样式不同而已。
下述代码中我分别定义了FloatingActionButton、FlatButton与RaisedButton它们的功能完全一样在点击时打印一段文字
FloatingActionButton(onPressed: () => print('FloatingActionButton pressed'),child: Text('Btn'),);
FlatButton(onPressed: () => print('FlatButton pressed'),child: Text('Btn'),);
RaisedButton(onPressed: () => print('RaisedButton pressed'),child: Text('Btn'),);
图5 按钮控件
既然是按钮,因此除了控制基本样式之外,还需要响应用户点击行为。这就对应着按钮控件中的两个最重要的参数了:
onPressed参数用于设置点击回调告诉Flutter在按钮被点击时通知我们。如果onPressed参数为空则按钮会处于禁用状态不响应用户点击。
child参数用于设置按钮的内容告诉Flutter控件应该长成什么样也就是控制着按钮控件的基本样式。child可以接收任意的Widget比如我们在上面的例子中传入的Text除此之外我们还可以传入Image等控件。
虽然我们可以通过child参数来控制按钮控件的基本样式但是系统默认的样式还是太单调了。因此通常情况下我们还是会进行控件样式定制。
与Text控件类似按钮控件也提供了丰富的样式定制功能比如背景颜色color、按钮形状shape、主题颜色colorBrightness等等。
接下来我就以FlatButton为例与你介绍按钮的样式定制
FlatButton(
color: Colors.yellow, //设置背景色为黄色
shape:BeveledRectangleBorder(borderRadius: BorderRadius.circular(20.0)), //设置斜角矩形边框
colorBrightness: Brightness.light, //确保文字按钮为深色
onPressed: () => print('FlatButton pressed'),
child: Row(children: <Widget>[Icon(Icons.add), Text("Add")],)
)
可以看到我们将一个加号Icon与文本组合定义了按钮的基本外观随后通过shape来指定其外形为一个斜角矩形边框并将按钮的背景色设置为黄色。
因为按钮背景颜色是浅色的为避免按钮文字看不清楚我们通过设置按钮主题colorBrightness为Brightness.light保证按钮文字颜色为深色。
展示效果如下:
图6 按钮控件定制外观
总结
UI控件是构建一个视图的基本元素而文本、图片和按钮则是其中最经典的控件。
接下来,我们简单回顾一下今天的内容,以便加深理解与记忆。
首先我们认识了支持单一样式和混合样式两种类型的文本展示控件Text。其中通过TextStyle控制字符串的展示样式其他参数控制文本布局可以实现单一样式的文本展示而通过TextSpan将字符串分割为若干片段对每个片段单独设置样式后组装可以实现支持混合样式的富文本展示。
然后我带你学习了支持多种图片源加载方式的图片控件Image。Image内部通过ImageProvider根据缓存状态触发异步加载流程通知_ImageState刷新UI。不过由于图片缓存是内存缓存因此只在运行期间生效。如果要支持缓存到文件系统可以使用第三方的CachedNetworkImage。
最后我们学习了按钮控件。Flutter提供了多种按钮控件而它们的使用方法也都类似。其中控件初始化的child参数用于设置按钮长什么样而onPressed参数则用于设置点击回调。与Text类似按钮内部也有丰富的UI定制接口可以满足开发者的需求。
通过今天的学习我们可以发现在UI基本信息的表达上Flutter的经典控件与原生Android、iOS系统提供的控件没有什么本质区别。但是在自定义控件样式上Flutter的这些经典控件提供了强大而简洁的扩展能力使得我们可以快速开发出功能复杂、样式丰富的页面。
思考题
最后,我给你留下一道思考题吧。
请你打开IDE阅读Flutter SDK中Text、Image、FadeInImage以及按钮控件FloatingActionButton、FlatButton与RaisedButton的源码在build函数中找出在内部真正承载其视觉功能的控件。请和我分享下你在这一过程中发现了什么现象
欢迎你在评论区给我留言分享你的观点,我会在下一篇文章中等待你!感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。

View File

@@ -0,0 +1,313 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
13 经典控件UITableView_ListView在Flutter中是什么
你好,我是陈航。
在上一篇文章中我和你一起学习了文本、图片和按钮这3大经典组件在Flutter中的使用方法以及如何在实际开发中根据不同的场景去自定义展示样式。
文本、图片和按钮这些基本元素需要进行排列组合才能构成我们看到的UI视图。那么当这些基本元素的排列布局超过屏幕显示尺寸即超过一屏我们就需要引入列表控件来展示视图的完整内容并根据元素的多少进行自适应滚动展示。
这样的需求在Android中是由ListView或RecyclerView实现的在iOS中是用UITableView实现的而在Flutter中实现这种需求的则是列表控件ListView。
ListView
在Flutter中ListView可以沿一个方向垂直或水平方向来排列其所有子Widget因此常被用于需要展示一组连续视图元素的场景比如通信录、优惠券、商家列表等。
我们先来看看ListView怎么用。ListView提供了一个默认构造函数ListView我们可以通过设置它的children参数很方便地将所有的子Widget包含到ListView中。
不过这种创建方式要求提前将所有子Widget一次性创建好而不是等到它们真正在屏幕上需要显示时才创建所以有一个很明显的缺点就是性能不好。因此这种方式仅适用于列表中含有少量元素的场景。
如下所示我定义了一组列表项组件并将它们放在了垂直滚动的ListView中
ListView(
children: <Widget>[
//设置ListTile组件的标题与图标
ListTile(leading: Icon(Icons.map), title: Text('Map')),
ListTile(leading: Icon(Icons.mail), title: Text('Mail')),
ListTile(leading: Icon(Icons.message), title: Text('Message')),
]);
备注ListTile是Flutter提供的用于快速构建列表项元素的一个小组件单元用于1~3行leading、title、subtitle展示文本、图标等视图元素的场景通常与ListView配合使用。-
上面这段代码中用到ListTile是为了演示ListView的能力。关于ListTile的具体使用细节并不是本篇文章的重点如果你想深入了解的话可以参考官方文档。
运行效果,如下图所示:
图1 ListView默认构造函数
除了默认的垂直方向布局外ListView还可以通过设置scrollDirection参数支持水平方向布局。如下所示我定义了一组不同颜色背景的组件将它们的宽度设置为140并包在了水平布局的ListView中让它们可以横向滚动
ListView(
scrollDirection: Axis.horizontal,
itemExtent: 140, //item延展尺寸(宽度)
children: <Widget>[
Container(color: Colors.black),
Container(color: Colors.red),
Container(color: Colors.blue),
Container(color: Colors.green),
Container(color: Colors.yellow),
Container(color: Colors.orange),
]);
运行效果,如下图所示:
图2 水平滚动的ListView
在这个例子中我们一次性创建了6个子Widget。但从图2的运行效果可以看到由于屏幕的宽高有限同一时间用户只能看到3个Widget。也就是说是否一次性提前构建出所有要展示的子Widget与用户而言并没有什么视觉上的差异。
所以考虑到创建子Widget产生的性能问题更好的方法是抽象出创建子Widget的方法交由ListView统一管理在真正需要展示该子Widget时再去创建。
ListView的另一个构造函数ListView.builder则适用于子Widget比较多的场景。这个构造函数有两个关键参数
itemBuilder是列表项的创建方法。当列表滚动到相应位置时ListView会调用该方法创建对应的子Widget。
itemCount表示列表项的数量如果为空则表示ListView为无限列表。
同样地我通过一个案例与你说明itemBuilder与itemCount这两个参数的具体用法。
我定义了一个拥有100个列表元素的ListView在列表项的创建方法中分别将index的值设置为ListTile的标题与子标题。比如第一行列表项会展示title 0 body 0
ListView.builder(
itemCount: 100, //元素个数
itemExtent: 50.0, //列表项高度
itemBuilder: (BuildContext context, int index) => ListTile(title: Text("title $index"), subtitle: Text("body $index"))
);
这里需要注意的是itemExtent并不是一个必填参数。但对于定高的列表项元素我强烈建议你提前设置好这个参数的值。
因为如果这个参数为nullListView会动态地根据子Widget创建完成的结果决定自身的视图高度以及子Widget在ListView中的相对位置。在滚动发生变化而列表项又很多时这样的计算就会非常频繁。
但如果提前设置好itemExtentListView则可以提前计算好每一个列表项元素的相对位置以及自身的视图高度省去了无谓的计算。
因此在ListView中指定itemExtent比让子Widget自己决定自身高度会更高效。
运行这个示例,效果如下所示:
图3 ListView.builder构造函数
可能你已经发现了我们的列表还缺少分割线。在ListView中有两种方式支持分割线
一种是在itemBuilder中根据index的值动态创建分割线也就是将分割线视为列表项的一部分
另一种是使用ListView的另一个构造方法ListView.separated单独设置分割线的样式。
第一种方式实际上是视图的组合之前的分享中我们已经多次提及对你来说应该已经比较熟悉了这里我就不再过多地介绍了。接下来我和你演示一下如何使用ListView.separated设置分割线。
与ListView.builder抽离出了子Widget的构建方法类似ListView.separated抽离出了分割线的创建方法separatorBuilder以便根据index设置不同样式的分割线。
如下所示我针对index为偶数的场景创建了绿色的分割线而针对index为奇数的场景创建了红色的分割线
//使用ListView.separated设置分割线
ListView.separated(
itemCount: 100,
separatorBuilder: (BuildContext context, int index) => index %2 ==0? Divider(color: Colors.green) : Divider(color: Colors.red),//index为偶数创建绿色分割线index为奇数则创建红色分割线
itemBuilder: (BuildContext context, int index) => ListTile(title: Text("title $index"), subtitle: Text("body $index"))//创建子Widget
)
运行效果,如下所示:
图4 ListView.separated构造函数
好了我已经与你分享完了ListView的常见构造函数。接下来我准备了一张表格总结了ListView常见的构造方法及其适用场景供你参考以便理解与记忆
图5 ListView常见的构造方法及其适用场景
CustomScrollView
好了ListView实现了单一视图下可滚动Widget的交互模型同时也包含了UI显示相关的控制逻辑和布局模型。但是对于某些特殊交互场景比如多个效果联动、嵌套滚动、精细滑动、视图跟随手势操作等还需要嵌套多个ListView来实现。这时各自视图的滚动和布局模型就是相互独立、分离的就很难保证整个页面统一一致的滑动效果。
那么Flutter是如何解决多ListView嵌套时页面滑动效果不一致的问题的呢
在Flutter中有一个专门的控件CustomScrollView用来处理多个需要自定义滚动效果的Widget。在CustomScrollView中这些彼此独立的、可滚动的Widget被统称为Sliver。
比如ListView的Sliver实现为SliverListAppBar的Sliver实现为SliverAppBar。这些Sliver不再维护各自的滚动状态而是交由CustomScrollView统一管理最终实现滑动效果的一致性。
接下来我通过一个滚动视差的例子与你演示CustomScrollView的使用方法。
视差滚动是指让多层背景以不同的速度移动,在形成立体滚动效果的同时,还能保证良好的视觉体验。 作为移动应用交互设计的热点趋势,越来越多的移动应用使用了这项技术。
以一个有着封面头图的列表为例,我们希望封面头图和列表这两层视图的滚动联动起来,当用户滚动列表时,头图会根据用户的滚动手势,进行缩小和展开。
经分析得出要实现这样的需求我们需要两个Sliver作为头图的SliverAppBar与作为列表的SliverList。具体的实现思路是
在创建SliverAppBar时把flexibleSpace参数设置为悬浮头图背景。flexibleSpace可以让背景图显示在AppBar下方高度和SliverAppBar一样
而在创建SliverList时通过SliverChildBuilderDelegate参数实现列表项元素的创建
最后将它们一并交由CustomScrollView的slivers参数统一管理。
具体的示例代码如下所示:
CustomScrollView(
slivers: <Widget>[
SliverAppBar(//SliverAppBar作为头图控件
title: Text('CustomScrollView Demo'),//标题
floating: true,//设置悬浮样式
flexibleSpace: Image.network("https://xx.jpg",fit:BoxFit.cover),//设置悬浮头图背景
expandedHeight: 300,//头图控件高度
),
SliverList(//SliverList作为列表控件
delegate: SliverChildBuilderDelegate(
(context, index) => ListTile(title: Text('Item #$index')),//列表项创建方法
childCount: 100,//列表元素个数
),
),
]);
运行一下,视差滚动效果如下所示:
图6 CustomScrollView示例
ScrollController与ScrollNotification
现在,你应该已经知道如何实现滚动视图的视觉和交互效果了。接下来,我再与你分享一个更为复杂的问题:在某些情况下,我们希望获取视图的滚动信息,并进行相应的控制。比如,列表是否已经滑到底(顶)了?如何快速回到列表顶部?列表滚动是否已经开始,或者是否已经停下来了?
对于前两个问题我们可以使用ScrollController进行滚动信息的监听以及相应的滚动控制而最后一个问题则需要接收ScrollNotification通知进行滚动事件的获取。下面我将分别与你介绍。
在Flutter中因为Widget并不是渲染到屏幕的最终视觉元素RenderObject才是所以我们无法像原生的Android或iOS系统那样向持有的Widget对象获取或设置最终渲染相关的视觉信息而必须通过对应的组件控制器才能实现。
ListView的组件控制器则是ScrollControler我们可以通过它来获取视图的滚动信息更新视图的滚动位置。
一般而言获取视图的滚动信息往往是为了进行界面的状态控制因此ScrollController的初始化、监听及销毁需要与StatefulWidget的状态保持同步。
如下代码所示我们声明了一个有着100个元素的列表项当滚动视图到特定位置后用户可以点击按钮返回列表顶部
首先我们在State的初始化方法里创建了ScrollController并通过_controller.addListener注册了滚动监听方法回调根据当前视图的滚动位置判断当前是否需要展示“Top”按钮。
随后在视图构建方法build中我们将ScrollController对象与ListView进行了关联并且在RaisedButton中注册了对应的回调方法可以在点击按钮时通过_controller.animateTo方法返回列表顶部。
最后在State的销毁方法中我们对ScrollController进行了资源释放。
class MyAPPState extends State<MyApp> {
ScrollController _controller;//ListView控制器
bool isToTop = false;//标示目前是否需要启用"Top"按钮
@override
void initState() {
_controller = ScrollController();
_controller.addListener(() {//为控制器注册滚动监听方法
if(_controller.offset > 1000) {//如果ListView已经向下滚动了1000则启用Top按钮
setState(() {isToTop = true;});
} else if(_controller.offset < 300) {//如果ListView向下滚动距离不足300则禁用Top按钮
setState(() {isToTop = false;});
}
});
super.initState();
}
Widget build(BuildContext context) {
return MaterialApp(
...
//顶部Top按钮根据isToTop变量判断是否需要注册滚动到顶部的方法
RaisedButton(onPressed: (isToTop ? () {
if(isToTop) {
_controller.animateTo(.0,
duration: Duration(milliseconds: 200),
curve: Curves.ease
);//做一个滚动到顶部的动画
}
}:null),child: Text("Top"),)
...
ListView.builder(
controller: _controller,//初始化传入控制器
itemCount: 100,//列表元素总数
itemBuilder: (context, index) => ListTile(title: Text("Index : $index")),//列表项构造方法
)
...
);
@override
void dispose() {
_controller.dispose(); //销毁控制器
super.dispose();
}
}
ScrollController的运行效果如下所示
图7 ScrollController示例
介绍完了如何通过ScrollController来监听ListView滚动信息以及怎样进行滚动控制之后接下来我们再看看如何获取ScrollNotification通知从而感知ListView的各类滚动事件。
在Flutter中ScrollNotification通知的获取是通过NotificationListener来实现的。与ScrollController不同的是NotificationListener是一个Widget为了监听滚动类型的事件我们需要将NotificationListener添加为ListView的父容器从而捕获ListView中的通知。而这些通知需要通过onNotification回调函数实现监听逻辑
Widget build(BuildContext context) {
return MaterialApp(
title: 'ScrollController Demo',
home: Scaffold(
appBar: AppBar(title: Text('ScrollController Demo')),
body: NotificationListener<ScrollNotification>(//添加NotificationListener作为父容器
onNotification: (scrollNotification) {//注册通知回调
if (scrollNotification is ScrollStartNotification) {//滚动开始
print('Scroll Start');
} else if (scrollNotification is ScrollUpdateNotification) {//滚动位置更新
print('Scroll Update');
} else if (scrollNotification is ScrollEndNotification) {//滚动结束
print('Scroll End');
}
},
child: ListView.builder(
itemCount: 30,//列表元素个数
itemBuilder: (context, index) => ListTile(title: Text("Index : $index")),//列表项创建方法
),
)
)
);
}
相比于ScrollController只能和具体的ListView关联后才可以监听到滚动信息通过NotificationListener则可以监听其子Widget中的任意ListView不仅可以得到这些ListView的当前滚动位置信息还可以获取当前的滚动事件信息 。
总结
在处理用于展示一组连续、可滚动的视图元素的场景Flutter提供了比原生Android、iOS系统更加强大的列表组件ListView与CustomScrollView不仅可以支持单一视图下可滚动Widget的交互模型及UI控制模型对于某些特殊交互需要嵌套多重可滚动Widget的场景也提供了统一管理的机制最终实现体验一致的滑动效果。这些强大的组件使得我们不仅可以开发出样式丰富的界面更可以实现复杂的交互。
接下来,我们简单回顾一下今天的内容,以便加深你的理解与记忆。
首先我们认识了ListView组件。它同时支持垂直方向和水平方向滚动不仅提供了少量一次性创建子视图的默认构造方式也提供了大量按需创建子视图的ListView.builder机制并且支持自定义分割线。为了节省性能对于定高的列表项视图提前指定itemExtent比让子Widget自己决定要更高效。
随后我带你学习了CustomScrollView组件。它引入了Sliver的概念将多重嵌套的可滚动视图的交互与布局进行统一接管使得像视差滚动这样的高级交互变得更加容易。
最后我们学习了ScrollController与NotificationListener前者与ListView绑定进行滚动信息的监听进行相应的滚动控制而后者通过将ListView纳入子Widget实现滚动事件的获取。
我把今天分享讲的三个例子视差、ScrollController、ScrollNotification放到了GitHub上你可以下载后在工程中实际运行并对照着今天的知识点进行学习体会ListView的一些高级用法。
思考题
最后,我给你留下两个小作业吧:
在ListView.builder方法中ListView根据Widget是否将要出现在可视区域内按需创建。对于一些场景为了避免Widget渲染时间过长比如图片下载我们需要提前将可视区域上下一定区域内的Widget提前创建好。那么在Flutter中如何才能实现呢
请你使用NotificationListener来实现图7 ScrollController示例中同样的功能。
欢迎你在评论区给我留言分享你的观点,我会在下一篇文章中等待你!感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。

View File

@@ -0,0 +1,265 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
14 经典布局:如何定义子控件在父容器中排版的位置?
你好,我是陈航。
在前面两篇文章中我们一起学习了构建视图的基本元素文本、图片和按钮用于展示一组连续视图元素的ListView以及处理多重嵌套的可滚动视图的CustomScrollView。
在Flutter中一个完整的界面通常就是由这些小型、单用途的基本控件元素依据特定的布局规则堆砌而成的。那么今天我就带你一起学习一下在Flutter中搭建出一个漂亮的布局我们需要了解哪些布局规则以及这些规则与其他平台类似概念的差别在哪里。希望这样的设计可以帮助你站在已有经验的基础上去高效学习Flutter的布局规则。
我们已经知道在Flutter中一切皆Widget那么布局也不例外。但与基本控件元素不同布局类的Widget并不会直接呈现视觉内容而是作为承载其他子Widget的容器。
这些布局类的Widget内部都会包含一个或多个子控件并且都提供了摆放子控件的不同布局方式可以实现子控件的对齐、嵌套、层叠和缩放等。而我们要做的就是通过各种定制化的参数将其内部的子Widget依照自己的布局规则放置在特定的位置上最终形成一个漂亮的布局。
Flutter提供了31种布局Widget对布局控件的划分非常详细一些相同或相似的视觉效果可以通过多种布局控件实现因此布局类型相比原生Android、iOS平台多了不少。比如Android布局一般就只有FrameLayout、LinearLayout、RelativeLayout、GridLayout和TableLayout这5种而iOS的布局更少只有Frame布局和自动布局两种。
为了帮你建立起对布局类Widget的认知了解基本布局类Widget的布局特点和用法从而学以致用快速上手开发在今天的这篇文章中我特意挑选了几类在开发Flutter应用时最常用也最有代表性的布局Widget包括单子Widget布局、多子Widget布局、层叠Widget布局与你展开介绍。
掌握了这些典型的Widget你也就基本掌握了构建一个界面精美的App所需要的全部布局方式了。接下来我们就先从单子Widget布局聊起吧。
单子Widget布局Container、Padding与Center
单子Widget布局类容器比较简单一般用来对其唯一的子Widget进行样式包装比如限制大小、添加背景色样式、内间距、旋转变换等。这一类布局Widget包括Container、Padding与Center三种。
Container是一种允许在其内部添加其他控件的控件也是UI框架中的一个常见概念。
在Flutter中Container本身可以单独作为控件存在比如单独设置背景色、宽高也可以作为其他控件的父级存在Container可以定义布局过程中子Widget如何摆放以及如何展示。与其他框架不同的是Flutter的Container仅能包含一个子Widget。
所以对于多个子Widget的布局场景我们通常会这样处理先用一个根Widget去包装这些子Widget然后把这个根Widget放到Container中再由Container设置它的对齐alignment、边距padding等基础属性和样式属性。
接下来我通过一个示例与你演示如何定义一个Container。
在这个示例中我将一段较长的文字包装在一个红色背景、圆角边框的、固定宽高的Container中并分别设置了Container的外边距距离其父Widget的边距和内边距距离其子Widget的边距
Container(
child: Text('Container容器在UI框架中是一个很常见的概念Flutter也不例外。'),
padding: EdgeInsets.all(18.0), // 内边距
margin: EdgeInsets.all(44.0), // 外边距
width: 180.0,
height:240,
alignment: Alignment.center, // 子Widget居中对齐
decoration: BoxDecoration( //Container样式
color: Colors.red, // 背景色
borderRadius: BorderRadius.circular(10.0), // 圆角边框
),
)
图1 Container示例
如果我们只需要将子Widget设定间距则可以使用另一个单子容器控件Padding进行内容填充
Padding(
padding: EdgeInsets.all(44.0),
child: Text('Container容器在UI框架中是一个很常见的概念Flutter也不例外。'),
);
图2 Padding示例
在需要设置内容间距时我们可以通过EdgeInsets的不同构造函数分别制定四个方向的不同补白方式如均使用同样数值留白、只设置左留白或对称方向留白等。如果你想更深入地了解这部分内容可以参考这个API文档。
接下来我们再来看看单子Widget布局容器中另一个常用的容器Center。正如它的名字一样Center会将其子Widget居中排列。
比如我们可以把一个Text包在Center里实现居中展示
Scaffold(
body: Center(child: Text("Hello")) // This trailing comma makes auto-formatting nicer for build methods.
);
图3 Center示例
需要注意的是为了实现居中布局Center所占据的空间一定要比其子Widget要大才行这也是显而易见的如果Center和其子Widget一样大自然就不需要居中也没空间居中了。因此Center通常会结合Container一起使用。
现在我们结合Container一起看看Center的具体使用方法吧。
Container(
child: Center(child: Text('Container容器在UI框架中是一个很常见的概念Flutter也不例外。')),
padding: EdgeInsets.all(18.0), // 内边距
margin: EdgeInsets.all(44.0), // 外边距
width: 180.0,
height:240,
decoration: BoxDecoration( //Container样式
color: Colors.red, // 背景色
borderRadius: BorderRadius.circular(10.0), // 圆角边框
),
);
可以看到我们通过Center容器实现了Container容器中alignment: Alignment.center的效果。
事实上为了达到这一效果Container容器与Center容器底层都依赖了同一个容器Align通过它实现子Widget的对齐方式。Align的使用也比较简单如果你想深入了解的话可以参考官方文档这里我就不再过多介绍了。
接下来我们再看看多子Widget布局的三种方式即Row、Column与Expanded。
多子Widget布局Row、Column与Expanded
对于拥有多个子Widget的布局类容器而言其布局行为无非就是两种规则的抽象水平方向上应该如何布局、垂直方向上应该如何布局。
如同Android的LinearLayout、前端的Flex布局一样Flutter中也有类似的概念即将子Widget按行水平排列的Row按列垂直排列的Column以及负责分配这些子Widget在布局方向行/列中剩余空间的Expanded。
Row与Column的使用方法很简单我们只需要将各个子Widget按序加入到children数组即可。在下面的代码中我们把4个分别设置了不同的颜色和宽高的Container加到Row与Column中
//Row的用法示范
Row(
children: <Widget>[
Container(color: Colors.yellow, width: 60, height: 80,),
Container(color: Colors.red, width: 100, height: 180,),
Container(color: Colors.black, width: 60, height: 80,),
Container(color: Colors.green, width: 60, height: 80,),
],
);
//Column的用法示范
Column(
children: <Widget>[
Container(color: Colors.yellow, width: 60, height: 80,),
Container(color: Colors.red, width: 100, height: 180,),
Container(color: Colors.black, width: 60, height: 80,),
Container(color: Colors.green, width: 60, height: 80,),
],
);
(a)Row示例
(b)Column示例
图4 Row与Column示例
可以看到单纯使用Row和Column控件在子Widget的尺寸较小时无法将容器填满视觉样式比较难看。对于这样的场景我们可以通过Expanded控件来制定分配规则填满容器的剩余空间。
比如我们希望Row组件或Column组件中的绿色容器与黄色容器均分剩下的空间于是就可以设置它们的弹性系数参数flex都为1这两个Expanded会按照其flex的比例即11来分割剩余的Row横向Column纵向空间
Row(
children: <Widget>[
Expanded(flex: 1, child: Container(color: Colors.yellow, height: 60)), //设置了flex=1因此宽度由Expanded来分配
Container(color: Colors.red, width: 100, height: 180,),
Container(color: Colors.black, width: 60, height: 80,),
Expanded(flex: 1, child: Container(color: Colors.green,height: 60),)/设置了flex=1因此宽度由Expanded来分配
],
);
图5 Expanded控件示例
于Row与Column而言Flutter提供了依据坐标轴的布局对齐行为即根据布局方向划分出主轴和纵轴主轴表示容器依次摆放子Widget的方向纵轴则是与主轴垂直的另一个方向。
图6 Row和Column控件的主轴与纵轴
我们可以根据主轴与纵轴设置子Widget在这两个方向上的对齐规则mainAxisAlignment与crossAxisAlignment。比如主轴方向start表示靠左对齐、center表示横向居中对齐、end表示靠右对齐、spaceEvenly表示按固定间距对齐而纵轴方向start则表示靠上对齐、center表示纵向居中对齐、end表示靠下对齐。
下图展示了在Row中设置不同方向的对齐规则后的呈现效果
图7 Row的主轴对齐方式
图8 Row的纵轴对齐方式
Column的对齐方式也是类似的我就不再过多展开了。
这里需要注意的是对于主轴而言Flutter默认是让父容器决定其长度即尽可能大类似Android中的match_parent。
在上面的例子中Row的宽度为屏幕宽度Column的高度为屏幕高度。主轴长度大于所有子Widget的总长度意味着容器在主轴方向的空间比子Widget要大这也是我们能通过主轴对齐方式设置子Widget布局效果的原因。
如果想让容器与子Widget在主轴上完全匹配我们可以通过设置Row的mainAxisSize参数为MainAxisSize.min由所有子Widget来决定主轴方向的容器长度即主轴方向的长度尽可能小类似Android中的wrap_content
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly, //由于容器与子Widget一样宽因此这行设置排列间距的代码并未起作用
mainAxisSize: MainAxisSize.min, //让容器宽度与所有子Widget的宽度一致
children: <Widget>[
Container(color: Colors.yellow, width: 60, height: 80,),
Container(color: Colors.red, width: 100, height: 180,),
Container(color: Colors.black, width: 60, height: 80,),
Container(color: Colors.green, width: 60, height: 80,),
],
)
图9 Row 的主轴大小
可以看到我们设置了主轴大小为MainAxisSize.min之后Row的宽度变得和其子Widget一样大因此再设置主轴的对齐方式也就不起作用了。
层叠Widget布局Stack与Positioned
有些时候我们需要让一个控件叠加在另一个控件的上面比如在一张图片上放置一段文字又或者是在图片的某个区域放置一个按钮。这时候我们就需要用到层叠布局容器Stack了。
Stack容器与前端中的绝对定位、Android中的Frame布局非常类似子Widget之间允许叠加还可以根据父容器上、下、左、右四个角的位置来确定自己的位置。
Stack提供了层叠布局的容器而Positioned则提供了设置子Widget位置的能力。接下来我们就通过一个例子来看一下Stack和Positioned的具体用法吧。
在这个例子中我先在Stack中放置了一块300_300的黄色画布随后在(18,18)处放置了一个50_50的绿色控件然后在(18,70)处放置了一个文本控件。
Stack(
children: <Widget>[
Container(color: Colors.yellow, width: 300, height: 300),//黄色容器
Positioned(
left: 18.0,
top: 18.0,
child: Container(color: Colors.green, width: 50, height: 50),//叠加在黄色容器之上的绿色控件
),
Positioned(
left: 18.0,
top:70.0,
child: Text("Stack提供了层叠布局的容器"),//叠加在黄色容器之上的文本
)
],
)
试着运行一下可以看到这三个子Widget都按照我们预定的规则叠加在一起了。
图10 Stack与Positioned容器示例
Stack控件允许其子Widget按照创建的先后顺序进行层叠摆放而Positioned控件则用来控制这些子Widget的摆放位置。需要注意的是Positioned控件只能在Stack中使用在其他容器中使用会报错。
总结
Flutter的布局容器强大而丰富可以将小型、单用途的基本视觉元素快速封装成控件。今天我选取了Flutter中最具代表性也最常用的几类布局Widget与你介绍了构建一个界面精美的App所需要的布局概念。
接下来,我们简单回顾一下今天的内容,以便加深理解与记忆:
首先我们认识了单子容器Container、Padding与Center。其中Container内部提供了间距、背景样式等基础属性为子Widget的摆放方式及展现样式都提供了定制能力。而Padding与Center提供的功能则正如其名一样简洁就是对齐与居中。
然后我们深入学习了多子Widget布局中的Row和Column各子Widget间对齐的规则以及容器自身扩充的规则以及如何通过Expanded控件使用容器内部的剩余空间
最后我们学习了层叠布局Stack以及与之搭配使用的定位子Widget位置的Positioned容器你可以通过它们实现多个控件堆放的布局效果。
通过今天的文章相信你已经对如何搭建App的界面有了足够的知识储备所以在下一篇文章中我会通过一些实际的例子带你认识在Flutter中如何通过这些基本控件与布局规则实现好看的界面。
思考题
最后,我给你留下一道思考题吧。
Row与Column自身的大小是如何决定的当它们嵌套时又会出现怎样的情况呢
欢迎你在评论区给我留言分享你的观点,我会在下一篇文章中等待你!感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。

View File

@@ -0,0 +1,276 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
15 组合与自绘我该选用何种方式自定义Widget
你好,我是陈航。
在上一次分享中我们认识了Flutter中最常用也最经典的布局Widget即单子容器Container、多子容器Row/Column以及层叠容器Stack与Positioned也学习了这些不同容器之间的摆放子Widget的布局规则我们可以通过它们来实现子控件的对齐、嵌套、层叠等它们也是构建一个界面精美的App所必须的布局概念。
在实际开发中我们会经常遇到一些复杂的UI需求往往无法通过使用Flutter的基本Widget通过设置其属性参数来满足。这个时候我们就需要针对特定的场景自定义Widget了。
在Flutter中自定义Widget与其他平台类似可以使用基本Widget组装成一个高级别的Widget也可以自己在画板上根据特殊需求来画界面。
接下来我会分别与你介绍组合和自绘这两种自定义Widget的方式。
组装
使用组合的方式自定义Widget即通过我们之前介绍的布局方式摆放项目所需要的基础Widget并在控件内部设置这些基础Widget的样式从而组合成一个更高级的控件。
这种方式对外暴露的接口比较少减少了上层使用成本但也因此增强了控件的复用性。在Flutter中组合的思想始终贯穿在框架设计之中这也是Flutter提供了如此丰富的控件库的原因之一。
比如在新闻类应用中我们经常需要将新闻Icon、标题、简介与日期组合成一个单独的控件作为一个整体去响应用户的点击事件。面对这类需求我们可以把现有的Image、Text及各类布局组合成一个更高级的新闻Item控件对外暴露设置model和点击回调的属性即可。
接下来,我通过一个例子为你说明如何通过组装去自定义控件。
下图是App Store的升级项UI示意图图里的每一项都有应用Icon、名称、更新日期、更新简介、应用版本、应用大小以及更新/打开按钮。可以看到这里面的UI元素还是相对较多的现在我们希望将升级项UI封装成一个单独的控件节省使用成本以及后续的维护成本。
图1 App Store 升级项UI
在分析这个升级项UI的整体结构之前我们先定义一个数据结构UpdateItemModel来存储升级信息。在这里为了方便讨论我把所有的属性都定义为了字符串类型你在实际使用中可以根据需要将属性定义得更规范比如将appDate定义为DateTime类型
class UpdateItemModel {
String appIcon;//App图标
String appName;//App名称
String appSize;//App大小
String appDate;//App更新日期
String appDescription;//App更新文案
String appVersion;//App版本
//构造函数语法糖,为属性赋值
UpdateItemModel({this.appIcon, this.appName, this.appSize, this.appDate, this.appDescription, this.appVersion});
}
接下来我以Google Map为例和你一起分析下这个升级项UI的整体结构。
按照子Widget的摆放方向布局方式只有水平和垂直两种因此我们也按照这两个维度对UI结构进行拆解。
按垂直方向我们用绿色的框把这个UI拆解为上半部分与下半部分如图2所示。下半部分比较简单是两个文本控件的组合上半部分稍微复杂一点我们先将其包装为一个水平布局的Row控件。
接下来,我们再一起看看水平方向应该如何布局。
图2 升级项UI整体结构示意图
我们先把升级项的上半部分拆解成对应的UI元素
左边的应用图标拆解为Image
右边的按钮拆解为FlatButton
中间部分是两个文本在垂直方向上的组合因此拆解为ColumnColumn内部则是两个Text。
拆解示意图,如下所示:
图3 上半部分UI结构示意图
通过与拆解前的UI对比你就会发现还有3个问题待解决即控件间的边距如何设置、中间部分的伸缩截断规则又是怎样、图片圆角怎么实现。接下来我们分别来看看。
Image、FlatButton以及Column这三个控件与父容器Row之间存在一定的间距因此我们还需要在最左边的Image与最右边的FlatButton上包装一层Padding用以留白填充。
另一方面,考虑到需要适配不同尺寸的屏幕,中间部分的两个文本应该是变长可伸缩的,但也不能无限制地伸缩,太长了还是需要截断的,否则就会挤压到右边按钮的固定空间了。
因此我们需要在Column的外层用Expanded控件再包装一层让Image与FlatButton之间的空间全留给Column。不过通常情况下这两个文本并不能完全填满中间的空间因此我们还需要设置对齐格式按照垂直方向上居中水平方向上居左的方式排列。
最后一项需要注意的是升级项UI的App Icon是圆角的但普通的Image并不支持圆角。这时我们可以使用ClipRRect控件来解决这个问题。ClipRRect可以将其子Widget按照圆角矩形的规则进行裁剪所以用ClipRRect将Image包装起来就可以实现图片圆角的功能了。
下面的代码,就是控件上半部分的关键代码:
Widget buildTopRow(BuildContext context) {
return Row(//Row控件用来水平摆放子Widget
children: <Widget>[
Padding(//Paddng控件用来设置Image控件边距
padding: EdgeInsets.all(10),//上下左右边距均为10
child: ClipRRect(//圆角矩形裁剪控件
borderRadius: BorderRadius.circular(8.0),//圆角半径为8
child: Image.asset(model.appIcon, width: 80,height:80)图片控件//
)
),
Expanded(//Expanded控件用来拉伸中间区域
child: Column(//Column控件用来垂直摆放子Widget
mainAxisAlignment: MainAxisAlignment.center,//垂直方向居中对齐
crossAxisAlignment: CrossAxisAlignment.start,//水平方向居左对齐
children: <Widget>[
Text(model.appName,maxLines: 1),//App名字
Text(model.appDate,maxLines: 1),//App更新日期
],
),
),
Padding(//Paddng控件用来设置Widget间边距
padding: EdgeInsets.fromLTRB(0,0,10,0),//右边距为10其余均为0
child: FlatButton(//按钮控件
child: Text("OPEN"),
onPressed: onPressed,//点击回调
)
)
]);
}
升级项UI的下半部分比较简单是两个文本控件的组合。与上半部分的拆解类似我们用一个Column控件将它俩装起来如图4所示
图4 下半部分UI结构示意图
与上半部分类似这两个文本与父容器之间存在些间距因此在Column的最外层还需要用Padding控件给包装起来设置父容器间距。
另一方面Column的两个文本控件间也存在间距因此我们仍然使用Padding控件将下面的文本包装起来单独设置这两个文本之间的间距。
同样地,通常情况下这两个文本并不能完全填满下部空间,因此我们还需要设置对齐格式,即按照水平方向上居左的方式对齐。
控件下半部分的关键代码如下所示:
Widget buildBottomRow(BuildContext context) {
return Padding(//Padding控件用来设置整体边距
padding: EdgeInsets.fromLTRB(15,0,15,0),//左边距和右边距为15
child: Column(//Column控件用来垂直摆放子Widget
crossAxisAlignment: CrossAxisAlignment.start,//水平方向距左对齐
children: <Widget>[
Text(model.appDescription),//更新文案
Padding(//Padding控件用来设置边距
padding: EdgeInsets.fromLTRB(0,10,0,0),//上边距为10
child: Text("${model.appVersion} • ${model.appSize} MB")
)
]
));
}
最后我们将上下两部分控件通过Column包装起来这次升级项UI定制就完成了
class UpdatedItem extends StatelessWidget {
final UpdatedItemModel model;//数据模型
//构造函数语法糖用来给model赋值
UpdatedItem({Key key,this.model, this.onPressed}) : super(key: key);
final VoidCallback onPressed;
@override
Widget build(BuildContext context) {
return Column(//用Column将上下两部分合体
children: <Widget>[
buildTopRow(context),//上半部分
buildBottomRow(context)//下半部分
]);
}
Widget buildBottomRow(BuildContext context) {...}
Widget buildTopRow(BuildContext context) {...}
}
试着运行一下,效果如下所示:
图5 升级项UI运行示例
搞定!
按照从上到下、从左到右去拆解UI的布局结构把复杂的UI分解成各个小UI元素在以组装的方式去自定义UI中非常有用请一定记住这样的拆解方法。
自绘
Flutter提供了非常丰富的控件和布局方式使得我们可以通过组合去构建一个新的视图。但对于一些不规则的视图用SDK提供的现有Widget组合可能无法实现比如饼图k线图等这个时候我们就需要自己用画笔去绘制了。
在原生iOS和Android开发中我们可以继承UIView/View在drawRect/onDraw方法里进行绘制操作。其实在Flutter中也有类似的方案那就是CustomPaint。
CustomPaint是用以承接自绘控件的容器并不负责真正的绘制。既然是绘制那就需要用到画布与画笔。
在Flutter中画布是Canvas画笔则是Paint而画成什么样子则由定义了绘制逻辑的CustomPainter来控制。将CustomPainter设置给容器CustomPaint的painter属性我们就完成了一个自绘控件的封装。
对于画笔Paint我们可以配置它的各种属性比如颜色、样式、粗细等而画布Canvas则提供了各种常见的绘制方法比如画线drawLine、画矩形drawRect、画点DrawPoint、画路径drawPath、画圆drawCircle、画圆弧drawArc等。
这样我们就可以在CustomPainter的paint方法里通过Canvas与Paint的配合实现定制化的绘制逻辑。
接下来,我们看一个例子。
在下面的代码中我们继承了CustomPainter在定义了绘制逻辑的paint方法中通过Canvas的drawArc方法用6种不同颜色的画笔依次画了6个1/6圆弧拼成了一张饼图。最后我们使用CustomPaint容器将painter进行封装就完成了饼图控件Cake的定义。
class WheelPainter extends CustomPainter {
// 设置画笔颜色
Paint getColoredPaint(Color color) {//根据颜色返回不同的画笔
Paint paint = Paint();//生成画笔
paint.color = color;//设置画笔颜色
return paint;
}
@override
void paint(Canvas canvas, Size size) {//绘制逻辑
double wheelSize = min(size.width,size.height)/2;//饼图的尺寸
double nbElem = 6;//分成6份
double radius = (2 * pi) / nbElem;//1/6圆
//包裹饼图这个圆形的矩形框
Rect boundingRect = Rect.fromCircle(center: Offset(wheelSize, wheelSize), radius: wheelSize);
// 每次画1/6个圆弧
canvas.drawArc(boundingRect, 0, radius, true, getColoredPaint(Colors.orange));
canvas.drawArc(boundingRect, radius, radius, true, getColoredPaint(Colors.black38));
canvas.drawArc(boundingRect, radius * 2, radius, true, getColoredPaint(Colors.green));
canvas.drawArc(boundingRect, radius * 3, radius, true, getColoredPaint(Colors.red));
canvas.drawArc(boundingRect, radius * 4, radius, true, getColoredPaint(Colors.blue));
canvas.drawArc(boundingRect, radius * 5, radius, true, getColoredPaint(Colors.pink));
}
// 判断是否需要重绘,这里我们简单的做下比较即可
@override
bool shouldRepaint(CustomPainter oldDelegate) => oldDelegate != this;
}
//将饼图包装成一个新的控件
class Cake extends StatelessWidget {
@override
Widget build(BuildContext context) {
return CustomPaint(
size: Size(200, 200),
painter: WheelPainter(),
);
}
}
试着运行一下,效果如下所示:
图6 自绘控件示例
可以看到使用CustomPainter进行自绘控件并不算复杂。这里我建议你试着用画笔和画布去实现更丰富的功能。
在实现视觉需求上自绘需要自己亲自处理绘制逻辑而组合则是通过子Widget的拼接来实现绘制意图。因此从渲染逻辑处理上自绘方案可以进行深度的渲染定制从而实现少数通过组合很难实现的需求比如饼图、k线图。不过当视觉效果需要调整时采用自绘的方案可能需要大量修改绘制代码而组合方案则相对简单只要布局拆分设计合理可以通过更换子Widget类型来轻松搞定。
总结
在面对一些复杂的UI视图时Flutter提供的单一功能类控件往往不能直接满足我们的需求。于是我们需要自定义Widget。Flutter提供了组装与自绘两种自定义Widget的方式来满足我们对视图的自定义需求。
以组装的方式构建UI我们需要将目标视图分解成各个UI小元素。通常我们可以按照从上到下、从左到右的布局顺序去对控件层次结构进行拆解将基本视觉元素封装到Column、Row中。对于有着固定间距的视觉元素我们可以通过Padding对其进行包装而对于大小伸缩可变的视觉元素我们可以通过Expanded控件让其填充父容器的空白区域。
而以自绘的方式定义控件则需要借助于CustomPaint容器以及最终承接真实绘制逻辑的CustomPainter。CustomPainter是绘制逻辑的封装在其paint方法中我们可以使用不同类型的画笔Paint利用画布Canvas提供的不同类型的绘制图形能力实现控件自定义绘制。
无论是组合还是自绘在自定义UI时有了目标视图整体印象后我们首先需要考虑的事情应该是如何将它化繁为简把视觉元素拆解细分变成自己立即可以着手去实现的一个小控件然后再思考如何将这些小控件串联起来。把大问题拆成小问题后实现目标也逐渐清晰落地方案就自然浮出水面了。
这其实就和我们学习新知识的过程是一样的,在对整体知识概念有了初步认知之后,也需要具备将复杂的知识化繁为简的能力:先理清楚其逻辑脉络,然后再把不懂的知识拆成小点,最后逐个攻破。
我把今天分享讲的两个例子放到了GitHub上你可以下载后在工程中实际运行并对照着今天的知识点进行学习体会在不同场景下组合和自绘这两种自定义Widget的具体使用方法。
思考题
最后,我给你留下两道作业题吧。
请扩展UpdatedItem控件使其能自动折叠过长的更新文案并能支持点击后展开的功能。
请扩展Cake控件使其能够根据传入的double数组最多10个元素中数值的大小定义饼图的圆弧大小。
欢迎你在评论区给我留言分享你的观点,我会在下一篇文章中等待你!感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。

View File

@@ -0,0 +1,200 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
16 从夜间模式说起如何定制不同风格的App主题
你好我是陈航。今天我和你分享的主题是从夜间模式说起如何定制不同风格的App主题。
在上一篇文章中我与你介绍了组装与自绘这两种自定义Widget的方式。对于组装我们按照从上到下、从左到右的布局顺序去分解目标视图将基本的Widget封装到Column、Row中从而合成更高级别的Widget而对于自绘我们则通过承载绘制逻辑的载体CustomPainter在其paint方法中使用画笔Paint与画布Canvas绘制不同风格、不同类型的图形从而实现基于自绘的自定义组件。
对于一个产品来说在业务早期其实更多的是处理基本功能有和无的问题工程师来负责实现功能PM负责功能好用不好用。在产品的基本功能已经完善做到了六七十分的时候再往上的如何做增长就需要运营来介入了。
在这其中如何通过用户分层去实现App的个性化是常见的增长运营手段而主题样式更换则是实现个性化中的一项重要技术手段。
比如微博、UC浏览器和电子书客户端都提供了对夜间模式的支持而淘宝、京东这样的电商类应用还会在特定的电商活动日自动更新主题样式就连现在的手机操作系统也提供了系统级切换展示样式的能力。
那么这些在应用内切换样式的功能是如何实现的呢在Flutter中在普通的应用上增加切换主题的功能又要做哪些事情呢这些问题我都会在今天的这篇文章中与你详细分享。
主题定制
主题又叫皮肤、配色一般由颜色、图片、字号、字体等组成我们可以把它看做是视觉效果在不同场景下的可视资源以及相应的配置集合。比如App的按钮无论在什么场景下都需要背景图片资源、字体颜色、字号大小等而所谓的主题切换只是在不同主题之间更新这些资源及配置集合而已。
因此在App开发中我们通常不关心资源和配置的视觉效果好不好看只要关心资源提供的视觉功能能不能用。比如对于图片类资源我们并不需要关心它渲染出来的实际效果只需要确定它渲染出来是一张固定宽高尺寸的区域不影响页面布局能把业务流程跑通即可。
视觉效果是易变的,我们将这些变化的部分抽离出来,把提供不同视觉效果的资源和配置按照主题进行归类,整合到一个统一的中间层去管理,这样我们就能实现主题的管理和切换了。
在iOS中我们通常会将主题的配置信息预先写到plist文件中通过一个单例来控制App应该使用哪种配置而Android的配置信息则写入各个style属性值的xml中通过activity的setTheme进行切换前端的处理方式也类似简单更换css就可以实现多套主题/配色之间的切换。
Flutter也提供了类似的能力由ThemeData来统一管理主题的配置信息。
ThemeData涵盖了Material Design规范的可自定义部分样式比如应用明暗模式brightness、应用主色调primaryColor、应用次级色调accentColor、文本字体fontFamily、输入框光标颜色cursorColor等。如果你想深入了解ThemeData的其他API参数可以参考官方文档ThemeData。
通过ThemeData来自定义应用主题我们可以实现App全局范围或是Widget局部范围的样式切换。接下来我便分别与你讲述这两种范围的主题切换。
全局统一的视觉风格定制
在Flutter中应用程序类MaterialApp的初始化方法为我们提供了设置主题的能力。我们可以通过参数theme选择改变App的主题色、字体等设置界面在MaterialApp下的展示样式。
以下代码演示了如何设置App全局范围主题。在这段代码中我们设置了App的明暗模式brightness为暗色、主色调为青色
MaterialApp(
title: 'Flutter Demo',//标题
theme: ThemeData(//设置主题
brightness: Brightness.dark,//明暗模式为暗色
primaryColor: Colors.cyan,//主色调为青色
),
home: MyHomePage(title: 'Flutter Demo Home Page'),
);
试着运行一下,效果如下:
图1 Flutter全局模式主题
可以看到虽然我们只修改了主色调和明暗模式两个参数但按钮、文字颜色都随之调整了。这是因为默认情况下ThemeData中很多其他次级视觉属性都会受到主色调与明暗模式的影响。如果我们想要精确控制它们的展示样式需要再细化一下主题配置。
下面的例子中我们将icon的颜色调整为黄色文字颜色调整为红色按钮颜色调整为黑色
MaterialApp(
title: 'Flutter Demo',//标题
theme: ThemeData(//设置主题
brightness: Brightness.dark,//设置明暗模式为暗色
accentColor: Colors.black,//(按钮Widget前景色为黑色
primaryColor: Colors.cyan,//主色调为青色
iconTheme:IconThemeData(color: Colors.yellow),//设置icon主题色为黄色
textTheme: TextTheme(body1: TextStyle(color: Colors.red))//设置文本颜色为红色
),
home: MyHomePage(title: 'Flutter Demo Home Page'),
);
运行一下,可以看到图标、文字、按钮的颜色都随之更改了。
图2 Flutter全局模式主题示例2
局部独立的视觉风格定制
为整个App提供统一的视觉呈现效果固然很有必要但有时我们希望为某个页面、或是某个区块设置不同于App风格的展现样式。以主题切换功能为例我们希望为不同的主题提供不同的展示预览。
在Flutter中我们可以使用Theme来对App的主题进行局部覆盖。Theme是一个单子Widget容器与MaterialApp类似的我们可以通过设置其data属性对其子Widget进行样式定制
如果我们不想继承任何App全局的颜色或字体样式可以直接新建一个ThemeData实例依次设置对应的样式
而如果我们不想在局部重写所有的样式则可以继承App的主题使用copyWith方法只更新部分样式。
下面的代码演示了这两种方式的用法:
// 新建主题
Theme(
data: ThemeData(iconTheme: IconThemeData(color: Colors.red)),
child: Icon(Icons.favorite)
);
// 继承主题
Theme(
data: Theme.of(context).copyWith(iconTheme: IconThemeData(color: Colors.green)),
child: Icon(Icons.feedback)
);
图3 Theme局部主题更改示例
对于上述例子而言由于Theme的子Widget只有一个Icon组件因此这两种方式都可以实现覆盖全局主题从而更改Icon样式的需求。而像这样使用局部主题覆盖全局主题的方式在Flutter中是一种常见的自定义子Widget展示样式的方法。
除了定义Material Design规范中那些可自定义部分样式外主题的另一个重要用途是样式复用。
比如如果我们想为一段文字复用Materia Design规范中的title样式或是为某个子Widget的背景色复用App的主题色我们就可以通过Theme.of(context)方法,取出对应的属性,应用到这段文字的样式中。
Theme.of(context)方法将向上查找Widget树并返回Widget树中最近的主题Theme。如果Widget的父Widget们有一个单独的主题定义则使用该主题。如果不是那就使用App全局主题。
在下面的例子中我们创建了一个包装了一个Text组件的Container容器。在Text组件的样式定义中我们复用了全局的title样式而在Container的背景色定义中则复用了App的主题色
Container(
color: Theme.of(context).primaryColor,//容器背景色复用应用主题色
child: Text(
'Text with a background color',
style: Theme.of(context).textTheme.title,//Text组件文本样式复用应用文本样式
));
图4 主题复用示例
分平台主题定制
有时候为了满足不同平台的用户需求我们希望针对特定的平台设置不同的样式。比如在iOS平台上设置浅色主题在Android平台上设置深色主题。面对这样的需求我们可以根据defaultTargetPlatform来判断当前应用所运行的平台从而根据系统类型来设置对应的主题。
在下面的例子中我们为iOS与Android分别创建了两个主题。在MaterialApp的初始化方法中我们根据平台类型设置了不同的主题
// iOS浅色主题
final ThemeData kIOSTheme = ThemeData(
brightness: Brightness.light,//亮色主题
accentColor: Colors.white,//(按钮)Widget前景色为白色
primaryColor: Colors.blue,//主题色为蓝色
iconTheme:IconThemeData(color: Colors.grey),//icon主题为灰色
textTheme: TextTheme(body1: TextStyle(color: Colors.black))//文本主题为黑色
);
// Android深色主题
final ThemeData kAndroidTheme = ThemeData(
brightness: Brightness.dark,//深色主题
accentColor: Colors.black,//(按钮)Widget前景色为黑色
primaryColor: Colors.cyan,//主题色Wie青色
iconTheme:IconThemeData(color: Colors.blue),//icon主题色为蓝色
textTheme: TextTheme(body1: TextStyle(color: Colors.red))//文本主题色为红色
);
// 应用初始化
MaterialApp(
title: 'Flutter Demo',
theme: defaultTargetPlatform == TargetPlatform.iOS ? kIOSTheme : kAndroidTheme,//根据平台选择不同主题
home: MyHomePage(title: 'Flutter Demo Home Page'),
);
试着运行一下:
aiOS平台
bAndroid平台
图5 根据不同平台设置对应主题
当然除了主题之外你也可以用defaultTargetPlatform这个变量去实现一些其他需要判断平台的逻辑比如在界面上使用更符合Android或iOS设计风格的组件。
总结
好了,今天的分享就到这里。我们简单回顾一下今天的主要内容吧。
主题设置属于App开发的高级特性归根结底其实是提供了一种视觉资源与视觉配置的管理机制。与其他平台类似Flutter也提供了集中式管理主题的机制可以在遵循Material Design规范的ThemeData中定义那些可定制化的样式。
我们既可以通过设置MaterialApp全局主题实现应用整体视觉风格的统一也可以通过Theme单子Widget容器使用局部主题覆盖全局主题实现局部独立的视觉风格。
除此之外在自定义组件过程中我们还可以使用Theme.of方法取出主题对应的属性值从而实现多种组件在视觉风格上的复用。
最后面对常见的分平台设置主题场景我们可以根据defaultTargetPlatform来精确识别当前应用所处的系统从而配置对应的主题。
思考题
最后,我给你留下一个课后小作业吧。
在上一篇文章中我与你介绍了如何实现App Store升级项UI自定义组件布局。现在请在这个自定义Widget的基础上增加切换夜间模式的功能。
欢迎你在评论区给我留言分享你的观点,我会在下一篇文章中等待你!感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。

View File

@@ -0,0 +1,208 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
17 依赖管理图片、配置和字体在Flutter中怎么用
你好,我是陈航。
在上一篇文章中我与你介绍了Flutter的主题设置也就是将视觉资源与视觉配置进行集中管理的机制。
Flutter提供了遵循Material Design规范的ThemeData可以对样式进行定制化既可以初始化App时实现全局整体视觉风格统一也可以在使用单子Widget容器Theme实现局部主题的覆盖还可以在自定义组件时取出主题对应的属性值实现视觉风格的复用。
一个应用程序主要由两部分内容组成代码和资源。代码关注逻辑功能而如图片、字符串、字体、配置文件等资源则关注视觉功能。如果说上一次文章更多的是从逻辑层面分享应该如何管理资源的配置那今天的分享则会从物理存储入手与你介绍Flutter整体的资源管理机制。
资源外部化即把代码与资源分离是现代UI框架的主流设计理念。因为这样不仅有利于单独维护资源还可以对特定设备提供更准确的兼容性支持使得我们的应用程序可以自动根据实际运行环境来组织视觉功能适应不同的屏幕大小和密度等。
随着各类配置各异的终端设备越来越多资源管理也越来越重要。那么今天我们就先看看Flutter中的图片、配置和字体的管理机制吧。
资源管理
在移动开发中常见的资源类型包括JSON文件、配置文件、图标、图片以及字体文件等。它们都会被打包到App安装包中而App中的代码可以在运行时访问这些资源。
在Android、iOS平台中为了区分不同分辨率的手机设备图片和其他原始资源是区别对待的
iOS使用Images.xcassets来管理图片其他的资源直接拖进工程项目即可
Android的资源管理粒度则更为细致使用以drawable+分辨率命名的文件夹来分别存放不同分辨率的图片其他类型的资源也都有各自的存放方式比如布局文件放在res/layout目录下资源描述文件放在res/values目录下原始文件放在assets目录下等。
而在Flutter中资源管理则简单得多资源assets可以是任意类型的文件比如JSON配置文件或是字体文件等而不仅仅是图片。
而关于资源的存放位置Flutter并没有像Android那样预先定义资源的目录结构所以我们可以把资源存放在项目中的任意目录下只需要使用根目录下的pubspec.yaml文件对这些资源的所在位置进行显式声明就可以了以帮助Flutter识别出这些资源。
而在指定路径名的过程中,我们既可以对每一个文件进行挨个指定,也可以采用子目录批量指定的方式。
接下来,我以一个示例和你说明挨个指定和批量指定这两种方式的区别。
如下所示我们将资源放入assets目录下其中两张图片background.jpg、loading.gif与JSON文件result.json在assets根目录而另一张图片food_icon.jpg则在assets的子目录icons下。
assets
├── background.jpg
├── icons
│ └── food_icon.jpg
├── loading.gif
└── result.json
对于上述资源文件存放的目录结构,以下代码分别演示了挨个指定和子目录批量指定这两种方式:通过单个文件声明的,我们需要完整展开资源的相对路径;而对于目录批量指定的方式,只需要在目录名后加路径分隔符就可以了:
flutter:
assets:
- assets/background.jpg #挨个指定资源路径
- assets/loading.gif #挨个指定资源路径
- assets/result.json #挨个指定资源路径
- assets/icons/ #子目录批量指定
- assets/ #根目录也是可以批量指定的
需要注意的是,目录批量指定并不递归,只有在该目录下的文件才可以被包括,如果下面还有子目录的话,需要单独声明子目录下的文件。
完成资源的声明后我们就可以在代码中访问它们了。在Flutter中对不同类型的资源文件处理方式略有差异接下来我将分别与你介绍。
对于图片类资源的访问我们可以使用Image.asset构造方法完成图片资源的加载及显示在第12篇文章“经典控件文本、图片和按钮在Flutter中怎么用”中你应该已经了解了具体的用法这里我就不再赘述了。
而对于其他资源文件的加载我们可以通过Flutter应用的主资源Bundle对象rootBundle来直接访问。
对于字符串文件资源我们使用loadString方法而对于二进制文件资源则通过load方法。
以下代码演示了获取result.json文件并将其打印的过程
rootBundle.loadString('assets/result.json').then((msg)=>print(msg));
与Android、iOS开发类似Flutter也遵循了基于像素密度的管理方式如1.0x、2.0x、3.0x或其他任意倍数Flutter可以根据当前设备分辨率加载最接近设备像素比例的图片资源。而为了让Flutter更好地识别我们的资源目录应该将1.0x、2.0x与3.0x的图片资源分开管理。
以background.jpg图片为例这张图片位于assets目录下。如果想让Flutter适配不同的分辨率我们需要将其他分辨率的图片放到对应的分辨率子目录中如下所示
assets
├── background.jpg //1.0x图
├── 2.0x
│ └── background.jpg //2.0x图
└── 3.0x
└── background.jpg //3.0x图
而在pubspec.yaml文件声明这个图片资源时仅声明1.0x图资源即可:
flutter:
assets:
- assets/background.jpg #1.0x图资源
1.0x分辨率的图片是资源标识符而Flutter则会根据实际屏幕像素比例加载相应分辨率的图片。这时如果主资源缺少某个分辨率资源Flutter会在剩余的分辨率资源中选择最接近的分辨率资源去加载。
举个例子如果我们的App包只包括了2.0x资源对于屏幕像素比为3.0的设备则会自动降级读取2.0x的资源。不过需要注意的是即使我们的App包没有包含1.0x资源我们仍然需要像上面那样在pubspec.yaml中将它显示地声明出来因为它是资源的标识符。
字体则是另外一类较为常用的资源。手机操作系统一般只有默认的几种字体,在大部分情况下可以满足我们的正常需求。但是,在一些特殊的情况下,我们可能需要使用自定义字体来提升视觉体验。
在Flutter中使用自定义字体同样需要在pubspec.yaml文件中提前声明。需要注意的是字体实际上是字符图形的映射。所以除了正常字体文件外如果你的应用需要支持粗体和斜体同样也需要有对应的粗体和斜体字体文件。
在将RobotoCondensed字体摆放至assets目录下的fonts子目录后下面的代码演示了如何将支持斜体与粗体的RobotoCondensed字体加到我们的应用中
fonts:
- family: RobotoCondensed #字体名字
fonts:
- asset: assets/fonts/RobotoCondensed-Regular.ttf #普通字体
- asset: assets/fonts/RobotoCondensed-Italic.ttf
style: italic #斜体
- asset: assets/fonts/RobotoCondensed-Bold.ttf
weight: 700 #粗体
这些声明其实都对应着TextStyle中的样式属性如字体名family对应着 fontFamily属性、斜体italic与正常normal对应着style属性、字体粗细weight对应着fontWeight属性等。在使用时我们只需要在TextStyle中指定对应的字体即可
Text("This is RobotoCondensed", style: TextStyle(
fontFamily: 'RobotoCondensed',//普通字体
));
Text("This is RobotoCondensed", style: TextStyle(
fontFamily: 'RobotoCondensed',
fontWeight: FontWeight.w700, //粗体
));
Text("This is RobotoCondensed italic", style: TextStyle(
fontFamily: 'RobotoCondensed',
fontStyle: FontStyle.italic, //斜体
));
图1 自定义字体
原生平台的资源设置
在前面的第5篇文章“从标准模板入手体会Flutter代码是如何运行在原生系统上的”中我与你介绍了Flutter应用实际上最终会以原生工程的方式打包运行在Android和iOS平台上因此Flutter启动时依赖的是原生Android和iOS的运行环境。
上面介绍的资源管理机制其实都是在Flutter应用内的而在Flutter框架运行之前我们是没有办法访问这些资源的。Flutter需要原生环境才能运行但是有些资源我们需要在Flutter框架运行之前提前使用比如要给应用添加图标或是希望在等待Flutter框架启动时添加启动图我们就需要在对应的原生工程中完成相应的配置所以下面介绍的操作步骤都是在原生系统中完成的。
我们先看一下如何更换App启动图标。
对于Android平台启动图标位于根目录android/app/src/main/res/mipmap下。我们只需要遵守对应的像素密度标准保留原始图标名称将图标更换为目标资源即可
图2 更换Android启动图标
对于iOS平台启动图位于根目录ios/Runner/Assets.xcassets/AppIcon.appiconset下。同样地我们只需要遵守对应的像素密度标准将其替换为目标资源并保留原始图标名称即可
图3 更换iOS启动图标
然后。我们来看一下如何更换启动图。
对于Android平台启动图位于根目录android/app/src/main/res/drawable下是一个名为launch_background的XML界面描述文件。
图4 修改Android启动图描述文件
我们可以在这个界面描述文件中自定义启动界面,也可以换一张启动图片。在下面的例子中,我们更换了一张居中显示的启动图片:
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<!-- 白色背景 -->
<item android:drawable="@android:color/white" />
<item>
<!-- 内嵌一张居中展示的图片 -->
<bitmap
android:gravity="center"
android:src="@mipmap/bitmap_launcher" />
</item>
</layer-list>
而对于iOS平台启动图位于根目录ios/Runner/Assets.xcassets/LaunchImage.imageset下。我们保留原始启动图名称将图片依次按照对应像素密度标准更换为目标启动图即可。
图5 更换iOS启动图
总结
好了,今天的分享就到这里。我们简单回顾一下今天的内容。
将代码与资源分离不仅有助于单独维护资源还可以更精确地对特定设备提供兼容性支持。在Flutter中资源可以是任意类型的文件可以被放到任意目录下但需要通过pubspec.yaml文件将它们的路径进行统一地显式声明。
Flutter对图片提供了基于像素密度的管理方式我们需要将1.0x2.0x与3.0x的资源分开管理但只需要在pubspec.yaml中声明一次。如果应用中缺少对于高像素密度设备的资源支持Flutter会进行自动降级。
对于字体这种基于字符图形映射的资源文件Flutter提供了精细的管理机制可以支持除了正常字体外还支持粗体、斜体等样式。
最后由于Flutter启动时依赖原生系统运行环境因此我们还需要去原生工程中设置相应的App启动图标和启动图。
思考题
最后,我给你留下两道思考题吧。
如果我们只提供了1.0x与2.0x的资源图片对于像素密度为3.0的设备Flutter会自动降级到哪套资源
如果我们只提供了2.0x的资源图片对于像素密度为1.0的设备Flutter会如何处理呢
你可以参考原生平台的经验,在模拟器或真机上实验一下。
欢迎你在评论区给我留言分享你的观点,我会在下一篇文章中等待你!感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。

View File

@@ -0,0 +1,160 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
18 依赖管理第三方组件库在Flutter中要如何管理
你好,我是陈航。
在上一篇文章中我与你介绍了Flutter工程的资源管理机制。在Flutter中资源采用先声明后使用的机制在pubspec.yaml显式地声明资源路径后才可以使用。
对于图片Flutter基于像素密度设立不同分辨率的目录分开管理但只需要在pubspec.yaml声明一次而字体则基于样式支持除了正常字体还可以支持粗体、斜体等样式。最后由于Flutter需要原生运行环境因此对于在其启动之前所需的启动图和图标这两类特殊资源我们还需要分别去原生工程中进行相应的设置。
其实除了管理这些资源外pubspec.yaml更为重要的作用是管理Flutter工程代码的依赖比如第三方库、Dart运行环境、Flutter SDK版本都可以通过它来进行统一管理。所以pubspec.yaml与iOS中的Podfile、Android中的build.gradle、前端的package.json在功能上是类似的。
那么今天这篇文章我就主要与你分享在Flutter中如何通过配置文件来管理工程代码依赖。
Pub
Dart提供了包管理工具Pub用来管理代码和资源。从本质上说package实际上就是一个包含了pubspec.yaml文件的目录其内部可以包含代码、资源、脚本、测试和文档等文件。包中包含了需要被外部依赖的功能抽象也可以依赖其他包。
与Android中的JCenter/Maven、iOS中的CocoaPods、前端中的npm库类似Dart提供了官方的包仓库Pub。通过Pub我们可以很方便地查找到有用的第三方包。
当然这并不意味着我们可以简单地拿别人的库来拼凑成一个应用程序。Dart提供包管理工具Pub的真正目的是让你能够找到真正好用的、经过线上大量验证的库复用他人的成果来缩短开发周期提升软件质量。
在Dart中库和应用都属于包。pubspec.yaml是包的配置文件包含了包的元数据比如包的名称和版本、运行环境也就是Dart SDK与Fluter SDK版本、外部依赖、内部配置比如资源管理
在下面的例子中我们声明了一个flutter_app_example的应用配置文件其版本为1.0Dart运行环境支持2.1至3.0之间依赖flutter和cupertino_icon
name: flutter_app_example #应用名称
description: A new Flutter application. #应用描述
version: 1.0.0
#Dart运行环境区间
environment:
sdk: ">=2.1.0 <3.0.0"
#Flutter依赖库
dependencies:
flutter:
sdk: flutter
cupertino_icons: ">0.1.1"
运行环境和依赖库cupertino_icons冒号后面的部分是版本约束信息由一组空格分隔的版本描述组成可以支持指定版本、版本号区间以及任意版本这三种版本约束方式。比如上面的例子中cupertino_icons引用了大于0.1.1的版本。
需要注意的是,由于元数据与名称使用空格分隔,因此版本号中不能出现空格;同时又由于大于符号“>”也是YAML语法中的折叠换行符号因此在指定版本范围的时候必须使用引号 比如”>=2.1.0 < 3.0.0”。
对于包我们通常是指定版本区间而很少直接指定特定版本因为包升级变化很频繁如果有其他的包直接或间接依赖这个包的其他版本时就会经常发生冲突
而对于运行环境如果是团队多人协作的工程建议将Dart与Flutter的SDK环境写死统一团队的开发环境避免因为跨SDK版本出现的API差异进而导致工程问题
比如在上面的示例中我们可以将Dart SDK写死为2.3.0Flutter SDK写死为1.2.1
environment:
sdk: 2.3.0
flutter: 1.2.1
基于版本的方式引用第三方包需要在其Pub上进行公开发布我们可以访问https://pub.dev/来获取可用的第三方包而对于不对外公开发布或者目前处于开发调试阶段的包我们需要设置数据源使用本地路径或Git地址的方式进行包声明
在下面的例子中我们分别以路径依赖以及Git依赖的方式声明了package1和package2这两个包
dependencies:
package1:
path: ../package1/ #路径依赖
date_format:
git:
url: https://github.com/xxx/package2.git #git依赖
在开发应用时我们可以不写明具体的版本号而是以区间的方式声明包的依赖但对于一个程序而言其运行时具体引用哪个版本的依赖包必须要确定下来因此除了管理第三方依赖包管理工具Pub的另一个职责是找出一组同时满足每个包版本约束的包版本包版本一旦确定接下来就是下载对应版本的包了
对于dependencies中的不同数据源Dart会使用不同的方式进行管理最终会将远端的包全部下载到本地比如对于Git声明依赖的方式Pub会clone Git仓库对于版本号的方式Pub则会从pub.dartlang.org下载包如果包还有其他的依赖包比如package1包还依赖package3包Pub也会一并下载
然后在完成了所有依赖包的下载后Pub会在应用的根目录下创建.packages文件将依赖的包名与系统缓存中的包文件路径进行映射方便后续维护
最后Pub会自动创建pubspec.lock文件pubspec.lock文件的作用类似iOS的Podfile.lock或前端的package-lock.json文件用于记录当前状态下实际安装的各个直接依赖间接依赖的包的具体来源和版本号
比较活跃的第三方包的升级通常比较频繁因此对于多人协作的Flutter应用来说我们需要把pubspec.lock文件也一并提交到代码版本管理中这样团队中的所有人在使用这个应用时安装的所有依赖都是完全一样的以避免出现库函数找不到或者其他的依赖错误
除了提供功能和代码维度的依赖之外包还可以提供资源的依赖在依赖包中的pubspec.yaml文件已经声明了同样资源的情况下为节省应用程序安装包大小我们需要复用依赖包中的资源
在下面的例子中我们的应用程序依赖了一个名为package4的包而它的目录结构是这样的
pubspec.yaml
└──assets
├──2.0x
└── placeholder.png
└──3.0x
└── placeholder.png
其中placeholder.png是可复用资源因此在应用程序中我们可以通过Image和AssetImage提供的package参数根据设备实际分辨率去加载图像
Image.asset('assets/placeholder.png', package: 'package4');
AssetImage('assets/placeholder.png', package: 'package4');
例子
例子
接下来我们通过一个日期格式化的例子来演示如何使用第三方库
在Flutter中提供了表达日期的数据结构DateTime这个类拥有极大的表示范围可以表达1970-01-01 UTC时间后 100,000,000天内的任意时刻不过如果我们想要格式化显示日期和时间DateTime并没有提供非常方便的方法我们不得不自己取出年来定制显示方式
值得庆幸的是我们可以通过date_format这个第三方包来实现我们的诉求date_format提供了若干常用的日期格式化方法可以很方便地实现格式化日期的功能
首先我们在Pub上找到date_format这个包确定其使用说明
图1 date_format使用说明
date_format包最新的版本是1.0.6于是接下来我们把date_format添加到pubspec.yaml中
dependencies:
date_format: 1.0.6
随后IDEAndroid Studio监测到了配置文件的改动提醒我们进行安装包依赖更新于是我们点击Get dependencies下载date_format :
图2 下载安装包依赖
下载完成后我们就可以在工程中使用date_format来进行日期的格式化了
print(formatDate(DateTime.now(), [mm, '', dd, '', hh, ':', n]));
//输出2019年06月30日01:56
print(formatDate(DateTime.now(), [m, '月第', w, '']));
//输出6月第5周
总结
好了今天的分享就到这里我们简单回顾一下今天的内容
在Flutter中资源与工程代码依赖属于包管理范畴采用包的配置文件pubspec.yaml进行统一管理
我们可以通过pubspec.yaml设置包的元数据比如包的名称和版本)、运行环境比如Dart SDK与Fluter SDK版本)、外部依赖和内部配置
对于依赖的指定可以以区间的方式确定版本兼容范围也可以指定本地路径GitPub这三种不同的数据源包管理工具会找出同时满足每个依赖包版本约束的包版本然后依次下载并通过.packages文件建立下载缓存与包名的映射最后统一将当前状态下实际安装的各个包的具体来源和版本号记录至pubspec.lock文件
现代编程语言大都自带第依赖管理机制其核心功能是为工程中所有直接或间接依赖的代码库找到合适的版本但这并不容易就比如前端的依赖管理器npm的早期版本就曾因为不太合理的算法设计导致计算依赖耗时过长依赖文件夹也高速膨胀一度被开发者们戏称为黑洞”。而Dart使用的Pub依赖管理机制所采用的PubGrub算法则解决了这些问题因此被称为下一代版本依赖解决算法在2018年底被苹果公司吸纳成为Swift所采用的依赖管理器算法
当然如果你的工程里的依赖比较多并且依赖关系比较复杂即使再优秀的依赖解决算法也需要花费较长的时间才能计算出合适的依赖库版本如果我们想减少依赖管理器为你寻找代码库依赖版本所耗费的时间一个简单的做法就是从源头抓起在pubspec.yaml文件中固定那些依赖关系复杂的第三方库们及它们递归依赖的第三方库的版本号
思考题
最后我给你留下两道思考题吧
pubspec.yaml、.packages与pubspec.lock这三个文件在包管理中的具体作用是什么
.packages与pubspec.lock是否需要做代码版本管理呢为什么
欢迎你在评论区给我留言分享你的观点我会在下一篇文章中等待你感谢你的收听也欢迎你把这篇文章分享给更多的朋友一起阅读

View File

@@ -0,0 +1,218 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
19 用户交互事件该如何响应?
你好,我是陈航。今天,我和你分享的主题是,如何响应用户交互事件。
在前面两篇文章中我和你一起学习了Flutter依赖的包管理机制。在Flutter中包是包含了外部依赖的功能抽象。对于资源和工程代码依赖我们采用包配置文件pubspec.yaml进行统一管理。
通过前面几个章节的学习我们已经掌握了如何在Flutter中通过内部实现和外部依赖去实现自定义UI完善业务逻辑。但除了按钮和ListView这些动态的组件之外我们还无法响应用户交互行为。那今天的分享中我就着重与你讲述Flutter是如何监听和响应用户的手势操作的。
手势操作在Flutter中分为两类
第一类是原始的指针事件Pointer Event即原生开发中常见的触摸事件表示屏幕上触摸或鼠标、手写笔行为触发的位移行为
第二类则是手势识别Gesture Detector表示多个原始指针事件的组合操作如点击、双击、长按等是指针事件的语义化封装。
接下来,我们先看一下原始的指针事件。
指针事件
指针事件表示用户交互的原始触摸数据如手指接触屏幕PointerDownEvent、手指在屏幕上移动PointerMoveEvent、手指抬起PointerUpEvent以及触摸取消PointerCancelEvent这与原生系统的底层触摸事件抽象是一致的。
在手指接触屏幕触摸事件发起时Flutter会确定手指与屏幕发生接触的位置上究竟有哪些组件并将触摸事件交给最内层的组件去响应。与浏览器中的事件冒泡机制类似事件会从这个最内层的组件开始沿着组件树向根节点向上冒泡分发。
不过Flutter无法像浏览器冒泡那样取消或者停止事件进一步分发我们只能通过hitTestBehavior去调整组件在命中测试期内应该如何表现比如把触摸事件交给子组件或者交给其视图层级之下的组件去响应。
关于组件层面的原始指针事件的监听Flutter提供了Listener Widget可以监听其子Widget的原始指针事件。
现在我们一起看一个Listener的案例。我定义了一个宽度为300的红色正方形Container利用Listener监听其内部Down、Move及Up事件
Listener(
child: Container(
color: Colors.red,//背景色红色
width: 300,
height: 300,
),
onPointerDown: (event) => print("down $event"),//手势按下回调
onPointerMove: (event) => print("move $event"),//手势移动回调
onPointerUp: (event) => print("up $event"),//手势抬起回调
);
我们试着在红色正方形区域内进行触摸点击、移动、抬起可以看到Listener监听到了一系列原始指针事件并打印出了这些事件的位置信息
I/flutter (13829): up PointerUpEvent(Offset(97.7, 287.7))
I/flutter (13829): down PointerDownEvent(Offset(150.8, 313.4))
I/flutter (13829): move PointerMoveEvent(Offset(152.0, 313.4))
I/flutter (13829): move PointerMoveEvent(Offset(154.6, 313.4))
I/flutter (13829): up PointerUpEvent(Offset(157.1, 312.3))
手势识别
使用Listener可以直接监听指针事件。不过指针事件毕竟太原始了如果我们想要获取更多的触摸事件细节比如判断用户是否正在拖拽控件直接使用指针事件的话就会非常复杂。
通常情况下响应用户交互行为的话我们会使用封装了手势语义操作的Gesture如点击onTap、双击onDoubleTap、长按onLongPress、拖拽onPanUpdate、缩放onScaleUpdate等。另外Gesture可以支持同时分发多个手势交互行为意味着我们可以通过Gesture同时监听多个事件。
Gesture是手势语义的抽象而如果我们想从组件层监听手势则需要使用GestureDetector。GestureDetector是一个处理各种高级用户触摸行为的Widget与Listener一样也是一个功能性组件。
接下来我们通过一个案例来看看GestureDetector的用法。
我定义了一个Stack层叠布局使用Positioned组件将1个红色的Container放置在左上角并同时监听点击、双击、长按和拖拽事件。在拖拽事件的回调方法中我们更新了Container的位置
//红色container坐标
double _top = 0.0;
double _left = 0.0;
Stack(//使用Stack组件去叠加视图便于直接控制视图坐标
children: <Widget>[
Positioned(
top: _top,
left: _left,
child: GestureDetector(//手势识别
child: Container(color: Colors.red,width: 50,height: 50),//红色子视图
onTap: ()=>print("Tap"),//点击回调
onDoubleTap: ()=>print("Double Tap"),//双击回调
onLongPress: ()=>print("Long Press"),//长按回调
onPanUpdate: (e) {//拖动回调
setState(() {
//更新位置
_left += e.delta.dx;
_top += e.delta.dy;
});
},
),
)
],
);
运行这段代码并查看控制台输出可以看到红色的Container除了可以响应我们的拖拽行为外还能够同时响应点击、双击、长按这些事件。
图1 GestureDetector示例
尽管在上面的例子中我们对一个Widget同时监听了多个手势事件但最终只会有一个手势能够得到本次事件的处理权。对于多个手势的识别Flutter引入了手势竞技场Arena的概念用来识别究竟哪个手势可以响应用户事件。手势竞技场会考虑用户触摸屏幕的时长、位移以及拖动方向来确定最终手势。
那手势竞技场具体是怎么实现的呢?
实际上GestureDetector内部对每一个手势都建立了一个工厂类Gesture Factory。而工厂类的内部会使用手势识别类GestureRecognizer来确定当前处理的手势。
而所有手势的工厂类都会被交给RawGestureDetector类以完成监测手势的大量工作使用Listener监听原始指针事件并在状态改变时把信息同步给所有的手势识别器然后这些手势会在竞技场决定最后由谁来响应用户事件。
有些时候我们可能会在应用中给多个视图注册同类型的手势监听器,比如微博的信息流列表中的微博,点击不同区域会有不同的响应:点击头像会进入用户个人主页,点击图片会进入查看大图页面,点击其他部分会进入微博详情页等。
像这样的手势识别发生在多个存在父子关系的视图时,手势竞技场会一并检查父视图和子视图的手势,并且通常最终会确认由子视图来响应事件。而这也是合乎常理的:从视觉效果上看,子视图的视图层级位于父视图之上,相当于对其进行了遮挡,因此从事件处理上看,子视图自然是事件响应的第一责任人。
在下面的示例中我定义了两个嵌套的Container容器分别加入了点击识别事件
GestureDetector(
onTap: () => print('Parent tapped'),//父视图的点击回调
child: Container(
color: Colors.pinkAccent,
child: Center(
child: GestureDetector(
onTap: () => print('Child tapped'),//子视图的点击回调
child: Container(
color: Colors.blueAccent,
width: 200.0,
height: 200.0,
),
),
),
),
);
运行这段代码然后在蓝色区域进行点击可以发现尽管父容器也监听了点击事件但Flutter只响应了子容器的点击事件。
I/flutter (16188): Child tapped
图2 父子嵌套GestureDetector示例
为了让父容器也能接收到手势我们需要同时使用RawGestureDetector和GestureFactory来改变竞技场决定由谁来响应用户事件的结果。
在此之前我们还需要自定义一个手势识别器让这个识别器在竞技场被PK失败时能够再把自己重新添加回来以便接下来还能继续去响应用户事件。
在下面的代码中我定义了一个继承自点击手势识别器TapGestureRecognizer的类并重写了其rejectGesture方法手动地把自己又复活了
class MultipleTapGestureRecognizer extends TapGestureRecognizer {
@override
void rejectGesture(int pointer) {
acceptGesture(pointer);
}
}
接下来我们需要将手势识别器和其工厂类传递给RawGestureDetector以便用户产生手势交互事件时能够立刻找到对应的识别方法。事实上RawGestureDetector的初始化函数所做的配置工作就是定义不同手势识别器和其工厂类的映射关系。
这里由于我们只需要处理点击事件所以只配置一个识别器即可。工厂类的初始化采用GestureRecognizerFactoryWithHandlers函数完成这个函数提供了手势识别对象创建以及对应的初始化入口。
在下面的代码中我们完成了自定义手势识别器的创建并设置了点击事件回调方法。需要注意的是由于我们只需要在父容器监听子容器的点击事件所以只需要将父容器用RawGestureDetector包装起来就可以了而子容器保持不变
RawGestureDetector(//自己构造父Widget的手势识别映射关系
gestures: {
//建立多手势识别器与手势识别工厂类的映射关系从而返回可以响应该手势的recognizer
MultipleTapGestureRecognizer: GestureRecognizerFactoryWithHandlers<
MultipleTapGestureRecognizer>(
() => MultipleTapGestureRecognizer(),
(MultipleTapGestureRecognizer instance) {
instance.onTap = () => print('parent tapped ');//点击回调
},
)
},
child: Container(
color: Colors.pinkAccent,
child: Center(
child: GestureDetector(//子视图可以继续使用GestureDetector
onTap: () => print('Child tapped'),
child: Container(...),
),
),
),
);
运行一下这段代码我们可以看到当点击蓝色容器时其父容器也收到了Tap事件。
I/flutter (16188): Child tapped
I/flutter (16188): parent tapped
总结
好了今天的分享就到这里。我们来简单回顾下Flutter是如何响应用户事件的。
首先我们了解了Flutter底层原始指针事件以及对应的监听方式和冒泡分发机制。
然后我们学习了封装了底层指针事件手势语义的Gesture了解了多个手势的识别方法以及其同时支持多个手势交互的能力。
最后我与你介绍了Gesture的事件处理机制在Flutter中尽管我们可以对一个Widget监听多个手势或是对多个Widget监听同一个手势但Flutter会使用手势竞技场来进行各个手势的PK以保证最终只会有一个手势能够响应用户行为。如果我们希望同时能有多个手势去响应用户行为需要去自定义手势利用RawGestureDetector和手势工厂类在竞技场PK失败时手动把它复活。
在处理多个手势识别场景,很容易出现手势冲突的问题。比如,当需要对图片进行点击、长按、旋转、缩放、拖动等操作的时候,如何识别用户当前是点击还是长按,是旋转还是缩放。如果想要精确地处理复杂交互手势,我们势必需要介入手势识别过程,解决异常。
不过需要注意的是冲突的只是手势的语义化识别过程原始指针事件是不会冲突的。所以在遇到复杂的冲突场景通过手势很难搞定时我们也可以通过Listener直接识别原始指针事件从而解决手势识别的冲突。
我把今天分享所涉及到的事件处理demo放到了GitHub上你可以下载下来自己运行进一步巩固学习效果。
思考题
最后,我给你留下两个思考题吧。
对于一个父容器中存在按钮FlatButton的界面在父容器使用GestureDetector监听了onTap事件的情况下如果我们点击按钮父容器的点击事件会被识别吗为什么
如果监听的是onDoubleTap事件在按钮上双击父容器的双击事件会被识别吗为什么
欢迎你在评论区给我留言分享你的观点,我会在下一篇文章中等待你!感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。

View File

@@ -0,0 +1,308 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
20 关于跨组件传递数据,你只需要记住这三招
你好,我是陈航。
在上一篇文章中我带你一起学习了在Flutter中如何响应用户交互事件手势。手势处理在Flutter中分为两种原始的指针事件处理和高级的手势识别。
其中指针事件以冒泡机制分发通过Listener完成监听而手势识别则通过Gesture处理。但需要注意的是虽然Flutter可以同时支持多个手势包括一个Widget监听多个手势或是多个Widget监听同一个手势但最终只会有一个Widget的手势能够响应用户行为。为了改变这一点我们需要自定义手势修改手势竞技场对于多手势优先级判断的默认行为。
除了需要响应外部的事件之外UI框架的另一个重要任务是处理好各个组件之间的数据同步关系。尤其对于Flutter这样大量依靠组合Widget的行为来实现用户界面的框架来说如何确保数据的改变能够映射到最终的视觉效果上就显得更为重要。所以在今天这篇文章中我就与你介绍在Flutter中如何进行跨组件数据传递。
在之前的分享中通过组合嵌套的方式利用数据对基础Widget的样式进行视觉属性定制我们已经实现了多种界面布局。所以你应该已经体会到了在Flutter中实现跨组件数据传递的标准方式是通过属性传值。
但是对于稍微复杂一点的、尤其视图层级比较深的UI样式一个属性可能需要跨越很多层才能传递给子组件这种传递方式就会导致中间很多并不需要这个属性的组件也需要接收其子Widget的数据不仅繁琐而且冗余。
所以对于数据的跨层传递Flutter还提供了三种方案InheritedWidget、Notification和EventBus。接下来我将依次为你讲解这三种方案。
InheritedWidget
InheritedWidget是Flutter中的一个功能型Widget适用于在Widget树中共享数据的场景。通过它我们可以高效地将数据在Widget树中进行跨层传递。
在前面的第16篇文章“从夜间模式说起如何定制不同风格的App主题”中我与你介绍了如何通过Theme去访问当前界面的样式风格从而进行样式复用的例子比如Theme.of(context).primaryColor。
Theme类是通过InheritedWidget实现的典型案例。在子Widget中通过Theme.of方法找到上层Theme的Widget获取到其属性的同时建立子Widget和上层父Widget的观察者关系当上层父Widget属性修改的时候子Widget也会触发更新。
接下来我就以Flutter工程模板中的计数器为例与你说明InheritedWidget的使用方法。
首先为了使用InheritedWidget我们定义了一个继承自它的新类CountContainer。
然后我们将计数器状态count属性放到CountContainer中并提供了一个of方法方便其子Widget在Widget树中找到它。
最后我们重写了updateShouldNotify方法这个方法会在Flutter判断InheritedWidget是否需要重建从而通知下层观察者组件更新数据时被调用到。在这里我们直接判断count是否相等即可。
class CountContainer extends InheritedWidget {
//方便其子Widget在Widget树中找到它
static CountContainer of(BuildContext context) => context.inheritFromWidgetOfExactType(CountContainer) as CountContainer;
final int count;
CountContainer({
Key key,
@required this.count,
@required Widget child,
}): super(key: key, child: child);
// 判断是否需要更新
@override
bool updateShouldNotify(CountContainer oldWidget) => count != oldWidget.count;
}
然后我们使用CountContainer作为根节点并用0初始化count。随后在其子Widget Counter中我们通过InheritedCountContainer.of方法找到它获取计数状态count并展示
class _MyHomePageState extends State<MyHomePage> {
@override
Widget build(BuildContext context) {
//将CountContainer作为根节点并使用0作为初始化count
return CountContainer(
count: 0,
child: Counter()
);
}
}
class Counter extends StatelessWidget {
@override
Widget build(BuildContext context) {
//获取InheritedWidget节点
CountContainer state = CountContainer.of(context);
return Scaffold(
appBar: AppBar(title: Text("InheritedWidget demo")),
body: Text(
'You have pushed the button this many times: ${state.count}',
),
);
}
运行一下,效果如下图所示:
图1 InheritedWidget使用方法
可以看到InheritedWidget的使用方法还是比较简单的无论Counter在CountContainer下层什么位置都能获取到其父Widget的计数属性count再也不用手动传递属性了。
不过InheritedWidget仅提供了数据读的能力如果我们想要修改它的数据则需要把它和StatefulWidget中的State配套使用。我们需要把InheritedWidget中的数据和相关的数据修改方法全部移到StatefulWidget中的State上而InheritedWidget只需要保留对它们的引用。
我们对上面的代码稍加修改删掉CountContainer中持有的count属性增加对数据持有者State以及数据修改方法的引用
class CountContainer extends InheritedWidget {
...
final _MyHomePageState model;//直接使用MyHomePage中的State获取数据
final Function() increment;
CountContainer({
Key key,
@required this.model,
@required this.increment,
@required Widget child,
}): super(key: key, child: child);
...
}
然后我们将count数据和其对应的修改方法放在了State中仍然使用CountContainer作为根节点完成了数据和修改方法的初始化。
在其子Widget Counter中我们还是通过InheritedCountContainer.of方法找到它将计数状态count与UI展示同步将按钮的点击事件与数据修改同步
class _MyHomePageState extends State<MyHomePage> {
int count = 0;
void _incrementCounter() => setState(() {count++;});//修改计数器
@override
Widget build(BuildContext context) {
return CountContainer(
model: this,//将自身作为model交给CountContainer
increment: _incrementCounter,//提供修改数据的方法
child:Counter()
);
}
}
class Counter extends StatelessWidget {
@override
Widget build(BuildContext context) {
//获取InheritedWidget节点
CountContainer state = CountContainer.of(context);
return Scaffold(
...
body: Text(
'You have pushed the button this many times: ${state.model.count}', //关联数据读方法
),
floatingActionButton: FloatingActionButton(onPressed: state.increment), //关联数据修改方法
);
}
}
运行一下可以看到我们已经实现InheritedWidget数据的读写了。
图2 InheritedWidget数据修改示例
Notification
Notification是Flutter中进行跨层数据共享的另一个重要的机制。如果说InheritedWidget的数据流动方式是从父Widget到子Widget逐层传递那Notificaiton则恰恰相反数据流动方式是从子Widget向上传递至父Widget。这样的数据传递机制适用于子Widget状态变更发送通知上报的场景。
在前面的第13篇文章“经典控件UITableView/ListView在Flutter中是什么”中我与你介绍了ScrollNotification的使用方法ListView在滚动时会分发通知我们可以在上层使用NotificationListener监听ScrollNotification根据其状态做出相应的处理。
自定义通知的监听与ScrollNotification并无不同而如果想要实现自定义通知我们首先需要继承Notification类。Notification类提供了dispatch方法可以让我们沿着context对应的Element节点树向上逐层发送通知。
接下来我们一起看一个具体的案例吧。在下面的代码中我们自定义了一个通知和子Widget。子Widget是一个按钮在点击时会发送通知
class CustomNotification extends Notification {
CustomNotification(this.msg);
final String msg;
}
//抽离出一个子Widget用来发通知
class CustomChild extends StatelessWidget {
@override
Widget build(BuildContext context) {
return RaisedButton(
//按钮点击时分发通知
onPressed: () => CustomNotification("Hi").dispatch(context),
child: Text("Fire Notification"),
);
}
}
而在子Widget的父Widget中我们监听了这个通知一旦收到通知就会触发界面刷新展示收到的通知信息
class _MyHomePageState extends State<MyHomePage> {
String _msg = "通知:";
@override
Widget build(BuildContext context) {
//监听通知
return NotificationListener<CustomNotification>(
onNotification: (notification) {
setState(() {_msg += notification.msg+" ";});//收到子Widget通知更新msg
},
child:Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[Text(_msg),CustomChild()],//将子Widget加入到视图树中
)
);
}
}
运行一下代码,可以看到,我们每次点击按钮之后,界面上都会出现最新的通知信息:
图3 自定义Notification
EventBus
无论是InheritedWidget还是Notificaiton它们的使用场景都需要依靠Widget树也就意味着只能在有父子关系的Widget之间进行数据共享。但是组件间数据传递还有一种常见场景这些组件间不存在父子关系。这时事件总线EventBus就登场了。
事件总线是在Flutter中实现跨组件通信的机制。它遵循发布/订阅模式允许订阅者订阅事件当发布者触发事件时订阅者和发布者之间可以通过事件进行交互。发布者和订阅者之间无需有父子关系甚至非Widget对象也可以发布/订阅。这些特点与其他平台的事件总线机制是类似的。
接下来我们通过一个跨页面通信的例子来看一下事件总线的具体使用方法。需要注意的是EventBus是一个第三方插件因此我们需要在pubspec.yaml文件中声明它
dependencies:
event_bus: 1.1.0
EventBus的使用方式灵活可以支持任意对象的传递。所以在这里我们传输数据的载体就选择了一个有字符串属性的自定义事件类CustomEvent
class CustomEvent {
String msg;
CustomEvent(this.msg);
}
然后我们定义了一个全局的eventBus对象并在第一个页面监听了CustomEvent事件一旦收到事件就会刷新UI。需要注意的是千万别忘了在State被销毁时清理掉事件注册否则你会发现State永远被EventBus持有着无法释放从而造成内存泄漏
//建立公共的event bus
EventBus eventBus = new EventBus();
//第一个页面
class _FirstScreenState extends State<FirstScreen> {
String msg = "通知:";
StreamSubscription subscription;
@override
initState() {
//监听CustomEvent事件刷新UI
subscription = eventBus.on<CustomEvent>().listen((event) {
setState(() {msg+= event.msg;});//更新msg
});
super.initState();
}
dispose() {
subscription.cancel();//State销毁时清理注册
super.dispose();
}
@override
Widget build(BuildContext context) {
return new Scaffold(
body:Text(msg),
...
);
}
}
最后我们在第二个页面以按钮点击回调的方式触发了CustomEvent事件
class SecondScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
return new Scaffold(
...
body: RaisedButton(
child: Text('Fire Event'),
// 触发CustomEvent事件
onPressed: ()=> eventBus.fire(CustomEvent("hello"))
),
);
}
}
运行一下,多点击几下第二个页面的按钮,然后返回查看第一个页面上的消息:
图4 EventBus示例
可以看到EventBus的使用方法还是比较简单的使用限制也相对最少。
这里我准备了一张表格把属性传值、InheritedWidget、Notification与EventBus这四种数据共享方式的特点和使用场景做了简单总结供你参考
图5 属性传值、InheritedWidget、Notification与EventBus数据传递方式对比
总结
好了今天的分享就到这里。我们来简单回顾下在Flutter中如何实现跨组件的数据共享。
首先我们认识了InheritedWidget。对于视图层级比较深的UI样式直接通过属性传值的方式会导致很多中间层增加冗余属性而使用InheritedWidget可以实现子Widget跨层共享父Widget的属性。需要注意的是InheritedWidget中的属性在子Widget中只能读如果有修改的场景我们需要把它和StatefulWidget中的State配套使用。
然后我们学习了Notification这种由下到上传递数据的跨层共享机制。我们可以使用NotificationListener在父Widget监听来自子Widget的事件。
最后我与你介绍了EventBus这种无需发布者与订阅者之间存在父子关系的数据同步机制。
我把今天分享所涉及到的三种跨组件的数据共享方式demo放到了GitHub你可以下载下来自己运行体会它们之间的共同点和差异。
思考题
最后,我来给你留下一个思考题吧。
请你分别概括属性传值、InheritedWidget、Notification与EventBus的优缺点。
欢迎你在评论区给我留言分享你的观点,我会在下一篇文章中等待你!感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。

View File

@@ -0,0 +1,225 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
21 路由与导航Flutter是这样实现页面切换的
你好,我是陈航。
在上一篇文章中我带你一起学习了如何在Flutter中实现跨组件数据传递。其中InheritedWidget适用于子Widget跨层共享父Widget数据的场景如果子Widget还需要修改父Widget数据则需要和State一起配套使用。而Notification则适用于父Widget监听子Widget事件的场景。对于没有父子关系的通信双方我们还可以使用EventBus实现基于订阅/发布模式的机制实现数据交互。
如果说UI框架的视图元素的基本单位是组件那应用程序的基本单位就是页面了。对于拥有多个页面的应用程序而言如何从一个页面平滑地过渡到另一个页面我们需要有一个统一的机制来管理页面之间的跳转通常被称为路由管理或导航管理。
我们首先需要知道目标页面对象在完成目标页面初始化后用框架提供的方式打开它。比如在Android/iOS中我们通常会初始化一个Intent或ViewController通过startActivity或pushViewController来打开一个新的页面而在React中我们使用navigation来管理所有页面只要知道页面的名称就可以立即导航到这个页面。
其实Flutter的路由管理也借鉴了这两种设计思路。那么今天我们就来看看如何在一个Flutter应用中管理不同页面的命名和过渡。
路由管理
在Flutter中页面之间的跳转是通过Route和Navigator来管理的
Route是页面的抽象主要负责创建对应的界面接收参数响应Navigator打开和关闭
而Navigator则会维护一个路由栈管理RouteRoute打开即入栈Route关闭即出栈还可以直接替换栈内的某一个Route。
而根据是否需要提前注册页面标识符Flutter中的路由管理可以分为两种方式
基本路由。无需提前注册,在页面切换时需要自己构造页面实例。
命名路由。需要提前注册页面标识符,在页面切换时通过标识符直接打开新的路由。
接下来,我们先一起看看基本路由这种管理方式吧。
基本路由
在Flutter中基本路由的使用方法和Android/iOS打开新页面的方式非常相似。要导航到一个新的页面我们需要创建一个MaterialPageRoute的实例调用Navigator.push方法将新页面压到堆栈的顶部。
其中MaterialPageRoute是一种路由模板定义了路由创建及切换过渡动画的相关配置可以针对不同平台实现与平台页面切换动画风格一致的路由切换动画。
而如果我们想返回上一个页面则需要调用Navigator.pop方法从堆栈中删除这个页面。
下面的代码演示了基本路由的使用方法:在第一个页面的按钮事件中打开第二个页面,并在第二个页面的按钮事件中回退到第一个页面:
class FirstScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
return RaisedButton(
//打开页面
onPressed: ()=> Navigator.push(context, MaterialPageRoute(builder: (context) => SecondScreen()));
);
}
}
class SecondPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return RaisedButton(
// 回退页面
onPressed: ()=> Navigator.pop(context)
);
}
}
运行一下代码,效果如下:
图1 基本路由示例
可以看到,基本路由的使用还是比较简单的。接下来,我们再看看命名路由的使用方法。
命名路由
基本路由使用方式相对简单灵活适用于应用中页面不多的场景。而在应用中页面比较多的情况下再使用基本路由方式那么每次跳转到一个新的页面我们都要手动创建MaterialPageRoute实例初始化页面然后调用push方法打开它还是比较麻烦的。
所以Flutter提供了另外一种方式来简化路由管理即命名路由。我们给页面起一个名字然后就可以直接通过页面名字打开它了。这种方式简单直观与React中的navigation使用方式类似。
要想通过名字来指定页面切换我们必须先给应用程序MaterialApp提供一个页面名称映射规则即路由表routes这样Flutter才知道名字与页面Widget的对应关系。
路由表实际上是一个Map其中key值对应页面名字而value值则是一个WidgetBuilder回调函数我们需要在这个函数中创建对应的页面。而一旦在路由表中定义好了页面名字我们就可以使用Navigator.pushNamed来打开页面了。
下面的代码演示了命名路由的使用方法在MaterialApp完成了页面的名字second_page及页面的初始化方法注册绑定后续我们就可以在代码中以second_page这个名字打开页面了
MaterialApp(
...
//注册路由
routes:{
"second_page":(context)=>SecondPage(),
},
);
//使用名字打开页面
Navigator.pushNamed(context,"second_page");
可以看到,命名路由的使用也很简单。
不过由于路由的注册和使用都采用字符串来标识,这就会带来一个隐患:如果我们打开了一个不存在的路由会怎么办?
也许你会想到,我们可以约定使用字符串常量去定义、使用路由,但我们无法避免通过接口数据下发的错误路由标识符场景。面对这种情况,无论是直接报错或是不响应错误路由,都不是一个用户体验良好的解决办法。
更好的办法是对用户进行友好的错误提示比如跳转到一个统一的NotFoundScreen页面也方便我们对这类错误进行统一收集、上报。
在注册路由表时Flutter提供了UnknownRoute属性我们可以对未知的路由标识符进行统一的页面跳转处理。
下面的代码演示了如何注册错误路由处理。和基本路由的使用方法类似,我们只需要返回一个固定的页面即可。
MaterialApp(
...
//注册路由
routes:{
"second_page":(context)=>SecondPage(),
},
//错误路由处理统一返回UnknownPage
onUnknownRoute: (RouteSettings setting) => MaterialPageRoute(builder: (context) => UnknownPage()),
);
//使用错误名字打开页面
Navigator.pushNamed(context,"unknown_page");
运行一下代码,可以看到,我们的应用不仅可以处理正确的页面路由标识,对错误的页面路由标识符也可以统一跳转到固定的错误处理页面了。
图2 命名路由示例
页面参数
与基本路由能够精确地控制目标页面初始化方式不同命名路由只能通过字符串名字来初始化固定目标页面。为了解决不同场景下目标页面的初始化需求Flutter提供了路由参数的机制可以在打开路由时传递相关参数在目标页面通过RouteSettings来获取页面参数。
下面的代码演示了如何传递并获取参数使用页面名称second_page打开页面时传递了一个字符串参数随后在SecondPage中我们取出了这个参数并将它展示在了文本中。
//打开页面时传递字符串参数
Navigator.of(context).pushNamed("second_page", arguments: "Hey");
class SecondPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
//取出路由参数
String msg = ModalRoute.of(context).settings.arguments as String;
return Text(msg);
}
}
除了页面打开时需要传递参数,对于特定的页面,在其关闭时,也需要传递参数告知页面处理结果。
比如在电商场景下,我们会在用户把商品加入购物车时,打开登录页面让用户登录,而在登录操作完成之后,关闭登录页面返回到当前页面时,登录页面会告诉当前页面新的用户身份,当前页面则会用新的用户身份刷新页面。
与Android提供的startActivityForResult方法可以监听目标页面的处理结果类似Flutter也提供了返回参数的机制。在push目标页面时可以设置目标页面关闭时监听函数以获取返回参数而目标页面可以在关闭路由时传递相关参数。
下面的代码演示了如何获取参数在SecondPage页面关闭时传递了一个字符串参数随后在上一页监听函数中我们取出了这个参数并将它展示了出来。
class SecondPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
body: Column(
children: <Widget>[
Text('Message from first screen: $msg'),
RaisedButton(
child: Text('back'),
//页面关闭时传递参数
onPressed: ()=> Navigator.pop(context,"Hi")
)
]
));
}
}
class _FirstPageState extends State<FirstPage> {
String _msg='';
@override
Widget build(BuildContext context) {
return new Scaffold(
body: Column(children: <Widget>[
RaisedButton(
child: Text('命名路由(参数&回调)'),
//打开页面,并监听页面关闭时传递的参数
onPressed: ()=> Navigator.pushNamed(context, "third_page",arguments: "Hey").then((msg)=>setState(()=>_msg=msg)),
),
Text('Message from Second screen: $_msg'),
],),
);
}
}
运行一下可以看到在关闭SecondPage重新回到FirstPage页面时FirstPage把接收到的msg参数展示了出来
图3 页面路由参数
总结
好了,今天的分享就到这里。我们简单回顾一下今天的主要内容吧。
Flutter提供了基本路由和命名路由两种方式来管理页面间的跳转。其中基本路由需要自己手动创建页面实例通过Navigator.push完成页面跳转而命名路由需要提前注册页面标识符和页面创建方法通过Navigator.pushNamed传入标识符实现页面跳转。
对于命名路由如果我们需要响应错误路由标识符还需要一并注册UnknownRoute。为了精细化控制路由切换Flutter提供了页面打开与页面关闭的参数机制我们可以在页面创建和目标页面关闭时取出相应的参数。
可以看到关于路由导航Flutter综合了Android、iOS和React的特点简洁而不失强大。
而在中大型应用中,我们通常会使用命名路由来管理页面间的切换。命名路由的最重要作用,就是建立了字符串标识符与各个页面之间的映射关系,使得各个页面之间完全解耦,应用内页面的切换只需要通过一个字符串标识符就可以搞定,为后期模块化打好基础。
我把今天分享所涉及的的知识点打包到了GitHub上你可以下载工程到本地多运行几次从而加深对基本路由、命名路由以及路由参数具体用法的印象。
思考题
最后,我给你留下两个小作业吧。
对于基本路由,如何传递页面参数?
请实现一个计算页面这个页面可以对前一个页面传入的2个数值参数进行求和并在该页面关闭时告知上一页面计算的结果。
欢迎你在评论区给我留言分享你的观点,我会在下一篇文章中等待你!感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。

View File

@@ -0,0 +1,299 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
22 如何构造炫酷的动画效果?
你好,我是陈航。
在上一篇文章中我带你一起学习了Flutter中实现页面路由的两种方式基本路由与命名路由即手动创建页面进行切换和通过前置路由注册后提供标识符进行跳转。除此之外Flutter还在这两种路由方式的基础上支持页面打开和页面关闭传递参数可以更精确地控制路由切换。
通过前面第12、13、14和15篇文章的学习我们已经掌握了开发一款样式精美的小型App的基本技能。但当下用户对于终端页面的要求已经不再满足于只能实现产品功能除了样式美观之外还希望交互良好、有趣、自然。
动画就是提升用户体验的一个重要方式一个恰当的组件动画或者页面切换动画不仅能够缓解用户因为等待而带来的情绪问题还会增加好感。Flutter既然完全接管了渲染层除了静态的页面布局之外对组件动画的支持自然也不在话下。
因此在今天的这篇文章中我会向你介绍Flutter中动画的实现方法看看如何让我们的页面动起来。
Animation、AnimationController与Listener
动画就是动起来的画面,是静态的画面根据事先定义好的规律,在一定时间内不断微调,产生变化效果。而动画实现由静止到动态,主要是靠人眼的视觉残留效应。所以,对动画系统而言,为了实现动画,它需要做三件事儿:
确定画面变化的规律;
根据这个规律,设定动画周期,启动动画;
定期获取当前动画的值,不断地微调、重绘画面。
这三件事情对应到Flutter中就是Animation、AnimationController与Listener
Animation是Flutter动画库中的核心类会根据预定规则在单位时间内持续输出动画的当前状态。Animation知道当前动画的状态比如动画是否开始、停止、前进或者后退以及动画的当前值但却不知道这些状态究竟应用在哪个组件对象上。换句话说Animation仅仅是用来提供动画数据而不负责动画的渲染。
AnimationController用于管理Animation可以用来设置动画的时长、启动动画、暂停动画、反转动画等。
Listener是Animation的回调函数用来监听动画的进度变化我们需要在这个回调函数中根据动画的当前值重新渲染组件实现动画的渲染。
接下来我们看一个具体的案例让大屏幕中间的Flutter Logo由小变大。
首先我们初始化了一个动画周期为1秒的、用于管理动画的AnimationController对象并用线性变化的Tween创建了一个变化范围从50到200的Animaiton对象。
然后我们给这个Animaiton对象设置了一个进度监听器并在进度监听器中强制界面重绘刷新动画状态。
接下来我们调用AnimationController对象的forward方法启动动画
class _AnimateAppState extends State<AnimateApp> with SingleTickerProviderStateMixin {
AnimationController controller;
Animation<double> animation;
@override
void initState() {
super.initState();
//创建动画周期为1秒的AnimationController对象
controller = AnimationController(
vsync: this, duration: const Duration(milliseconds: 1000));
// 创建从50到200线性变化的Animation对象
animation = Tween(begin: 50.0, end: 200.0).animate(controller)
..addListener(() {
setState(() {}); //刷新界面
});
controller.forward(); //启动动画
}
...
}
需要注意的是我们在创建AnimationController的时候设置了一个vsync属性。这个属性是用来防止出现不可见动画的。vsync对象会把动画绑定到一个Widget当Widget不显示时动画将会暂停当Widget再次显示时动画会重新恢复执行这样就可以避免动画的组件不在当前屏幕时白白消耗资源。
我们在一开始提到Animation只是用于提供动画数据并不负责动画渲染所以我们还需要在Widget的build方法中把当前动画状态的值读出来用于设置Flutter Logo容器的宽和高才能最终实现动画效果
@override
@override
Widget build(BuildContext context) {
return MaterialApp(
home: Center(
child: Container(
width: animation.value, // 将动画的值赋给widget的宽高
height: animation.value,
child: FlutterLogo()
)));
}
最后,别忘了在页面销毁时,要释放动画资源:
@override
void dispose() {
controller.dispose(); // 释放资源
super.dispose();
}
我们试着运行一下可以看到Flutter Logo动起来了
图1 动画示例
我们在上面用到的Tween默认是线性变化的但可以创建CurvedAnimation来实现非线性曲线动画。CurvedAnimation提供了很多常用的曲线比如震荡曲线elasticOut
//创建动画周期为1秒的AnimationController对象
controller = AnimationController(
vsync: this, duration: const Duration(milliseconds: 1000));
//创建一条震荡曲线
final CurvedAnimation curve = CurvedAnimation(
parent: controller, curve: Curves.elasticOut);
// 创建从50到200跟随振荡曲线变化的Animation对象
animation = Tween(begin: 50.0, end: 200.0).animate(curve)
运行一下可以看到Flutter Logo有了一个弹性动画
图2 CurvedAnimation 示例
现在的问题是,这些动画只能执行一次。如果想让它像心跳一样执行,有两个办法:
在启动动画时使用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();//启动动画
运行一下可以看到我们实现了Flutter Logo的心跳效果。
图3 Flutter Logo心跳
AnimatedWidget与AnimatedBuilder
在为Widget添加动画效果的过程中我们不难发现Animation仅提供动画的数据因此我们还需要监听动画执行进度并在回调中使用setState强制刷新界面才能看到动画效果。考虑到这些步骤都是固定的Flutter提供了两个类来帮我们简化这一步骤即AnimatedWidget与AnimatedBuilder。
接下来,我们分别看看这两个类如何使用。
在构建Widget时AnimatedWidget会将Animation的状态与其子Widget的视觉样式绑定。要使用AnimatedWidget我们需要一个继承自它的新类并接收Animation对象作为其初始化参数。然后在build方法中读取出Animation对象的当前值用作初始化Widget的样式。
下面的案例演示了Flutter Logo的AnimatedWidget版本用AnimatedLogo继承了AnimatedWidget并在build方法中把动画的值与容器的宽高做了绑定
class AnimatedLogo extends AnimatedWidget {
//AnimatedWidget需要在初始化时传入animation对象
AnimatedLogo({Key key, Animation<double> animation})
: super(key: key, listenable: animation);
Widget build(BuildContext context) {
//取出动画对象
final Animation<double> animation = listenable;
return Center(
child: Container(
height: animation.value,//根据动画对象的当前状态更新宽高
width: animation.value,
child: FlutterLogo(),
));
}
}
在使用时我们只需把Animation对象传入AnimatedLogo即可再也不用监听动画的执行进度刷新UI了
MaterialApp(
home: Scaffold(
body: AnimatedLogo(animation: animation)//初始化AnimatedWidget时传入animation对象
));
在上面的例子中在AnimatedLogo的build方法中我们使用Animation的value作为logo的宽和高。这样做对于简单组件的动画没有任何问题但如果动画的组件比较复杂一个更好的解决方案是将动画和渲染职责分离logo作为外部参数传入只做显示而尺寸的变化动画则由另一个类去管理。
这个分离工作我们可以借助AnimatedBuilder来完成。
与AnimatedWidget类似AnimatedBuilder也会自动监听Animation对象的变化并根据需要将该控件树标记为dirty以自动刷新UI。事实上如果你翻看源码就会发现AnimatedBuilder其实也是继承自AnimatedWidget。
我们以一个例子来演示如何使用AnimatedBuilder。在这个例子中AnimatedBuilder的尺寸变化动画由builder函数管理渲染则由外部传入child参数负责
MaterialApp(
home: Scaffold(
body: Center(
child: AnimatedBuilder(
animation: animation,//传入动画对象
child:FlutterLogo(),
//动画构建回调
builder: (context, child) => Container(
width: animation.value,//使用动画的当前状态更新UI
height: animation.value,
child: child, //child参数即FlutterLogo()
)
)
)
));
可以看到通过使用AnimatedWidget和AnimatedBuilder动画的生成和最终的渲染被分离开了构建动画的工作也被大大简化了。
hero动画
现在我们已经知道了如何在一个页面上实现动画效果那么如何实现在两个页面之间切换的过渡动画呢比如在社交类App在Feed流中点击小图进入查看大图页面的场景中我们希望能够实现小图到大图页面逐步放大的动画切换效果而当用户关闭大图时也实现原路返回的动画。
这样的跨页面共享的控件动画效果有一个专门的名词即“共享元素变换”Shared Element Transition
对于Android开发者来说这个概念并不陌生。Android原生提供了对这种动画效果的支持通过几行代码就可以实现在两个Activity共享的组件之间做出流畅的转场动画。
又比如Keynote提供了的“神奇移动”Magic Move功能可以实现两个Keynote页面之间的流畅过渡。
Flutter也有类似的概念即Hero控件。通过Hero我们可以在两个页面的共享元素之间做出流畅的页面切换效果。
接下来我们通过一个案例来看看Hero组件具体如何使用。
在下面的例子中我定义了两个页面其中page1有一个位于底部的小Flutter Logopage2有一个位于中部的大Flutter Logo。在点击了page1的小logo后会使用hero效果过渡到page2。
为了实现共享元素变换我们需要将这两个组件分别用Hero包裹并同时为它们设置相同的tag “hero”。然后为page1添加点击手势响应在用户点击logo时跳转到page2
class Page1 extends StatelessWidget {
Widget build(BuildContext context) {
return Scaffold(
body: GestureDetector(//手势监听点击
child: Hero(
tag: 'hero',//设置共享tag
child: Container(
width: 100, height: 100,
child: FlutterLogo())),
onTap: () {
Navigator.of(context).push(MaterialPageRoute(builder: (_)=>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()
))
);
}
}
运行一下,可以看到,我们通过简单的两步,就可以实现元素跨页面飞行的复杂动画效果了!
图4 Hero动画
总结
好了,今天的分享就到这里。我们简单回顾一下今天的主要内容吧。
在Flutter中动画的状态与渲染是分离的。我们通过Animation生成动画曲线使用AnimationController控制动画时间、启动动画。而动画的渲染则需要设置监听器获取动画进度后重新触发组件用新的动画状态刷新后才能实现动画的更新。
为了简化这一步骤Flutter提供了AnimatedWidget和AnimatedBuilder这两个组件省去了状态监听和UI刷新的工作。而对于跨页面动画Flutter提供了Hero组件只要两个相同相似的组件有同样的tag就能实现元素跨页面过渡的转场效果。
可以看到Flutter对于动画的分层设计还是非常简单清晰的但造成的副作用就是使用起来稍微麻烦一些。对于实际应用而言由于动画过程涉及到页面的频繁刷新因此我强烈建议你尽量使用AnimatedWidget或AnimatedBuilder来缩小受动画影响的组件范围只重绘需要做动画的组件即可要避免使用进度监听器直接刷新整个页面让不需要做动画的组件也跟着一起销毁重建。
我把今天分享中所涉及的针对控件的普通动画AnimatedBuilder和AnimatedWidget以及针对页面的过渡动画Hero打包到了GitHub上你可以把工程下载下来多运行几次体会这几种动画的具体使用方法。
思考题
最后,我给你留下两个小作业吧。
AnimatedBuilder(
animation: animation,
child:FlutterLogo(),
builder: (context, child) => Container(
width: animation.value,
height: animation.value,
child: child
)
)
在AnimatedBuilder的例子中child似乎被指定了两遍第3行的child与第7行的child你可以解释下这么做的原因吗
如果我把第3行的child删掉把Flutter Logo放到第7行动画是否能正常执行这会有什么问题吗
欢迎你在评论区给我留言分享你的观点,我会在下一篇文章中等待你!感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。

View File

@@ -0,0 +1,354 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
23 单线程模型怎么保证UI运行流畅
你好,我是陈航。
在上一篇文章中我带你一起学习了如何在Flutter中实现动画。对于组件动画Flutter将动画的状态与渲染进行了分离因此我们需要使用动画曲线生成器Animation、动画状态控制器AnimationController与动画进度监听器一起配合完成动画更新而对于跨页面动画Flutter提供了Hero组件可以实现共享元素变换的页面切换效果。
在之前的章节里我们介绍了很多Flutter框架出色的渲染和交互能力。支撑起这些复杂的能力背后实际上是基于单线程模型的Dart。那么与原生Android和iOS的多线程机制相比单线程的Dart如何从语言设计层面和代码运行机制上保证Flutter UI的流畅性呢
因此今天我会通过几个小例子循序渐进地向你介绍Dart语言的Event Loop处理机制、异步处理和并发编程的原理和使用方法从语言设计和实践层面理解Dart单线程模型下的代码运行本质从而懂得后续如何在工作中使用Future与Isolate优化我们的项目。
Event Loop机制
首先我们需要建立这样一个概念那就是Dart是单线程的。那单线程意味着什么呢这意味着Dart代码是有序的按照在main函数出现的次序一个接一个地执行不会被其他代码中断。另外作为支持Flutter这个UI框架的关键技术Dart当然也支持异步。需要注意的是单线程和异步并不冲突。
那为什么单线程也可以异步?
这里有一个大前提那就是我们的App绝大多数时间都在等待。比如等用户点击、等网络请求返回、等文件IO结果等等。而这些等待行为并不是阻塞的。比如说网络请求Socket本身提供了select模型可以异步查询而文件IO操作系统也提供了基于事件的回调机制。
所以,基于这些特点,单线程模型可以在等待的过程中做别的事情,等真正需要响应结果了,再去做对应的处理。因为等待过程并不是阻塞的,所以给我们的感觉就像是同时在做多件事情一样。但其实始终只有一个线程在处理你的事情。
等待这个行为是通过Event Loop驱动的。事件队列Event Queue会把其他平行世界比如Socket完成的需要主线程响应的事件放入其中。像其他语言一样Dart也有一个巨大的事件循环在不断的轮询事件队列取出事件比如键盘事件、I\O事件、网络事件等在主线程同步执行其回调函数如下图所示
图1 简化版Event Loop
异步任务
事实上图1的Event Loop示意图只是一个简化版。在Dart中实际上有两个队列一个事件队列Event Queue另一个则是微任务队列Microtask Queue。在每一次事件循环中Dart总是先去第一个微任务队列中查询是否有可执行的任务如果没有才会处理后续的事件队列的流程。
所以Event Loop完整版的流程图应该如下所示
图2 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方法体放入微任务队列尽快执行。
下面的代码演示了Future的执行规则先加入事件队列或者先声明的任务先执行then在Future结束后立即执行。
在第一个例子中由于f1比f2先声明因此会被先加入事件队列所以f1比f2先执行
在第二个例子中由于Future函数体与then共用一个事件循环因此f3执行后会立刻同步执行then 3
最后一个例子中Future函数体是null这意味着它不需要也没有事件循环因此后续的then也无法与它共享。在这种场景下Dart会把后续的then放入微任务队列在下一次事件循环中执行。
//f1比f2先执行
Future(() => print('f1'));
Future(() => print('f2'));
//f3执行后会立刻同步执行then 3
Future(() => print('f3')).then((_) => print('then 3'));
//then 4会加入微任务队列尽快执行
Future(() => null).then((_) => print('then 4'));
说了这么多规则,可能大家并没有完全记住。那我们通过一个综合案例,来把之前介绍的各个执行规则都串起来,再集中学习一下。
在下面的例子中我们依次声明了若干个异步任务Future以及微任务。在其中的一些Future内部我们又内嵌了Future与microtask的声明
Future(() => print('f1'));//声明一个匿名Future
Future fx = Future(() => null);//声明Future fx其执行体为null
//声明一个匿名Future并注册了两个then。在第一个then回调里启动了一个微任务
Future(() => print('f2')).then((_) {
print('f3');
scheduleMicrotask(() => print('f4'));
}).then((_) => print('f5'));
//声明了一个匿名Future并注册了两个then。第一个then是一个Future
Future(() => print('f6'))
.then((_) => Future(() => print('f7')))
.then((_) => print('f8'));
//声明了一个匿名Future
Future(() => print('f9'));
//往执行体为null的fx注册了了一个then
fx.then((_) => print('f10'));
//启动一个微任务
scheduleMicrotask(() => print('f11'));
print('f12');
运行一下,上述各个异步任务会依次打印其内部执行结果:
f12
f11
f1
f10
f2
f3
f5
f4
f6
f9
f7
f8
看到这儿你可能已经懵了。别急我们先来看一下这段代码执行过程中Event Queue与Microtask Queue中的变化情况依次分析一下它们的执行顺序为什么会是这样的
图3 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。
上面的代码很是烧脑万幸我们平时开发Flutter时一般不会遇到这样奇葩的写法所以你大可放心。你只需要记住一点then会在Future函数体执行完毕后立刻执行无论是共用同一个事件循环还是进入下一个微任务。
在深入理解Future异步任务的执行规则之后我们再来看看怎么封装一个异步函数。
异步函数
对于一个异步函数来说其返回时内部执行动作并未结束因此需要返回一个Future对象供调用者使用。调用者根据Future对象来决定是在这个Future对象上注册一个then等Future的执行体结束了以后再进行异步处理还是一直同步等待Future执行体结束。
对于异步函数返回的Future对象如果调用者决定同步等待则需要在调用处使用await关键字并且在调用处的函数体使用async关键字。
在下面的例子中异步方法延迟3秒返回了一个Hello 2019在调用处我们使用await进行持续等待等它返回了再打印
//声明了一个延迟3秒返回Hello的Future并注册了一个then返回拼接后的Hello 2019
Future<String> fetchContent() =>
Future<String>.delayed(Duration(seconds:3), () => "Hello")
.then((x) => "$x 2019");
main() async{
print(await fetchContent());//等待Hello 2019的返回
}
也许你已经注意到了我们在使用await进行等待的时候在等待语句的调用上下文函数main加上了async关键字。为什么要加这个关键字呢
因为Dart中的await并不是阻塞等待而是异步等待。Dart会将调用体的函数也视作异步函数将等待语句的上下文放入Event Queue中一旦有了结果Event Loop就会把它从Event Queue中取出等待代码继续执行。
接下来,为了帮助你加深印象,我准备了两个具体的案例。
我们先来看下这段代码。第二行的then执行体f2是一个Future为了等它完成再进行下一步操作我们使用了await期望打印结果为f1、f2、f3、f4
Future(() => print('f1'))
.then((_) async => await Future(() => print('f2')))
.then((_) => print('f3'));
Future(() => print('f4'));
实际上当你运行这段代码时就会发现打印出来的结果其实是f1、f4、f2、f3
我来给你分析一下这段代码的执行顺序:
按照任务的声明顺序f1和f4被先后加入事件队列。
f1被取出并打印然后到了then。then的执行体是个future f2于是放入Event Queue。然后把await也放到Event Queue里。
这个时候要注意了Event Queue里面还有一个f4我们的await并不能阻塞f4的执行。因此Event Loop先取出f4打印f4然后才能取出并打印f2最后把等待的await取出开始执行后面的f3。
由于await是采用事件队列的机制实现等待行为的所以比它先在事件队列中的f4并不会被它阻塞。
接下来我们再看另一个例子在主函数调用一个异步函数去打印一段话而在这个异步函数中我们使用await与async同步等待了另一个异步函数返回字符串
//声明了一个延迟2秒返回Hello的Future并注册了一个then返回拼接后的Hello 2019
Future<String> fetchContent() =>
Future<String>.delayed(Duration(seconds:2), () => "Hello")
.then((x) => "$x 2019");
//异步函数会同步等待Hello 2019的返回并打印
func() async => print(await fetchContent());
main() {
print("func before");
func();
print("func after");
}
运行这段代码我们发现最终输出的顺序其实是“func before”“func after”“Hello 2019”。func函数中的等待语句似乎没起作用。这是为什么呢
同样,我来给你分析一下这段代码的执行顺序:
首先第一句代码是同步的因此先打印“func before”。
然后进入func函数func函数调用了异步函数fetchContent并使用await进行等待因此我们把fetchContent、await语句的上下文函数func先后放入事件队列。
await的上下文函数并不包含调用栈因此func后续代码继续执行打印“func after”。
2秒后fetchContent异步任务返回“Hello 2019”于是func的await也被取出打印“Hello 2019”。
通过上述分析你发现了什么现象那就是await与async只对调用上下文的函数有效并不向上传递。因此对于这个案例而言func是在异步等待。如果我们想在main函数中也同步等待需要在调用异步函数时也加上await在main函数也加上async。
经过上面两个例子的分析你应该已经明白await与async是如何配合完成等待工作的了吧。
介绍完了异步我们再来看在Dart中如何通过多线程实现并发。
Isolate
尽管Dart是基于单线程模型的但为了进一步利用多核CPU将CPU密集型运算进行隔离Dart也提供了多线程机制即Isolate。在Isolate中资源隔离做得非常好每个Isolate都有自己的Event Loop与QueueIsolate之间不共享任何资源只能依靠消息机制通信因此也就没有资源抢占问题。
和其他语言一样Isolate的创建非常简单我们只要给定一个函数入口创建时再传入一个参数就可以启动Isolate了。如下所示我们声明了一个Isolate的入口函数然后在main函数中启动它并传入了一个字符串参数
doSth(msg) => print(msg);
main() {
Isolate.spawn(doSth, "Hi");
...
}
但更多情况下我们的需求并不会这么简单不仅希望能并发还希望Isolate在并发执行的时候告知主Isolate当前的执行结果。
对于执行结果的告知Isolate通过发送管道SendPort实现消息通信机制。我们可以在启动并发Isolate时将主Isolate的发送管道作为参数传给它这样并发Isolate就可以在任务执行完毕后利用这个发送管道给我们发消息了。
下面我们通过一个例子来说明在主Isolate里我们创建了一个并发Isolate在函数入口传入了主Isolate的发送管道然后等待并发Isolate的回传消息。在并发Isolate中我们用这个管道给主Isolate发了一个Hello字符串
Isolate isolate;
start() async {
ReceivePort receivePort= ReceivePort();//创建管道
//创建并发Isolate并传入发送管道
isolate = await Isolate.spawn(getMsg, receivePort.sendPort);
//监听管道消息
receivePort.listen((data) {
print('Data$data');
receivePort.close();//关闭管道
isolate?.kill(priority: Isolate.immediate);//杀死并发Isolate
isolate = null;
});
}
//并发Isolate往管道发送一个字符串
getMsg(sendPort) => sendPort.send("Hello");
这里需要注意的是在Isolate中发送管道是单向的我们启动了一个Isolate执行某项任务Isolate执行完毕后发送消息告知我们。如果Isolate执行任务时需要依赖主Isolate给它发送参数执行完毕后再发送执行结果给主Isolate这样双向通信的场景我们如何实现呢答案也很简单让并发Isolate也回传一个发送管道即可。
接下来,我们以一个并发计算阶乘的例子来说明如何实现双向通信。
在下面的例子中我们创建了一个异步函数计算阶乘。在这个异步函数内创建了一个并发Isolate传入主Isolate的发送管道并发Isolate也回传一个发送管道主Isolate收到回传管道后发送参数N给并发Isolate然后立即返回一个Future并发Isolate用参数N调用同步计算阶乘的函数返回执行结果最后主Isolate打印了返回结果
//并发计算阶乘
Future<dynamic> asyncFactoriali(n) async{
final response = ReceivePort();//创建管道
//创建并发Isolate并传入管道
await Isolate.spawn(_isolate,response.sendPort);
//等待Isolate回传管道
final sendPort = await response.first as SendPort;
//创建了另一个管道answer
final answer = ReceivePort();
//往Isolate回传的管道中发送参数同时传入answer管道
sendPort.send([n,answer.sendPort]);
return answer.first;//等待Isolate通过answer管道回传执行结果
}
//Isolate函数体参数是主Isolate传入的管道
_isolate(initialReplyTo) async {
final port = ReceivePort();//创建管道
initialReplyTo.send(port.sendPort);//往主Isolate回传管道
final message = await port.first as List;//等待主Isolate发送消息(参数和回传结果的管道)
final data = message[0] as int;//参数
final send = message[1] as SendPort;//回传结果的管道
send.send(syncFactorial(data));//调用同步计算阶乘的函数回传结果
}
//同步计算阶乘
int syncFactorial(n) => n < 2 ? n : n * syncFactorial(n-1);
main() async => print(await asyncFactoriali(4));//等待并发计算阶乘结果
看完这段代码你是什么感觉呢我们只是为了并发计算一个阶乘这样是不是太繁琐了
没错确实太繁琐了在Flutter中像这样执行并发计算任务我们可以采用更简单的方式Flutter提供了支持并发计算的compute函数其内部对Isolate的创建和双向通信进行了封装抽象屏蔽了很多底层细节我们在调用时只需要传入函数入口和函数参数就能够实现并发计算和消息通知
我们试着用compute函数改造一下并发计算阶乘的代码
//同步计算阶乘
int syncFactorial(n) => n < 2 ? n : n * syncFactorial(n-1);
//使用compute函数封装Isolate的创建和结果的返回
main() async => print(await compute(syncFactorial, 4));
可以看到用compute函数改造以后整个代码就变成了两行现在并发计算阶乘的代码看起来就清爽多了
总结
好了今天关于Dart的异步与并发机制实现原理的分享就到这里了我们来简单回顾一下主要内容
Dart是单线程的但通过事件循环可以实现异步而Future是异步任务的封装借助于await与async我们可以通过事件循环实现非阻塞的同步等待Isolate是Dart中的多线程可以实现并发有自己的事件循环与Queue独占资源Isolate之间可以通过消息机制进行单向通信这些传递的消息通过对方的事件循环驱动对方进行异步处理
在UI编程过程中异步和多线程是两个相伴相生的名词也是很容易混淆的概念对于异步方法调用而言代码不需要等待结果的返回而是通过其他手段比如通知回调事件循环或多线程在后续的某个时刻主动或被动地接收执行结果
因此从辩证关系上来看异步与多线程并不是一个同等关系异步是目的多线程只是我们实现异步的一个手段之一而在Flutter中借助于UI框架提供的事件循环我们可以不用阻塞的同时等待多个异步任务因此并不需要开多线程我们一定要记住这一点
我把今天分享所涉及到的知识点打包到了GitHub中你可以下载下来反复运行几次加深理解
思考题
最后我给你留下两道思考题吧
在通过并发Isolate计算阶乘的例子中我在asyncFactoriali方法里先后发给了并发Isolate两个SendPort你能否解释下这么做的原因可以只发一个SendPort吗
请改造以下代码在不改变整体异步结构的情况下实现输出结果为f1f2f3f4
Future(() => print('f1'))
.then((_) async => await Future(() => print('f2')))
.then((_) => print('f3'));
Future(() => print('f4'));
欢迎你在评论区给我留言分享你的观点,我会在下一篇文章中等待你!感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。

View File

@@ -0,0 +1,397 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
24 HTTP网络编程与JSON解析
你好,我是陈航。
在上一篇文章中我带你一起学习了Dart中异步与并发的机制及实现原理。与其他语言类似Dart的异步是通过事件循环与队列实现的我们可以使用Future来封装异步任务。而另一方面尽管Dart是基于单线程模型的但也提供了Isolate这样的“多线程”能力这使得我们可以充分利用系统资源在并发Isolate中搞定CPU密集型的任务并通过消息机制通知主Isolate运行结果。
异步与并发的一个典型应用场景,就是网络编程。一个好的移动应用,不仅需要有良好的界面和易用的交互体验,也需要具备和外界进行信息交互的能力。而通过网络,信息隔离的客户端与服务端间可以建立一个双向的通信通道,从而实现资源访问、接口数据请求和提交、上传下载文件等操作。
为了便于我们快速实现基于网络通道的信息交换实时更新App数据Flutter也提供了一系列的网络编程类库和工具。因此在今天的分享中我会通过一些小例子与你讲述在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库中提供的网络请求类实现了基本的网络编程功能。
接下来我将和你分享一个实例对照着上面提到的网络调用步骤来演示HttpClient如何使用。
在下面的代码中我们创建了一个HttpClien网络调用实例设置了其超时时间为5秒。随后构造了Flutter官网的URI并设置了请求Header的user-agent为Custom-UA。然后发起请求等待Flutter官网响应。最后在收到响应后打印出返回结果
get() async {
//创建网络调用示例,设置通用请求行为(超时时间)
var httpClient = HttpClient();
httpClient.idleTimeout = Duration(seconds: 5);
//构造URI设置user-agent为"Custom-UA"
var uri = Uri.parse("https://flutter.dev");
var request = await httpClient.getUrl(uri);
request.headers.add("user-agent", "Custom-UA");
//发起请求,等待响应
var response = await request.close();
//收到响应,打印结果
if (response.statusCode == HttpStatus.ok) {
print(await response.transform(utf8.decoder).join());
} else {
print('Error: \nHttp status ${response.statusCode}');
}
}
可以看到使用HttpClient来发起网络调用还是相对比较简单的。
这里需要注意的是由于网络请求是异步行为因此在Flutter中所有网络编程框架都是以Future作为异步请求的包装所以我们需要使用await与async进行非阻塞的等待。当然你也可以注册then以回调的方式进行相应的事件处理。
http
HttpClient使用方式虽然简单但其接口却暴露了不少内部实现细节。比如异步调用拆分得过细链接需要调用方主动关闭请求结果是字符串但却需要手动解码等。
http是Dart官方提供的另一个网络请求类相比于HttpClient易用性提升了不少。同样我们以一个例子来介绍http的使用方法。
首先我们需要将http加入到pubspec中的依赖里
dependencies:
http: '>=0.11.3+12'
在下面的代码中与HttpClient的例子类似的我们也是先后构造了http网络调用实例和Flutter官网URI在设置user-agent为Custom-UA后发出请求最后打印请求结果
httpGet() async {
//创建网络调用示例
var client = http.Client();
//构造URI
var uri = Uri.parse("https://flutter.dev");
//设置user-agent为"Custom-UA",随后立即发出请求
http.Response response = await client.get(uri, headers : {"user-agent" : "Custom-UA"});
//打印请求结果
if(response.statusCode == HttpStatus.ok) {
print(response.body);
} else {
print("Error: ${response.statusCode}");
}
}
可以看到相比于HttpClienthttp的使用方式更加简单仅需一次异步调用就可以实现基本的网络通信。
dio
HttpClient和http使用方式虽然简单但其暴露的定制化能力都相对较弱很多常用的功能都不支持或者实现异常繁琐比如取消请求、定制拦截器、Cookie管理等。因此对于复杂的网络请求行为我推荐使用目前在Dart社区人气较高的第三方dio来发起网络请求。
接下来我通过几个例子来和你介绍dio的使用方法。与http类似的我们首先需要把dio加到pubspec中的依赖里
dependencies:
dio: '>2.1.3'
在下面的代码中与前面HttpClient与http例子类似的我们也是先后创建了dio网络调用实例、创建URI、设置Header、发出请求最后等待请求结果
void getRequest() async {
//创建网络调用示例
Dio dio = new Dio();
//设置URI及请求user-agent后发起请求
var response = await dio.get("https://flutter.dev", options:Options(headers: {"user-agent" : "Custom-UA"}));
//打印请求结果
if(response.statusCode == HttpStatus.ok) {
print(response.data.toString());
} else {
print("Error: ${response.statusCode}");
}
}
这里需要注意的是创建URI、设置Header及发出请求的行为都是通过dio.get方法实现的。这个方法的options参数提供了精细化控制网络请求的能力可以支持设置Header、超时时间、Cookie、请求方法等。这部分内容不是今天分享的重点如果你想深入理解的话可以访问其API文档学习具体使用方法。
对于常见的上传及下载文件需求dio也提供了良好的支持文件上传可以通过构建表单FormData实现而文件下载则可以使用download方法搞定。
在下面的代码中我们通过FormData创建了两个待上传的文件通过post方法发送至服务端。download的使用方法则更为简单我们直接在请求参数中把待下载的文件地址和本地文件名提供给dio即可。如果我们需要感知下载进度可以增加onReceiveProgress回调函数
//使用FormData表单构建待上传文件
FormData formData = FormData.from({
"file1": UploadFileInfo(File("./file1.txt"), "file1.txt"),
"file2": UploadFileInfo(File("./file2.txt"), "file1.txt"),
});
//通过post方法发送至服务端
var responseY = await dio.post("https://xxx.com/upload", data: formData);
print(responseY.toString());
//使用download方法下载文件
dio.download("https://xxx.com/file1", "xx1.zip");
//增加下载进度回调函数
dio.download("https://xxx.com/file1", "xx2.zip", onReceiveProgress: (count, total) {
//do something
});
有时我们的页面由多个并行的请求响应结果构成这就需要等待这些请求都返回后才能刷新界面。在dio中我们可以结合Future.wait方法轻松实现
//同时发起两个并行请求
List<Response> responseX= await Future.wait([dio.get("https://flutter.dev"),dio.get("https://pub.dev/packages/dio")]);
//打印请求1响应结果
print("Response1: ${responseX[0].toString()}");
//打印请求2响应结果
print("Response2: ${responseX[1].toString()}");
此外与Android的okHttp一样dio还提供了请求拦截器通过拦截器我们可以在请求之前或响应之后做一些特殊的操作。比如可以为请求option统一增加一个header或是返回缓存数据或是增加本地校验处理等等。
在下面的例子中我们为dio增加了一个拦截器。在请求发送之前不仅为每个请求头都加上了自定义的user-agent还实现了基本的token认证信息检查功能。而对于本地已经缓存了请求uri资源的场景我们可以直接返回缓存数据避免再次下载
//增加拦截器
dio.interceptors.add(InterceptorsWrapper(
onRequest: (RequestOptions options){
//为每个请求头都增加user-agent
options.headers["user-agent"] = "Custom-UA";
//检查是否有token没有则直接报错
if(options.headers['token'] == null) {
return dio.reject("Error:请先登录");
}
//检查缓存是否有数据
if(options.uri == Uri.parse('http://xxx.com/file1')) {
return dio.resolve("返回缓存数据");
}
//放行请求
return options;
}
));
//增加try catch防止请求报错
try {
var response = await dio.get("https://xxx.com/xxx.zip");
print(response.data.toString());
}catch(e) {
print(e);
}
需要注意的是由于网络通信期间有可能会出现异常比如域名无法解析、超时等因此我们需要使用try-catch来捕获这些未知错误防止程序出现异常。
除了这些基本的用法dio还支持请求取消、设置代理证书校验等功能。不过这些高级特性不属于本次分享的重点故不再赘述详情可以参考dio的GitHub主页了解具体用法。
JSON解析
移动应用与Web服务器建立好了连接之后接下来的两个重要工作分别是服务器如何结构化地去描述返回的通信信息以及移动应用如何解析这些格式化的信息。
如何结构化地描述返回的通信信息?
在如何结构化地去表达信息上我们需要用到JSON。JSON是一种轻量级的、用于表达由属性值和字面量组成对象的数据交换语言。
一个简单的表示学生成绩的JSON结构如下所示
String jsonString = '''
{
"id":"123",
"name":"张三",
"score" : 95
}
''';
需要注意的是由于Flutter不支持运行时反射因此并没有提供像Gson、Mantle这样自动解析JSON的库来降低解析成本。在Flutter中JSON解析完全是手动的开发者要做的事情多了一些但使用起来倒也相对灵活。
接下来我们就看看Flutter应用是如何解析这些格式化的信息。
如何解析格式化的信息?
所谓手动解析是指使用dart:convert库中内置的JSON解码器将JSON字符串解析成自定义对象的过程。使用这种方式我们需要先将JSON字符串传递给JSON.decode方法解析成一个Map然后把这个Map传给自定义的类进行相关属性的赋值。
以上面表示学生成绩的JSON结构为例我来和你演示手动解析的使用方法。
首先我们根据JSON结构定义Student类并创建一个工厂类来处理Student类属性成员与JSON字典对象的值之间的映射关系
class Student{
//属性id名字与成绩
String id;
String name;
int score;
//构造方法
Student({
this.id,
this.name,
this.score
});
//JSON解析工厂类使用字典数据为对象初始化赋值
factory Student.fromJson(Map<String, dynamic> parsedJson){
return Student(
id: parsedJson['id'],
name : parsedJson['name'],
score : parsedJson ['score']
);
}
}
数据解析类创建好了剩下的事情就相对简单了我们只需要把JSON文本通过JSON.decode方法转换成Map然后把它交给Student的工厂类fromJson方法即可完成Student对象的解析
loadStudent() {
//jsonString为JSON文本
final jsonResponse = json.decode(jsonString);
Student student = Student.fromJson(jsonResponse);
print(student.name);
}
在上面的例子中JSON文本所有的属性都是基本类型因此我们直接从JSON字典取出相应的元素为对象赋值即可。而如果JSON下面还有嵌套对象属性比如下面的例子中Student还有一个teacher的属性我们又该如何解析呢
String jsonString = '''
{
"id":"123",
"name":"张三",
"score" : 95,
"teacher": {
"name": "李四",
"age" : 40
}
}
''';
这里teacher不再是一个基本类型而是一个对象。面对这种情况我们需要为每一个非基本类型属性创建一个解析类。与Student类似我们也需要为它的属性teacher创建一个解析类Teacher
class Teacher {
//Teacher的名字与年龄
String name;
int age;
//构造方法
Teacher({this.name,this.age});
//JSON解析工厂类使用字典数据为对象初始化赋值
factory Teacher.fromJson(Map<String, dynamic> parsedJson){
return Teacher(
name : parsedJson['name'],
age : parsedJson ['age']
);
}
}
然后我们只需要在Student类中增加teacher属性及对应的JSON映射规则即可
class Student{
...
//增加teacher属性
Teacher teacher;
//构造函数增加teacher
Student({
...
this.teacher
});
factory Student.fromJson(Map<String, dynamic> parsedJson){
return Student(
...
//增加映射规则
teacher: Teacher.fromJson(parsedJson ['teacher'])
);
}
}
完成了teacher属性的映射规则添加之后我们就可以继续使用Student来解析上述的JSON文本了
final jsonResponse = json.decode(jsonString);//将字符串解码成Map对象
Student student = Student.fromJson(jsonResponse);//手动解析
print(student.teacher.name);
可以看到,通过这种方法,无论对象有多复杂的非基本类型属性,我们都可以创建对应的解析类进行处理。
不过到现在为止我们的JSON数据解析还是在主Isolate中完成。如果JSON的数据格式比较复杂数据量又大这种解析方式可能会造成短期UI无法响应。对于这类CPU密集型的操作我们可以使用上一篇文章中提到的compute函数将解析工作放到新的Isolate中完成
static Student parseStudent(String content) {
final jsonResponse = json.decode(content);
Student student = Student.fromJson(jsonResponse);
return student;
}
doSth() {
...
//用compute函数将json解析放到新Isolate
compute(parseStudent,jsonString).then((student)=>print(student.teacher.name));
}
通过compute的改造我们就不用担心JSON解析时间过长阻塞UI响应了。
总结
好了,今天的分享就到这里了,我们简单回顾一下主要内容。
首先我带你学习了实现Flutter应用与服务端通信的三种方式即HttpClient、http与dio。其中dio提供的功能更为强大可以支持请求拦截、文件上传下载、请求合并等高级能力。因此我推荐你在实际项目中使用dio的方式。
然后我和你分享了JSON解析的相关内容。JSON解析在Flutter中相对比较简单但由于不支持反射所以我们只能手动解析先将JSON字符串转换成Map然后再把这个Map给到自定义类进行相关属性的赋值。
如果你有原生Android、iOS开发经验的话可能会觉得Flutter提供的JSON手动解析方案并不好用。在Flutter中没有像原生开发那样提供了Gson或Mantle等库用于将JSON字符串直接转换为对应的实体类。而这些能力无一例外都需要用到运行时反射这是Flutter从设计之初就不支持的理由如下
运行时反射破坏了类的封装性和安全性会带来安全风险。就在前段时间Fastjson框架就爆出了一个巨大的安全漏洞。这个漏洞使得精心构造的字符串文本可以在反序列化时让服务器执行任意代码直接导致业务机器被远程控制、内网渗透、窃取敏感信息等操作。
运行时反射会增加二进制文件大小。因为搞不清楚哪些代码可能会在运行时用到因此使用反射后会默认使用所有代码构建应用程序这就导致编译器无法优化编译期间未使用的代码应用安装包体积无法进一步压缩这对于自带Dart虚拟机的Flutter应用程序是难以接受的。
反射给开发者编程带来了方便但也带来了很多难以解决的新问题因此Flutter并不支持反射。而我们要做的就是老老实实地手动解析JSON吧。
我把今天分享所涉及到的知识点打包到了GitHub中你可以下载下来反复运行几次加深理解与记忆。
思考题
最后,我给你留两道思考题吧。
请使用dio实现一个自定义拦截器拦截器内检查header中的token如果没有token需要暂停本次请求同时访问”http://xxxx.com/token“在获取新token后继续本次请求。
为以下Student JSON写相应的解析类
String jsonString = '''
{
"id":"123",
"name":"张三",
"score" : 95,
"teachers": [
{
"name": "李四",
"age" : 40
},
{
"name": "王五",
"age" : 45
}
]
}
''';
欢迎你在评论区给我留言分享你的观点,我会在下一篇文章中等待你!感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。

View File

@@ -0,0 +1,245 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
25 本地存储与数据库的使用和优化
你好,我是陈航。
在上一篇文章中我带你一起学习了Flutter的网络编程即如何建立与Web服务器的通信连接以实现数据交换以及如何解析结构化后的通信信息。
其中建立通信连接在Flutter中有三种基本方案包括HttpClient、http与dio。考虑到HttpClient与http并不支持复杂的网络请求行为因此我重点介绍了如何使用dio实现资源访问、接口数据请求与提交、上传及下载文件、网络拦截等高级操作。
而关于如何解析信息由于Flutter并不支持反射因此只提供了手动解析JSON的方式把JSON转换成字典然后给自定义的类属性赋值即可。
正因为有了网络我们的App拥有了与外界进行信息交换的通道也因此具备了更新数据的能力。不过经过交换后的数据通常都保存在内存中而应用一旦运行结束内存就会被释放这些数据也就随之消失了。
因此,我们需要把这些更新后的数据以一定的形式,通过一定的载体保存起来,这样应用下次运行时,就可以把数据从存储的载体中读出来,也就实现了数据的持久化。
数据持久化的应用场景有很多。比如用户的账号登录信息需要保存用于每次与Web服务验证身份又比如下载后的图片需要缓存避免每次都要重新加载浪费用户流量。
由于Flutter仅接管了渲染层真正涉及到存储等操作系统底层行为时还需要依托于原生Android、iOS因此与原生开发类似的根据需要持久化数据的大小和方式不同Flutter提供了三种数据持久化方法即文件、SharedPreferences与数据库。接下来我将与你详细讲述这三种方式。
文件
文件是存储在某种介质(比如磁盘)上指定路径的、具有文件名的一组有序信息的集合。从其定义看,要想以文件的方式实现数据持久化,我们首先需要确定一件事儿:数据放在哪儿?这,就意味着要定义文件的存储路径。
Flutter提供了两种文件存储的目录即临时Temporary目录与文档Documents目录
临时目录是操作系统可以随时清除的目录通常被用来存放一些不重要的临时缓存数据。这个目录在iOS上对应着NSTemporaryDirectory返回的值而在Android上则对应着getCacheDir返回的值。
文档目录则是只有在删除应用程序时才会被清除的目录通常被用来存放应用产生的重要数据文件。在iOS上这个目录对应着NSDocumentDirectory而在Android上则对应着AppData目录。
接下来我通过一个例子与你演示如何在Flutter中实现文件读写。
在下面的代码中我分别声明了三个函数即创建文件目录函数、写文件函数与读文件函数。这里需要注意的是由于文件读写是非常耗时的操作所以这些操作都需要在异步环境下进行。另外为了防止文件读取过程中出现异常我们也需要在外层包上try-catch
//创建文件目录
Future<File> get _localFile async {
final directory = await getApplicationDocumentsDirectory();
final path = directory.path;
return File('$path/content.txt');
}
//将字符串写入文件
Future<File> writeContent(String content) async {
final file = await _localFile;
return file.writeAsString(content);
}
//从文件读出字符串
Future<String> readContent() async {
try {
final file = await _localFile;
String contents = await file.readAsString();
return contents;
} catch (e) {
return "";
}
}
有了文件读写函数我们就可以在代码中对content.txt这个文件进行读写操作了。在下面的代码中我们往这个文件写入了一段字符串后隔了一会又把它读了出来
writeContent("Hello World!");
...
readContent().then((value)=>print(value));
除了字符串读写之外Flutter还提供了二进制流的读写能力可以支持图片、压缩包等二进制文件的读写。这些内容不是本次分享的重点如果你想要深入研究的话可以查阅官方文档。
SharedPreferences
文件比较适合大量的、有序的数据持久化如果我们只是需要缓存少量的键值对信息比如记录用户是否阅读了公告或是简单的计数则可以使用SharedPreferences。
SharedPreferences会以原生平台相关的机制为简单的键值对数据提供持久化存储即在iOS上使用NSUserDefaults在Android使用SharedPreferences。
接下来我通过一个例子来演示在Flutter中如何通过SharedPreferences实现数据的读写。在下面的代码中我们将计数器持久化到了SharedPreferences中并为它分别提供了读方法和递增写入的方法。
这里需要注意的是settersetInt方法会同步更新内存中的键值对然后将数据保存至磁盘因此我们无需再调用更新方法强制刷新缓存。同样地由于涉及到耗时的文件读写因此我们必须以异步的方式对这些操作进行包装
//读取SharedPreferences中key为counter的值
Future<int>_loadCounter() async {
SharedPreferences prefs = await SharedPreferences.getInstance();
int counter = (prefs.getInt('counter') ?? 0);
return counter;
}
//递增写入SharedPreferences中key为counter的值
Future<void>_incrementCounter() async {
SharedPreferences prefs = await SharedPreferences.getInstance();
int counter = (prefs.getInt('counter') ?? 0) + 1;
prefs.setInt('counter', counter);
}
在完成了计数器存取方法的封装后,我们就可以在代码中随时更新并持久化计数器数据了。在下面的代码中,我们先是读取并打印了计数器数据,随后将其递增,并再次把它读取打印:
//读出counter数据并打印
_loadCounter().then((value)=>print("before:$value"));
//递增counter数据后再次读出并打印
_incrementCounter().then((_) {
_loadCounter().then((value)=>print("after:$value"));
});
可以看到SharedPreferences的使用方式非常简单方便。不过需要注意的是以键值对的方式只能存储基本类型的数据比如int、double、bool和string。
数据库
SharedPrefernces的使用固然方便但这种方式只适用于持久化少量数据的场景我们并不能用它来存储大量数据比如文件内容文件路径是可以的
如果我们需要持久化大量格式化后的数据并且这些数据还会以较高的频率更新为了考虑进一步的扩展性我们通常会选用sqlite数据库来应对这样的场景。与文件和SharedPreferences相比数据库在数据读写上可以提供更快、更灵活的解决方案。
接下来,我就以一个例子分别与你介绍数据库的使用方法。
我们以上一篇文章中提到的Student类为例
class Student{
String id;
String name;
int score;
//构造方法
Student({this.id, this.name, this.score,});
//用于将JSON字典转换成类对象的工厂类方法
factory Student.fromJson(Map<String, dynamic> parsedJson){
return Student(
id: parsedJson['id'],
name : parsedJson['name'],
score : parsedJson ['score'],
);
}
}
JSON类拥有一个可以将JSON字典转换成类对象的工厂类方法我们也可以提供将类对象反过来转换成JSON字典的实例方法。因为最终存入数据库的并不是实体类对象而是字符串、整型等基本类型组成的字典所以我们可以通过这两个方法实现数据库的读写。同时我们还分别定义了3个Student对象用于后续插入数据库
class Student{
...
//将类对象转换成JSON字典方便插入数据库
Map<String, dynamic> toJson() {
return {'id': id, 'name': name, 'score': score,};
}
}
var student1 = Student(id: '123', name: '张三', score: 90);
var student2 = Student(id: '456', name: '李四', score: 80);
var student3 = Student(id: '789', name: '王五', score: 85);
有了实体类作为数据库存储的对象接下来就需要创建数据库了。在下面的代码中我们通过openDatabase函数给定了一个数据库存储地址并通过数据库表初始化语句创建了一个用于存放Student对象的students表
final Future<Database> database = openDatabase(
join(await getDatabasesPath(), 'students_database.db'),
onCreate: (db, version)=>db.execute("CREATE TABLE students(id TEXT PRIMARY KEY, name TEXT, score INTEGER)"),
onUpgrade: (db, oldVersion, newVersion){
//dosth for migration
},
version: 1,
);
以上代码属于通用的数据库创建模板,有三个地方需要注意:
在设定数据库存储地址时使用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并无不同因此这里就不再赘述了。你可以参考sqflite插件的API文档或是查阅SQLite教程了解具体的使用方法。
总结
好了,今天的分享就这里。我们简单回顾下今天学习的内容吧。
首先我带你学习了文件这种最常见的数据持久化方式。Flutter提供了两类目录即临时目录与文档目录。我们可以根据实际需求通过写入字符串或二进制流实现数据的持久化。
然后我通过一个小例子和你讲述了SharedPreferences这种适用于持久化小型键值对的存储方案。
最后,我们一起学习了数据库。围绕如何将一个对象持久化到数据库,我与你介绍了数据库的创建、写入和读取方法。可以看到,使用数据库的方式虽然前期准备工作多了不少,但面对持续变更的需求,适配能力和灵活性都更强了。
数据持久化是CPU密集型运算因此数据存取均会大量涉及到异步操作所以请务必使用异步等待或注册then回调正确处理读写操作的时序关系。
我把今天分享所涉及到的知识点打包到了GitHub中你可以下载下来反复运行几次加深理解与记忆。
思考题
最后,我给你留下两道思考题吧。
请你分别介绍一下文件、SharedPreferences和数据库这三种持久化数据存储方式的适用场景。
我们的应用经历了1.0、1.1和1.2三个版本。其中1.0版本新建了数据库并创建了Student表1.1版本将Student表增加了一个字段ageALTER TABLE students ADD age INTEGER。请你写出1.1版本及1.2版本的数据库升级代码。
//1.0版本数据库创建代码
final Future<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,
);
欢迎你在评论区给我留言分享你的观点,我会在下一篇文章中等待你!感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。

View File

@@ -0,0 +1,170 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
26 如何在Dart层兼容Android_iOS平台特定实现
你好,我是陈航。
在上一篇文章中我与你介绍了在Flutter中实现数据持久化的三种方式即文件、SharedPreferences与数据库。
其中文件适用于字符串或者二进制流的数据持久化我们可以根据访问频次决定将它存在临时目录或是文档目录。而SharedPreferences则适用于存储小型键值对信息可以应对一些轻量配置缓存的场景。数据库则适用于频繁变化的、结构化的对象存取可以轻松应对数据的增删改查。
依托于与Skia的深度定制及优化Flutter给我们提供了很多关于渲染的控制和支持能够实现绝对的跨平台应用层渲染一致性。但对于一个应用而言除了应用层视觉显示和对应的交互逻辑处理之外有时还需要原生操作系统Android、iOS提供的底层能力支持。比如我们前面提到的数据持久化以及推送、摄像头硬件调用等。
由于Flutter只接管了应用渲染层因此这些系统底层能力是无法在Flutter框架内提供支持的而另一方面Flutter还是一个相对年轻的生态因此原生开发中一些相对成熟的Java、C++或Objective-C代码库比如图片处理、音视频编解码等可能在Flutter中还没有相关实现。
因此为了解决调用原生系统底层能力以及相关代码库复用问题Flutter为开发者提供了一个轻量级的解决方案即逻辑层的方法通道Method Channel机制。基于方法通道我们可以将原生代码所拥有的能力以接口形式暴露给Dart从而实现Dart代码与原生代码的交互就像调用了一个普通的Dart API一样。
接下来我就与你详细讲述Flutter的方法通道机制吧。
方法通道
Flutter作为一个跨平台框架提供了一套标准化的解决方案为开发者屏蔽了操作系统的差异。但Flutter毕竟不是操作系统因此在某些特定场景下比如推送、蓝牙、摄像头硬件调用时也需要具备直接访问系统底层原生代码的能力。为此Flutter提供了一套灵活而轻量级的机制来实现Dart和原生代码之间的通信即方法调用的消息传递机制而方法通道则是用来传递通信消息的信道。
一次典型的方法调用过程类似网络调用由作为客户端的Flutter通过方法通道向作为服务端的原生代码宿主发送方法调用请求原生代码宿主在监听到方法调用的消息后调用平台相关的API来处理Flutter发起的请求最后将处理完毕的结果通过方法通道回发至Flutter。调用过程如下图所示
图1 方法通道示意图
从上图中可以看到方法调用请求的处理和响应在Android中是通过FlutterView而在iOS中则是通过FlutterViewController进行注册的。FlutterView与FlutterViewController为Flutter应用提供了一个画板使得构建于Skia之上的Flutter通过绘制即可实现整个应用所需的视觉效果。因此它们不仅是Flutter应用的容器同时也是Flutter应用的入口自然也是注册方法调用请求最合适的地方。
接下来,我通过一个例子来演示如何使用方法通道实现与原生代码的交互。
方法通道使用示例
在实际业务中提示用户跳转到应用市场iOS为App Store、Android则为各类手机应用市场去评分是一个高频需求考虑到Flutter并未提供这样的接口而跳转方式在Android和iOS上各不相同因此我们需要分别在Android和iOS上实现这样的功能并暴露给Dart相关的接口。
我们先来看看作为客户端的Flutter怎样实现一次方法调用请求。
Flutter如何实现一次方法调用请求
首先我们需要确定一个唯一的字符串标识符来构造一个命名通道然后在这个通道之上Flutter通过指定方法名“openAppMarket”来发起一次方法调用请求。
可以看到这和我们平时调用一个Dart对象的方法完全一样。因为方法调用过程是异步的所以我们需要使用非阻塞或者注册回调来等待原生代码给予响应。
//声明MethodChannel
const platform = MethodChannel('samples.chenhang/utils');
//处理按钮点击
handleButtonClick() async{
int result;
//异常捕获
try {
//异步等待方法通道的调用结果
result = await platform.invokeMethod('openAppMarket');
}
catch (e) {
result = -1;
}
print("Result$result");
}
需要注意的是与网络调用类似方法调用请求有可能会失败比如Flutter发起了原生代码不支持的API调用或是调用过程出错等因此我们需要把发起方法调用请求的语句用try-catch包装起来。
调用方的实现搞定了接下来就需要在原生代码宿主中完成方法调用的响应实现了。由于我们需要适配Android和iOS两个平台所以我们分别需要在两个平台上完成对应的接口实现。
在原生代码中完成方法调用的响应
首先我们来看看Android端的实现方式。在上一小结最后我提到在Android平台方法调用的处理和响应是在Flutter应用的入口也就是在MainActivity中的FlutterView里实现的因此我们需要打开Flutter的Android宿主App找到MainActivity.java文件并在其中添加相关的逻辑。
调用方与响应方都是通过命名通道进行信息交互的所以我们需要在onCreate方法中创建一个与调用方Flutter所使用的通道名称一样的MethodChannel并在其中设置方法处理回调响应openAppMarket方法打开应用市场的Intent。同样地考虑到打开应用市场的过程可能会出错我们也需要增加try-catch来捕获可能的异常
protected void onCreate(Bundle savedInstanceState) {
...
//创建与调用方标识符一样的方法通道
new MethodChannel(getFlutterView(), "samples.chenhang/utils").setMethodCallHandler(
//设置方法处理回调
new MethodCallHandler() {
//响应方法请求
@Override
public void onMethodCall(MethodCall call, Result result) {
//判断方法名是否支持
if(call.method.equals("openAppMarket")) {
try {
//应用市场URI
Uri uri = Uri.parse("market://details?id=com.hangchen.example.flutter_module_page.host");
Intent intent = new Intent(Intent.ACTION_VIEW, uri);
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
//打开应用市场
activity.startActivity(intent);
//返回处理结果
result.success(0);
} catch (Exception e) {
//打开应用市场出现异常
result.error("UNAVAILABLE", "没有安装应用市场", null);
}
}else {
//方法名暂不支持
result.notImplemented();
}
}
});
}
现在方法调用响应的Android部分已经搞定接下来我们来看一下iOS端的方法调用响应如何实现。
在iOS平台方法调用的处理和响应是在Flutter应用的入口也就是在Applegate中的rootViewController即FlutterViewController里实现的因此我们需要打开Flutter的iOS宿主App找到AppDelegate.m文件并添加相关逻辑。
与Android注册方法调用响应类似我们需要在didFinishLaunchingWithOptions:方法中创建一个与调用方Flutter所使用的通道名称一样的MethodChannel并在其中设置方法处理回调响应openAppMarket方法通过URL打开应用市场
- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
//创建命名方法通道
FlutterMethodChannel* channel = [FlutterMethodChannel methodChannelWithName:@"samples.chenhang/utils" binaryMessenger:(FlutterViewController *)self.window.rootViewController];
//往方法通道注册方法调用处理回调
[channel setMethodCallHandler:^(FlutterMethodCall* call, FlutterResult result) {
//方法名称一致
if ([@"openAppMarket" isEqualToString:call.method]) {
//打开App Store(本例打开微信的URL)
[[UIApplication sharedApplication] openURL:[NSURL URLWithString:@"itms-apps://itunes.apple.com/xy/app/foo/id414478124"]];
//返回方法处理结果
result(@0);
} else {
//找不到被调用的方法
result(FlutterMethodNotImplemented);
}
}];
...
}
这样iOS端的方法调用响应也已经实现了。
接下来我们就可以在Flutter应用里通过调用openAppMarket方法实现打开不同操作系统提供的应用市场功能了。
需要注意的是在原生代码处理完毕后将处理结果返回给Flutter时我们在Dart、Android和iOS分别用了三种数据类型Android端返回的是java.lang.Integer、iOS端返回的是NSNumber、Dart端接收到返回结果时又变成了int类型。这是为什么呢
这是因为在使用方法通道进行方法调用时由于涉及到跨系统数据交互Flutter会使用StandardMessageCodec对通道中传输的信息进行类似JSON的二进制序列化以标准化数据传输行为。这样在我们发送或者接收数据时这些数据就会根据各自系统预定的规则自动进行序列化和反序列化。看到这里你是不是对这样类似网络调用的方法通道技术有了更深刻的印象呢。
对于上面提到的例子类型为java.lang.Integer或NSNumber的返回值先是被序列化成了一段二进制格式的数据在通道中传输然后当该数据传递到Flutter后又被反序列化成了Dart语言中的int类型的数据。
关于Android、iOS和Dart平台间的常见数据类型转换我总结成了下面一张表格帮助你理解与记忆。你只要记住像null、布尔、整型、字符串、数组和字典这些基本类型是可以在各个平台之间以平台定义的规则去混用的就可以了。
图2 Android、iOS和Dart平台间的常见数据类型转换
总结
好了,今天的分享就到这里,我们来总结一下主要内容吧。
方法通道解决了逻辑层的原生能力复用问题使得Flutter能够通过轻量级的异步方法调用实现与原生代码的交互。一次典型的调用过程由Flutter发起方法调用请求开始请求经由唯一标识符指定的方法通道到达原生代码宿主而原生代码宿主则通过注册对应方法实现、响应并处理调用请求最后将执行结果通过消息通道回传至Flutter。
需要注意的是方法通道是非线程安全的。这意味着原生代码与Flutter之间所有接口调用必须发生在主线程。Flutter是单线程模型因此自然可以确保方法调用请求是发生在主线程Isolate而原生代码在处理方法调用请求时如果涉及到异步或非主线程切换需要确保回调过程是在原生系统的UI线程也就是Android和iOS的主线程中执行的否则应用可能会出现奇怪的Bug甚至是Crash。
我把今天分享所涉及到的知识点打包到了GitHub中你可以下载下来反复运行几次加深理解。
思考题
最后,我给你留下一道思考题吧。
请扩展方法通道示例让openAppMarket支持传入AppID和包名使得我们可以跳转到任意一个App的应用市场。
欢迎你在评论区给我留言分享你的观点,我会在下一篇文章中等待你!感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。

View File

@@ -0,0 +1,419 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
27 如何在Dart层兼容Android_iOS平台特定实现
你好,我是陈航。
在上一篇文章中我与你介绍了方法通道这种在Flutter中实现调用原生Android、iOS代码的轻量级解决方案。使用方法通道我们可以把原生代码所拥有的能力以接口形式提供给Dart。
这样当发起方法调用时Flutter应用会以类似网络异步调用的方式将请求数据通过一个唯一标识符指定的方法通道传输至原生代码宿主而原生代码处理完毕后会将响应结果通过方法通道回传至Flutter从而实现Dart代码与原生Android、iOS代码的交互。这与调用一个本地的Dart 异步API并无太多区别。
通过方法通道我们可以把原生操作系统提供的底层能力以及现有原生开发中一些相对成熟的解决方案以接口封装的形式在Dart层快速搞定从而解决原生代码在Flutter上的复用问题。然后我们可以利用Flutter本身提供的丰富控件做好UI渲染。
底层能力+应用层渲染看似我们已经搞定了搭建一个复杂App的所有内容。但真的是这样吗
构建一个复杂App都需要什么
别急在下结论之前我们先按照四象限分析法把能力和渲染分解成四个维度分析构建一个复杂App都需要什么。
图1 四象限分析法
经过分析我们终于发现原来构建一个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那样使用原生视图了。整个流程如下图所示
图2 平台视图示例
接下来我以一个具体的案例也就是将一个红色的原生视图内嵌到Flutter中与你演示如何使用平台视图。这部分内容主要包括两部分
作为调用发起方的Flutter如何实现原生视图的接口调用
如何在原生Android和iOS系统实现接口
接下来,我将分别与你讲述这两个问题。
Flutter如何实现原生视图的接口调用
在下面的代码中我们在SampleView的内部分别使用了原生Android、iOS视图的封装类AndroidView和UIkitView并传入了一个唯一标识符用于和原生视图建立关联
class SampleView extends StatelessWidget {
@override
Widget build(BuildContext context) {
//使用Android平台的AndroidView传入唯一标识符sampleView
if (defaultTargetPlatform == TargetPlatform.android) {
return AndroidView(viewType: 'sampleView');
} else {
//使用iOS平台的UIKitView传入唯一标识符sampleView
return UiKitView(viewType: 'sampleView');
}
}
}
可以看到平台视图在Flutter侧的使用方式比较简单与普通Widget并无明显区别。而关于普通Widget的使用方式你可以参考第12、13篇的相关内容进行复习。
调用方的实现搞定了。接下来我们需要在原生代码中完成视图创建的封装建立相关的绑定关系。同样的由于需要同时适配Android和iOS平台我们需要分别在两个系统上完成对应的接口实现。
如何在原生系统实现接口?
首先我们来看看Android端的实现。在下面的代码中我们分别创建了平台视图工厂和原生视图封装类并通过视图工厂的create方法将它们关联起来
//视图工厂类
class SampleViewFactory extends PlatformViewFactory {
private final BinaryMessenger messenger;
//初始化方法
public SampleViewFactory(BinaryMessenger msger) {
super(StandardMessageCodec.INSTANCE);
messenger = msger;
}
//创建原生视图封装类,完成关联
@Override
public PlatformView create(Context context, int id, Object obj) {
return new SimpleViewControl(context, id, messenger);
}
}
//原生视图封装类
class SimpleViewControl implements PlatformView {
private final View view;//缓存原生视图
//初始化方法,提前创建好视图
public SimpleViewControl(Context context, int id, BinaryMessenger messenger) {
view = new View(context);
view.setBackgroundColor(Color.rgb(255, 0, 0));
}
//返回原生视图
@Override
public View getView() {
return view;
}
//原生视图销毁回调
@Override
public void dispose() {
}
}
将原生视图封装类与原生视图工厂完成关联后接下来就需要将Flutter侧的调用与视图工厂绑定起来了。与上一篇文章讲述的方法通道类似我们仍然需要在MainActivity中进行绑定操作
protected void onCreate(Bundle savedInstanceState) {
...
Registrar registrar = registrarFor("samples.chenhang/native_views");//生成注册类
SampleViewFactory playerViewFactory = new SampleViewFactory(registrar.messenger());//生成视图工厂
registrar.platformViewRegistry().registerViewFactory("sampleView", playerViewFactory);//注册视图工厂
}
完成绑定之后平台视图调用响应的Android部分就搞定了。
接下来我们再来看看iOS端的实现。
与Android类似我们同样需要分别创建平台视图工厂和原生视图封装类并通过视图工厂的create方法将它们关联起来
//平台视图工厂
@interface SampleViewFactory : NSObject<FlutterPlatformViewFactory>
- (instancetype)initWithMessenger:(NSObject<FlutterBinaryMessenger>*)messager;
@end
@implementation SampleViewFactory{
NSObject<FlutterBinaryMessenger>*_messenger;
}
- (instancetype)initWithMessenger:(NSObject<FlutterBinaryMessenger> *)messager{
self = [super init];
if (self) {
_messenger = messager;
}
return self;
}
-(NSObject<FlutterMessageCodec> *)createArgsCodec{
return [FlutterStandardMessageCodec sharedInstance];
}
//创建原生视图封装实例
-(NSObject<FlutterPlatformView> *)createWithFrame:(CGRect)frame viewIdentifier:(int64_t)viewId arguments:(id)args{
SampleViewControl *activity = [[SampleViewControl alloc] initWithWithFrame:frame viewIdentifier:viewId arguments:args binaryMessenger:_messenger];
return activity;
}
@end
//平台视图封装类
@interface SampleViewControl : NSObject<FlutterPlatformView>
- (instancetype)initWithWithFrame:(CGRect)frame viewIdentifier:(int64_t)viewId arguments:(id _Nullable)args binaryMessenger:(NSObject<FlutterBinaryMessenger>*)messenger;
@end
@implementation SampleViewControl{
UIView * _templcateView;
}
//创建原生视图
- (instancetype)initWithWithFrame:(CGRect)frame viewIdentifier:(int64_t)viewId arguments:(id)args binaryMessenger:(NSObject<FlutterBinaryMessenger> *)messenger{
if ([super init]) {
_templcateView = [[UIView alloc] init];
_templcateView.backgroundColor = [UIColor redColor];
}
return self;
}
-(UIView *)view{
return _templcateView;
}
@end
然后我们同样需要把原生视图的创建与Flutter侧的调用关联起来才可以在Flutter侧找到原生视图的实现
- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
NSObject<FlutterPluginRegistrar>* registrar = [self registrarForPlugin:@"samples.chenhang/native_views"];//生成注册类
SampleViewFactory* viewFactory = [[SampleViewFactory alloc] initWithMessenger:registrar.messenger];//生成视图工厂
[registrar registerViewFactory:viewFactory withId:@"sampleView"];//注册视图工厂
...
}
需要注意的是在iOS平台上Flutter内嵌UIKitView目前还处于技术预览状态因此我们还需要在Info.plist文件中增加一项配置把内嵌原生视图的功能开关设置为true才能打开这个隐藏功能
<dict>
...
<key>io.flutter.embedded_views_preview</key>
<true/>
....
</dict>
经过上面的封装与绑定Android端与iOS端的平台视图功能都已经实现了。接下来我们就可以在Flutter应用里像使用普通Widget一样去内嵌原生视图了
Scaffold(
backgroundColor: Colors.yellowAccent,
body: Container(width: 200, height:200,
child: SampleView(controller: controller)
));
如下所示我们分别在iOS和Android平台的Flutter应用上内嵌了一个红色的原生视图
图3 内嵌原生视图示例
在上面的例子中我们将原生视图封装在一个StatelessWidget中可以有效应对静态展示的场景。如果我们需要在程序运行时动态调整原生视图的样式又该如何处理呢
如何在程序运行时,动态地调整原生视图的样式?
与基于声明式的Flutter Widget每次变化只能以数据驱动其视图销毁重建不同原生视图是基于命令式的可以精确地控制视图展示样式。因此我们可以在原生视图的封装类中将其持有的修改视图实例相关的接口以方法通道的方式暴露给Flutter让Flutter也可以拥有动态调整视图视觉样式的能力。
接下来,我以一个具体的案例来演示如何在程序运行时动态调整内嵌原生视图的背景颜色。
在这个案例中我们会用到原生视图的一个初始化属性即onPlatformViewCreated原生视图会在其创建完成后以回调的形式通知视图id因此我们可以在这个时候注册方法通道让后续的视图修改请求通过这条通道传递给原生视图。
由于我们在底层直接持有了原生视图的实例因此理论上可以直接在这个原生视图的Flutter封装类上提供视图修改方法而不管它到底是StatelessWidget还是StatefulWidget。但为了遵照Flutter的Widget设计理念我们还是决定将视图展示与视图控制分离将原生视图封装为一个StatefulWidget专门用于展示通过其controller初始化参数在运行期修改原生视图的展示效果。如下所示
//原生视图控制器
class NativeViewController {
MethodChannel _channel;
//原生视图完成创建后通过id生成唯一方法通道
onCreate(int id) {
_channel = MethodChannel('samples.chenhang/native_views_$id');
}
//调用原生视图方法,改变背景颜色
Future<void> changeBackgroundColor() async {
return _channel.invokeMethod('changeBackgroundColor');
}
}
//原生视图Flutter侧封装继承自StatefulWidget
class SampleView extends StatefulWidget {
const SampleView({
Key key,
this.controller,
}) : super(key: key);
//持有视图控制器
final NativeViewController controller;
@override
State<StatefulWidget> createState() => _SampleViewState();
}
class _SampleViewState extends State<SampleView> {
//根据平台确定返回何种平台视图
@override
Widget build(BuildContext context) {
if (defaultTargetPlatform == TargetPlatform.android) {
return AndroidView(
viewType: 'sampleView',
//原生视图创建完成后通过onPlatformViewCreated产生回调
onPlatformViewCreated: _onPlatformViewCreated,
);
} else {
return UiKitView(viewType: 'sampleView',
//原生视图创建完成后通过onPlatformViewCreated产生回调
onPlatformViewCreated: _onPlatformViewCreated
);
}
}
//原生视图创建完成后调用control的onCreate方法传入view id
_onPlatformViewCreated(int id) {
if (widget.controller == null) {
return;
}
widget.controller.onCreate(id);
}
}
Flutter的调用方实现搞定了接下来我们分别看看Android和iOS端的实现。
程序的整体结构与之前并无不同,只是在进行原生视图初始化时,我们需要完成方法通道的注册和相关事件的处理;在响应方法调用消息时,我们需要判断方法名,如果完全匹配,则修改视图背景,否则返回异常。
Android端接口实现代码如下所示
class SimpleViewControl implements PlatformView, MethodCallHandler {
private final MethodChannel methodChannel;
...
public SimpleViewControl(Context context, int id, BinaryMessenger messenger) {
...
//用view id注册方法通道
methodChannel = new MethodChannel(messenger, "samples.chenhang/native_views_" + id);
//设置方法通道回调
methodChannel.setMethodCallHandler(this);
}
//处理方法调用消息
@Override
public void onMethodCall(MethodCall methodCall, MethodChannel.Result result) {
//如果方法名完全匹配
if (methodCall.method.equals("changeBackgroundColor")) {
//修改视图背景,返回成功
view.setBackgroundColor(Color.rgb(0, 0, 255));
result.success(0);
}else {
//调用方发起了一个不支持的API调用
result.notImplemented();
}
}
...
}
iOS端接口实现代码
@implementation SampleViewControl{
...
FlutterMethodChannel* _channel;
}
- (instancetype)initWithWithFrame:(CGRect)frame viewIdentifier:(int64_t)viewId arguments:(id)args binaryMessenger:(NSObject<FlutterBinaryMessenger> *)messenger{
if ([super init]) {
...
//使用view id完成方法通道的创建
_channel = [FlutterMethodChannel methodChannelWithName:[NSString stringWithFormat:@"samples.chenhang/native_views_%lld", viewId] binaryMessenger:messenger];
//设置方法通道的处理回调
__weak __typeof__(self) weakSelf = self;
[_channel setMethodCallHandler:^(FlutterMethodCall* call, FlutterResult result) {
[weakSelf onMethodCall:call result:result];
}];
}
return self;
}
//响应方法调用消息
- (void)onMethodCall:(FlutterMethodCall*)call result:(FlutterResult)result {
//如果方法名完全匹配
if ([[call method] isEqualToString:@"changeBackgroundColor"]) {
//修改视图背景色,返回成功
_templcateView.backgroundColor = [UIColor blueColor];
result(@0);
} else {
//调用方发起了一个不支持的API调用
result(FlutterMethodNotImplemented);
}
}
...
@end
通过注册方法通道以及暴露的changeBackgroundColor接口Android端与iOS端修改平台视图背景颜色的功能都已经实现了。接下来我们就可以在Flutter应用运行期间修改原生视图展示样式了
class DefaultState extends State<DefaultPage> {
NativeViewController controller;
@override
void initState() {
controller = NativeViewController();//初始化原生View控制器
super.initState();
}
@override
Widget build(BuildContext context) {
return Scaffold(
...
//内嵌原生View
body: Container(width: 200, height:200,
child: SampleView(controller: controller)
),
//设置点击行为:改变视图颜色
floatingActionButton: FloatingActionButton(onPressed: ()=>controller.changeBackgroundColor())
);
}
}
运行一下,效果如下所示:
图4 动态修改原生视图样式
总结
好了,今天的分享就到这里。我们总结一下今天的主要内容吧。
平台视图解决了原生渲染能力的复用问题使得Flutter能够通过轻量级的代码封装把原生视图组装成一个Flutter控件。
Flutter提供了平台视图工厂和视图标识符两个概念因此Dart层发起的视图创建请求可以通过标识符直接找到对应的视图创建工厂从而实现原生视图与Flutter视图的融合复用。对于需要在运行期动态调用原生视图接口的需求我们可以在原生视图的封装类中注册方法通道实现精确控制原生视图展示的效果。
需要注意的是由于Flutter与原生渲染方式完全不同因此转换不同的渲染数据会有较大的性能开销。如果在一个界面上同时实例化多个原生控件就会对性能造成非常大的影响所以我们要避免在使用Flutter控件也能实现的情况下去使用内嵌平台视图。
因为这样做一方面需要分别在Android和iOS端写大量的适配桥接代码违背了跨平台技术的本意也增加了后续的维护成本另一方面毕竟除去地图、WebView、相机等涉及底层方案的特殊情况外大部分原生代码能够实现的UI效果完全可以用Flutter实现。
我把今天分享所涉及到的知识点打包到了GitHub中你可以下载下来反复运行几次加深理解。
思考题
最后,我给你留下一道思考题吧。
请你在动态调整原生视图样式的代码基础上,增加颜色参数,以实现动态变更原生视图颜色的需求。
欢迎你在评论区给我留言分享你的观点,我会在下一篇文章中等待你!感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。

View File

@@ -0,0 +1,273 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
28 如何在原生应用中混编Flutter工程
你好我是陈航。今天我来和你聊聊如何在原生应用中接入Flutter。
在前面两篇文章中我与你分享了如何在Dart层引入Android/iOS平台特定的能力来提升App的功能体验。
使用Flutter从头开始写一个App是一件轻松惬意的事情。但对于成熟产品来说完全摒弃原有App的历史沉淀而全面转向Flutter并不现实。用Flutter去统一iOS/Android技术栈把它作为已有原生App的扩展能力通过逐步试验有序推进从而提升终端开发效率可能才是现阶段Flutter最具吸引力的地方。
那么Flutter工程与原生工程该如何组织管理不同平台的Flutter工程打包构建产物该如何抽取封装封装后的产物该如何引入原生工程原生工程又该如何使用封装后的Flutter能力
这些问题使得在已有原生App中接入Flutter看似并不是一件容易的事情。那接下来我就和你介绍下如何在原生App中以最自然的方式接入Flutter。
准备工作
既然是要在原生应用中混编Flutter相信你一定已经准备好原生应用工程来实施今天的改造了。如果你还没有准备好也没关系我会以一个最小化的示例和你演示这个改造过程。
首先我们分别用Xcode与Android Studio快速建立一个只有首页的基本工程工程名分别为iOSDemo与AndroidDemo。
这时Android工程就已经准备好了而对于iOS工程来说由于基本工程并不支持以组件化的方式管理项目因此我们还需要多做一步将其改造成使用CocoaPods管理的工程也就是要在iOSDemo根目录下创建一个只有基本信息的Podfile文件
use_frameworks!
platform :ios, '8.0'
target 'iOSDemo' do
#todo
end
然后在命令行输入pod install后会自动生成一个iOSDemo.xcworkspace文件这时我们就完成了iOS工程改造。
Flutter混编方案介绍
如果你想要在已有的原生App里嵌入一些Flutter页面有两个办法
将原生工程作为Flutter工程的子工程由Flutter统一管理。这种模式就是统一管理模式。
将Flutter工程作为原生工程共用的子模块维持原有的原生工程管理方式不变。这种模式就是三端分离模式。
图1 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的工程结构比较特殊包括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打开它其目录如下图所示
图2 Flutter模块工程结构
可以看到和传统的Flutter工程相比Flutter模块工程也有内嵌的Android工程与iOS工程因此我们可以像普通工程一样使用Android Studio进行开发调试。
仔细查看可以发现Flutter模块有一个细微的变化Android工程下多了一个Flutter目录这个目录下的build.gradle配置就是我们构建aar的打包配置。这就是模块工程既能像Flutter传统工程一样使用Android Studio开发调试又能打包构建aar与pod的秘密。
实际上iOS工程的目录结构也有细微变化但这个差异并不影响打包构建因此我就不再展开了。
然后我们打开main.dart文件将其逻辑更新为以下代码逻辑即一个写着“Hello from Flutter”的全屏红色的Flutter Widget
import 'package:flutter/material.dart';
import 'dart:ui';
void main() => runApp(_widgetForRoute(window.defaultRouteName));//独立运行传入默认路由
Widget _widgetForRoute(String route) {
switch (route) {
default:
return MaterialApp(
home: Scaffold(
backgroundColor: const Color(0xFFD63031),//ARGB红色
body: Center(
child: Text(
'Hello from Flutter', //显示的文字
textDirection: TextDirection.ltr,
style: TextStyle(
fontSize: 20.0,
color: Colors.blue,
),
),
),
),
);
}
}
注意我们创建的Widget实际上是包在一个switch-case语句中的。这是因为封装的Flutter模块一般会有多个页面级Widget原生App代码则会通过传入路由标识字符串告诉Flutter究竟应该返回何种Widget。为了简化案例在这里我们忽略标识字符串统一返回一个MaterialApp。
接下来我们要做的事情就是把这段代码编译打包构建出对应的Android和iOS依赖库实现原生工程的接入。
现在我们首先来看看Android工程如何接入。
Android模块集成
之前我们提到原生工程对Flutter的依赖主要分为两部分对应到Android平台这两部分分别是
Flutter库和引擎也就是icudtl.dat、libFlutter.so还有一些class文件。这些文件都封装在Flutter.jar中。
Flutter工程产物主要包括应用程序数据段isolate_snapshot_data、应用程序指令段isolate_snapshot_instr、虚拟机数据段vm_snapshot_data、虚拟机指令段vm_snapshot_instr、资源文件Flutter_assets。
搞清楚Flutter工程的Android编译产物之后我们对Android的Flutter依赖抽取步骤如下
首先在Flutter_library的根目录下执行aar打包构建命令
Flutter build apk --debug
这条命令的作用是编译工程产物并将Flutter.jar和工程产物编译结果封装成一个aar。你很快就会想到如果是构建release产物只需要把debug换成release就可以了。
其次打包构建的flutter-debug.aar位于.android/Flutter/build/outputs/aar/目录下我们把它拷贝到原生Android工程AndroidDemo的app/libs目录下并在App的打包配置build.gradle中添加对它的依赖:
...
repositories {
flatDir {
dirs 'libs' // aar目录
}
}
android {
...
compileOptions {
sourceCompatibility 1.8 //Java 1.8
targetCompatibility 1.8 //Java 1.8
}
...
}
dependencies {
...
implementation(name: 'flutter-debug', ext: 'aar')//Flutter模块aar
...
}
Sync一下Flutter模块就被添加到了Android项目中。
再次我们试着改一下MainActivity.java的代码把它的contentView改成Flutter的widget
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
View FlutterView = Flutter.createView(this, getLifecycle(), "defaultRoute"); //传入路由标识符
setContentView(FlutterView);//用FlutterView替代Activity的ContentView
}
最后点击运行可以看到一个写着“Hello from Flutter”的全屏红色的Flutter Widget就展示出来了。至此我们完成了Android工程的接入。
图3 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' => '[email protected]' }
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工程的接入我们也顺利搞定了。
图4 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工程将不同平台的构建产物依照标准组件化的形式进行管理针对Android平台打包构建生成aar通过build.gradle进行依赖管理针对iOS平台打包构建生成framework将其封装成独立的pod并通过podfile进行依赖管理。
我把今天分享所涉及到的知识点打包到了GitHubflutter_module_page、iOS_demo、Android_Demo你可以下载下来反复运行几次加深理解与记忆。
思考题
最后,我给你下留一个思考题吧。
对于有资源依赖的Flutter模块工程而言其打包构建的产物以及抽离Flutter组件库的过程会有什么不同吗
欢迎你在评论区给我留言分享你的观点,我会在下一篇文章中等待你!感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。

View File

@@ -0,0 +1,272 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
29 混合开发,该用何种方案管理导航栈?
你好,我是陈航。
为了把Flutter引入到原生工程我们需要把Flutter工程改造为原生工程的一个组件依赖并以组件化的方式管理不同平台的Flutter构建产物即Android平台使用aar、iOS平台使用pod进行依赖管理。这样我们就可以在Android工程中通过FlutterViewiOS工程中通过FlutterViewController为Flutter搭建应用入口实现Flutter与原生的混合开发方式。
我在第26篇文章中提到FlutterView与FlutterViewController是初始化Flutter的地方也是应用的入口。可以看到以混合开发方式接入Flutter与开发一个纯Flutter应用在运行机制上并无任何区别只需要原生工程为它提供一个画板容器Android为FlutterViewiOS为FlutterViewControllerFlutter就可以自己管理页面导航栈从而实现多个复杂页面的渲染和切换。
关于纯Flutter应用的页面路由与导航我已经在第21篇文章中与你介绍过了。今天这篇文章我会为你讲述在混合开发中应该如何管理混合导航栈。
对于混合开发的应用而言通常我们只会将应用的部分模块修改成Flutter开发其他模块继续保留原生开发因此应用内除了Flutter的页面之外还会有原生Android、iOS的页面。在这种情况下Flutter页面有可能会需要跳转到原生页面而原生页面也可能会需要跳转到Flutter页面。这就涉及到了一个新的问题如何统一管理原生页面和Flutter页面跳转交互的混合导航栈。
接下来,我们就从这个问题入手,开始今天的学习吧。
混合导航栈
混合导航栈指的是原生页面和Flutter页面相互掺杂存在于用户视角的页面导航栈视图中。
以下图为例Flutter与原生Android、iOS各自实现了一套互不相同的页面映射机制即原生采用单容器单页面一个ViewController/Activity对应一个原生页面、Flutter采用单容器多页面一个ViewController/Activity对应多个Flutter页面的机制。Flutter在原生的导航栈之上又自建了一套Flutter导航栈这使得Flutter页面与原生页面之间涉及页面切换时我们需要处理跨引擎的页面切换。
图1 混合导航栈示意图
接下来我们就分别看看从原生页面跳转至Flutter页面以及从Flutter页面跳转至原生页面应该如何处理吧。
从原生页面跳转至Flutter页面
从原生页面跳转至Flutter页面实现起来比较简单。
因为Flutter本身依托于原生提供的容器iOS为FlutterViewControllerAndroid为Activity中的FlutterView所以我们通过初始化Flutter容器为其设置初始路由页面之后就可以以原生的方式跳转至Flutter页面了。
如下代码所示。对于iOS我们初始化一个FlutterViewController的实例为其设置初始化页面路由后将其加入原生的视图导航栈中完成跳转。
对于Android而言则需要多加一步。因为Flutter页面的入口并不是原生视图导航栈的最小单位Activity而是一个View即FlutterView所以我们还需要把这个View包装到Activity的contentView中。在Activity内部设置页面初始化路由之后在外部就可以采用打开一个普通的原生视图的方式打开Flutter页面了。
//iOS 跳转至Flutter页面
FlutterViewController *vc = [[FlutterViewController alloc] init];
[vc setInitialRoute:@"defaultPage"];//设置Flutter初始化路由页面
[self.navigationController pushViewController:vc animated:YES];//完成页面跳转
//Android 跳转至Flutter页面
//创建一个作为Flutter页面容器的Activity
public class FlutterHomeActivity extends AppCompatActivity {
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
//设置Flutter初始化路由页面
View FlutterView = Flutter.createView(this, getLifecycle(), "defaultRoute"); //传入路由标识符
setContentView(FlutterView);//用FlutterView替代Activity的ContentView
}
}
//用FlutterPageActivity完成页面跳转
Intent intent = new Intent(MainActivity.this, FlutterHomeActivity.class);
startActivity(intent);
从Flutter页面跳转至原生页面
从Flutter页面跳转至原生页面则会相对麻烦些我们需要考虑以下两种场景
从Flutter页面打开新的原生页面
从Flutter页面回退到旧的原生页面。
首先我们来看看Flutter如何打开原生页面。
Flutter并没有提供对原生页面操作的方法所以不可以直接调用。我们需要通过方法通道你可以再回顾下第26篇文章的相关内容在Flutter和原生两端各自初始化时提供Flutter操作原生页面的方法并注册方法通道在原生端收到Flutter的方法调用时打开新的原生页面。
接下来我们再看看如何从Flutter页面回退到原生页面。
因为Flutter容器本身属于原生导航栈的一部分所以当Flutter容器内的根页面即初始化路由页面需要返回时我们需要关闭Flutter容器从而实现Flutter根页面的关闭。同样Flutter并没有提供操作Flutter容器的方法因此我们依然需要通过方法通道在原生代码宿主为Flutter提供操作Flutter容器的方法在页面返回时关闭Flutter页面。
Flutter跳转至原生页面的两种场景如下图所示
图2 Flutter页面跳转至原生页面示意图
接下来我们一起看看这两个需要通过方法通道实现的方法即打开原生页面openNativePage与关闭Flutter页面closeFlutterPage在Android和iOS平台上分别如何实现。
注册方法通道最合适的地方是Flutter应用的入口即在FlutterViewControlleriOS端和Activity中的FlutterViewAndroid端这两个容器内部初始化Flutter页面前。为了将Flutter相关的行为封装到容器内部我们需要分别继承FlutterViewController和Activity在其viewDidLoad和onCreate初始化容器时注册openNativePage和closeFlutterPage这两个方法。
iOS端的实现代码如下所示
@interface FlutterHomeViewController : FlutterViewController
@end
@implementation FlutterHomeViewController
- (void)viewDidLoad {
[super viewDidLoad];
//声明方法通道
FlutterMethodChannel* channel = [FlutterMethodChannel methodChannelWithName:@"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
));
}
}
整个混合导航栈示例的代码流程,如下图所示。通过这张图,你就可以把这个示例的整个代码流程串起来了。
图3 混合导航栈示例
在我们的混合应用中RootViewController与MainActivity分别是iOS和Android应用的原生页面入口可以初始化为Flutter容器的FlutterHomeViewControlleriOS端与FlutterHomeActivityAndroid端
在为其设置初始路由页面DefaultPage之后就可以以原生的方式跳转至Flutter页面。但是Flutter并未提供接口来支持从Flutter的DefaultPage页面返回到原生页面因此我们需要利用方法通道来注册关闭Flutter容器的方法即closeFlutterPage让Flutter容器接收到这个方法调用时关闭自身。
在Flutter容器内部我们可以使用Flutter内部的页面路由机制通过Navigator.push方法完成从DefaultPage到PageA的页面跳转而当我们想从Flutter的PageA页面跳转到原生页面时因为涉及到跨引擎的页面路由所以我们仍然需要利用方法通道来注册打开原生页面的方法即openNativePage让 Flutter容器接收到这个方法调用时在原生代码宿主完成原生页面SomeOtherNativeViewControlleriOS端与SomeNativePageActivityAndroid端的初始化并最终完成页面跳转。
总结
好了,今天的分享就到这里。我们一起总结下今天的主要内容吧。
对于原生Android、iOS工程混编Flutter开发由于应用中会同时存在Android、iOS和Flutter页面所以我们需要妥善处理跨渲染引擎的页面跳转解决原生页面如何切换Flutter页面以及Flutter页面如何切换到原生页面的问题。
在原生页面切换到Flutter页面时我们通常会将Flutter容器封装成一个独立的ViewControlleriOS端或ActivityAndroid端在为其设置好Flutter容器的页面初始化路由即根视图原生的代码就可以按照打开一个普通的原生页面的方式来打开Flutter页面了。
而如果我们想在Flutter页面跳转到原生页面则需要同时处理好打开新的原生页面以及关闭自身回退到老的原生页面两种场景。在这两种场景下我们都需要利用方法通道来注册相应的处理方法从而在原生代码宿主实现新页面的打开和Flutter容器的关闭。
需要注意的是与纯Flutter应用不同原生应用混编Flutter由于涉及到原生页面与Flutter页面之间切换因此导航栈内可能会出现多个Flutter容器的情况即多个Flutter实例。
Flutter实例的初始化成本非常高昂每启动一个Flutter实例就会创建一套新的渲染机制即Flutter Engine以及底层的Isolate。而这些实例之间的内存是不互相共享的会带来较大的系统资源消耗。
因此我们在实际业务开发中应该尽量用Flutter去开发闭环的业务模块原生只需要能够跳转到Flutter模块剩下的业务都应该在Flutter内部完成而尽量避免Flutter页面又跳回到原生页面原生页面又启动新的Flutter实例的情况。
为了解决混编工程中Flutter多实例的问题业界有两种解决方案
以今日头条为代表的修改Flutter Engine源码使多FlutterView实例对应的多Flutter Engine能够在底层共享Isolate
以闲鱼为代表的共享FlutterView即由原生层驱动Flutter层渲染内容的方案。
坦白说,这两种方案各有不足:
前者涉及到修改Flutter源码不仅开发维护成本高而且增加了线程模型和内存回收出现异常的概率稳定性不可控。
后者涉及到跨渲染引擎的hack包括Flutter页面的新建、缓存和内存回收等机制因此在一些低端机或是处理页面切换动画时容易出现渲染Bug。
除此之外这两种方式均与Flutter的内部实现绑定较紧因此在处理Flutter SDK版本升级时往往需要耗费较大的适配成本。
综合来说目前这两种解决方案都不够完美。所以在Flutter官方支持多实例单引擎之前我们还是尽量在产品模块层面保证应用内不要出现多个Flutter容器实例吧。
我把今天分享所涉及到的知识点打包到了GitHubflutter_module_page、android_demo、iOS_demo你可以下载下来反复运行几次加深理解与记忆。
思考题
最后,我给你留两道思考题吧。
请在openNativePage方法的基础上增加页面id的功能可以支持在Flutter页面打开任意的原生页面。
混编工程中会出现两种页面过渡动画原生页面之间的切换动画、Flutter页面之间的切换动画。请你思考下如何能够确保这两种页面过渡动画在应用整体的效果是一致的。
欢迎你在评论区给我留言分享你的观点,我会在下一篇文章中等待你!感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。

View File

@@ -0,0 +1,290 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
30 为什么需要做状态管理,怎么做?
你好,我是陈航。
在上一篇文章中我与你分享了如何在原生混编Flutter工程中管理混合导航栈应对跨渲染引擎的页面跳转即解决原生页面如何切换到Flutter页面以及Flutter页面如何切换到原生页面的问题。
如果说跨渲染引擎页面切换的关键在于如何确保页面跳转的渲染体验一致性那么跨组件页面之间保持数据共享的关键就在于如何清晰地维护组件共用的数据状态了。在第20篇文章“关于跨组件传递数据你只需要记住这三招”中我已经与你介绍了InheritedWidget、Notification和EventBus这3种数据传递机制通过它们可以实现组件间的单向数据传递。
如果我们的应用足够简单数据流动的方向和顺序是清晰的我们只需要将数据映射成视图就可以了。作为声明式的框架Flutter可以自动处理数据到渲染的全过程通常并不需要状态管理。
但,随着产品需求迭代节奏加快,项目逐渐变得庞大时,我们往往就需要管理不同组件、不同页面之间共享的数据关系。当需要共享的数据关系达到几十上百个的时候,我们就很难保持清晰的数据流动方向和顺序了,导致应用内各种数据传递嵌套和回调满天飞。在这个时候,我们迫切需要一个解决方案,来帮助我们理清楚这些共享数据的关系,于是状态管理框架便应运而生。
Flutter在设计声明式UI上借鉴了不少React的设计思想因此涌现了诸如flutter_redux、flutter_mobx 、fish_redux等基于前端设计理念的状态管理框架。但这些框架大都比较复杂且需要对框架设计概念有一定理解学习门槛相对较高。
而源自Flutter官方的状态管理框架Provider则相对简单得多不仅容易理解而且框架的入侵性小还可以方便地组合和控制UI刷新粒度。因此在Google I/O 2019大会一经面世Provider就成为了官方推荐的状态管理方式之一。
那么今天我们就来聊聊Provider到底怎么用吧。
Provider
从名字就可以看出Provider是一个用来提供数据的框架。它是InheritedWidget的语法糖提供了依赖注入的功能允许在Widget树中更加灵活地处理和传递数据。
那么,什么是依赖注入呢?通俗地说,依赖注入是一种可以让我们在需要时提取到所需资源的机制,即:预先将某种“资源”放到程序中某个我们都可以访问的位置,当需要使用这种“资源”时,直接去这个位置拿即可,而无需关心“资源”是谁放进去的。
所以为了使用Provider我们需要解决以下3个问题
资源(即数据状态)如何封装?
资源放在哪儿,才都能访问得到?
具体使用时,如何取出资源?
接下来我通过一个例子来与你演示如何使用Provider。
在下面的示例中我们有两个独立的页面FirstPage和SecondPage它们会共享计数器的状态其中FirstPage负责读SecondPage负责读和写。
在使用Provider之前我们首先需要在pubspec.yaml文件中添加Provider的依赖
dependencies:
flutter:
sdk: flutter
provider: 3.0.0+1 #provider依赖
添加好Provider的依赖后我们就可以进行数据状态的封装了。这里我们只有一个状态需要共享即count。由于第二个页面还需要修改状态因此我们还需要在数据状态的封装上包含更改数据的方法
//定义需要共享的数据模型通过混入ChangeNotifier管理听众
class CounterModel with ChangeNotifier {
int _count = 0;
//读方法
int get counter => _count;
//写方法
void increment() {
_count++;
notifyListeners();//通知听众刷新
}
}
可以看到我们在资源封装类中使用mixin混入了ChangeNotifier。这个类能够帮助我们管理所有依赖资源封装类的听众。当资源封装类调用notifyListeners时它会通知所有听众进行刷新。
资源已经封装完毕,接下来我们就需要考虑把它放到哪儿了。
因为Provider实际上是InheritedWidget的语法糖所以通过Provider传递的数据从数据流动方向来看是由父到子或者反过来。这时我们就明白了原来需要把资源放到FirstPage和SecondPage的父Widget也就是应用程序的实例MyApp中当然把资源放到更高的层级也是可以的比如放到main函数中
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
//通过Provider组件封装数据资源
return ChangeNotifierProvider.value(
value: CounterModel(),//需要共享的数据资源
child: MaterialApp(
home: FirstPage(),
)
);
}
}
可以看到既然Provider是InheritedWidget的语法糖因此它也是一个Widget。所以我们直接在MaterialApp的外层使用Provider进行包装就可以把数据资源依赖注入到应用中。
这里需要注意的是由于封装的数据资源不仅需要为子Widget提供读的能力还要提供写的能力因此我们需要使用Provider的升级版ChangeNotifierProvider。而如果只需要为子Widget提供读能力直接使用Provider即可。
最后在注入数据资源完成之后我们就可以在FirstPage和SecondPage这两个子Widget完成数据的读写操作了。
关于读数据与InheritedWidget一样我们可以通过Provider.of方法来获取资源数据。而如果我们想写数据则需要通过获取到的资源数据调用其暴露的更新数据方法本例中对应的是increment代码如下所示
//第一个页面,负责读数据
class FirstPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
//取出资源
final _counter = Provider.of<CounterModel>(context);
return Scaffold(
//展示资源中的数据
body: Text('Counter: ${_counter.counter}'),
//跳转到SecondPage
floatingActionButton: FloatingActionButton(
onPressed: () => Navigator.of(context).push(MaterialPageRoute(builder: (context) => SecondPage()))
));
}
}
//第二个页面,负责读写数据
class SecondPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
//取出资源
final _counter = Provider.of<CounterModel>(context);
return Scaffold(
//展示资源中的数据
body: Text('Counter: ${_counter.counter}'),
//用资源更新方法来设置按钮点击回调
floatingActionButton:FloatingActionButton(
onPressed: _counter.increment,
child: Icon(Icons.add),
));
}
}
运行代码,试着多点击几次第二个界面的“+”按钮,关闭第二个界面,可以看到第一个界面也同步到了按钮的点击数。
图1 Provider使用示例
Consumer
通过上面的示例可以看到使用Provider.of获取资源可以得到资源暴露的数据的读写接口在实现数据的共享和同步上还是比较简单的。但是滥用Provider.of方法也有副作用那就是当数据更新时页面中其他的子Widget也会跟着一起刷新。
为验证这一点我们以第二个界面右下角FloatingActionButton中的子Widget “+”Icon为例做个测试。
首先为了打印出Icon控件每一次刷新的情况我们需要自定义一个控件TestIcon并在其build方法中返回Icon实例的同时打印一句话
//用于打印build方法执行情况的自定义控件
class TestIcon extends StatelessWidget {
@override
Widget build(BuildContext context) {
print("TestIcon build");
return Icon(Icons.add);//返回Icon实例
}
}
然后我们用TestIcon控件替换掉SecondPage中FloatingActionButton的Icon子Widget
class SecondPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
//取出共享的数据资源
final _counter = Provider.of<CounterModel>(context);
return Scaffold(
...
floatingActionButton:FloatingActionButton(
onPressed: _counter.increment,
child: TestIcon(),//替换掉原有的Icon(Icons.add)
));
}
运行这段实例,然后在第二个页面多次点击“+”按钮,观察控制台输出:
I/flutter (21595): TestIcon build
I/flutter (21595): TestIcon build
I/flutter (21595): TestIcon build
I/flutter (21595): TestIcon build
I/flutter (21595): TestIcon build
可以看到TestIcon控件本来是一个不需要刷新的StatelessWidget但却因为其父Widget FloatingActionButton所依赖的数据资源counter发生了变化导致它也要跟着刷新。
那么有没有办法能够在数据资源发生变化时只刷新对资源存在依赖关系的Widget而其他Widget保持不变呢
答案当然是可以的。
在本次分享一开始时我曾说Provider可以精确地控制UI刷新粒度而这一切是基于Consumer实现的。Consumer使用了Builder模式创建UI收到更新通知就会通过builder重新构建Widget。
接下来我们就看看如何使用Consumer来改造SecondPage吧。
在下面的例子中我们在SecondPage中去掉了Provider.of方法来获取counter的语句在其真正需要这个数据资源的两个子Widget即Text和FloatingActionButton中使用Consumer来对它们进行了一层包装
class SecondPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
//使用Consumer来封装counter的读取
body: Consumer<CounterModel>(
//builder函数可以直接获取到counter参数
builder: (context, CounterModel counter, _) => Text('Value: ${counter.counter}')),
//使用Consumer来封装increment的读取
floatingActionButton: Consumer<CounterModel>(
//builder函数可以直接获取到increment参数
builder: (context, CounterModel counter, child) => FloatingActionButton(
onPressed: counter.increment,
child: child,
),
child: TestIcon(),
),
);
}
}
可以看到Consumer中的builder实际上就是真正刷新UI的函数它接收3个参数即context、model和child。其中context是Widget的build方法传进来的BuildContextmodel是我们需要的数据资源而child则用来构建那些与数据资源无关的部分。在数据资源发生变更时builder会多次执行但child不会重建。
运行这段代码,可以发现,不管我们点击了多少次“+”按钮TestIcon控件始终没有发生销毁重建。
多状态的资源封装
通过上面的例子我们学习了Provider是如何共享一个数据状态的。那么如果有多个数据状态需要共享我们又该如何处理呢
其实也不难。接下来我就按照封装、注入和读写这3个步骤与你介绍多个数据状态的共享。
在处理多个数据状态共享之前我们需要先扩展一下上面计数器状态共享的例子让两个页面之间展示计数器数据的Text能够共享App传递的字体大小。
首先,我们来看看如何封装。
多个数据状态与单个数据的封装并无不同,如果需要支持数据的读写,我们需要一个接一个地为每一个数据状态都封装一个单独的资源封装类;而如果数据是只读的,则可以直接传入原始的数据对象,从而省去资源封装的过程。
接下来,我们再看看如何实现注入。
在单状态的案例中我们通过Provider的升级版ChangeNotifierProvider实现了可读写资源的注入而如果我们想注入多个资源则可以使用Provider的另一个升级版MultiProvider来实现多个Provider的组合注入。
在下面的例子中我们通过MultiProvider往App实例内注入了double和CounterModel这两个资源Provider
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MultiProvider(providers: [
Provider.value(value: 30.0),//注入字体大小
ChangeNotifierProvider.value(value: CounterModel())//注入计数器实例
],
child: MaterialApp(
home: FirstPage(),
));
}
}
在完成了多个资源的注入后,最后我们来看看如何获取这些资源。
这里我们还是使用Provider.of方式来获取资源。相较于单状态资源的获取来说获取多个资源时我们只需要依次读取每一个资源即可
final _counter = Provider.of<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个数据资源。
总结
好了,今天的分享就到这里,我们总结一下今天的主要内容吧。
我与你介绍了在Flutter中通过Provider进行状态管理的方法Provider以InheritedWidget语法糖的方式通过数据资源封装、数据注入和数据读写这3个步骤为我们实现了跨组件跨页面之间的数据共享。
我们既可以用Provider来实现静态的数据读传递也可以使用ChangeNotifierProvider来实现动态的数据读写传递还可以通过MultiProvider来实现多个数据资源的共享。
在具体使用数据时Provider.of和Consumer都可以实现数据的读取并且Consumer还可以控制UI刷新的粒度避免与数据无关的组件的无谓刷新。
可以看到通过Provider来实现数据传递无论在单个页面内还是在整个App之间我们都可以很方便地实现状态管理搞定那些通过StatefulWidget无法实现的场景进而开发出简单、层次清晰、可扩展性高的应用。事实上当我们使用Provider后我们就再也不需要使用StatefulWidget了。
我把今天分享所涉及到的知识点打包到了GitHub中你可以下载下来反复运行几次加深理解与记忆。
思考题
最后,我给你留一道思考题吧。
使用Provider可以实现2个同样类型的对象共享你知道应该如何实现吗
欢迎你在评论区给我留言分享你的观点,我会在下一篇文章中等待你!感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。

View File

@@ -0,0 +1,540 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
31 如何实现原生推送能力?
你好,我是陈航。
在上一篇文章中我与你分享了如何使用Provider去维护Flutter组件共用的数据状态。在Flutter中状态即数据通过数据资源封装、注入和读写这三步我们不仅可以实现跨组件之间的数据共享还能精确控制UI刷新粒度避免无关组件的刷新。
其实,数据共享不仅存在于客户端内部,同样也存在于服务端与客户端之间。比如,有新的微博评论,或者是发生了重大新闻,我们都需要在服务端把这些状态变更的消息实时推送到客户端,提醒用户有新的内容。有时,我们还会针对特定的用户画像,通过推送实现精准的营销信息触达。
可以说,消息推送是增强用户黏性,促进用户量增长的重要手段。那么,消息推送的流程是什么样的呢?
消息推送流程
手机推送每天那么多,导致在我们看来这很简单啊。但其实,消息推送是一个横跨业务服务器、第三方推送服务托管厂商、操作系统长连接推送服务、用户终端、手机应用五方的复杂业务应用场景。
在iOS上苹果推送服务APNs接管了系统所有应用的消息通知需求而Android原生则提供了类似Firebase的云消息传递机制FCM可以实现统一的推送托管服务。
当某应用需要发送消息通知时这则消息会由应用的服务器先发给苹果或Google经由APNs或FCM被发送到设备设备操作系统在完成解析后最终把消息转给所属应用。这个流程的示意图如下所示。
图1 原生消息推送流程
不过Google服务在大陆地区使用并不稳定因此国行Android手机通常会把Google服务换成自己的服务定制一套推送标准。而这对开发者来说无疑是增大了适配负担。所以针对Android端我们通常会使用第三方推送服务比如极光推送、友盟推送等。
虽然这些第三方推送服务使用自建的长连接无法享受操作系统底层的优化但它们会对所有使用推送服务的App共享推送通道只要有一个使用第三方推送服务的应用没被系统杀死就可以让消息及时送达。
而另一方面这些第三方服务简化了业务服务器与手机推送服务建立连接的操作使得我们的业务服务器通过简单的API调用就可以完成消息推送。
而为了保持Android/iOS方案的统一在iOS上我们也会使用封装了APNs通信的第三方推送服务。
第三方推送的服务流程,如下图所示。
图2 第三方推送服务流程
这些第三方推送服务厂商提供的能力和接入流程大都一致考虑到极光的社区和生态相对活跃所以今天我们就以极光为例来看看在Flutter应用中如何才能引用原生的推送能力。
原生推送接入流程
要想在Flutter中接收推送消息我们需要把原生的推送能力暴露给Flutter应用即在原生代码宿主实现推送能力极光SDK的接入并通过方法通道提供给Dart层感知推送消息的机制。
插件工程
在第26篇文章中我们学习了如何在原生工程中的Flutter应用入口注册原生代码宿主回调从而实现Dart层调用原生接口的方案。这种方案简单直接适用于Dart层与原生接口之间交互代码量少、数据流动清晰的场景。
但对于推送这种涉及Dart与原生多方数据流转、代码量大的模块这种与工程耦合的方案就不利于独立开发维护了。这时我们需要使用Flutter提供的插件工程对其进行单独封装。
Flutter的插件工程与普通的应用工程类似都有android和ios目录这也是我们完成平台相关逻辑代码的地方而Flutter工程插件的注册则仍会在应用的入口完成。除此之外插件工程还内嵌了一个example工程这是一个引用了插件代码的普通Flutter应用工程。我们通过example工程可以直接调试插件功能。
图3 插件工程目录结构
在了解了整体工程的目录结构之后接下来我们需要去Dart插件代码所在的flutter_push_plugin.dart文件实现Dart层的推送接口封装。
Dart接口实现
为了实现消息的准确触达我们需要提供一个可以标识手机上App的地址即token或id。一旦完成地址的上报我们就可以等待业务服务器给我们发消息了。
因为我们需要使用极光这样的第三方推送服务所以还得进行一些前置的应用信息关联绑定以及SDK的初始化工作。可以看到对于一个应用而言接入推送的过程可以拆解为以下三步
初始化极光SDK
获取地址id
注册消息通知。
这三步对应着在Dart层需要封装的3个原生接口调用setup、registrationID和setOpenNotificationHandler。
前两个接口是在方法通道上调用原生代码宿主提供的方法而注册消息通知的回调函数setOpenNotificationHandler则相反是原生代码宿主在方法通道上调用Dart层所提供的事件回调因此我们需要在方法通道上为原生代码宿主注册反向回调方法让原生代码宿主收到消息后可以直接通知它。
另外考虑到推送是整个应用共享的能力因此我们将FlutterPushPlugin这个类封装成了单例
//Flutter Push插件
class FlutterPushPlugin {
//单例
static final FlutterPushPlugin _instance = new FlutterPushPlugin.private(const MethodChannel('flutter_push_plugin'));
//方法通道
final MethodChannel _channel;
//消息回调
EventHandler _onOpenNotification;
//构造方法
FlutterPushPlugin.private(MethodChannel channel) : _channel = channel {
//注册原生反向回调方法让原生代码宿主可以执行onOpenNotification方法
_channel.setMethodCallHandler(_handleMethod);
}
//初始化极光SDK
setupWithAppID(String appID) {
_channel.invokeMethod("setup", appID);
}
//注册消息通知
setOpenNotificationHandler(EventHandler onOpenNotification) {
_onOpenNotification = onOpenNotification;
}
//注册原生反向回调方法让原生代码宿主可以执行onOpenNotification方法
Future<Null> _handleMethod(MethodCall call) {
switch (call.method) {
case "onOpenNotification":
return _onOpenNotification(call.arguments);
default:
throw new UnsupportedError("Unrecognized Event");
}
}
//获取地址id
Future<String> get registrationID async {
final String regID = await _channel.invokeMethod('getRegistrationID');
return regID;
}
}
Dart层是原生代码宿主的代理可以看到这一层的接口设计算是简单。接下来我们分别去接管推送的Android和iOS平台上完成相应的实现。
Android接口实现
考虑到Android平台的推送配置工作相对较少因此我们先用Android Studio打开example下的android工程进行插件开发工作。需要注意的是由于android子工程的运行依赖于Flutter工程编译构建产物所以在打开android工程进行开发前你需要确保整个工程代码至少build过一次否则IDE会报错。
备注以下操作步骤参考极光Android SDK集成指南。
首先我们需要在插件工程下的build.gradle引入极光SDK即jpush与jcore
dependencies {
implementation 'cn.jiguang.sdk:jpush:3.3.4'
implementation 'cn.jiguang.sdk:jcore:2.1.2'
}
然后在原生接口FlutterPushPlugin类中依次把Dart层封装的3个接口调用即setup、getRegistrationID与onOpenNotification提供极光Android SDK的实现版本。
需要注意的是由于极光Android SDK的信息绑定是在应用的打包配置里设置并不需要通过代码完成iOS才需要因此setup方法的Android版本是一个空实现
public class FlutterPushPlugin implements MethodCallHandler {
//注册器通常为MainActivity
public final Registrar registrar;
//方法通道
private final MethodChannel channel;
//插件实例
public static FlutterPushPlugin instance;
//注册插件
public static void registerWith(Registrar registrar) {
//注册方法通道
final MethodChannel channel = new MethodChannel(registrar.messenger(), "flutter_push_plugin");
instance = new FlutterPushPlugin(registrar, channel);
channel.setMethodCallHandler(instance);
//把初始化极光SDK提前至插件注册时
JPushInterface.setDebugMode(true);
JPushInterface.init(registrar.activity().getApplicationContext());
}
//私有构造方法
private FlutterPushPlugin(Registrar registrar, MethodChannel channel) {
this.registrar = registrar;
this.channel = channel;
}
//方法回调
@Override
public void onMethodCall(MethodCall call, Result result) {
if (call.method.equals("setup")) {
//极光Android SDK的初始化工作需要在App工程中配置因此不需要代码实现
result.success(0);
}
else if (call.method.equals("getRegistrationID")) {
//获取极光推送地址标识符
result.success(JPushInterface.getRegistrationID(registrar.context()));
} else {
result.notImplemented();
}
}
public void callbackNotificationOpened(NotificationMessage message) {
//将推送消息回调给Dart层
channel.invokeMethod("onOpenNotification",message.notificationContent);
}
}
可以看到我们的FlutterPushPlugin类中仅提供了callbackNotificationOpened这个工具方法用于推送消息参数回调给Dart但这个类本身并没有去监听极光SDK的推送消息。
为了获取推送消息我们分别需要继承极光SDK提供的两个类JCommonService和JPushMessageReceiver。
JCommonService是一个后台Service实际上是极光共享长连通道的核心可以在多手机平台上使得推送通道更稳定。
JPushMessageReceiver则是一个BroadcastReceiver推送消息的获取都是通过它实现的。我们可以通过覆盖其onNotifyMessageOpened方法从而在用户点击系统推送消息时获取到通知。
作为BroadcastReceiver的JPushMessageReceiver可以长期在后台存活监听远端推送消息但Flutter可就不行了操作系统会随时释放掉后台应用所占用的资源。因此在用户点击推送时我们在收到相应的消息回调后需要做的第一件事情不是立刻通知Flutter而是应该启动应用的MainActivity。在确保Flutter已经完全初始化后才能通知Flutter有新的推送消息。
因此在下面的代码中我们在打开MainActivity后等待了1秒才执行相应的Flutter回调通知
//JPushXCustomService.java
//长连通道核心,可以使推送通道更稳定
public class JPushXCustomService extends JCommonService {
}
//JPushXMessageReceiver.java
//获取推送消息的Receiver
public class JPushXMessageReceiver extends JPushMessageReceiver {
//用户点击推送消息回调
@Override
public void onNotifyMessageOpened(Context context, final NotificationMessage message) {
try {
//找到MainActivity
String mainClassName = context.getApplicationContext().getPackageName() + ".MainActivity";
Intent i = new Intent(context, Class.forName(mainClassName));
i.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_SINGLE_TOP);
//启动主Activity
context.startActivity(i);
} catch (Exception e) {
Log.e("tag","找不到MainActivity");
}
new Timer().schedule(new TimerTask() {
@Override
public void run() {
FlutterPushPlugin.instance.callbackNotificationOpened(message);
}
},1000); // 延迟1秒通知Dart
}
}
最后我们还需要在插件工程的AndroidManifest.xml中分别声明receiver JPushXMessageReceiver和service JPushXCustomService完成对系统的注册
...
<application>
<!--注册推送消息接收类 -->
<receiver android:name=".JPushXMessageReceiver">
<intent-filter>
<action android:name="cn.jpush.android.intent.RECEIVE_MESSAGE" />
<category android:name="${applicationId}" />
</intent-filter>
</receiver>
<!--注册长连通道Service -->
<service android:name=".JPushXCustomService"
android:enabled="true"
android:exported="false"
android:process=":pushcore">
<intent-filter>
<action android:name="cn.jiguang.user.service.action" />
</intent-filter>
</service>
</application>
...
接收消息和回调消息的功能完成后FlutterPushPlugin插件的Android部分就搞定了。接下来我们去开发插件的iOS部分。
iOS接口实现
与Android类似我们需要使用Xcode打开example下的ios工程进行插件开发工作。同样在打开ios工程前你需要确保整个工程代码至少build过一次否则IDE会报错。
备注以下操作步骤参考极光iOS SDK集成指南
首先我们需要在插件工程下的flutter_push_plugin.podspec文件中引入极光SDK即jpush。这里我们选用了不使用广告id的版本
Pod::Spec.new do |s|
...
s.dependency 'JPush', '3.2.2-noidfa'
end
然后在原生接口FlutterPushPlugin类中同样依次为setup、getRegistrationID与onOpenNotification提供极光 iOS SDK的实现版本。
需要注意的是APNs的推送消息是在ApplicationDelegate中回调的所以我们需要在注册插件时为插件提供同名的回调函数让极光SDK把推送消息转发到插件的回调函数中。
与Android类似在极光SDK收到推送消息时我们的应用可能处于后台因此在用户点击了推送消息把Flutter应用唤醒时我们应该在确保Flutter已经完全初始化后才能通知Flutter有新的推送消息。
因此在下面的代码中我们在用户点击了推送消息后也等待了1秒才执行相应的Flutter回调通知
@implementation FlutterPushPlugin
//注册插件
+ (void)registerWithRegistrar:(NSObject<FlutterPluginRegistrar>*)registrar {
//注册方法通道
FlutterMethodChannel* channel = [FlutterMethodChannel methodChannelWithName:@"flutter_push_plugin" binaryMessenger:[registrar messenger]];
//初始化插件实例,绑定方法通道
FlutterPushPlugin* instance = [[FlutterPushPlugin alloc] init];
instance.channel = channel;
//为插件提供ApplicationDelegate回调方法
[registrar addApplicationDelegate:instance];
//注册方法通道回调函数
[registrar addMethodCallDelegate:instance channel:channel];
}
//处理方法调用
- (void)handleMethodCall:(FlutterMethodCall*)call result:(FlutterResult)result {
if([@"setup" isEqualToString:call.method]) {
//极光SDK初始化方法
[JPUSHService setupWithOption:self.launchOptions appKey:call.arguments channel:@"App Store" apsForProduction:YES advertisingIdentifier:nil];
} else if ([@"getRegistrationID" isEqualToString:call.method]) {
//获取极光推送地址标识符
[JPUSHService registrationIDCompletionHandler:^(int resCode, NSString *registrationID) {
result(registrationID);
}];
} else {
//方法未实现
result(FlutterMethodNotImplemented);
}
}
//应用程序启动回调
-(BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
//初始化极光推送服务
JPUSHRegisterEntity * entity = [[JPUSHRegisterEntity alloc] init];
//设置推送权限
entity.types = JPAuthorizationOptionAlert|JPAuthorizationOptionBadge|JPAuthorizationOptionSound;
//请求推送服务
[JPUSHService registerForRemoteNotificationConfig:entity delegate:self];
//存储App启动状态用于后续初始化调用
self.launchOptions = launchOptions;
return YES;
}
//推送token回调
- (void)application:(UIApplication *)application didRegisterForRemoteNotificationsWithDeviceToken:(NSData *)deviceToken {
///注册DeviceToken换取极光推送地址标识符
[JPUSHService registerDeviceToken:deviceToken];
}
//推送被点击回调
- (void)jpushNotificationCenter:(UNUserNotificationCenter *)center didReceiveNotificationResponse:(UNNotificationResponse *)response withCompletionHandler:(void(^)(void))completionHandler {
//获取推送消息
NSDictionary * userInfo = response.notification.request.content.userInfo;
NSString *content = userInfo[@"aps"][@"alert"];
if ([content isKindOfClass:[NSDictionary class]]) {
content = userInfo[@"aps"][@"alert"][@"body"];
}
//延迟1秒通知Flutter确保Flutter应用已完成初始化
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(1 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
[self.channel invokeMethod:@"onOpenNotification" arguments:content];
});
//清除应用的小红点
UIApplication.sharedApplication.applicationIconBadgeNumber = 0;
//通知系统,推送回调处理完毕
completionHandler();
}
//前台应用收到了推送消息
- (void)jpushNotificationCenter:(UNUserNotificationCenter *)center willPresentNotification:(UNNotification *)notification withCompletionHandler:(void (^)(NSInteger options))completionHandler {
//通知系统展示推送消息提示
completionHandler(UNNotificationPresentationOptionAlert);
}
@end
至此在完成了极光iOS SDK的接口封装之后FlutterPushPlugin插件的iOS部分也搞定了。
FlutterPushPlugin插件为Flutter应用提供了原生推送的封装不过要想example工程能够真正地接收到推送消息我们还需要对exmaple工程进行最后的配置为它提供应用推送证书并关联极光应用配置。
应用工程配置
在单独为Android/iOS应用进行推送配置之前我们首先需要去极光的官方网站为example应用注册一个唯一标识符即AppKey
图4 极光应用注册
在得到了AppKey之后我们需要依次进行Android与iOS的配置工作。
Android的配置工作相对简单整个配置过程完全是应用与极光SDK的关联工作。
首先根据example的Android工程包名完成Android工程的推送注册
图5 example 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", //填写默认值即可
]
}
至此Android部分的所有配置工作和接口实现都已经搞定了。接下来我们再来看看iOS的配置实现。
iOS的应用配置相对Android会繁琐一些因为整个配置过程涉及应用、苹果APNs服务、极光三方之间的信息关联。
除了需要在应用内绑定极光信息之外即handleMethodCall中的setup方法还需要在苹果的开发者官网提前申请苹果的推送证书。关于申请证书苹果提供了.p12证书和APNs Auth Key两种鉴权方式。
这里我推荐使用更为简单的Auth Key方式。申请推送证书的过程极光官网提供了详细的注册步骤这里我就不再赘述了。需要注意的是申请iOS的推送证书时你只能使用付费的苹果开发者账号。
在拿到了APNs Auth Key之后我们同样需要去极光官网根据Bundle ID进行推送设置并把Auth Key上传至极光进行托管由它完成与苹果的鉴权工作
图6 example iOS推送注册
通过上面的步骤我们已经完成了将推送证书与极光信息绑定的操作接下来我们回到Xcode打开的example工程进行最后的配置工作。
首先我们需要为example工程开启Application Target的Capabilities->Push Notifications选项启动应用的推送能力支持如下图所示
图7 example iOS推送配置
然后我们需要切换到Application Target的Info面板手动配置NSAppTransportSecurity键值对以支持极光SDK非https域名服务
图8 example iOS支持Http配置
最后在Info tab下的Bundle identifier项把我们刚刚在极光官网注册的Bundle ID显式地更新进去
图9 Bundle ID配置
至此example工程运行所需的所有原生配置工作和接口实现都已经搞定了。接下来我们就可以在example工程中的main.dart文件中使用FlutterPushPlugin插件来实现原生推送能力了。
在下面的代码中我们在main函数的入口使用插件单例注册了极光推送服务随后在应用State初始化时获取了极光推送地址并设置了消息推送回调
//获取推送插件单例
FlutterPushPlugin fpush = FlutterPushPlugin();
void main() {
//使用AppID注册极光推送服务(仅针对iOS平台)
fpush.setupWithAppID("f861910af12a509b34e266c2");
runApp(MyApp());
}
class MyApp extends StatefulWidget {
@override
_MyAppState createState() => _MyAppState();
}
class _MyAppState extends State<MyApp> {
//极光推送地址regID
String _regID = 'Unknown';
//接收到的推送消息
String _notification = "";
@override
initState() {
super.initState();
//注册推送消息回调
fpush.setOpenNotificationHandler((String message) async {
//刷新界面状态,展示推送消息
setState(() {
_notification = message;
});
});
//获取推送地址regID
initPlatformState();
}
initPlatformState() async {
//调用插件封装的regID
String regID = await fpush.registrationID;
//刷新界面状态展示regID
setState(() {
_regID = regID;
});
}
@override
Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(
body: Center(
child: Column(
children: <Widget>[
//展示regID以及收到的消息
Text('Running on: $_regID\n'),
Text('Notification Received: $_notification')
],
),
),
),
);
}
}
点击运行,可以看到,我们的应用已经可以获取到极光推送地址了:
图10 iOS运行示例
图11 Android运行示例
接下来我们再去极光开发者服务后台发一条真实的推送消息。在服务后台选择我们的App随后进入极光推送控制台。这时我们就可以进行消息推送测试了。
在发送通知一栏我们把通知标题改为“测试”通知内容设置为“极光推送测试”在目标人群一栏由于是测试账号我们可以直接选择“广播所有人”如果你希望精确定位到接收方也可以提供在应用中获取到的极光推送地址即Registration ID
图12 极光推送后台
点击发送预览并确认可以看到我们的应用不仅可以被来自极光的推送消息唤醒还可以在Flutter应用内收到来自原生宿主转发的消息内容
图13 iOS推送消息
图 14 Android推送消息
总结
好了,今天的分享就到这里。我们一起来小结一下吧。
我们以Flutter插件工程的方式为极光SDK提供了一个Dart层的封装。插件工程同时提供了iOS和Android目录我们可以在这两个目录下完成原生代码宿主封装不仅可以为Dart层提供接口正向回调比如初始化、获取极光推送地址还可以通过方法通道以反向回调的方式将推送消息转发给Dart。
今天我和你分享了很多原生代码宿主的配置、绑定、注册的逻辑。不难发现推送过程链路长、涉众多、配置复杂要想在Flutter完全实现原生推送能力工作量主要集中在原生代码宿主Dart层能做的事情并不多。
我把今天分享所改造的Flutter_Push_Plugin放到了GitHub中你可以把插件工程下载下来多运行几次体会插件工程与普通Flutter工程的异同并加深对消息推送全流程的认识。其中Flutter_Push_Plugin提供了实现原生推送功能的最小集合你可以根据实际需求完善这个插件。
需要注意的是我们今天的实际工程演示是通过内嵌的example工程示例所完成的如果你有一个独立的Flutter工程比如Flutter_Push_Demo需要接入Flutter_Push_Plugin其配置方式与example工程并无不同唯一的区别是需要在pubspec.yaml文件中将对插件的依赖显示地声明出来而已
dependencies:
flutter_push_plugin:
git:
url: https://github.com/cyndibaby905/31_flutter_push_plugin.git
思考题
在Flutter_Push_Plugin的原生实现中用户点击了推送消息把Flutter应用唤醒时为了确保Flutter完成初始化我们等待了1秒才执行相应的Flutter回调通知。这段逻辑有需要优化的地方吗为了让Flutter代码能够更快地收到推送消息你会如何优化呢
欢迎你在评论区给我留言分享你的观点,我会在下一篇文章中等待你!感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。

View File

@@ -0,0 +1,237 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
32 适配国际化除了多语言我们还需要注意什么_
你好我是陈航。今天我们来聊聊Flutter应用的国际化。
借助于App Store与Google Play我们能够把应用发布到全世界的任何一个应用商店里。应用的潜在使用者可能来自于不同国家、说着不同的语言。如果我们想为全世界的使用者提供统一而标准的体验那么首先就需要让App能够支持多种语言。而这一过程一般被称为“国际化”。
提起国际化你可能会认为这等同于翻译App内所有用户可见的文本。其实这个观点不够精确。更为准确地描述国际化的工作职责应该是“涉及语言及地区差异的适配改造过程”。
比如如果我们要显示金额同样的面值在中国会显示为¥100而在美国则会显示为$100又比如App的引导图在中国我们可能会选用长城作为背景而在美国我们则可能会选择金门大桥作为背景。
因此对一款App做国际化的具体过程除了翻译文案之外还需要将货币单位和背景图等资源也设计成可根据不同地区自适应的变量。这也就意味着我们在设计App架构时需要提前将语言与地区的差异部分独立出来。
其实这也是在Flutter中进行国际化的整体思路即语言差异配置抽取+国际化代码生成。而在语言差异配置抽取的过程中文案、货币单位以及背景图资源的处理其实并没有本质区别。所以在今天的分享中我会以多语言文案为主为你讲述在Flutter中如何实现语言与地区差异的独立化相信在学习完这部分的知识之后对于其他类型的语言差异你也能够轻松搞定国际化了。
Flutter i18n
在Flutter中国际化的语言和地区的差异性配置是应用程序代码的一部分。如果要在Flutter中实现文本的国际化我们需要执行以下几步
首先实现一个LocalizationsDelegate即翻译代理并将所有需要翻译的文案全部声明为它的属性
然后,依次为需要支持的语言地区进行手动翻译适配;
最后在应用程序MaterialApp初始化时将这个代理类设置为应用程序的翻译回调。
如果我们中途想要新增或者删除某个语系或者文案,都需要修改程序代码。
看到这里你会发现如果我们想要使用官方提供的国际化方案来设计App架构不仅工作量大、繁琐而且极易出错。所以要开始Flutter应用的国际化道路我们不如把官方的解决方案扔到一边直接从Android Studio中的Flutter i18n插件开始学习。这个插件在其内部提供了不同语言地区的配置封装能够帮助我们自动地从翻译稿生成Dart代码。
为了安装Flutter i18n插件我们需要打开Android Studio的Preference选项在左边的tab中切换到Plugins选项搜索这个插件点击install即可。安装完成之后再重启Android Studio这个插件就可以使用了。
图1 Flutter i18n插件安装
Flutter i18n依赖flutter_localizations插件包所以我们还需要在pubspec.yaml文件里声明对它的依赖否则程序会报错
dependencies:
flutter_localizations:
sdk: flutter
这时我们会发现在res文件夹下多了一个values/strings_en.arb的文件。
arb文件是JSON格式的配置用来存放文案标识符和文案翻译的键值对。所以我们只要修改了res/values下的arb文件i18n插件就会自动帮我们生成对应的代码。
strings_en文件则是系统默认的英文资源配置。为了支持中文我们还需要在values目录下再增加一个strings_zh.arb文件
图2 arb文件格式
试着修改一下strings_zh.arb文件可以看到Flutter i18n插件为我们自动生成了generated/i18n.dart。这个类中不仅以资源标识符属性的方式提供了静态文案的翻译映射对于通过参数来实现动态文案的message_tip标识符也自动生成了一个同名内联函数
图3 Flutter i18n插件自动生成代码
我们把strings_en.arb继续补全提供英文版的文案。需要注意的是i18n.dart是由插件自动生成的每次arb文件有新的变更都会自动更新所以切忌手动编辑这个文件。
接下来我们以Flutter官方的工程模板即计数器demo来演示如何在Flutter中实现国际化。
在下面的代码中我们在应用程序的入口即MaterialApp初始化时为其设置了支持国际化的两个重要参数即localizationsDelegates与supportedLocales。前者为应用的翻译回调而后者则为应用所支持的语言地区属性。
S.delegate是Flutter i18n插件自动生成的类包含了所支持的语言地区属性以及对应的文案翻译映射。理论上通过这个类就可以完全实现应用的国际化但为什么我们在配置应用程序的翻译回调时除了它之外还加入了GlobalMaterialLocalizations.delegate与GlobalWidgetsLocalizations.delegate这两个回调呢
这是因为Flutter提供的Widget其本身已经支持了国际化所以我们没必要再翻译一遍直接用官方的就可以了而这两个类则就是官方所提供的翻译回调。事实上我们刚才在pubspec.yaml文件中声明的flutter_localizations插件包就是Flutter提供的翻译套装而这两个类就是套装中的著名成员。
在完成了应用程序的国际化配置之后我们就可以在程序中通过S.of(context)直接获取arb文件中翻译的文案了。
不过需要注意的是提取翻译文案的代码需要在能获取到翻译上下文的前提下才能生效也就是说只能针对MaterialApp的子Widget生效。因此在这种配置方式下我们是无法对MaterialApp的title属性进行国际化配置的。不过好在MaterialApp提供了一个回调方法onGenerateTitle来提供翻译上下文因此我们可以通过它实现title文案的国际化
//应用程序入口
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
localizationsDelegates: const [
S.delegate,//应用程序的翻译回调
GlobalMaterialLocalizations.delegate,//Material组件的翻译回调
GlobalWidgetsLocalizations.delegate,//普通Widget的翻译回调
],
supportedLocales: S.delegate.supportedLocales,//支持语系
//title的国际化回调
onGenerateTitle: (context){
return S.of(context).app_title;
},
home: MyHomePage(),
);
}
}
应用的主界面文案的国际化则相对简单得多了直接通过S.of(context)方法就可以拿到arb声明的翻译文案了
Widget build(BuildContext context) {
return Scaffold(
//获取appBar title的翻译文案
appBar: AppBar(
title: Text(S.of(context).main_title),
),
body: Center(
//传入_counter参数获取计数器动态文案
child: Text(
S.of(context).message_tip(_counter.toString())
)
),
floatingActionButton: FloatingActionButton(
onPressed: _incrementCounter,//点击回调
tooltip: 'Increment',
child: Icon(Icons.add),
),
);
}
在Android手机上分别切换英文和中文系统可以看到计数器应用已经正确地处理了多语言的情况。
图4 计数器示例Android英文系统
图5 计数器示例Android中文系统
由于iOS应用程序有一套自建的语言环境管理机制默认是英文。为了让iOS应用正确地支持国际化我们还需要在原生的iOS工程中进行额外的配置。我们打开iOS原生工程切换到工程面板。在Localization这一项配置中我们看到iOS工程已经默认支持了英文所以还需要点击“+”按钮,新增中文:
图6 iOS工程中文配置
完成iOS的工程配置后我们回到Flutter工程选择iOS手机运行程序。可以看到计数器的iOS版本也可以正确地支持国际化了。
图7 计数器示例iOS英文系统
图8 计数器示例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文件。这里由于默认文件是英文的所以我们只需要为中文创建一个文件即可。字符串资源的文件目录结构如下图所示
图9 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>
完成Android应用标题的工程配置后我们回到Flutter工程选择Android手机运行程序可以看到计数器的Android应用标题也可以正确地支持国际化了。
接下来我们再看iOS工程下如何实现应用名称的配置。
与Android工程类似iOS工程中的应用名称是在Info.list文件的Bundle name属性声明的所以我们也需要将其修改为字符串资源中的一个引用使其能够根据语言地区自动选择文案
图10 iOS工程应用名称配置
由于应用名称默认是不可配置的,所以工程并没有提供英文或者中文的可配置项,这些都需要通过新建与字符串引用对应的资源文件去搞定的。
我们右键单击Runner文件夹然后选择New File来添加名为InfoPlist.strings的字符串资源文件并在工程面板的最右侧文件检查器中的Localization选项中添加英文和中文两种语言。InfoPlist.strings的英文版和中文版内容如下所示
//英文版
"CFBundleName" = "Computer";
//中文版
"CFBundleName" = "计数器";
至此我们也完成了iOS应用标题的工程配置。我们回到Flutter工程选择iOS手机运行程序发现计数器的iOS应用标题也支持国际化了。
总结
好了,今天的分享就到这里。我们来总结下核心知识点吧。
在今天的分享中我与你介绍了Flutter应用国际化的解决方案即在代码中实现一个LocalizationsDelegate在这个类中将所有需要翻译的文案全部声明为它的属性然后依次进行手动翻译适配最后将这个代理类设置为应用程序的翻译回调。
而为了简化手动翻译到代码转换的过程我们通常会使用多个arb文件存储文案在不同语言地区的映射关系并使用Flutter i18n插件来实现代码的自动转换。
国际化的核心就是语言差异配置抽取。在原生Android和iOS系统中进行国际化适配我们只需为需要国际化的资源比如字符串文本、图片、布局等提供不同的文件夹目录就可以在应用层代码访问国际化资源时自动根据语言地区进行适配。
Flutter的国际化能力就相对原始很多不同语言和地区的国际化资源既没有存放在单独的xml或者JSON上也没有存放在不同的语言和地区文件夹中。幸好有Flutter i18n插件的帮助否则为一个应用提供国际化的支持将会是件极其繁琐的事情。
我把今天分享所涉及到的知识点打包到了GitHub中你可以下载下来反复运行几次加深理解与记忆。
思考题
最后,我给你留下一道思考题吧。
在Flutter中如何实现图片类资源的国际化呢
欢迎你在评论区给我留言分享你的观点,我会在下一篇文章中等待你!感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。

View File

@@ -0,0 +1,234 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
33 如何适配不同分辨率的手机屏幕?
你好,我是陈航。
在上一篇文章中我与你分享了在Flutter中实现国际化的基本原理。与原生Android和iOS只需为国际化资源提供不同的目录就可以在运行时自动根据语言和地区进行适配不同Flutter的国际化是完全在代码中实现的。
即通过代码声明的方式将应用中所有需要翻译的文案都声明为LocalizationsDelegate的属性然后针对不同的语言和地区进行手动翻译适配最后在初始化应用程序时将这个代理设置为国际化的翻译回调。而为了简化这个过程也为了将国际化资源与代码实现分离我们通常会使用arb文件存储不同语言地区的映射关系并通过Flutter i18n插件来实现代码的自动生成。
可以说,国际化为全世界的用户提供了统一而标准的体验。那么,为不同尺寸、不同旋转方向的手机提供统一而标准的体验,就是屏幕适配需要解决的问题了。
在移动应用的世界中,页面是由控件组成的。如果我们支持的设备只有普通手机,可以确保同一个页面、同一个控件,在不同的手机屏幕上的显示效果是基本一致的。但,随着平板电脑和类平板电脑等超大屏手机越来越普及,很多原本只在普通手机上运行的应用也逐渐跑在了平板上。
由于平板电脑的屏幕非常大展示适配普通手机的界面和控件时可能会出现UI异常的情况。比如对于新闻类手机应用来说通常会有新闻列表和新闻详情两个页面如果我们把这两个页面原封不动地搬到平板电脑上就会出现控件被拉伸、文字过小过密、图片清晰度不够、屏幕空间被浪费的异常体验。
而另一方面即使对于同一台手机或平板电脑来说屏幕的宽高配置也不是一成不变的。因为加速度传感器的存在所以当我们旋转屏幕时屏幕宽高配置会发生逆转即垂直方向与水平方向的布局行为会互相交换从而导致控件被拉伸等UI异常问题。
因此,为了让用户在不同的屏幕宽高配置下获得最佳的体验,我们不仅需要对平板进行屏幕适配,充分利用额外可用的屏幕空间,也需要在屏幕方向改变时重新排列控件。即,我们需要优化应用程序的界面布局,为用户提供新功能、展示新内容,以将拉伸变形的界面和控件替换为更自然的布局,将单一的视图合并为复合视图。
在原生Android或iOS中这种在同一页面实现不同布局的行为我们通常会准备多个布局文件通过判断当前屏幕分辨率来决定应该使用哪套布局方式。在Flutter中屏幕适配的原理也非常类似只不过Flutter并没有布局文件的概念我们需要准备多个布局来实现。
那么今天,我们就来分别来看一下如何通过多个布局,实现适配屏幕旋转与平板电脑。
适配屏幕旋转
在屏幕方向改变时,屏幕宽高配置也会发生逆转:从竖屏模式变成横屏模式,原来的宽变成了高(垂直方向上的布局空间更短了),而高则变成了宽(水平方向上的布局空间更长了)。
通常情况下由于ScrollView和ListView的存在我们基本上不需要担心垂直方向上布局空间更短的问题大不了一屏少显示几个控件元素用户仍然可以使用与竖屏模式同样的交互滚动视图来查看其他控件元素但水平方向上布局空间更长界面和控件通常已被严重拉伸原有的布局方式和交互方式都需要做较大调整。
从横屏模式切回竖屏模式,也是这个道理。
为了适配竖屏模式与横屏模式我们需要准备两个布局方案一个用于纵向一个用于横向。当设备改变方向时Flutter会通知我们重建布局Flutter提供的OrientationBuilder控件可以在设备改变方向时通过builder函数回调告知其状态。这样我们就可以根据回调函数提供的orientation参数来识别当前设备究竟是处于横屏landscape还是竖屏portrait状态从而刷新界面。
如下所示的代码演示了OrientationBuilder的具体用法。我们在其builder回调函数中准确地识别出了设备方向并对横屏和竖屏两种模型加载了不同的布局方式而_buildVerticalLayout和_buildHorizontalLayout是用于创建相应布局的方法
@override
Widget build(BuildContext context) {
return Scaffold(
//使用OrientationBuilder的builder模式感知屏幕旋转
body: OrientationBuilder(
builder: (context, orientation) {
//根据屏幕旋转方向返回不同布局行为
return orientation == Orientation.portrait
? _buildVerticalLayout()
: _buildHorizontalLayout();
},
),
);
}
OrientationBuilder提供了orientation参数可以识别设备方向而如果我们在OrientationBuilder之外希望根据设备的旋转方向设置一些组件的初始化行为也可以使用MediaQueryData提供的orientation方法
if(MediaQuery.of(context).orientation == Orientation.portrait) {
//dosth
}
需要注意的是Flutter应用默认支持竖屏和横屏两种模式。如果我们的应用程序不需要提供横屏模式也可以直接调用SystemChrome提供的setPreferredOrientations方法告诉Flutter这样Flutter就可以固定视图的布局方向了
SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]);
适配平板电脑
当适配更大的屏幕尺寸时我们希望App上的内容可以适应屏幕上额外的可用空间。如果我们在平板中使用与手机相同的布局就会浪费大量的可视空间。与适配屏幕旋转类似最直接的方法是为手机和平板电脑创建两种不同的布局。然而考虑到平板电脑和手机为用户提供的功能并无差别因此这种实现方式将会新增许多不必要的重复代码。
为解决这个问题我们可以采用另外一种方法将屏幕空间划分为多个窗格即采用与原生Android、iOS类似的Fragment、ChildViewController概念来抽象独立区块的视觉功能。
多窗格布局可以在平板电脑和横屏模式上实现更好的视觉平衡效果增强App的实用性和可读性。而我们也可以通过独立的区块在不同尺寸的手机屏幕上快速复用视觉功能。
如下图所示,分别展示了普通手机、横屏手机与平板电脑,如何使用多窗格布局来改造新闻列表和新闻详情交互:
图1 多窗格布局示意图
首先,我们需要分别为新闻列表与新闻详情创建两个可重用的独立区块:
新闻列表可以在元素被点击时通过回调函数告诉父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),
);
},
));
});
}
}),
);
}
}
运行一下代码,可以看到,我们的应用已经完全适配不同尺寸、不同方向的设备屏幕了。
图2 竖屏手机版列表详情
图3 横屏手机版列表详情
图4 竖屏平板列表详情
图5 横屏平板列表详情
总结
好了,今天的分享就到这里。我们总结一下今天的核心知识点吧。
在Flutter中为了适配不同设备屏幕我们需要提供不同的布局方式。而将独立的视觉区块进行封装通过OrientationBuilder提供的orientation回调参数以及MediaQueryData提供的屏幕尺寸以多窗格布局的方式为它们提供不同的页面呈现形态能够大大降低编写独立布局所带来的重复工作。如果你的应用不需要支持设备方向也可以通过SystemChrome提供的setPreferredOrientations方法强制竖屏。
做好应用开发,我们除了要保证产品功能正常,还需要兼容碎片化(包括设备碎片化、品牌碎片化、系统碎片化、屏幕碎片化等方面)可能带来的潜在问题,以确保良好的用户体验。
与其他维度碎片化可能带来功能缺失甚至Crash不同屏幕碎片化不至于导致功能完全不可用但控件显示尺寸却很容易在没有做好适配的情况下产生变形让用户看到异形甚至不全的UI信息影响产品形象因此也需要重点关注。
在应用开发中我们可以分别在不同屏幕尺寸的主流机型和模拟器上运行我们的程序来观察UI样式和功能是否异常从而写出更加健壮的布局代码。
我把今天分享所涉及到的知识点打包到了GitHub中你可以下载下来反复运行几次加深理解与记忆。
思考题
最后,我给你留下一道思考题吧
setPreferredOrientations方法是全局生效的如果你的应用程序中有两个相邻的页面页面A仅支持竖屏页面B同时支持竖屏和横屏你会如何实现呢
欢迎你在评论区给我留言分享你的观点,我会在下一篇文章中等待你!感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。

View File

@@ -0,0 +1,243 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
34 如何理解Flutter的编译模式
你好我是陈航。今天我们来聊聊Flutter的编译模式吧。
在开发移动应用程序时一个App的完整生命周期包括开发、测试和上线3个阶段。在每个阶段开发者的关注点都不一样。
比如,在开发阶段,我们希望调试尽可能方便、快速,尽可能多地提供错误上下文信息;在测试阶段,我们希望覆盖范围尽可能全面,能够具备不同配置切换能力,可以测试和验证还没有对外发布的新功能;而在发布阶段,我们则希望能够去除一切测试代码,精简调试信息,使运行速度尽可能快,代码足够安全。
这就要求开发者在构建移动应用时不仅要在工程内提前准备多份配置环境还要利用编译器提供的编译选项打包出符合不同阶段优化需求的App。
对于Flutter来说它既支持常见的Debug、Release等工程物理层面的编译模式也支持在工程内提供多种配置环境入口。今天我们就来学习一下Flutter提供的编译模式以及如何在App中引用开发环境和生产环境使得我们在不破坏任何生产环境代码的情况下能够测试处于开发期的新功能。
Flutter的编译模式
Flutter支持3种运行模式包括Debug、Release和Profile。在编译时这三种模式是完全独立的。首先我们先来看看这3种模式的具体含义吧。
Debug模式对应Dart的JIT模式可以在真机和模拟器上同时运行。该模式会打开所有的断言assert以及所有的调试信息、服务扩展和调试辅助比如Observatory。此外该模式为快速开发和运行做了优化支持亚秒级有状态的Hot reload热重载但并没有优化代码执行速度、二进制包大小和部署。flutter run debug命令就是以这种模式运行的。
Release模式对应Dart的AOT模式只能在真机上运行不能在模拟器上运行其编译目标为最终的线上发布给最终的用户使用。该模式会关闭所有的断言以及尽可能多的调试信息、服务扩展和调试辅助。此外该模式优化了应用快速启动、代码快速执行以及二级制包大小因此编译时间较长。flutter run release命令就是以这种模式运行的。
Profile模式基本与Release模式一致只是多了对Profile模式的服务扩展的支持包括支持跟踪以及一些为了最低限度支持所需要的依赖比如可以连接Observatory到进程。该模式用于分析真实设备实际运行性能。flutter run profile命令就是以这种模式运行的。
由于Profile与Release在编译过程上几乎无差异因此我们今天只讨论Debug和Release模式。
在开发应用时为了便于快速发现问题我们通常会在运行时识别当前的编译模式去改变代码的部分执行行为在Debug模式下我们会打印详细的日志调用开发环境接口而在Release模式下我们会只记录极少的日志调用生产环境接口。
在运行时识别应用的编译模式,有两种解决办法:
通过断言识别;
通过Dart VM所提供的编译常数识别。
我们先来看看如何通过断言识别应用的编译模式。
通过Debug与Release模式的介绍我们可以得出Release与Debug模式的一个重要区别就是Release模式关闭了所有的断言。因此我们可以借助于断言写出只在Debug模式下生效的代码。
如下所示我们在断言里传入了一个始终返回true的匿名函数执行结果这个匿名函数的函数体只会在Debug模式下生效
assert(() {
//Do sth for debug
return true;
}());
需要注意的是,匿名函数声明调用结束时追加了小括号()。 这是因为断言只能检查布尔值所以我们必须使用括号强制执行这个始终返回true的匿名函数以确保匿名函数体的代码可以执行。
接下来,我们再看看如何通过编译常数识别应用的编译模式。
如果说通过断言只能写出在Debug模式下运行的代码而通过Dart提供的编译常数我们还可以写出只在Release模式下生效的代码。Dart提供了一个布尔型的常量kReleaseMode用于反向指示当前App的编译模式。
如下所示,我们通过判断这个常量,可以准确地识别出当前的编译模式:
if(kReleaseMode){
//Do sth for release
} else {
//Do sth for debug
}
分离配置环境
通过断言和kReleaseMode常量我们能够识别出当前App的编译环境从而可以在运行时对某个代码功能进行局部微调。而如果我们想在整个应用层面为不同的运行环境提供更为统一的配置比如对于同一个接口调用行为开发环境会使用dev.example.com域名而生产环境会使用api.example.com域名则需要在应用启动入口提供可配置的初始化方式根据特定需求为应用注入配置环境。
在Flutter构建App时为应用程序提供不同的配置环境总体可以分为抽象配置、配置多入口、读配置和编译打包4个步骤
抽象出应用程序的可配置部分并使用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;
}
接下来,我们需要为不同的环境创建不同的应用入口。
在这个例子中由于只有两个环境即开发环境与生产环境因此我们将文件分别命名为main_dev.dart和main.dart。在这两个文件中我们会使用不同的配置数据来对AppConfig进行初始化同时把应用程序实例MyApp作为其子Widget这样整个应用内都可以获取到配置数据
//main_dev.dart
void main() {
var configuredApp = AppConfig(
appName: 'dev',//主页标题
apiBaseUrl: 'http://dev.example.com/',//接口域名
child: MyApp(),
);
runApp(configuredApp);//启动应用入口
}
//main.dart
void main() {
var configuredApp = AppConfig(
appName: 'example',//主页标题
apiBaseUrl: 'http://api.example.com/',//接口域名
child: MyApp(),
);
runApp(configuredApp);//启动应用入口
}
完成配置环境的注入之后接下来就可以在应用内获取配置数据来实现定制化的功能了。由于AppConfig是整个应用程序的根节点因此我可以通过调用AppConfig.of方法来获取到相关的数据配置。
在下面的代码中,我分别获取到了应用主页的标题,以及接口域名,并显示了出来:
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
var config = AppConfig.of(context);//获取应用配置
return MaterialApp(
title: config.appName,//应用主页标题
home: MyHomePage(),
);
}
}
class MyHomePage extends StatelessWidget {
@override
Widget build(BuildContext context) {
var config = AppConfig.of(context);//获取应用配置
return Scaffold(
appBar: AppBar(
title: Text(config.appName),//应用主页标题
),
body: Center(
child: Text('API host: ${config.apiBaseUrl}'),//接口域名
),
);
}
}
现在我们已经完成了分离配置环境的代码部分。最后我们可以使用Flutter提供的编译选项来构建出不同配置的安装包了。
如果想要在模拟器或真机上运行这段代码我们可以在flutter run命令后面追加target或-t参数来指定应用程序初始化入口
//运行开发环境应用程序
flutter run -t lib/main_dev.dart
//运行生产环境应用程序
flutter run -t lib/main.dart
如果我们想在Android Studio上为应用程序创建不同的启动配置则可以通过Flutter插件为main_dev.dart增加启动入口。
首先点击工具栏上的Config Selector选择Edit Configurations进入编辑应用程序启动选项
图1 Config Selector新增入口
然后,点击位于工具栏面板左侧顶部的“+”按钮在弹出的菜单中选择Flutter选项为应用程序新增一项启动入口
图2 选择新增类型
最后在入口的编辑面板中为main_dev选择程序的Dart入口点击OK后就完成了入口的新增工作
图3 编辑启动入口
接下来我们就可以在Config Selector中切换不同的启动入口从而直接在Android Studio中注入不同的配置环境了
图4 Config Selector切换启动入口
我们试着在不同的入口中进行切换和运行可以看到App已经可以识别出不同的配置环境了
图5 开发环境运行示例
图6 生产环境运行示例
而如果我们想要打包构建出适用于Android的APK或是iOS的IPA安装包则可以在flutter build 命令后面同样追加target或-t参数指定应用程序初始化入口
//打包开发环境应用程序
flutter build apk -t lib/main_dev.dart
flutter build ios -t lib/main_dev.dart
//打包生产环境应用程序
flutter build apk -t lib/main.dart
flutter build ios -t lib/main.dart
总结
好了,今天的分享就到这里。我们来总结一下今天的主要内容吧。
Flutter支持Debug与Release的编译模式并且这两种模式在构建时是完全独立的。Debug模式下会打开所有的断言和调试信息而Release模式下则会关闭这些信息因此我们可以通过断言写出只在Debug模式下生效的代码。而如果我们想更精准地识别出当前的编译模式则可以利用Dart所提供的编译常数kReleaseMode写出只在Release模式下生效的代码。
除此之外Flutter对于常见的分环境配置能力也提供了支持我们可以使用InheritedWidget为应用中可配置部分进行封装抽象通过配置多入口的方式为应用的启动注入配置环境。
需要注意的是虽然断言和kReleaseMode都能够识别出Debug编译模式但它们对二进制包的打包构建影响是不同的。
采用断言的方式其相关代码会在Release构建中被完全剔除而如果使用kReleaseMode常量来识别Debug环境虽然这段代码永远不会在Release环境中执行但却会被打入到二进制包中增大包体积。因此如果没有特殊需求的话一定要使用断言来实现Debug特有的逻辑或是在发布期前将使用kReleaseMode判断的Debug逻辑完全删除。
我把今天分享所涉及到的知识点打包到了GitHub中你可以下载下来反复运行几次加深理解与记忆。
思考题
最后,我给你留一道思考题吧。
在保持生产环境代码不变的情况下,如果想在开发环境中支持不同配置的切换,我们应该如何实现?
欢迎你在评论区给我留言分享你的观点,我会在下一篇文章中等待你!感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。

View File

@@ -0,0 +1,254 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
35 Hot Reload是怎么做到的
你好,我是陈航。
在上一篇文章中我与你分享了Flutter的Debug与Release编译模式以及如何通过断言与编译常数来精准识别当前代码所运行的编译模式从而写出只在Debug或Release模式下生效的代码。
另外对于在开发期与发布期分别使用不同的配置环境Flutter也提供了支持。我们可以将应用中可配置的部分进行封装抽象使用配置多入口的方式通过InheritedWidget来为应用的启动注入环境配置。
如果你有过原生应用的开发经历,那你一定知道在原生应用开发时,如果我们想要在硬件设备上看到调整后的运行效果,在完成了代码修改后,必须要经过漫长的重新编译,才能同步到设备上。
而Flutter则不然由于Debug模式支持JIT并且为开发期的运行和调试提供了大量优化因此代码修改后我们可以通过亚秒级的热重载Hot Reload进行增量代码的快速刷新而无需经过全量的代码编译从而大大缩短了从代码修改到看到修改产生的变化之间所需要的时间。
比如在开发页面的过程中当我们点击按钮出现一个弹窗的时候发现弹窗标题没有对齐这时候只要修改标题的对齐样式然后保存在代码并没有重新编译的情况下标题样式就发生了改变感觉就像是在UI编辑面板中直接修改元素样式一样非常方便。
那么Flutter的热重载到底是如何实现的呢
热重载
热重载是指在不中断App正常运行的情况下动态注入修改后的代码片段。而这一切的背后离不开Flutter所提供的运行时编译能力。为了更好地理解Flutter的热重载实现原理我们先简单回顾一下Flutter编译模式背后的技术吧。
JITJust In Time指的是即时编译或运行时编译在Debug模式中使用可以动态下发和执行代码启动速度快但执行性能受运行时编译影响
图1 JIT编译模式示意图
AOTAhead Of Time指的是提前编译或运行前编译在Release模式中使用可以为特定的平台生成稳定的二进制代码执行性能好、运行速度快但每次执行均需提前编译开发调试效率低。
图2 AOT编译模式示意图
可以看到Flutter提供的两种编译模式中AOT是静态编译即编译成设备可直接执行的二进制码而JIT则是动态编译即将Dart代码编译成中间代码Script Snapshot在运行时设备需要Dart VM解释执行。
而热重载之所以只能在Debug模式下使用是因为Debug模式下Flutter采用的是JIT动态编译而Release模式下采用的是AOT静态编译。JIT编译器将Dart代码编译成可以运行在Dart VM上的Dart Kernel而Dart Kernel是可以动态更新的这就实现了代码的实时更新功能。
图3 热重载流程
总体来说热重载的流程可以分为扫描工程改动、增量编译、推送更新、代码合并、Widget重建5个步骤
工程改动。热重载模块会逐一扫描工程中的文件检查是否有新增、删除或者改动直到找到在上次编译之后发生变化的Dart代码。
增量编译。热重载模块会将发生变化的Dart代码通过编译转化为增量的Dart Kernel文件。
推送更新。热重载模块将增量的Dart Kernel文件通过HTTP端口发送给正在移动设备上运行的Dart VM。
代码合并。Dart VM会将收到的增量Dart Kernel文件与原有的Dart Kernel文件进行合并然后重新加载新的Dart Kernel文件。
Widget重建。在确认Dart VM资源加载成功后Flutter会将其UI线程重置通知Flutter Framework重建Widget。
可以看到Flutter提供的热重载在收到代码变更后并不会让App重新启动执行而只会触发Widget树的重新绘制因此可以保持改动前的状态这就大大节省了调试复杂交互界面的时间。
比如我们需要为一个视图栈很深的页面调整UI样式若采用重新编译的方式不仅需要漫长的全量编译时间而为了恢复视图栈也需要重复之前的多次点击交互才能重新进入到这个页面查看改动效果。但如果是采用热重载的方式不仅没有编译时间而且页面的视图栈状态也得以保留完成热重载之后马上就可以预览UI效果了相当于局部界面刷新。
不支持热重载的场景
Flutter提供的亚秒级热重载一直是开发者的调试利器。通过热重载我们可以快速修改UI、修复Bug无需重启应用即可看到改动效果从而大大提升了UI调试效率。
不过Flutter的热重载也有一定的局限性。因为涉及到状态保存与恢复所以并不是所有的代码改动都可以通过热重载来更新。
接下来,我就与你介绍几个不支持热重载的典型场景:
代码出现编译错误;
Widget状态无法兼容
全局变量和静态属性的更改;
main方法里的更改
initState方法里的更改
枚举和泛类型更改。
现在,我们就具体看看这几种场景的问题,应该如何解决吧。
代码出现编译错误
当代码更改导致编译错误时,热重载会提示编译错误信息。比如下面的例子中,代码中漏写了一个反括号,在使用热重载时,编译器直接报错:
Initializing hot reload...
Syncing files to device iPhone X...
Compiler message:
lib/main.dart:84:23: Error: Can't find ')' to match '('.
return MaterialApp(
^
Reloaded 1 of 462 libraries in 301ms.
在这种情况下,只需更正上述代码中的错误,就可以继续使用热重载。
Widget状态无法兼容
当代码更改会影响Widget的状态时会使得热重载前后Widget所使用的数据不一致即应用程序保留的状态与新的更改不兼容。这时热重载也是无法使用的。
比如下面的代码中,我们将某个类的定义从 StatelessWidget改为StatefulWidget时热重载就会直接报错
//改动前
class MyWidget extends StatelessWidget {
Widget build(BuildContext context) {
return GestureDetector(onTap: () => print('T'));
}
}
//改动后
class MyWidget extends StatefulWidget {
@override
State<MyWidget> createState() => MyWidgetState();
}
class MyWidgetState extends State<MyWidget> { /*...*/ }
当遇到这种情况时,我们需要重启应用,才能看到更新后的程序。
全局变量和静态属性的更改
在Flutter中全局变量和静态属性都被视为状态在第一次运行应用程序时会将它们的值设为初始化语句的执行结果因此在热重载期间不会重新初始化。
比如下面的代码中我们修改了一个静态Text数组的初始化元素。虽然热重载并不会报错但由于静态变量并不会在热重载之后初始化因此这个改变并不会产生效果
//改动前
final sampleText = [
Text("T1"),
Text("T2"),
Text("T3"),
Text("T4"),
];
//改动后
final sampleText = [
Text("T1"),
Text("T2"),
Text("T3"),
Text("T10"), //改动点
];
如果我们需要更改全局变量和静态属性的初始化语句,重启应用才能查看更改效果。
main方法里的更改
在Flutter中由于热重载之后只会根据原来的根节点重新创建控件树因此main函数的任何改动并不会在热重载后重新执行。所以如果我们改动了main函数体内的代码是无法通过热重载看到更新效果的。
在第1篇文章“预习篇 · 从零开始搭建Flutter开发环境”中我与你介绍了这种情况。在更新前我们通过MyApp封装了一个展示“Hello World”的文本在更新后直接在main函数封装了一个展示“Hello 2019”的文本
//更新前
class MyAPP extends StatelessWidget {
@override
Widget build(BuildContext context) {
return const Center(child: Text('Hello World', textDirection: TextDirection.ltr));
}
}
void main() => runApp(new MyAPP());
//更新后
void main() => runApp(const Center(child: Text('Hello, 2019', textDirection: TextDirection.ltr)));
由于main函数并不会在热重载后重新执行因此以上改动是无法通过热重载查看更新的。
initState方法里的更改
在热重载时Flutter会保存Widget的状态然后重建Widget。而initState方法是Widget状态的初始化方法这个方法里的更改会与状态保存发生冲突因此热重载后不会产生效果。
在下面的例子中我们将计数器的初始值由10改为100
//更改前
class _MyHomePageState extends State<MyHomePage> {
int _counter;
@override
void initState() {
_counter = 10;
super.initState();
}
...
}
//更改后
class _MyHomePageState extends State<MyHomePage> {
int _counter;
@override
void initState() {
_counter = 100;
super.initState();
}
...
}
由于这样的改动发生在initState方法中因此无法通过热重载查看更新我们需要重启应用才能看到更改效果。
枚举和泛型类型更改
在Flutter中枚举和泛型也被视为状态因此对它们的修改也不支持热重载。比如在下面的代码中我们将一个枚举类型改为普通类并为其增加了一个泛型参数
//更改前
enum Color {
red,
green,
blue
}
class C<U> {
U u;
}
//更改后
class Color {
Color(this.r, this.g, this.b);
final int r;
final int g;
final int b;
}
class C<U, V> {
U u;
V v;
}
这两类更改都会导致热重载失败,并生成对应的提示消息。同样的,我们需要重启应用,才能查看到更改效果。
总结
好了,今天的分享就到这里,我们总结一下今天的主要内容吧。
Flutter的热重载是基于JIT编译模式的代码增量同步。由于JIT属于动态编译能够将Dart代码编译成生成中间代码让Dart VM在运行时解释执行因此可以通过动态更新中间代码实现增量同步。
热重载的流程可以分为5步包括扫描工程改动、增量编译、推送更新、代码合并、Widget重建。Flutter在接收到代码变更后并不会让App重新启动执行而只会触发Widget树的重新绘制因此可以保持改动前的状态大大缩短了从代码修改到看到修改产生的变化之间所需要的时间。
而另一方面由于涉及到状态保存与恢复因此涉及状态兼容与状态初始化的场景热重载是无法支持的比如改动前后Widget状态无法兼容、全局变量与静态属性的更改、main方法里的更改、initState方法里的更改、枚举和泛型的更改等。
可以发现热重载提高了调试UI的效率非常适合写界面样式这样需要反复查看修改效果的场景。但由于其状态保存的机制所限热重载本身也有一些无法支持的边界。
如果你在写业务逻辑的时候不小心碰到了热重载无法支持的场景也不需要进行漫长的重新编译加载等待只要点击位于工程面板左下角的热重启Hot Restart按钮就可以以秒级的速度进行代码重新编译以及程序重启了同样也很快。
思考题
最后,我给你留下一道思考题吧。
你是否了解其他框架比如React Native、Webpack的热重载机制它们的热重载机制与Flutter有何区别
欢迎你在评论区给我留言分享你的观点,我会在下一篇文章中等待你!感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。

View File

@@ -0,0 +1,213 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
36 如何通过工具链优化开发调试效率?
你好我是陈航。今天我们来聊聊如何调试Flutter App。
软件开发通常是一个不断迭代、螺旋式上升的过程。在迭代的过程中我们不可避免地会经常与Bug打交道特别是在多人协作的项目中我们不仅要修复自己的Bug有时还需要帮别人解决Bug。
而修复Bug的过程不仅能帮我们排除代码中的隐患也能帮助我们更快地上手项目。因此掌握好调试这门技能就显得尤为重要了。
在Flutter中调试代码主要分为输出日志、断点调试和布局调试3类。所以在今天这篇文章中我将会围绕这3个主题为你详细介绍Flutter应用的代码调试。
我们先来看看,如何通过输出日志调试应用代码吧。
输出日志
为了便于跟踪和记录应用的运行情况我们在开发时通常会在一些关键步骤输出日志Log即使用print函数在控制台打印出相关的上下文信息。通过这些信息我们可以定位代码中可能出现的问题。
在前面的很多篇文章里我们都大量使用了print函数来输出应用执行过程中的信息。不过由于涉及I/O操作使用print来打印信息会消耗较多的系统资源。同时这些输出数据很可能会暴露App的执行细节所以我们需要在发布正式版时屏蔽掉这些输出。
说到操作方法你想到的可能是在发布版本前先注释掉所有的print语句等以后需要调试时再取消这些注释。但这种方法无疑是非常无聊且耗时的。那么Flutter给我们提供了什么更好的方式吗
为了根据不同的运行环境来开启日志调试功能我们可以使用Flutter提供的debugPrint来代替print。debugPrint函数同样会将消息打印至控制台但与print不同的是它提供了定制打印的能力。也就是说我们可以向debugPrint函数赋值一个函数声明来自定义打印行为。
比如在下面的代码中我们将debugPrint函数定义为一个空函数体这样就可以实现一键取消打印的功能了
debugPrint = (String message, {int wrapWidth}) {};//空实现
在Flutter 中我们可以使用不同的main文件来表示不同环境下的入口。比如在第34篇文章“如何理解Flutter的编译模式”中我们就分别用main.dart与main-dev.dart实现了生产环境与开发环境的分离。同样我们可以通过main.dart与main-dev.dart去分别定义生产环境与开发环境不同的打印日志行为。
在下面的例子中我们将生产环境的debugPrint定义为空实现将开发环境的debugPrint定义为同步打印数据
//main.dart
void main() {
// 将debugPrint指定为空的执行体, 所以它什么也不做
debugPrint = (String message, {int wrapWidth}) {};
runApp(MyApp());
}
//main-dev.dart
void main() async {
// 将debugPrint指定为同步打印数据
debugPrint = (String message, {int wrapWidth}) => debugPrintSynchronously(message, wrapWidth: wrapWidth);
runApp(MyApp());
}
可以看到在代码实现上我们只要将应用内所有的print都替换成debugPrint就可以满足开发环境下打日志的需求也可以保证生产环境下应用的执行信息不会被意外打印。
断点调试
输出日志固然方便,但如果要想获取更为详细,或是粒度更细的上下文信息,静态调试的方式非常不方便。这时,我们需要更为灵活的动态调试方法,即断点调试。断点调试可以让代码在目标语句上暂停,让程序逐条执行后续的代码语句,来帮助我们实时关注代码执行上下文中所有变量值的详细变化过程。
Android Studio提供了断点调试的功能调试Flutter应用与调试原生Android代码的方法完全一样具体可以分为三步即标记断点、调试应用、查看信息。
接下来我们以Flutter默认的计数器应用模板为例观察代码中_counter值的变化体会断点调试的全过程。
首先是标记断点。既然我们要观察_counter值的变化因此在界面上展示最新的_counter值时添加断点去观察其数值变化是最理想的。因此我们在行号右侧点击鼠标可以把断点加载到初始化Text控件所示的位置。
在下图的例子中我们为了观察_counter在等于20的时候是否正常还特意设置了一个条件断点_counter==20这样调试器就只会在第20次点击计数器按钮时暂停下来
图1 标记断点
添加断点后,对应的行号将会出现圆形的断点标记,并高亮显示整行代码。到此,断点就添加好了。当然,我们还可以同时添加多个断点,以便更好地观察代码的执行过程。
接下来则是调试应用了。和之前通过点击run按钮的运行方式不同这一次我们需要点击工具栏上的虫子图标以调试模式启动App如下图所示
图2 调试App
等调试器初始化好后我们的程序就启动了。由于我们的断点设置在了_counter为20时因此在第20次点击了“+”按钮后代码运行到了断点位置自动进入了Debug视图模式。
图3 Debug视图模式
如图所示我把Debug视图模式划分为4个区域即A区控制调试工具、B区步进调试工具、C区帧调试窗口、D区变量查看窗口。
A区的按钮主要用来控制调试的执行情况
图4 A区按钮
比如,我们可以点击继续执行按钮来让程序继续运行、点击终止执行按钮来让程序终止运行、点击重新执行按钮来让程序重新启动,或是在程序正常执行时,点击暂停执行按钮按钮来让程序暂停运行。
又比如,我们可以点击编辑断点按钮来编辑断点信息,或是点击禁用断点按钮来取消断点。
B区的按钮主要用来控制断点的步进情况
图5 B区按钮
比如,我们可以点击单步跳过按钮来让程序单步执行(但不会进入方法体内部)、点击单步进入或强制单步进入按钮让程序逐条语句执行,甚至还可以点击运行到光标处按钮让程序执行到在光标处(相当于新建临时断点)。
比如,当我们认为断点所在的方法体已经无需执行时,则可以点击单步跳出按钮让程序立刻执行完当前进入的方法,从而返回方法调用处的下一行。
又比如我们可以点击表达式计算按钮来通过赋值或表达式方式修改任意变量的值。如下图所示我们通过输入表达式_counter+=100将计数器更新为120
图6 Evaluate计算表达式
C区用来指示当前断点所包含的函数执行堆栈D区则是其堆栈中的函数帧所对应的变量。
在这个例子中我们的断点是在_MyHomePageState类中的build方法设置的因此D区显示的也是build方法上下文所包含的变量信息比如_counter、_widget、this、_element等。如果我们想切换到_MyHomePageState的build方法执行堆栈中的其他函数比如StatefulElement.build查看相关上下文的变量信息时只需要在C区中点击对应的方法名即可。
图7 切换函数执行堆栈
可以看到Android Studio提供的Flutter调试能力很丰富我们可以通过这些基本步骤的组合更为灵活地调整追踪步长观察程序的执行情况揪出代码中的Bug。
布局调试
通过断点调试我们在Android Studio的调试面板中可以随时查看执行上下文有关的变量的值根据逻辑来做进一步的判断确定跟踪执行的步骤。不过在更多时候我们使用Flutter的目的是实现视觉功能而视觉功能的调试是无法简单地通过Debug视图模式面板来搞定的。
在上一篇文章中我们通过Flutter提供的热重载机制已经极大地缩短了从代码编写到界面运行所耗费的时间可以更快地发现代码与目标界面的明显问题但如果想要更快地发现界面中更为细小的问题比如对齐、边距等则需要使用Debug Painting这个界面调试工具。
Debug Painting能够以辅助线的方式清晰展示每个控件元素的布局边界因此我们可以根据辅助线快速找出布局出问题的地方。而Debug Painting的开启也比较简单只需要将debugPaintSizeEnabled变量置为true即可。如下所示我们在main函数中开启了Debug Painting调试开关
import 'package:flutter/rendering.dart';
void main() {
debugPaintSizeEnabled = true; //打开Debug Painting调试开关
runApp(new MyApp());
}
运行代码后App在iPhone X中的执行效果如下
图8 Debug Painting运行效果
可以看到,计数器示例中的每个控件元素都已经被标尺辅助线包围了。
辅助线提供了基本的Widget可视化能力。通过辅助线我们能够感知界面中是否存在对齐或边距的问题但却没有办法获取到布局信息比如Widget距离父视图的边距信息、Widget宽高尺寸信息等。
如果我们想要获取到Widget的可视化信息比如布局信息、渲染信息等去解决渲染问题就需要使用更强大的Flutter Inspector了。Flutter Inspector对控件布局详细数据提供了一种强大的可视化手段来帮助我们诊断布局问题。
为了使用Flutter Inspector我们需要回到Android Studio通过工具栏上的“Open DevTools”按钮启动Flutter Inspector
图9 Flutter Inspector启动按钮
随后Android Studio会打开浏览器将计数器示例中的Widget树结构展示在面板中。可以看到Flutter Inspector所展示的Widget树结构与代码中实现的Widget层次是一一对应的。
图10 Flutter Inspector示意图
我们的App运行在iPhone X之上其分辨率为375*812。接下来我们以Column组件的布局信息为例通过确认其水平方向为居中布局、垂直方向为充满父Widget剩余空间的过程来说明Flutter Inspector的具体用法。
为了确认Column在垂直方向是充满其父Widget剩余空间的我们首先需要确定其父Widget在垂直方向上的另一个子Widget即AppBar的信息。我们点击Flutter Inspector面板左侧中的AppBar控件右侧对应显示了它的具体视觉信息。
可以看到AppBar控件距离左边距为0上边距也为0宽为375高为100
图11 Flutter Inspector之AppBar
然后我们将Flutter Inspector面板左侧选择的控件更新为Column右侧也更新了它的具体视觉信息比如排版方向、对齐模式、渲染信息以及它的两个子Widget-Text。
可以看到Column控件的距离左边距为38.5上边距为0宽为298高为712
图12 Flutter Inspector之Columnn
通过上面的数据我们可以得出:
Column的右边距=父Widget宽度即iPhone X宽度375-Column左边距即38.5- Column宽即298=38.5即左右边距相等因此Column是水平方向居中的
Column的高度=父Widget的高度即iPhone X高度812- AppBar上边距即0- AppBar高度即100 - Column上边距即0= 712.0即Column在垂直方向上完全填满了父Widget除去AppBar之后的剩余空间。
因此Column的布局行为是完全符合预期的。
总结
好了,今天的分享就到这里,我们总结一下今天的主要内容吧。
首先我带你学习了如何实现定制日志的输出能力。Flutter提供了debugPrint函数这是一个可以被覆盖的打印函数。我们可以分别定义生产环境与开发环境的日志输出行为来满足开发期打日志需求的同时保证发布期日志执行信息不会被意外打印。
然后我与你介绍了Android Studio提供的Flutter调试功能并通过观察计数器工程的计数器变量为例与你讲述了具体的调试方法。
最后我们一起学习了Flutter的布局调试能力即通过Debug Paiting来定义辅助线以及通过Flutter Inspector这种可视化手段来更为准确地诊断布局问题。
写代码不可避免会出现Bug出现时就需要Debug调试。调试代码本质上就是一个不断收敛问题发生范围的过程因此排查问题的一个最基本思路就是二分法。
所谓二分调试法是指通过某种稳定复现的特征比如Crash、某个变量的值、是否出现某个现象等任何明显的迹象加上一个能把问题出现的范围划分为两半的手段比如断点、assert、日志等两者结合反复迭代不断将问题可能出现的范围一分为二比如能判断出引发问题的代码出现在断点之前等。通过二分法我们可以快速缩小问题范围这样一来调试的效率也就上去了。
思考题
最后,我给你留下一道思考题吧。
请将debugPrint在生产环境下的打印日志行为更改为写日志文件。其中日志文件一共5个0-4每个日志文件不能超过2MB但可以循环写。如果日志文件已满则循环至下一个日志文件清空后重新写入。
欢迎你在评论区给我留言分享你的观点,我会在下一篇文章中等待你!感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。

View File

@@ -0,0 +1,232 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
37 如何检测并优化Flutter App的整体性能表现
你好,我是陈航。
在上一篇文章中我与你分享了调试Flutter代码的3种基本方式即输出日志、断点调试与布局调试。
通过可定制打印行为的debugPrint函数我们可以实现生产环境与开发环境不同的日志输出行为从而保证在开发期打印的调试信息不会被发布至线上借助于IDEAndroid Studio所提供的断点调试选项我们可以不断调整代码执行步长和代码暂停条件收敛问题发生范围直至找到问题根源而如果我们想找出代码中的布局渲染类Bug则可以通过Debug Painting和Flutter Inspector提供的辅助线和视图可视化信息来更为精准地定位视觉问题。
除了代码逻辑Bug和视觉异常这些功能层面的问题之外移动应用另一类常见的问题是性能问题比如滑动操作不流畅、页面出现卡顿丢帧现象等。这些问题虽然不至于让移动应用完全不可用但也很容易引起用户反感从而对应用质量产生质疑甚至失去耐心。
那么,如果应用渲染并不流畅,出现了性能问题,我们该如何检测,又该从哪里着手处理呢?
在Flutter中性能问题可以分为GPU线程问题和UI线程CPU问题两类。这些问题的确认都需要先通过性能图层进行初步分析而一旦确认问题存在接下来就需要利用Flutter提供的各类分析工具来定位问题了。
所以在今天这篇文章中我会与你一起学习分析Flutter应用性能问题的基本思路和工具以及常见的优化办法。
如何使用性能图层?
要解决问题我们首先得了解如何去度量问题性能分析也不例外。Flutter提供了度量性能问题的工具和手段来帮助我们快速定位代码中的性能问题而性能图层就是帮助我们确认问题影响范围的利器。
为了使用性能图层我们首先需要以分析Profile模式启动应用。与调试代码可以通过模拟器在调试模式下找到代码逻辑Bug不同性能问题需要在发布模式下使用真机进行检测。
这是因为相比发布模式而言调试模式增加了很多额外的检查比如断言这些检查可能会耗费很多资源更重要的是调试模式使用JIT模式运行应用代码执行效率较低。这就使得调试模式运行的应用无法真实反映出它的性能问题。
而另一方面模拟器使用的指令集为x86而真机使用的指令集是ARM。这两种方式的二进制代码执行行为完全不同因此模拟器与真机的性能差异较大一些x86指令集擅长的操作模拟器会比真机快而另一些操作则会比真机慢。这也使得我们无法使用模拟器来评估真机才能出现的性能问题。
为了调试性能问题我们需要在发布模式的基础之上为分析工具提供少量必要的应用追踪信息这就是分析模式。除了一些调试性能问题必须的追踪方法之外Flutter应用的分析模式和发布模式的编译和运行是类似的只是启动参数变成了profile而已我们既可以在Android Studio中通过菜单栏点击Run->Profile main.dart 选项启动应用也可以通过命令行参数flutter run profile运行Flutter应用。
分析渲染问题
在完成了应用启动之后接下来我们就可以利用Flutter提供的渲染问题分析工具即性能图层Performance Overlay来分析渲染问题了。
性能图层会在当前应用的最上层以Flutter引擎自绘的方式展示GPU与UI线程的执行图表而其中每一张图表都代表当前线程最近 300帧的表现如果UI产生了卡顿跳帧这些图表可以帮助我们分析并找到原因。
下图演示了性能图层的展现样式。其中GPU线程的性能情况在上面UI线程的情况显示在下面蓝色垂直的线条表示已执行的正常帧绿色的线条代表的是当前帧
图1 性能图层
为了保持60Hz的刷新频率GPU线程与UI线程中执行每一帧耗费的时间都应该小于16ms1/60秒。在这其中有一帧处理时间过长就会导致界面卡顿图表中就会展示出一个红色竖条。下图演示了应用出现渲染和绘制耗时的情况下性能图层的展示样式
图2 渲染和绘制耗时异常
如果红色竖条出现在GPU线程图表意味着渲染的图形太复杂导致无法快速渲染而如果是出现在了UI线程图表则表示Dart代码消耗了大量资源需要优化代码执行时间。
接下来我们就先看看GPU问题定位吧。
GPU问题定位
GPU问题主要集中在底层渲染耗时上。有时候Widget树虽然构造起来容易但在GPU线程下的渲染却很耗时。涉及Widget裁剪、蒙层这类多视图叠加渲染或是由于缺少缓存导致静态图像的反复绘制都会明显拖慢GPU的渲染速度。
我们可以使用性能图层提供的两项参数即检查多视图叠加的视图渲染开关checkerboardOffscreenLayers和检查缓存的图像开关checkerboardRasterCacheImages来检查这两种情况。
checkerboardOffscreenLayers
多视图叠加通常会用到Canvas里的savaLayer方法这个方法在实现一些特定的效果比如半透明时非常有用但由于其底层实现会在GPU渲染上涉及多图层的反复绘制因此会带来较大的性能问题。
对于saveLayer方法使用情况的检查我们只要在MaterialApp的初始化方法中将checkerboardOffscreenLayers开关设置为true分析工具就会自动帮我们检测多视图叠加的情况了使用了saveLayer的Widget会自动显示为棋盘格式并随着页面刷新而闪烁。
不过saveLayer是一个较为底层的绘制方法因此我们一般不会直接使用它而是会通过一些功能性Widget在涉及需要剪切或半透明蒙层的场景中间接地使用。所以一旦遇到这种情况我们需要思考一下是否一定要这么做能不能通过其他方式来实现呢。
比如下面的例子中我们使用CupertinoPageScaffold与CupertinoNavigationBar实现了一个动态模糊的效果。
CupertinoPageScaffold(
navigationBar: CupertinoNavigationBar(),//动态模糊导航栏
child: ListView.builder(
itemCount: 100,
//为列表创建100个不同颜色的RowItem
itemBuilder: (context, index)=>TabRowItem(
index: index,
lastItem: index == 100 - 1,
color: colorItems[index],//设置不同的颜色
colorName: colorNameItems[index],
)
)
);
图3 动态模糊效果
由于视图滚动过程中频繁涉及视图蒙层效果的更新因此checkerboardOffscreenLayers检测图层也感受到了对GPU的渲染压力频繁的刷新闪烁。
图4 检测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检测图层也不再频繁闪烁了。
图5 去掉动态模糊效果
checkerboardRasterCacheImages
从资源的角度看另一类非常消耗性能的操作是渲染图像。这是因为图像的渲染涉及I/O、GPU存储以及不同通道的数据格式转换因此渲染过程的构建需要消耗大量资源。为了缓解GPU的压力Flutter提供了多层次的缓存快照这样Widget重建时就无需重新绘制静态图像了。
与检查多视图叠加渲染的checkerboardOffscreenLayers参数类似Flutter也提供了检查缓存图像的开关checkerboardRasterCacheImages来检测在界面重绘时频繁闪烁的图像即没有静态缓存
我们可以把需要静态缓存的图像加到RepaintBoundary中RepaintBoundary可以确定Widget树的重绘边界如果图像足够复杂Flutter引擎会自动将其缓存避免重复刷新。当然因为缓存资源有限如果引擎认为图像不够复杂也可能会忽略RepaintBoundary。
如下代码展示了通过RepaintBoundary将一个静态复合Widget加入缓存的具体用法。可以看到RepaintBoundary在使用上与普通Widget并无区别
RepaintBoundary(//设置静态缓存图像
child: Center(
child: Container(
color: Colors.black,
height: 10.0,
width: 10.0,
),
));
UI线程问题定位
如果说GPU线程问题定位的是渲染引擎底层渲染异常那么UI线程问题发现的则是应用的性能瓶颈。比如在视图构建时在build方法中使用了一些复杂的运算或是在主Isolate中进行了同步的I/O操作。这些问题都会明显增加CPU的处理时间拖慢应用的响应速度。
这时我们可以使用Flutter提供的Performance工具来记录应用的执行轨迹。Performance是一个强大的性能分析工具能够以时间轴的方式展示CPU的调用栈和执行时间去检查代码中可疑的方法调用。
在点击了Android Studio底部工具栏中的“Open DevTools”按钮之后系统会自动打开Dart DevTools的网页将顶部的tab切换到Performance后我们就可以开始分析代码中的性能问题了。
图6 打开Performance工具
图7 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耗时问题皆可以查看火焰图底部的哪个函数占据的宽度最大只要有平顶”,就表示该函数可能存在性能问题比如我们这个案例的火焰图如下所示
图8 CPU帧图/火焰图
可以看到_MyHomePage.generateMd5函数的执行时间最长几乎占满了整个火焰图的宽而这也与代码中存在的问题是一致的
在找到了问题之后我们就可以使用Isolate或compute将这些耗时的操作挪到并发主Isolate之外去完成了
总结
好了今天的分享就到这里我们总结一下今天的主要内容吧
在Flutter中性能分析过程可以分为GPU线程问题定位和UI线程CPU问题定位而它们都需要在真机上以分析模式Profile启动应用并通过性能图层分析大致的渲染问题范围一旦确认问题存在接下来就需要利用Flutter所提供的分析工具来定位问题原因了
关于GPU线程渲染问题我们可以重点检查应用中是否存在多视图叠加渲染或是静态图像反复刷新的现象而UI线程渲染问题我们则是通过Performance工具记录的火焰图CPU帧图分析代码耗时找出应用执行瓶颈
通常来说由于Flutter采用基于声明式的UI设计理念以数据驱动渲染并采用Widget->Element->RenderObject三层结构屏蔽了无谓的界面刷新能够保证绝大多数情况下我们构建的应用都是高性能的所以在使用分析工具检测出性能问题之后通常我们并不需要做太多的细节优化工作只需要在改造过程中避开一些常见的坑就可以获得优异的性能。比如
控制build方法耗时将Widget拆小避免直接返回一个巨大的Widget这样Widget会享有更细粒度的重建和复用
尽量不要为Widget设置半透明效果而是考虑用图片的形式代替这样被遮挡的Widget部分区域就不需要绘制了
对列表采用懒加载而不是直接一次性创建所有的子Widget这样视图的初始化时间就减少了。
思考题
最后,我给你留下一道思考题吧。
请你改造ListView计算MD5的示例在保证原有功能的情况下使用并发Isolate或compute完成MD5的计算。提示计算过程可以使用CircularProgressIndicator来展示加载动画。
欢迎你在评论区给我留言分享你的观点,我会在下一篇文章中等待你!感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。

View File

@@ -0,0 +1,318 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
38 如何通过自动化测试提高交付质量?
你好,我是陈航。
在上一篇文章中我与你分享了如何分析并优化Flutter应用的性能问题。通过在真机上以分析模式运行应用我们可以借助于性能图层的帮助找到引起性能瓶颈的两类问题即GPU渲染问题和CPU执行耗时问题。然后我们就可以使用Flutter提供的渲染开关和CPU帧图火焰图来检查应用中是否存在过度渲染或是代码执行耗时长的情况从而去定位并着手解决应用的性能问题了。
在完成了应用的开发工作并解决了代码中的逻辑问题和性能问题之后接下来我们就需要测试验收应用的各项功能表现了。移动应用的测试工作量通常很大这是因为为了验证真实用户的使用体验测试往往需要跨越多个平台Android/iOS及不同的物理设备手动完成。
随着产品功能不断迭代累积,测试工作量和复杂度也随之大幅增长,手动测试变得越来越困难。那么,在为产品添加新功能,或者修改已有功能时,如何才能确保应用可以继续正常工作呢?
答案是,通过编写自动化测试用例。
所谓自动化测试,是把由人驱动的测试行为改为由机器执行。具体来说就是,通过精心设计的测试用例,由机器按照执行步骤对应用进行自动测试,并输出执行结果,最后根据测试用例定义的规则确定结果是否符合预期。
也就是说,自动化测试将重复的、机械的人工操作变为自动化的验证步骤,极大的节省人力、时间和硬件资源,从而提高了测试效率。
在自动化测试用例的编写上Flutter提供了包括单元测试和UI测试的能力。其中单元测试可以方便地验证单个函数、方法或类的行为而UI测试则提供了与Widget进行交互的能力确认其功能是否符合预期。
接下来,我们就具体看看这两种自动化测试用例的用法吧。
单元测试
单元测试是指,对软件中的最小可测试单元进行验证的方式,并通过验证结果来确定最小单元的行为是否与预期一致。所谓最小可测试单元,一般来说,就是人为规定的、最小的被测功能模块,比如语句、函数、方法或类。
在Flutter中编写单元测试用例我们可以在pubspec.yaml文件中使用test包来完成。其中test包提供了编写单元测试用例的核心框架即定义、执行和验证。如下代码所示就是test包的用法
dev_dependencies:
test:
备注test包的声明需要在dev_dependencies下完成在这个标签下面定义的包只会在开发模式生效。
与Flutter应用通过main函数定义程序入口相同Flutter单元测试用例也是通过main函数来定义测试入口的。不过这两个程序入口的目录位置有些区别应用程序的入口位于工程中的lib目录下而测试用例的入口位于工程中的test目录下。
一个有着单元测试用例的Flutter工程目录结构如下所示
图1 Flutter工程目录结构
接下来我们就可以在main.dart中声明一个用来测试的类了。在下面的例子中我们声明了一个计数器类Counter这个类可以支持以递增或递减的方式修改计数值count
class Counter {
int count = 0;
void increase() => count++;
void decrease() => count--;
}
实现完待测试的类我们就可以为它编写测试用例了。在Flutter中测试用例的声明包含定义、执行和验证三个部分定义和执行决定了被测试对象提供的、需要验证的最小可测单元而验证则需要使用expect函数将最小可测单元的执行结果与预期进行比较。
所以在Flutter中编写一个测试用例通常包含以下两大步骤
实现一个包含定义、执行和验证步骤的测试用例;
将其包装在test内部test是Flutter提供的测试用例封装类。
在下面的例子中我们定义了两个测试用例其中第一个用例用来验证调用increase函数后的计数器值是否为1而第二个用例则用来判断1+1是否等于2
import 'package:test/test.dart';
import 'package:flutter_app/main.dart';
void main() {
//第一个用例判断Counter对象调用increase方法后是否等于1
test('Increase a counter value should be 1', () {
final counter = Counter();
counter.increase();
expect(counter.value, 1);
});
//第二个用例判断1+1是否等于2
test('1+1 should be 2', () {
expect(1+1, 2);
});
}
选择widget_test.dart文件在右键弹出的菜单中选择“Run tests in widget_test就可以启动测试用例了。
图2 启动测试用例入口
稍等片刻,控制台就会输出测试用例的执行结果了。当然,这两个用例都能通过测试:
22:05 Tests passed: 2
如果测试用例的执行结果是不通过Flutter会给我们怎样的提示呢我们试着修改一下第一个计数器递增的用例将它的期望结果改为2
test('Increase a counter value should be 1', () {
final counter = Counter();
counter.increase();
expect(counter.value, 2);//判断Counter对象调用increase后是否等于2
});
运行测试用例可以看到Flutter在执行完计数器的递增方法后发现其结果1与预期的2不匹配于是报错
图3 单元测试失败示意图
上面的示例演示了单个测试用例的编写方法而如果有多个测试用例它们之间是存在关联关系的我们可以在最外层使用group将它们组合在一起。
在下面的例子中我们定义了计数器递增和计数器递减两个用例验证递增的结果是否等于1的同时判断递减的结果是否等于-1并把它们组合在了一起
import 'package:test/test.dart';
import 'package:counter_app/counter.dart';
void main() {
//组合测试用例判断Counter对象调用increase方法后是否等于1并且判断Counter对象调用decrease方法后是否等于-1
group('Counter', () {
test('Increase a counter value should be 1', () {
final counter = Counter();
counter.increase();
expect(counter.value, 1);
});
test('Decrease a counter value should be -1', () {
final counter = Counter();
counter.decrease();
expect(counter.value, -1);
});
});
}
同样的,这两个测试用例的执行结果也是通过。
在对程序的内部功能进行单元测试时我们还可能需要从外部依赖比如Web服务获取需要测试的数据。比如下面的例子Todo对象的初始化就是通过Web服务返回的JSON实现的。考虑到调用Web服务的过程中可能会出错所以我们还处理了请求码不等于200的其他异常情况
import 'package:http/http.dart' as http;
class Todo {
final String title;
Todo({this.title});
//工厂类构造方法将JSON转换为对象
factory Todo.fromJson(Map<String, dynamic> json) {
return Todo(
title: json['title'],
);
}
}
Future<Todo> fetchTodo(http.Client client) async {
final response =
await client.get('https://xxx.com/todos/1');
if (response.statusCode == 200) {
//请求成功解析JSON
return Todo.fromJson(json.decode(response.body));
} else {
//请求失败,抛出异常
throw Exception('Failed to load post');
}
}
考虑到这些外部依赖并不是我们的程序所能控制的因此很难覆盖所有可能的成功或失败方案。比如对于一个正常运行的Web服务来说我们基本不可能测试出fetchTodo这个接口是如何应对403或502状态码的。因此更好的一个办法是在测试用例中“模拟”这些外部依赖对应本例即为http.client让这些外部依赖可以返回特定结果。
在单元测试用例中模拟外部依赖我们需要在pubspec.yaml文件中使用mockito包以接口实现的方式定义外部依赖的接口
dev_dependencies:
test:
mockito:
要使用mockito包来模拟fetchTodo的依赖http.client我们首先需要定义一个继承自Mock这个类可以模拟任何外部依赖并以接口定义的方式实现了http.client的模拟类然后在测试用例的声明中为其制定任意的接口返回。
在下面的例子中我们定义了一个模拟类MockClient这个类以接口声明的方式获取到了http.Client的外部接口。随后我们就可以使用when语句在其调用Web服务时为其注入相应的数据返回了。在第一个用例中我们为其注入了JSON结果而在第二个用例中我们为其注入了一个403的异常。
import 'package:mockito/mockito.dart';
import 'package:http/http.dart' as http;
class MockClient extends Mock implements http.Client {}
void main() {
group('fetchTodo', () {
test('returns a Todo if successful', () async {
final client = MockClient();
//使用Mockito注入请求成功的JSON字段
when(client.get('https://xxx.com/todos/1'))
.thenAnswer((_) async => http.Response('{"title": "Test"}', 200));
//验证请求结果是否为Todo实例
expect(await fetchTodo(client), isInstanceOf<Todo>());
});
test('throws an exception if error', () {
final client = MockClient();
//使用Mockito注入请求失败的Error
when(client.get('https://xxx.com/todos/1'))
.thenAnswer((_) async => http.Response('Forbidden', 403));
//验证请求结果是否抛出异常
expect(fetchTodo(client), throwsException);
});
});
}
运行这段测试用例可以看到我们在没有调用真实Web服务的情况下成功模拟出了正常和异常两种结果同样也是顺利通过验证了。
接下来我们再看看UI测试吧。
UI测试
UI测试的目的是模仿真实用户的行为即以真实用户的身份对应用程序执行UI交互操作并涵盖各种用户流程。相比于单元测试UI测试的覆盖范围更广、更关注流程和交互可以找到单元测试期间无法找到的错误。
在Flutter中编写UI测试用例我们需要在pubspec.yaml中使用flutter_test包来提供编写UI测试的核心框架即定义、执行和验证
定义即通过指定规则找到UI测试用例需要验证的、特定的子Widget对象
执行意味着我们要在找到的子Widget对象中施加用户交互事件
验证表示在施加了交互事件后判断待验证的Widget对象的整体表现是否符合预期。
如下代码所示就是flutter_test包的用法
dev_dependencies:
flutter_test:
sdk: flutter
接下来我以Flutter默认的计时器应用模板为例与你说明UI测试用例的编写方法。
在计数器应用中有两处地方会响应外部交互事件包括响应用户点击行为的按钮Icon与响应渲染刷新事件的文本Text。按钮点击后计数器会累加文本也随之刷新。
图4 计数器示例
为确保程序的功能正常我们希望编写一个UI测试用例来验证按钮的点击行为是否与文本的刷新行为完全匹配。
与单元测试使用test对用例进行包装类似UI测试使用testWidgets对用例进行包装。testWidgets提供了tester参数我们可以使用这个实例来操作需要测试的Widget对象。
在下面的代码中我们首先声明了需要验证的MyApp对象。在通过pumpWidget触发其完成渲染后使用find.text方法分别查找了字符串文本为0和1的Text控件目的是验证响应刷新事件的文本Text的初始化状态是否为0。
随后我们通过find.byIcon方法找到了按钮控件并通过tester.tap方法对其施加了点击行为。在完成了点击后我们使用tester.pump方法强制触发其完成渲染刷新。最后我们使用了与验证Text初始化状态同样的语句判断在响应了刷新事件后的文本Text其状态是否为1
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_app_demox/main.dart';
void main() {
testWidgets('Counter increments UI test', (WidgetTester tester) async {
//声明所需要验证的Widget对象(即MyApp),并触发其渲染
await tester.pumpWidget(MyApp());
//查找字符串文本为'0'的Widget验证查找成功
expect(find.text('0'), findsOneWidget);
//查找字符串文本为'1'的Widget验证查找失败
expect(find.text('1'), findsNothing);
//查找'+'按钮,施加点击行为
await tester.tap(find.byIcon(Icons.add));
//触发其渲染
await tester.pump();
//查找字符串文本为'0'的Widget验证查找失败
expect(find.text('0'), findsNothing);
//查找字符串文本为'1'的Widget验证查找成功
expect(find.text('1'), findsOneWidget);
});
}
运行这段UI测试用例代码同样也顺利通过验证了。
除了点击事件之外tester还支持其他的交互行为比如文字输入enterText、拖动drag、长按longPress等这里我就不再一一赘述了。如果你想深入理解这些内容可以参考WidgetTester的官方文档进行学习。
总结
好了,今天的分享就到这里,我们总结一下今天的主要内容吧。
在Flutter中自动化测试可以分为单元测试和UI测试。
单元测试的步骤包括定义、执行和验证。通过单元测试用例我们可以验证单个函数、方法或类其行为表现是否与预期一致。而UI测试的步骤同样是包括定义、执行和验证。我们可以通过模仿真实用户的行为对应用进行交互操作覆盖更广的流程。
如果测试对象存在像Web服务这样的外部依赖为了让单元测试过程更为可控我们可以使用mockito为其定制任意的数据返回实现正常和异常两种测试用例。
需要注意的是尽管UI测试扩大了应用的测试范围可以找到单元测试期间无法找到的错误不过相比于单元测试用例来说UI测试用例的开发和维护代价非常高。因为一个移动应用最主要的功能其实就是UI而UI的变化非常频繁UI测试需要不断的维护才能保持稳定可用的状态。
“投入和回报”永远是考虑是否采用UI测试以及采用何种级别的UI测试需要最优先考虑的问题。我推荐的原则是项目达到一定的规模并且业务特征具有一定的延续规律性后再考虑UI测试的必要性。
我把今天分享涉及的知识点打包到了GitHub中你可以下载下来反复运行几次加深理解与记忆。
思考题
最后,我给你留下一道思考题吧。
在下面的代码中我们定义了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;
}
欢迎你在评论区给我留言分享你的观点,我会在下一篇文章中等待你!感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。

View File

@@ -0,0 +1,548 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
39 线上出现问题,该如何做好异常捕获与信息采集?
你好,我是陈航。
在上一篇文章中我与你分享了如何为一个Flutter工程编写自动化测试用例。设计一个测试用例的基本步骤可以分为3步即定义、执行和验证而Flutter提供的单元测试和UI测试框架则可以帮助我们简化这些步骤。
其中通过单元测试我们可以很方便地验证单个函数、方法或类的行为还可以利用mockito定制外部依赖返回任意数据从而让测试更可控而UI测试则提供了与Widget交互的能力我们可以模仿用户行为对应用进行相应的交互操作确认其功能是否符合预期。
通过自动化测试,我们可以把重复的人工操作变成自动化的验证步骤,从而在开发阶段更及时地发现问题。但终端设备的碎片化,使得我们终究无法在应用开发期就完全模拟出真实用户的运行环境。所以,无论我们的应用写得多么完美、测试得多么全面,总是无法完全避免线上的异常问题。
这些异常可能是因为不充分的机型适配、用户糟糕的网络状况也可能是因为Flutter框架自身的Bug甚至是操作系统底层的问题。这些异常一旦发生Flutter应用会无法响应用户的交互事件轻则报错重则功能无法使用甚至闪退这对用户来说都相当不友好是开发者最不愿意看到的。
所以我们要想办法去捕获用户的异常信息将异常现场保存起来并上传至服务器这样我们就可以分析异常上下文定位引起异常的原因去解决此类问题了。那么今天我们就一起来学习下Flutter异常的捕获和信息采集以及对应的数据上报处理。
Flutter异常
Flutter异常指的是Flutter程序中Dart代码运行时意外发生的错误事件。我们可以通过与Java类似的try-catch机制来捕获它。但与Java不同的是Dart程序不强制要求我们必须处理异常。
这是因为Dart采用事件循环的机制来运行任务所以各个任务的运行状态是互相独立的。也就是说即便某个任务出现了异常我们没有捕获它Dart程序也不会退出只会导致当前任务后续的代码不会被执行用户仍可以继续使用其他功能。
Dart异常根据来源又可以细分为App异常和Framework异常。Flutter为这两种异常提供了不同的捕获方式接下来我们就一起看看吧。
App异常的捕获方式
App异常就是应用代码的异常通常由未处理应用层其他模块所抛出的异常引起。根据异常代码的执行时序App异常可以分为两类即同步异常和异步异常同步异常可以通过try-catch机制捕获异步异常则需要采用Future提供的catchError语句捕获。
这两种异常的捕获方式,如下代码所示:
//使用try-catch捕获同步异常
try {
throw StateError('This is a Dart exception.');
}
catch(e) {
print(e);
}
//使用catchError捕获异步异常
Future.delayed(Duration(seconds: 1))
.then((e) => throw StateError('This is a Dart exception in Future.'))
.catchError((e)=>print(e));
//注意,以下代码无法捕获异步异常
try {
Future.delayed(Duration(seconds: 1))
.then((e) => throw StateError('This is a Dart exception in Future.'))
}
catch(e) {
print("This line will never be executed. ");
}
需要注意的是这两种方式是不能混用的。可以看到在上面的代码中我们是无法使用try-catch去捕获一个异步调用所抛出的异常的。
同步的try-catch和异步的catchError为我们提供了直接捕获特定异常的能力而如果我们想集中管理代码中的所有异常Flutter也提供了Zone.runZoned方法。
我们可以给代码执行对象指定一个Zone在Dart中Zone表示一个代码执行的环境范围其概念类似沙盒不同沙盒之间是互相隔离的。如果我们想要观察沙盒中代码执行出现的异常沙盒提供了onError回调函数拦截那些在代码执行对象中的未捕获异常。
在下面的代码中我们将可能抛出异常的语句放置在了Zone里。可以看到在没有使用try-catch和catchError的情况下无论是同步异常还是异步异常都可以通过Zone直接捕获到
runZoned(() {
//同步抛出异常
throw StateError('This is a Dart exception.');
}, onError: (dynamic e, StackTrace stack) {
print('Sync error caught by zone');
});
runZoned(() {
//异步抛出异常
Future.delayed(Duration(seconds: 1))
.then((e) => throw StateError('This is a Dart exception in Future.'));
}, onError: (dynamic e, StackTrace stack) {
print('Async error aught by zone');
});
因此如果我们想要集中捕获Flutter应用中的未处理异常可以把main函数中的runApp语句也放置在Zone中。这样在检测到代码中运行异常时我们就能根据获取到的异常上下文信息进行统一处理了
runZoned<Future<Null>>(() async {
runApp(MyApp());
}, onError: (error, stackTrace) async {
//Do sth for error
});
接下来我们再看看Framework异常应该如何捕获吧。
Framework异常的捕获方式
Framework异常就是Flutter框架引发的异常通常是由应用代码触发了Flutter框架底层的异常判断引起的。比如当布局不合规范时Flutter就会自动弹出一个触目惊心的红色错误界面如下所示
图1 Flutter布局错误提示
这其实是因为Flutter框架在调用build方法构建页面时进行了try-catch 的处理并提供了一个ErrorWidget用于在出现异常时进行信息提示
@override
void performRebuild() {
Widget built;
try {
//创建页面
built = build();
} catch (e, stack) {
//使用ErrorWidget创建页面
built = ErrorWidget.builder(_debugReportException(ErrorDescription("building $this"), e, stack));
...
}
...
}
这个页面反馈的信息比较丰富适合开发期定位问题。但如果让用户看到这样一个页面就很糟糕了。因此我们通常会重写ErrorWidget.builder方法将这样的错误提示页面替换成一个更加友好的页面。
下面的代码演示了自定义错误页面的具体方法。在这个例子中我们直接返回了一个居中的Text控件
ErrorWidget.builder = (FlutterErrorDetails flutterErrorDetails){
return Scaffold(
body: Center(
child: Text("Custom Error Widget"),
)
);
};
运行效果如下所示:
图2 自定义错误提示页面
比起之前触目惊心的红色错误页面白色主题的自定义页面看起来稍微友好些了。需要注意的是ErrorWidget.builder方法提供了一个参数details用于表示当前的错误上下文为避免用户直接看到错误信息这里我们并没有将它展示到界面上。但是我们不能丢弃掉这样的异常信息需要提供统一的异常处理机制用于后续分析异常原因。
为了集中处理框架异常Flutter提供了FlutterError类这个类的onError属性会在接收到框架异常时执行相应的回调。因此要实现自定义捕获逻辑我们只要为它提供一个自定义的错误处理回调即可。
在下面的代码中我们使用Zone提供的handleUncaughtError语句将Flutter框架的异常统一转发到当前的Zone中这样我们就可以统一使用Zone去处理应用内的所有异常了
FlutterError.onError = (FlutterErrorDetails details) async {
//转发至Zone中
Zone.current.handleUncaughtError(details.exception, details.stack);
};
runZoned<Future<Null>>(() async {
runApp(MyApp());
}, onError: (error, stackTrace) async {
//Do sth for error
});
异常上报
到目前为止,我们已经捕获到了应用中所有的未处理异常。但如果只是把这些异常在控制台中打印出来还是没办法解决问题,我们还需要把它们上报到开发者能看到的地方,用于后续分析定位并解决问题。
关于开发者数据上报目前市面上有很多优秀的第三方SDK服务厂商比如友盟、Bugly以及开源的Sentry等而它们提供的功能和接入流程都是类似的。考虑到Bugly的社区活跃度比较高因此我就以它为例与你演示在抓取到异常后如何实现自定义数据上报。
Dart接口实现
目前Bugly仅提供了原生Android/iOS的SDK因此我们需要采用与第31篇文章“如何实现原生推送能力”中同样的插件工程为Bugly的数据上报提供Dart层接口。
与接入Push能力相比接入数据上报要简单得多我们只需要完成一些前置应用信息关联绑定和SDK初始化工作就可以使用Dart层封装好的数据上报接口去上报异常了。可以看到对于一个应用而言接入数据上报服务的过程总体上可以分为两个步骤
初始化Bugly SDK
使用数据上报接口。
这两步对应着在Dart层需要封装的2个原生接口调用即setup和postException它们都是在方法通道上调用原生代码宿主提供的方法。考虑到数据上报是整个应用共享的能力因此我们将数据上报类FlutterCrashPlugin的接口都封装成了单例
class FlutterCrashPlugin {
//初始化方法通道
static const MethodChannel _channel =
const MethodChannel('flutter_crash_plugin');
static void setUp(appID) {
//使用app_id进行SDK注册
_channel.invokeMethod("setUp",{'app_id':appID});
}
static void postException(error, stack) {
//将异常和堆栈上报至Bugly
_channel.invokeMethod("postException",{'crash_message':error.toString(),'crash_detail':stack.toString()});
}
}
Dart层是原生代码宿主的代理可以看到这一层的接口设计还是比较简单的。接下来我们分别去接管数据上报的Android和iOS平台上完成相应的实现。
iOS接口实现
考虑到iOS平台的数据上报配置工作相对较少因此我们先用Xcode打开example下的iOS工程进行插件开发工作。需要注意的是由于iOS子工程的运行依赖于Flutter工程编译构建产物所以在打开iOS工程进行开发前你需要确保整个工程代码至少build过一次否则IDE会报错。
备注以下操作步骤参考Bugly异常上报iOS SDK接入指南。
首先我们需要在插件工程下的flutter_crash_plugin.podspec文件中引入Bugly SDK即Bugly这样我们就可以在原生工程中使用Bugly提供的数据上报功能了
Pod::Spec.new do |s|
...
s.dependency 'Bugly'
end
然后在原生接口FlutterCrashPlugin类中依次初始化插件实例、绑定方法通道并在方法通道中先后为setup与postException提供Bugly iOS SDK的实现版本
@implementation FlutterCrashPlugin
+ (void)registerWithRegistrar:(NSObject<FlutterPluginRegistrar>*)registrar {
//注册方法通道
FlutterMethodChannel* channel = [FlutterMethodChannel
methodChannelWithName:@"flutter_crash_plugin"
binaryMessenger:[registrar messenger]];
//初始化插件实例,绑定方法通道
FlutterCrashPlugin* instance = [[FlutterCrashPlugin alloc] init];
//注册方法通道回调函数
[registrar addMethodCallDelegate:instance channel:channel];
}
- (void)handleMethodCall:(FlutterMethodCall*)call result:(FlutterResult)result {
if([@"setUp" isEqualToString:call.method]) {
//Bugly SDK初始化方法
NSString *appID = call.arguments[@"app_id"];
[Bugly startWithAppId:appID];
} else if ([@"postException" isEqualToString:call.method]) {
//获取Bugly数据上报所需要的各个参数信息
NSString *message = call.arguments[@"crash_message"];
NSString *detail = call.arguments[@"crash_detail"];
NSArray *stack = [detail componentsSeparatedByString:@"\n"];
//调用Bugly数据上报接口
[Bugly reportExceptionWithCategory:4 name:message reason:stack[0] callStack:stack extraInfo:@{} terminateApp:NO];
result(@0);
}
else {
//方法未实现
result(FlutterMethodNotImplemented);
}
}
@end
至此在完成了Bugly iOS SDK的接口封装之后FlutterCrashPlugin插件的iOS部分也就搞定了。接下来我们去看看Android部分如何实现吧。
Android接口实现
与iOS类似我们需要使用Android Studio打开example下的android工程进行插件开发工作。同样在打开android工程前你需要确保整个工程代码至少build过一次否则IDE会报错。
备注以下操作步骤参考Bugly异常上报Android SDK接入指南
首先我们需要在插件工程下的build.gradle文件引入Bugly SDK即crashreport与nativecrashreport其中前者提供了Java和自定义异常的的数据上报能力而后者则是JNI的异常上报封装
dependencies {
implementation 'com.tencent.bugly:crashreport:latest.release'
implementation 'com.tencent.bugly:nativecrashreport:latest.release'
}
然后在原生接口FlutterCrashPlugin类中依次初始化插件实例、绑定方法通道并在方法通道中先后为setup与postException提供Bugly Android SDK的实现版本
public class FlutterCrashPlugin implements MethodCallHandler {
//注册器通常为MainActivity
public final Registrar registrar;
//注册插件
public static void registerWith(Registrar registrar) {
//注册方法通道
final MethodChannel channel = new MethodChannel(registrar.messenger(), "flutter_crash_plugin");
//初始化插件实例,绑定方法通道,并注册方法通道回调函数
channel.setMethodCallHandler(new FlutterCrashPlugin(registrar));
}
private FlutterCrashPlugin(Registrar registrar) {
this.registrar = registrar;
}
@Override
public void onMethodCall(MethodCall call, Result result) {
if(call.method.equals("setUp")) {
//Bugly SDK初始化方法
String appID = call.argument("app_id");
CrashReport.initCrashReport(registrar.activity().getApplicationContext(), appID, true);
result.success(0);
}
else if(call.method.equals("postException")) {
//获取Bugly数据上报所需要的各个参数信息
String message = call.argument("crash_message");
String detail = call.argument("crash_detail");
//调用Bugly数据上报接口
CrashReport.postException(4,"Flutter Exception",message,detail,null);
result.success(0);
}
else {
result.notImplemented();
}
}
}
在完成了Bugly Android接口的封装之后由于Android系统的权限设置较细考虑到Bugly还需要网络、日志读取等权限因此我们还需要在插件工程的AndroidManifest.xml文件中将这些权限信息显示地声明出来完成对系统的注册
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.hangchen.flutter_crash_plugin">
<!-- 电话状态读取权限 -->
<uses-permission android:name="android.permission.READ_PHONE_STATE" />
<!-- 网络权限 -->
<uses-permission android:name="android.permission.INTERNET" />
<!-- 访问网络状态权限 -->
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<!-- 访问wifi状态权限 -->
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<!-- 日志读取权限 -->
<uses-permission android:name="android.permission.READ_LOGS" />
</manifest>
至此在完成了极光Android SDK的接口封装和权限配置之后FlutterCrashPlugin插件的Android部分也搞定了。
FlutterCrashPlugin插件为Flutter应用提供了数据上报的封装不过要想Flutter工程能够真正地上报异常消息我们还需要为Flutter工程关联Bugly的应用配置。
应用工程配置
在单独为Android/iOS应用进行数据上报配置之前我们首先需要去Bugly的官方网站为应用注册唯一标识符即AppKey。这里需要注意的是在Bugly中Android应用与iOS应用被视为不同的产品所以我们需要分别注册
图3 Android应用Demo配置
图4 iOS应用Demo配置
在得到了AppKey之后我们需要依次进行Android与iOS的配置工作。
iOS的配置工作相对简单整个配置过程完全是应用与Bugly SDK的关联工作而这些关联工作仅需要通过Dart层调用setUp接口访问原生代码宿主所封装的Bugly API就可以完成因此无需额外操作。
而Android的配置工作则相对繁琐些。由于涉及NDK和Android P网络安全的适配我们还需要分别在build.gradle和AndroidManifest.xml文件进行相应的配置工作。
首先由于Bugly SDK需要支持NDK因此我们需要在App的build.gradle文件中为其增加NDK的架构支持
defaultConfig {
ndk {
// 设置支持的SO库架构
abiFilters 'armeabi' , 'x86', 'armeabi-v7a', 'x86_64', 'arm64-v8a'
}
}
然后由于Android P默认限制http明文传输数据因此我们需要为Bugly声明一项网络安全配置network_security_config.xml允许其使用http传输数据并在AndroidManifest.xml中新增同名网络安全配置
//res/xml/network_security_config.xml
<?xml version="1.0" encoding="utf-8"?>
<!-- 网络安全配置 -->
<network-security-config>
<!-- 允许明文传输数据 -->
<domain-config cleartextTrafficPermitted="true">
<!-- 将Bugly的域名加入白名单 -->
<domain includeSubdomains="true">android.bugly.qq.com</domain>
</domain-config>
</network-security-config>
//AndroidManifest/xml
<application
...
android:networkSecurityConfig="@xml/network_security_config"
...>
</application>
至此Flutter工程所需的原生配置工作和接口实现就全部搞定了。
接下来我们就可以在Flutter工程中的main.dart文件中使用FlutterCrashPlugin插件来实现异常数据上报能力了。当然我们首先还需要在pubspec.yaml文件中将工程对它的依赖显示地声明出来
dependencies:
flutter_push_plugin:
git:
url: https://github.com/cyndibaby905/39_flutter_crash_plugin
在下面的代码中我们在main函数里为应用的异常提供了统一的回调并在回调函数内使用postException方法将异常上报至Bugly。
而在SDK的初始化方法里由于Bugly视iOS和Android为两个独立的应用因此我们判断了代码的运行宿主分别使用两个不同的App ID对其进行了初始化工作。
此外,为了与你演示具体的异常拦截功能,我们还在两个按钮的点击事件处理中分别抛出了同步和异步两类异常:
//上报数据至Bugly
Future<Null> _reportError(dynamic error, dynamic stackTrace) async {
FlutterCrashPlugin.postException(error, stackTrace);
}
Future<Null> main() async {
//注册Flutter框架的异常回调
FlutterError.onError = (FlutterErrorDetails details) async {
//转发至Zone的错误回调
Zone.current.handleUncaughtError(details.exception, details.stack);
};
//自定义错误提示页面
ErrorWidget.builder = (FlutterErrorDetails flutterErrorDetails){
return Scaffold(
body: Center(
child: Text("Custom Error Widget"),
)
);
};
//使用runZone方法将runApp的运行放置在Zone中并提供统一的异常回调
runZoned<Future<Null>>(() async {
runApp(MyApp());
}, onError: (error, stackTrace) async {
await _reportError(error, stackTrace);
});
}
class MyApp extends StatefulWidget {
@override
State<StatefulWidget> createState() => _MyAppState();
}
class _MyAppState extends State<MyApp> {
@override
void initState() {
//由于Bugly视iOS和Android为两个独立的应用因此需要使用不同的App ID进行初始化
if(Platform.isAndroid){
FlutterCrashPlugin.setUp('43eed8b173');
}else if(Platform.isIOS){
FlutterCrashPlugin.setUp('088aebe0d5');
}
super.initState();
}
@override
Widget build(BuildContext context) {
return MaterialApp(
home: MyHomePage(),
);
}
}
class MyHomePage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('Crashy'),
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
RaisedButton(
child: Text('Dart exception'),
onPressed: () {
//触发同步异常
throw StateError('This is a Dart exception.');
},
),
RaisedButton(
child: Text('async Dart exception'),
onPressed: () {
//触发异步异常
Future.delayed(Duration(seconds: 1))
.then((e) => throw StateError('This is a Dart exception in Future.'));
},
)
],
),
),
);
}
}
运行这段代码分别点击Dart exception按钮和async Dart exception按钮几次可以看到我们的应用以及控制台并没有提示任何异常信息。
图5 异常拦截演示示例iOS
图6 异常拦截演示示例Android
然后我们打开Bugly开发者后台选择对应的App切换到错误分析选项查看对应的面板信息。可以看到Bugly已经成功接收到上报的异常上下文了。
图7 Bugly iOS错误分析上报数据查看
图8 Bugly Android错误分析上报数据查看
总结
好了,今天的分享就到这里,我们来小结下吧。
对于Flutter应用的异常捕获可以分为单个异常捕获和多异常统一拦截两种情况。
其中单异常捕获使用Dart提供的同步异常try-catch以及异步异常catchError机制即可实现。而对多个异常的统一拦截可以细分为如下两种情况一是App异常我们可以将代码执行块放置到Zone中通过onError回调进行统一处理二是Framework异常我们可以使用FlutterError.onError回调进行拦截。
在捕获到异常之后我们需要上报异常信息用于后续分析定位问题。考虑到Bugly的社区活跃度比较高所以我以Bugly为例与你讲述了以原生插件封装的形式如何进行异常信息上报。
需要注意的是Flutter提供的异常拦截只能拦截Dart层的异常而无法拦截Engine层的异常。这是因为Engine层的实现大部分是C++的代码一旦出现异常整个程序就直接Crash掉了。不过通常来说这类异常出现的概率极低一般都是Flutter底层的Bug与我们在应用层的实现没太大关系所以我们也无需过度担心。
如果我们想要追踪Engine层的异常比如给Flutter提Issue则需要借助于原生系统提供的Crash监听机制。这就是一个很繁琐的工作了。
幸运的是我们使用的数据上报SDK Bugly就提供了这样的能力可以自动收集原生代码的Crash。而在Bugly收集到对应的Crash之后我们需要做的事情就是将Flutter Engine层对应的符号表下载下来使用Android提供的ndk-stack、iOS提供的symbolicatecrash或atos命令对相应Crash堆栈进行解析从而得出Engine层崩溃的具体代码。
关于这些步骤的详细说明你可以参考Flutter官方文档。
我把今天分享涉及的知识点打包到了GitHub中你可以下载下来反复运行几次加深理解与记忆。
思考题
最后,我给你留下两道思考题吧。
第一个问题请扩展_reportError和自定义错误提示页面的实现在Debug环境下将异常数据打印至控制台并保留原有系统错误提示页面实现。
//上报数据至Bugly
Future<Null> _reportError(dynamic error, dynamic stackTrace) async {
FlutterCrashPlugin.postException(error, stackTrace);
}
//自定义错误提示页面
ErrorWidget.builder = (FlutterErrorDetails flutterErrorDetails){
return Scaffold(
body: Center(
child: Text("Custom Error Widget"),
)
);
};
第二个问题并发Isolate的异常可以通过今天分享中介绍的捕获机制去拦截吗如果不行应该怎么做呢
//并发Isolate
doSth(msg) => throw ConcurrentModificationError('This is a Dart exception.');
//主Isolate
Isolate.spawn(doSth, "Hi");
欢迎你在评论区给我留言分享你的观点,我会在下一篇文章中等待你!感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。

View File

@@ -0,0 +1,251 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
40 衡量Flutter App线上质量我们需要关注这三个指标
你好,我是陈航。
在上一篇文章中我与你分享了如何捕获Flutter应用的未处理异常。所谓异常指的是Dart代码在运行时意外发生的错误事件。对于单一异常来说我们可以使用try-catch或是catchError去处理而如果我们想对异常进行集中的拦截治理则需要使用Zone并结合FlutterError进行统一管理。异常一旦被抓取我们就可以利用第三方数据上报服务比如Bugly上报其上下文信息了。
这些线上异常的监控数据,对于开发者尽早发现线上隐患,确定问题根因至关重要。如果我们想进一步评估应用整体的稳定性的话,就需要把异常信息与页面的渲染关联起来。比如,页面渲染过程是否出现了异常,而导致功能不可用?
而对于以“丝般顺滑”著称的Flutter应用而言页面渲染的性能同样需要我们重点关注。比如界面渲染是否出现会掉帧卡顿现象或者页面加载是否会出现性能问题导致耗时过长这些问题虽不至于让应用完全不能使用但也很容易引起用户对应用质量的质疑甚至是反感。
通过上面的分析可以看到衡量线上Flutter应用整体质量的指标可以分为以下3类
页面异常率;
页面帧率;
页面加载时长。
其中,页面异常率反应了页面的健康程度,页面帧率反应了视觉效果的顺滑程度,而页面加载时长则反应了整个渲染过程中点对点的延时情况。
这三项数据指标是度量Flutter应用是否优秀的重要质量指标。通过梳理这些指标的统计口径建立起Flutter应用的质量监控能力这样一来我们不仅可以及早发现线上隐患还可以确定质量基线从而持续提升用户体验。
所以在今天的分享中我会与你详细讲述这3项指标是如何采集的。
页面异常率
页面异常率指的是,页面渲染过程中出现异常的概率。它度量的是页面维度下功能不可用的情况,其统计公式为:页面异常率=异常发生次数/整体页面PV数。
在了解了页面异常率的统计口径之后,接下来我们分别来看一下这个公式中的分子与分母应该如何统计吧。
我们先来看看异常发生次数的统计方法。通过上一篇文章我们已经知道了在Flutter中未处理异常需要通过Zone与FlutterError去捕获。所以如果我们想统计异常发生次数的话依旧是利用这两个方法只不过要在异常拦截的方法中通过一个计数器进行累加统一记录。
下面的例子演示了异常发生次数的具体统计方法。我们使用全局变量exceptionCount在异常捕获的回调方法_reportError中持续地累加捕获到的异常次数
int exceptionCount = 0;
Future<Null> _reportError(dynamic error, dynamic stackTrace) async {
exceptionCount++; //累加异常次数
FlutterCrashPlugin.postException(error, stackTrace);
}
Future<Null> main() async {
FlutterError.onError = (FlutterErrorDetails details) async {
//将异常转发至Zone
Zone.current.handleUncaughtError(details.exception, details.stack);
};
runZoned<Future<Null>>(() async {
runApp(MyApp());
}, onError: (error, stackTrace) async {
//拦截异常
await _reportError(error, stackTrace);
});
}
接下来我们再看看整体页面PV数如何统计吧。整体页面PV数其实就是页面的打开次数。通过第21篇文章“路由与导航Flutter是这样实现页面切换的”我们已经知道了Flutter页面的切换需要经过Navigator来实现所以页面切换状态也需要通过Navigator才能感知到。
与注册页面路由类似的在MaterialApp中我们可以通过NavigatorObservers属性去监听页面的打开与关闭。下面的例子演示了NavigatorObserver的具体用法。在下面的代码中我们定义了一个继承自NavigatorObserver的观察者并在其didPush方法中去统计页面的打开行为
int totalPV = 0;
//导航监听器
class MyObserver extends NavigatorObserver{
@override
void didPush(Route route, Route previousRoute) {
super.didPush(route, previousRoute);
totalPV++;//累加PV
}
}
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
//设置路由监听
navigatorObservers: [
MyObserver(),
],
home: HomePage(),
);
}
}
现在我们已经收集到了异常发生次数和整体页面PV数这两个参数接下来我们就可以计算出页面异常率了
double pageException() {
if(totalPV == 0) return 0;
return exceptionCount/totalPV;
}
可以看到,页面异常率的计算还是相对比较简单的。
页面帧率
页面帧率即FPS是图像领域中的定义指的是画面每秒传输帧数。由于人眼的视觉暂留特质当所见到的画面传输帧数高于一定数量的时候就会认为是连贯性的视觉效果。因此对于动态页面而言每秒钟展示的帧数越多画面就越流畅。
由此我们可以得出FPS的计算口径为单位时间内渲染的帧总数。在移动设备中FPS的推荐数值通常是60Hz即每秒刷新页面60次。
为什么是60Hz而不是更高或更低的值呢这是因为显示过程是由VSync信号周期性驱动的而VSync信号的周期就是每秒60次这也是FPS的上限。
CPU与GPU在接收到VSync信号后就会计算图形图像准备渲染内容并将其提交到帧缓冲区等待下一次VSync信号到来时显示到屏幕上。如果在一个VSync时间内CPU或者GPU没有完成内容提交这一帧就会被丢弃等待下一次机会再显示而这时页面会保留之前的内容不变造成界面卡顿。因此FPS低于60Hz时就会出现掉帧现象而如果低于45Hz则会有比较严重的卡顿现象。
为方便开发者统计FPSFlutter在全局window对象上提供了帧回调机制。我们可以在window对象上注册onReportTimings方法将最近绘制帧耗费的时间即FrameTiming以回调的形式告诉我们。有了每一帧的绘制时间后我们就可以计算FPS了。
需要注意的是onReportTimings方法只有在有帧被绘制时才有数据回调如果用户没有和App发生交互界面状态没有变化时是不会产生新的帧的。考虑到单个帧的绘制时间差异较大逐帧计算可能会产生数据跳跃所以为了让FPS的计算更加平滑我们需要保留最近25个FrameTiming用于求和计算。
而另一方面对于FPS的计算我们并不能孤立地只考虑帧绘制时间而应该结合VSync信号的周期即1/60秒即16.67毫秒)来综合评估。
由于帧的渲染是依靠VSync信号驱动的如果帧绘制的时间没有超过16.67毫秒我们也需要把它当成16.67毫秒来算因为绘制完成的帧必须要等到下一次VSync信号来了之后才能渲染。而如果帧绘制时间超过了16.67毫秒则会占用后续的VSync信号周期从而打乱后续的绘制次序产生卡顿现象。这里有两种情况
如果帧绘制时间正好是16.67的整数倍比如50则代表它花费了3个VSync信号周期即本来可以绘制3帧但实际上只绘制了1帧
如果帧绘制时间不是16.67的整数倍比如51那么它花费的VSync信号周期应该向上取整即4个这意味着本来可以绘制4帧实际上只绘制了1帧。
所以我们的FPS计算公式最终确定为FPS=60*实际渲染的帧数/本来应该在这个时间内渲染完成的帧数。
下面的示例演示了如何通过onReportTimings回调函数实现FPS的计算。在下面的代码中我们定义了一个容量为25的列表用于存储最近的帧绘制耗时FrameTiming。在FPS的计算函数中我们将列表中每帧绘制时间与VSync周期frameInterval进行比较得出本来应该绘制的帧数最后两者相除就得到了FPS指标。
需要注意的是Android Studio提供的Flutter插件里展示的FPS信息其实也来自于onReportTimings回调所以我们在注册回调时需要保留原始回调引用否则插件就读不到FPS信息了。
import 'dart:ui';
var orginalCallback;
void main() {
runApp(MyApp());
//设置帧回调函数并保存原始帧回调函数
orginalCallback = window.onReportTimings;
window.onReportTimings = onReportTimings;
}
//仅缓存最近25帧绘制耗时
const maxframes = 25;
final lastFrames = List<FrameTiming>();
//基准VSync信号周期
const frameInterval = const Duration(microseconds: Duration.microsecondsPerSecond ~/ 60);
void onReportTimings(List<FrameTiming> timings) {
lastFrames.addAll(timings);
//仅保留25帧
if(lastFrames.length > maxframes) {
lastFrames.removeRange(0, lastFrames.length - maxframes);
}
//如果有原始帧回调函数,则执行
if (orginalCallback != null) {
orginalCallback(timings);
}
}
double get fps {
int sum = 0;
for (FrameTiming timing in lastFrames) {
//计算渲染耗时
int duration = timing.timestampInMicroseconds(FramePhase.rasterFinish) - timing.timestampInMicroseconds(FramePhase.buildStart);
//判断耗时是否在Vsync信号周期内
if(duration < frameInterval.inMicroseconds) {
sum += 1;
} else {
//有丢帧向上取整
int count = (duration/frameInterval.inMicroseconds).ceil();
sum += count;
}
}
return lastFrames.length/sum * 60;
}
运行这段代码可以看到我们统计的FPS指标和Flutter插件展示的FPS走势是一致的
图1 FPS指标走势
页面加载时长
页面加载时长指的是页面从创建到可见的时间它反应的是代码中创建页面视图是否存在过度绘制或者绘制不合理导致创建视图时间过长的情况
从定义可以看出页面加载时长的统计口径为页面可见的时间-页面创建的时间获取页面创建的时间比较容易我们只需要在页面的初始化函数里记录时间即可那么页面可见的时间应该如何统计呢
在第11篇文章提到生命周期我们是在说什么?”我在介绍Widget的生命周期时曾向你介绍过Flutter的帧回调机制WidgetsBinding提供了单次Frame回调addPostFrameCallback方法它会在当前Frame绘制完成之后进行回调并且只会回调一次一旦监听到Frame绘制完成回调后我们就可以确认页面已经被渲染出来了因此我们可以借助这个方法去获取页面可见的时间
下面的例子演示了如何通过帧回调机制获取页面加载时长在下面的代码中我们在页面MyPage的初始化方法中记录了页面的创建时间startTime然后在页面状态的初始化方法中通过addPostFrameCallback注册了单次帧绘制回调并在回调函数中记录了页面的渲染完成时间endTime将这两个时间做减法我们就得到了MyPage的页面加载时长
class MyHomePage extends StatefulWidget {
int startTime;
int endTime;
MyHomePage({Key key}) : super(key: key) {
//页面初始化时记录启动时间
startTime = DateTime.now().millisecondsSinceEpoch;
}
@override
_MyHomePageState createState() => _MyHomePageState();
}
class _MyHomePageState extends State<MyHomePage> {
@override
void initState() {
super.initState();
//通过帧绘制回调获取渲染完成时间
WidgetsBinding.instance.addPostFrameCallback((_) {
widget.endTime = DateTime.now().millisecondsSinceEpoch;
int timeSpend = widget.endTime - widget.startTime;
print("Page render time:${timeSpend} ms");
});
}
...
}
试着运行一下代码,观察命令行输出:
flutter: Page render time:548 ms
可以看到通过单次帧绘制回调统计得出的页面加载时间为548毫秒。
至此我们就已经得到了页面异常率、页面帧率和页面加载时长这3个指标了。
总结
好了,今天的分享就到这里,我们来总结下主要内容吧。
今天我们一起学习了衡量Flutter应用线上质量的3个指标即页面异常率、页面帧率和页面加载时长以及分别对应的数据采集方式。
其中页面异常率表示页面渲染过程中的稳定性可以通过集中捕获未处理异常结合NavigatorObservers观察页面PV计算得出页面维度下功能不可用的概率。
页面帧率则表示了页面的流畅情况可以利用Flutter提供的帧绘制耗时回调onReportTimings以加权的形式计算出本应该绘制的帧数得到更为准确的FPS。
而页面加载时长,反应的是渲染过程的延时情况。我们可以借助于单次帧回调机制,来获取页面渲染完成时间,从而得到整体页面的加载时长。
通过这3个数据指标统计方法我们再去评估Flutter应用的性能时就有一个具体的数字化标准了。而有了数据之后我们不仅可以及早发现问题隐患准确定位及修复问题还可以根据它们去评估应用的健康程度和页面的渲染性能从而确定后续的优化方向。
我把今天分享涉及的知识点打包到了GitHub中你可以下载下来反复运行几次加深理解与记忆。
思考题
最后,我给你留一道思考题吧。
如果页面的渲染需要依赖单个或多个网络接口数据,这时的页面加载时长应该如何统计呢?
欢迎你在评论区给我留言分享你的观点,我会在下一篇文章中等待你!感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。

View File

@@ -0,0 +1,124 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
41 组件化和平台化该如何组织合理稳定的Flutter工程结构
你好我是陈航。今天我们来聊一聊Flutter应用的工程架构这个话题。
在软件开发中我们不仅要在代码实现中遵守常见的设计模式更需要在架构设计中遵从基本的设计原则。而在这其中DRY即Dont Repeat Yourself原则可以算是最重要的一个。
通俗来讲DRY原则就是“不要重复”。这是一个很朴素的概念因为即使是最初级的开发者在写了一段时间代码后也会不自觉地把一些常用的重复代码抽取出来放到公用的函数、类或是独立的组件库中从而实现代码复用。
在软件开发中,我们通常从架构设计中就要考虑如何去管理重复性(即代码复用),即如何将功能进行分治,将大问题分解为多个较为独立的小问题。而在这其中,组件化和平台化就是客户端开发中最流行的分治手段。
所以今天我们就一起来学习一下这两类分治复用方案的中心思想这样我们在设计Flutter应用的架构时也就能做到有章可循了。
组件化
组件化又叫模块化即基于可重用的目的将一个大型软件系统App按照关注点分离的方式拆分成多个独立的组件或模块。每个独立的组件都是一个单独的系统可以单独维护、升级甚至直接替换也可以依赖于别的独立组件只要组件提供的功能不发生变化就不会影响其他组件和软件系统的整体功能。
图1 组件化示意图
可以看到组件化的中心思想是将独立的功能进行拆分而在拆分粒度上组件化的约束则较为松散。一个独立的组件可以是一个软件包Package、页面、UI控件甚至可能是封装了一些函数的模块。
组件的粒度可大可小,那我们如何才能做好组件的封装重用呢?哪些代码应该被放到一个组件中?这里有一些基本原则,包括单一性原则、抽象化原则、稳定性原则和自完备性原则。
接下来,我们先看看这些原则具体是什么意思。
单一性原则指的是,每个组件仅提供一个功能。分而治之是组件化的中心思想,每个组件都有自己固定的职责和清晰的边界,专注地做一件事儿,这样这个组件才能良性发展。
一个反例是Common或Util组件这类组件往往是因为在开发中出现了定义不明确、归属边界不清晰的代码“哎呀这段代码放哪儿好像都不合适那就放CommonUtil吧”。久而久之这类组件就变成了无人问津的垃圾堆。所以再遇到不知道该放哪儿的代码时就需要重新思考组件的设计和职责了。
抽象化原则指的是,组件提供的功能抽象应该尽量稳定,具有高复用度。而稳定的直观表现就是对外暴露的接口很少发生变化,要做到这一点,需要我们提升对功能的抽象总结能力,在组件封装时做好功能抽象和接口设计,将所有可能发生变化的因子都在组件内部做好适配,不要暴露给它的调用方。
稳定性原则指的是不要让稳定的组件依赖不稳定的组件。比如组件1依赖了组件5如果组件1很稳定但是组件5经常变化那么组件1也就会变得不稳定了需要经常适配。如果组件5里确实有组件1不可或缺的代码我们可以考虑把这段代码拆出来单独做成一个新的组件X或是直接在组件1中拷贝一份依赖的代码。
自完备性即组件需要尽可能地做到自给自足尽量减少对其他底层组件的依赖达到代码可复用的目的。比如组件1只是依赖某个大组件5中的某个方法这时更好的处理方法是剥离掉组件1对组件5的依赖直接把这个方法拷贝到组件1中。这样一来组件1就能够更好地应对后续的外部变更了。
在理解了组件化的基本原则之后,我们再来看看组件化的具体实施步骤,即剥离基础功能、抽象业务模块和最小化服务能力。
首先我们需要剥离应用中与业务无关的基础功能比如网络请求、组件中间件、第三方库封装、UI组件等将它们封装为独立的基础库然后我们在项目里用pub进行管理。如果是第三方库考虑到后续的维护适配成本我们最好再封装一层使项目不直接依赖外部代码方便后续更新或替换。
基础功能已经封装成了定义更清晰的组件,接下来我们就可以按照业务维度,比如首页、详情页、搜索页等,去拆分独立的模块了。拆分的粒度可以先粗后细,只要能将大体划分清晰的业务组件进行拆分,后续就可以通过分布迭代、局部微调,最终实现整个业务项目的组件化。
在业务组件和基础组件都完成拆分封装后应用的组件化架构就基本成型了最后就可以按照刚才我们说的4个原则去修正各个组件向下的依赖以及最小化对外暴露的能力了。
平台化
从组件的定义可以看到,组件是个松散的广义概念,其规模取决于我们封装的功能维度大小,而各个组件之间的关系也仅靠依赖去维持。如果组件之间的依赖关系比较复杂,就会在一定程度上造成功能耦合现象。
如下所示的组件示意图中组件2和组件3同时被多个业务组件和基础功能组件直接引用甚至组件2和组件5、组件3和组件4之间还存在着循环依赖的情况。一旦这些组件的内部实现和外部接口发生变化整个App就会陷入不稳定的状态即所谓牵一发而动全身。
图2 循环依赖现象
平台化是组件化的升级即在组件化的基础上对它们提供的功能进行分类统一分层划分增加依赖治理的概念。为了对这些功能单元在概念上进行更为统一的分类我们按照四象限分析法把应用程序的组件按照业务和UI分解为4个维度来分析组件可以分为哪几类。
图3 组件划分原则
可以看出经过业务与UI的分解之后这些组件可以分为4类
具备UI属性的独立业务模块
不具备UI属性的基础业务功能
不具备业务属性的UI控件
不具备业务属性的基础功能
按照自身定义这4类组件其实隐含着分层依赖的关系。比如处于业务模块中的首页依赖位于基础业务模块中的账号功能再比如位于UI控件模块中的轮播卡片依赖位于基础功能模块中的存储管理等功能。我们将它们按照依赖的先后顺序从上到下进行划分就是一个完整的App了。
图4 组件化分层
可以看到,平台化与组件化最大的差异在于增加了分层的概念,每一层的功能均基于同层和下层的功能之上,这使得各个组件之间既保持了独立性,同时也具有一定的弹性,在不越界的情况下按照功能划分各司其职。
与组件化更关注组件的独立性相比,平台化更关注的是组件之间关系的合理性,而这也是在设计平台化架构时需要重点考虑的单向依赖原则。
所谓单向依赖原则,指的是组件依赖的顺序应该按照应用架构的层数从上到下依赖,不要出现下层模块依赖上层模块这样循环依赖的现象。这样可以最大限度地避免复杂的耦合,减少组件化时的困难。如果我们每个组件都只是单向依赖其他组件,各个组件之间的关系都是清晰的,代码解耦也就会变得非常轻松了。
平台化强调依赖的顺序性,除了不允许出现下层组件依赖上层组件的情况,跨层组件和同层组件之间的依赖关系也应当严格控制,因为这样的依赖关系往往会带来架构设计上的混乱。
如果下层组件确实需要调用上层组件的代码怎么办?
这时我们可以采用增加中间层的方式比如Event Bus、Provider或Router以中间层转发的形式实现信息同步。比如位于第4层的网络引擎中会针对特定的错误码跳转到位于第1层的统一错误页这时我们就可以利用Router提供的命名路由跳转在不感知错误页的实现情况下来完成。又比如位于第2层的账号组件中会在用户登入登出时主动刷新位于第1层的首页和我的页面这时我们就可以利用Event Bus来触发账号切换事件在不需要获取页面实例的情况下通知它们更新界面。关于这部分内容你可以参考第20和21篇文章中的相关内容这里就不再赘述了。
平台化架构是目前应用最广的软件架构设计其核心在于如何将离散的组件依照单向依赖的原则进行分层。而关于具体的分层逻辑除了我们上面介绍的业务和UI四象限法则之外你也可以使用其他的划分策略只要整体结构层次清晰明确不存在难以确定归属的组件就可以了。
比如Flutter就采用Embedder操作系统适配层、Engine渲染引擎及Dart VM层和FrameworkUI SDK层整体三层的划分。可以看到Flutter框架每一层的组件定义都有着明确的边界其向上提供的功能和向下依赖的能力也非常明确。
图5 Flutter框架架构
备注此图引自Flutter System Overview
总结
好了,今天的分享就到这里,我们总结一下主要内容吧。
组件化和平台化都是软件开发中流行的分治手段能够将App内的功能拆分成多个独立的组件或模块。
其中组件化更关注如何保持组件的独立性只要拆分的功能独立即可约束较为松散在中大型App中容易造成一定程度的功能耦合现象。而平台化则更强调组件之间关系的合理性增加了分层的概念使得组件之间既有边界也有一定的弹性。只要满足单向依赖原则各个组件之间的关系都是清晰的。
分治是一种与技术无关的架构思想有利于降低工程的复杂性从而提高App的可扩展和可维护性。今天这篇文章我重点与你分享的是组件化与平台化这两种架构设计的思路并没有讲解它们的具体实现。而关于组件化与平台化的实现细节网络上已经有很多文章了你可以在网上自行搜索了解。如果你还有关于组件化和平台化的其他问题那就在评论区中给我留言吧。
其实你也可以琢磨出今天这篇文章的目的是带你领会App架构设计的核心思想。因为理解思想之后剩下的就是去实践了当你需要设计App架构时再回忆起这些内容或是翻出这篇文章一定会事半功倍。
思考题
最后,我给你留一道思考题吧。
在App架构设计中你会采用何种方式去管理涉及资源类的依赖呢
欢迎你在评论区给我留言分享你的观点,我会在下一篇文章中等待你!感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。

View File

@@ -0,0 +1,306 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
42 如何构建高效的Flutter App打包发布环境
你好我是陈航。今天我们来聊一聊Flutter应用的交付这个话题。
软件项目的交付是一个复杂的过程任何原因都有可能导致交付过程失败。中小型研发团队经常遇到的一个现象是App在开发测试时没有任何异常但一到最后的打包构建交付时就问题频出。所以每到新版本发布时大家不仅要等候打包结果还经常需要加班修复临时出现的问题。如果没有很好地线上应急策略即使打包成功交付完成后还是非常紧张。
可以看到产品交付不仅是一个令工程师头疼的过程还是一个高风险动作。其实失败并不可怕可怕的是每次失败的原因都不一样。所以为了保障可靠交付我们需要关注从源代码到发布的整个流程提供一种可靠的发布支撑确保App是以一种可重复的、自动化的方式构建出来的。同时我们还应该将打包过程提前将构建频率加快因为这样不仅可以尽早发现问题修复成本也会更低并且能更好地保证代码变更能够顺利发布上线。
其实,这正是持续交付的思路。
所谓持续交付,指的是建立一套自动监测源代码变更,并自动实施构建、测试、打包和相关操作的流程链机制,以保证软件可以持续、稳定地保持在随时可以发布的状态。 持续交付可以让软件的构建、测试与发布变得更快、更频繁,更早地暴露问题和风险,降低软件开发的成本。
你可能会觉得大型软件工程里才会用到持续交付。其实不然通过运用一些免费的工具和平台中小型项目也能够享受到开发任务自动化的便利。而Travis CI就是这类工具之中市场份额最大的一个。所以接下来我就以Travis CI为例与你分享如何为Flutter工程引入持续交付的能力。
Travis CI
Travis CI 是在线托管的持续交付服务用Travis来进行持续交付不需要自己搭服务器在网页上点几下就好非常方便。
Travis和GitHub是一对配合默契的工作伙伴只要你在Travis上绑定了GitHub上的项目后续任何代码的变更都会被Travis自动抓取。然后Travis会提供一个运行环境执行我们预先在配置文件中定义好的测试和构建步骤并最终把这次变更产生的构建产物归档到GitHub Release上如下所示
图1 Travis CI持续交付流程示意图
可以看到通过Travis提供的持续构建交付能力我们可以直接看到每次代码的更新的变更结果而不需要累积到发布前再做打包构建。这样不仅可以更早地发现错误定位问题也会更容易。
要想为项目提供持续交付的能力我们首先需要在Travis上绑定GitHub。我们打开Travis官网使用自己的GitHub账号授权登陆就可以了。登录完成后页面中会出现一个“Activate”按钮点击按钮会跳回到GitHub中进行项目访问权限设置。我们保留默认的设置点击“Approve&Install”即可。
图2 激活Github集成
图3 授权Travis读取项目变更记录
完成授权之后页面会跳转到Travis。Travis主页上会列出GitHub上你的所有仓库以及你所属于的组织如下图所示
图4 完成Github项目绑定
完成项目绑定后接下来就是为项目增加Travis配置文件了。配置的方法也很简单只要在项目的根目录下放一个名为.travis.yml的文件就可以了。
.travis.yml是Travis的配置文件指定了Travis应该如何应对代码变更。代码commit上去之后一旦Travis检测到新的变更Travis就会去查找这个文件根据项目类型language确定执行环节然后按照依赖安装install、构建命令script和发布deploy这三大步骤依次执行里面的命令。一个Travis构建任务流程如下所示
图5 Travis工作流
可以看到为了更精细地控制持续构建过程Travis还为install、script和deploy提供了对应的钩子before_install、before_script、after_failure、after_success、before_deploy、after_deploy、after_script可以前置或后置地执行一些特殊操作。
如果你的项目比较简单没有其他的第三方依赖也不需要发布到GitHub Release上只是想看看构建会不会失败那么你可以省略配置文件中的install和deploy。
如何为项目引入Travis
可以看到一个最简单的配置文件只需要提供两个字段即language和script就可以让Travis帮你自动构建了。下面的例子演示了如何为一个Dart命令行项目引入Travis。在下面的配置文件中我们将language字段设置为Dart并在script字段中将dart_sample.dart定义为程序入口启动运行
#.travis.yml
language: dart
script:
- dart dart_sample.dart
将这个文件提交至项目中我们就完成了Travis的配置工作。
Travis会在每次代码提交时自动运行配置文件中的命令如果所有命令都返回0就表示验证通过完全没有问题你的提交记录就会被标记上一个绿色的对勾。反之如果命令运行过程中出现了异常则表示验证失败你的提交记录就会被标记上一个红色的叉这时我们就要点击红勾进入Travis构建详情去查看失败原因并尽快修复问题了。
图6 代码变更验证
可以看到,为一个工程引入自动化任务的能力,只需要提炼出能够让工程自动化运行需要的命令就可以了。
在第38篇文章中我与你介绍了Flutter工程运行自动化测试用例的命令即flutter test所以如果我们要为一个Flutter工程配置自动化测试任务直接把这个命令放置在script字段就可以了。
但需要注意的是Travis并没有内置Flutter运行环境所以我们还需要在install字段中为自动化任务安装Flutter SDK。下面的例子演示了如何为一个Flutter工程配置自动化测试能力。在下面的配置文件中我们将os字段设置为osx在install字段中clone了Flutter SDK并将Flutter命令设置为环境变量。最后我们在script字段中加上flutter test命令就完成了配置工作
os:
- osx
install:
- git clone https://github.com/flutter/flutter.git
- export PATH="$PATH:`pwd`/flutter/bin"
script:
- flutter doctor && flutter test
其实为Flutter工程的代码变更引入自动化测试能力相对比较容易但考虑到Flutter的跨平台特性要想在不同平台上验证工程自动化构建的能力即iOS平台构建出ipa包、Android平台构建出apk包又该如何处理呢
我们都知道Flutter打包构建的命令是flutter build所以同样的我们只需要把构建iOS的命令和构建Android的命令放到script字段里就可以了。但考虑到这两条构建命令执行时间相对较长所以我们可以利用Travis提供的并发任务选项matrix来把iOS和Android的构建拆开分别部署在独立的机器上执行。
下面的例子演示了如何使用matrix分拆构建任务。在下面的代码中我们定义了两个并发任务即运行在Linux上的Android构建任务执行flutter build apk和运行在OS X上的iOS构建任务flutter build ios。
考虑到不同平台的构建任务需要提前准备运行环境比如Android构建任务需要设置JDK、安装Android SDK和构建工具、接受相应的开发者协议而iOS构建任务则需要设置Xcode版本因此我们分别在这两个并发任务中提供对应的配置选项。
最后需要注意的是由于这两个任务都需要依赖Flutter环境所以install字段并不需要拆到各自任务中进行重复设置
matrix:
include:
#声明Android运行环境
- os: linux
language: android
dist: trusty
licenses:
- 'android-sdk-preview-license-.+'
- 'android-sdk-license-.+'
- 'google-gdk-license-.+'
#声明需要安装的Android组件
android:
components:
- tools
- platform-tools
- build-tools-28.0.3
- android-28
- sys-img-armeabi-v7a-google_apis-28
- extra-android-m2repository
- extra-google-m2repository
- extra-google-android-support
jdk: oraclejdk8
sudo: false
addons:
apt:
sources:
- ubuntu-toolchain-r-test
packages:
- libstdc++6
- fonts-droid
#确保sdkmanager是最新的
before_script:
- yes | sdkmanager --update
script:
- yes | flutter doctor --android-licenses
- flutter doctor && flutter -v build apk
#声明iOS的运行环境
- os: osx
language: objective-c
osx_image: xcode10.2
script:
- flutter doctor && flutter -v build ios --no-codesign
install:
- git clone https://github.com/flutter/flutter.git
- export PATH="$PATH:`pwd`/flutter/bin"
如何将打包好的二进制文件自动发布出来?
在这个案例中,我们构建任务的命令是打包,那打包好的二进制文件可以自动发布出来吗?
答案是肯定的。我们只需要为这两个构建任务增加deploy字段设置skip_cleanup字段告诉Travis在构建完成后不要清除编译产物然后通过file字段把要发布的文件指定出来最后就可以通过GitHub提供的API token上传到项目主页了。
下面的示例演示了deploy字段的具体用法在下面的代码中我们获取到了script字段构建出的app-release.apk并通过file字段将其指定为待发布的文件。考虑到并不是每次构建都需要自动发布所以我们在下面的配置中增加了on选项告诉Travis仅在对应的代码更新有关联tag时才自动发布一个release版本
...
#声明构建需要执行的命令
script:
- yes | flutter doctor --android-licenses
- flutter doctor && flutter -v build apk
#声明部署的策略即上传apk至github release
deploy:
provider: releases
api_key: xxxxx
file:
- build/app/outputs/apk/release/app-release.apk
skip_cleanup: true
on:
tags: true
...
需要注意的是由于我们的项目是开源库因此GitHub的API token不能明文放到配置文件中需要在Travis上配置一个API token的环境变量然后把这个环境变量设置到配置文件中。
我们先打开GitHub点击页面右上角的个人头像进入Settings随后点击Developer Settings进入开发者设置。
图7 进入开发者设置
在开发者设置页面中我们点击左下角的Personal access tokens选项生成访问token。token设置页面提供了比较丰富的访问权限控制比如仓库限制、用户限制、读写限制等这里我们选择只访问公共的仓库填好token名称cd_demo点击确认之后GitHub会将token的内容展示在页面上。
图8 生成访问token
需要注意的是这个token 你只会在GitHub上看到一次页面关了就再也找不到了所以我们先把这个token复制下来。
图9 访问token界面
接下来我们打开Travis主页找到我们希望配置自动发布的项目然后点击右上角的More options选择Settings打开项目配置页面。
图10 打开Travis项目设置
在Environment Variable里把刚刚复制的token改名为GITHUB_TOKEN加到环境变量即可。
图11 加入Travis环境变量
最后我们只要把配置文件中的api_key替换成${GITHUB_TOKEN}就可以了。
...
deploy:
api_key: ${GITHUB_TOKEN}
...
这个案例介绍的是Android的构建产物apk发布。而对于iOS而言我们还需要对其构建产物app稍作加工让其变成更通用的ipa格式之后才能发布。这里我们就需要用到deploy的钩子before_deploy字段了这个字段能够在正式发布前执行一些特定的产物加工工作。
下面的例子演示了如何通过before_deploy字段加工构建产物。由于ipa格式是在app格式之上做的一层包装所以我们把app文件拷贝到Payload后再做压缩就完成了发布前的准备工作接下来就可以在deploy阶段指定要发布的文件正式进入发布环节了
...
#对发布前的构建产物进行预处理打包成ipa
before_deploy:
- mkdir app && mkdir app/Payload
- cp -r build/ios/iphoneos/Runner.app app/Payload
- pushd app && zip -r -m app.ipa Payload && popd
#将ipa上传至github release
deploy:
provider: releases
api_key: ${GITHUB_TOKEN}
file:
- app/app.ipa
skip_cleanup: true
on:
tags: true
...
将更新后的配置文件提交至GitHub随后打一个tag。等待Travis构建完毕后可以看到我们的工程已经具备自动发布构建产物的能力了。
图12 Flutter App发布构建产物
如何为Flutter Module工程引入自动发布能力
这个例子介绍的是传统的Flutter App工程即纯Flutter工程如果我们想为Flutter Module工程即混合开发的Flutter工程引入自动发布能力又该如何设置呢
其实也并不复杂。Module工程的Android构建产物是aariOS构建产物是Framework。Android产物的自动发布比较简单我们直接复用apk的发布把file文件指定为aar文件即可iOS的产物自动发布稍繁琐一些需要将Framework做一些简单的加工将它们转换成Pod格式。
下面的例子演示了Flutter Module的iOS产物是如何实现自动发布的。由于Pod格式本身只是在App.Framework和Flutter.Framework这两个文件的基础上做的封装所以我们只需要把它们拷贝到统一的目录FlutterEngine下并将声明了组件定义的FlutterEngine.podspec文件放置在最外层最后统一压缩成zip格式即可。
...
#对构建产物进行预处理压缩成zip格式的组件
before_deploy:
- mkdir .ios/Outputs && mkdir .ios/Outputs/FlutterEngine
- cp FlutterEngine.podspec .ios/Outputs/
- cp -r .ios/Flutter/App.framework/ .ios/Outputs/FlutterEngine/App.framework/
- cp -r .ios/Flutter/engine/Flutter.framework/ .ios/Outputs/FlutterEngine/Flutter.framework/
- pushd .ios/Outputs && zip -r FlutterEngine.zip ./ && popd
deploy:
provider: releases
api_key: ${GITHUB_TOKEN}
file:
- .ios/Outputs/FlutterEngine.zip
skip_cleanup: true
on:
tags: true
...
将这段代码提交后可以看到Flutter Module工程也可以自动的发布原生组件了。
图13 Flutter Module工程发布构建产物
通过这些例子我们可以看到任务配置的关键在于提炼出项目自动化运行需要的命令集合并确认它们的执行顺序。只要把这些命令集合按照install、script和deploy三个阶段安置好接下来的事情就交给Travis去完成我们安心享受持续交付带来的便利就可以了。
总结
俗话说“90%的故障都是由变更引起的”,这凸显了持续交付对于发布稳定性保障的价值。通过建立持续交付流程链机制,我们可以将代码变更与自动化手段关联起来,让测试和发布变得更快、更频繁,不仅可以提早暴露风险,还能让软件可以持续稳定地保持在随时可发布的状态。
在今天的分享中我与你介绍了如何通过Travis CI为我们的项目引入持续交付能力。Travis的自动化任务的工作流依靠.travis.yml配置文件驱动我们可以在确认好构建任务需要的命令集合后在这个配置文件中依照install、script和deploy这3个步骤拆解执行过程。完成项目的配置之后一旦Travis检测到代码变更就可以自动执行任务了。
简单清晰的发布流程是软件可靠性的前提。如果我们同时发布了100个代码变更导致App性能恶化了我们可能需要花费大量时间和精力去定位究竟是哪些变更影响了App性能以及它们是如何影响的。而如果以持续交付的方式发布App我们能够以更小的粒度去测量和理解代码变更带来的影响是改善还是退化从而可以更早地找到问题更有信心进行更快的发布。
需要注意的是在今天的示例分析中我们构建的是一个未签名的ipa文件这意味着我们需要先完成签名之后才能在真实的iOS设备上运行或者发布到App Store。
iOS的代码签名涉及私钥和多重证书的校验以及对应的加解密步骤是一个相对繁琐的过程。如果我们希望在Travis上部署自动化签名操作需要导出发布证书、私钥和描述文件并提前将这些文件打包成一个压缩包后进行加密上传至仓库。
然后我们还需要在before_install时将这个压缩包进行解密并把证书导到Travis运行环境的钥匙串中这样构建脚本就可以使用临时钥匙串对二进制文件进行签名了。完整的配置你可以参考手机内侧服务厂商蒲公英提供的集成文档了解进一步的细节。
如果你不希望将发布证书、私钥暴露给Travis也可以把未签名的ipa包下载下来解压后通过codesign命令分别对App.Framework、Flutter.Framework以及Runner进行重签名操作然后重新压缩成ipa包即可。这篇文章介绍了详细的操作步骤这里我们也不再赘述了。
我把今天分享涉及的Travis配置上传到了GitHub你可以把这几个项目Dart_Sample、Module_Page、Crashy_Demo下载下来观察它们的配置文件并在Travis网站上查看对应的构建过程从而加深理解与记忆。
思考题
最后,我给你留一道思考题吧。
在Travis配置文件中如何选用特定的Flutter SDK版本比如v1.5.4-hotfix.2)呢?
欢迎你在评论区给我留言分享你的观点,我会在下一篇文章中等待你!感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。

View File

@@ -0,0 +1,91 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
43 如何构建自己的Flutter混合开发框架
你好我是陈航。在本次课程的最后一个主题里我来和你聊聊如何设计自己的Flutter混合开发框架。
所谓混合开发是指在App的整体架构继续使用原生技术栈的基础上将Flutter运行环境嵌入到原生App工程中由原生开发人员为Flutter运行提供宿主容器及基础能力支撑而Flutter开发人员则负责应用层业务及App内大部分渲染工作。
这种开发模式的好处十分明显。对于工程师而言跨平台的Flutter框架减少了对底层环境的依赖使用完整的技术栈和工具链隔离了各个终端系统的差异无论是Android、iOS甚至是前端工程师都可以使用统一而标准化的能力进行业务开发从而扩充了技能栈。而对于企业而言这种方式不仅具备了原生App良好的用户体验以及丰富的底层能力还同时拥有了跨平台技术开发低成本和多端体验一致性的优势直接节省研发资源。
那么在原生工程中引入Flutter混合开发能力我们应该如何设计工程架构原生开发与Flutter开发的工作模式又是怎样的呢
接下来,在今天的分享中,我会着重为你介绍这两个主题设计思路和建设方向;而在下一次分享中,我则会通过一个实际的案例,与你详细说明在业务落地中,我们需要重点考虑哪些技术细节,这样你在为自己的原生工程中设计混合开发框架时也就有迹可循了。
混合开发架构
在第41篇文章中我与你介绍了软件功能分治的两种手段即组件化和平台化以及如何在满足单向依赖原则的前提下以分层的形式将软件功能进行分类聚合的方法。这些设计思想能够让我们在设计软件系统架构时降低整体工程的复杂性提高App的可扩展性和可维护性。
与纯Flutter工程能够以自治的方式去分拆软件功能、管理工程依赖不同Flutter混合工程的功能分治需要原生工程与Flutter工程一起配合完成在Flutter模块的视角看来一部分与渲染相关的基础能力完全由Flutter代码实现而另一部分涉及操作系统底层、业务通用能力部分以及整体应用架构支撑则需要借助于原生工程给予支持。
在第41篇文章中我们通过四象限分析法把纯Flutter应用按照业务和UI分解成4类。同样的混合工程的功能单元也可以按照这个分治逻辑分为4个维度即不具备业务属性的原生基础功能、不具备业务属性的原生UI控件、不具备UI属性的原生基础业务功能和带UI属性的独立业务模块。
图1 四象限分析法
从图中可以看到对于前3个维度即原生UI控件、原生基础功能、原生基础业务功能的定义纯Flutter工程与混合工程并无区别只不过实现方式由Flutter变成了原生对于第四个维度即独立业务模块的功能归属考虑到业务模块的最小单元是页面而Flutter的最终呈现形式也是独立的页面因此我们把Flutter模块也归为此类我们的工程可以像依赖原生业务模块一样直接依赖它为用户提供独立的业务功能。
我们把这些组件及其依赖按照从上到下的方式进行划分就是一个完整的混合开发架构了。可以看到原生工程和Flutter工程的边界定义清晰双方都可以保持原有的分层管理依赖的开发模式不变。
图2 Flutter混合开发架构
需要注意的是作为一个内嵌在原生工程的功能组件Flutter模块的运行环境是由原生工程提供支持的这也就意味着在渲染交互能力之外的部分基础功能比如网络、存储以及和原生业务共享的业务通用能力比如支付、账号需要原生工程配合完成即原生工程以分层的形式提供上层调用接口Flutter模块以插件的形式直接访问原生代码宿主对应功能实现。
因此不仅不同归属定义的原生组件之前存在着分层依赖的关系Flutter模块与原生组件之前也隐含着分层依赖的关系。比如Flutter模块中处于基础业务模块的账号插件依赖位于原生基础业务模块中的账号功能Flutter模块中处于基础业务模块的网络插件依赖位于原生基础功能的网络引擎。
可以看到在混合工程架构中像原生工程依赖Flutter模块、Flutter模块又依赖原生工程这样跨技术栈的依赖管理行为我们实际上是通过将双方抽象为彼此对应技术栈的依赖从而实现分层管理的即将原生对Flutter的依赖抽象为依赖Flutter模块所封装的原生组件而Flutter对原生的依赖则抽象为依赖插件所封装的原生行为。
Flutter混合开发工作流
对于软件开发而言,工程师的职责涉及从需求到上线的整个生命周期,包含需求阶段->方案阶段->开发阶段->发布阶段->线上运维阶段。可以看出,这其实就是一种抽象的工作流程。
其中,和工程化关联最为紧密的是开发阶段和发布阶段。我们将工作流中和工程开发相关的部分抽离,定义为开发工作流,根据生命周期中关键节点和高频节点,可以将整个工作流划分为如下七个阶段,即初始化->开发/调试->构建->测试->发布->集成->原生工具链:
图3 Flutter混合开发工作流
前6个阶段是Flutter的标准工作流最后一个阶段是原生开发的标准工作流。
可以看到在混合开发工作模式中Flutter的开发模式与原生开发模式之间有着清晰的分工边界Flutter模块是原生工程的上游其最终产物是原生工程依赖。从原生工程视角看其开发模式与普通原生应用并无区别因此这里就不再赘述了我们重点讨论Flutter开发模式。
对于Flutter标准工作流的6个阶段而言每个阶段都会涉及业务或产品特性提出的特异性要求技术方案的选型各阶段工作成本可用性、可靠性的衡量以及监控相关基础服务的接入和配置等。
每件事儿都是一个固定的步骤,而当开发规模随着文档、代码、需求增加时,我们会发现重复的步骤越来越多。此时,如果我们把这些步骤像抽象代码一样,抽象出一些相同操作,就可以大大提升开发效率。
优秀的程序员会发掘工作中的问题从中探索提高生产力的办法而转变思维模式就是一个不错的起点。以持续交付的指导思想来看待这些问题我们希望整体方案能够以可重复、可配置化的形式来保障整个工作流的开发体验、效率、稳定性和可靠性而这些都离不开Flutter对命令行工具支持。
比如对于测试阶段的Dart代码分析我们可以使用flutter analyze命令对代码中可能存在的语法或语义问题进行检查又比如在发布期的package发布环节我们可以使用flutter packages pub publish dry-run命令对待发布的包进行发布前检查确认无误后使用去掉dry-run参数的publish命令将包提交至Pub站点。
这些基本命令对各个开发节点的输入、输出以及执行过程进行了抽象,熟练掌握它们及对应的扩展参数用法,我们不仅可以在本地开发时打造一个易用便捷的工程开发环境,还可以将这些命令部署到云端,实现工程构建及部署的自动化。
我把这六个阶段涉及的关键命令总结为了一张表格你可以结合这张表格体会落实在具体实现中的Flutter标准工作流。
表1 Flutter标准工作流命令
总结
对于Flutter混合开发而言如何处理好原生与Flutter之间的关系需要从工程架构与工作模式上定义清晰的分工边界。
在架构层面将Flutter模块定义为原生工程的独立业务层以原生基础业务层向Flutter模块提供业务通用能力、原生基础能力层向Flutter模块提供基础功能支持这样的方式去分层管理依赖。
在工作模式层面将作为原生工程上游的Flutter模块开发抽象为原生依赖产物的工程管理并提炼出对应的工作流以可重复、配置化的命令行方式对各个阶段进行统一管理。
可以看到在原生App工程中引入Flutter运行环境由原生开发主做应用架构和基础能力赋能、Flutter开发主做应用层业务的混合开发协作方式能够综合原生App与Flutter框架双方的特点和优势不仅可以直接节省研发资源也符合目前行业人才能力模型的发展趋势。
思考题
除了工程依赖之外我们还需要管理Flutter SDK自身的依赖。考虑到Flutter SDK升级非常频繁对于多人协作的团队模式中如何保证每个人使用的Flutter SDK版本完全一致呢
欢迎你在评论区给我留言分享你的观点,我会在下一篇文章中等待你!感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。

View File

@@ -0,0 +1,415 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
44 如何构建自己的Flutter混合开发框架
你好,我是陈航。
在上一篇文章中我从工程架构与工作模式两个层面与你介绍了设计Flutter混合框架需要关注的基本设计原则即确定分工边界。
在工程架构维度由于Flutter模块作为原生工程的一个业务依赖其运行环境是由原生工程提供的因此我们需要将它们各自抽象为对应技术栈的依赖管理方式以分层依赖的方式确定二者的边界。
而在工作模式维度考虑到Flutter模块开发是原生开发的上游因此我们只需要从其构建产物的过程入手抽象出开发过程中的关键节点和高频节点以命令行的形式进行统一管理。构建产物是Flutter模块的输出同时也是原生工程的输入一旦产物完成构建我们就可以接入原生开发的工作流了。
可以看到在Flutter混合框架中Flutter模块与原生工程是相互依存、互利共赢的关系
Flutter跨平台开发效率高渲染性能和多端体验一致性好因此在分工上主要专注于实现应用层的独立业务页面的渲染闭环
而原生开发稳定性高精细化控制力强底层基础能力丰富因此在分工上主要专注于提供整体应用架构为Flutter模块提供稳定的运行环境及对应的基础能力支持。
那么在原生工程中为Flutter模块提供基础能力支撑的过程中面对跨技术栈的依赖管理我们该遵循何种原则呢对于Flutter模块及其依赖的原生插件们我们又该如何以标准的原生工程依赖形式进行组件封装呢
在今天的文章中,我就通过一个典型案例,与你讲述这两个问题的解决办法。
原生插件依赖管理原则
在前面第26和31篇文章里我与你讲述了为Flutter应用中的Dart代码提供原生能力支持的两种方式在原生工程中的Flutter应用入口注册原生代码宿主回调的轻量级方案以及使用插件工程进行独立拆分封装的工程化解耦方案。
无论使用哪种方式Flutter应用工程都为我们提供了一体化的标准解决方案能够在集成构建时自动管理原生代码宿主及其相应的原生依赖因此我们只需要在应用层使用pubspec.yaml文件去管理Dart的依赖。
但对于混合工程而言依赖关系的管理则会复杂一些。这是因为与Flutter应用工程有着对原生组件简单清晰的单向依赖关系不同混合工程对原生组件的依赖关系是多向的Flutter模块工程会依赖原生组件而原生工程的组件之间也会互相依赖。
如果继续让Flutter的工具链接管原生组件的依赖关系那么整个工程就会陷入不稳定的状态之中。因此对于混合工程的原生依赖Flutter模块并不做介入完全交由原生工程进行统一管理。而Flutter模块工程对原生工程的依赖体现在依赖原生代码宿主提供的底层基础能力的原生插件上。
接下来我就以网络通信这一基础能力为例与你展开说明原生工程与Flutter模块工程之间应该如何管理依赖关系。
网络插件依赖管理实践
在第24篇文章“HTTP网络编程与JSON解析”中我与你介绍了在Flutter中我们可以通过HttpClient、http与dio这三种通信方式实现与服务端的数据交换。
但在混合工程中考虑到其他原生组件也需要使用网络通信能力所以通常是由原生工程来提供网络通信功能的。因为这样不仅可以在工程架构层面实现更合理的功能分治还可以统一整个App内数据交换的行为。比如在网络引擎中为接口请求增加通用参数或者是集中拦截错误等。
关于原生网络通信功能目前市面上有很多优秀的第三方开源SDK比如iOS的AFNetworking和Alamofire、Android的OkHttp和Retrofit等。考虑到AFNetworking和OkHttp在各自平台的社区活跃度相对最高因此我就以它俩为例与你演示混合工程的原生插件管理方法。
网络插件接口封装
要想搞清楚如何管理原生插件我们需要先使用方法通道来建立Dart层与原生代码宿主之间的联系。
原生工程为Flutter模块提供原生代码能力我们同样需要使用Flutter插件工程来进行封装。关于这部分内容我在第31和39篇文章中已经分别为你演示了推送插件和数据上报插件的封装方法你也可以再回过头来复习下相关内容。所以今天我就不再与你过多介绍通用的流程和固定的代码声明部分了而是重点与你讲述与接口相关的实现细节。
首先我们来看看Dart代码部分。
对于插件工程的Dart层代码而言由于它仅仅是原生工程的代码宿主代理所以这一层的接口设计比较简单只需要提供一个可以接收请求URL和参数并返回接口响应数据的方法doRequest即可
class FlutterPluginNetwork {
...
static Future<String> doRequest(url,params) async {
//使用方法通道调用原生接口doRequest传入URL和param两个参数
final String result = await _channel.invokeMethod('doRequest', {
"url": url,
"param": params,
});
return result;
}
}
Dart层接口封装搞定了我们再来看看接管真实网络调用的Android和iOS代码宿主如何响应Dart层的接口调用。
我刚刚与你提到过原生代码宿主提供的基础通信能力是基于AFNetworkingiOS和OkHttpAndroid做的封装所以为了在原生代码中使用它们我们首先需要分别在flutter_plugin_network.podspec和build.gradle文件中将工程对它们的依赖显式地声明出来
在flutter_plugin_network.podspec文件中声明工程对AFNetworking的依赖
Pod::Spec.new do |s|
...
s.dependency 'AFNetworking'
end
在build.gradle文件中声明工程对OkHttp的依赖
dependencies {
implementation "com.squareup.okhttp3:okhttp:4.2.0"
}
然后我们需要在原生接口FlutterPluginNetworkPlugin类中完成例行的初始化插件实例、绑定方法通道工作。
最后我们还需要在方法通道中取出对应的URL和query参数为doRequest分别提供AFNetworking和OkHttp的实现版本。
对于iOS的调用而言由于AFNetworking的网络调用对象是AFHTTPSessionManager类所以我们需要这个类进行实例化并定义其接口返回的序列化方式本例中为字符串。然后剩下的工作就是用它去发起网络请求使用方法通道通知Dart层执行结果了
@implementation FlutterPluginNetworkPlugin
...
//方法通道回调
- (void)handleMethodCall:(FlutterMethodCall*)call result:(FlutterResult)result {
//响应doRequest方法调用
if ([@"doRequest" isEqualToString:call.method]) {
//取出query参数和URL
NSDictionary *arguments = call.arguments[@"param"];
NSString *url = call.arguments[@"url"];
[self doRequest:url withParams:arguments andResult:result];
} else {
//其他方法未实现
result(FlutterMethodNotImplemented);
}
}
//处理网络调用
- (void)doRequest:(NSString *)url withParams:(NSDictionary *)params andResult:(FlutterResult)result {
//初始化网络调用实例
AFHTTPSessionManager *manager = [AFHTTPSessionManager manager];
//定义数据序列化方式为字符串
manager.responseSerializer = [AFHTTPResponseSerializer serializer];
NSMutableDictionary *newParams = [params mutableCopy];
//增加自定义参数
newParams[@"ppp"] = @"yyyy";
//发起网络调用
[manager GET:url parameters:params progress:nil success:^(NSURLSessionDataTask * _Nonnull task, id _Nullable responseObject) {
//取出响应数据响应Dart调用
NSString *string = [[NSString alloc] initWithData:responseObject encoding:NSUTF8StringEncoding];
result(string);
} failure:^(NSURLSessionDataTask * _Nullable task, NSError * _Nonnull error) {
//通知Dart调用失败
result([FlutterError errorWithCode:@"Error" message:error.localizedDescription details:nil]);
}];
}
@end
Android的调用也类似OkHttp的网络调用对象是OkHttpClient类所以我们同样需要这个类进行实例化。OkHttp的默认序列化方式已经是字符串了所以我们什么都不用做只需要URL参数加工成OkHttp期望的格式然后就是用它去发起网络请求使用方法通道通知Dart层执行结果了
public class FlutterPluginNetworkPlugin implements MethodCallHandler {
...
@Override
//方法通道回调
public void onMethodCall(MethodCall call, Result result) {
//响应doRequest方法调用
if (call.method.equals("doRequest")) {
//取出query参数和URL
HashMap param = call.argument("param");
String url = call.argument("url");
doRequest(url,param,result);
} else {
//其他方法未实现
result.notImplemented();
}
}
//处理网络调用
void doRequest(String url, HashMap<String, String> param, final Result result) {
//初始化网络调用实例
OkHttpClient client = new OkHttpClient();
//加工URL及query参数
HttpUrl.Builder urlBuilder = HttpUrl.parse(url).newBuilder();
for (String key : param.keySet()) {
String value = param.get(key);
urlBuilder.addQueryParameter(key,value);
}
//加入自定义通用参数
urlBuilder.addQueryParameter("ppp", "yyyy");
String requestUrl = urlBuilder.build().toString();
//发起网络调用
final Request request = new Request.Builder().url(requestUrl).build();
client.newCall(request).enqueue(new Callback() {
@Override
public void onFailure(Call call, final IOException e) {
//切换至主线程通知Dart调用失败
registrar.activity().runOnUiThread(new Runnable() {
@Override
public void run() {
result.error("Error", e.toString(), null);
}
});
}
@Override
public void onResponse(Call call, final Response response) throws IOException {
//取出响应数据
final String content = response.body().string();
//切换至主线程响应Dart调用
registrar.activity().runOnUiThread(new Runnable() {
@Override
public void run() {
result.success(content);
}
});
}
});
}
}
需要注意的是由于方法通道是非线程安全的所以原生代码与Flutter之间所有的接口调用必须发生在主线程。而OktHtp在处理网络请求时由于涉及非主线程切换所以需要调用runOnUiThread方法以确保回调过程是在UI线程中执行的否则应用可能会出现奇怪的Bug甚至是Crash。
有些同学可能会比较好奇为什么doRequest的Android实现需要手动切回UI线程而iOS实现则不需要呢这其实是因为doRequest的iOS实现背后依赖的AFNetworking已经在数据回调接口时为我们主动切换了UI线程所以我们自然不需要重复再做一次了。
在完成了原生接口封装之后Flutter工程所需的网络通信功能的接口实现就全部搞定了。
Flutter模块工程依赖管理
通过上面这些步骤我们以插件的形式提供了原生网络功能的封装。接下来我们就需要在Flutter模块工程中使用这个插件并提供对应的构建产物封装提供给原生工程使用了。这部分内容主要包括以下3大部分
第一如何使用FlutterPluginNetworkPlugin插件也就是模块工程功能如何实现
第二模块工程的iOS构建产物应该如何封装也就是原生iOS工程如何管理Flutter模块工程的依赖
第三模块工程的Android构建产物应该如何封装也就是原生Android工程如何管理Flutter模块工程的依赖。
接下来,我们具体看看每部分应该如何实现。
模块工程功能实现
为了使用FlutterPluginNetworkPlugin插件实现与服务端的数据交换能力我们首先需要在pubspec.yaml文件中将工程对它的依赖显示地声明出来
flutter_plugin_network:
git:
url: https://github.com/cyndibaby905/44_flutter_plugin_network.git
然后我们还得在main.dart文件中为它提供一个触发入口。在下面的代码中我们在界面上展示了一个RaisedButton按钮并在其点击回调函数时使用FlutterPluginNetwork插件发起了一次网络接口调用并把网络返回的数据打印到了控制台上
RaisedButton(
child: Text("doRequest"),
//点击按钮发起网络请求,打印数据
onPressed:()=>FlutterPluginNetwork.doRequest("https://jsonplaceholder.typicode.com/posts", {'userId':'2'}).then((s)=>print('Result:$s')),
)
运行这段代码点击doRequest按钮观察控制台输出可以看到接口返回的数据信息能够被正常打印证明Flutter模块的功能表现是完全符合预期的。
图1 Flutter模块工程运行示例
构建产物应该如何封装?
我们都知道模块工程的Android构建产物是aariOS构建产物是Framework。而在第28和42篇文章中我与你介绍了不带插件依赖的模块工程构建产物的两种封装方案即手动封装方案与自动化封装方案。这两种封装方案最终都会输出同样的组织形式Android是aariOS则是带podspec的Framework封装组件
如果你已经不熟悉这两种封装方式的具体操作步骤了,可以再复习下这两篇文章的相关内容。接下来,我重点与你讲述的问题是:如果我们的模块工程存在插件依赖,封装过程是否有区别呢?
答案是,对于模块工程本身而言,这个过程没有区别;但对于模块工程的插件依赖来说,我们需要主动告诉原生工程,哪些依赖是需要它去管理的。
由于Flutter模块工程把所有原生的依赖都交给了原生工程去管理因此其构建产物并不会携带任何原生插件的封装实现所以我们需要遍历模块工程所使用的原生依赖组件们为它们逐一生成插件代码对应的原生组件封装。
在第18篇文章“依赖管理第三方组件库在Flutter中要如何管理”中我与你介绍了Flutter工程管理第三方依赖的实现机制其中.packages文件存储的是依赖的包名与系统缓存中的包文件路径。
类似的,插件依赖也有一个类似的文件进行统一管理,即.flutter-plugins。我们可以通过这个文件找到对应的插件名字本例中即为flutter_plugin_network及缓存路径
flutter_plugin_network=/Users/hangchen/Documents/flutter/.pub-cache/git/44_flutter_plugin_network-9b4472aa46cf20c318b088573a30bc32c6961777/
插件缓存本身也可以被视为一个Flutter模块工程所以我们可以采用与模块工程类似的办法为它生成对应的原生组件封装。
对于iOS而言这个过程相对简单些所以我们先来看看模块工程的iOS构建产物封装过程。
iOS构建产物应该如何封装
在插件工程的ios目录下为我们提供了带podspec文件的源码组件podspec文件提供了组件的声明及其依赖因此我们可以把这个目录下的文件拷贝出来连同Flutter模块组件一起放到原生工程中的专用目录并写到Podfile文件里。
原生工程会识别出组件本身及其依赖,并按照声明的依赖关系依次遍历,自动安装:
#Podfile
target 'iOSDemo' do
pod 'Flutter', :path => 'Flutter'
pod 'flutter_plugin_network', :path => 'flutter_plugin_network'
end
然后我们就可以像使用不带插件依赖的模块工程一样把它引入到原生工程中为其设置入口在FlutterViewController中展示Flutter模块的页面了。
不过需要注意的是由于FlutterViewController并不感知这个过程因此不会主动初始化项目中的插件所以我们还需要在入口处手动将工程里所有的插件依次声明出来
//AppDelegate.m:
@implementation AppDelegate
- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
self.window = [[UIWindow alloc] initWithFrame:[UIScreen mainScreen].bounds];
//初始化Flutter入口
FlutterViewController *vc = [[FlutterViewController alloc]init];
//初始化插件
[FlutterPluginNetworkPlugin registerWithRegistrar:[vc registrarForPlugin:@"FlutterPluginNetworkPlugin"]];
//设置路由标识符
[vc setInitialRoute:@"defaultRoute"];
self.window.rootViewController = vc;
[self.window makeKeyAndVisible];
return YES;
}
在Xcode中运行这段代码点击doRequest按钮可以看到接口返回的数据信息能够被正常打印证明我们已经可以在原生iOS工程中顺利的使用Flutter模块了。
图2 原生iOS工程运行示例
我们再来看看模块工程的Android构建产物应该如何封装。
Android构建产物应该如何封装
与iOS的插件工程组件在ios目录类似Android的插件工程组件在android目录。对于iOS的插件工程我们可以直接将源码组件提供给原生工程但对于Andriod的插件工程来说我们只能将aar组件提供给原生工程所以我们不仅需要像iOS操作步骤那样进入插件的组件目录还需要借助构建命令为插件工程生成aar
cd android
./gradlew flutter_plugin_network:assRel
命令执行完成之后aar就生成好了。aar位于android/build/outputs/aar目录下我们打开插件缓存对应的路径提取出对应的aar本例中为flutter_plugin_network-debug.aar就可以了。
我们把生成的插件aar连同Flutter模块aar一起放到原生工程的libs目录下最后在build.gradle文件里将它显式地声明出来就完成了插件工程的引入。
//build.gradle
dependencies {
...
implementation(name: 'flutter-debug', ext: 'aar')
implementation(name: 'flutter_plugin_network-debug', ext: 'aar')
implementation "com.squareup.okhttp3:okhttp:4.2.0"
...
}
然后我们就可以在原生工程中为其设置入口在FlutterView中展示Flutter页面愉快地使用Flutter模块带来的高效开发和高性能渲染能力了
//MainActivity.java
public class MainActivity extends AppCompatActivity {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
View FlutterView = Flutter.createView(this, getLifecycle(), "defaultRoute");
setContentView(FlutterView);
}
}
不过需要注意的是与iOS插件工程的podspec能够携带组件依赖不同Android插件工程的封装产物aar本身不携带任何配置信息。所以如果插件工程本身存在原生依赖像flutter_plugin_network依赖OkHttp这样我们是无法通过aar去告诉原生工程其所需的原生依赖的。
面对这种情况我们需要在原生工程中的build.gradle文件里手动地将插件工程的依赖即OkHttp显示地声明出来。
//build.gradle
dependencies {
...
implementation(name: 'flutter-debug', ext: 'aar')
implementation(name: 'flutter_plugin_network-debug', ext: 'aar')
implementation "com.squareup.okhttp3:okhttp:4.2.0"
...
}
至此将模块工程及其插件依赖封装成原生组件的全部工作就完成了原生工程可以像使用一个普通的原生组件一样去使用Flutter模块组件的功能了。
在Android Studio中运行这段代码并点击doRequest按钮可以看到我们可以在原生Android工程中正常使用Flutter封装的页面组件了。
图3 原生Android工程运行示例
当然考虑到手动封装模块工程及其构建产物的过程繁琐且容易出错我们可以把这些步骤抽象成命令行脚本并把它部署到Travis上。这样在Travis检测到代码变更之后就会自动将Flutter模块的构建产物封装成原生工程期望的组件格式了。
关于这部分内容你可以参考我在flutter_module_demo里的generate_aars.sh与generate_pods.sh实现。如果关于这部分内容有任何问题都可以直接留言给我。
总结
好了关于Flutter混合开发框架的依赖管理部分我们就讲到这里。接下来我们一起总结下今天的主要内容吧。
Flutter模块工程的原生组件封装形式是aarAndroid和FrameworkPod。与纯Flutter应用工程能够自动管理插件的原生依赖不同这部分工作在模块工程中是完全交给原生工程去管理的。因此我们需要查找记录了插件名称及缓存路径映射关系的.flutter-plugins文件提取出每个插件所对应的原生组件封装集成到原生工程中。
从今天的分享可以看出对于有着插件依赖的Android组件封装来说由于aar本身并不携带任何配置信息因此其操作以手工为主我们不仅要执行构建命令依次生成插件对应的aar还需要将插件自身的原生依赖拷贝至原生工程其步骤相对iOS组件封装来说要繁琐一些。
为了解决这一问题业界出现了一种名为fat-aar的打包手段它能够将模块工程本身及其相关的插件依赖统一打包成一个大的aar从而省去了依赖遍历和依赖声明的过程实现了更好的功能自治性。但这种解决方案存在一些较为明显的不足
依赖冲突问题。如果原生工程与插件工程都引用了同样的原生依赖组件OkHttp则原生工程的组件引用其依赖时会产生合并冲突因此在发布时必须手动去掉原生工程的组件依赖。
嵌套依赖问题。fat-aar只会处理embedded关键字指向的这层一级依赖而不会处理再下一层的依赖。因此对于依赖关系复杂的插件支持我们仍需要手动处理依赖问题。
Gradle版本限制问题。fat-aar方案对Gradle插件版本有限制且实现方式并不是官方设计考虑的点加之Gradle API变更较快所以存在后续难以维护的问题。
其他未知问题。fat-aar项目已经不再维护了最近一次更新还是2年前在实际项目中使用“年久失修”的项目存在较大的风险。
考虑到这些因素fat-aar并不是管理插件工程依赖的好的解决方案所以我们最好还是得老老实实地去遍历插件依赖以持续交付的方式自动化生成aar。
我把今天分享涉及知识点打包上传到了GitHub中你可以把插件工程、Flutter模块工程、原生Android和iOS工程下载下来查看其Travis持续交付配置文件的构建执行命令体会在混合框架中如何管理跨技术栈的组件依赖。
思考题
最后,我给你留一道思考题吧。
原生插件的开发是一个需要Dart层代码封装以及原生Android、iOS代码层实现的长链路过程。如果需要支持的基础能力较多开发插件的过程就会变得繁琐且容易出错。我们都知道Dart是不支持反射的但是原生代码可以。我们是否可以利用原生的反射去实现插件定义的标准化呢
提示在Dart层调用不存在的接口或未实现的接口可以通过noSuchMethod方法进行统一处理。
class FlutterPluginDemo {
//方法通道
static const MethodChannel _channel =
const MethodChannel('flutter_plugin_demo');
//当调用不存在接口时Dart会交由该方法进行统一处理
@override
Future<dynamic> noSuchMethod(Invocation invocation) {
//从字符串Symbol("methodName")中取出方法名
String methodName = invocation.memberName.toString().substring(8, string.length - 2);
//参数
dynamic args = invocation.positionalArguments;
print('methodName:$methodName');
print('args:$args');
return methodTemplate(methodName, args);
}
//某未实现的方法
Future<dynamic> someMethodNotImplemented();
//某未实现的带参数方法
Future<dynamic> someMethodNotImplementedWithParameter(param);
}
欢迎你在评论区给我留言分享你的观点,我会在下一篇文章中等待你!感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。

View File

@@ -0,0 +1,459 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
特别放送 温故而知新,与你说说专栏的那些思考题
你好,我是陈航。专栏上线以来,我在评论区看到了很多同学写的心得、经验和建议,当然更多的还是大家提的问题。
为了能够让大家更好地理解我们专栏的核心知识点,我今天特意整理了每篇文章的课后思考题,并结合大家在留言区的回答情况做一次分析与扩展。
当然 我也希望你能把这篇答疑文章作为对整个专栏所讲知识点的一次复习如果你在学习或者使用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而不能做累加。比如五斤苹果和三盒巧克力做合并结果是一份巧克力苹果套餐而不是八份巧克力苹果套餐。
问题5Widget、Element 和 RenderObject之间是什么关系你能否在Android/iOS/Web中找到对应的概念呢
这个问题来自第9篇文章“Widget构建Flutter界面的基石”。
Widget是数据配置RenderObject负责渲染而Element是一个介于它们之间的中间类用于渲染资源复用。
Widget和Element是一一对应的但RenderObject不是只有实际需要布局和绘制的控件才会有RenderObject。
这三个概念在iOS、Android和Web开发中对应的概念分别是
在iOS中Xib相当于WidgetUIView相当于ElementCALayer相当于renderObject
在Android中XML相当于WidgetView相当于ElementCanvas相当于renderObject
在Web中以Vue为例Vue的模板相当于Widgetvirtual DOM相当于ElementDOM相当于RenderObject。
问题6State构造函数和initState的差异是什么
这个问题来自第11篇文章“提到生命周期我们是在说什么”。
State构造函数调用时Widget还未完成初始化因此仅适用于一些与UI无关的数据初始化比如父类传入的参数加工。
而initState函数调用时StatefulWidget已经完成了Widget树的插入工作因此与Widget相关的一些初始化工作比如设置滚动监听器则必须放在initState。
问题7Text、Image以及按钮控件真正承载其视觉功能的控件分别是什么
这个问题来自第12篇文章“经典控件文本、图片和按钮在Flutter中怎么用”。
Text是封装了RichText的StatelessWidgetImage是封装了RawImage的StatefulWidget而按钮则是封装了RawMaterialButton的StatelessWidget。
可以看到StatelessWidget和StatefulWidget只是封装了控件的容器并不参与实际绘制真正负责渲染的是继承自RenderObject的视觉功能组件们比如RichText与RawImage。
问题8在ListView中如何提前缓存子元素
这个问题来自第13篇文章“经典控件UITableView/ListView在Flutter中是什么”。
ListView构造函数中有一个cacheExtent参数即预渲染区域长度。ListView会在其可视区域的两边留一个cacheExtent长度的区域作为预渲染区域相当于提前缓存些元素这样当滑动时就可以迅速呈现了。
问题9Row与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在计算项目依赖时当前工程所有依赖的本地缓存文件。与本地环境有关无需统一。
问题13GestureDetector内嵌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函数即可。
问题16AnimatedBuilder中外层的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 和数据库这三种持久化数据存储方式的适用场景
文件比较适合大量的有序的数据持久化
SharedPreferences适用于缓存少量键值对信息
数据库则用来存储大量格式化后的数据并且这些数据需要以较高频率更新
接下来我们看看如何做数据库跨版本升级
数据库升级实际上就是改表结构如果升级过程是连续的我们只需要在每个版本执行修改表结构的语句就可以了如果升级过程不是连续的比如直接从1.0升到5.0中间2.03.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页面之间的切换动画。
保证整体效果一致,有两种方案:
一是分别定制原生工程主要是Android的切换动画及Flutter的切换动画
二是使用类似闲鱼的共享FlutterView的机制将页面切换统一交由原生处理FlutterView只负责刷新界面。
问题24如何使用Provider实现2个同样类型的对象共享
这个问题来自第30篇文章“为什么需要做状态管理怎么做
答案很简单你可以封装1个大对象将2个同样类型的对象封装为其内部属性。
问题25如何让Flutter代码能够更快地收到推送消息
这个问题来自第31篇文章“如何实现原生推送能力”。
我们需要先判断当前应用是处于前台还是后台,然后再用对应的方式去处理:
如果应用处于前台并且已经完成初始化则原生代码直接调用方法通道通知Flutter如果应用未完成初始化则原生代码将消息存在本地待Flutter应用初始化完成后调用方法通道主动拉取。
如果应用处于后台则原生代码将消息存在本地唤醒Flutter应用待Flutter应用初始化完成后调用方法通道主动拉取。
问题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核心技术与实战”专栏全部思考题的答案了。你如果还有其他问题的话欢迎给我留言我们一起讨论。

View File

@@ -0,0 +1,40 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
结束语 勿畏难,勿轻略
你好,我是陈航。
三个多月的时间转瞬即逝转眼间《Flutter核心技术与实战》已经走到了尾声。在这里我要感谢你对我和这个专栏的鼓励和支持也要向你表示祝贺你已经完整地学习了专栏的全部课程实现了从入门到进阶Flutter技术的目标你的坚持一定有所收获。现在专栏课程已经结束了但还不能松懈我们的Flutter学习旅程并未结束从进阶到精通还有很长的一段路需要走希望你能保持持续学习的习惯。
在这三个月的时间里我们先后扫清了Dart语言基础语法及常用特性障碍系统学习了Flutter框架原理和核心设计思想掌握了构建炫酷页面从底层原理到上层应用的关键技术学习了Flutter疑难问题及高阶特性的背后原理并通过一些围绕效率和质量典型的场景分析了在企业级应用迭代中如何构建自己的Flutter开发体系。
专栏正文虽然已经更新完毕了,但我们的交流还会继续。同时针对专栏前面的课后题及留言,我也会从中专门挑选一些有代表性的问题进行深入讲解。
与此同时我也很高兴地看到在Google针对前端和移动端的布局愿景和强力带动的形势下Flutter的发展方向愈加清晰。
在2019年Flutter有了越来越多的知名公司加持背书其开发者生态正在日益繁荣开发者体验越来越好支持的终端类型越来越广使用的项目也越来越多。在开源社区里Flutter是目前最火的大前端技术正在经历着从小范围验证到大面积商业应用的过程。
大前端的技术更新迭代快、东西多很容易让人挑花了眼。如果仅仅停留在对应用层API的使用上不仅容易滋生学不动的困扰也会让人产生工程师杂而不精的观点。大前端技术都是相似相通的我认为一名优秀的大前端工程师应该具备以下特征
在技术层面应该抛开对开发框架的站队除了应用层API之外能够更多地关注其底层原理、设计思路和通用理念对中短期技术发展方向有大致思路并思考如何与过往的开发经验相结合融汇进属于自己的知识体系抽象网络
而在业务上应该跳出自身职能的竖井,更多关注产品交互设计层面背后的决策思考,在推进项目时,能够结合大前端直面用户的优势,将自己的专业性和影响力辐射到协作方上下游,综合提升自己统筹项目的能力。
做好一件事从来都不是一蹴而就的。
以我写专栏的过程来说,我自认为在大前端领域摸爬滚打多年,撰写专栏应该是一件驾轻就熟的事情。但从一开始的筹备阶段,我就慢慢发现这个事情远比我想象的要困难。与之前零散的总结输出相比,专栏的组织形式和交付方式需要花费数倍的精力。
为了把每一个知识点讲透,我需要花费大量的时间和精力去构思文章结构、验证设计、准备素材、代码实践。期间也不乏为了确认一个知识细节,花费数天时间去查阅资料、阅读源码、验证实现。
就这样从初春写到深秋整整7个月几乎每个工作日的夜晚和周末都用在了学习、写作和录音上这个过程虽然很痛苦但对我来说收获是巨大的。可以说《Flutter核心技术与实战》这个专栏对我自己也是一个认知重塑的过程。
进步很难,其实是因为那些可以让人进步的事情往往都是那些让人焦虑、带来压力的。而人生的高度,可能就在于你怎么面对困难,真正能够减轻焦虑的办法就是走出舒适区,迎难而上,去搞定那些给你带来焦虑和压力的事情,这样人生的高度才能被一点点垫起来。解决问题的过程通常并不是一帆风顺的,这就需要坚持。所谓胜利者,往往是能比别人多坚持一分钟的人。
勿畏难,勿轻略,让我们在技术路上继续扩大自己的边界,保持学习,持续成长。