---
title: 'Cross-Platform Approaches'
source: 'https://academia.sh/en/courses/cross-platform-approaches'
language: en
updated: '2026-10-21T05:02:49+00:00'
license: 'CC BY-SA 4.0'
lessons: 10
outcomes:
    - 'Classifies cross-platform approaches by their working model'
    - 'Evaluates the limits and cost of code sharing'
    - 'Ties the choice of approach for a project to criteria'
---

# Cross-Platform Approaches

Every way of building an app for two platforms from a single codebase shares a feature's code between the platforms in some form: a WebView draws the interface from a single page, the bridge-based approach binds shared code to platform components, the approach with its own rendering engine draws every pixel itself, and the shared-logic approach shares only the business rules. The course measures every approach and every platform reality with a single question — how is this feature's code shared between the platforms? The working model of the approaches, selection criteria, platform design languages, the need for native modules, performance expectations and store constraints all pass through the same frame.

## Approaches

The case for cross-platform development, the WebView-based, bridge-based and own-rendering-engine approaches, the split between shared logic and native UI, and approach selection criteria.

### The Case for Cross-Platform

Source: https://academia.sh/en/courses/cross-platform-approaches/cross-platform-rationale

The case for cross-platform development is how many separate codes the same feature is written in on two platforms. The lesson classifies the sharing of a feature's code with four answers: shared, branched, split, single.

The previous two course series built an app twice from start to finish: once for one platform, once for the other. Read side by side, the two series show the same features written twice. The function that formats a price, the call that requests a permission, the response to the back button: each exists in both codebases. This course is about why that repetition exists, what it costs, and the ways proposed to reduce it.

The single question of the course starts in this lesson: **how is this feature's code shared between the platforms?** The answer takes four forms, and their names do not change for the rest of the course. If the feature runs the same code on both platforms and that code gives the same result on both platforms for every sample, the sharing is **shared**. If the same code runs but gives a different result on the platforms for at least one sample, the sharing is **branched**. If each platform has its own code, even when the results are identical, the sharing is **split**. If the feature's code is missing entirely on one of the platforms, the sharing is **single**. The umbrella name for the four answers is sharing; when someone speaks of a feature's sharing, one of these four is meant.

This question resembles the "what determines this result" question from M13/K04, but it is not the same. There the platform was an environment that determined the result, and what was measured was the source of the result. Here the source of the result is not asked about: what is asked is how many separate codes the same feature is written in, and on which platforms.

#### The Rule of Sharing

A label does not give the sharing; a rule does. The core below takes a feature's implementation on the two platforms and a list of samples. If a platform has no implementation, the sharing is single. If the two implementations are separate function objects, it is split. If it is the same function and no sample makes the two platforms' results differ, it is shared; if some sample does, it is branched.

```javascript
// sharing.mjs — classifies how a feature's code is shared between the platforms
export const PLATFORMS = ["ios", "android"];

export class Sharing {
  constructor(label, impl, samples) {
    this.label = label;
    this.total = samples.length;
    this.missing = PLATFORMS.filter((p) => typeof impl[p] !== "function");
    const present = PLATFORMS.filter((p) => !this.missing.includes(p));
    this.code = new Set(present.map((p) => impl[p])).size;
    if (this.missing.length > 0) {
      this.differ = null;
      this.verdict = "single";
      return;
    }
    this.differ = samples.filter((s) => {
      const outs = PLATFORMS.map((p) => JSON.stringify(impl[p](s, p)));
      return new Set(outs).size > 1;
    }).length;
    if (this.code > 1) this.verdict = "split";
    else this.verdict = this.differ === 0 ? "shared" : "branched";
  }

  line() {
    const detail = this.differ === null
      ? `missing=${this.missing.join(",")}`
      : `diff=${this.differ}/${this.total}`;
    console.log(`${this.label}: code=${this.code} | ${detail} -> ${this.verdict}`);
  }
}

export function countSharing(items) {
  for (const name of ["shared", "branched", "split", "single"]) {
    console.log(`${name}: ${items.filter((s) => s.verdict === name).length}`);
  }
}
```

`PLATFORMS` is the platform list; the `Sharing` class takes a feature's label, its implementations, and its samples. The `code` property holds how many separate function objects exist, and the `differ` property holds in how many samples the result differs by platform. `countSharing` counts the four sharings in a fixed order and prints a zero as well. The rule is the only place that does this work: a sharing is never assigned by hand.

The rule does not see two things. The first is cost: a three-line split and a three-hundred-line split count the same. The second is the sample that is not given: the rule sees only what the given samples show. The lesson text, not the rule, speaks about cost and about the choice of samples.

The measurement in the lessons is built as follows. A feature's implementation on the two platforms is written as a table of functions: a function under the `ios` key, a function under the `android` key. The table does not run a real compiler or a real platform.

**AP1.** The model builds this sharing's rule; the model is not the cross-platform framework itself. On a platform, a feature's code lives in a file, a build target, or a module. In the model, the code itself is a function, and saying "the same code" on two platforms means pointing at the same function object in the table.

#### Four Features, Four Sharings

Take four features of an app: the function that formats a price, the function that shortens a title, a permission request, and the back button. In the first, both platforms run the same function and the function ignores the platform. In the second, the same function runs but the shortening limit changes with the platform. In the third, each platform has its own permission function; even if both produce the same text, two separate functions were written. In the fourth, the back button exists only on Android, and there is no function on the iOS side. Each feature is measured with four samples.

```javascript
// main.mjs
import { PLATFORMS, Sharing, countSharing } from "./sharing.mjs";

function formatPrice(cents) {
  return `${Math.floor(cents / 100)}.${String(cents % 100).padStart(2, "0")}`;
}

function shortTitle(title, platform) {
  const limit = platform === "ios" ? 12 : 16;
  return title.length > limit ? title.slice(0, limit) + "..." : title;
}

function iosPermission(kind) {
  return `permission:${kind}`;
}

function androidPermission(kind) {
  return `permission:${kind}`;
}

function handleBack(screen) {
  return screen === "home" ? "exit" : "back";
}

const prices = [0, 199, 1250, 99999];
const titles = ["Coffee", "Weekly budget", "Shopping list", "Annual subscription renewal"];
const kinds = ["notification", "camera", "location", "contacts"];
const screens = ["home", "cart", "checkout", "profile"];

const features = [
  new Sharing("price-format", { ios: formatPrice, android: formatPrice }, prices),
  new Sharing("title-shortening", { ios: shortTitle, android: shortTitle }, titles),
  new Sharing("permission-request", { ios: iosPermission, android: androidPermission }, kinds),
  new Sharing("back-button", { android: handleBack }, screens),
];
console.log(`platforms: ${PLATFORMS.join(", ")}`);
features.forEach((f) => f.line());
countSharing(features);
```

```text
platforms: ios, android
price-format: code=1 | diff=0/4 -> shared
title-shortening: code=1 | diff=3/4 -> branched
permission-request: code=2 | diff=0/4 -> split
back-button: code=1 | missing=ios -> single
shared: 1
branched: 1
split: 1
single: 1
```

The four features gave four different sharings: shared 1, branched 1, split 1, single 1. This distribution is the binding number of the course; the sample count is 4 and the platform count is 2. Later lessons do not print this number again; they refer to it.

`price-format` is shared. `code=1` says the two platforms run the same function, and `diff=0/4` says the four prices are written the same on both. `title-shortening` is branched: the code is still one, but `shortTitle` chooses the limit by platform. "Weekly budget" is thirteen characters: on iOS it is cut after the twelfth character, on Android it is not cut. "Shopping list" is also thirteen characters and is cut the same way, on iOS only. "Annual subscription renewal" is twenty-seven characters; it is cut on both platforms, but at different places. Of the four titles, three differ in the result. `permission-request` is split: `code=2` means two separate functions, and `diff=0/4` shows that for now the two say the same thing. `back-button` is single: `missing=ios` says there is no iOS implementation, and `diff` cannot be computed.

#### Where the Rationale Comes From

The cross-platform decision usually comes from three observations. The first is team size: two codebases mean two skill sets and often two teams. The second is product consistency: writing the same rule separately on two platforms carries the risk that the rule gives different results on the two platforms. The third is release rhythm: a feature can ship on one platform and wait weeks for the other. All three are real reasons, and all three point to the same place in the measurement: reducing the number of split and single features.

The other side of the rationale can also be read from the same measurement. A branched feature carries the difference between the two platforms inside it even when the code is one; whoever writes that difference has to know both platforms. A single feature does not become shared even if the codebase is merged, because the capability the shared code would run on may not exist on one platform. For this reason the cross-platform decision is not made as "reduce two codes to one" but with the question, for each feature, "which sharing does it fall into". The following lessons ask this question for each of the four approaches and tie it to approach selection in the last lesson.

#### The Cost of Two Codebases

The typical picture of a split sharing is this: the same rule is written twice, in two languages. The two pieces below are the split form of a price formatter. The result is the same; the code was written twice.

```swift
// Platform code: PriceFormatter
import Foundation

func formatPrice(cents: Int) -> String {
    String(format: "%d.%02d", cents / 100, cents % 100)
}
```

```kotlin
// Platform code: PriceFormatter
fun formatPrice(cents: Int): String =
    "%d.%02d".format(cents / 100, cents % 100)
```

These two pieces are not measured; the model builds this sharing's rule and does not run a platform compiler. They are still enough to show the cost. Someone who changes the rule touches two files, tests the two files with two separate test setups, and ships them in two separate releases. The two codes give the same result at first; when one is forgotten, they drift apart. A drifted split is a defect that becomes harder to find as the team grows.

The promise of a single codebase is to pay this cost once. The rule is written in one place, tested in one place, and goes to both platforms together. The limit of the promise is also visible in the measurement: even when the code stays single, a feature like `title-shortening` branches by platform, and the branch needs maintenance too. Increasing sharing does not make a feature behave the same for every sample.

#### The Same Cart, Two Platforms

The cart total is a good example of a shared rule. Each line in the cart carries a unit price and a quantity, the total is found in cents, and it is formatted when written to the screen. The function never reads the platform. In the same block we also add a constant that does not depend on its input: the shop name. A function that returns a constant is legitimate, but it always gives shared, because no input can change the result. This is not a guarantee but a natural consequence of the rule.

```javascript
// main.mjs
import { Sharing, countSharing } from "./sharing.mjs";

function cartTotal(lines) {
  return lines.reduce((sum, [unitCents, quantity]) => sum + unitCents * quantity, 0);
}

function shopLabel() {
  return "Corner Ledger";
}

const carts = [
  [[199, 2], [1250, 1]],
  [[99, 3]],
  [[0, 5], [500, 1]],
  [[99999, 1], [1, 1]],
];

const features = [
  new Sharing("cart-total", { ios: cartTotal, android: cartTotal }, carts),
  new Sharing("constant-label", { ios: shopLabel, android: shopLabel }, carts),
];
features.forEach((f) => f.line());
countSharing(features);
```

```text
cart-total: code=1 | diff=0/4 -> shared
constant-label: code=1 | diff=0/4 -> shared
shared: 2
branched: 0
split: 0
single: 0
```

Both features came out shared, and `diff` is zero for both. `cart-total` is shared because the function looks only at its own input. `constant-label` is shared because its result depends on no input. The two are not shared for the same reason: in the first, the function really processed the input and gave the same result on both platforms; in the second, there was nothing to process. This difference is not visible in the rule, because the rule looks only at the `diff` value. The reader's job is to ask, when a feature comes out shared, "what did this feature do".

**AP2.** The unit prices in the cart lines are integers in cents; the model does not know decimal and currency rules. On a platform these rules come through locale and formatter classes; in the model, the total is only an integer sum.

#### The Bounding Measurement

Now the same function is measured twice. Think of the badge text of an app icon: if the unread count exceeds a limit that differs by platform, it turns into "99+" or "999+". The badge function reads the platform and carries a branch. It is first measured with three samples that never touch the branch: zero, five, and forty-two unread messages. Then the same function is measured with four samples: one hundred fifty is added to the list.

```javascript
// main.mjs
import { Sharing, countSharing } from "./sharing.mjs";

function badgeText(count, platform) {
  const limit = platform === "ios" ? 99 : 999;
  return count > limit ? `${limit}+` : String(count);
}

const quiet = [0, 5, 42];
const busy = [0, 5, 42, 150];

const features = [
  new Sharing("badge-3-samples", { ios: badgeText, android: badgeText }, quiet),
  new Sharing("badge-4-samples", { ios: badgeText, android: badgeText }, busy),
];
features.forEach((f) => f.line());
countSharing(features);
```

```text
badge-3-samples: code=1 | diff=0/3 -> shared
badge-4-samples: code=1 | diff=1/4 -> branched
shared: 1
branched: 1
split: 0
single: 0
```

The function did not change; `badgeText` is the same function in both measurements, and `code=1` stays. Only the sample list changed. The first three samples stayed below the branch's threshold, so the two platforms gave the same text and the sharing came out shared. The sample of one hundred fifty exceeds the iOS threshold and does not exceed the Android threshold; iOS produces "99+" while Android produces "150", and the sharing turned branched.

This result has two readings, and both are correct. The first is that the rule sees only what the given samples show: a branch looks shared until a sample touches it. Before saying a feature is shared, it is necessary to make sure the sample that triggers the branch is in the list. The second is the limit of the cross-platform rationale: keeping the code single does not remove the branch. The branch has moved inside the code; it has not left the rule.

**AP3.** The two thresholds, 99 and 999, are inputs of the model. In a real app, the badge limit is a platform and product decision; in the lesson these numbers are not presented as a platform rule.

#### Summary

- This lesson's sharing: shared. The lesson introduces the four sharings, and the cart total runs on both platforms with the same function, without reading the platform.
- In the core measurement, four features gave four sharings: shared 1, branched 1, split 1, single 1; the sample count is 4 and the platform count is 2.
- A split is the same rule written twice; even when the results are the same, maintenance is spread over two files and two test setups.
- A function that returns a constant always comes out shared, because no input changes the result.
- The same badge function came out shared with three samples and branched once the fourth sample was added: the rule sees only what the given samples show, and it does not see what it costs.

#### Next Step

This lesson established the codebase count and the concept of sharing. Cross-platform approaches change this concept with different mechanisms. The next lesson, `The WebView-Based Approach`, measures the mechanism that draws the interface from a single page, and how the input the page takes from the platform shell changes the sharing.

### The WebView-Based Approach

Source: https://academia.sh/en/courses/cross-platform-approaches/webview-based-approach

The WebView-based approach draws the interface from a single page and places the page inside a native shell on each platform. The lesson measures the sharing of this mechanism and shows how the input the page takes from the shell changes the sharing.

The previous lesson introduced the four sharings and taught how to classify how a feature's code stands on two platforms. This lesson runs the first approach through that classification: the WebView-based approach. The question of the course does not change: **how is this feature's code shared between the platforms?** And the lesson asks this question of a mechanism.

A WebView is a view placed inside a native app that displays a web page. The app's interface is this page: the list, the form, and the button are drawn inside the page. The native part surrounding the page is called the host shell. The shell carries the app's launch, the window, the package that goes to the app store, and the channel that talks to the page. This mechanism is also called a hybrid app. The page is written once and loaded on both platforms; the shell is written separately on each platform but kept thin.

This mechanism piles most of the sharing onto the page. Every function in the page is the same function on both platforms, because both shells load the same file. The measure of the lesson is to find where this breaks.

#### The Page's Own Code

The first function to read is the one that draws a list row. A row carries a title, a count, and an index; from these the function produces a row to be written into the page. The same page also builds the message to be sent to the shell when the row is tapped. Both functions look only at their inputs and do not read the platform.

**AP1.** The model builds this sharing's rule; the model does not run a WebView or a page. In the model, the page code is the same function object on both platforms, and a row is a plain object with a title, a count, an index, and a pinned flag.

```javascript
// sharing.mjs — classifies how a feature's code is shared between the platforms
export const PLATFORMS = ["ios", "android"];

export class Sharing {
  constructor(label, impl, samples) {
    this.label = label;
    this.total = samples.length;
    this.missing = PLATFORMS.filter((p) => typeof impl[p] !== "function");
    const present = PLATFORMS.filter((p) => !this.missing.includes(p));
    this.code = new Set(present.map((p) => impl[p])).size;
    if (this.missing.length > 0) {
      this.differ = null;
      this.verdict = "single";
      return;
    }
    this.differ = samples.filter((s) => {
      const outs = PLATFORMS.map((p) => JSON.stringify(impl[p](s, p)));
      return new Set(outs).size > 1;
    }).length;
    if (this.code > 1) this.verdict = "split";
    else this.verdict = this.differ === 0 ? "shared" : "branched";
  }

  line() {
    const detail = this.differ === null
      ? `missing=${this.missing.join(",")}`
      : `diff=${this.differ}/${this.total}`;
    console.log(`${this.label}: code=${this.code} | ${detail} -> ${this.verdict}`);
  }
}

export function countSharing(items) {
  for (const name of ["shared", "branched", "split", "single"]) {
    console.log(`${name}: ${items.filter((s) => s.verdict === name).length}`);
  }
}
```

The measurement below tests two features with four rows.

```javascript
// main.mjs
import { Sharing, countSharing } from "./sharing.mjs";

function renderRow(row) {
  return `<li>${row.title} (${row.count})</li>`;
}

function encodeMessage(row) {
  return JSON.stringify({ type: "open", id: row.index });
}

const rows = [
  { index: 0, title: "Coffee", count: 2, pinned: false },
  { index: 1, title: "Weekly budget", count: 7, pinned: true },
  { index: 2, title: "Shopping list", count: 0, pinned: false },
  { index: 3, title: "Annual plan", count: 12, pinned: false },
];

const features = [
  new Sharing("row-rendering", { ios: renderRow, android: renderRow }, rows),
  new Sharing("message-format", { ios: encodeMessage, android: encodeMessage }, rows),
];
features.forEach((f) => f.line());
countSharing(features);
```

```text
row-rendering: code=1 | diff=0/4 -> shared
message-format: code=1 | diff=0/4 -> shared
shared: 2
branched: 0
split: 0
single: 0
```

Both features came out shared. `code=1` says the two shells run the same function, and `diff=0/4` says the four rows are drawn the same on both. This is the purest form of the approach's promise: the format of the row is written in one place, tested in one place, and goes to both platforms together. A bug fix takes effect on both platforms at the same time.

The page reaches the shell as follows. The piece below shows three functions of the page; the shell carries the native code that receives the message and writes the safe area inset into the page.

```html
<!-- Platform code: page -->
<ul id="rows"></ul>
<script>
  function showRows(rows) {
    const items = rows.map((r) => {
      const item = document.createElement("li");
      item.textContent = `${r.title} (${r.count})`;
      return item;
    });
    document.getElementById("rows").replaceChildren(...items);
  }
  function setInsets(top, bottom) {
    document.body.style.paddingTop = `${top}px`;
    document.body.style.paddingBottom = `${bottom}px`;
  }
  function openRow(id) {
    const message = JSON.stringify({ type: "open", id: id });
    (window.webkit?.messageHandlers.host ?? window.Host).postMessage(message);
  }
</script>
```

This piece is not measured; the model does not run the page itself. The row title is data that comes from the server, and `textContent` writes it as text, not as markup. On a page connected to a bridge this distinction matters: a title interpreted as markup can run a script on the page and send a message to the shell over the bridge. The last line of the piece, however, shows a detail that matters for the lesson: the way of delivering the message to the shell differs on the two platforms, and the page separates this inside itself. This distinction concerns not the content of the message but the delivery channel; `message-format` stays shared, because what the rule looks at is the message text. The difference in the channel stays outside the measurement and is stated in the lesson text as a maintenance cost.

The part of the shell on the iOS side that writes the inset into the page looks like this.

```swift
// Platform code: HostViewController
import WebKit

@MainActor
func pushInsets(to webView: WKWebView) {
    let insets = webView.safeAreaInsets
    webView.evaluateJavaScript("setInsets(\(insets.top), \(insets.bottom))")
}
```

This piece is not measured either. What it shows is that the shell gives the page a number; the page itself decides what to do with that number. Here `setInsets` sets the page's top and bottom padding to that inset.

#### The Input the Shell Provides

The page cannot do everything with its own knowledge. Information such as the notch and bottom-bar inset of the screen is known by the device's window. A WebView can pass part of this inset to the page itself; in this model, however, the inset is an input that comes from the shell. The shell gives this inset to the page, and the page places a pinned row below the inset.

**AP2.** In the model, the safe area inset is a fixed table in the file `shell.mjs`: for iOS the top inset is 47 and the bottom inset is 34; for Android the top inset is 24 and the bottom inset is 0. These numbers are inputs of the model, not the measure of a device; the real inset is the value the shell reports on every call, depending on the device and the orientation.

```javascript
// shell.mjs — the safe area inset the shell gives to the page
export function safeInset(platform) {
  return platform === "ios"
    ? { top: 47, bottom: 34 }
    : { top: 24, bottom: 0 };
}

export function flatInset() {
  return { top: 0, bottom: 0 };
}
```

```javascript
// page.mjs — the page's function that places a row takes the shell's inset from outside
export function makePlacer(insetOf) {
  return function placeRow(row, platform) {
    const y = row.pinned ? insetOf(platform).top : row.index * 40;
    return `${row.title}:${y}`;
  };
}
```

`makePlacer` takes a shell function and returns the function that places the row. A pinned row sits below the inset, and the other rows are positioned in order. The same page code is measured on two platforms.

```javascript
// main.mjs
import { Sharing, countSharing } from "./sharing.mjs";
import { safeInset } from "./shell.mjs";
import { makePlacer } from "./page.mjs";

function renderRow(row) {
  return `<li>${row.title} (${row.count})</li>`;
}

const placeRow = makePlacer(safeInset);

const rows = [
  { index: 0, title: "Coffee", count: 2, pinned: false },
  { index: 1, title: "Weekly budget", count: 7, pinned: true },
  { index: 2, title: "Shopping list", count: 0, pinned: false },
  { index: 3, title: "Annual plan", count: 12, pinned: false },
];

const features = [
  new Sharing("row-rendering", { ios: renderRow, android: renderRow }, rows),
  new Sharing("row-placement", { ios: placeRow, android: placeRow }, rows),
];
features.forEach((f) => f.line());
countSharing(features);
```

```text
row-rendering: code=1 | diff=0/4 -> shared
row-placement: code=1 | diff=1/4 -> branched
shared: 1
branched: 1
split: 0
single: 0
```

`row-placement` is branched. Because `code=1`, the page code is the same on both platforms; but on the single pinned row the results of the two platforms differ, because the shell gives a different inset on each platform. The other three rows are positioned in order and produce no difference. So of the four samples, only one triggered the branch.

What deserves notice here is that the branch is not in the page's code. `makePlacer` never says "if the platform is this, do that". The platform is carried by the shell's answer, and the page uses it as it is. For this reason the sharing follows not the page code but the input the shell provides.

#### Common Misconceptions

Three misconceptions circle around the WebView-based approach, and the measurement gives a concrete answer to each.

The first is the idea that "a single page means a single sharing". The page code being single gives `code=1`, but whether the sharing is shared or branched is decided by the `diff` value. In the measurement above the page was single and yet one feature was branched. Single code does not guarantee that the feature behaves the same on both platforms; it guarantees only that the code is written in one place.

The second is the idea that "the shell can be ignored". Even if the shell is thin, it is written separately on each platform. The code that reads the inset, receives the message, and loads the page is two separate pieces of native code on two platforms. If the shell's functions were classified as features on their own, most would come out split. The approach's promise is not to reduce these splits to zero but to make them smaller: the number of splits stays low and each one stays small, but what ensures this is the shell's own discipline.

The third is the idea that "every capability called from the page is shared like the page". The page requests a capability such as the camera, a permission, or a file through a message; the line that builds the request is shared. The code that fulfills the capability, however, sits in the shell, written separately on each platform, and may be missing entirely on one platform. For the same capability, the call in the page is shared, the counterpart in the shell is split, and if it is missing on one platform it is single. Saying "this feature is shared" without saying which line was measured is an incomplete sentence. The native module need topic handles this distinction separately.

What the three misconceptions have in common is confusing the promise of the mechanism with the name of a sharing. The mechanism gathers the code in one place; whether the code behaves the same for every input depends on the inputs the shell provides being the same.

#### The Bounding Measurement

This inference can be tested. We take the same page code, the same four rows, and the same two platforms; we change only the shell. The second shell gives zero inset on both platforms.

```javascript
// main.mjs
import { Sharing, countSharing } from "./sharing.mjs";
import { safeInset, flatInset } from "./shell.mjs";
import { makePlacer } from "./page.mjs";

const withInset = makePlacer(safeInset);
const withoutInset = makePlacer(flatInset);

const rows = [
  { index: 0, title: "Coffee", count: 2, pinned: false },
  { index: 1, title: "Weekly budget", count: 7, pinned: true },
  { index: 2, title: "Shopping list", count: 0, pinned: false },
  { index: 3, title: "Annual plan", count: 12, pinned: false },
];

const features = [
  new Sharing("placement-with-inset", { ios: withInset, android: withInset }, rows),
  new Sharing("placement-without-inset", { ios: withoutInset, android: withoutInset }, rows),
];
features.forEach((f) => f.line());
countSharing(features);
```

```text
placement-with-inset: code=1 | diff=1/4 -> branched
placement-without-inset: code=1 | diff=0/4 -> shared
shared: 1
branched: 1
split: 0
single: 0
```

The page code did not change; `makePlacer` is the same function in both measurements, and `code=1` came out in both. When the shell gave an inset the sharing came out branched, and when it gave none the sharing came out shared. The two measurements are not two separate pages; they are the same page measured with two separate shells.

In approach selection this result means the following. In the WebView-based approach, keeping the page code single is not enough; every input the shell gives the page affects the sharing. As inputs such as the safe area inset increase, branched features increase. The rule shows this but does not show what it costs: the cost of reading an inset wrongly is visible only on the device concerned, and testing the page does not catch it.

Another limit of the approach is the shell itself. The package that goes to the app store, permission requests, and native capabilities sit in the shell. These are classified separately as features outside the page; they are taken up again in the following lessons and in the platform realities topic.

#### Summary

- This lesson's sharing: shared. The function that draws a page's list row is the same function on both platforms and does not read the platform.
- The WebView-based approach draws the interface from a single page; the shell is a separate, thin native piece on each platform.
- When the page read the safe area inset from the shell, the same page code came out branched: of four samples, only the pinned row differed.
- The same page code came out shared with a shell that gives no inset: the sharing follows the input the shell provides, not the page code.
- The rule sees how the sharing is structured, not what it costs; the cost of every inset read from the shell is stated in the lesson text.

#### Next Step

In this lesson the interface was drawn from a single page, and the page talked only to the shell. The second mechanism does not draw the interface in a page; it maps a tree produced by shared code onto the native components of each platform. The next lesson, `The Bridge-Based Approach`, measures the sharing of this mapping.

### The Bridge-Based Approach

Source: https://academia.sh/en/courses/cross-platform-approaches/bridge-based-approach

The bridge-based approach defines the interface in shared code and maps each node to a platform's native component through a bridge. The lesson measures the sharing of the mapping: the same mapping function gives a different component name per platform, and it turns split when the mapping is divided into two functions.

The previous lesson measured the mechanism that draws the interface from a single page and showed that the sharing follows not the page code but the input the shell provides. The second mechanism does not draw the interface in a page. The definition of the interface lives in shared code; the code produces a tree of nodes, and each node of this tree corresponds to a native component of the platform. The intermediary between the node and the component is called the bridge.

The question of the course does not change: **how is this feature's code shared between the platforms?** In this lesson the question is asked separately of the two ends of the bridge. The shared end is a single code. The native end is the components that actually appear on screen on each platform.

The mechanism works as follows. The shared code builds a tree of nodes: a button, a label, an input field, a spacer. The bridge translates this tree into the platform's tree; a mapping decides which native component to create for each node. The controls that appear on screen are the platform's own components. For this reason the look and the behavior match what the platform expects. What the shared code writes is only the definition of the tree.

**AP1.** The model builds this sharing's rule; the model does not run the bridge, the shared code, or the native components. In the model, the bridge's mapping is a table: a node type goes in, the platform's component name comes out. The component names are real API names, such as `UIButton` for iOS and `Button` for Android, but the model does not create these components; it only returns their names.

```javascript
// sharing.mjs — classifies how a feature's code is shared between the platforms
export const PLATFORMS = ["ios", "android"];

export class Sharing {
  constructor(label, impl, samples) {
    this.label = label;
    this.total = samples.length;
    this.missing = PLATFORMS.filter((p) => typeof impl[p] !== "function");
    const present = PLATFORMS.filter((p) => !this.missing.includes(p));
    this.code = new Set(present.map((p) => impl[p])).size;
    if (this.missing.length > 0) {
      this.differ = null;
      this.verdict = "single";
      return;
    }
    this.differ = samples.filter((s) => {
      const outs = PLATFORMS.map((p) => JSON.stringify(impl[p](s, p)));
      return new Set(outs).size > 1;
    }).length;
    if (this.code > 1) this.verdict = "split";
    else this.verdict = this.differ === 0 ? "shared" : "branched";
  }

  line() {
    const detail = this.differ === null
      ? `missing=${this.missing.join(",")}`
      : `diff=${this.differ}/${this.total}`;
    console.log(`${this.label}: code=${this.code} | ${detail} -> ${this.verdict}`);
  }
}

export function countSharing(items) {
  for (const name of ["shared", "branched", "split", "single"]) {
    console.log(`${name}: ${items.filter((s) => s.verdict === name).length}`);
  }
}
```

We put the mapping table and the mapping function in a shared file. The next two measurements import it.

```javascript
// bridge.mjs — bridge: maps a node type to the platform's component name
export const NATIVE = {
  button: { ios: "UIButton", android: "Button" },
  label: { ios: "UILabel", android: "TextView" },
  input: { ios: "UITextField", android: "EditText" },
  spacer: { ios: "spacing", android: "spacing" },
};

export function mapToNative(node, platform) {
  return NATIVE[node][platform];
}
```

The `spacer` node gets the same name on both platforms: a gap is drawn not as a component but as a share of the surrounding layout. The other three nodes have a separate component on each platform.

#### Shared Code, Branched Mapping

The same shared code goes to both platforms. The function of the bridge that translates a node type into a component name is a single function; it takes the platform as an argument. Also, the event name that returns to the shared code when the button is tapped is the same on both platforms: the bridge reduces the different events of the two platforms to a single name.

```javascript
// main.mjs
import { Sharing, countSharing } from "./sharing.mjs";
import { mapToNative } from "./bridge.mjs";

function eventName(node) {
  return node === "button" ? "press" : "none";
}

const nodes = ["button", "label", "input", "spacer"];

const features = [
  new Sharing("component-mapping", { ios: mapToNative, android: mapToNative }, nodes),
  new Sharing("event-name", { ios: eventName, android: eventName }, nodes),
];
features.forEach((f) => f.line());
countSharing(features);
```

```text
component-mapping: code=1 | diff=3/4 -> branched
event-name: code=1 | diff=0/4 -> shared
shared: 1
branched: 1
split: 0
single: 0
```

`component-mapping` is branched. The mapping function is single, but in three of the four nodes the result differs by platform: the button, the label, and the input field. The fourth node, the spacer, does not differ. Looked at from its shared end, one portion of the same code produces two separate components on two platforms. `event-name`, on the other hand, is shared, because the event name is an interface that the bridge leaves uniform on the way up.

These two lines show what the bridge shares. The definition of the tree is shared, and the identity of the component is branched. The shared code asks for a button; the bridge and the platform decide which button comes out. This is the gain of the approach: the platform's own control appears on screen. Its cost comes from the same place: a node type that the mapping does not know, or a feature specific to the platform, cannot be expressed in the shared code.

The tree produced by the shared code looks like this. The piece is not measured; the model does not run the shared code.

```jsx
// Platform code: Cart
function Cart({ count, onPay }) {
  return (
    <View>
      <Text>Cart: {count} items</Text>
      <Button title="Pay" onPress={onPay} />
    </View>
  );
}
```

There are three nodes in this tree, and each crosses the bridge once. A bridge crossing is a communication between the shared code and the native side; when it repeats often on a hot path, it gains importance as a unit of work. The performance expectations topic takes this up separately.

#### Two Trees, One Bridge

In the bridge-based approach two trees live. One is the node tree that the shared code builds; the other is the platform's own native view tree. When a state changes, the shared code produces a new node tree, the bridge compares the old one with the new one, and updates the native tree only for the nodes that changed. Each update is a bridge crossing: a message goes from the shared side to the native side.

This mechanism has two consequences. The first is that user interaction starts in the native component, the event comes through the bridge to the shared code, and the response of the shared code returns again through the bridge to the native side. The two-way crossing determines how a screen responds; for this reason the team examines frequently updated places, such as scrolling and typing, separately. The second is that, because the native tree is the platform's own tree, behaviors such as accessibility, focus, and text input are left to the platform's own component. The shared code neither writes nor repeats these behaviors.

The model builds these two trees as a single mapping. In a real bridge a node has many properties: color, size, text, margin. Each property can be mapped separately, and each is a separate sharing question. Whether a property gives the same result on two platforms or not tells whether the mapping of that property is shared or branched. The approach therefore produces not a single sharing but a distribution of sharing, feature by feature.

#### Branched Is Not Repetition

In an earlier course, in M30/K05, we asked whether repeating an operation or changing its order changes the result. The branched here is not that question. Branched is the result changing not with the repetition or the order of the input but with the change of the platform. To test this, the same mapping is measured with three separate sample lists: the plain order, the reversed order, and a list in which each node is repeated twice.

```javascript
// main.mjs
import { Sharing, countSharing } from "./sharing.mjs";
import { mapToNative } from "./bridge.mjs";

const nodes = ["button", "label", "input", "spacer"];
const reversed = [...nodes].reverse();
const doubled = [...nodes, ...nodes];

const features = [
  new Sharing("mapping-ordered", { ios: mapToNative, android: mapToNative }, nodes),
  new Sharing("mapping-reversed", { ios: mapToNative, android: mapToNative }, reversed),
  new Sharing("mapping-repeated", { ios: mapToNative, android: mapToNative }, doubled),
];
features.forEach((f) => f.line());
countSharing(features);
```

```text
mapping-ordered: code=1 | diff=3/4 -> branched
mapping-reversed: code=1 | diff=3/4 -> branched
mapping-repeated: code=1 | diff=6/8 -> branched
shared: 0
branched: 3
split: 0
single: 0
```

The order changed and `diff` stayed the same: the results of three nodes kept differing by platform. The list doubled and `diff` went from 3/4 to 6/8: the ratio is the same, because when each node was repeated, the platform difference was repeated too. All three measurements are branched. This shows that the cause of branched sharing is not the repetition or the order of the input; the cause is the platform itself.

**AP2.** The node types and the component names are inputs of the model. A real bridge can map one node type to more than one component and can change the mapping by version; the model reduces these to a single table.

#### Common Misconceptions

Two misconceptions are often seen about the bridge-based approach. The first is the idea that "if there is shared code, the screens look the same on both platforms". The component that appears on screen is the platform's own component; for this reason they differ. The bridge does not hide this difference; it hides it from the shared code. The shared code does not know which component is drawn, and because it does not know, it cannot control the behavior of that component either.

The second is the idea that "branched is bad, shared is good". Branched is the purpose of this approach: the user wants to see the component of their own platform on both platforms. Increasing sharing as much as possible is not the right goal for every feature. The rule classifies branching not as an error but as a structure; the team decides which feature should stay branched.

#### The Bounding Measurement

This time the same mapping is divided into two separate functions. One returns the component names of iOS, the other those of Android; each function reads its own table. The results are the same as in the previous measurement: only the number of functions becomes two.

```javascript
// main.mjs
import { Sharing, countSharing } from "./sharing.mjs";
import { NATIVE, mapToNative } from "./bridge.mjs";

function mapIos(node) {
  return NATIVE[node].ios;
}

function mapAndroid(node) {
  return NATIVE[node].android;
}

const nodes = ["button", "label", "input", "spacer"];

const features = [
  new Sharing("mapping-one-function", { ios: mapToNative, android: mapToNative }, nodes),
  new Sharing("mapping-two-functions", { ios: mapIos, android: mapAndroid }, nodes),
];
features.forEach((f) => f.line());
countSharing(features);
```

```text
mapping-one-function: code=1 | diff=3/4 -> branched
mapping-two-functions: code=2 | diff=3/4 -> split
shared: 0
branched: 1
split: 1
single: 0
```

The `diff` value of the two measurements is the same: the results of three nodes differ on the two platforms. Only `code` changed, from 1 to 2. The rule does not see the result; it sees the number of codes. Although the results stayed the same, the sharing turned from branched to split.

This limit is a warning. When the mapping is divided into two separate functions or two separate files, the same screen keeps producing the same components, but now two codes need maintenance. When a new node type is to be added, the two functions are updated separately, and when one is forgotten the platforms drift apart. In a branched mapping this risk does not exist, because the single table is the place that updates both platforms together. The rule does not see this maintenance difference; the lesson sees it and says it in the text.

#### Summary

- This lesson's sharing: branched. The single function that maps a node type to the platform's component name gave different names on the two platforms in three of four nodes.
- In the bridge-based approach the definition of the tree is shared and the identity of the component changes by platform; the platform's own component appears on screen.
- When the mapping was ordered or repeated, the `diff` ratio did not change: branched arises from the platform changing, not from repetition or order.
- When the same mapping was divided into two separate functions, the results stayed the same but the sharing turned split: the rule sees the number of codes.
- The rule does not see the maintenance difference; the risk of one of the two functions being forgotten is stated in the lesson text.

#### Next Step

In this lesson the platform's own components drew the interface. The third mechanism uses no native component: it draws everything onto a canvas with its own rendering engine. The next lesson, `The Own-Rendering-Engine Approach`, measures the sharing of the layout function in this mechanism.

### The Own-Rendering-Engine Approach

Source: https://academia.sh/en/courses/cross-platform-approaches/own-rendering-engine-approach

The own-rendering-engine approach does not use the platform's native components; it draws the interface onto a canvas with its own engine. The lesson measures the sharing of layout, paint order, and touch resolution, and shows how text width coming from the platform branches the layout.

The previous lesson showed that the shared code builds a tree of nodes and that the bridge maps this tree to the platform's own components; the sharing came out branched, because the identity of the component belonged to the platform. The third mechanism removes this mapping altogether: the platform's native components are not used. The app draws everything with its own rendering engine onto a single surface that the platform provides, the canvas.

The question of the course does not change: **how is this feature's code shared between the platforms?** The rendering engine gives a bold answer to this question. The button, the list, the text field, and the scroll bar are the engine's own code; the platform has no counterpart for them. For this reason the code can be shared in the widest way.

The mechanism works as follows. The app builds a component tree; each item in the tree is sized and positioned by a layout rule. The engine walks the tree, draws each item onto the canvas, and takes mainly inputs from the platform: touch points, the size of the canvas, and a signal for each frame. Accessibility, the lifecycle, and platform messages are also provided by the platform. Because there is no native component, the look or behavior of the platform's own button is not used; the engine imitates these itself or draws its own design.

**AP1.** The model builds this sharing's rule; the model does not run the rendering engine, the canvas, or a frame. In the model, the engine's layout function produces rows from the widths of boxes. The container width is 100, and this number is an input of the model; it is not the screen size of a device.

```javascript
// sharing.mjs — classifies how a feature's code is shared between the platforms
export const PLATFORMS = ["ios", "android"];

export class Sharing {
  constructor(label, impl, samples) {
    this.label = label;
    this.total = samples.length;
    this.missing = PLATFORMS.filter((p) => typeof impl[p] !== "function");
    const present = PLATFORMS.filter((p) => !this.missing.includes(p));
    this.code = new Set(present.map((p) => impl[p])).size;
    if (this.missing.length > 0) {
      this.differ = null;
      this.verdict = "single";
      return;
    }
    this.differ = samples.filter((s) => {
      const outs = PLATFORMS.map((p) => JSON.stringify(impl[p](s, p)));
      return new Set(outs).size > 1;
    }).length;
    if (this.code > 1) this.verdict = "split";
    else this.verdict = this.differ === 0 ? "shared" : "branched";
  }

  line() {
    const detail = this.differ === null
      ? `missing=${this.missing.join(",")}`
      : `diff=${this.differ}/${this.total}`;
    console.log(`${this.label}: code=${this.code} | ${detail} -> ${this.verdict}`);
  }
}

export function countSharing(items) {
  for (const name of ["shared", "branched", "split", "single"]) {
    console.log(`${name}: ${items.filter((s) => s.verdict === name).length}`);
  }
}
```

#### Layout: The Position of Boxes

The simplest form of layout is line wrapping. Boxes line up from left to right in order; if the next box does not fit in the container, a new row starts. The function produces rows from the widths of boxes and never reads the platform. We put the paint order in the same block: layers are sorted by their z value, and the smaller z is painted first.

```javascript
// main.mjs
import { Sharing, countSharing } from "./sharing.mjs";

function layoutBoxes(widths) {
  const rows = [];
  let row = [];
  let used = 0;
  for (const w of widths) {
    if (row.length > 0 && used + w > 100) {
      rows.push(row);
      row = [];
      used = 0;
    }
    row.push(w);
    used += w;
  }
  rows.push(row);
  return rows;
}

function paintOrder(layers) {
  return [...layers].sort((a, b) => a.z - b.z).map((l) => l.name).join(">");
}

const boxSets = [[30, 40, 20], [60, 50], [100], [45, 45, 45]];
const layerSets = [
  [{ name: "text", z: 2 }, { name: "background", z: 0 }, { name: "shadow", z: 1 }],
  [{ name: "button", z: 1 }, { name: "background", z: 0 }, { name: "hint", z: 3 }],
  [{ name: "list", z: 1 }, { name: "background", z: 0 }, { name: "title", z: 2 }],
];

const features = [
  new Sharing("box-placement", { ios: layoutBoxes, android: layoutBoxes }, boxSets),
  new Sharing("paint-order", { ios: paintOrder, android: paintOrder }, layerSets),
];
features.forEach((f) => f.line());
countSharing(features);
```

```text
box-placement: code=1 | diff=0/4 -> shared
paint-order: code=1 | diff=0/3 -> shared
shared: 2
branched: 0
split: 0
single: 0
```

Both features are shared. For `box-placement` the output was `code=1` and `diff=0/4`: in none of the four box sets did the two platforms produce different rows. The layout is computed inside the engine, and its only input is the box widths. The same component tree gives the same placement on both platforms; this is the strongest promise of the approach. A layout that is independent of the platform means that errors specific to the platform, arising from the component's own layout, disappear; inputs that come from the platform, such as a font, are outside this. When a layout error is fixed, both platforms are fixed together.

The side that uses the engine in code writes the drawing job onto the canvas like this. The piece is not measured; the model does not run the engine.

```dart
// Platform code: LabelPainter
import 'package:flutter/widgets.dart';

class LabelPainter extends CustomPainter {
  LabelPainter(this.text);
  final String text;

  @override
  void paint(Canvas canvas, Size size) {
    final painter = TextPainter(
      text: TextSpan(text: text, style: const TextStyle(color: Color(0xFF202020))),
      textDirection: TextDirection.ltr,
    )..layout(maxWidth: size.width);
    painter.paint(canvas, Offset.zero);
  }

  @override
  bool shouldRepaint(LabelPainter old) => old.text != text;
}
```

The critical line of the piece is the `layout(maxWidth: size.width)` line. How much room the text will take is computed here, and this computation determines what the engine will draw onto the canvas. The question of where the input of this computation comes from will meet us again in the bounding measurement of the lesson.

#### Input and Scrolling in the Engine

The engine does not only draw; it also resolves touch by itself. The platform gives the touch point, and the engine finds which item was touched. The engine also limits the scroll position: if the content is longer than a view, the position stays between zero and the difference of the content. Neither function reads the platform.

**AP2.** The fixed boxes are three regions in the model. The content height is 300 and the view height is 100; these numbers are an input of the model, and the scroll position stays between 0 and 200.

```javascript
// main.mjs
import { Sharing, countSharing } from "./sharing.mjs";

const regions = [
  { x: 0, y: 0, w: 50, h: 40 },
  { x: 50, y: 0, w: 50, h: 40 },
  { x: 0, y: 40, w: 100, h: 60 },
];

function hitTest(point) {
  const [px, py] = point;
  return regions.findIndex(
    (r) => px >= r.x && px < r.x + r.w && py >= r.y && py < r.y + r.h,
  );
}

function clampScroll(offset) {
  return Math.min(Math.max(offset, 0), 200);
}

const points = [[5, 5], [60, 10], [30, 70], [200, 5]];
const offsets = [-10, 0, 120, 500];

const features = [
  new Sharing("touch-target", { ios: hitTest, android: hitTest }, points),
  new Sharing("scroll-limit", { ios: clampScroll, android: clampScroll }, offsets),
];
features.forEach((f) => f.line());
countSharing(features);
```

```text
touch-target: code=1 | diff=0/4 -> shared
scroll-limit: code=1 | diff=0/4 -> shared
shared: 2
branched: 0
split: 0
single: 0
```

So four features came out shared: placement, paint order, touch target, and scroll limit. All four are inside the engine, and all four do not read the platform. The promise of sharing in this approach holds to the extent that the engine really leaves the native components behind.

The cost of this promise is the loss of what comes from the platform's own component. The accessibility behavior, the keyboard layout, and the selection menu of a native text field are the platform's job. The engine has to imitate these in an area it draws itself; if the imitation falls short, the user feels it. The rule does not see this shortfall, because the shortfall is lived not as a difference in sharing but as a difference in behavior.

#### The Bounding Measurement

We said the layout does not read the platform, but is the input of the layout only the box widths? Text is a box, and the width of the box comes from the character metrics of the font used. If the font is one that ships with the engine, the metric is the same on both platforms. If the platform's own font is used, the metric changes by platform.

**AP3.** The character width is an input in the model: 6 in the engine's own font, 6 in the system font of iOS, and 7 in the system font of Android. Real font metrics change from character to character, and these three numbers are a single representative value. The container width is 60; in this case the number of characters per line is 10 on iOS and 8 on Android.

```javascript
// text.mjs — splits text into lines by character width
export function glyphWidth(platform) {
  return platform === "ios" ? 6 : 7;
}

export function bundledWidth() {
  return 6;
}

export function makeWrap(widthOf) {
  return function wrapText(text, platform) {
    const perLine = Math.floor(60 / widthOf(platform));
    const lines = [];
    let line = "";
    for (const word of text.split(" ")) {
      const next = line === "" ? word : `${line} ${word}`;
      if (line !== "" && next.length > perLine) {
        lines.push(line);
        line = word;
      } else {
        line = next;
      }
    }
    lines.push(line);
    return lines.join("/");
  };
}
```

The same four texts are measured twice: once with the width of the font that ships with the engine, once with the width of the platform's font. The wrapping function is the same; the only thing that changes is where the width comes from.

```javascript
// main.mjs
import { Sharing, countSharing } from "./sharing.mjs";
import { glyphWidth, bundledWidth, makeWrap } from "./text.mjs";

const bundledWrap = makeWrap(bundledWidth);
const platformWrap = makeWrap(glyphWidth);

const texts = ["Coffee", "Black tea", "Weekly plan", "Tea mixes"];

const features = [
  new Sharing("text-engine-font", { ios: bundledWrap, android: bundledWrap }, texts),
  new Sharing("text-platform-font", { ios: platformWrap, android: platformWrap }, texts),
];
features.forEach((f) => f.line());
countSharing(features);
```

```text
text-engine-font: code=1 | diff=0/4 -> shared
text-platform-font: code=1 | diff=2/4 -> branched
shared: 1
branched: 1
split: 0
single: 0
```

In both measurements `code=1` and the engine is the same. With the font that ships with the engine, the four texts were split into the same lines on the two platforms and the sharing came out shared. With the platform's font, two texts diverged: "Black tea" and "Tea mixes". These two texts of nine characters fit in a line of ten characters on iOS, do not fit in a line of eight characters on Android, and are split in two. "Weekly plan", on the other hand, is split in two on both platforms, because its eleven characters exceed both limits; the result stays the same.

Even if the engine draws every pixel itself, a part of its input comes from the platform. When an input such as the choice of font is tied to the platform, the layout branches without the placement itself changing. In this approach the way to keep the sharing is to minimize taking input from the platform; the font that ships with the engine is for this reason the precondition of a shared layout. The rule does not see where the input comes from; it sees only that the result diverged, and the prose says the reason for the divergence.

#### Summary

- This lesson's sharing: shared. The layout function that computes the position of boxes does not read the platform and gave the same placement on the two platforms in four box sets.
- In the own-rendering-engine approach the layout, the paint order, the touch target, and the scroll limit are inside the engine; all four came out shared.
- When the text width came from the platform's font metrics, the same layout function broke lines differently in two of four texts, and the sharing turned branched.
- With the font that ships with the engine, the same texts came out shared: taking input from the platform branches the sharing without changing the code.
- The rule sees how the sharing is structured, not what it costs; the loss of the accessibility and selection behavior of the native component is said in the prose.

#### Next Step

In this lesson the platform's native components were left behind entirely, and the sharing reached its widest form. The fourth mechanism goes in the opposite direction: it shares only the business rules and writes the interface natively on each platform. The next lesson measures the sharing of the interface in this mechanism.

### Shared Logic, Native UI

Source: https://academia.sh/en/courses/cross-platform-approaches/shared-logic-native-ui

The shared-logic approach keeps the business rules in a shared module and writes the interface separately on each platform. The lesson measures that form validation comes out shared and the interface functions that build the form come out split, and it shows the maintenance cost of a drifted split.

The previous lesson measured an engine that draws everything and found the sharing in its widest form. This lesson goes in the opposite direction. The business rules of the app, that is, the validation, calculation, and communication rules, are written once in a module independent of the platform. The interface is written separately on each platform with its own tools. The shared module stands under both interfaces; the interface calls it, but the interface itself is not shared.

The question of the course does not change: **how is this feature's code shared between the platforms?** In this lesson the question is asked separately of the two layers. For the logic layer the answer is a single code. For the interface layer the answer is two codes.

The mechanism works as follows. The shared module takes a form input and returns the validation result. The interface of each platform builds the form with its own components, hands what the user typed to the shared module, and shows the returned result in its own form. The interface fully follows the rules of the platform: navigation, accessibility, and keyboard behavior come from the platform itself. The cost is that the interface is written twice.

The course classifies this cost, but measures it with a different axis. One question has to be answered: **where does the cost of this structure land?** (M11/K06). That course asks at which stage the cost is paid. This course classifies not the place of the cost but how many times the code is written; the maintenance cost of the split is stated below in the prose.

**AP1.** The model builds this sharing's rule; the model does not draw an interface, does not put a form on a screen, and does not take input from a user. In the model a form is an object: the `name` field is a name and the `phone` field is a phone string. The validation rule is also a chosen rule in the model: the name cannot be empty, and the phone carries at least five characters. The interface functions do not draw the form; they return as a string which fields and buttons would be built.

```javascript
// sharing.mjs — classifies how a feature's code is shared between the platforms
export const PLATFORMS = ["ios", "android"];

export class Sharing {
  constructor(label, impl, samples) {
    this.label = label;
    this.total = samples.length;
    this.missing = PLATFORMS.filter((p) => typeof impl[p] !== "function");
    const present = PLATFORMS.filter((p) => !this.missing.includes(p));
    this.code = new Set(present.map((p) => impl[p])).size;
    if (this.missing.length > 0) {
      this.differ = null;
      this.verdict = "single";
      return;
    }
    this.differ = samples.filter((s) => {
      const outs = PLATFORMS.map((p) => JSON.stringify(impl[p](s, p)));
      return new Set(outs).size > 1;
    }).length;
    if (this.code > 1) this.verdict = "split";
    else this.verdict = this.differ === 0 ? "shared" : "branched";
  }

  line() {
    const detail = this.differ === null
      ? `missing=${this.missing.join(",")}`
      : `diff=${this.differ}/${this.total}`;
    console.log(`${this.label}: code=${this.code} | ${detail} -> ${this.verdict}`);
  }
}

export function countSharing(items) {
  for (const name of ["shared", "branched", "split", "single"]) {
    console.log(`${name}: ${items.filter((s) => s.verdict === name).length}`);
  }
}
```

We put the validation rule and the error texts in a shared file. The next three measurements import it.

```javascript
// form.mjs — shared module: validation rule and error texts
export function validateForm(form) {
  if (form.name === "") return "name-missing";
  if (form.phone.length < 5) return "phone-short";
  return "valid";
}

const MESSAGES = {
  "name-missing": "Name cannot be left empty",
  "phone-short": "Phone is too short",
  "valid": "",
};

export function errorText(code) {
  return MESSAGES[code];
}
```

This file stands under the interface of both platforms. None of its lines reads the platform, and none of its lines creates a component.

#### Logic Shared, Interface Split

In the first measurement we put two features side by side. The first is validation: both platforms call the same `validateForm` function. The second is the interface function that builds the form: `iosForm` for iOS and `androidForm` for Android. The two are written separately, and each adds an error field according to the validation result.

```javascript
// main.mjs
import { Sharing, countSharing } from "./sharing.mjs";
import { validateForm } from "./form.mjs";

function iosForm(form) {
  const parts = ["name", "phone", "submit"];
  if (validateForm(form) !== "valid") parts.push("error");
  return parts.join(",");
}

function androidForm(form) {
  const fields = ["name", "phone"];
  const base = fields.concat(["submit"]);
  return validateForm(form) === "valid" ? base.join(",") : base.concat("error").join(",");
}

const forms = [
  { name: "Ada", phone: "55501" },
  { name: "", phone: "55501" },
  { name: "Ada", phone: "12" },
  { name: "Can", phone: "55502" },
];

const features = [
  new Sharing("form-validation", { ios: validateForm, android: validateForm }, forms),
  new Sharing("form-ui", { ios: iosForm, android: androidForm }, forms),
];
features.forEach((f) => f.line());
countSharing(features);
```

```text
form-validation: code=1 | diff=0/4 -> shared
form-ui: code=2 | diff=0/4 -> split
shared: 1
branched: 0
split: 1
single: 0
```

The two lines read in the same way. For `form-validation` the output was `code=1` and `diff=0/4`; this is a shared feature. For `form-ui` the output was `diff=0/4`, which means the two functions produced the same fields in all four forms, but because `code=2` the sharing is **split**. The rule sees not the result but the number of codes. Two separate codes that give the same output are not a single code.

This difference is the most confused place of the course. The results being the same is good news, but it is not a guarantee. The two functions give the same result today because the same person wrote both on the same day. One day one of them changes and the other does not, and the results drift apart. The definition of split rests on this possibility: there are two codes, and each asks for maintenance separately.

The source counterparts of the same measurement are as follows. The two pieces are not measured; the model does not run an interface. Still, they show what two separate codebases look like.

```swift
// Platform code: SignupForm
import SwiftUI

struct SignupForm: View {
    @State private var name = ""
    @State private var phone = ""
    let validate: (String, String) -> String

    var body: some View {
        Form {
            TextField("Name", text: $name)
            TextField("Phone", text: $phone)
            Button("Submit") { _ = validate(name, phone) }
        }
    }
}
```

```kotlin
// Platform code: SignupForm
@Composable
fun SignupForm(validate: (String, String) -> String) {
    var name by remember { mutableStateOf("") }
    var phone by remember { mutableStateOf("") }
    Column {
        TextField(value = name, onValueChange = { name = it })
        TextField(value = phone, onValueChange = { phone = it })
        Button(onClick = { validate(name, phone) }) { Text("Submit") }
    }
}
```

What the two pieces have in common is the `validate` parameter: both take the validation from outside. This is the job of the shared module. Everything that is separate lies in the bodies of the pieces: holding state, the field component, and the button are the platform's own tools.

#### Error View: Shared Text, Split Drawing

The error text is also divided into two layers. The text itself, that is, the sentence "Phone is too short", stays in the shared module as a rule: an error code goes in, a text comes out. How the text looks on the screen is the job of the platform. Each platform draws the error line in its own design language.

**AP2.** In the model a "view" is a string: the iOS interface function wraps the text in square brackets, and the Android interface function wraps it in angle brackets. The choice of bracket is an arbitrary representation of the model; it has nothing to do with the error views of real platforms.

```javascript
// main.mjs
import { Sharing, countSharing } from "./sharing.mjs";
import { errorText } from "./form.mjs";

const iosBanner = (code) => `[${errorText(code)}]`;
const androidBanner = (code) => `<${errorText(code)}>`;

const codes = ["name-missing", "phone-short", "valid"];

const features = [
  new Sharing("error-text", { ios: errorText, android: errorText }, codes),
  new Sharing("error-view", { ios: iosBanner, android: androidBanner }, codes),
];
features.forEach((f) => f.line());
countSharing(features);
```

```text
error-text: code=1 | diff=0/3 -> shared
error-view: code=2 | diff=3/3 -> split
shared: 1
branched: 0
split: 1
single: 0
```

In this measurement the line between split and branched becomes visible. For `error-view` the output was `diff=3/3`: the two platforms gave different results in all three codes. Still, the sharing is not branched but **split**, because the two functions are written separately. Branched is a single function branching by platform. Split is the existence of two functions. The results being different is intentional here: each platform wants its own view, and separating the two interface functions for this reason is a chosen cost of the approach.

A design decision follows from here. Because the error text stays in the shared module, changing the text is done in a single place and goes to both platforms together. Changing the view is done in two places. The approach puts things with different rates of change in different layers: the rule that changes often and is independent of the platform goes to the shared module, and the drawing that is tied to the platform goes to the native UI.

#### The Bounding Measurement

The cost of a split is that the two codes can drift apart. To measure this we compare two versions of the same interface function. The matched pair produces the same fields in every form. In the drifted pair the Android version has forgotten the newly added phone field.

**AP3.** In the model "forgetting" is a typing error: the `androidFormStale` function never puts the `phone` field in the list. In real teams this happens as a change made on one platform not being carried to the other; the model does not imitate this process, it only writes its result as a function.

```javascript
// main.mjs
import { Sharing, countSharing } from "./sharing.mjs";
import { validateForm } from "./form.mjs";

function iosForm(form) {
  const parts = ["name", "phone", "submit"];
  if (validateForm(form) !== "valid") parts.push("error");
  return parts.join(",");
}

function androidForm(form) {
  const base = ["name", "phone", "submit"];
  return validateForm(form) === "valid" ? base.join(",") : base.concat("error").join(",");
}

function androidFormStale(form) {
  const base = ["name", "submit"];
  return validateForm(form) === "valid" ? base.join(",") : base.concat("error").join(",");
}

const forms = [
  { name: "Ada", phone: "55501" },
  { name: "", phone: "55501" },
  { name: "Ada", phone: "12" },
  { name: "Can", phone: "55502" },
];

const features = [
  new Sharing("form-matched", { ios: iosForm, android: androidForm }, forms),
  new Sharing("form-drifted", { ios: iosForm, android: androidFormStale }, forms),
];
features.forEach((f) => f.line());
countSharing(features);
```

```text
form-matched: code=2 | diff=0/4 -> split
form-drifted: code=2 | diff=4/4 -> split
shared: 0
branched: 0
split: 2
single: 0
```

Both features came out **split**. For `form-matched` `diff=0/4` is read and for `form-drifted` `diff=4/4`, and the sharing of the two is the same. The rule reports the `diff` count but does not use it in deciding the sharing: as long as `code=2` it is split. This is the limit of the rule, and it was built this way on purpose. A zero diff is the state of today. A diff greater than zero is the drifted state of the split, and the measurement catches it; even so, it does not change the sharing.

The cost of the drifted split is stated in the prose, because the rule cannot count it. When the Android version forgets the phone field, the validation still checks the phone, but the user cannot see the field. The defect is not in the validation layer but in the interface layer, and nobody finds it in the validation tests. Defects of this kind stay hidden until the screens of the two platforms are put side by side and compared. As the team grows, the comparison is made less often and the defect lives longer.

For this reason it is not enough to measure the split of the approach; a safeguard is needed so that the split does not drift. A shared interface specification, a test that compares the screenshots of the two platforms, or a review rule that requires both platforms for every added field are among these safeguards. The rule sees none of them; it sees only that the code is written twice.

#### Summary

- This lesson's sharing: split. The logic layer is a single code in the shared module, but the interface, which is the carrying measurement of the lesson, is written separately on the two platforms.
- Form validation ran with the same function on the two platforms and gave no diff in four forms: the sharing came out shared.
- The interface functions that build the form produced the same fields in all four forms, but because they are two separate codes the sharing came out split: the result being the same does not mean a single code.
- When one of the two interface functions forgot a field, `diff` became greater than zero, but the sharing stayed split; the maintenance cost of a drifted split is a subject for the prose, not for the rule.
- The approach puts the platform-independent rule that changes often in the shared module and the platform-bound drawing in the native UI; its cost is that the interface is written twice and that the two writings may move away from each other.

#### Next Step

In this lesson and the previous ones, five approaches were measured one by one. Each approach had a sharing: we saw shared, branched, and split outputs. The next lesson, `Approach Selection Criteria`, measures the same list of features with two approaches and shows how the four sharings of the course are distributed on a single screen.

### Approach Selection Criteria

Source: https://academia.sh/en/courses/cross-platform-approaches/approach-selection-criteria

Approach selection starts from how dependent each feature is on the platform. The lesson measures the same six features with a single codebase and with shared logic and native UI, compares the two distributions of the four sharings, and shows that the count alone does not choose the approach.

Five lessons measured five mechanisms one by one. Each mechanism had a sharing: the page shared, the bridge branched, the rendering engine shared, and the mechanism with a separately written interface split. In a real app, however, there is no single feature. There is a list, and each row of the list carries its own sharing. Approach selection looks at how much of this list falls on which sharing.

The question of the course does not change: **how is this feature's code shared between the platforms?** In this lesson the question is asked of the same six features in two mechanisms, and the two distributions are put side by side. The lesson does not recommend an approach. It shows what a recommendation must rest on.

The first input of the selection is how dependent the feature is on the platform. The price format never reads the platform. Title shortening is affected by the platform according to the width of the text area. The navigation bar follows the platform's own habit. Biometric login calls the platform's own programming interface. A home screen tile, in this app, is written for only one platform. The six features are six different degrees of dependence on the platform.

**AP1.** The model builds this sharing's rule; the model does not build an app, does not draw a screen, and does not call the programming interface of a platform. In the model the six features are a fixed list. In the app the "home screen tile" exists only for iOS; an Android counterpart is outside the scope of this app. This is an assumption and not a real product decision.

```javascript
// sharing.mjs — classifies how a feature's code is shared between the platforms
export const PLATFORMS = ["ios", "android"];

export class Sharing {
  constructor(label, impl, samples) {
    this.label = label;
    this.total = samples.length;
    this.missing = PLATFORMS.filter((p) => typeof impl[p] !== "function");
    const present = PLATFORMS.filter((p) => !this.missing.includes(p));
    this.code = new Set(present.map((p) => impl[p])).size;
    if (this.missing.length > 0) {
      this.differ = null;
      this.verdict = "single";
      return;
    }
    this.differ = samples.filter((s) => {
      const outs = PLATFORMS.map((p) => JSON.stringify(impl[p](s, p)));
      return new Set(outs).size > 1;
    }).length;
    if (this.code > 1) this.verdict = "split";
    else this.verdict = this.differ === 0 ? "shared" : "branched";
  }

  line() {
    const detail = this.differ === null
      ? `missing=${this.missing.join(",")}`
      : `diff=${this.differ}/${this.total}`;
    console.log(`${this.label}: code=${this.code} | ${detail} -> ${this.verdict}`);
  }
}

export function countSharing(items) {
  for (const name of ["shared", "branched", "split", "single"]) {
    console.log(`${name}: ${items.filter((s) => s.verdict === name).length}`);
  }
}
```

We put the functions of the six features in a shared file. The next two measurements combine them with different mechanisms.

```javascript
// catalog.mjs — functions of the six features
export function priceText(cents) {
  const pad = String(cents % 100).padStart(2, "0");
  return `${Math.floor(cents / 100)}.${pad}`;
}

export function cartSum(lines) {
  return lines.reduce((sum, [price, qty]) => sum + price * qty, 0);
}

export function titleCut(title, platform) {
  const limit = platform === "ios" ? 10 : 8;
  return title.length > limit ? `${title.slice(0, limit - 1)}~` : title;
}

export function backBar(screen, platform) {
  return `${screen}:${platform === "ios" ? "left" : "bottom"}-back`;
}

export const iosBiometric = (user) => `face:${user}`;
export const androidBiometric = (user) => `finger:${user}`;
export const iosTile = (name) => `tile:${name}`;
```

`titleCut` and `backBar` are written independent of the platform but read the platform name. In the measurement these two functions stand as a single function. The two functions of biometric login and the single function of the home screen tile are named separately.

#### The Single Feature

In the first measurement we put the features at the two ends: the price format, which is not tied to the platform at all, and the home screen tile, which in this app is written for only one platform. The first will come out shared. For the second there is no Android app, and only the `ios` key exists in `impl`.

```javascript
// main.mjs
import { Sharing, countSharing } from "./sharing.mjs";
import { priceText, iosTile } from "./catalog.mjs";

const prices = [1250, 300, 99999, 5];
const tiles = ["daily", "weekly"];

const features = [
  new Sharing("price-format", { ios: priceText, android: priceText }, prices),
  new Sharing("home-screen-tile", { ios: iosTile }, tiles),
];
features.forEach((f) => f.line());
countSharing(features);
```

```text
price-format: code=1 | diff=0/4 -> shared
home-screen-tile: code=1 | missing=android -> single
shared: 1
branched: 0
split: 0
single: 1
```

For `price-format` the output was `diff=0/4` and `code=1`: this feature is written once and gives the same result on the two platforms. For `home-screen-tile` `missing=android` is read instead of `diff`, and the sharing is **single**. In a single feature there is no second app to compare with, so `diff` is not computed. Whichever approach is chosen, this feature stays single, because what is missing is not the mechanism of the code but the app on the other platform itself.

The source of a feature that exists only on iOS looks like this. The piece is not measured.

```swift
// Platform code: HomeTile
import WidgetKit
import SwiftUI

struct HomeTile: Widget {
    var body: some WidgetConfiguration {
        StaticConfiguration(kind: "home-tile", provider: TileProvider()) { entry in
            Text(entry.title)
        }
    }
}
```

This is a feature that stays on one platform by a scope decision. No shared layer brings an app that was not written on the other platform into existence by itself. The second input of the selection comes from here: **which features are written for only one platform?** These features are kept out of the comparison of approaches, because they have no difference between approaches.

#### Six Features in a Single Codebase

In the second measurement we measure the six features with the single-codebase mechanism. In this mechanism each feature is written as a single function as far as possible; the parts that depend on the platform are separated inside the function with the `platform` key. The exceptions are that biometric login calls the platform's programming interface and that the home screen tile exists on one platform.

**AP2.** In the model "single codebase" means this: the features that are sensitive to the platform stay as a single function and read the `platform` argument. Biometric login asks for two separate functions even in this mechanism, because the programming interfaces of the two platforms are not shared. This model does not imitate a shell, a bridge, or an engine; it only counts the number of functions.

```javascript
// main.mjs
import { Sharing, countSharing } from "./sharing.mjs";
import {
  priceText, cartSum, titleCut, backBar, iosBiometric, androidBiometric, iosTile,
} from "./catalog.mjs";

const prices = [1250, 300, 99999, 5];
const carts = [[[100, 2], [250, 1]], [[999, 3]], [[50, 4], [75, 2]]];
const titles = ["Coffee", "Black tea", "Weekly plan", "Tea"];
const screens = ["list", "detail", "cart"];
const users = ["ada", "can", "eda"];
const tiles = ["daily", "weekly"];

const features = [
  new Sharing("price-format", { ios: priceText, android: priceText }, prices),
  new Sharing("cart-sum", { ios: cartSum, android: cartSum }, carts),
  new Sharing("title-shortening", { ios: titleCut, android: titleCut }, titles),
  new Sharing("navigation-bar", { ios: backBar, android: backBar }, screens),
  new Sharing("biometric-login", { ios: iosBiometric, android: androidBiometric }, users),
  new Sharing("home-screen-tile", { ios: iosTile }, tiles),
];
features.forEach((f) => f.line());
countSharing(features);
```

```text
price-format: code=1 | diff=0/4 -> shared
cart-sum: code=1 | diff=0/3 -> shared
title-shortening: code=1 | diff=2/4 -> branched
navigation-bar: code=1 | diff=3/3 -> branched
biometric-login: code=2 | diff=3/3 -> split
home-screen-tile: code=1 | missing=android -> single
shared: 2
branched: 2
split: 1
single: 1
```

The distribution is two shared, two branched, one split, and one single. The first two features are shared because they never read the platform. `title-shortening` and `navigation-bar` are a single function but read the platform key; this is why they came out branched. For `title-shortening` `diff=2/4` is read: in two of the four titles the two platforms produced different shortenings, because the room is ten characters on iOS and eight characters on Android. These two numbers are an input of the model and are not the measure of a real text area. `biometric-login` came out split, because the two platforms call two separate programming interfaces and there are two codes that write this call.

This distribution shows both the strength and the limit of the promise of a single codebase. Four of the six features were written with a single function, but two of the four are still branched. Being a single function does not mean being a single behavior.

#### Six Features with Shared Logic and Native UI

In the third measurement we measure the same list with the other mechanism. In this mechanism the business rules stay in the shared module: the price format and the cart sum. The title, the navigation bar, and biometric login belong to the interface layer and are written as a separate function on each platform.

**AP3.** In the model the interface functions wrap the shared function with the platform name: `iosTitle` and `androidTitle` call the same shortening rule with their own platform name, and `iosBack` and `androidBack` call the same navigation rule. The two wrappers are separate function objects; this represents the two being written separately. In real teams two interface functions do not have to wrap a shared rule, and most of the time they do not.

```javascript
// main.mjs
import { Sharing, countSharing } from "./sharing.mjs";
import {
  priceText, cartSum, titleCut, backBar, iosBiometric, androidBiometric, iosTile,
} from "./catalog.mjs";

const iosTitle = (title) => titleCut(title, "ios");
const androidTitle = (title) => titleCut(title, "android");
const iosBack = (screen) => backBar(screen, "ios");
const androidBack = (screen) => backBar(screen, "android");

const prices = [1250, 300, 99999, 5];
const carts = [[[100, 2], [250, 1]], [[999, 3]], [[50, 4], [75, 2]]];
const titles = ["Coffee", "Black tea", "Weekly plan", "Tea"];
const screens = ["list", "detail", "cart"];
const users = ["ada", "can", "eda"];
const tiles = ["daily", "weekly"];

const features = [
  new Sharing("price-format", { ios: priceText, android: priceText }, prices),
  new Sharing("cart-sum", { ios: cartSum, android: cartSum }, carts),
  new Sharing("title-shortening", { ios: iosTitle, android: androidTitle }, titles),
  new Sharing("navigation-bar", { ios: iosBack, android: androidBack }, screens),
  new Sharing("biometric-login", { ios: iosBiometric, android: androidBiometric }, users),
  new Sharing("home-screen-tile", { ios: iosTile }, tiles),
];
features.forEach((f) => f.line());
countSharing(features);
```

```text
price-format: code=1 | diff=0/4 -> shared
cart-sum: code=1 | diff=0/3 -> shared
title-shortening: code=2 | diff=2/4 -> split
navigation-bar: code=2 | diff=3/3 -> split
biometric-login: code=2 | diff=3/3 -> split
home-screen-tile: code=1 | missing=android -> single
shared: 2
branched: 0
split: 3
single: 1
```

The distribution became two shared, zero branched, three split, and one single. The two features that were branched in the first measurement turned split here: their results did not change, `diff=2/4` and `diff=3/3` stayed the same, but because each platform has its own function the code count rose to two. The shared count and the single count did not change. Half of the six features are written twice in this mechanism.

#### The Bounding Measurement

When the two distributions are put side by side, it is tempting to draw a conclusion: the first has two branched and one split, the second has zero branched and three split, so one is as good or as bad as the other. The rule cannot say this. The counts give the sharing of the two mechanisms over six features. They do not give which will ask for less maintenance, which will lead to fewer errors, or which will look better to the user.

A branched feature is a single function but has a branch inside; both arms of the branch ask for maintenance, and the drifting of the arms must be watched. A split feature is two functions and both ask for maintenance. The answer to the question of which is more expensive depends on how often the feature changes and on whether a change goes to the two platforms together. The rule does not see this information; it sees only the structure of the code.

For this reason the selection looks at at least four inputs, and the measurement helps with only the first:

- Which sharing the features fall into. This is what the lesson measured.
- The share of features that exist on only one platform. These stay single in every mechanism.
- Which codebases the team can keep maintaining. This is not a measurement but a question of capacity.
- How much the app has to conform to the look and habits of the platform. This is not a question of sharing but a question of product.

The first input is measured, the others are discussed. No approach is chosen from the first input alone. The measurement gives the selection a ground, and the prose says where the counts may mislead.

#### Summary

- This lesson's sharing: single, for one feature; the carrying measurement of the lesson is the distribution of the same six features in two mechanisms. Because the home screen tile is written for only one platform, it stays single whichever mechanism is chosen.
- In the single-codebase mechanism the six features were distributed as two shared, two branched, one split, and one single.
- In the shared-logic and native UI mechanism the same six features were distributed as two shared, zero branched, three split, and one single; the two branched features turned split.
- The rule counts the structure of the code but cannot count its cost: which distribution will ask for less maintenance depends on how often the feature changes and on the capacity of the team.
- Approach selection looks at at least four inputs: the distribution of sharing, the features present on only one platform, the maintenance capacity of the team, and the need of the product to conform to the look of the platform.

#### Next Step

This course measured how the approaches share code. The next course, `Platform Design Language Differences`, draws the limit of sharing from another place: even when the same code runs on two platforms, the look and behavior that the user expects may diverge by platform.

## Platform Realities

The differences between platform design languages, the need for native modules, performance expectations, and store and release constraints.

### Platform Design Language Differences

Source: https://academia.sh/en/courses/cross-platform-approaches/platform-design-language-differences

The two platforms do the same job with different design languages. The lesson classifies a single function that picks the navigation pattern from the screen type, and gives different results on each platform, as branched, then measures how an adapted design raises sharing.

The previous topic compared the approaches and, in its last lesson, listed the selection criteria. One of those criteria was whether the interface would look the same on both platforms or each platform in its own language. This topic opens up the facts behind that question, and the first fact is the design language. The course's question does not change: **how is this feature's code shared between the platforms?** The four forms of the answer and the names in the core were introduced in the opening lesson; they are not redefined here, only referred to.

A short reminder. If the same code gives the same result in every sample, the feature is **shared**. If the same code gives a different result per platform in at least one sample, it is **branched**. If the feature is written as two separate function objects, it is **split**, even when the results come out the same. If it is missing on one platform, it is **single**. The umbrella name is sharing; when we speak of a feature's sharing, we mean one of these four answers.

The core supplies the rule and arrives in every lesson as the same file. The label does not decide the sharing; the rule does, and it is never assigned by hand.

```javascript
// sharing.mjs — classifies how a feature's code is shared between the platforms
export const PLATFORMS = ["ios", "android"];

export class Sharing {
  constructor(label, impl, samples) {
    this.label = label;
    this.total = samples.length;
    this.missing = PLATFORMS.filter((p) => typeof impl[p] !== "function");
    const present = PLATFORMS.filter((p) => !this.missing.includes(p));
    this.code = new Set(present.map((p) => impl[p])).size;
    if (this.missing.length > 0) {
      this.differ = null;
      this.verdict = "single";
      return;
    }
    this.differ = samples.filter((s) => {
      const outs = PLATFORMS.map((p) => JSON.stringify(impl[p](s, p)));
      return new Set(outs).size > 1;
    }).length;
    if (this.code > 1) this.verdict = "split";
    else this.verdict = this.differ === 0 ? "shared" : "branched";
  }

  line() {
    const detail = this.differ === null
      ? `missing=${this.missing.join(",")}`
      : `diff=${this.differ}/${this.total}`;
    console.log(`${this.label}: code=${this.code} | ${detail} -> ${this.verdict}`);
  }
}

export function countSharing(items) {
  for (const name of ["shared", "branched", "split", "single"]) {
    console.log(`${name}: ${items.filter((s) => s.verdict === name).length}`);
  }
}
```

#### What a Design Language Separates

A design language is the set of shared look-and-behavior rules a platform applies to the interface. On one platform, users have learned how screens open, how they close, and where the main navigation sits. When the same job is done differently on another platform, it looks unfamiliar rather than wrong.

The difference shows up in three places. The first is the navigation pattern: moving between the main sections can be done with a bottom tab, a side drawer, or a screen stack, and each platform has its own habit. The second is back behavior: on one platform, swiping from the edge of the screen goes back, while on the other the system has a back gesture or button. The third is the placement of the tab bar and the toolbar: the same tabs may sit at the bottom on one platform and at the top on the other.

Cross-platform code meets this difference in one of two ways. Either a single function looks at the screen type and the platform and picks the pattern, or each platform writes its own function. The first is branched, the second is split. Both can give users a look that suits their platform; the difference lies in how the code is shared.

#### One Function, Two Patterns

The model is a single function that picks the navigation pattern from the screen type (`navigationStyle`). The samples are screen types: home, list, detail, settings. The function is given as the same object on both platforms, so there is exactly one piece of code. On the home screen the two platforms pick different patterns, and so they do on the settings screen; on list and detail both pick the stack.

```javascript
// main.mjs — one function that picks the navigation pattern from the screen type
import { Sharing } from "./sharing.mjs";

const screens = ["home", "list", "detail", "settings"];

function navigationStyle(screen, platform) {
  if (screen === "home") return platform === "ios" ? "bottom-tab" : "bottom-bar";
  if (screen === "settings") return platform === "ios" ? "stack" : "drawer";
  return "stack";
}

const nav = new Sharing("navigation-style", { ios: navigationStyle, android: navigationStyle }, screens);
nav.line();
```

```text
navigation-style: code=1 | diff=2/4 -> branched
```

There is one piece of code, but in two of the four screens the result changes with the platform. By definition this is branched. The function looks at the `platform` parameter itself; shared code and the platform difference meet in the same place. A team usually keeps this structure in a single file and leaves the platform difference readable inside an `if`.

What has the rule seen here, and what has it not? It has seen that the code is one and that the results differ in two samples. It does not see how large the function has grown: a function with two branches and a function with twenty branches fall into the same class. Prose says that part.

**PR1.** The model builds this sharing's rule; the model is not the framework itself. In the model, a navigation pattern is a string and nothing is drawn on screen. In a real application, the framework's navigation layout makes the same decision, and the platform code looks something like this.

```swift
// Platform code: TabRoot
import SwiftUI

struct TabRoot: View {
    var body: some View {
        TabView {
            HomeScreen().tabItem { Label("Home", systemImage: "house") }
            SettingsScreen().tabItem { Label("Settings", systemImage: "gear") }
        }
    }
}
```

```kotlin
// Platform code: AppScaffold
@Composable
fun AppScaffold(navController: NavHostController) {
    Scaffold(bottomBar = { BottomBar(navController) }) { padding ->
        NavHost(navController, startDestination = "home", modifier = Modifier.padding(padding)) {
            composable("home") { HomeScreen() }
            composable("settings") { SettingsScreen() }
        }
    }
}
```

The two pieces write the same home screen in two separate native UI languages. The pieces produce no output; the only thing measured is the choice in the model. If a shared upper layer existed over these two pieces, there would be a single function asking it for the pattern, like `navigationStyle` in the model.

#### Three Features, Three Answers

A single feature does not make a table. Take three features together on the same set of screens: back behavior, tab placement, and brand color. Back behavior differs by platform on the home screen and on the others. Tab placement only means something on screens that carry tabs. Brand color is a constant, independent of the platform.

```javascript
// main.mjs — three features, the same four screens
import { Sharing, countSharing } from "./sharing.mjs";

const screens = ["home", "list", "detail", "settings"];

function backBehavior(screen, platform) {
  if (screen === "home") return platform === "ios" ? "none" : "exit-app";
  return platform === "ios" ? "edge-swipe" : "system-back";
}

function tabPlacement(screen, platform) {
  if (screen === "home" || screen === "list") return platform === "ios" ? "bottom" : "top";
  return "none";
}

function brandColor() {
  return "blue";
}

const rows = [
  new Sharing("back-behavior", { ios: backBehavior, android: backBehavior }, screens),
  new Sharing("tab-placement", { ios: tabPlacement, android: tabPlacement }, screens),
  new Sharing("brand-color", { ios: brandColor, android: brandColor }, screens),
];
rows.forEach((r) => r.line());
countSharing(rows);
```

```text
back-behavior: code=1 | diff=4/4 -> branched
tab-placement: code=1 | diff=2/4 -> branched
brand-color: code=1 | diff=0/4 -> shared
shared: 1
branched: 2
split: 0
single: 0
```

Back behavior differs in all four screens, tab placement in two. Both are a single function and both are branched; to be branched, a feature does not need to differ on every screen, since one sample is enough. Brand color differs on no screen and is shared. A small rule shows up here as well: a function that never reads the platform and returns a constant always comes out shared. The reason is not that the function is well shared, but that it carries nothing that could differ.

#### The Same Behavior, Two Functions

Back behavior can be written in another form. This time each platform has its own function: one knows the edge swipe, the other knows the system back gesture. The results are identical to the previous block, but the feature is now two separate function objects.

The second feature is more striking: the format of the screen title. The functions on the two platforms give the same result, yet they are written separately. The rule counts the number of objects, not the result, so this one is split as well.

```javascript
// main.mjs — the same behavior, two separate functions
import { Sharing } from "./sharing.mjs";

const screens = ["home", "list", "detail", "settings"];

function iosBack(screen) {
  return screen === "home" ? "none" : "edge-swipe";
}

function androidBack(screen) {
  return screen === "home" ? "exit-app" : "system-back";
}

const titleIos = (screen) => screen.toUpperCase();
const titleAndroid = (screen) => screen.toUpperCase();

new Sharing("back-behavior-split", { ios: iosBack, android: androidBack }, screens).line();
new Sharing("title-format", { ios: titleIos, android: titleAndroid }, screens).line();
```

```text
back-behavior-split: code=2 | diff=4/4 -> split
title-format: code=2 | diff=0/4 -> split
```

Back behavior came out **split** this time, yet the results are the same as in the previous block: it differs in all four of four screens. Only the form of the code changed. The title format is an even clearer example: the two functions give the same result, the difference is zero, and still the code is two. Here the rule says two things together: the result is shared, the code is split.

The cost of this distinction is real. In a branched feature, the platform difference is read in one place; in a split feature, keeping the same difference up to date means updating two functions together. The rule does not see this cost: a three-line split and a three-hundred-line split fall into the same class. Prose says how large it is.

#### The Bounding Measurement

Instead of leaving the design to the platforms, adapt it to the same pattern on both platforms. A bottom tab on the home screen, a stack everywhere else: both platforms make the same decision. The function is one, and the result is the same on every screen.

```javascript
// main.mjs — an adapted design that gives the same pattern on both platforms
import { Sharing, countSharing } from "./sharing.mjs";

const screens = ["home", "list", "detail", "settings"];

function navigationStyle(screen, platform) {
  if (screen === "home") return platform === "ios" ? "bottom-tab" : "bottom-bar";
  if (screen === "settings") return platform === "ios" ? "stack" : "drawer";
  return "stack";
}

function adaptedNavigation(screen) {
  return screen === "home" ? "bottom-tab" : "stack";
}

const rows = [
  new Sharing("navigation-style-by-platform", { ios: navigationStyle, android: navigationStyle }, screens),
  new Sharing("navigation-style-adapted", { ios: adaptedNavigation, android: adaptedNavigation }, screens),
];
rows.forEach((r) => r.line());
countSharing(rows);
```

```text
navigation-style-by-platform: code=1 | diff=2/4 -> branched
navigation-style-adapted: code=1 | diff=0/4 -> shared
shared: 1
branched: 1
split: 0
single: 0
```

The same feature was branched in the first row and became shared in the second. The code did not change, it is still a single function; what changed is that the function stopped splitting by platform. Sharing went up: from branched to shared.

What the rule cannot see is the cost of this move. The shared pattern deviates from the design language of one of the platforms: a bottom tab on the home screen may match habit on one platform and not on the other. The model does not measure this deviation and cannot, because user habit is not the result of a function call. What is measured is only this: the shared pattern gives the same result on both platforms. How far the design has moved from habit is stated in prose, and it is a decision the team weighs itself.

**PR2.** The model measures that a pattern gives the same result on both platforms; it does not measure whether users find that pattern natural. This assumption does not stand in for a design decision. Knowing that a shared sharing contains a deviation from the design language does not say that the deviation is acceptable.

#### Summary

- This lesson's sharing: branched. The single function that picks the navigation pattern from the screen type gave a different pattern depending on the platform in two of the four screens.
- Across four measurements, branched, split, and shared came out: back behavior and tab placement were branched, the same back behavior written as two functions was split, and brand color was shared.
- A function that does not read the platform and returns a constant always comes out shared; this shows not that the function is well shared but that it carries nothing that could differ.
- The title format came out split even though two functions gave the same result: the rule counts the form of the code, not the result.
- Adapting the design to one pattern on both platforms raises sharing from branched to shared; the deviation from the design language is stated by prose, not by the rule.

#### Next Step

The navigation pattern could be chosen from shared code because the choice was only a string. A job that touches the platform's own capability cannot be solved this way: shared code cannot make a biometric authentication request on its own. The next lesson, "The Need for Native Modules," measures how the code that makes such a request is shared between the platforms.

### The Need for Native Modules

Source: https://academia.sh/en/courses/cross-platform-approaches/native-module-needs

Shared code cannot reach every platform capability. The lesson classifies a biometric authentication request, written separately on each platform as a native module, as split, and measures that a capability present on only one platform comes out single.

The previous lesson showed that a design difference can be absorbed in shared code, because the choice was only a string. This lesson takes up the second fact: some jobs touch a capability that only the platform owns. The course's question does not change: **how is this feature's code shared between the platforms?** The four forms of the answer and the names in the core were introduced in the opening lesson; they are not redefined here, only referred to.

A short reminder. If the same code gives the same result in every sample, the feature is **shared**. If the same code gives a different result per platform in at least one sample, it is **branched**. If the feature is written as two separate function objects, it is **split**, even when the results come out the same. If it is missing on one platform, it is **single**. The umbrella name is sharing; when we speak of a feature's sharing, we mean one of these four answers.

The core supplies the rule and arrives in every lesson as the same file. The label does not decide the sharing; the rule does, and it is never assigned by hand.

```javascript
// sharing.mjs — classifies how a feature's code is shared between the platforms
export const PLATFORMS = ["ios", "android"];

export class Sharing {
  constructor(label, impl, samples) {
    this.label = label;
    this.total = samples.length;
    this.missing = PLATFORMS.filter((p) => typeof impl[p] !== "function");
    const present = PLATFORMS.filter((p) => !this.missing.includes(p));
    this.code = new Set(present.map((p) => impl[p])).size;
    if (this.missing.length > 0) {
      this.differ = null;
      this.verdict = "single";
      return;
    }
    this.differ = samples.filter((s) => {
      const outs = PLATFORMS.map((p) => JSON.stringify(impl[p](s, p)));
      return new Set(outs).size > 1;
    }).length;
    if (this.code > 1) this.verdict = "split";
    else this.verdict = this.differ === 0 ? "shared" : "branched";
  }

  line() {
    const detail = this.differ === null
      ? `missing=${this.missing.join(",")}`
      : `diff=${this.differ}/${this.total}`;
    console.log(`${this.label}: code=${this.code} | ${detail} -> ${this.verdict}`);
  }
}

export function countSharing(items) {
  for (const name of ["shared", "branched", "split", "single"]) {
    console.log(`${name}: ${items.filter((s) => s.verdict === name).length}`);
  }
}
```

#### What a Native Module Is

A native module is a small piece written in the platform's own language and called from shared code. In biometric authentication, this piece opens the platform's authentication interface and returns the result to the shared code. The shared code does not know the inside of the module; it knows only its name and the values it takes. This call path is called a platform channel: a name and an argument go from the shared code to the platform, and a reply comes back from the platform.

The module has to be written because shared code cannot draw the authentication dialog. The form of the dialog, how an enrolled face or fingerprint is read, the state returned when the user cancels: all of it is the platform's business. Because the interfaces of the two platforms differ, two separate pieces come out.

**PR1.** The model builds this sharing's rule; the model is not the framework itself. In the model, biometric authentication is a function that takes the enrollment state and returns a request string; no dialog opens on screen. The real module is two pieces, one per platform, and looks something like this.

```swift
// Platform code: IosBiometricModule
import LocalAuthentication

func requestBiometric(reply: @escaping (Bool) -> Void) {
    let context = LAContext()
    var error: NSError?
    guard context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error) else {
        reply(false)
        return
    }
    context.evaluatePolicy(.deviceOwnerAuthenticationWithBiometrics,
                           localizedReason: "Open your account") { success, _ in
        reply(success)
    }
}
```

```kotlin
// Platform code: AndroidBiometricModule
fun requestBiometric(activity: FragmentActivity, reply: (Boolean) -> Unit) {
    val executor = ContextCompat.getMainExecutor(activity)
    val callback = object : BiometricPrompt.AuthenticationCallback() {
        override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) = reply(true)
        override fun onAuthenticationError(code: Int, message: CharSequence) = reply(false)
    }
    val info = BiometricPrompt.PromptInfo.Builder()
        .setTitle("Open your account")
        .setNegativeButtonText("Cancel")
        .build()
    BiometricPrompt(activity, executor, callback).authenticate(info)
}
```

The two pieces carry the same name and do the same job, yet not a single line is shared between them: one asks for a context object and a policy, the other asks for an `activity` object, an executor, and a callback object. The pieces produce no output; what is measured is only the two functions in the model.

This question resembles the one we asked in M31/K02, but it is not the same. There, the runtime result of a request was classified: full, substitute, pending, or unmet. Here the result is not classified; the distribution across the platforms of the code that makes the request is. Even if the same request ends full on one platform and unmet on the other, the code can still be split.

#### Two Platforms, Two Modules

The model is two separate functions that show what each platform does for a given enrollment state: `iosBiometric` and `androidBiometric`. The samples are four enrollment states: fingerprint enrolled, face enrolled, nothing enrolled, locked. The two functions are separate objects; the rule sees that as the code count.

```javascript
// main.mjs — the biometric authentication request is written separately on each platform
import { Sharing } from "./sharing.mjs";

const states = ["fingerprint", "face", "not-enrolled", "locked"];

function iosBiometric(state) {
  if (state === "not-enrolled") return "passcode-fallback";
  if (state === "locked") return "wait";
  return "native-confirm-dialog";
}

function androidBiometric(state) {
  if (state === "not-enrolled") return "open-settings";
  if (state === "locked") return "wait";
  return "system-confirm-dialog";
}

new Sharing("biometric-request", { ios: iosBiometric, android: androidBiometric }, states).line();
```

```text
biometric-request: code=2 | diff=3/4 -> split
```

The code is two, and in three of the four states the requests of the two platforms differ. In the locked state both modules wait; even if the result came out the same in this example, the feature would still be split, because the rule looks at the code count first. Branched is impossible here: once two separate functions are given, the rule says split whatever the results are.

The cost of writing a module is paid in two places. Every new case, for example a second authentication type, is added to the two modules separately. Every bug fix is also sought in two modules. The rule does not see this cost: a three-line split and a three-hundred-line split fall into the same class. Prose says how large the cost is.

#### The Call Is Shared, the Module Is Split

The same feature has two pieces, and the two are not shared in the same way. The calling side stays in shared code: it prepares the channel name and the argument and sends the call. The side that answers the call is the native module. The call is the same on both platforms, because it is shared code; the side that answers is not the same.

```javascript
// main.mjs — the call from shared code to the native module
import { Sharing, countSharing } from "./sharing.mjs";

const states = ["fingerprint", "face", "not-enrolled", "locked"];

function invokeModule(state) {
  return { channel: "biometric", argument: state };
}

function channelName() {
  return "biometric";
}

const rows = [
  new Sharing("channel-call", { ios: invokeModule, android: invokeModule }, states),
  new Sharing("channel-name", { ios: channelName, android: channelName }, states),
];
rows.forEach((r) => r.line());
countSharing(rows);
```

```text
channel-call: code=1 | diff=0/4 -> shared
channel-name: code=1 | diff=0/4 -> shared
shared: 2
branched: 0
split: 0
single: 0
```

In both rows the code is one and the difference is zero; both are **shared**. `channelName` is bound to come out shared because it returns a constant. `invokeModule`, on the other hand, is a real function that wraps an argument and passes it on, and it still gives the same result on both platforms, because the platform difference lies not inside the call but in what answers the call. The call from shared code stays shared; the rest of the feature does not.

#### One Feature, Two Levels of Measurement

A feature's sharing depends on which function objects you give the rule. Write a single wrapper on the calling side that picks the right module for the platform. If we give this wrapper as the same object for both platforms, the rule sees one piece of code. If we give the modules themselves, it sees two.

```javascript
// main.mjs — the same feature, two levels of measurement
import { Sharing, countSharing } from "./sharing.mjs";

const states = ["fingerprint", "face", "not-enrolled", "locked"];

function iosBiometric(state) {
  if (state === "not-enrolled") return "passcode-fallback";
  if (state === "locked") return "wait";
  return "native-confirm-dialog";
}

function androidBiometric(state) {
  if (state === "not-enrolled") return "open-settings";
  if (state === "locked") return "wait";
  return "system-confirm-dialog";
}

function authenticate(state, platform) {
  return platform === "ios" ? iosBiometric(state) : androidBiometric(state);
}

const rows = [
  new Sharing("native-module", { ios: iosBiometric, android: androidBiometric }, states),
  new Sharing("auth-wrapper", { ios: authenticate, android: authenticate }, states),
];
rows.forEach((r) => r.line());
countSharing(rows);
```

```text
native-module: code=2 | diff=3/4 -> split
auth-wrapper: code=1 | diff=3/4 -> branched
shared: 0
branched: 1
split: 1
single: 0
```

The modules are split, the wrapper is branched. They are two faces of the same feature, and the rule gives the two separate answers.

**PR2.** The model measures which function the rule sees; it does not measure where the team cuts the feature. In this lesson, a feature's code means the native module, because that is the thing the need is about. Measuring the wrapper is not wrong, but it answers a different question: whether the choice is made in one place.

#### The Bounding Measurement

Now assume a capability exists on only one platform. Take a privacy capability that blurs the screen when the application moves to the background. We assume the capability exists only on the iOS side; there is no counterpart on the Android side. The module is written on one side only. The call from shared code to the module stays the same on both platforms.

**PR3.** The assumption here is that the capability exists on only one platform; the model does not present this as the real state of the platforms, it builds it as an example of a need. Which platforms a real capability exists on is not used as the basis of a lesson or a product decision before it is verified from the platform documentation.

```javascript
// main.mjs — a capability present on only one platform
import { Sharing, countSharing } from "./sharing.mjs";

const states = ["foreground", "background", "locked"];

function invokeModule(state) {
  return { channel: "privacy", argument: state };
}

function iosPrivacyScreen(state) {
  return state === "background" ? "blur" : "show";
}

const rows = [
  new Sharing("channel-call", { ios: invokeModule, android: invokeModule }, states),
  new Sharing("privacy-screen", { ios: iosPrivacyScreen }, states),
];
rows.forEach((r) => r.line());
countSharing(rows);
```

```text
channel-call: code=1 | diff=0/3 -> shared
privacy-screen: code=1 | missing=android -> single
shared: 1
branched: 0
split: 0
single: 1
```

The call is shared, the feature is single. The rule writes the missing side separately: `missing=android`. Although the call from shared code works on both platforms, what answers the call does not exist on one platform, and the feature's code has not been written there. This is not a bug; for a capability that only one platform offers, the most shared code can do is to leave the call quietly unanswered on that platform or to write a fallback.

A single may not cost as much as a split. In a split, two modules have to be kept alive together; in a single, there is one module to look after. But single also says that the feature does not look the same on the two platforms. The rule does not see the effect of this inequality on the user; the team weighs it.

#### Summary

- This lesson's sharing: split. The biometric authentication request was written as a separate native module on each platform, and the rule counted two pieces of code.
- The call from shared code to the module came out shared; even though the call stayed shared, the module stayed split.
- The same feature came out split when the modules were given and branched when the wrapper that picks them was given: the rule measures which function it sees.
- The module of a capability present on only one platform came out single; even though the call from shared code is shared, the feature's code does not exist on one platform.
- The rule sees the code count, not the size of the module or the cost of maintenance; prose says those.

#### Next Step

In this lesson, the native module opened an authentication dialog and produced nothing that would be counted as a unit of work. The next lesson, "Performance Expectations," takes up another reason for native code: what it costs, at every crossing from shared code, to take a frequently running path out of shared code and write it as native code.

### Performance Expectations

Source: https://academia.sh/en/courses/cross-platform-approaches/performance-expectations

Taking a frequently running path out of shared code and writing it natively is done for performance. The lesson classifies thumbnail decoding, written separately on each platform, as split, and counts bridge crossings as a unit of work.

The previous lesson showed that some jobs need a native module because shared code cannot reach the platform's capability. This lesson takes up the third fact: sometimes shared code can do the job, yet the team still writes it natively, because the user is waiting. The course's question does not change: **how is this feature's code shared between the platforms?** The four forms of the answer and the names in the core were introduced in the opening lesson; they are not redefined here, only referred to.

A short reminder. If the same code gives the same result in every sample, the feature is **shared**. If the same code gives a different result per platform in at least one sample, it is **branched**. If the feature is written as two separate function objects, it is **split**, even when the results come out the same. If it is missing on one platform, it is **single**. The umbrella name is sharing; when we speak of a feature's sharing, we mean one of these four answers.

The core supplies the rule and arrives in every lesson as the same file. The label does not decide the sharing; the rule does, and it is never assigned by hand.

```javascript
// sharing.mjs — classifies how a feature's code is shared between the platforms
export const PLATFORMS = ["ios", "android"];

export class Sharing {
  constructor(label, impl, samples) {
    this.label = label;
    this.total = samples.length;
    this.missing = PLATFORMS.filter((p) => typeof impl[p] !== "function");
    const present = PLATFORMS.filter((p) => !this.missing.includes(p));
    this.code = new Set(present.map((p) => impl[p])).size;
    if (this.missing.length > 0) {
      this.differ = null;
      this.verdict = "single";
      return;
    }
    this.differ = samples.filter((s) => {
      const outs = PLATFORMS.map((p) => JSON.stringify(impl[p](s, p)));
      return new Set(outs).size > 1;
    }).length;
    if (this.code > 1) this.verdict = "split";
    else this.verdict = this.differ === 0 ? "shared" : "branched";
  }

  line() {
    const detail = this.differ === null
      ? `missing=${this.missing.join(",")}`
      : `diff=${this.differ}/${this.total}`;
    console.log(`${this.label}: code=${this.code} | ${detail} -> ${this.verdict}`);
  }
}

export function countSharing(items) {
  for (const name of ["shared", "branched", "split", "single"]) {
    console.log(`${name}: ${items.filter((s) => s.verdict === name).length}`);
  }
}
```

#### The Hot Path and the Bridge Crossing

A hot path is a code path in the application that runs often and is directly tied to the moment the user is waiting. When scrolling a list, the operation that runs for every row is a typical hot path: if it does not finish quickly enough while a frame is being drawn, the user sees stutter. A team can move such a path into native code, because native code reaches the platform's own decoder directly.

We call the boundary between shared code and native code the bridge. Every call from shared code to native code is a bridge crossing. The crossing itself has work to do: values are carried over the boundary, the call is queued, the reply comes back. The real size of this work varies with the framework and the platform; the lesson does not measure it and does not write a number. In this lesson, performance is measured in units of work: how many bridge crossings are made on one screen.

So two questions must be kept apart. The first is performance: how many crossings does the fast path need? The second is sharing: is the fast path written separately on the two platforms? The course's measure is the second. The first is a reason that affects sharing, but it is not what gives the sharing.

To count bridge crossings we will use a small helper file. It does not touch the `Sharing` core; it only keeps how many times each function called from shared code was called.

```javascript
// bridge.mjs — counts every crossing from shared code to the platform
export function createBridge() {
  let crossings = 0;
  return {
    cross(fn, ...args) {
      crossings += 1;
      return fn(...args);
    },
    count() {
      return crossings;
    },
  };
}
```

**PR1.** The model builds this sharing's rule; the model is not the framework itself. In the model, a bridge crossing is one call to `cross`; the real cost of the boundary is not modeled. In a real application, thumbnail decoding calls each platform's own decoder, and the pieces look something like this.

```swift
// Platform code: IosThumbnailDecoder
import ImageIO

func decodeThumbnail(data: Data, maxSize: Int) -> CGImage? {
    guard let source = CGImageSourceCreateWithData(data as CFData, nil) else { return nil }
    let options: [CFString: Any] = [
        kCGImageSourceCreateThumbnailFromImageAlways: true,
        kCGImageSourceThumbnailMaxPixelSize: maxSize
    ]
    return CGImageSourceCreateThumbnailAtIndex(source, 0, options as CFDictionary)
}
```

```kotlin
// Platform code: AndroidThumbnailDecoder
fun decodeThumbnail(data: ByteArray, maxSize: Int): Bitmap? {
    val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
    BitmapFactory.decodeByteArray(data, 0, data.size, bounds)
    val options = BitmapFactory.Options().apply {
        inSampleSize = maxOf(1, maxOf(bounds.outWidth, bounds.outHeight) / maxSize)
    }
    return BitmapFactory.decodeByteArray(data, 0, data.size, options)
}
```

The two pieces do the same job, but each uses the platform's own image decoder and shares no lines. The pieces produce no output; what is measured is the two functions in the model.

#### One Crossing per List Item

Take a list of six items. Each item asks for a thumbnail in one of three sizes. Thumbnail decoding is written separately on each platform: one is the iOS side's function, the other the Android side's. The shared code walks the list and calls the native function separately for every item.

```javascript
// main.mjs — thumbnail decoding separate on each platform, one crossing per list item
import { Sharing } from "./sharing.mjs";
import { createBridge } from "./bridge.mjs";

const sizes = ["small", "medium", "large"];
const list = ["small", "small", "medium", "small", "large", "small"];

function iosDecodeThumbnail(size) {
  return `ios-decoded-${size}`;
}

function androidDecodeThumbnail(size) {
  return `android-decoded-${size}`;
}

const decodeThumbnail = { ios: iosDecodeThumbnail, android: androidDecodeThumbnail };
const bridge = createBridge();

for (const size of list) bridge.cross(decodeThumbnail.ios, size);

new Sharing("thumbnail-decode", decodeThumbnail, sizes).line();
console.log(`bridge-crossings: ${bridge.count()}`);
```

```text
thumbnail-decode: code=2 | diff=3/3 -> split
bridge-crossings: 6
```

The code is two, and in all three sizes the output of the two platforms differs: split. Six bridge crossings were made for six items. The count follows the path of a single platform, because the application runs on only one platform at a time.

This result says two things. The first is sharing: because the fast path is written separately on the two platforms, the feature is split. The second is the unit of work: as the list grows, the number of crossings grows with it. The two are independent.

#### The Batched Call

The known way to reduce crossings is to batch the call: the shared code gives the six thumbnails to native code in a single request, and native code decodes them all and returns with a single reply. The native functions are the same; only the form of the call changes.

```javascript
// main.mjs — the same native functions, the whole list in one crossing
import { Sharing } from "./sharing.mjs";
import { createBridge } from "./bridge.mjs";

const sizes = ["small", "medium", "large"];
const list = ["small", "small", "medium", "small", "large", "small"];

function iosDecodeThumbnail(size) {
  return `ios-decoded-${size}`;
}

function androidDecodeThumbnail(size) {
  return `android-decoded-${size}`;
}

const decodeThumbnail = { ios: iosDecodeThumbnail, android: androidDecodeThumbnail };
const bridge = createBridge();

bridge.cross((batch) => batch.map(decodeThumbnail.ios), list);

new Sharing("thumbnail-decode-batched", decodeThumbnail, sizes).line();
console.log(`bridge-crossings: ${bridge.count()}`);
```

```text
thumbnail-decode-batched: code=2 | diff=3/3 -> split
bridge-crossings: 1
```

The sharing stayed the same: code two, a difference in three of three, split. The crossings dropped from six to one. The two measurements tested the same rule with the same functions, and only the unit of work changed. This gives the lesson the chance to state the distinction.

This question resembles the one we asked in M31/K06, but it is not the same. There, a unit of work was measured: in how many samples a measurement exceeded its budget. Here too a unit of work, the bridge crossing, is counted. But the number does not give the sharing; what gives the sharing is whether the fast path is written separately on the two platforms. The unit of work is a reason and it shows a cost, but it does not supply the answer.

**PR2.** The model does not measure the performance justification for native code. Whether the native path is really faster than the shared path is not stated, because this lesson did not measure it; only the number of crossings the native path makes on the bridge is counted. A team's decision to write the fast path natively should rest on the team's own measurement.

In this lesson too, the rule fails to see two things. It does not see size: a three-line decoder and a three-hundred-line decoder fall into the same class. It does not see cost either: testing a decoder written separately on two platforms twice, fixing it twice, and the two drifting apart from each other all lie outside the rule. The rule sees the structure; prose says the cost.

#### The Bounding Measurement

Do the same computation again, this time in shared code with a single function. Thumbnail decoding is written in shared code, and every decoded thumbnail crosses to the platform for drawing: that is, the shared code decodes six pictures and sends all six to the screen separately.

```javascript
// main.mjs — the same computation in shared code with a single function
import { Sharing, countSharing } from "./sharing.mjs";
import { createBridge } from "./bridge.mjs";

const sizes = ["small", "medium", "large"];
const list = ["small", "small", "medium", "small", "large", "small"];

function iosDecodeThumbnail(size) {
  return `ios-decoded-${size}`;
}

function androidDecodeThumbnail(size) {
  return `android-decoded-${size}`;
}

function decodeShared(size) {
  return `decoded-${size}`;
}

function drawOnScreen(picture) {
  return picture;
}

const platformBridge = createBridge();
platformBridge.cross((batch) => batch.map(iosDecodeThumbnail), list);
const platformRow = new Sharing(
  "thumbnail-decode-native",
  { ios: iosDecodeThumbnail, android: androidDecodeThumbnail },
  sizes,
);

const sharedBridge = createBridge();
for (const size of list) sharedBridge.cross(drawOnScreen, decodeShared(size));
const sharedRow = new Sharing(
  "thumbnail-decode-shared",
  { ios: decodeShared, android: decodeShared },
  sizes,
);

platformRow.line();
console.log(`bridge-crossings: ${platformBridge.count()}`);
sharedRow.line();
console.log(`bridge-crossings: ${sharedBridge.count()}`);
countSharing([platformRow, sharedRow]);
```

```text
thumbnail-decode-native: code=2 | diff=3/3 -> split
bridge-crossings: 1
thumbnail-decode-shared: code=1 | diff=0/3 -> shared
bridge-crossings: 6
shared: 1
branched: 0
split: 1
single: 0
```

This time the same computation came out shared: code one, difference zero. Sharing rose from split to shared. But the bridge crossings rose from one to six: because the shared code decodes the picture itself, every decoded picture goes to the platform separately for drawing.

Here the rule has seen the structure and has not seen the cost. The rise in sharing says the team writes less; it does not say whether performance got worse or not. What six crossings cost on a real screen is not measured in this model.

**PR3.** The crossing counts in this block compare two paths the model builds. That every decoded picture crosses separately for drawing is an assumption; a real framework may merge drawing requests. The numbers only tell the paths in this model apart; they do not evaluate a framework.

#### Summary

- This lesson's sharing: split. Because thumbnail decoding was written natively and separately on each platform, the rule counted two pieces of code.
- The bridge crossing was counted as a unit of work: six with a call per list item, one with a batched call; the sharing stayed split.
- What gives the sharing is not the crossing count but whether the fast path is written separately on the two platforms; the unit of work shows the reason and the cost.
- When the same computation was written in shared code with a single function, sharing rose to shared and the model's bridge crossings rose from one to six.
- The rule sees the structure and not the cost; whether the native path is really faster was not measured in this lesson and was not stated.

#### Next Step

In this topic, the design language, the native module, and performance have shown how the code is divided between the platforms. All of them concerned the application at runtime. Reaching the user, however, passes through a separate boundary. The next lesson, "Store and Release Constraints," takes up the app store's release rules, store review, and the in-app update from the point of view of cross-platform code.

### Store and Release Constraints

Source: https://academia.sh/en/courses/cross-platform-approaches/store-and-release-constraints

App store rules, store review, and the in-app update also shape how code is shared. The lesson classifies an update flow written on only one platform as single, and measures that code switched off by a flag still counts as branched.

The previous lesson showed that the performance justification can turn the code split. All of that concerned the application at runtime. Getting the code to the user passes through a separate boundary: the app store. The course's question stays the same here too: **how is this feature's code shared between the platforms?** The definitions of the four answers are as in the opening lesson and are not rewritten here.

The core supplies the rule and arrives in every lesson as the same file.

```javascript
// sharing.mjs — classifies how a feature's code is shared between the platforms
export const PLATFORMS = ["ios", "android"];

export class Sharing {
  constructor(label, impl, samples) {
    this.label = label;
    this.total = samples.length;
    this.missing = PLATFORMS.filter((p) => typeof impl[p] !== "function");
    const present = PLATFORMS.filter((p) => !this.missing.includes(p));
    this.code = new Set(present.map((p) => impl[p])).size;
    if (this.missing.length > 0) {
      this.differ = null;
      this.verdict = "single";
      return;
    }
    this.differ = samples.filter((s) => {
      const outs = PLATFORMS.map((p) => JSON.stringify(impl[p](s, p)));
      return new Set(outs).size > 1;
    }).length;
    if (this.code > 1) this.verdict = "split";
    else this.verdict = this.differ === 0 ? "shared" : "branched";
  }

  line() {
    const detail = this.differ === null
      ? `missing=${this.missing.join(",")}`
      : `diff=${this.differ}/${this.total}`;
    console.log(`${this.label}: code=${this.code} | ${detail} -> ${this.verdict}`);
  }
}

export function countSharing(items) {
  for (const name of ["shared", "branched", "split", "single"]) {
    console.log(`${name}: ${items.filter((s) => s.verdict === name).length}`);
  }
}
```

#### Store Rules and Store Review

Each platform has its own app store, its own publishing console, and its own release rules. Before an application is published, it goes through store review: the package is evaluated against the rules the store sets for that platform. Even if the cross-platform codebase is one, this line is not one. The same fix passes through two separate store lines as two packages and may reach users at two separate times.

This affects the sharing question from two directions. The first is that the rules themselves are platform-specific: a behavior one store allows may not be accepted by the other, and shared code cannot know that. The second is that the features the store provides are platform-specific: one platform may offer users an update flow outside the store, and the other may not. The first direction limits which behavior the code can carry; the second limits on which platform the code can be written. Both directions show up in the sharing class, and this lesson measures the second.

Each platform's package also carries its own version identity. Independent of the shared code, the platform side holds pieces that look something like this.

```kotlin
// Platform code: AndroidVersionIdentity
android {
    defaultConfig {
        versionCode = 14
        versionName = "2.1"
    }
}
```

```swift
// Platform code: IosVersionIdentity
import Foundation

func installedVersion() -> String? {
    Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String
}
```

The two pieces keep the version in two separate places, in two separate forms. The pieces produce no output; they only show that the version information lives outside the shared code, on the platform side.

**PR1.** The model builds this sharing's rule; the model is not the framework itself. In the model, the update decision and the update flow are two separate functions. The version numbers and the state names are examples; they do not show a real application's version scheme.

#### The Update Decision Is Shared

An in-app update is a flow that offers to install the new version without taking the user to the store. The flow has two parts. The first is the decision: is the installed version below the minimum version the server asks for? The second is the flow itself: showing the offer to the user, starting the download, completing the installation. The decision is a pure comparison. The flow rests on the platform's store infrastructure.

The decision comes first. There are three samples: the installed version below the minimum, equal to it, above it.

```javascript
// main.mjs — the function that makes the update decision is the same on both platforms
import { Sharing } from "./sharing.mjs";

const versions = [
  { installed: 12, minimum: 14 },
  { installed: 14, minimum: 14 },
  { installed: 15, minimum: 14 },
];

function isBelowMinimum(version) {
  return version.installed < version.minimum;
}

new Sharing(
  "update-decision",
  { ios: isBelowMinimum, android: isBelowMinimum },
  versions,
).line();
```

```text
update-decision: code=1 | diff=0/3 -> shared
```

The decision is shared: the same function, the same result on both platforms in all three samples. The version identity sits on the platform side, but the comparison is between one number and another, and that can move into shared code.

#### The Flow on Only One Platform

Now the flow. In this model we assume the in-app update flow is written on the Android side only. There is no counterpart of the flow on the iOS side: the user goes to the store on their own.

```javascript
// main.mjs — the in-app update flow is written on only one platform
import { Sharing } from "./sharing.mjs";

const states = ["outdated", "current", "required"];

function androidUpdate(state) {
  if (state === "current") return "no-action";
  return state === "required" ? "install-started" : "update-offered";
}

const inAppUpdate = { android: androidUpdate };

new Sharing("in-app-update", inAppUpdate, states).line();
```

```text
in-app-update: code=1 | missing=ios -> single
```

The `missing=ios` output means the iOS key is not in the table at all. Because the rule finds no function, it computes no difference; it prints `missing` instead of `diff` and the class is single. The code is one, because there is a single function on a single platform.

**PR2.** Which platform the update flow can be written on is an assumption in this lesson. In the model, the Android side exists and the iOS side does not. What a platform's store infrastructure really offers is not measured in the lesson and is not written as a claim on behalf of a platform; only the form of the table is shown.

A distinction has to be made here, because two questions get mixed up. Does this code go to production? The answer to this question, which we asked in M12/K05, is yes: it enters the Android package and reaches users. But single does not mean the code stays out of the package; it means it has not been written on one platform at all. Entering the package and being written are separate questions: the first asks about the contents of the package, the second about the existence of code on the two platforms.

The decision is shared, the flow is single. When the same feature is split into two pieces, two separate classes came out. The rule does not classify a feature as a whole; it looks at which piece is measured.

#### Code Downloaded Later

There is one more release constraint, and this lesson does not measure it. Some frameworks can change behavior with code downloaded later, outside the package that passed through the app store. This promises to ship fixes without waiting for store review.

**PR3.** How far downloaded-later code is permitted within the store rules is an assumption in this lesson and is not measured. The limit depends on the store's rules; the rules can change and can differ from platform to platform. Before using this path, a team should check the current rules for its own release; the lesson writes no limit.

From the point of view of the sharing question, this path changes nothing: code downloaded later also sits in a codebase, and the rule classifies it with the same three questions.

#### The Bounding Measurement

A flag draws the last boundary. Put the update code into both packages, but switch it off with a fixed flag in the iOS package. The code is the same function; the flag is on or off depending on the platform.

```javascript
// main.mjs — the same update code enters both packages, switched off by a flag on one platform
import { Sharing, countSharing } from "./sharing.mjs";

const states = ["outdated", "current", "required"];
const updateEnabled = { ios: false, android: true };

function androidUpdate(state) {
  if (state === "current") return "no-action";
  return state === "required" ? "install-started" : "update-offered";
}

function guardedUpdate(state, platform) {
  if (!updateEnabled[platform]) return "off";
  return androidUpdate(state);
}

const single = new Sharing("in-app-update", { android: androidUpdate }, states);
const flagged = new Sharing(
  "flagged-update",
  { ios: guardedUpdate, android: guardedUpdate },
  states,
);

single.line();
flagged.line();
countSharing([single, flagged]);
```

```text
in-app-update: code=1 | missing=ios -> single
flagged-update: code=1 | diff=3/3 -> branched
shared: 0
branched: 1
split: 0
single: 1
```

The same feature received two different classes. When written on one platform, it came out single; when it entered both packages and was switched off by a flag on one platform, it came out branched. The code is one, because both platforms carry the same `guardedUpdate` function. The difference is three of three, because on the switched-off platform every sample returns `off`, while on the enabled platform the result varies with the sample.

This makes the distinction concrete. Code switched off by a flag enters the package, but its behavior runs on only one platform. The rule does not count it as single, because the function exists on both platforms; it counts it as branched, because the result differs by platform.

The rule sees the structure, not the cost. Code that is carried in both packages and never runs in one of them puts weight on the package, has to be tested, and reaches users the day the flag is forgotten. None of this shows up in the class. Prose says the maintenance cost.

**PR4.** In the model the flag is a fixed table. In a real application the flag may come from the server, may change by version, or may remove the code from the package entirely at build time; in the last case the code does not enter the package and the model being measured changes. This lesson measures only code that stays inside the package and is switched off at runtime.

#### The Closing Table

Over ten lessons the course asked the same question. The table puts side by side the feature each lesson measured, the sharing it received, and the measurement that bounded the lesson.

| Lesson | Feature measured | Sharing | Bounding measurement |
|---|---|---|---|
| The Case for Cross-Platform | Cart total, the same function on both platforms | shared | The badge function is shared with three samples; branched once a fourth sample is added |
| The WebView-Based Approach | The function that draws a list row | shared | Branched once the safe area inset is read from the shell |
| The Bridge-Based Approach | A single function that maps a node to a platform widget | branched | Split once the mapping is divided into two separate functions |
| The Own-Rendering-Engine Approach | The layout function that computes box positions | shared | Branched once the text width comes from the platform font |
| Shared Logic, Native UI | The UI functions; validation shared | split | Once a UI field is forgotten, the diff is above zero and it is still split |
| Approach Selection Criteria | A home screen piece written on only one platform | single | Counting the same six features under two setups changes the distribution; this stays single |
| Platform Design Language Differences | A single function that picks the navigation pattern from the screen type | branched | Shared in the adapted design |
| The Need for Native Modules | The biometric authentication module | split | A capability present on only one platform is single |
| Performance Expectations | Thumbnail decoding | split | The same computation is shared in shared code with a single function; bridge crossings from 1 to 6 |
| Store and Release Constraints | The in-app update flow | single | Code switched off by a flag is branched |

The distribution over the ten lessons: shared 3, branched 2, split 3, single 2. This is not the same thing as the 1 shared, 1 branched, 1 split, 1 single distribution in the opening lesson. The core tested four features and gave one example from each class; the table counts ten lessons, and each lesson's main feature is a single row. The two numbers are the results of separate measurements and are not derived from each other.

What stands out in the table is that no class stays empty and none comes out dominant. But this is a distribution, not a recommendation. The lessons made the choice of features themselves; had other features been chosen, the distribution would be different. The table does not say whether an approach is good or bad; it only shows how the code of the feature each lesson measured is shared.

#### Summary

- This lesson's sharing: single. Because the in-app update flow was written on only one platform, the rule printed `missing=ios` and counted the feature as single.
- The function that makes the update decision is the same on both platforms and is shared; two pieces of the same feature can receive two separate classes.
- Single does not mean the code stays out of the package; it means it has not been written on one platform at all. Entering the package and being written are separate questions.
- When the same code entered two packages and was switched off by a flag on one platform, the feature became branched; the rule sees the structure and does not see the maintenance cost of the switched-off code.
- How far downloaded-later code is permitted within the store rules is an assumption in this lesson and is not measured.

#### Course Wrap-Up

The course asked a single question: how is this feature's code shared between the platforms? The closing table shows that all four answers were given: shared, branched, split, and single. Each answer was met with a real measurement in at least two lessons. The course's gain is being able to make this distinction on a feature's code, and knowing that the distinction is one where the rule sees the structure and does not see the cost. The next course sets its own question in its own opening lesson.

