1. 简介
Flutter 是 Google 的界面工具包,用于通过单一代码库针对移动设备、Web 和桌面设备构建经过原生编译的精美应用。在此 Codelab 中,您将学习如何构建能够适应运行平台的 Flutter 应用,无论是 Android、iOS、Web、Windows、macOS 还是 Linux 平台。
学习内容
- 如何将针对移动设备设计的 Flutter 应用扩展为可在 Flutter 支持的所有六个平台上运行。
- 用于检测平台的不同 Flutter API,以及何时使用每个 API。
- 适应在 Web 上运行应用的限制和期望。
- 如何同时使用不同的软件包来支持 Flutter 的所有平台。
您将构建的内容
在此 Codelab 中,您将首先构建一个适用于 Android 和 iOS 的 Flutter 应用,用于探索 Flutter 的 YouTube 播放列表。然后,您将根据应用窗口的大小修改信息的显示方式,从而使该应用适用于三种桌面设备平台(Windows、macOS 和 Linux)。然后,您将按照 Web 用户的期望,通过使应用中显示的文本可选择,来针对 Web 调整该应用。最后,您将向该应用添加身份验证机制,以便探索您自己的播放列表,而不是 Flutter 团队创建的播放列表,后者需要使用不同的方法来针对 Android、iOS、Web 以及三种桌面设备平台(Windows、macOS 和 Linux)进行身份验证。
这是该 Flutter 应用在 Android 和 iOS 上的屏幕截图:
这是该应用在 macOS 上以宽屏布局运行时的屏幕截图:
此 Codelab 的重点是将 Flutter 移动应用转换为可在所有六个 Flutter 平台上运行的自适应应用。对于不相关的概念,我们仅会略作介绍,但是会提供相应代码块供您复制和粘贴。
您想通过此 Codelab 学习哪些内容?
2. 设置您的 Flutter 开发环境
您需要使用两款软件才能完成此 Codelab:Flutter SDK 和一款编辑器。
您可使用以下任一设备学习此 Codelab:
- 一台连接到计算机并设置为开发者模式的实体 Android 或 iOS 设备。
- iOS 模拟器(需要安装 Xcode 工具)。
- Android 模拟器(需要在 Android Studio 中设置)。
- 浏览器(需要使用 Chrome,以便进行调试)。
- 对于 Windows、Linux 或 macOS 桌面应用,您必须在打算部署到的平台上进行开发。因此,如果您要开发 Windows 桌面应用,则必须在 Windows 上进行开发,才能使用相应的构建链。如需详细了解针对各种操作系统的具体要求,请访问 docs.flutter.dev/desktop。
3. 开始
确认您的开发环境
要确保一切都已做好开发准备,最简单的方法是运行以下命令:
$ flutter doctor
如果显示的任何内容没有选中标记,请运行以下命令,获取关于错误的更多详细信息:
$ flutter doctor -v
您可能需要安装用于移动应用开发或桌面应用开发的开发者工具。如需关于根据主机操作系统配置工具的更多详细信息,请参阅 Flutter 安装文档中的文档。
创建一个 Flutter 项目
若要开始编写桌面版 Flutter 应用,一种简单的方法是使用 Flutter 命令行工具创建一个 Flutter 项目。再者,您的 IDE 可能会提供一个通过其界面创建 Flutter 项目的工作流程。
$ flutter create adaptive_app Creating project adaptive_app [Eliding listing of created files] Running "flutter pub get" in adaptive_app... 2,445ms Wrote 128 files. All done! In order to run your application, type: $ cd adaptive_app $ flutter run Your application code is in adaptive_app/lib/main.dart.
为确保一切正常运行,请将样板 Flutter 应用作为移动应用运行,如下所示。或者,在您的 IDE 中打开此项目,并使用它所集成的工具运行该应用。得益于上一步的操作,作为桌面应用运行应该是唯一可用的选项。
$ flutter run Launching lib/main.dart on iPhone 12 in debug mode... Running Xcode build... └─Compiling, linking and signing... 15.9s Xcode build done. 41.2s Syncing files to device iPhone 12... 213ms Flutter run key commands. r Hot reload. 🔥🔥🔥 R Hot restart. h List all available interactive commands. d Detach (terminate "flutter run" but leave application running). c Clear the screen q Quit (terminate the application on the device). 💪 Running with sound null safety 💪 An Observatory debugger and profiler on iPhone 12 is available at: http://127.0.0.1:60071/t9hy0pnIWgE=/ Activating Dart DevTools... 3.3s The Flutter DevTools debugger and profiler on iPhone 12 is available at: http://127.0.0.1:9101?uri=http://127.0.0.1:60071/t9hy0pnIWgE=/
现在,您应该看到该应用正在运行。按如下所示修改 lib/main.dart 中的内容,并进行热重载以更新内容。如何执行热重载取决于您是通过命令行(在控制台窗口中输入“r”)还是通过编辑器(在这种情况下,通常可能只需保存文件,便可触发热重载)运行应用。
lib/main.dart
import 'dart:io' show Platform;
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:flutter/material.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
theme: ThemeData(
primarySwatch: Colors.blue,
),
home: const ResizeablePage(),
);
}
}
class ResizeablePage extends StatelessWidget {
const ResizeablePage({super.key});
@override
Widget build(BuildContext context) {
final mediaQuery = MediaQuery.of(context);
final themePlatform = Theme.of(context).platform;
return Scaffold(
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Text(
'Window properties',
style: Theme.of(context).textTheme.headline5,
),
const SizedBox(height: 8),
SizedBox(
width: 350,
child: Table(
textBaseline: TextBaseline.alphabetic,
children: <TableRow>[
_fillTableRow(
context: context,
property: 'Window Size',
value: '${mediaQuery.size.width.toStringAsFixed(1)} x '
'${mediaQuery.size.height.toStringAsFixed(1)}',
),
_fillTableRow(
context: context,
property: 'Device Pixel Ratio',
value: mediaQuery.devicePixelRatio.toStringAsFixed(2),
),
_fillTableRow(
context: context,
property: 'Platform.isXXX',
value: platformDescription(),
),
_fillTableRow(
context: context,
property: 'Theme.of(ctx).platform',
value: themePlatform.toString(),
),
],
),
),
],
),
),
);
}
TableRow _fillTableRow(
{required BuildContext context,
required String property,
required String value}) {
return TableRow(
children: [
TableCell(
verticalAlignment: TableCellVerticalAlignment.baseline,
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Text(property),
),
),
TableCell(
verticalAlignment: TableCellVerticalAlignment.baseline,
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Text(value),
),
),
],
);
}
String platformDescription() {
if (kIsWeb) {
return 'Web';
} else if (Platform.isAndroid) {
return 'Android';
} else if (Platform.isIOS) {
return 'iOS';
} else if (Platform.isWindows) {
return 'Windows';
} else if (Platform.isMacOS) {
return 'macOS';
} else if (Platform.isLinux) {
return 'Linux';
} else if (Platform.isFuchsia) {
return 'Fuchsia';
} else {
return 'Unknown';
}
}
}
上面的应用旨在让您了解如何检测和适应不同的平台。以下是以原生方式在 Android 和 iOS 上运行的应用:
以下是以原生方式在 macOS 上和在 Chrome 内部(同样是在 macOS 上运行)运行的相同代码。
这里要注意的非常重要的一点是,我们一看便知 Flutter 正在尽其所能使内容适应其使用的显示屏。截取这些屏幕截图时使用的笔记本电脑具有高分辨率 Mac 显示屏,因此该应用的 macOS 版和 Web 版都以设备像素比 2 呈现。同时,在 iPhone 12 上,设备像素比为 3,在 Pixel 2 上则为 2.63。在所有情况下,显示的文本都大致相似,这大幅简化了开发者的工作。
要注意的第二点是,用于检查代码在哪个平台上运行的两个选项会产生不同的值。第一个选项用于检查从 dart:io
导入的 Platform
对象,而第二个选项(仅在 widget 的 build
方法内可用)用于从 BuildContext
参数检索 Theme
对象。
这两种方法返回不同结果的原因是它们的意图不同。从 dart:io
导入的 Platform
对象旨在用于做出独立于渲染选择的决策。一个典型的例子是决定使用哪些插件,这些插件可能具有也可能不具有针对特定物理平台的匹配原生实现。
从 BuildContext
中提取 Theme
旨在做出以 Theme 为中心的实现决策。如 Slider.adaptive
中所述,这方面的一个典型例子是决定是使用 Material 滑块还是 Cupertino 滑块。
在下一部分,您将构建一个基本的 YouTube 播放列表浏览器应用,该应用仅针对 Android 和 iOS 进行了优化。在以下部分中,您将进行各种调整,以使该应用在桌面设备和 Web 上更好地运行。
4. 构建移动应用
添加软件包
在该应用中,您将使用各种 Flutter 软件包,来获取对 YouTube Data API、状态管理和一些主题的访问权限。
$ flutter pub add googleapis Resolving dependencies... + _discoveryapis_commons 1.0.3 async 2.8.2 (2.9.0 available) characters 1.2.0 (1.2.1 available) clock 1.1.0 (1.1.1 available) fake_async 1.3.0 (1.3.1 available) + googleapis 9.1.0 + http 0.13.4 + http_parser 4.0.1 matcher 0.12.11 (0.12.12 available) material_color_utilities 0.1.4 (0.1.5 available) meta 1.7.0 (1.8.0 available) path 1.8.1 (1.8.2 available) source_span 1.8.2 (1.9.0 available) string_scanner 1.1.0 (1.1.1 available) term_glyph 1.2.0 (1.2.1 available) test_api 0.4.9 (0.4.12 available) + typed_data 1.3.1 Changed 5 dependencies!
第一个软件包 googleapis
是一个生成的 Dart 库,用于访问 Google API。
$ flutter pub add http Resolving dependencies... async 2.8.2 (2.9.0 available) characters 1.2.0 (1.2.1 available) clock 1.1.0 (1.1.1 available) fake_async 1.3.0 (1.3.1 available) matcher 0.12.11 (0.12.12 available) material_color_utilities 0.1.4 (0.1.5 available) meta 1.7.0 (1.8.0 available) path 1.8.1 (1.8.2 available) source_span 1.8.2 (1.9.0 available) string_scanner 1.1.0 (1.1.1 available) term_glyph 1.2.0 (1.2.1 available) test_api 0.4.9 (0.4.12 available) Got dependencies!
http
软件包将有助于构建使用 API 密钥访问 YouTube Data API 的功能。
$ flutter pub add provider Resolving dependencies... async 2.8.2 (2.9.0 available) characters 1.2.0 (1.2.1 available) clock 1.1.0 (1.1.1 available) fake_async 1.3.0 (1.3.1 available) matcher 0.12.11 (0.12.12 available) material_color_utilities 0.1.4 (0.1.5 available) meta 1.7.0 (1.8.0 available) + nested 1.0.0 path 1.8.1 (1.8.2 available) + provider 6.0.3 source_span 1.8.2 (1.9.0 available) string_scanner 1.1.0 (1.1.1 available) term_glyph 1.2.0 (1.2.1 available) test_api 0.4.9 (0.4.12 available) Changed 2 dependencies!
对于状态管理,请使用 provider
。
$ flutter pub add url_launcher Resolving dependencies... async 2.8.2 (2.9.0 available) characters 1.2.0 (1.2.1 available) clock 1.1.0 (1.1.1 available) fake_async 1.3.0 (1.3.1 available) + flutter_web_plugins 0.0.0 from sdk flutter + js 0.6.4 matcher 0.12.11 (0.12.12 available) material_color_utilities 0.1.4 (0.1.5 available) meta 1.7.0 (1.8.0 available) path 1.8.1 (1.8.2 available) + plugin_platform_interface 2.1.2 source_span 1.8.2 (1.9.0 available) string_scanner 1.1.0 (1.1.1 available) term_glyph 1.2.0 (1.2.1 available) test_api 0.4.9 (0.4.12 available) + url_launcher 6.1.4 + url_launcher_android 6.0.17 + url_launcher_ios 6.0.17 + url_launcher_linux 3.0.1 + url_launcher_macos 3.0.1 + url_launcher_platform_interface 2.1.0 + url_launcher_web 2.0.12 + url_launcher_windows 3.0.1 Changed 11 dependencies!
使用 url_launcher
作为一种从播放列表跳转到视频的方式。从已解析的依赖项可以看出,url_launcher
不仅具有适用于默认 Android 和 iOS 的实现,还有适用于 Windows、macOS、Linux 和 Web 的实现。这是一项您不需要为其创建平台特定代码的功能。
$ flutter pub add flex_color_scheme Resolving dependencies... async 2.8.2 (2.9.0 available) characters 1.2.0 (1.2.1 available) clock 1.1.0 (1.1.1 available) fake_async 1.3.0 (1.3.1 available) + flex_color_scheme 5.1.0 matcher 0.12.11 (0.12.12 available) material_color_utilities 0.1.4 (0.1.5 available) meta 1.7.0 (1.8.0 available) path 1.8.1 (1.8.2 available) source_span 1.8.2 (1.9.0 available) string_scanner 1.1.0 (1.1.1 available) term_glyph 1.2.0 (1.2.1 available) test_api 0.4.9 (0.4.12 available) Changed 1 dependency!m
这个软件包纯粹是为了给应用提供一个不错的默认配色方案。请参阅 flex_color_scheme
文档,了解全部功能。
$ flutter pub add go_router Resolving dependencies... async 2.9.0 (2.10.0 available) boolean_selector 2.1.0 (2.1.1 available) collection 1.16.0 (1.17.0 available) + flutter_web_plugins 0.0.0 from sdk flutter + go_router 5.2.0 + js 0.6.4 (0.6.5 available) + logging 1.1.0 matcher 0.12.12 (0.12.13 available) material_color_utilities 0.1.5 (0.2.0 available) source_span 1.9.0 (1.9.1 available) stack_trace 1.10.0 (1.11.0 available) stream_channel 2.1.0 (2.1.1 available) string_scanner 1.1.1 (1.2.0 available) test_api 0.4.12 (0.4.16 available) vector_math 2.1.2 (2.1.4 available) Changed 4 dependencies!
若要实现不同屏幕之间的导航,请将 go_router 添加到项目中。
这个软件包提供了一个方便、基于网址的 API,用于使用 Flutter 的路由器进行导航。
针对 url_launcher
配置移动应用
url_launcher
插件需要配置 Android 和 iOS runner 应用。在 iOS Flutter runner 中,将以下行添加到 plist
字典。
ios/Runner/Info.plist
<key>LSApplicationQueriesSchemes</key>
<array>
<string>https</string>
<string>http</string>
<string>tel</string>
<string>mailto</string>
</array>
在 Android Flutter runner 中,将以下行添加到 Manifest.xml
。将此 queries
节点添加为 manifest
节点的直接子节点和 application
节点的对等节点。
android/app/src/main/AndroidManifest.xml
<queries>
<intent>
<action android:name="android.intent.action.VIEW" />
<data android:scheme="https" />
</intent>
<intent>
<action android:name="android.intent.action.DIAL" />
<data android:scheme="tel" />
</intent>
<intent>
<action android:name="android.intent.action.SEND" />
<data android:mimeType="*/*" />
</intent>
</queries>
如需关于这些必需配置更改的更多详细信息,请参阅 url_launcher
文档。
访问 YouTube Data API
若要访问 YouTube Data API 以列出播放列表,您需要创建一个 API 项目,以便生成所需的 API 密钥。以下步骤假定您已经拥有一个 Google 帐号,如果您还没有该帐号,请创建一个。
转到 Play 管理中心,创建一个 API 项目:
创建好项目后,转到 API 库页面。在搜索框中,输入“youtube”,然后选择 youtube data api v3。
在 YouTube Data API v3 详情页面中,启用该 API。
启用该 API 后,转到凭据页面,然后创建 API 密钥。
几秒钟后,您应该会看到一个对话框,其中包含全新的 API 密钥。您很快就会用到这个密钥。
添加代码
对于此步骤的其余部分,您将剪切并粘贴大量代码来构建移动应用,而无需对代码进行任何注释。此 Codelab 旨在调整该移动应用,使其适应桌面设备和 Web。如需关于构建适用于移动设备的 Flutter 应用的更详细说明,请参阅编写您的第一个 Flutter 应用,第 1 部分、第 2 部分和使用 Flutter 构建精美的界面。
添加以下文件,首先是应用的状态对象。
lib/src/app_state.dart
import 'dart:collection';
import 'package:flutter/foundation.dart';
import 'package:googleapis/youtube/v3.dart';
import 'package:http/http.dart' as http;
class FlutterDevPlaylists extends ChangeNotifier {
FlutterDevPlaylists({
required String flutterDevAccountId,
required String youTubeApiKey,
}) : _flutterDevAccountId = flutterDevAccountId {
_api = YouTubeApi(
_ApiKeyClient(
client: http.Client(),
key: youTubeApiKey,
),
);
_loadPlaylists();
}
Future<void> _loadPlaylists() async {
String? nextPageToken;
_playlists.clear();
do {
final response = await _api.playlists.list(
['snippet', 'contentDetails', 'id'],
channelId: _flutterDevAccountId,
maxResults: 50,
pageToken: nextPageToken,
);
_playlists.addAll(response.items!);
_playlists.sort((a, b) => a.snippet!.title!
.toLowerCase()
.compareTo(b.snippet!.title!.toLowerCase()));
notifyListeners();
} while (nextPageToken != null);
}
final String _flutterDevAccountId;
late final YouTubeApi _api;
final List<Playlist> _playlists = [];
List<Playlist> get playlists => UnmodifiableListView(_playlists);
final Map<String, List<PlaylistItem>> _playlistItems = {};
List<PlaylistItem> playlistItems({required String playlistId}) {
if (!_playlistItems.containsKey(playlistId)) {
_playlistItems[playlistId] = [];
_retrievePlaylist(playlistId);
}
return UnmodifiableListView(_playlistItems[playlistId]!);
}
Future<void> _retrievePlaylist(String playlistId) async {
String? nextPageToken;
do {
var response = await _api.playlistItems.list(
['snippet', 'contentDetails'],
playlistId: playlistId,
maxResults: 25,
pageToken: nextPageToken,
);
var items = response.items;
if (items != null) {
_playlistItems[playlistId]!.addAll(items);
}
notifyListeners();
nextPageToken = response.nextPageToken;
} while (nextPageToken != null);
}
}
class _ApiKeyClient extends http.BaseClient {
_ApiKeyClient({required this.key, required this.client});
final String key;
final http.Client client;
@override
Future<http.StreamedResponse> send(http.BaseRequest request) {
final url = request.url.replace(queryParameters: <String, List<String>>{
...request.url.queryParametersAll,
'key': [key]
});
return client.send(http.Request(request.method, url));
}
}
接下来,添加个别播放列表的详情页面。
lib/src/playlist_details.dart
import 'package:flutter/material.dart';
import 'package:googleapis/youtube/v3.dart';
import 'package:provider/provider.dart';
import 'package:url_launcher/link.dart';
import 'app_state.dart';
class PlaylistDetails extends StatelessWidget {
const PlaylistDetails(
{required this.playlistId, required this.playlistName, super.key});
final String playlistId;
final String playlistName;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(playlistName),
),
body: Consumer<FlutterDevPlaylists>(
builder: (context, playlists, _) {
final playlistItems = playlists.playlistItems(playlistId: playlistId);
if (playlistItems.isEmpty) {
return const Center(child: CircularProgressIndicator());
}
return _PlaylistDetailsListView(playlistItems: playlistItems);
},
),
);
}
}
class _PlaylistDetailsListView extends StatelessWidget {
const _PlaylistDetailsListView({required this.playlistItems});
final List<PlaylistItem> playlistItems;
@override
Widget build(BuildContext context) {
return ListView.builder(
itemCount: playlistItems.length,
itemBuilder: (context, index) {
final playlistItem = playlistItems[index];
return Padding(
padding: const EdgeInsets.all(8.0),
child: ClipRRect(
borderRadius: BorderRadius.circular(4),
child: Stack(
alignment: Alignment.center,
children: [
if (playlistItem.snippet!.thumbnails!.high != null)
Image.network(playlistItem.snippet!.thumbnails!.high!.url!),
_buildGradient(context),
_buildTitleAndSubtitle(context, playlistItem),
_buildPlayButton(context, playlistItem),
],
),
),
);
},
);
}
Widget _buildGradient(BuildContext context) {
return Positioned.fill(
child: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [Colors.transparent, Theme.of(context).backgroundColor],
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
stops: const [0.5, 0.95],
),
),
),
);
}
Widget _buildTitleAndSubtitle(
BuildContext context, PlaylistItem playlistItem) {
return Positioned(
left: 20,
right: 0,
bottom: 20,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
playlistItem.snippet!.title!,
style: Theme.of(context).textTheme.bodyText1!.copyWith(
fontSize: 18,
// fontWeight: FontWeight.bold,
),
),
if (playlistItem.snippet!.videoOwnerChannelTitle != null)
Text(
playlistItem.snippet!.videoOwnerChannelTitle!,
style: Theme.of(context).textTheme.bodyText2!.copyWith(
fontSize: 12,
),
),
],
),
);
}
Widget _buildPlayButton(BuildContext context, PlaylistItem playlistItem) {
return Stack(
alignment: AlignmentDirectional.center,
children: [
Container(
width: 42,
height: 42,
decoration: const BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.all(
Radius.circular(21),
),
),
),
Link(
uri: Uri.parse(
'https://www.youtube.com/watch?v=${playlistItem.snippet!.resourceId!.videoId}'),
builder: (context, followLink) => IconButton(
onPressed: followLink,
color: Colors.red,
icon: const Icon(Icons.play_circle_fill),
iconSize: 45,
),
),
],
);
}
}
接下来,添加播放列表清单。
lib/src/playlists.dart
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:googleapis/youtube/v3.dart';
import 'package:provider/provider.dart';
import 'app_state.dart';
class Playlists extends StatelessWidget {
const Playlists({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('FlutterDev Playlists'),
),
body: Consumer<FlutterDevPlaylists>(
builder: (context, flutterDev, child) {
final playlists = flutterDev.playlists;
if (playlists.isEmpty) {
return const Center(
child: CircularProgressIndicator(),
);
}
return _PlaylistsListView(items: playlists);
},
),
);
}
}
class _PlaylistsListView extends StatelessWidget {
const _PlaylistsListView({required this.items});
final List<Playlist> items;
@override
Widget build(BuildContext context) {
return ListView.builder(
itemCount: items.length,
itemBuilder: (context, index) {
var playlist = items[index];
return Padding(
padding: const EdgeInsets.all(8.0),
child: ListTile(
leading: Image.network(
playlist.snippet!.thumbnails!.default_!.url!,
),
title: Text(playlist.snippet!.title!),
subtitle: Text(
playlist.snippet!.description!,
),
onTap: () {
context.go(
Uri(
path: '/playlist/${playlist.id}',
queryParameters: <String, String>{
'title': playlist.snippet!.title!
},
).toString(),
);
},
),
);
},
);
}
}
按如下所示替换 main.dart
文件的内容:
lib/main.dart
import 'dart:io';
import 'package:flex_color_scheme/flex_color_scheme.dart';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:provider/provider.dart';
import 'src/app_state.dart';
import 'src/playlist_details.dart';
import 'src/playlists.dart';
// From https://www.youtube.com/channel/UCwXdFgeE9KYzlDdR7TG9cMw
const flutterDevAccountId = 'UCwXdFgeE9KYzlDdR7TG9cMw';
// TODO: Replace with your YouTube API Key
const youTubeApiKey = 'AIzaNotAnApiKey';
final _router = GoRouter(
routes: <RouteBase>[
GoRoute(
path: '/',
builder: (context, state) {
return const Playlists();
},
routes: <RouteBase>[
GoRoute(
path: 'playlist/:id',
builder: (context, state) {
final title = state.queryParams['title']!;
final id = state.params['id']!;
return PlaylistDetails(
playlistId: id,
playlistName: title,
);
},
),
],
),
],
);
void main() {
if (youTubeApiKey == 'AIzaNotAnApiKey') {
print('youTubeApiKey has not been configured.');
exit(1);
}
runApp(ChangeNotifierProvider<FlutterDevPlaylists>(
create: (context) => FlutterDevPlaylists(
flutterDevAccountId: flutterDevAccountId,
youTubeApiKey: youTubeApiKey,
),
child: const PlaylistsApp(),
));
}
class PlaylistsApp extends StatelessWidget {
const PlaylistsApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp.router(
title: 'FlutterDev Playlists',
theme: FlexColorScheme.light(
scheme: FlexScheme.red,
useMaterial3: true,
).toTheme,
darkTheme: FlexColorScheme.dark(
scheme: FlexScheme.red,
useMaterial3: true,
).toTheme,
themeMode: ThemeMode.dark, // Or ThemeMode.System if you'd prefer
debugShowCheckedModeBanner: false,
routerConfig: _router,
);
}
}
您几乎已准备好在 Android 和 iOS 上运行此代码。不过,还有一项内容需要更改,即使用上一步生成的 YouTube API 密钥修改第 14 行中的 youTubeApiKey
常量。
lib/main.dart
// TODO: Replace with your YouTube API Key
const youTubeApiKey = 'AIzaNotAnApiKey';
若要在 macOS 上运行该应用,您需要启用该应用以发出 HTTP 请求,如下所示。编辑 DebugProfile.entitlements
和 Release.entitilements
文件,如下所示:
macos/Runner/DebugProfile.entitlements
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.app-sandbox</key>
<true/>
<key>com.apple.security.cs.allow-jit</key>
<true/>
<key>com.apple.security.network.server</key>
<true/>
<!-- add the following two lines -->
<key>com.apple.security.network.client</key>
<true/>
</dict>
</plist>
macos/Runner/Release.entitlements
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.app-sandbox</key>
<true/>
<!-- add the following two lines -->
<key>com.apple.security.network.client</key>
<true/>
</dict>
</plist>
运行应用
现在,您已经有了一个完整的应用,应该能够在 Android 模拟器或 iPhone 模拟器上成功运行它。您将看到 Flutter 的播放列表清单,当您选择某个播放列表时,会看到该播放列表中的视频,如果您点击播放按钮,则会打开 YouTube 以观看相应视频。
不过,如果您尝试在桌面设备上运行该应用,则会看到,当扩展到普通桌面设备大小的窗口时,布局看起来不对。您将在下一步中了解进行调整以适应这种情况的方式。
5. 适应桌面设备
桌面设备问题
如果您在某个原生桌面设备平台(Windows、macOS 或 Linux)上运行该应用,会注意到一个有趣的问题。它能运行,但看起来有点奇怪。
若要解决此问题,一种方法是添加拆分视图,即在左侧列出播放列表,在右侧显示视频。不过,您只希望在代码不是在 Android 或 iOS 上运行且窗口足够宽时,才启动此布局。以下说明显示了如何实现此功能。
首先,添加 split_view
软件包,以帮助构建布局。
$ flutter pub add split_view Resolving dependencies... + split_view 3.1.0 test_api 0.4.3 (0.4.8 available) Changed 1 dependency!
引入自适应 widget
在此 Codelab 中,您要使用的模式是引入自适应 widget,这些 widget 能够根据屏幕宽度、平台主题等属性做出实现选择。在这种情况下,您将引入一个 AdaptivePlaylists
widget,以便重新处理 Playlists
与 PlaylistDetails
的交互方式。按如下方式修改 lib/main.dart
文件:
lib/main.dart
import 'dart:io';
import 'package:flex_color_scheme/flex_color_scheme.dart';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:provider/provider.dart';
import 'src/adaptive_playlists.dart';
import 'src/app_state.dart';
import 'src/playlist_details.dart';
// From https://www.youtube.com/channel/UCwXdFgeE9KYzlDdR7TG9cMw
const flutterDevAccountId = 'UCwXdFgeE9KYzlDdR7TG9cMw';
// TODO: Replace with your YouTube API Key
const youTubeApiKey = 'AIzaNotAnApiKey';
final _router = GoRouter(
routes: <RouteBase>[
GoRoute(
path: '/',
builder: (context, state) {
return const AdaptivePlaylists();
},
routes: <RouteBase>[
GoRoute(
path: 'playlist/:id',
builder: (context, state) {
final title = state.queryParams['title']!;
final id = state.params['id']!;
return Scaffold(
appBar: AppBar(title: Text(title)),
body: PlaylistDetails(
playlistId: id,
playlistName: title,
),
);
},
),
],
),
],
);
void main() {
if (youTubeApiKey == 'AIzaNotAnApiKey') {
print('youTubeApiKey has not been configured.');
exit(1);
}
runApp(ChangeNotifierProvider<FlutterDevPlaylists>(
create: (context) => FlutterDevPlaylists(
flutterDevAccountId: flutterDevAccountId,
youTubeApiKey: youTubeApiKey,
),
child: const PlaylistsApp(),
));
}
class PlaylistsApp extends StatelessWidget {
const PlaylistsApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp.router(
title: 'FlutterDev Playlists',
theme: FlexColorScheme.light(
scheme: FlexScheme.red,
useMaterial3: true,
).toTheme,
darkTheme: FlexColorScheme.dark(
scheme: FlexScheme.red,
useMaterial3: true,
).toTheme,
themeMode: ThemeMode.dark, // Or ThemeMode.System if you'd prefer
debugShowCheckedModeBanner: false,
routerConfig: _router,
);
}
}
接下来,为 AdaptivePlaylist widget 创建文件:
lib/src/adaptive_playlists.dart
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:googleapis/youtube/v3.dart';
import 'package:split_view/split_view.dart';
import 'playlist_details.dart';
import 'playlists.dart';
class AdaptivePlaylists extends StatelessWidget {
const AdaptivePlaylists({super.key});
@override
Widget build(BuildContext context) {
final screenWidth = MediaQuery.of(context).size.width;
final targetPlatform = Theme.of(context).platform;
if (targetPlatform == TargetPlatform.android ||
targetPlatform == TargetPlatform.iOS ||
screenWidth <= 600) {
return const NarrowDisplayPlaylists();
} else {
return const WideDisplayPlaylists();
}
}
}
class NarrowDisplayPlaylists extends StatelessWidget {
const NarrowDisplayPlaylists({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('FlutterDev Playlists')),
body: Playlists(
playlistSelected: (playlist) {
context.go(
Uri(
path: '/playlist/${playlist.id}',
queryParameters: <String, String>{
'title': playlist.snippet!.title!
},
).toString(),
);
},
),
);
}
}
class WideDisplayPlaylists extends StatefulWidget {
const WideDisplayPlaylists({super.key});
@override
State<WideDisplayPlaylists> createState() => _WideDisplayPlaylistsState();
}
class _WideDisplayPlaylistsState extends State<WideDisplayPlaylists> {
Playlist? selectedPlaylist;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: selectedPlaylist == null
? const Text('FlutterDev Playlists')
: Text('FlutterDev Playlist: ${selectedPlaylist!.snippet!.title!}'),
),
body: SplitView(
viewMode: SplitViewMode.Horizontal,
children: [
Playlists(playlistSelected: (playlist) {
setState(() {
selectedPlaylist = playlist;
});
}),
if (selectedPlaylist != null)
PlaylistDetails(
playlistId: selectedPlaylist!.id!,
playlistName: selectedPlaylist!.snippet!.title!)
else
const Center(
child: Text('Select a playlist'),
),
],
),
);
}
}
由于几个不同的原因,这个文件很有趣。首先,它不仅使用窗口的宽度(使用 MediaQuery.of(context).size.width
),还会检查主题(使用 Theme.of(context).platform
),从而决定是使用 SplitView
widget 显示宽布局,还是显示窄布局,而不使用该 widget。
第二个要注意的要点是,它现在会处理导航,这在以前是硬编码的。这是通过在 Playlists
widget 中使用一个回调参数来完成的,该参数会通知周围的代码用户已经选择了一个播放列表,并且需要执行显示该播放列表所需的任何操作。另请注意,现在已将 Scaffold
排除在 Playlists
和 PlaylistDetails
widget 外,因为这些 widget 不是顶级 widget。
接下来,按如下方式修改 src/lib/playlists.dart
文件:
lib/src/playlists.dart
import 'package:flutter/material.dart';
import 'package:googleapis/youtube/v3.dart';
import 'package:provider/provider.dart';
import 'app_state.dart';
class Playlists extends StatelessWidget {
const Playlists({super.key, required this.playlistSelected});
final PlaylistsListSelected playlistSelected;
@override
Widget build(BuildContext context) {
return Consumer<FlutterDevPlaylists>(
builder: (context, flutterDev, child) {
final playlists = flutterDev.playlists;
if (playlists.isEmpty) {
return const Center(
child: CircularProgressIndicator(),
);
}
return _PlaylistsListView(
items: playlists,
playlistSelected: playlistSelected,
);
},
);
}
}
typedef PlaylistsListSelected = void Function(Playlist playlist);
class _PlaylistsListView extends StatefulWidget {
const _PlaylistsListView({
required this.items,
required this.playlistSelected,
});
final List<Playlist> items;
final PlaylistsListSelected playlistSelected;
@override
State<_PlaylistsListView> createState() => _PlaylistsListViewState();
}
class _PlaylistsListViewState extends State<_PlaylistsListView> {
late ScrollController _scrollController;
@override
void initState() {
super.initState();
_scrollController = ScrollController();
}
@override
void dispose() {
_scrollController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return ListView.builder(
controller: _scrollController,
itemCount: widget.items.length,
itemBuilder: (context, index) {
var playlist = widget.items[index];
return Padding(
padding: const EdgeInsets.all(8.0),
child: ListTile(
leading: Image.network(
playlist.snippet!.thumbnails!.default_!.url!,
),
title: Text(playlist.snippet!.title!),
subtitle: Text(
playlist.snippet!.description!,
),
onTap: () {
widget.playlistSelected(playlist);
},
),
);
},
);
}
}
该文件经过了很多更改。除了前面提到的引入 playlistSelected 回调和消除 Scaffold
widget 之外,_PlaylistsListView
widget 还从无状态转换为有状态。由于引入了必须构建和销毁的自有 ScrollController
,因此需要进行此更改。
ScrollController
的引入很有趣,需要使用它是因为事实上在宽布局上有两个并排 ListView
widget。在手机上,传统上只有一个 ListView
,因此可以有一个长期存在的 ScrollController,所有 ListView
在其各自的生命周期中都附加到该 ScrollController 和从其分离。桌面设备有所不同,在这里并排使用多个 ListView
是有意义的。
最后,按如下所示修改 lib/src/playlist_details.dart 文件:
lib/src/playlist_details.dart
import 'package:flutter/material.dart';
import 'package:googleapis/youtube/v3.dart';
import 'package:provider/provider.dart';
import 'package:url_launcher/link.dart';
import 'app_state.dart';
class PlaylistDetails extends StatelessWidget {
const PlaylistDetails(
{required this.playlistId, required this.playlistName, super.key});
final String playlistId;
final String playlistName;
@override
Widget build(BuildContext context) {
return Consumer<FlutterDevPlaylists>(
builder: (context, playlists, _) {
final playlistItems = playlists.playlistItems(playlistId: playlistId);
if (playlistItems.isEmpty) {
return const Center(child: CircularProgressIndicator());
}
return _PlaylistDetailsListView(playlistItems: playlistItems);
},
);
}
}
class _PlaylistDetailsListView extends StatefulWidget {
const _PlaylistDetailsListView({required this.playlistItems});
final List<PlaylistItem> playlistItems;
@override
State<_PlaylistDetailsListView> createState() =>
_PlaylistDetailsListViewState();
}
class _PlaylistDetailsListViewState extends State<_PlaylistDetailsListView> {
late ScrollController _scrollController;
@override
void initState() {
super.initState();
_scrollController = ScrollController();
}
@override
void dispose() {
_scrollController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return ListView.builder(
controller: _scrollController,
itemCount: widget.playlistItems.length,
itemBuilder: (context, index) {
final playlistItem = widget.playlistItems[index];
return Padding(
padding: const EdgeInsets.all(8.0),
child: ClipRRect(
borderRadius: BorderRadius.circular(4),
child: Stack(
alignment: Alignment.center,
children: [
if (playlistItem.snippet!.thumbnails!.high != null)
Image.network(playlistItem.snippet!.thumbnails!.high!.url!),
_buildGradient(context),
_buildTitleAndSubtitle(context, playlistItem),
_buildPlayButton(context, playlistItem),
],
),
),
);
},
);
}
Widget _buildGradient(BuildContext context) {
return Positioned.fill(
child: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [Colors.transparent, Theme.of(context).backgroundColor],
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
stops: const [0.5, 0.95],
),
),
),
);
}
Widget _buildTitleAndSubtitle(
BuildContext context, PlaylistItem playlistItem) {
return Positioned(
left: 20,
right: 0,
bottom: 20,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
playlistItem.snippet!.title!,
style: Theme.of(context).textTheme.bodyText1!.copyWith(
fontSize: 18,
// fontWeight: FontWeight.bold,
),
),
if (playlistItem.snippet!.videoOwnerChannelTitle != null)
Text(
playlistItem.snippet!.videoOwnerChannelTitle!,
style: Theme.of(context).textTheme.bodyText2!.copyWith(
fontSize: 12,
),
),
],
),
);
}
Widget _buildPlayButton(BuildContext context, PlaylistItem playlistItem) {
return Stack(
alignment: AlignmentDirectional.center,
children: [
Container(
width: 42,
height: 42,
decoration: const BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.all(
Radius.circular(21),
),
),
),
Link(
uri: Uri.parse(
'https://www.youtube.com/watch?v=${playlistItem.snippet!.resourceId!.videoId}'),
builder: (context, followLink) => IconButton(
onPressed: followLink,
color: Colors.red,
icon: const Icon(Icons.play_circle_fill),
iconSize: 45,
),
),
],
);
}
}
与上面的 Playlists
widget 一样,此文件也需要进行一些更改,以消除 Scaffold
widget,并引入一个自有的 ScrollController
。
再次运行应用!
在您选择的桌面设备平台上运行该应用,无论该平台是 Windows、macOS 还是 Linux。它现在应该像您期望的那样工作。
6. 适应 Web
这些图像是怎么回事,嗯?
如果您尝试像在 Web 上一样运行该应用,即使在上一步更改了布局,您也会看到仍需进行一些调整工作来适应网络浏览器环境:
如果看一下调试控制台,您会看到一个关于接下来必须做什么的温和提示。
════════ Exception caught by image resource service ════════════════════════════ Failed to load network image. Image URL: https://i.ytimg.com/vi/QIW35-vcA2o/default.jpg Trying to load an image from another domain? Find answers at: https://flutter.dev/docs/development/platform-integration/web-images ═════════════════════════════════════════════════════════════════════════
创建 CORS 代理
处理图像渲染问题的一种方法是,引入代理 Web 服务以添加所需的跨域资源共享标头。打开一个终端并创建一个 Dart 网络服务器,如下所示:
$ dart create --template server-shelf yt_cors_proxy Creating yt_cors_proxy using template server-shelf... .gitignore analysis_options.yaml CHANGELOG.md pubspec.yaml README.md Dockerfile .dockerignore test/server_test.dart bin/server.dart Running pub get... 3.9s Resolving dependencies... Changed 53 dependencies! Created project yt_cors_proxy in yt_cors_proxy! In order to get started, run the following commands: cd yt_cors_proxy dart run bin/server.dart
将目录更改为 yt_cors_proxy
服务器,并添加一些必需的依赖项:
$ cd yt_cors_proxy $ dart pub add shelf_cors_headers Resolving dependencies... + shelf_cors_headers 0.1.2 Changed 1 dependency! $ dart pub add http "http" was found in dev_dependencies. Removing "http" and adding it to dependencies instead. Resolving dependencies... Got dependencies!
有一些当前不再需要的依赖项。按如下所示修剪这些依赖项:
$ dart pub remove args Resolving dependencies... These packages are no longer being depended on: - args 2.2.0 Changed 1 dependency! $ dart pub remove shelf_router Resolving dependencies... These packages are no longer being depended on: - http_methods 1.1.0 - shelf_router 1.1.1 Changed 2 dependencies!
接下来,修改 server.dart 文件的内容以匹配以下内容:
yt_cors_proxy/bin/server.dart
import 'dart:async';
import 'dart:io';
import 'package:http/http.dart' as http;
import 'package:shelf/shelf.dart';
import 'package:shelf/shelf_io.dart';
import 'package:shelf_cors_headers/shelf_cors_headers.dart';
Future<Response> _requestHandler(Request req) async {
final target = req.url.replace(scheme: 'https', host: 'i.ytimg.com');
final response = await http.get(target);
return Response.ok(response.bodyBytes, headers: response.headers);
}
void main(List<String> args) async {
// Use any available host or container IP (usually `0.0.0.0`).
final ip = InternetAddress.anyIPv4;
// Configure a pipeline that adds CORS headers and proxies requests.
final handler = Pipeline()
.addMiddleware(logRequests())
.addMiddleware(corsHeaders(headers: {ACCESS_CONTROL_ALLOW_ORIGIN: '*'}))
.addHandler(_requestHandler);
// For running in containers, we respect the PORT environment variable.
final port = int.parse(Platform.environment['PORT'] ?? '8080');
final server = await serve(handler, ip, port);
print('Server listening on port ${server.port}');
}
您可以按如下方式运行此服务器:
$ dart run bin/server.dart Server listening on port 8080
或者,您可以将其构建为 Docker 映像,并按如下方式运行生成的 Docker 映像:
$ docker build . -t yt-cors-proxy [+] Building 2.7s (14/14) FINISHED $ docker run -p 8080:8080 yt-cors-proxy Server listening on port 8080
接下来,修改 Flutter 代码以利用此 CORS 代理,但仅限在网络浏览器中运行时使用。
一对适应性强的 widget
这对 widget 中的第一个 widget 控制您的应用将如何使用 CORS 代理。
lib/src/adaptive_image.dart
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
class AdaptiveImage extends StatelessWidget {
AdaptiveImage.network(String url, {super.key}) {
if (kIsWeb) {
_url = Uri.parse(url)
.replace(host: 'localhost', port: 8080, scheme: 'http')
.toString();
} else {
_url = url;
}
}
late final String _url;
@override
Widget build(BuildContext context) {
return Image.network(_url);
}
}
需要注意的要点是,您使用的是 kIsWeb
,这是因为事实上该差异不是因主题所致,而是由于运行时平台所致。另一个适应性很强的 widget 处理网络浏览器用户希望文本可选择这一实际问题:
lib/src/adaptive_text.dart
import 'package:flutter/material.dart';
class AdaptiveText extends StatelessWidget {
const AdaptiveText(this.data, {super.key, this.style});
final String data;
final TextStyle? style;
@override
Widget build(BuildContext context) {
switch (Theme.of(context).platform) {
case TargetPlatform.android:
case TargetPlatform.iOS:
return Text(data, style: style);
default:
return SelectableText(data, style: style);
}
}
}
现在,在整个代码库中传播这些调整:
lib/src/playlist_details.dart
import 'package:flutter/material.dart';
import 'package:googleapis/youtube/v3.dart';
import 'package:provider/provider.dart';
import 'package:url_launcher/link.dart';
import 'adaptive_image.dart'; // Add this line,
import 'adaptive_text.dart'; // and this line
import 'app_state.dart';
class PlaylistDetails extends StatelessWidget {
const PlaylistDetails(
{required this.playlistId, required this.playlistName, super.key});
final String playlistId;
final String playlistName;
@override
Widget build(BuildContext context) {
return Consumer<FlutterDevPlaylists>(
builder: (context, playlists, _) {
final playlistItems = playlists.playlistItems(playlistId: playlistId);
if (playlistItems.isEmpty) {
return const Center(child: CircularProgressIndicator());
}
return _PlaylistDetailsListView(playlistItems: playlistItems);
},
);
}
}
class _PlaylistDetailsListView extends StatefulWidget {
const _PlaylistDetailsListView({required this.playlistItems});
final List<PlaylistItem> playlistItems;
@override
State<_PlaylistDetailsListView> createState() =>
_PlaylistDetailsListViewState();
}
class _PlaylistDetailsListViewState extends State<_PlaylistDetailsListView> {
late ScrollController _scrollController;
@override
void initState() {
super.initState();
_scrollController = ScrollController();
}
@override
void dispose() {
_scrollController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return ListView.builder(
controller: _scrollController,
itemCount: widget.playlistItems.length,
itemBuilder: (context, index) {
final playlistItem = widget.playlistItems[index];
return Padding(
padding: const EdgeInsets.all(8.0),
child: ClipRRect(
borderRadius: BorderRadius.circular(4),
child: Stack(
alignment: Alignment.center,
children: [
if (playlistItem.snippet!.thumbnails!.high != null)
AdaptiveImage.network( // Modify this line
playlistItem.snippet!.thumbnails!.high!.url!),
_buildGradient(context),
_buildTitleAndSubtitle(context, playlistItem),
_buildPlayButton(context, playlistItem),
],
),
),
);
},
);
}
Widget _buildGradient(BuildContext context) {
return Positioned.fill(
child: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [Colors.transparent, Theme.of(context).backgroundColor],
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
stops: const [0.5, 0.95],
),
),
),
);
}
Widget _buildTitleAndSubtitle(
BuildContext context, PlaylistItem playlistItem) {
return Positioned(
left: 20,
right: 0,
bottom: 20,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
AdaptiveText( // This line
playlistItem.snippet!.title!,
style: Theme.of(context).textTheme.bodyText1!.copyWith(
fontSize: 18,
// fontWeight: FontWeight.bold,
),
),
if (playlistItem.snippet!.videoOwnerChannelTitle != null)
AdaptiveText( // And, this line
playlistItem.snippet!.videoOwnerChannelTitle!,
style: Theme.of(context).textTheme.bodyText2!.copyWith(
fontSize: 12,
),
),
],
),
);
}
Widget _buildPlayButton(BuildContext context, PlaylistItem playlistItem) {
return Stack(
alignment: AlignmentDirectional.center,
children: [
Container(
width: 42,
height: 42,
decoration: const BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.all(
Radius.circular(21),
),
),
),
Link(
uri: Uri.parse(
'https://www.youtube.com/watch?v=${playlistItem.snippet!.resourceId!.videoId}'),
builder: (context, followLink) => IconButton(
onPressed: followLink,
color: Colors.red,
icon: const Icon(Icons.play_circle_fill),
iconSize: 45,
),
),
],
);
}
}
在上面的代码中,您调整了 Image.network
和 Text
widget。接下来,调整 Playlists
widget。
lib/src/playlists.dart
import 'package:flutter/material.dart';
import 'package:googleapis/youtube/v3.dart';
import 'package:provider/provider.dart';
import 'adaptive_image.dart'; // Add this line
import 'app_state.dart';
class Playlists extends StatelessWidget {
const Playlists({super.key, required this.playlistSelected});
final PlaylistsListSelected playlistSelected;
@override
Widget build(BuildContext context) {
return Consumer<FlutterDevPlaylists>(
builder: (context, flutterDev, child) {
final playlists = flutterDev.playlists;
if (playlists.isEmpty) {
return const Center(
child: CircularProgressIndicator(),
);
}
return _PlaylistsListView(
items: playlists,
playlistSelected: playlistSelected,
);
},
);
}
}
typedef PlaylistsListSelected = void Function(Playlist playlist);
class _PlaylistsListView extends StatefulWidget {
const _PlaylistsListView({
required this.items,
required this.playlistSelected,
});
final List<Playlist> items;
final PlaylistsListSelected playlistSelected;
@override
State<_PlaylistsListView> createState() => _PlaylistsListViewState();
}
class _PlaylistsListViewState extends State<_PlaylistsListView> {
late ScrollController _scrollController;
@override
void initState() {
super.initState();
_scrollController = ScrollController();
}
@override
void dispose() {
_scrollController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return ListView.builder(
controller: _scrollController,
itemCount: widget.items.length,
itemBuilder: (context, index) {
var playlist = widget.items[index];
return Padding(
padding: const EdgeInsets.all(8.0),
child: ListTile(
leading: AdaptiveImage.network( // Change this one.
playlist.snippet!.thumbnails!.default_!.url!,
),
title: Text(playlist.snippet!.title!),
subtitle: Text(
playlist.snippet!.description!,
),
onTap: () {
widget.playlistSelected(playlist);
},
),
);
},
);
}
}
这次您只调整了 Image.network
widget,将两个 Text
widget 保留不变。这是有意为之,因为如果您调整文本 widget,则当用户点按文本时,ListTile
的 onTap
功能将被阻止。
在网络上正确运行应用
在运行 CORS 代理的情况下,您应该能够运行该应用的 Web 版本,并使其看起来如下所示:
7. 自适应身份验证
在此步骤中,您将扩展该应用,为其增加以下功能:对用户进行身份验证,然后显示该用户的播放列表。您将不得不使用多个插件来覆盖应用可以运行的不同平台,因为在 Android、iOS、Web、Windows、macOS 和 Linux 之间处理 OAuth 的方式截然不同。
添加插件以启用 Google 身份验证
您将安装三个软件包来处理 Google 身份验证。
$ flutter pub add googleapis_auth Resolving dependencies... + crypto 3.0.1 + googleapis_auth 1.3.0 test_api 0.4.3 (0.4.8 available) Changed 2 dependencies! $ flutter pub add google_sign_in Resolving dependencies... + google_sign_in 5.2.1 + google_sign_in_platform_interface 2.1.0 + google_sign_in_web 0.10.0+3 + quiver 3.0.1+1 test_api 0.4.3 (0.4.8 available) Changed 4 dependencies! $ flutter pub add extension_google_sign_in_as_googleapis_auth Resolving dependencies... + extension_google_sign_in_as_googleapis_auth 2.0.4 test_api 0.4.3 (0.4.8 available) Changed 1 dependency!
使用 googleapis_auth
插件以在 Windows、macOS 和 Linux 上通过用户的网络浏览器进行身份验证。在 Android、iOS 和 Web 上,使用 google_sign_in
,其中 extension_google_sign_in_as_googleapis_auth
充当两个软件包之间的互操作 shim。
更新代码
通过创建一个新的可重用抽象 AdaptiveLogin widget 来开始更新。这个 widget 专为重复使用而设计,因此需要一些配置:
lib/src/adaptive_login.dart
import 'dart:io' show Platform;
import 'package:extension_google_sign_in_as_googleapis_auth/extension_google_sign_in_as_googleapis_auth.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:google_sign_in/google_sign_in.dart';
import 'package:googleapis_auth/auth_io.dart';
import 'package:provider/provider.dart';
import 'package:url_launcher/link.dart';
import 'app_state.dart';
typedef _AdaptiveLoginButtonWidget = Widget Function({
required VoidCallback? onPressed,
});
class AdaptiveLogin extends StatelessWidget {
const AdaptiveLogin({
super.key,
required this.clientId,
required this.scopes,
required this.loginButtonChild,
});
final ClientId clientId;
final List<String> scopes;
final Widget loginButtonChild;
@override
Widget build(BuildContext context) {
if (kIsWeb || Platform.isAndroid || Platform.isIOS) {
return _GoogleSignInLogin(
button: _loginButton,
scopes: scopes,
);
} else {
return _GoogleApisAuthLogin(
button: _loginButton,
scopes: scopes,
clientId: clientId,
);
}
}
Widget _loginButton({required VoidCallback? onPressed}) => ElevatedButton(
onPressed: onPressed,
child: loginButtonChild,
);
}
class _GoogleSignInLogin extends StatefulWidget {
const _GoogleSignInLogin({
required this.button,
required this.scopes,
});
final _AdaptiveLoginButtonWidget button;
final List<String> scopes;
@override
State<_GoogleSignInLogin> createState() => _GoogleSignInLoginState();
}
class _GoogleSignInLoginState extends State<_GoogleSignInLogin> {
@override
initState() {
super.initState();
_googleSignIn = GoogleSignIn(
scopes: widget.scopes,
);
_googleSignIn.onCurrentUserChanged.listen((account) {
if (account != null) {
_googleSignIn.authenticatedClient().then((authClient) {
if (authClient != null) {
context.read<AuthedUserPlaylists>().authClient = authClient;
context.go('/');
}
});
}
});
}
late final GoogleSignIn _googleSignIn;
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: widget.button(onPressed: () {
_googleSignIn.signIn();
}),
),
);
}
}
class _GoogleApisAuthLogin extends StatefulWidget {
const _GoogleApisAuthLogin({
required this.button,
required this.scopes,
required this.clientId,
});
final _AdaptiveLoginButtonWidget button;
final List<String> scopes;
final ClientId clientId;
@override
State<_GoogleApisAuthLogin> createState() => _GoogleApisAuthLoginState();
}
class _GoogleApisAuthLoginState extends State<_GoogleApisAuthLogin> {
@override
initState() {
super.initState();
clientViaUserConsent(widget.clientId, widget.scopes, (url) {
setState(() {
_authUrl = Uri.parse(url);
});
}).then((authClient) {
context.read<AuthedUserPlaylists>().authClient = authClient;
context.go('/');
});
}
Uri? _authUrl;
@override
Widget build(BuildContext context) {
final authUrl = _authUrl;
if (authUrl != null) {
return Scaffold(
body: Center(
child: Link(
uri: authUrl,
builder: (context, followLink) =>
widget.button(onPressed: followLink),
),
),
);
}
return const Scaffold(
body: Center(
child: CircularProgressIndicator(),
),
);
}
}
此文件包含许多操作。重要的部分是,在 AdaptiveLogin
的 build
方法中,使用 kIsWeb
和 dart:io
的 Platform.isXXX
调用的组合来检查运行时平台,为 Android、iOS 和 Web 实例化 _GoogleSignInLogin
有状态 widget;而对于 Windows、macOS 和 Linux,则构建了一个 _GoogleApisAuthLogin
有状态 widget。
使用这些类需要进行额外的配置,在更新代码库的其余部分以使用这个新 widget 后即可进行配置。首先将 FlutterDevPlaylists
重命名为 AuthedUserPlaylists
以更好地反映其在现实中的新用途,并更新代码以反映 http.Client
现在是在构建后传递的。最后,不再需要 _ApiKeyClient
类:
lib/src/app_state.dart
import 'dart:collection';
import 'package:flutter/foundation.dart';
import 'package:googleapis/youtube/v3.dart';
import 'package:http/http.dart' as http;
class AuthedUserPlaylists extends ChangeNotifier { // Rename class
set authClient(http.Client client) { // Drop constructor, add setter
_api = YouTubeApi(client);
_loadPlaylists();
}
bool get isLoggedIn => _api != null; // Add property
Future<void> _loadPlaylists() async {
String? nextPageToken;
_playlists.clear();
do {
final response = await _api!.playlists.list( // Add ! to _api
['snippet', 'contentDetails', 'id'],
mine: true, // convert from channelId: to mine:
maxResults: 50,
pageToken: nextPageToken,
);
_playlists.addAll(response.items!);
_playlists.sort((a, b) => a.snippet!.title!
.toLowerCase()
.compareTo(b.snippet!.title!.toLowerCase()));
notifyListeners();
} while (nextPageToken != null);
}
YouTubeApi? _api; // Convert from late final to optional
final List<Playlist> _playlists = [];
List<Playlist> get playlists => UnmodifiableListView(_playlists);
final Map<String, List<PlaylistItem>> _playlistItems = {};
List<PlaylistItem> playlistItems({required String playlistId}) {
if (!_playlistItems.containsKey(playlistId)) {
_playlistItems[playlistId] = [];
_retrievePlaylist(playlistId);
}
return UnmodifiableListView(_playlistItems[playlistId]!);
}
Future<void> _retrievePlaylist(String playlistId) async {
String? nextPageToken;
do {
var response = await _api!.playlistItems.list( // Add ! to _api
['snippet', 'contentDetails'],
playlistId: playlistId,
maxResults: 25,
pageToken: nextPageToken,
);
var items = response.items;
if (items != null) {
_playlistItems[playlistId]!.addAll(items);
}
notifyListeners();
nextPageToken = response.nextPageToken;
} while (nextPageToken != null);
}
}
// Delete the now unused _ApiKeyClient class
接下来,使用提供的应用状态对象的新名称更新 PlaylistDetails
widget:
lib/src/playlist_details.dart
class PlaylistDetails extends StatelessWidget {
const PlaylistDetails(
{required this.playlistId, required this.playlistName, super.key});
final String playlistId;
final String playlistName;
@override
Widget build(BuildContext context) {
return Consumer<AuthedUserPlaylists>( // Update this line
builder: (context, flutterDev, _) {
final playlistItems = flutterDev.playlistItems(playlistId: playlistId);
if (playlistItems.isEmpty) {
return const Center(child: CircularProgressIndicator());
}
return _PlaylistDetailsListView(playlistItems: playlistItems);
},
);
}
}
同样,更新 Playlists
widget:
lib/src/playlists.dart
class Playlists extends StatelessWidget {
const Playlists({required this.playlistSelected, super.key});
final PlaylistsListSelected playlistSelected;
@override
Widget build(BuildContext context) {
return Consumer<AuthedUserPlaylists>( // Update this line
builder: (context, flutterDev, child) {
final playlists = flutterDev.playlists;
if (playlists.isEmpty) {
return const Center(
child: CircularProgressIndicator(),
);
}
return _PlaylistsListView(
items: playlists,
playlistSelected: playlistSelected,
);
},
);
}
}
最后,更新 main.dart
文件以正确使用新的 AdaptiveLogin
widget:
lib/main.dart
// Drop dart:io import
import 'package:flex_color_scheme/flex_color_scheme.dart';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:googleapis_auth/googleapis_auth.dart'; // Add this line
import 'package:provider/provider.dart';
import 'src/adaptive_login.dart'; // Add this line
import 'src/adaptive_playlists.dart';
import 'src/app_state.dart';
import 'src/playlist_details.dart';
// Drop flutterDevAccountId and youTubeApiKey
// Add from this line
// From https://developers.google.com/youtube/v3/guides/auth/installed-apps#identify-access-scopes
final scopes = [
'https://www.googleapis.com/auth/youtube.readonly',
];
// TODO: Replace with your Client ID and Client Secret for Desktop configuration
final clientId = ClientId(
'TODO-Client-ID.apps.googleusercontent.com',
'TODO-Client-secret',
);
// To this line
final _router = GoRouter(
routes: <RouteBase>[
GoRoute(
path: '/',
builder: (context, state) {
return const AdaptivePlaylists();
},
// Add redirect configuration
redirect: (context, state) {
if (!context.read<AuthedUserPlaylists>().isLoggedIn) {
return '/login';
} else {
return null;
}
},
// To this line
routes: <RouteBase>[
// Add new login Route
GoRoute(
path: 'login',
builder: (context, state) {
return AdaptiveLogin(
clientId: clientId,
scopes: scopes,
loginButtonChild: const Text('Login to YouTube'),
);
},
),
// To this line
GoRoute(
path: 'playlist/:id',
builder: (context, state) {
final title = state.queryParams['title']!;
final id = state.params['id']!;
return Scaffold(
appBar: AppBar(title: Text(title)),
body: PlaylistDetails(
playlistId: id,
playlistName: title,
),
);
},
),
],
),
],
);
void main() {
runApp(ChangeNotifierProvider<AuthedUserPlaylists>( // Modify this line
create: (context) => AuthedUserPlaylists(), // Modify this line
child: const PlaylistsApp(),
));
}
class PlaylistsApp extends StatelessWidget {
const PlaylistsApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Your Playlists', // Change FlutterDev to Your
theme: FlexColorScheme.light(
scheme: FlexScheme.red,
useMaterial3: true,
).toTheme,
darkTheme: FlexColorScheme.dark(
scheme: FlexScheme.red,
useMaterial3: true,
).toTheme,
themeMode: ThemeMode.dark, // Or ThemeMode.System if you'd prefer
debugShowCheckedModeBanner: false,
routerConfig: _router,
);
}
}
此文件中的更改反映了从仅显示 Flutter 的 YouTube 播放列表到显示经过身份验证的用户的播放列表这一变化。虽然代码现已完成,但仍需要对该文件以及相应 Runner 应用下的文件进行一系列修改,以正确配置用于身份验证的 google_sign_in
和 googleapis_auth
软件包。
配置 googleapis_auth
配置身份验证的第一步是删除您之前配置和使用的 API 密钥。转到您的 API 项目的凭据页面,然后删除 API 密钥:
这会生成一个弹出窗口,您可以通过点击“Delete”按钮确认进行删除:
然后,创建一个 OAuth 客户端 ID:
对于“Application type”,选择“Desktop app”。
接受相应名称,然后点击 Create。
这将创建客户端 ID 和客户端密钥,您必须将它们添加到 lib/main.dart
才能配置 googleapis_auth
流。一个重要的实现细节是,googleapis_auth 流使用在本地主机上运行的临时网络服务器来捕获生成的 OAuth 令牌,而且在 macOS 上还需要修改 macos/Runner/Release.entitlements
文件:
macos/Runner/Release.entitlements
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.app-sandbox</key>
<true/>
<!-- add the following two lines -->
<key>com.apple.security.network.server</key>
<true/>
<key>com.apple.security.network.client</key>
<true/>
</dict>
</plist>
您不需要对 macos/Runner/DebugProfile.entitlements
文件进行此修改,因为它已经拥有 com.apple.security.network.server
的授权,可以启用热重载和 Dart 虚拟机调试工具。
您现在应该能够在 Windows、macOS 或 Linux 上运行您的应用(如果应用是在这些目标平台上编译的)。
针对 Android 配置 google_sign_in
返回 API 项目的凭据页面,创建另一个 OAuth 客户端 ID,但这次选择 Android:
对于表单的其余部分,请使用 android/app/src/main/AndroidManifest.xml
中声明的软件包填写软件包名称。如果您严格按照说明进行了操作,则该名称应该为 com.example.adaptive_app
。按照 Google Cloud Platform Console 帮助页面中的说明提取 SHA-1 证书指纹:
这足以让应用在 Android 上正常运行。根据您选择使用的 Google API,您可能需要将生成的 JSON 文件添加到应用软件包中。
针对 iOS 配置 google_sign_in
返回 API 项目的凭据页面,创建另一个 OAuth 客户端 ID,但这次选择 iOS:
.
对于表单的其余部分,通过在 Xcode 中打开 ios/Runner.xcworkspace
来填写软件包 ID。转到项目导航器,在导航器中选择“Runner”,然后选择“General”标签页,并复制软件包标识符。如果您严格按照此 Codelab 的说明执行了每一步,则软件包标识符应该为 com.example.adaptiveApp
。
暂时忽略“App Store ID”和“Team ID”,因为本地开发不需要它们:
下载生成的 .plist
文件,其名称基于您生成的客户端 ID。将下载的文件重命名为 GoogleService-Info.plist
,然后将其拖到正在运行的 Xcode 编辑器中,与左侧导航器中 Runner/Runner
下的 Info.plist
文件在一起。对于 Xcode 中的选项对话框,选择 Copy items if needed、Create folder references,并为 Add to targets 选择“Runner”。
退出 Xcode,然后在选择的 IDE 中,将以下内容添加到 Info.plist
:
ios/Runner/Info.plist
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>CFBundleURLSchemes</key>
<array>
<!-- TODO Replace this value: -->
<!-- Copied from GoogleService-Info.plist key REVERSED_CLIENT_ID -->
<string>com.googleusercontent.apps.TODO-REPLACE-ME</string>
</array>
</dict>
</array>
您需要编辑该值以匹配生成的 GoogleService-Info.plist
文件中的条目。您还必须将最低 iOS 版本设置为 9。按如下所示修改 ios/Podfile
:
ios/Podfile
# iOS 9 for google_sign_in
platform :ios, '9.0' # Uncomment this line
# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
ENV['COCOAPODS_DISABLE_STATS'] = 'true'
project 'Runner', {
'Debug' => :debug,
'Profile' => :release,
'Release' => :release,
}
def flutter_root
generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'Generated.xcconfig'), __FILE__)
unless File.exist?(generated_xcode_build_settings_path)
raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure flutter pub get is executed first"
end
File.foreach(generated_xcode_build_settings_path) do |line|
matches = line.match(/FLUTTER_ROOT\=(.*)/)
return matches[1].strip if matches
end
raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Generated.xcconfig, then run flutter pub get"
end
require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root)
flutter_ios_podfile_setup
target 'Runner' do
use_frameworks!
use_modular_headers!
flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__))
end
post_install do |installer|
installer.pods_project.targets.each do |target|
flutter_additional_ios_build_settings(target)
end
end
运行您的应用,在登录后,您应该会看到您的播放列表。
针对 Web 配置 google_sign_in
返回 API 项目的凭据页面,创建另一个 OAuth 客户端 ID,但这次选择 Web application:
对于表单的其余部分,在“Authorized JavaScript origins”中填写相关信息,如下所示:
这会生成一个客户端 ID。将以下 meta
标记添加到 web/index.html
,并更新为包含生成的客户端 ID:
web/index.html
<meta name="google-signin-client_id" content="YOUR_GOOGLE_SIGN_IN_OAUTH_CLIENT_ID.apps.googleusercontent.com">
运行此示例需要一些具体的指导。您需要运行在上一步中创建的 CORS 代理,并且需要使用以下说明在 Web 应用 OAuth 客户端 ID 表单中指定的端口上运行 Flutter Web 应用。
在一个终端中,运行 CORS 代理服务器,如下所示:
$ dart run bin/server.dart Server listening on port 8080
在另一个终端中,运行 Flutter 应用,如下所示:
$ flutter run -d chrome --web-hostname localhost --web-port 8090 Launching lib/main.dart on Chrome in debug mode... Waiting for connection from debug service on Chrome... 20.4s This app is linked to the debug service: ws://127.0.0.1:52430/Nb3Q7puZqvI=/ws Debug service listening on ws://127.0.0.1:52430/Nb3Q7puZqvI=/ws 💪 Running with sound null safety 💪 🔥 To hot restart changes while running, press "r" or "R". For a more detailed help message, press "h". To quit, press "q".
再次登录后,您应该会看到您的播放列表:
8. 后续步骤
恭喜!
您已完成此 Codelab,并构建了一个自适应 Flutter 应用,该应用可在 Flutter 支持的所有六个平台上运行。您调整了代码以处理不同的屏幕布局方式、文本交互方式、图像加载方式以及身份验证工作方式。
您还可以在应用中调整更多内容。要了解调整代码以适应不同运行环境的其他方法,请参阅构建自适应应用。