11 实战项目一:搭建“一言”项目骨架

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

作为本小册的首个实战项目,它的功能很简单,但使用到的技能却不少。我将用 6 讲的篇幅来和大家一起完成整个项目,具体是如何规划的呢?

规划

作为开篇,同样也是作为软件开发流程的重要一环,便是软件源码结构的设计,也是本讲的内容。我将带领大家完成项目源码结构的规划、项目的创建和功能的初步实现。

实际上,一款客户端/前端产品,大体可拆分为两部分:一是UI,二是数据。所以紧接着我将教大家如何从网络上获取和解析数据,并将其摆在正确的位置上。

接下来就是 UI 实现和美化的过程。如上图所示,竖排文本框是 Flutter 组件库不具备的,所以我们要自定义组件。这个环节大家会对"一切皆组件"有更深的认识和体会。

然后便是文本组件的淡入淡出动画,核心知识点是动画特效

再然后便是使用自定义字体对文本进行美化,这里要掌握的是资源文件的使用。资源文件其实不止包括字体,还有图片、音频、视频等等。只要是项目所需的"素材",都算资源。

最后便是软件中普通和暗黑模式的切换。对于暗黑模式,我们都深有体会,很多用户特别喜欢暗色的应用背景。特别是对于 OLED 屏幕,不仅能看到更"纯净"的黑,更能节约设备耗电。所以主题风格的切换是很多程序中都具有的功能,我们自然也不能落下。

下面,就让我们进入本讲的主题:软件设计吧!

源码结构设计

我们不妨先拆解一下这款软件,分析所有要实现的功能点,再根据这些功能点确定实现思路。

为了表述方便、节约篇幅,我这里直接列举出所有的实现需求:

  1. 每次程序启动都显示不同的图片和文字;
  2. 图片和文字作为一个整体,位于整个界面的中央(水平 + 垂直);
  3. 图片要求以圆形展示;
  4. 文本展示的内容部分竖排显示,作者/来源横向显示,字体为"Aa舒云宋";
  5. 文本部分的出现以淡入动画方式进行。
  6. 程序右上角提供普通/暗黑模式的切换按钮。

若要实现上述所有要求,一种简单可行的方式就是将所有实现逻辑都写在 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,尝试从网络上获取资源,并讲获取到的资源显示在正确的位置上,完成所有数据层面的工作。我们下一讲见!

相关推荐
用户2181697049301 小时前
Flutter (十五) CustomScrollView PageView
前端
WILLF1 小时前
Python vs JavaScript 异常处理对比
前端·python
__sjfzllv___1 小时前
在职前端Leader学习/转行 AI Agent -DAY31
前端
岁月留痕1681 小时前
4 Dart 篇:异步(并发)
前端
MartinYeung51 小时前
[论文学习]AdInject:通过广告投放对Web代理发起真实世界黑盒攻击
前端·学习
用户2181697049301 小时前
Flutter (十六) 组件通信
前端
mONESY1 小时前
React 前端如何不傻等后端接口?
前端·javascript·后端
岁月留痕1681 小时前
6 Flutter 篇:Flutter 分层式架构设计
前端
乘风gg1 小时前
AI Coding:从单兵提效到多 Agent 的团队全链路协作模式
前端·ai编程·claude