Android SDK
Through Yorkie Android SDK, you can efficiently build collaborative applications. On the client-side implementation, you can create Documents that are automatically synced with remote peers with minimal effort.
If you want to install the SDK, refer to the Getting Started with Android SDK.
Client
Client
is a normal client that can communicate with the server. It has Documents and sends changes of the Document from local to the server to synchronize with other replicas in remote.
Creating a Client
We can create a Client using Client(context: Context, rpcHost: String, rpcPort: Int, usePlainText: Boolean, options: Options)
. After the Client has been activated, it is connected to the server and ready to use.
val client = Client(context, "api.yorkie.dev", 443, Options(apiKey = "xxxxx"), false)// Declare your own CoroutineScopescope.launch {client.activateAsync().await}
The API key is used to identify the project in Yorkie. You can get the API key of the project you created in the Dashboard.
usePlainText should be set false unless you use it for testing. For more information, please refer to usePlainText.
Document
Document
is a primary data type in Yorkie, which provides a JSON-like updating experience that makes it easy to represent your application's model.
A Document
can be updated without being attached to the client, and its changes are automatically propagated to other clients when the Document
is attached to the Client
or when the network is restored.
Creating a Document
We can create a Document using Document()
. Let's create a Document with a key and attach it to the Client.
val document = Document(Document.Key("doc-1"))
The document key is used to identify the Document in Yorkie. It is a string that can be freely defined by the user.
However, it is allowed to use only a-z
, A-Z
, 0-9
, -
, .
, _
, ~
and must be less than 120 characters.
Attaching the document
When you attach, the client notifies the server that it is subscribing to this document. If the document does not exist on the server, it will be created, and any local changes that occurred will be updated to the server's document. If the server already has a document associated with the provided key, it sends the existing changes to the client, which are then applied to synchronize the document.
Once attached, the document becomes synchronized with other clients. This ensures that any modifications made by one client are instantly propagated to other clients collaborating on the same document.
The second argument is options.
initialPresence
: Sets the initial presence of the client that attaches the document. The presence is shared with other users participating in the document. It must be serializable to JSON.syncMode
(Optional): Specifies synchronization modes. The default value isSyncMode.Realtime
, which automatically pushes and pulls changes. If you set it toSyncMode.Manual
, you'll need to manually handle synchronization.
// Declare your own CoroutineScopescope.launch {client.attachAsync(document,initialPresences = mapOf("name" to "a"),syncMode = SyncMode.Manual,)}
Updating presence
The Document.updateAsync()
method allows you to make changes to the state of the current user's presence.
Specific properties provided will be changed. The existing presence object will be updated by merging the new changes. In other words, properties not specified in the update function will remain unchanged.
// Declare your own CoroutineScopescope.launch {document.updateAsync { _, presence ->presence.put(mapOf("name" to "b"))}.await()}// final state// presence = { "name": "b" }// we can see that the changes made were merged and the final state of the current user's presence is as we desire
Getting Presence
Document.presences
It returns StateFlow
emitting all the clients currently particiting in the document and their presences.
document.presences.value.forEach { (clientID, presence) ->// Do something}
Document.Event.PresenceChange
It includes all the presence-related events. By subscribing to it, you can be notified whenever clients watch, unwatch, or modify their presence.
The Initialized
event occurs when the client list needs to be initialized.
For example, this happens when you first connect a watch stream to a document, when the connection is lost, or when it is reconnected.
Subscribe before attaching the document to ensure you receive the initial Initialized
event.
// Declare your own CoroutineScopescope.launch {document.events.filterIsInstance<PresenceChange>().collect { event ->when (event) {is MyPresence.Initialized -> { // users currently participating in the document }is Others.Watched -> { // a user has joined the document editing in online }is Others.Unwatched -> { // a user has left the document editing }is Others.PresenceChanged -> { // a user has updated the presence }}}}
You can also subscribe to specific types of presence changes to distinguish between your own events and those of others.
document.event.filterIsInstance<PresenceChange.MyPresnece>
:Initialized
,PresenceChanged
document.event.filterIsInstance<PresenceChange.Others>
:Watched
,Unwatched
,PresenceChanged
Editing the Document
Document.updateAsync(message, updater)
enables you to modify a Document. The optional message
allows you to add a description to the change. If the Document is attached to the Client, all changes are automatically synchronized with other Clients.
// Declare your own CoroutineScopescope.launch {val message = "update document for test"document.updateAsync(message) { root ->root.setNewObject("obj") // {"obj":{}}root.getAs<JsonObject>("obj")["num"] = 1 // {"obj":{"num":1}}root.getAs<JsonObject>("obj").setNewObject("obj")["str"] = "a" // {"obj":{"num":1,"obj":{"str":"a"}}}root.getAs<JsonObject>("obj").setNewArray("arr").apply { // {"obj":{"num":1,"obj":{"str":"a"},"arr":[1,2]}}put(1)put(2)}}.await()}
Under the hood, root
in the updateAsync()
function creates a change
, a set of operations, using a JavaScript proxy. Every element has its unique ID, created by the logical clock. This ID is used by Yorkie to track which object is which.
You can get the contents of the Document using document.getRoot()
.
val root = document.getRoot()println(root["obj"]) // {"num":1,"obj":{"str":"a"},"arr":[1,2]}println(root.getAs<JsonObject>("obj")["num"]) // 1println(root.getAs<JsonObject>("obj")["obj"]) // {"str":"a"}println(root.getAs<JsonObject>("obj")["arr"]) // [1,2]
Subscribing to Document
A Document can be modified by changes generated remotely or locally in Yorkie.
Whenever the Document is modified, change events are triggered and we can subscribe to these events using the document.events
.
The events are triggered with an event object, and the event type indicates the source of the change, which can be one of the following values: LocalChange
, RemoteChange
, or Snapshot
.
// Declare your own CoroutineScopescope.launch {document.events.collect { event ->when (event) {is Document.Event.Snapshot -> {// Update with data from the Yorkie Document.}is Document.Event.LocalChange -> {println(event)}is Document.Event.RemoteChange -> {event.changeInfo.operations.forEach { op ->if (op is OperationInfo.IncreaseOpInfo) {// Do something...}}}}}}
When the event type is LocalChange
or RemoteChange
, it passes changeInfo, which has operations
and messages
properties.
For more information about changeInfo for document events, please refer to the ChangeInfo.
The Snapshot
event is triggered when a snapshot is received from the server.
This occurs when the changes that a document needs to fetch from the server exceed a certain SnapshotThreshold
.
Instead of sending numerous changes, the server sends a snapshot of the document.
In such cases, it is essential to update with data from the Yorkie Document.
If a client has not synchronized for a prolonged period and then makes a sync request, it might receive a Snapshot
event.
Ensure your application processes these snapshot events correctly to maintain document synchronization.
Additionally, you can subscribe to changes for a specific path in the Document using document.events(path)
with a path argument, such as $.todos
, where the $
sign indicates the root of the document.
The events are delivered when the target path and its nested values are changed.
With this feature, you can easily subscribe to changes for a specific part of the document and perform different actions based on the updated values.
// Declare your own CoroutineScopescope.launch {document.events("$.todos").collect { event ->// Events will be delivered when the root.todos or its nested values change.val target = document.getValueByPath("$.todos")// Do something...}
Changing Synchronization Mode
To change the synchronization mode for a document, you can use client.changeSyncMode(doc, syncMode)
Yorkie offers four SyncModes:
-
SyncMode.Realtime
: Local changes are automatically pushed to the server, and remote changes are pulled from the server. -
SyncMode.RealtimePushOnly
: Only -
SyncMode.RealtimeSyncOff
: Changes are not synchronized, but the watch stream remains active. -
SyncMode.Manual
: Synchronization no longer occurs in real-time, and the watch stream is disconneted.
// Enable automatic synchronization of both local and remote changes.client.changeSyncMode(doc, SyncMode.Realtime)// Only push local changes automatically.client.changeSyncMode(doc, SyncMode.RealtimePushOnly)// Synchronization turned off, but the watch stream remains active.client.changeSyncMode(doc, SyncMode.RealtimeSyncOff)// Synchronization turned off, and the watch stream is disconneted.client.changeSyncMode(doc, SyncMode.Manual)client.syncAsync(doc).await() // Trigger synchronization manually using the sync function.
Detaching the Document
If the document is no longer used, it should be detached to increase the efficiency of GC removing CRDT tombstones. For more information about GC, please refer to Garbage Collection.
// Declare your own CoroutineScopescope.launch {client.detachAsync(document).await()}
Custom CRDT types
Custom CRDT types are data types that can be used for special applications such as text editors and counters, unlike general JSON data types such as JsonObject
and JsonArray
. Custom CRDT types can be created in the callback function of document.updateAsync()
.
JsonText
JsonText
provides supports for collaborative text editing. In addition, contents in JsonText
can have attributes; for example, characters can be bold, italic, or underlined.
// Declare your own CoroutineScopescope.launch {target.updateAsync { root ->root.setNewText("text") // {"text":[]}root.getAs<JsonText>("text").edit(0, 0, "hello") // {"text":["val":"hello"]}root.getAs<JsonText>("text").edit(0, 1, "H") // {"text":[{"val":"H"},{"val":"ello"}]}root.getAs<JsonText>("text").select(0, 1)root.getAs<JsonText>("text").style(0, 1, mapOf("bold" to "true")) // {"text":[{"attrs":{"bold":"true"},"val":"H"},{"val":"ello"}]}}.await()}
An example of TextEditor: Text Editor example
Selection using presence
The temporary client information, such as text selection, does not need to be stored in the document permanently.
Instead, it can be effectively shared using presence
.
When transmitting text selection information, it is essential to convert the index
, which can vary based on the text state, into the position
used by JsonText
.
This converted position selection can then be sent and applied through presence.
Here is an example where presence is used to share text selection between users in CodeMirror editor.
- When the text selection is changed:
document.updateAsync { root, presence ->val text = root.getAs<JsonText>("content")val indexRange = text.edit(from, to, content)val posRange = text.indexRangeToPosRange(range)presence.put(mapOf("selection" to gson.toJson(posRange))) // Serialize posRange as a JSON string}
- When applying other user's selection changes:
document.events.filterIsInstance<Others>().collect { event ->if (event is Others.PresenceChanged) {val (clientID, presence) = event.changedval range = document.getRoot().getAs<JsonText>("content").posRangeToIndexRange(presence["selection"])// handle the updated selection in the editor}}
Text selection can be efficiently shared using presence. Please refer to the following example for a complete code:
An example of Text Editor: Text Editor example
JsonCounter
JsonCounter
supports integer types changing with addition and subtraction. If an integer data needs to be modified simultaneously, JsonCounter
should be used instead of primitives.
// Declare your own CoroutineScopescope.launch {target.updateAsync { root ->root.setNewCounter("counter", 1) // {"counter":1}root.getAs<JsonCounter>("counter").increase(3) // {"counter":4}root.getAs<JsonCounter>("counter").increase(6) // {"counter":10}root.getAs<JsonCounter>("counter").increase(-3) // {"counter":7}}.await()}
Reference
For details on how to use the Android SDK, please refer to Android SDK Reference.