目录
- 前言
- 实现原理
- 1. 基本原理
- 2. 具体步骤
- 3. 原理图
- 插件实现
- 实现过程中的坑
- 使用方式
- 写在最后
前言
通过此篇文章,你将了解到:
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 nameIdMap = ConcurrentHashMap<String, String>() //判断是否存在了任务 | |
private val idNameMap = ConcurrentHashMap<String, String>() //根据任务获取name并清除 | |
private val engineCallback = | |
ConcurrentHashMap<String, EngineCallback>() //通知调用方引擎状态-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 && nameIdMap[name] != null) { | |
return nameIdMap[name] | |
} | |
val windowUid = UUID.randomUUID().toString() | |
if (type == TYPE_SINGLE) { | |
nameIdMap[name] = windowUid | |
idNameMap[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? ?: | |
val height = params["height"] as Int? ?: | |
val config = GravityConfig() | |
config.paddingX = params["paddingX"] as Double? ?:.0 | |
config.paddingY = params["paddingY"] as Double? ?:.0 | |
config.gravityX = GravityForX.values()[params["gravityX"] as Int? ?:] | |
config.gravityY = GravityForY.values()[params["gravityY"] as Int? ?:] | |
// 把创建好的引擎传给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 =f | |
private var startY =f | |
private var initialX = | |
private var initialY = | |
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() | |
private var rootView = inflater.inflate(R.layout.floating, null, false) as ViewGroup | |
private val layoutParams = WindowManager.LayoutParams( | |
dippx(context, width.toFloat()), | |
dippx(context, height.toFloat()), | |
WindowManager.LayoutParams.TYPE_SYSTEM_ALERT, // 系统应用才可使用此类型 | |
WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE, | |
PixelFormat.TRANSLUCENT | |
) | |
fun open() { | |
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) / + 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) / + 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) { | |
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 | |
} | |
} | |
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" | |
} | |
override fun onAttachedToEngine(FlutterPlugin.FlutterPluginBinding) 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(FlutterPlugin.FlutterPluginBinding) binding: { | |
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) | |
} | |
} | |
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的多窗口实现!