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,161 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
01 前言-教程内容导读
一、 教程的是什么,不是什么
本系列教程是完全面向 Flutter 新手朋友,即使没有任何编程基础,也可以观看。希望本册以最有趣和通俗的方式,来 迎接 你们走到 Flutter 新手村的第一站。就像不可能在小学一年级就教学生微积分,所以本册:
[1]. 不会 涉及复杂的算法和数据结构
[2]. 不会 涉及系统全面的 Dart 语法介绍
[3]. 不会 涉及项目组织、代码管理的思想
[4]. 不会 涉及框架原理、源码的原理剖析
[5]. 不会 涉及跨平台适配、插件编写知识
[6]. 不会 涉及应用性能分析和优化
不过这些知识点在你的编程之路上,总会在某处等待着你。对知识而言,每个阶段有不同的渴求,很多知识需要门槛和经验积累才能领悟,需要时间来沉淀,所以并不用急于求成。入门的第一站,别给自己太大的压力,玩好就行了,如果能有些自己的体悟,那就是锦上添花了。
本教程只是一个起点,我会尽可能通过有趣的例子,让你在最初的路途中不对编程产生不适。如果能燃起你的一丝兴趣,将是本教程的荣光。学完本课程,你将会:
[1]. 初步认知 Flutter 框架是什么,能干什么。
[2]. 初步了解 最基础的 Dart 语法知识。
[3]. 学会 通过常用组件构建出简单的界面。
[4]. 学会 在 Flutter 项目中使用别人的依赖库。
[5]. 初步掌握 Flutter 中数据的持久化手段。
[6]. 学会 通过界面交互完成一些简单的功能逻辑。
二、 教程的五大模块
本教程共 26 章,分为如下 5 大模块:
1.Flutter 基础
第一个模块是 Flutter 最基础的前置知识准备阶段包括环境搭建、Dart 基础语法介绍、计数器项目解读个三部分。如果已经开发过 Flutter 项目的朋友,可以选择跳过本模块,也可以温故知新。
2. 猜数字模块
第二个模块是猜数字项目,这是我设计的一个比较简单有趣的小案例,生成随机数后,在头部输入框猜数字。其中包含着 Flutter 最基础的知识点,比如基础组件的使用、界面的布局、逻辑的控制、动画的使用等。麻雀虽小五脏俱全,非常适合新手学习。
生成随机数
输入比较
3.电子木鱼模块
第三个模块是电子木鱼项目,也是一个比较简单有趣的小案例,最主要的功能是点击图片发出木鱼的音效。另外支持功德记录的查看,以及音效、图片的选择。其中包含也着 Flutter 很多的知识点,比如基础组件的使用、状态类生命周期回调、依赖库的使用、本地资源配置等。
点击木鱼
查看功德记录
4.白板绘制模块
第四个模块是白板绘制项目,用户可以通过手势交互在界面上绘制线条,交互性很强,也非常有趣;支持线颜色和线宽的选择,并可以回退上一步和撤销回退。其中包含也着 Flutter 很多的知识点,比如绘制的使用、手势监听器的使用、组件封装等。
画板绘制
回退和撤销
5.项目整合
最后一部分将介绍如何将之前的一个个孤零零的界面,通过导航结构整合为一个项目。并了解如何在切换界面时,保活状态数据。这部分还会介绍数据的持久化存储,这样用户的选择项和一些记录数据就可以存储到本地,不会随着应用的退出而重置。最后,会介绍对网络数据的访问,完成下面文章展示页的小案例:
下拉刷新
加载更多
三、关于本教程的源码
项目源码在 github 上托管,项目名是 : flutter_first_station ,寓意是 Flutter 的第一站。
另外,源码是最终版效果,中间一步步的实现过程通过提交节点来查看,在文章相关位置会有对应节点的连接地址,访问即可看到当前步骤下的源码。
比如上面是 13 章介绍电子木鱼静态界面构建的章节,点击就会进入当前节点所处的源码位置:
那废话不多说,一起开始本教程的旅程吧 ~

View File

@ -0,0 +1,176 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
02 Flutter 开发环境的搭建
如果你已经有了 Flutter 的开发环境,可以跳过本篇。先声明一点: Flutter 虽然可以开发 Windows、Linux、Macos、Android、iOS 、web 六大主流平台的应用程序。但作为初学者,最好先在一端上学习 Flutter 的基础知识,不用过分追逐在每个平台上都跑一遍。
对于编程的新手朋友,我比较建议先在 Android 平台学习,首先 Android 真机设备或模拟器的门槛比较低,只需要一个 windows 电脑就可以了,而 iOS 应用需要 Mac 笔记本才能开发其次安卓的应用可以直接打包分享给别人用iOS 则比较复杂;最后,移动端要比桌面端成熟一些,并不建议新手一开始从桌面端应用来学习 Flutter 。
当然,如果你以前是做 iOS 开发的,或者手上有 Mac 笔记本、iOS 真机,也可以选择通过 iOS 应用来学习。对于入门级别的 Flutter 知识来说,各个平台没有什么大的差异,所以不用过于纠结。
对于新手而言,开发环境搭建是一个非常大的坎,特别是跨平台的技术,涉及面比较广。我以前在 bilibili 发表过几个视频,介绍 Flutter 在各个平台开发应用的环境搭建。不想看文章或者是看文章无法理解的朋友,可以根据视频中的操作来搭建开发环境,尽可能降低门槛。
(主要) Flutter 全平台开发环境 | SDK 和 开发工具
(主要) Flutter 全平台开发环境 | Android 设备运行项目
(选看) Flutter 全平台开发环境 | Window 平台桌面应用
(选看) Flutter 全平台开发环境 | iOS/macOS 平台应用
在介绍 Flutter 开发环境之前,先打个比方:如果说编写应用程序是在做一道 麻婆豆腐Flutter 环境本身相当于 原材料 ,提供应用中需要的素材。但只有原材料是不足以做出一道菜的,还需要厨具进行烹调,厨具就相当于 IDE (集成开发环境),也就是编辑代码和调试的工具。最后,才是盛到盘子里,给用户品尝。
1. FlutterSDK 的下载与安装
对于 Flutter 本身而言,最重要的是下载 FlutterSDK ,地址如下:
docs.flutter.dev/development…
根据计算机的操作系统,选择最新稳定版的文件,比如现在最新版是 3.7.10 ,点击一下版本号就下载压缩包。这里以 Windows 操作系统为例:
下载完后,解压到一个文件夹下:
为了可以在计算机的任何地方都可以访问 flutter 提供的可执行文件,一般都会将 flutter\bin 文件夹路径配置到 Path 环境变量中。如下所示,红框中的路径和上一步解压的路径有关:
注: 如果不会配置环境变量,可以参考上面第一个视频中的操作,或自己搜索解决。
另外,由于网络原因,可能国外网站的依赖难以下载,可以顺便在系统变量中配置官方提供的国内镜像,第一个视频中也有介绍操作方式。
PUB_HOSTED_URL=https://pub.flutter-io.cn
FLUTTER_STORAGE_BASE_URL=https://storage.flutter-io.cn
如果在命令行中执行 flutter --version能给出结果说明 Flutter 环境没有问题。这时烹饪的材料就已经准备好了,接下来看看怎么拿到厨具。
2. IDE 开发工具的准备
对于一道菜来说,用什么厨具并不重要,重要的是如何把菜做好。有人喜欢用大锅,有人喜欢用小锅,个人偏好没有什么值得争吵的,工具最重要的是自己用着 趁手。 对于开发工具而言,我个人比较推荐 AndroidStudio ,因为:
AndroidStudio 的调试功能非常强大,也方便查阅源码
AndroidStudio 方便管理和下载 AndroidSDK可以创建安卓模拟器
AndroidStudio 是 Android 的官方开发工具,对 Android 开发比较友好
如果不喜欢 AndroidStudio也可以自己选择其他的开发工具比如 VSCode 等
AndroidStudio 下载地址如下:
developer.android.google.cn/studio
下载完后运行安装包,一直下一步即可。首次安装时,会引导你下载 AndroidSDK在接受之后进行下载
最后,在 Settings/Plugins 中安装 Dart 和 Flutter 插件,即可完成开发工具的准备工作。如果有什么不清楚的,可以参考上面的第一个视频。
3. 创建 Flutter 项目
安装完插件之后,重启 AndroidStudio在新建项目时会有创建 Flutter 项目的选项。红框中选择 Flutter SDK 的路径位置:
在下一步中,填写应用的基本信息:
然后,就会创建出 Flutter 默认的计数器项目,这个小项目将在下一节进行分析。接下来看一下如何将项目运行到 Android 设备中。
4. Android 模拟器创建与运行项目
Flutter 全平台开发环境 | Android 设备运行项目 视频中介绍了 Android 模拟器的创建过程,这里简单说一下要点。首先,最好在环境变量中添加一个 ANDROID_HOME 的环境变量,值为 Android SDK 下载的目录:
在 AndroidStudio 上栏图标中找到如下设备管理器,点击 Create device 创建设备:
选择一个你觉得合适的手机尺寸:
另外最好在 New Hardware Profile 中将 RAM 调大一些,否则可能在运行时内存不足,程序安装不上:
然后选择下载镜像,一般都选最高版本:
最后,模拟器创建完成,点击运行:
要将项目运行到手机中,点击上面菜单栏的小三角按钮即可:
项目运行之后,可以看到模拟器上展示了一个计数器应用,随着点击右下角的按钮,中间的数字会进行自加。这就是默认的计数器项目。
项目运行
点击加号
到这里 Flutter 的开发环境就已经搭建完成,其他平台应用的开发环境基本上类似,如有需要可以参考视频中的操作。本篇到这里就告一段落,巧妇难为无米之炊,在介绍计数器项目之前,有必要先简单了解一下 Dart 的基础语法,你才有使用代码完成逻辑的能力。
5.本章小结
到这里 Flutter 的开发环境就已经搭建完成,这里主要以 Android 应用开发的视角对 Flutter 框架进行学习。开发其他平台应用的开发环境基本上类似,如有需要可以参考视频中的操作。对于初学者而言,建议在起初专注在某一个平台中,学习 Flutter 基础知识,这些基础知识是全平台通用的。
本篇到这里就告一段落,如果把应用开发比作烹饪一席晚宴,那么环境搭建就相当于准备炊具和食材。其中编辑器是应用开发的炊具; Flutter SDK 中提供的 Dart 语法、类型体系就是食材。正所谓巧妇难为无米之炊,在介绍计数器项目之前,有必要先简单了解一下 Dart 的基础语法,你才有使用代码完成逻辑的能力。

View File

@ -0,0 +1,477 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
03 新手村基础 Dart 语法 (上)
无论你处于编程的任何阶段,都应该铭记一点:
对于计算机编程而言,最重要的是 维护数据 的变化。
比如你支付宝中的账户金额;你微信聊天记录的内容;你游戏中的资源装备;你随手拍的一张照片、录制的一段意义非凡的视频,都是数据在计算机中的不同表现形式。作为编程者要通过代码逻辑,保证数据在 用户与应用交互 过程中的准确性,来完成功能需求。
本教程只会介绍最最基础的 Dart 语法,能支撑后续教程的内容即可。无论是自然语言,还是编程语言,语法的学习都是非常枯燥的。对于新手而言,不必任何细节都面面俱到,正所谓贪多嚼不烂。学习应该循序渐进、细水长流,先自己能站立了,再想跑的事。如果已经有 Dart 基础的朋友,本章简单过一下就行了,也可能温故而知新哦 ~
一、基础数据类型
先思考一下,在现实世界中,我们如何表示不同类别的事物? 其实很简单,就是根据事物的特征进行划分,然后 取名字 进行表示。当这种表示形式在全人类中达到共识,就会成为一个认知标准,比如下面是一只猫:
而提到猫,自然就会想到它体型不大,有两个眼睛,两个耳朵,四个腿,脸上有胡须,这就是类型的特征。而 猫 这个名字本身只是一个标志,用于对一类事物的指代。虽然不同的地域可能有不同的叫法,但重要的其实不是它叫什么,而是它是什么,能干什么。
在编程中,数据类型也是类似,它的存在是为了解决:
如何在代码中 表示数据种类 的问题。
解决方法也是一样: 取名字 —- 通过名称区分不同类型数据的特点。在任何编程语言中有 内置基础类型 以供使用,下面来简单看一下 Dart 中基础数据类型变量的定义。到这里,先简单认识一下四种数据类型:
数字、文字是人们日常生活在接触最多的数据,对于任何编程语言来说都是至关重要的。布尔型本身非常简单,只能表示真或假,对逻辑判断有着重要的意义。
1. 数字类型
在日常生活在,数字 是非常必要的,它可以描述数量,也可以进行数学运算。在 Dart 语言中,数字有两大类型: 整型(整数) int 和浮点型(小数) double 。定义变量的语法是 类型名 变量名 = 值 ,下面代码中定义了两个变量:
void main(){
// 整型 age表示年龄的数值
int age = 2;
// 浮点型 weight表示体重的数值
double weight = 4.5;
}
在这里学习 Dart 基础知识时,可以在项目的 test 文件夹里,编辑代码做测试。点击三角可以运行代码:
现在通过一个小练习,认识一下代码如何进行简单的逻辑运算:
定义三个浮点型变量 a,b,c值分别是 2.3, 4.5, 2.5
通过代码计算出平均值 avg ;并将结果输出到控制台。
代码如下,数字量之间可以进行四则运算,求平均值就是将数字相加,除以个数。通过 print 函数可以在控制台输出量的信息:
void main(){
double a = 2.3;
double b = 4.5;
double c = 2.5;
double avg = (a+b+c)/3;
print(avg);
}
运行后,可以看到计算的结果。比如 a,b,c 变量分别代表三只小猫的体重,那这段代码就是:解决获得平均体重问题的一个方案。代码本身的意义在于 解决问题, 脱离问题需求而写代码,就像是在和空气斗智斗勇。
2. 字符串类型
在日常生活中,除了数字之外,最重要的就是 文字,在编程中称之为 字符串。 文字可以为我们提供信息,如何通过代码来表示和操作它,是一件非常重要的事。
在 Dart 中,通过 String 类型表示字符串,字符串值可以通过单引号 'str' 或 双引号 "str" 进行包裹,一般情况下,两者没有太大的区别,用自己喜欢的即可。字符串直接可以通过 + 号进行连接,如下所示:
void main() {
String hello1 = 'Hello, World!';
String hello2 = "Hello, Flutter!";
print(hello1 + hello2);
}
---->[输出结果]----
Hello, World!Hello, Flutter!
通过 $变量名 可以在字符串内插入变量值。如下所示,将计算平均值的输出表述进行了完善:
void main() {
double a = 2.3;
double b = 4.5;
double c = 2.5;
double avg = (a + b + c) / 3;
String output = '$a,$b,$c 的平均值是$avg';
print(output);
}
---->[输出结果]----
2.3,4.5,2.5 的平均值是3.1
另外,也可以通过 ${表达式} 来嵌入表达式,比如:
String output = '$a,$b,$c 的平均值是${(a + b + c) / 3}';
也可以理解为变量是特殊的表达式,在插入时可以省略 {}。对于字符串来说,先了解如何定义和使用就行了,以后如有需要,可以在其他资料中系统学习。
3. 布尔型 bool
布尔型用于表示真假,其中只有 true 和 false 两个值,一般用于判断的标识。布尔值可以直接书写,可以通过一些运算得到。比如数字的比较、布尔值的逻辑运算等。
void main() {
// 直接赋值
bool enable = true;
double height = 1.18;
// 布尔值可以通过运算获得
bool free = height < 1.2;
}
运算符
运算符可以和值进行连接进行特定运算产出结果比如加减乘除大小比较逻辑运算等可以说运算符是代码逻辑的半壁江山对于初学者而言先掌握下面的三类运算符
1. 算数运算符
算数运算符作为数学的基础运算从小就陪伴着我们大家应该不会感到陌生它连接两个数字进行运算返回数值结果
void main() {
print(1 + 2);//3
print(1 - 2);//-1
print(1 * 2);//2
print(1 / 2);//0.5
print(10 % 3);//1
print(10 ~/ 3);//3
}
2.比较运算符
比较运算符也是日常生活中极为常见的它连接两个值进行运算返回比较的 bool 值结果
void main() {
print(1 > 2); //false 大于
print(1 < 2); //true 小于
print(1 == 2); //false 等于
print(1 != 2); //true 不等
print(10 >= 3); //true 大于等于
print(10 <= 3); //false 小于等于
}
3.逻辑运算符
逻辑运算符,用于连接 bool 值进行运算,返回 bool 值。 理解起来也很简单,&& 表示两个 bool 值都为 true才返回 true ; || 表示两个 bool 值有一个是 true就返回 true ; ! 之后连接一个 bool 值,返回与之相反的值。
如下代码所示open 和 free 是两个 bool 值,表示条件。通过 && 运算得到的 bool 值表示需要同时满足这两个条件,即 免费进入 需要公园开放,并且可以免费进入:
void main() {
// 公园是否开放
bool open = true;
// 是否免费
bool free = false;
// 公园是否免费进入
bool freeEnter = open && free;
}
三、流程控制
如果是运算符是代码逻辑的半壁江山,那流程控制 就是另外一半。流程控制可以分为 条件控制 和 循环控制;其中:
条件控制 可以通过逻辑判断的语法规则,执行特定的分支代码块。
循环控制 可以让某个代码块执行若干次,直到符合某些条件节点结束。
1. 条件流程 : if - else
如果怎么样,就做什么,这种选择执行的场景,在日常生活中非常常见。而 if - else 就是处理这种选择分支的语法。 if 之后的 () 中填入布尔值,用于逻辑判断;条件成立时,执行 if 代码块;条件不成立,执行 else 代码块。如下代码根据 free 布尔值,打印是否可以免费入园:
void main() {
double height = 1.18;
// 布尔值可以通过运算获得
bool free = height < 1.2;
if(free){
print("可免费入园");
}else{
print("请购买门票");
}
}
2. 条件控制 : switch - case
if - else 只能对 bool 值进行逻辑判断进行分支处理某些场景中需要对更多类型的值进行判断这时就可以使用 switch - case 进行分支处理
如下代码所示根据字母等级打印对应的评级信息其中 switch 后面的括号在是校验值 case 之后是同类型的值 case 后的值和校验值一致时会触发其下的分支逻辑 default 分支用于处理无法匹配的场合
void main() {
String mark = 'A';
switch (mark) {
case 'A':
print("优秀");
break;
case 'B':
print("良好");
break;
case 'C':
print("普通");
break;
case 'D':
print("较差");
break;
case 'E':
print("极差");
break;
default:
print("未知等级");
}
}
3. 循环流程 - for 循环
有些情况下我们需要不断执行某一段逻辑(循环体)直到条件完成(循环条件)这就是循环控制
for 循环中() 里有三个表达式通过 ; 隔开
第一个表达式是进入循环之前执行的语句在循环过程中不会再次执行
第二个表达式是循环条件每次循环体执行完毕都会校验一次当条件满足时会执行下次循环
第三个表达式在每次循环体执行完毕后都会执行一次
如下代码的含义就是在循环开始时定义 i 整数变量赋值为 0 i < 5 的条件下执行循环体每次循环体执行完毕后 i 增加 1 循环体中对 i 值进行累加并且打印信息
void main() {
int sum = 0;
for (int i = 0; i < 5; i = i + 1) {
sum = sum + i;
print(" $i 次执行,sum = $sum");
}
}
---->[输出结果]----
第 0 次执行,sum = 0
第 1 次执行,sum = 1
第 2 次执行,sum = 3
第 3 次执行,sum = 6
第 4 次执行,sum = 10
注: i = i + 1 可以简写为 i += 1, 其他的算数运算符也都有这种简写形式。
另外,表示整数 i 自加 1 ,也可以简写为 i++ 或 ++i。两者的区别在于对 i 赋值的先后性, 自减 1 同理。如果初学者觉得简写难看懂,可以不用简写。
---->[情况1i++]----
int i = 3;
int a = i++; //执行赋值后i才自加,故a=3
print('a=$a,i=$i');//a=3,i=4
---->[情况2++i]----
int i = 3;
int a = ++i; //执行赋值前i已经自加,故a=4
print('a=$a,i=$i');//a=4,i=4
4. 循环流程 - while 循环
for 循环和 while 循环并没有什么本质上的区别,只是形式上的不同,两者可以进行相互转换。下面是类比 for 循环中的三块,将上面的代码转换为 while 循环。
可能有人会问,既然 while 循环和 for 循环可以相互转化,那为什么不干掉一个呢? 就像菜刀和美工刀虽然都可以完成切割的任务,但不同的场景下会有更适合的工具。 while 只需要关注循环条件,在某些场合下更简洁,语义也很不错; for 循环的固定格式,更便于阅读,可以一眼看出循环的相关信息。
另外还有 do - while 循环,算是 while 的变式。 do 代码块中是循环体while 后依然是条件。 do - while 循环的最大特点是: 先执行循环体,再校验条件。也就是说,它的循环体必然会被执行一次。
void main() {
int sum = 0;
int i = 0;
do{
sum += i;
print("第 $i 次执行,sum = $sum");
i = i + 1;
} while (i < 5);
}
根据不同的场景可以选择不同的形式 但千万别被形式整的晕头转向记住一点它们在本质上并没有区别都是控制条件执行循环体而且它们之间都可以进行转换
5. 中断控制 - break continue
在循环流程中除了循环条件可以终止循环还可以通过其他关键字来中断循环
break 关键字: 直接跳出循环让循环终止
continue 关键字跳出本次循环将进入下次循环
void main() {
// ---->[break情景]----
for (int i = 0; i < 10; i++) {
if (i % 3 == 2) {
break; //直接跳出循环
}
print("i:$i"); //打印了 01
}
// ---->[continue情景]----
for (int i = 0; i < 10; i++) {
if (i % 3 == 2) {
continue; //跳出本次循环将进入下次循环
}
print("i:$i"); //打印了 0134679
}
}
函数的定义和使用
函数是一段可以有输入和输出的逻辑单元通过函数可以将特定的算法进行封装简化使用这里我们通过一个实际的问题场景来介绍函数
身体质量指数是BMIBody Mass Index指数简称体质指数是国际上常用的衡量人体胖瘦程度以及是否健康的一个标准
对于一个人来说只要知道自己的身高和体重就可以通过公式 : BMI=体重÷身高^2 计算出体质指数如果每次计算时都要根据公式计算是非常麻烦的
void main() {
double toly = 1.8 / (70 * 70);
double ls = 1.79 / (65 * 65);
double wy = 1.69 / (50 * 50);
}
另外如果某天体质指数 的计算公式改变了那散落在代码中各处的直接计算都需要修改这无疑是非常麻烦的 函数的价值就在于封装算法下面来体会一下函数的价值
1. 函数的简单定义
函数可以有若干个输入值和一个输出值比如对于计算体质指数 来说有身高 height 和体重 weight 两个小数输出是 体质指数 的具体值如下所示定义 bmi 函数函数名前是返回值类型之后的括号中是参数列表通过 , 号隔开每个参数由 参数类型 参数名 构成{} 中是函数的具体算法逻辑
double bmi(double height, double wight) {
// 具体算法
double result = wight / (height * height);
return result;
}
需要获取一个人的 体质指数 只要调用 bmi 函数传入身高和体重即可使用者不需要在意公式具体是什么如果某天公式改变了只需要修改 bmi 中的算法实现其他代码都无需改动所以函数的价值不仅在于实现算法逻辑也能为某个功能提供一个公共的接入口以应对可能改变的风险
void main() {
double toly = bmi(1.8, 70);
double ls = bmi(1.79, 65);
double wy = bmi(1.69, 50);
print("===toly:$toly===ls:$ls===wy:$wy===");
}
2. 命名参数
有些时候函数的参数过多在调用时需要记清顺序是比较麻烦的Dart 中支持命名参数可以通过参数的名称来传参不需要在意入参的顺序通过 {} 包裹命名的参数其中 required 关键字表示该入参必须传入; 另外可以用 = 提供参数的默认值,使用者在调用时可以选填:
double bmi({
required double height,
double weight = 65,
}) {
// 具体算法
double result = weight / (height * height);
return result;
}
如下所示在使用时可以通过 weight height 指定参数参数的顺序是无所谓的另外由于 weight 有默认值在调用时可以不填计算时会取默认值
void main() {
double toly = bmi(weight: 70, height: 1.8);
double ls = bmi(height: 1.79);
double wy = bmi(height: 1.69, weight: 50);
}
3. 位置参数
方括号 [] 包围参数列表位置参数可以给默认值
double bmi([double height = 1.79, double weight = 65]) {
// 具体算法
double result = weight / (height * height);
return result;
}
在使用时必须要按照参数顺序传入它和普通参数列表的区别在于在调用时可以省略若干个参数省略的参数使用默认值
void main() {
double toly = bmi(70, 1.8);
double ls = bmi();
double wy = bmi(1.69);
}
函数是一个非常有用的语法功能在一个函数中可以调用其他函数来组织逻辑上面的 bmi 函数其实只是将一行代码的运算进行封装已经显现出了很大的价值更不用说能完成某些特定功能的复杂函数但封装也很容易造成只会使用不懂内部实现逻辑的局面这在即使好事(不用在意底层逻辑专注上层使用)也是坏事(底层逻辑有问题难以自己修复)。
这里给个小作业: 找一个计算公式通过函数来封装对它的调用
本章小结
到这里对于编程来说最最基础的三个模块: 基本数据类型运算符流程控制就有了基本的认识这些语法点是代码逻辑实现的基石对烹饪来说相当于水盐等最基础的材料对一顿饭来说不可或缺
基本的食材可以保证能吃饱但对于一席盛大的晚宴来说是远远不够的酱醋茶酒鸡鸭鱼肉是更高等的食物素材函数对象可以对代码逻辑进行封装从而更好地维护代码结构下一篇将继续介绍 Dart 基础语法了解面向对象的相关知识

View File

@ -0,0 +1,458 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
04 新手村基础 Dart 语法 (下)
一、面向对象
在学习的时候我们要明确一点,语言是一种工具,语法特性是为了方便我们实现需求的,不是故意制造出来刁难我们的。任何语法点的存在,都有它的价值和独特性。面向对象是 Dart 语法体系中非常重要的一支,也是一个威力无穷的工具。对于初学者而言,很难对其有深刻的认知,需要你在实践中累积与体悟。这一小结,将介绍一下面向对象最基础的语法点。
1. 自定义数据类型
上一篇说了Dart 中有一些基础的数据类型。但有些场景下,只用基础的数据类型,解决问题是非常麻烦的。比如,现在要记录人身高体重数据信息。
编程的本质是对数据的维护,而 身高、体重、姓名 三个数据是成对存在的,可以将其视为一个事物的三个属性,而在代码中如何表示有若干属性的某个事物呢? 答案就是 自定义数据类型,也就是人们常说的面向对象思想。
如下所示,通过 class 关键字来定义一个类型,{} 内是类的具体内容;其中可以定义若干个属性,也称之为 成员属性 。
class Human {
String name = '';
double weight = 0;
double height = 0;
}
下面 tag1 处表示定义了一个 Human 类型的 toly 对象,将其赋值为 Human() ; 其中 Human() 表示通过构造函数来创建对象,也可以称之为 实例化 Human 对象 。 通过 对象.属性 =可以对属性进行赋值; 通过 对象.属性 可以访问属性对应的数据。
void main(){
Human toly = Human(); // tag1
toly.name = "捷特";
toly.weight = 70;
toly.height = 180;
print("Human: name{${toly.name},weight:${toly.weight}kg,height:${toly.height}cm}");
}
这就是最简单的面向对象思想:通过一个自定义类型,定义若干个属性;通过实例化对象,来维护属性数据。
2. 构造函数
构造函数本身也是一个函数,它的价值在于:实例化对象时,可以对属性进行初始化。如下所示,构造函数的函数名和类名相同;参数列表就是函数的参数列表语法,不再赘述,这里是普通的参数列表传递参数;在实例化对象时,会触发函数体的逻辑,对属性进行赋值,这就是通过构造函数初始化成员属性。
注: 当入参名称和成员属性名称相同时,使用 this 关键字表示当前对象,对属性进行操作,从而避免歧义。
class Human {
String name = '';
double weight = 0;
double height = 0;
Human(String name,double weight,double height){
this.name = name;
this.weight = weight;
this.height = height;
}
}
这样就可以在实例化对象时,传入参数,为成员属性进行赋值:
void main(){
Human toly = Human("捷特",70,180);
print("Human: name{${toly.name},weight:${toly.weight}kg,height:${toly.height}cm}");
}
另外,构造函数中,通过 this 对象进行赋值的操作,可以进行简化书写,如下所示:
class Human {
// 略同...
Human(this.name,this.weight,this.height);
}
小作业: 自己可以练习一下,构造方法中 命名传参{} 和 位置传参 []
3. 成员函数(方法)
自定义类型中,不仅可以定义成员属性,也可以定义成员函数。一般来说,在面向对象的语言中,我们习惯于称类中的函数为 方法 。姓名通过一个小例子,来体会一下成员方法的价值:
如下所示,创建了三个 Human 对象,并且打印了他们的信息。可以看到 print 里面的信息格式基本一致,只是对象不同而已。每次都写一坨,非常繁琐。
void main(){
Human toly = Human("捷特",70,180);
print("Human: name{${toly.name},weight:${toly.weight}kg,height:${toly.height}cm}");
Human ls = Human("龙少",65,179);
print("Human: name{${ls.name},weight:${ls.weight}kg,height:${ls.height}cm}");
Human wy = Human("巫缨",65,179);
print("Human: name{${wy.name},weight:${wy.weight}kg,height:${wy.height}cm}");
}
我们可以定义一个成员方法,来处理介绍信息的获取工作:在成员方法中可以访问成员属性,这就相当于通过函数给出一个公共的访问入口,任何该类对象都可以通过 .info() 获取信息。如下所示,在使用时就会非常简洁和方便:
小作业: 为 Human 类型添加一个 bmi 方法,用于计算 体质指数 。
4. 类的继承
比如要记录的信息针对于学生,需要了解学生的学校信息,同时也可以基于身高体重计算 bmi 值。在已经有 Human 类型的基础上,可以使用关键字 extends,通过继承来派生类型。
在 Student 类中可以定义额外的成员属性 school 另外 super.name 语法是:在入参中为父类中的成员赋值。
class Student extends Human {
final String school;
Student(
super.name,
super.weight,
super.height, {
required this.school,
});
}
这样就可以通过 Student 来创建对象通过继承可以访问父类的方法如下所示Student 对象也可以使用 bmi 方法获取 体质指数
void main() {
Student toly = Student("捷特", 70, 180,school: "安徽建筑大学");
print(toly.bmi());
}
5. 子类覆写父类方法
当子类中存在和父类同名的方法时,就称 子类覆写了父类的方法 ,在对象调用方法时,会优先使用子类方法,子类没有该方法时,才会触发父类方法。比如下面的代码,子类中也定义了 info 方法,在程序运行时如下:
注: 通过 super. 可调用父类方法; 一般子类覆写方法时,加 @override 注解进行示意 (非强制)
class Student extends Human {
// 略同...
@override
String info() {
String info = super.info() + "school: $school ";
return info;
}
}
void main() {
Student toly = Student("捷特", 70, 180,school: "安徽建筑大学");
print(toly.bmi());
print(toly.info());
}
对于初学者而言,面向对象的知识了解到这里就差不多了。这里介绍的是基础中的基础,随着知识的累计,未来肯定会接触到更多其他的知识。
二、聚合类型
日常生活中,还有一类数据总是批量呈现的:比如一个班级里有很多学生,一个英文字典有很多对应关系,围棋盘中有很多点位。如何对结构相似的批量数据进行维护,也是编程中非常重要的事。可以称这样的数据为 聚合类型 或 容器类型 。在 Dart 中,有三个最常用的聚合类型,分别是 列表 List、 映射 Map 和 集合 Set :
对于聚合类型而言,本质上是 Dart 语言提供的内置自定义类型,也就是说他们也是通过 class 定义的,其中有成员属性,也有成员方法。我们在一开始并不能对所有的成员方法进行面面俱到的讲解,只会对元素的添加、修改、访问、删除进行介绍,了解最基础的使用。
1. 列表 List
列表类型中可以盛放若干个同类型的对象,并且允许重复。在声明列表对象时,其中盛放的对象类型放在 <> 中,我们称之为 泛型 。如下定义 int 泛型的列表,就表示列表中只能盛放整数数据;可以通过 [] 便捷的创建列表对象,其中盛放初始的数据:
List<int> numList = [1,9,9,4,3,2,8];
我们一般称元素在列表中的位置为 索引 , 索引从 0 开始计数。通过索引可以对索引处的值进行获取和修改的操作,代码如下 :
List<int> numList = [1,9,9,4,3,2,8];
int second = numList[1];
print(second);
numList[3] = 6;
print(numList);
---->[控制台输出]----
9
[1, 9, 9, 6, 3, 2, 8]
通过 add 方法可以在列表的末尾添加一个元素insert 方法,可以在指定的索引处插入一个元素:
List<int> numList = [1,9,9,4,3,2,8];
numList.add(10);
numList.insert(0,49);
print(numList);
---->[控制台输出]----
[49, 1, 9, 9, 4, 3, 2, 8, 10]
列表方法中 remove 相关方法用于移除元素,比如 removeAt 移除指定索引处的元素remove 移除某个元素值; removeLast 移除最后元素:
List<int> numList = [1,9,9,4,3,2,8];
numList.removeAt(2);
numList.remove(3);
numList.removeLast();
print(numList);
---->[控制台输出]----
[1, 9, 4, 2]
对于聚合型的对象来说,还有一个比较重要的操作,就是如何遍历访问其中的元素。通过 .length 可以得到列表的长度,所以自然可以想到使用 for 循环,让索引自加,就能依次输出对应索引的值:
List<int> numList = [1, 9, 9, 4];
for (int i = 0; i < numList.length; i++) {
int value = numList[i];
print("索引:$i, 元素值:$value");
}
---->[控制台输出]----
索引:0, 元素值:1
索引:1, 元素值:9
索引:2, 元素值:9
索引:3, 元素值:4
如果遍历过程中,不需要索引信息,也可以通过 for-in 循环的语法,方便地遍历列表中的值:
for(int value in numList){
print("元素值:$value");
}
---->[控制台输出]----
元素值:1
元素值:9
元素值:9
元素值:4
2. 集合 Set
集合类型也可以盛放若干个同类型的对象,它最大的区别是 不允许重复 ,它同样也支持一个泛型。如下定义 int 泛型的集合,就表示列表中只能盛放整数数据;可以通过 {} 便捷的创建集合对象,其中盛放初始的数据。
如下所示,当创建的集合在存在重复元素,将被自动合并,在输出时只有一个 9 元素:
Set<int> numSet = {1, 9, 9, 4};
print(numSet);
---->[控制台输出]----
{1, 9, 4}
集合本身是没有索引概念的,所以无法通过索引来访问和修改元素,因为集合本身在数学上的概念就是无序的。它可以通过 add 方法在集合中添加元素;以及 remove 方法移除某个元素值:
Set<int> numSet = {1, 9, 4};
numSet.add(10);
print(numSet);
---->[控制台输出]----
{1, 4, 10}
集合最重要的特征是可以进行集合间的运算,这点 List 列表是无法做到的。两个集合间通过 difference、union 、intersection 方法可以分别计算差集、并集、交集。计算的结果也是一个集合:
Set<int> a = {1, 9, 4};
Set<int> b = {1, 9, 3};
print(a.difference(b));// 差集
print(a.union(b)); // 并集
print(a.intersection(b)); // 交集
---->[控制台输出]----
{4}
{1, 9, 4, 3}
{1, 9}
由于集合没有索引概念,使用无法像 List 那样通过 for 循环增加索引来访问元素;但可以通过 for-in 循环来遍历元素值:
Set<int> numSet = {1, 9, 4};
for(int value in numSet){
print("元素值:$value");
}
---->[控制台输出]----
元素值:1
元素值:9
元素值:4
3. 映射 Map
地图上的一个点,和现实中的移除位置一一对应,这种就是映射关系。地图上的点可以称为 键 key ,实际位置称为 值 value ; Map 就是维护若干个键值对的数据类型。 日常生活中有很多映射关系,比如字典中的字和对应释义、书目录中的标题和对应的页数、钥匙和对应的锁等。
应用映射中的一个元素记录着两个对象,所以 Map 类型有两个泛型,分别表示 key 的类型和 value 的类型。如下所示,定义一个 Map<int, String> 的映射对象其中维护数字和英文单词remove 方法可以根据 key 移除元素:
Map<int, String> numMap = {
0: 'zero',
1: 'one',
2: 'two',
};
print(numMap);
numMap.remove(1);
print(numMap);
---->[控制台输出]----
{0: zero, 1: one, 2: two}
{0: zero, 2: two}
通过 [key] = value 语法可以向映射中添加元素,如果 key 已经存在,这个行为就是修改对应的值:
Map<int, String> numMap = {
0: 'zero',
1: 'one',
2: 'two',
};
numMap[3] = 'three';
numMap[4] = 'four';
print(numMap);
---->[控制台输出]----
{0: zero, 1: one, 2: two, 3: three, 4: four}
对于映射来说,可以通过 forEach 方法来遍历元素值:
Map<int, String> numMap = {
0: 'zero',
1: 'one',
2: 'two',
};
numMap.forEach((key, value) {
print("${key} = $value");
});
---->[控制台输出]----
0 = zero
1 = one
2 = two
三、 语言特性
Dart 中有一些特殊的语言特性,比如空安全、异步等知识。这里简单介绍一下,能满足本教程的使用即可。
1. 空安全
Dart 是一个空安全的语言,也就是说,你无法将一个非空类型对象值设为 null :
如果希望对象可以赋值为 null ,需要在类型后加上 ? 表示可空:
这样,如果一个函数中是 String 入参,那么函数体内的 word 对象就必定不为 null ,这样就可以在编码时明确对象的可空性,做到被访问对象的空安全。
如果希望在调用时可以传入 null ,入参类型就是 String? ,那么在函数体内访问可空对象时,也在编码阶段给出警告示意。如果没有空安全的支持,编码期间就很难确定 String 对象是否可空,从而 null 调用方法的异常只能在运行时暴露;有了空安全机制,在编码期间就可以杜绝一些空对象调用方法导致的异常。
2. 异步任务
关于异步是一个很大的话题,这里只简单介绍一下用法。想要更深入了解,可以研读我在掘金发表过一个专栏 《Flutter 知识进阶 - 异步编程》
异步任务可以在未完成之前,让程序继续执行其他的逻辑,在完成之后接收到通知。拿最常见的文件读取异步任务来说:如下 test 函数中,使用 readAsString 异步方法读取一个文件,通过 then 监听对调,回调中的参数就是读取的字符串。
此时下面的 做些其他的事 将会在读取完毕之前触发。也就是说:一个任务没有完成,第二个任务可以进行,这就是异步。就像烧水和扫地两个任务可以同时进行。
String path = r'E:\Projects\Flutter\flutter_first_station\pubspec.yaml';
File file = File(path);
print("开始读取");
file.readAsString().then((value) {
print("===读取完毕: 文字内容长度 = ${value.length}====");
});
print("做些其他的事");
有些时候,需要等待异步任务完成,才能继续之后的任务。比如,只要水烧开才能去倒水,可以通过 await 关键字等待异步任务的完成,获取结果:
Future<void> test2() async{
String path = r'E:\Projects\Flutter\flutter_first_station\pubspec.yaml';
File file = File(path);
print("开始读取");
String content = await file.readAsString();
print("===读取完毕: 文字内容长度 = ${content.length}====");
print("做些其他的事");
}
有一点需要注意在控制台输出时main 函数结束后就会停止,而文件读取的结果要稍后才能完成,导致无法打印出读取结果。由于应用程序交互时一直在启动中,这个问题不会出现,也不用太在意。不过,大家可以在这里埋个小问题,在以后的生涯中尝试解决:
想个方法让 main 函数等一下,可以完成如下的输出效果:
四、本章小结
最基础的 Dart 语法就介绍到这里对本教程的学习来说这些基本上够用了。如果后面遇到其他的语法会单独介绍一下。再次强调这些知识只够入个门而言Dart 还有非常丰富的语言特性。后期如果对 Flutter 感兴趣,请务必系统地学习一下 Dart 语言。
目前,本教程的晚宴食材已经准备就绪。下一章将分析一下 Flutter 计数器项目,了解官方提供的这道初始点心的烹饪手法,以及其中蕴含的知识点。

View File

@ -0,0 +1,319 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
05 Flutter 计数器项目解读
通过前面两篇基础语法的学习,已经掌握了 Dart 语言最基础的逻辑控制能力。接下来我们来看一下新建时的默认计数器项目,对项目几个问题进行有一个简单的认知:
界面上的文字是由代码中的何处决定的?
点击按钮时,数字自加和更新界面是如何完成的?
界面中的显示部件是如何布局的?
我们如何修改界面中展示的信息,比如颜色、字体等?
界面显示部件
修改展示信息
一、代码定位工具
不管是自己写的代码,还是别人写的,随着代码量的增长,代码的可读性将面临考验。特别是新手朋友,喜欢把所有代码的实现逻辑塞在一块。如何在复杂的代码中找到关键的位置;或如何提纲挈领,将复杂的文字展示出结构性,对编程来说是非常重要的。在看代码之前,有必要先介绍几个快速定位代码的小技巧。
1. 全局搜索
在顶部栏 Edit/Find/Find in Files 打开搜索面板,面板中也可以看到对应的快捷键。如果快捷键没响应,很大可能是和输入法的快捷键冲突了。比如搜狗输入法,禁用其快捷键即可。
从视觉上可以看出,界面中有一些固定的文字,这些文字很可能在代码之中。所以全局的搜索是一个很有用的技巧,比如搜索 You have pushed 时,可以找到代码中与之对应的地方:
注:
在点击之后,就可以跳转到对应的位置,此时光标会在那里闪烁,如下所示:
当鼠标 悬浮 在上方的文件名上时, 会弹出文件所在的磁盘地址,这样能便于我们找到文件所在:
这样就能分析出,界面上展示的信息是由 lib/main.dart 决定的。这就是一个非常基本的 逻辑推理 过程,而整个过程和并不需要用到什么编程知识。相比于推理结果,这种推理的意识更加重要,很多时候初学者都会处于: 我不知道自己该知道什么,而推理的意识就是在让自己:我要知道自己想知道什么 。
2. 类结构信息
当分析一份代码文件时,在 AndroidStudio 中可以打开 Structure 页签,其中会展示出当前文件中的所有类的结构信息,比如成员属性、成员方法等。这样,你可以快速了解这份代码有哪些东西,在点击信息时,也能立刻跳转到对应的代码处:
其中 C 图标表示类m 图标表示方法, f 图标表示成员属性。当前文件夹中定义了三个类型和一个 main 方法。每个类型中会定义若干方法和属性,其中可以清晰地看出函数的名称、入参和返回值。
3. 布局分析
在 Flutter Inspector 页签中,可以看出当前界面的布局结构。点击某项时,会跳转到代码对应的位置,这就是不过展示布局结构,辅助我们快速定位到对应代码位置:
如果布局结构过于复杂,在树中寻找节点也非常麻烦。如果现在已经有了一个项目,运行起来,如何迅速找到界面中的部件,对应代码中的位置呢? Flutter Inspector 中提供了选择模式,点击下面的图标开启:
选择模式下,当点击界面上的显示部件,就会自动挑战到对应的代码位置。对于定位代码来说,可谓神器。另外注意一点,点击后左下角会有个搜索按钮,如果想选择其他部件,要先点一下那个搜索按钮:
选择模式
选择模式
二、计数器代码分析
去除注释之后,计数器项目也就 68 行 代码,算是一个非常简单的小项目,但它完成了一个基本的功能交互。可谓麻雀虽小五脏俱全。一开始是 main 方法,表示程序的入口,其中先创建 MyApp 类型对象,并将该对象作为参数传入 runApp 函数中。
void main() {
runApp(const MyApp());
}
1. 初见 Widget 类型
MyApp 继承自 StatelessWidget 类,并覆写了其 build 方法,返回 Widget 类型对象;在方法的实现中,创建了 MaterialApp 对象并返回,其中 theme 入参表示主题, 通过 Colors.blue 可以看到蓝色主题的来源。
这时,你可以将 blue 改为 red 然后按 Ctrl+S 进行保存,可以看到界面中的主题变成了红色。这种在开发过程中,不重新启动就可以更新界面的能力,称之为 热重载 hot reload 。不用每次都重新编译、启动,这可以大大提升开发的时间效率。
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
theme: ThemeData(
primarySwatch: Colors.blue,
),
home: const MyHomePage(title: 'Flutter Demo Home Page'),
);
}
}
决定界面展示的配置信息的类型我们称之为 组件 Widget runApp 方法的入参是 Widget 类型,而 MyApp 可以占位参数,就说明它是 Widget 的派生类MyApp#build 方法返回的是 Widget 类型,方法实现中实际返回的是 MaterialApp 对象,就说明 MaterialApp 也是 Widget 的派生类。这也是一个简单的逻辑推理过程。
另外 MaterialApp 构造方法的 home 入参,也是需要传入一个 Widget 类型,所以下面的 MyHomePage 也是 Widget 的派生类。从这里可以看出, Flutter 框架中界面的展示和 Widget 一族息息相关。
2. MyHomePage 代码分析
从代码中可以看出 MyHomePage 继承自 StatefulWidget , 其中有一个 String 类型的成员属性 title并在构造时进行赋值。另外还覆写了 createState 方法,创建 _MyHomePageState 对象并返回。
class MyHomePage extends StatefulWidget {
const MyHomePage({super.key, required this.title});
final String title;
@override
State<MyHomePage> createState() => _MyHomePageState();
}
对于初学者而言,并不需要太关注覆写的方法是何时触发的,应该在意的是它能提供什么功能。就像现实生活中,你学习画画,如果纠结为什么蓝色颜料和黄色颜料混合起来会是绿色,而去研究大量的光学资料、人视觉的成像原理,是本末倒置的。 探索世界(框架)的原理固然重要,但绝不是新手阶段需要做的事,除非你是天赋异禀,或你的目的不是画画,而是科研。
3. _MyHomePageState 代码分析
上面的 MyApp 和 MyHomePage 两者都是 Widget 的派生类,其中的代码逻辑并不是太复杂,主要是覆写父类方法,完成特定的任务。代码中还剩下一个 _MyHomePageState 类。 从结构上来看,其中有一个整型的 _counter 成员属性;两个成员方法:
从类定义可以看出, _MyHomePageState 继承自 State 类:
class _MyHomePageState extends State<MyHomePage> {
其中 _incrementCounter 方法中,会让 _counter 变量自加;很明显 _counter 变量就是计数器中展示的数值。而点击按钮时将会触发 _incrementCounter方法完成数值自加
int _counter = 0;
void _incrementCounter() {
setState(() {
_counter++;
});
}
最后,就是 _MyHomePageState 中覆写的 build 方法,可以看出界面中的信息是和代码中的内容是对应的。所以,很容易理解代码如何决定界面显示内容。
动手小实验: 大家可将 _incrementCounter 中的 setState 去掉 (如下),运行后点击按钮查看效果。将其作为一个对比实验,思考一下 setState 的作用。
void _incrementCounter() {
_counter++;
}
三、修改界面展示的信息
到这里,我们已经 感性地 认识了代码如何决定界面的显示。接下来,通过修改界面上的内容,更切身地体验一下,通过代码控制界面展示的感觉。
一、文字的修改
即使没有任何编程基础,也知道代码中的哪些字对应着屏幕中的哪些字,所以修改文字的展示是最简单的。比如现在将屏幕中间的英文改成中文,只需要把字符串换一下即可:
这样,在 Ctrl+S 保存之后,界面就会立刻更新:
小练习: 试着把顶部的英文标题改成 计数器 三个字。
经常玩手机的都知道,界面上文字有着非常多样式可以配置,最常见的就是文字和字号。在计数器的案例中,下面的数字要大很多,从代码中可以看出,区别在于指定了 style 入参。现在我们来尝试修改一下上方文字的大小和颜色:
实现方式就是在 Text 对象构造函数中传入 style 参数,参数类型是 TextStyle除了文字和颜色之外它还有其他的配置信息以后可以慢慢了解。
const Text(
'你点击按钮的次数:',
style: TextStyle(color: Colors.purple, fontSize: 16),
),
到这里,我们知道了可以通过 Text 对象在屏幕上展示文字信息。
2.查看界面布局的技巧
对于布局来说,我们要清楚各个区域占据在屏幕中的哪些位置,就像古代皇帝分封土地,那片区域归谁管,是非常明确的。但初学者在不明白布局组件特性的情况下,很难知晓界面中的 “势力范围”。这时可以通过 Flutter Performance 页签中的按钮来开启 布局网格辅助 ,来快速了解界面情况:
如下所示,在有辅助线的界面中,有哪些 “势力范围” 一清二楚。另外,也可以通过上面介绍的 选择模式 来快速定位哪块区域是谁的 “地盘” 。
无辅助
有辅助
如下,通过选择模式,可以很轻松地知道,中间的区域是 Column 的地盘。代码中的表现是 Column 在构造时将两个文字作为孩子; Column 单词的含义是 列 ,它的作用是将若干个子组件竖直排列。
其中 Column 在构造时传入的 mainAxisAlignment 入参可以控制子组件在它地盘内的对其方式,比如下面是修改该属性时的表现效果。这里简单了解一下即可,感兴趣的也可以自己尝试一下,就像神农尝百草,了解效力。
MainAxisAlignment.start
MainAxisAlignment.spaceBetween
之前说过,在选择模式下,点击树中条目,对选中对于的位置。比如下面点击 Center就可以看到它的地盘它的作用是让它地盘中的子组件居中对其这也是 Column 能在中间的根本原因:
小练习: 试着在代码中去除掉 Center 组件,查看表现效果。
有了 布局网格辅助 和 选择模式 两大利器,对于新手认知布局结构是非常友好的。新手应该多多使用它们,逐渐形成布局划分,领域约束的认知,对之后的工作会大有裨益。初始项目代码解读到这里就差不多了,其实这里新手需要关注的只有 mian.dart 中的代码。关于项目中的其他东西,暂时不用理会,专注于一点,更有利于新手的学习,细枝末节的东西,以后可以慢慢了解。最后,留个小练习:
小练习: 修改代码,使得每次在点击按钮时,数字 + 10
四、本章小结
本章主要结合初始计数器项目的代码,分析一下代码与界面间的关系。同时认识一下 Flutter 最基础的组件概念,通过更改界面的呈现,感性地了解代码中的文字是如何决定界面展示的。
另外,也介绍了 AndroidStudio 中的 Flutter Inspector 和 Flutter Performance 两个界面布局分析的工具;以及 Structure 页签查看当前文件类结构信息。合理地使用工具,可以让你更快地理解和掌握知识。接下来,我们将正式新手村进入第一个小案例 —- 猜数字项目。

View File

@ -0,0 +1,169 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
06 猜数字界面交互与需求分析
1. 界面交互介绍
猜数字是本教程的第一个案例,功能比较简单,非常适合新手朋友入门学习。下面是两个最基础的交互:
点击按钮生成 0~99 的随机数,并将随机数密文隐藏。
头部的输入框,点击时弹出软键盘,可输入猜测的数字。
点击生成随机数
可输入文字
如下所示,点击右上角的运行按钮,可以比较输入猜测值和生成值的大小,并在界面上通过两个色块进行提示。每次比较时,提示面板中的文字会有动画的变化,给出交互示意。
比较结果:小了
比较结果:大了
这三个交互就是本案例的所有功能需求。你可以找几个朋友一起玩这个猜数字的小游戏,比如随机生成一个数后,每人输入一个数,最后猜中的人获取胜利。其中控制猜测的范围,使其更利于自己猜出结果,也是一点斗智斗勇。
2. 猜数字需求分析
现在从数据和界面的角度,来分析一下猜数字中的需求:
随机数的生成
随机数生成的需求中,有两个需要变化的数据,其一是待猜测的数字 _value 的赋值;其二是游戏的状态 _guessing 置为 true。
int _value = 0;
bool _guessing = false;
对于界面来说,当生成随机数后,要禁用按钮。也就是说按钮的表现形式,会受到 _guessing 数据的限制。
同样,中间数字的显示也会受到 _guessing 的影响,猜测过程中为密文;猜对之后游戏结束,展示明文数字。
游戏进行中
游戏结束
输入框的输入功能
输入框输入需求中,需要一个数据来记录输入的内容。一般使用 TextEditingController 类型的数据和输入框进行双向绑定:也就是说用户的输入会导致控制器数值的变化,控制器数值的修改也会导致输入框内容的变化。
TextEditingController _guessCtrl = TextEditingController();
猜测需求分析
猜测需求中,需要一个数据表示猜测结果;猜测结果有三种:大了、小了和相等,这里使用一个可空的 bool 类型对象 _isBig 表示三种状态:
bool? _isBig;
null: 相等
true: 大了
false: 小了
对于界面来说,需要根据 _isBig 的值,给出提示信息。其中 大了和小了的展示面板叠放在主题界面之上,占据一般的高度空间:
大了
小了
相等
需求中的功能和数据这里简单地分析了一下,最后来说一下本案例中蕴含的知识点。
3. 猜数字中的知识点
首先,猜数字项目会接触到如下的常用组件,大家再完成猜数字项目的同时,也会了解这些组件的使用方式。
另外,会对 Flutter 中的界面相关的知识有初步的认知:
组件与界面的关系
界面构建逻辑的封装
状态数据与界面更新
组件有无状态的差异性
最后,在逻辑处理的过程中,是对 Dart 语法使用练习的好机会,在完成需求的过程中,会收获一些技能点。
回调函数的使用
动画控制器的使用
随机数的使用
界面交互和需求分析就到这里,下面一起开始第一个小项目的学习吧!

View File

@ -0,0 +1,346 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
07 使用组件构建静态界面
1. 代码的分文件管理
在计数器项目中,我们知道界面中展示的内容和组件息息相关,其中其决定性作用的是 MyHomePage 组件。但所有的代码都塞在了 main.dart 文件中,随着需求的增加,把所有代码都放一块,显然是不明智的。所以首先来看一下如何分文件来管理代码。
如下所示,先创建一个 counter 文件夹,用于盛放计数器界面的相关代码文件;然后创建 counter_page.dart 文件,并把 MyHomePage 的相关代码放入其中:
这时,可以将 main.dart 中 MyHomePage 的相关代码删除;会发现红色的波浪线,表示找不到 MyHomePage 类型:
此时只需要通过 import 关键字,在 main.dart 上方导入文件即可:
---->[main.dart]----
import 'counter/counter_page.dart';
// 略同...
分文件管理代码就像整理书籍,分门别类地进行摆放,各个区域各司其职,自己容易检阅,别人也容易看懂。下面创建一个 guess 文件夹,用于盛放本模块 猜数字 小项目的相关代码:
2. 创建你自己的组件
首先要明确一点,文件夹的名称、文件的名称、类型的名称、属性的名称、函数的名称,都是可以任意的,甚至可以使用汉字(但不建议),只要在使用时对应访问即可。但一个好名字对于阅读来非常重要,取 a1,c,b,d45,rrr 这样的名字,对阅读者而言是灾难,也许过两天,连你自己也认不得。所以一个好名字是个非常重要,不要偷懒, 最好有明确的含义。
比如对于猜数字这个需求来说,整体的界面可以叫 GuessPage , 这里我们可以先借用一下计数器中 MyHomePage 代码,照葫芦画瓢,改巴改巴。先把 MyHomePage 代码复制到 guess_page.dart 中。
重命名小技巧: 当你想对一个类型、函数、属性名进行重命名,并且想让在它们使用使用处自动修改。可以将鼠标点在名称上,右键 -> Refactor -> Rename ; 也可以使用后面的快捷键直接操作。
输入新名称后,点击 Refactor , 所有使用处都会同步更新:
然后在 main.dart 中,将 GuessPage 作为 home 参数,即可展示 GuessPage 组件中的界面效果:
---->[main.dart]----
import 'guess/guess_page.dart';
// 略同...
home: const GuessPage(title: '猜数字'),
接下来的任务是如何修改代码,来完成猜数字的功能需求。首先我们来完成一件简单的事:
点击按钮生成随机 0~99 之间的数字
初始效果
点击生成随机数
3.随机数的生成与界面更新
计数器项目中,点击按钮之所以界面数字自加,是因为 _incrementCounter 方法中,触发了 _counter++ 。所以想要在点击时显示随机数,思路很简单:将 _counter 变量赋值为随机数即可。
void _incrementCounter() {
setState(() {
_counter++;
});
}
所以首先需要了解一下 Dart 中如何生成随机数:在 dart 的 math 包中,通过 Random 类型可以生成随机数。这里生成的是随机整数,使用 nextInt 方法,它有一个入参,表示生成随机数的最大值(不包含)。比如传入 100 时,将返回 0~99 之间的随机整数:
import 'dart:math';
Random _random = Random();
_random.nextInt(100);
这样,点击按钮时只要将 _counter 赋值为随机数即可,如下所示:
void _incrementCounter() {
setState(() {
_counter = _random.nextInt(100);
});
}
但之前说过,名字非常重要。但这里 _counter 含义是计数器_incrementCounter 含义是自增计数器,在当前需求的语境中并不是非常适合。起名字最好和其功能相关,比如可以将数值变量可以称之 _value ; 方法可以称之 _generateRandomValue这样代码阅读起来就会更容易理解。
同样,也可以使用 Refactor 重命名:
class _GuessPageState extends State<GuessPage> {
int _value = 0;
Random _random = Random();
void _generateRandomValue() {
setState(() {
_value = _random.nextInt(100);
});
}
4. 头部栏 AppBar 和输入框 TextFiled 的使用
接下来,我们要将头部的标题栏 AppBar 改成如下的样式,中间是可以输入的输入框,右侧是一个运行的图标按钮。
AppBar 常用于左中右布局结构,如下所示:
AppBar(
leading: 左侧,
actions: [右侧列表],
title: 中间部分,
)
也就是说在不同的参数中,可以插入不同的组件进行显示。比如这里 leading 指定为 Icon 组件,展示图标:
leading: Icon(Icons.menu, color: Colors.black,),
actions 入参是一个组件列表,这里放入一个 IconButton 组件,展示图标按钮:
actions: [
IconButton(
splashRadius: 20,
onPressed: (){},
icon: Icon(Icons.run_circle_outlined, color: Colors.blue,)
)
],
title 入参是中间部分,使用 TextField 组件展示输入框。这里组件构造时的入参对象都是用于配置展示信息的,可以简单认识一下,不用急着背诵,以后慢慢接触,早晚都会非常熟悉。
TextField(
keyboardType: TextInputType.number, //键盘类型: 数字
decoration: InputDecoration( //装饰
filled: true, //填充
fillColor: Color(0xffF3F6F9), //填充颜色
constraints: BoxConstraints(maxHeight: 35), //约束信息
border: UnderlineInputBorder( //边线信息
borderSide: BorderSide.none,
borderRadius: BorderRadius.all(Radius.circular(6)),
),
hintText: "输入 0~99 数字", //提示字
hintStyle: TextStyle(fontSize: 14) //提示字样式
),
),
最后说一下如何去掉顶部的灰块AppBar 组件在构造时,可以通过 systemOverlayStyle 入参控制状态类和导航栏的信息。如下代码可以使顶部状态栏变成透明色,文字图标是暗色:
AppBar(
systemOverlayStyle: SystemUiOverlayStyle(
statusBarIconBrightness: Brightness.dark,
statusBarColor: Colors.transparent
),
5. 叠放 Stack 组件和列 Column 组件的使用
当用户输入的数字大了,或小了。需要界面上给予提示。为了更加醒目,这里给出的设计如下所示。如果大了,上半屏亮红色,展示 大了;如果小了,下半屏亮蓝色,展示 小了:
小了提示
大了提示
可以看出此时中间的文字是浮在提示色块之上的,想实现这种多个组件层层堆叠的效果,可以使用 Stack 组件来完成。其构造函数中 children 入参传入组件列表,在显示层次上后来居上。
如下所示,将 Stack 作为 body , 在其中放入一个红色的 Container 容器组件, 以及之前的主内容。运行后会看到:中间文字就会浮在红色容器之上。
body: Stack(
children: [
Container(color: Colors.redAccent),
//主内容略...
],
),
除了堆叠的效果,还有一个问题,如何实现 上下平分区域 呢? 我们前面知道 Column 组件可以让两个组件竖直排列。在 Column 中可以通过 Expanded 组件延展剩余区域,另外 Spacer() 组件相当于空白的 Expanded。当存在多个 Expanded 组件时,就可以按比例分配剩余空间,默认是 1:1 ,也可以通过 flex 入参调节占比。举个小例子,如下所示:
Expanded 红+Expanded 蓝
Expanded 红+ Spacer
---->[红+蓝]----
Column(
children: [
Expanded( child: Container(color: Colors.redAccent)),
Expanded( child: Container(color: Colors.blueAccent)),
],
),
---->[红+空]----
Column(
children: [
Expanded( child: Container(color: Colors.redAccent)),
Spacer()
],
),
到这里,猜数字项目中需要使用的基础组件就已经会师完毕。大家可以基于现在已经掌握的知识,完成如下效果:
注: Container 组件的 child 入参可以设置内容组件。 本例参考代码见: guess_page.dart
6.组件的简单封装
上面虽然完成了布局效果,但是从 guess_page.dart 中可以感觉到,随着需求的增加,各种组件全都塞到了一块。这才只是一个简单的布局,就已经有点不堪入目了;如果再加上交互的逻辑,恐怕要乱成一锅粥了。
其实对于新手而言,编程语言语法本身并不是什么难事,对规整代码的把握才是最大的挑战;很容易要什么,写什么,最后什么东西都塞在一块,连自己都看不下去了,从而心灰意冷,劝退放弃。小学时,老师就教导我们,遇到巨大的问题,要尝试将它分解成若干个小问题,逐一解决。
其实有些大问题在肢解过程中,会有某些类似的小问题,这些小问题可以通过某种相同的解决方案来处理。这种通用解决方案,就是一种封装的思想,问题的专属解决方案一旦封装完毕,输入问题,就可以解决问题,使用者不必在意处理的过程,可以大大提升解决问题的效率。回忆一下,在介绍函数时,通过 bmi 函数,封装体质指数的计算公式,就是通过函数来封装解决方案。
对于组件来说,也是一样:某些相似的结构,也可以通过 封装 进行复用。比如这里 大了 和 小了 只是颜色和文字不同,两者的结构类似。就可以通过封装来简化代码:
最简单的封装形式是通过函数封装,通过入参来提供界面中差异性的信息,如下所示 _buildResultNotice 函数接收颜色和消息,返回 Widget 组件:
Widget _buildResultNotice(Color color, String info) {
return Expanded(
child: Container(
alignment: Alignment.center,
color: color,
child: Text(
info,
style: TextStyle(
fontSize: 54, color: Colors.white, fontWeight: FontWeight.bold),
),
));
}
如果把相似的结果写两遍,只会徒增无意义的代码。而封装之后,只需要调用方法,就可以完成任务:
Column(
children: [
_buildResultNotice(Colors.redAccent,'大了'),
_buildResultNotice(Colors.blueAccent,'小了'),
],
),
如果封装体的代码非常复杂,或者需要单独维护,以便之后修改方便定位,也可以通过新组件的形式来封装组件。如下所示,新创建 result_notice.dart 文件,在其中定义 ResultNotice 组件,专门处理结果提示信息的展示任务。
一方面,专人专用,需要更改时直接在这里更改。比如你让另一个人帮忙改改某处的代码,而他对项目不熟悉,如果代码分离的得当,你告诉他这个界面由 xxx.dart 文件负责,他就可以在不了解项目的前提下,对界面进行修改。这就是 职责分离 的益处。不同人干自己擅长的事,有利于整体结构的稳定。
另一方面也能缓解 guess_page.dart 中的代码压力,不至于随着需求的增加代码量激增,对可读性友好。在使用时,可以将 ResultNotice 视为普通的组件,放在 Column 之中:
Column(
children: [
ResultNotice(color:Colors.redAccent,info:'大了'),
ResultNotice(color:Colors.blueAccent,info:'小了'),
],
),
同样,这里 AppBar 组件的构建逻辑也是太复杂的,可以在 guess_app_bar.dart 中单独维护。这里主要是出于简化 guess_page.dart 中代码的考量,不强求可复用性。
这样 guess_page.dart 中的代码就整洁了很多,其他两个文件也在各司其职。相对与之前全塞在一块,更便于阅读,封装之后的代码见 guess_page.dart 。
7.本章小结
本章主要学习了如何通过 Flutter 框架提供的组件,来搭建期望的界面呈现效果。期间介绍了如何分文件来管理代码、以及通过自定义组件来封装构建逻辑。最后简单分析了一下组件封装的优势。
学完本章,你应该能够自己动手搭建一些简单的静态界面了。但应用程序是要和用户进行交互的,就需要界面随着用户的交互进行变化。下一篇将从用户交互的角度,通过代码来实现猜数字的具体功能。

View File

@ -0,0 +1,179 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
08 状态数据与界面更新
编程中的一切都是操作数据,界面上的表现都需要具体数据来支撑,决定界面展现的数据称之为 状态数据。然而:
有些数据中界面中是恒定不变的,比如一些固定的文字、图标、颜色等;
也有些数据会随着用户的交互发生改变,比如计数器项目中,数字会随着用户的点击而增加。
如何通过代码逻辑,维护状态数据在交互过程中的正确性,就是对状态数据的管理。在一个需求中,哪些 状态数据 是可变的,需要具体问题具体分析。
1. 从按钮禁用开始说起
在猜数字的需求之中,点击按钮生成随机数。但在猜测的过程中,我们期望禁止点击来重新生成,否则又要重新猜测。也就是说,在一次猜数字过程中,只能生成一个随机数;同时,猜对时,需要解除禁止,进入下一次游戏。
对于生成随机数的需求,需要一个量来标识是否是在游戏过程中。这就是根据具体需求,来分析必要的状态数据。比如这里通过 bool 类型的 _guessing 对象标识是否在游戏过程中。界面和交互的逻辑表现在:当 _guessing 为 false 时,支持点击,按钮呈蓝色;为 true 时,禁止点击,按钮呈灰色:
标题
如下所示,在 _GuessPageState 中定义 _guessing 属性FloatingActionButton 按钮组件在创建时根据 _guessing 值控制相关属性。比如 _guessing 为 true 时 onPressed 为 null, 表示不响应点击,且背景色是灰色 Colors.grey
---->[guess_page.dart#_GuessPageState]----
bool _guessing = false;
// 略...
// 按钮组件构建逻辑
floatingActionButton: FloatingActionButton(
onPressed: _guessing ? null : _generateRandomValue,
backgroundColor: _guessing ? Colors.grey : Colors.blue,
tooltip: 'Increment',
child: const Icon(Icons.generating_tokens_outlined),
),
注: boolValue ? a : b 称三目运算符相当于一种简写的赋值语句boolValue 为 true 时取 a反之取 b 。下面是一个小例子:
int a = 5;
int b = 6;
bool boolValue = true;
int c = boolValue ? a : b
// 上行代码等价于下面代码:
int c;
if(boolValue){
c = a;
}else{
c = b;
}
上面通过 _guessing 状态数据控制组件构造,从而达到控制表现的效果。写一个问题就是,如何在交互逻辑中,正确地维护 _guessing 状态数据值。这里的逻辑是:点击之后,表示游戏开始,将 _guessing 置为 true 。所以只需要在 _generateRandomValue 方法中添加一行即可:
void _generateRandomValue() {
setState(() {
_guessing = true; // 点击按钮时,表示游戏开始
_value = _random.nextInt(100);
});
}
大家可以将当前代码自己跑一下,点击操作。体会一下状态数据变化的过程 (业务逻辑),和对界面表现的控制力(界面构建逻辑)。
2.密文的展示
既然是猜数字,那么随机生成的数字肯定不能明晃晃地摆在那里,而且生成之后,上方的 点击生成随机数值 的提示信息也不需要了。可以看出这些界面表现都是通过 _guessing 状态数据决定的,通过状态数据控制界面呈现,一般称之为 界面构建逻辑 。
也就是在猜数字过程中,要隐藏数字的展示;取除上方的提示字,效果如下:
代码实现也比较简单,和之前一样,通过 _guessing 值,决定组件构造的内容。从这里可以看出,一个状态数据,可以控制界面中很多部件的表现形式。
注: 在列表中可以通过 if(boolValue) 来控制是否添加某个元素。
当前代码提交位置: guess_page.dart
3. 回调事件的传递
这样我们就实现了随机数字生成的需求,现在需要做的是猜数字需求。 在输入框中输入数字,点击确定按钮,比较后想用户提示大小信息。现在首要问题是知道如何获取输入的数字,以及如何触发按钮的点击事件。
我们之前将头部栏单独封装成一个组件,独立存放。现在想处理头部栏的相关工作,直接看 guess_app_bar.dart 文件即可。先来看一下点击事件的回调:
这里使用 IconButton ,也就是图标按钮,在 onPressed 构造入参中可以传入无参函数,用于回调。也就是说,你点击按钮就会触发一个函数(方法),如下所示,点击一下在控制台输出信息:
在 GuessAppBar 类中,并没有猜数字过程中的相关数据,在这里校验大小并不是很合适。这时就可以通过回调,将事件触发的任务移交给自己的使用者。因为函数本身也可以视为一个对象,如下所示,将函数作为属性成员,通过构造函数进行赋值:
这样在构造 GuessAppBar 组件时,就可以将回调事件交由 _GuessPageState 处理,而这里有我们维护的状态数据。处理如下:
4.输入控制器的使用
目前,输入框可以进行输入,但如何获取到输入内容呢? 我们可以使用输入控制器 TextEditingController 它可以承载输入的内容。它会作为 TextField 的构造入参,而 TextField 在 GuessAppBar 中;又因为由于核心逻辑的维护在 _GuessPageState 中,所以控制器对象可以交由 _GuessPageState 维护,并可以通过 GuessAppBar 构造函数来传入:
---->[guess_page.dart#GuessAppBar]----
class GuessAppBar extends StatelessWidget implements PreferredSizeWidget {
final VoidCallback onCheck;
final TextEditingController controller;
const GuessAppBar({
Key? key,
required this.onCheck,
required this.controller,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return AppBar(
// 略同...
title: TextField(
controller: controller,
这样,在 _GuessPageState 里创建 _guessCtrl 属性,作为 GuessAppBar 构造入参,就可以和输入框进行绑定。当输入文字,点击按钮后,查看控制台,就可以看到输入信息。
另外,注意一下,输入控制器有销毁的方法,需要覆写状态类的 dispose 方法,调用一下:
@override
void dispose() {
_guessCtrl.dispose();
super.dispose();
}
5.本章小结
到这里,相关的数据和界面就准备完毕,当前代码提交位置: guess_page.dart 。本章最主要的知识是通过改变数据来修改界面的呈现效果。比如在交互过程中,按钮的禁用、文字的密文展示会发生变化,它们的表现都在构造逻辑中由数据决定。大家可以通过当前的源码好好思考一下,状态数据和界面之间的关系。
下一章,将继续完善功能,处理校验以及提示用户输入值大了还是小了。

View File

@ -0,0 +1,156 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
09 校验结果与提示信息
1. 需求中的状态数据分析
在上一篇中,已经准备好了数据和界面。本篇将介绍比较结果的校验,以及展示用户输入值和随机目标值的大小信息。点击确定时,可能的结果有 3 中,如下所示:
如下所示,是点击确定时不同情况下期望的界面效果;也就是说,界面的展现需要随着用户交互而变化。所以在当前需求之下,需要引入新的状态数据,用于控制界面的表现。
大了
小了
相等
这个状态数据将用于表示校验结果,该用什么类型呢?首先要明确一点:比较结果有三种情况: 大了、小了、相等 。其实只要某种数据类型包含三个值,都可以用于表示校验状态,只要逻辑正确即可,比如:
int 型:
0: 大了
1: 小了
2: 相等
String 型:
'big': 大了
'small': 小了
'equal': 相等
但如果用 int 或 String 表示,首先必须提前做出规定。而对于不了解规定的人,在阅读时会增加理解的难度。如果这种类似的规定场景在一个项目里经常出现,就是一个个雷点,迟早会爆炸。其实 bool 类型的出现,就是为了避免用 0 和 1 整数来表示真假,带来语义上的难以理解。
能且仅能是一种语法上的约束,可以在根源上杜绝一些可能发生的错误。那问一个问题,什么类型有且仅有 3 个值?很多人会说 枚举,这确实可以,不过稍显麻烦。这里想用 bool?来表示三态,它有如下三个值:
null: 相等
true: 大了
false: 小了
分析完后,现在 _GuessPageState 里定义为变量 _isBig 作为状态数据,控制校验结果。默认为 null
bool? _isBig;
2. 校验逻辑与状态数据的维护
校验逻辑算是比较简单的,对状态数据的维护可以称之为 业务逻辑,校验逻辑将在 _onCheck 回调中进行。上篇说过,这里可以得到目标值和输入值,输入值可以通过 int.tryParse 吧字符串转为整型。
有些小细节要注意一下:如果 _guessing 为 false ,表示游戏未开始;或输入的不是整数,此时应该不做响应,直接返回即可。
void _onCheck() {
print("=====Check:目标数值:$_value=====${_guessCtrl.text}============");
int? guessValue = int.tryParse(_guessCtrl.text);
// 游戏未开始,或者输入非整数,无视
if (!_guessing || guessValue == null) return;
//猜对了
if (guessValue == _value) {
setState(() {
_isBig = null;
_guessing = false;
});
return;
}
// 猜错了
setState(() {
_isBig = guessValue > _value;
});
}
如果猜对了,表示游戏结束,将 _guessing 置为 true、_isBig 置为 null ; 如果猜错了,通过 guessValue 和 _value 的比较结果,为 _isBig 赋值。这就是对状态数据的维护过程。
3. 状态数据与界面构建逻辑
状态数据以及维护完毕,下面来看一下最后一步:使用 _isBig 状态控制界面的呈现。从最终效果可以推断出_isBig 对界面的功效:
大了
小了
相等
背景的色框提示,只有在大了或小了时才会出现,也就是说当 _isBig != null 时才会出现,对应下面代码的 69 行 。
大了和小了是互斥的,不会同时出现,通过 Spacer 进行占位,大了时占下半;小了时占上半。
注: isBig! 是空安全的语法,如果一个可空对象 100% 确定非 null。可以通过 对象名! 表示 非空对象。 由于上面 if(_isBig) 才会走下方逻辑,所以使用处 100% 非空。
4.本章小结
到这里,猜数字简单版本就已经完成了。当前代码提交位置: guess_page.dart 你可以随机生成数字,在输入框中猜测数字,并校验猜测的值,给出提示。虽然是个小案例,但相比于计数器来说复杂了一些,额外维护了几个状态数据,界面布局上也更加复杂。是一个初学者进一步了解 Flutter 的好案例,把它吃透,会对你受益匪浅。
虽然现在可以完成猜数字需求,但是还有一点缺陷。比如当校验 小了, 下次校验还小时,由于 _isBig 的状态类没变,提示界面就没有任何变化(下左图)。如果用户的一个操作得不到反馈,体感上会觉得可能没点好;此时给予用户视觉上的反馈就会有比较好的体验,比如加一个动画效果(下左图)
无动画
有动画

View File

@ -0,0 +1,299 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
10 动画使用与状态周期
1. 什么是动画
接下来,我们将进入猜数字项目的最后一个模块:动画的使用。上一节末尾说了,此处使用动画的目的是 增加交互反馈效果 。动画本质上就是不断更新界面展示的内容,玩过翻页动画的感触会深一些:
在每页纸上绘制连续的动作,手快速翻动时,内容快速变化,形成连续运动的动画效果。
这里根据翻页动画,先统一给定几个概念描述,方便后续的表述:
动画帧 : 一页纸上的内容。
动画时长 : 从开始翻看,到结束的时间差。
帧率 : 动画过程中,每秒钟包含动画帧的个数。
动画控制动器: 动画进行的动力来源,比如翻页动画中的手。
其实对于界面来说也是类似的,屏幕上展示的内容不断变化,给人视觉上的动画效果。对于界面编程来说,动画一般都是改变某些属性值;比如这里是对中间文字的大小进行动画表现:
标题
我们之前将提示信息的界面封装成 ResultNotice 组件进行展示,现在想要修改面板的展示效果,只要对该组件进行优化即可。可以很快定位到 result_notice.dart, 这也是各司其职的一个好处。下面就来看一下,让文字进行动画变化的流程。
2. 动画控制器的创建
要进行动画,首先要找到 驱动力, 也就是翻页的那只手怎么得到。Flutter 框架层对这只手 (Ticker) 进行了封装,给出一个更易用的 AnimationController 类型。想创该类型对象需要两步:
1. 一般在 State 派生类中创建 AnimationController 对象,使用这里将 ResultNotice 改为继承自 StatefulWidget
class ResultNotice extends StatefulWidget {
final Color color;
final String info;
const ResultNotice({
Key? key,
required this.color,
required this.info,
}) : super(key: key);
@override
State<ResultNotice> createState() => _ResultNoticeState();
}
class _ResultNoticeState extends State<ResultNotice>{
//...
}
1. 将状态类通过 with 关键字混入 SingleTickerProviderStateMixin, 让状态类拥有创建 Ticker 的能力。这样在 AnimationController 构造方法中 vsync 入参就可以传入当前状态类。
对于新手来说,这可能比较难理解,可以先记成固定的流程。不用太纠结,以后有能力时,可以在动画小册中探索更深层的原理。
class _ResultNoticeState extends State<ResultNotice> with SingleTickerProviderStateMixin{
late AnimationController controller;
@override
void initState() {
super.initState();
controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 200),
);
}
这样动画控制器对象就在创建完毕了,在创建对象时 duration 入参用于控制动画时长。默认情况下,动画控制器的值会在指定时长内,从 0 匀速变化到 1 。 下面来看一下如何通过动画控制器,来驱动字号大小的变化。
3. 动画构造器 AnimatedBuilder 的使用
动画本身就决定它需要频繁地变化,但很多时候我们只需要局部一小部分进行动画,比如这里只针对于文字。对于这种频繁变化的场景,最好尽可能小地进行重新构建。这里推荐新手使用 AnimatedBuilder 组件,可以非常方便地处理局部组件的动画变化。 由于需要动画的只是文字,所步骤如下:
将 AnimatedBuilder 套在 Text 组件之上。
将动画控制器作为 animation 入参。
将需要动画变化的属性值,根据 animation.value 进行计算即可。
刚才说过,默认情况下,动画控制器启动之后,它的值会在指定时长内,从 0 匀速变化到 1。所以这里 fontSize 会从 0 匀速变到 54 。
小思考: 通过简单的数学知识,思考一下如何让 fontSize 从 12 ~ 54 匀速变化。
4. 状态类的生命周期回调方法
这里介绍一下 State 派生类常用的几个生命周期回调方法;生命周期 顾名思义就是对象从生到死的过程,回调就是生命中特定时机的触发点;回调是 Flutter 框架中触发的,派生类可以通过 覆写 的方式,来感知某个特定时机。
比如,我们一般在 initState 回调中处理状态类中成员对象的初始化;如下这里对 controller 对象的初始化。在创建后可以通过 controller.forward 方法,启动动画器,让数值运动:
@override
void initState() {
super.initState();
controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 200),
);
controller.forward();
}
既然有生,就必然有死。当状态类不再需要时,其中持有的一些资源需要被释放,必然动画控制器。这时可以通过 dispose 回调监听到状态销毁的时机:
@override
void dispose() {
controller.dispose();
super.dispose();
}
人除了生和死,就是工作,状态类也是一样。而状态类最重要的工作就是 build 方法构建 Widget 组件,它也是生命周期回调中的一环:
@override
Widget build(BuildContext context) {
// 略同...
}
生和死都是只会触发一次,而工作是每天都要进行。所以,框架中对 State 对象的 initState 和 dispose 只会触发一次, build 方法可能触发多次。
现在一个问题,由于 controller 只会在 initState 触发一次,所以两次校验结果相同,状态类还活着,也只能进行一次动画。状态类如何监听到更新信息呢? 答案也是生命周期回调:
当上级状态触发 setState 时,会触发子级的 didUpdateWidget 生命回调
代码中在点击按钮时,会触发 setState , 所以 _ResultNoticeState 里可以覆写 didUpdateWidget 获得点击的时机,在此触发 controller 的 forward 进行动画。
如果 ResultNotice 提供了动画时长的参数,如果外界需要修改动画时长,而外界无法直接访问状态类。就可以通过 didUpdateWidget 来 间接 修改动画控制器的时长。其中 oldWidget 是之前的组件配置信息,另外最新的组件信息是 widget 成员,可以比较两者时长是否不同,对动画控制器进行修改。
---->[result_notice.dart]----
@override
void didUpdateWidget(covariant ResultNotice oldWidget) {
controller.forward(from: 0);
super.didUpdateWidget(oldWidget);
}
didUpdateWidget 可能对新手来说比较难理解,它提供了外界更新时机的回调,并根据新旧组件配置,来维护 状态类内部数据 。 在 State 的生命之中也可以被调用多次。
当前代码提交位置: result_notice.dart
initState 、build 、didUpdateWidget、dispose 三者是最基础的 State 生命周期回调。除此之外还有几个回调,不过对于新手来说并不重要,以后有能力时可以通过渲染机制小册,从源码的角度去了解它们。
5. Statless Or Statful
对于新手而言,面临的一个非常难的问题就是,我该选择 StatelessWidget 还是 StatefulWidget 。 这其实要取决于你对需求的理解,以及对组件的封装思路:比如这里 ResultNotice由于想让它进行动画而动画控制器的控制和维护需要在状态类中处理所以就选择了 StatefulWidget 。
但这并不是绝对的,因为上面选择 StatefulWidget 本质上就是由于 动画控制器 对象。那 ResultNotice 直接摆烂,由构造函数传入动画控制器。这就相当于动画控制器由外界维护,此时 ResultNotice 就可以是 StatelessWidget。
大家可以细品一下 StatefulWidget 变为 StatelessWidget 的过程。先自己思考一下两者的差异,后面我会进行分析。
class ResultNotice extends StatelessWidget {
final Color color;
final String info;
final AnimationController controller;
const ResultNotice({
Key? key,
required this.color,
required this.info,
required this.controller,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return Expanded(
child: Container(
alignment: Alignment.center,
color: color,
child: AnimatedBuilder(
animation: controller,
builder: (_, child) => Text(
info,
style: TextStyle(
fontSize: 54 * (controller.value),
color: Colors.white,
fontWeight: FontWeight.bold,
),
),
),
),
);
}
}
当前代码提交位置: result_notice.dart
俗话说,冤有头,债有主 。只要还想进行动画,控制器就需要有一个状态类做接盘侠(维护者)。由于 ResultNotice 已经甩锅了,那这里就需要上层状态 _GuessPageState 来维护:
这时,在构建 ResultNotice 时,传入控制器即可:
Column(
children: [
if(_isBig!)
ResultNotice(color:Colors.redAccent,info:'大了',controller: controller,),
Spacer(),
if(!_isBig!)
ResultNotice(color:Colors.blueAccent,info:'小了',controller: controller,),
],
),
最后在点击时触发动画器:
现在就 ResultNotice 来分析一下 StatlessWidget 和 StatefulWidget 在使用上的差异性。
ResultNotice 为 StatefulWidget 时,外界使用者无需和 AnimationController 就能进行动画。也就是说将动画控制器的逻辑封装到了内部,拿来即用,用起来简洁方便。
ResultNotice 为 StatlessWidget 时,外界使用者需要主动维护 AnimationController 对象,使用门槛较高。另外,由于使用者主动掌握控制器,可以更灵活地操作。
之前是静态的界面,现在想要进行动画,对于功能拓展来说,使用 StatefulWidget 来独立维护状态的变化。可以在不修改之前其他代码的前提下,完成需求。
好用 和 灵活 是一组矛盾,封装度越高,使用者操心的事就越少,用起来就越好用。但同时想要修改封装体内部的细节就越麻烦,灵活性就越差。所以,没有什么真正的好与坏,只有场景的适合于不适合。在面临选择时,要想一下:
你是只想看电视的用户,还是以后电视开膛破肚的维修也要自己做。
Flutter 中内置的很多 StatefulWidget 组件,我们就是使用者。比如点击按钮有水波纹的变化、点击 Switch 有滑动效果等,这些内部的状态变化逻辑是不用我们操心的。作为使用者可以非常轻松地完成复杂的交互效果,这就是封装的优势。但同时,如果需求的表现有一点不符合,改框架源码将会非常复杂,门槛也很高,这就是封装的劣势。对于选取 StatfulWidget 我的标准是:
当前组件展示区域,在交互中需要改变内容;且外界无需在意内部状态数据。
对于 ResultNotice 来说, StatlessWidget 或 StatefulWidget 差别并不是非常大,只是 AnimationController 交给谁维护的问题。每种方式都有好处,也有坏处,但都可以实现需求。所以结合场景,选取你觉得更好的即可。对于新手而言,能完成需求是第一要务,至于如何更优雅,你可以在以后的路中慢慢揣摩。
6.本章小结
本章主要介绍了 Flutter 中使用动画的方式和步骤,并简单了解了一下状态类的生命周期回调方法。最后分析了一个对于新手而言比较重要的话题 StatlessWidget 和 StatefulWidget 的差异性。
到这里,我们的第一个猜数字小案例的全部功能就实现完毕了。从中可以了解很多 Flutter 的基础知识,在下一篇中将对猜数字的小案例进行一个总结,看看现在我们已经用到了哪些知识,以及当前代码还有哪些优化的空间。

View File

@ -0,0 +1,317 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
11 猜数字整理与总结
通过上面 5 章的学习,我们已经完成了一个简单的猜数字小项目。这里将对项目在的一些知识点进行整理和总结,主要从界面相关知识和获得的技能点两个方面进行总结:
一、 界面相关的知识
对于新手来说Flutter 框架最最重要的就是完成界面在设备屏幕上的展示;并让用户在交互过程中展示准确的界面信息,以完成项目的功能需求。 从计数器和猜数字两个小项目中不难发现,组件 Widget 和界面呈现息息相关。
1.组件与界面的关系
现实中的一处建筑,在地图上可以用经纬度信息进行表示;界面的呈现效果,也可以用组件的信息进行表示。通过一个东西,可以确定另一个东西,这是一种非常典型的 映射关系 。组件对象可以决定界面上的展示内容,组件在构建过程中的配置参数,就是用于控制界面呈现效果的。
所以,组件(Widget) 本质上是对界面呈现的一种配置信息描述。
2.界面构建逻辑的封装
世界会分为很多国家、国家会分为很多省、省会分成很多市、市会分成很多县、区…等等。这是一个非常典型的 树形嵌套结构 ,对于管理来说,这种结构是不可或缺却的,比如一个公司的组织架构、一本书的目录结构。
当个体数量非常庞大,需要进行维护管理时,树形嵌套结构 可以很有效地将整体划分为若干层级。从而各司其职,完成整体的有序运转,社会是如此,对于界面来说也是一样。对于可交互软件的开发者而言,设备的界面就是一个世界,包含着各种各样的视觉元素,用户在使用软件交互过程中的正确性,就是世界的有序运转。
只靠一个人管理整个社会,其他全部躺平是不现实的; 同样只靠一个组件来维护界面也不是明智之举。组件可以表示界面上的一部分,合理地划分 "屏幕世界" 的区域,交由不同的组件进行管理,是一个好的习惯。这得益于组件可以对界面构建逻辑的进行封装。
3.状态数据与界面更新
Widget 对象的所有属性都需要是 final 修饰的,也就是说你无法修改 Widget 对象的属性。前面说过 Widget 可以决定界面的呈现效果,也就是说对于一个 Widget 对象 而言,它对应的界面效果是无法变化的。但界面在交互过程中,一定会有界面变化的需求,比如说计数器项目,点击按钮时文字发生变化。
可能会有人疑惑,既然你说 Widget 对象无法修改属性,那计数器的数字为什么会变化。理解这个问题是非常重要的,所以先举个小例子:
现实生活中,一只狗自诞生那一刻,毛色属性就无法修改。
有一天,你去小明家,站在一只白狗面前。
小明对你说: “你把眼睛闭上。”
过一会你睁开眼,看到面前有一只黑狗。
那么,你是否会惊奇的认为,自己的闭眼操作会改变一只狗的毛色。
(其实只是小明换了一只体型一样的黑狗)
同理,计数器项目中,你点击按钮时,数字从 0 -> 1。并不是 Text("0") 的文字属性变成了 1而是你面前的是一个新的 Text("1") 对象。数字需要在交互时发送变化,但不能再 Widget 类中变化,所以 Flutter 框架中,提供了一个状态数据维护的场所: State 的衍生类。它可以提供 setState触发重新构建从而更新界面呈现。
4.组件有无状态的差异性
对于新手而言,自己创建 Widget 的派生类,有两个选择。其一是继承自 StatelessWidget 其二是继承自 StatefulWidget 两者都可以通过已有的组件完成拼装的构建逻辑。
StatelessWidget 派生类是很简单直接的,它在 build 方法中通过已有组件来完成构建逻辑,返回 Widget 对象。 界面上需要什么,就在构造函数中传什么。相当于一个胶水,把其他组件黏在一块,作为新的组件个体。
StatefulWidget 和 StatelessWidget 在功能上是类似的,只不过 StatefulWidget 自己不承担 build 组件的责任,将构建任务委托给 State 派生类。它会通过 createState 方法创建 State 对象:
在上一点中提到State 派生类中可以维护状态数据的变化和重新触发自己的 build 构建方法实现界面的更新。另外State 对象有生命周期回调,可以通过覆写方法进行感知。
需要注意一点: 不要将 StatefulWidget 和 State 混为一谈,两者是不同的两个类型。可以感知生命周期、维护状态数据变化的是 State 类。 StatefulWidget 的任务是承载配置信息,和创建 State 对象。
二、 技能点
虽然说界面上展示的内容都是通过 Widget 确定的,但 Flutter 中除了 Widget 还有很多其他的类型对象。它们在一起共同工作,维护界面世界的运转。
1. 回调函数的使用
函数本身可以视为一个对象,作为函数的参数进行传递,这样可以在一个类中,很方便地感知另一个对象事件的触发。比如在 _GuessPageState 类中,使用 FloatingActionButton 组件,它的入参 onPressed 是一个函数,这个函数是由框架内部在恰当的时机触发的,这个时机就是点击。
也就是说,点击会触发 onPressed 参数传入的函数对象,这样我们就可以方便地 监听到 事件,并处理数据变化的逻辑。这种函数,就称之为 回调函数 。既然函数要作为对象传递,那最好要有类型名,可以通过 typedef 让函数有用类名:
比如, FloatingActionButton 的 onPressed 参数类型是 VoidCallback ,定义如下:表示一个无参的返回值为空的函数。
typedef VoidCallback = void Function();
AnimatedBuilder 组件的 builder 参数也是一个回调函数,类型为 TransitionBuilder ,定义如下:表示一个返回值为 Widget, 两个入参分别是 BuildContext 和 Widget? 的函数。
typedef TransitionBuilder = Widget Function(BuildContext context, Widget? child);
2. 动画控制器的使用
动画控制器是界面可以进行动画变化的驱动力,使用过程分为三步: 创建动画控制器、 在合适时机启动控制器、使用动画器的值构建界面
1.创建动画控制器主要在 State 派生类中进行:让 State 派生类混入 SingleTickerProviderStateMixin 后,将状态自身作为 AnimationController 的 vsync 入参:
class _GuessPageState extends State<GuessPage> with SingleTickerProviderStateMixin{
late AnimationController controller;
@override
void initState() {
super.initState();
controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 200),
);
}
2.在恰当的时机可以通过 AnimationController 的 forward 方法启动控制器。让它的值从 0 变化到 1
controller.forward(from: 0);
3.根据动画控制器的值,设置需要动画变化的属性。由于动画的触发非常频繁,推荐使用 AnimatedBuilder 监听控制器,实现局部组件的更新和构建:
3. 随机数的使用
Dart 内部提供了 Random 类,因此获取随机数就非常方便。只要创建 Random 对象,提供 nextInt 就可以得到 0 ~ 100 的随机整数 (不包括 100)
Random _random = Random();
_random.nextInt(100);
另外,可以通过 nextDouble 方法获取 0 ~ 1 的随机小浮点数 (不包括 1) ;通过 nextBool 方法获取随机的 bool 值:
三、接触的内置组件
最后来整理一下目前猜数字项目中用到的 Flutter 内置组件,大家可以根据下表,结合源码以及应用界面,思考一下这些组件的作用和使用方式:
1. 基础组件
基础组件是常用的简单组件,功能单一,相当于积木的最小单元:
组件名称
功能
猜数字中的使用
Text
文本展示
展示相关的文字信息
TextField
输入框展示
头部的输入框,得到用户输入
Container
一个容器
对比较结果界面进行着色
Icon
图标展示
作为图标按钮的内容
IconButton
图标按钮,通过 onPressed 监听点击回调
头部运行按钮,
FloatingActionButton
浮动按钮 ,通过 onPressed 监听点击回调
右下角按钮,生成随机数
2. 组合结构型
有些组件是比较复杂的,在构造中可以配置若干个组件,作为各个部分。比如 AppBar 可以设置左中右下四个区域Scaffold 可是设置上中下左右,它们像躯干一样,把布局结构已经固定了,只需将组件插入卡槽中即可。
组件名称
功能
猜数字中的使用
MaterialApp
整体应用配置
作为代码中的组件顶层,提供主题配置
Scaffold
通用界面结构
这里通过 appBar 设置标题、 body 设置字体内容
AppBar
应用标题栏
展示标题栏
3. 布局组件
布局组件无法在界面上进行任何色彩的展示,它们的作用是对其他组件进行排布与定位。比如 Row 和 Column 用于水平和竖直排列组件Stack 让若干组件叠放排布Center 可以将子组件居中。
组件名称
功能
猜数字中的使用
Row、Column
水平、竖直摆放若干组件
通过 Column 竖直排列文字信息和比较结果
Expanded、Spacer
延展区域
比较结果通过 Spacer 占位
Stack
叠放若干组件
比较结果和主体内容进行叠放
Center
居中定位
主体内容居中
4. 构建器
构建器是指,通过该组件回调来完成构建组件的任务。也就是说回调触发时会进行重新构建,从而让构建缩小在局部。
组件名称
功能
猜数字中的使用
AnimatedBuilder
监听动画器,通过回调局部构建组件
比较结果中文字的大小动画
到这里,猜数字的项目就总结完了,希望大家可以好好思考和体会,下面将进入 电子木鱼 的模块。

View File

@ -0,0 +1,154 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
12 电子木鱼界面交互与需求分析
1. 界面交互介绍
电子木鱼是本教程的第二个案例,相比于猜数字项目,功能需求比较复杂一点,适合新手朋友进一步了解 Flutter 相关知识。下面是两个最基础的交互:
点击木鱼图片发出敲击声,并增加功德,展示增加动画。
点击标题栏右侧按钮,近进入功德记录页面。
点击木鱼
查看功德记录
光是点击图片发出音效未免有些单调,这里提供了两个选择功能,让应用的表现更丰富一些。
点击右上角按钮,选择木鱼音效
点击右上角按钮,选择木鱼样式
切换音效
切换样式
2. 电子木鱼需求分析
现在从数据和界面的角度,来分析一下猜数字中的需求:
木鱼点击发声及功德记录
在这个需求中,在每次点击时,需要产生一份记录数据,并将数据添加到列表中。这份记录数据包括 增加量、记录时间、当前音频、当前图片 四个数据。所以可以将四者打包在一个类中作为数据模型,比如定义 MeritRecord 类型:
class MeritRecord {
final String id;
final int timestamp;
final int value;
final String image;
final String audio;
MeritRecord(this.id, this.timestamp, this.value, this.image, this.audio);
Map<String, dynamic> toJson() => {
"id":id,
"timestamp": timestamp,
"value": value,
"image": image,
"audio": audio,
};
}
对于界面来说,当功德记录发生变化时,触发当前功德的展示动画,功德数字会进行透明度、移动、缩放的叠加动画;进入功德详情页是,功德记录列表的数据将被传入其中,作为界面构建时的数据信息。由于数据量会很多,所以视图需要支持滑动。
音效和样式的选择
这两个需求的操作流程是类似的。拿音频来说,需要的数据有:支持的音频列表,列表中的每个元素需要有名称和资源两个数据,也可以通过一个类进行维护:
class AudioOption{
final String name;
final String src;
const AudioOption(this.name, this.src);
}
有了支持的列表数据,还需要当前激活的数据,这里维护 int 型的激活索引,即可通过列表获取到激活数据。对于木鱼样式来说也是一样,通过一个类型维护每种样式需要的数据:
class ImageOption{
final String name; // 名称
final String src; // 资源
final int min; // 每次点击时功德最小值
final int max; // 每次点击时功德最大值
const ImageOption(this.name, this.src, this.min, this.max);
}
对于界面来说,需要处理按钮的点击事件,从底部弹出选择的面板,在选择之后隐藏。选择面板中根据数据列表展示可选项,并根据激活索引控制当前的激活状态。
3. 电子木鱼中的知识点
首先,电子木鱼项目会 额外 接触到如下的常用组件,大家再完成电子木鱼项目的同时,也会了解这些组件的使用方式。
另外,会对 Flutter 中的界面相关的知识有进一步的认知:
数据模型与界面展示
组件的封装性
State 状态类的生命周期回调
组件有无状态的差异性
界面的跳转
最后Flutter 中可以依赖别人的类库完成自己项目的某些需求,将在电子木鱼项目中了解依赖库的使用方式。通过多种动画变换的使用,也可以加深对动画的理解。
资源配置与依赖库的使用
多种动画变换
短音效的播放
唯一标识 uuid
界面交互和需求分析就到这里,下面一起开始第二个小项目的学习吧!

View File

@ -0,0 +1,267 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
13 电子木鱼静态界面构建
下面我们正式开始 电子木鱼 小项目的开发,在 lib 下创建一个 muyu 的文件夹,用于存放电子木鱼相关代码文件:
1、界面布局分析
本篇主要完成电子木鱼的主要静态界面的构建、如下所示,分别是界面效果和布局分析效果。从右图可以很轻松地看出界面中的布局情况:
界面效果
布局查看
分为上中下三块,上方是标题、中间是文字和按钮、下方是图片。
整体使用 Scaffold 组件,头部使用 AppBar 组件;主体内容上下平分,可以使用 Column + Expanded 组合。
上半部分的两个绿色按钮,可以通过 Stack + Positioned 组合叠放在右上角。
下半部分通过 Centent + Image 组件让图片居中对其,并通过 GestureDetector 监听点击事件。
2. 电子木鱼整体界面 MuyuPage
电子木鱼在功能上和计数器是非常相似的,只不过是敲击点不同,界面不同不同罢了。由于点击过程中 功德数 会进行变换,使用这里让 MuyuPage 继承自 StatefulWidget ,通过对于的状态类 _MuyuPageState 来维护数据的和界面的构建及更新。
class MuyuPage extends StatefulWidget {
const MuyuPage({Key? key}) : super(key: key);
@override
State<MuyuPage> createState() => _MuyuPageState();
}
在 _MuyuPageState 的 build 方法中,对整体界面进行构建。使用 Scaffold 组件提供通用结构appBar 入参组件确实头部标题栏,一般使用 AppBar 组件。其中有很多配置属性:
backgroundColor 标题栏的背景色。
elevation标题栏的阴影深度。
titleTextStyle 标题的文字样式。
iconTheme 标题栏的图标主题。
actions : 标题栏右侧展示的组件列表。
这里说一下 titleTextStyle 和 iconTheme 。 如果直接为 title 中的文字设置样式,直接为 actions 中的图标设置颜色,效果是一样的。但如果 actions 有很多图标按钮,一个个配置就非常麻烦,而使用主题,可以提供默认样式,减少很多重复的操作。
class _MuyuPageState extends State<MuyuPage> {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
elevation: 0,
backgroundColor: Colors.white,
titleTextStyle: const TextStyle(color: Colors.black,fontSize: 16,fontWeight: FontWeight.bold),,
iconTheme: const IconThemeData(color: Colors.black),
title: const Text("电子木鱼"),
actions: [
IconButton(onPressed: _toHistory,icon: const Icon(Icons.history))
],
),
);
}
void _toHistory() {}
}
主体内容是上下平分区域,可以使用 Column + Expanded 组件。建议再写构建组件代码时,不要塞在一块,适当的通过函数或类进行隔离封装。比如下面代码中,两个部分组件的构建,交给两个方法完成,这样可以让组件构件的条理更清晰,易于阅读和修改。
Scaffold(
// 略同...
body: Column(
children: [
Expanded(child: _buildTopContent()),
Expanded(child: _buildImage()),
],
),
)
3. 主体内容的构建
上半部分界面交由 _buildTopContent 方法构建,效果如下: 其中 功德数 文字居中显示;上角有两个按钮,分别用于切换音效和切换图片;按可以通过 ElevatedButton 组件进行展示,并通过 style 入参调节按钮样式。
另外,两个按钮上下排列,可以使用 Column 组件,也可以使用竖直方向的 Wrap 组件。使用 Wrap 组件可以通过 spacing 参数,控制子组件在排列方向上的间距,想比 Column 来说方便一些。
Widget _buildTopContent() {
// 按钮样式
final ButtonStyle style = ElevatedButton.styleFrom(
minimumSize: const Size(36, 36), // 最小尺寸
padding: EdgeInsets.zero, // 边距
backgroundColor: Colors.green, // 背景色
elevation: 0, // 阴影深度
);
return Stack(
children: [
Center(
child: Text(
'功德数: 0',
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
),
),
),
Positioned(
right: 10,
top: 10,
child: Wrap(
spacing: 8,
direction: Axis.vertical,
children: [
ElevatedButton(
style: style,
onPressed: () {},
child: Icon(Icons.music_note_outlined),
),
ElevatedButton(
style: style,
onPressed: () {},
child: Icon(Icons.image),
)
],
)),
],
);
}
下半部分界面交由 _buildImage 方法构建,效果如下:是一个居中展示的图片,在 Flutter 中,展示图片可以使用 Image 组件。这里使用 Image.asset 构造函数,从本地资源中加载图片:
Widget _buildImage() {
return Center(
child: Image.asset(
'assets/images/muyu.png',
height: 200, //图片高度
));
}
想要使用本地资源,需要进行配置。一般来说,会在项目中创建一个 assets 文件夹,用于盛放本地资源文件,比如图片、音频、文本等。这里把图片放在 images 文件夹中,如何在 pubspec.yaml 文件的 flutter 节点下配置资源文件夹,这样 images 中的资源就可以使用了。
到这里,我们就已经实现了期望的布局效果,当前代码位置 muyu_page.dart 。从静态界面的构建过程中,不难体会出:这就像通过已经存在的积木,拼组成我们期望的展示效果。
界面效果
布局查看
4. 组件的封装
一个自定义的 Widget ,可以封装一部分界面展示内容的构建逻辑。在开发过程中,我们应该避免让一个 Widget 干所有的事,否则代码会非常杂乱。应该有意识地合理划分结构,将部分的构建逻辑独立出去,以便之后的修改和更新。
当前代码中,通过两个函数来封装上下部分界面的构建逻辑。但函数仍在状态类 _MuyuPageState 中,随着需求的增加,会导致一个类代码会越来越多。我们也可以将界面某部分的构建逻辑,通过自定义 Widget 来分离出去。
这里通过 CountPanel 组件来封装上半部分界面的构建逻辑。可以分析一下,界面在构建过程中需要依赖的数据,并通过构造函数传入数据。其中的构建逻辑和上面的 _buildTopContent 方法一样的。
如果把这些构建逻辑比作一个人,那函数封装和组件封装,就相当于这个人穿了不同的衣服。其内在的本质上并没有太大的差异,只是外部的表现不同罢了。函数封装,在其他的类中,相当于给别人打工;组件封装,有自己的类名,正规编制,相当于自己当老板,经营构建界面逻辑。
class CountPanel extends StatelessWidget {
final int count;
final VoidCallback onTapSwitchAudio;
final VoidCallback onTapSwitchImage;
const CountPanel({
super.key,
required this.count,
required this.onTapSwitchAudio,
required this.onTapSwitchImage,
});
Widget build(BuildContext context) {
//同上 _buildTopContent 方法
}
}
另外,对于组件封装而言,会遇到一些事件的触发。比如这里点击切换音效按钮该做什么,对于 CountPanel 而言是不关心的,它只需要经营好界面的构建逻辑即可。这时可以通过 回调 的方式,交由使用者来处理,其实按钮组件的 onPressed 回调入参也是这种思路:
这样,在 _MuyuPageState 中,可以直接使用 CountPanel 完成上半部分的展示,从而减少状态类中的代码量。而 CountPanel 组件的职责也很专一,就更容易做好一件事。
Expanded(
child: CountPanel(
count: 0,
onTapSwitchAudio: _onTapSwitchAudio,
onTapSwitchImage: _onTapSwitchImage,
),
),
void _onTapSwitchAudio() {}
void _onTapSwitchImage() {}
5.本章小结
本章主要对电子木鱼的静态界面进行搭建,除了学习使用基础组件布局之外,最主要的目的是了解组件对构建逻辑的封装。封装可以自责进行隔离,并在相似的场景中可以复用。
就像皇帝不可能一个人治理整个国家。无法良好地组织和管理各个区域,把所有事交由一个人来做,随着疆域的扩大,早晚会因为臃肿而无法前进,会阻碍社会(应用)的发展。虽然对于新手而言,关注代码的组织方式为时过早,但要有这种意识,避免出现一个掌控所有逻辑的 上帝类 。
小练习: 通过 MuyuAssetsImage 组件封装下半部分的界面构建逻辑。 当前代码位置 muyu
到这里,基本的静态界面就搭建完成了,下一章将处理一下基本的交互逻辑。

View File

@ -0,0 +1,322 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
14 计数变化与音效播放
1. 组件点击事件的监听
计时器项目中的 FloatingActionButton 按钮,猜数字项目中的 IconButton 点击事件,都是通过构造中的 onPressed 参数传入函数,执行相关逻辑。我们称这种以函数对象作为参数的形式为 回调函数 。现在我们的需求是:让图片可以响应点击事件,每次点击时功德数随机增加 1~3 点,并发出敲击的音效。
————————————————————
————————————————————
如下所示,在 MuyuAssetsImage 中使用 GestureDetector 嵌套在 Image 之上,这样图片区域内的点击事件,可以通过 onTap 回调监听到。另外,点击时功德累加的逻辑,和图片构建并没有太大关系,这里通过 onTap 入参,将事件向上级传递,交由使用者处理。
class MuyuAssetsImage extends StatelessWidget {
final String image;
final VoidCallback onTap;
const MuyuAssetsImage({super.key, required this.image, required this.onTap});
@override
Widget build(BuildContext context) {
return Center(
child: GestureDetector( // 使用 GestureDetector 组件监听手势回调
onTap: onTap,
child: Image.asset(
image,
height: 200,
),
),
);
}
}
通过 GestureDetector 组件的 onTap 参数,可以监听到任何组件在其区域内的点击事件。
在 _MuyuPageState#build 方法中,使用 MuyuAssetsImage 组件时,通过 onTap 参数指定逻辑处理函数,这和 FloatingActionButton的onPressed 点击事件本质上是一样的。这里通过 _onKnock 函数处理点击木鱼的逻辑:
---->[_MuyuPageState#build]----
Expanded(
child: MuyuAssetsImage(
image: 'assets/images/muyu.png',
onTap: _onKnock,
),
),
在 _MuyuPageState 中维护界面中需要的状态数据,这里最主要的是功德计数变量,通过 _counter 进行表示。另外,点击时功德数随机增加 1~3 ,需要随机数对象。这样,目前的逻辑就比较清晰了:在 _onKnock 函数中,对 _counter 进行累加即可,每次累加值为 1 + _random.nextInt(3) :
int _counter = 0;
final Random _random = Random();
void _onKnock() {
setState(() {
int addCount = 1 + _random.nextInt(3);
_counter += addCount;
});
}
最后,将 _counter 作为上半界面 CountPanel 的入参即可。这样点击时,更新 _counter 后,会重新构建,从而展示最新的数据。到这里本质上和计数器没有太大的差异:
当前代码位置 muyu
2. 插件的使用和配置
Flutter 是一个跨平台的 UI 框架,而音效的播放是平台的功能。在开发过程中,一般使用 插件 来完成平台功能。得益于 Flutter 良好的生态环境,在 pub 中有丰富的优秀插件和包。这里使用 flame_audio 插件来实现短音效播放的功能:
使用插件,首先要在 pubspec.yaml 的 dependencies 节点下配置依赖,在 pub 中的 installing 页签中,可以看到依赖的版本号:
如下所示,加入依赖后,点击 pub get 获取依赖包:
现在要播放音效,首先需要相关的音频资源,而使用本地资源,需要在 pubspec.yaml 中进行配置:这里准备了三个木鱼点击的音效,放在 audio 文件夹中。
3. 完成点击播放音效功能
点击事件的逻辑处理在 _MuyuPageState 类中,所以播放音效在该类中处理比较方便。插件的使用也比较简单,首先需要加载资源,通过 FlameAudio.createPool 方法创建 AudioPool 对象。在状态类的 initState 方法中使用 _initAudioPool 创建 pool 对象。
其中第一个参数是资源的名称flame_audio 默认将本地资源放在 assets/audio 中,指定资源时直接写文件名即可。
---->[_MuyuPageState]----
AudioPool? pool;
@override
void initState() {
super.initState();
_initAudioPool();
}
void _initAudioPool() async {
pool = await FlameAudio.createPool(
'muyu_1.mp3',
maxPlayers: 4,
);
}
然后,只需要在 _onKnock 方法在调用 pool 的 start 方法进行播放即可:
void _onKnock() {
pool?.start();
// 略同...
}
到这里,点击木鱼时,就可以听到敲击木鱼的音效,同时功德数也会随机增加 1~3 点。这样就完成了木鱼最基础的功能需求,下面我们将继续优化和拓展,添加一些新的视觉表现和功能。
当前代码位置 muyu
4. 增加功德时的动画展示
如下所示,现在需要在每次点击时展示功德增加的数字,并让数值有动画效果。比如这里是 缩放、移动、透明度 三个动画的叠加,从效果的表现上来说就是文字一边上移,一边缩小、一边透明度降低。
————————————————————
————————————————————
首先来分析一下界面构建:界面上需要展示的信息增加了 当前增加的功德值 ,使用需要增加一个状态数据用于表示。这里使用 _cruValue 变量,该数据维护的时机也很清晰,在点击时更新 _cruValue 的值:
---->[_MuyuPageState]----
int _cruValue = 0;
void _onKnock() {
pool?.start();
setState(() {
_cruValue = 1 + _random.nextInt(3);
_counter += _cruValue;
});
}
这样数据层面就搞定了,下面看一下界面构建逻辑。这里 当前功德 相当于一个浮层,可以通过 Stack 组件和下半部分进行叠放。如下所示,文字从下向上运动到下半部分的顶部:
由于动画的逻辑比较复杂,这里封装 AnimateText 组件来完成动画文字的功能,其中构造函数传入需要展示的文本信息。下半部分通过 Stack 进行叠放alignment 入参可以控制孩子们的对齐方式,比如这里 Alignment.topCenter 将会以上方中心对齐。表现上来说,就是文字在区域的上方中间:
现在数据和布局已经完成,就差动画效果了。在猜数字中我们简单地了解过动画的使用,通过 AnimatedBuilder 组件,监听动画控制器的变化,在构建组件的过程中让某些属性值不断变化。这里介绍一下几个 XXXTransition 动画组件的使用。
先拿 FadeTransition 组件来说,它的构造中需要传入 Animation<double> 的动画器表示透明度的动画变化。AnimationController 的数值运动是从 0 ~ 1 ,而这里透明度的变化是 1 ~ 0此时可以使用 Tween 来得到期望的补间动画器。如下 tag1 处,创建了 1~0 变化的动画器 opacity 。在 build 中,使用 FadeTransition 传入 opacity 动画器,当动画控制器运动时,子组件就会产生透明度动画。
————————————————————
————————————————————
class AnimateText extends StatefulWidget {
final String text;
const AnimateText({Key? key, required this.text}) : super(key: key);
@override
State<AnimateText> createState() => _FadTextState();
}
class _FadTextState extends State<AnimateText> with SingleTickerProviderStateMixin {
late AnimationController controller;
late Animation<double> opacity;
@override
void initState() {
super.initState();
controller = AnimationController(vsync: this, duration: const Duration(milliseconds: 500));
opacity = Tween(begin: 1.0, end: 0.0).animate(controller); // tag1
controller.forward();
}
@override
void didUpdateWidget(covariant AnimateText oldWidget) {
super.didUpdateWidget(oldWidget);
controller.forward(from: 0);
}
@override
void dispose() {
controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return FadeTransition(
opacity: opacity,
child: Text(widget.text),
);
}
}
当前代码位置 muyu
同理,使用 ScaleTransition 可以实现缩放动画;使用 SlideTransition 可以实现移动动:
@override
Widget build(BuildContext context) {
return ScaleTransition(
scale: scale,
child: SlideTransition(
position: position,
child: FadeTransition(
opacity: opacity,
child: Text(widget.text),
)),
);
}
XXXTransition 组件都需要指定对应类型的 Animation 动画器,而这些动画器可以以 AnimationController 为动力源,通过 Tween 来生成。 缩放变换传入 scale 动画器,从 1 ~ 0.9 变化;移动变化传入 position 动画器,泛型为 Offset从 Offset(0, 2) ~ Offset.zero 变化,对应的效果就是:在竖直方向上的偏移量,从两倍子组件高度变化到 0 。
————————————————————
————————————————————
class _FadTextState extends State<AnimateText> with SingleTickerProviderStateMixin {
late AnimationController controller;
late Animation<double> opacity;
late Animation<Offset> position;
late Animation<double> scale;
@override
void initState() {
super.initState();
controller = AnimationController(vsync: this, duration: Duration(milliseconds: 500));
opacity = Tween(begin: 1.0, end: 0.0).animate(controller);
scale = Tween(begin: 1.0, end: 0.9).animate(controller);
position = Tween<Offset>(begin: const Offset(0, 2), end: Offset.zero,).animate(controller);
controller.forward();
}
5.本章小结
本章我们学习了如何在自己的项目中使用别人提供的类库,这样就可以很轻松地完成复杂的功能。比如这里点击时的音效播放,如果完全靠自己来写代码实现,将会非常困难。但使用别人提供的插件,几行代码就搞定了。所以说,对于一项技术而言,良好的生态是非常重要的。
你可以使用别人的代码实现功能,方便大家;也可以分享自己的代码以供别人使用,让大家一起完善,这就是开源的价值。到这里,就完成了增加功德数动画的展示效果,当前代码位置 muyu 。下一篇将继续对当前项目进行功能拓展,增加切换音效和木鱼图片的效果。

View File

@ -0,0 +1,419 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
15 弹出选项与切换状态
1. 选择木鱼界面分析
现在想要的效果如下所示,点击第二个绿色按钮时,从底部弹出木鱼图片的选项。从界面上来说,有如下几个要点:
需要展示木鱼样式的基本信息,包括名称、图片及每次功德数范围。
需要将当前展示选择的木鱼边上蓝色边线,表示选择状态。
选择切换木鱼时,同时更新主界面木鱼样式。
木鱼界面
选择界面
仔细分析可以看出,两个选项的布局结构是类似的,不同点在于木鱼的信息以及激活状态。所以只要封装一个组件,就可以用来构建上面的两个选择项,这就是封装的可复用性。不过在此之前先梳理一下木鱼的数据信息:
根据目前的需求设定,木鱼需要 名称、图片资源、增加功德范围 的数据,这些数据可以通过一个类型进行维护,比如下面的 ImageOption 类:
--->[muyu/models/image_option.dart]---
class ImageOption{
final String name; // 名称
final String src; // 资源
final int min; // 每次点击时功德最小值
final int max; // 每次点击时功德最大值
const ImageOption(this.name, this.src, this.min, this.max);
}
对于一个样式来说,依赖的数据是 ImageOption 对象和 bool 类型的激活状态。这里封装一个 ImageOptionItem 组件用于构建一种样式的界面:
代码如下,红框中的单体是上中下的结果,通过 Column 组件竖向排列,另外使用装饰属性的 border ,根据是否激活,添加边线。其中的文字、图片数据都是通过 ImageOption 类型的成员确定的。
--->[muyu/options/select_image.dart]---
class ImageOptionItem extends StatelessWidget {
final ImageOption option;
final bool active;
const ImageOptionItem({
Key? key,
required this.option,
required this.active,
}) : super(key: key);
@override
Widget build(BuildContext context) {
const Border activeBorder = Border.fromBorderSide(BorderSide(color: Colors.blue));
return Container(
padding: const EdgeInsets.symmetric(vertical: 8),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
border: !active ? null : activeBorder,
),
child: Column(
children: [
Text(option.name, style: TextStyle(fontWeight: FontWeight.bold)),
Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8.0),
child: Image.asset(option.src),
),
),
Text('每次功德 +${option.min}~${option.max}',
style: const TextStyle(color: Colors.grey,fontSize: 12)),
],
),
);
}
}
2.底部弹框及界面构建
由于有了新需求_MuyuPageState 状态类需要添加一些状态数据来实现功能。如下,新增了 imageOptions 对象表示木鱼选项的列表;已及 _activeImageIndex 表示当前激活的木鱼索引:
---->[_MuyuPageState]----
final List<ImageOption> imageOptions = const [
ImageOption('基础版','assets/images/muyu.png',1,3),
ImageOption('尊享版','assets/images/muyu_2.png',3,6),
];
int _activeImageIndex = 0;
点击时的底部弹框,可以使用 showCupertinoModalPopup 方法实现。其中 builder 入参中返回底部弹框中的内容组件,这里通过自定义的 ImageOptionPanel 组件来完成弹框内界面的构建逻辑。
---->[_MuyuPageState]----
void _onTapSwitchImage() {
showCupertinoModalPopup(
context: context,
builder: (BuildContext context) {
return ImageOptionPanel(
imageOptions: imageOptions,
activeIndex: _activeImageIndex,
onSelect: _onSelectImage,
);
},
);
}
很容易可以看出选择木鱼的面板中需要 ImageOption 列表、当前激活索引两个数据;由于点击选择时需要更新主界面的数据,所以这里通过 onSelect 回调,让处理逻辑交由使用者来实现。
--->[muyu/options/select_image.dart]---
class ImageOptionPanel extends StatelessWidget {
final List<ImageOption> imageOptions;
final ValueChanged<int> onSelect;
final int activeIndex;
const ImageOptionPanel({
Key? key,
required this.imageOptions,
required this.activeIndex,
required this.onSelect,
}) : super(key: key);
界面的构建逻辑如下,整体结构并不复杂。主要是上下结构,上面是标题,下面是选项的条目。由于这里只要两个条目,使用 Row + Expanded 组件,让单体平分水平空间:
@override
Widget build(BuildContext context) {
const TextStyle labelStyle = TextStyle(fontSize: 16, fontWeight: FontWeight.bold);
const EdgeInsets padding = EdgeInsets.symmetric(horizontal: 8.0, vertical: 16);
return Material(
child: SizedBox(
height: 300,
child: Column(
children: [
Container(
height: 46,
alignment: Alignment.center,
child: const Text( "选择木鱼", style: labelStyle)),
Expanded(
child: Padding(
padding: padding,
child: Row(
children: [
Expanded(child: _buildByIndex(0)),
const SizedBox(width: 10),
Expanded(child: _buildByIndex(1)),
],
),
))
],
),
),
);
}
在 _buildByIndex 中,简单封装一下根据索引生成条目组件的逻辑,并通过点击事件,触发 onSelect 回调,将条目对应的索引传递出去。
Widget _buildByIndex(int index) {
bool active = index == activeIndex;
return GestureDetector(
onTap: () => onSelect(index),
child: ImageOptionItem(
option: imageOptions[index],
active: active,
),
);
}
到这里,万事俱备只欠东风,在点击木鱼样式回调中,需要更新 _activeImageIndex 的值即可,这里如果点击的是当前样式,则不进行更新操作。另外,在选择时通过 Navigator.of(context).pop() 可以关闭底部弹框。
---->[_MuyuPageState]----
void _onSelectImage(int value) {
Navigator.of(context).pop();
if(value == _activeImageIndex) return;
setState(() {
_activeImageIndex = value;
});
}
最后,主页面中的图片和点击时增加的值需要根据 _activeImageIndex 来确定。这里在 _MuyuPageState 中给两个 get 方法,方便通过 _activeImageIndex 和 imageOptions 获取需要的信息:
---->[_MuyuPageState]----
// 激活图像
String get activeImage => imageOptions[_activeImageIndex].src;
// 敲击是增加值
int get knockValue {
int min = imageOptions[_activeImageIndex].min;
int max = imageOptions[_activeImageIndex].max;
return min + _random.nextInt(max+1 - min);
}
//...
MuyuAssetsImage(
image: activeImage, // 使用激活图像
onTap: _onKnock,
),
void _onKnock() {
pool?.start();
setState(() {
_cruValue = knockValue; // 使用激活木鱼的值
_counter += _cruValue;
});
}
这样,就可以选择木鱼样式的功能,在敲击时每次功德的增加量也会不同。如下右图中,尊享版木鱼每次敲击,数字增加在 3~6 之间随机。当前代码位置 muyu
选择
切换后敲击
这里有个小问题,可以作为思考,这个问题将会在下一篇解决:
如上左图,在切换图片时,会看到 功德+2 的动画,这是为什么呢? 有哪些方式可以解决这个问题。
3. 选择音效功能
选择音效的整体思路和上面类似,点击主界面右上角第一个按钮,从底部弹出选择界面,这里准备了三个音效以供选择。当前音效也会高亮显示,另外右侧有一个播放按钮可以试听:
主界面
选择音效界面
首先还是对数据进行一下封装,对于音频选择界面而言,两个信息数据: 名称 和 资源。这里通过 AudioOption 类进行
--->[muyu/models/audio_option.dart]---
class AudioOption{
final String name;
final String src;
const AudioOption(this.name, this.src);
}
在 _MuyuPageState 中,通过 audioOptions 列表记录音频选项对应的数据_activeAudioIndex 表示当前激活的音频索引:
---->[_MuyuPageState]----
final List<AudioOption> audioOptions = const [
AudioOption('音效1', 'muyu_1.mp3'),
AudioOption('音效2', 'muyu_2.mp3'),
AudioOption('音效3', 'muyu_3.mp3'),
];
int _activeAudioIndex = 0;
如何同样,在点击按钮时,通过 showCupertinoModalPopup 弹出底部栏,使用 AudioOptionPanel 构建展示内容:
---->[_MuyuPageState]----
void _onTapSwitchAudio() {
showCupertinoModalPopup(
context: context,
builder: (BuildContext context) {
return AudioOptionPanel(
audioOptions: audioOptions,
activeIndex: _activeAudioIndex,
onSelect: _onSelectAudio,
);
},
);
}
目前只有三个数据,这里通过 Column 竖直排放即可,如果你想支持更多的选项,可以使用滑动列表(下篇介绍)。 另外对于条目而言Flutter 提供了一些通用的结构,比如这里可以使用 ListTile 组件,来构建左中右的视图。
AudioOptionPanel 界面构建逻辑如下,其中提供 List.generate 构造函数生成条目组件列表;每个条目由 _buildByIndex 方法根据 index 索引进行构建;构建的内容使用 ListTile 根据 AudioOption 数据进行展示。
注: 这里 listA = [1,2,…listB] 表示在创建 listA 的过程中,将 listB 列表元素嵌入其中。
--->[muyu/options/select_audio.dart]---
class AudioOptionPanel extends StatelessWidget {
final List<AudioOption> audioOptions;
final ValueChanged<int> onSelect;
final int activeIndex;
const AudioOptionPanel({
Key? key,
required this.audioOptions,
required this.activeIndex,
required this.onSelect,
}) : super(key: key);
@override
Widget build(BuildContext context) {
const TextStyle labelStyle = TextStyle(fontSize: 16, fontWeight: FontWeight.bold);
return Material(
child: SizedBox(
height: 300,
child: Column(
children: [
Container(
height: 46,
alignment: Alignment.center,
child: const Text("选择音效", style: labelStyle, )),
...List.generate(audioOptions.length, _buildByIndex)
],
),
),
);
}
Widget _buildByIndex(int index) {
bool active = index == activeIndex;
return ListTile(
selected: active,
onTap: () => onSelect(index),
title: Text(audioOptions[index].name),
trailing: IconButton(
splashRadius: 20,
onPressed: ()=> _tempPlay(audioOptions[index].src),
icon: const Icon(
Icons.record_voice_over_rounded,
color: Colors.blue,
),
),
);
}
void _tempPlay(String src) async{
AudioPool pool = await FlameAudio.createPool(src, maxPlayers: 1);
pool.start();
}
}
最后,在 _MuyuPageState 中,选择音频回调时,根据激活索引,更新 pool 对象即可。这样在点击时就可以播放对应的音效。
---->[_MuyuPageState]----
String get activeAudio => audioOptions[_activeAudioIndex].src;
void _onSelectAudio(int value) async{
Navigator.of(context).pop();
if (value == _activeAudioIndex) return;
_activeAudioIndex = value;
pool = await FlameAudio.createPool(
activeAudio,
maxPlayers: 1,
);
}
4.本章小结
本章通过弹出框展示切换音效和木鱼样式,可以进一步理解数据和界面视图之间的关系:数据为界面提供内容信息、界面交互操作更改数据信息。
另外,两个选项的面板通过自定义组件进行封装隔离,面板在需要的数据通过构造函数传入、界面中的事件通过回调函数交由外界执行更新数据逻辑。也就是说,封装的 Widget 在意的是如何构建展示内容,专注于做一件事,与界面构建无关的任务,交由使用者处理。
到这里,本篇新增的两个功能就完成了,当前代码位置 muyu 。目前为止,木鱼项目的代码已经有一点点复杂了,大家可以结合源码,好好消化一下。下一篇将继续对木鱼项目进行功能拓展,并以此学习一下滑动列表。

View File

@ -0,0 +1,224 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
16 用滑动列表展示记录
1. 功德记录的功能需求
现在想要记录每次点击时的功德数据,以便让用户了解功德增加的历史。对于一个需求而言,最重要的是分析其中需要的数据,以及如何维护这些数据。
效果如下,点击左上角按钮,跳转到功德记录的面板。功德记录界面是一个可以滑动的列表,每个条目展示当前功德的 木鱼图片、音效名称、时间、功德数 信息:
标题
根据界面中的数据,可以封装一个类来维护,如下 MeritRecord 类。其中 id 是一条记录的身份标识,就像人的身份证编号,就可以确保他在社会中的唯一性。
class MeritRecord {
final String id; // 记录的唯一标识
final int timestamp; // 记录的时间戳
final int value; // 功德数
final String image; // 图片资源
final String audio; // 音效名称
MeritRecord(this.id, this.timestamp, this.value, this.image, this.audio);
}
对于功德记录的需求而言,需要在 _MuyuPageState 中新加的数据也很明确: 对 MeritRecord 列表的维护。接下来的任务就是,在点击时,为 _records 列表添加 MeritRecord 类型的元素。
---->[_MuyuPageState]----
List<MeritRecord> _records = [];
2. 列表数据的维护
对于生成唯一 id 这里使用 uuid 库,目前最新版本 3.0.7。记得在 pubspec.yaml 的依赖节点配置:
dependencies:
...
uuid: ^3.0.7
如下代码是 _onKnock 函数处理点击时的逻辑,其中创建 MeritRecord 对象,构造函数中的相关数据,在状态类中都可以轻松的到。
---->[_MuyuPageState]----
final Uuid uuid = Uuid();
void _onKnock() {
pool?.start();
setState(() {
_cruValue = knockValue;
_counter += _cruValue;
// 添加功德记录
String id = uuid.v4();
_records.add(MeritRecord(
id,
DateTime.now().millisecondsSinceEpoch,
_cruValue,
activeImage,
audioOptions[_activeAudioIndex].name,
));
});
}
这里来分析一下上一篇中切换木鱼样式,会导致动画触发的问题。原因非常简单,因为动画控制器会在 didUpdateWidget 中启动。而切换木鱼样式时,通过触发了 setState 更新主界面的木鱼图片。于是会连带触发动画器的启动,解决方案有很多:
解决方案 1 : 将动画控制器交由上层维护,仅在点击时启动动画。
解决方案 2 :为 didUpdateWidget 回调中动画启动添加限制条件。
解决方案 3 :切换木鱼样式时,使用局部更新图片,不重新构建整个 _MuyuPageState 。
上面的三个方案中各有优劣,综合来看,方案 1 是最好的;方案 2 次之;方案 3 虽然可以解决当前问题,但治标不治本。就当前代码而言,使用 方案 2 处理最简单,那么动画启动的限制条件是什么呢?
当功德发生变化时,才需要启动动画。
而现在每个功德对应一个 MeritRecord 对象,且 id 可以作为功德的身份标识。功德发生变化 ,也就意味着新旧功德 id 的不同。那么具体的方案就呼之欲出了,如下所示:让 AnimateText 持有 MeritRecord 数据
在状态类 didUpdateWidget 回调中,可以访问到旧的组件,通过对比新旧 AnimateText 组件持有的 record 记录 id ,当不同时才启动动画。这样就避免了在功德未变化的情况下,启动动画的场景。通过个问题的解决,想必大家对状态类的 didUpdateWidget 方法,有更深一点的了解。
@override
void didUpdateWidget(covariant AnimateText oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.record.id != widget.record.id) {
controller.forward(from: 0);
}
}
小练习: 自己尝试使用解决方案 1 ,解决动画误启动问题。
3. ListView 的简单使用
我们已经知道,通过 Column 组件可以实现若干个组件的竖向排列。但当组件数量众多时,超越 Column 组件尺寸范围后,就无法显示;并且在 Debug 模式中会出现越界的异常(下左图)。很多场景中,需要支持视图的滑动,可以使用 ListView 组件来实现 (下右图)
Column
ListView
这里历史记录界面通过 RecordHistory 组件构建,构造函数中传入 MeritRecord 列表。主体内容通过 ListView.builder 构建滑动列表,通过 _buildItem 方法,根据索引值返回条目组件。
class RecordHistory extends StatelessWidget {
final List<MeritRecord> records;
const RecordHistory({Key? key, required this.records}) : super(key: key);
@override
Widget build(BuildContext context) {
return Scaffold(
appBar:_buildAppBar(),
body: ListView.builder(
itemBuilder: _buildItem,
itemCount: records.length,
),
);
}
PreferredSizeWidget _buildAppBar() =>
AppBar(
iconTheme: const IconThemeData(color: Colors.black),
centerTitle: true,
title: const Text(
'功德记录', style: TextStyle(color: Colors.black, fontSize: 16),),
elevation: 0,
backgroundColor: Colors.white,
);
在 _buildItem 方法中,使用 ListTile 组件构建条目视图;其中:
leading 表示左侧组件,使用 CircleAvatar 展示圆形图形;
title 表示标题组件,展示功德数;
subtitle 表示副标题组件,展示音效名称;
trailing 表示尾部组件,展示日期。
注: 这里使用 DateFormat 来格式化时间,需要在依赖中添加 intl: ^0.18.1 包。
DateFormat format = DateFormat('yyyy年MM月dd日 HH:mm:ss');
Widget? _buildItem(BuildContext context, int index) {
MeritRecord merit = records[index];
String date = format.format(DateTime.fromMillisecondsSinceEpoch(merit.timestamp));
return ListTile(
leading: CircleAvatar(
backgroundColor: Colors.blue,
backgroundImage: AssetImage(merit.image),
),
title: Text('功德 +${merit.value}'),
subtitle: Text(merit.audio),
trailing: Text(
date, style: const TextStyle(fontSize: 12, color: Colors.grey),),
);
}
}
最后在 _MuyuPageState 中处理 _toHistory 点击事件,通过 Navigator 跳转到 RecordHistory 。
---->[_MuyuPageState]----
void _toHistory() {
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => RecordHistory( records: _records.reversed.toList()),
),
);
}
4.本章小结
本章主要介绍了如何使用可滑动列表展示非常多的内容ListView 组件可以很便捷地帮我们让记录列表支持滑动。滑动视图使用起来并不是很复杂,但真的能用好,理解其内部的原理,还有很长的路要走。对于新手而言,目前简单能用就行了,以后可以基于小册来深入研究。
到这里,电子木鱼的基本功能完成了,当前代码位置 muyu 。不过现在的数据都是存储在内存中的,应用退出之后无论是选项,还是功德记录都会重置。想要数据持久化存储,在后面的 数据的持久化存储 一章中再继续完善,木鱼项目先告一段落。

View File

@ -0,0 +1,308 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
17 电子木鱼整理与总结
通过上面 5 章的学习,我们已经完成了一个简单的电子木鱼小项目。这里将对项目在的一些知识点进行整理和总结,主要从 界面相关知识 和 获得的技能点 两个方面进行总结:
一、 界面相关的知识
电子木鱼的界面交互要比猜数字要复杂很多,比如选择切换音效和图片、跳转历史记录界面。所以需要维护的数据以及组件的构建逻辑也更复杂,其中蕴含的知识也就更多。
1. 数据模型与界面展示
在界面中往往会有一些数据相关性很强,界面构建中需要很多份这样的数据。比如每个木鱼的样式,都有名称、资源、功德范围。这些数据就像一个个豌豆粒,一个个单独维护会非常复杂,把它们封装在一个类中,就相当于让它们住进一个豌豆皮中。
所以这种对若干个数据进行封装的类型也被形象地称之为 Bean也可以称之为 数据模型 Model 。比如这里的 ImageOption 类型就是对木鱼样式数据的封装,一个 ImageOption 对象就对应着界面展示中的一个图片选项。
class ImageOption{
final String name; // 名称
final String src; // 资源
final int min; // 每次点击时功德最小值
final int max; // 每次点击时功德最大值
const ImageOption(this.name, this.src, this.min, this.max);
}
2. 组件的封装性
还拿木鱼选择的界面来说,可以看出两个木鱼选项的界面结构是完全一样的,只不过是内容数据信息的不同。这时并没有必要写两份构建逻辑,通过一个组件进行封装,界面构建这依赖的数据通过构造中传入。比如这里依赖 ImageOption 和是否激活两个数据:
封装数据模型类之后,在传参时也更加方便,否则就需要传入一个个的豌豆粒。在构建逻辑中就可以根据传入的数据,构建对应的组件,完成界面展示的任务。
其中 ImageOptionItem 组件也像一个豌豆,里面的豌豆粒就是构建过程中使用的组件。豌豆把豌豆粒包裹在一块,这就是一种现实中的 封装 。为 ImageOptionItem 组件提供不同的 ImageOption就可以展示不同的界面。所以这里只需要准备份数据就行了而不是重复写两次构建逻辑。
这就是封装的一大特点: 可复用性。
复用的目标就是封装的内容,对于组件来说,封装的目标就是构建逻辑。
ImageOptionItem(option: option1, active: true),
ImageOptionItem(option: option2, active: false),
可复用性往往会让使用变得简单,比如 Text 组件是源码提供的组件,它封装了文本渲染的过程。对于使用者来说,只要创建 Text 对象,并不需要理解底层的渲染原理。如果 ImageOptionItem 组件是 A 同学写的,那么把它发给 B 同学,那 B 同学只需要准备数据即可。所以,逻辑一旦封装,也会有 普适性 : 一人封装,万人可用,这也是类库生态的基石。
3. State 状态类的生命周期回调
State 的生命周期回调是非常重要的,但对于初学者来说,目前只能先了解这些回调的作用和使用方式。
initState 回调方法会在 State 对象创建后被触发一次,可以在其中处理成员对象的初始化逻辑。比如动画控制器创建、音频播放器对象的创建等:
dispose 回调方法会在 State 对象销毁前触发一次,用于释放一些资源,比如动画控制器、文字控制器、音频播放器等的销毁工作。
build 回调方法返回 Widget 对象,也就是组件的构建逻辑。该方法可能在一个 State 对象生命之中可能触发多次,通过 State#setState 方法可以导致 build 方法重新执行。
didUpdateWidget 会在 State 对应的组件发生变化时被触发;它在一个 State 对象生命之中也可能触发多次。最常见的场合是上层状态类触发 setState导致组件发生变化该回调中可以访问到旧的组件通常会根据新旧组件的配置属性来触发事件或更新一些状态类内部数据。
现在只需要会用这四个回调即可,想要完全把握 State 状态类的生命周期回调,需要从源码的角度去理解回调触发的流程。希望之后,你可以在后续的路途上,通过 《Flutter 渲染机制 - 聚沙成塔》 找到满意的答案。
4. 界面的跳转
MaterialApp 的内部集成了一套路由体系,在代码中只通过 Navigator.of(context) 来获取 NavigatorState 对象,该对象的 push 方法实现可以跳转界面的功能。下面代码中 MaterialPageRoute 是一个默认的跳转动画界面路由,其中的 builder 方法用来构建跳转到的界面:
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => RecordHistory(
records: _records.reversed.toList(),
),
),
);
二、 技能点
在电子木鱼项目中涉及到了资源的引入以及使用依赖库。依赖库的存在极大方便了开发者。你可以轻松地获取和使用别人封装好的代码来为自己的项目服务比如这里的音频播放器、uuid 的获取以及日期时间的格式化。
1. 资源配置与依赖库的使用
Flutter 项目的配置文件是 pubspec.yaml ,其中依赖库配置在 dependencies 节点下。配置之后记得添加一右上角的 pub get 按钮获取依赖,或者在根目录下的命令行中输入:
flutter pub get
获取依赖之后,就可以导入包中的文件,然后使用其中的类或方法:
import 'package:flame_audio/flame_audio.dart';
图片、文本等本地资源需要在 flutter 节点下的 asstes 下配置,一般习惯于将资源文件放在项目根目录的 assets 文件夹中。配置时只需要写文件夹,就可以访问期内的所有文件:
2. 多种动画变换
木鱼在点击时,当前功德文字会以 缩放+移动+透明度 动画变化,代码中通过多个 XXXTransition 嵌套实现的。除了这三种,还有些其他的 Transition ,在使用时都需要使用者提供相关类型的动画器:
这里想强调的是 基于一个动画控制器,可以通过 Tween 来生成其他类型的动画器 Animation ,它们共用一个动力源。也就是说 controller 启动时,这些动画器都会进行动画变化:
3. 短音效的播放
flame_audio 插件可以实现音频播放的功能,使用起来也非常简单:通过 FlameAudio.createPool 静态方法,指定资源创建 AudioPool ,执行 start 即可。
AudioPool pool = await FlameAudio.createPool(src, maxPlayers: 1);
pool.start();
Flutter 中音频播放还有一个 audioplayers 库,其实 flame_audio 是对该库的一个上层封装,引入了缓存池,在播放短音效的场景中更好一些。我也试过 audioplayers ,感觉连续点击短音效有一点杂音,而 flame_audio 表现良好。
4. 唯一标识 uuid
有些时候,我们需要用一个成员属性来标识对象的唯一性, 一般应用通过 uuid 获取标识符可以视为唯一的。这里使用 uuid 的目的是为每次功德的增加记录提供一个身份标识。
final Uuid uuid = Uuid();
String id = uuid.v4();
得益于 Flutter 的良好生态环境,像这些基础功能,都已经有了依赖库,所以两行代码就能完成需求。以后有通用的能需要求,可以在 pub 中看一下有没有依赖库。当然,你有好的功能代码,也可以创建依赖库,提交到 pub 中,供大家一起使用。
三、接触的内置组件
最后来整理一下目前电子木鱼项目中用到的 Flutter 内置组件,大家可以根据下表,结合源码以及应用界面,思考一下这些组件的作用和使用方式:
1. 基础组件
组件名称
功能
猜数字中的使用
Image
图片展示
展示木鱼图片
GestureDetector
手势事件监听器
监听点击图片事件,处理敲击逻辑
ElevatedButton
升起按钮
左上角选择音频和木鱼样式和按钮
ScaleTransition
缩放变换
功德数字缩放动画
SlideTransition
移动变换
功德数字移动动画
FadeTransition
透明度变换
功德数字透明度动画
Material
材料组件
为选择界面提供材料设计默认主题
2. 组合结构型
组件名称
功能
猜数字中的使用
ListTile
列表条目通用结构
音频选项以及列表条目
3. 布局组件
组件名称
功能
猜数字中的使用
Expanded
Row/Column 中延展区域
主页面上下平分区域
Positioned
Stack 中定位组件
左上角按钮的定位
Wrap
包裹若干组件
左上角两个按钮的竖直包裹
Padding
设置内边距
木鱼样式选择界面中的布局
SizedBox
尺寸设置
选择界面的高度设置
ListView
滑动列表
历史记录界面
最后想说一下关于木鱼项目的演变:当前项目的功能有点击发声、切换图片、数据记录。其实可以基于此完成另一个小项目,比如电子宠物饲养类型的应用,可以喂食,抚摸发出叫声,切换宠物等操作。这和点击敲木鱼是殊途同归的,有时间和兴趣的可以自己尝试一下。

View File

@ -0,0 +1,132 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
18 白板绘制界面交互与需求分析
1. 为什么需要 Canvas
之前我们都是通过 Flutter 已有的组件进行组合来构建界面,这种组合式的界面构建方式虽然方便易用,但是也有一些局限性。有些特殊场景的展示效果通过内置的组件拼组很难实现,比如在画板上进行绘制、特殊图案、统计图表的展示等。我们需要一种自己控制绘制逻辑,来完成视图表现的手段,这就是 Canvas 绘制体系。
任何一种界面交互的开发框架,无论是 Web、 还是 Android、iOS 、还是桌面端的界面开发,都会提供 Canvas 让使用者更灵活地控制界面展现。同时,各种平台的 Canvas 操作接口基本一致,所以这项技能一通百通。绘制相当于通过编程来画画,是一项创造性的活动,具有很大的发散空间。
其实,本质上来说 Widget 之所以能展示出来,底层也是依赖于 Canvas 绘制实现的,所以它也不是 Flutter 体系中的异物。掌握 Canvas 的绘制,就能实现更多复杂的界面展示需求,是一个门槛比较高的技能。本教程只是简单的认识,并不能做系统的介绍。如果对绘制感兴趣,可以研读我的小册 《Flutter 绘制指南 - 妙笔生花》
2. 界面交互介绍
白板绘制是本教程的第三个案例,相比于前两个项目,可操作性更强一些,也更有趣。适合新手朋友进一步了解 Flutter 绘制相关知识,体会其创造性,绘制过程中练习 Dart 语法也是个不错的选择。下面是两个最基础的交互:
通过监听用户的拖拽手势,让界面留下触点的线条痕迹。
可以选择绘制时线条的颜色和粗细两个配置项。
画板绘制
颜色和线宽选择
对于界面绘制内容的管理,提供了两个功能:
当界面中存在绘制的线条时,可以回退上一步;在有回退历史时,可以撤销回退。
右上角的清除按钮,点击时会弹出对话框确认清除,完成清空绘制的功能。
回退和撤销
清除内容
3. 白板绘制需求分析
现在从数据和界面的角度,来分析一白板绘制中的需求:
用户手指拖拽绘制线条
在这个需求中,对于数据来说:线由多个点构成,并且每条线有颜色和粗细的属性,可以通过如下 Line 类型维护点集数据。那么多条线就是 Lines 列表,列表中的元素会根据用户的拖拽操作进行更新。
class Line {
List<Offset> points;
Color color;
double strokeWidth;
Line({
required this.points,
this.color = Colors.black,
this.strokeWidth = 1,
});
}
和木鱼项目选择类似,这里的颜色和线宽,也需要给出支持的选项列表,以及激活的索引值。用户在点击选项条目时更新激活索引数据,在手指拖拽开始是,添加的 Line 宽度和颜色使用激活的数据即可。
对于界面来说,绘制功能通过 CustomPaint 组件 + CustomPainter 画板实现,拖拽手势的监听使用 GestureDetector 组件实现。颜色和线宽的选择器,通过 Flutter 内置的组件来封装。
回退撤销清空功能
这三个功能点,都是对线列表数据的维护。回退是将线列表末尾元素移除;由于要撤销回退,需要额外维护被回退移除的元素,撤销回退时再加回线列表中。
对于界面来说,需要注意按钮可操作性的限制,比如当线列表为空时,无法向前回退:
当界面上有内容时,才允许点击左侧按钮回退。撤销按钮同理,只有回退历史中有元素,才可以操作。
这就是白板绘制的需求分析,这个案例的核心是 CustomPaint 组件 + GestureDetector 组件的使用。在数据维护的过程中,是练习语法的好机会,另外它交互性比较强,使用趣味性上是最好的,可以让家里的小朋友随便画画 (如下小外甥女作品),下面一起开始第二个小项目的学习吧!
标题

View File

@ -0,0 +1,233 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
19 认识自定义绘制组件
1.从绘制点开始说起
我们先通过绘制点来了解一下 Flutter 中绘制的使用方式。左图中是新建的 Paper 组件作为白板主界面;右图在白板的指定坐标处绘制了四个方形的点:
空白
绘制四个点
Paper 组件如下,由于之后需要进行切换画笔颜色、粗细的操作;涉及到界面中状态的变化,所以这里继承自 StatefulWidget在状态类的 build 方法中,完成界面的构建逻辑。 PaperAppBar 是单独封装的头部标题组件,和之前类似,就不赘述了;现在主要是想让 body 处设置为可绘制的组件。
class Paper extends StatefulWidget {
const Paper({Key? key}) : super(key: key);
@override
State<Paper> createState() => _PaperState();
}
class _PaperState extends State<Paper> {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: PaperAppBar(onClear: _clear,),
body: //TODO
);
}
void _clear() {}
}
我们已经知道, Flutter 中的所有界面呈现,都和 Widget 息息相关,绘制也不例外。在 Flutter 中接触到 Canvas 最常用的方式是 CustomPaint 组件 + CustomPainter 画板组合。下面代码,将 body 设置为 CustomPaint 组件CustomPaint 在构造时传入 painter 参数是 CustomPainter 的子类。
自定义绘制,就是指继承 CustomPainter 完成绘制逻辑,比如这里的 PaperPainter 画板。另外,我们希望画布的尺寸填充剩余空间,可以将 child 指定为 ConstrainedBox ,并通过 BoxConstraints.expand() 的约束。
body: CustomPaint(
painter: PaperPainter(),
child: ConstrainedBox(constraints: const BoxConstraints.expand()),
),
也就是说,对于自定义绘制来说,最重要的是 CustomPainter 子类代码的实现,这里通过 PaperPainter 来完成绘制逻辑。在 paint 回调中,可以访问到 Canvas 对象和画板的尺寸 Size 。
在该方法中通过调用 Canvas 的相关方法就可以进行绘制。比如这里使用 drawPoints 绘制点集,其中需要传入三个参数,分别是:
点的模式 PointMode : 共三种模式, points、lines、polygon
点集 List<Offset> 点的坐标列表
画笔 Paint : 绘制时的配置参数
class PaperPainter extends CustomPainter{
@override
void paint(Canvas canvas, Size size) {
List<Offset> points = const [
Offset(100,100),
Offset(100,150),
Offset(150,150),
Offset(200,100),
];
Paint paint = Paint();
paint.strokeWidth = 10;
canvas.drawPoints(PointMode.points, points , paint);
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) => true;
}
上面点模式是 PointMode.points 也就是绘制一个个点。如下是另外两者模式的效果看起来也很清晰PointMode.lines 会将点集分为若干对没对连接成线PointMode.polygon 会将点依次连接;
PointMode.lines
PointMode.polygon
到这里,就通过 Canvas 完成了一个最基础的点集绘制案例,当前代码位置 paper.dart 。接下来,介绍一下 Paint 对象,看看你的画笔有哪些功能。
2. 简单认识画笔的属性
可以先回想一下,我们现实中画画时用的笔有哪些特性:很自然的可以想到: 颜色、粗细 。 这两个配置项分别对应 strokeWidth 和 color 属性,在绘制之前通过直接 paint 对象设置即可:
Paint paint = Paint();
paint.strokeWidth = 10;
paint.color = Colors.red;
粗细 strokeWidth
颜色 color
之前线间的连接很突兀,可以使用将 strokeCap 设置为 StrokeCap.round 让线编程圆头:
paint.strokeCap = StrokeCap.round;
StrokeCap.butt
StrokeCap.round
这里面向新手,对于绘制的知识也点到为止,能满足当前需求即可,不会进行非常系统的介绍。不过感兴趣的可以看我的 《Flutter 绘制指南 - 妙笔生花》 小册,其中对于绘制方方面面都介绍的比较详细。
3. 简单的基础图形绘制
Canvas 中提供了一些基础图形的绘制,比如 圆形、矩形、圆角矩形、椭圆、圆弧 等,这里简单了解一下。
drawCircle 绘制圆形: 三个入参分别是圆心坐标 Offset、半径 double 、 画笔 Paint。
另外Paint 默认是填充样式,如下左图会填满内部;可以将 style 设置为 PaintingStyle.stroke变成线型模式如下右图
@override
void paint(Canvas canvas, Size size) {
Paint paint = Paint();
canvas.drawCircle(Offset(100, 100), 50, paint);
paint.style = PaintingStyle.stroke;
canvas.drawCircle(Offset(250, 100), 50, paint);
}
drawRect 绘制矩形:两个入参分别是矩形 Rect、画笔 Paint。
drawRRect 绘制圆角矩形:两个入参分别是矩形 RRect、画笔 Paint。
Paint paint = Paint();
paint.style = PaintingStyle.stroke;
paint.strokeWidth = 2;
// 绘制矩形
Rect rect = Rect.fromCenter(center: Offset(100, 100), width: 100, height: 80);
canvas.drawRect(rect, paint);
// 绘制圆角矩形
paint.style = PaintingStyle.fill;
RRect rrect = RRect.fromRectXY(rect.translate(150, 0), 8, 8);
canvas.drawRRect(rrect, paint);
drawOval 绘制椭圆:两个入参分别是矩形 Rect、画笔 Paint。
drawArc 绘制圆弧:五个入参分别是矩形 RRect、起始弧度 double、扫描弧度 double、是否闭合 bool、画笔 Paint。
Paint paint = Paint();
paint.strokeWidth = 2;
// 绘制椭圆
Rect overRect = Rect.fromCenter(center: Offset(100, 100), width: 100, height: 80);
canvas.drawOval(overRect, paint);
// 绘制圆弧
canvas.drawArc(overRect.translate(150, 0), 0, pi*1.3,true,paint);
4. 本章小结
本章主要介绍了如果在 Flutter 中通过 Canvas 自定义绘制内容。界面就相当于一张白纸、绘制接口方法就相当于画笔,使用已经存在的组件固然简单,但学会自己控制画笔绘制内容可以创造更多的精彩。当然,想要精通绘制也不是一朝一夕可以达成的,但凡工艺技能,都是熟能生巧。
这里只是简单认识了在 Flutter 中使用 Canvas 绘制的方式,接下来将结合手势和绘制,完成在手指在界面上拖拽留下痕迹的绘制效果。

View File

@ -0,0 +1,295 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
20 通过手势在白板上绘制
1. 白板绘制思路分析
对功能需求的分析主要从 界面数据信息、交互与数据维护、界面构建逻辑 三个方面来思考。
数据信息
界面上需要呈现多条线,每条线由若干个点构成,另外可以指定线的颜色和粗细。所以这里可以封装一个 Line 类维护这些数据:
class Line {
List<Offset> points;
Color color;
double strokeWidth;
Line({
required this.points,
this.color = Colors.black,
this.strokeWidth = 1,
});
}
然后需要 List<Line> 线列表来表示若干条线;由于支持颜色和粗细选择,需要给出支持的颜色、粗细选项列表,以及两者的激活索引:
List<Line> _lines = []; // 线列表
int _activeColorIndex = 0; // 颜色激活索引
int _activeStorkWidthIndex = 0; // 线宽激活索引
// 支持的颜色
final List<Color> supportColors = [
Colors.black, Colors.red, Colors.orange,
Colors.yellow, Colors.green, Colors.blue,
Colors.indigo, Colors.purple,
];
// 支持的线粗
final List<double> supportStorkWidths = [1,2, 4, 6, 8, 10];
交互与数据维护
线列表数据的维护和用户的拖拽事件息息相关:
用户开始拖拽开始时,需要创建 Line 对象,加入线列表。
用户拖拽过程中,将触点添加到线列表最后一条线中。
用户点击清除时,清空线列表。
用户可以通过交互选择颜色,更新颜色激活索引。
用户可以通过交互选择线宽,更新线宽激活索引。
界面构建逻辑
在该需求中的数据和交互分析完后,就可以考虑界面的构建逻辑了。下面是一个示意简图:
使用 Scaffold + Appbar 组件构建整体结构。
使用 CustomPaint 组件构建主体内容,作为可绘制区域。
通过 GestureDetector 组件监听拖拽手势,维护数据变化。
通过 Stack 组件将画笔颜色和线粗选择器叠放在绘制区上。
画笔颜色和线粗选择器的构建将在下一篇详细介绍。
到这里,主要的数据和交互,以及实现的思路就分析的差不多了,接下来就进入项目代码的编写。
2. 手势交互与数据维护
如下,新建一个 paper 文件夹,用于盛放白板绘制的相关代码。其中:
model.dart 中盛放相关的数据模型,比如 Line 类。
paper_app_bar.dart 是抽离的头部标题组件。
paper.dart 是白板绘制的主界面。
首先在 _PaperState 中放入之前分析的数据:
想要监听用户的拖拽手势,可以使用 GestureDetector 组件的 pan 系列回调。如下所示,在 CustomPaint 之上嵌套 GestureDetector 组件;通过 onPanStart 可以监听到用户开始拖拽那刻的事件、通过 onPanUpdate 可以监听到用户拖拽中的事件:
body: GestureDetector(
onPanStart: _onPanStart,
onPanUpdate: _onPanUpdate,
child: CustomPaint(
//略同...
),
)
void _onPanStart(DragStartDetails details) {}
void _onPanUpdate(DragUpdateDetails details) {}
回调中的逻辑处理也比较简单,在开始拖拽时为线列表添加新线;此时新线就是 _lines 的最后一个元素,在拖拽中,为新线添加点即可:
// 拖拽开始,添加新线
void _onPanStart(DragStartDetails details) {
_lines.add(Line(points: [details.localPosition],));
}
// 拖拽中,为新线添加点
void _onPanUpdate(DragUpdateDetails details) {
_lines.last.points.add(details.localPosition);
setState(() {
});
}
3. 画板的绘制逻辑
在 PaperPainter 中处理绘制逻辑,绘制过程中需要线列表的数据,而数据在 _PaperState 中维护。可以通过构造函数,将数据传入 PaperPainter 中,以供绘制时使用:
class PaperPainter extends CustomPainter {
PaperPainter({
required this.lines,
}) {
_paint = Paint()
..style = PaintingStyle.stroke
..strokeCap = StrokeCap.round;
}
late Paint _paint;
final List<Line> lines;
@override
void paint(Canvas canvas, Size size) {
for (int i = 0; i < lines.length; i++) {
drawLine(canvas, lines[i]);
}
}
///根据点集绘制线
void drawLine(Canvas canvas, Line line) {
_paint.color = line.color;
_paint.strokeWidth = line.strokeWidth;
canvas.drawPoints(PointMode.polygon, line.points, _paint);
}
@override
bool shouldRepaint(CustomPainter oldDelegate) => true;
}
绘制逻辑在上面的 paint 方法中,便历 Line 列表,通过 drawPoints 绘制 PointMode.polygon 类型的点集; Line 对象中的颜色和边线数据,可以在绘制前为画笔设置对应属性。最后,在 _PaperState 状态类中,当 PaperPainter 对象创建时,将 _lines 列表作为入参提供给画板即可:
————————————————————
————————————————————
--->[_PaperState]----
child: CustomPaint(
painter: PaperPainter(
lines: _lines
),
4.弹出对话框
在点击清除按钮时,清空线列表。一般对于清除的操作,需要给用户一个确认的对话框,从而避免误操作。如下所示:
点击弹框
确认清除
弹出对话框可以使用框架中提供的 showDialog 方法,在 builder 回调函数中创建需要展示的组件。这里封装了 ConformDialog 组件用于展示对话框,将一些文字描述作为参数。这样其他地方想弹出类似的对话框,可以用 ConformDialog 组件,这就是封装的可复用性。
void _showClearDialog() {
String msg = "您的当前操作会清空绘制内容,是否确定删除!";
showDialog(
context: context,
builder: (ctx) => ConformDialog(
title: '清空提示',
conformText: '确定',
msg: msg,
onConform: _clear,
));
}
// 点击清除按钮,清空线列表
void _clear() {
_lines.clear();
setState(() {
});
}
对话框背景是不透明灰色,这种展示效果可以使用 Dialog 组件;弹框整体结构也比较简单,是上中下竖直排列,可以使用 Column 组件;标题、消息内容、按钮这里通过三个函数来构建:
小练习: 自己完成 ConformDialog 中三个构建函数的逻辑。
class ConformDialog extends StatelessWidget {
final String title;
final String msg;
final String conformText;
final VoidCallback onConform;
ConformDialog({
Key? key,
required this.title,
required this.msg,
required this.onConform,
this.conformText = '删除',
}) : super(key: key);
@override
Widget build(BuildContext context) {
return Dialog(
child: Padding(
padding: const EdgeInsets.all(20.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: <Widget>[
_buildTitle(),
_buildMessage(),
_buildButtons(context),
],
),
),
);
}
//...
}
5. 本章小结
本章主要完成白板绘制的基础功能,包括整体布局结构、监听拖拽事件,维护点集数据、以及绘制线集数据。其中蕴含着数据和界面展现的关系,比如,数据是如何产生的、数据在用户的交互期间有哪些变化、数据是如何呈现到界面上的,大家可以自己多多思考和理解。
到这里,界面上的线条会随着手指的拖动而呈现,完成了最基础的功能,当前代码位置 paper。下一篇将介绍一下如何通过交互来修改画笔颜色和粗细让界面的呈现更加丰富。

View File

@ -0,0 +1,308 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
21 白板画笔的参数设置
1. 线粗选择器
如下所示,左下角可以选择线的粗细,激活状态通过蓝色线框表示。在选择之后,激活状态发生变化,绘制时线的宽度也会变化。这个选择器的构建逻辑相对独立,以后也有复用的可能,可以单独抽离为一个组件维护,这里创建 StorkWidthSelector 组件,并将其通过 Stack 组件叠放在画板之上:
线粗 = 1
线粗 = 8
通过选择器在界面上的展示效果,不难看出组件需要依赖的数据有:
支持的线宽列表 List<double>
激活索引 int
条目的颜色 Color
点击条目时的回调 ValueChanged<int>
类定义如下:
class StorkWidthSelector extends StatelessWidget {
final List<double> supportStorkWidths;
final int activeIndex;
final Color color;
final ValueChanged<int> onSelect;
const StorkWidthSelector({
Key? key,
required this.supportStorkWidths,
required this.activeIndex,
required this.onSelect,
required this.color,
}) : super(key: key);
在组件构建逻辑中,主要是便历 supportStorkWidths 列表,通过 _buildByIndex 方法根据索引值构建条目。其中可以通过 index == activeIndex 来确定当前条目是否被激活;再通过激活状态确定是否添加边线:
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: List.generate(
supportStorkWidths.length,
_buildByIndex,
)),
);
}
Widget _buildByIndex(int index) {
bool select = index == activeIndex;
return GestureDetector(
onTap: () => onSelect(index),
child: Container(
margin: const EdgeInsets.symmetric(horizontal: 2),
width: 70,
height: 18,
alignment: Alignment.center,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
border: select ? Border.all(color: Colors.blue) : null),
child: Container(
width: 50,
color: color,
height: supportStorkWidths[index],
),
),
);
}
最后通过 Stack + Positioned 组件,将 StorkWidthSelector 组件叠放在界面的右下角;并通过 _onSelectStorkWidth 作为选择的回调处理界面状态数据的变化逻辑:
处理逻辑很简单,只要更新 _activeStorkWidthIndex 激活索引即可;另外在 _onPanStart 创建 Line 对象时,设置激活线宽即可:
void _onSelectStorkWidth(int index) {
if (index != _activeStorkWidthIndex) {
setState(() {
_activeStorkWidthIndex = index;
});
}
}
void _onPanStart(DragStartDetails details) {
_lines.add(Line(
points: [details.localPosition],
// 使用激活线宽
strokeWidth: supportStorkWidths[_activeStorkWidthIndex],
));
}
到这里,就完成了线条宽度的选择功能,当前代码位置 paper 。
2. 颜色选择器
颜色选择器的原理也是一样,选择激活,在创建 Line 对象时设置颜色。只不过是条目的界面表现不同罢了,条目的构建逻辑也是通过 _buildByIndex 实现的。这里通过圆圈也表示颜色,点击时激活条目,激活状态由外圈的圆形边线进行表示:
标题
class ColorSelector extends StatelessWidget {
final List<Color> supportColors;
final ValueChanged<int> onSelect;
final int activeIndex;
const ColorSelector({
Key? key,
required this.supportColors,
required this.activeIndex,
required this.onSelect,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 8),
child: Wrap(
// crossAxisAlignment: CrossAxisAlignment.end,
children: List.generate(
supportColors.length,
_buildByIndex,
)),
);
}
Widget _buildByIndex(int index) {
bool select = index == activeIndex;
return GestureDetector(
onTap: () => onSelect(index),
child: Container(
margin: const EdgeInsets.symmetric(horizontal: 2),
padding: const EdgeInsets.all(2),
width: 24,
height: 24,
alignment: Alignment.center,
decoration: BoxDecoration(
shape: BoxShape.circle,
border: select ? Border.all(color: Colors.blue) : null
),
child: Container(
decoration: BoxDecoration(
shape: BoxShape.circle,
color: supportColors[index],
),
),
),
);
}
}
同理,在 _PaperState 中,颜色选择器也通过 Stack + Positioned 叠放在画板的左下角;点击回调时处理激活颜色索引数据的更新;以及创建 Line 对象时设置激活颜色:
void _onSelectColor(int index) {
if (index != _activeColorIndex) {
setState(() {
_activeColorIndex = index;
});
}
}
void _onPanStart(DragStartDetails details) {
_lines.add(Line(
points: [details.localPosition],
strokeWidth: supportStorkWidths[_activeStorkWidthIndex],
color: supportColors[_activeColorIndex],
));
}
到这里,颜色选择和线宽选择功能就已经实现了,当前代码位置 paper 。但这里在布局上还有些问题,下面来分析处理一下。
3. 布局分析
这里通过 Positioned 将两块分别叠放在 Stack 的两侧,上面颜色少时没有什么问题。但如果颜色过多,可以发现这种方式的叠放会让后者把前者覆盖住
多颜色时
布局边界
在布局树中可以发现,默认情况下 Positioned 之下的约束为无限约束,也就是子组件想要多大都可以。所以子组件在竖直方向上没有约束,颜色太多时就会溢出的原因。
解决方案方案有很多,其中最简单的是指定 Positioned 组件的 width 参数,在水平方向施加紧约束。如下所示,可以限制 ColorSelector 的宽度等于 240。 ColorSelector 内部通过 Wrap 进行构建,在区域之内会自动换行:
Positioned(
bottom: 40,
width: 240,
child: ColorSelector(
supportColors: supportColors,
activeIndex: _activeColorIndex,
onSelect: _onSelectColor,
),
),
通过布局查看器可以看出,此时 ColorSelector 受到的约束宽度就固定在 240。
另外,我们还可以将 Positioned 提供的约束尺寸设为屏幕宽度,通过 Row 来水平排列,其中 ColorSelector 的宽度通过 Expanded 延展成剩余宽度。当前代码位置 paper
Positioned(
bottom: 0,
width: MediaQuery.of(context).size.width,
child: Row(
children: [
Expanded(
child: ColorSelector(
supportColors: supportColors,
activeIndex: _activeColorIndex,
onSelect: _onSelectColor,
),
),
StorkWidthSelector(
supportStorkWidths: supportStorkWidths,
color: supportColors[_activeColorIndex],
activeIndex: _activeStorkWidthIndex,
onSelect: _onSelectStorkWidth,
),
],
),
),
如果这里不提供 width ,而使用 Row + Expanded 组件的话,就会报错;根本原因是 Positioned 施加了无限约束,而 Row 使用了 Expanded 组件,延展无限的宽度区域是不被允许的:
通过这个小问题,带大家简单认识一下布局中约束的分析。很多布局上的问题,都可以从约束的角度解决。这里点到为止,如果对约束感兴趣,或有很多布局的困扰,可以研读一下我的布局小册: Flutter 布局探索 - 薪火相传
4. 本章小结
本章主要任务是完成白板画笔的参数设置,为用户提供修改颜色和线宽的操作,以便于绘制更复杂多彩的图案。从中可以体会出:新增加一个需求,往往会引入相关的数据来实现功能。比如对于修改颜色的需求,需要引入支持的颜色列表和激活的颜色索引两个数据。所以对于任何功能需求而言,不要只看其表面的界面呈现,更重要的是分析其背后的用户交互过程中的数据变化情况。
下一章,将继续对当前的画板项目进行一些小优化,比如支持回退和撤销回退的功能;以及优化一下点集的收集策略,来尽可能地避免收录过多无用点,减小绘制的压力。

View File

@ -0,0 +1,210 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
22 撤销功能与画板优化
1. 回退与撤销功能需求分析
如下效果,在头部标题栏的左侧添加两个按钮,分别用于 向前回退 和 撤销回退
向前回退: 移除当前线列表中的最后一条线。
撤销回退: 向当前线列表中添加上次回退的线。
向前回退
撤销回退
由于需要 "后悔",所以需要引入一个线列表作为 "后悔药",也就是收集向前回退过程中被抛弃的线。这里在状态类中添加 _historyLines 列表来维护:
List<Line> _historyLines = [];
另外,在界面构建逻辑中,需要注意按钮可操作性的限制,比如当线列表为空时,无法向前回退:
当界面上有内容时,才允许点击左侧按钮回退。撤销按钮同理,只有回退历史中有元素,才可以操作。
2. 回退与撤销界面构建
首先看一下两个按钮的构建逻辑,这里封装一个 BackUpButtons 组件进行维护。其中有两个可空的回调函数,分别用于处理两个按钮的点击事件。当函数为空时,表示当前按钮不可用,呈灰色状态示意,代码中对应的是 backColor 和 revocationColor 颜色的赋值。
IconButton 默认情况下是 48*48 的尺寸,看起来比较大,可以设置 constraints 参数来修改约束,从而控制尺寸。这里用了 Transform 组件,通过 scale 构造让图标按钮沿 Y 轴镜像,就可以得到与右侧对称的效果。
class BackUpButtons extends StatelessWidget {
final VoidCallback? onBack;
final VoidCallback? onRevocation;
const BackUpButtons({
Key? key,
required this.onBack,
required this.onRevocation,
}) : super(key: key);
@override
Widget build(BuildContext context) {
const BoxConstraints cts = BoxConstraints(minHeight: 32, minWidth: 32);
Color backColor = onBack == null?Colors.grey:Colors.black;
Color revocationColor = onRevocation == null?Colors.grey:Colors.black;
return Center(
child: Wrap(
children: [
Transform.scale(
scaleX: -1,
child: IconButton(
splashRadius: 20,
constraints: cts,
onPressed: onBack,
icon: Icon(Icons.next_plan_outlined,color: backColor),
),
),
IconButton(
splashRadius: 20,
onPressed: onRevocation,
constraints: cts,
icon: Icon(Icons.next_plan_outlined, color: revocationColor),
)
],
),
);
}
}
然后将 BackUpButtons 放在恰当的位置即可,由于两个按钮属于头部标题,而头部标题栏的构建逻辑封装在 PaperAppBar 中。这里有两种思路:
将 BackUpButtons 封装在 PaperAppBar 内部,将两个按钮的点击事件继续向上传递。
将左侧组件作为插槽位置,通过构造将组件传入到 PaperAppBar 里进行使用。
其实两者本质上没有区别,只是形式上的差异、这里使用前者,这样对于 PaperAppBar 的使用者而言,只需要在意其中的事件,不需要关注标题中构造逻辑。代码如下:
在使用 PaperAppBar 组件时,为 onBack 和 onRevocation 设置回调处理函数。当 _lines 为空表示不可回退onBack 设为 null 即可,这样在 BackUpButtons 组件构建时就会将左侧按钮置位灰色onRevocation 同理。
appBar: PaperAppBar(
onClear: _showClearDialog,
onBack: _lines.isEmpty ? null : _back,
onRevocation: _historyLines.isEmpty ? null : _revocation,
),
3. 状态数据的维护
最后一步就是在 _back 方法中处理回退的逻辑;在 _revocation 中处理撤销的逻辑。在回退方法中移除 _lines 的最后一个元素,然后让 _historyLines 列表添加移除的线,再更新界面即可:
void _back() {
Line line = _lines.removeLast();
_historyLines.add(line);
setState(() {});
}
在撤销回退方法中移除 _historyLines 的最后一个元素,然后让 _lines 列表添加移除的线,再更新界面即可:
void _revocation() {
Line line = _historyLines.removeLast();
_lines.add(line);
setState(() {});
}
到这里, 向前回退和撤销回退的功能就已经实现了,当前代码位置 paper.dart。虽然现在画板操作时看起开没什么问题但内部是危机四伏的。
4.拖拽更新的频繁触发
如下所示,在拖拽更新的回调 _onPanUpdate 中打印一下日志,会发现它的触发非常频繁。而每触发一次都会像线中添加一个点,就会导致点非常多。
特别是缓慢移动的过程中,会加入很多相近的无用点,不仅占据内存,也会造成绘制的负担。如下所示,右图通过 PointMode.points 模式展示点前的点,可以看出虽然中间点线很短,但非常密集:
PointMode.polygon
PointMode.points
我们可以在收集点时优化一下逻辑,根据与前一点的距离决定加不加入改点,这样可以有效降低点的数量,减缓绘制压力。处理逻辑并不复杂,如下所示,只要校验当前点和线的最后一点的距离,是否超过阈值即可。
void _onPanUpdate(DragUpdateDetails details) {
Offset point = details.localPosition;
double distance = (_lines.last.points.last - point).distance;
if (distance > 5) {
_lines.last.points.add(details.localPosition);
setState(() {});
}
}
阈值越大,忽略的点就越多,线条越不精细,相对来说绘制压力也就越低,需要酌情处理。这里用 5 个逻辑像素,在操作体验上没什么影响,也能达到一定的优化效果。下面是缓慢移动过程中添加点集的情况,可以看出已经避免了点过于密集的问题:
PointMode.polygon
PointMode.points
5. 本章小结
到这里,白板绘制的基础功能就已经完成了,当前代码位置 paper。还有些值得优化和改进的地方比如
现在的线是通过点进行连接的折线,可以通过贝塞尔曲线进行拟合,让点之间的连接更加圆滑;
可以提供一些基础图形的绘制操作,让绘制更加丰富。
现在每次添加点都会将所有的内容绘制一边,随着绘制内容的增加,会带来频繁的复杂绘制。
如何存储绘制的信息到本地,这样即使在退出应用后,也可以在下次开启时恢复绘制的内容。
对这些问题的改进,大家可以在今后的路途中通过自己思考和理解,来尝试解决。接下来,我们将对这三个小项目进行整合,放入到一个项目中。

View File

@ -0,0 +1,303 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
23 应用界面整合
1. 界面整合的需求分析
如下所示,在应用的底部添加导航栏,进行界面间的切换操作。下面从数据和界面的角度对该进行分析:
————————————————————
————————————————————
当前界面中需要添加的数据有:
底部栏的文字、图标资源列表
底部栏的激活索引
在点击底部栏的按钮时,需要更新激活索引,并进行界面的重新构建。这里定义一个 MenuData 类,用于维护标签和图标数据:
class MenuData {
// 标签
final String label;
// 图标数据
final IconData icon;
const MenuData({
required this.label,
required this.icon,
});
}
对于界面构建逻辑来说,这是一个上下结构,上面是内容区域,下面是底部导航栏。所以,可以通过 Column 组件上下排列,其中内容区域通过 Expanded 组件进行延展,内容组件根据激活的索引值构建不同的界面。
2. 代码实现:第一版
Flutter 中提供了 BottomNavigationBar 组件可以展示底部栏,这里单独封装一个 AppBottomBar 组件用于维护底部栏的界面构建逻辑。其中需要传入激活索引、点击回调、菜单数据列表:
class AppBottomBar extends StatelessWidget {
final int currentIndex;
final List<MenuData> menus;
final ValueChanged<int>? onItemTap;
const AppBottomBar({
Key? key,
this.onItemTap,
this.currentIndex = 0,
required this.menus,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return BottomNavigationBar(
backgroundColor: Colors.white,
onTap: onItemTap,
currentIndex: currentIndex,
elevation: 3,
type: BottomNavigationBarType.fixed,
iconSize: 22,
selectedItemColor: Theme.of(context).primaryColor,
selectedLabelStyle: const TextStyle(fontWeight: FontWeight.bold),
showUnselectedLabels: true,
showSelectedLabels: true,
items: menus.map(_buildItemByMenuMeta).toList(),
);
}
BottomNavigationBarItem _buildItemByMenuMeta(MenuData menu) {
return BottomNavigationBarItem(
label: menu.label,
icon: Icon(menu.icon),
);
}
}
然后就是构建整体结构,这里创建一个 AppNavigation 组件来处理。由于激活索引数据需要在交互时改变,并重新构建界面,所以 AppNavigation 继承自 StatefulWidget在状态类中处理界面构建和状态数据维护的逻辑。
class AppNavigation extends StatefulWidget {
const AppNavigation({Key? key}) : super(key: key);
@override
State<AppNavigation> createState() => _AppNavigationState();
}
class _AppNavigationState extends State<AppNavigation> {
int _index = 0;
final List<MenuData> menus = const [
MenuData(label: '猜数字', icon: Icons.question_mark),
MenuData(label: '电子木鱼', icon: Icons.my_library_music_outlined),
MenuData(label: '白板绘制', icon: Icons.palette_outlined),
];
@override
Widget build(BuildContext context) {
return Column(
children: [
Expanded( child: _buildContent(_index)),
AppBottomBar(
currentIndex: _index,
onItemTap: _onChangePage,
menus: menus,
)
],
);
}
void _onChangePage(int index) {
setState(() {
_index = index;
});
}
内容区域的构建使用 _buildContent 方法,根据不同的激活索引,返回创建不同的界面:
index = 0 时,构建猜数字界面;
index = 1 时,构建电子木鱼界面;
index = 2 时,构建白板绘制界面;
Widget _buildContent(int index) {
switch(index){
case 0:
return const GuessPage();
case 1:
return const MuyuPage();
case 2:
return const Paper();
default:
return const SizedBox.shrink();
}
}
}
到这里就完成了点击底部导航,切换界面的功能,当前代码位置: navigation 。 但这种方式处理会有一些问题:伴随着界面的消失,状态类会被销毁;下次再到该界面时会重新初始化状态类,如下所示:
在绘制面板绘制
切换后,状态重置
3. 状态类数据的保持
想要避免每次切换都会重置状态数据,大体上有三种解决方案:
1.使用 AutomaticKeepAliveClientMixin 对状态类进行保活,这种方案只能用于可滑动组件中。这里可以使用 PageView 组件来实现切页并保活的效果。
2.将状态数据提升到上层,比如将三个界面的状态数据都交由 _AppNavigationState 状态类维护。如果直接用这种方式,很容易造成一个超级大的类,来维护很多数据。其实状态管理工具,就是基于这种思路,将数据交由上层维护,同时提供了分模块处理数据的能力。
3.保持数据的持久性,比如将数据保存到本地文件或数据库,每次初始化时进行加载复现。这种方式处理起来比较麻烦,初始化加载数据也需要一点时间。但这种方式在界面不可见时,可以释放内存中的数据。
这里使用 方式 1 来处理是最简单的。在 _buildContent 方法中返回 PageView 组件,并将三个内容界面作为 children 入参,通过 PageController 来控制界面的切换。注意一点:将 physics 设置设置为 NeverScrollableScrollPhysics 可以禁止 PageView 的滑动,如果想要运行滑动切页,可以去除。
---->[_AppNavigationState]----
final PageController _ctrl = PageController();
Widget _buildContent() {
return PageView(
physics: const NeverScrollableScrollPhysics(),
controller: _ctrl,
children: const [
GuessPage(),
MuyuPage(),
Paper(),
],
);
}
void _onChangePage(int index) {
_ctrl.jumpToPage(index);
setState(() {
_index = index;
});
}
另外如果期望某个状态类保活,需要让其混入 AutomaticKeepAliveClientMixin, 并覆写 wantKeepAlive 返回 true 。如下是对画板状态类的处理,其他两个同理:
在绘制面板绘制
切换后,状态保活
到这里,就将之前的三个小案例,集成到了一个应用中,并且在切换界面的过程中,可以保持状态数据不被重置。当前代码位置 navigation。
上面可以保证程序运行期间,各界面状态类的保活,但是当应用关闭之后,内存中的数据会被清空。再次进入应用时还是无法恢复到之前的状态,想要记住用户的信息,就必须对数据进行持久化的存储。比如存储为本地文件、数据库、网络数据等,下一篇将介绍数据的持久化存储。
4. 优化一些缺陷
如下所示,左侧是 Column 组件上下排列,当键盘顶起之后,底部会留出一块空白,高为底部导航高度。想解决这个问题,使用 Scaffold 组件即可,它有一个 bottomNavigationBar 的插槽,不会被键盘顶起。
Column 结构
Scaffold 结构
这时,将 _AppNavigationState 的构建方法改为如下代码:
@override
Widget build(BuildContext context) {
return Scaffold(
body: _buildContent(),
bottomNavigationBar: AppBottomBar(
currentIndex: _index,
onItemTap: _onChangePage,
menus: menus,
),
);
}
下面以 AppBar 的主题介绍一下 Flutter 默认配置的能力。项目中希望所有的 AppBar 都是白色背景、状态类透明、标题居中、图标颜色、文字颜色为黑色。
如果每次使用 AppBar 组件就配置一次那代码书写将会非常复杂。Flutter 在主题数据的功能,只要指定主题,其下节点中的对应组件,就会默认使用的配置数据。如下所示,在 MaterialApp 的 theme 入参中可以配置主题数据:
这样,以前使用 AppBar 的地方就不用再配置那么多信息了。比如电子木鱼界面的 AppBar 就可以清爽多了:
这里只是拿 AppBarTheme 举个例子,还有其他很多的主题可以配置,大家可以在以后慢慢了解。
5. 本章小结
本章我们主要将之前的三个小案例整合到了一个项目中,通过底部导航栏 + PageView 实现界面间的切换。另外也就 State 的状态保活进行了简单地认识,这里只是程序运行期间,保证各界面状态类的活性,但是当应用关闭之后,内存中的数据会被清空。再次进入应用时还是无法恢复到之前的状态。
想要永久记住用户的信息,就必须对数据进行持久化的存储。比如存储为本地文件、数据库、网络数据等,在程序启动时进行加载,恢复状态数据。这是应用程序非常重要的一个部分,下一篇将介绍数据的持久化存储。

View File

@ -0,0 +1,477 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
24 数据的持久化存储
一、猜数字项目的配置信息存储
在猜数字项目中,界面的状态数据有三个:
数据名
类型
含义
_guessing
bool
是否在猜数字游戏中
_value
int
待猜测的数字
_isBig
bool?
是否更大
现在的目的是,在退出应用后:可以继续上次的游戏进程,那么需要记录 _guessing 和 _value 两个数据。对于这种简单的配置数据,可以通过 shared_preferences 插件存储为 xml 配置文件。首先需要添加依赖:
dependencies:
...
shared_preferences: ^2.1.1
1. 单例模式访问对象和存储配置
数据的持久化中,我们需要在很多地方对数据进行读取和写入。这里将该功能封装为一个类进行操作,并提供唯一的静态对象,方便访问。 如下所示,创建一个 SpStorage 的类,私有化构造并提供实例对象的访问途径:
---->[lib/storage]----
class SpStorage {
SpStorage._(); // 私有化构造
static SpStorage? _storage;
// 提供实例对象的访问途径
static SpStorage get instance {
_storage = _storage ?? SpStorage._();
return _storage!;
}
}
2. 配置信息的存储
如下所示,在类中提供 saveGuessConfig 方法用于保存猜数字的配置信息。核心方法是使用 SharedPreferences 对象的 setString 方法,根据 key 值存储字符串。这里通过 json.encode 方法将 Map 对象编码成字符串:
const String kGuessSpKey = 'guess-config';
class SpStorage {
SpStorage._();
// 略同...
SharedPreferences? _sp;
Future<void> initSpWhenNull() async {
if (_sp != null) return;
_sp = _sp ?? await SharedPreferences.getInstance();
}
Future<bool> saveGuess({
required bool guessing,
required int value,
}) async {
await initSpWhenNull();
String content = json.encode({'guessing': guessing, 'value': value});
return _sp!.setString(kGuessSpKey, content);
}
}
由于 SpStorage 提供了静态的单例对象,所以在任何类中都可以通过 SpStorage.instance 得到实例对象。比如下面在 _GuessPageState 中生成随机数时,调用 saveGuessConfig 方法来存储记录,在如下文件中可以看到存储的配置信息:
/data/data/com.toly1994.flutter_first_station/shared_prefs/FlutterSharedPreferences.xml
---->[_GuessPageState#_generateRandomValue]----
void _generateRandomValue() {
setState(() {
_guessing = true;
_value = _random.nextInt(100);
SpStorage.instance.saveGuessConfig(guessing: _guessing,value: _value);
print(_value);
});
}
3. 访问配置与恢复状态
光存储起来,只完成了一半,还需要读取配置,并根据配置来设置猜数字的状态数据。如下所示,在 SpStorage 类中提供 readGuessConfig 方法用于读取猜数字的配置信息。核心方法是使用 SharedPreferences 对象的 getString 方法,根据 key 值获取存储的字符串。这里通过 json.decode 方法将字符串解码成 Map 对象:
class SpStorage {
// 略同...
Future<Map<String,dynamic>> readGuessConfig() async {
await initSpWhenNull();
String content = _sp!.getString(kGuessSpKey)??"{}";
return json.decode(content);
}
}
方便起见,这里在 _GuessPageState 的 initState 中读取配置文件,并为状态类赋值,完成存储数据的回显。在实际项目中,这些配置信息可以在闪屏页中提前读取。
class _GuessPageState extends State<GuessPage> with SingleTickerProviderStateMixin,AutomaticKeepAliveClientMixin{
@override
void initState() {
// 略...
_initConfig();
}
void _initConfig() async{
Map<String,dynamic> config = await SpStorage.instance.readGuessConfig();
_guessing = config['guessing']??false;
_value = config['value']??0;
setState(() {
});
}
这样,在生成数字之后,杀死应用,然后打开应用,就可以看到仍会恢复到之前的猜数字状态中,这就是数据持久化的意义所在。当前代码位置: sp_storage.dart
二、电子木鱼项目的配置信息存储
在电子木鱼项目中,需要存储的配置数据有:
数据名
类型
含义
counter
int
功德总数
activeImageIndex
int
激活图片索引
activeAudioIndex
int
激活音频索引
1. 配置信息的存储
同样,在 SpStorage 中定义 saveMuYUConfig 方法存储木鱼配置的信息。通过 SpStorage 统一对配置信息进行操作,一方面可以集中配置读写的代码逻辑,方便使用,另一方面可以避免在每个状态类内部都获取 SharedPreferences 对象进行操作。
const String kMuYUSpKey = 'muyu-config';
class SpStorage {
// 略...
Future<bool> saveMuYUConfig({
required int counter,
required int activeImageIndex,
required int activeAudioIndex,
}) async {
await initSpWhenNull();
String content = json.encode({
'counter': counter,
'activeImageIndex': activeImageIndex,
'activeAudioIndex': activeAudioIndex,
});
return _sp!.setString(kMuYUSpKey, content);
}
}
然后需要在配置数据发生变化的事件中保存配置,也就是在 _MuyuPageState 类中敲击木鱼、选择音频,选择图片三个场景,这三处的代码位置大家应该非常清楚。为了方便调用,这里写一个 saveConfig 方法来触发。然后操作界面,配置文件中就会存储对应的信息:
--->[_MuyuPageState]---
void saveConfig() {
SpStorage.instance.saveMuYUConfig(
counter: _counter,
activeImageIndex: activeAudioIndex,
activeAudioIndex: _activeAudioIndex,
);
}
2. 配置信息的读取
同理,在 SpStorage 中读取配置信息:
class SpStorage {
// 略同...
Future<Map<String, dynamic>> readMuYUConfig() async {
await initSpWhenNull();
String content = _sp!.getString(kMuYUSpKey) ?? "{}";
return json.decode(content);
}
}
并在 _MuyuPageState 初始化状态回调中,读取配置对状态数据进行设置。
class _MuyuPageState extends State<MuyuPage>
with AutomaticKeepAliveClientMixin {
// 略同...
@override
void initState() {
super.initState();
_initAudioPool();
_initConfig();
}
void _initConfig() async{
Map<String,dynamic> config = await SpStorage.instance.readMuYUConfig();
_counter = config['counter']??0;
_activeImageIndex = config['activeImageIndex']??0;
_activeAudioIndex = config['activeAudioIndex']??0;
setState(() {
});
}
这样,电子木鱼的配置信息就存储和读取的功能就实现完毕了,当前代码位置: sp_storage.dart
小练习:自己尝试完成白板绘制中颜色、线宽配置的数据持久化。
三、通过数据库进行存储
上面属于通过文件的方式来持久化数据,比较适合存储一些小的配置数据。如果想存储大量的数据,并且希望可以进行复杂的查询,最好使用数据库来存储。这里将对木鱼点击时的功德数记录,使用 sqlite 数据库进行存储。不过不会介绍的太深,会创建数据库和表,存储数据、读取数据即可。毕竟数据库的操作是另一门学问,感兴趣的可以系统地学习一下。
1. sqlite 数据库插件
目前来说,最完善的 sqlite 数据库插件是 sqlite , 使用前首先需要添加依赖:
dependencies:
...
sqflite: ^2.2.8+2
对于数据库操作来说,全局提供一个访问对象即可,也可以通过单例模式来处理,如下定义 DbStorage 类:
---->[storage/db_storage/db_storage.dart]----
class DbStorage {
DbStorage._();
static DbStorage? _storage;
static DbStorage get instance {
_storage = _storage ?? DbStorage._();
return _storage!;
}
}
2. 数据库操作对象 Dao
由于应用程序中可能存在多个数据表,一般每个表会通过一个类来单独操作。比如电子木鱼中的功德记录,是对一条条的 MeritRecord 对象进行记录,这里通过 MeritRecordDao 进行维护。在其构造函数中传入 Database 对象,以便在方法中操作数据库。
首先是数据库的创建语句,通过下面的 createTable 方法完成;使用 Database 的 execute 方法执行 sql 语句:
---->[storage/db_storage/dao/merit_record_dao.dart]----
import 'package:sqflite/sqflite.dart';
class MeritRecordDao {
final Database database;
MeritRecordDao(this.database);
static String tableName = 'merit_record';
static String tableSql = """
CREATE TABLE $tableName (
id VARCHAR(64) PRIMARY KEY,
value INTEGER,
image TEXT,
audio TEXT,
timestamp INTEGER
)""";
static Future<void> createTable(Database db) async{
return db.execute(tableSql);
}
}
然后在 DbStorage 中提供 open 方法打开数据库,如果数据库不存在的话 openDatabase 方法会创建数据库,并触发 _onCreate 回调。在其中可以使用 MeritRecordDao 执行数据表创建的逻辑。另外 DbStorage 持有 MeritRecordDao 类型对象,在数据库打开之后,初始化对象:
---->[storage/db_storage/db_storage.dart]----
class DbStorage {
//略同...
late Database _db;
late MeritRecordDao _meritRecordDao;
MeritRecordDao get meritRecordDao => _meritRecordDao;
void open() async {
String databasesPath = await getDatabasesPath();
String dbPath = path.join(databasesPath, 'first_station.db');
_db = await openDatabase(dbPath, version: 1, onCreate: _onCreate);
_meritRecordDao = MeritRecordDao(_db);
}
void _onCreate(Database db, int version) async {
await MeritRecordDao.createTable(db);
}
}
像打开数据库、加载本地资源的操作,在实际项目中可以放在闪屏页中处理。不过这里方便起见,直接程序开始时打开数据库。现在运行项目之后,就可以看到数据库已经创建了:
void main() async{
WidgetsFlutterBinding.ensureInitialized();
await DbStorage.instance.open(); // 打开数据库
runApp(const MyApp());
}
在 AndroidStudio 的 App inspection 中,可以查看当前运行项目在的数据库情况:
3. 数据的存储和读取方法
如下所示,在 MeritRecordDao 中定义 insert 方法插入记录数据;定义 query 方法读取记录列表。
class MeritRecordDao {
// 略同...
Future<int> insert(MeritRecord record) {
return database.insert(
tableName,
record.toJson(),
conflictAlgorithm: ConflictAlgorithm.replace,
);
}
Future<List<MeritRecord>> query() async {
List<Map<String, Object?>> data = await database.query(
tableName,
);
return data
.map((e) => MeritRecord(
e['id'].toString(),
e['timestamp'] as int,
e['value'] as int,
e['image'].toString(),
e['audio'].toString(),
))
.toList();
}
}
插入时需要传入 Map 对象,这里为 MeritRecord 类提供一个 toJson 的方法,以便将对象转为 Map :
class MeritRecord {
final String id;
final int timestamp;
final int value;
final String image;
final String audio;
MeritRecord(this.id, this.timestamp, this.value, this.image, this.audio);
Map<String, dynamic> toJson() => {
"id":id,
"timestamp": timestamp,
"value": value,
"image": image,
"audio": audio,
};
}
4.使用 Dao 完成数据读写功能
前面数据操作层准备完毕之后,使用起来就非常简单了。就剩两件事:
在 _MuyuPageState 中点击时存入数据库。
在 _MuyuPageState 中状态初始化时读取数据。
然后点击木鱼后就可以看到数据表中会存储对于的数据,应用退出之后也能从数据库中加载数据。
四、 本章小结
本章主要介绍使用 shared_preferences 通过 xml 存储配置数据;以及使用 sqflite 通过 sqlite3 数据库存储数据记录。其中也涉及了对单例模式的使用,让程序中只有一个数据的访问对象,一方面可以简化使用方式,另一方面也可以避免多次连接数据库,造成无意义的浪费。
到这里数据的本地持久化就介绍的差不多了,当前代码位置 db_storage.dart 。对于新手而言这算比较复杂的,希望大家可以好好消化。当然这些只是最简单的 Demo 级应用,怎么简单怎么来。对实际项目来说,整体的应用结构,数据维护和传递的方式,逻辑触发的时机都需要认真的考量,本教程只在新手的指引,就不展开介绍了。

View File

@ -0,0 +1,484 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
25 网络数据的访问
一、需求介绍和准备工作
上一章介绍了本地数据的持久化,它可以让应用退出后,仍可以在启动后通过读取数据,恢复状态数据。但如果手机丢了,或者本地数据被不小心清空了,应用就又会 "失忆" 。
1. 本章目的
现在移动互联网已经极度成熟了,将数据存储在远程的服务器中,通过网络来访问、操作数据对于现在的人已经是家常便饭了。比如微信应用中的聊天记录、支付宝应用中的余额、美团应用中的店铺信息、游戏里的资源装备、抖音里的视频评论… 现在的网络数据已经无处不在了。所以对于应用开发者来说,网络请求的技能是必不可少的。
但是学习网络请求有个很大的问题,一般的网络接口都是肯定不会暴露给大众使用,而自己想要搭建一个后端提供网络接口又很麻烦。所以一般会使用开放 api ,我曾建议过掘金提供一套开放 api , 以便写网络相关的教程,但目前还没什么动静。这里就选用 wanandroid 的开发 api 接口来进行测试。
本章目的是完成一个简单的应用场景:从网络中加载文章列表数据,展示在界面中。点击条目时,可以跳转到详情页,并通过 WebView 展示网页。
文章列表
文章详情
2. 界面准备
现在想在底部栏添加一个网络文章的按钮,点击时切换到网络请求测试的界面。只需要在 _AppNavigationState 的 menus 增加一个 MenuData
然后在 PageView 内增加一个 NetArticlePage 组件,用于展示网络文章的测试界面:
新建一个 net_article 的文件夹用于盛放网络文章的相关代码,其中:
views 文件夹盛放组件视图相关的文件,比如主页面、详情页等。
model 文件夹用于盛放数据模型,比如文章数据的封装类。
api 文件夹盛放网络数据请求的代码,在功能上相当于上一章的 storage , 负责读取和写入数据。只不过对于网络数据再说,是存储在服务器上的,需要提供接口来操作。
NetArticlePage 组件现在先准备一下:通过 Scaffold 构建界面结构,由于之前已经提供了 AppBar 的主题,这里直接给个 title 即可,其他配置信息会默认跟随主题。接下来最重要的任务就是对 body 主体内容的构建。
class NetArticlePage extends StatelessWidget {
const NetArticlePage({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('网络请求测试'),
),
body: Container(),
);
}
}
3. 接口介绍
这里只使用一个获取文章列表的如下接口,其中 0 是个可以改变的参数,表示文章的页数:
www.wanandroid.com/article/lis…
通过浏览器可以直接看到接口提供的 json 数据:
使用 json 美化工具可以看出如下的结构,主要的文章列表数据在 data["datas"] 中 :
每条记录的数据如下,其中数据有很多,不过没有必要全都使用。这里展示文章信息,只需要标题 title 、地址 link 、 时间 niceDate 即可。
{
"adminAdd": false,
"apkLink": "",
"audit": 1,
"author": "",
"canEdit": false,
"chapterId": 502,
"chapterName": "自助",
"collect": false,
"courseId": 13,
"desc": "",
"descMd": "",
"envelopePic": "",
"fresh": true,
"host": "",
"id": 26411,
"isAdminAdd": false,
"link": "https://juejin.cn/post/7233067863500849209",
"niceDate": "7小时前",
"niceShareDate": "7小时前",
"origin": "",
"prefix": "",
"projectLink": "",
"publishTime": 1684220135000,
"realSuperChapterId": 493,
"route": false,
"selfVisible": 0,
"shareDate": 1684220135000,
"shareUser": "张风捷特烈",
"superChapterId": 494,
"superChapterName": "广场Tab",
"tags": [],
"title": "Dart 3.0 语法新特性 | Records 记录类型 (元组)",
"type": 0,
"userId": 31634,
"visible": 1,
"zan": 0
}
4.数据模型的封装
这样,可以写出如下的 Article 类承载数据,并通过一个 formMap 构造通过 map 数据构造 Article 对象。
class Article {
final String title;
final String url;
final String time;
const Article({
required this.title,
required this.time,
required this.url,
});
factory Article.formMap(dynamic map) {
return Article(
title: map['title'] ?? '未知',
url: map['link'] ?? '',
time: map['niceDate'] ?? '',
);
}
@override
String toString() {
return 'Article{title: $title, url: $url, time: $time}';
}
}
二、基础功能的实现
俗话说巧妇难为无米之炊,如果说界面是一碗摆在台面上的饭,那数据就是生米,把生米煮成熟饭就是组件构建的过程。所以实现基础功能有两大步骤: 获取数据、构建界面。
1. 网络数据的请求
网络请求是非常通用的能力,开发者自己来写非常复杂,所以一般使用三方的依赖库。对于 Flutter 网络请求来说,最受欢迎的是 dio , 使用前先添加依赖:
dependencies:
...
dio: ^5.1.2
下面看一下最简单的使用,如下在 ArticleApi 中持有 Dio 类型的 _client 对象,构造时可以设置 baseUrl 。然后提供 loadArticles 方法,用于加载第 page 页的数据,其中的逻辑处理,就是加载网络数据的核心。
使用起来也很方便,提供 Dio#get 方法就可以异步获取数据,得到之后,从结果中拿到自己想要的数据,生成 Article 列表即可。
class ArticleApi{
static const String kBaseUrl = 'https://www.wanandroid.com';
final Dio _client = Dio(BaseOptions(baseUrl: kBaseUrl));
Future<List<Article>> loadArticles(int page) async {
String path = '/article/list/$page/json';
var rep = await _client.get(path);
if (rep.statusCode == 200) {
if(rep.data!=null){
var data = rep.data['data']['datas'] as List;
return data.map(Article.formMap).toList();
}
}
return [];
}
}
2. 文章内容界面展示
这里单独创建一个 ArticleContent 组件负责展示主题内容,由于需要加载网络数据,加载成功后要更新界面,使用需要使用状态类来维护数据。所以让它继承自 StatefulWidget
class ArticleContent extends StatefulWidget {
const ArticleContent({Key? key}) : super(key: key);
@override
State<ArticleContent> createState() => _ArticleContentState();
}
对于状态类来说,最重要数据是 Article 列表build 构建逻辑中通过 ListView 展示可滑动列表,其中构建条目时依赖列表中的数据:
class _ArticleContentState extends State<ArticleContent> {
List<Article> _articles = [];
@override
Widget build(BuildContext context) {
return ListView.builder(
itemExtent: 80,
itemCount: _articles.length,
itemBuilder: _buildItemByIndex,
);
}
Widget _buildItemByIndex(BuildContext context, int index) {
return ArticleItem(
article: _articles[index],
onTap: _jumpToPage,
);
}
}
另外这里单独封装了 ArticleItem 组件展示条目的单体,效果如下,大家可以自己处理一下,这里就不放代码了,处理不好的话可以参考源码。
最后只要在 initState 回调中通过 ArticleApi 加载网络数据即可,加载完成后通过 setState 更新界面:
ArticleApi api = ArticleApi();
@override
void initState() {
super.initState();
_loadData();
}
void _loadData() async{
_articles = await api.loadArticles(0);
setState(() {
});
}
到这里,最基础版的网络请求数据,进行界面展示的功能就完成了。当然现在的代码还存在很大的问题,下面将逐步进行优化。
————————————————————
————————————————————
3. 在应用中展示 Web 界面
文章数据中有一个链接地址,可以通过 WebView 来展示内容。同样也是使用三方的依赖库 webview_flutter 。 使用前先添加依赖:
dependencies:
...
webview_flutter: ^4.2.0
使用起来来非常简单,创建 WebViewController 请求地址,然后使用 WebViewWidget 组件展示即可:
class ArticleDetailPage extends StatefulWidget {
final Article article;
const ArticleDetailPage({Key? key, required this.article}) : super(key: key);
@override
State<ArticleDetailPage> createState() => _ArticleDetailPageState();
}
class _ArticleDetailPageState extends State<ArticleDetailPage> {
late WebViewController controller;
@override
void initState() {
super.initState();
controller = WebViewController()
..setJavaScriptMode(JavaScriptMode.unrestricted)
..setBackgroundColor(const Color(0x00000000))
..loadRequest(Uri.parse(widget.article.url));
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text(widget.article.title)),
body: WebViewWidget(controller: controller),
);
}
}
最后,在列表界面点击时挑战到 ArticleDetailPage 即可。这样就完成了 Web 界面在应用中的展示,当前代码位置 net_article
void _jumpToPage(Article article) {
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => ArticleDetailPage(article: article),
),
);
}
文章1
文章2
三、功能优化
现在有三个值得优化的地方:
网络加载数据的过程比较慢,加载成功之前文章列表是空的,界面展示空白页体验不好。可以在加载过程中展示 loading 界面。
当前加载数据完后,无法在重新加载,可以增加下拉刷新功能。
现在只能加载一页数据,可以在滑动到底部,加载下一页内容,也就是加载更多的功能。
1. 增加 loading 状态
如下左图在网络上请求时没有任何处理,会有有一段时间的白页;如右图所示,在加载过程中给出一些界面示意,在体验上会好很多。
无 loading 状态
有 loading 状态
其实处理起来也并不复杂,由于界面需要感知加载中的状态,示意需要增加一个状态数据用于控制。比如这里在状态类中提供 _loading 的布尔值来表示,该值的维修事件也很明确:加载数据前置为 true 、加载完后置为 false 。
bool _loading = false;
void _loadData() async {
_loading = true;
setState(() {});
_articles = await api.loadArticles(0);
_loading = false;
setState(() {});
}
上面是状态数据的逻辑处理,下面来看一下界面构建逻辑。只要在 _loading 为 true 时,返回加载中对应的组件即可。如果加载中的界面比较复杂,或想要在其他地方复用,也可以单独封装成一个组件来维护。
@override
Widget build(BuildContext context) {
if(_loading){
return Center(
child: Wrap(
spacing: 10,
direction: Axis.vertical,
crossAxisAlignment: WrapCrossAlignment.center,
children: const [
CupertinoActivityIndicator(),
Text("数据加载中,请稍后...",style: TextStyle(color: Colors.grey),)
],
),
);
}
return ListView.builder(
itemExtent: 80,
itemCount: _articles.length,
itemBuilder: _buildItemByIndex,
);
}
这样,就完成了展示界面加载中的功能,当前代码位置 article_content.dart。
2. 下拉刷新功能
如下所示,在列表下拉时,头部只可以展示加载的信息,这种效果组件手写起来非常麻烦。
这是一个通用的功能,好在我们可以依赖别人的代码,使用三方库来实现,这里用的是 easy_refresh。使用前先添加依赖
dependencies:
...
easy_refresh: ^3.3.1+2
使用方式也非常简单,将 EasyRefresh 组件套在 ListView 上即可。在 header 中可以放入头部的配置信息,通过 onRefresh 参数设置下拉刷新的回调,也就是从网络加载数据,成功后更新界面。
3. 加载更多功能
上面只能展示一页的数据,如果需要展示多页怎么办? 一般来说应用在滑动到底部会加载更多,如下所示:
实现起来也非常简单 EasyRefresh 的 onLoad 参数设置下拉回调,加载下一页数据,并加入 _articles 数据中即可。这里一页数据有 20 条,下一页也就是 _articles.length ~/ 20 :
void _onLoad() async{
int nextPage = _articles.length ~/ 20;
List<Article> newArticles = await api.loadArticles(nextPage);
_articles = _articles + newArticles;
setState(() {});
}
四、本章小结
本章主要介绍了如何访问网络数据,实现了文章列表的展示,以及通过 WebView 在应用中展示网页内容,完成简单的文章查看功能。并且基于插件实现了下拉刷新、加载更多的功能。
到这里一个最基本的网络文章数据的展示就实现完成了, 当前代码位置 article_content。也标志着本系列教程进入了尾声还有很多值得优化的地方希望大家再以后的路途中可以自己思考和处理。

View File

@ -0,0 +1,193 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
26 教程总结与展望
1. 教程小结
对于应用程序来说,最重要的有两方面: 数据和界面。
数据的信息为界面提供构建资源
界面的交互让数据内容发生变更
学完本教程,希望大家可以意识到 Flutter 中界面由 Widget 派生类来决定;而组件实例化时的配置信息就是界面中所依赖的数据。数据和界面就像一枚硬币的两面,两者相辅相成,缺一不可。用户站在界面的面前,开发者站在数据的面前。作为开发者,我们不能只关注界面,而忽略数据关系。
关于组件(界面)
组件 Widget 作为界面的决定因素,对界面来说,主要就是通过定义组件来处理 构建逻辑(Build Logic) 。在很长一段时间,你自己创建的组件只有 StatelessWidget 和 StatefulWidget 这两个族系。它们可以使用已经存在的组件进行组合,成为新的组件。这种组合形式,可以封装一些构建逻辑,以便复用或拆分结构。
StatefulWidget 依赖 State 状态类完成 build 构建任务,并且 State 有对应的生命周期回调,可以处理相关逻辑;也能使用 setState 方法重新触发当前状态类的构建任务,实现界面更新。
对 State 的认知也伴随你很长的路途,如果希望在 Flutter 有长远的发展,以后有时间和能力时,还是建议从框架底层的源码中去思考状态类的作用,才能看清 Flutter 机器的整个运转流程。
关于数据
数据是界面中依赖的信息,有的数据是死的,在程序运行期间永远不会改变,比如标题的文字、或固定的描述信息。图片、图标等;有些数据是活的,会通过函数进行传递、流动、变化,比如白板中的线列表、木鱼中的功德数、网络文章中的文章列表。
如何获取数据、如果保存数据、如何更改数据,是程序开发者最需要关注的事,数据的维护直接关系到程序功能的正确性。这些处理数据的逻辑可以称为 业务逻辑(Business Logic)。
对于 Flutter 开发者,甚至是任何和界面相关软件的开发者,都需要意识到构建逻辑和业务逻辑这两条命脉。如何合理地维护这两类逻辑,也是今后值得不断深入思考的事。
本教程的总结,我只想点出上面的两点,希望大家可以在以后的路途中铭记于心。
2. 关于 Flutter 组件的认知
Flutter 框架中提供的内置组件估计已经接近 400 个了,每个组件都有各自的特点。把它们一一背下来是不现实的,对于初学者来说,应该注重常用的组件,比如本教程四个案例中涉及到了组件,都可以深入了解一下。组件本质上只是配置信息,通过传参控制界面展示效果,当你了解玩常用组件,其余的都可以一通百通。在日常开发中逐渐接触,把组件看成可以帮里完成构建界面的朋友,而不是不得不接触的敌人。
教程中的四个案例中使用的组件,都没有进行非常细致的介绍。因为:
[1] 并不是非常必要。
考虑到对组件一一介绍起来非常繁琐和无聊,不仅会占据很多的篇幅,而且学起来也很枯燥,很容易拘泥于琐碎的组件而无法对 Flutter 有整体的认知。所以我才构思了四个小项目,让大家在实践中了解它们的存在和使用方式,在交互中体验更有趣。
[2] 已经的历史文章积累。
我在掘金中为很多常用的组件写过专文介绍,大家后期对某个组件感兴趣可以各取所需去了解。专栏地址为 Flutter 组件集录 :
[3] 完善的组件介绍开源项目。
关于Flutter的组件介绍我有一个使用 Flutter 框架实现开源项目 FlutterUnit ,其中收录了 350 多个组件的介绍和使用范例,支持范例的代码查看和分享。并且支持全平台,可以在手机和电脑上安装应用程序来体验:
主页面
详情页
桌面端界面:
想了解 Flutter 里有哪些组件,或通过组件名搜索查看使用方式, FlutterUnit 都是一个很好的选择。现在项目的 star 个数已经 6000 多了,对于 Flutter 开源项目来说算是比较高了,希望大家可以多多支持。
3. 对未来发展方向的展望
一个侠客行走江湖,需要精进的方向有两个,其一是 修炼内力,其二是 修炼招式 。对于开发者来说,理解底层运转的机理就是内力;如何开发出应用程序就是招式。使用刚步入江湖时,修炼的方向各有不同,人各有志,也不必强求。
如果你并不急着打造一个软件产品,在起步时打牢基础,修炼内力,是不错的选择。这时,推荐你去研读我的七本小册,我称之为 "Flutter 七剑",助你在未来的道路上披荆斩棘:
小册名称
发布时间
代码仓库
售价(RMB)
《Flutter 绘制指南 - 妙笔生花》
2020年11月11日
idraw
3.28
《Flutter 手势探索 - 执掌天下》
2021年05月13日
itouch
3.5
《Flutter 动画探索 - 流光幻影》
2021年07月09日
ianim
3.5
《Flutter 滑动探索 - 珠联璧合》
2022年02月10日
iscroll
3.5
《Flutter 布局探索 - 薪火相传 》
2022年03月30日
ilayout
3.5
《Flutter 渲染机制 - 聚沙成塔 》
2022年04月27日
irender
3.5
《Flutter 语言基础 - 梦始之地 》
2022年09月14日
idream
3.5
如果你迫于工作压力,或者急需上手开发项目,最好的方式是找一些开源项目来研究,学习招式。这里推荐几个项目,都是在持续维护更新的:
flutter_deer
gsy_github_app_flutter
FlutterUnit (我的)
RegExpo(我的)
另外,如果你学习编程只是业余的兴趣爱好,可以体验一下基于 flame 开发小型游戏,在游戏中学习可谓乐趣无穷。我也写过一个专栏 《Flutter&Flame 游戏专栏》:
其实不管开始修炼的道路是什么,只要想在这江湖中立足,最终内力和招式都要精进。所以不用过于纠结什么先,什么后,需要什么就去修炼什么。到这里,本教程就已经完结了,感谢大家的观看,以后有缘再见 ~