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 三個電腦平台,Flutter 團隊需要以不同方式進行驗證。

以下是 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、2.63。在所有情況下,顯示的文字大致上都很類似,讓我們能以更簡便的方式完成開發人員工作。

第二個重點是,透過兩個選項確認程式碼執行的平台會產生不同的值。第一個選項會檢查從 dart:io 匯入的 Platform 物件,第二個選項 (僅適用於小工具的 build 方法) 會從 BuildContext 引數擷取 Theme 物件。

這兩種方法傳回不同的結果,是因為兩者的意圖不同。從 dart:io 匯入的 Platform 物件可用於做出決策,而不受算繪選項影響。其中一個主要例子,就是決定要使用哪些外掛程式,而該外掛程式不一定與特定實體平台的原生實作項目相符。

BuildContext 擷取 Theme 適用於以主題為中心的實作決策。其中一個主要例子是,根據 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.6
+ flex_color_scheme 7.3.1
+ flex_seed_scheme 1.5.0
+ flutter_web_plugins 0.0.0 from sdk flutter
+ go_router 14.0.1
+ googleapis 13.1.0
+ http 1.2.1
+ http_parser 4.0.2
  leak_tracker 10.0.4 (10.0.5 available)
  leak_tracker_flutter_testing 3.0.3 (3.0.5 available)
+ logging 1.2.0
  material_color_utilities 0.8.0 (0.11.1 available)
  meta 1.12.0 (1.14.0 available)
+ nested 1.0.0
+ plugin_platform_interface 2.1.8
+ provider 6.1.2
  test_api 0.7.0 (0.7.1 available)
+ typed_data 1.3.2
+ url_launcher 6.2.6
+ url_launcher_android 6.3.1
+ url_launcher_ios 6.2.5
+ url_launcher_linux 3.1.1
+ url_launcher_macos 3.1.0
+ url_launcher_platform_interface 2.3.2
+ url_launcher_web 2.3.1
+ url_launcher_windows 3.1.1
+ web 0.5.1
Changed 22 dependencies!
5 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 的路由器進行瀏覽。

為「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 帳戶,如果您還沒有帳戶,請建立帳戶。

前往開發人員控制台建立 API 專案

顯示專案建立流程期間的 GCP 控制台

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

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

在 YouTube Data API 第 3 版詳細資料頁面上,啟用 API。

5a877ea82b83ae42.png

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

在 GCP 控制台中建立憑證

幾秒後,您應該會看到一個內含全新 API 金鑰的對話方塊。您很快就會使用這個金鑰。

API 金鑰建立的彈出式視窗,顯示已建立的 API 金鑰

新增程式碼

在這個步驟的其餘部分,您幾乎不需要撰寫任何程式碼來建構行動應用程式。本程式碼研究室的用意是將行動應用程式調整為電腦版和網頁。如需進一步瞭解如何建構適用於行動裝置的 Flutter 應用程式,請參閱「編寫第一個 Flutter 應用程式 (第 1 部分)」、「第 2 部分」,以及「使用 Flutter 建構精美的 UI」。

新增下列檔案,首先為應用程式的狀態物件。

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,
        useMaterial3: true,
      ).toTheme,
      darkTheme: FlexColorScheme.dark(
        scheme: FlexScheme.red,
        useMaterial3: true,
      ).toTheme,
      themeMode: ThemeMode.dark, // Or ThemeMode.System if you'd prefer
      debugShowCheckedModeBanner: false,
      routerConfig: _router,
    );
  }
}

您很快就能在 Android 和 iOS 上執行這段程式碼。只要再變更一項設定,請使用您在上一個步驟中產生的 YouTube API 金鑰修改第 14 行上的 youTubeApiKey 常數。

lib/main.dart

// TODO: Replace with your YouTube API Key
const youTubeApiKey = 'AIzaNotAnApiKey';

如要在 macOS 上執行這個應用程式,請啟用應用程式提出 HTTP 要求,如下所示。請按照下列方式編輯 DebugProfile.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...
+ split_view 3.1.0
  test_api 0.4.3 (0.4.8 available)
Changed 1 dependency!

自動調整小工具隆重登場

在本程式碼研究室中,我們會使用的模式是介紹自動調整式小工具,以便根據螢幕寬度、平台主題等屬性選擇實作方式。在這個範例中,您將引入一個 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,
        useMaterial3: true,
      ).toTheme,
      darkTheme: FlexColorScheme.dark(
        scheme: FlexScheme.red,
        useMaterial3: true,
      ).toTheme,
      themeMode: ThemeMode.dark, // Or ThemeMode.System if you'd prefer
      debugShowCheckedModeBanner: false,
      routerConfig: _router,
    );
  }
}

接著,建立 AdaptivePlaylist 小工具的檔案:

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);
            },
          ),
        );
      },
    );
  }
}

這個檔案有大量變更。除了上述引文介紹的播放清單已選取回呼以及淘汰 Scaffold 小工具之外,_PlaylistsListView 小工具會從無狀態轉換為有狀態。之所以需要變更,是因為使用自有的 ScrollController 時必須建構及刪除。

引進 ScrollController 很有趣,因為在寬版版面配置中,您有兩個 ListView 小工具並排顯示。一般而言,手機上的單一 ListView 只會使用單一 ListView,因此所有 ListView 都會在各自的生命週期中連接至和卸離。電腦與眾不同,世界上有多個 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...
  http 1.1.2 (from dev dependency to direct dependency)
  js 0.6.7 (0.7.0 available)
  lints 2.1.1 (3.0.0 available)
+ shelf_cors_headers 0.1.5
Changed 2 dependencies!
2 packages have newer versions incompatible with dependency constraints.
Try `dart pub outdated` for more information.

目前已不再需要某些依附元件,請依下列方式剪輯影片:

$ dart pub remove args shelf_router
Resolving dependencies...
  args 2.4.2 (from direct dependency to transitive dependency)
  js 0.6.7 (0.7.0 available)
  lints 2.1.1 (3.0.0 available)
These packages are no longer being depended on:
- http_methods 1.1.1
- shelf_router 1.1.4
Changed 3 dependencies!
2 packages have newer versions incompatible with dependency constraints.
Try `dart pub outdated` for more information.

接著,根據下列指令修改 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 小工具原本的內容。這是刻意所為的,如果調整文字小工具,當使用者輕觸文字時,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
Resolving dependencies...
+ args 2.4.2
+ crypto 3.0.3
+ extension_google_sign_in_as_googleapis_auth 2.0.12
+ google_identity_services_web 0.3.0+2
+ google_sign_in 6.2.1
+ google_sign_in_android 6.1.21
+ google_sign_in_ios 5.7.2
+ google_sign_in_platform_interface 2.4.4
+ google_sign_in_web 0.12.3+2
+ googleapis_auth 1.4.1
+ js 0.6.7 (0.7.0 available)
  matcher 0.12.16 (0.12.16+1 available)
  material_color_utilities 0.5.0 (0.8.0 available)
  meta 1.10.0 (1.11.0 available)
  path 1.8.3 (1.9.0 available)
  test_api 0.6.1 (0.7.0 available)
  web 0.3.0 (0.4.0 available)
Changed 11 dependencies!
7 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:io' show Platform;

import 'package:extension_google_sign_in_as_googleapis_auth/extension_google_sign_in_as_googleapis_auth.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:google_sign_in/google_sign_in.dart';
import 'package:googleapis_auth/auth_io.dart';
import 'package:provider/provider.dart';
import 'package:url_launcher/link.dart';

import 'app_state.dart';

typedef _AdaptiveLoginButtonWidget = Widget Function({
  required VoidCallback? onPressed,
});

class AdaptiveLogin extends StatelessWidget {
  const AdaptiveLogin({
    super.key,
    required this.clientId,
    required this.scopes,
    required this.loginButtonChild,
  });

  final ClientId clientId;
  final List<String> scopes;
  final Widget loginButtonChild;

  @override
  Widget build(BuildContext context) {
    if (kIsWeb || Platform.isAndroid || Platform.isIOS) {
      return _GoogleSignInLogin(
        button: _loginButton,
        scopes: scopes,
      );
    } else {
      return _GoogleApisAuthLogin(
        button: _loginButton,
        scopes: scopes,
        clientId: clientId,
      );
    }
  }

  Widget _loginButton({required VoidCallback? onPressed}) => ElevatedButton(
        onPressed: onPressed,
        child: loginButtonChild,
      );
}

class _GoogleSignInLogin extends StatefulWidget {
  const _GoogleSignInLogin({
    required this.button,
    required this.scopes,
  });

  final _AdaptiveLoginButtonWidget button;
  final List<String> scopes;

  @override
  State<_GoogleSignInLogin> createState() => _GoogleSignInLoginState();
}

class _GoogleSignInLoginState extends State<_GoogleSignInLogin> {
  @override
  initState() {
    super.initState();
    _googleSignIn = GoogleSignIn(
      scopes: widget.scopes,
    );
    _googleSignIn.onCurrentUserChanged.listen((account) {
      if (account != null) {
        _googleSignIn.authenticatedClient().then((authClient) {
          if (authClient != null) {
            context.read<AuthedUserPlaylists>().authClient = authClient;
            context.go('/');
          }
        });
      }
    });
  }

  late final GoogleSignIn _googleSignIn;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Center(
        child: widget.button(onPressed: () {
          _googleSignIn.signIn();
        }),
      ),
    );
  }
}

class _GoogleApisAuthLogin extends StatefulWidget {
  const _GoogleApisAuthLogin({
    required this.button,
    required this.scopes,
    required this.clientId,
  });

  final _AdaptiveLoginButtonWidget button;
  final List<String> scopes;
  final ClientId clientId;

  @override
  State<_GoogleApisAuthLogin> createState() => _GoogleApisAuthLoginState();
}

class _GoogleApisAuthLoginState extends State<_GoogleApisAuthLogin> {
  @override
  initState() {
    super.initState();
    clientViaUserConsent(widget.clientId, widget.scopes, (url) {
      setState(() {
        _authUrl = Uri.parse(url);
      });
    }).then((authClient) {
      context.read<AuthedUserPlaylists>().authClient = authClient;
      context.go('/');
    });
  }

  Uri? _authUrl;

  @override
  Widget build(BuildContext context) {
    final authUrl = _authUrl;
    if (authUrl != null) {
      return Scaffold(
        body: Center(
          child: Link(
            uri: authUrl,
            builder: (context, followLink) =>
                widget.button(onPressed: followLink),
          ),
        ),
      );
    }

    return const Scaffold(
      body: Center(
        child: CircularProgressIndicator(),
      ),
    );
  }
}

這個檔案很複雜,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({required this.playlistSelected, super.key});

  final PlaylistsListSelected playlistSelected;

  @override
  Widget build(BuildContext context) {
    return Consumer<AuthedUserPlaylists>(               // Update this line
      builder: (context, flutterDev, child) {
        final playlists = flutterDev.playlists;
        if (playlists.isEmpty) {
          return const Center(
            child: CircularProgressIndicator(),
          );
        }

        return _PlaylistsListView(
          items: playlists,
          playlistSelected: playlistSelected,
        );
      },
    );
  }
}

最後,更新 main.dart 檔案,正確使用新的 AdaptiveLogin 小工具:

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,
        useMaterial3: true,
      ).toTheme,
      darkTheme: FlexColorScheme.dark(
        scheme: FlexScheme.red,
        useMaterial3: true,
      ).toTheme,
      themeMode: ThemeMode.dark,                       // Or ThemeMode.System
      debugShowCheckedModeBanner: false,
      routerConfig: _router,
    );
  }
}

這個檔案中的變更反映了從顯示 Flutter 的 YouTube 播放清單,到顯示已驗證使用者播放清單而有所變動。雖然程式碼現已完成,但這個檔案還是需要進行一系列的修改,以及各個執行器應用程式下的檔案,才能正確設定用於驗證的 google_sign_ingoogleapis_auth 套件。

應用程式現在會顯示已驗證使用者的 YouTube 播放清單。這些功能完成後,您必須啟用驗證功能。如要執行此操作,請設定 google_sign_ingoogleapis_auth 套件。如要設定套件,您需要變更 main.dart 檔案以及執行器應用程式的檔案。

正在設定「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 Platform 控制台說明頁面的操作說明擷取 SHA-1 憑證指紋:

為 Android 用戶端 ID 命名

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

在 Android 上執行應用程式

為 iOS 設定 google_sign_in

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

,直接在 Google Cloud 控制台實際操作。選取 iOS 應用程式類型

為表單的其他部分,在 Xcode 中開啟 ios/Runner.xcworkspace 來填入軟體包 ID。前往「專案導覽工具」,在導覽器中選取「Runner」,然後依序選取「General」分頁和「Bundle Identifier」即可。如果您已逐步遵循本程式碼研究室,則應為 com.example.adaptiveApp

為表單的其他部分填寫軟體包 ID。在 Xcode 中開啟「ios/Runner.xcworkspace」。前往「專案導覽工具」。前往執行器 >。複製軟體包 ID。如果您已逐步遵循本程式碼研究室,其值應為 com.example.adaptiveApp

如何在 Xcode 中找出軟體包 ID

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

為 iOS 用戶端 ID 命名

下載產生的 .plist 檔案,系統會根據產生的用戶端 ID 命名。將下載的檔案重新命名為 GoogleService-Info.plist,然後拖曳至執行中的 Xcode 編輯器,以及左側導覽器中 Runner/Runner 下的 Info.plist 檔案。在 Xcode 中的選項對話方塊中,視需要選取「Copy items」、「Create folder reference」和「Add to the 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。但這次請選取「網頁應用程式:」

選取網頁應用程式類型

為表單的其他部分填寫已授權的 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 應用程式。您已調整程式碼,以處理畫面版面配置、文字互動方式、圖片載入方式和驗證運作方式的差異。

還有許多其他功能可以在應用程式中調整。如想瞭解在執行程式碼的不同環境下調整程式碼的其他方式,請參閱建構自動調整式應用程式