0%

【文章翻譯】Modern Flutter Plugin Development

【文章內容使用 Gemini 1.5 Pro 自動產生】

現代 Flutter Plugin 開發

作者:Amir Hardon, Chris Sells, Collin Jackson, Harry Terkelsen 以及 Matt Carroll

2019 年對於 Flutter Plugin 作者來說是科技進步的一年。我們推出了 Android Plugin API 2.0,它提供了一種更強大、功能更齊全的方式來在您的 Plugin 中實作 Android 支援。我們更新了 pubspec.yaml 格式,使其能夠清楚地指定 Android 和 iOS 支援,以及 Web、macOS、Windows 和 Linux。此外,隨著我們推進 Flutter 以支援多個平台,我們啟用了聯邦化,使具有不同專業知識的多個團隊能夠將其程式碼整合在一起,為使用 Plugin 的 Flutter 開發人員提供無縫的體驗。最後,我們在測試 Plugin 方面取得了長足的進步,還有更多進步即將到來。

Android Plugin API 2.0

在 2019 年 12 月,Flutter 發布了其 Android 嵌入的新版本。這是負責將 Flutter 整合到 Android 應用程式中的 Android 程式碼。它包含像 FlutterActivity、FlutterFragment、FlutterView 和 FlutterEngine 這樣的類別。v2 Android 嵌入包含對標準 Android 生命週期事件的支援,以及將 Flutter 執行與 Android UI 分離,這些功能在 v1 Android 嵌入中缺失。在開發 v2 Android 嵌入的過程中,很明顯現有的 Flutter Plugin API 不足以處理 v2 Android 嵌入的新功能。需要一個新的 Android Plugin API。我們將討論該 API 以及如何使用它。

首先,了解 v2 Android 嵌入中的 FlutterEngine 類別非常重要。FlutterEngine 物件代表一個單一的 Flutter 執行環境。這意味著 FlutterEngine 控制一個 Dart 隔離區(您的 Dart 程式碼,從像 main 這樣的進入點開始)。這也意味著 FlutterEngine 設定了一系列所有 Flutter 應用程式都需要使用的標準平台通道;它包含對平台視圖的支援,它知道如何使用 Flutter UI 繪製紋理,並且它處理執行單個 Flutter/Dart 應用程式的所有其他基本要求。此外,Android 應用程式可能同時包含多個 FlutterEngine。

“將 Plugin 加入”到 Flutter 應用程式中的基本概念是指將該 Plugin 應用到單個 FlutterEngine。例如,如果 Flutter 應用程式需要存取相機,則該功能是透過在特定 FlutterEngine 例項中註冊相機 Plugin 來實現的。此註冊是透過 GeneratedPluginRegistrant 自動完成的,但重要的是要了解每個 FlutterEngine 都維護著自己的 Flutter Plugin 集合。

在舊的 v1 Android 嵌入中,所有 Plugin 都在 Android 應用程式開始時初始化和配置,而且只有一個 Flutter 體驗。在 v2 嵌入中,我們不對 Plugin 何時初始化做任何假設,並且 Plugin 必須在每個 FlutterEngine 中初始化一次。因此,所有適用於 Android 的 Flutter Plugin 現在都必須支援實例化,而不是靜態初始化,並且它們必須支援附加到 FlutterEngine 以及從 FlutterEngine 中分離。以下程式碼範例展示了舊的 v1 Plugin 初始化實作與新的 v2 Plugin 初始化過程之間的差異。

舊的 Plugin 初始化

1
2
3
4
5
6
7
8
class MyOldPlugin {
public static void registerWith(PluginRegistrar registrar) {
// 從 registrar 取得 Plugin 所需的任何參考。
//
// 此 Plugin 現在被視為已“初始化”並“附加”到
// Flutter 體驗。
}
}

新的 Plugin 初始化

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
class MyNewPlugin implements FlutterPlugin {
public MyNewPlugin() {
// 所有 Android Plugin 類別都必須支援無參數
// 建構函式。預設情況下,在沒有宣告的情況下,會提供無參數建構函式,但我們在此為了清晰起見而包含它。
//
// 在這一點上,您的 Plugin 已被實例化,但它
// 尚未附加到任何 Flutter 體驗。您不應嘗試在這裡執行任何與取得
// 資源或操作 Flutter 相關的工作。
}

@override
public void onAttachedToFlutterEngine(FlutterPluginBinding binding) {
// 您的 Plugin 現在已附加到由給定 FlutterEngine
// 表示的 Flutter 體驗。
//
// 您可以透過 binding.getFlutterEngine() 取得關聯的 FlutterEngine
//
// 您可以透過 binding.getBinaryMessenger() 取得 BinaryMessenger
//
// 您可以透過 binding.getApplicationContext() 取得應用程式內容
//
// 您無法在此存取 Activity,因為這個 FlutterEngine 不一定是在
// Activity 中顯示的。有關更多資訊,請參閱 ActivityAware 介面。
}

@override
public void onDetachedFromFlutterEngine(FlutterPluginBinding binding) {
// 您的 Plugin 現在不再附加到 Flutter 體驗。
// 您需要清除在 onAttachedToFlutterEngine() 中建立的任何資源和參考。
}
}

如新的 Plugin API 所示,您的 Plugin 必須等到 onAttachedToFlutterEngine() 完成後才能採取任何有意義的動作,並且它必須透過釋放所有資源來尊重 onDetachedFromFlutterEngine()。您的 Plugin 可能會被附加和分離多次。

此外,您的 Plugin 不應在 onAttachedToFlutterEngine() 中依賴 Activity 參考。您的 Plugin 附加到 Flutter 體驗並不意味著 Flutter 體驗是在 Activity 中顯示的。這是舊的 Plugin API 與新的 Plugin API 之間最重大的差異之一。在舊的 v1 Plugin API 中,Plugin 作者可以依賴於 Activity 立即且永久地可用。這不再正確。

需要存取 Activity 的 Plugin 必須實作第二個名為 ActivityAware 的介面。ActivityAware 介面為您的 Plugin 類別新增回呼,這些回呼告訴您的 Plugin 何時處於 Activity 中、Activity 何時經過配置變更,以及 Plugin 何時不再處於 Activity 中。您的 Plugin 必須尊重這些回呼。以下範例展示了 ActivityAware Plugin 的輪廓:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
class MyNewPlugin implements FlutterPlugin, ActivityAware {
@override
public void onAttachedToFlutterEngine(FlutterPluginBinding binding) {
// ...
}

@override
public void onDetachedFromFlutterEngine(FlutterPluginBinding binding) {
// ...
}

@override
public void onAttachedToActivity(ActivityPluginBinding binding) {
// 您的 Plugin 現在與一個 Android Activity 關聯。
//
// 如果呼叫此方法,則始終在呼叫
// onAttachedToFlutterEngine() 後呼叫它。
//
// 您可以透過 binding.getActivity() 取得 Activity 參考
//
// 您可以透過 binding.getLifecycle() 監聽生命週期變更
//
// 您可以透過在 binding 上使用適當的方法,監聽 Activity 結果、新的 Intent、使用者
// 離開提示以及狀態儲存回呼。
}

@override
public void onDetachedFromActivityForConfigChanges() {
// 您的 Plugin 與之關聯的 Activity 因配置變更而被銷毀。它會立即回來,
// 但您的 Plugin 必須清除對該
// Activity 和關聯資源的任何參考。
}

@override
public void onReattachedToActivityForConfigChanges(
ActivityPluginBinding binding
) {
// 您的 Plugin 現在已與一個新的 Activity 例項關聯,
// 這是因為配置變更已發生。您現在可以重新建立
// 與 Activity 和關聯資源的參考。
}

@override
public void onDetachedFromActivity() {
// 您的 Plugin 現在不再與 Activity 關聯。
// 您必須清除所有資源和參考。您的
// Plugin 可能會再次與 Activity 關聯,也可能不再關聯。
}
}

新的 Plugin API 明確地認識到 Plugin 可能會或可能不會與 Activity 關聯,並且任何此類 Activity 都可能因配置變更而在任何時候被銷毀和重新建立。這些問題應該對所有 Android 開發人員來說都很熟悉。

為 Flutter 的 v2 Android 嵌入撰寫 Plugin 的關鍵是尊重您的 Plugin 實作的每個 Plugin 生命週期回呼。只要您等到正確的時間建立參考,並在適當的時間釋放那些參考,您的 Plugin 將按預期運作。

一些 Plugin,例如相機 Plugin,只有在 Activity 可用時才有意義。那麼這些 Plugin 要怎麼做呢?如果是只有 UI 的 Plugin,這些 Plugin 可以等到 onAttachedToActivity() 執行後才能執行任何工作。然後,在 onDetachedFromActivity() 中,這些 Plugin 可以清除所有參考,並基本上停用自身。Plugin 不需要在 onAttachedToFlutterEngine() 中做任何特殊事宜。Plugin 只有在附加到 Activity 時才執行工作是可以接受的。

有關如何將您的 Android Plugin 從 v1 API 遷移到 v2 API 的更多詳細資訊,請參閱 flutter.dev 上的 支援新的 Android Plugin API

新的 pubspec 格式

傳統上,Flutter Plugin 是一個單一套件,它讓在 Android 和 iOS 上執行的 Flutter 應用程式能夠存取平台特定的功能;技術上來說,Plugin 由 Dart 程式碼組成,程式碼背後是 Android 特定和 iOS 特定的程式碼。儘管任何 Flutter Plugin 都支援 Android 和 iOS 的假設並不準確(例如,android_intent Plugin 僅支援 Android),但在最初的設計中,Plugin 生態系統是以該假設為基礎。這個假設在很大程度上是正確的,這意味著對於少數幾個錯誤的案例,整體成本很低,而這個簡化的假設則能快速發展並集中焦點。

隨著 Flutter 逐漸支援更多平台,我們決定放棄這個簡化的假設,因為:

  1. 我們預計許多 Plugin 只會支援 Flutter 支援平台的一部分(更何況是下面提到的聯邦化 Plugin)。
  2. 我們想要解鎖需要了解 Plugin 支援平台的工具功能(例如,更智慧的 pub.dev 搜尋和平台感知的工具操作)。

核心缺失的部分是 Plugin 支援哪些平台的清楚指示,因此我們重新設計了 Flutter Plugin 的 pubspec 架構,以圍繞多平台支援。

在之前的 pubspec 架構下,flutter.plugin 鍵包含不同的 Plugin 組態位元,而我們則在 flutter.plugin.platforms 鍵下為每個平台引入了新的鍵,其中包含平台特定的 Plugin 組態。例如,以下展示了支援 Android、iOS、macOS 和 Web 的 Plugin 的 pubspec:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
flutter:
plugin:
platforms:
android:
package: com.example.hello
pluginClass: HelloPlugin
ios:
pluginClass: HelloPlugin
macos:
pluginClass: HelloPlugin
web:
pluginClass: HelloPlugin
fileName: hello_web.dart

environment:
sdk: ">=2.1.0 <3.0.0"
# Flutter 版本在 1.10 之前不支持
# flutter.plugin.platforms 映射。
flutter: ">=1.10.0"

支援這些平台一部分的 Plugin 可以從 platforms 映射中省略平台鍵,例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
flutter:
plugin:
platforms:
android:
package: com.example.hello
pluginClass: HelloPlugin
ios:
pluginClass: HelloPlugin

environment:
sdk: ">=2.1.0 <3.0.0"
# Flutter 版本在 1.10 之前不支持
# flutter.plugin.platforms 映射。
flutter: ">=1.10.0"

請注意,使用新的架構時,需要 Flutter SDK 大於 1.10.0,因為這是 Flutter 工具首度支援此架構的版本。

將現有的 Plugin 遷移到新的架構

本節以電池 Plugin 為例,逐步介紹如何將範例 Plugin 從之前的架構遷移到新的架構。

遷移時最重要的是只宣告 Plugin 支援的平台(這在之前是不可能的,這意味著只支援 Android 的 Plugin 必須包含一個無效的 iOS 實作,反之亦然)。

以下是範例 Plugin 的 pubspec.yaml 檔案在遷移之前相關的部分:

1
2
3
4
5
6
7
8
9
10
11
name: sample
version: 0.3.1+5

flutter:
plugin:
androidPackage: io.flutter.plugins.sample
iosPrefix: FLT
pluginClass: SamplePlugin

environment:
flutter: ">=1.6.7 <2.0.0"

假設 Plugin 支援 Android 和 iOS,則升級到新的架構包含以下步驟:

  • 將所需的最小 Flutter 版本提高到 1.10.0(這是首度支援新的架構的版本)。
  • 輕微的版本升級
  • 將 flutter.plugin 中的當前欄位替換為新的 platforms 欄位。
  • 如果之前使用了 iosPrefix 欄位,請重新命名主要的 iOS Plugin 檔案(更多詳細資訊在下面)。

更新後的 Plugin pubspec 如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
name: sample
version: 0.3.2

flutter:
plugin:
platforms:
android:
package: io.flutter.plugins.sample
pluginClass: SamplePlugin
ios:
pluginClass: FLTSamplePlugin

environment:
flutter: ">=1.10.0 <2.0.0"

請注意,由於 Plugin 支援 Android 和 iOS,因此這些是 flutter.plugin.platforms 下唯一的鍵。flutter.plugin.androidPackage 欄位在舊架構中的值成為 flutter.plugin.platforms.android.package 的值。新的架構沒有 iosPrefix 欄位的等效欄位,因為 iOS 的 pluginClass 有專用的鍵,我們可以在 flutter.plugin.platforms.ios.pluginClass 欄位中使用前綴,該欄位設定為 FLTSamplePlugin。

之前使用 iosPrefix 欄位的 Plugin

之前的架構暗示 iOS Plugin 的主介面名稱與其檔案名稱之間存在不一致,例如,對於這個使用之前的架構定義的範例 Plugin,將會有一個 SamplePlugin.h 檔案,該檔案宣告了一個 FLTSamplePlugin 介面。這種不一致不再受支援,這意味著升級到新的架構時,SamplePlugin.h 檔案必須重新命名為 FLTSamplePlugin.h。沒有使用 iosPrefix 鍵的 Plugin 不需要重新命名任何檔案。

有關開發支援任意平台的 Plugin 的更多資訊,請參閱 flutter.dev 上的 開發 Plugin 套件

聯邦化

新的 pubspec 架構不僅允許您精確指定 Plugin 支援的平台,它還讓您能夠將實作分散到多個套件中。過去,Plugin 的 Dart 程式碼、Android Java(或 Kotlin)程式碼以及 iOS Objective-C(或 Swift)程式碼都需要在同一個 Dart 套件中。現在,如果我們想要添加對另一個平台(Web、Mac OS、Windows 等)的支援,它不需要在同一個套件中。分散在多個套件中的 Plugin 被稱為 聯邦化 Plugin

與單一套件 Plugin 相比,聯邦化 Plugin 有幾個優點,包括:

  • Plugin 作者不需要擁有對每個支援的 Flutter 平台(Android、iOS、Web、Mac OS 等)的領域知識。
  • 您可以在不讓原始 Plugin 作者審查和拉取您的程式碼的情況下添加對新平台的支援。
  • 每個套件都可以獨立維護和測試。

那麼,您究竟如何建立 聯邦化 Plugin 呢?讓我們從一些術語開始:

  • 應用程式面向套件: 這是您在應用程式中導入以使用 Plugin 的套件。例如,package:url_launcher 就是一個應用程式面向套件。應用程式面向套件宣告應用程式面向 API,並與各種 平台套件 合作執行平台特定的功能。
  • 平台套件: 這是實作 應用程式面向套件 所需的平台特定功能的套件。例如 package:url_launcher_web:這個套件由 package:url_launcher 使用,在 Web 平台上運行 Flutter 應用程式時啟動 URL。平台套件不應在應用程式中導入,它們僅供 應用程式面向套件 用於呼叫所需的平台特定程式碼。
  • 平台介面套件: 這是將 應用程式面向套件平台套件 整合在一起的黏合劑。應用程式面向套件 宣告可以在 Flutter 應用程式中呼叫的 API,而 平台介面套件 則宣告每個 平台套件 必須實作的介面,以便支援 應用程式面向套件。讓單一套件定義此介面可以確保所有 平台套件 都以統一的方式實作相同的功能。

上圖顯示了應用程式、應用程式面向套件、平台套件和平台介面套件之間的相依性圖。應用程式始終只導入應用程式面向套件(在本例中為 package:url_launcher)。

平台介面 如何將應用程式面向套件與正確的平台套件整合在一起?過去,沒有“平台套件”,只有 Android 程式碼的子資料夾,以及 iOS 程式碼的另一個子資料夾。應用程式面向套件透過 MethodChannel 與平台程式碼通訊。您可以將 MethodChannel 視為事實上的“平台介面”,因為應用程式面向套件呼叫 MethodChannel,而對應的平台程式碼必須在 MethodChannel 上監聽具有正確參數的正確方法。沒有辦法靜態地確認 Android 程式碼或 iOS 程式碼是否正在監聽正確的 MethodChannel 呼叫。

啟動 URL 的舊方法

1
2
3
4
5
Future<void> launch(String url) {
channel.invokeMethod('launch', {
'url': url,
});
}

在聯邦化 Plugin 架構中,平台介面套件 替換了 MethodChannel。應用程式面向套件從平台套件需要的平台特定功能被封裝在平台介面中。在我們的範例中,應用程式面向套件是 package:url_launcher,它唯一需要的平台特定功能是能夠在給定平台上啟動 URL。一個(非常)簡單的平台介面將看起來像這樣:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
abstract class UrlLauncherPlatform {
/// 啟動給定的 [url]。
Future<void> launch(String url);

/// 此平台介面的實時“例項”。
///
/// 這是在註冊平台套件時設定的,
/// 通常是在平台初始化時。
///
/// 例如,Web 平台套件
/// (package:url_launcher_web) 將使用一個
/// 在新標籤頁中啟動 URL 的實作擴展此類別,
/// 並在初始化時將自己設定為實時
/// 例項,方法是:
///
/// UrlLauncherPlatform.instance = WebUrlLauncher();
static UrlLauncherPlatform instance;
}

現在,應用程式面向套件不再呼叫 MethodChannel,而是呼叫平台介面。

啟動 URL 的新方法

1
2
3
Future<void> launch(String url) {
return UrlLauncherPlatform.instance.launch(url);
}

因此,應用程式面向套件 呼叫 平台介面。平台介面如何與 平台套件 整合在一起?平台套件實作平台介面,並在平台初始化時將自己註冊為平台介面的預設例項。

例如,如果我們想要撰寫 package:url_launcher_web,我們只需要撰寫一個擴展 UrlLauncherPlatform 的類別,並為 Web 平台啟動 URL。程式碼將看起來像這樣:

1
2
3
4
5
6
7
8
9
10
class UrlLauncherWeb extends UrlLauncherPlatform {
/// Web 平台在應用程式初始化時會自動呼叫此方法。
static void registerWith(Registrar registrar) {
var webLauncher = UrlLauncherWeb();
UrlLauncherPlatform.instance = webLauncher;
}

@override
Future<void> launch(String url) => window.open(url, '');
}

遷移到聯邦化 Plugin 架構的好處是,一旦您設定了應用程式面向套件和平台介面套件,添加對新平台的支援就變得非常簡單(而且您甚至不需要親自做!)。所有需要做的就是建立一個新的平台套件,該套件擴展平台介面套件中宣告的平台介面。

有關聯邦化 Plugin 的更多詳細資訊,請參閱 flutter.dev 上的 聯邦化 Plugin

測試 Plugin

在撰寫新的跨平台 Plugin 或為現有的 Plugin 添加平台時,您可以透過撰寫測試來節省時間和避免未來的麻煩。自動化測試可以保護您的 Plugin 免受功能性回歸,讓您能夠快速開發新功能並合併貢獻。

一個經過充分測試的 Plugin 通常包含分散在多個套件中的幾種測試風格。您可能會因為撰寫不穩定或不太可能失敗的測試而降低效率,因此請專注於讓您相信關鍵用例仍然具有功能的測試撰寫。

AutomatedWidgetsFlutterBinding 測試

使用 AutomatedWidgetsFlutterBinding 運行的測試在開發機器上運行,而不是在設備或瀏覽器上運行。因此,它們運行速度更快,並且某些功能需要由模擬提供。

在應用程式面向套件中(例如 myplugin),套件的單元測試確保對應用程式面向 API 的呼叫會導致與平台介面套件的預期交互。這些測試通常導入 package:mockito 來提供一個虛假的平台介面,並驗證它是否接收到正確的呼叫。以下是一個來自 package:url_launcher 的 範例測試

1
2
3
4
5
6
test('returns true', () async {
when(mock.canLaunch('foo')).thenAnswer((_) =>
Future<bool>.value(true));
final bool result = await canLaunch('foo');
expect(result, isTrue);
});

在平台介面套件中(例如 myplugin_platform_interface),平台介面是一個抽象類別,無法直接實例化。但是,平台介面套件通常也包含平台介面的方法通道實作,因此這就是您應該測試的內容。此套件的測試應專注於從對平台介面的呼叫產生的方法通道呼叫以及方法通道。這些測試通常使用 setMockMethodCallHandlerisMethodCall 匹配器來驗證行為。

1
2
3
4
5
6
7
8
9
10
11
test('canLaunch', () async {
await launcher.canLaunch('http://example.com/');
expect(
log,
<Matcher>[
isMethodCall('canLaunch', arguments: <String, Object>{
'url': 'http://example.com/',
})
],
);
});

在平台測試中(例如 myplugin_web),您可以利用平台特定的功能。在當前的 Flutter SDK 中,flutter test 提供一個實驗性的 –platform 標誌,讓您能夠選擇在一個類似的 Chrome 環境中運行測試,該環境可以使用 dart:html。

這個測試模式對於在平台實作套件(例如,myplugin_web)中撰寫測試很有用。

1
2
3
test('cannot launch "tel" URLs', () {
expect(canLaunch('tel:5551234567'), completion(isFalse));
});

此外,您可以使用 針對 Web 的實驗性 ‘flutter drive’ 測試支援 在 Chrome 中運行您的 GUI 測試。

有關 Plugin 測試的更多資訊,請參閱 flutter.dev 上的 測試您的 Plugin

結論

如您所見,Flutter Plugin 開發人員有很多新功能,讓您能夠在更多平台上建立功能更齊全、更強大的 Plugin。如果您有興趣了解一些 Web 特定的細節,我推薦 Harry Terkelsen 的兩部分系列文章,如何撰寫 Flutter Web Plugin第 1 部分第 2 部分)。有關一般 Plugin 撰寫的更多資訊,flutter.dev 上的 開發 Plugin 套件 文件也是一個很好的資源。


現代 Flutter Plugin 開發 最初發佈在 Flutter 上的 Medium,人們在那裡透過突出顯示和回應這個故事來繼續討論。