目前 Flutter 的穩定版本支援 iOS 以及 Android 平台的行動應用程式開發,Web 版還處在 beta channel 中,還不知道什麼時候會進入穩定版本,而怎麼開發供 Flutter Web 使用的 plug-in,或是把既有的 plug-in 加上 Flutter Web 支援,這部份的文件似乎還不怎麼完整。
最近把一些手上的 plug-in 加上了 Flutter Web 支援,來把踩坑的過程記錄一下。
前置作業
前置作業一:切換到 Beta Channell
因為 Flutter channel 還在 beta channel 中,所以我們也需要先把開發環境切換到 beta channel,方法是輸入以下指令:
flutter channel beta
flutter upgrade
然後輸入以下指令,表示我們接下來的 Flutter 開發環境支援 Flutter Web 開發:
flutter config --enable-web
前置作業二:修改 pubspec.yaml
然後我們要去調整一下 pubspec.yaml 檔案。如果你原本寫好了一個支援 iOS 與 Android 平台的 plugin 的話,在 pubspec.yaml 裡頭關於 plugin 的設定是這樣的:
flutter:
plugin:
androidPackage: net.zonble.flutterinstallappplugin
pluginClass: FlutterInstallAppPlugin
我們要改成 Flutter 1.12 版之後的格式:
flutter:
plugin:
platforms:
android:
package: net.zonble.flutterinstallappplugin
pluginClass: FlutterInstallAppPlugin
ios:
pluginClass: FlutterInstallAppPlugin
web:
pluginClass: FlutterInstallAppPlugin
fileName: flutter_install_app_web.dart
我們後面會提到 web 底下的 pluginClass 與 fileName 的意義。不過,如果你開發了一個只支援 iOS 或 Android 的 plug-in,也建議改成這個格式,但是不要寫 web 這一段,如此一來,在 pub.dev 上面,就會顯示成這個 plug-in 只支援 iOS 或 Android,而不支援 web。
Dependencies 也要加上 flutter_web_plugins
dependencies:
flutter:
sdk: flutter
flutter_web_plugins:
sdk: flutter0
前置作業三:更新 example
每個 plug-in 都包含一個 example,讓使用 plug-in 的其他開發者知道這個 plug-in 如何使用,我們在開發 plug-in 的時候,也往往是透過 example 來查看執行的狀況。在增加 Flutter Web 支援之後,我們需要在 example 目錄下,輸入以下指令,建立必要的相關檔案:
flutter create .
基本觀念
我們在寫 iOS 或 Android 的 plug-in 的時候,撰寫的是平台上的原生程式語言,像在 iOS 平台上寫 Objective-C 或 Swift 語言,在 Android 平台上寫 Java 或是 Kotlin,但我們在寫 Flutter Web 的 plug-in 的時候,還是在寫 Dart 程式。
差別在於:在行動平台上,Flutter 行動 app 是跑在 Dart 的 virtual machine 上,然後透過 Flutter 的 codec 與原生程式溝通 — 因為有些平台相關的功能,像是相機、GPS、IAP/IAB 等,只能夠用原生程式碼介接。
不過,在 Flutter Web 上,我們其實是把整個 Futter 應用程式編譯成 JavaScript,所以,用 Dart 寫出來的 Flutter Web plug-in,最後也是編譯成 JavaScript,在瀏覽器當中執行,然後,我們用 Dart 撰寫的 plug-in,就可以用 Dart 本身提供的 dart:js 或 dart:html 這些套件,與瀏覽器當中的 JavaScript 物件、以及 DOM 物件溝通,然後再透過 HTML 5 的功能,使用 Audio、相機、推播…等功能。
先回頭看我們前面提到的這段
web:
pluginClass: FlutterInstallAppPlugin
fileName: flutter_install_app_web.dart
意思就是,我們要寫一個叫做 flutter_install_app_web.dart 的 Dart 程式,這個程式與原本 plug-in 在 Dart 這一端的其他程式位在同一個路徑下(也就是lib 目錄),然後,裡頭有一個叫做 FlutterInstallAppPlugin 的 class。大概像這樣:
import 'package:flutter/services.dart';
import 'package:flutter_web_plugins/flutter_web_plugins.dart';
import 'package:platform_detect/platform_detect.dart';class FlutterInstallAppPlugin {
static void registerWith(Registrar registrar) {
final MethodChannel channel = MethodChannel('METHOD_CHANNEL_NAME',
const StandardMethodCodec(), registrar.messenger);
final FlutterInstallAppPlugin instance = FlutterInstallAppPlugin();
channel.setMethodCallHandler(instance.handleMethodCall);
} Future<dynamic> handleMethodCall(MethodCall call) async {
switch (call.method) {
case 'some_method':
// Do something here.
// break;
default:
throw PlatformException(
code: 'Unimplemented',
details: "The url_launcher plugin for web doesn't implement "
"the method '${call.method}'");
}
}
}
就跟我們在寫 iOS/Android 的 plugin 一樣,我們需要先建立一個 method channel,讓 Flutter 應用程式可以對 plug-in 呼叫指令。在 registerWith 這個 method 中,就是在負責 method channel 的註冊工作,把來自我們的 method channel 交給 handleMethodCall 這個 method 負責。
在 handleMethodCall 中,我們透過 call.method,知道傳來的命令的名稱,然後用 call.arguments 中,取得傳遞過來的變數。在這邊 Flutter Web 與其他平台有些不同,行動版本的 API 中,會提供一個叫做 result 的物件,用來用 Swift/Kotlin 這些語言回傳執行命令的成果,像是成功或失敗、成功的話的回傳值是什麼。在 Flutter 這邊沒有 result 物件,成功的ˋ話,可以直接 return 回去,失敗的話,就直接拋出 exception 即可。
相依的 JavaScript 套件
在撰寫 iOS/Android 使用的 plug-in 時,我們使用 CocoaPods 以及 gradle,管理平台上所需要的相依套件。在 Flutter Web 這邊,我們可能會用到一些 JavaScript library,就沒有對應的套件管理工具可以用,而作法是,要在文件中請用戶自行修改 inedx.html。
在執行了前述的「flutter create .」指令後,會在 Flutter 專案中建立一個叫做 web 的目錄,裡頭有幾個基本檔案,像是 index.html、manifest.json 等。在 index.html 中,header 的地方不是很重要,我們可以看一下實際載入 Flutter Web 應用程式的部份
<script>
if ('serviceWorker' in navigator) {
window.addEventListener('load', function () {
navigator.serviceWorker.register('flutter_service_worker.js');
});
}
</script>
<script src="main.dart.js" type="application/javascript"></script>
Flutter Web 程式最後會被編譯成 main.dart.js 這個檔案,用戶打開我們的網頁時,會先載入 index.html,然後再載入 main.dart.js。所以,如果我們需要用到哪些 JavaScript 套件,就只要在載入 main.dart.js 之前載入就可以了。比方說,你需要用到一個 Audio Player,就可能想到 Howler,就是在這邊寫成:
<script src="https://cdn.jsdelivr.net/npm/howler@2.1.3/dist/howler.min.js"></script>
<script src="main.dart.js" type="application/javascript"></script>
Dart 與 JavaScript 互相呼叫
我們可以透過 dart.js 套件,讓 Dart 語言與網頁上的 JavaScript 互相傳遞訊息。在加上了「import ‘dart:js’;」之後,我們可以拿到一個叫做 context 的物件,這個物件相當於 JavaScript 當中 window 物件的角色。
比方說,我們想要播放音樂的話,在使用了 Howler 之後,就可以在 JavaScript 這邊建立一個叫做 audio 的物件:
<script type="application/javascript">
window.audio = Howl({src: 'https://zonble.net/MIDI/orz.mp3'});
</script>
在 Dart 這一段,我們就可以從 context,拿到 audio 物件,然後開始播放:
import 'dart:js' as js;final audio = js.context['audio'];
audio?.callMethod('play');
我們想從 Dart 這邊呼叫 console.log,則會像這樣:
js.context['console'].callMethod('log', ['hi']);
在 Dart 這一端,除了可以從 context 中拿到物件,也可以反過來設定物件。在 Dart 官方的例子是這樣的:
var object = js.JsObject(js.context['Object']);
object['greeting'] = 'Hello';
object['greet'] = (name) => "${object['greeting']} $name";
接下來,在 JavaScript 中,就可以用 window.Object,找到這個物件。我們看到了,這邊除了可以用字串、數字這些型態,也可以註冊 function,當 JavaScript 呼叫了 Object.greet 這個 method 的時候,greet 這個 function 中也可以呼叫 Dart 這一端的程式,於是我們可以讓 Dart/JavaScript 這兩端的程式互相呼叫。
Event Channel
在撰寫 iOS/Android app 的時候,我們往往透過 event channel,把原生這一端所發生的事件,傳遞回 Flutter 應用程式。在 Dart 這端,我們的 plug-in 大概會像這樣:
class MyPlayer {
static const _eventChannel = EventChannel('native_events');
static Stream<Event> get eventStream {
return _eventChannel.receiveBroadcastStream();
}
}
不過,在撰寫 Flutter Web plug-in 的時候,在 Dart 這端沒辦法對 event channel 發送訊息。想到了幾種作法,最後想到的改動最少的:在 MyPlayer 中,我們另外建立一個 StreamController,我們的 Flutter Web plug-in 就往這個 StreamController 傳送事件,然後,我們在 eventStream 中合併兩個 stream。大概像這樣:
class MyPlayer {
static const _eventChannel = EventChannel('native_events');
static StreamController<Event> customStreamController =
StreamController(); static Stream<Event> get eventStream {
return StreamGroup.merge([
_eventChannel.receiveBroadcastStream(),
customStreamController.stream
]);
}
}
這樣,在 plug-in 這邊,我們只要呼叫 MyPlayer.customStreamController.add(),就可以發出指令,而由於 eventStream 合併了來自 Native/Web 的兩條 stream,我們的 Flutter 應用程式不用做任何改動,就可以同時收到來自這兩個不同世界的事件。
這樣我的 Flutter 應用程式就有了網站版本?
如果想要把 Flutter 應用程式變成網站,意思就是,所有用到的 plug-in 都要有對應的網站版本,這一來要看這些 plug-in 的作者有沒有心力更新,再來就是有些功能是不是在網頁上到底有沒有辦法做,像是某些特殊的硬體功能,或是做網站一定會遇到的 CORS 問題:有些網路資料在 app 裡頭可以盡情使用,到了網站上就可能因為不在同一個網段而被瀏覽器擋掉。Flutter Web 的生態目前還算是起步階段,畢竟都還是在 beta channel 裡頭呢!