Android Nomad #42 - Server-Driven UI with Compose and Lua

Remote controlling views in your android app

Android Nomad #42 - Server-Driven UI with Compose and Lua

In the rapidly evolving world of mobile development, creating apps that can adapt quickly without requiring users to download new version is a major advantage. One approach to this problem is Server-Driven UI (SDUI).

SDUI allows the user interface (UI) of an android app to be dynamically controlled by server responses, enabling the app to load and render views based on data fetched at runtime.

Another layer of flexibility can be introduced through remote scripting, allowing the server to have direct control over app behavior beyond just rendering the UI. Lets explore how to enable Server-Driven UI in Android with added capability of remote scripting control.

What is Server-Driven UI?

Server-Driven UI is a design pattern where the server dictates the app’s UI. Unlike traditional approaches, where the UI logic and structure are baked into the app, SDUI enables you to offload that logic to the backend, allowing the UI to be customized or updated by simply modifying the server responses.

Why Use Server-Driven UI and Lua?

Server-Driven UI (SDUI) allows the server to control what UI components should be displayed in the app, offering several benefits:

  • Dynamic UI changes: Update UI without app updates.
  • Personalized experiences: Tailor UIs per user with server-driven data.
  • Consistent experiences across platforms: Achieve uniform UI across Android and iOS by having the UI definition centrally managed.

Lua scripting allows further control of the app by enabling the execution of scripts sent from the server. This allows for dynamic app behavior changes without updating the app, such as performing actions or invoking system functionalities like toasts or navigation.

Getting Started: Jetpack Compose and Lua Setup

1. Setting Up the Android Project

We’ll use Jetpack Compose for the UI and LuaJ as the scripting engine for handling Lua scripts. Start by setting up a Compose project in Android Studio.

In your build.gradle file, add dependencies for Compose and LuaJ:

dependencies {
    // Jetpack Compose
    implementation("androidx.compose.ui:ui:1.5.0")
    implementation("androidx.compose.material:material:1.5.0")
    implementation("androidx.compose.ui:ui-tooling-preview:1.5.0")
    
    // LuaJ
    implementation("org.luaj:luaj-jse:3.0.1")
}

Step-by-Step Implementation

2. Server JSON Response with Lua Script

The server sends a JSON payload that defines the UI and includes Lua scripts for dynamic behavior. Here's an example response:

{
    "type": "Column",
    "components": [
        {
            "type": "Text",
            "content": "Welcome to Server-Driven UI with Lua"
        },
        {
            "type": "Button",
            "content": "Click to Run Lua",
            "action": "executeLuaScript",
            "script": "showToast('Hello from Lua!')"
        }
    ]
}

In this JSON, we define a Column with a Text and a Button. The button is wired to run a Lua script when clicked.

3. Parsing the JSON and Rendering the UI with Compose

Using Jetpack Compose, we can create a function that parses this JSON and dynamically renders the UI.

import androidx.compose.foundation.layout.Column
import androidx.compose.material.Button
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.tooling.preview.Preview
import com.google.gson.Gson

data class UIComponent(
    val type: String,
    val content: String? = null,
    val action: String? = null,
    val script: String? = null
)

data class Layout(
    val type: String,
    val components: List<UIComponent>
)

@Composable
fun renderUI(layoutJson: String, onButtonClick: (String) -> Unit) {
    val layout = Gson().fromJson(layoutJson, Layout::class.java)

    when (layout.type) {
        "Column" -> Column {
            layout.components.forEach { component ->
                when (component.type) {
                    "Text" -> Text(text = component.content ?: "")
                    "Button" -> Button(onClick = {
                        component.script?.let { onButtonClick(it) }
                    }) {
                        Text(text = component.content ?: "Button")
                    }
                }
            }
        }
    }
}

@Preview
@Composable
fun PreviewUI() {
    val json = """
        {
            "type": "Column",
            "components": [
                {"type": "Text", "content": "Hello from Server"},
                {"type": "Button", "content": "Click Me", "action": "executeLuaScript", "script": "showToast('Hello from Lua!')"}
            ]
        }
    """
    renderUI(layoutJson = json, onButtonClick = { script ->
        // Handle Lua execution
    })
}

In the renderUI function, we parse the JSON response using Gson and dynamically render the UI using Jetpack Compose.

4. Adding Lua Script Execution

Next, we need to integrate LuaJ to execute Lua scripts sent from the server. Create a Lua scripting engine class:

import org.luaj.vm2.Globals
import org.luaj.vm2.LuaValue
import org.luaj.vm2.lib.jse.JsePlatform

class LuaScriptingEngine {
    private val globals: Globals = JsePlatform.standardGlobals()

    fun executeLuaScript(script: String) {
        val chunk = globals.load(script)
        chunk.call()
    }

    fun exposeToastFunction(context: Context) {
        val luaToastFunction = object : OneArgFunction() {
            override fun call(arg: LuaValue): LuaValue {
                Toast.makeText(context, arg.checkjstring(), Toast.LENGTH_SHORT).show()
                return LuaValue.NIL
            }
        }
        globals.set("showToast", luaToastFunction)
    }
}

Here, we create the LuaScriptingEngine that can execute Lua scripts, and expose a custom showToast function to Lua so that the server can trigger native Android functions.

5. Connecting UI Actions to Lua Scripts

Now, we can modify our renderUI function to execute Lua scripts when buttons are clicked.

@Composable
fun renderUI(layoutJson: String, luaEngine: LuaScriptingEngine) {
    val layout = Gson().fromJson(layoutJson, Layout::class.java)

    when (layout.type) {
        "Column" -> Column {
            layout.components.forEach { component ->
                when (component.type) {
                    "Text" -> Text(text = component.content ?: "")
                    "Button" -> Button(onClick = {
                        component.script?.let { luaEngine.executeLuaScript(it) }
                    }) {
                        Text(text = component.content ?: "Button")
                    }
                }
            }
        }
    }
}

When the user clicks the button, the corresponding Lua script from the server is executed using the LuaScriptingEngine.

6. Exposing Android Functions to Lua

To allow Lua scripts to interact with Android features, we can expose specific functions to the Lua environment. For example, we expose the ability to show a toast:

// In your Activity or Composable setup
val luaEngine = LuaScriptingEngine()
luaEngine.exposeToastFunction(context)

// Use luaEngine in renderUI
renderUI(layoutJson = json, luaEngine = luaEngine)

Now, any Lua script sent from the server that calls showToast('Message') will display a toast message on the user’s screen.


Security Considerations

When enabling remote scripting, especially when using Lua, it is important to consider security:

  • Sandboxing: Limit the functions and capabilities that are exposed to the Lua environment to avoid malicious code execution.
  • Validation: Ensure that the Lua scripts sent from the server are validated and authenticated to prevent unauthorized actions.
  • Rate limiting: Limit how frequently the server can send new scripts to prevent abuse.

Example Workflow

  1. Server Response:
{
    "type": "Column",
    "components": [
        {"type": "Text", "content": "Server-Driven UI with Lua"},
        {"type": "Button", "content": "Click Me", "action": "executeLuaScript", "script": "showToast('Hello from Lua!')"}
    ]
}
  1. Android App:
  • Parses the JSON to render a text view and a button using Jetpack Compose.
  • When the button is clicked, the Lua script showToast('Hello from Lua!') is executed, displaying a toast message.

Conclusion

By combining Server-Driven UI with Lua scripting, you can create dynamic and responsive Android apps that are easy to update without releasing new app versions. Jetpack Compose provides an elegant way to dynamically render UIs, and Lua scripting gives the server control over app behavior, allowing for remote feature toggling, personalized experiences, and quick iteration.

Using Lua for remote scripting in Android makes the app more flexible and adaptable, while retaining performance and simplicity. If you’re looking to build apps that are easier to update and manage, this approach can provide a powerful solution.

Subscribe to Sid Pillai

Don’t miss out on the latest issues. Sign up now to get access to the library of members-only issues.
jamie@example.com
Subscribe