Android开发Flutter 桌面应用窗口化实战示例
作者:Karl_wei
前言
通过此篇文章,你可以编写出一个完整桌面应用的窗口框架。
你将了解到:
- Flutter在开发windows和Android桌面应用初始阶段,应用窗口的常规配置;
- windows平台特定交互的实现,如:执行控制台指令,windows注册表,应用单例等;
- 桌面应用的交互习惯,如:交互点击态,不同大小的页面切换,获取系统唤起应用的参数等。
在使用Flutter开发桌面应用之前,笔者之前都是开发移动App的,对于移动应用的交互比较熟悉。开始桌面应用开发后,我发现除了技术栈一样之外,其他交互细节、用户行为习惯以及操作系统特性等都有很大的不同。
我将在windows和android桌面设备上,从0到1亲自搭建一个开源项目,并且记录实现细节和技术难点。
一、应用窗口的常规配置
众所周知,Flutter目前最大的应用是在移动app上,在移动设备上都是以全屏方式展示,因此没有应用窗口这个概念。而桌面应用是窗口化的,需求方一般都会对窗口外观有很高的要求,比如:自定义窗口导航栏、设置圆角、阴影;同时还有可能要禁止系统自动放大的行为。
应用窗口化
Flutter在windows桌面平台,是依托于Win32Window承载engine的,而Win32Windows本身就是窗口化的,无需再做过多的配置。(不过也正因为依托原生窗口,作为UI框架的flutter完全没办法对Win32Window的外观做任何配置)
// 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(); }
bool FlutterWindow::OnCreate() { if (!Win32Window::OnCreate()) { return false; } // GetClientArea获取创建的win32Window区域 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平台,Flutter应用是依托于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提供了一个全屏的透明窗口,Flutter 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目录下的模板代码创建的默认是带系统导航栏的(如下图)。
很遗憾Flutter官方也没有提供方法,pub库上对窗口操作支持的最好的是window_manager,由国内Flutter桌面开源社区leanFlutter所提供。
- yaml导入window_manager,在runApp之前执行以下代码,把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(); });
- 此时会发现应用打开时在左下角闪一下再居中。这是由于原生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和桌面平台还是需要区分下的;
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去做这么多的事情?
答案很简单:
跨平台! 要跨平台就势必需要绕一些,通过这种方式你会发现任何平台的应用,都可以得到相同效果的窗口,而代码只需要Flutter写一次,这才是Flutter存在的真正意义。
二、windows平台特定交互
在开发windows的过程中,我发现跟移动app最大的不同在于:桌面应用需要频繁的去与系统做一些交互。
注册表操作
应用开发过程中,经常需要通过注册表来做数据存储;在pub上也有一个库提供这个能力,但是我没有使用,因为dart已经提供了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 { /// 根据键名获取注册表的值 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); } } /// 根据项的路径获取所有值 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); } } }
执行控制台指令
windows上,我们可以通过cmd指令做所有事情,dart也提供了这种能力。我们可以通过io库中的Progress类来运行指令。如:帮助用户打开网络连接。
Process.start('ncpa.cpl', [],runInShell: true);
刚接触桌面开发的小伙伴,真的很需要这个知识点。
实现应用单例
应用单例是windows需要特殊处理,android默认是单例的。而windows如果不作处理,每次点击都会重新运行一个应用进程,这显然不合理。Flutter可以通过windows_single_instance插件来实现单例。在runApp之前执行下这个方法,重复点击时会让用户获得焦点置顶,而不是多开一个应用。
/// 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(); }); }
三、桌面应用的交互习惯
按钮点击态
按钮点击交互的状态,其实在移动端也存在。但不同的是移动端的按钮基本上水波纹的效果就能满足用户使用,但是桌面应用显示区域大,而点击的鼠标却很小,很多时候点击已经过去但水波纹根本就没显示出来。
正常交互是:点击按钮马上响应点击态的颜色(文本和背景都能编),松开恢复。
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); ```
四、写在最后
通过上面这么多的实现,我们已经完全把一个应用窗体结构搭建起来了。长篇幅的实战记录,希望可以切实的帮助到大家。总体来说,桌面开发虽然还有很多缺陷,但是能用,性能尚佳,跨平台降低成本。
以上就是Android开发Flutter 桌面应用窗口化实战示例的详细内容,更多关于Android Flutter 桌面应用窗口化的资料请关注脚本之家其它相关文章!