Android開發(fā)Flutter?桌面應用窗口化實戰(zhàn)示例
前言
通過此篇文章,你可以編寫出一個完整桌面應用的窗口框架。
你將了解到:
- Flutter在開發(fā)windows和Android桌面應用初始階段,應用窗口的常規(guī)配置;
- windows平臺特定交互的實現(xiàn),如:執(zhí)行控制臺指令,windows注冊表,應用單例等;
- 桌面應用的交互習慣,如:交互點擊態(tài),不同大小的頁面切換,獲取系統(tǒng)喚起應用的參數(shù)等。
在使用Flutter開發(fā)桌面應用之前,筆者之前都是開發(fā)移動App的,對于移動應用的交互比較熟悉。開始桌面應用開發(fā)后,我發(fā)現(xiàn)除了技術棧一樣之外,其他交互細節(jié)、用戶行為習慣以及操作系統(tǒng)特性等都有很大的不同。
我將在windows和android桌面設備上,從0到1親自搭建一個開源項目,并且記錄實現(xiàn)細節(jié)和技術難點。
一、應用窗口的常規(guī)配置
眾所周知,F(xiàn)lutter目前最大的應用是在移動app上,在移動設備上都是以全屏方式展示,因此沒有應用窗口這個概念。而桌面應用是窗口化的,需求方一般都會對窗口外觀有很高的要求,比如:自定義窗口導航欄、設置圓角、陰影;同時還有可能要禁止系統(tǒ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; }
應用窗口化主要是針對Android平臺,F(xiàn)lutter應用是依托于Activity的,Android平臺上Activity默認是全屏,且出于安全考慮,當一個Activity展示的時候,是不允許用戶穿透點擊的。所以想要讓Flutter應用在Android大屏桌面設備上展示出windows上的效果,需要以下步驟:
- 將底層承載的FlutterActivity的主題樣式設置為Dialog,同時全屏窗口的背景色設置為透明,點擊時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 { // 設置窗口背景透明 return TransparencyMode.transparent } override fun onResume() { super.onResume() setFinishOnTouchOutside(false) // 點擊外部,dialog不消失 // 設置窗口全屏 var lp = window.attributes lp.width = -1 lp.height = -1 window.attributes = lp } }
- 至此Android提供了一個全屏的透明窗口,F(xiàn)lutter runApp的時候,我在MaterialApp外層套了一個盒子控件,這個控件內部主要做邊距、陰影等一系列窗口化行為。
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構造方法 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, ), ),
- 效果圖
自定義窗口導航欄
主要針對Windows平臺,原因上面我們解析過:win32Window是在windows目錄下的模板代碼創(chuàng)建的默認是帶系統(tǒng)導航欄的(如下圖)。
很遺憾Flutter官方也沒有提供方法,pub庫上對窗口操作支持的最好的是window_manager,由國內Flutter桌面開源社區(qū)leanFlutter所提供。
- yaml導入window_manager,在runApp之前執(zhí)行以下代碼,把win32窗口的導航欄去掉,同時配置背景色為透明、居中顯示;
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 // 該屬性隱藏導航欄 ); w.waitUntilReadyToShow(windowOptions, () async { await w.setBackgroundColor(Colors.transparent); await w.show(); await w.focus(); await w.setAsFrameless(); });
- 此時會發(fā)現(xiàn)應用打開時在左下角閃一下再居中。這是由于原生win32窗口默認是左上角顯示,而后在flutter通過插件才居中;
- 處理方式建議在原生代碼中先把窗口設為默認不顯示,通過上面的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);
美化應用窗口
通過前面的步驟,我們在android和windows平臺上都得到了一個安全透明的窗口,接下來的修飾Flutter就可以為所欲為了。
- 窗口陰影、圓角
上面介紹過在MaterialApp外套有盒子控件,直接在Container內加入陰影和圓角即可,不過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, ), ); } }
- 自定義導航欄
回歸Scaffold的AppBar配置,再加上導航拖拽窗口事件(僅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(), ); }
到這里多平臺的窗口就配置好了,接下來可以愉快的編寫頁面啦。
可能有些小伙伴會說:窗口的效果本就應該由原生去寫,為啥要讓Flutter去做這么多的事情?
答案很簡單:
跨平臺! 要跨平臺就勢必需要繞一些,通過這種方式你會發(fā)現(xiàn)任何平臺的應用,都可以得到相同效果的窗口,而代碼只需要Flutter寫一次,這才是Flutter存在的真正意義。
二、windows平臺特定交互
在開發(fā)windows的過程中,我發(fā)現(xiàn)跟移動app最大的不同在于:桌面應用需要頻繁的去與系統(tǒng)做一些交互。
注冊表操作
應用開發(fā)過程中,經(jīng)常需要通過注冊表來做數(shù)據(jù)存儲;在pub上也有一個庫提供這個能力,但是我沒有使用,因為dart已經(jīng)提供了win32相關的接口,我認為這個基礎的能力沒必要引用多一個庫,所以手擼了一個工具類來操作注冊表。(值得注意的是部分注冊表的操作是需要管理員權限的,所以應用提權要做好)
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]; } /// 設置注冊表值 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)絡連接。
Process.start('ncpa.cpl', [],runInShell: true);
剛接觸桌面開發(fā)的小伙伴,真的很需要這個知識點。
實現(xiàn)應用單例
應用單例是windows需要特殊處理,android默認是單例的。而windows如果不作處理,每次點擊都會重新運行一個應用進程,這顯然不合理。Flutter可以通過windows_single_instance插件來實現(xiàn)單例。在runApp之前執(zhí)行下這個方法,重復點擊時會讓用戶獲得焦點置頂,而不是多開一個應用。
/// 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(); }); }
三、桌面應用的交互習慣
按鈕點擊態(tài)
按鈕點擊交互的狀態(tài),其實在移動端也存在。但不同的是移動端的按鈕基本上水波紋的效果就能滿足用戶使用,但是桌面應用顯示區(qū)域大,而點擊的鼠標卻很小,很多時候點擊已經(jīng)過去但水波紋根本就沒顯示出來。
正常交互是:點擊按鈕馬上響應點擊態(tài)的顏色(文本和背景都能編),松開恢復。
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), // 按鈕按下的時候的前景色,會讓文本的顏色按下時變?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), )
獲取應用啟動參數(shù)
由于我們的桌面設備升級自研的整機,因此在開發(fā)過程經(jīng)常遇到其他軟件要喚起Flutter應用的需求。那么如何喚起,又如何拿到喚起參數(shù)呢?
1. windows:其他應用通過Procress.start啟動.exe即可運行Flutter的軟件;傳參也非常簡單,直接.exe后面帶參數(shù),多個參數(shù)使用空格隔開,然后再Flutter main函數(shù)中的args就能拿到參數(shù)的列表,非常方便。
其實cmd執(zhí)行的參數(shù),是被win32Window接收了,只是Flutter幫我們做了這層轉換,通過engine傳遞給main函數(shù),而Android就沒那么方便了。
2. Android:Android原生啟動應用是通過Intent對應包名下的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)完全把一個應用窗體結構搭建起來了。長篇幅的實戰(zhàn)記錄,希望可以切實的幫助到大家??傮w來說,桌面開發(fā)雖然還有很多缺陷,但是能用,性能尚佳,跨平臺降低成本。
以上就是Android開發(fā)Flutter 桌面應用窗口化實戰(zhàn)示例的詳細內容,更多關于Android Flutter 桌面應用窗口化的資料請關注腳本之家其它相關文章!
相關文章
PowerManagerService之喚醒鎖的使用獲取創(chuàng)建示例解析
這篇文章主要為大家介紹了PowerManagerService之喚醒鎖的使用獲取創(chuàng)建示例解析,有需要的朋友可以借鑒參考下,希望能夠有所幫助,祝大家多多進步,早日升職加薪2022-10-10詳解android在mob平臺實現(xiàn)qq登陸和分享
這篇文章主要介紹了詳解android在mob平臺實現(xiàn)qq登陸和分享,對接入第三方平臺SDK感興趣的同學們,可以參考下2021-04-04Android UI設計系列之自定義Dialog實現(xiàn)各種風格的對話框效果(7)
這篇文章主要介紹了Android UI設計系列之自定義Dialog實現(xiàn)各種風格的對話框效果,具有一定的實用性和參考價值,感興趣的小伙伴們可以參考一下2016-06-06Android自定義View Flyme6的Viewpager指示器
這篇文章主要為大家詳細介紹了Android自定義View Flyme6的Viewpager指示器,具有一定的參考價值,感興趣的小伙伴們可以參考一下2017-01-01