A kotlin re-imagining of Typed! for Android
The premise:
“Fix” Android’s obnoxious Key-Value stores by defining Keys that have all info necessary to get/set values including a return Type, a defaultValue (if any) and any serialization/deserialization instructions.
Setup
def typed2Version = "2.0.0-alpha02"
dependencies {
// core implementation: supports SharedPreferences, Intents, Bundles & PersistableBundles
implementation "com.episode6.typed2:core:$typed2Version"
// optional add-on modules
implementation "com.episode6.typed2:saved-state-handle:$typed2Version"
implementation "com.episode6.typed2:navigation-compose:$typed2Version"
// optional serialization support
implementation "com.episode6.typed2:gson:$typed2Version"
implementation "com.episode6.typed2:kotlinx-serialization-json:$typed2Version"
implementation "com.episode6.typed2:kotlinx-serialization-bundlizer:$typed2Version"
}
Typed2 v2.0.0-alpha02 is compiled against Kotlin v1.7.10 and Coroutines v1.6.4
Usage
With Typed2, we declare our keys in an object that subclasses a KeyNamespace. Each key namespace is specific to the type of object that key can be used with.
SharedPreferences Example…
object PrefKeys : PrefKeyNamespace(prefix = "com.sample.prefkey.") {
val MY_INT = key("someInt").int(default = 2)
val MY_STRING = key("someString").string() // no default means null is the default
}
val sharedPreferences: SharedPreference = TODO()
fun main() {
// types & nullability are enforced by the keys
val someInt = sharedPreferences.get(PrefKeys.MY_INT)
val someString = sharedPreferences.get(PrefKeys.MY_STRING)
sharedPreferences.edit {
set(PrefKeys.MY_INT, 42)
set(PrefKeys.MY_STRING, "answer")
}
}
Also works with Bundles…
object Arguments : BundleKeyNamespace(prefix = "com.sample.arguments.") {
val MY_INT = key("someInt").int(default = 2)
val MY_STRING = key("someString").string()
}
val bundle: Bundle = TODO()
val intent: Intent = TODO()
fun main() {
// types are enforced by the keys
val someInt: Int = bundle.get(Arguments.MY_INT)
val someString: String? = intent.getExtra(Arguments.MY_STRING)
bundle.set(Arguments.MY_INT, 23)
intent.setExtra(Arguments.MY_STRING, "mj4l")
}
Can also be used to define screens for Navigation-Compose, enabling type-safe navigation arguments.
object MyScreen : NavScreen(name = "myScreen") {
val MY_INT = key("someInt").int(default = 2)
val MY_STRING = key("someString").string()
}
val savedStateHandle: SavedStateHandle = TODO()
val navController: NavController = TODO()
@Composable fun MyNavigationDefinition(navController: NavHostController) {
NavHost(navController = navController, startScreen = MyScreen) { // note: startScreen must not have any required args
// automatically define the route based on MyScreen's arguments
composableScreen(MyScreen) {
/* actual composable UI */
}
}
}
fun main() {
// can pull nav arguments from either SavedStateHandles or Bundles
val someInt: Int = savedStateHandle.get(Arguments.MY_INT)
val someString: String? = savedStateHandle.get(Arguments.MY_STRING)
// type-safe navigation arguments
navController.navigateTo(MyScreen) {
set(MyScreen.MY_INT, 5)
set(MyScreen.MY_STRING, "hi")
}
}
Object Serialization
We supply 3 modules to handle object serialization (they’re very simple and it should be easy to build your own as well).
object PrefKeys : PrefKeyNamespace() {
// with gson we can convert any data class to/from json using reflection
val MY_GSON_OBJ = key("gsonObj").gson<SomeDataClass>()
// with kotlinx-serialization-json we can convert classes annotated with @Serializable to/from json
val MY_JSON_OBJ = key("jsonObj").json(default = SerialDataClass(), SerialDataClass::serializer)
}
// with kotlinx-serialization-bundlizer we can convert classes annotated with @Serializable to/from a Bundle (only applies to BundleKeyNamespace)
object Arguments : BundleKeyNamespace() {
val MY_VIEW_STATE = key("viewState").bundlized(ViewState::serializer)
}
Async Support
Typed2 is built with kotlin coroutines in mind. Any key can force its mapping onto a background thread using the async()
function. When
using AsyncKeys, the get()
and set()
functions will be suspend functions.
object PrefKeys : PrefKeyNamespace() {
// async() forces the gson execution into a coroutine run on Dispatchers.Default (by default)
val MY_ASYNC_OBJ = key("asyncObj").gson<SomeBigDataClass>().async()
}
val sharedPreferences: SharedPreference = TODO()
fun main() {
coroutineContext {
val obj = sharedPreferences.get(MY_ASYNC_OBJ)
}
}
Properties and MutableStateFlows
All supported key-value stores also include extension methods to generate property delegates and MutableStateFlows
// getting and setting this var will call SharedPreferences.get() and set() under the hood
var intPref: Int by sharedPreferences.property(PrefKeys.MY_INT)
// when using async key, properties will always be nullable and will always start as null
var jsonObj: SerialDataClass? by sharedPreferences.property(PrefKeys.MY_JSON_OBJ, viewModelScope)
// create a mutableStateFlow that writes new values back to sharedPreferences
val stringMutableStateFlow: MutableStateFlow<String> = sharedPreferences.mutableStateFlow(PrefKeys.MY_STRING, viewModelScope)
// when using async keys, mutableStateFlows will always be nullable and use null as an initial value
val jsonObjMutableStateFLow: MutableStateFlow<SerialDataClass?> = sharedPreferences.mutableStateFlow(PrefKeys.MY_JSON_OBJ, viewModelScope)