基于 Flutter 从零开发一款产品(二)—— 路由导航

472 阅读6分钟

前言

项目完整代码:github.com/kangpeiqin/…

工欲善其事必先利其器,在开发项目前,首先需要搭建 Flutter 开发环境,安装必要的开发工具,不同的开发平台,有不同的安装方法。具体的安装步骤可以参考:

在安装过程中,可能会出现一些问题,网上有很多解决方案,这里就不再赘述了。由于 Flutter 的各个版本变动很大,各个版本不相兼容,在开发中,我们通常都会指定 SDK 的版本范围,一旦版本在这个范围之外,那么项目就无法运行,这时候就可以安装 Flutter 版本切换工具 FVM (fvm.app/), 帮我们快速切换 Flutter 的版本,开发 IDE 可以使用 VSCode (code.visualstudio.com/) 或者 Andoroid Studio。开发环境配置好后,就可以开始开发我们的软件了。

产品开发调研

开发一款产品,首先要分析潜在的用户,产品要解决什么用户痛点。接下来,需要进行市场调研,即调研市面上同类型功能的产品,确定了产品的方向后,接着就可以着手设计产品的功能了,包括绘制原型图,出 UI 设计交互图等。最后是技术选型,分析要使用什么技术开发我们的产品,以及后期方便迭代等。下面,分析一下我们要开发的 B 站视频下载器的主要功能:

  • 用户通过输入框,输入或者黏贴 B 站单个视频链接,软件解析链接,获取到视频的播放和下载地址。
  • 获取到视频信息和下载地址后,用户可以点击视频条目进行播放。
  • 用户更改下载视频存放目录,并且可以快速打开下载视频的目录。
  • 同时选择多个视频进行下载,在下载过程中可以进行暂停、重新下载等操作。
  • 额外功能:界面主题变换,如按亮色的切换等。

最后的完整界面如下:

product.gif

Flutter 使用简介

Flutter 采用代码构建 UI,参考了 React 的设计理念,首先要明白的就是 Widget,即组件,组件是可以复用的代码块,你可以想象是搭积木,使用 Flutter 开发的软件就是由多个组件拼而成,后面还涉及网路请求、数据持久化、状态管理内容,在后续的章节中通过实战我们可以快速理清这些概念。

项目的创建

  • 创建项目

可以使用 flutter create 命令创建一个 flutter 项目:

flutter create project_name
  • 运行项目,可以使用 flutter run -d 命令在不同的平台运行项目
# 在 windows 平台或者 macOS 平台运行项目
flutter run -d [windows/macOS]
  • 目录结构

image.png 对目录结构进行一下简单的说明:

  • lib 目录:用于放置我们编写的源代码。
  • ios、macos、linux、windows 等目录:flutter 在编译过程中会针对不同的运行平台编译生成不同的目录文件。
  • pubspec.yaml 文件:项目的依赖管理,在开发过程中,我们会使用到很多别人开发好的工具包,可以直接拿过来使用,比如我们要添加网络相关的依赖:dio,可以使用命令 flutter pub add dio,依赖就会自动添加到我们的项目当中,使用 flutter pub get,主动去获取依赖包。一般依赖包都会发布在 pub.dev 仓库当中,我们可以在:pub.dev/ 找到很多开发所需要的基础依赖。

路由导航

一款软件,路由和导航是一个很重要的部分,导航往往承载力与用户的交互,用户通过导航路由到不同的功能界面,导航对于一个软件的重要性不言而喻。Flutter 为我们提供了一个 Scaffold 组件,这相当于一个脚手架,包括:AppBar、body、bottomNavigationBar 等组件,帮助我们快速构建 App,例如:我们使用:bottomNavigationBar 就可以快速生成一个如下的底部导航栏:

image.png

flutter 自身也提供了路由功能,但是使用现有的成熟框架,可以让我们的应用构建更加简单。go_routerpub.dev/) 便是这样的一个路由框架,它使用声明式路由,我们可以为每个页面配置一个页面链接地址,指定在应用启动时进入某个页面。在桌面端如何对导航进行定制呢?下面我们就来看看,像下图这样一个侧栏路由导航是如何实现的。

image.png

实现侧栏导航

在桌面端,Flutter 提供了更加友好的侧栏控件:NavigationRail, 我们可以在官网看到提供的示例程序

image.png 所以,我们的侧栏导航可以以此作为基础进行改造,这是一个左右布局的结构,所以我们很容易就想到用 Row 组件来控制我们的布局,在点击侧栏按钮的时候,路由到不同的界面。

image.png 代码如下:

//desk_navigate.dart
class DeskNavigation extends StatelessWidget {
  final Widget content;

  const DeskNavigation({super.key, required this.content});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Row(
        children: [
          //侧栏组件
          const DeskNavigationRail(),
          //内容组件
          Expanded(child: content),
        ],
      ),
    );
  }
}

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

  @override
  State<DeskNavigationRail> createState() => _DeskNavigationRailState();
}

class _DeskNavigationRailState extends State<DeskNavigationRail> {
  // 导航列表
  final List<NavigationRailDestination> destinations = const [
    NavigationRailDestination(icon: Icon(Icons.search), label: Text("搜索")),
    NavigationRailDestination(icon: Icon(Icons.download), label: Text("下载")),
    NavigationRailDestination(icon: Icon(Icons.settings), label: Text("设置")),
  ];

  @override
  Widget build(BuildContext context) {
    //由于我们使用了路由框架,这里可以根据路径判断我们目前在哪个活跃的索引当中
    final activeIndex = _getCurrentActiveIndex();
    //由于我们隐藏了默认的菜单栏,所以这边我们添加了一个新的可以进行拖拽的组件,这边可以忽略,在后续章节会说
    return DragToMoveArea(
        child: NavigationRail(
      //最顶部的 Home 导航,点击时调转到 Search 按钮
      leading: FloatingActionButton(
        elevation: 0,
        onPressed: () {
          _onDestinationSelected(0);
        },
        child: const Icon(Icons.home),
      ),
      //底部的主题色切换按钮
      trailing: const ThemeSwitchIcon(),
      onDestinationSelected: _onDestinationSelected,
      //设置是否展示 icon 底下的文字(搜索、下载、设置)
      labelType: NavigationRailLabelType.all,
      destinations: destinations,
      selectedIndex: activeIndex,
    ));
  }

  final RegExp _segReg = RegExp(r'/\w+');
  int _getCurrentActiveIndex() {
    final String path = GoRouterState.of(context).uri.toString();
    RegExpMatch? match = _segReg.firstMatch(path);
    if (match == null) return 0;
    String? target = match.group(0);
    int index = RouterPath.pagesRoutePaths
        .indexWhere((menu) => menu.contains(target ?? ''));
    return index == -1 ? 0 : index;
  }
  //选中时,使用路由框架替换某个页面
  void _onDestinationSelected(int index) {
    if (index < RouterPath.pagesRoutePaths.length) {
      context.replace(RouterPath.pagesRoutePaths[index]);
    }
  }
}

使用 go_router 框架

  • 添加 go_router 依赖:
flutter pub add go_router
  • 配置路由
//app_router.dart
class RouterPath {
  static const String search = "/search";

  static const String download = "/download";

  static const String settings = "/setting";

  static final List<String> pagesRoutePaths = [
    search,
    download,
    settings,
  ];
}

final GoRouter routerConfig = GoRouter(
  //设置初始化路由地址
  initialLocation: RouterPath.search,
  routes: <RouteBase>[
  //ShellRoute 是一个容器路由,它允许你定义一个共享的布局(例如,侧边栏、底部导航等),并在这个布局内嵌套其他具体的页面路由。
    ShellRoute(
      builder: (BuildContext context, GoRouterState state, Widget child) {
        return DeskNavigation(content: child);
      },
      routes: [
        // 访问 /search:路由到 SearchPage 界面
        GoRoute(
          path: RouterPath.search,
          builder: (BuildContext context, GoRouterState state) =>
              const SearchPage(),
        ),
         // 访问 /download:路由到 DownloadNavigation 界面
        GoRoute(
          path: RouterPath.download,
          builder: (BuildContext context, GoRouterState state) {
            return const DownloadNavigation();
          },
        ),
        // 访问 /setting:路由到 SettingsPage 界面
        GoRoute(
          path: RouterPath.settings,
          builder: (BuildContext context, GoRouterState state) =>
              const SettingsPage(),
        )
      ],
    ),
  ],
);

在上面的代码中,ShellRoute 是一个容器路由,它允许定义一个共享的布局(例如,侧边栏、底部导航等),并在这个布局内嵌套其他具体的页面路由。child 参数是由 GoRouter 自动处理和传递的,代表当前匹配路由的子页面。 ShellRoutebuilder 方法会将 childSearchPageDownloadNavigationSettingsPage 页面)传递给 DeskNavigation,并构建最终的页面结构。

其他章节