Flutter 开发之旅从南到北

978-7-115-54637-1
作者: 杨加康
译者:
编辑: 谢晓芳

图书目录:

详情

本书主要讲述Flutter框架的核心技术。本书共12章,首先介绍了Flutter技术的基础知识、Dart的基础知识以及Flutter中的基础组件等,然后讲述了Flutter中的布局管理、动画管理、手势事件管理、路由管理以及状态管理等核心技术点,最后讨论了Flutter中的网络通信和应用测试并给出了一个完整的案例。本书有助于读者深入理解Flutter 技术的完整知识体系。 本书适合Web前端开发人员、Android开发人员、iOS开发人员、Flutter初学者以及对移动开发感兴趣的人员阅读,也可供相关专业人士参考。

图书摘要

版权信息

书名:Flutter 开发之旅从南到北

ISBN:978-7-115-54637-1

本书由人民邮电出版社发行数字版。版权所有,侵权必究。

您购买的人民邮电出版社电子书仅供您个人使用,未经授权,不得以任何方式复制和传播本书内容。

我们愿意相信读者具有这样的良知和觉悟,与我们共同保护知识产权。

如果购买者有侵权行为,我们可能对该用户实施包括但不限于关闭该帐号等维权措施,并可能追究法律责任。

著    杨加康

责任编辑 谢晓芳

人民邮电出版社出版发行  北京市丰台区成寿寺路11号

邮编 100164  电子邮件 315@ptpress.com.cn

网址 http://www.ptpress.com.cn

读者服务热线:(010)81055410

反盗版热线:(010)81055315


本书主要讲述Flutter框架的核心技术。本书共12章,首先介绍了Flutter技术的基础知识、Dart的基础知识以及Flutter中的基础组件等,然后讲述了Flutter中的布局管理、动画管理、手势事件管理、路由管理以及状态管理等核心技术点,最后讨论了Flutter中的网络通信和应用测试并给出了一个完整的案例。本书有助于读者深入理解Flutter 技术的完整知识体系。

本书适合Web前端开发人员、Android开发人员、iOS开发人员、Flutter初学者以及对移动开发感兴趣的人员阅读,也可供相关专业人士参考。


作为现在流行的跨平台开发框架,Flutter实现了高效的热重载、高的性能以及用户界面的高一致性,这正是跨平台开发技术的核心。高效的热重载可以让开发者更加快速地开发,而不用等待漫长的编译过程,高的性能让跨平台开发不再受性能限制,而用户界面的高一致性让跨平台开发能够真正用于生产实践。随着社区资源的不断丰富,Flutter将引领跨平台开发进入一个新的天地。

Flutter中国开发者社区引起了越来越多人的关注,越来越多的开发者开始研究Flutter。在社区中,大量的文章介绍了Flutter的相关技术,但这些零零散散的文章很难让初学者对Flutter有一个完整的认识,也很难让初学者理解Flutter的设计理念。同时,Flutter的知识点非常多,让开发者很难对Flutter有一个非常清晰的认识,因此很多开发者在入门之后,只会机械地搬运组件,而无法掌握Flutter的核心设计思想。

本书从Flutter开发的各个方面入手,不仅讲解了Flutter的基本使用方法,还分析了Flutter的设计思想和核心理念。通过阅读本书,开发者不仅能知其然,还能知其所以然,从而建立起完整的Flutter知识体系。本书可以帮助更多的开发者实现从初级到高级的进阶,希望读者都能从本书中受益。

徐宜生

《Android群英传》《Android群英传:神兵利器》的作者


我在2017年开始接触Flutter,还记得最初接触Flutter时只能靠自己去摸索和猜测,而如今我很高兴看到Flutter社区在日益壮大,不但开发者日益增多,而且参与推广和写书的技术人员持续增加,这说明了Flutter在这几年的发展中得到了开发者的认可。

在2018年年末,Flutter 1.0正式发布,我开始将Flutter应用于日常工作中,虽然它还存在诸如支持的第三方库不完善、框架功能不够成熟等问题,但是它优秀的跨平台能力确实足以弥补这些不足,而早期参与Flutter开发走过的弯路也让我对Flutter有了更深刻的了解。

2019年,越来越多的企业开始在他们的产品中使用Flutter。我曾分析过大的企业的53款移动应用,其中已经有近20款使用了Flutter框架,甚至像今日头条、阿里巴巴和小米等企业已经开始利用Flutter在个人计算机(Personal Computer,PC)上的能力重构其桌面端的产品。Flutter在移动开发或者前端开发领域中已经占据一席之地,了解它也是前端开发人员职业生涯中的加分项,相信未来它会是大前端开发中不可或缺的一门技术。

作为当前最热门的跨平台开发框架之一,Flutter的优势在于非常高的开发效率和跨平台的一致性。凭借优秀的底层设计、前瞻性的框架理念,Flutter在Android与iOS平台上非常火热。目前Web平台和macOS已经开始支持Flutter,预计Windows系统与Linux系统很快也会支持Flutter。

本书涵盖Flutter框架的方方面面,从入门的基础知识、前端开发的利器到前端开发实战,因此本书对于初次接触Flutter的开发者是很好的入门指南。

Flutter的世界很精彩,这里既有机遇也有挑战。我很高兴看到Flutter社区多了一位布道者,希望Flutter社区可以更加壮大。

郭树煜

《Flutter开发实战详解》的作者


写书需要很大的勇气,尤其是介绍新技术的书,因为一本好书是通向一个新的技术领域的阶梯,这就是我写这本书的初衷,我希望本书可以成为经典的Flutter入门图书。

写书是一个很大的挑战,与单独成篇的博客文章不同,图书更系统、更完整。因此我在写这本书时始终秉持着一个理念—要将内容系统化,要使脉络架构清晰,我希望本书能帮助读者更高效地了解Flutter的特性。

自从问世以来,Flutter占尽了技术版面的头条。在谷歌的大力支持下,Flutter在国外已经有了很多实际的落地场景,eBay、Square等国际互联网企业已经将它作为跨平台应用开发的首选方案。而在国内Flutter更是势如破竹,从字节跳动专门设立Flutter技术团队,到阿里的闲鱼技术团队宣布全面使用Flutter,腾讯、百度、美团等企业纷纷将目光聚焦于此,一时间,Flutter成为2020年最热门的新技术之一。

在这段时间里,涌现出了Flutter技术方面的很多先驱者,他们热爱开源,并将这种热情付诸行动,为Flutter初学者提供了丰富的学习资源,也出现了很多介绍Flutter的图书。大部分读者能通过这些图书了解Flutter的基础知识,但是它们只介绍基础知识,没有透彻地解释Flutter框架层的原理,因此,我觉得需要一本书来帮助读者在入门的基础上更上一层楼,这是我写这本书的动机。

本书共12章。从内容层面,本书从入门到进阶,从理论到实践;从技术层面,本书从Dart到Flutter,从源码层到应用层。

第1~3章可以作为读者入门Flutter的踏脚石,其中概述了Flutter、Dart并介绍了Flutter中的基础组件等。这部分内容有助于读者掌握Dart和Flutter组件的用法。

在学习完入门必备的知识之后,第4~9章不仅介绍了Dart进阶知识,还讨论了Flutter中的布局管理、动画管理、手势事件管理、路由管理以及状态管理这5部分。这5部分相互独立但又环环相扣,Flutter中通过3棵树—组件树、元素树和RenderObject树衔接了这5部分。学习完这些内容后,你不但能够在头脑中建立起完整的Flutter技术体系,而且能进一步提升自己的实践水平。

第10章和第11章讨论了Flutter中的数据存储与通信以及应用测试,这是移动端应用开发的共同话题。如果你是原生应用或者Web方面的开发者,可以将它们与你已经掌握的技术进行比较,这是有经验的开发者学习一门新技术的好方法。

第12章介绍了一个关于在线商城的实战案例。

本书从Flutter和Dart基础知识讲起,由浅入深,既适合初学者学习,又适合有一定开发经验的人阅读。你可以没有Flutter开发经验,但最好具备面向对象编程语言(如Java、Python等)的基础知识。本书还适合具有一定经验的Web前端开发人员、Android开发人员、iOS原生开发人员阅读。

如果你是初学者,建议从第1章开始阅读,并在阅读过程中结合本书的代码动手实践,这样会取得非常快的进步;如果你具备一定的Flutter基础,那么建议选择性地阅读前3章,而将主要注意力放在第4~9章和第12章。

添加微信公众号“MeandNi”或者扫描以下二维码,并回复“源码”,即可获得本书配套源代码。

本书配套资源的下载页面如下图所示。

感谢《Android群英传》《Android群英传:神兵利器》的作者徐宜生老师,他是我Android开发学习路上非常重要的导师。感谢字节跳动公司Flutter技术团队的袁辉辉老师为本书提出的宝贵的建议。感谢为本书写序的郭树煜老师,我非常钦佩他的开源精神,他为Flutter社区做了很多贡献,他的人格魅力和在技术领域的深耕都深深影响着我。最后,还要感谢我的家人,以及一路陪伴我的同学、同事和朋友。


杨加康 移动开发工程师,目前就职于小米,在Android开发与前端开发方面具有丰富的理论基础与实践经验,精通Android系统的体系结构和应用层的开发。他从2018年开始投身Flutter领域,是国内较早使用Flutter与Dart的开发人员。在个人博客与相关技术社区发表过多篇高质量文章并获得较高的关注量,翻译过《物联网项目实战:基于Android Things系统》。个人的微信公众号是“MeandNi”(其中不定期分享Android、Flutter、Java等方面的文章/视频)。


本书由异步社区出品,社区(https://www.epubit.com/)为您提供相关服务和后续服务。

作者和编辑尽最大努力来确保书中内容的准确性,但难免会存在疏漏。欢迎您将发现的问题反馈给我们,帮助我们提升图书的质量。

当您发现错误时,请登录异步社区,按书名搜索,进入本书页面,单击“提交勘误”,输入勘误信息,单击“提交”按钮即可,如下图所示。本书的作者和编辑会对您提交的勘误进行审核,确认并接受后,您将获赠异步社区的100积分。积分可用于在异步社区兑换优惠券、样书或奖品。

我们的联系邮箱是contact@epubit.com.cn。

如果您对本书有任何疑问或建议,请您发邮件给我们,并请在邮件标题中注明本书书名,以便我们更高效地做出反馈。

如果您有兴趣出版图书、录制教学视频,或者参与图书翻译、技术审校等工作,可以发邮件给我们;有意出版图书的作者也可以到异步社区在线投稿(直接访问www.epubit.com/contribute即可)。

如果您所在的学校、培训机构或企业想批量购买本书或异步社区出版的其他图书,也可以发邮件给我们。

如果您在网上发现有针对异步社区出品图书的各种形式的盗版行为,包括对图书全部或部分内容的非授权传播,请您将怀疑有侵权行为的链接通过邮件发送给我们。您的这一举动是对作者权益的保护,也是我们持续为您提供有价值的内容的动力之源。

“异步社区”是人民邮电出版社旗下IT(信息技术)专业图书社区,致力于出版精品IT图书和相关学习产品,为作译者提供优质出版服务。异步社区创办于2015年8月,提供大量精品IT图书和电子书,以及高品质技术文章和视频课程。更多详情请访问异步社区官网https://www.epubit.com。

“异步图书”是由异步社区编辑团队策划出版的精品IT专业图书的品牌,依托于人民邮电出版社近30年的计算机图书出版积累和专业编辑团队,相关图书在封面上印有异步图书的LOGO。异步图书的出版领域包括软件开发、大数据、人工智能、测试、前端、网络技术等。

异步社区

微信服务号


图3.11 Image组件

图3.12 将Image组件的fit属性设置为BoxFit.fill的效果

(a)            (b)            (c)

图4.10 SliverAppBar组件的滚动效果

图4.12 Row组件中的4个子组件在屏幕上的效果

图4.13 Column组件中的4个子组件在屏幕上的效果

图4.14 Column、Row组件的嵌套使用

(a)            (b)            (c)

图6.3 圆形容器的效果

(a)            (b)            (c)

图6.4 组件的过渡

(a)            (b)            (c)

图6.5 内部容器的过渡

图6.6 半透明的容器

(a)            (b)            (c)

图6.7 状态过渡动画

图7.8 绘制效果

图12.11 选中的商品类别


当读者翻开这本书时,心情应该是激动的。自谷歌推出Flutter的SDK框架之后,便引来了原生Android开发者、iOS开发者、Web前端开发者甚至后端开发者等的强烈追捧。在移动开发红利逐渐消失的这段时间,我们已经很久没有见过这种盛况了,在人手一部智能手机的时代里,移动开发的技术既面临饱和的挑战,也激发了人们对新技术的热情。

2014年,Flutter以Sky为名在GitHub上开源,2015年正式改名为Flutter并于2017年才发布了内测版。2018年是Flutter元年,这一年,谷歌在开发者大会之后立即推出了Flutter预测版。紧随其后,在万众期待之下,在2018年年底的Flutter Live大会中我们真正见到了Flutter 1.0版本。Flutter官网给出了它的核心定义——通过编写同一套代码,可以开发出同时运行在Android平台和iOS平台中的应用,正所谓“一次编写,两处运行”。在之后我们也将知道Flutter应用可以运行在除这两个平台之外的其他平台上。

在介绍Flutter的起源和基础知识之后,我们就可以开始随本书进入Flutter的世界了。

不知道有没有人统计过当今世界上还有多少人没有用过智能手机?这样的人非常少,目前几乎每人一部智能手机。从开发者的角度来看,一项技术的饱和程度莫过于此,在十年前智能手机和移动开发刚刚兴盛的那段时间,每个人都认为移动开发将是那个时代的未来,而很多开发人员当时顺其自然地投入原生(native)开发中。

原生开发通常是指针对某个特定的平台利用其提供的SDK和特定的工具进行开发。我们可以用Java或Kotlin调用Android SDK,开发Android应用,也可以通过Objective-C或Swift调用iOS SDK,开发iOS应用。Android SDK和iOS SDK在固定的平台上可以开发出性能功能强大的应用。另外,也可以通过系统提供的API访问手机的特定功能(相机、GPS等)。然而,当出现问题时,维护的成本较高,我们必须同时维护两套不同的代码,仅这一点就会使得大部分软件厂商难以生存。原生开发的动态化能力较弱,用户有时候势必须手动更新应用,这也带来了用户体验的下降。

针对原生开发带来的问题,后来的很多开发者选择在应用中嵌入WebView,也就是使用HTML5来替换一部分原生开发,从而使一套Web代码同时运行在Android和iOS两个平台上,而且获得了Web开发的动态特性。我们也将这种同时应用原生和跨平台两种开发方式的方案叫作混合(hybird)开发。但混合开发的不足在于难以调用系统级别的API,如果可以使用WebView提供的接口来实现通过JavaScript代码调用原生API的功能,那么WebView低下的渲染性能就是我们迫切要改变这种开发方式的强大动机,因为开发者知道这种方式始终摆脱不了Web浏览器渲染所耗的时间。

那么如何减少非原生UI在移动端渲染的耗时呢?React Native、Weex等响应式框架的跨平台方案随之而来,它们使用一种将非原生组件映射到原生组件并实现非原生代码与原生代码通信的技术(我们将它称为JavaScript Bridge)突破了这个瓶颈。图1.1展示了这种类型的响应式框架与原生平台交互的原理。

图1.1 ReactNative等响应式框架与平台交互的原理

由图1.1可知,这类框架的应用层与原生平台可以通过一个称为JavaScript Bridge的中间层交互,这样就能将原有的JavaScript组件转换为原生组件,同样,以这种方式在JavaScript侧使用原生平台的各种服务(如GPS、蓝牙等)。至此,跨平台方案已经成熟,我们的问题也相继得到了解决,开发者们开始热衷于对ReactNative社区的拓展并更加依赖该社区。如果读者对React方面的技术感兴趣,可以去深入了解,这对学习Flutter也有一定益处,因为Flutter的很多理念和思路来自React。

但经过更进一步的思考,似乎应用的性能问题依然没从根本上得到解决,以上提到的“将非原生组件映射到原生组件并实现非原生代码与原生代码通信的技术”便注定了这种方式永远不能使应用如原生应用那般流畅,因为我们无论在这一部分做多少的优化,始终都会存在通信转换的过程。如何消除这个过程?Flutter的创新性就在于此,它能够作为新一代跨平台方案的主要原因就在于它不依赖于原生组件。Flutter使用的Dart语言基于预(Ahead-of-Time,AOT)编译方式,这种编译方式在没有任何JavaScript Bridge的情况下就可以编译成平台原生的代码并直接与它们交互。另外,Flutter拥有自绘引擎Skia,Flutter与原生平台的交互方式如图1.2所示。平台层只需要提供一块画布(canvas),其他诸如手势、渲染和动画之类的任务可以由Flutter自身承担,这使得开发者拥有更大的控制权,并且能使应用的性能和原生应用几乎相等。这两个因素使通过Flutter开发的应用不需要任何转换过程就完全能够在原生环境下运行,并且可以直接与本机代码交互控制屏幕上的每个像素。

图1.2 Flutter与原生平台的交互方式

本节将介绍Flutter的架构。如图1.3所示,Flutter采用了分层设计的模式,整体架构分为两层——框架(framework)层和引擎(engine)层。

图1.3 Flutter的架构

引擎层由C++ 编写,其中主要包含Dart虚拟机、Skia渲染引擎库和文字渲染基础组件Text。我们已经知道Flutter与其他跨平台框架的不同就在于它有独立的渲染引擎库,而不依赖原生引擎库,Skia便是这个渲染引擎库。Dart虚拟机包含了Dart中编译方式、垃圾回收等机制的实现。而引擎层中的Text则负责Flutter中文字的渲染。

框架层完全由Dart语言实现,是开发者直接接触的部分,包含了图片、按钮等基础组件(widget),以及动画、手势等内置组件。在应用开发中开发者最常与该层提供的接口与组件打交道。从整体来看,Flutter的框架层依然采用了分层架构,每一层又按照功能模块划分。框架层中部分层的作用如下。

从这里,我们不难看出,我们在应用开发中最常接触的就是组件层,它也是我们开发整个视图的基础。官方文档提出的一个重要的理念就是“一切皆为组件”。在Flutter里,包括布局和动画等在内的大部分概念建立在组件的基础之上。本书旨在揭开Flutter神秘的面纱。

前面几节介绍了与Flutter相关的很多概念。本节主要讨论谷歌为什么在Flutter上使用Dart语言,Material Design为什么能应用在大多数的移动应用界面的设计上,以及Fuchsia、Flutter Web。

在介绍Dart之前,先讲一个故事。

有一天,谷歌跨平台部门的一个开发者Tom为了解决当下React Native等JavaScript跨平台框架所带来的问题而绞尽脑汁,当想到用自绘引擎来实现脱离原生平台渲染的方案时,他兴奋不已。“这种方案可能会改变当下移动开发的方向啊!”他心里暗暗自喜。为了酝酿情绪并与同事们分享这个想法时,他来到了楼下的咖啡厅喝咖啡,转念又想:“那这个技术要用什么语言呢?”就在这时,隔壁Dart部门的同事Jack过来打了一个招呼:“就用Dart吧!”

上面的故事纯属虚构,甚至有种调侃的意思。因为Flutter在国内火爆的时候Dart这门语言并不被我们熟悉,很多开发者不是很理解谷歌为什么要选择Dart而不是JavaScript作为它的开发语言,而且他们不是很喜欢Dart语言本身的语法。那谷歌真的仅仅是为了“扶持隔壁部门”而选择了Dart语言了吗?答案显然不是。下面列举了Flutter选择Dart作为开发语言的部分原因。

Dart还有其他各种优势。例如,当使用Dart编写应用程序时,不在需要将布局代码与逻辑代码分离而又引入xml、JSX这类模板和布局文件。下面是使用Dart编写组件的一个例子。

new Center(
  child: new Text('Hello, World!'),
)

即使还没有Dart语言的基础,你也清楚以上代码的意思,这里在Center组件中放了一个Text组件。而在Dart 2中,又添加了另一个使Dart语言使用起来更方便的特性,就是完全可以省去new、const等关键词。例如,上面的代码可修改为以下形式。

Center(
  child: Text('Hello, World!'),
)

第2章会介绍Dart语言的具体用法。相信读者深入学习Dart之后一定会更喜欢这门语言。

提示:

Dart语言同时支持AOT和JIT两种编译方式,而目前主流的语言大多只支持其中一种编译方式,如C仅支持AOT编译方式,JavaScript仅支持JIT编译方式。

一般来说,静态语言会使用AOT编译方式。在AOT编译方式下,编译器必须在执行代码前直接将代码编译成机器的原生代码,这样在程序运行时就不需要做其他额外的操作而能够直接快速地执行,它带来的不便就是编译时需要区分用户机器的架构,生成不同架构的二进制代码。而JIT编译方式通常适用于动态语言。在JIT编译方式下,程序运行前不需要编译代码而在运行时动态编译,不用考虑用户的机器是什么架构,为应用的用户提供丰富而动态的内容。虽然JIT编译方式缩短了开发周期,但是可能导致程序执行速度更慢。

Dart语言同时使用了以上两种编译方式,这一点为它能应用在Flutter中提供了显著的优势。在调试模式下,Dart使用JIT编译方式 ,编译器速度特别快,这使Flutter开发中支持热加载的功能。在发布模式下,Dart使用AOT编译方式,这样就能够大大提高应用运行速度。因此,借助先进的工具和编译器,Dart具有更多的优势——极快的开发周期和执行速度以及极短的启动时间。

熟悉Android开发的读者相信已经对Material Design有或多或少的了解,它是谷歌推出的一套视觉设计语言。谷歌有全世界顶尖的设计工程师,他们通过定义一系列设计原则,从而使得应用的颜色选择与搭配、界面排版、动画、交互方式、组件大小与间距等在用户界面(User Interface,UI)呈现上实现了相对统一。

Flutter提供了大量该风格的组件,我们可以按照目前已经提出的Material Design原则创建用户界面。这种方式大大地降低了开发者美化用户界面的工作量,用最简单的话来概括就是,我们可以用最少的工作量做出最好看的UI。其实,在Android最早推出的很长一段时间里,界面风格一直是用户吐槽的很大一方面,用户永远不会喜欢使用交互体验差劲的应用,谷歌在Material Design上花了很多工夫,这也是Android甚至谷歌所有的平台在UI方面的里程碑。

在Flutter引入Material Design的另外一个很重要的原因是它能够使应用风格趋于统一。我们可以在整个Flutter应用甚至不同的应用中只使用一整套的Material Design风格。同时,为了防止出现大部分应用的UI出现雷同的情况,Flutter团队一直致力于给开发者自定义独特的样式(如圆角角度、阴影厚度、主题颜色等参数)提供更多的接口。Flutter的每次更新总会对Material Design提供更多的支持。

Flutter能激发很多开发者的兴趣的一个原因是,它可能将作为谷歌此后将推出的新系统Fuchsia的原生开发方式。

早在谷歌刚刚开源Fuchsia的部分代码之后,很多感兴趣的“有识之士”就深究了这个可能取代Android的下一代移动操作系统。Fuchsia与之前各类操作系统都有较大的不同。其一,Fuchsia底层并没有使用与Android一样的Linux内核,而使用谷歌自研发的Zircon;其二,Fuchsia在顶层Topaz中明显地支持使用Flutter与Dart来开发应用。Fuchsia OS的分层架构见图1.4。

图1.4 Fuchsia OS的分层架构

当被问及为什么要学习一门技术的时候,人们可能会说这门技术在未来会很有前景。当将这句话应用在Flutter上时,我们可以说Flutter将是下一代操作系统应用的原生开发方式,我们现在使用Flutter开发的应用可能就是新操作系统上的应用,想想就很激动。

在2018年的Flutter Live大会上,谷歌给Flutter指派了另一项令人兴奋的任务——踏入Web开发领域,并在谷歌2019开发者大会上正式发布了Flutter for Web预览版,这一点已经完完全全地体现了Flutter在跨平台方向上的目标。在撰写本书时,Flutter已经可以同时作为移动端、Web端、桌面端的开发方式了。

其实从诞生之初,就希望Dart具有编译JavaScript的功能。现在许多重要的应用都是从Dart编译为JavaScript并在生产环境中运行的。为了能使Flutter运行Web应用,Flutter在标准浏览器API上使用Dart实现核心Painting层,这使得Flutter Web依然可以保留之前框架层的内容。Flutter Web的架构如图1.5所示。

图1.5 Flutter Web的架构

本书并不会讨论Flutter如何实现Web技术方面的细节,但Flutter在很大程度上将会使UI的开发方式变得统一,并且可以使用相同的代码使应用直接运行在Web浏览器中。我们可以骄傲地对其他人说:“一次编写,到处运行。”

在真正领略Flutter的风采之前,我们可以参考本书的附录A安装Flutter开发环境。完成安装操作之后,读者现在就可以跟随本节的内容尝试在没有Dart语言基础的情况下理解Flutter默认实现的一个计数器应用,你会发现一切都比你想象中的简单很多。

为了使各个阶段的开发者都能够轻松地完成本书中的操作,本书将会统一采用更加轻量的Visual Studio Code作为编写代码和展示示例的IDE,并且使用macOS进行演示(Windows系统中的操作与之类似)。可以按照如下步骤创建第一个Flutter应用。

(1)启动Visual Studio Code。

(2)选择菜单栏中的“查看”→“命令面板”,如图1.6所示,打开命令面板。

图1.6 选择“查看”→“命令面板”

(3)在命令面板中输入“flutter”,并选择下拉列表框中的“Flutter: New Project”,如图1.7所示,新建一个Flutter项目。

图1.7 选择下拉列表框中的“Flutter: New Project”

(4)输入自定义的项目名称(如hello_world),如图1.8所示,按Return键创建项目。

图1.8 输入项目名称

(5)单击Select a folder to create the project in按钮(见图1.9),指定项目将要放置的位置,这里选择目录后,单击OK按钮。

图1.9 单击Select a folder to create the project in按钮

(6)等待项目创建结束之后,新项目窗口便会自动打开。

创建Flutter项目后,可以在Visual Studio Code中看到Flutter项目的结构,如图1.10所示。

图1.10 Flutter项目的结构

部分文件夹的作用如下。

注意,pubspec.yaml文件是项目的配置文件,可以在该文件中声明项目中使用到的依赖库、环境版本以及资源文件等。附录A会介绍更多相关内容。

pubspec.yaml的另一个重要功能便是指定应用中需要使用的本地资源(图片、字体、音频、视频等)。通常情况下,我们会在项目根目录下创建一个images目录,用来存放应用中会使用到的图片资源,这些图片资源需要在该配置文件中的assent属性下声明(见图1.11)。

图1.11 pubspec.yaml中的资源文件声明

应用运行在设备上之后,这些资源文件就会一并打包在安装程序中,在之后的章节会对配置文件的其他配置项做具体的介绍。

熟悉了这些文件夹的大致作用之后,我们先尝试运行一下这个默认的项目。为此,我们需要启动模拟器或者使用USB接口接入真机。具体步骤如下。

(1)模拟器打开或者真机接入后,在Visual Studio Code主界面右下角的状态栏中选择可以运行的目标设备(见图1.12)。

图1.12 选择可用设备

(2)打开lib文件夹下的main.dart文件,按F5键或选择菜单栏中的“调试”→“启动调试”,开始运行项目(见图1.13)。

图1.13 选择“调试”→“启动调试”

(3)等待应用在模拟器或真机上自动启动。

(4)如果一切正常,在应用安装成功后,我们应该就能够在设备上看到图1.14所示的计数器应用。

图1.14 计数器应用

此时,我们的第一个应用就已经启动了,你可以看到这个应用的首页展示了一个标准的Material Design风格的界面,顶部有一个带有页面标题的导航栏,右下角有一个带有“+”号的悬浮按钮,单击这个按钮就会使页面中的数字增加。这个应用可以用来记录单击按钮的次数。

已经运行的计数器应用是我们步入Flutter殿堂的阶梯。通过分析这个应用的实现方式,我们会对Flutter中的应用开发有一个直观的理解。

首先,打开lib文件夹下的main.dart文件,这里面存放了这个计数器应用的所有代码。忽视注释中的内容,我们可以在文件的最上方看到带有import字样的代码行,它的作用是导入该文件需要使用到的库。这里我们导入了Material库,因为我们需要使用该这个库下面的UI组件。下面我们可以看到一个main()函数,它是Dart语言的主函数,每当我们运行应用后,系统都会首先调用main.dart文件中的这个函数。

import 'package:flutter/material.dart';

void main() => runApp(MyApp());

main()函数中调用了runApp()函数,我们可以将runApp()理解为运行Flutter应用的入口,而传入的MyApp对象就代表了需要运行的应用。在Flutter中,MyApp又称为组件对象,它在这里就相当于应用显示在屏幕上的UI组件,应用启动后就能够显示MyApp中的内容。下面是MyApp组件的具体实现。

class MyApp extends StatelessWidget {

  // 重写StatelessWidget的build()方法,返回一个组件对象
  @override
  Widget build(BuildContext context) {
    /*
    * MaterialApp表明应用采用Material Design风格,
    * 可以在theme属性下配置应用中与主题相关的属性,如颜色、按钮风格
    * */
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: MyHomePage(title: 'Flutter Demo Home Page'),
    );
  }
}

通过阅读上面的代码,我们发现类MyApp继承自StatelessWidget,并重写了它的build()方法,这个方法返回了一个组件对象,所以这里我们可以推理出MaterialApp()也是一个组件对象。前面已经提到了“一切皆为组件”,读者可以从这里开始随着阅读本书慢慢地理解这句话了,它是我们开发出用户能看见的应用的基础,我们可以通过设置组件的属性来控制应用所能展示的内容。

继续分析下面的代码,我们可以看到在MaterialApp组件中有3个属性,分别是title、theme、home。其中,title表示组件的标题属性;theme可以用来配置应用的主题样式;home参数用来指定MaterialApp中的主体内容,它接受另一个组件,这里指定为MyHomePage。在使用MyHomePage时,还传入了一个title参数,它用来接受显示在计数器应用顶部导航栏中的标题。我们可以尝试修改这个值然后保存代码,如果程序依然处于运行状态,由于Flutter支持热加载的特性,导航栏中的文字就会实时更新为最新的值,这个特性能够帮助我们更高效地开发应用。

继续向下,我们就可以看到MyHomePage组件的具体实现了。

class MyHomePage extends StatefulWidget {
  // 构造函数,用于接受调用者的参数
  MyHomePage({Key key, this.title}) : super(key: key);

  // 声明了一个字符串类型的final变量,并在构造函数中初始化
  final String title;

  /*
  * 所有继承自StatefulWidget的组件都要重写createState() 方法,
  * 用于指定该页面的状态是由谁来控制的。
  * 在Dart中,以下划线开头的变量和方法的默认访问权限就是私有的,
  * 类似于Java中用private关键字修饰的变量和方法,只能在类的内部访问
  */
  @override
  _MyHomePageState createState() => _MyHomePageState();
}

/*
* State是一个状态对象,<> 里面表示该状态是与谁绑定的。
* 在修改状态时,在该类中进行编写
*/
class _MyHomePageState extends State<MyHomePage> {
  int _counter = 0;

  // 实现计数值加1的函数
  void _incrementCounter() {
    // setState方法用于更新属性
    setState(() {
      _counter++;
    });
  }

  @override
  Widget build(BuildContext context) {
    /* 
    * Scaffold是一个Material Design风格的组件,
    * 它继承自StatefulWidget,包含appBar、body、drawer等属性
    * */
    return Scaffold(
      /* 顶部导航栏 */
      appBar: AppBar(
        /*
        * 这里的Widget其实就是MyHomePage,
        * 它在这里调用了上面传递过来的title变量
        */
        title: Text(widget.title),
      ),
     // Scaffold中的主体布局
      body: Center(
        /*
        * 在Center组件中有一个child属性,用来定义它的子组件Column,
        * Column表示以行的形式显示其子组件
        */
        child: Column(
          /*
          * mainAxisAlignment用来控制子组件的对齐方式,
          * 也可以把值设置为start、end等
          */
          mainAxisAlignment: MainAxisAlignment.center,
           /* 
           * Column组件的children属性用于指定它的子组件,
           * 它接受一个数组,可以向该属性传递多个组件
           */
          children: <Widget>[
            // Text组件,用于显示文本
            Text(
              'You have pushed the button this many times:',
            ),
            // Text组件,使用style属性来设置它的样式
            Text(
              '$_counter',
              style: Theme.of(context).textTheme.display1,
            ),
          ],
        ),
      ),
      /*
      * FloatingActionButton也是Material Design风格的组件,
      * 可以在onPressed属性中定义其单击事件
      */
      floatingActionButton: FloatingActionButton(
        // 通过单击触发_incrementCounter函数
        onPressed: _incrementCounter,
        tooltip: 'Increment',
        // 指定child的子组件为一个“+”号图标
        child: Icon(Icons.add),
      ),
    );
  }
}

MyHomePage是实现计数器应用的核心,它同样是一个组件,最终会显示在应用中。在_MyHomePageState中,我们可以重写build()方法,返回MyHomePage组件中显示的内容。根据注释,我们可以在代码中找到显示在屏幕中的组件,其中涉及了Scaffold、Column、Text等常用组件,以及对FloatingActionButton响应事件的处理。Dart语言的相关语法和组件的具体含义会在下面的章节中介绍。

在上述代码中,很多组件都有child属性,如FloatingActionButton组件中的child属性是一个“+”图标,它就表示将“+”图标设置为FloatingActionButton的子组件,表现在屏幕上的效果就是“+”图标显示在FloatingActionButton中。通过这种方法,我们可以将多个组件组合在一起而开发出一个完整的页面。

另外,读者还可能注意到一个重要的部分,MyHomePage继承自StatefulWidget,MyApp也继承自一个与它类似的StatelessWidget。作为计数器应用的核心组件,MyHomePage用于改变计数值。也就是说,当单击“+”按钮后,增加计数这个功能需要由它负责。要想达到这样的效果,必然就需要改变_counter变量的值,StatefulWidget是可以改变它对应State对象中的值的一个组件,而StatelessWidget不具备这个功能,它只能用来展示UI。第3章会具体介绍StatefulWidget和StatelessWidget这两个类的具体用法。

在学习Flutter前,作为理论基础,我们还需要理解一些会经常提及的概念,本节就揭秘Flutter框架层中最核心的概念。了解HTML的读者一定听说过“DOM树”这个概念,它由页面中一个个的标签构成,这些标签所形成的一种天然的嵌套关系使它们可以表示为“树”状结构。例如,下面这一段HTML代码就可以使用图1.15所示的HTML DOM树的结构来表示。

图1.15 HTML DOM树的结构

<html>
  <body>
    <h1>...</h1>
    <p>...</p>
    <div>
      <a>...</a>
      <p>...</p>
    </div>
  </body>
</html>

Flutter中虽然没有HTML、XML这类配置语言,但DOM树同样可以应用在Flutter中。例如,在计数器应用中很多组件可以通过child属性设置它们的子组件,因此我们可以用一棵树(见图1.16)来表示计数器应用的整体结构。

图1.16 计数器应用的组件树

和HTML中的标签不同,Flutter中的这棵树由一个个组件组成,因此我们可以也将它称为组件树(widget tree),它就表示在Dart代码中所写的一个个组件所组成的结构。然而,前面提到,应用运行后,组件渲染的任务并不在组件层完成,而在渲染层完成,从这里我们可以简单地推断出一个结论,这棵组件树并不是真正意义上展示在手机屏幕上的各个组件。

Flutter官方文档中组件的定义是不可变的UI描述信息。这意味着组件在创建后将不能再改变,当我们想要更新页面的状态时,也无法主动改变页面信息,因此,为了解决这类问题,Flutter又引入了元素树和RenderObject树。

元素树与组件树相对应,它由一个个元素(element)构成。大部分情况下,其实我们可以把元素理解为展示在屏幕上的真正UI组件,它会根据我们在代码中配置的组件和属性生成。因此,应用开发者可以在代码中创建的组件仅仅作为Flutter创建的元素的配置信息。当应用运行并调用build()方法后,Flutter就会根据这些配置信息生成一个个与组件对应的元素实例,在这个过程中创建了元素树。

创建组件树和元素树有很多值得我们深思的益处,我们也会在之后的章节中持续地关注这个话题。

这里举一个形象的例子来帮助读者更深刻地理解组件和元素的含义。类似于公司的总经理,组件的任务就是把近期的战略部署(即配置信息)写在纸上并下发给经理人——元素,元素看到详细的配置信息就开始干活。我们还需要注意一点,总经理随时会改变战略部署,而由于组件的不可变性,它并不会在原有的纸上修改,而只能拿一张新的白纸并重新写下配置信息。这时,经理人——元素为了减少工作量需要将新的计划与旧的计划仔细比较,再采取相应的更新措施。这就是Flutter框架层在此基础上做的一部分优化操作。问题又来了,元素作为经理人很体面。当然,元素不会把活全干完,于是又找了一个叫作RenderObject的员工来帮它做粗重的工作。

RenderObject在Flutter当中负责页面中组件的绘制和布局,其中会涉及的布局约束和绘制等技术会在第4章继续深究。同时,由RenderObject组成对应的RenderObject树(也称为渲染树)。最后,如果我们运行如下这段带有Center和Text两个组件的代码,最终Flutter内部就会生成图1.17所示的3棵树,并最终显示在手机屏幕中。

Center(
  child: Text('MeandNi'),
)

图1.17 Flutter中的3棵树

通过前一节的学习,读者一定对Flutter中3棵树各自的作用有了一定的了解,但对Flutter内部创建这3棵树的意义并不完全理解,因为所有的功能貌似仅用一棵树就能实现,创建3棵树只会加大工作量。本节结合计数器应用对这3棵树再做一个简要的分析。对于组件树中存放计数值的Text组件,在开发者指定显示的属性内容为_counter后它就不能再更新了。因此,为了在页面中改变这个状态值,必须调用_MyHomePageState的setState()函数通知与它对应的元素将状态更新为最新的计数值。下面的_incrementCounter()就是单击“+”按钮后调用的函数。

void _incrementCounter() {
  setState(() {
    _counter++;
  });
}

这段代码就让元素意识到状态已经改变,因为在setState()函数内部会将组件树中MyHomePage以下的所有组件标记为可更新状态,这时,元素就可以开始使对应的RenderObject将那些可更新组件用最新状态值渲染出来。

另外,还值得我们继续深究的就是Flutter中渲染树是如何将最新状态下的实际组件渲染在屏幕中的。图1.18揭示了Flutter中组件的渲染流程。

图1.18 Flutter中组件的渲染流程

从图1.18可以看出来,始终由用户触发重新渲染UI的操作。用户可能会单击页面中的某个按钮,调用setState()函数,然后就会触发页面更新,接下来就会执行过渡动画,在动画执行期间,Flutter将会一直更新,直到渲染完成。构建组件的过程就是Flutter构建上一节介绍的3棵树的过程。构建完成之后,Flutter就会通过RenderObject树上的RenderObject节点执行真正的渲染工作。

RenderObject依赖在代码中配置的组件,它会根据已经设置的属性完成接下来的布局(layout)、绘制(paint)以及合成(composite)操作。其中,布局操作会使用布局约束等原理计算各部分组件的实际大小,这部分内容将在第4章详细介绍;绘制过程就是根据配置的视图数据将组件的内容绘制在屏幕当中;合成就是将各部分的视图层合并在一起。

在日常开发中,我们只需要在代码中针对各个组件的特性配置好组件树,其余的工作可以直接交给Flutter框架层去实现,因此,我们大部分时间可能花在了解各种组件的特性与使用方法上。理解这部分内容后对我们之后学习常用组件有很大的帮助。在以后的学习中,我们应当用不同的眼光去看待我们所建立的布局和组件。在本书后面的内容中,我们会继续探究这部分内容,让读者更加深入地理解这3棵树。

本章首先介绍了Flutter的由来,然后以发展的角度审视了Flutter与其他跨平台框架的区别,最终得出的结论就是Flutter可以作为开发者学习的移动开发新技术之一。

另外,通过对计数器应用的分析,你已经感受到了Flutter开发的简单快捷。日常开发过程中,我们常接触的就是组件,Flutter以它作为基石构建屏幕中显示出来的页面。同时,为了开发复杂的页面,本章最后对Flutter中的3棵树和渲染原理做了简单的介绍。这部分内容过于抽象,你可以带着疑问阅读,下面的章节会解答各种疑惑。你的Flutter旅程才刚开始。后面几章将介绍关于Flutter的更多知识。


要游刃有余地开发Flutter应用,扎实的Dart基础是必要的前提,本章逐步介绍Dart的各种语法特性,包括变量、运算符以及面向对象编程等。

本章介绍Dart的基础知识,讨论Dart的特性以及使用方法。同时,作为一门高级编程语言,Dart与目前流行的Java、Python有很多类似的地方,如果你已经是高级程序员了,完全可以选择性地阅读本章。

和学习其他编程语言一样,本节会以一个简单的“Hello,Dart”程序作为学习Dart的开端。

首先,创建一个名为dart-basis的文件夹,并放入Visual Studio Code中。

然后,在dart-basis下创建一个名为hello.dart的文件并输入以下代码。

// void为返回类型,main为函数名
void main() {
      print('Hello, Dart!');    // 在控制台中输出"Hello, Dart!"
}

如果你已经按照附录B安装了Dart SDK,就可以在终端中使用命令行工具运行这段代码了。接下来,在终端程序中进入dart-basic对应的目录,执行下面这段命令。

$ dart hello_world.dart
// Hello, Dart!

如果控制台中成功输出了“Hello, Dart! ”(见图2.1),就表示你已经成功运行了这段程序。

对于为什么终端能输出这段文本,我们继续对这段程序做进一步的剖析。在Dart程序中,函数是程序运行的基本单位,这段代码中的main()就是一个主函数,当在终端按Return键后,底层运行这段代码的虚拟机就会找到里面的main()函数并执行里面的内容。print()是另外一个可以用来输出文本的函数,它可以直接将传入的字符串输出到控制台中,这里,它存放在了main()函数中,虚拟机运行时识别出了它,这段代码就由此输出了“Hello, Dart!”文本。

图2.1 hello.dart的执行结果

另外,上面的代码中,main()属于自定义的函数,而print是系统内置的函数,我们自己很清楚的一点就是我们并没有实现将文本显示在屏幕上的这个功能,该功能在内置的print中实现了。对于这些内置的函数,我们可以任意调用。除了print之外,Dart还提供了很多其他功能强大的内置函数。而自定义函数的内容可以随意变化。要创建自定义函数,首先要做的就是命名自定义函数,然后定义函数体的内容,这段内容需要用花括号括起来,例如,下面就是自定义的一个名为hello()的函数。

void hello() {

}

这是一个空函数,因为在它的 {} 中并没有需要完成的任务。在函数名前,我们通常还会声明返回类型,hello()函数的返回类型为void,表示这个函数不返回具体的值。函数名后的圆括号用来放这个函数接受的参数,如果参数为空,表示不接受参数。如果我们希望hello()函数接受一个参数,可以将上面的代码修改为下面这段代码。

void hello(String name) {

}

上面的hello()函数接受一个名字为name的参数,之后在函数体内就可以使用使用name参数完成其他的任务,我们可以继续将name按照上面的格式输出。

void hello(String name) {
       print('Hello, ${name}!');
}

这样,hello()函数就具有了一个功能——传给它的参数都会以“hello,×××”这样的格式输出,可以在main()函数中使用hello()函数。

void hello(String name) {
       print('Hello, ${name}!');
}

void main() {
    hello("小明");
}

再次运行这段代码后,我们就会在控制台中看到“Hello,小明”。另外,和Java、C等程序一样,Dart程序中的每一条语句都需要以分号结尾。至于其他的内容,如字符串类型、void的具体含义,后面会详细介绍。

Dart有和其他语言一样的普适性概念,也有一些自己的新特性。高级程序员能够快速学习一门新语言的秘密就是他能够将主要的注意力放在对应语言的新特性上,而新手就需要逐个特性去理解。从零开始学习是每个程序员必须经历的阶段,突破了这个瓶颈,就可以游刃有余。

学习Dart的过程中,无论是有经验还是刚入门的开发者都应该记住以下两点。

1.变量

在各类编程语言中,变量的职责就是存放数据。下面是Dart中使用变量的方法。

var name = '小明';

上面定义了一个名为name的变量。这里使用var作为声明变量的关键词,在变量名后还使用等号运算符为这个变量赋予了一个具体的值——“小明”,这就是该变量要存放的数据,这种对变量赋值的操作也可以称作变量的初始化。在后面的程序中,我们就可以使用name变量得到初始化的数据。

var修饰的每个变量初始化后都会指向一个具体类型的数据。例如,上面用单引号标注的文本 '小明' 属于Dart中的字符串(string)类型,Dart编译器执行这段代码时,就会将name变量定义为字符串变量。

因此,var声明的变量属于哪种类型取决于它第一次被赋予哪种类型的值。如果为age变量赋予一个整型数据,那么它就属于整型变量。

var age;

age = 20;        // 第一次赋值,指定变量的类型为整型
age = '20';         // 错误,变量的值不能是字符串

当把age的值20指定为整型后,它就不能接受其他类型的值了,所以age = '20'是一种错误的赋值方式。

另外,Dart也支持直接指定变量类型的声明方式。下面的代码中的name和age在赋值之前就已经定义了变量类型,因此它们分别接受string或int类型的值。

// 直接声明变量类型
String name;
int age = 20;

name = 2  // 错误,name为字符串类型的变量

如果在赋值前明确指定变量类型,那么这个变量就只能接受特定类型的数据,因此,String name = 2这种声明方式是错误的,因为name变量属于字符串变量,不能接受整数。运行这段代码后,编译器会抛出下面这样的错误。

Error: A value of type 'int' can't be assigned to a variable of type 'String'.
      String name = 2;

如果某个变量没初始化,那么Dart会默认为它们安排一个初始值null,代表这个变量为空。

var name;
print(name); // null

如果我们希望在变量多次赋值的过程中改变它所属的类型,可以使用dynamic。

dynamic person = '小明';
person = 12;

这样,dynamic修饰的变量person就可以在第二次赋值后由之前的字符串类型动态变成整型,但Dart官方并不鼓励经常使用dynamic,因为这对程序的性能有影响。

2.常量

如前所述,使用var声明变量,允许我们对它指向的数据再次修改,例如,下面这段程序。

var name = '小明';

name = '小强';
print(name); // '小强'

在控制台中输出之前,如果将name变量的数据修改为'小强',name变量就会被再次赋值,最后输出的结果为修改后的字符串 '小强'。在Dart中,如果我们想要声明一个不允许改变的变量,可以使用final和const这两个关键词,它们要求我们在声明变量时给定一个数据,并在之后不允许做任何改变。下面的代码展示了final的用法。

final name = 'Bob'; // 没有指定数据类型,name默认为赋予的字符串变量
final String nickname = 'Bobby';        // 指定类型的字符串

name = 'Alice'  // 错误,final修饰的name变量不能修改

final age;                            // 错误,final修饰的变量需要在声明时立即初始化

上面这段代码中的name和nickname都被final修饰,它们之后将不能再更改,所以如果你尝试加入name = 'Alice' 对name变量做第二次赋值,编译器就会抛出下面这个错误。

Error: Setter not found: 'name'.
  name = 'Alice';
  ^^^^

下面的代码展示了const的用法。

const name = 'Bob'; // 没有类型批注
const String nickname = 'Bobby';

// const name;     // 错误,const修饰的变量必须在声明时初始化
// name = 'Alice'  // 错误

const与final的主要区别在于const修饰的变量在编译时就需要准确计算出具体的值,例如,当要声明一个变量来存储当前的时间时,这个变量就不能被const修饰,因为编译时这个变量值并不能确定,每次运行程序时的当前时间都会不同。

final dateFinal = DateTime.now(); // 正确
const dateConst = DateTime.now(); // 错误

因此,也不能将一个可变变量赋值给const修饰的变量。下面给出一个示例。

var firstName = '李';
var lastName = '小明';
// ...
firstName = '张';
final fullName1 = firstName + lastName; // 正确
const fullName2 = firstName + lastName; // 错误

上面的例子中,因为firstName和lastName变量在赋值给fullName2之前很可能会被程序修改,所以const修饰的fullName2并不能接受由它们组成的字符串。虽然在赋值之后程序不能修改final修饰的fullName1,但赋值时可以接受可变的值。

除了整型(int)之外,Dart中还内置了多个功能强大的数据类型。开发中常用的数据类型如下:

与之前已经介绍的字符串和整型一样,在声明这些类型的变量时可以使用var关键词,也可以直接指定类型。另外,Dart的面向对象特性还要求它的每一个变量都属于一个类的实例,因此我们可以使用变量内部的属性和方法(例如,通过String变量的length属性)获得字符串的长度,2.3节会介绍这个性质。本节详细介绍这些数据类型的特性和使用 方法。

1.数值类型

数值类型包括整型和浮点型。下面的代码展示了整型和浮点型变量的声明方式。

int age = 20;                    // 整型
double weight = 64.5;            // 浮点型

这两种数值类型都属于num,因此也可以使用下面这种方式声明整型和浮点型变量。

num age = 20;
num weight = 64.5

同时,Dart 2中支持将整型数值赋值给浮点型变量,而不能将浮点型数值赋值给整型变量。

double weight = 20;// 正确,整型数值可以赋值给double类型的变量
int weight = 64.5; // 错误,由于会降低精度,因此浮点型不能直接转换为整型

2.字符串类型

字符串类型在Dart中可以使用String声明,被赋值的内容可以通过单引号或双引号引用。

String name = 'Joker';
var company = "Google";

和其他语言类似,在Dart字符串中也可以使用像\、\t、\n这样的转义字符。

var s = 'It\'s easy to study flutter';

这里,由于字符串中的单引号会和外部的单引号冲突,因此使用“\”对前者转义。这时,输出的字符串s如下。

It's easy to study flutter

可以使用${表达式/变量}在字符串中嵌入其他字符串。如果表达式直接指向一个变量,可以省略花括号{}。在下面这个示例中,我们在字符串intro中嵌入了name。

String name = "小明";
String intro = '他的名字叫 $name'; // 他的名字叫小明

可以像下面这样通过加号运算符连接多个字符串。

var firstName = '李';
var lastName = '小明';
final fullName1 = firstName + lastName; // 李小明

3.布尔类型

布尔类型是用来存放两种状态值的变量,两种状态值分别是true和false。在Dart中,使用bool来声明布尔值。

var name = '';
// isEmpty用来判断字符串name是否为空
bool isEmpty = name.isEmpty;
print(isEmpty); // true,表示为空

对于面向对象语言Dart来说,包括前面已经介绍的整型变量和字符串变量,任何变量都属于对象,每个对象都有它的属性和方法,2.3节会对其中的概念做具体的介绍。

上面这段代码中,我们使用name.isEmpty获得了字符串变量name的isEmpty属性,这个属性的值是一个布尔值,true表示字符串为空,false则表示字符串不为空。

==运算符可以用来比较两个对象是否相等,比较结果是一个布尔值。当使用该运算符比较两个字符串时,比较的就是字符串内容是否相等。下面给出了一段示例代码。

String s1 = 'hello world';
String s2 = 'hello dart';
String s3 = 'hello world';

print(s1 == s2); // false
print(s1 == s3); // true

4.列表类型

Dart中的列表类型对应于其他编程语言中的数组,可以用来存放一组相同类型的数据对象,用List表示。可以使用下面这些方法声明列表。

var list = [1, 2, 3];        // 方法1,使用var变量接受列表

List list = [1, 2, 3];        // 方法2,使用List类型的变量接受列表

声明列表变量的方法有很多种。与整型和字符类型变量的声明方法一样,除了可以直接使用方法1中的var关键词之外,也可以直接声明变量接受的类型。列表的值使用中括号[]包裹,其中的每一项使用逗号分隔。上面就声明了包含3个整数的列表。

需要注意的是,一个列表中只能存放同一种类型的数据。下面这个list中由于既存在整型又存在字符串类型,因此就是一个错误列表,编译时就会报错。

var list = [1, 'name', 3]; // 错误,一个列表中只能存放同一类型的数据

如果要在声明列表时直接指定存放在里面的数据类型,可以使用 <> 包裹对应数据类型。下面的代码中就指定列表中可以存放的数据类型为整型。

List<int> list = [1, 2, 3];// 方法3,直接指定列表中的数据类型为整型

List list = <int>[1, 2, 3];// 方法4,在值中指定数据类型

列表是有序的,0是它的第一个元素的索引,这里可以推断出最后一个元素的索引为list.length −1,这里list.length表示列表长度。

var list = [1, 2, 3];
print(list.length);    // 3,列表长度
print(list[1]);        // 2,列表中第二个元素的值

list[1] = 4;            // 修改第二个元素的值
print(list[1]);        // 4

正常情况下,我们可以修改列表中的每一项,例如,上面的代码中的list[1] = 4可以将第二个元素修改为4,也可以像下面这样使用add/addAll()方法继续向列表添加数据。

var list = [1, 2, 3];
list.add(4);            // [1, 2, 3, 4]

var list2 = [];
list2.addAll(list); // [1, 2, 3, 4]

上面的代码中,我们使用add()方法向list中添加了一个元素4,在新声明的list2中调用了addAll()方法,这个方法可把传入列表中的数据全部添加到list2中,因此,list2中最终的值也是[1, 2, 3, 4]。

同时,Dart也提供了多个针对列表的运算符。当要向列表插入另一个列表中的所有元素时,可以直接使用展开(...)运算符。

var list = [1, 2, 3];
var list2 = [0, ...list]; // [0, 1, 2, 3]

上面的例子中,没有使用addAll()方法,而使用展开运算符轻松将list中的元素都插入list2中。但如果被展开的变量list为空(即没有初始化),却直接对它使用展开运算符,在运行时就会抛出一个错误。这时,我们可以使用判空展开(...?)运算符。

var list = null;                // null
var list3 = [0, ...list];        // 错误
var list2 = [0, ...?list];        // 正确
print(list2);                    // [0]

判空展开运算符能够预先对list进行判空操作,如果list为空,就不会对它做展开操作,从而避免了出现空指针。

最后,如果我们想定义一个不可修改的列表常量,可以使用const关键词。

var constantList = const [1, 2, 3];

constantList[1] = 1; // 错误,const修饰的列表中的元素不能修改
constantList.add(4); // 错误,const修饰的列表赋值后就不能再向其中添加数据

不同于使用const定义整型和字符串常量,在声明常量列表时,可以在等号之后使用const关键词声明该列表为常量列表。

5.集合类型

集合可以被视为无序列表,在Dart中使用Set表示并且数据使用花括号{}包裹。下面的fruits就属于一个集合变量。

Set<String> fruits = {'apple', 'banana', 'grape'};

集合的一个非常重要的特性是不存放重复的数据。当向集合中放入已存在的数据时,Dart就会认为这是无效添加。如下代码中,当向fruits中添加一个apple时,集合中元素并无增加。

halogens.add('apple');    // 集合中已存在该元素,无效添加
print(fruits); // {'apple', 'banana', 'grape'}

集合的其他大部分操作与列表相同,如下所示。

var set = <String>{};                                    // 支持直接指定存放的元素类型
set.add('fluorine');                                     // 支持add()方法
var set2 = {'chlorine', 'bromine', ...set};    // 支持展开运算符

print(elements.length);                                // 5,支持使用length()方法

6.键值对类型

键值对是用来关联一组键和值的对象,使用Map表示。其中,键和值可以使用任意类型,并且在整个键值对中键的值必须唯一,可以作为查找值的索引。下面是键值对的声明方式。

var person = {            // 键类型为String,值类型也为String
  // 键:    值
  'name': 'Tom',
  'age': '22',
  'sex': 'boy'
};

Map<int, String> cases = { // 键类型为int,值类型为String
  5: 'Liu',
  10: 'Zhang',
  18: 'Wang',
};

print(person['name']);     // Tom,person中键name对应的值
print(cases[10]);     // Zhang,cases中键10对应的值

基于上面的代码,键值对和集合的值都使用花括号表示。然而,键值对中的每一项都会包括键和值两种数据,并使用冒号分隔。这里的person变量被赋值后,Dart会将它的类型定义为Map<String, String>,cases变量则为Map<int, String>类型,<>中的两位分别表示键和值的类型。通过person['name']就可以得到person中键name对应的值。

如果要在集合中添加新的键值对,可以通过直接引用这个变量为新键赋值。

cases[20] = 'Zhou'; // 添加一个新的键值对

Dart中List、Set、Map都属于存储多个值的数据类型,它们也有许多相同的性质,例如,它们都支持展开运算符和判空运算符。下面就是对键值对变量person使用判空展开运算符的示例。

var person2 = {
  "year": '1997',
  ...?person    //展开键值对person,将其中的键值对复制到person2中
};

print(person2.length); // 4

List、Set、Map分别支持使用构造函数创建空的列表、集合以及键值对。示例代码如下。

Set<String> set = Set();                    // <String>{}
List<String> list = List();                // <String>[]
Map<String, String> map = Map();// <String, String>{}

我们会在之后进一步学习Set、List、Map更多的使用方法。

Dart内部提供了数个可以提高开发效率的运算符,具体如表2.1所示。

表2.1 Dart中的运算符

类别

运算符

算术运算符

*/%+

关系运算符

>=><=<==!=

类型判断运算符

asisis!

逻辑运算符

&&||

赋值运算符

=+=−=* =/=~/=%=<<=>>=&=^=

三目运算符

expr1 ? expr2 : expr3(expr表示一个表达式)

判空运算符

???.

级联运算符

..

一元运算符(针对单个变量)

expr++expr---expr!expr~exp++expr--expr,以及“.”与“ ?

在之前的示例中,我们已经使用了其中的一些运算符,如使用加号运算符实现字符串的连接,这类算符运算符也可以用于构成算术表达式。

print(2 + 3);        //输出5,这里使用了加号运算符
print(2 - 3);        //输出-1,这里使用了减号运算符
print(2 * 3);        //输出6,这里使用了乘号运算符
print(5 / 2);        //输出2.5,这里使用了除号运算符,结果是浮点型
print(5 ~/ 2);    //输出2,这里使用了除号运算符,结果是整型
print(5 % 2);        //输出1,余数

print(2 == 2);  // true

上面的 == 属于关系运算符,用来比较两个数值是否相等。除此之外,还可以使用 >=><=<!=对两个变量做非等关系的验证,它们的结果都为布尔类型。

使用下面这些一元运算符,可以对数值类型做自增和自减操作。

var a, b;

a = 0;
b = ++a;            // a自加后赋值给b
print(a == b);    // true,1 = 1

a = 0;
b = a++;            // a先赋值给b,再自加
print(a != b);    // true,1不等于0

a = 0;
b = --a;            // a自减后赋值给b
print(a == b);    // true,-1等于 -1

a = 0;
b = a--;            // a先赋值给b,再自减
print(a != b);    // true,-1不等于0

类型判断运算符中的as可以用来对变量的数据类型做转换。

(emp as Animal).name = 'Tom';

这里,仅当我们确定emp变量属于Animal类型时才可以使用as,否则运行时就会抛出一个类型错误,2.3节会介绍类与子类的概念。

isis!可以用来判断变量与数据是否属于某种类型。

var names = {'Li', 'Liu', 'Wang'};
print('Tom'' is String);        // true,判断 'Tom' 是否属于字符串
print(names is Set);            // true,判断names是否属于集合

表格中的判空运算符是Dart语言中比较特殊的一组运算符,充分使用它们,我们可以编写出非常简洁的代码,这里要介绍的判空运算符有3种——.?????=

在编写程序的过程中,我们经常会遇到下面这种需要判断某个变量是否为空的情况。

void setLength(String s) {
  if (s != null) {            // 判断s是否为空
 this.length = s.length;
  }
}

如果我们不判断s变量是否为空,传入的s没初始化,而直接使用“.”获取length属性,程序就会抛出错误,因此要写出一段强健的程序必须在必要的时候判断变量是否为空。使用.? 运算符可以让代码变得非常简洁。

void setLength(String s) async {
  this.length = s?.length;    // s为空时不赋值
}

?.放在需要使用的变量后面,如果变量s为空,将直接抛出错误,被赋值的变量length会被直接定义为null;如果变量s不为空,那么length就会被正常赋值为s字符串的长度。

?. 运算符通常在我们需要获取某个变量的属性时使用,而??可以用来直接判断某个变量是否为空。

// 使用 ?? 判断传入的name是否为空
String playerName(String name) => name ?? 'Guest';

??处理的变量为空时,playerName函数则返回的是??之后的默认字符串'Guest';反之,直接返回name变量的值。对于同样的功能,也可以使用三目运算符来实现。

// 使用三目运算符
String playerName(String name) => name != null ? name : 'Guest';

在上面的代码中,三目运算符包含3个部分,问号之前属于判断条件,问号之后表示要取得的两个值。当条件为true时,取冒号之前的值;反之,取冒号之后的默认值'Guest'。

最后介绍的??=属于赋值时的判空运算符,在使用它时,只有当变量为空时才能有效赋值。示例代码如下。

int a = 2;
a ??= 4;    // a不为空,因此赋值无效

print(a)    // 2

上面的代码中,当使用??运算符时,由于a变量不为空,因此它的值仍然是2。

表格中其他的运算符会在之后的实际使用过程中详细介绍。

流程控制语句是指那些用来控制代码运行顺序的语句,我们可以对执行的代码做各种跳转验证操作来控制它们执行的流程。Dart主要提供以下几种流程控制语句:

Dart中的这部分内容与大部分语言类似,有其他语言基础的读者可以留意一些比较特殊的用法。

1.if...else

if...else主要用来进行条件判断,下面是它在Dart中的基本使用方式。

int a = 5;
int b = 10;

if (a >= b) {
  print(a);
} else {
  print(b);
}

在条件判断语句中可以使用if、else…if和else,圆括号中的条件必须是一个布尔类型的值,因此我们可以使用变量或数据的比较结果作为条件。上面这段代码中,由于a的数值小于b,因此不符合a>=b的条件,最终会执行else中的代码块,输出b的值。

可以使用逻辑运算符||(或)、&&(与)连接多个条件。

if (inPortland && isSummer) {
      print('天气很好!');            
} else if(inPortland && isAnyOtherSeason) {
      print('倾盆大雨');            
} else {
      print ('请天气预报');            
}

上面这段代码中,如果inPortland和isSummer都为true,就会执行第一条print语句;如果inPortland和isAnyOtherSeason为true,执行第二条print语句;否则,执行第三条print语句。

2.for循环

Dart支持标准的for循环语句,这种方式可以在每次循环时得到一个索引。

List<String> names = ["Liu", "Wang", "Li"];
for (var i = 0; i < names.length; i++) {
      print(names[i]);
}

其中i<5表示循环终止条件,运行上面的代码就会在控制台中将列表names中的字符串依次输出。

前面已经介绍的List、Set、Map在Dart中都属于可迭代类型。当对这些类型的对象循环取值时,可以使用下面这种forEach语句。

names.forEach((value) {
  print(value);
});

这里,在遍历names时就使用了forEach语句,它以另一个函数作为参数,传入这个函数的value参数就表示每次循环得到的元素值。这段代码运行后,就会依次执行传入的函数,输出列表中的字符串。

可迭代对象支持for…in语句。

for (String value in names) {
  print(value);
}

这段代码同样会输出names列表中每一个元素。

3.while和do...while

除了for循环语句外,Dart还支持while和do...while两种循环语句。while和do...while之间的不同是while循环在执行循环体前判断条件是否满足。

// 当!isDone() 为true时,执行循环体内的代码
while (!isDone()) {     
  doSomething();
}

do...while则在执行一遍循环体后判断条件,下面是它的基本用法。

do {
  doSomething();
} while (!isDone());
// 当!isDone()为true时,继续执行循环体内的代码

也就是说,do...while中的循环体至少执行一次,而while中的循环语句在条件不满足时可能一次都不执行。

4.break和continue

break和continue语句可以帮助我们控制循环语句的执行。break用来终止循环。

for (var i = 1; i <= 100; i++) {
  if (i > 10) break;
  print(i);
}

在上述代码中,循环到i=11时满足break的条件,因此只能输出数字1~10。

continue用于立即跳出本次循环,执行下一次循环。

for (var i = 1; i <= 10; i++) {
  if (i%2 != 0) continue;
  print(i);
}

上面这段代码最终只会输出1~10的偶数值,因为当i为奇数(i%2!=0)时,continue将会结束本次循环,直接执行下一次循环。

5.switch...case

switch...case语句同样用于条件判断。在单个类型的值存在多种可能的情况下,使用switch...case比if...else更加高效。然而,在switch...case语句中只允许比较整型或字符串这类常量。下面是关于switch...case语句的示例。

int number = 1;
switch(number) {
  case 0:
    print('zero!');
    break;
  case 1:
    print('one!');
    break;
  case 2:
    print('two!');
    break;
//  错误条件:number >= 3在运行过程中可能是false也可能是true,并不是一个不变的常量,因此不能使
//  用它作为case条件
//  case (number >= 3):
//    print(number);
//    break;
  default:
    print('choose a different number!');
}

// 最终输出one

上述代码的含义是使用switch中number值与各个case的值进行比较。当匹配到相等的case值时,表示命中条件,执行相应的case语句;当没有匹配到任何条件时,则会执行default下的默认代码段。另外,每个非空case语句需要使用break语句作为结束语句,否则将会报错。示例代码如下。

switch (number) {
 case 0:
   print('zero!');
   // 错误: 缺失break语句
 case 1:
   print('one!');
   break;
 // ...
}

然而,当case语句为空时,就可以不使用break语句。这种情况下,程序将会继续执行下一个case语句。如下这段代码中,number为1,由于case 1后面的语句为空,因此会执行case 2代码段。

int number = 1;
switch(number) {
  case 0:
    print('zero!');
    break;
  case 1:
  case 2:
    print('two!');
    break;
  default:
    print('choose a different number!');
}
// 最终输出two

switch...case语句支持通过continue与标签控制代码段的执行。

int number = 1;
switch(number) {
  case 0:
    print('zero!');
    break;
  case 1:
    continue ok;
  case 2:
    print('two!');
    break;
  ok:
  default:
    print('choose a different number!');
}

当执行case 1语句时会直接跳转到ok标签,继续执行default下的代码。

在本章开始,我们已经接触并且成功运行了多类函数,其中包括主函数main()和一些自定义函数。学习Dart时,我们应当时刻记住它是一门面向对象编程语言。函数也是一个对象,它有自己的类型(Function),也能够作为参数传递给另一个函数。下面的forEach语句就以一个带单个参数的print()函数作为参数。

names.forEach((value){
  print(value);
);

回调函数print()的主要功能就是对外部提供一个操作列表元素的入口,这里的参数value就代表列表中的每一个元素。

另外,在Dart中,当一个函数只包含一行代码时,可以使用语法更简洁的箭头函数实现它。例如,对于上面这个传入forEach的回调函数print(),可以将它转换成下面这种形式。

names.forEach((value) => print(value));

这里的 (value) => print(value) 就属于箭头函数,其中的函数参数与函数体之间可以使用箭头(=>)连接。

如果箭头函数需要返回值,就需要直接使用一行代码计算出结果。例如,以下代码中的isEqual函数可以用于比较传入的num1和num2是否相等并返回一个结果值。若相等,返回true;反之,返回false。

bool isEqual(int num1, int num2) => num1 == num2;

// 非箭头函数写法
// bool isEqual(int num1, int num2){
//    return num1 == num2;
//};

这里=> num1 == num2与 { return num1 == num2;}的作用相等。

和其他函数一样,可以在main()函数中使用isEqual()函数。

void main() {
  bool equal = isEqual(1, 2); 
  print(equal);        // false
}

这里isEqual()函数返回的布尔值被equal变量接受,由于1和2不相等,因此equal的值就为false。

1.位置参数

Dart为函数提供了丰富的参数传递机制。一般情况下,函数接受的参数的声明方式如下。

int insertUser(int id, String name) {
  // ...省略无关代码
}

在主程序中调用insertUser()函数时,必须按照对应的位置提供int类型的id和String类型的name。

insertUser(1, 'xiaoming');

// insertUser('xiaoming', 1); // 错误,参数位置必须与函数声明中相对应

因为调用函数时的参数位置固定,所以这种声明方式下的参数又称为位置参数。Dart允许开发者在函数中声明可选的位置参数,如下面这段代码所示。

String insertUser(int id, String name, [int age]) {
  // ...
}

可选参数用[]包裹,因此在调用函数时可以传递也可以省略age参数。

insertUser(1, 'xiaoming');

insertUser(2, 'xiaohong', 20); // 正确

2.命名参数

另一种常用的参数声明方式是使用命名参数。这种方式下,自定义函数时的参数都需要用花括号括起来。

String insertUser({int id, String name, int age}) {
      // ...
}

在调用时只需要根据对应的名称传递命名参数各自的值。

insertUser(id: 1, name: 'xiaoming');

insertUser(name: 'xiaohong', id: 2);// 正确,命名参数位置不固定

所有的命名参数默认都是无序并且可选的,因此只要指定要传入的对应参数名称,就可以在任何位置传入它的值,也可以选择不传入值。对于必须要传递的命名参数,可以使用 @required来声明。

String insertUser({@required int id, String name, int age}) {
      // ...
}

这里,insertUser()函数的参数id就被指定为必须要传递的参数,我们在调用这个函数时就必须要传递id参数。

3.默认参数值

在自定义函数时,可以使用赋值(=)运算符为参数指定默认值,这表示当这个参数没有传入时参数就会被指定为默认的值。命名参数和可选位置参数都可以使用下面这种方式设置默认值。

String insertUser({int id, String name, int age = 20}) { // 在命名参数下为age指定默认值

}

String insertUser(int id, String name, [int age = 20]) { // 在可选位置参数下为age指定默认值

}

4.main()函数

每个Flutter应用和每段可运行的Dart代码中都应该有一个顶层的main()函数,作为程序运行的入口,main()函数默认的返回值为void。下面就是Flutter应用中main()函数的一个示例。

void main() => runApp(MyApp());

5.返回值

除了在函数内部可以实现自己的逻辑外,还可以从函数的调用方得到函数的返回结果,这是函数能够帮助我们实现功能拆分的一个重要特性。

在自定义函数时,在函数名之前标识返回值的类型。当函数没有明确返回值时,返回值可以标识为void。下面的add()函数就是一个返回值为整数的函数,在调用时可以通过函数得到相应的结果。

int add(int num1, int num2) {
  return num1+num2;
}

void main() {
  int numAdd = add(2, 5);
  print(numAdd); // 7
}

注释是对代码的解释说明,在实际运行时会被忽略。充分利用注释能在很大程度上提高代码的可读性。Dart支持单行注释、多行注释以及文档注释。

1.单行注释

单行注释以“//”符号开始,“//”后直到该行结束都作为注释文字被编译器忽略。示例如下。

void main() {
  // 这是单行注释
}

2.多行注释

多行注释以“/”开始,以“/”结束,并且可以在注释内任意换行。示例如下。

void main() {
  /*
   * 这是多行注释,一整段代码都会被编译器忽略

  Llama larry = Llama();
  larry.feed();
  larry.exercise();
  larry.clean();
   */
}

3.文档注释

文档注释就是以“///”或“/**”开头的多行或单行注释。在文档注释中,Dart编译器将忽略所有文本,开发者可以在其中使用方括号引用类、方法和字段,这些名称将会在词法范围内解析到相应的类和方法字段。在Flutter源代码中大量使用文档注释来增强可读性。

/// 这是文档注释
///
/// 我们可以在这里对Llama类做一些说明
class Llama {
  String name;

  /// 当使用中括号包裹类型[Food]时,编译器可以解析到Food类
  void feed(Food food) {
    // ...
  }

  void exercise(Activity activity, int timeLimit) {
    // ...
  }
}

现代应用中,我们开发的大部分程序用来解决现实中的问题。面向对象编程就是更够让我们写出更加贴近现实世界的代码的一种编程思想,它对现实世界做了各种抽象而衍生出类、对象、继承等概念。

我们可以试着使用一个例子来理解面向对象的具体含义。当需要编写一个图书管理系统时,对应现实的世界,我们就需要在面向对象的世界中声明Book(书)、Borrower(借阅者)、Admin(管理员)等类。这些类下会有一个个实例,每本具体的书就是Book类的实例,表示这本书属于Book类。每个类下又会有一些独特的属性,例如,Book类下有书名、作者、是否被借走等属性。实例化就是将类具体化而产生一本真正的书,这样,类就成为一个有规则的结构体,我们可以使用它模拟现实世界中的各种事物。在面向对象的世界里还可以创造其他一切有意思的事物。

如果你还不是很能理解这些抽象的描述,可以跟着下面的具体描述学习Dart如何实现面向对象的程序。

和其他语言一样,Dart中的类使用class声明。下面就是定义的一个Cat类。

// 使用class声明类,Cat称为类名
class Cat {
  String name;
  int age;
}

在Cat类内部,还定义了name、age两个属性,我们可以称它们为类的成员变量。通过前面的描述,我们已经知道了Cat类并不是真正的猫,要得到一只真正的猫还需要做实例化操作,在Dart下可以使用以下方法实例化Cat类。

Cat tom = new Cat();
tom.name = 'tom';
tom.age = 2;

上面的代码中,使用Cat类声明了一个tom变量,用new关键词实例化了Cat类并给tom赋值。这里,我们可以通过“.”(点)运算符获取实例的属性并赋值,为这只猫取名字,定义年龄。这时,tom就成为面向对象世界中一个有生命的个体,我们还可以再次实例化Cat类。

Cat jacky = new Cat();
jacky.name = 'jacky';
jacky.age = 1;

这里,我们又实例化了一只名为jacky的猫,它和tom同属于Cat类的实例,但实际上表示两个个体。

类中也可以定义一些方法来描述实例的一些行为,比如每只猫都应该会有吃这个行为,因此可以在Cat类中定义一个eat()方法。

class Cat {
  String name;
  int age;

  void eat() {
    print("$name开始吃");
  }
}

类中的方法与之前已经介绍的函数在结构上一样,并且类中的方法可以直接使用类中的各个属性,这里,我们在eat()方法中输出了name属性。当实例化Cat类的实例后,就可以给调用实例下的eat()方法来模拟猫吃东西的这个行为。

Cat tom = new Cat();
tom.name = 'tom';
tom.age = 2;

tom.eat(); // tom开始吃

setter与getter

setter与getter是两类特殊的方法,它们的作用就是向外部提供一些特殊属性的访问和赋值的入口。例如,下面的Cat类中定义了firstName和lastName两个属性,我们可以使用一个getter方法直接获得由这两个属性拼接而成的fullName,也可以通过setter方法传入fullName字符串,再对它解析。

class Cat{
  String firstName;
  String lastName;

  String get fullName => '$firstName $lastName';
  set fullName(String value) {
    List<String> nameList = value.split(' ');
    firstName = nameList[1];
    lastName = nameList[0];
  }
}

接下来,可以在主函数中使用这两个方法。

void main(){
  Cat cat = new Cat();
  cat.fullName = 'Tom Liu';    // 触发setter方法,并传入值
  print(cat.firstName);        // Liu
  print(cat.lastName);            // Tom
  print(cat.fullName);            // Tom Liu,触发getter方法,返回全名
}

如上面两段代码所示,每当对fullName使用=运算符进行赋值时,就会触发set fullName(String value)方法的调用,传入的参数就表示所赋的值。同理,当使用点运算符得到fullName时,也会触发String get fullName方法的调用,这两种方法在类外部使用时和属性相同,而在类的内部可以定义其他逻辑。

静态变量和方法是指类中那些被static修饰的变量与方法,它们在直接被类管理,我们可以在不实例化类对象的情况下,直接通过类名使用这些静态变量和方法。例如,下面这个CatShop类中的initialCount变量就是一个静态变量。

class CatShop {
  static int initialCount = 16;// 静态变量
  // ···
}

可以使用下面这种方式在main()方法中直接获取initialCount变量的值。

void main() {
  print(CatShop.initialCount); // 16
}

这里,我们就成功地直接通过类名获取了静态变量initialCount的值。静态方法的使用方法如下。

class CatShop {
  static const initialCount = 16;

  static double compareAge(Cat catA, Cat catB) {
    double difference = catA.age - catB.age;
    // 返回difference的绝对值
    return difference.abs();
  }
}

CatShop类中可以定义compareAge()静态方法来计算两只猫的年龄差,在main()方法中,我们就可以像下面这样使用compareAge()方法。

void main() {
  // ...定义Cat类的实例tom和marry 

  var difference = CatShop.compareAge(tom, marry);
  print(difference);
}

如上述代码所示,可以直接使用CatShop.compareAge(tom, marry)调用compareAge()静态方法。由于静态方法在没有实例化对象的时候就可以调用,因此静态方法中仅能使用和它作用域相同的静态变量,而不能使用类中的成员变量。

继承是面向对象编程中一个非常重要的概念。通过对前面内容的学习,我们可以将类看作各个实例的抽象,而类本身其实也可以继续抽象。例如,除了需要Cat类之外,还可以继续创建Fish、Bird等类,这些类都属于现实世界中的动物,因此创建一个Animal类将所有动物的属性和行为抽象出来。Animal类可以作为Cat、Dog等类的父类,在Dart中父类的定义和使用方式如下。

class Animal {
  String name;
  int age;

  void eat() {
    print('$name开始吃');
  }
}

class Cat extends Animal{
}

class Fish extends Animal {
}

class Bird extends Animal {
}

由于每个动物都有name和age这两个属性并且都有吃这个行为,因此我们在Animal类中定义了name、age属性和eat()方法。当再次创建Cat类时,只需要在类名后使用extends关键词声明它的父类,就可以直接继承Animal类中所有的属性和行为。我们依然可以实例化出一个Cat对象,并且属性可以正常赋值,方法可以正常调用。对于同样继承自Animal的Fish类也是如此。

Cat cat = new Cat();
cat.name = 'cat';
cat.age = 2;

cat.eat(); // tom开始吃

Fish fish = new Fish();
fish.name = 'fish';
fish.age = 1;

fish.eat(); // fish开始吃

这种现象就称为类的继承,使用extends声明父类后,Cat、Fish和Bird类就都成为Animal类的子类,它们的实例都属于Animal类,利用这个特性我们可以更高效地实现代码的复用。

同时,子类也并非只能继承父类中的属性和方法,子类也可以在类主体中定义自己特有的属性和行为。例如,鱼可以游泳,而猫和鸟并不会,因此我们可以在Fish类下定义它专属的swim()方法,Bird类中可以定义fly()方法,表示鸟会飞这个行为,Cat类中的run()方法表示猫会跑这个行为。

class Cat extends Animal{
  @override
  void eat() {
    print('$name吃猫粮');
  }

  void run() { 
    print("$name开始跑");
  }
}

class Fish extends Animal {
  @override
  void eat() {
    print('$name吃虾米');
  }

  void swim() {
    print("$name开始游泳");
  }
}

class Bird extends Animal {
  @override
  void eat() {
    print('$name吃小米');
  }

  void fly() {
    print("$name开始飞");
  }
}

同时,即使猫、鱼、鸟都有吃东西的行为,但它们吃的东西各不相同,因此在适当的时候我们可以重写父类中的方法来执行该类中特有的一些行为逻辑。上述代码中Cat类、Fish类、Bird类针对eat()方法重写了它们具体的逻辑。需要注意的是,子类的方法使用 @override表示这个方法来自父类。

在Dart中,每个类的最终父类都是Object,因此每个类都拥有一些属性和行为,这使得面向对象的程序变成了一个密封而可操作的整体,这是我们能写出健壮的程序的奥秘所在。

抽象类是以一种只能继承而不能直接实例化的类。在Dart中,可以使用abstract修饰符定义一个抽象类。另外,抽象类中通常有抽象方法,这类方法在抽象类中没有具体实现,而是在子类继承它后实现。

首先,可以将Animal修改成一个抽象类,并将它的eat()修改成一个抽象方法。

abstract class Animal {
  String name;
  int age;

  void eat();        // 抽象方法,在抽象类中没有具体实现
}

当再次继承Animal后,就必须重写它的eat()方法;否则,就会报错。

class Cat extends Animal{

  @override
  void eat() {
    // 重写eat()方法
  }
}

构造函数是类中用来实例化类对象并且与类名同名的方法。默认情况下,Dart会为每个类声明一个空构造函数,因此在之前的例子中可以使用new Cat()来实例化Cat类,也可以自定义构造函数。在下面的Cat类中,定义的构造函数Cat()接受两个参数,分别使用它们初始化类中的变量。

class Cat {
  String name;
  int age;

  Cat(String name, int age) {
    this.name = name;
    this.age = age;
  }
}

当参数名与属性名冲突时,可以使用this关键字指代当前创建的实例,使用Cat()构造函数创建对象后就会自动将传入的参数赋值给实例对应的属性。下面的代码中使用Cat()构造函数创建了一个cat对象。

Cat cat = new Cat('tom', 2);

print(cat.name) // tom

声明自己的构造函数后,空构造函数就会失效,因此就不能继续使用new Cat()来实例化对象了。

由于在构造函数中初始化属性这种形式非常常见,因此Dart为构造函数提供了更加简洁的写法。示例代码如下。

class Cat {
  String name;
  int age;

  Cat(this.name, this.age);
}

// Cat cat = new cat('tom', 2);

这种方式直接在构造函数的参数列表内使用this指向对应的属性,传入的参数就会把对应的值赋给实例的属性。这时,我们可以依然可以使用上述的实例化方式。同时,需要注意的是,构造函数不能被子类继承,如果子类中没有直接声明构造函数,那么它依然仅有一个空构造函数。

在Dart 2之后,在实例化对象时可以省略new关键词。下面这段代码可以成功实例化Cat类的一个对象。

Cat cat = Cat('tom', 2);

1.命名构造函数

Dart支持声明一些特殊的构造函数。常用的命名构造函数的声明方式如下。

class Cat extends Animal{
  String name;
  int age;

  Cat.born(String name) {
    this.name = name;
    this.age = 0;
  }

  Cat(String name, int age) {
    this.name = name;
    this.age = age;
  }
}

命名构造函数的名称以ClassName.identifier()格式声明,其中,ClassName表示类名,identifier表示这个构造函数的名称。上面的Cat.born()就表示我们用这个构造函数实例化一只刚出生的小猫,这里我们只需要传入猫名,而年龄默认为0,使用方法如下。

Cat cat = new Cat.born('tom');

可以为类定义多个意义不同的命名构造函数。命名构造函数依然不能被子类所继承,如果子类需要与父类名称相同的命名构造函数,必须在子类中手动实现。

2.工厂构造函数

与默认的构造函数与命名构造函数每次都会生成一个新的实例对象不同,Dart支持的工厂构造函数通常需要一个返回值,我们可以在构造函数体内返回一个已经生成的实例对象,例如,缓存中的实例或者已经创建的单例对象等。

要创建一个工厂构造函数,只需要使用factory关键字修饰普通构造函数。下面这个示例中就使用factory修饰Cat的默认工厂构造函数。

class Cat {
  String name;
  int age;

  static Cat cat;                                        // 单例cat的具体实例

  Cat._born(this.name) : age = 0;        // 命名构造函数
  factory Cat() {                                        // 工厂构造函数
    if (cat == null) cat = Cat._born('单例猫');
    return cat;
  }
}

当我们使用new Cat()创建cat实例时,就会调用工厂构造函数factory Cat(),而这个工厂构造函数仅会返回一个已经生成的单例cat。

void main() {
  Cat cat = new Cat();
  print(cat.name);            // 单例cat
}

3.调用父类构造函数

默认情况下,子类的构造函数会直接触发父类的非命名无参构造函数,例如,下面这段程序。

class Animal{
  Animal() {
    print('Animal无参构造函数');
  }
}

class Cat extends Animal{
  Cat() {
    print('Cat构造函数');
  }
}

main(){
  Cat cat  = new Cat();                // 调用Cat()构造函数 
}

运行上述程序,输出结果如下。

Animal无参构造函数
Cat构造函数

如果父类中不存在无参构造函数,那么子类必须手动触发父类的其中一个构造函数,可以在子类的构造函数主体之前、冒号之后指定需要调用的构造函数,如下面的Cat类所示。

class Animal extends Object{

  Animal.fromJson(Map data) {
    print('Animal.fromJson()');
  }
}

class Cat extends Animal{

  Cat.fromJson(Map data) : super.fromJson(data) {
    print('Cat.fromJson()');
  }
}

main() {
  var cat = new Cat.fromJson({});
}

这里,在Cat的命名构造函数Cat.fromJson()后使用super关键词调用Animal.fromJson()。运行程序后控制台中的输出结果如下。

Animal.fromJson()
Cat.fromJson()

4.初始化列表

除了可以调用父类构造函数外,还可以在构造函数主体运行之前使用初始化列表初始化一些类中的实例变量。例如,在下面这个Cat.fromJson()构造函数后面我们可以直接为firstName和lastName变量赋值,这里,赋值语句之间使用逗号分隔。

Cat.fromJson(Map<String, String> json)
    : firstName = json['firstName'],
      lastName = json['lastName'] {
  print('Cat.fromJson(): ($fitstName, $lastName)');
}

使用初始化列表也可以初始化final修饰的变量,方法如下。

class Cat extends Animal{

  final String firstName;
  final String lastName;
  final String fullName;

  Cat(String firstName, String lastName)
      : firstName = firstName,
        lastName = lastName,
        fullName = firstName+lastName {
  }
}

枚举类(通常称为枚举)是一种特殊的类,用来存放一组固定数量的常量值。在代码中,可以使用enum关键词来自定义一个枚举类,例如,下面这个Animals枚举类。

enum Animals { dog, cat, bird }

Animals中存放了3个值,可以直接使用Animals.dog获得dog这个枚举值,并且枚举类中的每个值都有一个索引(从0开始),它表示这个枚举值在枚举声明中的位置。 例如,第一个值的索引为0,第二个值的索引为1。

print(Animals.dog.index);     // 0
print(Animals.cat.index);     // 1
print(Animals.bird.index);    // 2

可以使用Animals枚举类的values属性获取其中所有值的列表。

List<Animals> animals = Animal.values;
print(animals); // [Animals.dog, Animals.cat, Animals.bird]

还可以将枚举值作为switch的case条件。

var aAnimal = Animals.bird;

switch (aAnimal) {
  case Animal.dog:
    print('小狗');
    break;
  case Animal.cat:
    print('小猫');
    break;
  default:                    // 若没有这个条件,就会出现一个警告
    print(aAnimal); // Animal.bird
}
// 执行这段代码,最终就会在控制台中输出Animals.bird

需要注意的一点是,此时case语句中的条件必须覆盖枚举类Animal中的所有值,否则会出现警告。

学完本章,不知道你有没有为自己又掌握了一门编程语言而激动呢?相信你已经对Dart中的函数、变量、运算符、面向对象编程有了一定的理解,但是对Dart的学习还远不止于此。在之后的Flutter学习旅程中,我们还会将这些概念运用到实际的应用程序中,在实践中体会它们的用法。

同时,你一定还会不断遇到到新的挑战和问题,保持热情,不断学习后面的知识点,体会Dart在程序设计方面的优势。从下一章开始,我们就真正踏上Flutter学习之旅了。


相关图书

TypeScript全栈开发
TypeScript全栈开发
Java EE企业级应用开发实战(Spring Boot+Vue+Element)
Java EE企业级应用开发实战(Spring Boot+Vue+Element)
Vue.js全平台前端实战
Vue.js全平台前端实战
Flutter内核源码剖析
Flutter内核源码剖析
智能前端技术与实践
智能前端技术与实践
从0到1:ES6快速上手
从0到1:ES6快速上手

相关文章

相关课程