如果你想用Flutter技术在桌面端落地,从技术上来讲,你必须解决这三大难题: 1. 应用窗口化,提供窗口操作的能力;
2. 实现多窗口;
3. 对外设的支持。

前言

首先给个结论,Flutter在桌面端落地,完全是可行的;但生态远没有官方所说的那么完善,我甚至认为其达不到stable的标准
目前我们的桌面设备主要有Windows、Android系统,系统不同但UI一致,我们将在这两个平台上解决以上问题,并落地Flutter。

一、窗口化和窗口操作存在的问题

  1. 实现应用窗口化:即应用是窗口化展示的,同时可拖拽、可以点击应用外的地方
    Flutter Windows本身是窗口化的;
    而Android默认是全屏应用,需要让普通应用支持窗口化;若是小工具性质的应用,还需要支持可拖拽、可点击应用外的地方,这些在Flutter上都是需要我们在原生实现的。
  2. 实现应用窗口化后,一般开发过程中,肯定会需要以下对窗口的操作:
  • 应用窗体圆形、阴影效果;
  • 配置应用初始的显示位置;(很多小工具可能不是居中展示)
  • 从窗口变为全屏、从全屏变为窗口;

二、支持多窗口

目前Flutter是明确不支持多窗口的。官方好像对多窗口不太感兴趣,一直没有把优先级提上来,还是停留在p4级别,具体见issue。
但是作为桌面应用,多窗口的需求是非常普遍的,因此这个技术壁垒是必须打破的。

三、窗口化实现方案

1. Windows端

Windows端Flutter默认支持窗口化,交互方式基本符合习惯,因此无需再做开发。

2. Android端

  • Android普通应用实现窗口化,是把整个应用展示成窗口的效果,但是点击外部窗口外的地方其实是不响应。同一时间只能显示一个应用进程,这是安卓的机制,也保证了其安全性。要实现窗口化,需要把应用Theme设置成Dialog的样式;同时设置窗口全屏,但是背景色为透明,设置点击外部Dialog不消失,即可实现应用的窗口化展示。
  1. 设置主题
<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>
<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>
  1. 设置窗口全屏,但是背景色为透明,点击外部Dialog不消失
class MainActivity : FlutterActivity() {
    // 设置窗口背景透明
    override fun getTransparencyMode(): TransparencyMode {
        return TransparencyMode.transparent
    }
    override fun onResume() {
        super.onResume()
        // 点击外部,dialog不消失
        setFinishOnTouchOutside(false)
        // 设置窗口全屏
        var lp = window.attributes
        lp.width = -1
        lp.height = -1
        window.attributes = lp
    }
}
  1. 到这里原生提供给Flutte一个全屏的透明窗体,那么Flutter的视图想长成啥样都可以
  • 若是小工具之类的,需要实现应用可拖拽,可点击应用区域外,这在android的实现相对复杂。我们利用原生的窗口管理,弹出一个悬浮框,然后通过entry-point 找到Flutter层的UI。这其实就是我们实现多窗口的思路,这里就不单纯讲解,跟着后面一起讲了。

窗口化操作

实现窗口化后,需要做很多相关的操作,我们分两个系统讲。

1. Windows端

  • 应用窗体圆形、阴影效果:通过window_manager插件,让应用背景色透明;然后我们在MaterialApp外面套一层Container可以设置圆角和阴影,再在外面加一次Container,加入padding以展示内层容器的阴影;
  • 小工具配置初始位置:通过window_manager插件的setPosition可以设置位置;
  • 从窗口变为全屏、从全屏变为窗口:通过window_manager插件可以实现全屏和退出全屏,在切换的过程中页面会闪烁,解决思路是:把透明度设置为0 → 全屏 → 透明度恢复为1。设置透明度的方法也由window_manager插件提供。

2. Android端

对于普通应用,我们上面实现窗口化后,原生就已经为Flutter提供了一个透明的全屏窗口,因此任何窗体的操作都是Flutter层去实现的,没啥技术难度。

  • 应用窗体圆形、阴影效果:上面我们实现应用窗口后,其实整个应用窗体的背景色就是透明的了,因此我们比Windows少做了背景色透明这一步,然后后面的Container都是通用的,代码达到多平台复用;
  • 小工具配置初始位置:直接通过Stack和Positioned来配置就行了。但这种场景一般使用悬浮弹框做,设置定位见后面多窗口;
  • 从窗口变为全屏、从全屏变为窗口:Android依然很简单,只需要在全屏的时候把整个Flutter窗口的padding去除,恢复的时候加上就可以了。

多窗口的实现

首先明确一个观点,Flutter应用是基于Flutter engine,由原生提供的一个Surface画布,在这个画布上面用Skia 2绘制Flutter Widget。
也就是说本身这个应用就是一个窗口,它绝对没有能力为自己再创建一个窗口。 所以多窗口的实现,需要依赖于原生的窗口管理。下面是Android端的实现原理图,这个原理适用于任何平台。 [图片上传失败…(image-618d7f-1666938691902)]

  • 原生新建一个Flutter engine,通过dart执行器DartExecutor执行方法executeDartEntrypoint,根据传入的字符串找到对应的方法入口点Entrypoint,从而拿到Flutter widget;
  • Flutter在方法上声明@pragma('vm:entry-point') 后,此方法即便在Flutter项目没有被调用到,也能编译进去,因此原生新的engine就能找到这个切入点,拿到方法返回的widget;

这是非常典型的Flutter玩法,诸如混合开发都是如此。带来的影响是存在多引擎(engine),增加一些内存,但是这个不可避免,除非你定制Flutter引擎. 目前pub上支持多窗口的库也都是这个原理,但是库的质量其实不高,大家还是自己写吧。

实现步骤

  1. Plugin与原生通信,由于操作都是异步的,所以务必使用双向通信机制BasicMessageChannel,而且需要两个通道:主应用与子窗口通道
  2. 定义接口协议,一般至少需提供以下能力
// 主应用打开子窗口
void open(String entryPoint, Size size, GravityConfig? gravityConfig,
bool draggable);

// 主应用关闭子窗口
void close();

// 主应用设置大小
void resize(int width, int height);

// 主应用设置位置
void setPosition(int x, int y);

// 子窗口启动app,需要支持后台唤起以及命令行启动
void launchApp();

// 子窗口自行关闭
void closeByWindows();

// 子窗口设置大小
void resizeByWindows(int width, int height);

// 子窗口设置位置
void setPositionByWindows(int x, int y);
  1. 各端实现,下面贴下Android端的关键代码
  • 新建Flutter engine,找到Dart中的方法,此时engine就拿到了Flutter的widget实例;
engine = FlutterEngine(application)
val entry = intent.getStringExtra("entryPoint") ?: "multiWindow"
val entryPoint = DartExecutor.DartEntrypoint(findAppBundlePath(), entry)
engine.dartExecutor.executeDartEntrypoint(entryPoint)
  • 新建窗口管理类,通过FlutterViewe吸附engin,然后渲染到悬浮框的view上
///......
private var windowManager = service.getSystemService(Service.WINDOW_SERVICE) as WindowManager
///......
windowManager.addView(rootView, layoutParams)
///......///......
flutterView = FlutterView(inflater.context, FlutterSurfaceView(inflater.context, true))
flutterView.attachToFlutterEngine(engine)
///......
engine.lifecycleChannel.appIsResumed()
///......
rootView.findViewById<LinearLayout>(R.id.floating_window)
.addView(
    flutterView,
    ViewGroup.LayoutParams(
    ViewGroup.LayoutParams.MATCH_PARENT,
    ViewGroup.LayoutParams.MATCH_PARENT
    )
)
  1. 实现悬浮框后,Android平台上的桌面小工具,也就顺利成章的实现了,只是在小工具这个项目的MainAcitivy上,就不需要去加载FlutterActivity了,直接启动悬浮框即可。

外设支持

usb设备在Flutter上,支持也是非常若的。具体可见我上一篇文章:Flutter桌面端实践之识别外接媒体设备

写在最后

以上是我在桌面端预研Flutter的一些经验和思路分享,如果你想在桌面端落地Flutter,我想这边文章对你是很有帮助的。
以上问题,我们遇到了,也解决了。但转念一想这么多基础的操作Flutter都不支持,这真的可以称得上Stable版本了吗? Flutter桌面端的生态,急需我们共同建设,文中多次提起的window_manager插件就是国内出色的组织:LeanFlutter 提供的,期待Flutter桌面端越来越好!

作者:Karl_wei