1. 簡介
Flutter 是 Google 的 UI 工具包,可讓您根據單一程式碼集,打造出適合在行動裝置、網頁和電腦環境中執行且以原生方式編譯的應用程式。在本程式碼研究室中,您將瞭解如何建構可配合執行平台調整的 Flutter 應用程式,無論是 Android、iOS、網頁、Windows、macOS 或 Linux 皆適用。
課程內容
- 瞭解如何擴展專為行動裝置設計的 Flutter 應用程式,使其適用於 Flutter 支援的所有六個平台。
- 用於平台偵測的不同 Flutter API,以及各 API 的使用時機。
- 配合在網路上執行應用程式的限制和期望。
- 如何同時使用不同套件,支援 Flutter 的所有平台。
建構項目
在本程式碼研究室中,您一開始會建構適用於 Android 和 iOS 的 Flutter 應用程式,探索 Flutter 的 YouTube 播放清單。接著,您會修改資訊的顯示方式 (根據應用程式視窗大小),讓這個應用程式在三個桌面平台 (Windows、macOS 和 Linux) 上運作。接著,您要調整應用程式,讓應用程式顯示的文字可供選取,以符合網頁使用者的期望。最後,您將在應用程式中新增驗證功能,以便探索自己的播放清單,而非 Flutter 團隊建立的播放清單。相較於 Windows、macOS 和 Linux 這三個桌面平台,Android、iOS 和網頁需要不同的驗證方法。
以下是 Android 和 iOS 上的 Flutter 應用程式螢幕截圖:
在 macOS 上以寬螢幕模式執行的這個應用程式,應該會類似於下列螢幕截圖。
本程式碼研究室著重於將行動版 Flutter 應用程式轉換為適用於所有六個 Flutter 平台的自適應應用程式。我們不會對與本主題無關的概念和程式碼多做介紹,但會事先準備好這些程式碼區塊,屆時您只要複製及貼上即可。
您想從本程式碼研究室學到什麼?
2. 設定 Flutter 開發環境
您需要兩項軟體才能完成本實驗室活動:Flutter SDK 和編輯器。
您可以使用下列任一裝置執行程式碼研究室:
- 連線至電腦並設為開發人員模式的實體 Android 或 iOS 裝置。
- iOS 模擬器 (需要安裝 Xcode 工具)。
- Android Emulator (需在 Android Studio 中設定)。
- 瀏覽器 (偵錯時必須使用 Chrome)。
- 以 Windows、Linux 或 macOS 桌面應用程式的形式。您必須在要部署的平台上進行開發。因此,如要開發 Windows 桌面應用程式,您必須在 Windows 上開發,才能存取適當的建構鏈。如需作業系統專屬需求,請參閱 docs.flutter.dev/desktop。
3. 開始操作
確認開發環境
如要確認一切就緒,可以執行下列指令:
flutter doctor
如果顯示任何沒有勾號的項目,請執行下列指令,進一步瞭解問題:
flutter doctor -v
您可能需要安裝行動裝置或電腦開發的開發人員工具。如要進一步瞭解如何根據主機作業系統設定工具,請參閱 Flutter 安裝說明文件。
建立 Flutter 專案
如要開始編寫 Flutter 電腦版應用程式,請使用 Flutter 指令列工具建立 Flutter 專案。或者,IDE 也可能提供透過 UI 建立 Flutter 專案的工作流程。
$ flutter create adaptive_app Creating project adaptive_app... Resolving dependencies in adaptive_app... (1.8s) Got dependencies in adaptive_app. Wrote 129 files. All done! You can find general documentation for Flutter at: https://docs.flutter.dev/ Detailed API documentation is available at: https://api.flutter.dev/ If you prefer video documentation, consider: https://www.youtube.com/c/flutterdev 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 15 in debug mode... Running Xcode build... └─Compiling, linking and signing... 6.5s Xcode build done. 24.6s Syncing files to device iPhone 15... 46ms 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). A Dart VM Service on iPhone 15 is available at: http://127.0.0.1:50501/JHGBwC_hFJo=/ The Flutter DevTools debugger and profiler on iPhone 15 is available at: http://127.0.0.1:9102?uri=http://127.0.0.1:50501/JHGBwC_hFJo=/
現在應該會看到應用程式正在執行。內容需要更新。
如要更新內容,請使用下列程式碼更新 lib/main.dart
中的程式碼。如要變更應用程式顯示的內容,請執行熱重載。
- 如果使用指令列執行應用程式,請在控制台中輸入
r
,即可進行熱重載。 - 如果您使用 IDE 執行應用程式,儲存檔案時應用程式會重新載入。
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(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
),
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.headlineSmall,
),
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 和網頁版都會以 2 的裝置像素比例顯示。iPhone 12 的比例為 3,Pixel 2 則為 2.63。在所有情況下,顯示的文字大致相同,因此開發人員的工作輕鬆許多。
第二個要注意的重點是,檢查程式碼執行所在平台的兩個選項會產生不同的值。第一個選項會檢查從 dart:io
匯入的 Platform
物件,第二個選項 (僅適用於 Widget 的 build
方法內) 則會從 BuildContext
引數擷取 Theme
物件。
這兩種方法會傳回不同結果的原因在於其意圖不同。從 dart:io
匯入的 Platform
物件可用於做出與算繪選項無關的決策。舉例來說,您要決定使用哪些外掛程式時,可能需要考慮這些外掛程式是否與特定實體平台的原生實作項目相符。
擷取 Theme
中的 BuildContext
是為了做出以主題為中心的實作決策。舉例來說,如Slider.adaptive
所述,您必須決定要使用 Material 滑桿還是 Cupertino 滑桿。
在下一節中,您將建構基本 YouTube 播放清單探索應用程式,完全針對 Android 和 iOS 進行最佳化。在接下來的章節中,您將新增各種調整項目,讓應用程式在電腦和網頁上運作得更順暢。
4. 建構行動應用程式
新增套件
在本應用程式中,您將使用各種 Flutter 套件來存取 YouTube Data API、狀態管理和主題設定。
$ flutter pub add googleapis http provider url_launcher flex_color_scheme go_router Resolving dependencies... Downloading packages... + _discoveryapis_commons 1.0.7 characters 1.4.0 (1.4.1 available) + flex_color_scheme 8.3.0 + flex_seed_scheme 3.5.1 > flutter_lints 6.0.0 (was 5.0.0) + flutter_web_plugins 0.0.0 from sdk flutter + go_router 16.2.0 + googleapis 14.0.0 + http 1.5.0 + http_parser 4.1.2 > lints 6.0.0 (was 5.1.1) + logging 1.3.0 material_color_utilities 0.11.1 (0.13.0 available) meta 1.16.0 (1.17.0 available) + nested 1.0.0 + plugin_platform_interface 2.1.8 + provider 6.1.5 test_api 0.7.6 (0.7.7 available) + typed_data 1.4.0 + url_launcher 6.3.2 + url_launcher_android 6.3.17 + url_launcher_ios 6.3.4 + url_launcher_linux 3.2.1 + url_launcher_macos 3.2.3 + url_launcher_platform_interface 2.3.2 + url_launcher_web 2.4.1 + url_launcher_windows 3.1.4 + web 1.1.1 Changed 24 dependencies! 4 packages have newer versions incompatible with dependency constraints. Try `flutter pub outdated` for more information.
這個指令會將多個套件新增至應用程式:
googleapis
:產生的 Dart 程式庫,可提供 Google API 的存取權。http
:用於建立 HTTP 要求的程式庫,可隱藏原生和網頁瀏覽器之間的差異。provider
:提供狀態管理功能。url_launcher
:提供從播放清單跳到影片的方式。如已解決的依附元件所示,除了預設的 Android 和 iOS 之外,url_launcher
也適用於 Windows、macOS、Linux 和網頁。使用這個套件表示您不需要為這項功能建立平台專屬的程式碼。flex_color_scheme
:為應用程式提供合適的預設配色。詳情請參閱flex_color_scheme
API 說明文件。go_router
:實作不同畫面間的導覽功能。這個套件提供以網址為基礎的便利 API,可使用 Flutter 的 Router 進行導覽。
設定 url_launcher
的行動應用程式
url_launcher
外掛程式需要設定 Android 和 iOS 執行器應用程式。在 iOS Flutter 執行器中,將下列程式碼行新增至 plist
字典。
ios/Runner/Info.plist
<key>LSApplicationQueriesSchemes</key>
<array>
<string>https</string>
<string>http</string>
<string>tel</string>
<string>mailto</string>
</array>
在 Android Flutter 執行器中,將下列程式碼行新增至 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 帳戶,如果沒有,請先建立帳戶。
前往 Developer Console 建立 API 專案:
建立專案後,請前往 API 程式庫頁面。在搜尋框中輸入「youtube」,然後選取「youtube data api v3」。
在 YouTube Data API v3 詳細資料頁面中,啟用 API。
啟用 API 後,請前往憑證頁面,然後建立 API 金鑰。
幾秒後,您應該會看到一個對話方塊,內含全新的 API 金鑰。您很快就會用到這組金鑰。
新增程式碼
在本步驟的其餘部分,您將剪下並貼上大量程式碼來建構行動應用程式,但不會對程式碼進行任何註解。本程式碼研究室的目標是將行動應用程式改編為適用於電腦和網頁。如要進一步瞭解如何建構行動裝置專用的 Flutter 應用程式,請參閱「您的第一個 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();
nextPageToken = response.nextPageToken;
} 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).colorScheme.surface],
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.bodyLarge!.copyWith(
fontSize: 18,
// fontWeight: FontWeight.bold,
),
),
if (playlistItem.snippet!.videoOwnerChannelTitle != null)
Text(
playlistItem.snippet!.videoOwnerChannelTitle!,
style: Theme.of(
context,
).textTheme.bodyMedium!.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.uri.queryParameters['title']!;
final id = state.pathParameters['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).toTheme,
darkTheme: FlexColorScheme.dark(scheme: FlexScheme.red).toTheme,
themeMode: ThemeMode.dark, // Or ThemeMode.System if you'd prefer
debugShowCheckedModeBanner: false,
routerConfig: _router,
);
}
}
您即將能在 Android 和 iOS 上執行這段程式碼。還有一件事要變更,請使用上一個步驟產生的 YouTube API 金鑰修改 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... Downloading packages... characters 1.4.0 (1.4.1 available) material_color_utilities 0.11.1 (0.13.0 available) meta 1.16.0 (1.17.0 available) + split_view 3.2.1 test_api 0.7.6 (0.7.7 available) Changed 1 dependency! 4 packages have newer versions incompatible with dependency constraints. Try `flutter pub outdated` for more information.
推出自動調整小工具
在本程式碼研究室中,您將使用自動調整式小工具模式,根據螢幕寬度、平台主題等屬性做出實作選擇。在本例中,您將導入 AdaptivePlaylists
小工具,重新設計 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'; // Add this import
import 'src/app_state.dart';
import 'src/playlist_details.dart';
// Remove the src/playlists.dart import
// 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(); // Modify this line
},
routes: <RouteBase>[
GoRoute(
path: 'playlist/:id',
builder: (context, state) {
final title = state.uri.queryParameters['title']!;
final id = state.pathParameters['id']!;
return Scaffold( // Modify from here
appBar: AppBar(title: Text(title)),
body: PlaylistDetails(playlistId: id, playlistName: title),
); // To here.
},
),
],
),
],
);
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).toTheme,
darkTheme: FlexColorScheme.dark(scheme: FlexScheme.red).toTheme,
themeMode: ThemeMode.dark, // Or ThemeMode.System if you'd prefer
debugShowCheckedModeBanner: false,
routerConfig: _router,
);
}
}
接著,為 AdaptivePlaylist 小工具建立檔案:
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: switch (selectedPlaylist?.snippet?.title) {
String title => Text('FlutterDev Playlist: $title'),
_ => const Text('FlutterDev Playlists'),
},
),
body: SplitView(
viewMode: SplitViewMode.Horizontal,
children: [
Playlists(
playlistSelected: (playlist) {
setState(() {
selectedPlaylist = playlist;
});
},
),
switch ((selectedPlaylist?.id, selectedPlaylist?.snippet?.title)) {
(String id, String title) => PlaylistDetails(
playlistId: id,
playlistName: title,
),
_ => const Center(child: Text('Select a playlist')),
},
],
),
);
}
}
這個檔案之所以有趣,有以下幾個原因。首先,它會使用視窗寬度 (使用 MediaQuery.of(context).size.width
),並檢查主題 (使用 Theme.of(context).platform
),判斷是否要顯示含有 SplitView
小工具的寬版面配置,或是不含該小工具的窄版面配置。
其次,本節會處理導覽的硬式編碼處理作業。這個小工具會顯示 Playlists
中的回呼引數。該回呼會通知周圍的程式碼,使用者已選取播放清單。接著,程式碼必須執行工作來顯示該播放清單。這項變更會影響 Playlists
和 PlaylistDetails
小工具中 Scaffold
的必要性。現在這些小工具不再位於頂層,因此您必須從這些小工具中移除 Scaffold
。
接著,編輯 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
小工具之外,_PlaylistsListView
小工具也從無狀態轉換為有狀態。由於系統導入了必須建構及毀損的自有 ScrollController
,因此需要進行這項變更。
導入 ScrollController
很有趣,因為在寬版面配置中,您有兩個並排的 ListView
小工具,因此需要導入 ScrollController
。在手機上,通常只有一個 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).colorScheme.surface],
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.bodyLarge!.copyWith(
fontSize: 18,
// fontWeight: FontWeight.bold,
),
),
if (playlistItem.snippet!.videoOwnerChannelTitle != null)
Text(
playlistItem.snippet!.videoOwnerChannelTitle!,
style: Theme.of(
context,
).textTheme.bodyMedium!.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
小工具類似,這個檔案也進行了變更,以淘汰 Scaffold
小工具,並導入自有 ScrollController
。
再次執行應用程式!
在您選擇的桌機上執行應用程式,無論是 Windows、macOS 或 Linux。現在應該可以正常運作。
6. 配合網站調整
這些圖片是怎麼回事?
現在嘗試在網路上執行這個應用程式時,會顯示需要更多作業,才能適應網頁瀏覽器。
如果您查看偵錯控制台,會看到溫和的提示,瞭解下一步該怎麼做。
══╡ EXCEPTION CAUGHT BY IMAGE RESOURCE SERVICE ╞════════════════════════════════════════════════════ The following ProgressEvent$ object was thrown resolving an image codec: [object ProgressEvent] When the exception was thrown, this was the stack Image provider: NetworkImage("https://i.ytimg.com/vi/4AoFA19gbLo/default.jpg", scale: 1.0) Image key: NetworkImage("https://i.ytimg.com/vi/4AoFA19gbLo/default.jpg", scale: 1.0) ════════════════════════════════════════════════════════════════════════════════════════════════════
建立 CORS Proxy
如要解決圖片顯示問題,其中一種方法是導入 Proxy 網路服務,加入必要的跨源資源共享標頭。開啟終端機,然後依下列方式建立 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 http "http" was found in dev_dependencies. Removing "http" and adding it to dependencies instead. Resolving dependencies... Downloading packages... http 1.5.0 (from dev dependency to direct dependency) + shelf_cors_headers 0.1.5 Changed 2 dependencies!
目前有不再需要的依附元件。請依下列方式修剪:
$ dart pub remove shelf_router Resolving dependencies... Downloading packages... These packages are no longer being depended on: - http_methods 1.1.1 - shelf_router 1.1.4 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 Proxy,但僅限在網頁瀏覽器中執行時。
一組可調整式小工具
這組小工具的第一個,是應用程式使用 CORS Proxy 的方式。
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
常數。另一個適應性小工具會變更應用程式,使其運作方式與其他網頁類似。瀏覽器使用者會預期文字可供選取。
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) {
return switch (Theme.of(context).platform) {
TargetPlatform.android || TargetPlatform.iOS => Text(data, style: style),
_ => 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).colorScheme.surface],
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( // Also, this line
playlistItem.snippet!.title!,
style: Theme.of(context).textTheme.bodyLarge!.copyWith(
fontSize: 18,
// fontWeight: FontWeight.bold,
),
),
if (playlistItem.snippet!.videoOwnerChannelTitle != null)
AdaptiveText( // And this line
playlistItem.snippet!.videoOwnerChannelTitle!,
style: Theme.of(
context,
).textTheme.bodyMedium!.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
小工具。接著,調整 Playlists
小工具。
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
小工具,但保留了兩個 Text
小工具。這是刻意設計,因為如果調整 Text 小工具,使用者輕觸文字時,ListTile
的 onTap
功能會遭到封鎖。
在網路上正確執行應用程式
執行 CORS Proxy 後,您應該可以執行網頁版應用程式,畫面如下所示:
7. 適應性驗證
在這個步驟中,您要擴充應用程式,讓應用程式能夠驗證使用者,然後顯示該使用者的播放清單。您必須使用多個外掛程式,才能涵蓋應用程式可執行的不同平台,因為 Android、iOS、網頁、Windows、macOS 和 Linux 處理 OAuth 的方式大不相同。
新增外掛程式以啟用 Google 驗證
您將安裝三個套件來處理 Google 驗證。
$ flutter pub add googleapis_auth google_sign_in \ extension_google_sign_in_as_googleapis_auth logging Resolving dependencies... Downloading packages... + args 2.7.0 characters 1.4.0 (1.4.1 available) + crypto 3.0.6 + extension_google_sign_in_as_googleapis_auth 3.0.0 + google_identity_services_web 0.3.3+1 + google_sign_in 7.1.1 + google_sign_in_android 7.0.3 + google_sign_in_ios 6.1.0 + google_sign_in_platform_interface 3.0.0 + google_sign_in_web 1.0.0 + googleapis_auth 2.0.0 logging 1.3.0 (from transitive dependency to direct dependency) material_color_utilities 0.11.1 (0.13.0 available) meta 1.16.0 (1.17.0 available) test_api 0.7.6 (0.7.7 available) Changed 11 dependencies! 4 packages have newer versions incompatible with dependency constraints. Try `flutter pub outdated` for more information.
如要在 Windows、macOS 和 Linux 上進行驗證,請使用 googleapis_auth
套件。這些電腦平台會使用網路瀏覽器進行驗證。如要在 Android、iOS 和網路上進行驗證,請使用 google_sign_in
和 extension_google_sign_in_as_googleapis_auth
套件。第二個套件則做為兩個套件之間的互通性墊片。
更新程式碼
請先建立新的可重複使用抽象化項目,也就是 AdaptiveLogin 小工具,然後開始更新。這個小工具的設計目的是供您重複使用,因此需要進行一些設定:
lib/src/adaptive_login.dart
import 'dart:async';
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:logging/logging.dart';
import 'package:provider/provider.dart';
import 'package:url_launcher/link.dart';
import 'app_state.dart';
final _log = Logger('AdaptiveLogin');
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.instance;
_googleSignIn.initialize();
_authEventsSubscription = _googleSignIn.authenticationEvents.listen((
event,
) async {
_log.fine('Google Sign-In authentication event: $event');
if (event is GoogleSignInAuthenticationEventSignIn) {
final googleSignInClientAuthorization = await event
.user
.authorizationClient
.authorizationForScopes(widget.scopes);
if (googleSignInClientAuthorization == null) {
_log.warning('Google Sign-In authenticated client creation failed');
return;
}
_log.fine('Google Sign-In authenticated client created');
final context = this.context;
if (context.mounted) {
context.read<AuthedUserPlaylists>().authClient =
googleSignInClientAuthorization.authClient(scopes: widget.scopes);
context.go('/');
}
}
});
// Check if user is already authenticated
_log.fine('Attempting lightweight authentication');
_googleSignIn.attemptLightweightAuthentication();
}
@override
dispose() {
_authEventsSubscription.cancel();
super.dispose();
}
late final GoogleSignIn _googleSignIn;
late final StreamSubscription _authEventsSubscription;
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: widget.button(
onPressed: () {
_googleSignIn.authenticate();
},
),
),
);
}
}
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) {
final context = this.context;
if (context.mounted) {
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 和網頁,這會例項化 _GoogleSignInLogin
具狀態小工具。如果是 Windows、macOS 和 Linux,則會具現化 _GoogleApisAuthLogin
有狀態的小工具。
如要使用這些類別,必須進行額外設定,這部分會在稍後說明,也就是更新其餘程式碼集以使用這個新小工具之後。首先,請將 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();
nextPageToken = response.nextPageToken;
} while (nextPageToken != null);
}
YouTubeApi? _api; // Convert 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
小工具:
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, playlists, _) {
final playlistItems = playlists.playlistItems(playlistId: playlistId);
if (playlistItems.isEmpty) {
return const Center(child: CircularProgressIndicator());
}
return _PlaylistDetailsListView(playlistItems: playlistItems);
},
);
}
}
同樣地,請更新 Playlists
小工具:
lib/src/playlists.dart
class Playlists extends StatelessWidget {
const Playlists({super.key, required this.playlistSelected});
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
小工具:
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.uri.queryParameters['title']!;
final id = state.pathParameters['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.router(
title: 'Your Playlists', // Change FlutterDev to Your
theme: FlexColorScheme.light(scheme: FlexScheme.red).toTheme,
darkTheme: FlexColorScheme.dark(scheme: FlexScheme.red).toTheme,
themeMode: ThemeMode.dark, // Or ThemeMode.System
debugShowCheckedModeBanner: false,
routerConfig: _router,
);
}
}
這個檔案的變更反映了從只顯示 Flutter 的 YouTube 播放清單,到顯示已驗證使用者播放清單的變化。雖然程式碼已完成,但您仍須對這個檔案和相應 Runner 應用程式下的檔案進行一系列修改,才能正確設定 google_sign_in
和 googleapis_auth
套件的驗證。
應用程式現在會顯示經過驗證的使用者的 YouTube 播放清單。完成功能後,您需要啟用驗證。如要這麼做,請設定 google_sign_in
和 googleapis_auth
套件。如要設定套件,您需要變更 main.dart
檔案和 Runner 應用程式的檔案。
設定 googleapis_auth
設定驗證的第一步,是移除先前設定及使用的 API 金鑰。前往 API 專案的憑證頁面,然後刪除 API 金鑰:
系統會產生對話方塊,您只要按下「刪除」按鈕即可確認:
接著,建立 OAuth 用戶端 ID:
在「應用程式類型」部分,選取「電腦應用程式」。
接受名稱,然後按一下「建立」。
系統會建立用戶端 ID 和用戶端密鑰,您必須將這些資訊新增至 lib/main.dart
,才能設定 googleapis_auth
流程。重要的實作細節是,googleapis_auth 流程會使用在 localhost 上執行的臨時網路伺服器,擷取產生的 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 VM 偵錯工具。
現在您應該可以在 Windows、macOS 或 Linux 上執行應用程式 (前提是應用程式已在這些目標上編譯)。
設定 Android 版 google_sign_in
返回 API 專案的憑證頁面,然後建立另一個 OAuth 用戶端 ID,但這次請選取「Android」:
填寫表單的其餘部分時,請在「套件名稱」中填入 android/app/src/main/AndroidManifest.xml
中宣告的套件。如果完全按照指示操作,應該會是 com.example.adaptive_app
。按照 Google Cloud 控制台說明頁面的指示,擷取 SHA-1 憑證指紋:
這樣就足以讓應用程式在 Android 上運作。視您使用的 Google API 而定,您可能需要將產生的 JSON 檔案新增至應用程式套件。
設定 iOS 版 google_sign_in
返回 API 專案的憑證頁面,然後建立另一個 OAuth 用戶端 ID,但這次請選取「iOS」:
如要填寫表單的其餘部分,請在 Xcode 中開啟 ios/Runner.xcworkspace
,前往「Project Navigator」,在導覽器中選取「Runner」,然後選取「General」分頁標籤,並複製「Bundle Identifier」。如果您已逐步完成本程式碼研究室,這個值應為 com.example.adaptiveApp
。
填寫表單的其他部分,包括軟體包 ID。在 Xcode 中開啟「ios/Runner.xcworkspace
」。前往「Project Navigator」。前往「Runner」>「General」分頁。複製套件 ID。如果您已逐步完成本程式碼研究室,這個值應為 com.example.adaptiveApp
。
暫時忽略 App Store ID 和團隊 ID,因為本機開發不需要這些 ID:
下載產生的 .plist
檔案,檔案名稱會依據產生的用戶端 ID 而定。將下載的檔案重新命名為 GoogleService-Info.plist
,然後拖曳至執行中的 Xcode 編輯器,與左側導覽器中 Runner/Runner
下的 Info.plist
檔案並列。在 Xcode 的選項對話方塊中,視需要選取「Copy items」(複製項目)、「Create folder references」(建立資料夾參照) 和「Add to the Runner」(新增至 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
檔案中的項目相符。執行應用程式,登入後應該會看到播放清單。
設定網站適用的 google_sign_in
返回 API 專案的憑證頁面,然後建立另一個 OAuth 用戶端 ID,但這次請選取「Web application」(網路應用程式):
填寫表單其餘部分時,請按照下列方式填寫「已授權的 JavaScript 來源」:
系統會產生用戶端 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 Proxy,並按照下列操作說明,在網頁應用程式 OAuth 用戶端 ID 表單中指定的連接埠上執行 Flutter 網頁應用程式。
在一個終端機中,執行 CORS Proxy 伺服器,如下所示:
$ 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. 後續步驟
恭喜!
您已完成程式碼研究室,並建構出可在 Flutter 支援的所有六個平台上執行的自適應 Flutter 應用程式。您已調整程式碼,以處理螢幕版面配置、文字互動、圖片載入和驗證運作方式的差異。
您可以在應用程式中調整更多項目。如要瞭解如何以其他方式調整程式碼,使其適用於不同執行環境,請參閱「建構適應性應用程式」。