<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" ><generator uri="https://jekyllrb.com/" version="3.10.0">Jekyll</generator><link href="https://ezhoureal.github.io/feed.xml" rel="self" type="application/atom+xml" /><link href="https://ezhoureal.github.io/" rel="alternate" type="text/html" /><updated>2026-07-26T03:46:13+00:00</updated><id>https://ezhoureal.github.io/feed.xml</id><title type="html">Tianer Zhou</title><subtitle>Physical intelligence</subtitle><author><name>Zireael</name></author><entry><title type="html">Uncovering the UI Framework of HarmonyOS, Part 3: Efficient Redraws and Dirty Region Management</title><link href="https://ezhoureal.github.io/2026/07/26/efficient-redraws-in-harmonyos-pt-3/" rel="alternate" type="text/html" title="Uncovering the UI Framework of HarmonyOS, Part 3: Efficient Redraws and Dirty Region Management" /><published>2026-07-26T02:30:00+00:00</published><updated>2026-07-26T02:30:00+00:00</updated><id>https://ezhoureal.github.io/2026/07/26/efficient-redraws-in-harmonyos-pt-3</id><content type="html" xml:base="https://ezhoureal.github.io/2026/07/26/efficient-redraws-in-harmonyos-pt-3/"><![CDATA[<p>After covering the Rosen Render Service pipeline in <a href="/2026-05-14-uncovering-ui-framework-of-harmonyos-pt-1">Part 1</a> and ArkUI’s declarative engine in <a href="/2026-05-15-uncovering-ui-framework-of-harmonyos-pt-2">Part 2</a>, we now dive into one of the most performance-critical aspects of any rendering system: <strong>deciding what actually needs to be redrawn each frame</strong>.</p>

<p>Modern UI frameworks face a simple but brutal math problem. A 120Hz display gives you roughly 8 milliseconds per frame. In that window, you need to process input, run animations, update layouts, generate draw commands, and submit everything to the GPU. If your screen has 2 million pixels and you redraw every single one of them every frame, you are burning CPU, GPU, and memory bandwidth on work that is often unnecessary.</p>

<p>OpenHarmony’s unified rendering architecture tackles this with two complementary strategies: <strong>reuse</strong> and <strong>de-redundancy</strong>. Together, they form the dirty region management system in Rosen Render Service.</p>

<p>This article unpacks how those strategies work, where they fit in the rendering pipeline, and how the system tracks dirty regions at both the application level and the global screen level.</p>

<h2 id="the-mental-model">The Mental Model</h2>

<p>Only the region that actually changed—for example, a shopping app’s content area updating—needs new draw commands. Everything else can be reused from the previous frame’s buffer. This is the essence of partial rendering.</p>

<p><img src="/assets/post3_001_phone_redraw.png" alt="Side-by-side: frame N vs frame N+1 with dirty region highlighted" /></p>

<h2 id="1-the-two-pillars">1. The Two Pillars</h2>

<p>The dirty region system rests on two ideas that work in concert:</p>

<h3 id="pillar-1-reuse">Pillar 1: Reuse</h3>

<p>In the unified rendering architecture, each display manages a surface backed by a <code class="language-plaintext highlighter-rouge">BufferQueue</code>. Every frame, the render pipeline dequeues a buffer from this queue as its drawing target. Critically, that buffer still contains rendered pixels in its memory—but not necessarily from the immediately preceding frame.</p>

<blockquote>
  <p>In a multi-buffered rendering pipeline, the buffer you dequeue today might contain the rendering result from 2 or even 3 frames ago. This is the central complication that the dirty region system must account for.</p>
</blockquote>

<p>The insight: <strong>if a region of the screen hasn’t changed across all the buffered frames, the corresponding pixels in the buffer are still correct</strong>. You don’t need to redraw them. But “haven’t changed” must span the buffer age—the number of frames the current buffer lags behind. We’ll explore this mechanism in detail in Section 5.</p>

<p>This is exposed through the GPU hardware via <code class="language-plaintext highlighter-rouge">VK_KHR_incremental_present</code> (<a href="https://docs.vulkan.org/refpages/latest/refpages/source/VK_KHR_incremental_present.html#VK_KHR_incremental_present">VK_KHR_incremental_present</a>), which tells the GPU which parts of the framebuffer actually need rendering. The GPU can then skip work for the rest—directly reducing power consumption and load.</p>

<h3 id="pillar-2-de-redundancy">Pillar 2: De-redundancy</h3>

<p>Reuse handles the GPU side, but there is still a CPU-side problem: generating draw commands for widgets whose output will never be seen. De-redundancy eliminates two categories of unnecessary work:</p>

<p><strong>Category A — Reused-region widgets.</strong> If a desktop icon’s pixels are being reused from the previous buffer, there is no point in generating draw commands for that icon in the current frame. It’s already on screen.</p>

<p><strong>Category B — Occluded widgets.</strong> If a full-screen app window completely covers the home screen, every widget on the home screen is invisible. Generating draw commands for them is wasted effort—their output will be immediately overwritten by the app window’s pixels.</p>

<p>The benefits cascade across the entire pipeline:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>De-redundancy
    → Fewer draw commands generated  (CPU saved)
    → Fewer commands flushed to GPU  (DDR bandwidth saved)
    → Fewer rasterization passes     (GPU saved)
</code></pre></div></div>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Reuse
    → Fewer pixels shaded            (GPU saved)
    → Lower power consumption        (battery saved)
</code></pre></div></div>

<h2 id="2-where-it-fits-in-the-pipeline">2. Where It Fits in the Pipeline</h2>

<p>Dirty region optimization lives in two key phases of the render loop, both inside <code class="language-plaintext highlighter-rouge">RSMainThread::Render()</code>:</p>

<p><img src="/assets/post3_002_damage_region.png" alt="Dirty region pipeline overview — QuickPrepare and OnDraw phases" /></p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Render()
├── QuickPrepare()
│   ├── Calculate occlusion (reverse tree traversal)
│   ├── Collect per-app dirty regions
│   └── Compute screen dirty region (global dirty)
│
└── OnDraw()
    └── Issue draw commands, skipping:
        ├── nodes in reused (clean) regions
        └── nodes that are fully occluded
</code></pre></div></div>

<p>Before <code class="language-plaintext highlighter-rouge">Render()</code> even begins, earlier phases contribute vital metadata:</p>

<ul>
  <li><strong>ConsumeAndUpdateAllNodes</strong>: marks self-drawing nodes that received new buffer content.</li>
  <li><strong>Animate</strong>: marks nodes with active animations as dirty.</li>
</ul>

<p>This staged approach means that by the time <code class="language-plaintext highlighter-rouge">OnDraw</code> executes, every node carries enough information for a quick yes/no decision: <em>does this node contribute to a region that actually needs drawing?</em></p>

<h2 id="3-quickprepare-the-decision-phase">3. QuickPrepare: The Decision Phase</h2>

<p><code class="language-plaintext highlighter-rouge">QuickPrepare</code> is where the system builds the dirty region model that <code class="language-plaintext highlighter-rouge">OnDraw</code> will later consume. It happens in three passes.</p>

<h3 id="31-occlusion-calculation">3.1 Occlusion Calculation</h3>

<p>The render tree is traversed in <strong>reverse order</strong>—bottom to top, back to front—to compute occlusion relationships. In a multi-window environment, this matters enormously: the home screen sits below a floating app, which sits below the status bar.</p>

<p><img src="/assets/post3_003_occlusion.png" alt="Occlusion calculation — reverse tree traversal identifying visible vs occluded nodes" /></p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Reverse traversal order:
┌──────────────────────────────────────┐
│  1. Status Bar      (topmost)        │  ← traversed LAST
│  ┌────────────────────────────────┐  │
│  │ 2. Floating App                │  │
│  │  ┌──────────────────────────┐  │  │
│  │  │ 3. App Window            │  │  │
│  │  │  ┌────────────────────┐  │  │  │
│  │  │  │ 4. Home Screen     │  │  │  │  ← traversed FIRST
│  │  │  │  (occluded)        │  │  │  │
│  │  │  └────────────────────┘  │  │  │
│  │  └──────────────────────────┘  │  │
│  └────────────────────────────────┘  │
└──────────────────────────────────────┘
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">CalculateOcclusion</code> determines, for each node, what portion of it is actually visible. A node whose <code class="language-plaintext highlighter-rouge">VisibleRegion</code> is empty is <strong>fully occluded</strong> and can be skipped entirely in the draw phase. Nodes partially occluded get their visible region clipped.</p>

<p>This is fundamentally a spatial data structure problem. The system solves this: <em>given the accumulated opaque regions of all nodes drawn on top of this one, what remains visible?</em></p>

<h3 id="32-per-app-dirty-region-collection">3.2 Per-App Dirty Region Collection</h3>

<p>While traversing the tree, the system simultaneously collects dirty regions—but not as one giant list. Each application gets its own dirty region, tracked independently.</p>

<blockquote>
  <p>The key architectural decision: dirty regions are tracked at the <strong>Surface level</strong>, not at the individual node level.</p>
</blockquote>

<p>This granularity choice is important. A Surface represents a window or a self-drawing component. By aggregating dirty regions per-surface, the system can answer two questions simultaneously:</p>

<ol>
  <li><em>What changed inside this application?</em></li>
  <li><em>Does this application’s change affect other windows?</em></li>
</ol>

<p>This per-app design provides clearer debugging information, and allows us to perform aggressive optimizations, like skipping traversals of an entire surface when appropriate.</p>

<p>A single app might have multiple dirty sub-regions in one frame—a button highlight here, a text update there. These are merged into one bounding rectangle via <code class="language-plaintext highlighter-rouge">RectI::JoinRect()</code>:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>App Window dirty regions:
┌──────────────────┐
│                  │
│  ┌────┐          │   Button highlight:  (100,200) → (160,240)
│  │ B1 │          │
│  └────┘          │   Text update:       (400,300) → (550,320)
│        ┌───────┐ │
│        │ Text  │ │
│        └───────┘ │   Merged dirty rect: (100,200) → (550,320)  ← JoinRect result
│                  │
└──────────────────┘
</code></pre></div></div>

<p>This merging is a tradeoff: a larger bounding rectangle catches more nodes in the “dirty” net, potentially reducing the savings. But computing per-node dirty regions would cost more CPU than the draw commands it saves. The rectangle-based approximation hits a sweet spot.</p>

<h3 id="33-screen-dirty-region-global-dirty">3.3 Screen Dirty Region (Global Dirty)</h3>

<p>At the end of QuickPrepare, after all nodes have been traversed, the system performs one final step: <code class="language-plaintext highlighter-rouge">UpdateSurfaceDirtyAndGlobalDirty()</code>. This is where per-app dirty regions are elevated to a screen-level understanding.</p>

<blockquote>
  <p>Per-app dirty regions answer “what changed in my window?” Screen dirty region answers “what pixels on the physical display actually need new content?”</p>
</blockquote>

<p>The two are not the same, because windows interact with each other. A translucent window over a dirty app means the window’s region is also dirty. A window that moved or resized creates dirty regions in both its old and new positions. A shadow extending beyond an app window dirties pixels in neighboring windows.</p>

<p>The system accounts for these cross-window effects by adding to the global dirty region when:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Screen dirty region ← union of:
├── Per-app dirty regions (direct changes)
├── Regions intersecting transparent windows
│   (translucency → background bleeds through → must redraw)
├── Regions affected by z-order changes
│   (window moved to front → what was visible is now different)
├── Regions affected by window position changes
│   (window moved → old area and new area both dirty)
├── Regions affected by window add/remove
├── Shadow regions that extend beyond window bounds
├── Regions intersecting blur effects
│   (blur samples surrounding pixels → surroundings must be up-to-date)
└── Special full-screen refresh triggers
    (power state changes, color filter changes, screen curtain,
     first frame entering partial update mode, watermark changes)
</code></pre></div></div>

<p>There is also a notable anti-abuse mechanism: <code class="language-plaintext highlighter-rouge">ResetDisplayDirtyRegion()</code> exists for cases where the entire screen must be refreshed (power state changes, color filter changes, screen curtain mode, watermark transitions, and first-frame entry into partial update mode). The source comments carry a warning:</p>

<blockquote>
  <p>Add log entries when adding new full-refresh scenarios. <strong>Do not abuse this interface!</strong></p>
</blockquote>

<p>A full-screen refresh defeats the entire purpose of the dirty region system. Every new trigger for it should be carefully justified.</p>

<h2 id="4-how-dirty-regions-change-frame-to-frame">4. How Dirty Regions Change Frame-to-Frame</h2>

<p>A dirty region is fundamentally a frame-to-frame concept: <em>what pixels are different in frame N compared to frame N-1?</em></p>

<p>If an application’s page is completely static—no animations, no user interaction, no data updates—its dirty region for the current frame is <strong>empty</strong>. The entire window can be reused from the buffer. This is the ideal case that the system optimizes for.</p>

<p>In practice, applications are rarely completely static, but they are usually <strong>mostly</strong> static. A typical messaging app might have:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Frame N-1 → Frame N:
├── Chat list:         unchanged (reuse)
├── Top bar:           unchanged (reuse)
├── Input field:       changed  (redraw — cursor blink)
├── Keyboard:          unchanged (reuse)
└── Status bar:        unchanged (reuse)
</code></pre></div></div>

<p>The dirty region for this frame is tiny relative to the screen—just the area around the blinking cursor. And that tiny region is all that gets drawn.</p>

<p>When multiple regions change within a single app in the same frame, they are merged:</p>

<div class="language-cpp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// Simplified — the actual merge happens via RectI::JoinRect()</span>
<span class="n">dirty_region</span> <span class="o">=</span> <span class="n">region_button</span><span class="p">.</span><span class="n">JoinRect</span><span class="p">(</span><span class="n">region_text</span><span class="p">).</span><span class="n">JoinRect</span><span class="p">(</span><span class="n">region_image</span><span class="p">);</span>
</code></pre></div></div>

<p>This produces one bounding rectangle that covers all changed areas. It may include some unchanged pixels in between, but the overhead of those extra pixels is typically smaller than the cost of managing multiple disjoint dirty rectangles.</p>

<h2 id="5-buffer-age-and-historical-dirty-merging">5. Buffer Age and Historical Dirty Merging</h2>

<p>There is a subtle but critical problem with the frame-to-frame model we just described. It assumes the buffer we draw into today contains the pixels we drew yesterday. In a multi-buffered rendering pipeline, that assumption breaks.</p>

<h3 id="51-the-multi-buffer-problem">5.1 The Multi-Buffer Problem</h3>

<p>Modern graphics pipelines use double or triple buffering to decouple GPU rendering from display scanout. The GPU renders into a <em>back buffer</em> while the display controller reads from a <em>front buffer</em>. When rendering completes, the buffers swap. This means the buffer the system dequeues for the current frame may contain pixels rendered 2 or even 3 frames ago—not the immediately preceding frame.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Triple-buffered pipeline:
┌──────────────────────────────────────────────────────────┐
│  Buffer A (Front)  │  Buffer B (Back 1)  │  Buffer C (Back 2) │
│  → On screen now   │  → Rendered N-1 ago │  → Rendered N-2 ago │
│                    │  → Just swapped in   │  → About to be used  │
└──────────────────────────────────────────────────────────┘
</code></pre></div></div>

<p>Now imagine you only track the dirty region for the current frame. If <code class="language-plaintext highlighter-rouge">Buffer C</code> (rendered 2 frames ago) is the next draw target, and you only redraw the regions that changed in <em>this</em> frame, you’ve missed the regions that changed in the <em>previous</em> frame. Those previous-frame changes aren’t in Buffer C’s memory—they happened after Buffer C was last used. The result: stale content appears on screen.</p>

<blockquote>
  <p>The dirty region for a frame must cover all changes since the buffer was last rendered into—not just changes since the last frame.</p>
</blockquote>

<h3 id="52-the-rsdirtyregionmanager-and-its-circular-history">5.2 The <code class="language-plaintext highlighter-rouge">RSDirtyRegionManager</code> and Its Circular History</h3>

<p>Rosen solves this with <code class="language-plaintext highlighter-rouge">RSDirtyRegionManager</code>, which maintains a <strong>circular history buffer</strong> of recent dirty regions:</p>

<div class="language-cpp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// From rs_dirty_region_manager.h</span>
<span class="kt">int</span> <span class="n">historyHead_</span> <span class="o">=</span> <span class="o">-</span><span class="mi">1</span><span class="p">;</span>
<span class="kt">unsigned</span> <span class="kt">int</span> <span class="n">historySize_</span> <span class="o">=</span> <span class="mi">0</span><span class="p">;</span>
<span class="k">const</span> <span class="kt">unsigned</span> <span class="n">HISTORY_QUEUE_MAX_SIZE</span> <span class="o">=</span> <span class="mi">10</span><span class="p">;</span>   <span class="c1">// up to 10 frames of history</span>
</code></pre></div></div>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>dirtyHistory_ (circular buffer, max 10 entries):
┌────┬────┬────┬────┬────┬────┬────┬────┬────┬────┐
│ F0 │ F1 │ F2 │ F3 │ F4 │ F5 │ F6 │ F7 │ F8 │ F9 │
└────┴────┴────┴────┴────┴────┴────┴────┴────┴────┘
                                              ↑
                                         historyHead_
                                    (most recent frame)
</code></pre></div></div>

<p>Every frame, after the current dirty region is computed, <code class="language-plaintext highlighter-rouge">UpdateDirty()</code> does two things:</p>

<ol>
  <li><strong><code class="language-plaintext highlighter-rouge">PushHistory(rect)</code></strong> — appends the current frame’s dirty region to the circular buffer, advancing <code class="language-plaintext highlighter-rouge">historyHead_</code> and incrementing <code class="language-plaintext highlighter-rouge">historySize_</code> (up to <code class="language-plaintext highlighter-rouge">HISTORY_QUEUE_MAX_SIZE</code>).</li>
  <li><strong><code class="language-plaintext highlighter-rouge">MergeHistory(age, rect)</code></strong> — merges the current frame’s dirty region with the last <code class="language-plaintext highlighter-rouge">age</code> historical frames’ dirty regions.</li>
</ol>

<div class="language-cpp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kt">void</span> <span class="n">RSDirtyRegionManager</span><span class="o">::</span><span class="n">PushHistory</span><span class="p">(</span><span class="n">RectI</span> <span class="n">rect</span><span class="p">)</span>
<span class="p">{</span>
    <span class="kt">int</span> <span class="n">next</span> <span class="o">=</span> <span class="p">(</span><span class="n">historyHead_</span> <span class="o">+</span> <span class="mi">1</span><span class="p">)</span> <span class="o">%</span> <span class="n">HISTORY_QUEUE_MAX_SIZE</span><span class="p">;</span>
    <span class="n">dirtyHistory_</span><span class="p">[</span><span class="n">next</span><span class="p">]</span> <span class="o">=</span> <span class="n">rect</span><span class="p">;</span>
    <span class="k">if</span> <span class="p">(</span><span class="n">historySize_</span> <span class="o">&lt;</span> <span class="n">HISTORY_QUEUE_MAX_SIZE</span><span class="p">)</span> <span class="p">{</span>
        <span class="o">++</span><span class="n">historySize_</span><span class="p">;</span>
    <span class="p">}</span>
    <span class="n">historyHead_</span> <span class="o">=</span> <span class="n">next</span><span class="p">;</span>
<span class="p">}</span>
</code></pre></div></div>

<h3 id="53-how-mergehistory-works">5.3 How <code class="language-plaintext highlighter-rouge">MergeHistory</code> Works</h3>

<p>The <code class="language-plaintext highlighter-rouge">MergeHistory</code> function takes the <code class="language-plaintext highlighter-rouge">bufferAge_</code> and the current frame’s dirty region, then iterates backward through history to produce a merged region that covers all changes since the buffer was last used:</p>

<div class="language-cpp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">RectI</span> <span class="n">RSDirtyRegionManager</span><span class="o">::</span><span class="n">MergeHistory</span><span class="p">(</span><span class="kt">unsigned</span> <span class="kt">int</span> <span class="n">age</span><span class="p">,</span> <span class="n">RectI</span> <span class="n">rect</span><span class="p">)</span> <span class="k">const</span>
<span class="p">{</span>
    <span class="k">if</span> <span class="p">(</span><span class="n">age</span> <span class="o">==</span> <span class="mi">0</span> <span class="o">||</span> <span class="n">age</span> <span class="o">&gt;</span> <span class="n">historySize_</span><span class="p">)</span> <span class="p">{</span>
        <span class="n">rect</span> <span class="o">=</span> <span class="n">rect</span><span class="p">.</span><span class="n">JoinRect</span><span class="p">(</span><span class="n">surfaceRect_</span><span class="p">);</span>   <span class="c1">// full refresh fallback</span>
        <span class="n">age</span> <span class="o">=</span> <span class="n">historySize_</span><span class="p">;</span>
    <span class="p">}</span>
    <span class="c1">// Iterate from oldest to newest within the age window</span>
    <span class="k">for</span> <span class="p">(</span><span class="kt">unsigned</span> <span class="kt">int</span> <span class="n">i</span> <span class="o">=</span> <span class="n">historySize_</span><span class="p">;</span> <span class="n">i</span> <span class="o">&gt;</span> <span class="n">historySize_</span> <span class="o">-</span> <span class="n">age</span><span class="p">;</span> <span class="o">--</span><span class="n">i</span><span class="p">)</span> <span class="p">{</span>
        <span class="k">auto</span> <span class="n">subRect</span> <span class="o">=</span> <span class="n">GetHistory</span><span class="p">((</span><span class="n">i</span> <span class="o">-</span> <span class="mi">1</span><span class="p">));</span>
        <span class="k">if</span> <span class="p">(</span><span class="n">subRect</span><span class="p">.</span><span class="n">IsEmpty</span><span class="p">())</span> <span class="p">{</span>
            <span class="k">continue</span><span class="p">;</span>
        <span class="p">}</span>
        <span class="k">if</span> <span class="p">(</span><span class="n">rect</span><span class="p">.</span><span class="n">IsEmpty</span><span class="p">())</span> <span class="p">{</span>
            <span class="n">rect</span> <span class="o">=</span> <span class="n">subRect</span><span class="p">;</span>
            <span class="k">continue</span><span class="p">;</span>
        <span class="p">}</span>
        <span class="n">rect</span> <span class="o">=</span> <span class="n">rect</span><span class="p">.</span><span class="n">JoinRect</span><span class="p">(</span><span class="n">subRect</span><span class="p">);</span>
    <span class="p">}</span>
    <span class="k">return</span> <span class="n">rect</span><span class="p">;</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The algorithm is straightforward: starting from the current frame’s dirty region, it iterates backward through <code class="language-plaintext highlighter-rouge">age</code> historical entries, unioning each non-empty dirty rectangle via <code class="language-plaintext highlighter-rouge">JoinRect</code>. The result is a single bounding rectangle that covers everything that changed across the entire buffer age window.</p>

<h3 id="54-buffer-age-interpretation">5.4 Buffer Age Interpretation</h3>

<p>The <code class="language-plaintext highlighter-rouge">bufferAge_</code> parameter comes from the GPU’s buffer management system—it tells the renderer how many swaps have occurred since this buffer was last used as a render target:</p>

<table>
  <thead>
    <tr>
      <th>bufferAge</th>
      <th>Meaning</th>
      <th>Merge Behavior</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">0</code></td>
      <td>No history available</td>
      <td>Merge with full <code class="language-plaintext highlighter-rouge">surfaceRect_</code> (safe fallback — redraw everything)</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">1</code></td>
      <td>Double buffering, buffer is 1 frame old</td>
      <td>Merge current + last frame</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">2</code></td>
      <td>Triple buffering, buffer is 2 frames old</td>
      <td>Merge current + last 2 frames</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">&gt; 2</code></td>
      <td>Deeper pipeline</td>
      <td>Merge current + last N frames</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">-1</code></td>
      <td>Unknown / error</td>
      <td>Full refresh — merge all available history</td>
    </tr>
  </tbody>
</table>

<p><img src="/assets/post3_006_buffer.png" alt="Buffer age and dirty history merging across a multi-buffered pipeline" /></p>

<p>In the common triple-buffering case (<code class="language-plaintext highlighter-rouge">bufferAge = 2</code>), even if the app is static in the current frame (empty dirty rect), the merged dirty region would still include any changes from frames N-1 and N-2—ensuring the buffer is fully up to date.</p>

<h3 id="55-advanced-dirty-regions">5.5 Advanced Dirty Regions</h3>

<p>For more complex scenarios, Rosen also maintains an <code class="language-plaintext highlighter-rouge">advancedDirtyHistory_</code> that tracks <strong>multiple disjoint dirty rectangles</strong> per frame instead of a single bounding box. This is controlled by <code class="language-plaintext highlighter-rouge">AdvancedDirtyRegionType</code>:</p>

<ul>
  <li><strong><code class="language-plaintext highlighter-rouge">DISABLED</code></strong>: advanced dirty regions produce a single bounding rectangle (same as the standard path).</li>
  <li><strong>Enabled</strong>: advanced dirty regions preserve multiple disjoint rectangles, allowing even finer-grained partial updates at the cost of more complex region management.</li>
</ul>

<div class="language-cpp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kt">void</span> <span class="n">RSDirtyRegionManager</span><span class="o">::</span><span class="n">MergeAdvancedDirtyHistory</span><span class="p">(</span><span class="kt">unsigned</span> <span class="kt">int</span> <span class="n">age</span><span class="p">)</span>
<span class="p">{</span>
    <span class="c1">// Same buffer-age logic, but operates on vectors of rects</span>
    <span class="c1">// using Occlusion::Region for proper disjoint-region union</span>
    <span class="c1">// rather than a single bounding JoinRect.</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The advanced path uses <code class="language-plaintext highlighter-rouge">Occlusion::Region</code> operations instead of <code class="language-plaintext highlighter-rouge">RectI::JoinRect</code>, enabling true disjoint region tracking. This is useful when an application has changes scattered across the screen—maintaining separate rectangles avoids the over-draw penalty of a large bounding box that spans the gap between them.</p>

<h2 id="6-ondraw-where-the-savings-happen">6. OnDraw: Where the Savings Happen</h2>

<p>All of QuickPrepare’s work leads to this moment. <code class="language-plaintext highlighter-rouge">OnDraw</code> walks the render tree and issues actual draw commands to the GPU—but it only issues commands for nodes that pass two checks:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>For each node:
├── Is the node inside or intersecting the dirty region?
│   ├── No → SKIP (reuse buffer content)
│   └── Yes → continue to check 2
│
└── Is the node visible (not fully occluded)?
    ├── No → SKIP (occluded, would be overwritten anyway)
    └── Yes → GENERATE draw commands
</code></pre></div></div>

<p>Each <code class="language-plaintext highlighter-rouge">SurfaceNode</code> and <code class="language-plaintext highlighter-rouge">CanvasNode</code> is evaluated against the dirty region and occlusion data computed during QuickPrepare. Nodes outside the dirty region have their draw commands suppressed—their content is already correct in the buffer from the previous frame. Nodes that are fully occluded have their draw commands suppressed—their output would never reach the screen.</p>

<p>The logic cascades to children: if a parent node is entirely outside the dirty region or fully occluded, none of its descendants need to be evaluated. This makes the culling efficient even for deeply nested UI trees.</p>

<h2 id="putting-it-all-together">Putting It All Together</h2>

<p>Let’s trace through a concrete example. Imagine a user switches from the home screen to a full-screen video app, on a device with triple buffering (<code class="language-plaintext highlighter-rouge">bufferAge = 2</code>):</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Frame 1: Home screen visible
├── bufferAge: 2 (buffer was last rendered 2 frames ago)
├── MergeHistory(age=2): merges current + F-1 + F-2 dirty regions
├── QuickPrepare: nothing dirty (static home screen across all 3 frames)
├── OnDraw: very little work — mostly reuse
└── Global dirty: empty

Frame 2: App launch animation begins
├── bufferAge: 2
├── Animate marks animating nodes as dirty
├── QuickPrepare:
│   ├── Occlusion: app window partially covers home screen
│   ├── Per-app dirty: app window has dirty region (loading content)
│   └── Global dirty: app window + animation overlap region
├── MergeHistory(age=2):
│   ├── Current frame dirty: app launch area
│   ├── Frame N-1 dirty: (empty, was static home screen)
│   ├── Frame N-2 dirty: (empty, was static home screen)
│   └── Merged result: app launch area (unchanged — no stale frames to catch up)
├── OnDraw:
│   ├── App window area → REDRAW
│   ├── Home screen occluded region → SKIP
│   └── Home screen visible region → REUSE
└── → Partial frame, GPU only shades the changed area

Frame 3: App fully visible, home screen fully occluded
├── bufferAge: 2
├── QuickPrepare:
│   ├── Occlusion: home screen → fully occluded (VisibleRegion is empty)
│   ├── Per-app dirty: app window dirty (content loaded)
│   └── Global dirty: app window area
├── MergeHistory(age=2):
│   ├── Current frame dirty: newly loaded app content
│   ├── Frame N-1 dirty: app launch area (from Frame 2)
│   ├── Frame N-2 dirty: (empty)
│   └── Merged result: union of app content + launch area
│       → Ensures the 2-frame-old buffer gets ALL changes
│       → even those that happened while it wasn't the active target
├── OnDraw:
│   ├── App window (merged region) → REDRAW
│   └── Home screen → SKIP (fully occluded, no draw commands generated)
└── → Savings: home screen's entire widget tree is skipped,
│     AND the merged region is still much smaller than full screen
</code></pre></div></div>

<p>The system adapts on two axes. The same home screen that was mostly reused in Frame 1 has its entire draw command generation skipped in Frame 3, not because it changed, but because it became invisible. Meanwhile, <code class="language-plaintext highlighter-rouge">MergeHistory</code> ensures that even with a 2-frame-old buffer, every pixel that changed since the buffer was last touched is accounted for—without resorting to a full-screen redraw.</p>

<h2 id="summary">Summary</h2>

<p>The dirty region management system in Rosen Render Service achieves its efficiency through two complementary strategies, held together by a third mechanism that ensures correctness across multi-buffered frames:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Reuse (GPU-side)
    Frame buffer pixels that haven't changed are kept.
    VK_KHR_incremental_present tells the GPU what to shade.
    → Lower GPU load, lower power consumption.

De-redundancy (CPU-side)
    Draw commands for occluded or reused widgets are never generated.
    Both CPU instruction generation and GPU command submission are reduced.
    → Lower CPU usage, lower DDR bandwidth.

Historical Dirty Merging (the glue)
    RSDirtyRegionManager maintains a circular dirtyHistory_ (max 10 entries).
    MergeHistory(bufferAge) unions current + N previous frames' dirty regions.
    → Correctness guarantee: no stale pixels, even with triple buffering.
</code></pre></div></div>

<p>Together, they form an optimized system where:</p>

<blockquote>
  <p>Each frame does work proportional to what actually changed, not to what is on screen.</p>
</blockquote>

<p>This is the kind of optimization that users don’t consciously notice but feel every day. When a device with modest hardware scrolls smoothly, switches apps without jank, and delivers all-day battery life while animating at 120Hz—the dirty region system is one of the reasons why.</p>

<p>In the next part, we will explore the animation pipeline and how Rosen schedules and interpolates animated properties within the VSync rhythm we discussed in Part 1.</p>

<h2 id="appendix-i-design-choices">Appendix I, Design choices</h2>

<p>Here’s a quick recap of critical design choices in the dirty region management system:</p>

<ul>
  <li>
    <p><strong>Why per-surface dirty regions instead of a single global one?</strong> Because surfaces map to applications. When one app animates, only its surface’s dirty region needs updating. A single global dirty list would couple all applications together. It also provides better debugging information, showing exactly where the dirty region comes from through dumps and traces.</p>
  </li>
  <li>
    <p><strong>Why bounding rectangles instead of exact pixel regions?</strong> Because the CPU cost of computing pixel-accurate dirty regions across a deeply nested render tree would exceed the GPU savings. A rectangle join is a cheap operation; the slight over-draw is almost always worth it.</p>
  </li>
  <li>
    <p><strong>Why reverse traversal for occlusion?</strong> Because occlusion is easiest to compute top-down: you accumulate opaque regions from windows on top and subtract them from what’s visible underneath. Reverse traversal naturally builds this accumulation.</p>
  </li>
  <li>
    <p><strong>Why separate QuickPrepare from OnDraw?</strong> Cleaner separation of responsibilities since these two operations are naturally sequential. Plus, on the latest builds, these two stages are actually performed on two separate threads, QuickPrepare on the main thread and OnDraw on an exclusive render thread. This enables pipeline parallelism and better utilize the multi-core processor during heavy loads.</p>
  </li>
  <li>
    <p><strong>Why a circular dirty history and buffer age?</strong> Because multi-buffered rendering means the buffer you draw into today might be 2 or 3 frames old. Without merging dirty regions across the buffer age window, changes from intermediate frames would be lost, producing visible artifacts. The <code class="language-plaintext highlighter-rouge">MergeHistory</code> approach is a clean, bounded solution: it scales to any buffer depth, has a fixed memory cost (max 10 history entries), and degrades gracefully to full refresh when <code class="language-plaintext highlighter-rouge">bufferAge</code> exceeds the known history.</p>
  </li>
</ul>]]></content><author><name>Zireael</name></author><category term="HarmonyOS" /><summary type="html"><![CDATA[After covering the Rosen Render Service pipeline in Part 1 and ArkUI’s declarative engine in Part 2, we now dive into one of the most performance-critical aspects of any rendering system: deciding what actually needs to be redrawn each frame.]]></summary></entry><entry><title type="html">Uncovering the UI Framework of HarmonyOS, Part 2: ArkUI Declarative UI Engine</title><link href="https://ezhoureal.github.io/2026/05/15/uncovering-ui-framework-of-harmonyos-pt-2/" rel="alternate" type="text/html" title="Uncovering the UI Framework of HarmonyOS, Part 2: ArkUI Declarative UI Engine" /><published>2026-05-15T02:30:00+00:00</published><updated>2026-05-15T02:30:00+00:00</updated><id>https://ezhoureal.github.io/2026/05/15/uncovering-ui-framework-of-harmonyos-pt-2</id><content type="html" xml:base="https://ezhoureal.github.io/2026/05/15/uncovering-ui-framework-of-harmonyos-pt-2/"><![CDATA[<p>After exploring the Rosen Render Service in <a href="/2026-05-14-uncovering-ui-framework-of-harmonyos-pt-1">Part 1</a>, we now move one layer up the stack to the declarative UI framework that developers actually use every day: <strong>ArkUI</strong>.</p>

<p>The engine behind it lives in the <a href="https://gitcode.com/openharmony/arkui_ace_engine">arkui_ace_engine</a> repository (also mirrored on GitHub under openharmony).</p>

<h2 id="what-makes-it-special">What makes it special</h2>

<p>The declarative layer in ArkUI is deliberately <strong>very thin</strong>. High-level ArkTS (or TypeScript/JavaScript) syntax is transpiled at build time into imperative code that drives a small set of native hooks. The overwhelming majority of work—component creation, tree management, layout, rendering, property updates, and event handling—executes in highly optimized C++.</p>

<p>This is a conscious performance decision. Even with modern JavaScript engines and JIT/AOT compilation, staying in native code for the hot paths (especially per-frame work) is dramatically more efficient. When running cross-platform on iOS, ArkUI’s component creation and layout times have consistently outperformed SwiftUI counterpart by 10%. The performance comes from avoiding JS object churn, direct bridging into C++, and node-level incremental updates.</p>

<h2 id="before-transpilation-what-you-write">Before transpilation (what you write)</h2>

<pre><code class="language-arkts">Column() {
    Button("Click me") {
        // child content
    }
    .onClick(() =&gt; { ... })
    .width('100%')
    .aspectRatio(2.0)
}
</code></pre>

<h2 id="after-transpilation-what-actually-runs">After transpilation (what actually runs)</h2>

<p>The ArkTS compiler transforms the declarative syntax into imperative calls wrapped in <code class="language-plaintext highlighter-rouge">observeComponentCreation</code> (or the optimized <code class="language-plaintext highlighter-rouge">observeComponentCreation2</code>) closures. These closures interact with <code class="language-plaintext highlighter-rouge">ViewStackProcessor</code> for ID allocation, dependency tracking, and stack-based tree construction.</p>

<p>Here’s a simplified illustration of the generated shape:</p>

<div class="language-ts highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">this</span><span class="p">.</span><span class="nx">observeComponentCreation</span><span class="p">((</span><span class="nx">elmtId</span><span class="p">,</span> <span class="nx">isInitialRender</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">{</span>
    <span class="nx">ViewStackProcessor</span><span class="p">.</span><span class="nx">StartGetAccessRecordingFor</span><span class="p">(</span><span class="nx">elmtId</span><span class="p">);</span>

    <span class="nx">Column</span><span class="p">.</span><span class="nx">create</span><span class="p">();</span>
    <span class="c1">// width / aspectRatio applied via ModelNG or ModifierWithKey path</span>

    <span class="k">this</span><span class="p">.</span><span class="nx">observeComponentCreation</span><span class="p">((</span><span class="nx">childElmtId</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">{</span>
        <span class="nx">ViewStackProcessor</span><span class="p">.</span><span class="nx">StartGetAccessRecordingFor</span><span class="p">(</span><span class="nx">childElmtId</span><span class="p">);</span>
        <span class="nx">Button</span><span class="p">.</span><span class="nx">createWithLabel</span><span class="p">(</span><span class="dl">"</span><span class="s2">Click me</span><span class="dl">"</span><span class="p">);</span>
        <span class="nx">Button</span><span class="p">.</span><span class="nx">onClick</span><span class="p">(()</span> <span class="o">=&gt;</span> <span class="p">{</span> <span class="p">...</span> <span class="p">});</span>
        <span class="nx">Button</span><span class="p">.</span><span class="nx">pop</span><span class="p">();</span>
        <span class="nx">ViewStackProcessor</span><span class="p">.</span><span class="nx">StopGetAccessRecording</span><span class="p">();</span>
    <span class="p">},</span> <span class="nx">Button</span><span class="p">);</span>

    <span class="nx">Column</span><span class="p">.</span><span class="nx">pop</span><span class="p">();</span>
    <span class="nx">ViewStackProcessor</span><span class="p">.</span><span class="nx">StopGetAccessRecording</span><span class="p">();</span>
<span class="p">},</span> <span class="nx">Column</span><span class="p">);</span>
</code></pre></div></div>

<p><img src="/assets/transpilation_diagram.png" alt="ArkUI declarative transpilation pipeline and ViewStackProcessor bridge" /></p>

<p><em>Figure 1: From TS to transpiled <code class="language-plaintext highlighter-rouge">observeComponentCreation</code> code that drives <code class="language-plaintext highlighter-rouge">ViewStackProcessor</code> and builds the native <code class="language-plaintext highlighter-rouge">FrameNode</code> tree.</em></p>

<p>There is no runtime interpretation of the nice declarative syntax you wrote. Every component creation flows through a tiny, well-defined bridge into C++.</p>

<h2 id="tree-formation-with-viewstackprocessor">Tree formation with ViewStackProcessor</h2>

<p><code class="language-plaintext highlighter-rouge">ViewStackProcessor</code> (see <code class="language-plaintext highlighter-rouge">frameworks/core/components_ng/base/view_stack_processor.h</code>) is the central singleton that owns the construction stack while declarative code executes:</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">GetInstance()</code></li>
  <li><code class="language-plaintext highlighter-rouge">StartGetAccessRecordingFor(elmtId)</code> / <code class="language-plaintext highlighter-rouge">StopGetAccessRecording()</code> — records reads of state variables against the current element ID for fine-grained reactivity.</li>
  <li><code class="language-plaintext highlighter-rouge">Push(node)</code>, <code class="language-plaintext highlighter-rouge">Pop()</code>, <code class="language-plaintext highlighter-rouge">PopContainer()</code>, <code class="language-plaintext highlighter-rouge">Finish()</code> — builds the parent-child relationships.</li>
  <li><code class="language-plaintext highlighter-rouge">GetMainFrameNode()</code> — the node that property setters (via <code class="language-plaintext highlighter-rouge">ViewAbstract</code> and the various <code class="language-plaintext highlighter-rouge">ModelNG</code> classes) target.</li>
  <li>Support for element IDs, keys (used by ForEach), visual states, implicit animations, and prebuild commands.</li>
</ul>

<p><img src="/assets/view_stack_processor.png" alt="ViewStackProcessor stack-based tree construction" /></p>

<p><em>Figure 2: ViewStackProcessor maintains a construction stack that mirrors the nesting in your declarative code.</em></p>

<p>When your component’s <code class="language-plaintext highlighter-rouge">build()</code> (or equivalent) runs—whether on initial render or a targeted re-execution after a state change—it pushes nodes onto this stack. The pop/finish operations mount them into the real <code class="language-plaintext highlighter-rouge">UINode</code> / <code class="language-plaintext highlighter-rouge">FrameNode</code> tree and mark the work complete.</p>

<p>This mechanism is the direct counterpart to Jetpack Compose’s slot table / Composer or SwiftUI’s view builder internals, but wired straight into ArkUI’s retained C++ node tree.</p>

<h2 id="modules-that-remain-mostly-in-the-ts-world">Modules that remain (mostly) in the TS world</h2>

<p>While the heavy lifting lives in C++, a few pieces stay in the higher-level language for developer experience and reactivity:</p>

<h3 id="decorators-and-state-component-state-link-builder-prop-etc"><strong>Decorators and state</strong> (<code class="language-plaintext highlighter-rouge">@Component</code>, <code class="language-plaintext highlighter-rouge">@State</code>, <code class="language-plaintext highlighter-rouge">@Link</code>, <code class="language-plaintext highlighter-rouge">@Builder</code>, <code class="language-plaintext highlighter-rouge">@Prop</code>, etc.)</h3>

<p>The syntactic transformation and reactivity system are primarily the responsibility of the ArkTS compiler and the state management runtime. At execution time the engine only sees the <code class="language-plaintext highlighter-rouge">observeComponentCreation*</code> hooks plus access recording through <code class="language-plaintext highlighter-rouge">ViewStackProcessor</code>.</p>

<p>When a <code class="language-plaintext highlighter-rouge">@State</code> variable mutates, the state layer re-invokes only the affected observation closures. Inside those closures the element IDs and <code class="language-plaintext highlighter-rouge">ViewStackProcessor</code> allow the framework to reuse existing <code class="language-plaintext highlighter-rouge">FrameNode</code> instances instead of throwing everything away.</p>

<p><code class="language-plaintext highlighter-rouge">JSDataChangeListener</code> and <code class="language-plaintext highlighter-rouge">ElementRegister</code> provide the native-side hooks that make data-driven updates (ForEach, LazyForEach, etc.) efficient.</p>

<p><strong>Example: <code class="language-plaintext highlighter-rouge">@Component</code> + <code class="language-plaintext highlighter-rouge">@State</code> (conceptual)</strong></p>

<pre><code class="language-arkts">  @Component
  struct MyCard {
      @State count: number = 0;

      build() {
          Column() {
              Text(`Count: ${this.count}`)
              Button("Inc").onClick(() =&gt; this.count++)
          }
      }
  }
</code></pre>

<p>The compiler turns the body of <code class="language-plaintext highlighter-rouge">build()</code> into an <code class="language-plaintext highlighter-rouge">observeComponentCreation</code> closure. Reads of <code class="language-plaintext highlighter-rouge">this.count</code> during the recording phase register the dependency against the current <code class="language-plaintext highlighter-rouge">elmtId</code>. Later mutation triggers a minimal re-execution of just that closure.</p>

<p><img src="/assets/state_variable_flow.png" alt="@State reactivity and targeted re-execution flow in ArkUI" /></p>

<p><em>Figure 3: When a @State variable mutates, only the affected component closures are re-executed using element IDs for efficient updates.</em></p>

<h3 id="lazyforeach-virtualized--lazy-lists"><strong>LazyForEach</strong> (virtualized / lazy lists)</h3>

<p>This has first-class support on both sides of the bridge (<code class="language-plaintext highlighter-rouge">js_lazy_foreach.cpp</code> in the declarative frontend and <code class="language-plaintext highlighter-rouge">LazyForEachNode</code> + <code class="language-plaintext highlighter-rouge">LazyForEachBuilder</code> in <code class="language-plaintext highlighter-rouge">frameworks/core/components_ng/syntax/</code>).</p>

<p>It uses the <code class="language-plaintext highlighter-rouge">FrameProxy</code> virtualization mechanism inside <code class="language-plaintext highlighter-rouge">FrameNode</code> for on-demand creation, caching, and eviction of children based on the visible range. The ArkTS side supplies the data and item builder; the C++ side owns the recycling and diffing. This is a great example of deliberately keeping generation logic close to the developer while the performance-critical tree management stays native.</p>

<h3 id="modifiers">Modifiers</h3>
<p>Another important piece that stays lightweight in JavaScript is the <code class="language-plaintext highlighter-rouge">Modifier</code> system. This is what backs the fluent attribute calls developers write every day:</p>

<div class="language-ts highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nx">Button</span><span class="p">(</span><span class="dl">'</span><span class="s1">Save</span><span class="dl">'</span><span class="p">)</span>
  <span class="p">.</span><span class="nx">width</span><span class="p">(</span><span class="mi">160</span><span class="p">)</span>
  <span class="p">.</span><span class="nx">height</span><span class="p">(</span><span class="mi">48</span><span class="p">)</span>
  <span class="p">.</span><span class="nx">backgroundColor</span><span class="p">(</span><span class="nx">Color</span><span class="p">.</span><span class="nx">Blue</span><span class="p">)</span>
  <span class="p">.</span><span class="nx">fontColor</span><span class="p">(</span><span class="nx">Color</span><span class="p">.</span><span class="nx">White</span><span class="p">)</span>
  <span class="p">.</span><span class="nx">borderRadius</span><span class="p">(</span><span class="mi">8</span><span class="p">)</span>
  <span class="p">.</span><span class="nx">onClick</span><span class="p">(()</span> <span class="o">=&gt;</span> <span class="k">this</span><span class="p">.</span><span class="nx">save</span><span class="p">())</span>
</code></pre></div></div>

<p>For app authors, these calls feel like ordinary declarative styling and event binding. Under the hood, each chained call records a small modifier object against the current component. When state changes, ArkUI can compare the old and new modifier values and send only the changed native update instead of replaying every property blindly.</p>

<p>HarmonyOS also exposes this idea directly through <code class="language-plaintext highlighter-rouge">attributeModifier()</code>, which lets developers package reusable attribute logic into an <code class="language-plaintext highlighter-rouge">AttributeModifier</code> class:</p>

<div class="language-ts highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">class</span> <span class="nx">PrimaryButtonModifier</span> <span class="k">implements</span> <span class="nx">AttributeModifier</span><span class="o">&lt;</span><span class="nx">ButtonAttribute</span><span class="o">&gt;</span> <span class="p">{</span>
  <span class="nx">applyNormalAttribute</span><span class="p">(</span><span class="na">instance</span><span class="p">:</span> <span class="nx">ButtonAttribute</span><span class="p">):</span> <span class="k">void</span> <span class="p">{</span>
    <span class="nx">instance</span>
      <span class="p">.</span><span class="nx">height</span><span class="p">(</span><span class="mi">48</span><span class="p">)</span>
      <span class="p">.</span><span class="nx">backgroundColor</span><span class="p">(</span><span class="nx">Color</span><span class="p">.</span><span class="nx">Blue</span><span class="p">)</span>
      <span class="p">.</span><span class="nx">fontColor</span><span class="p">(</span><span class="nx">Color</span><span class="p">.</span><span class="nx">White</span><span class="p">)</span>
      <span class="p">.</span><span class="nx">borderRadius</span><span class="p">(</span><span class="mi">8</span><span class="p">);</span>
  <span class="p">}</span>
<span class="p">}</span>

<span class="nx">Button</span><span class="p">(</span><span class="dl">'</span><span class="s1">Save</span><span class="dl">'</span><span class="p">)</span>
  <span class="p">.</span><span class="nx">attributeModifier</span><span class="p">(</span><span class="k">new</span> <span class="nx">PrimaryButtonModifier</span><span class="p">())</span>
  <span class="p">.</span><span class="nx">onClick</span><span class="p">(()</span> <span class="o">=&gt;</span> <span class="k">this</span><span class="p">.</span><span class="nx">save</span><span class="p">())</span>
</code></pre></div></div>

<p>The internal implementation of a simple attribute like <code class="language-plaintext highlighter-rouge">width</code> is then just a thin bridge object:</p>

<div class="language-ts highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">class</span> <span class="nx">WidthModifier</span> <span class="kd">extends</span> <span class="nx">ModifierWithKey</span> <span class="p">{</span>
  <span class="nx">applyPeer</span><span class="p">(</span><span class="nx">node</span><span class="p">,</span> <span class="nx">reset</span><span class="p">)</span> <span class="p">{</span>
    <span class="k">if</span> <span class="p">(</span><span class="nx">reset</span><span class="p">)</span> <span class="p">{</span>
      <span class="nx">getUINativeModule</span><span class="p">().</span><span class="nx">common</span><span class="p">.</span><span class="nx">resetWidth</span><span class="p">(</span><span class="nx">node</span><span class="p">);</span>
    <span class="p">}</span> <span class="k">else</span> <span class="p">{</span>
      <span class="nx">getUINativeModule</span><span class="p">().</span><span class="nx">common</span><span class="p">.</span><span class="nx">setWidth</span><span class="p">(</span><span class="nx">node</span><span class="p">,</span> <span class="k">this</span><span class="p">.</span><span class="nx">value</span><span class="p">);</span>
    <span class="p">}</span>
  <span class="p">}</span>
  <span class="nx">checkObjectDiff</span><span class="p">()</span> <span class="p">{</span> <span class="cm">/* ... */</span> <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>This design allows cheap structural diffing on the JS side for state-driven updates before any native call is made. The developer sees reusable declarative attributes; the engine sees keyed modifier objects that can be compared, reset, and applied through <code class="language-plaintext highlighter-rouge">arkUINativeModule</code>.</p>

<h2 id="cross-platform-by-design">Cross platform by design</h2>

<p>The architecture was built with multiple frontends and backends in mind:</p>

<ul>
  <li><strong>Frontends</strong>: The main declarative path (ArkTS/TS/JS via the <code class="language-plaintext highlighter-rouge">declarative_frontend/</code> bridge and JSI), experimental ArkTS variants, Cangjie, and a full <strong>C/C++ Native API</strong> surface (<code class="language-plaintext highlighter-rouge">OH_ArkUI_*</code> functions + the Arkoala API).</li>
  <li><strong>Rendering backends</strong>: The primary backend is Rosen (<code class="language-plaintext highlighter-rouge">RosenRenderContext</code> wrapping <code class="language-plaintext highlighter-rouge">RSNode</code>). The design keeps a clean abstraction layer (there are also references to Skia in the broader ecosystem).</li>
  <li><strong>Platform adapters</strong>: <code class="language-plaintext highlighter-rouge">adapter/ohos/</code>, a previewer mode, and the cross-platform ports that allow the same core to run on Android and iOS.</li>
</ul>

<p>All paths—whether you write ArkTS with <code class="language-plaintext highlighter-rouge">@Component</code> or call the native C API directly—converge on the same <code class="language-plaintext highlighter-rouge">FrameNode</code> + <code class="language-plaintext highlighter-rouge">Pattern</code> + <code class="language-plaintext highlighter-rouge">LayoutProperty</code>/<code class="language-plaintext highlighter-rouge">PaintProperty</code> core, and ultimately the same <code class="language-plaintext highlighter-rouge">PipelineContext</code> and render backend.</p>

<p>This is why ArkUI can deliver a consistent, high-performance declarative model across HarmonyOS devices, Android, and iOS from a single C++ implementation.</p>

<h2 id="summary">Summary</h2>

<p>ArkUI’s declarative engine achieves its performance by:</p>

<ul>
  <li>Making the TS layer a thin transpiled bridge.</li>
  <li>Driving almost everything through <code class="language-plaintext highlighter-rouge">ViewStackProcessor</code> + <code class="language-plaintext highlighter-rouge">ModelNG</code> into native <code class="language-plaintext highlighter-rouge">FrameNode</code> / <code class="language-plaintext highlighter-rouge">Pattern</code> objects.</li>
  <li>Using element IDs + access recording for precise, incremental state updates.</li>
  <li>Keeping only the developer-facing ergonomics (decorators, builders, lazy item generation) in the higher-level language.</li>
  <li>Maintaining a clean multi-frontend / multi-backend architecture.</li>
</ul>

<p>The result is a system that feels like a modern declarative UI framework while executing with the efficiency of a native rendering engine.</p>

<p>In future parts we’ll go deeper into the <code class="language-plaintext highlighter-rouge">FrameNode</code> + <code class="language-plaintext highlighter-rouge">Pattern</code> composition model, the dirty propagation and layout/paint pipeline, how LazyForEach and virtual scrolling actually work at the node level, and the raw C interface. Or we’ll get back to the render service and talk about various low-level optimizations there.</p>]]></content><author><name>Zireael</name></author><category term="HarmonyOS" /><summary type="html"><![CDATA[After exploring the Rosen Render Service in Part 1, we now move one layer up the stack to the declarative UI framework that developers actually use every day: ArkUI.]]></summary></entry><entry><title type="html">Uncovering the UI Framework of HarmonyOS, Part 1: Rosen Render Service</title><link href="https://ezhoureal.github.io/2026/05/14/uncovering-ui-framework-of-harmonyos-pt-1/" rel="alternate" type="text/html" title="Uncovering the UI Framework of HarmonyOS, Part 1: Rosen Render Service" /><published>2026-05-14T12:55:00+00:00</published><updated>2026-05-14T12:55:00+00:00</updated><id>https://ezhoureal.github.io/2026/05/14/uncovering-ui-framework-of-harmonyos-pt-1</id><content type="html" xml:base="https://ezhoureal.github.io/2026/05/14/uncovering-ui-framework-of-harmonyos-pt-1/"><![CDATA[<p>After working on the open source foundation of HarmonyOS for nearly four years, and seeing the OS launch, ship, and climb from 0 to 60M+ users, I think it is time to unpack the design and optimization work behind the system.</p>

<p>This series starts with the core rendering layer: Rosen Render Service.</p>

<p>Rosen is one of the reasons HarmonyOS can deliver smooth animations and responsive UI even on devices with much weaker hardware comparing to flagship Android and iOS devices. It is the layer where abstract UI state becomes a scheduled, optimized, hardware-aware frame.</p>

<h2 id="overview">Overview</h2>

<p>OpenHarmony’s <a href="https://gitcode.com/openharmony/graphic_graphic_2d">Rosen library</a> is the core rendering system that turns application UI state into pixels on screen. At a high level, it works like a client-server rendering architecture:</p>

<p><img src="/assets/render_service_diagram.png" alt="Rosen render service pipeline" /></p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Application UI
  -&gt; RSNode property changes
  -&gt; RSTransactionData
  -&gt; render_service
  -&gt; RSRenderNode tree
  -&gt; drawable tree
  -&gt; GPU or hardware composer
  -&gt; display
</code></pre></div></div>

<p>The key idea is separation of responsibility. On the application side, UI code manipulates lightweight <code class="language-plaintext highlighter-rouge">RSNode</code> objects and batches visual property changes into transactions. These transactions cross process boundaries through IPC and are consumed by Render Service. On the server side, those commands update an authoritative <code class="language-plaintext highlighter-rouge">RSRenderNode</code> tree. This server-owned tree is what the rendering pipeline prepares, optimizes, and draws.</p>

<p>The pipeline is staged:</p>

<ol>
  <li>The app generates commands when UI properties change.</li>
  <li><code class="language-plaintext highlighter-rouge">RSMainThread</code> receives transaction data, applies node updates, advances animations, and computes visibility or occlusion.</li>
  <li>During the prepare phase, the render tree is traversed and converted into drawable objects. Render parameters are synchronized into frame-stable snapshots.</li>
  <li>The render thread executes those drawables, either through GPU rendering or, when possible, by handing eligible surfaces directly to the hardware composer.</li>
</ol>

<p>This architecture exists because modern UI rendering is not only about drawing shapes. It also has to coordinate animation timing, multi-window composition, dirty-region optimization, hardware overlays, refresh-rate control, buffer management, and cross-process synchronization.</p>

<p>Rosen contains the systems that make those responsibilities work together: render nodes and properties, drawable execution, VSync distribution, screen and display management, hardware composer integration, and runtime system-property switches for enabling features such as unified rendering, partial rendering, occlusion, and hardware composer paths.</p>

<h2 id="what-happens-after-a-ui-property-changes">What Happens After a UI Property Changes?</h2>

<p>When you write something simple in ArkUI:</p>

<pre><code class="language-arkts">Text("Hello")
  .opacity(0.5)
</code></pre>

<p>Internally, the framework launches a sophisticated pipeline involving:</p>

<ul>
  <li>UI tree mutation</li>
  <li>render tree transactions</li>
  <li>cross-process synchronization from application process to Render Service process</li>
  <li>VSync scheduling</li>
  <li>GPU composition</li>
  <li>hardware overlays</li>
  <li>final scanout</li>
</ul>

<p>UI intent is converted into a retained rendering system managed by Rosen Render Service.</p>

<p>Before diving into code paths, here is the most useful mental model:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>ArkUI
    = declarative intent layer
RenderNode tree
    = retained scene graph
Rosen Render Service
    = compositor + rendering engine
Hardware Composer
    = display plane scheduler
GPU
    = rasterization backend

UI property change
    -&gt; ArkUI node update
    -&gt; RenderNode mutation
    -&gt; transaction commit
    -&gt; IPC to Render Service
    -&gt; scene graph update
    -&gt; VSync-driven composition
    -&gt; GPU / Hardware Composer
    -&gt; display scanout
</code></pre></div></div>

<h2 id="11-a-property-changes-in-arkui">1.1 A Property Changes in ArkUI</h2>

<p>Imagine:</p>

<pre><code class="language-arkts">Image($r("app.media.wallpaper"))
  .opacity(0.8)
</code></pre>

<p>Then later:</p>

<pre><code class="language-arkts">this.alpha = 0.5
</code></pre>

<p>Somewhere internally, this becomes a property mutation:</p>

<div class="language-cpp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">node</span><span class="o">-&gt;</span><span class="n">SetOpacity</span><span class="p">(</span><span class="mf">0.5</span><span class="n">f</span><span class="p">);</span>
</code></pre></div></div>

<p>At this point, nothing has been rendered yet. The system has only mutated state inside the UI tree.</p>

<p>Conceptually:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Before:
OpacityNode(opacity=0.8)

After:
OpacityNode(opacity=0.5)
</code></pre></div></div>

<p>This is extremely important: HarmonyOS prefers retained rendering over immediate rendering. The tree persists across frames, and only changed properties are updated.</p>

<h2 id="12-ui-nodes-become-render-nodes">1.2 UI Nodes Become Render Nodes</h2>

<p>ArkUI components are semantic objects:</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">Button</code></li>
  <li><code class="language-plaintext highlighter-rouge">Text</code></li>
  <li><code class="language-plaintext highlighter-rouge">Image</code></li>
  <li><code class="language-plaintext highlighter-rouge">Column</code></li>
  <li><code class="language-plaintext highlighter-rouge">List</code></li>
</ul>

<p>The framework converts UI components into lower-level rendering primitives. Internally, these eventually become <code class="language-plaintext highlighter-rouge">RSCanvasNode</code>.</p>

<p>Conceptually:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Button
  -&gt; Background
  -&gt; Shadow
  -&gt; Glyphs
  -&gt; Effects

RSCanvasNode
  -&gt; RectDrawable
  -&gt; ShadowDrawable
  -&gt; TextDrawable
  -&gt; FilterDrawable
</code></pre></div></div>

<p>This transformation from semantic UI tree to render tree with a list of drawables, is one of the most important ideas in the pipeline.</p>

<h2 id="13-transactions-are-generated">1.3 Transactions Are Generated</h2>

<p>This is where Rosen becomes interesting.</p>

<p>Instead of sending entire trees every frame, the framework sends incremental transactions to dramatically reduces IPC cost.</p>

<p>Typical mutation operations include:</p>

<ul>
  <li>create node</li>
  <li>destroy node</li>
  <li>reorder children</li>
  <li>update property</li>
  <li>update transform</li>
  <li>update effect</li>
</ul>

<p>Internally, you will see concepts around:</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">RSTransaction</code></li>
  <li><code class="language-plaintext highlighter-rouge">RSCommand</code></li>
  <li><code class="language-plaintext highlighter-rouge">RSModifier</code></li>
</ul>

<p>Code paths worth exploring:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>rosen/modules/render_service_client/core/transaction/
transaction_proxy.cpp
rs_transaction.cpp
</code></pre></div></div>

<p>Conceptually:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Node #42:
    opacity = 0.5
Node #77:
    transform = matrix(...)
Node #81:
    blurRadius = 32
</code></pre></div></div>

<p>These updates are serialized and sent to Render Service, a separate process. One defining characteristic of Rosen is that rendering is largely centralized in a separate process.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Application Process
    &lt;-&gt; IPC
Render Service Process
</code></pre></div></div>

<p>Unlike many lightweight UI frameworks, the app process does not fully own rendering. The app submits scene updates. Render Service owns composition. This architecture makes multi-window animations and incremental redraws much easier to control and more efficient.</p>

<h2 id="21-render-service-updates-the-scene-graph">2.1 Render Service Updates the Scene Graph</h2>

<p>Inside Render Service, transactions mutate the authoritative render tree. This is where the retained scene graph truly lives.</p>

<p>Key nodes include:</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">RSDisplayNode</code> - the root node of the whole tree</li>
  <li><code class="language-plaintext highlighter-rouge">RSRenderNode</code> - elementary nodes that draw actual UI content.</li>
  <li><code class="language-plaintext highlighter-rouge">RSSurfaceRenderNode</code> - the root nodes of each window that own actual render surfaces.</li>
</ul>

<p>Relevant code paths:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>rosen/modules/render_service/core/pipeline/
rs_render_node.cpp
rs_surface_render_node.cpp
</code></pre></div></div>

<p>Now the compositor has a fully updated scene graph representing all windows and surfaces in the system.</p>

<h3 id="the-tree-optimizes-itself">The Tree Optimizes Itself</h3>

<p>The renderer aggressively optimizes the scene graph. The important thing to understand is:</p>

<blockquote>
  <p>The render tree is an optimization structure, not merely a hierarchy.</p>
</blockquote>

<p>The system continuously evaluates:</p>

<ul>
  <li>can nodes flatten?</li>
  <li>can surfaces merge?</li>
  <li>can layers cache?</li>
  <li>can redraws skip?</li>
  <li>can effects batch?</li>
</ul>

<h2 id="22-drawables-are-generated">2.2 Drawables Are Generated</h2>

<p>Eventually, nodes produce actual drawing commands. This is where the concept of a drawable becomes important.</p>

<p>Conceptually:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>RSNode parses its properties
    -&gt; Generate Corresponding Drawables
    -&gt; Generate GPU draw commands
</code></pre></div></div>

<p>Examples include:</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">RectDrawable</code></li>
  <li><code class="language-plaintext highlighter-rouge">ImageDrawable</code></li>
  <li><code class="language-plaintext highlighter-rouge">TextDrawable</code></li>
  <li><code class="language-plaintext highlighter-rouge">ShadowDrawable</code></li>
  <li><code class="language-plaintext highlighter-rouge">FilterDrawable</code></li>
</ul>

<h2 id="23-effects-become-render-passes">2.3 Effects Become Render Passes</h2>

<p>Effects are first-class citizens in Rosen:</p>

<ul>
  <li>blur</li>
  <li>backdrop blur</li>
  <li>shadows</li>
  <li>brightness</li>
  <li>saturation</li>
  <li>shader effects</li>
  <li>transitions</li>
</ul>

<p>Rosen models many effects directly in the render graph through <code class="language-plaintext highlighter-rouge">FilterDrawable</code>, which allows chaining multiple effects within a single draw pass. This is quite common in modern UI. For example,</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>FilterDrawable
  -&gt; Blur
  -&gt; Brightness
  -&gt; Saturation
</code></pre></div></div>

<p>These chained effect would require:</p>
<ul>
  <li>offscreen render targets</li>
  <li>intermediate textures</li>
  <li>multi-pass rendering</li>
</ul>

<p>Rosen records the draw commands and shader invocations of all the drawables via <code class="language-plaintext highlighter-rouge">Drawing</code> API, which encapsulates drawing library of <code class="language-plaintext highlighter-rouge">Skia</code> on open source devices and a proprietary drawing library on commercial HarmonyOS devices. These libraries issue low-level commands to multiple backends, including <code class="language-plaintext highlighter-rouge">OpenGL</code> and <code class="language-plaintext highlighter-rouge">Vulkan</code>. <code class="language-plaintext highlighter-rouge">Vulkan</code> is the default path today, because it allows more direct control over the GPU hardware. The call path is illustrated below:</p>

<p><img src="/assets/drawingAPI.png" alt="Drawing API call path" /></p>

<h3 id="why-partial-rendering-matters">Why Partial Rendering Matters</h3>

<p>Imagine an opacity animation of a small node on the screen.</p>

<p>Every animation frame, Rosen does:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>update opacity property
updates dirty nodes
propagate dirty nodes to calculate dirty region
reuse cached regions
redraw dirty regions only
</code></pre></div></div>
<p>Skipping cached regions save a lot of CPU and GPU cycles and keep the system performant.</p>

<h2 id="24-vsync-scheduling">2.4 VSync Scheduling</h2>

<p>Rendering is synchronized with display refresh. Render Service typically waits for VSync before committing composition.</p>

<div class="language-cpp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">while</span> <span class="p">(</span><span class="n">running</span><span class="p">)</span> <span class="p">{</span>
    <span class="n">wait_for_vsync</span><span class="p">();</span>
    <span class="n">apply_transactions</span><span class="p">();</span>
    <span class="n">composite</span><span class="p">();</span>
    <span class="n">present</span><span class="p">();</span>
<span class="p">}</span>
</code></pre></div></div>

<p>This is essential because it:</p>

<ul>
  <li>prevents tearing</li>
  <li>batches updates</li>
  <li>synchronizes animations</li>
  <li>stabilizes frame pacing</li>
</ul>

<p>Key concepts:</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">VSyncDistributor</code></li>
  <li><code class="language-plaintext highlighter-rouge">FrameScheduler</code></li>
  <li><code class="language-plaintext highlighter-rouge">RenderFrame</code></li>
</ul>

<p>Typical code paths:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>render_service/
vsync/
frame_scheduler/
</code></pre></div></div>

<p>The timing model is:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>property change
    -&gt; transaction queue
    -&gt; next VSync
    -&gt; composition
    -&gt; presentation
</code></pre></div></div>

<h2 id="31-surface-composition">3.1 Surface Composition</h2>

<p>At this point, Render Service owns multiple surfaces:</p>

<ul>
  <li>app window</li>
  <li>video surface</li>
  <li>system UI</li>
  <li>blur layers</li>
  <li>floating windows</li>
</ul>

<p>The compositor decides:</p>

<ul>
  <li>GPU composition?</li>
  <li>hardware overlay?</li>
  <li>cached texture reuse?</li>
  <li>direct scanout?</li>
</ul>

<h2 id="32-hardware-composer">3.2 Hardware Composer</h2>

<p>Eventually, composition reaches the hardware composer layer.</p>

<p>The hardware composer may:</p>

<ul>
  <li>directly scan out a video plane</li>
  <li>composite UI with GPU</li>
  <li>use overlay planes</li>
  <li>perform hardware scaling</li>
</ul>

<p>This is extremely important for efficiency. For example:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Video surface
    -&gt; hardware overlay

UI layer
    -&gt; GPU composition
</code></pre></div></div>

<p>This avoids expensive GPU blending for fullscreen video.</p>

<h2 id="33-final-presentation">3.3 Final Presentation</h2>

<p>Eventually:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>GPU command buffers submitted
    -&gt; framebuffer ready
    -&gt; display engine scanout
    -&gt; photons emitted
</code></pre></div></div>

<p>And your original:</p>

<pre><code class="language-arkts">.opacity(0.5)
</code></pre>

<p>finally becomes visible.</p>

<h2 id="summary">Summary</h2>

<p>The most important insight in the Rosen architecture is this:</p>

<blockquote>
  <p>Rosen separates UI semantics from rendering execution.</p>
</blockquote>

<p>The stages are intentionally decoupled:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>UI semantics
    -&gt; layout
    -&gt; scene graph
    -&gt; drawables
    -&gt; composition
    -&gt; presentation
</code></pre></div></div>

<p>This separation enables features like:</p>

<ul>
  <li>partial redraw</li>
  <li>advanced effects chaining</li>
  <li>multi-window composition</li>
  <li>distributed rendering</li>
  <li>GPU batching</li>
  <li>hardware overlays</li>
  <li>efficient animations at system scale</li>
</ul>

<p>We will talk about these features in more details in future episodes.</p>]]></content><author><name>Zireael</name></author><category term="HarmonyOS" /><summary type="html"><![CDATA[After working on the open source foundation of HarmonyOS for nearly four years, and seeing the OS launch, ship, and climb from 0 to 60M+ users, I think it is time to unpack the design and optimization work behind the system.]]></summary></entry></feed>