Flutter - 桌面應用視窗化實戰

語言: CN / TW / HK

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( frame.right - frame.left, frame.bottom - frame.top, project_);

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. 效果圖

8951d0b6c1dbbf21d32dd090b468982.jpg

自定義視窗導航欄

主要針對Windows平臺,原因上面我們解析過:win32Window是在windows目錄下的模板程式碼建立的預設是帶系統導航欄的(如下圖)。 image.png

很遺憾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](https://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(), ); } ``` image.png

到這裡多平臺的視窗就配置好了,接下來可以愉快的編寫頁面啦。
可能有些小夥伴會說:視窗的效果本就應該由原生去寫,為啥要讓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(); final lpKeyPath = regPath.toNativeUtf16(); final lpKey = key.toNativeUtf16(); final lpValue = value.toNativeUtf16();

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? getRegeditKeys(String regPath, {int hKeyValue = HKEY_LOCAL_MACHINE}) { final hKey = _getRegistryKeyHandle(hKeyValue, regPath);

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 getRegedit(String regPath, {int hKeyValue = HKEY_CURRENT_USER}) { final hKey = _getRegistryKeyHandle(hKeyValue, regPath); final Map portsList = {};

/// 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(); final lpKeyPath = key.toNativeUtf16();

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()..value = MAX_PATH; final lpType = calloc(); final lpData = calloc(maxItemLength); final lpcbData = calloc()..value = maxItemLength;

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()..value = MAX_PATH;

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就能拿到引數的列表,非常方便。
image.png
其實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);
```

四、寫在最後

通過上面這麼多的實現,我們已經完全把一個應用窗體結構搭建起來了。長篇幅的實戰記錄,希望可以切實的幫助到大家。總體來說,桌面開發雖然還有很多缺陷,但是能用,效能尚佳,跨平臺降低成本。
我們下期再見!