当前位置 : 主页 > 手机开发 > android >

Android开发Flutter 桌面应用窗口化实战示例

来源:互联网 收集:自由互联 发布时间:2023-02-01
目录 前言 一、应用窗口的常规配置 应用窗口化 自定义窗口导航栏 美化应用窗口 二、windows平台特定交互 注册表操作 执行控制台指令 实现应用单例 三、桌面应用的交互习惯 按钮点击
目录
  • 前言
  • 一、应用窗口的常规配置
    • 应用窗口化
    • 自定义窗口导航栏
    • 美化应用窗口
  • 二、windows平台特定交互
    • 注册表操作
    • 执行控制台指令
    • 实现应用单例
  • 三、桌面应用的交互习惯
    • 按钮点击态
    • 获取应用启动参数
  • 四、写在最后

    前言

    通过此篇文章,你可以编写出一个完整桌面应用的窗口框架。

    你将了解到:

    • 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 桌面应用窗口化的资料请关注自由互联其它相关文章!

    上一篇:Flutter异步操作实现流程详解
    下一篇:没有了
    网友评论