Frontend Development 26 min read

Cross‑Platform Self‑Rendering Engine: Design, Evolution, and Optimization with Skia and C++

This article documents the design, iterative evolution, and performance optimizations of a cross‑platform self‑rendering engine that started with QuickJS and Flutter, progressed through a C++‑based render tree, and now adopts a React Native‑style self‑draw approach using Skia, detailing architecture, code, and results.

JD Retail Technology
JD Retail Technology
JD Retail Technology
Cross‑Platform Self‑Rendering Engine: Design, Evolution, and Optimization with Skia and C++

The author, who has been working on a cross‑platform self‑rendering project, shares the motivations, challenges, and lessons learned to help developers dealing with rendering‑related tasks.

1. Motivation for Cross‑Platform Self‑Rendering

The goal is to build a backend rendering container that provides a unified solution for W3C‑compliant front‑end products, offering high performance, UI consistency, and broad compatibility.

2. Initial Exploration: QuickJS + Flutter

In mid‑2022 the team created a small‑program rendering endpoint using QuickJS to generate DOM commands and Flutter to render the UI. Tests on JD Home mini‑program showed mixed performance: Android pages were up to 20 % faster or 30 % slower than expected, iOS was about 10 % slower, and memory usage exceeded X5 WebView.

Analysis revealed bottlenecks such as slow JS runtime, complex render pipeline (JS ↔ Dart ↔ Flutter Engine), and limited optimization potential for QuickJS on embedded devices.

3. Evolution: Removing the Dart Layer

The team decided to replace the entire Flutter Dart framework with a lightweight C++ render‑object tree that talks directly to the Flutter Engine, eliminating FFI overhead and making the Dart VM redundant. This change aimed to improve memory usage and execution speed.

By early 2023 a C++ implementation of the simplified framework was completed, integrating a third‑party CSS parser, handling DOM/BOM APIs, layout (Yoga), drawing primitives, networking, image caching, and gesture handling. Performance tests on JD Supermarket H5 pages showed LCP of ~2440 ms on low‑end Android (close to native WebView) and ~1271 ms on iPhone 14 Pro (≈12 % slower than Safari).

4. New Solution: React Native‑Style Self‑Draw

The third iteration abandons browser‑engine‑based rendering and adopts a self‑draw pipeline compatible with W3C standards, similar to Weex2 or Lynx but using a custom drawing path.

In this approach the RN JS bundle runs on Hermes, but the middle layer intercepts rendering commands and draws directly with Skia, bypassing RN’s native view components. Expected benefits include faster startup, reduced development effort, and broader RN business support, while drawbacks involve potential loss of generic front‑end compatibility and new memory/performance risks.

5. Implementation Steps

Skia Integration : Initially tried Skia’s main branch, later switched to the Flutter‑branch for stability.

Rendering Pipeline : Two phases – traverse the view tree to generate draw commands, then execute them in the frame callback.

Surface Setup : Obtain ANativeWindow , configure buffers, create SkBitmap , and draw with SkCanvas and SkPaint .

// ShadowTree.cpp // RN 的提交阶段
CommitStatus ShadowTree::tryCommit(const ShadowTreeCommitTransaction &transaction, const CommitOptions &commitOptions) const {
  // ...
  auto newRootShadowNode = transaction(*oldRevision.rootShadowNode); // RN根据状态生成新树
  // ...
  newRootShadowNode->layoutIfNeeded(&affectedLayoutableNodes); // RN布局计算
  // ...
  auto rootView = newRevision.rootShadowNode; // 拿到根节点
  rootView->crossPaintRoot(); // 走我们自绘路径
  // ...
  return CommitStatus::Succeeded;
}

// SkiaSurfaceView.cpp
class SkiaSurfaceView {
  void render(ANativeWindow *nativeWindow, int width, int height, float density);
  static ANativeWindow *nativeWindow;
  static int width, height;
  static float density;
};

// LayoutableShadowNode.cpp
void LayoutableShadowNode::crossPaintStart() const {
  auto layoutMetrics = getLayoutMetrics();
  Point offset = layoutMetrics.frame.origin;
  Size size = layoutMetrics.frame.size;
  ANativeWindow_acquire(SkiaSurfaceView::nativeWindow);
  ANativeWindow_setBuffersGeometry(SkiaSurfaceView::nativeWindow, SkiaSurfaceView::width, SkiaSurfaceView::height, WINDOW_FORMAT_RGBA_8888);
  auto *buffer = new ANativeWindow_Buffer();
  SkBitmap bitmap;
  SkImageInfo image_info = SkImageInfo::MakeS32(buffer->width, buffer->height, SkAlphaType::kPremul_SkAlphaType);
  bitmap.setInfo(image_info, buffer->stride * 4);
  bitmap.setPixels(buffer->bits);
  SkCanvas skCanvas{bitmap};
  SkPaint skPaint;
  skCanvas.clear(SK_ColorWHITE);
  for (auto &child : getChildren()) {
    visitViewTree(1, *child, skCanvas, skPaint);
  }
  ANativeWindow_unlockAndPost(SkiaSurfaceView::nativeWindow);
  ANativeWindow_release(SkiaSurfaceView::nativeWindow);
}

void visitViewTree(int index, ShadowNode const &shadowNode, SkCanvas &canvas, SkPaint &paint) {
  shadowNode.crossPaint(canvas, paint);
  index++;
  for (auto &child : shadowNode.getChildren()) {
    visitViewTree(index, *child, canvas, paint);
  }
}

void ViewShadowNode::crossPaint(SkCanvas &canvas, SkPaint &paint) const {
  const SharedColor &backgroundColor = getConcreteProps().backgroundColor;
  const CascadedBorderColors &borderColors = getConcreteProps().borderColors;
  const CascadedBorderRadii &borderRadii = getConcreteProps().borderRadii;
  const CascadedBorderStyles &borderStyles = getConcreteProps().borderStyles;
  float density = SkiaSurfaceView::density;
  auto layoutMetrics = getLayoutMetrics();
  Point offset = layoutMetrics.frame.origin;
  offset = {offset.x * density, offset.y * density};
  Size size = layoutMetrics.frame.size;
  size = {size.width * density, size.height * density};
  paint.setAntiAlias(true);
  const SkRect &rect = SkRect::MakeXYWH(offset.x, offset.y, size.width, size.height);
  canvas.translate(offset.x, offset.y);
  if (borderColors.all.has_value() && borderStyles.all.has_value()) {
    float cornerRadius = borderRadii.all ? *borderRadii.all : 0;
    auto borderWidth = layoutMetrics.borderWidth.top * density;
    paint.setStrokeWidth(borderWidth);
    auto bStyle = *borderStyles.all;
    switch (bStyle) {
      case BorderStyle::Dotted:
        paint.setStyle(SkPaint::kStroke_Style);
        break;
      // ...
    }
    const SkRRect &rRect = SkRRect::MakeRectXY(rect, cornerRadius, cornerRadius);
    canvas.drawRRect(rRect, paint);
  }
  if (backgroundColor) {
    paint.setColor(*backgroundColor);
    paint.setStyle(SkPaint::kFill_Style);
    canvas.drawRect(rect, paint);
  }
}

Layer Architecture : Introduced classes such as PictureLayer , ContainerLayer , StackingContextContainerLayer , and StackingContext to represent the drawing command hierarchy, support opacity, clipping, and ordering.

// Layer base class
class Layer {
public:
  virtual void composeToSceneCanvas(SkCanvas &canvas) const {};
  virtual void translateTo(const Point &originPoint, const std::optional
&clipRect = std::nullopt) {}
};

// PictureLayer
class PictureLayer : public Layer {
public:
  PictureLayer(sk_sp<SkPicture> picture) : picture_(picture) {}
  void composeToSceneCanvas(SkCanvas &canvas) const override;
private:
  sk_sp<SkPicture> picture_;
};

// ContainerLayer
class ContainerLayer : public Layer {
public:
  ContainerLayer(Rect &rect) : rect_(rect) {}
  QueueOfLayer getChildren() const { return children_; }
  Canvas &getCanvas();
  void finishRecorderIfNeeded();
  void pushLayer(const Layer::Shared &layer);
  void composeToSceneCanvas(SkCanvas &canvas) const override;
  void translateTo(const Point &originPoint, const std::optional
&clipRect = std::nullopt) override { rect_.origin = originPoint; }
private:
  Rect rect_;
  std::unique_ptr<SkPictureRecorder> recorder_;
  std::unique_ptr<Canvas> canvas_;
  QueueOfLayer children_;
};

// StackingContextContainerLayer
class StackingContextContainerLayer : public Layer {
public:
  StackingContextContainerLayer(Rect &&rect, std::unique_ptr<LayerEffectParams> &&effectParams = nullptr)
    : rect_(rect), effectParams_(std::move(effectParams)) {}
private:
  Rect rect_;
  std::map<StackingContextLevel, ContainerLayerUnShared> children_;
  std::unique_ptr<LayerEffectParams> effectParams_;
  std::optional
clipRect_{};
};

// StackingContext
class StackingContext {
public:
  StackingContext(StackingContextContainerLayer::UnShared &container) : container_(container) {};
  StackingContext createChildStackingContext(StackingContextContainerLayer::UnShared &container, StackingContextLevel level);
  void reuseLayer(Layer::Shared &layer, StackingContextLevel level);
  Canvas &getCanvas(StackingContextLevel level);
  void closeStackingContext();
  std::shared_ptr<StackingContextContainerLayer> getStackingContextContainerLayer() const { return container_; }
  void setClipRect(const Rect &clipRect) { container_->setClipRect(clipRect); }
  void restoreClipRect() { container_->restoreClipRect(); }
private:
  std::shared_ptr<StackingContextContainerLayer> container_;
};

Rendering Logic : Traverses the shadow tree, creates layers, handles opacity, clipping, and stacking order, and finally replays the recorded SkPicture on the real SkCanvas during the frame callback.

void performChildrenPaint(StackingContext &stackingContext, Point layerOffset, const std::optional
&clipRect_ = std::nullopt) const {
  if (clipRect_) { stackingContext.setClipRect(*clipRect_); }
  for (auto const &childNode : getChildren()) {
    ParentContext parentContext = buildParentContext();
    childNode->checkCurrentLevelAndStackingContext(parentContext);
    if (auto layer = childNode->getLayer()) {
      auto mutableLayer = std::const_pointer_cast<Layer>(layer);
      mutableLayer->translateTo(layerOffset, stackingContext.getStackingContextContainerLayer()->getClipRect());
      stackingContext.reuseLayer(layer, childNode->currentLevel());
    } else {
      childNode->performSelfPaint(parentContext, stackingContext, layerOffset);
    }
  }
  if (clipRect_) { stackingContext.restoreClipRect(); }
}

void ViewShadowNode::performSelfPaint(const ParentContext &parentContext, StackingContext &stackingContext, Point layerOffset) const {
  if (isStackingContext()) {
    auto stackingContextContainerLayer = std::make_shared<StackingContextContainerLayer>(Rect{layerOffset, getLayoutMetrics().frame.size}, std::move(getEffectParams()));
    auto childStackingContext = stackingContext.createChildStackingContext(stackingContextContainerLayer, currentLevel_);
    paintBackground(childStackingContext, getLayoutMetrics().frame.origin, StackingContextLevel::BACKGROUND);
    performPaint(parentContext, childStackingContext, getLayoutMetrics().frame.origin);
    stackingContextContainerLayer->closeStackingContext();
    saveLayer(stackingContextContainerLayer);
  } else {
    Point offset = getLayoutMetrics().frame.origin + layerOffset;
    paintBackground(stackingContext, offset, currentLevel_);
    performPaint(parentContext, stackingContext, offset);
  }
}

The final system integrates Web‑standard concepts such as Block Formatting Context (BFC) and Stacking Context into the cross‑platform renderer, resolves view‑layer inconsistencies, improves rendering efficiency, and adds a hierarchical logging mechanism for easier debugging.

Conclusion

The described optimizations and architectural changes have transformed the project from a simple QuickJS‑Flutter prototype into a robust, Skia‑driven self‑draw engine that aligns with web standards, achieves competitive performance, and provides a solid foundation for future cross‑platform UI development.

frontendperformancecross-platformRenderingC++React NativeSkia
JD Retail Technology
Written by

JD Retail Technology

Official platform of JD Retail Technology, delivering insightful R&D news and a deep look into the lives and work of technologists.

0 followers
Reader feedback

How this landed with the community

login Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.