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 object
s 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?
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 :
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()
}
}
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,
)
}
}
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())
}
// 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()
}
}