Lesson 09 / 10
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.
Published Updated
In this lesson
- Measure that writing the hot path natively for performance turns the sharing into split
- Count bridge crossings as a unit of work, per list item and with a batched call
- Explain that what gives the sharing is whether the fast path is written separately on the two platforms
- Measure that writing the same computation in shared code raises sharing to shared while increasing bridge crossings
Contents
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.
// 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.
// 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.
// 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)
}
// 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.
// 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()}`);
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.
// 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()}`);
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.
// 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]);
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.
To keep your progress and take notes, Log in
My notes
Log in to take notes.