Flutter?Android多窗口方案落地實(shí)戰(zhàn)
前言
通過此篇文章,你將了解到:
Flutter如何在Android上實(shí)現(xiàn)多窗口機(jī)制;
Flutter與Android的事件機(jī)制和沖突解決;
Flutter多窗口存在的隱患和展望。
Flutter在桌面端的多窗口需求,一直是個(gè)歷史巨坑。隨著Flutter的技術(shù)在我們windows、android桌面設(shè)備落地,我們發(fā)現(xiàn)多窗口需求必不可少,突破這個(gè)技術(shù)壁壘已經(jīng)刻不容緩。
實(shí)現(xiàn)原理
1. 基本原理
對(duì)于Android移動(dòng)設(shè)備來說,多窗口的應(yīng)用大多是用于直播/音視頻的懸浮彈窗,讓用戶離開應(yīng)用后還能在小窗口中觀看內(nèi)容。實(shí)現(xiàn)原理是通過WindowManager創(chuàng)建和管理窗口,包括視圖內(nèi)容、拖拽、事件等操作。
我們都清楚Flutter只是一個(gè)可以做業(yè)務(wù)邏輯的UI框架,在Flutter中想要實(shí)現(xiàn)多窗口,也必須依賴Android的窗口管理機(jī)制?;谠腤indow,顯示Flutter繪制的UI,從而實(shí)現(xiàn)跨平臺(tái)的視圖交互和業(yè)務(wù)邏輯。
2. 具體步驟
- Android端基于Window Manager創(chuàng)建Window,管理窗口的生命周期和拖拽邏輯;
- 使用FlutterEngineGroup來管理Flutter Engine,通過引擎吸附Flutter的UI,加入到原生的FlutterView;
- 把FlutterView通過addView的方式加入到Window上。
3. 原理圖

插件實(shí)現(xiàn)
基于上述原理,可以在Android的窗口顯示Flutter的UI。但要真正提供給Flutter層使用,還需要再封裝一個(gè)插件層。
- 通過單例管理多個(gè)窗口 由于是多窗口,可能項(xiàng)目中多個(gè)地方都會(huì)調(diào)用到,因此需要使用單例來統(tǒng)一管理所有窗口的生命周期,保證準(zhǔn)確創(chuàng)建、及時(shí)銷毀。
//引擎生命鉤子回調(diào),讓調(diào)用方感知引擎狀態(tài)
interface EngineCallback {
fun onCreate(id:String)
fun onEngineDestroy(id: String)
}
class EngineManager private constructor(context: Context) {
// 單例對(duì)象
companion object :
SingletonHolder<EngineManager, Context>(::EngineManager)
// 窗口類型;如果是單一類型,那么同名窗口將返回上一次的未銷毀的實(shí)例。
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)
// 每個(gè)窗口對(duì)應(yīng)一個(gè)引擎,基于引擎ID和名稱存儲(chǔ)多窗口的信息,以及查找
private val engineMap = ConcurrentHashMap<String, Entry>() //搜索引擎,用作消息分發(fā)
private val name2IdMap = ConcurrentHashMap<String, String>() //判斷是否存在了任務(wù)
private val id2NameMap = ConcurrentHashMap<String, String>() //根據(jù)任務(wù)獲取name并清除
private val engineCallback =
ConcurrentHashMap<String, EngineCallback>() //通知調(diào)用方引擎狀態(tài) 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)
}
// 把調(diào)用方傳遞的參數(shù)回調(diào)給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]
// 把創(chuàng)建好的引擎?zhèn)鹘oAndroidWindow,由其去創(chuàng)建窗口
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 {
// ......
}
}
通過代碼我們可以看到,每個(gè)窗口都對(duì)應(yīng)一個(gè)engine,通過name和生成的UUID做唯一標(biāo)識(shí),然后把engine傳給AndroidWindow,在那里加入WindowManger,以及Flutter UI的獲取。
- AndroidWindow的實(shí)現(xiàn);通過
context.getSystemService(Service.WINDOW_SERVICE) as WindowManager獲取窗口管理器;同時(shí)創(chuàng)建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, // 系統(tǒng)應(yīng)用才可使用此類型
WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE,
PixelFormat.TRANSLUCENT
)
fun open() {
@Suppress("Deprecation")
windowManager.defaultDisplay.getMetrics(metrics)
layoutParams.gravity = Gravity.START or Gravity.TOP
selectMeasurementMode()
// 設(shè)置位置
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)
}
// .....
- 插件層封裝。插件層就很簡(jiǎn)單了,創(chuàng)建好
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
// 必須單例調(diào)用
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 -> {
}
}
}
}
同時(shí)需要清楚,Engine通過傳入的entryPoint,就可以找到Flutter層中的方法入口點(diǎn),在入口點(diǎn)中runApp即可。
實(shí)現(xiàn)過程中的坑
在實(shí)現(xiàn)過程中我們遇到的值得分享的坑,就是Flutter GestureDetector和Window滑動(dòng)事件的沖突。 由于懸浮窗是需要可滑動(dòng)的,因此在原生層需要監(jiān)聽對(duì)應(yīng)的事件;而Flutter的事件,是Android層分發(fā)給FlutterView的,兩者形成沖突,導(dǎo)致Flutter內(nèi)部滑動(dòng)的時(shí)候,原生層也會(huì)捕獲到,最終造成沖突。
如何解決?從需求上來看,懸浮窗是否需要滑動(dòng),應(yīng)該交給調(diào)用方?jīng)Q定,也就是由Flutter層來決定是否Android是否要對(duì)Flutter的滑動(dòng)事件進(jìn)行監(jiān)聽,即flutterView.setOnTouchListener。這里我們使用一種更輕量級(jí)的操作,F(xiàn)lutterView的監(jiān)聽默認(rèn)加上,然后在事件處理中,我們通過變量來做處理;而Flutter通過MethodChannel改變這個(gè)變量,加快了通信速度,避免了事件來回監(jiān)聽和銷毀。
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層去驅(qū)動(dòng)的:FlutterMultiWindowsPlugin().dragStart();
private fun dragStart() {
dragging = true
}
private fun dragEnd() {
dragging = false
}
使用方式
目前我們內(nèi)部已在4個(gè)應(yīng)用落地了這個(gè)方案。應(yīng)用方式有兩種:一種是Flutter通過插件調(diào)用,也可以直接通過后臺(tái)Service打開。效果尚佳,目的都是為了讓Flutter的UI跨端使用。
另外,F(xiàn)lutter的方法入口點(diǎn)必須聲明@pragma('vm:entry-point')。
寫在最后
目前來看這種方式可以完美支持Flutter在Android上開啟多窗口,且能精準(zhǔn)控制。但由于一個(gè)engine對(duì)應(yīng)一個(gè)窗口,過多engine帶來的內(nèi)存隱患還是不可忽視的。我們希望Flutter官方能盡快的支持engine對(duì)應(yīng)多個(gè)入口點(diǎn),并且共享內(nèi)存,只不過目前來看還是有點(diǎn)天方夜譚~~
這篇文章,需要有一定原生基礎(chǔ)的同學(xué)才能看懂。只講基礎(chǔ)原理,代碼不全,僅供參考! 另外多窗口的需求,不知道大家需求量如何,熱度可以的話我再出個(gè)windows的多窗口實(shí)現(xiàn)!
更多關(guān)于Flutter Android多窗口的資料請(qǐng)關(guān)注腳本之家其它相關(guān)文章!
相關(guān)文章
Android簡(jiǎn)易圖片瀏覽器的實(shí)現(xiàn)
最近做了一個(gè)圖片瀏覽小程序,本文主要介紹了Android簡(jiǎn)易圖片瀏覽器的實(shí)現(xiàn),文中通過示例代碼介紹的非常詳細(xì),對(duì)大家的學(xué)習(xí)或者工作具有一定的參考學(xué)習(xí)價(jià)值,需要的朋友們下面隨著小編來一起學(xué)習(xí)學(xué)習(xí)吧2022-03-03
android自動(dòng)化測(cè)試知識(shí)點(diǎn)總結(jié)
在本文里小編給大家分享了關(guān)于android自動(dòng)化測(cè)試入門的相關(guān)知識(shí)點(diǎn),需要的朋友們跟著參考下吧。2019-06-06
淺析Android手機(jī)衛(wèi)士保存手機(jī)安全號(hào)碼
這篇文章主要介紹了淺析Android手機(jī)衛(wèi)士保存手機(jī)安全號(hào)碼的相關(guān)資料,需要的朋友可以參考下2016-04-04
Android中將Bitmap對(duì)象以PNG格式保存在內(nèi)部存儲(chǔ)中的方法
在Android中進(jìn)行圖像處理的任務(wù)時(shí),有時(shí)我們希望將處理后的結(jié)果以圖像文件的格式保存在內(nèi)部存儲(chǔ)空間中,本文以此為目的,介紹將Bitmap對(duì)象的數(shù)據(jù)以PNG格式保存下來的方法2017-08-08
Android更多條目收縮展開控件ExpandView的示例代碼
本篇文章主要介紹了Android更多條目收縮展開控件ExpandView的示例代碼,小編覺得挺不錯(cuò)的,現(xiàn)在分享給大家,也給大家做個(gè)參考。一起跟隨小編過來看看吧2018-01-01
android設(shè)備不識(shí)別awk命令 缺少busybox怎么辦
這篇文章主要為大家詳細(xì)介紹了android設(shè)備不識(shí)別awk命令,缺少busybox的解決方法,具有一定的參考價(jià)值,感興趣的小伙伴們可以參考一下2017-04-04
Android編程實(shí)現(xiàn)自定義Tab選項(xiàng)卡功能示例
這篇文章主要介紹了Android編程實(shí)現(xiàn)自定義Tab選項(xiàng)卡功能,結(jié)合完整實(shí)例形式分析了Android自定義tab選項(xiàng)卡的遍歷、設(shè)置及屬性操作相關(guān)技巧,需要的朋友可以參考下2017-02-02
Android listview定位到上次顯示的位置的實(shí)現(xiàn)方法
這篇文章主要介紹了Android listview定位到上次顯示的位置的實(shí)現(xiàn)方法的相關(guān)資料,希望通過本文能幫助到大家,需要的朋友可以參考下2017-08-08
Android筆記之:深入為從右向左語言定義復(fù)雜字串的詳解
本篇文章是對(duì)Android中為從右向左語言定義復(fù)雜字串進(jìn)行了詳細(xì)的分析介紹,需要的朋友參考下2013-05-05

