Flutter 中的自動調整應用程式

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 應用程式螢幕截圖:

在 Android 模擬器上執行的完成版應用程式

在 iOS 模擬器上執行的完成版應用程式

在 macOS 上以寬螢幕模式執行的這個應用程式,應該會類似於下列螢幕截圖。

在 macOS 上執行的完成版應用程式

本程式碼研究室著重於將行動版 Flutter 應用程式轉換為適用於所有六個 Flutter 平台的自適應應用程式。我們不會對與本主題無關的概念和程式碼多做介紹,但會事先準備好這些程式碼區塊,屆時您只要複製及貼上即可。

您想從本程式碼研究室學到什麼?

我對這個主題不熟悉,希望獲得全面性的介紹。 我對這個主題略有瞭解,但想複習一下。 我想尋找可在專案中使用的程式碼範例。 我想瞭解特定主題。

2. 設定 Flutter 開發環境

您需要兩項軟體才能完成本實驗室活動:Flutter SDK編輯器

您可以使用下列任一裝置執行程式碼研究室:

  • 連線至電腦並設為開發人員模式的實體 AndroidiOS 裝置。
  • iOS 模擬器 (需要安裝 Xcode 工具)。
  • Android Emulator (需在 Android Studio 中設定)。
  • 瀏覽器 (偵錯時必須使用 Chrome)。
  • WindowsLinuxmacOS 桌面應用程式的形式。您必須在要部署的平台上進行開發。因此,如要開發 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 裝置上原生執行的應用程式:

在 Android 模擬器上顯示視窗屬性

在 iOS 模擬器上顯示視窗屬性

這是相同的程式碼,在 macOS 上以原生方式執行,以及在 Chrome 內執行 (同樣是在 macOS 上)。

在 macOS 上顯示視窗屬性

在 Chrome 瀏覽器中顯示視窗屬性

請注意,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 專案

在專案建立流程中顯示 GCP 主控台

建立專案後,請前往 API 程式庫頁面。在搜尋框中輸入「youtube」,然後選取「youtube data api v3」

在 GCP 主控台中選取 YouTube Data API v3

在 YouTube Data API v3 詳細資料頁面中,啟用 API。

5a877ea82b83ae42.png

啟用 API 後,請前往憑證頁面,然後建立 API 金鑰。

在 GCP 主控台中建立憑證

幾秒後,您應該會看到一個對話方塊,內含全新的 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.entitlementsRelease.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 體驗並觀看影片。

應用程式顯示 FlutterDev YouTube 帳戶的播放清單

顯示特定播放清單中的影片

在 YouTube 播放器中播放所選影片

不過,如果您嘗試在桌面上執行這個應用程式,當應用程式展開為一般桌面大小的視窗時,您會發現版面配置有誤。您將在下一個步驟中瞭解如何因應這項異動。

5. 配合桌機調整

電腦問題

如果您在其中一個原生桌面平台 (Windows、macOS 或 Linux) 上執行應用程式,會發現一個有趣的問題。這個做法雖有成效,但看起來...很奇怪。

在 macOS 上執行的應用程式顯示播放清單,比例看起來很奇怪

macOS 上的播放清單影片

如要修正這個問題,請新增分割畫面,左側列出播放清單,右側列出影片。不過,您只希望在程式碼未於 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 小工具,重新設計 PlaylistsPlaylistDetails 的互動方式。按照下列方式編輯 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 中的回呼引數。該回呼會通知周圍的程式碼,使用者已選取播放清單。接著,程式碼必須執行工作來顯示該播放清單。這項變更會影響 PlaylistsPlaylistDetails 小工具中 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。現在應該可以正常運作。

在 macOS 上以分割畫面執行應用程式

6. 配合網站調整

這些圖片是怎麼回事?

現在嘗試在網路上執行這個應用程式時,會顯示需要更多作業,才能適應網頁瀏覽器。

在 Chrome 瀏覽器中執行的應用程式,沒有 YouTube 圖片縮圖

如果您查看偵錯控制台,會看到溫和的提示,瞭解下一步該怎麼做。

══╡ 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.networkText 小工具。接著,調整 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 小工具,使用者輕觸文字時,ListTileonTap 功能會遭到封鎖。

在網路上正確執行應用程式

執行 CORS Proxy 後,您應該可以執行網頁版應用程式,畫面如下所示:

在 Chrome 瀏覽器中執行的應用程式,其中填入 YouTube 圖片縮圖

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_inextension_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()));
  }
}

這個檔案會執行許多作業。AdaptiveLoginbuild 方法會執行繁複的工作。這個方法會呼叫 kIsWebdart:ioPlatform.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_ingoogleapis_auth 套件的驗證。

應用程式現在會顯示經過驗證的使用者的 YouTube 播放清單。完成功能後,您需要啟用驗證。如要這麼做,請設定 google_sign_ingoogleapis_auth 套件。如要設定套件,您需要變更 main.dart 檔案和 Runner 應用程式的檔案。

設定 googleapis_auth

設定驗證的第一步,是移除先前設定及使用的 API 金鑰。前往 API 專案的憑證頁面,然後刪除 API 金鑰:

GCP 主控台中的 API 專案憑證頁面

系統會產生對話方塊,您只要按下「刪除」按鈕即可確認:

「刪除憑證」彈出式視窗

接著,建立 OAuth 用戶端 ID:

建立 OAuth 用戶端 ID

在「應用程式類型」部分,選取「電腦應用程式」。

選取「電腦版應用程式」應用程式類型

接受名稱,然後按一下「建立」

為用戶端 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 應用程式類型

填寫表單的其餘部分時,請在「套件名稱」中填入 android/app/src/main/AndroidManifest.xml 中宣告的套件。如果完全按照指示操作,應該會是 com.example.adaptive_app。按照 Google Cloud 控制台說明頁面的指示,擷取 SHA-1 憑證指紋:

為 Android 用戶端 ID 命名

這樣就足以讓應用程式在 Android 上運作。視您使用的 Google API 而定,您可能需要將產生的 JSON 檔案新增至應用程式套件。

在 Android 上執行應用程式

設定 iOS 版 google_sign_in

返回 API 專案的憑證頁面,然後建立另一個 OAuth 用戶端 ID,但這次請選取「iOS」

選取 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

如何在 Xcode 中查看軟體包 ID

暫時忽略 App Store ID 和團隊 ID,因為本機開發不需要這些 ID:

為 iOS 用戶端 ID 命名

下載產生的 .plist 檔案,檔案名稱會依據產生的用戶端 ID 而定。將下載的檔案重新命名為 GoogleService-Info.plist,然後拖曳至執行中的 Xcode 編輯器,與左側導覽器中 Runner/Runner 下的 Info.plist 檔案並列。在 Xcode 的選項對話方塊中,視需要選取「Copy items」(複製項目)、「Create folder references」(建立資料夾參照) 和「Add to the Runner」(新增至 Runner) 目標。

在 Xcode 中將產生的 plist 檔案新增至 iOS 應用程式

退出 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 裝置上執行的應用程式

設定網站適用的 google_sign_in

返回 API 專案的憑證頁面,然後建立另一個 OAuth 用戶端 ID,但這次請選取「Web application」(網路應用程式)

選取「網頁應用程式」類型

填寫表單其餘部分時,請按照下列方式填寫「已授權的 JavaScript 來源」:

為網頁應用程式用戶端 ID 命名

系統會產生用戶端 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".

再次登入後,您應該會看到播放清單:

在 Chrome 瀏覽器中執行的應用程式

8. 後續步驟

恭喜!

您已完成程式碼研究室,並建構出可在 Flutter 支援的所有六個平台上執行的自適應 Flutter 應用程式。您已調整程式碼,以處理螢幕版面配置、文字互動、圖片載入和驗證運作方式的差異。

您可以在應用程式中調整更多項目。如要瞭解如何以其他方式調整程式碼,使其適用於不同執行環境,請參閱「建構適應性應用程式」。