前言
项目完整代码:github.com/kangpeiqin/…
工欲善其事必先利其器,在开发项目前,首先需要搭建 Flutter 开发环境,安装必要的开发工具,不同的开发平台,有不同的安装方法。具体的安装步骤可以参考:
在安装过程中,可能会出现一些问题,网上有很多解决方案,这里就不再赘述了。由于 Flutter 的各个版本变动很大,各个版本不相兼容,在开发中,我们通常都会指定 SDK 的版本范围,一旦版本在这个范围之外,那么项目就无法运行,这时候就可以安装 Flutter 版本切换工具 FVM (fvm.app/), 帮我们快速切换 Flutter 的版本,开发 IDE 可以使用 VSCode (code.visualstudio.com/) 或者 Andoroid Studio。开发环境配置好后,就可以开始开发我们的软件了。
产品开发调研
开发一款产品,首先要分析潜在的用户,产品要解决什么用户痛点。接下来,需要进行市场调研,即调研市面上同类型功能的产品,确定了产品的方向后,接着就可以着手设计产品的功能了,包括绘制原型图,出 UI 设计交互图等。最后是技术选型,分析要使用什么技术开发我们的产品,以及后期方便迭代等。下面,分析一下我们要开发的 B 站视频下载器的主要功能:
- 用户通过输入框,输入或者黏贴 B 站单个视频链接,软件解析链接,获取到视频的播放和下载地址。
- 获取到视频信息和下载地址后,用户可以点击视频条目进行播放。
- 用户更改下载视频存放目录,并且可以快速打开下载视频的目录。
- 同时选择多个视频进行下载,在下载过程中可以进行暂停、重新下载等操作。
- 额外功能:界面主题变换,如按亮色的切换等。
最后的完整界面如下:
Flutter 使用简介
Flutter 采用代码构建 UI,参考了 React 的设计理念,首先要明白的就是 Widget
,即组件,组件是可以复用的代码块,你可以想象是搭积木,使用 Flutter 开发的软件就是由多个组件拼而成,后面还涉及网路请求、数据持久化、状态管理内容,在后续的章节中通过实战我们可以快速理清这些概念。
项目的创建
- 创建项目
可以使用 flutter create
命令创建一个 flutter 项目:
flutter create project_name
- 运行项目,可以使用
flutter run -d
命令在不同的平台运行项目
# 在 windows 平台或者 macOS 平台运行项目
flutter run -d [windows/macOS]
- 目录结构
对目录结构进行一下简单的说明:
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
就可以快速生成一个如下的底部导航栏:
flutter 自身也提供了路由功能,但是使用现有的成熟框架,可以让我们的应用构建更加简单。go_router
(pub.dev/) 便是这样的一个路由框架,它使用声明式路由,我们可以为每个页面配置一个页面链接地址,指定在应用启动时进入某个页面。在桌面端如何对导航进行定制呢?下面我们就来看看,像下图这样一个侧栏路由导航是如何实现的。
实现侧栏导航
在桌面端,Flutter 提供了更加友好的侧栏控件:NavigationRail
,
我们可以在官网看到提供的示例程序:
所以,我们的侧栏导航可以以此作为基础进行改造,这是一个左右布局的结构,所以我们很容易就想到用
Row
组件来控制我们的布局,在点击侧栏按钮的时候,路由到不同的界面。
代码如下:
//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
自动处理和传递的,代表当前匹配路由的子页面。 ShellRoute
的 builder
方法会将 child
(SearchPage
、DownloadNavigation
、SettingsPage
页面)传递给 DeskNavigation
,并构建最终的页面结构。