網(wǎng)站首頁 編程語言 正文
前言
通過此篇文章,你可以編寫出一個完整桌面應(yīng)用的窗口框架。
你將了解到:
- Flutter在開發(fā)windows和Android桌面應(yīng)用初始階段,應(yīng)用窗口的常規(guī)配置;
- windows平臺特定交互的實現(xiàn),如:執(zhí)行控制臺指令,windows注冊表,應(yīng)用單例等;
- 桌面應(yīng)用的交互習(xí)慣,如:交互點擊態(tài),不同大小的頁面切換,獲取系統(tǒng)喚起應(yīng)用的參數(shù)等。
在使用Flutter開發(fā)桌面應(yīng)用之前,筆者之前都是開發(fā)移動App的,對于移動應(yīng)用的交互比較熟悉。開始桌面應(yīng)用開發(fā)后,我發(fā)現(xiàn)除了技術(shù)棧一樣之外,其他交互細節(jié)、用戶行為習(xí)慣以及操作系統(tǒng)特性等都有很大的不同。
我將在windows和android桌面設(shè)備上,從0到1親自搭建一個開源項目,并且記錄實現(xiàn)細節(jié)和技術(shù)難點。
一、應(yīng)用窗口的常規(guī)配置
眾所周知,F(xiàn)lutter目前最大的應(yīng)用是在移動app上,在移動設(shè)備上都是以全屏方式展示,因此沒有應(yīng)用窗口這個概念。而桌面應(yīng)用是窗口化的,需求方一般都會對窗口外觀有很高的要求,比如:自定義窗口導(dǎo)航欄、設(shè)置圓角、陰影;同時還有可能要禁止系統(tǒng)自動放大的行為。
應(yīng)用窗口化
Flutter在windows桌面平臺,是依托于Win32Window承載engine的,而Win32Windows本身就是窗口化的,無需再做過多的配置。(不過也正因為依托原生窗口,作為UI框架的flutter完全沒辦法對Win32Window的外觀做任何配置)
// win32_window.cpp
bool Win32Window::CreateAndShow(const std::wstring& title,
const Point& origin,
const Size& size) {
// ...此處省略代碼...
// 這里創(chuàng)建了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();
}
bool FlutterWindow::OnCreate() {
if (!Win32Window::OnCreate()) {
return false;
}
// GetClientArea獲取創(chuàng)建的win32Window區(qū)域
RECT frame = GetClientArea();
// 綁定窗口和flutter engine
flutter_controller_ = std::make_unique<flutter::FlutterViewController>(
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;
}
應(yīng)用窗口化主要是針對Android平臺,F(xiàn)lutter應(yīng)用是依托于Activity的,Android平臺上Activity默認是全屏,且出于安全考慮,當(dāng)一個Activity展示的時候,是不允許用戶穿透點擊的。所以想要讓Flutter應(yīng)用在Android大屏桌面設(shè)備上展示出windows上的效果,需要以下步驟:
- 將底層承載的FlutterActivity的主題樣式設(shè)置為Dialog,同時全屏窗口的背景色設(shè)置為透明,點擊時Dialog不消失;
<!-- android/app/src/main/res/values/styles.xml --> <style name="Theme.DialogApp" parent="Theme.AppCompat.Light.Dialog"> <item name="android:windowBackground">@drawable/launch_application</item> <item name="android:windowIsTranslucent">true</item> <item name="android:windowContentOverlay">@null</item> <item name="android:backgroundDimEnabled">false</item> <item name="windowActionBar">false</item> <item name="windowNoTitle">true</item> </style>
<!-- android/app/src/main/AndroidManifest.xml --> <activity android:name=".MainActivity" android:exported="true" android:hardwareAccelerated="true" android:launchMode="singleTop" android:theme="@style/Theme.DialogApp" android:windowSoftInputMode="adjustResize"> <meta-data android:name="io.flutter.embedding.android.NormalTheme" android:resource="@style/Theme.DialogApp" /> <intent-filter> <action android:name="android.intent.action.MAIN" /> <category android:name="android.intent.category.LAUNCHER" /> </intent-filter> </activity>
// android/app/src/main/kotlin/com/maxhub/upgrade_assistant/MainActivity.kt
class MainActivity : FlutterActivity() {
override fun getTransparencyMode(): TransparencyMode {
// 設(shè)置窗口背景透明
return TransparencyMode.transparent
}
override fun onResume() {
super.onResume()
setFinishOnTouchOutside(false) // 點擊外部,dialog不消失
// 設(shè)置窗口全屏
var lp = window.attributes
lp.width = -1
lp.height = -1
window.attributes = lp
}
}
- 至此Android提供了一個全屏的透明窗口,F(xiàn)lutter runApp的時候,我在MaterialApp外層套了一個盒子控件,這個控件內(nèi)部主要做邊距、陰影等一系列窗口化行為。
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,
);
}
}
// MyApp下的build構(gòu)造方法
GlobalBoxManager(
child: GetMaterialApp(
locale: Get.deviceLocale,
translations: Internationalization(),
// 桌面應(yīng)用的頁面跳轉(zhuǎn)習(xí)慣是無動畫的,符合用戶習(xí)慣
defaultTransition: Transition.noTransition,
transitionDuration: Duration.zero,
theme: lightTheme,
darkTheme: darkTheme,
initialRoute: initialRoute,
getPages: RouteConfig.getPages,
title: 'appName'.tr,
),
),
- 效果圖
自定義窗口導(dǎo)航欄
主要針對Windows平臺,原因上面我們解析過:win32Window是在windows目錄下的模板代碼創(chuàng)建的默認是帶系統(tǒng)導(dǎo)航欄的(如下圖)。
很遺憾Flutter官方也沒有提供方法,pub庫上對窗口操作支持的最好的是window_manager,由國內(nèi)Flutter桌面開源社區(qū)leanFlutter所提供。
- yaml導(dǎo)入window_manager,在runApp之前執(zhí)行以下代碼,把win32窗口的導(dǎo)航欄去掉,同時配置背景色為透明、居中顯示;
dependencies: flutter: sdk: flutter window_manager: ^0.2.6
// runApp之前運行
WindowManager w = WindowManager.instance;
await w.ensureInitialized();
WindowOptions windowOptions = WindowOptions(
size: normalWindowSize,
center: true,
titleBarStyle: TitleBarStyle.hidden // 該屬性隱藏導(dǎo)航欄
);
w.waitUntilReadyToShow(windowOptions, () async {
await w.setBackgroundColor(Colors.transparent);
await w.show();
await w.focus();
await w.setAsFrameless();
});
- 此時會發(fā)現(xiàn)應(yīng)用打開時在左下角閃一下再居中。這是由于原生win32窗口默認是左上角顯示,而后在flutter通過插件才居中;
- 處理方式建議在原生代碼中先把窗口設(shè)為默認不顯示,通過上面的window_manager.show()展示出來;
// 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);
美化應(yīng)用窗口
通過前面的步驟,我們在android和windows平臺上都得到了一個安全透明的窗口,接下來的修飾Flutter就可以為所欲為了。
- 窗口陰影、圓角
上面介紹過在MaterialApp外套有盒子控件,直接在Container內(nèi)加入陰影和圓角即可,不過Android和桌面平臺還是需要區(qū)分下的;
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,
),
);
}
}
- 自定義導(dǎo)航欄
回歸Scaffold的AppBar配置,再加上導(dǎo)航拖拽窗口事件(僅windows可拖拽)
@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(),
);
}
到這里多平臺的窗口就配置好了,接下來可以愉快的編寫頁面啦。
可能有些小伙伴會說:窗口的效果本就應(yīng)該由原生去寫,為啥要讓Flutter去做這么多的事情?
答案很簡單:
跨平臺! 要跨平臺就勢必需要繞一些,通過這種方式你會發(fā)現(xiàn)任何平臺的應(yīng)用,都可以得到相同效果的窗口,而代碼只需要Flutter寫一次,這才是Flutter存在的真正意義。
二、windows平臺特定交互
在開發(fā)windows的過程中,我發(fā)現(xiàn)跟移動app最大的不同在于:桌面應(yīng)用需要頻繁的去與系統(tǒng)做一些交互。
注冊表操作
應(yīng)用開發(fā)過程中,經(jīng)常需要通過注冊表來做數(shù)據(jù)存儲;在pub上也有一個庫提供這個能力,但是我沒有使用,因為dart已經(jīng)提供了win32相關(guān)的接口,我認為這個基礎(chǔ)的能力沒必要引用多一個庫,所以手擼了一個工具類來操作注冊表。(值得注意的是部分注冊表的操作是需要管理員權(quán)限的,所以應(yīng)用提權(quán)要做好)
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 {
/// 根據(jù)鍵名獲取注冊表的值
static String? getRegeditForKey(String regPath, String key,
{int hKeyValue = HKEY_LOCAL_MACHINE}) {
var res = getRegedit(regPath, hKeyValue: hKeyValue);
return res[key];
}
/// 設(shè)置注冊表值
static setRegeditValue(String regPath, String key, String value,
{int hKeyValue = HKEY_CURRENT_USER}) {
final phKey = calloc<HANDLE>();
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<String>? 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);
}
}
/// 根據(jù)項的路徑獲取所有值
static Map<String, String> getRegedit(String regPath,
{int hKeyValue = HKEY_CURRENT_USER}) {
final hKey = _getRegistryKeyHandle(hKeyValue, regPath);
final Map<String, String> portsList = <String, String>{};
/// 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<HANDLE>();
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<DWORD>()..value = MAX_PATH;
final lpType = calloc<DWORD>();
final lpData = calloc<BYTE>(maxItemLength);
final lpcbData = calloc<DWORD>()..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<DWORD>()..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);
}
}
}
執(zhí)行控制臺指令
windows上,我們可以通過cmd指令做所有事情,dart也提供了這種能力。我們可以通過io庫中的Progress類來運行指令。如:幫助用戶打開網(wǎng)絡(luò)連接。
Process.start('ncpa.cpl', [],runInShell: true);
剛接觸桌面開發(fā)的小伙伴,真的很需要這個知識點。
實現(xiàn)應(yīng)用單例
應(yīng)用單例是windows需要特殊處理,android默認是單例的。而windows如果不作處理,每次點擊都會重新運行一個應(yīng)用進程,這顯然不合理。Flutter可以通過windows_single_instance插件來實現(xiàn)單例。在runApp之前執(zhí)行下這個方法,重復(fù)點擊時會讓用戶獲得焦點置頂,而不是多開一個應(yīng)用。
/// windows設(shè)置單實例啟動
static setSingleInstance(List<String> args) async {
await WindowsSingleInstance.ensureSingleInstance(args, "desktop_open",
onSecondWindow: (args) async {
// 喚起并聚焦
if (await windowManager.isMinimized()) await windowManager.restore();
windowManager.focus();
});
}
三、桌面應(yīng)用的交互習(xí)慣
按鈕點擊態(tài)
按鈕點擊交互的狀態(tài),其實在移動端也存在。但不同的是移動端的按鈕基本上水波紋的效果就能滿足用戶使用,但是桌面應(yīng)用顯示區(qū)域大,而點擊的鼠標卻很小,很多時候點擊已經(jīng)過去但水波紋根本就沒顯示出來。
正常交互是:點擊按鈕馬上響應(yīng)點擊態(tài)的顏色(文本和背景都能編),松開恢復(fù)。
TextButton(
clipBehavior: Clip.antiAliasWithSaveLayer,
style: ButtonStyle(
animationDuration: Duration.zero, // 動畫延時設(shè)置為0
visualDensity: VisualDensity.compact,
overlayColor: MaterialStateProperty.all(Colors.transparent),
padding: MaterialStateProperty.all(EdgeInsets.zero),
textStyle:
MaterialStateProperty.all(Theme.of(context).textTheme.subtitle1),
// 按鈕按下的時候的前景色,會讓文本的顏色按下時變?yōu)榘咨?
foregroundColor: MaterialStateProperty.resolveWith((states) {
return states.contains(MaterialState.pressed)
? Colors.white
: Theme.of(context).toggleableActiveColor;
}),
// 按鈕按下的時候的背景色,會讓背景按下時變?yōu)樗{色
backgroundColor: MaterialStateProperty.resolveWith((states) {
return states.contains(MaterialState.pressed)
? Theme.of(context).toggleableActiveColor
: null;
}),
),
onPressed: null,
child: XXX),
)
獲取應(yīng)用啟動參數(shù)
由于我們的桌面設(shè)備升級自研的整機,因此在開發(fā)過程經(jīng)常遇到其他軟件要喚起Flutter應(yīng)用的需求。那么如何喚起,又如何拿到喚起參數(shù)呢?
1. windows:其他應(yīng)用通過Procress.start啟動.exe即可運行Flutter的軟件;傳參也非常簡單,直接.exe后面帶參數(shù),多個參數(shù)使用空格隔開,然后再Flutter main函數(shù)中的args就能拿到參數(shù)的列表,非常方便。
其實cmd執(zhí)行的參數(shù),是被win32Window接收了,只是Flutter幫我們做了這層轉(zhuǎn)換,通過engine傳遞給main函數(shù),而Android就沒那么方便了。
2. Android:Android原生啟動應(yīng)用是通過Intent對應(yīng)包名下的Activity,然后再Activity中通過Intent.getExtra可以拿到參數(shù)。我們都知道Android平臺下Flutter只有一個Activity,因此做法是先在MainActivity中拿到Intent的參數(shù),然后建立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函數(shù)中通過Method Channel的方式取到MainActivity中存儲的參數(shù),繞多了一層鏈路。
```dart
const platform = MethodChannel('app.open.shared.data');
String? sharedData = await platform.invokeMethod('getSharedText');
if (sharedData == null) return null;
return jsonDecode(sharedData);
```
四、寫在最后
通過上面這么多的實現(xiàn),我們已經(jīng)完全把一個應(yīng)用窗體結(jié)構(gòu)搭建起來了。長篇幅的實戰(zhàn)記錄,希望可以切實的幫助到大家。總體來說,桌面開發(fā)雖然還有很多缺陷,但是能用,性能尚佳,跨平臺降低成本。
原文鏈接:https://juejin.cn/post/7144602729044770847
相關(guān)推薦
- 2022-05-08 Entity?Framework生成DataBase?First模式_實用技巧
- 2023-01-01 C語言之如何求三次方根_C 語言
- 2024-07-15 arthas操作spring被代理目標對象命令速查
- 2022-11-07 python學(xué)習(xí)pymongo模塊的使用方法_python
- 2022-04-09 Linux(CentOS7)后臺運行程序關(guān)掉窗口不被殺掉
- 2022-08-10 pandas溫差查詢案例的實現(xiàn)_python
- 2023-07-27 原型和原型鏈條、ES6、前后端交互Ajax
- 2024-01-11 源碼解析list.contains()方法,比較集合中的是存在某對象
- 最近更新
-
- window11 系統(tǒng)安裝 yarn
- 超詳細win安裝深度學(xué)習(xí)環(huán)境2025年最新版(
- Linux 中運行的top命令 怎么退出?
- MySQL 中decimal 的用法? 存儲小
- get 、set 、toString 方法的使
- @Resource和 @Autowired注解
- Java基礎(chǔ)操作-- 運算符,流程控制 Flo
- 1. Int 和Integer 的區(qū)別,Jav
- spring @retryable不生效的一種
- Spring Security之認證信息的處理
- Spring Security之認證過濾器
- Spring Security概述快速入門
- Spring Security之配置體系
- 【SpringBoot】SpringCache
- Spring Security之基于方法配置權(quán)
- redisson分布式鎖中waittime的設(shè)
- maven:解決release錯誤:Artif
- restTemplate使用總結(jié)
- Spring Security之安全異常處理
- MybatisPlus優(yōu)雅實現(xiàn)加密?
- Spring ioc容器與Bean的生命周期。
- 【探索SpringCloud】服務(wù)發(fā)現(xiàn)-Nac
- Spring Security之基于HttpR
- Redis 底層數(shù)據(jù)結(jié)構(gòu)-簡單動態(tài)字符串(SD
- arthas操作spring被代理目標對象命令
- Spring中的單例模式應(yīng)用詳解
- 聊聊消息隊列,發(fā)送消息的4種方式
- bootspring第三方資源配置管理
- GIT同步修改后的遠程分支