我的编程空间,编程开发者的网络收藏夹
学习永远不晚

Flutter Android多窗口方案落地实战

短信预约 -IT技能 免费直播动态提醒
省份

北京

  • 北京
  • 上海
  • 天津
  • 重庆
  • 河北
  • 山东
  • 辽宁
  • 黑龙江
  • 吉林
  • 甘肃
  • 青海
  • 河南
  • 江苏
  • 湖北
  • 湖南
  • 江西
  • 浙江
  • 广东
  • 云南
  • 福建
  • 海南
  • 山西
  • 四川
  • 陕西
  • 贵州
  • 安徽
  • 广西
  • 内蒙
  • 西藏
  • 新疆
  • 宁夏
  • 兵团
手机号立即预约

请填写图片验证码后获取短信验证码

看不清楚,换张图片

免费获取短信验证码

Flutter Android多窗口方案落地实战

前言

通过此篇文章,你将了解到:

Flutter如何在Android上实现多窗口机制;

Flutter与Android的事件机制和冲突解决;

Flutter多窗口存在的隐患和展望。

Flutter在桌面端的多窗口需求,一直是个历史巨坑。随着Flutter的技术在我们windows、android桌面设备落地,我们发现多窗口需求必不可少,突破这个技术壁垒已经刻不容缓。

实现原理

1. 基本原理

对于Android移动设备来说,多窗口的应用大多是用于直播/音视频的悬浮弹窗,让用户离开应用后还能在小窗口中观看内容。实现原理是通过WindowManager创建和管理窗口,包括视图内容、拖拽、事件等操作。
我们都清楚Flutter只是一个可以做业务逻辑的UI框架,在Flutter中想要实现多窗口,也必须依赖Android的窗口管理机制。基于原生的Window,显示Flutter绘制的UI,从而实现跨平台的视图交互和业务逻辑。

2. 具体步骤

  • Android端基于Window Manager创建Window,管理窗口的生命周期和拖拽逻辑;
  • 使用FlutterEngineGroup来管理Flutter Engine,通过引擎吸附Flutter的UI,加入到原生的FlutterView;
  • 把FlutterView通过addView的方式加入到Window上。

3. 原理图

插件实现

基于上述原理,可以在Android的窗口显示Flutter的UI。但要真正提供给Flutter层使用,还需要再封装一个插件层。

  • 通过单例管理多个窗口 由于是多窗口,可能项目中多个地方都会调用到,因此需要使用单例来统一管理所有窗口的生命周期,保证准确创建、及时销毁。
//引擎生命钩子回调,让调用方感知引擎状态
interface EngineCallback {
    fun onCreate(id:String)
    fun onEngineDestroy(id: String)
}
class EngineManager private constructor(context: Context) {
    // 单例对象
    companion object :
        SingletonHolder<EngineManager, Context>(::EngineManager)
    // 窗口类型;如果是单一类型,那么同名窗口将返回上一次的未销毁的实例。
    private val TYPE_SINGLE: String = "single"
    init {
        Log.d("EngineManager", "EngineManager init")
    }
    data class Entry(
        val engine: FlutterEngine,
        val window: AndroidWindow?
    )
    private var myContext: Context = context
    private var engineGroup: FlutterEngineGroup = FlutterEngineGroup(myContext)
    // 每个窗口对应一个引擎,基于引擎ID和名称存储多窗口的信息,以及查找
    private val engineMap = ConcurrentHashMap<String, Entry>() //搜索引擎,用作消息分发
    private val name2IdMap = ConcurrentHashMap<String, String>() //判断是否存在了任务
    private val id2NameMap = ConcurrentHashMap<String, String>() //根据任务获取name并清除
    private val engineCallback =
        ConcurrentHashMap<String, EngineCallback>() //通知调用方引擎状态 0-create 1-attach 2-destroy
    fun showWindow(
        params: HashMap<String, Any>,
        engineStatusCallback: EngineCallback
    ): String? {
        val entry: String?
        if (params.containsKey("entryPoint")) {
            entry = params["entryPoint"] as String
        } else {
            return null
        }
        val name: String?
        if (params.containsKey("name")) {
            name = params["name"] as String
        } else {
            return null
        }
        val type = params["type"]
        if (type == TYPE_SINGLE && name2IdMap[name] != null) {
            return name2IdMap[name]
        }
        val windowUid = UUID.randomUUID().toString()
        if (type == TYPE_SINGLE) {
            name2IdMap[name] = windowUid
            id2NameMap[windowUid] = name
            engineCallback[windowUid] = engineStatusCallback
        }
        val dartEntrypoint = DartExecutor.DartEntrypoint(findAppBundlePath(), entry)
        val args = mutableListOf(windowUid)
        var user: List<String>? = null
        if (params.containsKey("params")) {
            user = params["params"] as List<String>
        }
        if (user != null) {
            args.addAll(user)
        }
        // 把调用方传递的参数回调给Flutter
        val option =
            FlutterEngineGroup.Options(myContext).setDartEntrypoint(dartEntrypoint)
                .setDartEntrypointArgs(
                    args
                )
        val engine = engineGroup.createAndRunEngine(option)
        val draggable = params["draggable"] as Boolean? ?: true
        val width = params["width"] as Int? ?: 0
        val height = params["height"] as Int? ?: 0
        val config = GravityConfig()
        config.paddingX = params["paddingX"] as Double? ?: 0.0
        config.paddingY = params["paddingY"] as Double? ?: 0.0
        config.gravityX = GravityForX.values()[params["gravityX"] as Int? ?: 1]
        config.gravityY = GravityForY.values()[params["gravityY"] as Int? ?: 1]
        // 把创建好的引擎传给AndroidWindow,由其去创建窗口
        val androidWindow =
            AndroidWindow(myContext, draggable, width, height, config, engine)
        engineMap[windowUid] = Entry(engine, androidWindow)
        androidWindow.open()
        engine.platformViewsController.attach(
            myContext,
            engine.renderer,
            engine.dartExecutor
        )
        return windowUid
    }
    fun setPosition(id: String?, x: Int, y: Int): Boolean {
        id ?: return false
        val entry = engineMap[id]
        entry ?: return false
        entry.window?.setPosition(x, y)
        return true
    }
    fun setSize(id: String?, width: double, height: double): Boolean {
        // ......
    }
}

通过代码我们可以看到,每个窗口都对应一个engine,通过name和生成的UUID做唯一标识,然后把engine传给AndroidWindow,在那里加入WindowManger,以及Flutter UI的获取。

  • AndroidWindow的实现;通过context.getSystemService(Service.WINDOW_SERVICE) as WindowManager获取窗口管理器;同时创建FlutterView和LayoutInfalter,通过engine拿到视图吸附到FlutterView,把FlutterView加到Layout中,最后把Layout通过addView加到WindowManager中显示。
class AndroidWindow(
    private val context: Context,
    private val draggable: Boolean,
    private val width: Int,
    private val height: Int,
    private val config: GravityConfig,
    private val engine: FlutterEngine
) {
    private var startX = 0f
    private var startY = 0f
    private var initialX = 0
    private var initialY = 0
    private var dragging = false
    private lateinit var flutterView: FlutterView
    private var windowManager = context.getSystemService(Service.WINDOW_SERVICE) as WindowManager
    private val inflater =
        context.getSystemService(Service.LAYOUT_INFLATER_SERVICE) as LayoutInflater
    private val metrics = DisplayMetrics()
    @SuppressLint("InflateParams")
    private var rootView = inflater.inflate(R.layout.floating, null, false) as ViewGroup
    private val layoutParams = WindowManager.LayoutParams(
        dip2px(context, width.toFloat()),
        dip2px(context, height.toFloat()),
        WindowManager.LayoutParams.TYPE_SYSTEM_ALERT, // 系统应用才可使用此类型
        WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE,
        PixelFormat.TRANSLUCENT
    )
    fun open() {
        @Suppress("Deprecation")
        windowManager.defaultDisplay.getMetrics(metrics)
        layoutParams.gravity = Gravity.START or Gravity.TOP
        selectMeasurementMode()
        // 设置位置
        val screenWidth = metrics.widthPixels
        val screenHeight = metrics.heightPixels
        when (config.gravityX) {
            GravityForX.Left -> layoutParams.x = config.paddingX!!.toInt()
            GravityForX.Center -> layoutParams.x =
                ((screenWidth - layoutParams.width) / 2 + config.paddingX!!).toInt()
            GravityForX.Right -> layoutParams.x =
                (screenWidth - layoutParams.width - config.paddingX!!).toInt()
            null -> {}
        }
        when (config.gravityY) {
            GravityForY.Top -> layoutParams.y = config.paddingY!!.toInt()
            GravityForY.Center -> layoutParams.y =
                ((screenHeight - layoutParams.height) / 2 + config.paddingY!!).toInt()
            GravityForY.Bottom -> layoutParams.y =
                (screenHeight - layoutParams.height - config.paddingY!!).toInt()
            null -> {}
        }
        windowManager.addView(rootView, layoutParams)
        flutterView = FlutterView(inflater.context, FlutterSurfaceView(inflater.context, true))
        flutterView.attachToFlutterEngine(engine)
        if (draggable) {
            @Suppress("ClickableViewAccessibility")
            flutterView.setOnTouchListener { _, event ->
                when (event.action) {
                    MotionEvent.ACTION_MOVE -> {
                        if (dragging) {
                            setPosition(
                                initialX + (event.rawX - startX).roundToInt(),
                                initialY + (event.rawY - startY).roundToInt()
                            )
                        }
                    }
                    MotionEvent.ACTION_UP -> {
                        dragEnd()
                    }
                    MotionEvent.ACTION_DOWN -> {
                        startX = event.rawX
                        startY = event.rawY
                        initialX = layoutParams.x
                        initialY = layoutParams.y
                        dragStart()
                        windowManager.updateViewLayout(rootView, layoutParams)
                    }
                }
                false
            }
        }
        @Suppress("ClickableViewAccessibility")
        rootView.setOnTouchListener { _, event ->
            when (event.action) {
                MotionEvent.ACTION_DOWN -> {
                    layoutParams.flags =
                        layoutParams.flags or WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE
                    windowManager.updateViewLayout(rootView, layoutParams)
                    true
                }
                else -> false
            }
        }
        engine.lifecycleChannel.appIsResumed()
        rootView.findViewById<FrameLayout>(R.id.floating_window)
            .addView(
                flutterView,
                ViewGroup.LayoutParams(
                    ViewGroup.LayoutParams.MATCH_PARENT,
                    ViewGroup.LayoutParams.MATCH_PARENT
                )
            )
        windowManager.updateViewLayout(rootView, layoutParams)
    }
    // .....
  • 插件层封装。插件层就很简单了,创建好MethodCallHandler之后,直接持有单例的EngineManager就可以了。
class FlutterMultiWindowsPlugin : FlutterPlugin, MethodCallHandler {
    companion object {
        private const val TAG = "MultiWindowsPlugin"
    }
    @SuppressLint("LongLogTag")
    override fun onAttachedToEngine(@NonNull flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) {
        Log.i(TAG, "onMessage: onAttachedToEngine")
        Log.i(TAG, "onAttachedToEngine: ${Thread.currentThread().name}")
        MessageHandle.init(flutterPluginBinding.applicationContext)
        MethodChannel(
            flutterPluginBinding.binaryMessenger,
            "flutter_multi_windows.messageChannel",
        ).setMethodCallHandler(this)
    }
    override fun onDetachedFromEngine(@NonNull binding: FlutterPlugin.FlutterPluginBinding) {
        Log.i(TAG, "onDetachedFromEngine: ${Thread.currentThread().name}")
    }
    override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
        Log.i(TAG, "onMethodCall: thread : ${Thread.currentThread().name}")
        MessageHandle.onMessage(call, result)
    }
}
@SuppressLint("StaticFieldLeak")
internal object MessageHandle {
    private const val TAG = "MessageHandle"
    private var context: Context? = null
    private var manager: EngineManager? = null
    fun init(context: Context) {
        this.context = context
        if (manager != null)
            return
        // 必须单例调用
        manager = EngineManager.getInstance(this.context!!)
    }
    // 处理消息,所有管道通用。需要共享Flutter Activity
    fun onMessage(
        call: MethodCall, result: MethodChannel.Result
    ) {
        val params = call.arguments as Map<*, *>
        when (call.method) {
            "open" -> {
                Log.i(TAG, "onMessage: open")
                val map: HashMap<String, Any> = HashMap()
                map["needShowWindow"] = true
                map["name"] = params["name"] as String
                map["entryPoint"] = params["entryPoint"] as String
                map["width"] = (params["width"] as Double).toInt()
                map["height"] = (params["height"] as Double).toInt()
                map["gravityX"] = params["gravityX"] as Int
                map["gravityY"] = params["gravityY"] as Int
                map["paddingX"] = params["paddingX"] as Double
                map["paddingY"] = params["paddingY"] as Double
                map["draggable"] = params["draggable"] as Boolean
                map["type"] = params["type"] as String
                if (params["params"] != null) {
                    map["params"] = params["params"] as ArrayList<String>
                }
                result.success(manager?.showWindow(map, object : EngineCallback {
                    override fun onEngineDestroy(id: String) {
                    }
                }))
            }
            "close" -> {
                val windowId = params["windowId"] as String
                manager?.dismissWindow(windowId)
            }
            "executeTask" -> {
                Log.i(TAG, "onMessage: executeTask")
                val map: HashMap<String, Any> = HashMap()
                map["name"] = params["name"] as String
                map["entryPoint"] = params["entryPoint"] as String
                map["type"] = params["type"] as String
                result.success(manager?.executeTask(map))
            }
            "finishTask" -> {
                manager?.finishTask(params["taskId"] as String)
            }
            "setPosition" -> {
                val res = manager?.setPosition(
                    params["windowId"] as String,
                    params["x"] as Int,
                    params["y"] as Int
                )
                result.success(res)
            }
            "setAlpha" -> {
                val res = manager?.setAlpha(
                    params["windowId"] as String,
                    (params["alpha"] as Double).toFloat(),
                )
                result.success(res)
            }
            "resize" -> {
                val res = manager?.resetWindowSize(
                    params["windowId"] as String,
                    params["width"] as Int,
                    params["height"] as Int
                )
                result.success(res)
            }
            else -> {
            }
        }
    }
}

同时需要清楚,Engine通过传入的entryPoint,就可以找到Flutter层中的方法入口点,在入口点中runApp即可。

实现过程中的坑

在实现过程中我们遇到的值得分享的坑,就是Flutter GestureDetector和Window滑动事件的冲突。 由于悬浮窗是需要可滑动的,因此在原生层需要监听对应的事件;而Flutter的事件,是Android层分发给FlutterView的,两者形成冲突,导致Flutter内部滑动的时候,原生层也会捕获到,最终造成冲突。

如何解决?从需求上来看,悬浮窗是否需要滑动,应该交给调用方决定,也就是由Flutter层来决定是否Android是否要对Flutter的滑动事件进行监听,即flutterView.setOnTouchListener。这里我们使用一种更轻量级的操作,FlutterView的监听默认加上,然后在事件处理中,我们通过变量来做处理;而Flutter通过MethodChannel改变这个变量,加快了通信速度,避免了事件来回监听和销毁。

flutterView.setOnTouchListener { _, event ->
    when (event.action) {
        MotionEvent.ACTION_MOVE -> {
            if (dragging) {
                setPosition(
                    initialX + (event.rawX - startX).roundToInt(),
                    initialY + (event.rawY - startY).roundToInt()
                )
            }
        }
        MotionEvent.ACTION_UP -> {
            dragEnd()
        }
        MotionEvent.ACTION_DOWN -> {
            startX = event.rawX
            startY = event.rawY
            initialX = layoutParams.x
            initialY = layoutParams.y
            dragStart()
            windowManager.updateViewLayout(rootView, layoutParams)
        }
    }
    false
}

dragging则是通过Flutter层去驱动的:FlutterMultiWindowsPlugin().dragStart();

private fun dragStart() {
    dragging = true
}
private fun dragEnd() {
    dragging = false
}

使用方式

目前我们内部已在4个应用落地了这个方案。应用方式有两种:一种是Flutter通过插件调用,也可以直接通过后台Service打开。效果尚佳,目的都是为了让Flutter的UI跨端使用。

另外,Flutter的方法入口点必须声明@pragma('vm:entry-point')

写在最后

目前来看这种方式可以完美支持Flutter在Android上开启多窗口,且能精准控制。但由于一个engine对应一个窗口,过多engine带来的内存隐患还是不可忽视的。我们希望Flutter官方能尽快的支持engine对应多个入口点,并且共享内存,只不过目前来看还是有点天方夜谭~~

这篇文章,需要有一定原生基础的同学才能看懂。只讲基础原理,代码不全,仅供参考! 另外多窗口的需求,不知道大家需求量如何,热度可以的话我再出个windows的多窗口实现!

更多关于Flutter Android多窗口的资料请关注编程网其它相关文章!

免责声明:

① 本站未注明“稿件来源”的信息均来自网络整理。其文字、图片和音视频稿件的所属权归原作者所有。本站收集整理出于非商业性的教育和科研之目的,并不意味着本站赞同其观点或证实其内容的真实性。仅作为临时的测试数据,供内部测试之用。本站并未授权任何人以任何方式主动获取本站任何信息。

② 本站未注明“稿件来源”的临时测试数据将在测试完成后最终做删除处理。有问题或投稿请发送至: 邮箱/279061341@qq.com QQ/279061341

Flutter Android多窗口方案落地实战

下载Word文档到电脑,方便收藏和打印~

下载Word文档

猜你喜欢

Flutter Android多窗口方案落地实战

这篇文章主要为大家介绍了Flutter Android多窗口方案落地实战示例详解,有需要的朋友可以借鉴参考下,希望能够有所帮助,祝大家多多进步,早日升职加薪
2023-02-12

android 浏览器之多窗口方案详解

我们Android平台是一个又一个的Activity组成的,每一个Activity有一个或者多个View构成。所以说,当我们想显示一个界面的时候,我们首先想到的是建立一个Activity,然后所有的操作在Activity里面实现,或者是一个
2022-06-06

编程热搜

  • Android:VolumeShaper
    VolumeShaper(支持版本改一下,minsdkversion:26,android8.0(api26)进一步学习对声音的编辑,可以让音频的声音有变化的播放 VolumeShaper.Configuration的三个参数 durati
    Android:VolumeShaper
  • Android崩溃异常捕获方法
    开发中最让人头疼的是应用突然爆炸,然后跳回到桌面。而且我们常常不知道这种状况会何时出现,在应用调试阶段还好,还可以通过调试工具的日志查看错误出现在哪里。但平时使用的时候给你闹崩溃,那你就欲哭无泪了。 那么今天主要讲一下如何去捕捉系统出现的U
    Android崩溃异常捕获方法
  • android开发教程之获取power_profile.xml文件的方法(android运行时能耗值)
    系统的设置–>电池–>使用情况中,统计的能耗的使用情况也是以power_profile.xml的value作为基础参数的1、我的手机中power_profile.xml的内容: HTC t328w代码如下:
    android开发教程之获取power_profile.xml文件的方法(android运行时能耗值)
  • Android SQLite数据库基本操作方法
    程序的最主要的功能在于对数据进行操作,通过对数据进行操作来实现某个功能。而数据库就是很重要的一个方面的,Android中内置了小巧轻便,功能却很强的一个数据库–SQLite数据库。那么就来看一下在Android程序中怎么去操作SQLite数
    Android SQLite数据库基本操作方法
  • ubuntu21.04怎么创建桌面快捷图标?ubuntu软件放到桌面的技巧
    工作的时候为了方便直接打开编辑文件,一些常用的软件或者文件我们会放在桌面,但是在ubuntu20.04下直接直接拖拽文件到桌面根本没有效果,在进入桌面后发现软件列表中的软件只能收藏到面板,无法复制到桌面使用,不知道为什么会这样,似乎并不是很
    ubuntu21.04怎么创建桌面快捷图标?ubuntu软件放到桌面的技巧
  • android获取当前手机号示例程序
    代码如下: public String getLocalNumber() { TelephonyManager tManager =
    android获取当前手机号示例程序
  • Android音视频开发(三)TextureView
    简介 TextureView与SurfaceView类似,可用于显示视频或OpenGL场景。 与SurfaceView的区别 SurfaceView不能使用变换和缩放等操作,不能叠加(Overlay)两个SurfaceView。 Textu
    Android音视频开发(三)TextureView
  • android获取屏幕高度和宽度的实现方法
    本文实例讲述了android获取屏幕高度和宽度的实现方法。分享给大家供大家参考。具体分析如下: 我们需要获取Android手机或Pad的屏幕的物理尺寸,以便于界面的设计或是其他功能的实现。下面就介绍讲一讲如何获取屏幕的物理尺寸 下面的代码即
    android获取屏幕高度和宽度的实现方法
  • Android自定义popupwindow实例代码
    先来看看效果图:一、布局
  • Android第一次实验
    一、实验原理 1.1实验目标 编程实现用户名与密码的存储与调用。 1.2实验要求 设计用户登录界面、登录成功界面、用户注册界面,用户注册时,将其用户名、密码保存到SharedPreference中,登录时输入用户名、密码,读取SharedP
    Android第一次实验

目录