年轻人的第一个跨平台应用:Flutter 上手指南

干了两年后端,最近才开始接触前端技术,而且我没有选择网页开发三件套。

主要原因是我已经接近了 PyWebIO 的能力极限,前段时间又发现了一个新的 UI 库 Flet,简单跑了下 Demo,看了看实现原理,发现底层是 Flutter。

于是我想着,Flet 目前还不支持封装移动端应用,网页端应用的性能也一般,不如去学一下 Flutter,点一下前端开发技能。

于是十一假期在 B 站上找了个教程,开始边看边写,也读了一些官方文档,到现在有一个月了,总算是刷完了教程(一共 24 小时的视频),学了几十个组件的常用参数,踩了一些坑,自己做出了一点东西。

这是一篇技术文章,读者最好熟悉至少一门编程语言,并对异步编程有基本的了解,如果之前接触过前端开发,上手 Flutter 应该不会太难。

这篇教程中,我们将开发一个跨平台应用,实现对大转盘中奖列表的展示。

Flutter 是什么

一句话介绍:它是 Google 推出的一套跨平台开发框架,可以在桌面端、移动端和网页端上构建高性能的用户界面。

也就是说,我们只需要写一份代码,Flutter 会帮你处理不同平台的底层差异,帮你完成应用打包,最后生成不同平台的可执行文件。

不同于基于网页(HTML5)的跨平台技术,Flutter 应用会被编译成对应平台的机器码,因此拥有更高的性能与更小的内存占用,在优化得当的情况下,可以轻松达到 60 帧甚至更高的帧率。

对于部分依赖原生代码的功能,Flutter 提供了与平台原生语言良好的互操作性,你可以将部分逻辑用原生语言实现,然后整合到 Flutter 中。

闲鱼 App 大量使用 Flutter 进行开发,腾讯和字节跳动也在旗下产品中使用了 Flutter 与原生混合开发的架构。

目前,Google Play 上已经有超过 50 万款应用程序使用 Flutter,它已成为最流行的跨平台开发框架。

环境配置

Flutter 的开发环境相比 Python 略显复杂,因为要支持 Android / iOS 的交叉编译,需要下载对应的 SDK。

这里要预先说明的是,iOS 和 MacOS 应用只能在 MacOS 上构建,但 Android 应用没有这个限制,在 MacOS 上也可以构建。

官方的 安装指南 写的比较详细,可以作为参照。

建议预留 10GB 硬盘空间用于安装,如果要使用 Android 模拟器,空间需求可能会更大。

下面简述 Windows 和 Linux 平台的环境配置过程。

Windows

官方文档:https://docs.flutter.dev/get-started/install/windows

在官方文档中下载最新的 Flutter SDK ,将其解压到一个目录下,尽量避免路径中出现特殊字符。

更新系统环境变量,加入 flutter/bin 文件夹对应的路径。

在命令提示符中输入 flutter --version,看到版本信息输出即配置成功。

现在,你已经可以正常构建 Windows 和 Web 应用了,但如果要构建 Android 应用,还需要配置 Android 开发环境。

下载并安装 Android Studio,这一步是必须的,但在后续的开发过程中,你可以使用任何自己喜欢的编辑器 / IDE。

在安装过程中,会要求你选择 Android SDK 版本,一般选择最新版即可,Flutter 框架的最低支持系统版本与这一选项无关,更高的 SDK 版本有助于提高性能。

参考 这篇文章 下载并安装 Android 设备的 USB 驱动,Flutter SDK 自带了 Android 真机调试所需的 adb 工具,不需要单独下载。

在命令提示符中执行以下命令,根据指引同意 Android SDK 相关许可协议:

flutter doctor --android-licenses

如果你需要设置 Android 模拟器,请参考 官方文档的相应章节。

在手机上打开开发者模式,启用 USB 调试,将手机连接到电脑,之后执行以下命令:

flutter devices

如果能在输出中找到你的 Android 设备,则代表配置无误。

构建 Web 应用程序时,Chrome 浏览器会提供最好的兼容性和体验,但这不是必须的。如果需要,你可以在 这里 下载。

最后,执行以下命令,进行最终的环境自检:

flutter doctor

你会看到类似这样的输出:(以下为 Linux 系统的输出)

Doctor summary (to see all details, run flutter doctor -v):
[✓] Flutter (Channel beta, 3.4.0-34.1.pre, on Manjaro Linux 6.1.0-1-MANJARO, locale zh_CN.UTF-8)
[✓] Android toolchain - develop for Android devices (Android SDK version 33.0.0)
[✓] Chrome - develop for the web
[✓] Linux toolchain - develop for Linux desktop
[✓] Android Studio
[✓] Connected device (2 available)
[✓] HTTP Host Availability

• No issues found!

Linux

如果你想偷懒:使用 Snap 一键完成 Flutter SDK 安装

官方文档:https://docs.flutter.dev/get-started/install/linux

同样的,你需要下载 Flutter SDK,将其解压到一个文件夹下,建议放置在 /home/your_user_name 或其它不需要 Root 权限的目录。

接下来,需要添加环境变量,你可以选择将其加入你的 Shell 配置文件,或通过其它方式完成配置。

完成操作后,source 对应配置文件或重新打开终端,输入 flutter --version,确认环境变量配置无误。

用相同的方式安装 Android Studio,这里你可能会遇到关于 JDK 和 JRE 版本冲突的问题,请善用搜索引擎。

用相同的方式将 Android Studio 的路径加入环境变量,最好一并指定 JAVA_HOME

记得同意许可协议,如果遇到无法找到目录的问题,可通过临时设置环境变量解决。

运行 flutter doctor,确认环境配置无误。

如果你遇到 Linux Toolchain 缺失的问题,请通过包管理器补全相应依赖。

至此,你已经可以构建所有受支持平台的应用程序了,但还有一些事情需要完成。

配置 IDE / 编辑器

Android Studio 对 Flutter 有开箱即用的支持。

对于 VS Code,需要安装 Dart 和 Flutter 插件。

建议一并安装 Awesome Flutter Snippets 插件,它提供了很多 Flutter 代码片段,可以大大提升开发效率。

配置国内镜像源

对于国内开发者,Flutter 默认的软件包源速度可能不理想,这会在一定程度上增加应用构建所需的时间。

在 Windows 下,设置以下环境变量:

PUB_HOSTED_URL=https://pub.flutter-io.cn  
FLUTTER_STORAGE_BASE_URL=https://storage.flutter-io.cn

在 Linux 下,将以下内容添加到 Shell 配置文件:

export PUB_HOSTED_URL=https://pub.flutter-io.cn  
export FLUTTER_STORAGE_BASE_URL=https://storage.flutter-io.cn

更改镜像源后,当有涉及网络请求的操作时,将输出以下提示:

Flutter assets will be downloaded from https://storage.flutter-io.cn. Make sure you trust this
source!

这是正常现象,无需理会。

预下载资源文件

为了提升构建速度,建议在空闲时间或连接 Virtual Private Network 后执行以下命令,预下载 Flutter SDK 资源文件:

flutter precache

运行示例项目

新建一个文件夹,用于存放 Flutter 项目。

在终端中打开该文件夹,执行以下命令,新建一个名为 demo 的项目。

flutter create demo

进入刚刚新建的 demo 文件夹,执行命令:

flutter run

选择对应平台,等待应用构建完成。

你会看到如下所示的应用:

点击右下角的按钮,数字会随之增长。

如果你愿意,现在就可以构建 .apk 文件,安装到 Android 设备上:

flutter build apk

构建完成后,可以在 ./build/app/outputs/flutter-apk/ 找到 .apk 文件。

热重载

在原生 Android 开发中,从修改到新的应用完成编译并在设备上运行,需要几十秒到几分钟的时间。

Flutter 相比原生开发的一个显著优势,就是可以使用热重载快速预览效果。

在触发热重载后,Flutter 会重新编译有变化的文件,并触发所有组件的重新构建。

打开 demo 项目的 lib/main.dart 文件,同时启动应用。

如果你使用 VS Code 进行开发,需要在左侧“运行和调试”面板中运行项目。

找到数字上方的提示语,在我的示例中位于第 99 行,将其改为以下内容:

'您已点击过的次数:'

在 VS Code 中,按下 Ctrl + SCtrl + F5,或在终端中输入 r,会看到以下提示:

Performing hot reload...                                                
Reloaded 1 of 623 libraries in 400ms (compile: 41 ms, reload: 198 ms, reassemble: 112 ms).

同时,你会发现应用中的显示发生了变化。

注意,在部分场景下,热重载可能不起作用,此时需要使用热重启,VS Code 快捷键为 Ctrl + Shift + F5,或在命令行输入 R

如果热重启依然不起作用,则需要重新运行项目。

项目目录结构

Flutter 会在创建项目时建立很多文件夹:

androidioswindowsmacoslinuxweb 这些文件夹中存放的是对应平台的文件,如果想编写平台原生代码,或自定义对应平台的构建方式,需要在这些位置修改。

在创建项目时,可以指定项目支持的平台:

flutter create demo --platforms=android,ios

这样可以创建一个只支持 Android 和 iOS 平台的项目,这时,其它平台对应的文件夹不会被创建。

如果你需要在后期添加更多平台的支持,可以使用以下命令:

flutter create . --platforms=windows,macos

test 文件夹内保存了应用测试的相关代码。

build 文件夹内存放着应用的构建缓存和产物。

.gitignore 是版本管理系统的忽略文件。

analysis_options.yaml 是代码风格检查的配置文件。

your-project-name.iml 文件是供 IntelliJ IDEA 使用的,如果你不使用这个 IDE 进行开发,可以忽略。

README.md 文件存放着你的项目简介。

pubspec.yaml 存储着项目依赖、名称、版本等重要配置,我们会在后文中用到这个文件。

.metadatapubspec.lock 是依赖管理的锁定文件,会随着依赖变动自动更新。它们需要被添加到版本管理,以保证协作开发时的环境一致,但一般不需要手动更改其内容。

你的应用程序代码存放在 lib 目录下。

Dart

Dart 是编写 Flutter 应用使用的语言。

它是一个强类型的静态语言,但也可以声明动态类型。

语法方面,更像是 C 和 Javascript 的结合体,支持 asyncawait 异步语法,条件判断语句则使用了 C 中的大括号写法。

Dart 拥有空安全特性,你可以这样定义一个可空的 int

int? a = 1

这样定义一个不可空的 int

int a = 1

使用类型断言,强制要求 int 不能为空:

print(a!)

或只在 int 不为空时才运行对应代码:

print(a?.toString())

Dart 语法并不复杂,读者可以先去阅读基础教程,也可以直接跳过这一部分,在后续的应用开发中逐渐了解。

Flutter Widget

Flutter 中万物皆 Widget,所谓 Widget 就是界面中的一个元素,大到整个应用的基础结构,小到一个文本块,本质上都是 Widget,它们的嵌套与组合构成了整个界面。

例如,这是 Flutter 中显示一个文本块的代码:

Text("Hello World")

将它放置在应用中,是这样的:

文本出现在了左上方,并且应用了默认的样式。

现在我们想让这个元素居中,对应的 Widget 是 Center,我们将 Text 组件用 Center 包裹,在 Flutter 中,组件嵌套时需要传入单个子组件的属性名一般为 child,多个子组件则是 children,因此,我们将代码改成这样:

Center(
    child: Text("Hello World"),
),

如果想让文字变成黑色,并且去掉下划线呢?

Text 组件有一个 style 属性,我们只需要定义这个属性,传入一个 TextStyle 对象,即可实现这一功能,代码如下:

Center(
  child: Text(
    "Hello World",
    style: TextStyle(
      color: Colors.black,
      decoration: TextDecoration.none,
    ),
  ),
),

你能想到的一切界面元素,都可以用类似的方式实现,你也可以自己定义新的组件,例如为“透明度为 50%,颜色为浅蓝色,显示加号图标的按钮”单独定义一个组件,避免在代码中重复设置这些参数,我们在后文也会定义自己的组件。

现在,你可以尝试开发自己的第一个 Flutter App 了,而且是可以解决实际问题的那种。

最基础的页面

创建项目

由于我已经写好了这个程序的源码,下面的例子中,我将用 demo 这个项目进行演示。

创建项目的过程在上文中已经提到过,这里不再赘述。为了避免项目中出现无用的文件夹,如果你使用的并不是 MacOS,创建项目时可以取消对 MacOS 和 iOS 平台的支持。

用你喜欢的编辑器打开项目文件夹,找到 lib/main.dart 文件,运行,应该会看到和上文相同的计数器示例应用。

main.dart 是应用的入口,你的应用会从这里开始执行。

导入依赖

官方示例代码中包含大量的注释,一共有一百多行,我们将 main.dart 删除,重新创建一个同名文件,一切从头开始。

首先,我们需要导入依赖库,如果你安装了上文中提到的代码片段扩展,输入 importM 即可。

import 'package:flutter/material.dart';

后面,你还会用到更多的依赖库。构建发布包时,第三方库中没有被使用到的代码会被自动裁剪,不必担心应用大小快速膨胀。

主函数

和 C、Java 等编程语言一样,我们的程序需要一个 main 函数,它看起来是这样的:

void main() {
  runApp(const MyApp());
}

在这里,我们定义了一个无返回值的函数,在里面调用了 runApp 函数,这是前面导入的 Material 库中的函数,作用是运行一个 Flutter 应用。

我们向这个函数传入了一个参数,它是一个 MyApp 类的对象,并且是一个常量。

第一个组件

在你输入这些代码后,将会看到一个报错,提示你 MyApp 未定义,我们来修复这个错误。

这里,我们要创建一个 Stateless Widget(无状态组件),所谓无状态组件,可以理解为只显示内容,不保存动态数据的组件,这种组件会经常被重新构建。

如果你担心频繁构建组件的性能,Flutter 的 Widget 树和 Render 树是分开的,所以并不会导致频繁重绘。

输入 statelessW

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return Container();
  }
}

这段代码可能有些难以理解,我们来逐行解释。

第一行,我们创建了 MyApp 类,extends关键字表明后面的类是 MyApp 类的父类,我们做了一个继承操作,让 MyApp 类拥有 StatelessWidget 类的所有属性和方法。

接下来,我们定义了 MyApp 类的构造函数,大家对 Dart 的语法还不太熟悉,这里定义了一个可选的参数,名为 key,大家暂时不需要了解这个参数的作用。

由于这是一个无状态组件,构造函数返回的对象也是一个常量,因此我们在构造函数前加上 const

接下来,我们覆写(override)了 StatelessWidget 类的 build 方法,该方法用于构建这个 Widget。

组件的构建可能会非常频繁,这个方法的调用频率很高,所以我们不能在里面做耗时的操作,否则会阻塞 UI 线程,造成应用卡顿。

来点内容

默认的代码片段返回了一个 Container 对象,也就是一个容器。

在后续开发中,Container 对象会经常用于需要限制某个组件大小的场景。

由于 MyApp 是整个应用程序的根 Widget,我们要用 MaterialApp 替换 Container,Material Design 是 Google 的设计语言,可以保证应用的美观简洁。

对于 iOS 风的应用,使用 CupertinoApp,但它拥有的参数和 MaterialApp 有一些不同,本文中不会涉及。

MaterialApp 对象中,指定我们应用程序的主页,也就是 home 参数。

当然,你可以在这里传入一个 Container,但更好的选择是传入一个 Scaffold(脚手架)对象,它为我们的应用程序提供了顶栏等基础部件。

最后,在 Scaffold 对象中定义我们应用程序的主体,也就是 body 参数,传入一个 Center 对象,里面包裹一个 Text 对象。

稍微修饰一下这个文本块,使用 Text 对象的 style 属性,设置 fontSize 参数为 36。

如果你使用 VS Code,代码下面会出现一些蓝色波浪线,这是因为我们没有将静态内容声明为常量,这一操作可以避免对象的重复构建,在一定程度上提升应用性能,你可以使用快速修复来解决这个问题,或者直接在 return 后加入一个 const

完整代码如下:

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return const MaterialApp(
      home: Scaffold(
        body: Center(
          child: Text(
            "Hello World",
            style: TextStyle(
              fontSize: 36,
            ),
          ),
        ),
      ),
    );
  }
}

运行应用:

设置顶栏

但应用的顶栏呢?我们还没有设置。在 Scaffold 组件中设置 appBar 属性,传入一个 AppBar 对象。

这时,会出现一些红色波浪线,提示你现在 MaterialApp 不再是静态对象了,需要去掉 const,你可以将 const 移到 body 参数的 Center 对象前。

在 AppBar 对象中,设置 title 参数为 Text 组件,填入文字“Flutter App”。

如果你不知道某个属性对应的数据类型,可以将鼠标悬浮在属性名上查看。

默认情况下,标题是靠左对齐的,所以我们一并传入 centerTitle 参数,值为 true

另外,我们应用程序的右上角还有一个 Debug 标识,它只会在 Debug 模式下显示,如果你希望去掉它,可以为 MaterialApp 组件传入参数 debugShowCheckedModeBanner,值为 false

在上述过程中,你可以随时使用 Ctrl + S 或者 Ctrl + F5 触发热重载,查看更改后的结果。

现在,你的应用是这样的:

完整代码如下:

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Scaffold(
        appBar: AppBar(
          title: const Text("Flutter App"),
          centerTitle: true,
        ),
        body: const Center(
          child: Text(
            "Hello World",
            style: TextStyle(
              fontSize: 36,
            ),
          ),
        ),
      ),
      debugShowCheckedModeBanner: false,
    );
  }
}

界面实现

接下来,我们将着手完成应用程序的界面部分。

我们的应用标题为“大转盘中奖列表”,应用整体色调为红色。

应用的主体部分是一个列表,共有 100 项,每项的内容都是一个卡片,显示一条中奖记录。

右下角有一个刷新按钮,可以获取最新的数据。

顶栏

首先是顶栏部分,很简单,修改 Text 组件的内容即可。

应用主题也很简单,向 MaterialApp 传入一个 theme 参数,值为一个 ThemeData 对象,设置该对象的 primarySwatch 属性为 Colors.red

Colors 对象中提供了很多颜色可供选择。

这里,我们还可以做一件看起来很复杂,但在 Flutter 中很简单的事:为应用适配深色模式。

只需要向 MaterialApp 传入 darkTheme 参数,同样用 ThemeData 对象,将该对象的 brightness 参数设置为 Brightness.dark 即可。

最后设置 themeModeThemeMode.system,剩下的交给框架。

相关代码如下:

theme: ThemeData(
  brightness: Brightness.light,
  primarySwatch: Colors.red,
),
darkTheme: ThemeData(
  brightness: Brightness.dark,
  primarySwatch: Colors.red,
),
themeMode: ThemeMode.system,

现在,你可以将系统切换到夜间模式来查看效果,或者按照上文所述的方法打包安卓应用,在手机上尝试。

浮动按钮

Scaffold 组件中已经提供了浮动按钮对应的参数,设置 floatingActionButton 参数,传入一个 FloatingActionButton 对象,在 child 参数中传入 Icon(Icons.refresh)

这时会有报错出现,因为我们必须为按钮绑定点击回调,向 onPressed 参数传入一个空函数 () {} 即可,你可以使用 VS Code 的快速修复功能。

热重载,可以在右下方看到浮动按钮,而且它也自动使用了红色作为主题色:

列表

接下来我们要实现应用的主体部分:一个列表。

Flutter 中提供了两种可以让元素纵向排列的组件:ColumnListView。在 Column 组件中,超出显示范围(视口)的组件不会被自动销毁,而且不支持动态构建元素,因此在列表中的元素很多时会占用大量内存,并造成应用卡顿。

因此,我们使用 ListView 作为应用的主体部分。

将我们前面在 body 参数中传入的 Center 组件替换成 ListView,设置其 children 参数为一个列表,包含三个 Text 元素,其值分别为 123

可以看到,数字聚集在左上角,可读性不是很好,我们可以使用 Flutter 的 ListTile 组件解决这个问题,它是一个通用的列表项组件。

将三个 Text 组件换成三个 ListTile 组件,分别设置三个 ListTiletitle 参数,传入对应的 Text 组件,效果如下:

卡片

我们完全可以用这个组件进行数据的展示,这也是我最初的设计,但后来发现更好的选择是 Card 组件。

这里我们解析一下的接口,最后筛选出需要展示的数据有这几项:

  • 头像
  • 昵称
  • UID
  • 奖品名称
  • 获奖时间

将列表中的内容换成一个 Card 组件,设置其 child 为一个 Container

在容器中,我们使用 Column 进行垂直布局,上下各放一个 ListTile,上面显示头像、昵称和 UID,下面显示奖品名称和获奖时间。

结合上面学到的知识,我们可以写出如下代码:

Card(
  child: Container(
    child: Column(
      children: const [
        ListTile(
          title: Text("初心不变_叶子"),
          subtitle: Text("UID:19867175"),
          leading: CircleAvatar(
            foregroundImage: NetworkImage(
              "https://upload.jianshu.io/collections/images/1998647/crop1666796378205.jpg"
            ),
          ),
        ),
        ListTile(
          title: Text("奖品名称:收益加成卡1万\n获奖时间:2022-10-29 10:00:01"),
        ),
      ],
    )
  ),
),

这里我们使用 leading 参数设置 ListTile 的头部内容,CircleAvatar 是 Flutter 提供的圆形头像组件,向它传入一个 ImageProvider 对象即可展示圆形头像框,如果要考虑头像加载失败的问题,可以设置 backgroundImagebackgroundColor

Dart 中的换行与其它编程语言一样,都是使用 \n 转义符。

运行效果如下所示:

(夹带私货)

很明显,这个 Card 还不够好看,我们对它进行一点优化:

  • 将昵称的字体大小设为 20
  • 将奖品名称和获奖时间的行高设为 1.6,可以通过 TextStyle 对象的 height 参数实现
  • 调整 Card 组件的阴影值为 3,通过 Card 组件的 elevation 参数实现
  • Card 组件加入适当的圆角效果,设置 Card 组件的 shape 参数,传入一个 RoundedRectangleBorder 对象,设置其 borderRadius 参数为 BorderRadius.circular(20)
  • 调整 Card 组件的外边距,设置 Card 组件的 margin 参数,传入 EdgeInsets.symmetric,设置水平边距为 15,垂直边距为 7
  • 用相同的方式设置 Container 组件的内边距(padding),水平边距为 10,垂直边距为 15

热重载,现在我们的应用是这样的:

相应代码如下:

Card(
  elevation: 3,
  shape: RoundedRectangleBorder(
    borderRadius: BorderRadius.circular(20),
  ),
  margin: const EdgeInsets.symmetric(horizontal: 15, vertical: 7),
  child: Container(
      padding:
          const EdgeInsets.symmetric(horizontal: 10, vertical: 15),
      child: Column(
        children: const [
          ListTile(
            title: Text(
              "初心不变_叶子",
              style: TextStyle(
                fontSize: 20,
              ),
            ),
            subtitle: Text("UID:19867175"),
            leading: CircleAvatar(
              foregroundImage: NetworkImage(
                  "https://upload.jianshu.io/collections/images/1998647/crop1666796378205.jpg"),
            ),
          ),
          ListTile(
            title: Text(
              "奖品名称:收益加成卡1万\n获奖时间:2022-10-29 10:00:00",
              style: TextStyle(
                height: 1.6,
              ),
            ),
          ),
        ],
      )),
),

组件封装

在后文中,我们会大量用到这个组件,所以我们使用 StatelessWidget 类将它封装成 RewardItem 组件,重写它的构造函数,使其根据传入参数显示对应内容,代码如下:

class RewardItem extends StatelessWidget {
  final String userName;
  final int uid;
  final String avatarUrl;
  final String rewardName;
  final DateTime rewardTime;

  const RewardItem({
    super.key,
    required this.userName,
    required this.uid,
    required this.avatarUrl,
    required this.rewardName,
    required this.rewardTime,
  });

  @override
  Widget build(BuildContext context) {
    final String timeString = rewardTime.toString().replaceRange(19, null, "");

    return Card(
      elevation: 3,
      shape: RoundedRectangleBorder(
        borderRadius: BorderRadius.circular(20),
      ),
      margin: const EdgeInsets.symmetric(horizontal: 15, vertical: 7),
      child: Container(
          padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 15),
          child: Column(
            children: [
              ListTile(
                title: Text(
                  userName,
                  style: const TextStyle(
                    fontSize: 20,
                  ),
                ),
                subtitle: Text("UID:$uid"),
                leading: CircleAvatar(
                  foregroundImage: NetworkImage(avatarUrl),
                ),
              ),
              ListTile(
                title: Text(
                  "奖品名称:$rewardName\n获奖时间:$timeString",
                  style: const TextStyle(
                    height: 1.6,
                  ),
                ),
              ),
            ],
          )),
    );
  }
}

这里有几点需要我们注意,首先,构造函数前的 const 表示这是一个常量对象,所以它的所有属性必须用 final 声明为不可变。

在 Dart 中,文本内可以使用 ${var_name} 插入变量,在仅取值,不做任何计算的情况下,大括号可以省略。

Dart 语言规范要求所有变量遵循小驼峰命名。

我不太了解怎么在 Dart 中指定时间字符串的格式,这里直接通过截断字符串解决,反正能跑,管它呢。

接下来,我们可以将 ListViewchildren 参数的列表清空,替换成以下内容:

RewardItem(
  userName: "初心不变_叶子",
  uid: 19867175,
  avatarUrl: "https://upload.jianshu.io/collections/images/1998647/crop1666796378205.jpg",
  rewardName: "收益加成卡1万",
  rewardTime: DateTime.now(),
),

热重载,显示不会发生变化(除了时间字段),但这样的封装可以帮助我们在大型应用程序中做到模块的解耦,有助于提升可读性。

从 API 获取数据

我们要做一个数据展示 App,因此要解析的 API,拿到相应的数据。

大转盘中奖列表的 API 如下:https://www.jianshu.com/asimov/ad_rewards/winner_list。

请求方式是 GET,接收一个 query arguments,名称为 count,控制返回的数据量,这里我们将该参数设置为 100。

安装依赖库

Dart 有很多网络请求库,我们这里使用的是 http 库。

转到 pubspec.yaml,在 dependencies 下加入以下内容:

http:

保存文件,如果你使用 VS Code,插件将自动帮你完成依赖下载,如果你需要手动刷新依赖列表,请运行以下命令:

flutter pub get

发起网络请求

回到 main.dart,在文件开头导入这个库:

import 'package:http/http.dart' as http;

使用 as 关键字,可以为库指定别名。

Dart 中有很多异步操作,网络请求也不例外,所以我们编写一个异步函数,发起网络请求并返回结果:

Future> getData() async {
  var response = await http
      .get(Uri.parse("https://www.jianshu.com/asimov/ad_rewards/winner_list?count=100"));
  List result = convert.jsonDecode(response.body);
  return result;
}

在这里,我们要注意指定返回值的类型,返回值是一个 List,内部有一些键值对,为了降低难度,我们使用 dynamic 将内层声明为动态类型。

如果你需要确保函数编写无误,可以在 MyAppbuild 方法开头插入以下代码:

getData().then((value) => print(value));

热重载,命令行中会输出接口请求的结果。

根据数据更新 UI

由于 Widget 的构建是同步操作,网络请求是异步操作,我们需要一种机制,在数据可用时通知 UI 线程去更新内容。Flutter 为我们提供了 FutureBuilder 组件,可以实现我们需要的功能。

在加入数据列表后,我们的应用具有了状态,它的 Widget 类型也要从 StatelessWidget 变成 StatefulWidget

输入 statefulW,生成的代码片段如下所示:

class MyApp extends StatefulWidget {
  const MyApp({super.key});

  @override
  State createState() => _MyAppState();
}

class _MyAppState extends State {
  @override
  Widget build(BuildContext context) {
    return Container();
  }
}

我们需要将原先 StatelessWidget 中的代码拷贝到 _MyAppState 类中。

热重载应用,应该不会看到任何变化。

接下来,我们在 _MyAppState 类中添加一个属性 _dataList,声明如下:

late Future> _listData;

late 关键字表示这个关键字不可空,并且我们将在稍后对它进行赋值。

为了实现数据的动态获取与展示,我们需要使用 ListViewbuilder 构造方法,它可以根据 index 动态构建元素,这样也可以避免单次构建全部元素时造成的卡顿。

我们将 Scafflod 组件的 body 元素清空,改为一个 FutureBuilder 对象。

向这个元素传入 future 参数,值为我们刚刚定义的 _listData 变量。

传入 builder 参数,该参数的值为一个函数,我们将在这里完成解析逻辑。

这个函数的调用频率也很高,不应该在里面执行密集计算逻辑,否则会影响列表构建。

我们可以使用 snapshot.hasData 布尔值来判断是否已经获取到数据。

CircularProgressIndicator 组件可以显示一个圆形加载进度条。

最终,我们的代码如下:

body: FutureBuilder(
  future: _listData,
  builder: (context, snapshot) {
    if (!snapshot.hasData) {
      return const Center(
        child: CircularProgressIndicator(),
      );
    } else {
      return ListView.builder(
        itemCount: 100,
        itemBuilder: (context, index) {
          Map itemData = snapshot.data![index];
          String userName = itemData["user"]["nickname"];
          int uid = itemData["user"]["id"];
          String avatarUrl = itemData["user"]["avatar"];
          String rewardName = itemData["name"];
          int rewardTimestamp = itemData["created_at"] * 1000;
          DateTime rewardTime =
              DateTime.fromMillisecondsSinceEpoch(rewardTimestamp);

          return RewardItem(
            userName: userName,
            uid: uid,
            avatarUrl: avatarUrl,
            rewardName: rewardName,
            rewardTime: rewardTime,
          );
        },
      );
    }
  },
),

我们还需要重载 StatefulWidget 的一个初始化方法,在里面调用我们的异步函数进行数据请求:

@override
void initState() {
  super.initState();
  _listData = getData();
}

这时,需要使用热重启才能让更改生效,在命令行中输入 R,VS Code 中可以使用快捷键 Ctrl + Shift + F5

你将会看到一个圆形的加载指示器,当数据加载完成后,将显示一个由卡片组成的列表。

如果你尝试上下滑动,可能会感到卡顿,因为我们的应用现在运行在 debug 模式下,其代码是使用 JIT(即时编译)执行的,并且开启了大量的调试功能。当构建 release 版本时,所有代码将被打包成对应平台的机器码,运行效率会大大提升。

编写刷新按钮回调

最后,我们还有一个问题需要解决:现在右下角的刷新按钮是无效的。

修改按钮的回调函数,使用 setState 方法更改组件状态,Flutter 将为你处理好其它逻辑。

同时,我们希望给用户一个明确的反馈,因此,我们弹出一个 SnackBar,他将会显示在页面底部,同时我们希望它在显示 2 秒后自动消失。

使用 SnackBar 组件可以实现这一功能。

将以下代码加入到按钮回调中:

ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
  content: Text("数据刷新成功!"),
  duration: Duration(seconds: 2),
));

热重载,点击刷新按钮,应用报错:

FlutterError (No ScaffoldMessenger widget found.
MyApp widgets require a ScaffoldMessenger widget ancestor.
The specific widget that could not find a ScaffoldMessenger ancestor was: MyApp
The ancestors of this widget were: [root]
Typically, the ScaffoldMessenger widget is introduced by the MaterialApp at the top of your application widget tree.)

抽离主页组件

报错信息中提示我们,想要展示 SnackBar,必须要从这个组件的上层找到一个 ScaffoldMessenger,由于我们的应用中,MyApp 直接返回了一个 MaterialAppScaffoldMessenger 是位于组件内的,所以会产生异常。

刚好借这个机会提醒一下大家:即使是单页应用,也建议把页面单独抽离成一个 Widget。

我们创建一个新的 StatelessWidget,名为 MyApp,在其中写入以下代码:

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: const HomePage(),
      debugShowCheckedModeBanner: false,
      theme: ThemeData(
        brightness: Brightness.light,
        primarySwatch: Colors.red,
      ),
      darkTheme: ThemeData(
        brightness: Brightness.dark,
        primarySwatch: Colors.red,
      ),
      themeMode: ThemeMode.system,
    );
  }
}

同时,修改之前的 MyApp 类为 HomePage,对应的 State 类也需要更改。

删除 HomePage 类关于 MaterialApp 的内容,直接返回一个 Scaffold 组件。

这里我们需要手动重启应用,热重载和热重启对这种修改都是无效的。

现在,点击刷新按钮,可以看到有新的数据出现,同时页面下方出现 SnackBar

至此,该应用的所有功能已经完成。

打包前的准备工作

我们需要更改一下应用的名称,转到 pubspec.yaml

将第一行的 demo 改为你喜欢的应用名称,第二行的 description 也可以一并修改。

下方的 version 将影响不同平台构建产物的版本号,切记只能增加不可减少。

X.Y.Z+A,X 代表主版本号,Y 代表次版本号,Z 代表修订版本号,A 是内部的构建版本号。

构建可执行文件

使用以下命令,构建 Windows .exe 程序:

flutter build windows

构建 Linux 二进制文件:

flutter build linux

构建 Android 通用 .apk 文件:

flutter build apk

构建 Android 分包 .apk 文件:

flutter build apk --split-per-abi

打包 Web 应用会遇到跨域请求问题导致无限加载,这部分内容超出了本文的范围,此处不做展开。

Linux 应用程序位于 build/linux/x64/release/bundle 目录下,大小约为 21.3MB。

Android 应用程序位于 build/app/outputs/apk/release/ 目录下,大小如下:

  • 通用包:18.0MB
  • 32 位:6.0MB
  • 64 位:6.4MB

虽然 32 位包更小,但在应用分发过程中,建议使用 64 位包,这样可以获得更好的性能,部分应用商店也会强制要求 64 位包。

后记

这篇文章断断续续写了一周,期间查了很多资料。

九千多字了,应该是我写过第二长的文章,第一是一篇教程。

从环境配置到运行示例应用,介绍基础概念,最后开发一个具有实用价值的应用,我认为这是一篇完整的教程应该做到的。

由于这是一个练手项目,我并没有打算将代码开源到 GitHub 上,如果大家在学习过程中遇到任何问题,或者想索要应用的源代码,欢迎与我简信联系。

这个应用我还会进行一些迭代,等它被打磨的足够完美,再开始我的下一个 Flutter 项目。

跨平台不会取代原生,但它大大降低了应用开发的成本,让独立开发者也可以轻松在不同设备上构建一致的用户体验。

Flutter 将作为我未来的技术发展方向之一。

Happy Coding, Happy Fluttering.

你可能感兴趣的:(年轻人的第一个跨平台应用:Flutter 上手指南)