17 实战项目二:实现“日记”项目多页面管理

从本讲开始,进入《日记》实战项目的开发部分,这款应用缘起《你的名字》动漫。先来通过下面的 GIF 动画大致了解下最终的"成品"。

从图中可以看到,这其实是在使用 Android 手机运行 iOS 风格的程序。得益于 Flutter 中组件设计上的自由,动图中虽然使用 Android 模拟器运行,但整体程序风格仍保持 iOS 风格该有的样式。除了动图中展示的功能外,首次运行程序还将引导用户完成性别和主题色选择,还有日记编辑功能。

显然,和之前的《一言》程序相比,《日记》则更加复杂。最直观的感受可能就是页面变多了,功能也变多了。

这节课我们会对整体的项目页面结构进行设计与实现,为了实现多页面的组织管理,还要引入一个新的概念------页面导航

设计页面导航

通俗地说,页面导航就是多页面跳转的"地图",它指明了所有页面的所有出口和入口。举例来说,下图便是一个简单的游戏页面导航图设计:

注:该图摘自 Android Developer 网站(设计导航图 | Android 开发者 | Android Developers (google.cn)

可以看到,游戏所有的页面都罗列在导航图中箭头表明了跳转的方向线条表明了跳转的起点和目的地

下面回到《日记》应用,除了调用系统相机页面外,总共包含 4 个页面,分别是:

  • 首次运行时的资料配置页;
  • 浏览日记列表页;
  • 写日记页面;
  • 读日记页面。

💡 提示:为什么其它程序的界面不包含在导航图中呢?这是因为其它程序是如何设计的,我们并不知道,也不太可能知道。我们的可控范围只局限在我们要开发的软件中。

接下来,对比下面两个设计图,大家想想哪一种设计更合理?

仔细观察 A 和 B,它们有两个不同点:

  • 程序启动后的首页不同;
  • A 中,资料配置页面只能单向跳转到日记列表页面,B 中二者可以双向跳转。

可能很多人会选择 A,因为它最符合"流程":软件启动后先进行资料配置,完成后进入日记列表页,在列表页可以查看历史日记(跳转到读日记页面)和写新的日记(跳转到写日记页面)。这套逻辑似乎无可厚非,但真的合适吗?

想象一下,首次启动软件,确实可以按上述逻辑进行。但如果是第二次、第三次呢?用户会感觉很奇怪:我明明之前设置过了各种资料,为什么又让我设置一次?同理,类似有登录/注册功能的软件也是如此。明明登录过,为何又让我登录一次......

一种解决之道(A)是在资料配置页面做判断。

如果之前配置过就直接跳转到日记列表页面。这确实解决了重复配置的问题,但新的问题随之出现:"做判断"的逻辑包含在资料配置页之中,这就意味着该页面无论如何也要出现一次,然后才有可能发生跳转。也就意味着用户无论如何也要面对一次他有可能无需面对的页面......

当然,也不是没有优化方法:如果需要配置,则绘制页面组件;不需要配置则直接跳转,同时无需执行页面跳转动画。天呢!真是太麻烦了,搞不好会引入 Bug......

另一种解决之道(B)是把日记列表页作为起始页,程序启动则立即判断是否已经配置了资料,没有配置的话则跳转到资料配置页面;如果配置过了就呆在这,继续显示日记列表页,哪儿都不跳。

相比起来,B 方案无论在体验方面还是开发上都更优,这也是目前大部分软件都采用的一种方案,但并不是全部。有些安全等级要求高的软件依然需要登录,比如银行、交易类的软件。甚至还会在某个时机跳出手势或面部识别页面进行验证,这就需要大家在设计页面导航时考虑更多条件分支,更为妥善处理了。

页面堆栈

当页面跳转时,旧的页面不会消失。默认情况下,新的页面会在旧页面的"上方","挡住"旧页面,就好像叠罗汉一样。这种"叠罗汉"的结构,被称为"页面堆栈"。要理解"页面堆栈"的概念,最好是"看图说话"。我们一起来看下图:

这个图描绘的是页面跳转的过程,即由页面 A 跳转到页面 B,再跳转到页面 C。同时,存在 "页面堆栈",它"记录"着页面跳转的历史,以栈的形式存在,所有页面的存入和取出都在栈顶进行。这就意味着,当用户从页面 C 进行返回操作时,页面 B 会再次出现。

这样的返回逻辑满足了大部分的场景需求,但总有那么一小撮需求独具特色。

回到《日记》,日记列表页和资料配置页面就属于那"一小撮"。程序运行后,日记列表页会首先显示,当没有配置资料时,跳转到资料配置页面,随后再跳回日记列表页。此时,用户若进行返回操作,在默认情况下,将再次回到资料配置页。再返回的话,又会回到最初的日记列表页......还是看图说话:

要破局,有两种解决之道:

  • 一是不跳转,让当前页面直接出栈。这种方式适用于资料配置页跳转到日记列表页,让资料配置页直接出栈就行了。不过这样做需要"告知"日记列表页:配置发生更改了,你要赶快应用最新的参数;
  • 二是跳转的同时清理堆栈,也就是说跳转后自己成为栈底,之前的页面都不在了。这样一来,无论用户怎么返回,也不会回到错误的页面上。

上述两种方式孰优孰劣要根据实际情况做判定,对于《日记》,显然第二种更简单易行。还是用图来表示:

💡 提示:无需担心返回会直接退出程序。想象一下,在进行首次资料配置时,用户按了返回键,相当于放弃使用。退出程序不正是很合理的响应吗?

使用页面"路由"是实现页面跳转的途径 。可以按照"key-value"的形式理解,key 是路径,value 表示某个页面及跳转处理逻辑 。跳转时,通过key来指明跳转的目的地。同时,还能附带数据、设置跳转动画、设置是否清除 堆栈。具体的实现步骤,稍后将结合实际代码讲解。

创建项目,并添加"占位"页面

我们先创建一个项目,名为 diary。同时别忘了修改默认包名,选中所有支持的平台,修改 Android 开发语言为 Java,iOS 开发语言为 Objective-C。如下图所示:

接着,按照如下图所示的结构创建项目结构。每个文件及目录的意义在下图中都已经标明,供大家参考:

💡 提示:读日记页面去哪儿了?在一开始的动图中可以看到,读日记页面其实是一个对话框。在 Flutter 中,对话框通过 showCupertinoDialog() 或类似函数显示,和全屏页面不同。具体内容将在后面的章节中详述。根据实际需求,读日记对话框的源码位置在 ui\index\widget 中。

可以看到,每个页面都有各自的 widget 目录,在 ui 目录中,还有一个公共的 widget 目录。前者存放的自定义组件仅用于所属的页面中,若某个自定义组件的使用范围超过单个页面,则应将其放在公共 widget 目录中。

日记列表页面包含全部日记列表页面和按日期筛选日记列表页面,源码组织形式结构与此保持一致。

最后,为了确认页面跳转逻辑的正确性,每个页面都暂时摆放一个居中的标题文本框。比如资料配置页面,它的完整代码如下:

dart 复制代码
import 'package:flutter/cupertino.dart';
class AppConfigurationPage extends StatefulWidget {
  const AppConfigurationPage({Key? key}) : super(key: key);
  @override
  State<AppConfigurationPage> createState() => _AppConfigurationPageState();
}
class _AppConfigurationPageState extends State<AppConfigurationPage> {
  @override
  Widget build(BuildContext context) {
    return const CupertinoPageScaffold(child: Center(child: Text('资料配置页面')));
  }
}

依葫芦画瓢,其它页面修改类名和文本框中的文字内容即可,这里就不再详述了。

值得注意的是,写日记页面需要传入日记 id 以便实现编辑功能。当 id 存在时,从数据库加载相应内容,最后更新数据库;当 id 不存在时,保持内容为空,最后向数据库写入新数据。

另外还需创建一个路由出错的页面,用于找不到路由"key"值时的默认跳转目的地(尽管这不太可能发生,但万一呢)。

实战 fluro 路由库的使用

Flutter 框架内置了页面跳转的 API,但为了更高效地编程,我更推荐大家使用成熟的库,fluro 便是其中一款。

集成 fluro 的方法非常简单,在 fluro 库首页就能找到最新的版本,添加到 pubspec.yaml 中即可。我使用的版本号是 2.0.3,相应代码如下:

dart 复制代码
dependencies:
  flutter:
    sdk: flutter
  cupertino_icons: ^1.0.2
  ...
  # 全局路由
  fluro: ^2.0.3
  ...

添加好后,执行一次 flutter pub get 获取包内容。

接下来,在 lib 目录中新建一个 router 目录,用来存放 routes.dart,这个文件中定义了具体的路由跳转路径。

通过阅读官方指导文档,我们了解到路由的定义通过 router.define() 方法实现,该方法需要两个参数,一个是路径,另一个是 handler 类型的变量

前文中提到:我们可以把路由看作是"key-value"结构,key 相当于路径,value 包含某个页面及跳转处理逻辑。换言之,跳转目的地和处理逻辑都在 handler 中

《日记》程序的起始页是日记列表页,对应到代码中是 index.dart。因此,路由如下定义:

dart 复制代码
import 'package:fluro/fluro.dart';
import 'package:flutter/cupertino.dart';
import '../ui/index/index.dart';
//主页面
var indexHandler = Handler(
    handlerFunc: (BuildContext? context, Map<String, List<String>> params) {
  return const IndexPage();
});
class Routes {
  static String indexPage = '/';
  static void configureRoutes(FluroRouter router) {
    router.define(indexPage, handler: indexHandler);
  }
}

对于无需传递参数的页面而言,如此便定义好了路由。而对于需要传递参数的页面而言,则需要做些特定的处理了。比如写日记页面,该页面承担了新增日记和编辑旧日记的功能。其关键在于有无 id 参数。当 id 存在且不为空字符串时为编辑状态;反之则是新增。因此,写日记页面的路由定义如下:

dart 复制代码
import 'package:fluro/fluro.dart';
import 'package:flutter/cupertino.dart';
import '../ui/write_new/write_new.dart';
var writeDiaryHandler = Handler(
    handlerFunc: (BuildContext? context, Map<String, List<String>> params) {
  if (params.isNotEmpty && params['id'] != null) {
    return WriteNewPage(id: params['id']![0]);
  } else {
    return const WriteNewPage(id: "");
  }
});
class Routes {
  static String writeDiaryPage = '/writeDiary';
  static void configureRoutes(FluroRouter router) {
     router.define("$writeDiaryPage/:id", handler: writeDiaryHandler);
  }
}

请大家注意此时 router.define() 方法传入的路径格式。如果你还不清楚如何构建可接受参数的 WriteNewPage 页面,请参考文末的附录。

全部日记列表和按日期筛选日记列表页是日记列表页面的子页面,有日记列表页面的 Tab 结构管理,无需进行路由定义。这样一来,《日记》程序总共需要 4 个页面被路由管理,其中包含 1 个默认跳转页面(或称为找不到地址页面、404 页面......)。

完整的 routes.dart 源码如下所示:

dart 复制代码
import 'package:fluro/fluro.dart';
import 'package:flutter/cupertino.dart';
import '../ui/app_configuration/app_configuration.dart';
import '../ui/write_new/write_new.dart';
import '../ui/default/default_page.dart';
import '../ui/index/index.dart';
//路由出错的默认页面
var errorHandler = Handler(
    handlerFunc: (BuildContext? context, Map<String, List<String>> params) {
      return const DefaultPage();
    });
//程序参数配置页面
var appConfigurationHandler = Handler(
    handlerFunc: (BuildContext? context, Map<String, List<String>> params) {
      return const AppConfigurationPage();
    });
//主页面
var indexHandler = Handler(
    handlerFunc: (BuildContext? context, Map<String, List<String>> params) {
      return const IndexPage();
    });
//写日记
var writeDiaryHandler = Handler(
    handlerFunc: (BuildContext? context, Map<String, List<String>> params) {
      if (params.isNotEmpty && params['id'] != null) {
        return WriteNewPage(id: params['id']![0]);
      } else {
        return const WriteNewPage(id: "");
      }
    });
class Routes {
  static String indexPage = '/';
  static String errorPage = '/error';
  static String writeDiaryPage = '/writeDiary';
  static String appConfigurationPage = '/appConfiguration';
  static void configureRoutes(FluroRouter router) {
    router.define(appConfigurationPage, handler: appConfigurationHandler);
    router.define(indexPage, handler: indexHandler);
    router.define("$writeDiaryPage/:id", handler: writeDiaryHandler);
    router.notFoundHandler = errorHandler;
  }
}

回到 main.dart,按照 fluro 文档中所述创建 FluroRouter 实例(router);然后调用刚刚实现的 Routes 类中 configureRoutes() 方法,将 router 作为参数传入其中;最后,在 CupertinoApp 中将 router.generator 作为 onGenerateRoute 参数的值使用。代码如下:

dart 复制代码
import 'package:diary/router/routes.dart';
import 'package:diary/ui/index/index.dart';
import 'package:fluro/fluro.dart';
import 'package:flutter/cupertino.dart';
final router = FluroRouter();
void main() {
  // 配置路由
  Routes.configureRoutes(router);
  runApp(const MyApp());
}
class MyApp extends StatelessWidget {
  const MyApp({Key? key}) : super(key: key);
  @override
  Widget build(BuildContext context) {
    return CupertinoApp(onGenerateRoute: router.generator, home: const Diary());
  }
}
class Diary extends StatefulWidget {
  const Diary({Key? key}) : super(key: key);
  @override
  State<Diary> createState() => _DiaryState();
}
class _DiaryState extends State<Diary> {
  @override
  Widget build(BuildContext context) {
    return const IndexPage();
  }
}

好了,运行一下程序吧,出现下图所示的界面就代表大功告成了!

最后,如果要实现从日记列表页跳转到资料配置页面,该如何做呢?

答案还是按照 fluro 官方文档的指导即可:

dart 复制代码
router.navigateTo(context, Routes.appConfigurationPage, clearStack: true);

请注意,这里 clearStack 给了 true,意思是清理页面跳转堆栈。该值默认为 false。

在 Flutter 中,setState()、页面跳转等操作都要求当前界面处于 mounted 状态,否则会出现异常。由于页面跳转的时机非常靠前,因此就要确保当前界面为 mounted 状态才行。

Flutter 中提供了 WidgetsBinding.instance.addPostFrameCallback() 回调方法来保证这一点,具体请参考下面的代码片段:

dart 复制代码
@override
void initState() {
  super.initState();
  WidgetsBinding.instance.addPostFrameCallback((_) {
    // 未进行资料配置,跳转到相应页面进行
    if (true) {
      router.navigateTo(context, Routes.appConfigurationPage,
          clearStack: true);
    }
  });
}

再次运行程序,显示如下:

此时在按返回键,程序将直接退出。

总结

🎉 恭喜,您完成了本次课程的学习!

📌 以下是本次课程的重点内容总结:

本讲详细规划设计了《日记》项目中所有页面的跳转逻辑 。具体来说,我们首先设计了"页面导航图 ",它指明了程序的入口页面以及每个页面的所有出口和入口路径 。同时,由于"页面堆栈"机制的存在,还需在必要时清除堆栈

为了实现导航图中所描绘的"愿景",引入了"页面路由 "的概念。使用时的思路类似"key-value"的形式,通过 key 来指明跳转的目的地,value 表示详细的跳转动作 。跳转时还可包含数据、指定跳转动画以及设置是否清除堆栈。fluro 库可以帮我们轻松实现页面跳转。

在项目创建伊始,程序的功能往往并未准备就绪,因此我们使用若干"占位页面 "实现了所有页面跳转。 同时,这些 "占位页面"充当了整个程序的 UI 结构,为后面的开发工作指明了方向。

最后,需要特别注意的一点:在页面尚未处于"mounted"状态时,强行跳转会发生异常。此处我们使用WidgetsBinding.instance.addPostFrameCallback() 回调方法等待页面进入"mounted" 后再执行跳转。

到此,《日记》项目的整体 UI 框架就构建得差不多了,后面到了填"肉"的环节。

➡️ 在下次课程中,我们会继续《日记》程序的开发,具体内容是:

  • 本地持久化的实现,具体包括首选项与本地数据库(SQLite)的设计、实现与封装。

附录:WriteNewDiary.dart 源码

dart 复制代码
import 'package:flutter/cupertino.dart';
class WriteNewPage extends StatefulWidget {
  const WriteNewPage({Key? key, required this.id}) : super(key: key);
  final String id;
  @override
  State<WriteNewPage> createState() => _WriteNewPageState();
}
class _WriteNewPageState extends State<WriteNewPage> {
  @override
  Widget build(BuildContext context) {
    return const CupertinoPageScaffold(child: Center(child: Text('写日记')));
  }
}
相关推荐
李顿波1 小时前
Chrome 插件弹窗一直停留在初始的小尺寸 —— 你看到的小方块
前端·javascript·chrome
悟空瞎说1 小时前
Cesium 与 Three.js 融合实战:在数字地球上渲染自定义 3D 场景
前端
渣波1 小时前
React 移动端首页架构实战:从并发请求到防御性编程的深度解析
前端·javascript
a1117761 小时前
原生 Markdown 阅读与编辑器 开源项目
前端·开源·软件
laity171 小时前
python发光表白爱心(从零到一实现)
前端·后端
程序员爱钓鱼1 小时前
Rust impl详解:为Struct定义方法与关联函数
前端·后端·rust
用户059540174461 小时前
Qdrant 召回不一致踩坑实录:跑了 300 次测试才发现是索引没刷新
前端·css
Highcharts.js2 小时前
Highcharts 主流前端框架无缝集成指南
前端·vue.js·前端框架·highcharts·可视化图表
JAVA面经实录9172 小时前
网络编程基础(Java Web/分布式前置·完整版)(十一)
java·前端·网络