编写 Flutter 桌面应用

1. 简介

Flutter 是 Google 的界面工具包,用于利用单一代码库为移动设备、网络和桌面设备构建精巧的原生编译应用。在此 Codelab 中,您将学习构建一个具备以下功能的 Flutter 桌面应用:可访问 GitHub API 来检索您的代码库、已分配的问题和拉取请求。为完成此任务,您不仅需要创建插件并使用它们与原生 API 和桌面应用互动,还需要使用代码生成工具为 GitHub 的 API 构建类型安全的客户端库。

您将学习什么

  • 如何创建 Flutter 桌面应用
  • 如何在桌面设备上使用 OAuth2 进行身份验证
  • 如何使用 Dart GitHub 软件包
  • 如何创建 Flutter 插件以与原生 API 集成

构建内容

在此 Codelab 中,您将使用 Flutter SDK 构建一个与 GitHub 集成的桌面应用。您的应用会执行以下操作:

  • 向 GitHub 验证身份
  • 从 GitHub 检索数据
  • 创建一个适用于 Windows、macOS 和/或 Linux 的 Flutter 插件
  • 将 Flutter 界面热重载开发为原生桌面应用

下面的屏幕截图显示了您将构建的桌面应用在 Windows 上运行时的情景。

a456fca6e2997992.png

此 Codelab 将重点介绍如何向 Flutter 桌面应用添加 OAuth2 和 GitHub 访问功能。对于不相关的概念和代码块,仅仅一带而过,提供这些内容只是为了方便您复制和粘贴。

您想通过此 Codelab 学习哪些内容?

我不熟悉这个主题,想好好了解一下。 我对这个主题有所了解,但想复习并深入了解一下。 我在寻找示例代码以用到我的项目中。 我在寻找有关特定内容的说明。

2. 设置您的 Flutter 环境

您必须在打算部署到的平台上进行开发。因此,如果您要开发 Windows 桌面应用,则必须在 Windows 上进行开发,才能访问相应的构建链。

若想针对所有操作系统进行开发,您便需要使用 2 款软件才能完成这个实验:Flutter SDK一款编辑器

此外,flutter.dev/desktop 上详述了各种操作系统的具体要求。

3. 开始

开始使用 Flutter 开发桌面应用

您需要通过一次性的配置更改来配置桌面支持。

$ flutter config --enable-windows-desktop # for the Windows runner
$ flutter config --enable-macos-desktop   # for the macOS runner
$ flutter config --enable-linux-desktop   # for the Linux runner

若要确认是否已启用桌面版 Flutter,请运行以下命令。

$ flutter devices
1 connected device:

Windows (desktop) • windows    • windows-x64    • Microsoft Windows [Version 10.0.19041.508]
macOS (desktop)   • macos      • darwin-x64     • macOS 11.2.3 20D91 darwin-x64
Linux (desktop)   • linux      • linux-x64      • Linux

如果您在运行上述命令后的输出结果中未看到相应的桌面设备代码行,请考虑以下几点:

  • 您是在目标平台(您的开发工作是围绕这个平台展开的)上进行开发吗?
  • 正在运行的 flutter config 是否将 macOS 列为已启用 enable-[os]-desktop: true
  • 正在运行的 flutter channel 是否将 devmaster 列为当前渠道?这是必要步骤,因为此代码不会在 stablebeta 渠道上运行。

若要开始编写桌面版 Flutter 应用,一种简单的方法是使用 Flutter 命令行工具创建一个 Flutter 项目。再者,您的 IDE 可能会提供通过其界面创建 Flutter 项目的工作流。

$ flutter create github_client
Creating project github_client...
Running "flutter pub get" in github_client...                    1,103ms
Wrote 128 files.

All done!
In order to run your application, type:

  $ cd github_client
  $ flutter run

Your application code is in github_client\lib\main.dart.

为了简化此 Codelab,请删除 Android、iOS 和网络支持文件。编写 Flutter 桌面应用时不需要使用这些文件。删除这些文件有助于避免在此 Codelab 期间意外地运行错误的变体。

对于 macOS 和 Linux:

$ rm -r android ios web

对于 Windows:

PS C:\src\github_client> rmdir android
PS C:\src\github_client> rmdir ios
PS C:\src\github_client> rmdir web

为确保一切正常,请将样板 Flutter 应用作为桌面应用运行,如下所示。或者,在您的 IDE 中打开此项目,并使用它所集成的工具运行该应用。得益于上一步的操作,作为桌面应用运行应该是唯一可用的选项。

$ flutter run
Launching lib\main.dart on Windows in debug mode...
Building Windows application...
Syncing files to device Windows...                                  56ms

Flutter run key commands.
r Hot reload. 🔥🔥🔥
R Hot restart.
h List all available interactive commands.
d Detach (terminate "flutter run" but leave application running).
c Clear the screen
q Quit (terminate the application on the device).

💪 Running with sound null safety 💪

An Observatory debugger and profiler on Windows is available at: http://127.0.0.1:61920/OHTnly7_TMk=/
The Flutter DevTools debugger and profiler on Windows is available at: http://127.0.0.1:9101?uri=http://127.0.0.1:61920/OHTnly7_TMk=/

现在,您应该会在屏幕上看到如下所示的应用窗口。直接点击悬浮操作按钮以确保增量器能按预期工作。您还可通过更改主题颜色或更改 lib/main.dart_incrementCounter 方法的行为来尝试热重载。

下图显示了该应用在 Windows 上运行时的情景。

bee40fe7a8e69791.png

在下一部分中,您将使用 OAuth2 在 GitHub 上进行身份验证。

4. 添加身份验证机制

在桌面设备上进行身份验证

如果您是在 Android、iOS 或网络上使用 Flutter,那么关于身份验证软件包,您有诸多可用选项。不过,面向桌面设备进行开发会使您的可用选项受到限制。目前,您必须从头开始构建身份验证集成,但随着软件包作者陆续实施桌面版 Flutter 支持,这种情况将会有所改变。

注册 GitHub OAuth 应用

若要构建一款使用 GitHub API 的桌面应用,您首先需进行身份验证。可用的选项有多个,但最佳用户体验是引导用户在其浏览器中完成 GitHub 的 OAuth2 登录流程。这样会便于处理双重身份验证并轻松集成密码管理器。

若要注册一款采用 GitHub 的 OAuth2 流程的应用,请前往 github.com,只需按照 GitHub 的构建 OAuth 应用第一步中的说明进行操作即可。以下步骤在您准备发布应用时非常重要,但在完成 Codelab 期间则不然。

创建 OAuth 应用的过程中,第 8 步会要求您提供授权回调网址。对于桌面应用,请输入 http://localhost/ 作为回调网址。在设置 GitHub 的 OAuth2 流程时定义一个 localhost 回调网址可允许使用任何端口,从而让您能够在临时本地高端口上搭建一个网络服务器。这样可以避免要求用户在 OAuth 过程中将 OAuth 代码令牌复制到应用中。

下面的示例屏幕截图说明了如何填写创建 GitHub OAuth 应用的表单:

be454222e07f01d9.png

在 GitHub 管理界面中注册 OAuth 应用后,您会收到客户端 ID 和客户端密钥。如果日后需要使用这些值,您可从 GitHub 的开发者设置中检索它们。您需要使用应用内的这些凭据,才能构建有效的 OAuth2 授权网址。您将使用 oauth2 Dart 软件包来处理 OAuth2 流程,并且使用 url_launcher Flutter 插件来允许启动用户的网络浏览器。

将 oauth2 和 url_launcher 添加到 pubspec.yaml

您可以通过运行 flutter pub add 为应用添加软件包依赖项,如下所示:

$ flutter pub add http
Resolving dependencies...
+ http 0.13.4
+ http_parser 4.0.0
  path 1.8.0 (1.8.1 available)
  source_span 1.8.1 (1.8.2 available)
  test_api 0.4.8 (0.4.9 available)
Changed 2 dependencies!

第一个命令添加了 http 软件包,以跨平台、一致的方式进行 HTTP 调用。接下来,添加 oauth2 软件包,如下所示。

$ flutter pub add oauth2
Resolving dependencies...
+ crypto 3.0.1
+ oauth2 2.0.0
  path 1.8.0 (1.8.1 available)
  source_span 1.8.1 (1.8.2 available)
  test_api 0.4.8 (0.4.9 available)
Changed 2 dependencies!

最后,添加 url_launcher 软件包。

$ flutter pub add url_launcher
Resolving dependencies...
+ flutter_web_plugins 0.0.0 from sdk flutter
+ js 0.6.3 (0.6.4 available)
  path 1.8.0 (1.8.1 available)
+ plugin_platform_interface 2.1.2
  source_span 1.8.1 (1.8.2 available)
  test_api 0.4.8 (0.4.9 available)
+ url_launcher 6.0.18
+ url_launcher_android 6.0.14
+ url_launcher_ios 6.0.14
+ url_launcher_linux 2.0.3
+ url_launcher_macos 2.0.2
+ url_launcher_platform_interface 2.0.5
+ url_launcher_web 2.0.6
+ url_launcher_windows 2.0.2
Downloading url_launcher 6.0.18...
Downloading url_launcher_ios 6.0.14...
Downloading url_launcher_android 6.0.14...
Downloading url_launcher_platform_interface 2.0.5...
Downloading plugin_platform_interface 2.1.2...
Downloading url_launcher_linux 2.0.3...
Downloading url_launcher_web 2.0.6...
Changed 11 dependencies!

添加客户端凭据

将客户端凭据添加到新文件 lib/github_oauth_credentials.dart,如下所示:

lib/github_oauth_credentials.dart

// TODO(CodelabUser): Create an OAuth App
const githubClientId = 'YOUR_GITHUB_CLIENT_ID_HERE';
const githubClientSecret = 'YOUR_GITHUB_CLIENT_SECRET_HERE';

// OAuth scopes for repository and user information
const githubScopes = ['repo', 'read:org'];

将上一步中的客户端凭据复制并粘贴到此文件中。

构建桌面 OAuth2 流程

构建一个微件,以包含桌面 OAuth2 流程。这是一个相当复杂的逻辑块,因为您必须运行一个临时网络服务器,在用户的网络浏览器中将用户重定向到 GitHub 中的端点,等待用户在其浏览器中完成授权流程,并处理来自 GitHub 的包含代码的重定向调用(稍后需要通过单独调用 GitHub 的 API 服务器将之转换为 OAuth2 令牌)。

lib/src/github_login.dart

import 'dart:io';
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
import 'package:oauth2/oauth2.dart' as oauth2;
import 'package:url_launcher/url_launcher.dart';

final _authorizationEndpoint =
    Uri.parse('https://github.com/login/oauth/authorize');
final _tokenEndpoint = Uri.parse('https://github.com/login/oauth/access_token');

class GithubLoginWidget extends StatefulWidget {
  const GithubLoginWidget({
    required this.builder,
    required this.githubClientId,
    required this.githubClientSecret,
    required this.githubScopes,
    Key? key,
  }) : super(key: key);
  final AuthenticatedBuilder builder;
  final String githubClientId;
  final String githubClientSecret;
  final List<String> githubScopes;

  @override
  _GithubLoginState createState() => _GithubLoginState();
}

typedef AuthenticatedBuilder = Widget Function(
    BuildContext context, oauth2.Client client);

class _GithubLoginState extends State<GithubLoginWidget> {
  HttpServer? _redirectServer;
  oauth2.Client? _client;

  @override
  Widget build(BuildContext context) {
    final client = _client;
    if (client != null) {
      return widget.builder(context, client);
    }

    return Scaffold(
      appBar: AppBar(
        title: const Text('Github Login'),
      ),
      body: Center(
        child: ElevatedButton(
          onPressed: () async {
            await _redirectServer?.close();
            // Bind to an ephemeral port on localhost
            _redirectServer = await HttpServer.bind('localhost', 0);
            var authenticatedHttpClient = await _getOAuth2Client(
                Uri.parse('http://localhost:${_redirectServer!.port}/auth'));
            setState(() {
              _client = authenticatedHttpClient;
            });
          },
          child: const Text('Login to Github'),
        ),
      ),
    );
  }

  Future<oauth2.Client> _getOAuth2Client(Uri redirectUrl) async {
    if (widget.githubClientId.isEmpty || widget.githubClientSecret.isEmpty) {
      throw const GithubLoginException(
          'githubClientId and githubClientSecret must be not empty. '
          'See `lib/github_oauth_credentials.dart` for more detail.');
    }
    var grant = oauth2.AuthorizationCodeGrant(
      widget.githubClientId,
      _authorizationEndpoint,
      _tokenEndpoint,
      secret: widget.githubClientSecret,
      httpClient: _JsonAcceptingHttpClient(),
    );
    var authorizationUrl =
        grant.getAuthorizationUrl(redirectUrl, scopes: widget.githubScopes);

    await _redirect(authorizationUrl);
    var responseQueryParameters = await _listen();
    var client =
        await grant.handleAuthorizationResponse(responseQueryParameters);
    return client;
  }

  Future<void> _redirect(Uri authorizationUrl) async {
    var url = authorizationUrl.toString();
    if (await canLaunch(url)) {
      await launch(url);
    } else {
      throw GithubLoginException('Could not launch $url');
    }
  }

  Future<Map<String, String>> _listen() async {
    var request = await _redirectServer!.first;
    var params = request.uri.queryParameters;
    request.response.statusCode = 200;
    request.response.headers.set('content-type', 'text/plain');
    request.response.writeln('Authenticated! You can close this tab.');
    await request.response.close();
    await _redirectServer!.close();
    _redirectServer = null;
    return params;
  }
}

class _JsonAcceptingHttpClient extends http.BaseClient {
  final _httpClient = http.Client();
  @override
  Future<http.StreamedResponse> send(http.BaseRequest request) {
    request.headers['Accept'] = 'application/json';
    return _httpClient.send(request);
  }
}

class GithubLoginException implements Exception {
  const GithubLoginException(this.message);
  final String message;
  @override
  String toString() => message;
}

花时间完成此代码是值得的,因为它可展现在桌面设备上使用 Flutter 和 Dart 时会获享的一些功能。的确,此代码非常复杂,但很多功能都封装在一个相对简便易用的微件中。

此微件可公开一个临时网络服务器,并可发出安全的 HTTP 请求。在 macOS 中,需要通过权限文件来请求这两项功能。

更改客户端和服务器权限(仅限 MacOS)

发出网络请求以及将网络服务器作为 macOS 桌面应用运行都需要更改应用的权限。如需了解详情,请参阅权限和应用沙盒

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 this entry -->
        <key>com.apple.security.network.client</key>
        <true/>
</dict>
</plist>

您还需修改正式版 build 的发布权限。

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 entries -->
        <key>com.apple.security.network.server</key>
        <true/>
        <key>com.apple.security.network.client</key>
        <true/>
</dict>
</plist>

融为一体

您已配置一款新的 OAuth 应用,已为相应项目配置所需的软件包和插件,而且已编写用于封装 OAuth 身份验证流程的微件,并已通过权限文件启用该应用,使之在 macOS 上同时用作网络客户端和服务器。准备好所有这些必需的构建块后,您可将它们全都放在 lib/main.dart 文件中。

lib/main.dart

import 'package:flutter/material.dart';
import 'github_oauth_credentials.dart';
import 'src/github_login.dart';

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'GitHub Client',
      theme: ThemeData(
        primarySwatch: Colors.blue,
        visualDensity: VisualDensity.adaptivePlatformDensity,
      ),
      home: const MyHomePage(title: 'GitHub Client'),
    );
  }
}

class MyHomePage extends StatelessWidget {
  const MyHomePage({Key? key, required this.title}) : super(key: key);
  final String title;

  @override
  Widget build(BuildContext context) {
    return GithubLoginWidget(
      builder: (context, httpClient) {
        return Scaffold(
          appBar: AppBar(
            title: Text(title),
          ),
          body: const Center(
            child: Text(
              'You are logged in to GitHub!',
            ),
          ),
        );
      },
      githubClientId: githubClientId,
      githubClientSecret: githubClientSecret,
      githubScopes: githubScopes,
    );
  }
}

运行这款 Flutter 应用时,您首先会看到一个用于启动 GitHub OAuth 登录流程的按钮。点击该按钮后,在网络浏览器中完成登录流程,然后您会看到该应用处于已登录状态。

现在您已掌握了 OAuth 身份验证知识,接下来便可开始使用 GitHub 软件包。

5. 访问 GitHub

连接到 GitHub

通过 OAuth 身份验证流程,您已获取在 GitHub 上访问您的数据的必要令牌。为完成此任务,您将使用软件包 github(可从 pub.dev 处获得)。

添加更多依赖项

运行以下命令:

$ flutter pub add github

将 OAuth 凭据与 GitHub 软件包搭配使用

您在上一步中创建的 GithubLoginWidget 提供了一个可与 GitHub API 交互的 HttpClient。在此步骤中,您将使用 HttpClient 中包含的凭据,通过 GitHub 软件包访问 GitHub API,如下所示:

final accessToken = httpClient.credentials.accessToken;
final gitHub = GitHub(auth: Authentication.withToken(accessToken));

再次融为一体

现在,您可以将 GitHub 客户端集成到 lib/main.dart 文件中。

lib/main.dart

import 'package:flutter/material.dart';
import 'package:github/github.dart';

import 'github_oauth_credentials.dart';
import 'src/github_login.dart';

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'GitHub Client',
      theme: ThemeData(
        primarySwatch: Colors.blue,
        visualDensity: VisualDensity.adaptivePlatformDensity,
      ),
      home: const MyHomePage(title: 'GitHub Client'),
    );
  }
}

class MyHomePage extends StatelessWidget {
  const MyHomePage({Key? key, required this.title}) : super(key: key);
  final String title;

  @override
  Widget build(BuildContext context) {
    return GithubLoginWidget(
      builder: (context, httpClient) {
        return FutureBuilder<CurrentUser>(
          future: viewerDetail(httpClient.credentials.accessToken),
          builder: (context, snapshot) {
            return Scaffold(
              appBar: AppBar(
                title: Text(title),
              ),
              body: Center(
                child: Text(
                  snapshot.hasData
                      ? 'Hello ${snapshot.data!.login}!'
                      : 'Retrieving viewer login details...',
                ),
              ),
            );
          },
        );
      },
      githubClientId: githubClientId,
      githubClientSecret: githubClientSecret,
      githubScopes: githubScopes,
    );
  }
}

Future<CurrentUser> viewerDetail(String accessToken) async {
  final gitHub = GitHub(auth: Authentication.withToken(accessToken));
  return gitHub.users.getCurrentUser();
}

当您运行这款 Flutter 应用后,界面上会显示一个用于启动 GitHub OAuth 登录流程的按钮。点击该按钮后,在网络浏览器中完成登录流程。您现在已登录该应用。

在下一步中,您将清除当前代码库中的问题。在网络浏览器中对应用进行身份验证后,您需要将应用带回到前台。

6. 创建一个适用于 Windows、macOS 和 Linux 的 Flutter 插件

清除问题

目前,此代码存在一个令人烦恼的问题:完成身份验证流程后(当 GitHub 验证完您的应用的身份时),屏幕上只会显示一个网络浏览器页面。理想情况下,您应该会自动返回到应用。若要解决此问题,您需为桌面平台创建一个 Flutter 插件。

创建一个适用于 Windows、macOS 和 Linux 的 Flutter 插件

若要在 OAuth 流程完成后让应用自动返回到应用窗口堆栈的前面,您需使用一些原生代码。对于 macOS,您需要使用的 API 是 NSApplicationactivate(ignoringOtherApps:) 实例方法;对于 Linux,我们将使用 gtk_window_present;对于 Windows,则需借助 Stack Overflow。为了能够调用这些 API,您需要创建一个 Flutter 插件。

您可以使用 flutter 创建新的插件项目。

$ cd .. # step outside of the github_client project
$ flutter create -t plugin --platforms=linux,macos,windows window_to_front

确认生成的 pubspec.yaml 与下例相似。

../window_to_front/pubspec.yaml

name: window_to_front
description: A new flutter plugin project.
version: 0.0.1

environment:
  sdk: ">=2.12.0 <3.0.0"
  flutter: ">=1.20.0"

dependencies:
  flutter:
    sdk: flutter

dev_dependencies:
  flutter_test:
    sdk: flutter
  flutter_lints: ^1.0.0

flutter:
  plugin:
    platforms:
      linux:
        pluginClass: WindowToFrontPlugin
      macos:
        pluginClass: WindowToFrontPlugin
      windows:
        pluginClass: WindowToFrontPlugin

此插件已针对 macOS、Linux 和 Windows 进行了配置。现在,您可以添加用于向前弹出窗口的 Swift 代码。按如下方式修改 macos/Classes/WindowToFrontPlugin.swift

../window_to_front/macos/Classes/WindowToFrontPlugin.swift

import Cocoa
import FlutterMacOS

public class WindowToFrontPlugin: NSObject, FlutterPlugin {
  public static func register(with registrar: FlutterPluginRegistrar) {
    let channel = FlutterMethodChannel(name: "window_to_front", binaryMessenger: registrar.messenger)
    let instance = WindowToFrontPlugin()
    registrar.addMethodCallDelegate(instance, channel: channel)
  }

  public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
    switch call.method {
    // Add from here
    case "activate":
      NSApplication.shared.activate(ignoringOtherApps: true)
      result(nil)
    // to here.
    // Delete the getPlatformVersion case,
    // as we won't be using it.
    default:
      result(FlutterMethodNotImplemented)
    }
  }
}

若要在 Linux 插件中执行同样的操作,请将 linux/window_to_front_plugin.cc 的内容替换为以下内容:

../window_to_front/linux/window_to_front_plugin.cc

#include "include/window_to_front/window_to_front_plugin.h"

#include <flutter_linux/flutter_linux.h>
#include <gtk/gtk.h>
#include <sys/utsname.h>

#define WINDOW_TO_FRONT_PLUGIN(obj) \
  (G_TYPE_CHECK_INSTANCE_CAST((obj), window_to_front_plugin_get_type(), \
                              WindowToFrontPlugin))

struct _WindowToFrontPlugin {
  GObject parent_instance;

  FlPluginRegistrar* registrar;
};

G_DEFINE_TYPE(WindowToFrontPlugin, window_to_front_plugin, g_object_get_type())

// Called when a method call is received from Flutter.
static void window_to_front_plugin_handle_method_call(
    WindowToFrontPlugin* self,
    FlMethodCall* method_call) {
  g_autoptr(FlMethodResponse) response = nullptr;

  const gchar* method = fl_method_call_get_name(method_call);

  if (strcmp(method, "activate") == 0) {
    FlView* view = fl_plugin_registrar_get_view(self->registrar);
    if (view != nullptr) {
      GtkWindow* window = GTK_WINDOW(gtk_widget_get_toplevel(GTK_WIDGET(view)));
      gtk_window_present(window);
    }

    response = FL_METHOD_RESPONSE(fl_method_success_response_new(nullptr));
  } else {
    response = FL_METHOD_RESPONSE(fl_method_not_implemented_response_new());
  }

  fl_method_call_respond(method_call, response, nullptr);
}

static void window_to_front_plugin_dispose(GObject* object) {
  G_OBJECT_CLASS(window_to_front_plugin_parent_class)->dispose(object);
}

static void window_to_front_plugin_class_init(WindowToFrontPluginClass* klass) {
  G_OBJECT_CLASS(klass)->dispose = window_to_front_plugin_dispose;
}

static void window_to_front_plugin_init(WindowToFrontPlugin* self) {}

static void method_call_cb(FlMethodChannel* channel, FlMethodCall* method_call,
                           gpointer user_data) {
  WindowToFrontPlugin* plugin = WINDOW_TO_FRONT_PLUGIN(user_data);
  window_to_front_plugin_handle_method_call(plugin, method_call);
}

void window_to_front_plugin_register_with_registrar(FlPluginRegistrar* registrar) {
  WindowToFrontPlugin* plugin = WINDOW_TO_FRONT_PLUGIN(
      g_object_new(window_to_front_plugin_get_type(), nullptr));

  plugin->registrar = FL_PLUGIN_REGISTRAR(g_object_ref(registrar));

  g_autoptr(FlStandardMethodCodec) codec = fl_standard_method_codec_new();
  g_autoptr(FlMethodChannel) channel =
      fl_method_channel_new(fl_plugin_registrar_get_messenger(registrar),
                            "window_to_front",
                            FL_METHOD_CODEC(codec));
  fl_method_channel_set_method_call_handler(channel, method_call_cb,
                                            g_object_ref(plugin),
                                            g_object_unref);

  g_object_unref(plugin);
}

若要在 Windows 插件中执行同样的操作,请将 windows/window_to_front_plugin.cc 的内容替换为以下内容:

..\window_to_front\windows\window_to_front_plugin.cpp

#include "include/window_to_front/window_to_front_plugin.h"

// This must be included before many other Windows headers.
#include <windows.h>

#include <flutter/method_channel.h>
#include <flutter/plugin_registrar_windows.h>
#include <flutter/standard_method_codec.h>

#include <map>
#include <memory>

namespace {

class WindowToFrontPlugin : public flutter::Plugin {
 public:
  static void RegisterWithRegistrar(flutter::PluginRegistrarWindows *registrar);

  WindowToFrontPlugin(flutter::PluginRegistrarWindows *registrar);

  virtual ~WindowToFrontPlugin();

 private:
  // Called when a method is called on this plugin's channel from Dart.
  void HandleMethodCall(
      const flutter::MethodCall<flutter::EncodableValue> &method_call,
      std::unique_ptr<flutter::MethodResult<>> result);

  // The registrar for this plugin, for accessing the window.
  flutter::PluginRegistrarWindows *registrar_;
};

// static
void WindowToFrontPlugin::RegisterWithRegistrar(
    flutter::PluginRegistrarWindows *registrar) {
  auto channel =
      std::make_unique<flutter::MethodChannel<>>(
          registrar->messenger(), "window_to_front",
          &flutter::StandardMethodCodec::GetInstance());

  auto plugin = std::make_unique<WindowToFrontPlugin>(registrar);

  channel->SetMethodCallHandler(
      [plugin_pointer = plugin.get()](const auto &call, auto result) {
        plugin_pointer->HandleMethodCall(call, std::move(result));
      });

  registrar->AddPlugin(std::move(plugin));
}

WindowToFrontPlugin::WindowToFrontPlugin(flutter::PluginRegistrarWindows *registrar)
  : registrar_(registrar) {}

WindowToFrontPlugin::~WindowToFrontPlugin() {}

void WindowToFrontPlugin::HandleMethodCall(
    const flutter::MethodCall<> &method_call,
    std::unique_ptr<flutter::MethodResult<>> result) {
  if (method_call.method_name().compare("activate") == 0) {
    // See https://stackoverflow.com/a/34414846/2142626 for an explanation of how
    // this raises a window to the foreground.
    HWND m_hWnd = registrar_->GetView()->GetNativeWindow();
    HWND hCurWnd = ::GetForegroundWindow();
    DWORD dwMyID = ::GetCurrentThreadId();
    DWORD dwCurID = ::GetWindowThreadProcessId(hCurWnd, NULL);
    ::AttachThreadInput(dwCurID, dwMyID, TRUE);
    ::SetWindowPos(m_hWnd, HWND_TOPMOST, 0, 0, 0, 0, SWP_NOSIZE | SWP_NOMOVE);
    ::SetWindowPos(m_hWnd, HWND_NOTOPMOST, 0, 0, 0, 0, SWP_SHOWWINDOW | SWP_NOSIZE | SWP_NOMOVE);
    ::SetForegroundWindow(m_hWnd);
    ::SetFocus(m_hWnd);
    ::SetActiveWindow(m_hWnd);
    ::AttachThreadInput(dwCurID, dwMyID, FALSE);
    result->Success();
  } else {
    result->NotImplemented();
  }
}

}  // namespace

void WindowToFrontPluginRegisterWithRegistrar(
    FlutterDesktopPluginRegistrarRef registrar) {
  WindowToFrontPlugin::RegisterWithRegistrar(
      flutter::PluginRegistrarManager::GetInstance()
          ->GetRegistrar<flutter::PluginRegistrarWindows>(registrar));
}

添加代码,以将我们在上方创建的原生功能提供给 Flutter 环境。

../window_to_front/lib/window_to_front.dart

import 'dart:async';

import 'package:flutter/services.dart';

class WindowToFront {
  static const MethodChannel _channel = MethodChannel('window_to_front');
  // Add from here
  static Future<void> activate(){
    return _channel.invokeMethod('activate');
  }
  // to here.

  // Delete the getPlatformVersion getter method.
}

此 Flutter 插件已创建完毕,您可返回以编辑 github_graphql_client 项目了。

$ cd ../github_client

添加依赖项

您刚刚创建的 Flutter 插件非常不错,但对独自工作的人而言用处不大。您需要将此插件作为依赖项添加到 Flutter 应用中才能使用它。

$ flutter pub add --path ../window_to_front window_to_front
Resolving dependencies...
  js 0.6.3 (0.6.4 available)
  path 1.8.0 (1.8.1 available)
  source_span 1.8.1 (1.8.2 available)
  test_api 0.4.8 (0.4.9 available)
+ window_to_front 0.0.1 from path ..\window_to_front
Changed 1 dependency!

请注意为 window_to_front 依赖项指定的路径:因为这是一个本地软件包而不是发布到 pub.dev 的路径,因此您应指定路径而不是版本号。

再次融为一体

现在,该将 window_to_front 集成到您的 lib/main.dart 文件中了。我们只需在合适的时间将导入操作和调用操作添加到原生代码即可。

lib/main.dart

import 'package:flutter/material.dart';
import 'package:github/github.dart';
import 'package:window_to_front/window_to_front.dart';    // Add this

import 'github_oauth_credentials.dart';
import 'src/github_login.dart';

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'GitHub Client',
      theme: ThemeData(
        primarySwatch: Colors.blue,
        visualDensity: VisualDensity.adaptivePlatformDensity,
      ),
      home: const MyHomePage(title: 'GitHub Client'),
    );
  }
}

class MyHomePage extends StatelessWidget {
  const MyHomePage({Key? key, required this.title}) : super(key: key);
  final String title;

  @override
  Widget build(BuildContext context) {
    return GithubLoginWidget(
      builder: (context, httpClient) {
        WindowToFront.activate();                        // and this.
        return FutureBuilder<CurrentUser>(
          future: viewerDetail(httpClient.credentials.accessToken),
          builder: (context, snapshot) {
            return Scaffold(
              appBar: AppBar(
                title: Text(title),
              ),
              body: Center(
                child: Text(
                  snapshot.hasData
                      ? 'Hello ${snapshot.data!.login}!'
                      : 'Retrieving viewer login details...',
                ),
              ),
            );
          },
        );
      },
      githubClientId: githubClientId,
      githubClientSecret: githubClientSecret,
      githubScopes: githubScopes,
    );
  }
}

Future<CurrentUser> viewerDetail(String accessToken) async {
  final gitHub = GitHub(auth: Authentication.withToken(accessToken));
  return gitHub.users.getCurrentUser();
}

运行这款 Flutter 应用后,您会看到一款外观完全相同的应用,但点击相应按钮会显示行为差异。如果您将该应用放在进行身份验证时所用的网络浏览器上,那么当您点击“登录”按钮时,您的应用将会被推到网络浏览器后面,不过,一旦您在浏览器中完成身份验证流程,您的应用即会再次回到前面。这会使该应用得到显著改进。

在下一部分中,您将根据已有的基础构建桌面 GitHub 客户端,从而深入了解您在 GitHub 上拥有的内容。您将检查帐号中的代码库列表、Flutter 项目中的拉取请求以及已分配的问题。

7. 查看代码库、拉取请求和已分配的问题

您已为构建该应用做了相当多的工作,但该应用所能做的只是显示您的登录状态。您可能需要从桌面 GitHub 客户端了解更多信息。接下来,您将添加用于列出代码库、拉取请求和已分配的问题的功能。

添加最后一个依赖项

在呈现上述查询返回的数据时,您将使用一个额外的软件包(即 fluttericon)以便轻松显示 GitHub 的 Octicons

$ flutter pub add fluttericon
Resolving dependencies...
+ fluttericon 2.0.0
  js 0.6.3 (0.6.4 available)
  material_color_utilities 0.1.3 (0.1.4 available)
  path 1.8.0 (1.8.1 available)
  source_span 1.8.1 (1.8.2 available)
  test_api 0.4.8 (0.4.9 available)
  url_launcher_macos 2.0.2 (2.0.3 available)
Changed 1 dependency!

用于将结果呈现到屏幕上的微件

您将使用之前添加的 GitHub 软件包来填充 NavigationRail 微件,其中会包含您的代码库、已分配的问题和 Flutter 项目中的拉取请求的视图。Material.io 设计系统文档说明了导航侧边栏可如何实现在应用内主要目的地之间进行符合人体工程学的移动。

创建一个新文件,并在其中填充以下内容。

lib/src/github_summary.dart

import 'package:flutter/material.dart';
import 'package:fluttericon/octicons_icons.dart';
import 'package:github/github.dart';
import 'package:url_launcher/url_launcher.dart';

class GitHubSummary extends StatefulWidget {
  const GitHubSummary({required this.gitHub, Key? key}) : super(key: key);
  final GitHub gitHub;

  @override
  _GitHubSummaryState createState() => _GitHubSummaryState();
}

class _GitHubSummaryState extends State<GitHubSummary> {
  int _selectedIndex = 0;

  @override
  Widget build(BuildContext context) {
    return Row(
      children: [
        NavigationRail(
          selectedIndex: _selectedIndex,
          onDestinationSelected: (index) {
            setState(() {
              _selectedIndex = index;
            });
          },
          labelType: NavigationRailLabelType.selected,
          destinations: const [
            NavigationRailDestination(
              icon: Icon(Octicons.repo),
              label: Text('Repositories'),
            ),
            NavigationRailDestination(
              icon: Icon(Octicons.issue_opened),
              label: Text('Assigned Issues'),
            ),
            NavigationRailDestination(
              icon: Icon(Octicons.git_pull_request),
              label: Text('Pull Requests'),
            ),
          ],
        ),
        const VerticalDivider(thickness: 1, width: 1),
        // This is the main content.
        Expanded(
          child: IndexedStack(
            index: _selectedIndex,
            children: [
              RepositoriesList(gitHub: widget.gitHub),
              AssignedIssuesList(gitHub: widget.gitHub),
              PullRequestsList(gitHub: widget.gitHub),
            ],
          ),
        ),
      ],
    );
  }
}

class RepositoriesList extends StatefulWidget {
  const RepositoriesList({required this.gitHub, Key? key}) : super(key: key);
  final GitHub gitHub;

  @override
  _RepositoriesListState createState() => _RepositoriesListState();
}

class _RepositoriesListState extends State<RepositoriesList> {
  @override
  initState() {
    super.initState();
    _repositories = widget.gitHub.repositories.listRepositories().toList();
  }

  late Future<List<Repository>> _repositories;

  @override
  Widget build(BuildContext context) {
    return FutureBuilder<List<Repository>>(
      future: _repositories,
      builder: (context, snapshot) {
        if (snapshot.hasError) {
          return Center(child: Text('${snapshot.error}'));
        }
        if (!snapshot.hasData) {
          return const Center(child: CircularProgressIndicator());
        }
        var repositories = snapshot.data;
        return ListView.builder(
          itemBuilder: (context, index) {
            var repository = repositories![index];
            return ListTile(
              title:
                  Text('${repository.owner?.login ?? ''}/${repository.name}'),
              subtitle: Text(repository.description),
              onTap: () => _launchUrl(context, repository.htmlUrl),
            );
          },
          itemCount: repositories!.length,
        );
      },
    );
  }
}

class AssignedIssuesList extends StatefulWidget {
  const AssignedIssuesList({required this.gitHub, Key? key}) : super(key: key);
  final GitHub gitHub;

  @override
  _AssignedIssuesListState createState() => _AssignedIssuesListState();
}

class _AssignedIssuesListState extends State<AssignedIssuesList> {
  @override
  initState() {
    super.initState();
    _assignedIssues = widget.gitHub.issues.listByUser().toList();
  }

  late Future<List<Issue>> _assignedIssues;

  @override
  Widget build(BuildContext context) {
    return FutureBuilder<List<Issue>>(
      future: _assignedIssues,
      builder: (context, snapshot) {
        if (snapshot.hasError) {
          return Center(child: Text('${snapshot.error}'));
        }
        if (!snapshot.hasData) {
          return const Center(child: CircularProgressIndicator());
        }
        var assignedIssues = snapshot.data;
        return ListView.builder(
          itemBuilder: (context, index) {
            var assignedIssue = assignedIssues![index];
            return ListTile(
              title: Text(assignedIssue.title),
              subtitle: Text('${_nameWithOwner(assignedIssue)} '
                  'Issue #${assignedIssue.number} '
                  'opened by ${assignedIssue.user?.login ?? ''}'),
              onTap: () => _launchUrl(context, assignedIssue.htmlUrl),
            );
          },
          itemCount: assignedIssues!.length,
        );
      },
    );
  }

  String _nameWithOwner(Issue assignedIssue) {
    final endIndex = assignedIssue.url.lastIndexOf('/issues/');
    return assignedIssue.url.substring(29, endIndex);
  }
}

class PullRequestsList extends StatefulWidget {
  const PullRequestsList({required this.gitHub, Key? key}) : super(key: key);
  final GitHub gitHub;

  @override
  _PullRequestsListState createState() => _PullRequestsListState();
}

class _PullRequestsListState extends State<PullRequestsList> {
  @override
  initState() {
    super.initState();
    _pullRequests = widget.gitHub.pullRequests
        .list(RepositorySlug('flutter', 'flutter'))
        .toList();
  }

  late Future<List<PullRequest>> _pullRequests;

  @override
  Widget build(BuildContext context) {
    return FutureBuilder<List<PullRequest>>(
      future: _pullRequests,
      builder: (context, snapshot) {
        if (snapshot.hasError) {
          return Center(child: Text('${snapshot.error}'));
        }
        if (!snapshot.hasData) {
          return const Center(child: CircularProgressIndicator());
        }
        var pullRequests = snapshot.data;
        return ListView.builder(
          itemBuilder: (context, index) {
            var pullRequest = pullRequests![index];
            return ListTile(
              title: Text(pullRequest.title ?? ''),
              subtitle: Text('flutter/flutter '
                  'PR #${pullRequest.number} '
                  'opened by ${pullRequest.user?.login ?? ''} '
                  '(${pullRequest.state?.toLowerCase() ?? ''})'),
              onTap: () => _launchUrl(context, pullRequest.htmlUrl ?? ''),
            );
          },
          itemCount: pullRequests!.length,
        );
      },
    );
  }
}

Future<void> _launchUrl(BuildContext context, String url) async {
  if (await canLaunch(url)) {
    await launch(url);
  } else {
    return showDialog(
      context: context,
      builder: (context) => AlertDialog(
        title: const Text('Navigation error'),
        content: Text('Could not launch $url'),
        actions: <Widget>[
          TextButton(
            onPressed: () {
              Navigator.of(context).pop();
            },
            child: const Text('Close'),
          ),
        ],
      ),
    );
  }
}

您在此处添加了许多新代码。好处在于,这些都是十分正常的 Flutter 代码,还包含一些微件用于区分对不同事项的责任。请花点时间检查该代码,然后再执行下一个步骤(让这款应用的所有元素都运行起来)。

最后一次融为一体

现在,该将 GitHubSummary 集成到您的 lib/main.dart 文件中了。这次的更改相当重大,但主要涉及删除操作。将 lib/main.dart 文件的内容替换为以下内容。

lib/main.dart

import 'package:flutter/material.dart';
import 'package:github/github.dart';
import 'package:window_to_front/window_to_front.dart';

import 'github_oauth_credentials.dart';
import 'src/github_login.dart';
import 'src/github_summary.dart';

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'GitHub Client',
      theme: ThemeData(
        primarySwatch: Colors.blue,
        visualDensity: VisualDensity.adaptivePlatformDensity,
      ),
      home: const MyHomePage(title: 'GitHub Client'),
    );
  }
}

class MyHomePage extends StatelessWidget {
  const MyHomePage({Key? key, required this.title}) : super(key: key);
  final String title;

  @override
  Widget build(BuildContext context) {
    return GithubLoginWidget(
      builder: (context, httpClient) {
        WindowToFront.activate(); // and this.
        return Scaffold(
          appBar: AppBar(
            title: Text(title),
          ),
          body: GitHubSummary(
            gitHub: _getGitHub(httpClient.credentials.accessToken),
          ),
        );
      },
      githubClientId: githubClientId,
      githubClientSecret: githubClientSecret,
      githubScopes: githubScopes,
    );
  }
}

GitHub _getGitHub(String accessToken) {
  return GitHub(auth: Authentication.withToken(accessToken));
}

运行这款应用,然后您应该会看到如下内容:

d5c9bebf448a2519.png

8. 后续步骤

恭喜!

您已完成此 Codelab,并构建了一款可访问 GitHub 的 API 的桌面 Flutter 应用。您使用了一个采用 OAuth 的身份验证 API,并通过您创建的另一个插件使用了多个原生 API。

如需详细了解桌面设备上的 Flutter,请访问 flutter.dev/desktop。最后,如需以一种完全不同的视角了解 Flutter 和 GitHub,请参阅 GroovinChip 的 GitHub-Activity-Feed