Skip to content
academia.sh

Lesson 08 / 10

The Need for Native Modules

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.

Published Updated

In this lesson

  • Explain why code that touches a platform capability requires a native module
  • Classify a biometric authentication module written separately on the two platforms as split
  • Measure that the call from shared code to a native module can stay shared while the module itself stays split
  • Classify a feature whose capability exists on only one platform as single
Contents

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.

// 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.

// 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)
    }
}
// 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.

// 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();
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.

// 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);
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.

// 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);
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.

// 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);
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.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ ↵ Esc navigate · open · close