从本讲开始,我们一起进入第一个实战项目------《一言》的开发。先来看看最终的成品:

作为本小册的首个实战项目,它的功能很简单,但使用到的技能却不少。我将用 6 讲的篇幅来和大家一起完成整个项目,具体是如何规划的呢?
规划
作为开篇,同样也是作为软件开发流程的重要一环,便是软件源码结构的设计,也是本讲的内容。我将带领大家完成项目源码结构的规划、项目的创建和功能的初步实现。
实际上,一款客户端/前端产品,大体可拆分为两部分:一是UI,二是数据。所以紧接着我将教大家如何从网络上获取和解析数据,并将其摆在正确的位置上。
接下来就是 UI 实现和美化的过程。如上图所示,竖排文本框是 Flutter 组件库不具备的,所以我们要自定义组件。这个环节大家会对"一切皆组件"有更深的认识和体会。
然后便是文本组件的淡入淡出动画,核心知识点是动画特效。
再然后便是使用自定义字体对文本进行美化,这里要掌握的是资源文件的使用。资源文件其实不止包括字体,还有图片、音频、视频等等。只要是项目所需的"素材",都算资源。
最后便是软件中普通和暗黑模式的切换。对于暗黑模式,我们都深有体会,很多用户特别喜欢暗色的应用背景。特别是对于 OLED 屏幕,不仅能看到更"纯净"的黑,更能节约设备耗电。所以主题风格的切换是很多程序中都具有的功能,我们自然也不能落下。
下面,就让我们进入本讲的主题:软件设计吧!
源码结构设计
我们不妨先拆解一下这款软件,分析所有要实现的功能点,再根据这些功能点确定实现思路。
为了表述方便、节约篇幅,我这里直接列举出所有的实现需求:
- 每次程序启动都显示不同的图片和文字;
- 图片和文字作为一个整体,位于整个界面的中央(水平 + 垂直);
- 图片要求以圆形展示;
- 文本展示的内容部分竖排显示,作者/来源横向显示,字体为"Aa舒云宋";
- 文本部分的出现以淡入动画方式进行。
- 程序右上角提供普通/暗黑模式的切换按钮。
若要实现上述所有要求,一种简单可行的方式就是将所有实现逻辑都写在 main.dart 中。这种写法的好处就是几乎省去了所有软件设计的过程,直接开干。对于逻辑简单的程序,这种写法似乎更省时省力。但现实的开发工作通常是规模更大、逻辑更复杂的。如此编程只会徒增编写和阅读代码的负担,若要进行维护,更是一场噩梦。
根据软件工程"模块化设计"的理念,将软件中不同的功能点、工具函数等分别按照预定的接口通信方式实现是个不错的解决方案。
想象一下,在项目 A 中需要进行文件的读写,在项目 B 中也需要这个操作。为何不将其单独"抽"出来,以工具类或包的形式引入呢?使用时,按照规定的接口进行参数传递,即可完成相应的工作。日后再也不用重复实现具体细节了,除非需要维护它。
这也就是大家常听到的"造轮子"的过程,而我们要做的,就是留心制造并保留好各种各样的轮子,为复用它们提供可能。一种较为理想的远景就是开发软件就好像搭积木一样,不同的轮子就是不同形状的积木,无论最终要搭成什么样子,只需将手头的轮子进行不同排列组合就完工了。
说到《一言》,我们不妨按照上面 6 点需求进行模块化设计进行代码"分工"。
main.dart 是 Flutter App 的入口,通常会在这里配置软件的一些参数,比如主题、字体、多语言等等,当我们集成一些库的时候,某些库的初始化操作也需要在一开始进行。所以我不太推荐在 main.dart 中包含任何业务有关的代码,只是将一些参数和初始化的操作放到这里就足够了。也就是说 main.dart 的工作是配置参数和初始化。
继续往下分析,《一言》只有一个页面,该页面从宏观上可以拆分为:主题切换按钮 + 圆形的图片 + 文本 三部分组成。
其中,按钮组件有现成的 MaterialButton,圆形的图片组件也有 CircleAvatar 可以直接用。唯独竖排+横排的文本区域没有合适的组件,这就需要我们自己造"轮子"了。所以,我们不妨创建一个名为 vertical_text.dart 的源码文件,用来实现自定义的文本区域,再创建 home_page.dart 的源码文件,就可以用来摆放上述所有三个部分的组件了。也就是说,vertical_text.dart 的工作是实现自定义的文本区域组件 ,home_page.dart 的工作是组织显示所有 UI 组件。
如此一来,《一言》App 的代码结构就逐渐清晰起来。我们需要分别实现三个源码文件:
- main.dart:配置参数和初始化;
- home_page.dart:组织并显示所有 UI 组件;
- vertical_text.dart:实现自定义的文本区域组件。
接下来还有一个步骤,就是将上述三个源码文件分包归类存放。
所谓的分包归类,实际就是创建不同的目录,将它们保存在相应的目录中。这些目录通常会根据其在整个软件中起到的不同作用进行创建。
比如 home_page.dart,它和 UI 直接相关,又代表整个页面,我们就可以创建 lib\ui\page 目录,将其归类到这个目录中;
再比如 vertical_text.dart,它也和 UI 直接相关,是一个自定义的组件,我们就可以创建 lib\ui\widget 目录,将其归类到这个目录中;
而 main.dart,它和具体的业务无关,只是配置参数和初始化等操作,我们甚至可以不将其归类到某个具体的目录,就直接放在 lib 目录中就行了。
当然,如此归类还有一个原因,便是软件需求足够简单。假设某个软件有若干页面,我们或许就应创建 lib\ui\page_a、lib\ui\page_b......而对应每个页面的自定义组件,也应归类到 lib\ui\widget\page_a_widget、lib\ui\widget\page_b_widget......
能不能使用其他的归类方式呢?当然可以!只要能从目录的层次结构和命名上看出它是干什么的就可以了。
到此,整个项目的源码结构就彻底梳理清楚了,如果用树状图来表示 lib 目录的话,它的内部构造将会是:

另一方面,我非常欢迎大家使用不同的源码结构来规划。这种目录层级划分大多是某个开发者个人,或是大部分人都约定俗成的概念,没有绝对的对错。在 A 项目中的"正确"结构,换到 B 项目中未必合适。所以大家也不要将我的规划方式套用在所有的项目上,还要具体问题具体分析才行。
源码结构涉及到这个地步就差不多了,接下来就开始逐步实现它们,让我们从创建一个项目开始。
创建项目
创建一个 Flutter App 工程,相信大家都已经很熟了,我就不再过多赘述了。有一些需要注意的地方,在下图中都用红色框线标注出来了。

项目名无需多言,大家都懂。组织名那里要注意,不要填写全称(com.juejin.flutter_juejin_yiyan),只需要填前面的一部分(com.juejin)就行了,后面的部分会自动补全。Android 和 iOS 的开发语言选择没有固定要求,大家根据自身或所在公司的情况自由选择就好。支持的平台我在此都勾选了,意味着《一言》可在所有的平台上运行。
如此,点击 Finish,静候项目创建完成吧!
初步实现
等待项目创建完成后,先按照我们规划的源码结构创建目录和源码文件。这一步也不用过多描述,我直接上图,大家动手实战后对比一下。

创建好之后,源码文件是空的,我们先来初步实现它们。vertical_text.dart 负责实现自定义组件,我们先不理它,等讲到竖排文本框的时候再说它。这里将重点放在 home_page.dart 和 main.dart 上。
home_page.dart
经过前面的分析,我们知道它包含两部分:即圆形图片 和文字。
css
💡 提示:为何不包含右上角的主题切换按钮呢?这是因为我把主题配置在 main.dart 中,切换主题的动作写在 main.dart 中更方便。所以主题切换按钮的实现将在 main.dart 源码中。
首先实现圆形图片组件:CircleAvatar,具体代码如下:
less
CircleAvatar(
backgroundColor: Colors.white70,
foregroundImage: NetworkImage(
'https://img.xjh.me/random_img.php?type=bg&ctype=nature&return=302'),
radius: 90);
经测试,代码中的网络地址每请求一次,将返回一张不同的图片。于是我们使用这个网络接口实现了每次启动程序,都显示一张不同图片的需求。
你可能会感觉奇怪:为什么要将 NetworkImage 赋给 foregroundImage 参数呢?backgroundColor 又表示什么呢?
这是因为我在使用 CircleAvatar 的时候顺手看了一眼它的"使用说明书":

如上图所示,circle_avatar.dart 源码开头就很清楚地表明当 foregroundImage 加载失败时,backgroundImage 就会加载;如果 backgroundImage 也加载失败时,就会使用 backgroundColor 来显示。
所以,本例的实现效果就是先去网络上获取图片,如果图片获取失败,将会显示颜色为Colors.white70的纯色实心圆。
文本竖排显示需要自定义组件实现,我们暂且先用 Text 组件代替,具体代码如下:
scss
Text("文本显示示例");
接下来,使用 Column 组件将上述两个组件组合在一起,并使用 Center 组件将它们"约束"在屏幕中央。完整代码如下:
scala
class HomePage extends StatefulWidget {
const HomePage({Key? key}) : super(key: key);
@override
State<HomePage> createState() => _HomePageState();
}
class _HomePageState extends State<HomePage> {
Widget imageWidget() {
return const CircleAvatar(
backgroundColor: Colors.white70,
foregroundImage: NetworkImage(
'https://img.xjh.me/random_img.php?type=bg&ctype=nature&return=302'),
radius: 90);
}
Widget textWidget() {
return Text("文本显示示例");
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[imageWidget(), textWidget()],
),
),
);
}
}
这里的 HomePage 组件是一个有状态的组件(StatefulWidget)。大家想想看,为什么不是 StatelessWidget 的呢?
没错!因为我们要从网上获取文本,要调用setState()更新组件状态,这一行为只能使用有状态组件才能实现。
home_page.dart 源码到这一步可以算是初步完工了,接下来让我们回到 main.dart。
main.dart
想必大家已经熟知:main.dart 要干两个活:一是将 HomePage 组件显示出来;二是添加主题切换按钮。
我们首先删除创建项目后默认的非必要逻辑,然后为 Scaffold 的 body 参数传递 HomePage 组件。代码如下:
scala
void main() {
runApp(const MyApp());
}
class MyApp extends StatefulWidget {
const MyApp({super.key});
@override
State<MyApp> createState() => _MyAppState();
}
class _MyAppState extends State<MyApp> {
@override
Widget build(BuildContext context) {
return const MaterialApp(
home: Scaffold(
body: HomePage(),
),
);
}
}
是不是很简单?我知道你已经迫不及待地要看运行效果了,好吧,那就运行起来试试看!

怎么样?看上去和最终效果已经很接近了!
最后,我们用 Stack 组件将 HomePage 和用来切换主题的 MaterialButton 组件结合在一起,代码如下:
scala
void main() {
runApp(const MyApp());
}
class MyApp extends StatefulWidget {
const MyApp({super.key});
@override
State<MyApp> createState() => _MyAppState();
}
class _MyAppState extends State<MyApp> {
bool darkModeOn = false;
@override
Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(
body: Stack(children: [
const HomePage(),
Positioned(
right: 20,
top: 40,
child: SizedBox(
width: 30,
height: 30,
child: MaterialButton(
padding: const EdgeInsets.all(0),
child: Icon(
darkModeOn ? Icons.dark_mode_outlined : Icons.dark_mode,
size: 25),
onPressed: () {
setState(() {
darkModeOn = !darkModeOn;
});
},
),
),
)
]),
),
);
}
}
再次运行程序,我们将会看到如下图所示的界面:

试试点击右上角的按钮,看看效果吧!什么?背景没有变?当然不会变。我们还没讲配置应用主题呢!
好了,到此,整个程序该有的元素就算齐了,接下来就是填充真实数据和调整 UI 效果了。恭喜你!以如此迅速的效率实现了这个小而美的程序的......55% 的工作。
为什么我说是 55% 呢?因为文本内容还是假的,这功能都不全,怎么能及格呢?不过想要从网络获取和解析内容,其实并不难,所以我把它定义为 5% 的工作量。实现了所有功能,也就刚刚及格,后面的"进阶类"工作才是最繁琐和最影响视觉美观度的。
别着急,我们一步一步来。
小结
🎉恭喜,您完成了本次课程的学习!
📌 以下是本次课程的重点内容总结:
本讲在一开始概述了《一言》项目的实战过程,我将用包括本讲在内的 6 讲篇幅带大家从 0 到 1 完成项目的开发。
在软件开发伊始,通常要做的一件事是源码结构的设计。好在这个项目规模小,功能也比较单一。稍加分析,便梳理出来了整个源码的层级结构。
接着,依据上一步梳理出的结果进行编码,搭建项目的整体"骨架"。最终实现的效果,看上去已经和最终的成品很相似了。但仍缺少真实的文本、要求的样式、动画效果、主题切换功能等等。
➡️ 在下次课程中,我们继续开发《一言》App,尝试从网络上获取资源,并讲获取到的资源显示在正确的位置上,完成所有数据层面的工作。我们下一讲见!