Flutter - 桌面應用視窗化實戰
theme: vuepress
通過此篇文章,你可以編寫出一個完整桌面應用的視窗框架。你將瞭解到: 1. Flutter在開發windows和Android桌面應用初始階段,應用視窗的常規配置; 2. windows平臺特定互動的實現,如:執行控制檯指令,windows登錄檔,應用單例等; 3. 桌面應用的互動習慣,如:互動點選態,不同大小的頁面切換,獲取系統喚起應用的引數等。
⚠️本文為稀土掘金技術社群首發簽約文章,14天內禁止轉載,14天后未獲授權禁止轉載,侵權必究!
前言
在使用Flutter開發桌面應用之前,筆者之前都是開發移動App的,對於移動應用的互動比較熟悉。開始桌面應用開發後,我發現除了技術棧一樣之外,其他互動細節、使用者行為習慣以及作業系統特性等都有很大的不同。
我將在windows和android桌面裝置上,從0到1親自搭建一個開源專案,並且記錄實現細節和技術難點。
一、應用視窗的常規配置
眾所周知,Flutter目前最大的應用是在移動app上,在移動裝置上都是以全屏方式展示,因此沒有應用視窗這個概念。而桌面應用是視窗化的
,需求方一般都會對視窗外觀有很高的要求
,比如:自定義視窗導航欄、設定圓角、陰影;同時還有可能要禁止系統自動放大的行為。
應用視窗化
Flutter在windows桌面平臺,是依託於Win32Window承載engine的,而Win32Windows本身就是視窗化的,無需再做過多的配置。(不過也正因為依託原生視窗,作為UI框架的flutter完全沒辦法對Win32Window的外觀做任何配置)
``` cpp
// win32_window.cpp
bool Win32Window::CreateAndShow(const std::wstring& title,
const Point& origin,
const Size& size) {
// ...此處省略程式碼...
// 這裡建立了win32介面的控制代碼
HWND window = CreateWindow(
window_class, title.c_str(), WS_POPUP | WS_SYSMENU | WS_MINIMIZEBOX | WS_MAXIMIZEBOX,
Scale(origin.x, scale_factor), Scale(origin.y, scale_factor),
Scale(size.width, scale_factor), Scale(size.height, scale_factor),
nullptr, nullptr, GetModuleHandle(nullptr), this);
UpdateWindow(window);
if (!window) {
return false;
}
return OnCreate();
}
cpp
bool FlutterWindow::OnCreate() {
if (!Win32Window::OnCreate()) {
return false;
}
// GetClientArea獲取建立的win32Window區域
RECT frame = GetClientArea();
// 繫結視窗和flutter engine
flutter_controller_ = std::make_unique
if (!flutter_controller_->engine() || !flutter_controller_->view()) { return false; } RegisterPlugins(flutter_controller_->engine()); SetChildContent(flutter_controller_->view()->GetNativeWindow()); return true; } ```
應用視窗化主要是針對Android平臺,Flutter應用是依託於Activity的,Android平臺上Activity預設是全屏,且出於安全考慮,當一個Activity展示的時候,是不允許使用者穿透點選的
。所以想要讓Flutter應用在Android大屏桌面裝置上展示出windows上的效果,需要以下步驟:
1. 將底層承載的FlutterActivity的主題樣式設定為Dialog
,同時全屏視窗的背景色設定為透明,點選時Dialog不消失;
``` xml
```
``` xml
activity
android:name=".MainActivity"
android:exported="true"
android:hardwareAccelerated="true"
android:launchMode="singleTop"
android:theme="@style/Theme.DialogApp"
android:windowSoftInputMode="adjustResize"
// android/app/src/main/kotlin/com/maxhub/upgrade_assistant/MainActivity.kt
class MainActivity : FlutterActivity() {
override fun getTransparencyMode(): TransparencyMode {
// 設定視窗背景透明
return TransparencyMode.transparent
}
override fun onResume() {
super.onResume()
setFinishOnTouchOutside(false) // 點選外部,dialog不消失
// 設定視窗全屏
var lp = window.attributes
lp.width = -1
lp.height = -1
window.attributes = lp
}
}
2. 至此Android提供了一個全屏的透明視窗,Flutter runApp的時候,我在MaterialApp外層套了一個盒子控制元件,這個控制元件內部主要做邊距、陰影等一系列視窗化行為。
dart
class GlobalBoxManager extends StatelessWidget {
GlobalBoxManager({Key? key, required this.child}) : super(key: key);
final Widget child;
@override
Widget build(BuildContext context) {
return Container(
width: ScreenUtil().screenWidth,
height: ScreenUtil().screenHeight,
// android偽全屏,加入邊距
padding: EdgeInsets.symmetric(horizontal: 374.w, vertical: 173.h),
child: child,
);
}
}
dart
// MyApp下的build構造方法
GlobalBoxManager(
child: GetMaterialApp(
locale: Get.deviceLocale,
translations: Internationalization(),
// 桌面應用的頁面跳轉習慣是無動畫的,符合使用者習慣
defaultTransition: Transition.noTransition,
transitionDuration: Duration.zero,
theme: lightTheme,
darkTheme: darkTheme,
initialRoute: initialRoute,
getPages: RouteConfig.getPages,
title: 'appName'.tr,
),
),
```
3. 效果圖
自定義視窗導航欄
主要針對Windows平臺,原因上面我們解析過:win32Window是在windows目錄下的模板程式碼建立的預設是帶系統導航欄的(如下圖)。
很遺憾Flutter官方也沒有提供方法,pub庫上對視窗操作支援的最好的是window_manager,由國內Flutter桌面開源社群leanFlutter所提供。
1. yaml匯入window_manager,在runApp之前執行以下程式碼,把win32視窗的導航欄去掉,同時配置背景色為透明、居中顯示;
yaml
dependencies:
flutter:
sdk: flutter
window_manager: ^0.2.6
dart
// runApp之前執行
WindowManager w = WindowManager.instance;
await w.ensureInitialized();
WindowOptions windowOptions = WindowOptions(
size: normalWindowSize,
center: true,
titleBarStyle: TitleBarStyle.hidden // 該屬性隱藏導航欄
);
w.waitUntilReadyToShow(windowOptions, () async {
await w.setBackgroundColor(Colors.transparent);
await w.show();
await w.focus();
await w.setAsFrameless();
});
2. 此時會發現應用開啟時在左下角閃一下再居中。這是由於原生win32視窗預設是左上角顯示,而後在flutter通過外掛才居中;處理方式建議在原生程式碼中先把視窗設為預設不顯示,通過上面的window_manager.show()展示出來;
cpp
// windows/runner/win32_window.cpp
HWND window = CreateWindow(
// 去除WS_VISIBLE屬性
window_class, title.c_str(), WS_OVERLAPPEDWINDOW,
Scale(origin.x, scale_factor), Scale(origin.y, scale_factor),
Scale(size.width, scale_factor), Scale(size.height, scale_factor),
nullptr, nullptr, GetModuleHandle(nullptr), this);
美化應用視窗
通過前面的步驟,我們在android和windows平臺上都得到了一個安全透明的視窗,接下來的修飾Flutter就可以為所欲為了。
- 視窗陰影、圓角
上面介紹過在MaterialApp
外套有盒子控制元件,直接在Container內加入陰影和圓角即可,不過Android和桌面平臺還是需要區分下的;
``` dart
import 'dart:io';
import 'package:flutter/material.dart';
class GlobalBoxManager extends StatelessWidget { const GlobalBoxManager({Key? key, required this.child}) : super(key: key); final Widget child;
@override
Widget build(BuildContext context) {
return Container(
width: double.infinity,
height: double.infinity,
// android偽全屏,加入邊距
padding: Platform.isAndroid
? const EdgeInsets.symmetric(horizontal: 374, vertical: 173)
: EdgeInsets.zero,
child: Container(
clipBehavior: Clip.antiAliasWithSaveLayer,
margin: const EdgeInsets.all(10),
decoration: const BoxDecoration(
borderRadius: BorderRadius.all(Radius.circular(8)),
boxShadow: [
BoxShadow(color: Color(0x33000000), blurRadius: 8),
]),
child: child,
),
);
}
}
![image.png](http://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/899865390973495e997755e1997d1ed6~tplv-k3u1fbpfcp-watermark.image?)
- 自定義導航欄<br/>
迴歸Scaffold的AppBar配置,再加上導航拖拽視窗事件(僅windows可拖拽)
dart
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: PreferredSize(
preferredSize: const Size.fromHeight(64),
child: GestureDetector(
behavior: HitTestBehavior.translucent,
onPanStart: (details) {
if (Platform.isWindows) windowManager.startDragging();
},
onDoubleTap: () {},
child: AppBar(
title: Text(widget.title),
centerTitle: true,
actions: [
GestureDetector(
behavior: HitTestBehavior.opaque,
child: const Padding(
padding: EdgeInsets.symmetric(horizontal: 16),
child: Icon(
Icons.close,
size: 24,
),
),
),
],
),
),
),
body: Center(),
);
}
```
到這裡多平臺的視窗就配置好了,接下來可以愉快的編寫頁面啦。
可能有些小夥伴會說:視窗的效果本就應該由原生去寫,為啥要讓Flutter去做這麼多的事情?答案很簡單:跨平臺! 要跨平臺就勢必需要繞一些,通過這種方式你會發現任何平臺的應用,都可以得到相同效果的視窗,而程式碼只需要Flutter寫一次,這才是Flutter存在的真正意義。
二、windwos平臺特定互動
在開發windows的過程中,我發現跟移動app最大的不同在於:桌面應用需要頻繁的去與系統做一些互動。
登錄檔操作
應用開發過程中,經常需要通過登錄檔來做資料儲存;在pub上也有一個庫提供這個能力,但是我沒有使用,因為dart已經提供了win32相關的介面,我認為這個基礎的能力沒必要引用多一個庫,所以手擼了一個工具類
來操作登錄檔。(值得注意的是部分登錄檔的操作是需要管理員許可權的,所以應用提權要做好)
``` dart
import 'dart:ffi';
import 'package:ffi/ffi.dart'; import 'package:win32/win32.dart';
const maxItemLength= 2048;
class RegistryKeyValuePair { final String key; final String value;
const RegistryKeyValuePair(this.key, this.value); }
class RegistryUtil { /// 根據鍵名獲取登錄檔的值 static String? getRegeditForKey(String regPath, String key, {int hKeyValue = HKEY_LOCAL_MACHINE}) { var res = getRegedit(regPath, hKeyValue: hKeyValue); return res[key]; }
/// 設定登錄檔值
static setRegeditValue(String regPath, String key, String value,
{int hKeyValue = HKEY_CURRENT_USER}) {
final phKey = calloc
try {
if (RegSetKeyValue(hKeyValue, lpKeyPath, lpKey, REG_SZ, lpValue,
lpValue.length * 2) !=
ERROR_SUCCESS) {
throw Exception("Can't set registry key");
}
return phKey.value;
} finally {
free(phKey);
free(lpKeyPath);
free(lpKey);
free(lpValue);
RegCloseKey(HKEY_CURRENT_USER);
}
}
/// 獲取登錄檔所有子項
static List
var dwIndex = 0;
String? key;
List<String>? keysList;
key = _enumerateKeyList(hKey, dwIndex);
while (key != null) {
keysList ??= [];
keysList.add(key);
dwIndex++;
key = _enumerateKeyList(hKey, dwIndex);
}
RegCloseKey(hKey);
return keysList;
}
/// 刪除登錄檔的子項 static bool deleteRegistryKey(String regPath, String subPath, {int hKeyValue = HKEY_LOCAL_MACHINE}) { final subKeyForPath = subPath.toNativeUtf16(); final hKey = _getRegistryKeyHandle(hKeyValue, regPath); try { final status = RegDeleteKey(hKey, subKeyForPath); switch (status) { case ERROR_SUCCESS: return true; case ERROR_MORE_DATA: throw Exception('An item required more than $maxItemLength bytes.'); case ERROR_NO_MORE_ITEMS: return false; default: throw Exception('unknown error'); } } finally { RegCloseKey(hKey); free(subKeyForPath); } }
/// 根據項的路徑獲取所有值
static Map
/// The index of the value to be retrieved.
var dwIndex = 0;
RegistryKeyValuePair? item;
item = _enumerateKey(hKey, dwIndex);
while (item != null) {
portsList[item.key] = item.value;
dwIndex++;
item = _enumerateKey(hKey, dwIndex);
}
RegCloseKey(hKey);
return portsList;
}
static int _getRegistryKeyHandle(int hive, String key) {
final phKey = calloc
try {
final res = RegOpenKeyEx(hive, lpKeyPath, 0, KEY_READ, phKey);
if (res != ERROR_SUCCESS) {
throw Exception("Can't open registry key");
}
return phKey.value;
} finally {
free(phKey);
free(lpKeyPath);
}
}
static RegistryKeyValuePair? _enumerateKey(int hKey, int index) {
final lpValueName = wsalloc(MAX_PATH);
final lpcchValueName = calloc
try {
final status = RegEnumValue(hKey, index, lpValueName, lpcchValueName,
nullptr, lpType, lpData, lpcbData);
switch (status) {
case ERROR_SUCCESS:
{
// if (lpType.value != REG_SZ) throw Exception('Non-string content.');
if (lpType.value == REG_DWORD) {
return RegistryKeyValuePair(lpValueName.toDartString(),
lpData.cast<Uint32>().value.toString());
}
if (lpType.value == REG_SZ) {
return RegistryKeyValuePair(lpValueName.toDartString(),
lpData.cast<Utf16>().toDartString());
}
break;
}
case ERROR_MORE_DATA:
throw Exception('An item required more than $maxItemLength bytes.');
case ERROR_NO_MORE_ITEMS:
return null;
default:
throw Exception('unknown error');
}
} finally {
free(lpValueName);
free(lpcchValueName);
free(lpType);
free(lpData);
free(lpcbData);
}
return null;
}
static String? _enumerateKeyList(int hKey, int index) {
final lpValueName = wsalloc(MAX_PATH);
final lpcchValueName = calloc
try {
final status = RegEnumKeyEx(hKey, index, lpValueName, lpcchValueName,
nullptr, nullptr, nullptr, nullptr);
switch (status) {
case ERROR_SUCCESS:
return lpValueName.toDartString();
case ERROR_MORE_DATA:
throw Exception('An item required more than $maxItemLength bytes.');
case ERROR_NO_MORE_ITEMS:
return null;
default:
throw Exception('unknown error');
}
} finally {
free(lpValueName);
free(lpcchValueName);
}
} } ```
執行控制檯指令
windows上,我們可以通過cmd指令做所有事情,dart也提供了這種能力。我們可以通過io庫中的Progress類
來執行指令。如:幫助使用者開啟網路連線。
dart
Process.start('ncpa.cpl', [],runInShell: true);
剛接觸桌面開發的小夥伴,真的很需要這個知識點。
實現應用單例
應用單例是windows需要特殊處理,android預設是單例的。而windows如果不作處理,每次點選都會重新執行一個應用程序,這顯然不合理。Flutter可以通過windows_single_instance外掛來實現單例。在runApp之前執行下這個方法,重複點選時會讓使用者獲得焦點置頂,而不是多開一個應用。
dart
/// windows設定單例項啟動
static setSingleInstance(List<String> args) async {
await WindowsSingleInstance.ensureSingleInstance(args, "desktop_open",
onSecondWindow: (args) async {
// 喚起並聚焦
if (await windowManager.isMinimized()) await windowManager.restore();
windowManager.focus();
});
}
三、桌面應用的互動習慣
按鈕點選態
按鈕點選互動的狀態,其實在移動端也存在。但不同的是移動端的按鈕基本上水波紋的效果就能滿足使用者使用,但是桌面應用顯示區域大,而點選的滑鼠卻很小,很多時候點選已經過去但水波紋根本就沒顯示出來。
正常互動是:點選按鈕馬上響應點選態的顏色(文字和背景都能編),鬆開恢復。
dart
TextButton(
clipBehavior: Clip.antiAliasWithSaveLayer,
style: ButtonStyle(
animationDuration: Duration.zero, // 動畫延時設定為0
visualDensity: VisualDensity.compact,
overlayColor: MaterialStateProperty.all(Colors.transparent),
padding: MaterialStateProperty.all(EdgeInsets.zero),
textStyle:
MaterialStateProperty.all(Theme.of(context).textTheme.subtitle1),
// 按鈕按下的時候的前景色,會讓文字的顏色按下時變為白色
foregroundColor: MaterialStateProperty.resolveWith((states) {
return states.contains(MaterialState.pressed)
? Colors.white
: Theme.of(context).toggleableActiveColor;
}),
// 按鈕按下的時候的背景色,會讓背景按下時變為藍色
backgroundColor: MaterialStateProperty.resolveWith((states) {
return states.contains(MaterialState.pressed)
? Theme.of(context).toggleableActiveColor
: null;
}),
),
onPressed: null,
child: XXX),
)
獲取應用啟動引數
由於我們的桌面裝置升級自研的整機,因此在開發過程經常遇到其他軟體要喚起Flutter應用的需求。那麼如何喚起,又如何拿到喚起引數呢?
1. windows:
其他應用通過Procress.start啟動.exe即可執行Flutter的軟體
;傳參也非常簡單,直接.exe後面帶引數,多個引數使用空格隔開,然後再Flutter main函式中的args
就能拿到引數的列表,非常方便。
其實cmd執行的引數,是被win32Window接收了,只是Flutter幫我們做了這層轉換,通過engine傳遞給main函式,而Android就沒那麼方便了。
2. Android:
Android原生啟動應用是通過Intent對應包名下的Activity,然後再Activity中通過Intent.getExtra
可以拿到引數。我們都知道Android平臺下Flutter只有一個Activity,因此做法是先在MainActivity中拿到Intent的引數,然後建立Method Channel通道;
``` kotlin
class MainActivity : FlutterActivity() {
private var sharedText: String? = null
private val channel = "app.open.shared.data"
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val intent = intent
handleSendText(intent) // Handle text being sent
}
override fun onRestart() {
super.onRestart()
flutterEngine!!.lifecycleChannel.appIsResumed()
}
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, channel)
.setMethodCallHandler { call: MethodCall, result: MethodChannel.Result ->
when (call.method) {
"getSharedText" -> {
result.success(sharedText)
}
}
}
}
private fun handleSendText(intent: Intent) {
sharedText = intent.getStringExtra("params")
}
}
```
Flutter層在main函式中通過Method Channel的方式取到MainActivity中儲存的引數,繞多了一層鏈路。
```dart
const platform = MethodChannel('app.open.shared.data');
String? sharedData = await platform.invokeMethod('getSharedText');
if (sharedData == null) return null;
return jsonDecode(sharedData);
```
四、寫在最後
通過上面這麼多的實現,我們已經完全把一個應用窗體結構搭建起來了。長篇幅的實戰記錄,希望可以切實的幫助到大家。總體來說,桌面開發雖然還有很多缺陷,但是能用,效能尚佳,跨平臺降低成本。
我們下期再見!
- Flutter桌面開發 - windows外掛開發
- Flutter桌面開發-專案工程化框架搭建
- Flutter桌面小工具 -- 靈動島【Windows Android版本】
- Flutter資源下載實現斷點續傳
- Flutter桌面應用如何進行多解析度適配
- Flutter - 桌面應用視窗化實戰
- 你真的敢落地Flutter桌面端嗎?
- Flutter 桌面端實踐之識別外接媒體裝置
- Flutter【移動端】如何進行多渠道打包釋出
- Flutter實現新手引導蒙層的兩種方式
- 移動端音影片需求實現方案探索
- Flutter實現酷狗流暢Tabbar效果
- Flutter實現動態化更新-技術預研
- Flutter動畫-實現閃爍星星
- 我該如何給Flutter webview新增透明背景?
- Flutter輸入框獲取剪下板-合規問題踩坑