Overview
Dala's render engine transfers UI tree data from Elixir (BEAM) to native platforms (iOS/Android) using a JSON-based pipeline. This guide explains the complete flow, implementation details, and tradeoffs.
Architecture
Elixir (Dala.Renderer)
↓ 1. Build tree (Elixir structs)
↓ 2. Prepare + resolve tokens
↓ 3. Encode to JSON
↓ 4. Call NIF
↓
Rust NIF (dala_nif)
↓ 5. Receive JSON string
↓ 6. Pass to ObjC/Java
↓
Native Bridge (ObjC / Java)
↓ 7. Parse JSON to native objects
↓ 8. Update UI tree
↓
SwiftUI / Jetpack Compose
↓ 9. Render to screenStep-by-Step Pipeline
1. Elixir UI Tree Construction
Screens define UI using Elixir structs (or Spark DSL):
# In your screen module
def render(_assigns, _socket) do
~V"""
<column>
<text text="Hello World" />
<button text="Tap me" on_tap: {self(), :tap}>
</column>
"""
endThis produces a tree of %Dala.Screen.Node{} structs:
%Dala.Screen.Node{
type: :column,
props: %{},
children: [
%Dala.Screen.Node{type: :text, props: %{text: "Hello World"}, children: []},
%Dala.Screen.Node{type: :button, props: %{text: "Tap me", on_tap: {pid, :tap}}, children: []}
]
}2. Prepare Phase (Dala.Renderer.prepare/4)
The prepare/4 function transforms the tree before JSON encoding:
- Resolves theme tokens: Converts
@color.primary→"#007AFF" - Applies component defaults: Adds default props for each component type
- Handles platform blocks: Merges
:iosor:androidspecific props - Registers tap handlers: Converts
{pid, :tag}tuples to NIF tap handles
Key code in lib/dala/renderer.ex:
defp prepare(%{type: type, props: props, children: children}, nif, platform, ctx) do
defaults = Map.get(@component_defaults, type, %{})
with_defaults = Map.merge(defaults, props)
prepared_props = prepare_props(with_defaults, nif, platform, ctx)
prepared_children = Enum.map(children, &prepare(&1, nif, platform, ctx))
%{type: type, props: prepared_props, children: prepared_children}
end3. JSON Encoding
The prepared tree is encoded to JSON using :json.encode/1:
json =
tree
|> prepare(nif, platform, ctx)
|> :json.encode()
|> IO.iodata_to_binary()Example JSON output:
{
"type": "column",
"props": {},
"children": [
{
"type": "text",
"props": {"text": "Hello World"},
"children": []
},
{
"type": "button",
"props": {
"text": "Tap me",
"on_tap": "tap_handle_123",
"accessibility_id": "tap"
},
"children": []
}
]
}4. NIF Call (Rust)
The JSON string is passed to the Rust NIF via nif.set_root(json):
// native/dala_nif/src/lib.rs
fn set_root<'a>(env: Env<'a>, json: Term<'a>) -> NifResult<Term<'a>> {
let json_str: String = json.decode()?;
let transition = get_transition_and_clear();
platform_set_root(&json_str, &transition);
ok(env)
}5. Platform Bridge (iOS Example)
The Rust NIF calls Objective-C to pass JSON to the iOS side:
// native/dala_nif/src/ios.rs
pub fn set_root(json: &str, transition: &str) {
unsafe {
let vm: *mut Object = msg_send![class!(DalaViewModel), shared];
let ns_json = ns_string_from_str(json);
let ns_transition = ns_string_from_str(transition);
let _: () = msg_send![vm, setRootFromJSON: ns_json, transition: ns_transition];
}
}6. SwiftUI Update
DalaViewModel parses JSON and updates the @Published property:
// ios/DalaViewModel.swift
@objc public func setRootFromJSON(_ json: String, transition: String) {
let data = json.data(using: .utf8)!
let obj = try JSONSerialization.jsonObject(with: data)
let node = DalaNode.fromDictionary(obj as! [String: Any])
setRoot(node, transition: transition)
}SwiftUI observes root changes and re-renders:
// ios/DalaRootView.swift
struct DalaRootView: View {
@StateObject private var viewModel = DalaViewModel.shared
var body: some View {
if let root = viewModel.root {
DalaNodeView(node: root)
.id(viewModel.navVersion) // Identity changes only on navigation
.animation(animationForTransition(viewModel.transition))
}
}
}Optimized Path: render_fast/4
An optimized version batches tap registrations to reduce NIF calls:
def render_fast(tree, platform, nif \\ @default_nif, transition \\ :none) do
nif.clear_taps()
nif.set_transition(transition)
{prepared, taps} = prepare_with_taps(tree, nif, platform, ctx)
# Batch register taps (single NIF call)
nif.set_taps(taps)
json = prepared |> :json.encode() |> IO.iodata_to_binary()
nif.set_root(json)
endBenefit: Reduces NIF calls from N+2 (clear + N register + set_root) to 3 (clear + set_taps + set_root).
Data Transfer: Pros and Cons
✅ Advantages of JSON-based Transfer
Simplicity
- Easy to debug (print JSON string)
- Language-agnostic (any platform can parse JSON)
- No complex binary serialization
Interoperability
- Works with any native platform (iOS, Android, desktop)
- No need for platform-specific binary formats
- Can use standard JSON libraries on native side
Inspectability
Dala.Test.inspect(node)returns readable tree- Can log JSON before sending to NIF
- Easy to write tests against JSON structure
Flexibility
- Easy to add new props (just add to JSON)
- Platform-specific props handled via
:ios/:androidblocks - No need to recompile NIF for UI changes
❌ Disadvantages of JSON-based Transfer
Performance Overhead
- Encoding: Elixir → JSON string (CPU + allocation)
- Decoding: JSON → Native objects (CPU + allocation)
- String parsing:
DalaNode.fromDictionarymust parse every prop - Memory: Intermediate JSON string allocation
Type Safety Loss
- JSON is weakly typed (everything is string/number/array/object)
- Native side must validate and convert types (e.g.,
props[@"text_size"]todouble) - Runtime errors if JSON structure is wrong
No Incremental Updates
- Entire tree is re-sent on every render
- Even if only one text value changed, whole tree is re-encoded
- SwiftUI's diffing helps, but JSON parsing still happens
NIF Call Overhead
- Each
set_rootis a Rust NIF call (context switch) - Rust → ObjC message sending (another context switch)
- Main thread dispatch for UI update
- Each
Performance Considerations
Throttling
DalaViewModel throttles rapid updates (< 16ms) to prevent overwhelming SwiftUI:
private let minSetRootInterval: TimeInterval = 0.016 // ~60fps
if elapsed < self.minSetRootInterval && transition == "none" {
return // Skip this update
}Identity vs Content Changes
SwiftUI uses navVersion (not root itself) as view identity:
.id(viewModel.navVersion) // Only changes on navigationThis prevents full view teardown on state updates (e.g., typing in text field).
Skip Unchanged Renders
Dala.Renderer skips renders when nothing changed (see AGENTS.md rule 12):
# In Dala.Screen.do_render/3
if no_assigns_changed? && !navigation_occurred? do
# Skip render, but clear changed tracking
clear_changed(socket)
{:noreply, socket}
endAlternative Approaches (Not Implemented)
1. Binary Protocol (e.g., Protocol Buffers)
- Pros: Faster encoding/decoding, smaller payload, type-safe
- Cons: More complex, requires code generation, less inspectable
2. Shared Memory
- Pros: Zero-copy between BEAM and native
- Cons: Extremely complex, platform-specific, safety issues
3. Incremental Patches (like React Fiber)
- Pros: Only send changes, not full tree
- Cons: Complex diffing logic, need to track previous tree
Debugging Tips
Log JSON before sending:
json = tree |> prepare(...) |> :json.encode() IO.puts("Sending JSON: #{inspect(json)}") nif.set_root(json)Inspect native tree:
Dala.Test.inspect(node) # Returns full tree with assignsCheck NIF calls:
adb logcat | grep Dala # Android tail -f ~/Library/Logs/.../app.log # iOS simulatorVerify SwiftUI updates:
// Add to DalaViewModel.setRoot: NSLog("[Dala] Setting root: %@", node?.description ?? "nil")
Summary
Dala uses a JSON-based render pipeline that prioritizes simplicity and debuggability over raw performance. The tradeoff is acceptable because:
- UI trees are typically small (< 100 nodes)
- Updates are infrequent (user-driven, not 60fps animations)
- SwiftUI's diffing minimizes actual view updates
- Throttling prevents overwhelming the UI thread
For most apps, this approach works well. If you hit performance issues with very large or rapidly updating UIs, consider:
- Using
render_fast/4for batched tap registration - Ensuring
Dala.Screenskips unchanged renders - Profiling to identify actual bottlenecks (likely not JSON encoding)