Search code examples
androidkotlinstartup

Creating singletons using Initializer<T> vs object


Heyo, I'm looking for some advice. I was taking some time to redo a chunk of my code to support dependency injection, and I found the androix.startup App Startup library. It seemed like a great solution for singleton registration, as dependencies and initialization order are automatically managed.

But, looking at the sample code, where they construct an ExampleLogger with a dependency on WorkManager...

// Initializes ExampleLogger.
class ExampleLoggerInitializer : Initializer<ExampleLogger> {
    override fun create(context: Context): ExampleLogger {
        // WorkManager.getInstance() is non-null only after
        // WorkManager is initialized.
        return ExampleLogger(WorkManager.getInstance(context))
    }

    override fun dependencies(): List<Class<out Initializer<*>>> {
        // Defines a dependency on WorkManagerInitializer so it can be
        // initialized after WorkManager is initialized.
        return listOf(WorkManagerInitializer::class.java)
    }
}

I'm left with a few questions. This code seems to construct a class object. How is this supposed to work if my singletons are already objects since object classes don't allow for constructors?

Assuming that ExampleLogger was an object, how should the create function look?

object ExampleLogger {
     lateinit var someProperty : String
     fun initialize(wm : WorkManager) {
         // do something
         // someProperty = wm.GetSomeProperty()
     }
}

class ExampleLoggerInitializer : Initializer<ExampleLogger> {
    override fun create(context: Context): ExampleLogger {
        ExampleLogger.initialize(WorkManager.getInstance(context))
        return ExampleLogger
    }

I guess my question is :

Is it okay that the create function doesn't return a new object, rather just an initialized object? I guess I'm worried that with an object, there's nothing guaranteeing that the initialization happened properly, and code can essentially access an uninitialized object.

Is there a better way to handle this?


Solution

  • For anyone coming upon this question, here is what I've learned from this process:

    While it is technically fine for the Initializer class to return an object, it is better for each "singleton" class to avoid the object class type since it allows for creation of individual instances, which allows for much greater testability.

    Furthermore, the point of the Androidx.Startup library is to manage dependency chains for your managers / singletons. Once you provide a list of dependencies for each singleton, it handles the initialization order for you. However, according to the documentation for object classes :

    The initialization of an object declaration is thread-safe and done on first access.

    That list bit is very important. It means that initialization order cannot be guaranteed when you are using object for your class specifier, and using object will actively undermine the benefits of using the Startup library.


    Structure-wise, I chose to organize my code like this :

    1) Every manager / singleton I create must have 3 things :

    a) An interface that defines all of the public methods on the manager

    // Managers/ILoggingManager.kt
    // a simple manager for providing channels for different log messages at different levels
    // LoggingChannels are simply an object that will check the kind of log against the configured level and conditionally output messages.
    interface ILoggingManager {
        fun getChannel() : LoggingChannel
        fun createChannel(channelName : String? = null,
            level : LogLevel = LogLevel.NONE,
            handler : ((Any?)->Unit)? = null) : LoggingChannel
    }
    

    b) A class definition that implements the interface. Along with a companion object that provides the functionality of static singleton.

    // Managers/LoggingManager.kt
    class LoggingManager : ILoggingManager {
        private val DEFAULT_CHANNEL = LoggingChannel()
        override fun getChannel(): LoggingChannel {
            return DEFAULT_CHANNEL
        }
    
        override fun createChannel(
            channelName : String?,
            level : LogLevel,
            handler : ((Any?)->Unit)?) : LoggingChannel {
            return LoggingChannel(channelName, level, handler)
        }
    
        companion object : IManagerAccessor<LoggingManager> {
            private lateinit var instance : LoggingManager
            override fun get(): LoggingManager {
                return instance
            }
            fun init() {
                instance = LoggingManager()
            }
        }
    }
    

    c) Its Initializer, which establishes its dependencies, calls the init() function, and returns the static instance of the class.

    // Managers/LoggingManagerInitializer.kt
    class LoggingManagerInitializer : Initializer<LoggingManager> {
        override fun create(context: Context): LoggingManager {
            LoggingManager.init() // << pass in any required arguments here...
            LoggingManager.get().getChannel().logLevel = LogLevel.INFORMATION
            return LoggingManager.get()
        }
    
        override fun dependencies(): List<Class<out Initializer<*>>> {
            return emptyList()
        }
    }
    

    2) A SingletonManager pulls all of the implementations into one place.

    It has with a constructor that accepts the interfaces of all of my singletons.

    // Managers/SingletonManager.kt
    class SingletonManager(
        val AuthManager : IAuthManager,
        val ContentManager : IContentManager,
        val DBManager : IDBManager,
        val LoggingManager : ILoggingManager,
        val NetworkingManager : INetworkingManager)
    {
        companion object : IManagerAccessor<SingletonManager> {
            private lateinit var instance : SingletonManager
            override fun get() : SingletonManager {
                return instance
            }
    
            fun init(
                am : IAuthManager,
                cm : IContentManager,
                dbm : IDBManager,
                lm : ILoggingManager,
                nm : INetworkingManager) {
                instance = SingletonManager(am, cm, dbm, lm, nm)
            }
        }
    }
    

    Its corresponding Initializer pulls in all of the implementations that my app will use.

    // Managers/SingletonManagerInitializer.kt
    class SingletonManagerInitializer : Initializer<SingletonManager> {
        override fun create(context: Context): SingletonManager {
            SingletonManager.init(
                am = AuthManager.get(),
                cm = ContentManager.get(),
                dbm = DBManager.get(),
                lm = LoggingManager.get(),
                nm = NetworkingManager.get())
            return SingletonManager.get()
        }
    
        override fun dependencies(): List<Class<out Initializer<*>>> {
            return listOf(
                AuthManagerInitializer::class.java,
                ContentManagerInitializer::class.java,
                DBManagerInitializer::class.java,
                LoggingManagerInitializer::class.java,
                NetworkingManagerInitializer::class.java,
            )
        }
    }
    

    3) Business logic across the app accesses singletons through the SingletonManager

    fun FetchUsersByUsernames(usernames : List<String>) : Promise {    
        val nm = SingletonManager.get().NetworkingManager
        val lm = SingletonManager.get().LoggingManager
    
        val fetchPromises: MutableList<Promise> = mutableListOf()
        usernames.forEach { username: String ->
            // pull down details for each of the missing posts
            fetchPromises.add(
                nm.requestUser(username).fetchContent()
                .then(fun(details: Any?) {
                    // save the information we get into local storage
                }, fun(errorDetails: Any?) {
                    lm.getChannel().logError("$usernames failed to load : $errorDetails")
                }))
        }
    
        return Promise.all(fetchPromises.toTypedArray())
    }
    

    4) Unit and Device Tests can simply call the `SingletonManager.Init()` function and pass in mocked versions of the managers.

    // TestHelpers/SingletonInitializer.kt
    object SingletonInitializer {
        fun init(
            authImpl : IAuthManager = MockAuthManager(),
            contentImpl : IContentManager = MockContentManager(),
            databaseImpl : IDBManager = MockDBManager(),
            loggingManager: ILoggingManager = MockLoggingManager(),
            networkingManager: INetworkingManager = MockNetworkingManager()
        ) {
            SingletonManager.init(
                am = authImpl,
                cm = contentImpl,
                dbm = databaseImpl,
                lm = loggingManager,
                nm = networkingManager,
            )
        }
    }
    

    For my tests that need to mock different layers, I can just create a test specific implementation of the specific manager for that test, or simply create a new SingletonManager.

    // TestHelpers/DBTestClass.kt
    open class DBTestClass() {
        protected fun getDB() : AppDatabase { return SingletonManager.get().DBManager.getDB() }
    
        companion object {
            @BeforeClass
            @JvmStatic
            fun initDB() {
                SingletonInitializer.init()
            }
    
            @AfterClass
            @JvmStatic
            fun closeDB() {
                SingletonManager.get().DBManager.getDB().close()
            }
        }
    
    
        @After
        fun cleanDb() {
            SingletonManager.get().DBManager.getDB().clearAllTables()
        }
    }
    

    TL;DR : Don't use object in your Initializer classes, you lose too much for the simplicity it provides