Skip to content
academia.sh

Lesson 19 / 20

Writing Native Modules

A native module is the bridge through which the JavaScript side calls platform-specific code. The lesson measures that a native map view is a single node in the tree and that this node is updated when the center changes.

Published Updated

In this lesson

  • Shows with a bridge function how a native module is called from the JavaScript side
  • Measures that a native view is a single node in the tree
  • Shows that when a prop changes the native view node is updated and gives a single command
  • Explains that the inner details of the native layer receive no fate in the rule
Contents

The previous lesson measured tests that look at the tree. There is one more layer beneath the tree: when a feature is needed that the framework does not provide ready-made, for example a map or a sensor, platform-specific code is written and opened to the JavaScript side. This lesson measures how many nodes such code is in the tree and what happens to it when a prop changes.

The course’s question: what does this update do to the node in the view tree? The answers are kept, updated, mounted, and unmounted; the sum of everything other than kept is the commands. The rule is in the core file.

// fate.mjs — classifies what an update does to each node of the view tree
export const FATES = ["kept", "updated", "mounted", "unmounted"];

export function el(type, props = {}, children = [], key = null) {
  return { type, props, children, key };
}

function segment(node) {
  return node.key === null ? node.type : `${node.type}[${node.key}]`;
}

function identity(node, index) {
  return node.key === null ? `#${index}` : `key:${node.key}`;
}

function sameProps(a, b) {
  const names = [...new Set([...Object.keys(a), ...Object.keys(b)])].sort();
  return names.every((n) => JSON.stringify(a[n]) === JSON.stringify(b[n]));
}

function whole(node, path, fate, ops) {
  const here = `${path}/${segment(node)}`;
  ops.push({ fate, path: here });
  node.children.forEach((child) => whole(child, here, fate, ops));
}

export function reconcile(prev, next, path = "", ops = []) {
  if (prev === null) {
    whole(next, path, "mounted", ops);
    return ops;
  }
  if (next === null) {
    whole(prev, path, "unmounted", ops);
    return ops;
  }
  if (prev.type !== next.type || prev.key !== next.key) {
    whole(prev, path, "unmounted", ops);
    whole(next, path, "mounted", ops);
    return ops;
  }
  const here = `${path}/${segment(next)}`;
  ops.push({ fate: sameProps(prev.props, next.props) ? "kept" : "updated", path: here });
  const before = new Map(prev.children.map((child, i) => [identity(child, i), child]));
  const matched = new Set();
  next.children.forEach((child, i) => {
    const id = identity(child, i);
    matched.add(id);
    reconcile(before.has(id) ? before.get(id) : null, child, here, ops);
  });
  prev.children.forEach((child, i) => {
    if (!matched.has(identity(child, i))) reconcile(child, null, here, ops);
  });
  return ops;
}

export function printOps(ops) {
  ops.forEach((op) => console.log(`${op.fate}: ${op.path}`));
}

export function countFates(ops) {
  for (const name of FATES) {
    console.log(`${name}: ${ops.filter((op) => op.fate === name).length}`);
  }
  console.log(`commands: ${ops.filter((op) => op.fate !== "kept").length}`);
}

A native module has two parts. The first is the code on the platform side: Swift or Objective-C for iOS, Kotlin for Android. The second is the bridge on the JavaScript side: it calls a native function by name and takes the result back. The file below models the bridge and a single native module. Each call writes a line, so it is visible when the native side is entered. The screen puts the native map view into the tree with a single node named NativeMap.

// bridge.mjs — model of the native module and the bridge that calls it
import { el } from "./fate.mjs";

const MODULES = {
  Geo: { label: (lat, lng) => `${lat},${lng}` },
};

export function callNative(moduleName, fnName, ...args) {
  console.log(`native call: ${moduleName}.${fnName}`);
  return MODULES[moduleName][fnName](...args);
}

export function renderScreen(center) {
  const label = callNative("Geo", "label", center.lat, center.lng);
  return el("Screen", {}, [
    el("Header", { title: "Map" }),
    el("NativeMap", { label }),
  ]);
}

MODULES stands in for the native side: the Geo module has a single function and produces a label from a position. callNative finds this function by module and function name, writes the call, and returns the result. renderScreen takes the label from the center and puts the map into the tree as a single node.

First Launch

When the screen is built for the first time, the question is what happens in the tree. There is no previous tree, so every node is mounted.

// main.mjs
import { reconcile, printOps } from "./fate.mjs";
import { renderScreen } from "./bridge.mjs";

printOps(reconcile(null, renderScreen({ lat: 41, lng: 29 })));
native call: Geo.label
mounted: /Screen
mounted: /Screen/Header
mounted: /Screen/NativeMap

The native call was made once. Three nodes were mounted in the tree; the map is one of them. Many things can be inside the map: layers, markers, scroll and zoom state. The tree sees none of them; for the rule, the map is a single node.

When the Center Changes

The user moves the map somewhere else. The JavaScript side gives the new center and the screen is built again. The map’s type and key stay the same; only its label changes.

// main.mjs
import { reconcile, printOps } from "./fate.mjs";
import { renderScreen } from "./bridge.mjs";

const before = renderScreen({ lat: 41, lng: 29 });
const after = renderScreen({ lat: 39, lng: 32 });
printOps(reconcile(before, after));
native call: Geo.label
native call: Geo.label
kept: /Screen
kept: /Screen/Header
updated: /Screen/NativeMap

Two screens were built and two native calls were made. The map came out updated; the header and the root stayed kept. An updated node keeps its identity: the native map view was not created again, the same view received the new prop. The map’s scroll and zoom state therefore continues.

Same Center

If the center does not change, the tree gives no command. This separates the effect of a native call on the tree from the number of native calls.

// main.mjs
import { reconcile, countFates } from "./fate.mjs";
import { renderScreen } from "./bridge.mjs";

const before = renderScreen({ lat: 41, lng: 29 });
const after = renderScreen({ lat: 41, lng: 29 });
countFates(reconcile(before, after));
native call: Geo.label
native call: Geo.label
kept: 3
updated: 0
mounted: 0
unmounted: 0
commands: 0

Two native calls were made but zero commands went out. A native call and a tree command are not the same thing: a call is going down to the native side, while a command is changing a node in the tree. A call may or may not give rise to a command.

When the Map Leaves the Screen

Until now the map was always in the tree. If the user changes the screen, the map leaves the tree. This is the counterexample that shows where the life of a native view ends in the tree.

// main.mjs
import { el, reconcile, printOps } from "./fate.mjs";
import { renderScreen } from "./bridge.mjs";

const before = renderScreen({ lat: 41, lng: 29 });
const after = el("Screen", {}, [el("Header", { title: "Map" })]);
printOps(reconcile(before, after));
native call: Geo.label
kept: /Screen
kept: /Screen/Header
unmounted: /Screen/NativeMap

The header and the root stayed kept; the map came out unmounted. An updated map would keep its identity, while an unmounted map loses it: the native view’s scroll and zoom state goes away with it. If the user returns to the map, a new node is mounted and the map starts from the state it had when first opened. The native call was made only once, because going down to the module was not needed to build the new tree.

How the Native Layer Looks in the Source

The platform side of a native module is code the tree never sees. The two snippets below briefly show the iOS and Android forms of the same Geo module; the model does not run them.

// Platform code: GeoModule (iOS)
@objc(GeoModule)
class GeoModule: NSObject {
  @objc func label(_ lat: Double, lng: Double,
                   resolve: RCTPromiseResolveBlock,
                   reject: RCTPromiseRejectBlock) {
    resolve("\(lat),\(lng)")
  }
}
// Platform code: GeoModule (Android)
class GeoModule(context: ReactApplicationContext) :
  ReactContextBaseJavaModule(context) {
  override fun getName() = "Geo"

  @ReactMethod
  fun label(lat: Double, lng: Double, promise: Promise) {
    promise.resolve("$lat,$lng")
  }
}

The two snippets carry the same contract: the module has a name, the function takes two numbers, and it returns the result to the JavaScript side with a promise object. This duality between platforms is an example of the distribution question in M32/K01: part of the code is written twice. The tree does not see this duality; on both platforms the screen is built with the NativeMap node.

Reading the Source Snippets Line by Line

The two snippets write the same contract in two languages; the lines match one to one.

  • @objc(GeoModule) and getName() = "Geo" determine under what name the module appears on the JavaScript side. The model shows this with the Geo key of the MODULES object. If the name is not the same on the two platforms, the JavaScript side cannot find the module.
  • label(lat, lng, ...) is the function itself. In both snippets it takes two numbers; on the Swift side there are additionally the resolve and reject blocks, and on the Kotlin side a promise object. The label: (lat, lng) => ... in the model is only the common core of these.
  • resolve("\(lat),\(lng)") and promise.resolve("$lat,$lng") give the result back to the JavaScript side. The model does the same job with return; the difference is that in reality the result returns with a promise object and asynchronously.

What the lines show is this: writing a native module comes down to keeping the contract the same on both sides. If the name, the parameter order, and the returned value diverge between the platforms, the error appears not in the tree but in the bridge.

Common Misconceptions

The first misconception is that a native module is a view. The Geo module above draws nothing on the screen; it only offers a function. A view is a separate matter: the native view that draws the map is represented in the tree by the NativeMap node. A module can be without a view, and a view can be a set of props without a module.

The second misconception is that every native call gives rise to a command. The Same Center measurement showed that two calls gave zero commands. A call is going down to the native side; a command is a change of a node in the tree. Reducing the number of calls does not directly reduce the number of commands.

The third misconception is that the inside of a native view shows in the tree. Even if the map draws hundreds of layers, it is a single node in the tree. Looking for a fate for each of the layers is futile: the rule classifies only the nodes in the tree.

Module or View

Before deciding to write native code, two questions are asked. The first: is what is needed a function or a view? Reading the position, encrypting a file, or getting a sensor’s value is a function; these take no space on the screen and have no nodes in the tree. A map, a camera preview, or a video player is a view; it takes space on the screen and is represented by a node in the tree.

The second: does this feature really need native code? Writing a new module for a feature that the platform offers and the framework already wraps means maintaining two code bases on two platforms. The distribution question of M32/K01 returns here: every feature written in native code enlarges the unshared part of the code. For this reason, writing a module is preferred when there is no ready-made solution and the feature is really needed.

The answers to the two questions also determine the trace in the tree. A module without a view never touches the tree; it goes down to the native side only when it is called. A module with a view leaves a node in the tree, and that node’s life is written with the fates mounted, updated, and unmounted, as this lesson’s measurements showed.

The Limit of the Bridge

The bridge is a passage between two worlds, and every crossing has a contract. The JavaScript side can give only a name and simple values; the native side can answer only with simple values or a promise object. The objects of the two sides do not pass directly to each other. This is why the label function in the model returns a text, not an object.

When the contract is broken, the error often appears at run time, at the moment of the call. If MODULES[moduleName][fnName] is called in the model with a name that does not exist, JavaScript raises an error; in the real bridge too, if the module is not found, the call fails. What catches this error is the error boundary built in a previous lesson; but the boundary catches only errors during rendering. If the call is made from a touch handler, the error must be caught separately.

This limit also explains the trace in the tree. A value that passes through the bridge enters the tree only as a prop; in the model, label first returns from the native call and then becomes the prop of the NativeMap node. The tree sees not the call itself but the value the call leaves in the tree. If the same value arrives, the node stays kept; if a different value arrives, it is updated. Keeping the value returned from the bridge simple therefore also prevents unnecessary updates in the tree.

The Bounding Measurement

The last measurement shows that the inner details of the native layer receive no fate in the rule. The center changes; the rule is filtered only to the path /Screen/NativeMap. There are no nodes in the tree for the layers and markers inside the map, so there is no record for them either.

// main.mjs
import { reconcile, printOps } from "./fate.mjs";
import { renderScreen } from "./bridge.mjs";

const before = renderScreen({ lat: 41, lng: 29 });
const after = renderScreen({ lat: 39, lng: 32 });
const ops = reconcile(before, after);
printOps(ops.filter((op) => op.path === "/Screen/NativeMap"));
console.log(`commands: ${ops.filter((op) => op.fate !== "kept").length}`);
native call: Geo.label
native call: Geo.label
updated: /Screen/NativeMap
commands: 1

The focus node is /Screen/NativeMap. It came out updated and a single command went out. How many layers were drawn inside the map or how many markers were moved does not enter this number: the native side does that work, and the tree knows only that the map node’s prop changed.

One consequence of this limit is the following: the tree does not show an error in the native layer. Even if the map shows a wrong place, as long as its prop arrives correctly, nothing looks wrong in the tree. An error in the native layer is looked for with native tools.

The result of this is: if a native view is expensive, the cost does not show in the number of commands in the tree. The rule says that the map node’s prop changed; it does not say what the native side does with that change. The count in the tree and the cost of the native work are measured separately.

PP6. The model builds this fate’s rule; the model is not the framework itself. In the model, a native module is a function inside an object and a call is a synchronous function call. A real native module can run on a separate thread, returns with a promise, and requires the platform code to be compiled; how long the call takes and on which thread it runs are outside the model. In the model, the map is represented by a single label; the prop set of a real native view is much wider, and the change of each prop carries its own rule.

Summary

  • This lesson’s fate: updated. When the center changed, the /Screen/NativeMap node was updated and a single command went out.
  • A native view is a single node in the tree; the layers, markers, and state inside it receive no fate for the rule.
  • A native call is not a command: two calls can give zero commands.
  • The same module is written separately for iOS and Android; in the tree both appear with the same node.

Next Step

Once native code is written, the application is no longer only JavaScript: the platform side is compiled, signed, and goes to the app store. The next lesson, Build and Release, is the course’s wrap-up and covers this road from start to finish.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ ↵ Esc navigate · open · close