Properly initialize heavy objects

Viewed 82

I am trying to convert some Java code to Kotlin. I have a "heavy" object that I cannot reason about how to initialize properly in the app. The object can take some time to create and I don't want to block except for when the functionality is actually required. I wrote some code that meets my requirements, but it doesn't seem like a good pattern and I was hoping someone tell me what the proper pattern here (will list what I don't like about it after the code):

package com.example.myapplication

import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import kotlinx.coroutines.*
import javax.inject.Provider
import kotlinx.coroutines.channels.Channel


class MainActivity : AppCompatActivity() {

    lateinit var heavyInitObject: Provider<HeavyInitObject>

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        heavyInitObject = initHeavyObject()
    }


    fun useTheHeavyInitObject() {

        // Need it to block here, before work is done.
        val hio =  heavyInitObject.get()
    }
}


fun initHeavyObject(): Provider<HeavyInitObject> {

    val cnl = Channel<HeavyInitObject>(Channel.BUFFERED)

    //purposely block to ensure to ensure it is initialized
    val provider = Provider { runBlocking { cnl.receive().also { cnl.close() }}}

    HeavyInitObject.get(object : HeavyInitObject.HeavyInitObjectListener{

        override fun onReady(heavyInitObject: HeavyInitObject) = runBlocking {
            cnl.send(heavyInitObject)
        }
    })

    return provider
}


// Mocked library I am using (i.e. I don't have control over the implementation)
class HeavyInitObject {
    companion object {

        fun get(listener: HeavyInitObjectListener) {

            val heavyInitObject = HeavyInitObject()

            listener.onReady(heavyInitObject)
        }

    }

    interface HeavyInitObjectListener {
        fun onReady(heavyInitObject: HeavyInitObject)
    }
}

What I don't like

Should be a val

It naturally really be a val, because the value should never change once initialized.

class MainActivity : AppCompatActivity() {

    val heavyInitObject: Provider<HeavyInitObject> = initHeavyObject()

    // OR...

    val heavyInitObject: HeavyInitObject by lazy {
        initHeavyObject().get()
    }

The first option seems like it could do too much too fast. Depending on how someone would add MainActivity to the object graph it could really affect startup performance.

The second one is too slow. If we haven't requested the heavy object to be created before it is needed, there will be definite jank in the application when the heavy object is queried the first time.

Is there a good way to have the object be a val while requesting the object to be created in onCreate (understanding that I don't have control over implementation of the underlying library)?

Is channel the right data structure here?

Maybe this is bareable, but I wanted to see if there is a better option. A RENDEZVOUS channel makes more sense, but send suspends until receive is called and I don't want to block anything on thread initializing the object (i.e. since i can't convert the implementation to a suspend function). Switching to a bufferend channel won't block since I only send one element through, but that seems like a hack. What is the best data structure for this task?

Edit:

Thanks to some help in the comments I have improved the second condition (eliminate akward use of channel). I have a couple ideas for how to improve the first condition...

Code for getting rid of channel

import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException

class MainActivity : AppCompatActivity() {

    lateinit var heavyInitObject: Provider<HeavyInitObject>

    override suspend fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        heavyInitObject = lifecycleScope.async {deferredHeavyObject()}
    }

    fun useTheHeavyInitObject() {
        // Need it to block here, before work is done.
        val hio = heavyInitObject.await()
    }
}

suspend fun initHeavyObject(): HeavyInitObject = suspendCancellableCoroutine { continuation ->

    HeavyInitObject.get(object : HeavyInitObject.HeavyInitObjectListener {
        override fun onReady(heavyInitObject: HeavyInitObject) {
            continuation.resume(heavyInitObject)
        }
    })
}

Code to finalize heavyInitObject

class MainActivity : AppCompatActivity() {

    val heavyInitObject by lazy { heavyInitObjectBackingField }
    private lateinit var heavyInitObjectBackingField: Deferred<HeavyInitObject>

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        heavyInitObjectBackingField = lifecycleScope.async { deferredHeavyObject()}
    }}

I basically get a lateinit val that way... and can be confident I won't get an error for it not being initialized. Ideally, it makes me realize this is overcomplicated, because I can't get under the hood and easily seperate the object initialization from the call back initialization.. Unless anyone else has a better idea?

1 Answers

Channel is kind of weird for returning a single item. I would load it in a Deferred privately and publicly expose a suspend getter that awaits the Deferred result. Once the object is ready, it won’t have to be waited for. And since it’s a suspend function, you can access it via a coroutine without unlocking your main thread.

object HeavyObjectCreator {
    private val heavyObject: Deferred<HeavyObject> = GlobalScope.async {
        // Long running actions to generate the object…
        HeavyObject(params)
    }

    suspend fun getInstance(): HeavyObject =
        heavyObject.await()
}

In your activity you can use lifecycleScope.launch to start a coroutine when you need to do a task that uses the object and it can call the above function to get it in a suspending way. If you want to preload the heavy object, you can put the statement HeavyObjectCreator in onCreate of your Application class or your Activity so the creator object will be instantiated and start the coroutine to load the heavy object.

This is just one example of a way to do it for an object that you’ll be reusing on multiple screens. If you intend to load a new heavy object only on screens that need it, I’d consider putting the contents of the class above directly in a ViewModel and use viewModelScope instead of GlobalScope.

Related