Skip to content

Instantly share code, notes, and snippets.

@menjoo
Last active June 30, 2020 11:29
Show Gist options
  • Save menjoo/f5692151dc2766fc3f71e1af145db4e8 to your computer and use it in GitHub Desktop.
Save menjoo/f5692151dc2766fc3f71e1af145db4e8 to your computer and use it in GitHub Desktop.
Tie object lifetime to a set of activities
/**
* 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) {}
}
}
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()
}
class FeatureSession : Closable {
fun close() { ... }
}
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