Last active
June 30, 2020 11:29
-
-
Save menjoo/f5692151dc2766fc3f71e1af145db4e8 to your computer and use it in GitHub Desktop.
Tie object lifetime to a set of activities
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
* Ties the lifetime of an instance to the lifetime of a set of activities. | |
* If all activities in the scope are destroyed, T will be stopped and recycled. | |
* | |
* This solves the problem that it is very hard to stop and recycle objects when no longer needed | |
* that need to survive activity navigation. | |
* | |
* An example is when you have a MainActivity, and a feature with Activities A, B, C and D. | |
* If you must have an session object hold by a Module that is instantiated in A and needed in B, C and D. | |
* Then it needs to be recycled whenever the user leaves the feature. | |
* If this is not done correctly the object is leaked and may cause unwanted behaviour. | |
* Recycling it at the right time is harder than it sounds. | |
* There are many exit paths out of a feature and it is very hard to find all and cover them with | |
* code that recycles the object. | |
* | |
* This activity scoped module solves just that problem. | |
*/ | |
abstract class ActivityScopedModule<T : Closeable> { | |
private var instance: T? = null | |
private var scopeTracker: ScopeTracker? = null | |
/** | |
* Should be called by the concrete Module to start tracking the scope and managing the instance. | |
* @param app Application needed to track the scoped activities | |
* @param newInstance The expensive instance that must be recycled if no longer needed.. | |
* @param scope The activities that make the scope which defines the lifetime of T. | |
*/ | |
protected fun limitInstanceLifetimeToScope( | |
app: Application, | |
newInstance: T, | |
scope: Set<KClass<out Activity>> | |
) { | |
synchronized(this) { | |
if (this.instance != null) { | |
throw IllegalStateException("Instance of T should be recycled before creating a new one!") | |
} | |
scopeTracker = ScopeTracker(scope, onOutOfScope = { recycle() }) | |
// Track the activities in the supplied scope | |
app.registerActivityLifecycleCallbacks(scopeTracker) | |
// Need to increment once because the first activity of the scope is already created. | |
scopeTracker!!.activeActivitiesInScope++ | |
this.instance = newInstance | |
} | |
} | |
/** | |
* Gets the instance or throws IllegalStateException if there is none. | |
*/ | |
fun getInstance(): T = synchronized(this) { | |
if (instance == null) { | |
throw IllegalStateException("Instance of T should be created first!") | |
} | |
return@synchronized instance!! | |
} | |
/** | |
* Whether there is an instance | |
*/ | |
fun hasInstance(): Boolean = synchronized(this) { | |
return@synchronized instance != null | |
} | |
/** | |
* Returns the instance or null if there is none | |
*/ | |
fun getInstanceOrNull(): T? = synchronized(this) { | |
return@synchronized instance | |
} | |
/** | |
* Called by the ScopeTracker when instance went out of scope. | |
*/ | |
protected fun recycle() = synchronized(this) { | |
instance?.close() | |
instance = null | |
scopeTracker = null | |
} | |
private class ScopeTracker( | |
private val activitiesInScope: Set<KClass<out Activity>>, | |
private val onOutOfScope: () -> Unit | |
) : Application.ActivityLifecycleCallbacks { | |
var activeActivitiesInScope = 0 | |
private var configChangingActivity: KClass<out Activity>? = null | |
override fun onActivityCreated(activity: Activity, bundle: Bundle?) { | |
if (!activitiesInScope.contains(activity::class)) return | |
// When activity is changing config, don't make it have effect on the counter. | |
// This prevents a bug #4793 where it went out of scope. | |
// Happened when for a split second, the counter becomes zero if there is only one activity in scope at that moment. | |
if (configChangingActivity != activity::class) { | |
activeActivitiesInScope++ | |
} | |
else { | |
configChangingActivity = null | |
} | |
} | |
override fun onActivityDestroyed(activity: Activity) { | |
if (!activitiesInScope.contains(activity::class)) return | |
// When activity is changing config, don't make it have effect on the counter. | |
// This prevents a bug #4793 where it went out of scope. | |
// Happened when for a split second, the counter becomes zero if there is only one activity in scope at that moment. | |
if (activity.isChangingConfigurations) { | |
configChangingActivity = activity::class | |
return | |
} | |
activeActivitiesInScope-- | |
if (activeActivitiesInScope == 0) { | |
// Tracking can be stopped | |
activity.application.unregisterActivityLifecycleCallbacks(this) | |
// Scope is no longer active, instance can be recycled | |
onOutOfScope() | |
} | |
} | |
override fun onActivityPaused(activity: Activity) {} | |
override fun onActivityStarted(activity: Activity) {} | |
override fun onActivitySaveInstanceState(activity: Activity, bundle: Bundle?) {} | |
override fun onActivityStopped(activity: Activity) {} | |
override fun onActivityResumed(activity: Activity) {} | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
class ActivityScopedModuleTest { | |
@get:Rule | |
val mockitoRule: MockitoRule = MockitoJUnit.rule() | |
@Mock | |
private lateinit var appMock: Application | |
@Mock | |
private lateinit var activity1Mock: Activity1 | |
@Mock | |
private lateinit var activity2Mock: Activity2 | |
@Captor | |
private lateinit var activityLifeCycleCallbacksCaptor: ArgumentCaptor<Application.ActivityLifecycleCallbacks> | |
@Before | |
fun setup() { | |
whenever(activity1Mock.application).thenReturn(appMock) | |
whenever(activity2Mock.application).thenReturn(appMock) | |
} | |
@Test | |
fun `When scoped activities are all destroyed, then instance is stopped and recycled`() { | |
val session = SessionModule.createInstance(activity1Mock, scope = setOf( | |
Activity1::class, | |
Activity2::class | |
)) | |
// Setup captor | |
verify(appMock).registerActivityLifecycleCallbacks(activityLifeCycleCallbacksCaptor.capture()) | |
// Activity 1 is already created, so activeActivitiesInScope will be 1, so session will not yet be recycled. | |
assertThat(session.isClosed).isFalse() | |
activityLifeCycleCallbacksCaptor.value.onActivityCreated(activity2Mock, null) | |
// Activity 2 is now also created, so activeActivitiesInScope will be 2, so session will not yet be recycled. | |
assertThat(session.isClosed).isFalse() | |
activityLifeCycleCallbacksCaptor.value.onActivityDestroyed(activity1Mock) | |
// Activity 1 is now destroyed, so activeActivitiesInScope will be 1, so session will not yet be recycled. | |
assertThat(session.isClosed).isFalse() | |
activityLifeCycleCallbacksCaptor.value.onActivityDestroyed(activity2Mock) | |
// Activity 2 is now destroyed, so activeActivitiesInScope will be 0, so session will be recycled. | |
assertThat(session.isClosed).isTrue() | |
assertThat(SessionModule.hasInstance()).isFalse() | |
} | |
/** | |
* When activity is changing config, it should not have effect on the counter. | |
* We had bug #4793 where it went out of scope when for a split second, the counter becomes zero if there is only one activity in scope at that moment. | |
*/ | |
@Test | |
fun `Given only one activity is in scope, when it recreates after config change, then instance is NOT closed and recycled`() { | |
val session = SessionModule.createInstance(activity1Mock, scope = setOf( | |
Activity1::class, | |
Activity2::class | |
)) | |
// Setup captor | |
verify(appMock).registerActivityLifecycleCallbacks(activityLifeCycleCallbacksCaptor.capture()) | |
whenever(activity1Mock.isChangingConfigurations).thenReturn(true) | |
activityLifeCycleCallbacksCaptor.value.onActivityDestroyed(activity1Mock) | |
assertThat(session.isClosed).isFalse() // This was the bug, before it was now recycled. | |
whenever(activity1Mock.isChangingConfigurations).thenReturn(false) | |
activityLifeCycleCallbacksCaptor.value.onActivityDestroyed(activity1Mock) | |
assertThat(session.isClosed).isTrue() | |
} | |
// Concrete Module just to test ActivityScopedModule | |
private object SessionModule : ActivityScopedModule<Session>() { | |
fun createInstance(activity: Activity1, scope: Set<KClass<out Activity>>): Session { | |
val session = Session() | |
limitInstanceLifetimeToScope(activity.application, session, scope) | |
return session | |
} | |
} | |
// The expensive object that needs to be survive activity navigation, but need to be recycled when Activity 1 and 2 are destroyed. | |
private class Session : Closeable { | |
var isClosed = false | |
override fun close() { | |
isClosed = true | |
} | |
} | |
// Two test activities to which the lifetime of Session will be bound. | |
private class Activity1 : Activity() | |
private class Activity2 : Activity() | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
class FeatureSession : Closable { | |
fun close() { ... } | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
object FeatureSessionModule: ActivityScopedModule<FeatureSession>() { | |
fun createSession(config: Config, activity: FeatureLoadActivity): FeatureSession { | |
val session = FeatureSession( | |
param = config.param | |
) | |
limitInstanceLifetimeToScope( | |
app = activity.application, | |
instance = session, | |
scope = setOf( | |
FeatureLoadActivity::class, | |
FeatureScreen1Activity::class, | |
FeatureScreen2Activity::class, | |
FeatureScreen3Activity::class | |
) | |
) | |
return session | |
} | |
data class Config(val param: String) | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment