指南

原生组件

原生组件是由嵌入方应用按名称注册的视图:地图、相机预览、图表库,或者 iOS 上任意的 UIView、Android 上任意的 android.view.View。nsh 应用通过 nsh/native 提供的 nativeComponent() 声明它,然后像使用其他组件一样使用它。nsh 会把它和应用的其余部分一起布局,向它发送 props,把它的事件交给对应的处理函数,并转发通过 ref 发出的命令。

nsh 本身就用系统原生视图渲染,所以原生组件只是同一视图层级里多出来的一个子视图:不存在层级叠放问题,它会像 <View> 一样被裁剪、随内容滚动并接收触摸。

#整体流程

  1. 宿主应用在它的 NshAppView(iOS)或 NshView(Android)上以某个名称(如 "embed.badge")注册一个工厂。 每个视图都有自己的注册表,没有任何全局状态。
  2. nsh 应用为这个名称声明一个组件,列出它用到的 props 和事件。
  3. 元素创建时,nsh 先创建一个容器视图(和普通视图一样负责装饰、定位和命中测试),再向工厂请求一个组件,并把组件的视图放进容器、铺满容器。
  4. 组件创建后会立即收到所有 props,之后每当某个 prop 变化时再收到该 prop。
  5. 组件可以发出事件、上报自身尺寸;JS 通过 ref 调用它的命令。

#在 JS 中声明组件

import { mount, signal, Text, type YNode } from "nsh";
import { nativeComponent } from "nsh/native";
import { SafeAreaView } from "nsh/ui/mobile";

/** The host app's badge: a label it draws, sized to its text. */
const Badge = nativeComponent<{ text: string; color?: string }, { tap: { count: number } }>("embed.badge", {
  props: ["text", "color"],
  events: ["tap"],
});

const colors = ["#8250df", "#0969da", "#1a7f37", "#cf222e"];

function App() {
  const taps = signal(0);
  let badge: YNode | undefined;
  return (
    <SafeAreaView style={{ flex: 1, padding: 20, gap: 16, backgroundColor: "#f6f8fa" }}>
      <Text style={{ fontSize: 22, fontWeight: "700" }}>Rendered by nsh</Text>
      <Badge
        ref={(node) => (badge = node)}
        text={taps() === 0 ? "Tap the native badge" : `Tapped ${taps()}×`}
        color={colors[taps() % colors.length]}
        onTap={(e) => {
          taps.set(e.count);
          badge?.command("pulse");
        }}
        style={{ alignSelf: "flex-start", borderRadius: 8, overflow: "hidden" }}
      />
      <Text>{`JS saw ${taps()} taps`}</Text>
    </SafeAreaView>
  );
}

mount(App);

这就是 examples/embedded/App.tsx,仓库里的两个嵌入示例运行的都是它。

#nativeComponent(name, spec)

function nativeComponent<P extends object = {}, E extends object = {}>(
  name: string,
  spec: NativeComponentSpec<P, E>,
): (props: NativeComponentProps<P, E>) => YNode;
参数类型说明
namestring宿主应用注册时使用的名称。请加上自己的前缀("maps.view"、"acme.chart"),以保证名称唯一。
spec.propsreadonly (keyof P & string)[]组件自己的 props。只有这里列出的 props 才会发送到原生端。
spec.eventsreadonly (keyof E & string)[]组件发出的事件。可选。

类型参数描述 props(P)以及每个事件的负载(E)。返回的组件接受:

  • 组件自己的 props P;
  • 每个事件对应一个处理函数:事件 tap 由 onTap 处理,regionChange 由 onRegionChange 处理(首字母大写并加上 on 前缀);
  • <View> 能接受的一切(children 除外):style、class / classList(Tailwind)、布局、 无障碍 props、testID、onLayout、ref。这些作用于 nsh 绘制的容器,而不是组件本身。

原生组件是叶子节点:它不接受 children。

#Props

每个自有 prop 都以 JSON 形式发送,所以它的值可以是任意 JSON 值:字符串、数字、布尔值、null、数组和普通对象。Props 和其他属性一样是响应式的:当 text={label()} 读取了一个 signal,signal 变化时(且仅在此时)新值才会被发送。

JS 中发生了什么组件收到什么
元素被创建组件创建后立即收到所有已设置的自有 prop
某个 prop 的值变化该 prop 及其新值
某个 prop 变为 undefined该 prop,但没有值(Swift 中为 nil,Java 中为 null)

非 JSON 值(函数、类实例、NativeValue)无法跨越边界;接口只传数据。

#事件

组件按名称发出事件,可附带一个 JSON 负载。nsh 把它投递到引擎线程,并用该负载调用对应的处理函数:

const MapView = nativeComponent<
  { latitude: number; longitude: number; zoom?: number },
  { regionChange: { latitude: number; longitude: number }; markerPress: { id: string } }
>("acme.map", {
  props: ["latitude", "longitude", "zoom"],
  events: ["regionChange", "markerPress"],
});

<MapView
  latitude={52.52}
  longitude={13.405}
  onRegionChange={(region) => center.set(region)}
  onMarkerPress={({ id }) => open(id)}
  style={{ flex: 1 }}
/>;

事件是异步的:主线程从不等待 JS。如果组件必须当场做出决定(这个手势能否开始、这个区域能否改变),就只能在原生端根据之前收到的 props 来决定。

#命令

命令式调用(缩放到某个点、开始播放、播放一段动画)通过元素的 ref 发起:

let map: YNode | undefined;

<MapView ref={(node) => (map = node)} latitude={52.52} longitude={13.405} style={{ flex: 1 }} />;

map?.command("zoomTo", { level: 3 });

command(name, args?) 把名称和参数(任意 JSON 值)发送给组件的 command 方法。命令会和在它之前做出的变更按顺序应用,所以紧跟在 prop 变更之后发出的命令能看到新的 prop。

#尺寸

容器的布局方式与 <View> 相同:

  • 样式给出了某个维度时(width、height、flex: 1、alignSelf: "stretch" 等),以样式为准。
  • 样式未确定某个维度时,容器采用组件通过 setSize 上报的尺寸,就像图片采用它的固有尺寸一样。从不上报尺寸的组件在未确定的维度上尺寸为 0。

每当内容变化时,以点(iOS)或 dp(Android)为单位上报尺寸。上面的 badge 每次 text 变化时都会测量标签,并上报标签尺寸加上内边距,因此 alignSelf: "flex-start" 能让 badge 恰好与文字等宽。

#在 iOS 上注册(Swift)

实现 NshComponent,并在 NshAppView 上注册一个工厂:

import NativeShell
import UIKit

/// `embed.badge`: props `text` and `color`; emits `tap`; command `pulse`.
final class Badge: NshComponent {
    let view = UIView()
    private let label = UILabel()
    private let context: NshComponentContext
    private var taps = 0

    init(_ context: NshComponentContext) {
        self.context = context
        label.textColor = .white
        label.font = .boldSystemFont(ofSize: 17)
        label.textAlignment = .center
        label.frame = view.bounds
        label.autoresizingMask = [.flexibleWidth, .flexibleHeight]
        view.addSubview(label)
        view.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(tapped)))
    }

    func setProp(_ name: String, _ value: Any?) {
        switch name {
        case "text":
            label.text = value as? String
            let size = label.intrinsicContentSize
            context.setSize(CGSize(width: size.width + 32, height: size.height + 20))
        case "color":
            view.backgroundColor = (value as? String).flatMap(UIColor.init(hex:)) ?? .systemPurple
        default:
            break
        }
    }

    func command(_ name: String, _ args: Any?) {
        guard name == "pulse" else { return }
        view.alpha = 0.3
        UIView.animate(withDuration: 0.6) { self.view.alpha = 1 }
    }

    @objc private func tapped() {
        taps += 1
        context.emit("tap", ["count": taps])
    }
}

// Before loading the bundle:
app.register(component: "embed.badge") { context in Badge(context) }

(UIColor(hex:) 是示例里的一个小工具函数,不属于 NativeShell。)

API说明
NshAppView.register(component:_:)以某个名称注册一个工厂 (NshComponentContext) -> NshComponent。此后每创建一个元素,它就生成一个组件。重复注册同一名称会替换工厂。
protocol NshComponentvar view: UIView(铺满容器)、setProp(_:_:)、command(_:_:)(可选:默认实现忽略命令)。隔离在 main actor 上。
setProp(_ name: String, _ value: Any?)值是 Foundation 的 JSON 对象:String、NSNumber、[Any]、[String: Any]、NSNull;prop 被移除时为 nil。
NshComponentContext.emit(_:_:)发出一个事件;数据必须是 JSONSerialization 能接受的值(或 nil)。线程安全。
NshComponentContext.setSize(_:)以点为单位上报组件自身的尺寸。线程安全。

#在 Android 上注册(Java / Kotlin)

实现 com.longbridge.nsh.NshComponent,并在 NshView 上注册一个工厂:

import com.longbridge.nsh.NshComponent;
import com.longbridge.nsh.NshComponentContext;

final class Badge implements NshComponent {
    private final TextView label;
    private final NshComponentContext context;
    private final float density;
    private int taps;

    Badge(Context app, NshComponentContext context) {
        this.context = context;
        density = app.getResources().getDisplayMetrics().density;
        label = new TextView(app);
        label.setTextColor(Color.WHITE);
        label.setTextSize(17);
        label.setGravity(Gravity.CENTER);
        label.setOnClickListener(v -> context.emit("tap", Collections.singletonMap("count", ++taps)));
    }

    @Override
    public View view() {
        return label;
    }

    @Override
    public void setProp(String name, Object value) {
        if (name.equals("text")) {
            label.setText(value instanceof String ? (String) value : "");
            label.measure(0, 0);
            // Sizes are in dp.
            context.setSize(label.getMeasuredWidth() / density + 32, label.getMeasuredHeight() / density + 12);
        } else if (name.equals("color")) {
            label.setBackgroundColor(value instanceof String ? Color.parseColor((String) value) : 0xff8250df);
        }
    }

    @Override
    public void command(String name, Object args) {
        if (!name.equals("pulse")) return;
        label.setAlpha(0.3f);
        label.animate().alpha(1).setDuration(600).start();
    }
}

// Before loading the bundle:
nsh.registerComponent("embed.badge", context -> new Badge(this, context));

用 Kotlin 写法相同:

nsh.registerComponent("embed.badge") { context -> Badge(this, context) }
API说明
NshView.registerComponent(String name, NshComponent.Factory factory)以某个名称注册一个工厂;此后每创建一个元素,它就生成一个组件(UI 线程)。
interface NshComponentView view()、void setProp(String, Object)、default void command(String, Object)、default void destroy()(元素已移除)。在 UI 线程上调用。
setProp 的值String、Number、Boolean、java.util.List、java.util.Map;被移除时(或 JSON null)为 null。
NshComponentContext.emit(String event, Object data)发出一个事件;data 使用同样的 Java 类型,或 null。可在任意线程调用。
NshComponentContext.setSize(float width, float height)以 dp 为单位上报组件自身的尺寸。可在任意线程调用。

#从 Rust 注册

从 Rust 嵌入 iOS 运行时(nsh-platform-ios)的应用,在 AppView 上注册一个 NativeComponent:

use std::rc::Rc;
use nsh_core::JsonValue;
use nsh_platform_ios::{AppView, ComponentContext, NativeComponent};
use objc2_ui_kit::UIView;

struct Badge {
    view: objc2::rc::Retained<UIView>,
    context: ComponentContext,
}

impl NativeComponent for Badge {
    fn view(&self) -> &UIView {
        &self.view
    }

    fn set_prop(&mut self, name: &str, value: Option<&JsonValue>) {
        // ... update the view, then report its size in points:
        self.context.set_size(120.0, 40.0);
    }

    fn command(&mut self, name: &str, args: &JsonValue) {
        // optional
    }
}

fn register(app: &AppView, make_view: impl Fn() -> objc2::rc::Retained<UIView> + 'static) {
    app.register_component(
        "embed.badge",
        Rc::new(move |context: ComponentContext| -> Box<dyn NativeComponent> {
            Box::new(Badge { view: make_view(), context })
        }),
    );
}

ComponentContext::emit(name, data) 发送一个事件(data 是 serde_json::Value),set_size(width, height) 上报尺寸;两者都可以在任意线程调用。

#规则与限制

  • 先注册,再加载。 工厂只对注册之后创建的元素生效。请在 loadBundle / loadAssetBundle / run 之前注册所有名称,这样第一帧就能找到它们。
  • 装饰和触摸归容器管。 背景、边框、圆角、阴影、不透明度、变换以及 overflow: "hidden" 都作用于 nsh 绘制的容器。你的视图铺满容器,并接收落在其中的触摸。
  • 能力声明不适用。 注册的组件是宿主应用自己的代码;应用声明的能力限制不了它做什么。如果你嵌入的是不受信任的应用,就只注册那些可以放心让它们使用的组件。
  • 接口只传数据。 Props、事件负载和命令参数都以 JSON 文本跨越边界;每次变更时传递标识符和小数据,而不是大块数据。
  • 一个 bundle,两个平台。 在 iOS 和 Android 上用相同的 props 和事件注册相同的名称,同一个 bundle 就能在两个平台上运行。

#另请参阅