Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Kotlin - lateinit memory leak issues #234

Open
ZakTaccardi opened this issue Feb 22, 2017 · 27 comments
Open

Kotlin - lateinit memory leak issues #234

ZakTaccardi opened this issue Feb 22, 2017 · 27 comments

Comments

@ZakTaccardi
Copy link

ZakTaccardi commented Feb 22, 2017

Conductor should boldly advertise (I don't think it's made obvious enough for new users) that its instances survive configuration changes (stark contrast to Activity/Fragment/View) - this has huge implications for dagger + kotlin users.

class SalesController : BaseController, SalesView {
    @Inject lateinit var viewBinder: SalesController.ViewBinder
    @Inject lateinit var renderer: SalesRenderer
    @Inject lateinit var presenter: SalesPresenter

    lateinit private var component: SalesScreenComponent

    override var state = SalesScreen.State.INITIAL  //only property that I want to survive config changes

    fun onCreateView(): View {  /** lateinit variables are set here */ }
    fun onDestroyView() { /** lateinit variables need to be dereferenced here, or we have a memory leak  */ }
}

My lateinit properties are injected by dagger, and I need to set them to null in onDestroyView - or have a memory leak. This however is not possible in kotlin, as far as I am aware (without reflection). I could make these properties nullable, but that would defeat the purpose of Kotlin's null safety.

I'm not quite sure how to solve this. Ideally there could be some type of annotation processor that would null out specific variables automatically in onDestroyView

@EricKuck
Copy link
Member

This is a tough problem to solve, and I've run into it too. How would you like to see this called out?

In regards to an annotation processor nulling out the property, that's no more possible than it is for you to set it to null yourself. You just can't set a property to null unless it's an optional. One way to get around this would be to mark it as a @JvmField, then use java to set the backing field itself to null without Kotlin's knowledge, but that feels very hacky.

@EricKuck
Copy link
Member

FWIW, I believe that these properties should simply be optional. Even if you have a good understanding of when it would be null and when it wouldn't, you'd be bypassing the null-safety of the language by declaring something as non-nullable when it isn't. It would be pretty easy to accidentally allow a callback from a long-running operation try to access these properties after they've been nulled out. This seems like something that subtly lowers the maintainability of your codebase, as a new developer coming in wouldn't know about that unexpected gotcha.

@ZakTaccardi
Copy link
Author

ZakTaccardi commented Feb 22, 2017

You just can't set a property to null unless it's an optional.

You're right, my bad

mark it as a @JvmField

@JvmField cannot be applied to lateinit property

Using delegates like below sounds like a pretty interesting solution, I'll have to try it out and report back.

http://stackoverflow.com/a/42398736/891242

If this approach ends up being the best way to handle it - could we add a conductor-specific delegate as an optional artifact?

@ZakTaccardi
Copy link
Author

I believe that these properties should simply be optional

For my navigation drawer, I have a drawer: DrawerLayout? = null because it will exist in a phone configuration, but not exist in the tablet configuration.

I disagree that all properties should be nullable in Kotlin, because then there's no distinction between what can be nonnull/nullable within the scope of onCreateView/onDestoryView.

@nomisRev
Copy link
Contributor

nomisRev commented Feb 22, 2017

Why would you want to set them back to null on onDestroyView? I'm also not sure why you'd want to inject the values, I just use dagger 2 component based functionality.

I make my component initialization (mutable) lazy and enforce initialization in onCreateView (A lateinit would work as well here). The reason I enforce initialization of my component in onCreateView is that on a configuration change the parent dependency (Activity) will also be different and thus a new initialization is required. (in the case your component depends on the activity's component at least).

In my Controller I'd just access my dependencies by component.presenter for some more syntactic sugar you could just use val presenter: SalesPresenter get()= component.presenter and with kotlin 1.1 you could even make the getter inline.

Not sure why you'd still want to reset your references because in the case the controller goes through another onCreateView/onDestroyView the controller holds a reference to the new component and the previous one will be garbage collected. (correct me if I'm missing something)

No nullable types, only for actual nullable dependencies.

@sockeqwe
Copy link
Contributor

sockeqwe commented Feb 22, 2017 via email

@nomisRev
Copy link
Contributor

nomisRev commented Feb 22, 2017

Wouldn't it just get garbage collected with the controller?

And if that's not the case that could be solved with a Conductor aware delegate something like this

class ControllerLazy<out V>(private val initializer: (Controller, KProperty<*>) -> V) : ReadOnlyProperty<Controller, V> {
    private object EMPTY

    private var value: Any? = EMPTY

    override fun getValue(thisRef: Controller, property: KProperty<*>): V {
        if (value == EMPTY) {
            value = initializer(thisRef, property)
            thisRef.addLifecycleListener(object : Controller.LifecycleListener() {
                override fun postDestroyView(controller: Controller) {
                    super.postDestroyView(controller)
                    value = EMPTY
                    thisRef.removeLifecycleListener(this)
                }
            })
        }
        @Suppress("UNCHECKED_CAST")
        return value as V
    }
}

@sockeqwe
Copy link
Contributor

sockeqwe commented Feb 23, 2017 via email

@sockeqwe
Copy link
Contributor

sockeqwe commented Feb 23, 2017

Instead in the cause that controller A was removed by gc, Conductor would simply instate a new controller using reflection.

no, conductor keeps every controller in memory as long as it is on the back stack (doesn't have to be on the top of the back stack), only the View (xml layout) is removed and garbage collected because the UI widget are the heavy weighted things (regarding memory consumption), not the controller itself.

You can simply add a lifecycle listener to log the lifecycle events a controller and you will see that when coming back from back stack the same controller instance is used (no new controller instance created). Furthermore, you will see that controller.onDestroy() is only called when the Controller has been removed permanently from back stack (i.e. the top most controller is removed once the user presses the back button).

And for that reason we use no-arg and bundle constructors

The bundle constructor is only used to restore a controller after a process death.

@nomisRev
Copy link
Contributor

nomisRev commented Feb 23, 2017

Yeah, I took a look at the code the make sure. And I was wrong. That is why I deleted my comment :)

Thanks for pointing this out @sockeqwe! What are your thoughts on the approach I mentioned with a delegate that clears the reference on onDestroyView?

I realise it is just avoiding dealing with nullable dependencies, but those dependencies shouldn't be used in a longer lifecycle than onCreateView/onDestroyView. Now I'm quite unsure what the best approach is.

I use the same delegate for Kotterknife, but I feel that throwing an IllegalStateException when trying to use a view outside of the onCreateView/onDestroyView lifecycle makes sense for views.

@dimsuz
Copy link
Contributor

dimsuz commented Feb 23, 2017

no, conductor keeps every controller in memory as long as it is on the back stack (doesn't have to be on the top of the back stack), only the View (xml layout) is removed and garbage collected because the UI widget are the heavy weighted things (regarding memory consumption), not the controller itself.

And I hope it will stay this way, it makes working with controllers so much more predictable, especially when you have some MVP stuff built on top, like presenters which live for as long as backstack lives...

@ZakTaccardi
Copy link
Author

Ultimately, this is the solution I've implemented, and I'm happy to report it works quite well. If I want a property to survive a configuration change, I just don't add the by Ref(ref) delegate.

abstract class BaseController : Controller() {

    val ref = ResettableReferencesManager()

    @CallSuper
    override fun onDestroyView(view: View) {
        ref.reset()
        super.onDestroyView(view)
    }
}

/**
 * Manages the list of references to reset. This is useful in Conductor's controllers to automatically clear
 * references in [Controller.onDestroyView]. References scoped to the view's lifecycle must be cleared
 * because Conductor's [Controller]s survive configuration changes
 */
class ResettableReferencesManager {
    private val delegates = mutableListOf<Ref<*, *>>()

    fun register(delegate: Ref<*, *>) {
        delegates.add(delegate)
    }

    fun reset() {
        delegates.forEach {
            it.reset()
        }
    }
}

/**
 * Kotlin delegate to automatically clear (nullify) strong references.
 */
class Ref<in R, T : Any>(manager: ResettableReferencesManager) {
    init {
        manager.register(this)
    }

    private var value: T? = null

    operator fun getValue(thisRef: R, property: KProperty<*>): T =
            value ?: throw UninitializedPropertyAccessException(property.name)

    operator fun setValue(thisRef: R, property: KProperty<*>, t: T) {
        value = t
    }

    fun reset() {
        value = null
    }
}


abstract class ExampleController : BaseController() {
    @set:Inject var presenter: HistoryPresenter by Ref(ref)

    /**
     * Binds views w/ butterknife. Lateinit won't work with a delegate.
     */
    private var viewBinder: ExampleController.ViewBinder by Ref(ref)

    /**
     * Named injections won't work. use kotlin property `get()` to workaround it
     */
    private val swipeRefresh get() = viewBinder.swipeRefresh

    private val recycler get() = viewBinder.recycler

    val state: State = State.INITIAL_VALUE //will survive config changes
}

@mradzinski
Copy link

Nice solution @ZakTaccardi, and thanks for making it public, I had this issue myself and solved it pretty much the same way. Although, I'm not a very big fan of that by Ref(ref) naming xD (RefRef is a DDoS attack tool, lol).

@ZakTaccardi
Copy link
Author

ZakTaccardi commented Feb 27, 2017

@mradzinski yeah I wasn't sure what to name it - trying to keep it as terse as possible. by Resettable(manager) seems a little verbose - but I'm open to any ideas that you may have

@EricKuck
Copy link
Member

Just throwing another thank you on there @ZakTaccardi. I had another solution going that I was stubborn about swapping out until today. Your resettable reference made things so much nicer!

@sockeqwe
Copy link
Contributor

One could also write a ResetableReference without a "manager". Just add a Controller LifecycleListener internally in ResetableReference.

@sevar83
Copy link

sevar83 commented Jul 27, 2017

What about this?

abstract class BaseController(args: Bundle) : Controller(args) {

    /**
     * Kotlin delegate to automatically clear (nullify) strong references.
     */
    protected inner class Ref<in R, T : Any> : Controller.LifecycleListener() {

        init {
            this@BaseController.addLifecycleListener(this)
        }

        private var value: T? = null

        operator fun getValue(thisRef: R, property: KProperty<*>): T =
                value ?: throw UninitializedPropertyAccessException(property.name)

        operator fun setValue(thisRef: R, property: KProperty<*>, t: T) {
            value = t
        }

        override fun postDestroyView(controller: Controller) {
            reset()
        }

        fun reset() {
            value = null
            this@BaseController.removeLifecycleListener(this)
        }
    }
}

class ExampleController(args: Bundle) : BaseController(args) {
    @set:Inject var presenter: ExamplePresenter by Ref()
    // ...
}

@ZakTaccardi
Copy link
Author

https://youtrack.jetbrains.com/issue/KT-19621

@PaulWoitaschek
Copy link
Collaborator

@sevar83

If you remove the LifeCycleListener upon postDestroyView, then when the view gets re-created, it will cause a memory leak the next time.
So instead you should just leave the listener attached and let it get carbage collected with the controller.

Instead I'd directly couple it to the instance by passing it. (Else it is also some kind of error prone because you could pass the property around and use it in multiple controllers.

import com.bluelinelabs.conductor.Controller
import kotlin.properties.ReadWriteProperty
import kotlin.reflect.KProperty

/**
 * A property that clears the reference after [Controller.onDestroyView]
 */
class ClearAfterDestroyView<T : Any>(controller: Controller) : ReadWriteProperty<Controller, T> {

  private var value: T? = null

  init {
    controller.addLifecycleListener(object : Controller.LifecycleListener() {
      override fun postDestroyView(@Suppress("NAME_SHADOWING") controller: Controller) {
        value = null
      }
    })
  }

  override fun getValue(thisRef: Controller, property: KProperty<*>) = value
      ?: throw IllegalStateException("Property ${property.name} should be initialized before get and not called after postDestroyView")

  override fun setValue(thisRef: Controller, property: KProperty<*>, value: T) {
    this.value = value
  }
}

fun <T : Any> Controller.clearAfterDestroyView(): ReadWriteProperty<Controller, T> = ClearAfterDestroyView(this)

This has also the advantage that it is not coupled to a base class but can be used through the extension function from any controller.

@sevar83
Copy link

sevar83 commented Aug 14, 2017

Hi @PaulWoitaschek, thanks for the feedback. Do you mean the controller is leaked? I don't see how removeLifecycleListener() causes the controller to leak. Both the controller and the property hold references to each other, so GC can release them both. A call to removeLifecycleListener() shouldn't make a difference. I've just added it for symmetry. I've done a simple rotation test with MAT. Only 1 instance of the controller is held after the rotation.

@PaulWoitaschek
Copy link
Collaborator

PaulWoitaschek commented Aug 14, 2017

You create the property only once. When now postDestroyView gets called, you remove the listener, but the property stays the same. Now the view gets re-created and the reference gets set. When you now change the orientation again, postDestroyView won't get called because you already removed the listener. Therefore the reference is hold.

When you now push another controller on top, the view gets destroyed, but the reference stays.

@sevar83
Copy link

sevar83 commented Sep 29, 2017

Good news. Half of the proposal is going to be implemented in Kotlin 1.2 and the rest in 1.3.
https://blog.jetbrains.com/kotlin/2017/09/kotlin-1-2-beta-is-out/ Check the lateinit improvements section.

@mradzinski
Copy link

@sevar83

the remaining part (the deinitialize method) has been postponed, tentatively until 1.3.

Until we can deinit we're still stuck with the same memory leak (lateinit var = null will not be allowed until the deinit method exists). Good news is that we can at least check if a lateinit has been initialized which is absolutely awesome :)

Waiting till 1.3 to come out some day next year to get rid of this hacks, lol

@PaulWoitaschek
Copy link
Collaborator

Tee property that is tied to the like cycle is actually pretty nice so I'm not sure if I'd de-init the property manually.

@1gravity
Copy link

1gravity commented Jan 31, 2018

If you follow an MVVM pattern instead of MVP you should not have this issue at all because in MVVM the injected components (view model) won't hold a reference to the views and thus even if the reference in the controller isn't nulled it won't leak a reference to the Activity.

In MVVM the view will hold a reference to the view model (e.g. injected by Dagger) but that's ok since the views will be garbage collected once they detach from the window/activity/controller.

In other words, conductor might not be as architecture-agnostic as it claims to be, at least not in combination with Kotlin.

@ZakTaccardi
Copy link
Author

@1gravity are you referring to a databinding trick where you don't have a hard reference to a view?

In MVVM you generally do reference your views from your activity/fragment/controller, so..

@1gravity
Copy link

1gravity commented Feb 12, 2018

The conductor controller (as well as fragments and activities) together with the layout and custom views are the View in an MVVM design and hold a reference to the view model but not vice versa. This article explains what I mean quite accurately: https://medium.com/upday-devs/android-architecture-patterns-part-3-model-view-viewmodel-e7eeee76b73b.

The controller holds references to the layout/view and the ViewModel. In onDestroyView the references to the views are destroyed severing the reference to the Activity context while the references to the ViewModel won't leak anything since these should imo be singletons. All that matters is that rx subscriptions are cancelled or disposed (I dispose in onDetach) or you might end up with multiple subscriptions to the same event stream.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

9 participants