Introducing Convex for Android
On behalf of the team at Convex, I’m happy to announce the initial release of Convex for Android!
Over the last 10 years, I’ve had the chance to help build some of the biggest Android apps in the world. In a lot of ways, I grew as a developer along with the Android platform as it has matured. Like any growing process, there were bumps along the way, but by and large I have learned a lot about what good application and API design looks like.
Recently I’ve had the exciting opportunity to work with the team here at Convex to bring mobile support to their reactive backend platform. It was my goal to create an API that both feels natural to Android developers who have kept up with modern application architecture principles, as well something that feels like a natural fit in the Convex ecosystem. Ideally it should disappear into the background as your app takes shape around it.
What follows is a walkthrough of how you can integrate Convex for Android into an app written in Kotlin.
Getting started
It’s really easy to hack together a quick example app with the library, as we demonstrate in the Android quickstart. If you want to go deeper and see how to structure a more realistic application with Android and Convex, read on to learn how a chat app like the one in the official Convex tour might be built. You’ll learn the fundamentals to create something that works like this:
ConvexClient - your data source
At the heart of Convex for Android is the ConvexClient
. It wraps the official convex-rs Rust client and handles the connection and communication with the backend. Your application should keep a single reference to a ConvexClient
and use it whenever it needs to communicate with the backend. To create a client, simply pass it the Convex deployment URL you want it to connect to.
val convex = ConvexClient(DEPLOYMENT_URL)
ConvexClient
provides methods like mutation
to trigger updates to your data based on passed in arguments and subscribe
to reactively receive results from a Convex query.
Kotlin data classes for your data
It’s idiomatic to represent structured data as a data class
in Kotlin. Not surprisingly they’re a great fit for the chat messages our app will send and receive and they work well with the ConvexClient
too. Here’s a short data class
for the chat messages:
@Serializable
data class ChatMessage(
val author: String,
val body: String,
)
The class is @Serializable
so it can be marshaled across the FFI boundary between the Kotlin and Rust layers of the library. The author
and body
properties allow the ChatMessage
to match the “shape” of the data stored in the backend messages
table.
A Repository to wrap ConvexClient
Now that we have our data source and a class to represent the data, we need somewhere to tie them together. In a modern Android application that is done in a Repository class. Our ChatRepository
will contain the business logic required to implement a simple chat client: sending and receiving messages.
class ChatRepository(private val client: ConvexClient) {
/** Sends the given message to the backend. */
suspend fun sendMessage(message: ChatMessage) {
client.mutation(
"messages:send",
mapOf(
"body" to message.body,
"author" to message.author,
)
)
}
/**
* Subscribes to a Flow of messages from the backend.
*
* The Flow will be updated whenever a new message is posted.
*/
suspend fun getMessages(): Flow<Result<List<ChatMessage>>> =
client.subscribe<List<ChatMessage>>("messages:list")
}
A typical Repository layer will likely be pretty thin when using Convex. The ConvexClient
provides fairly rich and high level functionality that the Repository exposes with a slightly more friendly API. If you want, you can extract an interface for your Repository and use it in place of concrete implementations. That makes it easier to switch to different data storage implementations (like a fake for testing) while keeping your UI layer blissfully ignorant of those lower level details.
That’s actually about it for the Convex-specific integration! The rest of the walkthrough shows how to neatly wire this functionality into a reactive Android UI.
A custom Application class for holding the Repository
It’s convenient to provide access to instances of your Repository class(es) in a custom Application class. The custom class can be accessed at runtime and used to pass the Repository instance to other code that needs it.
class ChatApplication : Application() {
lateinit var repository: ChatRepository
override fun onCreate() {
super.onCreate()
val convex = ConvexClient(DEPLOYMENT_URL)
repository = ChatRepository(convex)
}
}
A ViewModel to bridge the Repository and your UI
Whether you build your UI with standard Android Views or with a modern reactive style using Jetpack Compose, the core of your UI state should live in a ViewModel. The ViewModel is the source of truth for your UI. It provides all of the data to render it and methods to perform any functionality it exposes to users. The ViewModel will also contain the in-memory state for your UI, comprised of:
- Data received from the Repository for use in the UI
- Data received from the UI for eventual transmission to the Repository
To start, let’s create a ChatViewModel
to expose a custom UiState
object that will be consumed by the UI to update the appearance of the app with messages received from the backend and an outgoing message.
data class UiState(
val outgoingMessage: String,
val messages: List<ChatMessage>,
)
class ChatViewModel(private val chatRepository: ChatRepository) : ViewModel() {
private val _outgoingMessage: MutableStateFlow<String> = MutableStateFlow("")
private val _uiState: MutableStateFlow<UiState> = MutableStateFlow(
UiState(
outgoingMessage = "",
messages = emptyList(),
)
)
val uiState: StateFlow<UiState> = _uiState
}
The actual UiState
that is produced by the ViewModel will change over time; as new messages are received from the backend and as the user enters new messages to send.
Modeling changes to the data over time is a great fit for Kotlin’s Flow
type. If you’re not familiar with Flow
, think of it as a Future
or Promise
that can “complete” multiple times whenever the upstream producer of data emits a new value.
Above we use MutableStateFlow<String>
and MutableStateFlow<UiState>
which represent flows that will emit a given value
and always return their latest state to a consumer. In both uses, an initial value is passed in to be used as the value emitted by the flow before any real data is produced.
Let’s update the code to populate the uiState
flow with messages received from Convex.
class ChatViewModel(private val chatRepository: ChatRepository) : ViewModel() {
init {
// When the ViewModel is initialized, collect the Flow of messages from
// the Repository and set a new _uiState.value each time the data changes.
viewModelScope.launch {
chatRepository.getMessages().collect {
incoming ->
_uiState.value = UiState(
messages = incoming.orElse(emptyList()),
outgoingMessage = ""
)
}
}
}
}
That code is enough to ensure that the public uiState
value will always contain the latest messages from the backend. We can make use of that in a Jetpack Compose UI. The following code will observe changes to uiState
and automatically re-render the list of ChatMessage
values when there’s a change:
@Composable
fun MessageList(val viewModel: ChatViewModel) {
val uiState by viewModel.uiState.collectAsState()
LazyColumn {
items(uiState.messages) { message ->
Text(
text = message.body
)
}
}
}
Receiving messages and rebuilding the UI is great, but let’s add the ability to send messages too. We already have a MutableStateFlow
in our ViewModel to store an outgoing message. Now we’ll add methods to update that value and send it to the backend.
class ChatViewModel(private val chatRepository: ChatRepository) : ViewModel() {
/** Called as the user enters text to compose an outgoing message. */
fun updateOutgoingMessage(message: String) {
_outgoingMessage.value = message
}
/** Called to send a composed outgoing message. */
fun sendOutgoingMessage() {
viewModelScope.launch {
chatRepository.sendMessage(
ChatMessage(
author = "Android Demo User",
body = _outgoingMessage.value
)
)
// Clear the outgoing message after it's sent.
_outgoingMessage.value = ""
}
}
}
Let’s hook those methods up in the UI. We’ll create a simple Row
with a TextField
and an ElevatedButton
.
@Composable
fun OutgoingMessage(val viewModel: ChatViewModel) {
val uiState by viewModel.uiState.collectAsState()
Row{
TextField(
value = uiState.outgoingMessage,
onValueChange = viewModel::updateOutgoingMessage
)
ElevatedButton(
onClick = viewModel::sendOutgoingMessage
) {
Text(text = "Send")
}
}
}
Now the OutgoingMessage
UI will send changes in the TextField
to the ChatViewModel
and will also update its state when new UiState
is emitted (e.g. outgoingMessage
cleared after send).
There’s a problem though. The UiState
exposed by the ChatViewModel
only updates when new messages are received from the backend.
The flow without combine
The TextInput
in the UI won’t reflect what’s typed on the keyboard. Let’s change it so it updates whenever the outgoingMessage
changes too.
Again, we’ll make use of Flow
and use the combine
operator to take the latest ChatMessage
values from Convex and input from the UI and create one flow of UiState
from their data.
class ChatViewModel(private val chatRepository: ChatRepository) : ViewModel() {
init {
// When the ViewModel is initialized, combine the messages from
// the Repository and the the outgoing message into a new Flow and
// collect to build a new _uiState.value each time either source changes.
viewModelScope.launch {
combine(_outgoingMessage,
chatRepository.getMessages()) { outgoing, incoming ->
UiState(
messages = incoming.orElse(emptyList()),
outgoingMessage = outgoing
)
}.collect { uiState ->
_uiState.value = uiState
}
}
}
}
There we go! Now when either _outgoingMessage
or chatRespository.getMessages()
receives new data, the UI will rebuild with the latest data from each source.
The flow with combine
Where to next?
The walkthrough above left out some details for the sake of brevity. The app in the demo video shown near the beginning is nicely styled and has support for signing in and signing out, and the walkthrough code left those details out.
If you’re interested in building out a compelling UI using Jetpack Compose, take a look at the official Jetpack Compose Basics codelab.
If you want more details on how to use Convex with Android, check out the official docs and the source code for a multi-screen example app with auth support called Workout Tracker.
As for me, I’m excited to keep working on bring Convex to more mobile platforms! Keep an eye out for a follow on release for iOS and let us know if you hit any bugs or have a feature request by filing an issue in the convex-mobile Github repo.
Convex is the sync platform with everything you need to build your full-stack project. Cloud functions, a database, file storage, scheduling, search, and realtime updates fit together seamlessly.