UI Render Pipeline — Deep Technical Guide

Copy Markdown View Source

Overview

Dala's UI render pipeline transfers UI tree data from Elixir (BEAM) to native platforms (iOS/Android) using a custom binary protocol. This guide explains the complete flow, implementation details, and performance characteristics.

Architecture

Elixir (Dala.Renderer)
     1. Build tree (Dala.Node structs)
     2. Prepare + resolve tokens
     3. Encode to binary (encode_tree/1 or encode_frame/1)
     4. Call Dala.Platform.Native with binary
    
Rust NIF (dala_nif)
     5. Receive binary (zero-copy via Rustler Binary<'a>)
     6. Parse binary directly
     7. Pass to ObjC/Java
    
Native Bridge (ObjC / Java)
     8. Convert binary data to native objects
     9. Update UI tree
    
SwiftUI / Jetpack Compose
     10. Render to screen

Step-by-Step Pipeline

1. Elixir UI Tree Construction

Screens define UI using Elixir structs or Spark DSL:

# In your screen module
dala do
  screen name: :home do
    column do
      text "Hello World"
      button "Tap me", on_tap: :tap
    end
  end
end

This produces a tree of %Dala.Node{} structs (via Dala.Node.from_map/2) with stable :id fields for reconciliation:

%Dala.Node{
  id: "root",
  type: :column,
  props: %{},
  children: [
    %Dala.Node{id: "text1", type: :text, props: %{text: "Hello World"}, children: []},
    %Dala.Node{id: "btn1", type: :button, props: %{text: "Tap me", on_tap: {pid, :tap}}, children: []}
  ]
}

2. Prepare Phase (Dala.Ui.Renderer)

The renderer transforms the tree before binary encoding — token resolution, component defaults, platform blocks, and tap registration.

3. Binary Encoding

The prepared tree is encoded to a compact binary format using encode_tree/1 or encode_frame/1:

Full Tree Encoding

# In Dala.Ui.Renderer.render/4
node = to_node(tree, "root")
binary = encode_tree(node)
nif.set_root_binary(binary)

Binary format (version 3):

[2 bytes magic 0xDAA1][2 bytes version=3][8 bytes node_count][node1][node2]...[nodeN]

Incremental Patch Encoding

# In Dala.Ui.Renderer.render_patches/5
patches = Dala.Diff.diff(old_tree, new_tree)
binary = encode_frame(patches)
nif.apply_patches(binary)

Binary format (version 3):

[2 bytes magic][2 bytes version=3][2 bytes flags][2 bytes patch_count][patch1][patch2]...[patchN]

4. NIF Call (Rust)

The binary is passed to the Rust NIF via Dala.Platform.Native.set_root_binary/1 or Dala.Platform.Native.apply_patches/1:

// native/dala_nif/src/lib.rs
#[rustler::nif]
fn set_root_binary(binary: Binary) -> NifResult<Atom> {
    let bytes: &[u8] = binary.as_slice();  // Zero-copy access!
    let transition = get_transition_and_clear();
    platform_set_root_binary(bytes, &transition);
    ok(env)
}

Key benefit: Rustler's Binary<'a> maps directly to BEAM off-heap binaries — no copy occurs at the boundary.

5. Platform Bridge (iOS Example)

The Rust NIF calls Objective-C to pass binary data to the iOS side:

// native/dala_nif/src/ios.rs
pub fn set_root_binary(data: &[u8], transition: &str) {
    unsafe {
        let vm: *mut Object = msg_send![class!(DalaViewModel), shared];
        let ns_data = NSData::dataWithBytes(data.as_ptr() as *const c_void, data.len());
        let ns_transition = ns_string_from_str(transition);
        let _: () = msg_send![vm, setRootFromBinary: ns_data, transition: ns_transition];
    }
}

6. Native UI Update

DalaViewModel parses the binary and updates the UI:

// ios/DalaViewModel.swift
@objc public func setRootFromBinary(_ data: Data, transition: String) {
    let bytes = data.withUnsafeBytes { ptr -> [UInt8] in
        Array(ptr.bindMemory(to: UInt8.self))
    }
    guard let node = DalaNode.fromBinary(bytes, length: bytes.count) else {
        NSLog("[Dala] Failed to decode binary tree")
        return
    }
    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))
        }
    }
}

Binary Protocol Specification

For complete details on the binary format, see Binary Protocol.

Key Format Elements

ElementSizeDescription
Magic2 bytes0xDA, 0xA1 — identifies Dala protocol
Version2 bytes3 for full trees and patches
Flags2 bytesReserved (currently 0)
Node Count8 bytesTotal nodes in tree (little-endian)
Node ID8 bytes64-bit hashed identifier (from hash_id/1)
Node Type1 byteEnum (0=column, 1=row, 2=text, etc.)
Property Count1 byteNumber of properties (0-255)
PropertiesVariableTagged values (see Binary Protocol guide)
Child Count4 bytesNumber of children (little-endian)
Child IDsN×8 bytesArray of child IDs

Node Identity (hash_id/1)

Stable node IDs are computed using SHA-256:

defp hash_id(id) do
  id_str = to_string(id)
  <<hash::unsigned-64-big, _rest::binary>> = :crypto.hash(:sha256, id_str)
  hash
end

This ensures deterministic IDs for diffing in Dala.Diff.diff/2.

Incremental Rendering with Diff Engine

Dala supports patch-based UI updates instead of full tree re-renders.

Architecture

Patch Types

PatchFormatDescription
{:replace, id, node}Replace entire node
{:update_props, id, props}Update props on existing node
{:insert, parent_id, index, node}Insert new node
{:remove, id}Remove node
{:patch_node, id, field_mask, props}Update specific fields by mask

Fallback Behavior

If native doesn't support apply_patches/1, the system falls back to full render via set_root_binary/1.

Render Functions

render/4 (Full Render)

def render(tree, platform, nif \\ Dala.Platform.Native, _transition \\ :none) do
  node = to_node(tree, "root")
  binary = encode_tree(node)
  nif.set_root_binary(binary)
  {:ok, :binary_tree}
end

render_fast/4 (Optimized with Tap Batching)

def render_fast(tree, platform, nif \\ Dala.Platform.Native, transition \\ :none) do
  nif.clear_taps()
  nif.set_transition(transition)

  {prepared, taps} = encode_tree_with_taps(node, nif, platform, ctx)

  nif.set_root_binary(prepared)
  {:ok, :binary_tree}
end

render_patches/5 (Incremental Updates)

def render_patches(old_tree, new_tree, platform, nif \\ Dala.Platform.Native, transition \\ :none) do
  old_node = to_node(old_tree, "old_root")
  new_node = to_node(new_tree, "new_root")

  patches = Dala.Diff.diff(old_node, new_node)

  if patches == [] do
    {:ok, []}
  else
    send_patches(patches, new_node, platform, nif, ctx)
    {:ok, patches}
  end
end

Performance Considerations

Zero-Copy at BEAM↔Rust Boundary

Elixir BEAM (off-heap binary)  Rustler Binary<'a>  &[u8]

No copying occurs. The binary data is referenced directly via Rustler's Binary type.

Skip Unchanged Renders

Dala.Ui.Renderer skips renders when nothing changed:

# In Dala.Screen.do_render/3
if no_assigns_changed? && !navigation_occurred? do
  clear_changed(socket)
  {:noreply, socket}
end

Throttling (Native Side)

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 navigation

This prevents full view teardown on state updates (e.g., typing in text field).

Debugging Tips

  1. Inspect the tree (Elixir side):

    Dala.Test.inspect(node)  # Returns full tree with assigns
  2. Check binary size:

    binary = Dala.Ui.Renderer.encode_tree(node)
    IO.puts("Binary size: #{byte_size(binary)} bytes")
  3. Verify NIF calls:

    adb logcat | grep Dala  # Android
    tail -f ~/Library/Logs/.../app.log  # iOS simulator
    
  4. Test Diff engine:

    old = Dala.Node.from_map(old_map, "root")
    new = Dala.Node.from_map(new_map, "root")
    patches = Dala.Diff.diff(old, new)
    IO.inspect(patches, label: "Patches")
  5. Verify native patches (iOS):

    NSLog("[Dala] Setting root from binary, size: %d bytes", data.length)

Testing

Test files:

  • test/dala/binary_protocol_test.exs — Binary encoding/decoding
  • test/dala/diff_test.exs — Diff engine tests

Run tests:

mix test test/dala/binary_protocol_test.exs
mix test test/dala/diff_test.exs

Summary

Dala uses a binary protocol for UI transfer that prioritizes performance and type safety:

  • Full tree encoding via encode_tree/1 (version 3 format with magic bytes)
  • Incremental patches via encode_frame/1 (version 3 format)
  • Zero-copy at BEAM↔Rust boundary using Rustler's Binary<'a>
  • Stable node IDs via SHA-256 hash_id/1 for diffing
  • Patch-based updates via Dala.Diff.diff/2

The binary protocol provides excellent performance with minimal payload sizes.

References

  • Implementation: lib/dala/ui/renderer.ex (functions encode_tree, encode_frame, hash_id)
  • NIF declarations: lib/dala/platform/native.ex (set_root_binary/1, apply_patches/1)
  • Tests: test/dala/binary_protocol_test.exs (Elixir) and native/dala_nif/src/protocol.rs (Rust)
  • Rust NIF decoder: native/dala_nif/src/protocol.rs (fully implemented with 21+ tests)