diff --git a/app/src/main/java/com/petterp/floatingx/app/MainActivity.kt b/app/src/main/java/com/petterp/floatingx/app/MainActivity.kt index 40b47002..84a258b9 100644 --- a/app/src/main/java/com/petterp/floatingx/app/MainActivity.kt +++ b/app/src/main/java/com/petterp/floatingx/app/MainActivity.kt @@ -12,8 +12,10 @@ import androidx.cardview.widget.CardView import com.petterp.floatingx.FloatingX import com.petterp.floatingx.app.kotlin.FxSystemSimple import com.petterp.floatingx.app.simple.FxAnimationImpl +import com.petterp.floatingx.app.test.EdgeCaseTestActivity import com.petterp.floatingx.app.test.MultipleFxActivity import com.petterp.floatingx.app.test.SystemActivity +import com.petterp.floatingx.app.test.TestLifecycleActivity import com.petterp.floatingx.util.createFx class MainActivity : AppCompatActivity() { @@ -110,6 +112,12 @@ class MainActivity : AppCompatActivity() { addItemView("进入system浮窗测试页面") { SystemActivity::class.java.start(this@MainActivity) } + addItemView("测试生命周期时序问题修复") { + TestLifecycleActivity::class.java.start(this@MainActivity) + } + addItemView("测试边界情况") { + EdgeCaseTestActivity::class.java.start(this@MainActivity) + } } } } diff --git a/app/src/main/java/com/petterp/floatingx/app/test/EdgeCaseTestActivity.kt b/app/src/main/java/com/petterp/floatingx/app/test/EdgeCaseTestActivity.kt new file mode 100644 index 00000000..90406df1 --- /dev/null +++ b/app/src/main/java/com/petterp/floatingx/app/test/EdgeCaseTestActivity.kt @@ -0,0 +1,172 @@ +package com.petterp.floatingx.app.test + +import android.os.Bundle +import android.os.Handler +import android.os.Looper +import android.widget.Toast +import androidx.appcompat.app.AppCompatActivity +import com.petterp.floatingx.FloatingX +import com.petterp.floatingx.app.addItemView +import com.petterp.floatingx.app.addLinearLayout +import com.petterp.floatingx.app.addNestedScrollView +import com.petterp.floatingx.app.createLinearLayoutToParent +import com.petterp.floatingx.app.R + +/** + * Comprehensive test for edge cases in the lifecycle timing fix + */ +class EdgeCaseTestActivity : AppCompatActivity() { + + companion object { + const val EDGE_TEST_TAG = "edge_test" + } + + private val handler = Handler(Looper.getMainLooper()) + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + createLinearLayoutToParent { + addNestedScrollView { + addLinearLayout { + addItemView("Test rapid operations") { + testRapidOperations() + } + addItemView("Test duplicate show/hide") { + testDuplicateOperations() + } + addItemView("Test operations during initialization") { + testOperationsDuringInit() + } + addItemView("Test cancel with pending operations") { + testCancelWithPending() + } + addItemView("Test reinstall with operations") { + testReinstallWithOperations() + } + addItemView("Clean up all") { + cleanUp() + } + } + } + } + } + + private fun testRapidOperations() { + // Install and immediately call multiple operations + FloatingX.install { + setContext(applicationContext) + setLayout(R.layout.item_floating) + setTag(EDGE_TEST_TAG) + setEnableLog(true, "edge_test") + } + + // Rapid fire operations - these should all be queued and executed properly + FloatingX.control(EDGE_TEST_TAG).move(100f, 100f) + FloatingX.control(EDGE_TEST_TAG).show() + FloatingX.control(EDGE_TEST_TAG).move(200f, 200f) + FloatingX.control(EDGE_TEST_TAG).hide() + FloatingX.control(EDGE_TEST_TAG).move(300f, 300f) + FloatingX.control(EDGE_TEST_TAG).show() + + Toast.makeText(this, "Rapid operations queued", Toast.LENGTH_SHORT).show() + } + + private fun testDuplicateOperations() { + if (!FloatingX.isInstalled(EDGE_TEST_TAG)) { + FloatingX.install { + setContext(applicationContext) + setLayout(R.layout.item_floating) + setTag(EDGE_TEST_TAG) + } + } + + // Multiple show calls - should not cause issues + FloatingX.control(EDGE_TEST_TAG).show() + FloatingX.control(EDGE_TEST_TAG).show() + FloatingX.control(EDGE_TEST_TAG).show() + + // Multiple hide calls - should not cause issues + handler.postDelayed({ + FloatingX.control(EDGE_TEST_TAG).hide() + FloatingX.control(EDGE_TEST_TAG).hide() + FloatingX.control(EDGE_TEST_TAG).hide() + }, 1000) + + Toast.makeText(this, "Duplicate operations test", Toast.LENGTH_SHORT).show() + } + + private fun testOperationsDuringInit() { + // Reinstall to trigger initialization + FloatingX.install { + setContext(applicationContext) + setLayout(R.layout.item_floating) + setTag(EDGE_TEST_TAG + "_init") + } + + // Call operations in rapid succession during potential initialization + for (i in 1..5) { + handler.postDelayed({ + FloatingX.control(EDGE_TEST_TAG + "_init").move(i * 50f, i * 50f) + if (i % 2 == 0) { + FloatingX.control(EDGE_TEST_TAG + "_init").show() + } else { + FloatingX.control(EDGE_TEST_TAG + "_init").hide() + } + }, i * 50L) + } + + Toast.makeText(this, "Operations during init test", Toast.LENGTH_SHORT).show() + } + + private fun testCancelWithPending() { + // Install and queue operations + FloatingX.install { + setContext(applicationContext) + setLayout(R.layout.item_floating) + setTag(EDGE_TEST_TAG + "_cancel") + } + + // Queue some operations + FloatingX.control(EDGE_TEST_TAG + "_cancel").move(400f, 400f) + FloatingX.control(EDGE_TEST_TAG + "_cancel").show() + + // Cancel immediately - pending operations should be cleared + FloatingX.control(EDGE_TEST_TAG + "_cancel").cancel() + + Toast.makeText(this, "Cancel with pending operations", Toast.LENGTH_SHORT).show() + } + + private fun testReinstallWithOperations() { + val tag = EDGE_TEST_TAG + "_reinstall" + + // Install + FloatingX.install { + setContext(applicationContext) + setLayout(R.layout.item_floating) + setTag(tag) + } + + // Queue operations + FloatingX.control(tag).move(500f, 500f) + FloatingX.control(tag).show() + + // Reinstall (should cancel previous and start fresh) + FloatingX.install { + setContext(applicationContext) + setLayout(R.layout.item_floating) + setTag(tag) + } + + // New operations + FloatingX.control(tag).move(600f, 600f) + FloatingX.control(tag).show() + + Toast.makeText(this, "Reinstall with operations", Toast.LENGTH_SHORT).show() + } + + private fun cleanUp() { + FloatingX.uninstallAll() + Toast.makeText(this, "All floating windows uninstalled", Toast.LENGTH_SHORT).show() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/petterp/floatingx/app/test/TestLifecycleActivity.kt b/app/src/main/java/com/petterp/floatingx/app/test/TestLifecycleActivity.kt new file mode 100644 index 00000000..5e409791 --- /dev/null +++ b/app/src/main/java/com/petterp/floatingx/app/test/TestLifecycleActivity.kt @@ -0,0 +1,148 @@ +package com.petterp.floatingx.app.test + +import android.os.Bundle +import android.widget.Toast +import androidx.appcompat.app.AppCompatActivity +import com.petterp.floatingx.FloatingX +import com.petterp.floatingx.app.addItemView +import com.petterp.floatingx.app.addLinearLayout +import com.petterp.floatingx.app.addNestedScrollView +import com.petterp.floatingx.app.createLinearLayoutToParent +import com.petterp.floatingx.app.R + +/** + * Test activity to reproduce and verify the fix for the lifecycle timing issue + * This simulates the problem described in the issue where FloatingX.control() + * operations in onCreate() don't execute properly + */ +class TestLifecycleActivity : AppCompatActivity() { + + companion object { + const val TEST_TAG = "lifecycle_test" + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + createLinearLayoutToParent { + addNestedScrollView { + addLinearLayout { + addItemView("Install FloatingX") { + installFloatingX() + } + addItemView("Test onCreate() operations (BEFORE fix: ignored)") { + testOnCreateOperations() + } + addItemView("Show Menu 1 (move then show)") { + showMenu1() + } + addItemView("Hide Menu 1 (move then hide)") { + hideMenu1() + } + addItemView("Test immediate operations") { + testImmediateOperations() + } + addItemView("Test updateViewContent() in onCreate()") { + testUpdateViewContentInOnCreate() + } + addItemView("Cancel FloatingX") { + FloatingX.controlOrNull(TEST_TAG)?.cancel() + } + } + } + } + } + + private fun installFloatingX() { + FloatingX.install { + setContext(applicationContext) + setLayout(R.layout.item_floating) + setTag(TEST_TAG) + setEnableLog(true, "lifecycle_test") + } + Toast.makeText(this, "FloatingX installed", Toast.LENGTH_SHORT).show() + } + + private fun testOnCreateOperations() { + // Simulate operations being called immediately in onCreate + // Before fix: these would be ignored if internal view isn't ready + // After fix: these get queued and executed when ready + + if (!FloatingX.isInstalled(TEST_TAG)) { + installFloatingX() + } + + // Line equivalent to 202: move operation + FloatingX.control(TEST_TAG).move(700f, 700f) + + // Line equivalent to 203: hide operation + FloatingX.control(TEST_TAG).hide() + + Toast.makeText(this, "onCreate operations executed", Toast.LENGTH_SHORT).show() + } + + private fun showMenu1() { + if (!FloatingX.isInstalled(TEST_TAG)) { + installFloatingX() + } + + // This should now work properly: move first, then show + // Before fix: move would be ignored on first call + FloatingX.control(TEST_TAG).move(150f, 100f) + FloatingX.control(TEST_TAG).show() + + Toast.makeText(this, "showMenu1 executed (move + show)", Toast.LENGTH_SHORT).show() + } + + private fun hideMenu1() { + if (!FloatingX.isInstalled(TEST_TAG)) { + installFloatingX() + } + + // This should now show the move animation before hiding + // Before fix: would hide immediately without move animation + FloatingX.control(TEST_TAG).move(300f, 300f) + FloatingX.control(TEST_TAG).hide() + + Toast.makeText(this, "hideMenu1 executed (move + hide)", Toast.LENGTH_SHORT).show() + } + + private fun testImmediateOperations() { + // Test calling operations immediately after install + FloatingX.install { + setContext(applicationContext) + setLayout(R.layout.item_floating) + setTag(TEST_TAG + "_immediate") + setEnableLog(true, "immediate_test") + } + + // These should work with queuing + FloatingX.control(TEST_TAG + "_immediate").move(400f, 200f) + FloatingX.control(TEST_TAG + "_immediate").show() + + Toast.makeText(this, "Immediate operations executed", Toast.LENGTH_SHORT).show() + } + + private fun testUpdateViewContentInOnCreate() { + // Test updateViewContent() being called before the floating window is ready + // This simulates the scenario mentioned in the comment + + FloatingX.install { + setContext(applicationContext) + setLayout(R.layout.item_floating) + setTag(TEST_TAG + "_content") + setEnableLog(true, "content_test") + } + + // Before fix: this would be ignored if viewHolder isn't ready + // After fix: this gets queued and executed when ready + FloatingX.control(TEST_TAG + "_content").updateViewContent { holder -> + holder.setText(R.id.tvItemFx, "Updated in onCreate()!") + } + + // Show the floating window to see the result + FloatingX.control(TEST_TAG + "_content").show() + + Toast.makeText(this, "updateViewContent() in onCreate() executed", Toast.LENGTH_SHORT).show() + } +} \ No newline at end of file diff --git a/build.gradle b/build.gradle index c9f56094..d04e6d63 100644 --- a/build.gradle +++ b/build.gradle @@ -5,7 +5,7 @@ buildscript { plugins { alias(libs.plugins.vanniketch.maven.publish) apply false alias(libs.plugins.compose.compiler) apply false - alias(libs.plugins.android.lirary) apply false + alias(libs.plugins.android.library) apply false alias(libs.plugins.android.application) apply false alias(libs.plugins.jetbrains.kotlin.android) apply false } \ No newline at end of file diff --git a/floatingx/src/main/java/com/petterp/floatingx/imp/FxBasisControlImp.kt b/floatingx/src/main/java/com/petterp/floatingx/imp/FxBasisControlImp.kt index 5c3ed6d4..7327e46f 100644 --- a/floatingx/src/main/java/com/petterp/floatingx/imp/FxBasisControlImp.kt +++ b/floatingx/src/main/java/com/petterp/floatingx/imp/FxBasisControlImp.kt @@ -23,6 +23,12 @@ abstract class FxBasisControlImp>( protected lateinit var platformProvider: P private lateinit var _configControl: IFxConfigControl private lateinit var _animationProvider: IFxAnimationProvider + + // Queue for operations that are called before the floating window is ready + private val pendingOperations = mutableListOf() + // Flag to prevent duplicate execution of operations + private var isExecutingPendingOperations = false + private val internalView: IFxInternalHelper? get() = platformProvider.internalView @@ -47,8 +53,15 @@ abstract class FxBasisControlImp>( override fun show() { if (isShow()) return helper.enableFx = true - if (!platformProvider.checkOrInit()) return - // FIXME: 这里有可能会触发多次show + if (!platformProvider.checkOrInit()) { + // Queue the show operation for later execution + queueOperation(FxQueuedOperation.Show()) + return + } + executeShowInternal() + } + + private fun executeShowInternal() { val fxView = getManagerView() ?: return platformProvider.show() helper.fxLog.d("fxView -> showFx") @@ -59,7 +72,19 @@ abstract class FxBasisControlImp>( override fun hide() { // 这里同时增加判断状态,因为有可能view正在等待postAttach - if (!isShow()) return + if (!isShow()) { + // If not showing but we have a valid floating window, queue the hide operation + if (getManagerView() != null) { + executeHideInternal() + } else { + queueOperation(FxQueuedOperation.Hide()) + } + return + } + executeHideInternal() + } + + private fun executeHideInternal() { helper.enableFx = false val fxView = getManagerView() ?: return helper.fxLog.d("fxView -> hideFx") @@ -102,7 +127,13 @@ abstract class FxBasisControlImp>( } override fun updateViewContent(provider: IFxHolderProvider) { - provider.apply(getViewHolder() ?: return) + val viewHolder = getViewHolder() + if (viewHolder != null) { + provider.apply(viewHolder) + } else { + // Queue the updateViewContent operation for later execution + queueOperation(FxQueuedOperation.UpdateViewContent(provider)) + } } override fun setClickListener(time: Long, listener: View.OnClickListener?) { @@ -129,11 +160,23 @@ abstract class FxBasisControlImp>( } override fun move(x: Float, y: Float, useAnimation: Boolean) { - internalView?.moveLocation(x, y, useAnimation) + val internalView = this.internalView + if (internalView != null) { + internalView.moveLocation(x, y, useAnimation) + } else { + // Queue the move operation for later execution + queueOperation(FxQueuedOperation.Move(x, y, useAnimation)) + } } override fun moveByVector(x: Float, y: Float, useAnimation: Boolean) { - internalView?.moveLocationByVector(x, y, useAnimation) + val internalView = this.internalView + if (internalView != null) { + internalView.moveLocationByVector(x, y, useAnimation) + } else { + // Queue the move operation for later execution + queueOperation(FxQueuedOperation.MoveByVector(x, y, useAnimation)) + } } override fun updateConfig(obj: IFxConfigControl.() -> Unit) { @@ -144,6 +187,74 @@ abstract class FxBasisControlImp>( platformProvider.reset() _animationProvider.reset() helper.clear() + // Clear pending operations on reset + synchronized(pendingOperations) { + pendingOperations.clear() + } + isExecutingPendingOperations = false helper.fxLog.d("fxView-lifecycle-> code->cancelFx") } + + /** + * Queue an operation to be executed when the floating window is ready + */ + private fun queueOperation(operation: FxQueuedOperation) { + // Only queue if not currently executing pending operations to avoid infinite loops + if (isExecutingPendingOperations) { + helper.fxLog.d("fxView -> skipping queue during execution: $operation") + return + } + + helper.fxLog.d("fxView -> queueOperation: $operation") + synchronized(pendingOperations) { + pendingOperations.add(operation) + } + } + + /** + * Execute all pending operations and clear the queue + * This should be called when the floating window becomes ready + */ + internal fun executePendingOperations() { + synchronized(pendingOperations) { + if (pendingOperations.isEmpty() || isExecutingPendingOperations) return + + helper.fxLog.d("fxView -> executing ${pendingOperations.size} pending operations") + val operations = pendingOperations.toList() + pendingOperations.clear() + isExecutingPendingOperations = true + + try { + for (operation in operations) { + when (operation) { + is FxQueuedOperation.Show -> { + // Only execute show if not already showing + if (!isShow()) { + executeShowInternal() + } + } + is FxQueuedOperation.Hide -> { + // Only execute hide if currently showing + if (isShow()) { + executeHideInternal() + } + } + is FxQueuedOperation.Move -> { + internalView?.moveLocation(operation.x, operation.y, operation.useAnimation) + } + is FxQueuedOperation.MoveByVector -> { + internalView?.moveLocationByVector(operation.x, operation.y, operation.useAnimation) + } + is FxQueuedOperation.UpdateViewContent -> { + getViewHolder()?.let { viewHolder -> + operation.provider.apply(viewHolder) + } + } + } + } + } finally { + isExecutingPendingOperations = false + } + } + } } diff --git a/floatingx/src/main/java/com/petterp/floatingx/imp/FxQueuedOperation.kt b/floatingx/src/main/java/com/petterp/floatingx/imp/FxQueuedOperation.kt new file mode 100644 index 00000000..a49af7cc --- /dev/null +++ b/floatingx/src/main/java/com/petterp/floatingx/imp/FxQueuedOperation.kt @@ -0,0 +1,15 @@ +package com.petterp.floatingx.imp + +import com.petterp.floatingx.listener.provider.IFxHolderProvider + +/** + * Represents a queued operation that should be executed when the floating window is ready + * @author petterp + */ +internal sealed class FxQueuedOperation { + data class Show(val dummy: Unit = Unit) : FxQueuedOperation() + data class Hide(val dummy: Unit = Unit) : FxQueuedOperation() + data class Move(val x: Float, val y: Float, val useAnimation: Boolean) : FxQueuedOperation() + data class MoveByVector(val x: Float, val y: Float, val useAnimation: Boolean) : FxQueuedOperation() + data class UpdateViewContent(val provider: IFxHolderProvider) : FxQueuedOperation() +} \ No newline at end of file diff --git a/floatingx/src/main/java/com/petterp/floatingx/imp/app/FxAppPlatformProvider.kt b/floatingx/src/main/java/com/petterp/floatingx/imp/app/FxAppPlatformProvider.kt index 4fe2f57c..c707e580 100644 --- a/floatingx/src/main/java/com/petterp/floatingx/imp/app/FxAppPlatformProvider.kt +++ b/floatingx/src/main/java/com/petterp/floatingx/imp/app/FxAppPlatformProvider.kt @@ -58,11 +58,14 @@ class FxAppPlatformProvider( helper.fxLog.d("fx not show,This ${act.javaClass.simpleName} is not in the list of allowed inserts!") return false } - if (_internalView == null) { + val wasNull = _internalView == null + if (wasNull) { _internalView = FxDefaultContainerView(helper, helper.context) _internalView?.initView() checkOrInitSafeArea(act) attach(act) + // Execute pending operations now that the floating window is ready + control.executePendingOperations() } return true } diff --git a/floatingx/src/main/java/com/petterp/floatingx/imp/system/FxSystemPlatformProvider.kt b/floatingx/src/main/java/com/petterp/floatingx/imp/system/FxSystemPlatformProvider.kt index 7025648b..b0e656e7 100644 --- a/floatingx/src/main/java/com/petterp/floatingx/imp/system/FxSystemPlatformProvider.kt +++ b/floatingx/src/main/java/com/petterp/floatingx/imp/system/FxSystemPlatformProvider.kt @@ -46,7 +46,8 @@ class FxSystemPlatformProvider( } override fun checkOrInit(): Boolean { - if (_internalView != null) return true + val wasNull = _internalView == null + if (!wasNull) return true checkOrRegisterActivityLifecycle() // topAct不为null,进行黑名单判断 @@ -61,6 +62,8 @@ class FxSystemPlatformProvider( wm = helper.context.getSystemService(Context.WINDOW_SERVICE) as WindowManager _internalView = FxSystemContainerView(helper, wm!!, context) _internalView!!.initView() + // Execute pending operations now that the floating window is ready + control.executePendingOperations() } else { internalAskAutoPermission(act ?: return false) } @@ -92,8 +95,18 @@ class FxSystemPlatformProvider( val permissionControl = activity.permissionControl ?: return requestRunnable = { helper.fxLog.d("tag:[${helper.tag}] requestPermission end,result:$[$it]---->") - if (it && isAutoShow) { - control.show() + if (it) { + // Permission granted, initialize internal view if not done already + if (_internalView == null) { + wm = helper.context.getSystemService(Context.WINDOW_SERVICE) as WindowManager + _internalView = FxSystemContainerView(helper, wm!!, context) + _internalView!!.initView() + // Execute pending operations now that the floating window is ready + control.executePendingOperations() + } + if (isAutoShow) { + control.show() + } } else if (!it && canUseAppScope) { downgradeToAppScope() } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index eb68421b..90a84afe 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -45,7 +45,7 @@ compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" [plugins] vanniketch-maven-publish = { id = "com.vanniktech.maven.publish", version.ref = "vanniketch-maven-publish" } compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } -android-lirary = { id = "com.android.library", version.ref = "androidGradlePlugin" } +android-library = { id = "com.android.library", version.ref = "androidGradlePlugin" } android-application = { id = "com.android.application", version.ref = "androidGradlePlugin" } jetbrains-kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }